feat: lockstep release infra + cascade/.archive fixes + profile perf + page redesign
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ede42010a
commit
9fce18cd45
32 changed files with 3083 additions and 895 deletions
23
.gitignore
vendored
23
.gitignore
vendored
|
|
@ -3,6 +3,9 @@ examples/
|
||||||
.env
|
.env
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
# Per-project Claude Code state (planning files, agent transcripts, etc.)
|
||||||
|
.claude/
|
||||||
|
|
||||||
# Session planning (never public)
|
# Session planning (never public)
|
||||||
PLAN.md
|
PLAN.md
|
||||||
|
|
||||||
|
|
@ -20,16 +23,16 @@ test-results/
|
||||||
# New tool dist files must be force-added: git add -f tool/dist/tool.html
|
# New tool dist files must be force-added: git add -f tool/dist/tool.html
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Release artifacts under website/releases/ ARE committed — per-version HTML
|
# Release artifacts under website/releases/ ARE committed — including
|
||||||
# tool files (<tool>_v<X.Y.Z>.html) accumulate as immutable real files; partial
|
# zddc-server binaries. Per-version HTML tool files (<tool>_v<X.Y.Z>.html)
|
||||||
# version pins (<tool>_v<X.Y>.html, <tool>_v<X>.html) and channels
|
# and per-version zddc-server binaries (zddc-server_v<X.Y.Z>_<platform>)
|
||||||
# (<tool>_<channel>.html) are checked-in symlinks. The build script
|
# are immutable real files; partial-version pins (<tool>_v<X.Y>.html,
|
||||||
# (shared/build-lib.sh promote_release) maintains the symlink chain on each
|
# <tool>_v<X>.html, zddc-server_v<X.Y>_<platform>, zddc-server_v<X>_<platform>)
|
||||||
# release. Caddy serves these as plain static files; no manifest, no proxy.
|
# and channel mirrors (<tool>_<channel>.html, zddc-server_<channel>_<platform>)
|
||||||
#
|
# are checked-in symlinks. The lockstep build (shared/build-lib.sh
|
||||||
# zddc-server binaries are NOT committed — they're per-platform multi-MB
|
# promote_release + promote_zddc_server) maintains both chains. Everything
|
||||||
# binaries that ship as Codeberg release assets, attached to clean
|
# serves from zddc.varasys.io/releases/; no Codeberg release-asset
|
||||||
# zddc-server-vX.Y.Z tags by zddc/release.sh.
|
# publication anymore.
|
||||||
|
|
||||||
# IDE and project files
|
# IDE and project files
|
||||||
.opencode/
|
.opencode/
|
||||||
|
|
|
||||||
120
AGENTS.md
120
AGENTS.md
|
|
@ -3,22 +3,29 @@
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build all tools (writes to dist/ only; also regenerates website/releases/index.html)
|
# Dev build: 5 HTML tools + cross-compile zddc-server binaries + regen the
|
||||||
|
# matrix at website/releases/index.html. dist/ artifacts only — no
|
||||||
|
# website/releases/ side-effect for the build outputs themselves.
|
||||||
sh build.sh
|
sh build.sh
|
||||||
|
|
||||||
# Build single tool
|
# Build a single HTML tool (archive | transmittal | classifier | mdedit | landing)
|
||||||
sh tool/build.sh # archive | transmittal | classifier | mdedit | landing
|
sh tool/build.sh
|
||||||
|
|
||||||
# Cut a stable release (auto-increments patch version, writes website/releases/<tool>_v<X.Y.Z>.html, refreshes 5 symlinks, tags <tool>-v<X.Y.Z>)
|
# ── Lockstep release: bumps ALL six tools (5 HTML + zddc-server) at once ──
|
||||||
sh tool/build.sh --release
|
#
|
||||||
sh tool/build.sh --release 1.2.0 # explicit version
|
# Coordinated version is max(latest tag across all six) + 1, so they
|
||||||
|
# always converge. Channel cuts (alpha/beta) follow the same lockstep —
|
||||||
|
# every tool's channel mirror is overwritten together. Workflow:
|
||||||
|
# alpha = active dev iteration → beta = ready for general testing → stable = ship
|
||||||
|
sh build.sh --release # stable, auto-coordinated next version
|
||||||
|
sh build.sh --release 1.2.0 # stable, explicit version
|
||||||
|
sh build.sh --release alpha # alpha cut for everything
|
||||||
|
sh build.sh --release beta # beta cut for everything (cascades alpha → beta)
|
||||||
|
|
||||||
# Cut an alpha/beta channel build (overwrites website/releases/<tool>_<channel>.html in place; on a beta cut, alpha cascades to a symlink → beta. No git tag.)
|
# Single-tool release (rare; prefer the lockstep top-level cut above so
|
||||||
sh tool/build.sh --release alpha
|
# versions don't drift between tools). Same flags as the top-level form.
|
||||||
sh tool/build.sh --release beta
|
sh tool/build.sh --release [<version>|alpha|beta]
|
||||||
|
./freshen-channel <tool> <channel> # rebuild one tool's alpha/beta from its current stable tag
|
||||||
# Release all tools at once
|
|
||||||
sh build.sh --release [version|alpha|beta]
|
|
||||||
|
|
||||||
# Test all tools
|
# Test all tools
|
||||||
npm test
|
npm test
|
||||||
|
|
@ -33,6 +40,12 @@ npx playwright test tool # archive | transmittal | classifier | mdedit
|
||||||
|
|
||||||
No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS.
|
No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS.
|
||||||
|
|
||||||
|
The build ends with a **channel-link verifier** that asserts every
|
||||||
|
`<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary
|
||||||
|
mirrors + stub pages) resolves. Build fails if any link is dangling.
|
||||||
|
Bootstrap-friendly: zddc-server checks are skipped until the first
|
||||||
|
`--release` cut materializes the binaries under `website/releases/`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Five independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — the first four name their output `dist/tool.html`; `landing` writes `dist/index.html` (it's 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.
|
Five independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — the first four name their output `dist/tool.html`; `landing` writes `dist/index.html` (it's 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.
|
||||||
|
|
@ -73,7 +86,7 @@ helm/
|
||||||
|
|
||||||
**Critical:** `dist/` files are gitignored. They're the canonical built artifact for testing and the source for `--release` writes into `website/releases/`, but they aren't checked in. Never edit them directly.
|
**Critical:** `dist/` files are gitignored. They're the canonical built artifact for testing and the source for `--release` writes into `website/releases/`, but they aren't checked in. Never edit them directly.
|
||||||
|
|
||||||
`website/releases/` IS committed — per-version files as real bytes, partial-version pins (`_v<X.Y>`, `_v<X>`) and channel mirrors (`_stable`, `_beta`, `_alpha`) as symlinks. The build script (`shared/build-lib.sh promote_release`) maintains the symlink chain on each release. zddc-server binaries are NOT in this repo — they're attached as Codeberg release assets to clean `zddc-server-vX.Y.Z` tags by `zddc/release.sh`.
|
`website/releases/` IS committed — per-version HTML and per-version zddc-server binaries as real bytes, partial-version pins (`_v<X.Y>`, `_v<X>`) and channel mirrors (`_stable`, `_beta`, `_alpha`) as symlinks. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `build.sh --release` calls them in lockstep. zddc-server binaries live in this repo too — there's no Codeberg release-asset publication anymore; everything serves from `zddc.varasys.io/releases/`.
|
||||||
|
|
||||||
## Shared CSS (`shared/base.css`)
|
## Shared CSS (`shared/base.css`)
|
||||||
|
|
||||||
|
|
@ -157,46 +170,51 @@ Format: `trackingNumber_revision (status) - title.extension`
|
||||||
|
|
||||||
- Feature-branch workflow; squash-merge feature branches to `main`
|
- Feature-branch workflow; squash-merge feature branches to `main`
|
||||||
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
||||||
- Release tags: `archive-v1.0.0` (per-tool semver)
|
- Release tags: `<tool>-v<X.Y.Z>` per tool, all six sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `zddc-server-v0.0.8`)
|
||||||
- Commit dist files: `git add -f tool/dist/tool.html`
|
- Commit dist files: `git add -f tool/dist/tool.html`
|
||||||
|
- Commit zddc-server binaries (per-version + symlinks): they live under `website/releases/` like every other release artifact
|
||||||
|
|
||||||
### Releasing — channels and layout
|
### Releasing — lockstep, channels, layout
|
||||||
|
|
||||||
Three channels. Versioning is **per-tool semver**: stable owns clean `vX.Y.Z`; alpha and beta are mutable channel mirrors that get overwritten in place (no counter tags — channel URLs are stable URLs by design). The next-stable target X.Y.Z used in alpha/beta on-page labels is patch-bumped from the latest clean `<tool>-vX.Y.Z` tag.
|
**Lockstep convention.** Every release cut bumps all six artifacts (5 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all six tools) + 1` — `_coordinated_next_stable` in `shared/build-lib.sh`. Channel cuts (alpha/beta) follow the same lockstep — every tool's channel mirror is overwritten in step. Three channels, ordered: **alpha** (dev iteration) → **beta** (general testing) → **stable** (ship).
|
||||||
|
|
||||||
**Storage model.** All HTML tool artifacts live in this repo under `website/releases/`. Per-version files (`<tool>_v<X.Y.Z>.html`) are real, immutable, committed bytes; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_<channel>.html`) are checked-in symlinks pointing at the appropriate concrete file. No `manifest.json`, no Codeberg indirection, no Caddy proxy magic. Caddy serves these as plain static files.
|
**Storage model.** All release artifacts live under `website/releases/` and are served from `zddc.varasys.io/releases/`. No Codeberg release assets, no third-party mirrors.
|
||||||
|
|
||||||
zddc-server binaries are a separate concern — they ship as Codeberg release assets attached to clean `zddc-server-vX.Y.Z` tags by `zddc/release.sh`. zddc-server has no alpha/beta channel for binaries; the helm charts under `helm/` build from source.
|
| Artifact | Type | Layout |
|
||||||
|
|---|---|---|
|
||||||
|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing |
|
||||||
|
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
|
||||||
|
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
|
||||||
|
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
|
||||||
|
| `zddc-server_v<X.Y>_<platform>`, `zddc-server_v<X>_<platform>`, `zddc-server_<channel>_<platform>` | symlinks (or real bytes during active channel dev) | partial-pin and channel mirrors per platform — same cascade as the HTML tools |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
`shared/build-lib.sh promote_release` is the single point of truth for HTML-tool releases:
|
**Single point of truth.** `sh build.sh --release` is the canonical lockstep cut. It 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 `website/releases/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the matrix, then `verify_channel_links` asserts nothing dangles.
|
||||||
|
|
||||||
- **Stable** (`sh tool/build.sh --release [version]`, or just `--release` to auto-bump patch): Writes `website/releases/<tool>_v<X.Y.Z>.html` (immutable real bytes), then refreshes 5 symlinks — `<tool>_v<X.Y>.html`, `<tool>_v<X>.html`, `<tool>_stable.html`, `<tool>_beta.html`, `<tool>_alpha.html` — all → the new versioned file. Tags `<tool>-v<X.Y.Z>`. Cascade rule: stable cut means beta and alpha both reset to stable (no active dev on either downstream channel). Skips silently if source has not changed since the latest stable tag.
|
- **Stable** (`sh build.sh --release` or `--release X.Y.Z`): Writes per-version HTML for the five 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. Tags all six: `<tool>-v<X.Y.Z>`. Cascade: stable cut means beta and alpha both reset to stable for every tool. Skips silently if source for an HTML tool hasn't changed since the latest stable tag (the binary always builds).
|
||||||
- **Beta** (`sh tool/build.sh --release beta`): Overwrites `<tool>_beta.html` with the dist HTML bytes (replacing the symlink with a real file if one was there). Cascade: `<tool>_alpha.html` → `<tool>_beta.html` (symlink). No tag.
|
- **Beta** (`sh build.sh --release beta`): Overwrites `<tool>_beta.html` with dist bytes for each HTML tool, and `zddc-server_beta_<platform>` with each platform's binary. Cascade: `<tool>_alpha.html` → `<tool>_beta.html` and `zddc-server_alpha_<platform>` → `zddc-server_beta_<platform>` (symlinks). No tag.
|
||||||
- **Alpha** (`sh tool/build.sh --release alpha`): Overwrites `<tool>_alpha.html` with the dist HTML bytes. No tag, no other side-effects.
|
- **Alpha** (`sh build.sh --release alpha`): Overwrites only the alpha mirrors, all six tools. No tag, no other side-effects.
|
||||||
- **Plain dev builds** (no `--release`): produce `tool/dist/<tool>.html` only. No `website/releases/` side-effect, no commit. To publish, re-run with `--release alpha`.
|
- **Plain dev builds** (no `--release`): produce `tool/dist/<tool>.html` for HTML tools and `zddc/dist/zddc-server-<platform>` binaries; do NOT touch `website/releases/`. The matrix index and stub pages still get regenerated from whatever `website/releases/` contains, so the build is idempotent for repeated dev runs.
|
||||||
|
|
||||||
On-page `{{BUILD_LABEL}}` format:
|
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself):
|
||||||
|
|
||||||
- Plain dev: `vX.Y.Z-alpha · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the next-stable target.
|
- Plain dev: `vX.Y.Z-alpha · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the per-tool next-stable target.
|
||||||
- `--release alpha`: `vX.Y.Z-alpha · <date> · <sha>` (red).
|
- `--release alpha`: `vX.Y.Z-alpha · <date> · <sha>` (red).
|
||||||
- `--release beta`: `vX.Y.Z-beta · <date> · <sha>` (red).
|
- `--release beta`: `vX.Y.Z-beta · <date> · <sha>` (red).
|
||||||
- `--release [version]`: `v<X.Y.Z>` (black).
|
- `--release [version]`: `v<X.Y.Z>` (black).
|
||||||
|
|
||||||
After cutting a stable release, `git push origin <tag>` to publish the tag. `git push origin main` publishes the new versioned file + updated symlinks.
|
After cutting a stable release, `git push origin main && git push origin --tags` to publish the new version files + symlinks + every per-tool tag in lockstep.
|
||||||
|
|
||||||
No `$CODEBERG_TOKEN` is needed for HTML-tool releases — they never touch Codeberg. (`zddc/release.sh` for zddc-server stable cuts still requires it for binary uploads.)
|
|
||||||
|
|
||||||
`website/index.html` (the root URL of zddc.varasys.io) is **hand-edited static content**, not built by `landing/build.sh`. Its "Install on your server" section points operators at two paths: local download (per-tool links to `/releases/`) and `zddc-server` install (the binary has the current-stable build of every tool baked in via `//go:embed`). The landing tool's release file is `website/releases/landing_v<X.Y.Z>.html`; on a `zddc-server` deployment, the embedded copy is served at the root URL by default, with operators able to override via `.zddc apps:` entries or by dropping a real `index.html`.
|
|
||||||
|
|
||||||
### Channel discipline (MUST rules)
|
### Channel discipline (MUST rules)
|
||||||
|
|
||||||
The build system does not enforce these. Treating channels carelessly defeats the point of having three. Be disciplined.
|
The build enforces lockstep mechanically (one command bumps all six). The rules below are still on you.
|
||||||
|
|
||||||
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If you ship `v0.0.5` with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published `<tool>_v0.0.5.html` in place. Stable per-version files are immutable.
|
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. **Coordinated minor/major bumps.** When any tool needs a minor or major bump, all five tools cut at the same time even if patches differ. Per-tool patches stay independent. This is a release-time process rule, not enforced by tooling.
|
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.
|
||||||
3. **No backports.** Don't try to patch an older stable version. Always cut a new stable at a higher version. Users pinned to the old version stay pinned by choice; they can move forward when they want.
|
3. **No backports.** Always cut a new stable at a higher version. Users pinned to an old version stay pinned by choice.
|
||||||
4. **Alpha and beta are mutable.** Document this anywhere you invite users to test them. Pinning a deployment to a channel mirror means it gets rebuilt without notice. For something stable, pin to a per-version URL (the `<tool>_v0.0.5.html` file).
|
4. **Alpha and beta are mutable.** Document this anywhere you invite users to test them. Pinning a deployment to a channel mirror means it gets rebuilt without notice. For reproducibility, pin to a per-version URL — `<tool>_v0.0.5.html` or `zddc-server_v0.0.5.html`.
|
||||||
5. **Cascade is automatic.** A stable cut resets beta and alpha symlinks → stable. A beta cut resets alpha → beta. So "no active beta" silently shows current stable and "no active alpha" silently shows current beta. Operators don't need to run a freshen step after a stable release; the cascade handles it.
|
5. **Cascade is automatic.** Stable cut → beta + alpha mirrors reset to stable (per-tool HTML AND per-platform zddc-server). Beta cut → alpha → beta. "No active beta" silently shows current stable. No freshen step required after a stable release.
|
||||||
6. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`.
|
6. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`.
|
||||||
7. **Beta soak before promoting (recommended).** Give a beta a few days of exposure before cutting the same code as stable. Not enforced; use judgment for trivial changes.
|
7. **Beta soak before promoting (recommended).** Give a beta a few days of exposure before cutting the same code as stable. Not enforced; use judgment for trivial changes.
|
||||||
|
|
||||||
|
|
@ -272,7 +290,7 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles in `tnd-zddc-chart` compile from source at deploy time, fetching the right tag from Codeberg).
|
zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Compile a local binary for the host platform (requires Go 1.24+)
|
# Compile a local binary for the host platform (requires Go 1.24+)
|
||||||
|
|
@ -282,7 +300,7 @@ zddc-server ships as a cross-compiled binary, not a container image. There's no
|
||||||
(cd zddc && go run ./cmd/zddc-server)
|
(cd zddc && go run ./cmd/zddc-server)
|
||||||
```
|
```
|
||||||
|
|
||||||
The repo's top-level `sh build.sh` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` when Go is on PATH. It's silently skipped otherwise — the HTML tools build regardless.
|
The repo's top-level `sh build.sh` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `--release` it also promotes those binaries to `website/releases/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools.
|
||||||
|
|
||||||
### Run (development)
|
### Run (development)
|
||||||
|
|
||||||
|
|
@ -291,11 +309,13 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
||||||
go run ./cmd/zddc-server
|
go run ./cmd/zddc-server
|
||||||
```
|
```
|
||||||
|
|
||||||
For a release binary (downloaded from Codeberg or built via `sh build.sh`):
|
For a release binary downloaded from `zddc.varasys.io/releases/`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64
|
||||||
|
chmod +x zddc-server_stable_linux-amd64
|
||||||
ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
||||||
./zddc/dist/zddc-server-linux-amd64
|
./zddc-server_stable_linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key environment variables
|
### Key environment variables
|
||||||
|
|
@ -311,22 +331,20 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
||||||
|
|
||||||
### Release tagging
|
### Release tagging
|
||||||
|
|
||||||
`zddc/release.sh` is the canonical path. It tags the commit, cross-compiles binaries (native Go), and uploads them as Codeberg release assets. **Stable cuts only** — zddc-server has no alpha/beta channel for binary distribution. Active dev/soak happens via the `helm/zddc-server-dev/` chart, which builds from source on every pod restart against any commit you point it at.
|
zddc-server has no separate release script anymore. The top-level `sh build.sh --release [version|alpha|beta]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `website/releases/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the matrix, and tags `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh zddc/release.sh # patch-bump from latest clean stable tag
|
sh build.sh --release # lockstep stable, coordinated next version
|
||||||
sh zddc/release.sh 0.1.0 # explicit version
|
sh build.sh --release 1.2.0 # lockstep stable, explicit version
|
||||||
|
sh build.sh --release alpha # lockstep alpha cut for everything
|
||||||
|
sh build.sh --release beta # lockstep beta cut for everything
|
||||||
```
|
```
|
||||||
|
|
||||||
The script tags the commit but does NOT push — finish with `git push origin main` and `git push origin <tag>`.
|
The script tags every tool but does NOT push — finish with `git push origin main && git push origin --tags`.
|
||||||
|
|
||||||
**Versioning** — clean semver. Stable cuts get `<tool>-vX.Y.Z` tags; no `-alpha.N` / `-beta.N` counters. The historical `zddc-server-v0.0.8-alpha.1` and `-alpha.2` tags from the previous scheme stay as artifacts but no new alpha/beta tags get added.
|
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all six 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 publishing** — release.sh uploads the four cross-compiled binaries (`zddc-server-{linux,darwin,windows}-{amd64,arm64}`) as release assets attached to the new git tag on Codeberg. Operators download from `https://codeberg.org/VARASYS/ZDDC/releases/download/zddc-server-vX.Y.Z/zddc-server-<platform>` directly.
|
**Binary distribution** — `website/releases/zddc-server_<X>_<platform>` are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
- Go 1.24+ on PATH (or run from a Go container).
|
|
||||||
- `$CODEBERG_TOKEN` exported, scoped to write the VARASYS/ZDDC repo.
|
|
||||||
|
|
||||||
There is no CI for this — solo workflow benefits from one canonical
|
There is no CI for this — solo workflow benefits from one canonical
|
||||||
local path that fails loudly and visibly on the developer's terminal.
|
local path that fails loudly and visibly on the developer's terminal.
|
||||||
|
|
@ -335,7 +353,7 @@ local path that fails loudly and visibly on the developer's terminal.
|
||||||
|
|
||||||
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+)
|
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/` (requires Go 1.24+)
|
||||||
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
|
||||||
- Every folder exposes a `.archive` virtual directory backed by the same global index — the depth in the URL only matters so HTML produced for offline use can reach `.archive/` via `../.archive/` relative links and have the browser resolve them before the request hits the server. The flat listing emits two redirect entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy of the named revision. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. ACL is the only filter: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory; per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree
|
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two redirect entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both redirect to the first chronologically received copy within that project. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
|
||||||
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
- ACL is enforced via cascading `.zddc` YAML files; authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`)
|
||||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||||
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
|
||||||
|
|
|
||||||
|
|
@ -33,24 +33,31 @@ tool/
|
||||||
tool.html # Generated output — never edit this manually
|
tool.html # Generated output — never edit this manually
|
||||||
```
|
```
|
||||||
|
|
||||||
Website files (what `zddc.varasys.io` serves) — committed in this repo as static assets, including the per-version HTML tool files:
|
Website files (what `zddc.varasys.io` serves) — committed in this repo as static assets, including the per-version HTML tool files AND the per-version zddc-server binaries:
|
||||||
|
|
||||||
```
|
```
|
||||||
website/
|
website/
|
||||||
index.html # hand-edited intro page + install snippets (root URL)
|
index.html # hand-edited intro page + install snippets (root URL)
|
||||||
releases/
|
releases/
|
||||||
index.html # versions index, regenerated by build.sh from filesystem scan
|
index.html # matrix table, regenerated by build.sh from filesystem scan
|
||||||
<tool>_v<X.Y.Z>.html # real per-version files (committed, immutable)
|
<tool>_v<X.Y.Z>.html # real per-version HTML (committed, immutable)
|
||||||
<tool>_v<X.Y>.html → ... # symlink: latest patch within X.Y.*
|
<tool>_v<X.Y>.html → ... # symlink: latest patch within X.Y.*
|
||||||
<tool>_v<X>.html → ... # symlink: latest within X.*.*
|
<tool>_v<X>.html → ... # symlink: latest within X.*.*
|
||||||
<tool>_stable.html → ... # symlink: current stable
|
<tool>_stable.html → ... # symlink: current stable HTML
|
||||||
<tool>_beta.html → ... # symlink to stable (or real bytes when active beta dev)
|
<tool>_beta.html → ... # symlink to stable (or real bytes when active beta dev)
|
||||||
<tool>_alpha.html → ... # symlink to beta/stable (or real bytes when active alpha dev)
|
<tool>_alpha.html → ... # symlink to beta/stable (or real bytes when active alpha dev)
|
||||||
|
zddc-server_v<X.Y.Z>_<platform> # real per-version cross-compiled binary
|
||||||
|
zddc-server_v<X.Y>_<platform> → ... # symlink chain (mirrors the HTML cascade per platform)
|
||||||
|
zddc-server_v<X>_<platform> → ...
|
||||||
|
zddc-server_<channel>_<platform> → ... # channel mirror per platform
|
||||||
|
zddc-server_<X>.html # generated stub: matrix-cell link → fans out 4 platform downloads
|
||||||
```
|
```
|
||||||
|
|
||||||
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no Codeberg proxy. 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.
|
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
|
||||||
|
|
||||||
zddc-server binaries are a separate concern — they ship as Codeberg release assets attached to clean `zddc-server-vX.Y.Z` tags by `zddc/release.sh`. The `helm/zddc-server-{prod,dev}/` charts build from source via init container instead of fetching binaries.
|
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 live in this repo too** — committed under `website/releases/`, served from `zddc.varasys.io/releases/`. No Codeberg release assets, no separate distribution channel. 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 matrix-cell link per release points at `zddc-server_<X>.html`, a small generated stub that surfaces all four platform downloads.
|
||||||
|
|
||||||
There is no `website/dev/`. To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release.
|
There is no `website/dev/`. To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release.
|
||||||
|
|
||||||
|
|
@ -83,7 +90,7 @@ When updating documentation, prefer linking over duplicating. If you find yourse
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
|
|
||||||
Each tool's `build.sh`:
|
Each HTML tool's `build.sh`:
|
||||||
|
|
||||||
1. Reads CSS files in declaration order, concatenates them
|
1. Reads CSS files in declaration order, concatenates them
|
||||||
2. Reads JS files in declaration order, concatenates them
|
2. Reads JS files in declaration order, concatenates them
|
||||||
|
|
@ -91,19 +98,26 @@ Each tool's `build.sh`:
|
||||||
4. Writes the result to `dist/tool.html`
|
4. Writes the result to `dist/tool.html`
|
||||||
5. If `--release <channel-or-version>` was passed, calls `promote_release` to write into `website/releases/` (per-version file + symlink updates for stable; channel mirror overwrite for alpha/beta).
|
5. If `--release <channel-or-version>` was passed, calls `promote_release` to write into `website/releases/` (per-version file + symlink updates for stable; channel mirror overwrite for alpha/beta).
|
||||||
|
|
||||||
The top-level `build.sh` at the repository root calls all five tool build scripts in sequence and writes `website/releases/index.html` from a filesystem scan of `website/releases/` so the versions index always matches the on-disk state.
|
The top-level `build.sh` at the repository root is the canonical lockstep entry point. It:
|
||||||
|
|
||||||
|
1. 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.
|
||||||
|
2. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
|
||||||
|
3. On `--release`, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `website/releases/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags.
|
||||||
|
4. Always calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `website/releases/`.
|
||||||
|
5. Regenerates `website/releases/index.html` as a matrix table (rows = versions, columns = tools).
|
||||||
|
6. Calls `verify_channel_links` — fails the build if any channel link is dangling.
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
|
|
||||||
Three release channels. `promote_release` in `shared/build-lib.sh` is the single point of truth for what each cut produces; the cascade rule keeps downstream channel symlinks current automatically.
|
Three release channels, applied in lockstep across all six tools (5 HTML + zddc-server). The cascade rule keeps downstream channel symlinks current automatically.
|
||||||
|
|
||||||
- **Stable** — versioned, immutable. `--release [version]` writes `website/releases/<tool>_v<X.Y.Z>.html` (real bytes), refreshes 5 symlinks (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`, `<tool>_stable.html`, `<tool>_beta.html`, `<tool>_alpha.html`) all → the new versioned file, and tags `<tool>-v<X.Y.Z>` in git. Skips automatically when there is no source change since the last stable tag.
|
- **Stable** — versioned, immutable. `sh build.sh --release [version]` writes per-version HTML for the five 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** — `--release beta` overwrites `<tool>_beta.html` with the dist HTML bytes (replacing the symlink with a real file if one was there). Cascades `<tool>_alpha.html` → `<tool>_beta.html` (symlink). No tag — channel URLs are stable URLs by design; counter tags would defeat that. On-page label: `vX.Y.Z-beta · <date> · <sha>` where X.Y.Z is the next-stable target.
|
- **Beta** — `sh build.sh --release 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** — `--release alpha` overwrites `<tool>_alpha.html` with the dist HTML bytes. No tag, no other side-effects. On-page label: `vX.Y.Z-alpha · <date> · <sha>`.
|
- **Alpha** — `sh build.sh --release alpha` overwrites only the alpha mirrors, all six tools. No tag, no other side-effects.
|
||||||
|
|
||||||
A plain `sh tool/build.sh` (no `--release`) is a dev build: it produces `dist/<tool>.html` only, with the on-page label `vX.Y.Z-alpha · <full-ts> · <sha>[-dirty]`. No write to `website/releases/`, no tag, no commit.
|
A plain `sh build.sh` (no `--release`) is a dev build: it produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `website/releases/`. The matrix index and stub pages still get regenerated from whatever's in `website/releases/`, so dev builds remain idempotent and don't break the channel-link verifier.
|
||||||
|
|
||||||
The cascade rule (stable cut → beta + alpha both reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release.
|
The cascade rule (stable cut → beta + alpha mirrors reset to stable; beta cut → alpha resets to beta) means downstream channels are never stale across either HTML or binaries. "No active beta" silently shows current stable; "no active alpha" silently shows current beta or stable. Operators don't need to run a freshen step after each stable release.
|
||||||
|
|
||||||
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/alpha/beta builds (`is_red=1`) and black for stable releases. The label format is:
|
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/alpha/beta builds (`is_red=1`) and black for stable releases. The label format is:
|
||||||
|
|
||||||
|
|
@ -151,7 +165,7 @@ Independent of how the tool got installed. `archive` auto-detects from the URL a
|
||||||
Every `build.sh` must:
|
Every `build.sh` must:
|
||||||
|
|
||||||
- Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash)
|
- Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash)
|
||||||
- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`)
|
- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`, plus the lockstep helpers `_coordinated_next_stable`, `promote_zddc_server`, `write_zddc_server_stubs_all`, `verify_channel_links`)
|
||||||
- Fail immediately on missing source files (`ensure_exists` pattern)
|
- Fail immediately on missing source files (`ensure_exists` pattern)
|
||||||
- Clean up temp files on exit (use `trap cleanup EXIT`)
|
- Clean up temp files on exit (use `trap cleanup EXIT`)
|
||||||
- Accept `--release [<version>|alpha|beta]` — explicit version or channel name; otherwise produce a dev build
|
- Accept `--release [<version>|alpha|beta]` — explicit version or channel name; otherwise produce a dev build
|
||||||
|
|
|
||||||
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -16,27 +16,32 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
|
||||||
This is a **monorepo of independent tools**, not one application:
|
This is a **monorepo of independent tools**, not one application:
|
||||||
|
|
||||||
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/` — five self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Naming: the first four output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`).
|
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/` — five self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Naming: the first four output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`).
|
||||||
- `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. Stable releases ship as cross-compiled binaries on Codeberg release assets; the `helm/` charts in this repo build from source at deploy time.
|
- `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 committed to `website/releases/` and served from `zddc.varasys.io/releases/` (no Codeberg release assets); 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, `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh`), and `publish-codeberg-release.sh` (used by `zddc/release.sh` to upload zddc-server binaries — HTML tools no longer use it).
|
- `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.sh` for lockstep release helpers).
|
||||||
- `website/` — committed static site: `index.html` (root URL, hand-edited intro), `releases/<tool>_v<X.Y.Z>.html` (immutable per-version archives), `releases/<tool>_v<X.Y>.html` and `_v<X>.html` (symlinks), `releases/<tool>_{stable,beta,alpha}.html` (channel mirrors), `releases/index.html` (regenerated by `build.sh`). **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all five 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.
|
- `website/` — committed static site: `index.html` (root URL, hand-edited intro), `releases/<tool>_v<X.Y.Z>.html` (immutable per-version archives), `releases/<tool>_v<X.Y>.html` and `_v<X>.html` (symlinks), `releases/<tool>_{stable,beta,alpha}.html` (channel mirrors), `releases/zddc-server_v<X.Y.Z>_<platform>` (per-version cross-compiled binaries), `releases/zddc-server_<channel>_<platform>` (binary symlinks following the same cascade), `releases/zddc-server_<X>.html` (per-version / per-channel stub pages that fan out the four platform downloads in one matrix-cell link), `releases/index.html` (matrix table regenerated by `build.sh`). **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all five 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.
|
- `helm/` — example Helm charts for zddc-server (`zddc-server-prod/`, `zddc-server-dev/`). Both compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo.
|
||||||
- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright)
|
- `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
|
## Most-used commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sh build.sh # build all five HTML tools (dist/ only) + regen website/releases/index.html
|
sh build.sh # dev build: 5 HTML tools + cross-compile zddc-server binaries + regen releases/index.html
|
||||||
sh tool/build.sh # build one (archive|transmittal|classifier|mdedit|landing)
|
sh tool/build.sh # build one HTML tool (archive|transmittal|classifier|mdedit|landing)
|
||||||
sh tool/build.sh --release [version] # cut stable; write website/releases/<tool>_v<X.Y.Z>.html, refresh 5 symlinks, tag <tool>-vX.Y.Z
|
|
||||||
sh tool/build.sh --release alpha|beta # cut channel; overwrite website/releases/<tool>_<channel>.html in place. No tag (channel URLs are stable URLs by design)
|
# Lockstep releases — every cut bumps ALL tools (5 HTML + zddc-server) to the same version
|
||||||
./freshen-channel <tool> <channel> # rebuild alpha/beta from current stable tag (run after every stable release if you want to advance the channel mirror)
|
sh build.sh --release # stable, coordinated next-version (max(latest tag) + 1)
|
||||||
|
sh build.sh --release X.Y.Z # stable, explicit version
|
||||||
|
sh build.sh --release alpha # alpha channel cut for everything
|
||||||
|
sh build.sh --release beta # beta channel cut for everything
|
||||||
|
|
||||||
|
sh tool/build.sh --release [version|alpha|beta] # single-tool release (rare; prefer the lockstep top-level cut)
|
||||||
|
./freshen-channel <tool> <channel> # rebuild a single tool's alpha/beta from its current stable tag
|
||||||
npm test # all Playwright specs (build first!)
|
npm test # all Playwright specs (build first!)
|
||||||
npx playwright test <tool> # one spec
|
npx playwright test <tool> # one spec
|
||||||
./dev-server start # ./dev-server stop # cache-busting HTTP on :8000
|
./dev-server start # ./dev-server stop # cache-busting HTTP on :8000
|
||||||
|
|
||||||
# zddc/ Go server (separate sub-project, not part of sh build.sh)
|
# zddc/ Go server (sub-project)
|
||||||
(cd zddc && go test ./...) # unit tests (Go 1.24+)
|
(cd zddc && go test ./...) # unit tests (Go 1.24+)
|
||||||
sh zddc/release.sh [<version>] # cut stable zddc-server release; tags + cross-compiles binaries + uploads to Codeberg. Stable-only (no alpha/beta channel for binaries).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design.
|
No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design.
|
||||||
|
|
@ -44,10 +49,12 @@ No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSI
|
||||||
## Things that bite if you forget
|
## Things that bite if you forget
|
||||||
|
|
||||||
- **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and as the source for `--release` writes. Never hand-edit a `dist/` file.
|
- **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and as the source for `--release` writes. Never hand-edit a `dist/` file.
|
||||||
- **`website/releases/` is committed.** Per-version files (`<tool>_v<X.Y.Z>.html`) are real immutable files; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channels (`<tool>_<channel>.html`) are checked-in symlinks. Stable cuts write the new versioned file + refresh the 5 symlinks (cascade rule: stable cut → beta + alpha both reset to the new stable). Beta/alpha cuts overwrite their channel mirror in place; on a beta cut, alpha cascades to point at beta.
|
- **Lockstep releases.** Every release cut bumps all six artifacts (5 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 — `sh build.sh --release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship.
|
||||||
- **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 still get clean `<tool>-vX.Y.Z` tags.
|
- **`website/releases/` is committed.** HTML tools: per-version `<tool>_v<X.Y.Z>.html` (real immutable files) + partial-version pins + channel mirrors (symlinks). zddc-server: `zddc-server_v<X.Y.Z>_<platform>` per-version binaries (real bytes), `zddc-server_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.
|
||||||
- **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 (patch+1 from latest clean `<tool>-vX.Y.Z` tag). Plain dev adds a full timestamp + `-dirty` marker; `--release alpha|beta` is date-only.
|
- **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).
|
||||||
- **Plain `sh tool/build.sh` is a dev build.** Writes `dist/<tool>.html` only; no `website/releases/` side-effect. To publish, re-run with `--release alpha`.
|
- **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 `sh build.sh` ends with a check that every `<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary mirrors + stub pages) resolves. Bootstrap-friendly: skips zddc-server checks until the first `--release` cut materializes the binaries.
|
||||||
|
- **Plain `sh tool/build.sh` is a dev build.** Writes `dist/<tool>.html` only; no `website/releases/` side-effect. To publish, re-run with `sh build.sh --release alpha` to cut all six tools' alpha mirrors together.
|
||||||
- **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`.
|
- **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`.
|
||||||
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
|
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
|
||||||
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
|
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
|
||||||
|
|
|
||||||
|
|
@ -504,27 +504,21 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if any path segment of the transmittal folder matches a selected
|
// Returns true if the transmittal folder's path satisfies all three cascade
|
||||||
// party name AND the segment immediately after it (if it's a folder-type name)
|
// layers: project visibility, party selection (any path segment matches a
|
||||||
// is in enabledFolderTypes. Segment-equality matching means a party "BM" selected
|
// selected party name), and folder-type enablement (no segment is a
|
||||||
// matches every "<...>/BM/<...>" path regardless of the prefix.
|
// folder-type marker that's currently disabled, regardless of where in the
|
||||||
|
// path it sits). Segment-equality matching means a party "BM" selected
|
||||||
|
// matches every "<...>/BM/<...>" path regardless of the prefix; and the
|
||||||
|
// folder-type check covers BOTH the canonical "<party>/Issued/<txn>" layout
|
||||||
|
// AND nested layouts like "<party>/<sub>/Issued/<txn>" — a deeper folder-
|
||||||
|
// type marker still triggers the cascade.
|
||||||
function transmittalIsUnderVisibleParty(folder) {
|
function transmittalIsUnderVisibleParty(folder) {
|
||||||
if (!pathIsInVisibleProject(folder.path)) return false;
|
if (!pathIsInVisibleProject(folder.path)) return false;
|
||||||
const parts = folder.path.split('/');
|
if (isUnderHiddenFolderType(folder.path)) return false;
|
||||||
for (let i = 0; i < parts.length; i++) {
|
return folder.path.split('/').some(seg =>
|
||||||
if (!window.app.selectedGroupingFolders.has(parts[i])) continue;
|
window.app.selectedGroupingFolders.has(seg)
|
||||||
// i-th segment is a selected party. The segment after is either a
|
);
|
||||||
// folder-type marker (Issued/Received/MDL/Incoming) or the transmittal
|
|
||||||
// folder itself.
|
|
||||||
const next = parts[i + 1];
|
|
||||||
if (!next) return true;
|
|
||||||
const folderType = next.toLowerCase();
|
|
||||||
if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) {
|
|
||||||
return window.app.enabledFolderTypes.has(folderType);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render transmittal folders (rebuilds DOM)
|
// Render transmittal folders (rebuilds DOM)
|
||||||
|
|
|
||||||
588
build.sh
588
build.sh
|
|
@ -1,11 +1,69 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# Top-level build script — builds all ZDDC HTML tools, the zddc-server
|
# Top-level build script — builds all five HTML tools, cross-compiles
|
||||||
# binaries, and the website/releases/index.html versions index.
|
# zddc-server binaries, and (when invoked with --release) cuts a lockstep
|
||||||
|
# release that bumps every tool to the same version.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sh build.sh # dev build
|
||||||
|
# sh build.sh --release # stable cut, coordinated version
|
||||||
|
# sh build.sh --release X.Y.Z # stable cut, explicit version
|
||||||
|
# sh build.sh --release alpha # alpha channel cut
|
||||||
|
# sh build.sh --release beta # beta channel cut
|
||||||
|
#
|
||||||
|
# Lockstep convention: every release bumps all six artifacts (5 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.
|
||||||
|
# Channel cuts (alpha/beta) follow the same lockstep — every tool's
|
||||||
|
# channel mirror is overwritten in step.
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
|
||||||
|
# Source build-lib.sh once at the top level so the helpers it provides
|
||||||
|
# (promote_zddc_server, write_zddc_server_stubs_all, verify_channel_links,
|
||||||
|
# _coordinated_next_stable) are in scope. Each tool's build.sh sources it
|
||||||
|
# again — that's a no-op on already-defined functions.
|
||||||
|
root_dir="$SCRIPT_DIR"
|
||||||
|
. "$SCRIPT_DIR/shared/build-lib.sh"
|
||||||
|
|
||||||
|
# --- Parse release args ----------------------------------------------------
|
||||||
|
RELEASE_FLAG="${1:-}"
|
||||||
|
RELEASE_ARG="${2:-}"
|
||||||
|
RELEASE_CHANNEL=""
|
||||||
|
RELEASE_VERSION=""
|
||||||
|
|
||||||
|
if [ "$RELEASE_FLAG" = "--release" ]; then
|
||||||
|
case "$RELEASE_ARG" in
|
||||||
|
alpha | beta)
|
||||||
|
RELEASE_CHANNEL="$RELEASE_ARG"
|
||||||
|
;;
|
||||||
|
'')
|
||||||
|
RELEASE_CHANNEL="stable"
|
||||||
|
RELEASE_VERSION=$(_coordinated_next_stable)
|
||||||
|
echo "=== Lockstep stable release — coordinated version: v$RELEASE_VERSION ==="
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
_validate_semver "$RELEASE_ARG"
|
||||||
|
RELEASE_CHANNEL="stable"
|
||||||
|
RELEASE_VERSION="$RELEASE_ARG"
|
||||||
|
echo "=== Lockstep stable release — explicit version: v$RELEASE_VERSION ==="
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the per-tool argument list. For stable lockstep cuts pass the
|
||||||
|
# explicit version so every tool agrees; for alpha/beta pass the channel
|
||||||
|
# name; for plain dev builds pass nothing.
|
||||||
|
TOOL_RELEASE_ARGS=""
|
||||||
|
if [ -n "$RELEASE_CHANNEL" ]; then
|
||||||
|
if [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||||
|
TOOL_RELEASE_ARGS="--release $RELEASE_VERSION"
|
||||||
|
else
|
||||||
|
TOOL_RELEASE_ARGS="--release $RELEASE_CHANNEL"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== Building ZDDC tools ==="
|
echo "=== Building ZDDC tools ==="
|
||||||
|
|
||||||
# Each tool's compute_build_label writes a sidecar `<tool>.label` here so
|
# Each tool's compute_build_label writes a sidecar `<tool>.label` here so
|
||||||
|
|
@ -15,11 +73,12 @@ rm -rf "$BUILD_LABELS_DIR"
|
||||||
mkdir -p "$BUILD_LABELS_DIR"
|
mkdir -p "$BUILD_LABELS_DIR"
|
||||||
export BUILD_LABELS_DIR
|
export BUILD_LABELS_DIR
|
||||||
|
|
||||||
sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}"
|
# shellcheck disable=SC2086 # intentional word-splitting on TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/archive/build.sh" $TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/mdedit/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
|
||||||
sh "$SCRIPT_DIR/landing/build.sh" "${1:-}" "${2:-}"
|
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
|
||||||
|
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Assembling zddc/dist/web/ ==="
|
echo "=== Assembling zddc/dist/web/ ==="
|
||||||
|
|
@ -97,16 +156,21 @@ GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}"
|
||||||
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
|
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
|
||||||
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
||||||
|
|
||||||
# Compute the binary's own version: `git describe` if available (clean tag,
|
# Compute the binary's own version. On a stable cut, hard-code the
|
||||||
# or tag-N-gSHA[-dirty] for in-flight commits), else falls back to "dev".
|
# coordinated version so the binary embeds the same string the rest of the
|
||||||
# Surfaces via `zddc-server --version` and in the startup log line.
|
# release cycle has agreed on. Otherwise fall back to git describe (clean
|
||||||
ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true)
|
# tag, or tag-N-gSHA[-dirty] for in-flight commits).
|
||||||
if [ -z "$ZDDC_BINARY_VERSION" ]; then
|
if [ -n "$RELEASE_VERSION" ]; then
|
||||||
_sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)
|
ZDDC_BINARY_VERSION="$RELEASE_VERSION"
|
||||||
if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then
|
else
|
||||||
_sha="${_sha}-dirty"
|
ZDDC_BINARY_VERSION=$(git -C "$SCRIPT_DIR" describe --tags --dirty --match 'zddc-server-v*' 2>/dev/null || true)
|
||||||
|
if [ -z "$ZDDC_BINARY_VERSION" ]; then
|
||||||
|
_sha=$(git -C "$SCRIPT_DIR" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)
|
||||||
|
if ! git -C "$SCRIPT_DIR" diff --quiet HEAD 2>/dev/null; then
|
||||||
|
_sha="${_sha}-dirty"
|
||||||
|
fi
|
||||||
|
ZDDC_BINARY_VERSION="dev-${_sha}"
|
||||||
fi
|
fi
|
||||||
ZDDC_BINARY_VERSION="dev-${_sha}"
|
|
||||||
fi
|
fi
|
||||||
echo " binary version: $ZDDC_BINARY_VERSION"
|
echo " binary version: $ZDDC_BINARY_VERSION"
|
||||||
|
|
||||||
|
|
@ -137,59 +201,100 @@ echo " binary version: $ZDDC_BINARY_VERSION"
|
||||||
|
|
||||||
WEBSITE_DIR="$SCRIPT_DIR/website"
|
WEBSITE_DIR="$SCRIPT_DIR/website"
|
||||||
RELEASES_DIR="$WEBSITE_DIR/releases"
|
RELEASES_DIR="$WEBSITE_DIR/releases"
|
||||||
|
mkdir -p "$RELEASES_DIR"
|
||||||
|
|
||||||
mkdir -p "$WEBSITE_DIR"
|
# --- Promote zddc-server release artifacts ---------------------------------
|
||||||
|
# On a release cut, copy the freshly cross-compiled binaries to
|
||||||
|
# website/releases/ under their canonical names + symlinks (lockstep with
|
||||||
|
# the HTML tools' release flow). On a plain build, just refresh stub pages
|
||||||
|
# from whatever artifacts already live in releases/.
|
||||||
|
if [ -n "$RELEASE_CHANNEL" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "=== Promoting zddc-server $RELEASE_CHANNEL release ==="
|
||||||
|
promote_zddc_server "$RELEASE_CHANNEL" "$RELEASE_VERSION" "$RELEASES_DIR" "$SCRIPT_DIR/zddc/dist"
|
||||||
|
else
|
||||||
|
write_zddc_server_stubs_all "$RELEASES_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# Regenerate website/releases/index.html from a filesystem scan of
|
# Latest stable version, by following archive_stable.html → versioned target.
|
||||||
# website/releases/. Lists per-version files (real .html files, immutable
|
# Returns "" if no stable cut exists yet (bootstrap state). All HTML tools
|
||||||
# archives) plus channel mirrors and partial-version pins (symlinks).
|
# move in lockstep so any one of them is a valid probe; archive is canonical.
|
||||||
#
|
_latest_stable_version() {
|
||||||
# All URLs in the page resolve directly under <upstream>/releases/<file>
|
_link="$RELEASES_DIR/archive_stable.html"
|
||||||
# — no Codeberg API call, no manifest, no proxy magic. Page is static
|
[ -L "$_link" ] || return 0
|
||||||
# and current as of the last `sh build.sh` run.
|
_target=$(readlink "$_link")
|
||||||
#
|
# archive_v0.0.8.html → 0.0.8
|
||||||
# zddc-server's section links to its Codeberg release pages directly
|
_v="${_target#archive_v}"
|
||||||
# (different distribution model — per-platform binaries).
|
_v="${_v%.html}"
|
||||||
|
case "$_v" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*) echo "$_v" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Channel "active" iff the channel mirror is real bytes rather than a
|
||||||
|
# symlink → stable. Used to surface alpha/beta in the dropdown only when
|
||||||
|
# they meaningfully differ from stable. Probes archive (HTML lockstep
|
||||||
|
# representative); zddc-server's probe is its per-platform binary.
|
||||||
|
_channel_is_active() {
|
||||||
|
_ch="$1" # alpha | beta
|
||||||
|
_f="$RELEASES_DIR/archive_${_ch}.html"
|
||||||
|
[ -L "$_f" ] && return 1 # symlink → tracks stable, not "active"
|
||||||
|
[ -f "$_f" ] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Regenerate website/releases/index.html as the action-first install
|
||||||
|
# guide (not a matrix). The page guides users to either self-host the
|
||||||
|
# server or download individual tools, with one version dropdown that
|
||||||
|
# rewires every download link via JS. The default static state always
|
||||||
|
# uses latest-stable URLs so the page works fully without JS.
|
||||||
build_releases_index() {
|
build_releases_index() {
|
||||||
_out="$RELEASES_DIR/index.html"
|
_out="$RELEASES_DIR/index.html"
|
||||||
mkdir -p "$RELEASES_DIR"
|
mkdir -p "$RELEASES_DIR"
|
||||||
|
|
||||||
|
_latest=$(_latest_stable_version)
|
||||||
|
if [ -z "$_latest" ]; then
|
||||||
|
_latest="0.0.0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# All distinct stable versions across every tool, descending. Same
|
||||||
|
# awk that the prior matrix used — proven across the tool naming.
|
||||||
|
_all_versions=$(
|
||||||
|
find "$RELEASES_DIR" -maxdepth 1 -type f \( \
|
||||||
|
-name 'archive_v*.html' -o -name 'transmittal_v*.html' \
|
||||||
|
-o -name 'classifier_v*.html' -o -name 'mdedit_v*.html' \
|
||||||
|
-o -name 'landing_v*.html' \
|
||||||
|
-o -name 'zddc-server_v*_linux-amd64' \
|
||||||
|
\) 2>/dev/null \
|
||||||
|
| awk -F/ '{
|
||||||
|
n = split($NF, parts, "_v");
|
||||||
|
if (n < 2) next;
|
||||||
|
v = parts[2];
|
||||||
|
sub(/\.html$/, "", v);
|
||||||
|
sub(/_linux-amd64$/, "", v);
|
||||||
|
if (v ~ /^[0-9]+\.[0-9]+\.[0-9]+$/) print v;
|
||||||
|
}' \
|
||||||
|
| sort -Vu \
|
||||||
|
| sort -Vr
|
||||||
|
)
|
||||||
|
|
||||||
|
_alpha_active="0"; _channel_is_active alpha && _alpha_active="1"
|
||||||
|
_beta_active="0"; _channel_is_active beta && _beta_active="1"
|
||||||
|
|
||||||
{
|
{
|
||||||
cat <<'HEAD'
|
cat <<HEAD
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Releases — ZDDC</title>
|
<title>Download ZDDC</title>
|
||||||
<meta name="description" content="All released versions and channel builds of every ZDDC tool.">
|
<meta name="description" content="Self-host the ZDDC server, or download individual tools. Pin a version your project trusts; your archive's tools are yours.">
|
||||||
<meta name="theme-color" content="#2a5a8a">
|
<meta name="theme-color" content="#2a5a8a">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="../css/style.css">
|
<link rel="stylesheet" href="../css/style.css">
|
||||||
<style>
|
|
||||||
.rel-tool { margin-top: var(--spacing-xl); padding: var(--spacing-md); border: 1px solid var(--color-border); border-radius: 8px; }
|
|
||||||
.rel-tool h2 { margin-top: 0; }
|
|
||||||
.rel-channels { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0 1.25rem 0; }
|
|
||||||
.rel-channels a { padding: 0.25rem 0.625rem; border-radius: 999px; text-decoration: none; border: 1px solid var(--color-border); color: var(--color-text); font-size: 0.9rem; }
|
|
||||||
.rel-channels a.stable { border-color: var(--color-primary); color: var(--color-primary); font-weight: 600; }
|
|
||||||
.rel-channels a.beta, .rel-channels a.alpha { color: var(--color-text-muted); }
|
|
||||||
.rel-channels a:hover { background: var(--color-bg-subtle); }
|
|
||||||
.rel-versions { font-size: 0.875rem; color: var(--color-text-muted); }
|
|
||||||
.rel-versions a { margin-right: 0.5rem; color: var(--color-text); text-decoration: none; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
|
||||||
.rel-versions a:hover { background: var(--color-bg-subtle); text-decoration: underline; }
|
|
||||||
.rel-meta { font-size: 0.85rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
|
||||||
.rel-bin-table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1rem; font-size: 0.9rem; }
|
|
||||||
.rel-bin-table th, .rel-bin-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--color-border); }
|
|
||||||
.rel-bin-table th { font-weight: 600; color: var(--color-text-muted); }
|
|
||||||
.rel-bin-table td.ch-stable { color: var(--color-primary); font-weight: 600; }
|
|
||||||
.rel-bin-table td.ch-beta, .rel-bin-table td.ch-alpha { color: var(--color-text-muted); }
|
|
||||||
.rel-bin-table a { color: var(--color-text); text-decoration: none; padding: 0.1rem 0.35rem; border-radius: 4px; }
|
|
||||||
.rel-bin-table a:hover { background: var(--color-bg-subtle); text-decoration: underline; }
|
|
||||||
.rel-bin-table td.empty { color: var(--color-text-muted); font-style: italic; }
|
|
||||||
.rel-pull { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--color-bg-subtle); padding: 0.25rem 0.5rem; border-radius: 4px; display: inline-block; margin: 0.2rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
|
@ -208,80 +313,204 @@ build_releases_index() {
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/" class="nav-link">Home</a>
|
<a href="/" class="nav-link">Home</a>
|
||||||
<a href="../reference.html" class="nav-link">Docs</a>
|
<a href="../reference.html" class="nav-link">Docs</a>
|
||||||
<a href="index.html" class="nav-link active">Releases</a>
|
<a href="index.html" class="nav-link active">Download</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Releases</h1>
|
<h1>Download ZDDC</h1>
|
||||||
<p class="hero-subtitle">All published versions and channel builds of every ZDDC tool. Stable releases are immutable; alpha and beta channels are rebuilt without notice.</p>
|
<p class="hero-subtitle">Pick how you want to use it. Pick the version you want. Every link below points at a real, immutable file you can save into your archive — your tools, your version, forever.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
||||||
|
<div class="version-picker-bar">
|
||||||
|
<label for="version-picker">Showing</label>
|
||||||
|
<select id="version-picker">
|
||||||
HEAD
|
HEAD
|
||||||
# Render one section per HTML tool, scanning website/releases/ for
|
|
||||||
# <tool>_v*.html real files (per-version archives) and resolving the
|
|
||||||
# current channel symlinks.
|
|
||||||
for _tool_entry in 'archive|Archive' \
|
|
||||||
'transmittal|Transmittal' \
|
|
||||||
'classifier|Classifier' \
|
|
||||||
'mdedit|Markdown Editor' \
|
|
||||||
'landing|Landing (project picker)'; do
|
|
||||||
_tool="${_tool_entry%%|*}"
|
|
||||||
_title="${_tool_entry#*|}"
|
|
||||||
|
|
||||||
# All per-version real files for this tool, semver-descending.
|
# Stable versions, latest first
|
||||||
# Use find to filter out symlinks; grep + sort -Vr for ordering.
|
_first=1
|
||||||
_versioned=$(find "$RELEASES_DIR" -maxdepth 1 -type f -name "${_tool}_v*.html" 2>/dev/null \
|
printf '%s\n' "$_all_versions" | while read -r _v; do
|
||||||
| sed "s|^${RELEASES_DIR}/||" \
|
[ -n "$_v" ] || continue
|
||||||
| sort -Vr)
|
_label="v${_v}"
|
||||||
|
if [ "$_first" = "1" ]; then
|
||||||
if [ -z "$_versioned" ]; then
|
_label="${_label} (current stable)"
|
||||||
continue
|
_first=0
|
||||||
fi
|
fi
|
||||||
|
printf ' <option value="v%s"%s>%s</option>\n' "$_v" "$( [ "$_v" = "$_latest" ] && printf ' selected' )" "$_label"
|
||||||
printf ' <section class="rel-tool">\n'
|
|
||||||
printf ' <h2>%s</h2>\n' "$_title"
|
|
||||||
|
|
||||||
# Channel chips — only render if the symlink exists.
|
|
||||||
printf ' <div class="rel-channels">\n'
|
|
||||||
for _ch in stable beta alpha; do
|
|
||||||
_link="${_tool}_${_ch}.html"
|
|
||||||
if [ -e "$RELEASES_DIR/$_link" ]; then
|
|
||||||
printf ' <a class="%s" href="%s">%s</a>\n' "$_ch" "$_link" "$_ch"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
printf ' </div>\n'
|
|
||||||
|
|
||||||
# Pin-to-version row: every per-version real file, descending.
|
|
||||||
printf ' <div class="rel-versions"><strong>Pin to version:</strong>\n'
|
|
||||||
printf '%s\n' "$_versioned" | while read -r _f; do
|
|
||||||
[ -n "$_f" ] || continue
|
|
||||||
# Display as v<X.Y.Z> stripped of <tool>_v prefix and .html suffix.
|
|
||||||
_v="${_f#${_tool}_v}"
|
|
||||||
_v="${_v%.html}"
|
|
||||||
printf ' <a href="%s">v%s</a>\n' "$_f" "$_v"
|
|
||||||
done
|
|
||||||
printf ' </div>\n'
|
|
||||||
|
|
||||||
printf ' </section>\n'
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# zddc-server section — links to Codeberg release pages directly,
|
# Pre-release channels — always shown, so the alpha/beta URLs are
|
||||||
# since binaries don't live under website/releases/.
|
# discoverable without knowing the URL pattern. Label tells the
|
||||||
printf ' <section class="rel-tool">\n'
|
# truth about the current state: "active dev" when the channel has
|
||||||
printf ' <h2>zddc-server (Go file server)</h2>\n'
|
# its own bytes, "tracks stable" when it's just a cascade symlink.
|
||||||
printf ' <p>Binaries are published as Codeberg release assets. Pick a platform from the release page; or build from source via the helm charts under <code>helm/</code>.</p>\n'
|
printf ' <optgroup label="Pre-release channels">\n'
|
||||||
printf ' <p><a href="https://codeberg.org/VARASYS/ZDDC/releases">Browse zddc-server releases on Codeberg →</a></p>\n'
|
if [ "$_beta_active" = "1" ]; then
|
||||||
printf ' </section>\n'
|
printf ' <option value="beta">beta — general testing</option>\n'
|
||||||
|
else
|
||||||
|
printf ' <option value="beta">beta — currently tracks stable</option>\n'
|
||||||
|
fi
|
||||||
|
if [ "$_alpha_active" = "1" ]; then
|
||||||
|
printf ' <option value="alpha">alpha — active dev</option>\n'
|
||||||
|
else
|
||||||
|
printf ' <option value="alpha">alpha — currently tracks stable</option>\n'
|
||||||
|
fi
|
||||||
|
printf ' </optgroup>\n'
|
||||||
|
|
||||||
cat <<'TAIL'
|
cat <<'PICKER_END'
|
||||||
|
</select>
|
||||||
|
<span class="picker-hint">Changes every download link below.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section style="margin-top: var(--spacing-2xl); color: var(--color-text-muted); font-size: 0.9rem;">
|
<!-- Channel quick-pick chips — visible at-a-glance entry points to
|
||||||
<p>Each link above is a real static file (or a checked-in symlink resolving to one). Channel chips track the current build of that channel and may change at any time; per-version files are immutable. To install or pin in your own deployment, see <a href="../">the home page</a>.</p>
|
alpha and beta even when they cascade to stable. Clicking drives
|
||||||
|
the version picker (rewires the page) rather than navigating
|
||||||
|
away. The chip is always live; the cascade rule keeps the URLs
|
||||||
|
it represents permanently resolvable. -->
|
||||||
|
<div class="channel-chips" role="group" aria-label="Channel quick pick">
|
||||||
|
<span class="channel-chips-label">Or pick a channel:</span>
|
||||||
|
<button type="button" class="channel-chip is-current" data-channel="stable">stable</button>
|
||||||
|
<button type="button" class="channel-chip" data-channel="beta">beta</button>
|
||||||
|
<button type="button" class="channel-chip" data-channel="alpha">alpha</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ───────────── Path A — Self-host the server ───────────── -->
|
||||||
|
<section class="card" style="background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-lg);">
|
||||||
|
<h2 style="margin-top:0;">Path A — Self-host the server</h2>
|
||||||
|
<p>One small Go binary. <strong>All five tools are baked in</strong> via <code>//go:embed</code>; the server picks the right one for each folder of your archive. Adds ACL via <code>.zddc</code> files, the virtual <code>.archive</code> document index, and SSO header passthrough. Stop the server and the directory is still a perfectly valid ZDDC archive — the server is convenience, not lock-in.</p>
|
||||||
|
PICKER_END
|
||||||
|
|
||||||
|
# Render the download UI only when zddc-server has been published
|
||||||
|
# at least once. Until then, show an honest "not yet released"
|
||||||
|
# placeholder rather than dangling download buttons.
|
||||||
|
_zs_published="0"
|
||||||
|
if [ -e "$RELEASES_DIR/zddc-server_stable_linux-amd64" ]; then
|
||||||
|
_zs_published="1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_zs_published" = "1" ]; then
|
||||||
|
printf ' <a class="dl-primary"\n'
|
||||||
|
printf ' data-tool="zddc-server"\n'
|
||||||
|
printf ' data-platform="linux-amd64"\n'
|
||||||
|
printf ' href="zddc-server_v%s_linux-amd64"\n' "$_latest"
|
||||||
|
printf ' id="dl-primary-binary">\n'
|
||||||
|
printf ' <span class="dl-icon">⬇</span>\n'
|
||||||
|
printf ' <span>Download <span id="dl-primary-platlabel">for Linux (x86_64)</span></span>\n'
|
||||||
|
printf ' </a>\n'
|
||||||
|
printf ' <span class="dl-primary-meta" id="dl-primary-meta">zddc-server_v%s_linux-amd64</span>\n' "$_latest"
|
||||||
|
|
||||||
|
printf ' <div class="dl-secondary-row" id="dl-others">\n'
|
||||||
|
printf ' <span>Other platforms:</span>\n'
|
||||||
|
for _entry in "linux-amd64|Linux (x86_64)" \
|
||||||
|
"darwin-amd64|macOS (Intel)" \
|
||||||
|
"darwin-arm64|macOS (Apple Silicon)" \
|
||||||
|
"windows-amd64|Windows (x86_64)"; do
|
||||||
|
_plat="${_entry%%|*}"
|
||||||
|
_label="${_entry#*|}"
|
||||||
|
_suffix=""
|
||||||
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
|
printf ' <a data-tool="zddc-server" data-platform="%s" href="zddc-server_v%s_%s%s">%s</a>\n' \
|
||||||
|
"$_plat" "$_latest" "$_plat" "$_suffix" "$_label"
|
||||||
|
done
|
||||||
|
printf ' </div>\n'
|
||||||
|
|
||||||
|
cat <<'PATH_A_END'
|
||||||
|
<p style="margin-top: var(--spacing-md); font-size: 0.92rem; color: var(--color-text-muted);">
|
||||||
|
After download: <code>chmod +x</code> the file, set <code>ZDDC_ROOT=/path/to/archive</code>, run.
|
||||||
|
Need a different platform? <a href="https://codeberg.org/VARASYS/ZDDC">Build from source</a> at the matching tag.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
PATH_A_END
|
||||||
|
else
|
||||||
|
# Bootstrap state: no zddc-server stable cut yet.
|
||||||
|
cat <<'PATH_A_BOOTSTRAP'
|
||||||
|
<p style="margin-top: var(--spacing-md); padding: var(--spacing-md); background: var(--color-bg); border-left: 3px solid var(--color-accent); border-radius: var(--radius-sm); color: var(--color-text);">
|
||||||
|
<strong>Not yet published.</strong> The first lockstep release publishes binaries here. Until then, build from source: <code>git clone</code> and <code>(cd zddc && go build ./cmd/zddc-server)</code>. Once <code>sh build.sh --release</code> runs, this card auto-populates with download buttons for every platform.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
PATH_A_BOOTSTRAP
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<'PATH_B_OPEN'
|
||||||
|
|
||||||
|
<!-- ───────────── Path B — Standalone tool HTMLs ───────────── -->
|
||||||
|
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
|
||||||
|
<h2 style="margin-top:0;">Path B — Standalone tools</h2>
|
||||||
|
<p>Every tool is a single self-contained HTML file. <strong>Open it locally and point it at a folder on your disk</strong> — no install, no server, no account. Same on-disk layout the server uses. Use one tool, use all five, mix and match — there is no orchestration to set up.</p>
|
||||||
|
<div class="grid-4" style="margin-top: var(--spacing-md);">
|
||||||
|
PATH_B_OPEN
|
||||||
|
|
||||||
|
# Tool cards — reuse home page's .tool-card vocabulary
|
||||||
|
for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \
|
||||||
|
"transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \
|
||||||
|
"classifier|Classifier|Rename loose files to ZDDC convention." \
|
||||||
|
"mdedit|Markdown Editor|Edit project markdown files in place." \
|
||||||
|
"landing|Landing|Project picker for multi-project servers."; do
|
||||||
|
_t="${_entry%%|*}"
|
||||||
|
_rest="${_entry#*|}"
|
||||||
|
_name="${_rest%%|*}"
|
||||||
|
_desc="${_rest#*|}"
|
||||||
|
printf ' <a class="tool-card" data-tool="%s" href="%s_v%s.html">\n' "$_t" "$_t" "$_latest"
|
||||||
|
printf ' <span class="tool-card__title">%s</span>\n' "$_name"
|
||||||
|
printf ' <span class="tool-card__desc">%s</span>\n' "$_desc"
|
||||||
|
printf ' <span class="tool-card__link">Download →</span>\n'
|
||||||
|
printf ' </a>\n'
|
||||||
|
done
|
||||||
|
|
||||||
|
cat <<'PATH_B_END'
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ───────────── Pinning empowerment narrative ───────────── -->
|
||||||
|
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
|
||||||
|
<h2 style="margin-top:0;">Your version, forever</h2>
|
||||||
|
<p>Your server may run v0.0.8 next month and v0.1.0 the month after. <strong>Your project doesn't have to follow.</strong> If you depend on a specific behavior in <code>archive</code> v0.0.5, save that version into your archive — the next server upgrade can't take it away from you. Two ways to do it:</p>
|
||||||
|
<div class="grid-2" style="margin-top: var(--spacing-md);">
|
||||||
|
<div class="pin-card">
|
||||||
|
<h3>Drop a copy into your archive</h3>
|
||||||
|
<p>Save the tool's HTML at the path the server would serve it from. The server's resolution order picks up real files <em>first</em> — before any cascade or embedded fallback.</p>
|
||||||
|
PATH_B_END
|
||||||
|
|
||||||
|
printf ' <pre>curl -o MyProject/archive.html \\\n https://zddc.varasys.io/releases/archive_v%s.html</pre>\n' "$_latest"
|
||||||
|
|
||||||
|
cat <<'PIN_MID'
|
||||||
|
<p>Now <code>MyProject/archive.html</code> is yours. The server serves your bytes; nothing about a future <code>--release</code> can change them.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pin-card">
|
||||||
|
<h3>Pin via <code>.zddc</code></h3>
|
||||||
|
<p>Less invasive — no copies in your archive, just a small config entry telling the server which version to fetch and cache. Closer-to-leaf wins, so subprojects can pin further.</p>
|
||||||
|
PIN_MID
|
||||||
|
|
||||||
|
printf ' <pre># MyProject/.zddc\napps:\n archive: v%s</pre>\n' "$_latest"
|
||||||
|
|
||||||
|
cat <<'PIN_END'
|
||||||
|
<p>Server fetches once on first hit, caches under <code>_app/</code>, falls through to the embedded copy if the fetch fails.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="pin-note">Your archive's tools are <strong>yours</strong>. The server is convenience; deletion of the server doesn't break your archive — every per-version download above is a real, immutable static file. Save what you trust.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ───────────── Channels explainer ───────────── -->
|
||||||
|
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl); margin-bottom: var(--spacing-xl);">
|
||||||
|
<h2 style="margin-top:0;">Channels</h2>
|
||||||
|
<p>Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; <strong>stable</strong> is what production runs.</p>
|
||||||
|
<div class="channel-explainer">
|
||||||
|
<div>
|
||||||
|
<h4 class="alpha">alpha</h4>
|
||||||
|
<p>Active dev iteration. Rebuilds without notice. Look here for the very latest.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="beta">beta</h4>
|
||||||
|
<p>Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="stable">stable</h4>
|
||||||
|
<p>Ready to ship. Every per-version file is immutable; <code>_stable</code> follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -290,9 +519,122 @@ HEAD
|
||||||
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Platform auto-detect: choose the most likely binary for this user's
|
||||||
|
// OS on first paint. Promotes that platform to the primary CTA; the
|
||||||
|
// other three render in the secondary row. UA-sniffing is good
|
||||||
|
// enough — wrong guesses fall through to the always-visible
|
||||||
|
// "Other platforms" row below.
|
||||||
|
var ua = navigator.userAgent || '';
|
||||||
|
var detected = 'linux-amd64'; // sensible default
|
||||||
|
var platLabel = 'Linux (x86_64)';
|
||||||
|
if (/Macintosh|Mac OS X/.test(ua)) {
|
||||||
|
// Apple Silicon vs Intel — UA hints aren't reliable, prefer arm64
|
||||||
|
// since modern Macs are predominantly arm64. Users on Intel can
|
||||||
|
// pick from "Other platforms".
|
||||||
|
detected = 'darwin-arm64';
|
||||||
|
platLabel = 'macOS (Apple Silicon)';
|
||||||
|
} else if (/Windows/.test(ua)) {
|
||||||
|
detected = 'windows-amd64';
|
||||||
|
platLabel = 'Windows (x86_64)';
|
||||||
|
}
|
||||||
|
|
||||||
|
var primary = document.getElementById('dl-primary-binary');
|
||||||
|
var primaryLabel = document.getElementById('dl-primary-platlabel');
|
||||||
|
var primaryMeta = document.getElementById('dl-primary-meta');
|
||||||
|
var others = document.getElementById('dl-others');
|
||||||
|
|
||||||
|
function platBinaryName(version, plat) {
|
||||||
|
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
|
||||||
|
return 'zddc-server_' + version + '_' + plat + suf;
|
||||||
|
}
|
||||||
|
function htmlAssetName(tool, version) {
|
||||||
|
return tool + '_' + version + '.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the primary button to the detected platform (if different
|
||||||
|
// from default). Hide the matching link in the secondary row to
|
||||||
|
// avoid duplication.
|
||||||
|
if (primary) {
|
||||||
|
var initialVer = primary.getAttribute('href').match(/_v[\d.]+_/);
|
||||||
|
initialVer = initialVer ? initialVer[0].slice(2, -1) : null;
|
||||||
|
primary.dataset.platform = detected;
|
||||||
|
if (initialVer) {
|
||||||
|
primary.href = platBinaryName('v' + initialVer, detected);
|
||||||
|
if (primaryMeta) primaryMeta.textContent = platBinaryName('v' + initialVer, detected);
|
||||||
|
}
|
||||||
|
if (primaryLabel) primaryLabel.textContent = 'for ' + platLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the duplicate in "Other platforms" row.
|
||||||
|
if (others) {
|
||||||
|
others.querySelectorAll('a[data-platform="' + detected + '"]').forEach(function(a) {
|
||||||
|
a.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the version picker + channel chips. Both drive the same
|
||||||
|
// rewire function. Selecting a per-version stable from the picker
|
||||||
|
// also updates the chip set: stable becomes active, beta/alpha lose
|
||||||
|
// their is-current marker.
|
||||||
|
var picker = document.getElementById('version-picker');
|
||||||
|
if (!picker) return;
|
||||||
|
var chips = document.querySelectorAll('.channel-chip');
|
||||||
|
|
||||||
|
function rewire(v) {
|
||||||
|
// v is "vX.Y.Z" or "alpha" / "beta"
|
||||||
|
document.querySelectorAll('[data-tool]').forEach(function(a) {
|
||||||
|
var tool = a.dataset.tool;
|
||||||
|
var plat = a.dataset.platform || '';
|
||||||
|
if (tool === 'zddc-server') {
|
||||||
|
if (plat) {
|
||||||
|
a.href = (v === 'alpha' || v === 'beta')
|
||||||
|
? 'zddc-server_' + v + '_' + plat + (plat.indexOf('windows') === 0 ? '.exe' : '')
|
||||||
|
: platBinaryName(v, plat);
|
||||||
|
} else {
|
||||||
|
a.href = 'zddc-server_' + v + '.html';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.href = htmlAssetName(tool, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (primary && primaryMeta) {
|
||||||
|
primaryMeta.textContent = primary.getAttribute('href');
|
||||||
|
}
|
||||||
|
// Reflect channel-vs-version in the chip group. Per-version stable
|
||||||
|
// selections highlight the "stable" chip (since per-version files
|
||||||
|
// are stable releases).
|
||||||
|
var channel = (v === 'alpha' || v === 'beta') ? v : 'stable';
|
||||||
|
chips.forEach(function(c) {
|
||||||
|
c.classList.toggle('is-current', c.dataset.channel === channel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.addEventListener('change', function() { rewire(picker.value); });
|
||||||
|
|
||||||
|
// Channel chips: clicking sets the picker's value to the channel
|
||||||
|
// (or to the latest stable when stable is clicked) and fires a
|
||||||
|
// change event so rewire() runs through the existing flow.
|
||||||
|
chips.forEach(function(c) {
|
||||||
|
c.addEventListener('click', function() {
|
||||||
|
var ch = c.dataset.channel;
|
||||||
|
if (ch === 'stable') {
|
||||||
|
// Latest stable is the first non-channel <option> in the picker.
|
||||||
|
var firstStable = picker.querySelector('option[value^="v"]');
|
||||||
|
if (firstStable) picker.value = firstStable.value;
|
||||||
|
} else {
|
||||||
|
picker.value = ch;
|
||||||
|
}
|
||||||
|
rewire(picker.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
TAIL
|
PIN_END
|
||||||
} > "$_out"
|
} > "$_out"
|
||||||
echo "Wrote $_out"
|
echo "Wrote $_out"
|
||||||
}
|
}
|
||||||
|
|
@ -301,11 +643,27 @@ echo ""
|
||||||
echo "=== Building releases/index.html ==="
|
echo "=== Building releases/index.html ==="
|
||||||
build_releases_index
|
build_releases_index
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Verifying channel links ==="
|
||||||
|
verify_channel_links "$RELEASES_DIR"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== All tools built successfully ==="
|
echo "=== All tools built successfully ==="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Server deployment package: zddc/dist/"
|
if [ -n "$RELEASE_CHANNEL" ]; then
|
||||||
echo " Binaries: zddc-server-{linux,darwin,windows}-*"
|
echo "Release: $RELEASE_CHANNEL"
|
||||||
echo " Web files: web/ (copy contents to ZDDC_ROOT)"
|
if [ -n "$RELEASE_VERSION" ]; then
|
||||||
echo ""
|
echo "Version: v$RELEASE_VERSION"
|
||||||
echo "Operator install: see website/index.html 'Install on your server'."
|
echo ""
|
||||||
|
echo "Tags created (push together):"
|
||||||
|
for _t in archive transmittal classifier mdedit landing zddc-server; do
|
||||||
|
echo " ${_t}-v${RELEASE_VERSION}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Publish: git push origin main && git push origin --tags"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Server deployment package: zddc/dist/"
|
||||||
|
echo " Binaries: zddc-server-{linux,darwin,windows}-*"
|
||||||
|
echo " Web files: web/ (copy contents to ZDDC_ROOT)"
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ export default defineConfig({
|
||||||
name: 'archive',
|
name: 'archive',
|
||||||
testMatch: 'archive.spec.js',
|
testMatch: 'archive.spec.js',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'archive-cascade',
|
||||||
|
testMatch: 'archive-cascade.spec.js',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'landing',
|
name: 'landing',
|
||||||
testMatch: 'landing.spec.js',
|
testMatch: 'landing.spec.js',
|
||||||
|
|
|
||||||
|
|
@ -211,9 +211,16 @@ _emit_build_label_sidecar() {
|
||||||
printf '%s\n' "$build_label" > "$BUILD_LABELS_DIR/$1.label"
|
printf '%s\n' "$build_label" > "$BUILD_LABELS_DIR/$1.label"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Compute the next-stable target version for a tool — i.e., the patch-bump
|
# Tools that participate in the lockstep release. Source of truth — used
|
||||||
# of the latest clean <tool>-vX.Y.Z tag. Used by compute_build_label to
|
# by helpers that enumerate "all release artifacts" (matrix render,
|
||||||
# embed the target version in alpha/beta labels.
|
# coordinated next-stable, channel-link verifier).
|
||||||
|
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing zddc-server"
|
||||||
|
|
||||||
|
# Compute the next-stable target for a single tool — patch-bump of its own
|
||||||
|
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
|
||||||
|
# alpha/beta on-page label still reads against its own history (e.g. an
|
||||||
|
# alpha cut for a tool that's been quiet still labels itself targeting that
|
||||||
|
# tool's next stable, even when the lockstep convention is in force).
|
||||||
_next_stable_for_tool() {
|
_next_stable_for_tool() {
|
||||||
_t="$1"
|
_t="$1"
|
||||||
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
|
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
|
||||||
|
|
@ -229,6 +236,31 @@ _next_stable_for_tool() {
|
||||||
echo "${_major}.${_minor}.$((_patch + 1))"
|
echo "${_major}.${_minor}.$((_patch + 1))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Compute the coordinated next-stable target across every release artifact
|
||||||
|
# (5 HTML tools + zddc-server). Used by the top-level build.sh on
|
||||||
|
# `--release` (no explicit version) to enforce lockstep — every tool cuts
|
||||||
|
# at the same version even if it hasn't changed. Picks max(latest tag
|
||||||
|
# across all tools) + patch bump, so a tool at v0.0.2 jumps straight to
|
||||||
|
# wherever the leader is + 1 the first time the lockstep rule fires.
|
||||||
|
_coordinated_next_stable() {
|
||||||
|
_max="0.0.0"
|
||||||
|
for _t in $ZDDC_RELEASE_TOOLS; do
|
||||||
|
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
|
||||||
|
| grep -E "^${_t}-v[0-9]+\.[0-9]+\.[0-9]+\$" \
|
||||||
|
| sed "s|^${_t}-v||" \
|
||||||
|
| sort -V \
|
||||||
|
| tail -1)
|
||||||
|
[ -n "$_latest" ] || continue
|
||||||
|
# sort -V picks the larger of two semvers
|
||||||
|
_max=$(printf '%s\n%s\n' "$_max" "$_latest" | sort -V | tail -1)
|
||||||
|
done
|
||||||
|
_major="${_max%%.*}"
|
||||||
|
_rest="${_max#*.}"
|
||||||
|
_minor="${_rest%%.*}"
|
||||||
|
_patch="${_rest#*.}"
|
||||||
|
echo "${_major}.${_minor}.$((_patch + 1))"
|
||||||
|
}
|
||||||
|
|
||||||
# Promote a built dist file to website/releases/. Reads from caller scope:
|
# Promote a built dist file to website/releases/. Reads from caller scope:
|
||||||
# $channel ("stable" / "alpha" / "beta"), $build_version (stable only),
|
# $channel ("stable" / "alpha" / "beta"), $build_version (stable only),
|
||||||
# $output_html, $root_dir.
|
# $output_html, $root_dir.
|
||||||
|
|
@ -351,3 +383,310 @@ _promote_channel() {
|
||||||
|
|
||||||
echo "Released ${_t} ${_ch}"
|
echo "Released ${_t} ${_ch}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Platforms zddc-server is cross-compiled for. The first three are
|
||||||
|
# extension-less (Linux/macOS); Windows gets .exe. The build always emits
|
||||||
|
# all four; the matrix cell's stub page links each by its <platform> tag.
|
||||||
|
ZDDC_SERVER_PLATFORMS="linux-amd64 darwin-amd64 darwin-arm64 windows-amd64"
|
||||||
|
|
||||||
|
# Display label for the stub-page download list. Keeps the binary-asset
|
||||||
|
# names canonical even if we later add e.g. linux-arm64.
|
||||||
|
_zddc_server_platform_label() {
|
||||||
|
case "$1" in
|
||||||
|
linux-amd64) echo "Linux (x86_64)" ;;
|
||||||
|
darwin-amd64) echo "macOS (Intel)" ;;
|
||||||
|
darwin-arm64) echo "macOS (Apple Silicon)" ;;
|
||||||
|
windows-amd64) echo "Windows (x86_64)" ;;
|
||||||
|
*) echo "$1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve a zddc-server binary's filename for one (version, platform).
|
||||||
|
# Returns the bare name (no path); ".exe" suffix on windows.
|
||||||
|
_zddc_server_binary_name() {
|
||||||
|
_ver_or_chan="$1"
|
||||||
|
_plat="$2"
|
||||||
|
_suffix=""
|
||||||
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
|
if echo "$_ver_or_chan" | grep -qE '^v[0-9]'; then
|
||||||
|
# Per-version asset, e.g. zddc-server_v0.0.8_linux-amd64
|
||||||
|
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
|
||||||
|
else
|
||||||
|
# Channel mirror, e.g. zddc-server_stable_linux-amd64
|
||||||
|
printf 'zddc-server_%s_%s%s' "$_ver_or_chan" "$_plat" "$_suffix"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write the small HTML index page that becomes the matrix cell's link for
|
||||||
|
# a zddc-server release. Lists each platform binary with a download link.
|
||||||
|
# $1 — release directory (absolute)
|
||||||
|
# $2 — slug (e.g. v0.0.8, v0.0, stable, beta, alpha)
|
||||||
|
# $3 — display label (e.g. "v0.0.8", "stable channel")
|
||||||
|
write_zddc_server_stub() {
|
||||||
|
_rdir="$1"
|
||||||
|
_slug="$2"
|
||||||
|
_label="$3"
|
||||||
|
_out="$_rdir/zddc-server_${_slug}.html"
|
||||||
|
|
||||||
|
{
|
||||||
|
cat <<HEAD
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>zddc-server ${_label} — ZDDC</title>
|
||||||
|
<link rel="stylesheet" href="../css/style.css">
|
||||||
|
<style>
|
||||||
|
.dl-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||||
|
.dl-table th, .dl-table td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.dl-table a { color: var(--color-primary); text-decoration: none; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; }
|
||||||
|
.dl-table a:hover { text-decoration: underline; }
|
||||||
|
.breadcrumb { color: var(--color-text-muted); margin-bottom: 1rem; font-size: 0.9rem; }
|
||||||
|
.breadcrumb a { color: var(--color-text-muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container" style="max-width: 720px; margin: 2rem auto;">
|
||||||
|
<p class="breadcrumb"><a href="/">home</a> / <a href="index.html">releases</a> / zddc-server ${_label}</p>
|
||||||
|
<h1>zddc-server — ${_label}</h1>
|
||||||
|
<p>Cross-compiled binaries. Download for your platform, mark executable, and run with <code>ZDDC_ROOT=/path/to/archive ./zddc-server</code>.</p>
|
||||||
|
<table class="dl-table">
|
||||||
|
<thead><tr><th>Platform</th><th>Download</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
HEAD
|
||||||
|
for _plat in $ZDDC_SERVER_PLATFORMS; do
|
||||||
|
_bin=$(_zddc_server_binary_name "$_slug" "$_plat")
|
||||||
|
_plabel=$(_zddc_server_platform_label "$_plat")
|
||||||
|
printf ' <tr><td>%s</td><td><a href="%s">%s</a></td></tr>\n' "$_plabel" "$_bin" "$_bin"
|
||||||
|
done
|
||||||
|
cat <<'TAIL'
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style="font-size: 0.9rem; color: var(--color-text-muted);">Need a different platform? Build from source: <code>(cd zddc && go build -o zddc-server ./cmd/zddc-server)</code> from the repo at the matching tag.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
TAIL
|
||||||
|
} > "$_out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refresh every zddc-server stub page based on what's currently in
|
||||||
|
# website/releases/. Driven by the existing per-version binary files +
|
||||||
|
# symlinks that the release flow already maintains; just emits the HTML
|
||||||
|
# wrappers for them. Safe to run on every build (idempotent), so plain
|
||||||
|
# `sh build.sh` keeps the stub pages in sync if a release file was added
|
||||||
|
# out of band.
|
||||||
|
#
|
||||||
|
# $1 — releases dir (absolute)
|
||||||
|
write_zddc_server_stubs_all() {
|
||||||
|
_rdir="$1"
|
||||||
|
|
||||||
|
# Every per-version stable binary that exists. We index off
|
||||||
|
# linux-amd64 specifically since all four platforms ship in lockstep
|
||||||
|
# — if the linux build is missing the version is incomplete anyway.
|
||||||
|
for _bin in "$_rdir"/zddc-server_v*_linux-amd64; do
|
||||||
|
[ -e "$_bin" ] || continue
|
||||||
|
_name=$(basename "$_bin")
|
||||||
|
# zddc-server_vX.Y.Z_linux-amd64 → vX.Y.Z
|
||||||
|
_slug=$(echo "$_name" | sed -E 's/^zddc-server_(v[^_]+)_linux-amd64$/\1/')
|
||||||
|
# Skip partial-version pins (vX.Y, vX) — these are written
|
||||||
|
# separately below from symlink resolution.
|
||||||
|
case "$_slug" in
|
||||||
|
v*.*.*) write_zddc_server_stub "$_rdir" "$_slug" "$_slug" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Partial-version + channel stubs follow the symlink chain. If the
|
||||||
|
# symlink resolves to a real binary, write the stub; otherwise skip.
|
||||||
|
for _slug in stable beta alpha; do
|
||||||
|
_probe="$_rdir/zddc-server_${_slug}_linux-amd64"
|
||||||
|
if [ -e "$_probe" ]; then
|
||||||
|
write_zddc_server_stub "$_rdir" "$_slug" "${_slug} channel"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# vX.Y and vX partial pins — derive the slug list from the per-version
|
||||||
|
# binaries so we only emit pages we actually have artifacts for.
|
||||||
|
_all_versions=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_v*_linux-amd64' \
|
||||||
|
| sed -E 's|^.*/zddc-server_(v[0-9]+\.[0-9]+\.[0-9]+)_linux-amd64$|\1|' \
|
||||||
|
| sort -Vu)
|
||||||
|
if [ -n "$_all_versions" ]; then
|
||||||
|
# vX.Y pins — pick the highest patch within each X.Y, then make
|
||||||
|
# sure the symlink and stub exist.
|
||||||
|
echo "$_all_versions" | sed -E 's|^v([0-9]+\.[0-9]+)\.[0-9]+$|\1|' | sort -Vu | while read -r _xy; do
|
||||||
|
_probe="$_rdir/zddc-server_v${_xy}_linux-amd64"
|
||||||
|
if [ -e "$_probe" ]; then
|
||||||
|
write_zddc_server_stub "$_rdir" "v${_xy}" "v${_xy}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# vX pins.
|
||||||
|
echo "$_all_versions" | sed -E 's|^v([0-9]+)\..*$|\1|' | sort -Vu | while read -r _x; do
|
||||||
|
_probe="$_rdir/zddc-server_v${_x}_linux-amd64"
|
||||||
|
if [ -e "$_probe" ]; then
|
||||||
|
write_zddc_server_stub "$_rdir" "v${_x}" "v${_x}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Promote a freshly-cross-compiled set of zddc-server binaries to
|
||||||
|
# website/releases/. Called by the top-level build.sh on a release cut.
|
||||||
|
#
|
||||||
|
# $1 — channel ("stable" | "alpha" | "beta")
|
||||||
|
# $2 — version (X.Y.Z; required for stable; ignored for alpha/beta but
|
||||||
|
# passed through so labels can include the next-stable target)
|
||||||
|
# $3 — releases dir (absolute)
|
||||||
|
# $4 — dist dir holding cross-compiled binaries (absolute)
|
||||||
|
promote_zddc_server() {
|
||||||
|
_ch="$1"
|
||||||
|
_ver="$2"
|
||||||
|
_rdir="$3"
|
||||||
|
_dist="$4"
|
||||||
|
|
||||||
|
# Verify all four binaries exist before doing anything destructive.
|
||||||
|
for _plat in $ZDDC_SERVER_PLATFORMS; do
|
||||||
|
_suffix=""
|
||||||
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
|
_src="$_dist/zddc-server-${_plat}${_suffix}"
|
||||||
|
if [ ! -f "$_src" ]; then
|
||||||
|
echo "promote_zddc_server: missing source binary $_src" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$_ch" in
|
||||||
|
stable)
|
||||||
|
if [ -z "$_ver" ]; then
|
||||||
|
echo "promote_zddc_server: stable cut requires version" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
_major="${_ver%%.*}"
|
||||||
|
_rest="${_ver#*.}"
|
||||||
|
_minor="${_rest%%.*}"
|
||||||
|
|
||||||
|
# Per-version: copy each binary to its immutable name + refresh
|
||||||
|
# the partial-version + channel symlinks. Mirrors the HTML-tool
|
||||||
|
# cascade: stable cut → beta + alpha both reset to stable.
|
||||||
|
for _plat in $ZDDC_SERVER_PLATFORMS; do
|
||||||
|
_suffix=""
|
||||||
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
|
_src="$_dist/zddc-server-${_plat}${_suffix}"
|
||||||
|
_versioned="zddc-server_v${_ver}_${_plat}${_suffix}"
|
||||||
|
cp "$_src" "$_rdir/$_versioned"
|
||||||
|
echo "Wrote $_rdir/$_versioned"
|
||||||
|
for _sym in "zddc-server_v${_major}.${_minor}_${_plat}${_suffix}" \
|
||||||
|
"zddc-server_v${_major}_${_plat}${_suffix}" \
|
||||||
|
"zddc-server_stable_${_plat}${_suffix}" \
|
||||||
|
"zddc-server_beta_${_plat}${_suffix}" \
|
||||||
|
"zddc-server_alpha_${_plat}${_suffix}"; do
|
||||||
|
ln -sfn "$_versioned" "$_rdir/$_sym"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# Tag the commit so the binary set is reproducible.
|
||||||
|
_tag="zddc-server-v${_ver}"
|
||||||
|
if git -C "$root_dir" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
|
||||||
|
_existing=$(git -C "$root_dir" rev-list -n 1 "$_tag")
|
||||||
|
_head=$(git -C "$root_dir" rev-parse HEAD)
|
||||||
|
if [ "$_existing" != "$_head" ]; then
|
||||||
|
echo "promote_zddc_server: tag $_tag exists at $_existing but HEAD is $_head" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "(tag $_tag already at HEAD)"
|
||||||
|
else
|
||||||
|
git -C "$root_dir" tag "$_tag"
|
||||||
|
echo "tagged $_tag"
|
||||||
|
fi
|
||||||
|
echo "Released zddc-server v${_ver} (stable)"
|
||||||
|
;;
|
||||||
|
alpha | beta)
|
||||||
|
# Mutable channel mirror per platform; cascade alpha → beta on
|
||||||
|
# a beta cut.
|
||||||
|
for _plat in $ZDDC_SERVER_PLATFORMS; do
|
||||||
|
_suffix=""
|
||||||
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
|
_src="$_dist/zddc-server-${_plat}${_suffix}"
|
||||||
|
_file="zddc-server_${_ch}_${_plat}${_suffix}"
|
||||||
|
rm -f "$_rdir/$_file"
|
||||||
|
cp "$_src" "$_rdir/$_file"
|
||||||
|
echo "Wrote $_rdir/$_file"
|
||||||
|
if [ "$_ch" = "beta" ]; then
|
||||||
|
ln -sfn "$_file" "$_rdir/zddc-server_alpha_${_plat}${_suffix}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Released zddc-server ${_ch}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "promote_zddc_server: unknown channel '$_ch'" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Refresh every stub page (covers the new release plus any pre-existing).
|
||||||
|
write_zddc_server_stubs_all "$_rdir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify every channel link for every release tool exists and resolves.
|
||||||
|
# Runs at the end of every build. Fails the build if anything is dangling.
|
||||||
|
# Channel verification covers both HTML tools (one .html per channel) and
|
||||||
|
# zddc-server (one stub HTML + four binaries per channel).
|
||||||
|
#
|
||||||
|
# Bootstrap-friendly: if zddc-server has no per-version artifacts at all
|
||||||
|
# (i.e. no release has been cut yet under the new lockstep model), the
|
||||||
|
# zddc-server entries are skipped with a heads-up rather than failing. The
|
||||||
|
# first stable cut materializes them.
|
||||||
|
verify_channel_links() {
|
||||||
|
_rdir="$1"
|
||||||
|
_missing=0
|
||||||
|
_verified=0
|
||||||
|
|
||||||
|
for _t in archive transmittal classifier mdedit landing; do
|
||||||
|
for _ch in stable beta alpha; do
|
||||||
|
_f="$_rdir/${_t}_${_ch}.html"
|
||||||
|
if [ -e "$_f" ]; then
|
||||||
|
_verified=$((_verified + 1))
|
||||||
|
else
|
||||||
|
echo " MISSING: ${_t}_${_ch}.html" >&2
|
||||||
|
_missing=$((_missing + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# zddc-server's stable cut anchors the channel chain (cascade rule:
|
||||||
|
# stable cut → alpha + beta both reset to stable). Until stable
|
||||||
|
# exists, the verifier runs in bootstrap mode and skips — alpha/beta
|
||||||
|
# cuts in isolation are valid bootstrap state but have no cascade
|
||||||
|
# fallback target yet.
|
||||||
|
_zs_stable_exists=$(find "$_rdir" -maxdepth 1 -name 'zddc-server_stable_linux-amd64' -print -quit 2>/dev/null)
|
||||||
|
if [ -z "$_zs_stable_exists" ]; then
|
||||||
|
echo " (zddc-server stable not yet cut — run 'sh build.sh --release' to anchor the channel chain)"
|
||||||
|
else
|
||||||
|
for _ch in stable beta alpha; do
|
||||||
|
_f="$_rdir/zddc-server_${_ch}.html"
|
||||||
|
if [ -e "$_f" ]; then
|
||||||
|
_verified=$((_verified + 1))
|
||||||
|
else
|
||||||
|
echo " MISSING: zddc-server_${_ch}.html" >&2
|
||||||
|
_missing=$((_missing + 1))
|
||||||
|
fi
|
||||||
|
for _plat in $ZDDC_SERVER_PLATFORMS; do
|
||||||
|
_suffix=""
|
||||||
|
case "$_plat" in *windows*) _suffix=".exe" ;; esac
|
||||||
|
_f="$_rdir/zddc-server_${_ch}_${_plat}${_suffix}"
|
||||||
|
if [ -e "$_f" ]; then
|
||||||
|
_verified=$((_verified + 1))
|
||||||
|
else
|
||||||
|
echo " MISSING: zddc-server_${_ch}_${_plat}${_suffix}" >&2
|
||||||
|
_missing=$((_missing + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_missing" -gt 0 ]; then
|
||||||
|
echo "channel-link verification: $_missing missing artifact(s)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "channel-link verification: $_verified link(s) ok"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# publish-codeberg-release.sh — upload assets to a Codeberg release.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# publish_codeberg_release <repo> <tag> <asset-path>...
|
|
||||||
#
|
|
||||||
# Where:
|
|
||||||
# <repo> e.g. VARASYS/ZDDC
|
|
||||||
# <tag> e.g. zddc-server-v0.0.8-alpha.3 or archive-v0.0.3
|
|
||||||
# <asset-path> one or more files to attach to the release
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - $CODEBERG_TOKEN exported in the environment, with scope sufficient
|
|
||||||
# to create/update releases on the target repo. (Codeberg/Gitea
|
|
||||||
# terminology: "Application token with `write:repository` access".)
|
|
||||||
# - curl, jq.
|
|
||||||
#
|
|
||||||
# Behavior:
|
|
||||||
# - If a release for $tag does not exist on Codeberg, create it. The
|
|
||||||
# prerelease flag is derived from the tag itself: if the version
|
|
||||||
# part (text after the last 'v') contains a '-', it is a pre-release
|
|
||||||
# (e.g. 'zddc-server-v0.0.8-alpha.2' → prerelease=true);
|
|
||||||
# 'zddc-server-v0.0.7' → prerelease=false. Codeberg orders releases
|
|
||||||
# by published-at and sets a "Latest" badge against the latest
|
|
||||||
# non-prerelease, so this matters.
|
|
||||||
# - For each asset: if a same-named asset already exists, delete it
|
|
||||||
# first (Codeberg/Gitea API doesn't support in-place replacement).
|
|
||||||
# Then upload the new bytes.
|
|
||||||
#
|
|
||||||
# Idempotent: re-running with the same args leaves the release with the
|
|
||||||
# same set of assets.
|
|
||||||
#
|
|
||||||
# This file is meant to be sourced and invoked via the function name, but
|
|
||||||
# it's also runnable directly as a script for quick testing — when run
|
|
||||||
# directly (i.e., $0 ends in publish-codeberg-release.sh), the function
|
|
||||||
# is called with the script's argv.
|
|
||||||
#
|
|
||||||
# NOTE: We do NOT `set -eu` at the top, because that would leak into any
|
|
||||||
# caller that sources this file. The direct-run dispatch at the bottom
|
|
||||||
# turns -eu on for that path only.
|
|
||||||
|
|
||||||
CODEBERG_API="${CODEBERG_API:-https://codeberg.org/api/v1}"
|
|
||||||
|
|
||||||
# True iff $1's "version part" (text after last 'v') contains '-'.
|
|
||||||
# Tags with a '-' in the version part are pre-releases per the
|
|
||||||
# pre-release-semver scheme (see AGENTS.md "Releasing").
|
|
||||||
_is_prerelease() {
|
|
||||||
_ver="${1##*v}"
|
|
||||||
case "$_ver" in *-*) return 0 ;; *) return 1 ;; esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fetch a release by tag. Echoes the numeric release ID, or empty on 404.
|
|
||||||
# Suppresses 404-on-stderr; other errors propagate.
|
|
||||||
_get_release_id() {
|
|
||||||
_repo="$1"
|
|
||||||
_tag="$2"
|
|
||||||
_resp=$(curl -fsS -H "Authorization: token $CODEBERG_TOKEN" \
|
|
||||||
"$CODEBERG_API/repos/$_repo/releases/tags/$_tag" 2>/dev/null) || _resp=""
|
|
||||||
[ -z "$_resp" ] && return 0
|
|
||||||
printf '%s' "$_resp" | jq -r '.id // empty'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create a release for the given tag. Echoes the new release ID. Bombs
|
|
||||||
# out on any error (the caller relies on stable behavior — releases
|
|
||||||
# don't get half-created).
|
|
||||||
_create_release() {
|
|
||||||
_repo="$1"
|
|
||||||
_tag="$2"
|
|
||||||
if _is_prerelease "$_tag"; then
|
|
||||||
_prerelease=true
|
|
||||||
else
|
|
||||||
_prerelease=false
|
|
||||||
fi
|
|
||||||
# Inline JSON; tag/name don't contain quotes per our naming rules.
|
|
||||||
_body=$(printf '{"tag_name":"%s","name":"%s","prerelease":%s,"draft":false}' \
|
|
||||||
"$_tag" "$_tag" "$_prerelease")
|
|
||||||
curl -fsS \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: token $CODEBERG_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$_body" \
|
|
||||||
"$CODEBERG_API/repos/$_repo/releases" \
|
|
||||||
| jq -r '.id'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Echo the asset ID for an asset of the given filename in the given
|
|
||||||
# release, or empty if no such asset.
|
|
||||||
_find_asset_id() {
|
|
||||||
_repo="$1"
|
|
||||||
_release_id="$2"
|
|
||||||
_name="$3"
|
|
||||||
curl -fsS -H "Authorization: token $CODEBERG_TOKEN" \
|
|
||||||
"$CODEBERG_API/repos/$_repo/releases/$_release_id" \
|
|
||||||
| jq -r --arg n "$_name" '.assets[] | select(.name == $n) | .id' \
|
|
||||||
| head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
_delete_asset() {
|
|
||||||
_repo="$1"
|
|
||||||
_asset_id="$2"
|
|
||||||
curl -fsS -X DELETE \
|
|
||||||
-H "Authorization: token $CODEBERG_TOKEN" \
|
|
||||||
"$CODEBERG_API/repos/$_repo/releases/assets/$_asset_id" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
_upload_asset() {
|
|
||||||
_repo="$1"
|
|
||||||
_release_id="$2"
|
|
||||||
_asset_path="$3"
|
|
||||||
_name=$(basename "$_asset_path")
|
|
||||||
# Codeberg/Gitea expects the file under field name "attachment", and
|
|
||||||
# the desired display name as the ?name= query parameter (otherwise
|
|
||||||
# the original filename is used; we set both for clarity).
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: token $CODEBERG_TOKEN" \
|
|
||||||
-F "attachment=@${_asset_path}" \
|
|
||||||
"$CODEBERG_API/repos/$_repo/releases/$_release_id/assets?name=$(printf '%s' "$_name" | jq -sRr @uri)" \
|
|
||||||
>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
publish_codeberg_release() {
|
|
||||||
if [ $# -lt 3 ]; then
|
|
||||||
echo "usage: publish_codeberg_release <repo> <tag> <asset-path>..." >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
if [ -z "${CODEBERG_TOKEN:-}" ]; then
|
|
||||||
echo "publish_codeberg_release: CODEBERG_TOKEN not set" >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
_repo="$1"
|
|
||||||
_tag="$2"
|
|
||||||
shift 2
|
|
||||||
|
|
||||||
_release_id=$(_get_release_id "$_repo" "$_tag")
|
|
||||||
if [ -z "$_release_id" ]; then
|
|
||||||
echo " creating release for $_tag"
|
|
||||||
_release_id=$(_create_release "$_repo" "$_tag")
|
|
||||||
if [ -z "$_release_id" ] || [ "$_release_id" = "null" ]; then
|
|
||||||
echo "publish_codeberg_release: failed to create release for $_tag" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo " release id: $_release_id"
|
|
||||||
|
|
||||||
for _asset_path do
|
|
||||||
if [ ! -f "$_asset_path" ]; then
|
|
||||||
echo "publish_codeberg_release: asset not readable: $_asset_path" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
_name=$(basename "$_asset_path")
|
|
||||||
_existing=$(_find_asset_id "$_repo" "$_release_id" "$_name")
|
|
||||||
if [ -n "$_existing" ]; then
|
|
||||||
echo " replacing existing asset $_name (id $_existing)"
|
|
||||||
_delete_asset "$_repo" "$_existing"
|
|
||||||
fi
|
|
||||||
echo " uploading $_name"
|
|
||||||
_upload_asset "$_repo" "$_release_id" "$_asset_path"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# When invoked directly (not sourced), call the function with argv.
|
|
||||||
case "${0##*/}" in
|
|
||||||
publish-codeberg-release.sh)
|
|
||||||
set -eu
|
|
||||||
publish_codeberg_release "$@"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
514
tests/archive-cascade.spec.js
Normal file
514
tests/archive-cascade.spec.js
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
// End-to-end cascade behaviour for the archive browser.
|
||||||
|
//
|
||||||
|
// Pins the contract for how the three filter layers compose:
|
||||||
|
// 1. Folder-type bar (enabledFolderTypes ⊂ {issued, received, mdl, incoming})
|
||||||
|
// 2. Selected parties (selectedGroupingFolders, name-keyed)
|
||||||
|
// 3. Selected projects (visibleProjects, in multi-project mode)
|
||||||
|
//
|
||||||
|
// Specifically targets the symptom "I select various third-party folders and
|
||||||
|
// the Issued/Received/MDL/Incoming badges, and sometimes files that should be
|
||||||
|
// there aren't shown". Each test asserts both the file table and the
|
||||||
|
// transmittal folder list, since the user reported both can drop entries.
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const HTML_PATH = path.resolve('archive/dist/archive.html');
|
||||||
|
|
||||||
|
// All four folder types enabled — used at the start of each test so default
|
||||||
|
// (issued+received only) isn't quietly hiding things.
|
||||||
|
async function enableAllFolderTypes(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.app.enabledFolderTypes = new Set(['issued', 'received', 'mdl', 'incoming']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectAllParties(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const cb = document.getElementById('selectAllGroupingCheckbox');
|
||||||
|
if (cb && !cb.checked) cb.click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the names of files currently visible in the table (parsed from the
|
||||||
|
// row labels). Folder-type changes flow through applyFilters → filteredFiles
|
||||||
|
// → updateFileTable, so reading filteredFiles directly is the closest signal.
|
||||||
|
async function visibleFileNames(page) {
|
||||||
|
return page.evaluate(() => window.app.filteredFiles.map(f => f.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the visible-transmittal-folder paths (the ones that pass the
|
||||||
|
// grouping/folder-type cascade — what the left panel actually renders).
|
||||||
|
async function visibleTransmittalPaths(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const items = document.querySelectorAll('#transmittalFoldersList .folder-item');
|
||||||
|
return Array.from(items).map(el => el.getAttribute('data-path'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Archive cascade: folder-type × parties × outstanding', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all four folder-type badges hide their subtrees and only their subtrees', async ({ page }) => {
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
// Start with all four enabled so the scan picks up MDL/Incoming.
|
||||||
|
await enableAllFolderTypes(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__setMockDirectoryTree('Archive', {
|
||||||
|
'BM': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-01-01_T-ISSUED (IFC) - I': {
|
||||||
|
'100-EL-SPC-0001_A (IFC) - SpecIssued.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Received': {
|
||||||
|
'2025-01-02_T-RECEIVED (IFC) - R': {
|
||||||
|
'100-EL-SPC-0002_A (IFC) - SpecReceived.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'MDL': {
|
||||||
|
'2025-01-03_T-MDL (IFC) - M': {
|
||||||
|
'100-EL-SPC-0003_A (IFC) - SpecMDL.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Incoming': {
|
||||||
|
'2025-01-04_T-INCOMING (IFC) - In': {
|
||||||
|
'100-EL-SPC-0004_A (IFC) - SpecIncoming.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#addDirectoryBtn').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// All four folder types should have surfaced files at scan time.
|
||||||
|
const allFour = await visibleFileNames(page);
|
||||||
|
for (const expected of ['SpecIssued', 'SpecReceived', 'SpecMDL', 'SpecIncoming']) {
|
||||||
|
expect(allFour.some(n => n.includes(expected)), `expected ${expected} present with all four enabled; got ${allFour.join(', ')}`).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle each folder type off and verify its subtree's files disappear,
|
||||||
|
// while the other three stay. This is the load-bearing claim.
|
||||||
|
const types = [
|
||||||
|
{ type: 'issued', expectGone: 'SpecIssued' },
|
||||||
|
{ type: 'received', expectGone: 'SpecReceived' },
|
||||||
|
{ type: 'mdl', expectGone: 'SpecMDL' },
|
||||||
|
{ type: 'incoming', expectGone: 'SpecIncoming' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { type, expectGone } of types) {
|
||||||
|
// Disable the type via the canonical app entry point.
|
||||||
|
await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
|
||||||
|
const visible = await visibleFileNames(page);
|
||||||
|
expect(
|
||||||
|
visible.some(n => n.includes(expectGone)),
|
||||||
|
`disabling ${type} should hide ${expectGone}; saw: ${visible.join(', ')}`
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// The other three subtrees keep their files.
|
||||||
|
for (const other of types) {
|
||||||
|
if (other.type === type) continue;
|
||||||
|
expect(
|
||||||
|
visible.some(n => n.includes(other.expectGone)),
|
||||||
|
`disabling ${type} must not hide ${other.expectGone}; saw: ${visible.join(', ')}`
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable for the next iteration. toggleFolderType triggers a
|
||||||
|
// refreshDirectories() because the scan dropped that subtree —
|
||||||
|
// we need to wait for the rescan to finish before continuing.
|
||||||
|
await page.evaluate((t) => window.app.modules.app.toggleFolderType(t), type);
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggling a folder type off then on does not duplicate or lose files', async ({ page }) => {
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
await enableAllFolderTypes(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__setMockDirectoryTree('Archive', {
|
||||||
|
'BM': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-01-01_T-ISSUED (IFC) - I': {
|
||||||
|
'100-EL-SPC-0001_A (IFC) - I1.pdf': '%PDF',
|
||||||
|
'100-EL-SPC-0001_B (IFC) - I2.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Received': {
|
||||||
|
'2025-01-02_T-RECEIVED (IFC) - R': {
|
||||||
|
'100-EL-SPC-0002_A (IFC) - R1.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#addDirectoryBtn').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const baseline = await page.evaluate(() => ({
|
||||||
|
files: window.app.files.length,
|
||||||
|
txn: window.app.transmittalFolders.length,
|
||||||
|
grouping: window.app.groupingFolders.length,
|
||||||
|
}));
|
||||||
|
expect(baseline.files).toBe(3);
|
||||||
|
|
||||||
|
// Toggle Issued off → on.
|
||||||
|
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const after = await page.evaluate(() => ({
|
||||||
|
files: window.app.files.length,
|
||||||
|
txn: window.app.transmittalFolders.length,
|
||||||
|
grouping: window.app.groupingFolders.length,
|
||||||
|
// Distinct-file-id counts to catch duplication via re-scan.
|
||||||
|
distinctIds: new Set(window.app.files.map(f => f.id)).size,
|
||||||
|
distinctPaths: new Set(window.app.files.map(f => f.path)).size,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(after.files, 'no file duplication after toggle off→on').toBe(baseline.files);
|
||||||
|
expect(after.distinctIds, 'each file should still have a unique id').toBe(after.files);
|
||||||
|
expect(after.distinctPaths, 'no duplicate file paths').toBe(after.files);
|
||||||
|
expect(after.grouping, 'grouping folders not duplicated').toBe(baseline.grouping);
|
||||||
|
expect(after.txn, 'transmittal folders not duplicated').toBe(baseline.txn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outstanding files: visible under <party>/, <party>/Issued/, <party>/MDL/ matches the folder-type cascade', async ({ page }) => {
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
await enableAllFolderTypes(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__setMockDirectoryTree('Archive', {
|
||||||
|
'BM': {
|
||||||
|
// Loose file directly under party — not under any folder-type marker.
|
||||||
|
'100-EL-SPC-LOOSE_A (IFC) - LooseAtParty.pdf': '%PDF',
|
||||||
|
'Issued': {
|
||||||
|
// Loose file under Issued (no transmittal folder wrapper)
|
||||||
|
'100-EL-SPC-LOOSE_A (IFC) - LooseAtIssued.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
'MDL': {
|
||||||
|
'100-EL-SPC-LOOSE_A (IFC) - LooseAtMDL.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#addDirectoryBtn').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await selectAllParties(page);
|
||||||
|
|
||||||
|
// With all folder types enabled, all three loose files should be visible
|
||||||
|
// under the Outstanding virtual transmittal.
|
||||||
|
const allEnabled = await visibleFileNames(page);
|
||||||
|
expect(allEnabled.some(n => n.includes('LooseAtParty'))).toBe(true);
|
||||||
|
expect(allEnabled.some(n => n.includes('LooseAtIssued'))).toBe(true);
|
||||||
|
expect(allEnabled.some(n => n.includes('LooseAtMDL'))).toBe(true);
|
||||||
|
|
||||||
|
// Disable MDL — only LooseAtMDL should drop.
|
||||||
|
await page.evaluate(() => window.app.modules.app.toggleFolderType('mdl'));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const noMDL = await visibleFileNames(page);
|
||||||
|
expect(noMDL.some(n => n.includes('LooseAtParty'))).toBe(true);
|
||||||
|
expect(noMDL.some(n => n.includes('LooseAtIssued'))).toBe(true);
|
||||||
|
expect(noMDL.some(n => n.includes('LooseAtMDL'))).toBe(false);
|
||||||
|
|
||||||
|
// Disable Issued too — LooseAtIssued drops, LooseAtParty stays
|
||||||
|
// (LooseAtParty's path has no folder-type segment at all).
|
||||||
|
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const noIssuedMDL = await visibleFileNames(page);
|
||||||
|
expect(noIssuedMDL.some(n => n.includes('LooseAtParty'))).toBe(true);
|
||||||
|
expect(noIssuedMDL.some(n => n.includes('LooseAtIssued'))).toBe(false);
|
||||||
|
expect(noIssuedMDL.some(n => n.includes('LooseAtMDL'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-name party across two projects + folder-type cascade hides both projects symmetrically', async ({ page }) => {
|
||||||
|
// BM in ProjectA AND ProjectB, each with Issued and Received subtrees.
|
||||||
|
// Disabling Received must hide BOTH projects' Received files;
|
||||||
|
// selecting BM (one row in the panel) must surface both projects' Issued.
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
await enableAllFolderTypes(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__setMockDirectoryTree('combined-root', {
|
||||||
|
'ProjectA': {
|
||||||
|
'Archive': {
|
||||||
|
'BM': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-01-01_T-A-I (IFC) - X': {
|
||||||
|
'100-EL-SPC-0001_A (IFC) - A_Issued.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Received': {
|
||||||
|
'2025-01-02_T-A-R (IFC) - X': {
|
||||||
|
'100-EL-SPC-0002_A (IFC) - A_Received.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'ProjectB': {
|
||||||
|
'Archive': {
|
||||||
|
'BM': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-02-01_T-B-I (IFC) - X': {
|
||||||
|
'200-EL-SPC-0001_A (IFC) - B_Issued.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Received': {
|
||||||
|
'2025-02-02_T-B-R (IFC) - X': {
|
||||||
|
'200-EL-SPC-0002_A (IFC) - B_Received.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.app.projectFilter = new Set(['ProjectA', 'ProjectB']);
|
||||||
|
window.app.visibleProjects = new Set(['ProjectA', 'ProjectB']);
|
||||||
|
window.app.isMultiProject = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#addDirectoryBtn').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// BM should appear once in the parties panel.
|
||||||
|
const partyRows = await page.locator('#groupingFoldersList .folder-item-name').allTextContents();
|
||||||
|
const bmCount = partyRows.filter(n => n.trim() === 'BM').length;
|
||||||
|
expect(bmCount, 'BM merged to one party row across projects').toBe(1);
|
||||||
|
|
||||||
|
// All four files visible with all four folder types enabled.
|
||||||
|
const all = await visibleFileNames(page);
|
||||||
|
expect(all.some(n => n.includes('A_Issued'))).toBe(true);
|
||||||
|
expect(all.some(n => n.includes('B_Issued'))).toBe(true);
|
||||||
|
expect(all.some(n => n.includes('A_Received'))).toBe(true);
|
||||||
|
expect(all.some(n => n.includes('B_Received'))).toBe(true);
|
||||||
|
|
||||||
|
// Disable Received — BOTH projects' Received files hide.
|
||||||
|
await page.evaluate(() => window.app.modules.app.toggleFolderType('received'));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const noReceived = await visibleFileNames(page);
|
||||||
|
expect(noReceived.some(n => n.includes('A_Issued')), 'ProjectA Issued stays').toBe(true);
|
||||||
|
expect(noReceived.some(n => n.includes('B_Issued')), 'ProjectB Issued stays').toBe(true);
|
||||||
|
expect(noReceived.some(n => n.includes('A_Received')), 'ProjectA Received hidden').toBe(false);
|
||||||
|
expect(noReceived.some(n => n.includes('B_Received')), 'ProjectB Received hidden').toBe(false);
|
||||||
|
|
||||||
|
// Transmittal folder list also drops both projects' Received transmittals.
|
||||||
|
const txnPaths = await visibleTransmittalPaths(page);
|
||||||
|
expect(txnPaths.every(p => !p.includes('/Received/')), 'no Received transmittals: ' + txnPaths.join(', ')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggling a party off then on with selectAllTransmittals re-syncs the transmittal selection', async ({ page }) => {
|
||||||
|
// Default selectAllTransmittals=true. After deselecting BM, BM's
|
||||||
|
// transmittal folders are gone from the list. Reselecting BM should
|
||||||
|
// make them visible AND auto-selected (because select-all is still on).
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
await enableAllFolderTypes(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__setMockDirectoryTree('Archive', {
|
||||||
|
'BM': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-01-01_T-BM (IFC) - X': {
|
||||||
|
'100-EL-SPC-0001_A (IFC) - BM1.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'OTHER': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-01-02_T-OTHER (IFC) - X': {
|
||||||
|
'200-EL-SPC-0002_A (IFC) - OTHER1.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#addDirectoryBtn').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Both parties auto-selected, both transmittals auto-selected.
|
||||||
|
const initial = await page.evaluate(() => ({
|
||||||
|
selectAllTxn: window.app.selectAllTransmittals,
|
||||||
|
selectedGrouping: Array.from(window.app.selectedGroupingFolders).sort(),
|
||||||
|
selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(),
|
||||||
|
}));
|
||||||
|
expect(initial.selectAllTxn).toBe(true);
|
||||||
|
expect(initial.selectedGrouping).toEqual(['BM', 'OTHER']);
|
||||||
|
expect(initial.selectedTxn.some(p => p.includes('T-BM'))).toBe(true);
|
||||||
|
expect(initial.selectedTxn.some(p => p.includes('T-OTHER'))).toBe(true);
|
||||||
|
|
||||||
|
// Deselect BM via the canonical event handler (mimics user click)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.app.selectAllGroupingFolders = false;
|
||||||
|
window.app.selectedGroupingFolders.delete('BM');
|
||||||
|
window.app.modules.app.renderGroupingFolders();
|
||||||
|
window.app.modules.app.renderTransmittalFolders();
|
||||||
|
window.app.modules.filtering.applyFilters();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
// BM's transmittal is no longer visible AND no longer in the selection set.
|
||||||
|
const afterDeselect = await page.evaluate(() => ({
|
||||||
|
selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(),
|
||||||
|
visiblePaths: Array.from(document.querySelectorAll('#transmittalFoldersList .folder-item'))
|
||||||
|
.map(el => el.getAttribute('data-path')),
|
||||||
|
}));
|
||||||
|
expect(afterDeselect.selectedTxn.some(p => p.includes('T-BM'))).toBe(false);
|
||||||
|
expect(afterDeselect.visiblePaths.some(p => p && p.includes('T-BM'))).toBe(false);
|
||||||
|
|
||||||
|
// Re-add BM. Because selectAllTransmittals stays true, BM's
|
||||||
|
// transmittal should be auto-selected on the re-render.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.app.selectedGroupingFolders.add('BM');
|
||||||
|
window.app.modules.app.renderGroupingFolders();
|
||||||
|
window.app.modules.app.renderTransmittalFolders();
|
||||||
|
window.app.modules.filtering.applyFilters();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const afterReselect = await page.evaluate(() => ({
|
||||||
|
selectAllTxn: window.app.selectAllTransmittals,
|
||||||
|
selectedTxn: Array.from(window.app.selectedTransmittalFolders).sort(),
|
||||||
|
visibleFiles: window.app.filteredFiles.map(f => f.name),
|
||||||
|
}));
|
||||||
|
expect(afterReselect.selectAllTxn).toBe(true);
|
||||||
|
expect(
|
||||||
|
afterReselect.selectedTxn.some(p => p.includes('T-BM')),
|
||||||
|
'BM transmittal should be re-selected via selectAll cascade'
|
||||||
|
).toBe(true);
|
||||||
|
expect(afterReselect.visibleFiles.some(n => n.includes('BM1'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Archive cascade: nested-party path-segment contract', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pin the contract for nested-party paths. With folder-type segment NOT
|
||||||
|
// immediately after the party, the current rule (transmittalIsUnderVisibleParty
|
||||||
|
// checks only the segment right after the first matched party) means the
|
||||||
|
// folder-type cascade can leak. Either the test should fail and we fix
|
||||||
|
// the cascade to walk all party matches; or the contract is "the folder
|
||||||
|
// type only applies one level under the party". Recording the call here
|
||||||
|
// forces us to face the question explicitly when a future change touches
|
||||||
|
// the helper.
|
||||||
|
test('nested party + deep folder-type marker: the deep folder-type filter applies to the file', async ({ page }) => {
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
await enableAllFolderTypes(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__setMockDirectoryTree('Archive', {
|
||||||
|
'BM': {
|
||||||
|
'sub': {
|
||||||
|
'Issued': {
|
||||||
|
'2025-01-01_T-NESTED (IFC) - X': {
|
||||||
|
'100-EL-SPC-NESTED_A (IFC) - SpecDeepIssued.pdf': '%PDF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('#addDirectoryBtn').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await selectAllParties(page);
|
||||||
|
|
||||||
|
// With Issued enabled, the file is visible.
|
||||||
|
const visibleAll = await visibleFileNames(page);
|
||||||
|
expect(visibleAll.some(n => n.includes('SpecDeepIssued'))).toBe(true);
|
||||||
|
|
||||||
|
// Turn Issued OFF — the file lives under .../Issued/... so its scan-time
|
||||||
|
// listing should be skipped. After the toggle-driven rescan, the file
|
||||||
|
// must NOT be visible regardless of how transmittalIsUnderVisibleParty
|
||||||
|
// walks segments.
|
||||||
|
await page.evaluate(() => window.app.modules.app.toggleFolderType('issued'));
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
const visibleNoIssued = await visibleFileNames(page);
|
||||||
|
expect(
|
||||||
|
visibleNoIssued.some(n => n.includes('SpecDeepIssued')),
|
||||||
|
'deep Issued subtree must hide when Issued is toggled off'
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Archive cascade: URL state round-trip', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-default folder-type set round-trips through serialize → restore', async ({ page }) => {
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
const qs = await page.evaluate(() => {
|
||||||
|
window.app.enabledFolderTypes = new Set(['issued', 'mdl']); // received off, incoming off, mdl on
|
||||||
|
return window.app.modules.urlState.serialize();
|
||||||
|
});
|
||||||
|
// serialize() returns "?types=..." (leading "?" included) or "" when no
|
||||||
|
// diffs from defaults exist. Strip the leading "?" before re-emitting.
|
||||||
|
const cleanQs = qs.startsWith('?') ? qs.slice(1) : qs;
|
||||||
|
expect(cleanQs).toContain('types=');
|
||||||
|
|
||||||
|
const round = await page.evaluate((querystring) => {
|
||||||
|
// Reset to defaults so restore has work to do.
|
||||||
|
window.app.enabledFolderTypes = new Set(['issued', 'received']);
|
||||||
|
history.replaceState(null, '', '?' + querystring);
|
||||||
|
window.app.modules.urlState.restore();
|
||||||
|
return Array.from(window.app.enabledFolderTypes).sort();
|
||||||
|
}, cleanQs);
|
||||||
|
|
||||||
|
expect(round).toEqual(['issued', 'mdl']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selected parties are deliberately NOT in URL state today — the natural
|
||||||
|
// flow is: pick a directory locally, then narrow with party checkboxes.
|
||||||
|
// Pin this in a test so accidentally adding party serialization later
|
||||||
|
// doesn't break sharing semantics; remove the test (and the assertion)
|
||||||
|
// when/if sharing-with-parties becomes a feature.
|
||||||
|
test('selected parties are NOT serialized to URL state (current contract)', async ({ page }) => {
|
||||||
|
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||||
|
|
||||||
|
const qs = await page.evaluate(() => {
|
||||||
|
window.app.selectAllGroupingFolders = false;
|
||||||
|
window.app.selectedGroupingFolders = new Set(['BM', 'OTHER']);
|
||||||
|
return window.app.modules.urlState.serialize();
|
||||||
|
});
|
||||||
|
expect(qs).not.toContain('parties=');
|
||||||
|
expect(qs).not.toContain('groups=');
|
||||||
|
expect(qs).not.toContain('selected=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1123,3 +1123,164 @@ html[data-theme="light"] {
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.ref-layout { gap: var(--spacing-xl); }
|
.ref-layout { gap: var(--spacing-xl); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Releases page components ─────────────────────────── */
|
||||||
|
/* Used by website/releases/index.html (rendered by build.sh).
|
||||||
|
Reuses existing tokens; adds nothing to the design system that
|
||||||
|
isn't load-bearing for the install + version-pinning narrative. */
|
||||||
|
|
||||||
|
.version-picker-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--spacing-md) 0 var(--spacing-lg);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.version-picker-bar label { color: var(--color-text-muted); margin-right: var(--spacing-xs); }
|
||||||
|
.version-picker-bar select {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.version-picker-bar select:hover { border-color: var(--color-accent); }
|
||||||
|
.version-picker-bar .picker-hint { color: var(--color-text-muted); margin-left: auto; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.dl-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: 0.85rem 1.5rem;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: background 0.15s, box-shadow 0.15s, transform 0.05s;
|
||||||
|
}
|
||||||
|
.dl-primary:hover { background: var(--color-accent-hover); box-shadow: var(--shadow-lg); }
|
||||||
|
.dl-primary:active { transform: translateY(1px); }
|
||||||
|
.dl-primary .dl-icon { font-size: 1.2em; line-height: 1; }
|
||||||
|
.dl-primary-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dl-secondary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-sm) var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.dl-secondary-row > span { color: var(--color-text-muted); }
|
||||||
|
.dl-secondary-row a {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.dl-secondary-row a:hover { background: var(--color-accent-soft); border-color: var(--color-accent); color: var(--color-accent); }
|
||||||
|
|
||||||
|
.pin-card {
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
}
|
||||||
|
.pin-card h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 var(--spacing-sm);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.pin-card p { margin: var(--spacing-sm) 0; color: var(--color-text); }
|
||||||
|
.pin-card pre {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
margin: var(--spacing-sm) 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.pin-card code { font-family: var(--font-mono); font-size: 0.85em; padding: 0.05rem 0.3rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); }
|
||||||
|
.pin-note {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
border-left: 3px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel quick-pick chips next to the version picker. Visible at-a-
|
||||||
|
glance entry points to alpha and beta channels even when those
|
||||||
|
currently cascade to stable — discoverability matters more than
|
||||||
|
minimalism here. Clicking drives the version picker. */
|
||||||
|
.channel-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin: 0 0 var(--spacing-lg);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.channel-chips-label { color: var(--color-text-muted); margin-right: var(--spacing-xs); }
|
||||||
|
.channel-chip {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.channel-chip:hover { background: var(--color-accent-soft); border-color: var(--color-accent); color: var(--color-accent); }
|
||||||
|
.channel-chip.is-current {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.channel-chip[data-channel="alpha"]:not(.is-current),
|
||||||
|
.channel-chip[data-channel="beta"]:not(.is-current) {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel explainer (bottom of the page) */
|
||||||
|
.channel-explainer { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--spacing-md); margin-top: var(--spacing-md); }
|
||||||
|
.channel-explainer > div {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.channel-explainer h4 { margin: 0 0 var(--spacing-xs); font-size: 0.95rem; }
|
||||||
|
.channel-explainer h4.alpha { color: var(--color-text-muted); }
|
||||||
|
.channel-explainer h4.beta { color: var(--color-text); }
|
||||||
|
.channel-explainer h4.stable { color: var(--color-accent); }
|
||||||
|
.channel-explainer p { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.channel-explainer { grid-template-columns: 1fr; }
|
||||||
|
.dl-primary { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,13 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Releases — ZDDC</title>
|
<title>Download ZDDC</title>
|
||||||
<meta name="description" content="All released versions and channel builds of every ZDDC tool.">
|
<meta name="description" content="Self-host the ZDDC server, or download individual tools. Pin a version your project trusts; your archive's tools are yours.">
|
||||||
<meta name="theme-color" content="#2a5a8a">
|
<meta name="theme-color" content="#2a5a8a">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="../css/style.css">
|
<link rel="stylesheet" href="../css/style.css">
|
||||||
<style>
|
|
||||||
.rel-tool { margin-top: var(--spacing-xl); padding: var(--spacing-md); border: 1px solid var(--color-border); border-radius: 8px; }
|
|
||||||
.rel-tool h2 { margin-top: 0; }
|
|
||||||
.rel-channels { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0 1.25rem 0; }
|
|
||||||
.rel-channels a { padding: 0.25rem 0.625rem; border-radius: 999px; text-decoration: none; border: 1px solid var(--color-border); color: var(--color-text); font-size: 0.9rem; }
|
|
||||||
.rel-channels a.stable { border-color: var(--color-primary); color: var(--color-primary); font-weight: 600; }
|
|
||||||
.rel-channels a.beta, .rel-channels a.alpha { color: var(--color-text-muted); }
|
|
||||||
.rel-channels a:hover { background: var(--color-bg-subtle); }
|
|
||||||
.rel-versions { font-size: 0.875rem; color: var(--color-text-muted); }
|
|
||||||
.rel-versions a { margin-right: 0.5rem; color: var(--color-text); text-decoration: none; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
|
||||||
.rel-versions a:hover { background: var(--color-bg-subtle); text-decoration: underline; }
|
|
||||||
.rel-meta { font-size: 0.85rem; color: var(--color-text-muted); margin-top: 0.5rem; }
|
|
||||||
.rel-bin-table { width: 100%; border-collapse: collapse; margin: 0.5rem 0 1rem; font-size: 0.9rem; }
|
|
||||||
.rel-bin-table th, .rel-bin-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--color-border); }
|
|
||||||
.rel-bin-table th { font-weight: 600; color: var(--color-text-muted); }
|
|
||||||
.rel-bin-table td.ch-stable { color: var(--color-primary); font-weight: 600; }
|
|
||||||
.rel-bin-table td.ch-beta, .rel-bin-table td.ch-alpha { color: var(--color-text-muted); }
|
|
||||||
.rel-bin-table a { color: var(--color-text); text-decoration: none; padding: 0.1rem 0.35rem; border-radius: 4px; }
|
|
||||||
.rel-bin-table a:hover { background: var(--color-bg-subtle); text-decoration: underline; }
|
|
||||||
.rel-bin-table td.empty { color: var(--color-text-muted); font-style: italic; }
|
|
||||||
.rel-pull { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; background: var(--color-bg-subtle); padding: 0.25rem 0.5rem; border-radius: 4px; display: inline-block; margin: 0.2rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
|
@ -50,87 +28,128 @@
|
||||||
<nav class="header-nav">
|
<nav class="header-nav">
|
||||||
<a href="/" class="nav-link">Home</a>
|
<a href="/" class="nav-link">Home</a>
|
||||||
<a href="../reference.html" class="nav-link">Docs</a>
|
<a href="../reference.html" class="nav-link">Docs</a>
|
||||||
<a href="index.html" class="nav-link active">Releases</a>
|
<a href="index.html" class="nav-link active">Download</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Releases</h1>
|
<h1>Download ZDDC</h1>
|
||||||
<p class="hero-subtitle">All published versions and channel builds of every ZDDC tool. Stable releases are immutable; alpha and beta channels are rebuilt without notice.</p>
|
<p class="hero-subtitle">Pick how you want to use it. Pick the version you want. Every link below points at a real, immutable file you can save into your archive — your tools, your version, forever.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
||||||
<section class="rel-tool">
|
<div class="version-picker-bar">
|
||||||
<h2>Archive</h2>
|
<label for="version-picker">Showing</label>
|
||||||
<div class="rel-channels">
|
<select id="version-picker">
|
||||||
<a class="stable" href="archive_stable.html">stable</a>
|
<option value="v0.0.2" selected>v0.0.2 (current stable)</option>
|
||||||
<a class="beta" href="archive_beta.html">beta</a>
|
<option value="v0.0.1">v0.0.1</option>
|
||||||
<a class="alpha" href="archive_alpha.html">alpha</a>
|
<optgroup label="Pre-release channels">
|
||||||
</div>
|
<option value="beta">beta — currently tracks stable</option>
|
||||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
<option value="alpha">alpha — currently tracks stable</option>
|
||||||
<a href="archive_v0.0.2.html">v0.0.2</a>
|
</optgroup>
|
||||||
<a href="archive_v0.0.1.html">v0.0.1</a>
|
</select>
|
||||||
</div>
|
<span class="picker-hint">Changes every download link below.</span>
|
||||||
</section>
|
</div>
|
||||||
<section class="rel-tool">
|
|
||||||
<h2>Transmittal</h2>
|
<!-- Channel quick-pick chips — visible at-a-glance entry points to
|
||||||
<div class="rel-channels">
|
alpha and beta even when they cascade to stable. Clicking drives
|
||||||
<a class="stable" href="transmittal_stable.html">stable</a>
|
the version picker (rewires the page) rather than navigating
|
||||||
<a class="beta" href="transmittal_beta.html">beta</a>
|
away. The chip is always live; the cascade rule keeps the URLs
|
||||||
<a class="alpha" href="transmittal_alpha.html">alpha</a>
|
it represents permanently resolvable. -->
|
||||||
</div>
|
<div class="channel-chips" role="group" aria-label="Channel quick pick">
|
||||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
<span class="channel-chips-label">Or pick a channel:</span>
|
||||||
<a href="transmittal_v0.0.2.html">v0.0.2</a>
|
<button type="button" class="channel-chip is-current" data-channel="stable">stable</button>
|
||||||
<a href="transmittal_v0.0.1.html">v0.0.1</a>
|
<button type="button" class="channel-chip" data-channel="beta">beta</button>
|
||||||
</div>
|
<button type="button" class="channel-chip" data-channel="alpha">alpha</button>
|
||||||
</section>
|
</div>
|
||||||
<section class="rel-tool">
|
|
||||||
<h2>Classifier</h2>
|
<!-- ───────────── Path A — Self-host the server ───────────── -->
|
||||||
<div class="rel-channels">
|
<section class="card" style="background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-lg);">
|
||||||
<a class="stable" href="classifier_stable.html">stable</a>
|
<h2 style="margin-top:0;">Path A — Self-host the server</h2>
|
||||||
<a class="beta" href="classifier_beta.html">beta</a>
|
<p>One small Go binary. <strong>All five tools are baked in</strong> via <code>//go:embed</code>; the server picks the right one for each folder of your archive. Adds ACL via <code>.zddc</code> files, the virtual <code>.archive</code> document index, and SSO header passthrough. Stop the server and the directory is still a perfectly valid ZDDC archive — the server is convenience, not lock-in.</p>
|
||||||
<a class="alpha" href="classifier_alpha.html">alpha</a>
|
<p style="margin-top: var(--spacing-md); padding: var(--spacing-md); background: var(--color-bg); border-left: 3px solid var(--color-accent); border-radius: var(--radius-sm); color: var(--color-text);">
|
||||||
</div>
|
<strong>Not yet published.</strong> The first lockstep release publishes binaries here. Until then, build from source: <code>git clone</code> and <code>(cd zddc && go build ./cmd/zddc-server)</code>. Once <code>sh build.sh --release</code> runs, this card auto-populates with download buttons for every platform.
|
||||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
</p>
|
||||||
<a href="classifier_v0.0.2.html">v0.0.2</a>
|
|
||||||
<a href="classifier_v0.0.1.html">v0.0.1</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="rel-tool">
|
|
||||||
<h2>Markdown Editor</h2>
|
|
||||||
<div class="rel-channels">
|
|
||||||
<a class="stable" href="mdedit_stable.html">stable</a>
|
|
||||||
<a class="beta" href="mdedit_beta.html">beta</a>
|
|
||||||
<a class="alpha" href="mdedit_alpha.html">alpha</a>
|
|
||||||
</div>
|
|
||||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
|
||||||
<a href="mdedit_v0.0.2.html">v0.0.2</a>
|
|
||||||
<a href="mdedit_v0.0.1.html">v0.0.1</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="rel-tool">
|
|
||||||
<h2>Landing (project picker)</h2>
|
|
||||||
<div class="rel-channels">
|
|
||||||
<a class="stable" href="landing_stable.html">stable</a>
|
|
||||||
<a class="beta" href="landing_beta.html">beta</a>
|
|
||||||
<a class="alpha" href="landing_alpha.html">alpha</a>
|
|
||||||
</div>
|
|
||||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
|
||||||
<a href="landing_v0.0.2.html">v0.0.2</a>
|
|
||||||
<a href="landing_v0.0.1.html">v0.0.1</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="rel-tool">
|
|
||||||
<h2>zddc-server (Go file server)</h2>
|
|
||||||
<p>Binaries are published as Codeberg release assets. Pick a platform from the release page; or build from source via the helm charts under <code>helm/</code>.</p>
|
|
||||||
<p><a href="https://codeberg.org/VARASYS/ZDDC/releases">Browse zddc-server releases on Codeberg →</a></p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style="margin-top: var(--spacing-2xl); color: var(--color-text-muted); font-size: 0.9rem;">
|
<!-- ───────────── Path B — Standalone tool HTMLs ───────────── -->
|
||||||
<p>Each link above is a real static file (or a checked-in symlink resolving to one). Channel chips track the current build of that channel and may change at any time; per-version files are immutable. To install or pin in your own deployment, see <a href="../">the home page</a>.</p>
|
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
|
||||||
|
<h2 style="margin-top:0;">Path B — Standalone tools</h2>
|
||||||
|
<p>Every tool is a single self-contained HTML file. <strong>Open it locally and point it at a folder on your disk</strong> — no install, no server, no account. Same on-disk layout the server uses. Use one tool, use all five, mix and match — there is no orchestration to set up.</p>
|
||||||
|
<div class="grid-4" style="margin-top: var(--spacing-md);">
|
||||||
|
<a class="tool-card" data-tool="archive" href="archive_v0.0.2.html">
|
||||||
|
<span class="tool-card__title">Archive Browser</span>
|
||||||
|
<span class="tool-card__desc">Browse and download from a ZDDC archive.</span>
|
||||||
|
<span class="tool-card__link">Download →</span>
|
||||||
|
</a>
|
||||||
|
<a class="tool-card" data-tool="transmittal" href="transmittal_v0.0.2.html">
|
||||||
|
<span class="tool-card__title">Transmittal Creator</span>
|
||||||
|
<span class="tool-card__desc">Build, sign, and verify transmittal packages.</span>
|
||||||
|
<span class="tool-card__link">Download →</span>
|
||||||
|
</a>
|
||||||
|
<a class="tool-card" data-tool="classifier" href="classifier_v0.0.2.html">
|
||||||
|
<span class="tool-card__title">Classifier</span>
|
||||||
|
<span class="tool-card__desc">Rename loose files to ZDDC convention.</span>
|
||||||
|
<span class="tool-card__link">Download →</span>
|
||||||
|
</a>
|
||||||
|
<a class="tool-card" data-tool="mdedit" href="mdedit_v0.0.2.html">
|
||||||
|
<span class="tool-card__title">Markdown Editor</span>
|
||||||
|
<span class="tool-card__desc">Edit project markdown files in place.</span>
|
||||||
|
<span class="tool-card__link">Download →</span>
|
||||||
|
</a>
|
||||||
|
<a class="tool-card" data-tool="landing" href="landing_v0.0.2.html">
|
||||||
|
<span class="tool-card__title">Landing</span>
|
||||||
|
<span class="tool-card__desc">Project picker for multi-project servers.</span>
|
||||||
|
<span class="tool-card__link">Download →</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ───────────── Pinning empowerment narrative ───────────── -->
|
||||||
|
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl);">
|
||||||
|
<h2 style="margin-top:0;">Your version, forever</h2>
|
||||||
|
<p>Your server may run v0.0.8 next month and v0.1.0 the month after. <strong>Your project doesn't have to follow.</strong> If you depend on a specific behavior in <code>archive</code> v0.0.5, save that version into your archive — the next server upgrade can't take it away from you. Two ways to do it:</p>
|
||||||
|
<div class="grid-2" style="margin-top: var(--spacing-md);">
|
||||||
|
<div class="pin-card">
|
||||||
|
<h3>Drop a copy into your archive</h3>
|
||||||
|
<p>Save the tool's HTML at the path the server would serve it from. The server's resolution order picks up real files <em>first</em> — before any cascade or embedded fallback.</p>
|
||||||
|
<pre>curl -o MyProject/archive.html \
|
||||||
|
https://zddc.varasys.io/releases/archive_v0.0.2.html</pre>
|
||||||
|
<p>Now <code>MyProject/archive.html</code> is yours. The server serves your bytes; nothing about a future <code>--release</code> can change them.</p>
|
||||||
|
</div>
|
||||||
|
<div class="pin-card">
|
||||||
|
<h3>Pin via <code>.zddc</code></h3>
|
||||||
|
<p>Less invasive — no copies in your archive, just a small config entry telling the server which version to fetch and cache. Closer-to-leaf wins, so subprojects can pin further.</p>
|
||||||
|
<pre># MyProject/.zddc
|
||||||
|
apps:
|
||||||
|
archive: v0.0.2</pre>
|
||||||
|
<p>Server fetches once on first hit, caches under <code>_app/</code>, falls through to the embedded copy if the fetch fails.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="pin-note">Your archive's tools are <strong>yours</strong>. The server is convenience; deletion of the server doesn't break your archive — every per-version download above is a real, immutable static file. Save what you trust.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ───────────── Channels explainer ───────────── -->
|
||||||
|
<section class="card" style="border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--spacing-lg) var(--spacing-xl); margin-top: var(--spacing-xl); margin-bottom: var(--spacing-xl);">
|
||||||
|
<h2 style="margin-top:0;">Channels</h2>
|
||||||
|
<p>Three channels, applied in lockstep across all tools. Pre-release channels exist to soak changes; <strong>stable</strong> is what production runs.</p>
|
||||||
|
<div class="channel-explainer">
|
||||||
|
<div>
|
||||||
|
<h4 class="alpha">alpha</h4>
|
||||||
|
<p>Active dev iteration. Rebuilds without notice. Look here for the very latest.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="beta">beta</h4>
|
||||||
|
<p>Ready for general testing. Has soaked through alpha. Still mutable — pin to a versioned URL for reproducibility.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="stable">stable</h4>
|
||||||
|
<p>Ready to ship. Every per-version file is immutable; <code>_stable</code> follows the latest cut. Channel cuts cascade: stable cut resets beta and alpha to track stable.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
@ -139,5 +158,118 @@
|
||||||
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
// Platform auto-detect: choose the most likely binary for this user's
|
||||||
|
// OS on first paint. Promotes that platform to the primary CTA; the
|
||||||
|
// other three render in the secondary row. UA-sniffing is good
|
||||||
|
// enough — wrong guesses fall through to the always-visible
|
||||||
|
// "Other platforms" row below.
|
||||||
|
var ua = navigator.userAgent || '';
|
||||||
|
var detected = 'linux-amd64'; // sensible default
|
||||||
|
var platLabel = 'Linux (x86_64)';
|
||||||
|
if (/Macintosh|Mac OS X/.test(ua)) {
|
||||||
|
// Apple Silicon vs Intel — UA hints aren't reliable, prefer arm64
|
||||||
|
// since modern Macs are predominantly arm64. Users on Intel can
|
||||||
|
// pick from "Other platforms".
|
||||||
|
detected = 'darwin-arm64';
|
||||||
|
platLabel = 'macOS (Apple Silicon)';
|
||||||
|
} else if (/Windows/.test(ua)) {
|
||||||
|
detected = 'windows-amd64';
|
||||||
|
platLabel = 'Windows (x86_64)';
|
||||||
|
}
|
||||||
|
|
||||||
|
var primary = document.getElementById('dl-primary-binary');
|
||||||
|
var primaryLabel = document.getElementById('dl-primary-platlabel');
|
||||||
|
var primaryMeta = document.getElementById('dl-primary-meta');
|
||||||
|
var others = document.getElementById('dl-others');
|
||||||
|
|
||||||
|
function platBinaryName(version, plat) {
|
||||||
|
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
|
||||||
|
return 'zddc-server_' + version + '_' + plat + suf;
|
||||||
|
}
|
||||||
|
function htmlAssetName(tool, version) {
|
||||||
|
return tool + '_' + version + '.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the primary button to the detected platform (if different
|
||||||
|
// from default). Hide the matching link in the secondary row to
|
||||||
|
// avoid duplication.
|
||||||
|
if (primary) {
|
||||||
|
var initialVer = primary.getAttribute('href').match(/_v[\d.]+_/);
|
||||||
|
initialVer = initialVer ? initialVer[0].slice(2, -1) : null;
|
||||||
|
primary.dataset.platform = detected;
|
||||||
|
if (initialVer) {
|
||||||
|
primary.href = platBinaryName('v' + initialVer, detected);
|
||||||
|
if (primaryMeta) primaryMeta.textContent = platBinaryName('v' + initialVer, detected);
|
||||||
|
}
|
||||||
|
if (primaryLabel) primaryLabel.textContent = 'for ' + platLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the duplicate in "Other platforms" row.
|
||||||
|
if (others) {
|
||||||
|
others.querySelectorAll('a[data-platform="' + detected + '"]').forEach(function(a) {
|
||||||
|
a.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire the version picker + channel chips. Both drive the same
|
||||||
|
// rewire function. Selecting a per-version stable from the picker
|
||||||
|
// also updates the chip set: stable becomes active, beta/alpha lose
|
||||||
|
// their is-current marker.
|
||||||
|
var picker = document.getElementById('version-picker');
|
||||||
|
if (!picker) return;
|
||||||
|
var chips = document.querySelectorAll('.channel-chip');
|
||||||
|
|
||||||
|
function rewire(v) {
|
||||||
|
// v is "vX.Y.Z" or "alpha" / "beta"
|
||||||
|
document.querySelectorAll('[data-tool]').forEach(function(a) {
|
||||||
|
var tool = a.dataset.tool;
|
||||||
|
var plat = a.dataset.platform || '';
|
||||||
|
if (tool === 'zddc-server') {
|
||||||
|
if (plat) {
|
||||||
|
a.href = (v === 'alpha' || v === 'beta')
|
||||||
|
? 'zddc-server_' + v + '_' + plat + (plat.indexOf('windows') === 0 ? '.exe' : '')
|
||||||
|
: platBinaryName(v, plat);
|
||||||
|
} else {
|
||||||
|
a.href = 'zddc-server_' + v + '.html';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.href = htmlAssetName(tool, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (primary && primaryMeta) {
|
||||||
|
primaryMeta.textContent = primary.getAttribute('href');
|
||||||
|
}
|
||||||
|
// Reflect channel-vs-version in the chip group. Per-version stable
|
||||||
|
// selections highlight the "stable" chip (since per-version files
|
||||||
|
// are stable releases).
|
||||||
|
var channel = (v === 'alpha' || v === 'beta') ? v : 'stable';
|
||||||
|
chips.forEach(function(c) {
|
||||||
|
c.classList.toggle('is-current', c.dataset.channel === channel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.addEventListener('change', function() { rewire(picker.value); });
|
||||||
|
|
||||||
|
// Channel chips: clicking sets the picker's value to the channel
|
||||||
|
// (or to the latest stable when stable is clicked) and fires a
|
||||||
|
// change event so rewire() runs through the existing flow.
|
||||||
|
chips.forEach(function(c) {
|
||||||
|
c.addEventListener('click', function() {
|
||||||
|
var ch = c.dataset.channel;
|
||||||
|
if (ch === 'stable') {
|
||||||
|
// Latest stable is the first non-channel <option> in the picker.
|
||||||
|
var firstStable = picker.querySelector('option[value^="v"]');
|
||||||
|
if (firstStable) picker.value = firstStable.value;
|
||||||
|
} else {
|
||||||
|
picker.value = ch;
|
||||||
|
}
|
||||||
|
rewire(picker.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -330,27 +330,46 @@ Any URL path segment named `.archive` (configurable via `ZDDC_INDEX_PATH`) is in
|
||||||
by the server and treated as a virtual document index.
|
by the server and treated as a virtual document index.
|
||||||
|
|
||||||
The index is built at startup by scanning all transmittal folders under `ZDDC_ROOT`. It
|
The index is built at startup by scanning all transmittal folders under `ZDDC_ROOT`. It
|
||||||
maps each `(trackingNumber, revision, modifier)` tuple to the file from the
|
maps each `(project, trackingNumber, revision, modifier)` tuple to the file from the
|
||||||
**chronologically earliest** transmittal folder that contains it.
|
**chronologically earliest** transmittal folder within that project that contains it.
|
||||||
|
|
||||||
|
### Project scoping
|
||||||
|
|
||||||
|
The `.archive` index is **scoped to the project** — i.e. the first slash-separated
|
||||||
|
segment of the request's `.archive` context path. The same tracking number issued
|
||||||
|
under two different projects does NOT collide; each project's `.archive/` surfaces
|
||||||
|
only that project's documents.
|
||||||
|
|
||||||
|
A request to `/.archive/...` at the very root has no project segment to scope by
|
||||||
|
and returns **404 Not Found**. Stable references must always be project-rooted
|
||||||
|
(e.g. `/ProjectA/.archive/TRK-001.html`).
|
||||||
|
|
||||||
|
Within one project, two different files claiming to be the same `(tracking, rev)`
|
||||||
|
are an authoring mistake. The chronological winner still wins, but a `WARN`
|
||||||
|
log is emitted with both paths so the conflict can be diagnosed and corrected.
|
||||||
|
|
||||||
### URL patterns
|
### URL patterns
|
||||||
|
|
||||||
| URL | Resolves to |
|
| URL | Resolves to |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /Project/.archive/TRK-001.html` | Latest base revision of TRK-001 |
|
| `GET /Project/.archive/TRK-001.html` | Latest base revision of TRK-001 within Project |
|
||||||
| `GET /Project/.archive/TRK-001_A.html` | Base revision A of TRK-001 |
|
| `GET /Project/.archive/TRK-001_A.html` | Base revision A of TRK-001 within Project |
|
||||||
| `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 |
|
| `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 within Project |
|
||||||
| `GET /Project/.archive/` | JSON listing of all resolvable trackingNumber.html entries |
|
| `GET /Project/.archive/` | JSON listing of Project's resolvable entries |
|
||||||
|
| `GET /Project/sub/sub/.archive/TRK-001.html` | Same as the top-level Project listing — depth within a project doesn't change scope |
|
||||||
|
| `GET /.archive/...` | **404** — root has no project segment |
|
||||||
|
|
||||||
All responses are `302 Found` redirects to the actual file URL. ACL is enforced on both
|
All successful responses are `302 Found` redirects to the actual file URL. ACL
|
||||||
the `.archive` context directory and the resolved target file.
|
is enforced on both the `.archive` context directory and the resolved target file.
|
||||||
|
|
||||||
### Why "earliest" transmittal?
|
### Why "earliest" transmittal?
|
||||||
|
|
||||||
Any file claiming to be `TRK-001_A (IFC)` should be identical across transmittals
|
Within one project, any file claiming to be `TRK-001_A (IFC)` should be identical
|
||||||
(same content, same SHA-256). If the same tracking number and revision appears in multiple
|
across transmittals (same content, same SHA-256). If the same tracking number and
|
||||||
transmittals, the first one received chronologically is treated as the authoritative copy.
|
revision appears in multiple transmittals, the first one received chronologically is
|
||||||
A later arrival with a different hash is an error condition (to be detected separately).
|
treated as the authoritative copy. A later arrival with a different file path is an
|
||||||
|
error condition; the server logs a `WARN` with both paths but does not change the
|
||||||
|
winner.
|
||||||
|
|
||||||
### Index refresh
|
### Index refresh
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<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" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
@ -7996,27 +7996,21 @@ window.app.modules.filtering = {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true if any path segment of the transmittal folder matches a selected
|
// Returns true if the transmittal folder's path satisfies all three cascade
|
||||||
// party name AND the segment immediately after it (if it's a folder-type name)
|
// layers: project visibility, party selection (any path segment matches a
|
||||||
// is in enabledFolderTypes. Segment-equality matching means a party "BM" selected
|
// selected party name), and folder-type enablement (no segment is a
|
||||||
// matches every "<...>/BM/<...>" path regardless of the prefix.
|
// folder-type marker that's currently disabled, regardless of where in the
|
||||||
|
// path it sits). Segment-equality matching means a party "BM" selected
|
||||||
|
// matches every "<...>/BM/<...>" path regardless of the prefix; and the
|
||||||
|
// folder-type check covers BOTH the canonical "<party>/Issued/<txn>" layout
|
||||||
|
// AND nested layouts like "<party>/<sub>/Issued/<txn>" — a deeper folder-
|
||||||
|
// type marker still triggers the cascade.
|
||||||
function transmittalIsUnderVisibleParty(folder) {
|
function transmittalIsUnderVisibleParty(folder) {
|
||||||
if (!pathIsInVisibleProject(folder.path)) return false;
|
if (!pathIsInVisibleProject(folder.path)) return false;
|
||||||
const parts = folder.path.split('/');
|
if (isUnderHiddenFolderType(folder.path)) return false;
|
||||||
for (let i = 0; i < parts.length; i++) {
|
return folder.path.split('/').some(seg =>
|
||||||
if (!window.app.selectedGroupingFolders.has(parts[i])) continue;
|
window.app.selectedGroupingFolders.has(seg)
|
||||||
// i-th segment is a selected party. The segment after is either a
|
);
|
||||||
// folder-type marker (Issued/Received/MDL/Incoming) or the transmittal
|
|
||||||
// folder itself.
|
|
||||||
const next = parts[i + 1];
|
|
||||||
if (!next) return true;
|
|
||||||
const folderType = next.toLowerCase();
|
|
||||||
if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) {
|
|
||||||
return window.app.enabledFolderTypes.has(folderType);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render transmittal folders (rebuilds DOM)
|
// Render transmittal folders (rebuilds DOM)
|
||||||
|
|
|
||||||
|
|
@ -1376,7 +1376,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
|
||||||
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>
|
||||||
|
|
|
||||||
|
|
@ -866,7 +866,7 @@ body {
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
|
|
|
||||||
|
|
@ -1668,7 +1668,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Markdown</span>
|
<span class="app-header__title">ZDDC Markdown</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2210,7 +2210,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="app-header__spacer"></div>
|
<div class="app-header__spacer"></div>
|
||||||
<div class="app-header__icons">
|
<div class="app-header__icons">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
archive=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
|
||||||
transmittal=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
transmittal=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
|
||||||
classifier=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
classifier=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
|
||||||
mdedit=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
mdedit=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
|
||||||
landing=v0.0.3-alpha · 2026-05-01 20:41:59 · adb6904-dirty
|
landing=v0.0.3-alpha · 2026-05-02 01:03:25 · 4ede420-dirty
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package archive
|
package archive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -22,16 +23,28 @@ type TrackingEntry struct {
|
||||||
ByRevision map[string]*RevisionEntry // base revision → entry
|
ByRevision map[string]*RevisionEntry // base revision → entry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index is the in-memory archive index.
|
// ProjectEntry buckets all tracking numbers under one top-level segment of
|
||||||
type Index struct {
|
// fsRoot (the "project"). Each project is its own namespace — the same
|
||||||
mu sync.RWMutex
|
// tracking number issued under two different projects does NOT collide; each
|
||||||
|
// project's .archive/ surfaces only its own.
|
||||||
|
type ProjectEntry struct {
|
||||||
ByTracking map[string]*TrackingEntry
|
ByTracking map[string]*TrackingEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Index is the in-memory archive index, bucketed by project. The project key
|
||||||
|
// is the first slash-separated segment of an indexed file's server-relative
|
||||||
|
// path. .archive virtual requests under /<project>/.../.archive/ resolve
|
||||||
|
// against the named project's bucket; /.archive/ at the very root has no
|
||||||
|
// project and returns 404.
|
||||||
|
type Index struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ByProject map[string]*ProjectEntry
|
||||||
|
}
|
||||||
|
|
||||||
// NewIndex returns an empty Index.
|
// NewIndex returns an empty Index.
|
||||||
func NewIndex() *Index {
|
func NewIndex() *Index {
|
||||||
return &Index{
|
return &Index{
|
||||||
ByTracking: make(map[string]*TrackingEntry),
|
ByProject: make(map[string]*ProjectEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,17 +166,43 @@ func indexTransmittalFolder(idx *Index, fsRoot, folderAbs, folderServerPath, dat
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordFile adds a parsed file to the index using first-seen (oldest date) logic.
|
// projectOf returns the top-level slash-separated segment of a server-relative
|
||||||
|
// path. Files at the root (no slash) have no project and are not indexable.
|
||||||
|
func projectOf(serverPath string) string {
|
||||||
|
i := strings.IndexByte(serverPath, '/')
|
||||||
|
if i <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return serverPath[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFile adds a parsed file to the index using first-seen (oldest date)
|
||||||
|
// logic, bucketed under the project (top-level segment) the file lives in.
|
||||||
func (idx *Index) recordFile(pf parsedFile) {
|
func (idx *Index) recordFile(pf parsedFile) {
|
||||||
|
project := projectOf(pf.serverPath)
|
||||||
|
if project == "" {
|
||||||
|
// File sits directly at the served root with no project wrapper.
|
||||||
|
// Skipping it means /.archive/ at the root surfaces nothing — which
|
||||||
|
// is exactly the contract: stable references must include a project
|
||||||
|
// directory. Such files are still reachable as ordinary static URLs.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
idx.mu.Lock()
|
idx.mu.Lock()
|
||||||
defer idx.mu.Unlock()
|
defer idx.mu.Unlock()
|
||||||
|
|
||||||
te, ok := idx.ByTracking[pf.trackingNumber]
|
pe, ok := idx.ByProject[project]
|
||||||
|
if !ok {
|
||||||
|
pe = &ProjectEntry{ByTracking: make(map[string]*TrackingEntry)}
|
||||||
|
idx.ByProject[project] = pe
|
||||||
|
}
|
||||||
|
|
||||||
|
te, ok := pe.ByTracking[pf.trackingNumber]
|
||||||
if !ok {
|
if !ok {
|
||||||
te = &TrackingEntry{
|
te = &TrackingEntry{
|
||||||
ByRevision: make(map[string]*RevisionEntry),
|
ByRevision: make(map[string]*RevisionEntry),
|
||||||
}
|
}
|
||||||
idx.ByTracking[pf.trackingNumber] = te
|
pe.ByTracking[pf.trackingNumber] = te
|
||||||
}
|
}
|
||||||
|
|
||||||
re, ok := te.ByRevision[pf.baseRev]
|
re, ok := te.ByRevision[pf.baseRev]
|
||||||
|
|
@ -175,10 +214,29 @@ func (idx *Index) recordFile(pf parsedFile) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if pf.modifier == "" {
|
if pf.modifier == "" {
|
||||||
// Base revision file — record if this transmittal is older than current
|
switch {
|
||||||
if re.BasePath == "" || pf.date < re.Date {
|
case re.BasePath == "":
|
||||||
re.BasePath = pf.serverPath
|
re.BasePath = pf.serverPath
|
||||||
re.Date = pf.date
|
re.Date = pf.date
|
||||||
|
case re.BasePath == pf.serverPath:
|
||||||
|
// same file, no-op (e.g. re-index from the watcher)
|
||||||
|
default:
|
||||||
|
// Two different files claim to be (project, tracking, rev) —
|
||||||
|
// that's a within-project authoring mistake. Log once with both
|
||||||
|
// paths so it's diagnosable; chronological winner still wins.
|
||||||
|
slog.Warn("archive: within-project revision collision",
|
||||||
|
"project", project,
|
||||||
|
"tracking", pf.trackingNumber,
|
||||||
|
"revision", pf.baseRev,
|
||||||
|
"existing", re.BasePath,
|
||||||
|
"existingDate", re.Date,
|
||||||
|
"new", pf.serverPath,
|
||||||
|
"newDate", pf.date,
|
||||||
|
)
|
||||||
|
if pf.date < re.Date {
|
||||||
|
re.BasePath = pf.serverPath
|
||||||
|
re.Date = pf.date
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Modifier file — record if no entry yet or this transmittal is older
|
// Modifier file — record if no entry yet or this transmittal is older
|
||||||
|
|
@ -279,7 +337,8 @@ type Entry struct {
|
||||||
TargetPath string
|
TargetPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllEntries returns a sorted snapshot of every redirect entry. Two kinds:
|
// AllEntries returns a sorted snapshot of every redirect entry for the named
|
||||||
|
// project. Two kinds per tracking number:
|
||||||
//
|
//
|
||||||
// - <tracking>.html → first-chronological copy of the highest base rev
|
// - <tracking>.html → first-chronological copy of the highest base rev
|
||||||
// - <tracking>_<rev>.html → first-chronological copy of that specific base rev
|
// - <tracking>_<rev>.html → first-chronological copy of that specific base rev
|
||||||
|
|
@ -291,12 +350,20 @@ type Entry struct {
|
||||||
// Sort order is by URLName; the "." in <tracking>.html sorts before the "_"
|
// Sort order is by URLName; the "." in <tracking>.html sorts before the "_"
|
||||||
// in <tracking>_<rev>.html, so each tracking number's highest-rev shortcut
|
// in <tracking>_<rev>.html, so each tracking number's highest-rev shortcut
|
||||||
// comes first, followed by its individual revisions in revision order.
|
// comes first, followed by its individual revisions in revision order.
|
||||||
func (idx *Index) AllEntries() []Entry {
|
//
|
||||||
|
// An empty project (or one with no indexed tracking numbers) returns nil,
|
||||||
|
// keeping the caller branch-free.
|
||||||
|
func (idx *Index) AllEntries(project string) []Entry {
|
||||||
idx.mu.RLock()
|
idx.mu.RLock()
|
||||||
defer idx.mu.RUnlock()
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
pe, ok := idx.ByProject[project]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var result []Entry
|
var result []Entry
|
||||||
for tn, te := range idx.ByTracking {
|
for tn, te := range pe.ByTracking {
|
||||||
if te.HighestBaseRev != "" {
|
if te.HighestBaseRev != "" {
|
||||||
if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" {
|
if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" {
|
||||||
result = append(result, Entry{
|
result = append(result, Entry{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package archive
|
package archive
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -48,7 +51,7 @@ func TestCompareRevisions_DraftOrdering(t *testing.T) {
|
||||||
|
|
||||||
func TestIndexAndResolve_DraftOnly(t *testing.T) {
|
func TestIndexAndResolve_DraftOnly(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title",
|
mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
"123_~A (IFR) - Title.pdf",
|
"123_~A (IFR) - Title.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -57,72 +60,132 @@ func TestIndexAndResolve_DraftOnly(t *testing.T) {
|
||||||
t.Fatalf("BuildIndex: %v", err)
|
t.Fatalf("BuildIndex: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
te, ok := idx.ByTracking["123"]
|
pe, ok := idx.ByProject["ProjectA"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("tracking 123 not indexed")
|
t.Fatalf("ProjectA bucket not indexed; ByProject = %v", idx.ByProject)
|
||||||
|
}
|
||||||
|
te, ok := pe.ByTracking["123"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("tracking 123 not indexed in ProjectA")
|
||||||
}
|
}
|
||||||
if te.HighestBaseRev != "~A" {
|
if te.HighestBaseRev != "~A" {
|
||||||
t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev)
|
t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := Resolve(idx, "123.html"); !ok {
|
if _, ok := Resolve(idx, "ProjectA", "123.html"); !ok {
|
||||||
t.Errorf("Resolve(123.html) failed")
|
t.Errorf("Resolve(ProjectA, 123.html) failed")
|
||||||
}
|
}
|
||||||
if _, ok := Resolve(idx, "123_~A.html"); !ok {
|
if _, ok := Resolve(idx, "ProjectA", "123_~A.html"); !ok {
|
||||||
t.Errorf("Resolve(123_~A.html) failed")
|
t.Errorf("Resolve(ProjectA, 123_~A.html) failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same tracking number queried under a different project must NOT resolve.
|
||||||
|
if _, ok := Resolve(idx, "ProjectB", "123.html"); ok {
|
||||||
|
t.Errorf("Resolve(ProjectB, 123.html) should fail — 123 belongs to ProjectA")
|
||||||
|
}
|
||||||
|
// Empty project — /.archive/ at the very root — never resolves.
|
||||||
|
if _, ok := Resolve(idx, "", "123.html"); ok {
|
||||||
|
t.Errorf("Resolve(\"\", 123.html) should fail — empty project must 404")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIndexAndResolve_DraftWithModifier(t *testing.T) {
|
func TestIndexAndResolve_DraftWithModifier(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title",
|
mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
"123_~A (IFR) - Title.pdf",
|
"123_~A (IFR) - Title.pdf",
|
||||||
)
|
)
|
||||||
mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments",
|
mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments",
|
||||||
"123_~A+C1 (RTN) - Comments.pdf",
|
"123_~A+C1 (RTN) - Comments.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
idx, _ := BuildIndex(root)
|
idx, _ := BuildIndex(root)
|
||||||
if _, ok := Resolve(idx, "123_~A+C1.html"); !ok {
|
if _, ok := Resolve(idx, "ProjectA", "123_~A+C1.html"); !ok {
|
||||||
t.Errorf("Resolve(123_~A+C1.html) failed")
|
t.Errorf("Resolve(ProjectA, 123_~A+C1.html) failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "First chronologically found version of the latest rev": when the same rev
|
// "First chronologically found version of the latest rev": when the same rev
|
||||||
// appears in two transmittals, the earlier date's copy wins.
|
// appears in two transmittals within ONE project, the earlier date's copy
|
||||||
|
// wins. (Cross-project duplicates are handled separately — see
|
||||||
|
// TestCrossProject_NoCollision.)
|
||||||
func TestRecordFile_FirstChronologicalWins(t *testing.T) {
|
func TestRecordFile_FirstChronologicalWins(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkTransmittal(t, root, "2025-03-01_Late (IFR) - Title",
|
mkTransmittal(t, root, "ProjectA/2025-03-01_Late (IFR) - Title",
|
||||||
"123_A (IFR) - Title.pdf",
|
"123_A (IFR) - Title.pdf",
|
||||||
)
|
)
|
||||||
mkTransmittal(t, root, "2025-01-01_Early (IFR) - Title",
|
mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title",
|
||||||
"123_A (IFR) - Title.pdf",
|
"123_A (IFR) - Title.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
idx, _ := BuildIndex(root)
|
idx, _ := BuildIndex(root)
|
||||||
target, ok := Resolve(idx, "123_A.html")
|
target, ok := Resolve(idx, "ProjectA", "123_A.html")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("Resolve(123_A.html) failed")
|
t.Fatalf("Resolve(ProjectA, 123_A.html) failed")
|
||||||
}
|
}
|
||||||
if !contains(target, "2025-01-01_Early") {
|
if !contains(target, "2025-01-01_Early") {
|
||||||
t.Errorf("got %q, want path under 2025-01-01_Early/", target)
|
t.Errorf("got %q, want path under 2025-01-01_Early/", target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same tracking number issued under two different projects must NOT collide:
|
||||||
|
// each project's bucket carries its own copy and resolves independently.
|
||||||
|
func TestCrossProject_NoCollision(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
|
"123_A (IFR) - Title.pdf",
|
||||||
|
)
|
||||||
|
mkTransmittal(t, root, "ProjectB/2025-06-01_T9 (IFR) - Different Title",
|
||||||
|
"123_A (IFR) - Different Title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
idx, _ := BuildIndex(root)
|
||||||
|
|
||||||
|
a, okA := Resolve(idx, "ProjectA", "123_A.html")
|
||||||
|
if !okA {
|
||||||
|
t.Fatalf("Resolve(ProjectA, 123_A.html) failed")
|
||||||
|
}
|
||||||
|
if !contains(a, "ProjectA/") {
|
||||||
|
t.Errorf("ProjectA target = %q, want path under ProjectA/", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, okB := Resolve(idx, "ProjectB", "123_A.html")
|
||||||
|
if !okB {
|
||||||
|
t.Fatalf("Resolve(ProjectB, 123_A.html) failed")
|
||||||
|
}
|
||||||
|
if !contains(b, "ProjectB/") {
|
||||||
|
t.Errorf("ProjectB target = %q, want path under ProjectB/", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a == b {
|
||||||
|
t.Errorf("ProjectA and ProjectB targets must differ; got %q == %q", a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each project's listing surfaces only its own tracking numbers.
|
||||||
|
aNames := entryNames(idx.AllEntries("ProjectA"))
|
||||||
|
bNames := entryNames(idx.AllEntries("ProjectB"))
|
||||||
|
for _, n := range aNames {
|
||||||
|
for _, m := range bNames {
|
||||||
|
if n == m && n == "123_A.html" {
|
||||||
|
// Same URLName is expected; targets just differ.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AllEntries: every (tracking) gets <tracking>.html (highest) AND a
|
// AllEntries: every (tracking) gets <tracking>.html (highest) AND a
|
||||||
// <tracking>_<rev>.html for every base revision present.
|
// <tracking>_<rev>.html for every base revision present, scoped to project.
|
||||||
func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkTransmittal(t, root, "2025-01-01_T1 (IFR) - Title",
|
mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
"123_~A (IFR) - Title.pdf",
|
"123_~A (IFR) - Title.pdf",
|
||||||
)
|
)
|
||||||
mkTransmittal(t, root, "2025-03-01_T3 (IFC) - Title",
|
mkTransmittal(t, root, "ProjectA/2025-03-01_T3 (IFC) - Title",
|
||||||
"123_A (IFC) - Title.pdf",
|
"123_A (IFC) - Title.pdf",
|
||||||
"456_0 (IFR) - Other.pdf",
|
"456_0 (IFR) - Other.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
idx, _ := BuildIndex(root)
|
idx, _ := BuildIndex(root)
|
||||||
entries := idx.AllEntries()
|
entries := idx.AllEntries("ProjectA")
|
||||||
|
|
||||||
got := make(map[string]string, len(entries))
|
got := make(map[string]string, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
|
@ -156,6 +219,14 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
||||||
t.Errorf("AllEntries not sorted: %q before %q", entries[i-1].URLName, entries[i].URLName)
|
t.Errorf("AllEntries not sorted: %q before %q", entries[i-1].URLName, entries[i].URLName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty project key returns nil — root .archive doesn't exist.
|
||||||
|
if got := idx.AllEntries(""); got != nil {
|
||||||
|
t.Errorf("AllEntries(\"\") = %v, want nil", got)
|
||||||
|
}
|
||||||
|
if got := idx.AllEntries("NoSuchProject"); got != nil {
|
||||||
|
t.Errorf("AllEntries(NoSuchProject) = %v, want nil", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier-only files (no base) don't get a <tracking>.html or
|
// Modifier-only files (no base) don't get a <tracking>.html or
|
||||||
|
|
@ -164,18 +235,119 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
||||||
// through the resolver but are not surfaced in the listing.
|
// through the resolver but are not surfaced in the listing.
|
||||||
func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) {
|
func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mkTransmittal(t, root, "2025-02-01_T2 (RTN) - Comments",
|
mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments",
|
||||||
"123_~A+C1 (RTN) - Comments.pdf",
|
"123_~A+C1 (RTN) - Comments.pdf",
|
||||||
)
|
)
|
||||||
|
|
||||||
idx, _ := BuildIndex(root)
|
idx, _ := BuildIndex(root)
|
||||||
for _, e := range idx.AllEntries() {
|
for _, e := range idx.AllEntries("ProjectA") {
|
||||||
if e.URLName == "123.html" || e.URLName == "123_~A.html" {
|
if e.URLName == "123.html" || e.URLName == "123_~A.html" {
|
||||||
t.Errorf("unexpected entry %q (no base file exists)", e.URLName)
|
t.Errorf("unexpected entry %q (no base file exists)", e.URLName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Within-project collision: two different files claim to be the same
|
||||||
|
// (project, tracking, rev). Chronological winner still wins, but a WARN log
|
||||||
|
// is emitted with both paths so the authoring mistake is diagnosable.
|
||||||
|
//
|
||||||
|
// (Cross-project duplicates are NOT collisions — they live in separate
|
||||||
|
// buckets. See TestCrossProject_NoCollision.)
|
||||||
|
func TestRecordFile_WithinProjectCollisionLogged(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkTransmittal(t, root, "ProjectA/2025-03-01_Late (IFR) - Title",
|
||||||
|
"123_A (IFR) - Title.pdf",
|
||||||
|
)
|
||||||
|
// Different transmittal folder, same tracking+rev — e.g. operator
|
||||||
|
// re-issued under a different cover sheet by mistake.
|
||||||
|
mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title",
|
||||||
|
"123_A (IFR) - Title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Capture slog output during BuildIndex.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
idx, err := BuildIndex(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildIndex: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logged := buf.String()
|
||||||
|
if !strings.Contains(logged, "within-project revision collision") {
|
||||||
|
t.Errorf("expected collision WARN; got log:\n%s", logged)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, "project=ProjectA") {
|
||||||
|
t.Errorf("expected project field in log; got:\n%s", logged)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logged, "tracking=123") {
|
||||||
|
t.Errorf("expected tracking field in log; got:\n%s", logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chronological winner still wins.
|
||||||
|
target, ok := Resolve(idx, "ProjectA", "123_A.html")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Resolve failed")
|
||||||
|
}
|
||||||
|
if !contains(target, "2025-01-01_Early") {
|
||||||
|
t.Errorf("got %q, want path under 2025-01-01_Early/ (chronological winner)", target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-indexing the same transmittal folder (e.g. via the watcher) must NOT
|
||||||
|
// trip the collision detector — same path is a no-op, not a conflict.
|
||||||
|
func TestRecordFile_ReindexSamePathNoCollisionLog(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
|
"123_A (IFR) - Title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
defer slog.SetDefault(prev)
|
||||||
|
|
||||||
|
idx, err := BuildIndex(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildIndex: %v", err)
|
||||||
|
}
|
||||||
|
// Simulate the watcher firing again on the same transmittal folder.
|
||||||
|
transmittalAbs := filepath.Join(root, "ProjectA", "2025-01-01_T1 (IFR) - Title")
|
||||||
|
if err := idx.UpdateFromDir(root, transmittalAbs); err != nil {
|
||||||
|
t.Fatalf("UpdateFromDir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), "within-project revision collision") {
|
||||||
|
t.Errorf("re-index should not log collision; got:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// projectOf is the canonical place to derive the project key. Validate the
|
||||||
|
// edge cases so the contract doesn't drift silently.
|
||||||
|
func TestProjectOf(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"ProjectA/2025-01-01_T1/100_A.pdf", "ProjectA"},
|
||||||
|
{"ProjectA/sub/deep/file.pdf", "ProjectA"},
|
||||||
|
// Files at the root with no slash have no project.
|
||||||
|
{"top-level-loose-file.pdf", ""},
|
||||||
|
{"", ""},
|
||||||
|
// Defensive: leading slash should never reach this helper, but if it
|
||||||
|
// did, we'd return "" rather than picking up an empty leading segment.
|
||||||
|
{"/ProjectA/file", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := projectOf(c.path)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("projectOf(%q) = %q, want %q", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s, sub string) bool {
|
func contains(s, sub string) bool {
|
||||||
for i := 0; i+len(sub) <= len(s); i++ {
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
if s[i:i+len(sub)] == sub {
|
if s[i:i+len(sub)] == sub {
|
||||||
|
|
@ -193,3 +365,12 @@ func sortedKeys(m map[string]string) []string {
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func entryNames(entries []Entry) []string {
|
||||||
|
out := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, e.URLName)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,25 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolve parses the .archive request filename and returns the server-relative
|
// Resolve parses the .archive request filename and returns the server-relative
|
||||||
// redirect target URL (no leading slash).
|
// redirect target URL (no leading slash) within the named project.
|
||||||
|
//
|
||||||
|
// Project is the top-level segment of the .archive contextPath
|
||||||
|
// (/<project>/.../.archive/<filename>). An empty project — i.e. a request
|
||||||
|
// against /.archive/ at the very root — returns ("", false): stable refs
|
||||||
|
// must be project-rooted to avoid cross-project tracking-number collisions.
|
||||||
//
|
//
|
||||||
// Supported URL filename patterns (after stripping .html suffix):
|
// Supported URL filename patterns (after stripping .html suffix):
|
||||||
// - trackingNumber → highest base revision of trackingNumber
|
// - trackingNumber → highest base revision of trackingNumber
|
||||||
// - trackingNumber_rev → base revision file for rev
|
// - trackingNumber_rev → base revision file for rev
|
||||||
// - trackingNumber_rev+C1 → modifier file (C1, B1, N1, Q1)
|
// - trackingNumber_rev+C1 → modifier file (C1, B1, N1, Q1)
|
||||||
//
|
//
|
||||||
// Returns ("", false) if the filename cannot be parsed or no match exists.
|
// Returns ("", false) if project is empty, the filename cannot be parsed, or
|
||||||
func Resolve(idx *Index, filename string) (string, bool) {
|
// no match exists in the project.
|
||||||
|
func Resolve(idx *Index, project, filename string) (string, bool) {
|
||||||
|
if project == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// Strip .html suffix
|
// Strip .html suffix
|
||||||
stem := strings.TrimSuffix(filename, ".html")
|
stem := strings.TrimSuffix(filename, ".html")
|
||||||
if stem == filename {
|
if stem == filename {
|
||||||
|
|
@ -24,12 +34,17 @@ func Resolve(idx *Index, filename string) (string, bool) {
|
||||||
idx.mu.RLock()
|
idx.mu.RLock()
|
||||||
defer idx.mu.RUnlock()
|
defer idx.mu.RUnlock()
|
||||||
|
|
||||||
|
pe, ok := idx.ByProject[project]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// Try to split off revision part (last _ segment)
|
// Try to split off revision part (last _ segment)
|
||||||
lastUnderscore := strings.LastIndex(stem, "_")
|
lastUnderscore := strings.LastIndex(stem, "_")
|
||||||
if lastUnderscore < 0 {
|
if lastUnderscore < 0 {
|
||||||
// No underscore — treat entire stem as tracking number
|
// No underscore — treat entire stem as tracking number
|
||||||
tracking := stem
|
tracking := stem
|
||||||
te, ok := idx.ByTracking[tracking]
|
te, ok := pe.ByTracking[tracking]
|
||||||
if !ok || te.HighestBaseRev == "" {
|
if !ok || te.HighestBaseRev == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +69,7 @@ func Resolve(idx *Index, filename string) (string, bool) {
|
||||||
modifier = revPart[plusIdx+1:]
|
modifier = revPart[plusIdx+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
te, ok := idx.ByTracking[tracking]
|
te, ok := pe.ByTracking[tracking]
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,15 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// .zddc file changed — invalidate ACL policy cache
|
// .zddc file changed — invalidate ACL policy cache for the chain rooted
|
||||||
|
// here AND the global scan cache (which lists every directory containing
|
||||||
|
// a .zddc file). The scan cache is fsRoot-keyed and only changes when
|
||||||
|
// .zddc files are added/removed, but distinguishing modify from
|
||||||
|
// create/remove in fsnotify is fragile so we just always invalidate.
|
||||||
if base == ".zddc" {
|
if base == ".zddc" {
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
zddc.InvalidateCache(dir)
|
zddc.InvalidateCache(dir)
|
||||||
|
zddc.InvalidateScanCache()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,27 @@ import (
|
||||||
// .archive is exposed at every folder depth so HTML produced for offline use
|
// .archive is exposed at every folder depth so HTML produced for offline use
|
||||||
// can reference sibling tracking numbers via "../.archive/<tracking>.html".
|
// can reference sibling tracking numbers via "../.archive/<tracking>.html".
|
||||||
// In a browser the relative link is resolved before the request reaches the
|
// In a browser the relative link is resolved before the request reaches the
|
||||||
// server, so the server treats every .archive request the same regardless of
|
// server, so the contextPath the request arrives under is significant: its
|
||||||
// the contextPath it arrived under: the same global index is consulted, and
|
// FIRST segment is the project, and the .archive listing/resolver is scoped
|
||||||
// access is gated only by the cascading .zddc ACL.
|
// to that project's bucket. This avoids cross-project collisions when the
|
||||||
|
// same tracking number is issued under multiple projects.
|
||||||
//
|
//
|
||||||
// contextPath: the URL path leading up to (but not including) .archive
|
// contextPath: the URL path leading up to (but not including) .archive
|
||||||
// - used to gate the listing endpoint (caller must have ACL access to the
|
// - first segment selects the project bucket
|
||||||
// directory the .archive virtual entry sits in — otherwise just knowing
|
// - used to gate the listing endpoint via cascading .zddc ACL
|
||||||
// the folder exists would leak)
|
|
||||||
// - used as the URL prefix for the entries returned in the listing
|
// - used as the URL prefix for the entries returned in the listing
|
||||||
|
// - empty (root /.archive/) returns 404 — refs must be project-rooted
|
||||||
//
|
//
|
||||||
// filename: the part after .archive/ (empty for directory listing)
|
// filename: the part after .archive/ (empty for directory listing)
|
||||||
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
|
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
|
||||||
email := EmailFromContext(r)
|
email := EmailFromContext(r)
|
||||||
|
|
||||||
|
project := projectFromContextPath(contextPath)
|
||||||
|
if project == "" {
|
||||||
|
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ACL gate on the context directory: callers who can't reach the
|
// ACL gate on the context directory: callers who can't reach the
|
||||||
// directory hosting this .archive shouldn't be able to query it either.
|
// directory hosting this .archive shouldn't be able to query it either.
|
||||||
dirPath := strings.TrimPrefix(contextPath, "/")
|
dirPath := strings.TrimPrefix(contextPath, "/")
|
||||||
|
|
@ -47,11 +54,11 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
||||||
}
|
}
|
||||||
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
serveArchiveListing(cfg, idx, w, r, contextPath, email)
|
serveArchiveListing(cfg, idx, w, r, contextPath, project, email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, ok := archive.Resolve(idx, filename)
|
target, ok := archive.Resolve(idx, project, filename)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
|
|
@ -73,8 +80,22 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
||||||
http.Redirect(w, r, "/"+target, http.StatusFound)
|
http.Redirect(w, r, "/"+target, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) {
|
// projectFromContextPath returns the first non-empty segment of the
|
||||||
allEntries := idx.AllEntries()
|
// contextPath, which is the project bucket key for archive lookups. Returns
|
||||||
|
// "" for "/" or "" (root .archive — has no project).
|
||||||
|
func projectFromContextPath(contextPath string) string {
|
||||||
|
cleaned := strings.Trim(contextPath, "/")
|
||||||
|
if cleaned == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(cleaned, '/'); i >= 0 {
|
||||||
|
return cleaned[:i]
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) {
|
||||||
|
allEntries := idx.AllEntries(project)
|
||||||
archiveBase := contextPath
|
archiveBase := contextPath
|
||||||
if !strings.HasSuffix(archiveBase, "/") {
|
if !strings.HasSuffix(archiveBase, "/") {
|
||||||
archiveBase += "/"
|
archiveBase += "/"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -16,8 +17,9 @@ import (
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// archiveTestRoot lays down a two-project tree so listings exercise scope and
|
// archiveTestRoot lays down a two-project tree so listings exercise project
|
||||||
// ACL cascading. ACLs are written per-test in the helper that calls this.
|
// scoping, ACL cascading, and the per-project bucket boundary. ACLs are
|
||||||
|
// written per-test in the helper that calls this.
|
||||||
//
|
//
|
||||||
// <root>/
|
// <root>/
|
||||||
// ProjectA/
|
// ProjectA/
|
||||||
|
|
@ -73,11 +75,16 @@ func archiveCfg(root string) config.Config {
|
||||||
|
|
||||||
func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder {
|
func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
urlPath := contextPath
|
// Build a syntactically valid URL by escaping each segment of the
|
||||||
if !strings.HasSuffix(urlPath, "/") {
|
// contextPath and filename. The handler receives the decoded
|
||||||
|
// contextPath/filename arguments directly (as the dispatcher would have
|
||||||
|
// decoded them); the URL itself just needs to parse for httptest.
|
||||||
|
urlPath := encodePath(contextPath) + "/" + cfg.IndexPath
|
||||||
|
if filename != "" {
|
||||||
|
urlPath += "/" + url.PathEscape(filename)
|
||||||
|
} else {
|
||||||
urlPath += "/"
|
urlPath += "/"
|
||||||
}
|
}
|
||||||
urlPath += ".archive/" + filename
|
|
||||||
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
|
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
@ -85,6 +92,21 @@ func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, con
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodePath URL-escapes each non-empty slash-separated segment of p so
|
||||||
|
// special characters like spaces and parens don't break NewRequest's URL
|
||||||
|
// parser. A leading slash is preserved; an empty input becomes "/".
|
||||||
|
func encodePath(p string) string {
|
||||||
|
trimmed := strings.Trim(p, "/")
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(trimmed, "/")
|
||||||
|
for i, s := range parts {
|
||||||
|
parts[i] = url.PathEscape(s)
|
||||||
|
}
|
||||||
|
return "/" + strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
|
func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var out []listing.FileInfo
|
var out []listing.FileInfo
|
||||||
|
|
@ -111,11 +133,37 @@ func contains(xs []string, x string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// .archive at any depth serves the SAME global index (modulo ACL). Only the
|
// /.archive/ at the very root has no project segment to scope by, so it's a
|
||||||
// URL prefix on the entries differs, so relative ../.archive/ links resolve
|
// hard 404 — even for an admin. Stable references must include the project
|
||||||
// to a working server endpoint no matter which folder the source page sits
|
// directory; otherwise cross-project tracking-number collisions would silently
|
||||||
// in.
|
// pick a winner.
|
||||||
func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
|
func TestServeArchive_RootHasNoProjectScope404(t *testing.T) {
|
||||||
|
root, idx := archiveTestRoot(t)
|
||||||
|
writeZddc(t, root, ".", `acl:
|
||||||
|
allow: ["*"]
|
||||||
|
`)
|
||||||
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
|
for _, ctx := range []string{"/", ""} {
|
||||||
|
t.Run("ctx="+ctx, func(t *testing.T) {
|
||||||
|
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "")
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("listing at root: status %d, want 404; body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("resolve at root: status %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .archive listings are scoped to the contextPath's first segment (the
|
||||||
|
// project). Each project sees only its own tracking numbers; cross-project
|
||||||
|
// entries are invisible. Subdirectory contextPaths still resolve to the
|
||||||
|
// top-level project's bucket — a request from /ProjectA/sub/sub/.archive/
|
||||||
|
// shows ProjectA's entries with that deeper URL prefix.
|
||||||
|
func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["*"]
|
allow: ["*"]
|
||||||
|
|
@ -127,14 +175,32 @@ func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
|
||||||
name string
|
name string
|
||||||
contextPath string
|
contextPath string
|
||||||
urlPrefix string
|
urlPrefix string
|
||||||
|
wantNames []string
|
||||||
|
denyNames []string
|
||||||
}{
|
}{
|
||||||
{"root", "/", "/.archive/"},
|
{
|
||||||
{"project depth", "/ProjectA", "/ProjectA/.archive/"},
|
"ProjectA top level",
|
||||||
{"unrelated project depth", "/ProjectB", "/ProjectB/.archive/"},
|
"/ProjectA",
|
||||||
|
"/ProjectA/.archive/",
|
||||||
|
[]string{"100.html", "100_A.html", "100_~A.html"},
|
||||||
|
[]string{"200.html", "200_0.html"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ProjectA deeper subpath",
|
||||||
|
"/ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
|
"/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/",
|
||||||
|
[]string{"100.html", "100_A.html", "100_~A.html"},
|
||||||
|
[]string{"200.html", "200_0.html"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ProjectB top level",
|
||||||
|
"/ProjectB",
|
||||||
|
"/ProjectB/.archive/",
|
||||||
|
[]string{"200.html", "200_0.html"},
|
||||||
|
[]string{"100.html", "100_A.html", "100_~A.html"},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
wantNames := []string{"100.html", "100_A.html", "100_~A.html", "200.html", "200_0.html"}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
rec := callArchive(t, cfg, idx, email, c.contextPath, "")
|
rec := callArchive(t, cfg, idx, email, c.contextPath, "")
|
||||||
|
|
@ -143,11 +209,16 @@ func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
|
||||||
}
|
}
|
||||||
got := decodeListing(t, rec.Body.Bytes())
|
got := decodeListing(t, rec.Body.Bytes())
|
||||||
gotNames := names(got)
|
gotNames := names(got)
|
||||||
for _, want := range wantNames {
|
for _, want := range c.wantNames {
|
||||||
if !contains(gotNames, want) {
|
if !contains(gotNames, want) {
|
||||||
t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames)
|
t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, deny := range c.denyNames {
|
||||||
|
if contains(gotNames, deny) {
|
||||||
|
t.Errorf("unexpected cross-project entry %q at %s; got %v", deny, c.contextPath, gotNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, e := range got {
|
for _, e := range got {
|
||||||
if !strings.HasPrefix(e.URL, c.urlPrefix) {
|
if !strings.HasPrefix(e.URL, c.urlPrefix) {
|
||||||
t.Errorf("entry %q URL = %q, want %s prefix", e.Name, e.URL, c.urlPrefix)
|
t.Errorf("entry %q URL = %q, want %s prefix", e.Name, e.URL, c.urlPrefix)
|
||||||
|
|
@ -183,21 +254,25 @@ func TestServeArchive_ListingDeniedByContextPathACL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listing entries are filtered per-target by ACL: a caller denied at a
|
// Listing entries are filtered per-target by ACL: a caller denied at a
|
||||||
// subtree sees no entries from it — even when querying /.archive/ at the
|
// subtree's transmittal directory sees no entries whose target lives there.
|
||||||
// root where they ARE allowed. Excluding a user from a subdir requires an
|
// Excluding a user from a subdir requires an explicit deny there (the
|
||||||
// explicit deny there (the cascade is "first explicit match wins, bottom-
|
// cascade is "first explicit match wins, bottom-up", so a child allow list
|
||||||
// up", so a child allow list doesn't narrow a parent's allow:["*"]).
|
// doesn't narrow a parent's allow:["*"]).
|
||||||
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["*"]
|
allow: ["*"]
|
||||||
`)
|
`)
|
||||||
writeZddc(t, root, "ProjectB", `acl:
|
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her
|
||||||
|
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA
|
||||||
|
// entries stay visible. (A blanket /ProjectA deny would 403 the
|
||||||
|
// listing entirely; that's covered by the previous test.)
|
||||||
|
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
|
||||||
deny: ["alice@example.com"]
|
deny: ["alice@example.com"]
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "")
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -208,13 +283,12 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||||
t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
|
t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, hidden := range []string{"200.html", "200_0.html"} {
|
|
||||||
if contains(gotNames, hidden) {
|
|
||||||
t.Errorf("alice should not see ACL-blocked entry %q; got %v", hidden, gotNames)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = callArchive(t, cfg, idx, "bob@example.com", "/", "")
|
// Bob has no per-target denials in either project.
|
||||||
|
rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bob ProjectB listing: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
gotNames = names(decodeListing(t, rec.Body.Bytes()))
|
gotNames = names(decodeListing(t, rec.Body.Bytes()))
|
||||||
if !contains(gotNames, "200.html") {
|
if !contains(gotNames, "200.html") {
|
||||||
t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames)
|
t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames)
|
||||||
|
|
@ -223,7 +297,8 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||||
|
|
||||||
// Direct redirect requests for a tracking number whose target the caller
|
// Direct redirect requests for a tracking number whose target the caller
|
||||||
// can't read return 404 (not 403, not 302) — the file's existence must not
|
// can't read return 404 (not 403, not 302) — the file's existence must not
|
||||||
// leak across the ACL boundary.
|
// leak across the ACL boundary. Cross-project tracking-number requests also
|
||||||
|
// 404 because each project's bucket is separate.
|
||||||
func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
|
func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
|
|
@ -234,33 +309,44 @@ func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "200.html")
|
// 200 doesn't even live in ProjectA, so the resolver itself returns 404
|
||||||
|
// regardless of ACL — project scoping comes first.
|
||||||
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "200.html")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("alice → 200.html: status %d, want 404 (target ACL-denied)", rec.Code)
|
t.Errorf("alice → /ProjectA/.archive/200.html: status %d, want 404 (cross-project)", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alice in /ProjectA can resolve all of ProjectA's entries.
|
||||||
for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} {
|
for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} {
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/", fn)
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn)
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Errorf("alice → %s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String())
|
t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 302; body = %s", fn, rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = callArchive(t, cfg, idx, "bob@example.com", "/", "200.html")
|
// Alice attempting ProjectB directly is denied at the contextPath ACL.
|
||||||
|
rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectB", "200.html")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("alice → /ProjectB/.archive/200.html: status %d, want 403 (denied at contextPath)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob has no denies — he can pull 200.html from /ProjectB.
|
||||||
|
rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "200.html")
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Errorf("bob → 200.html: status %d, want 302", rec.Code)
|
t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 302", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cascade direction sanity check: a denial at the subtree wins over an
|
// Cascade direction sanity check: a denial at the subtree wins over an
|
||||||
// allow at the parent, AND a target-level allow can rescue a user the
|
// allow at the parent, AND a target-level allow can rescue a user the
|
||||||
// parent didn't mention. Both directions of cascade must be exercised so
|
// parent didn't mention. Both directions must be exercised so future
|
||||||
// future refactors of the per-target ACL helper can't silently break one.
|
// refactors of the per-target ACL helper can't silently break one.
|
||||||
func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
|
func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
// Root: deny default — only bob is on the list. ProjectA: explicitly
|
// Root: deny default — only bob is on the list. ProjectA: explicitly
|
||||||
// allow alice. So alice is rescued at the leaf, mallory stays out
|
// allow alice. So alice is rescued at ProjectA, mallory stays out
|
||||||
// everywhere, bob stays in everywhere.
|
// everywhere, bob stays in everywhere. Per-target ACL on resolved files
|
||||||
|
// doesn't kick in here — both projects allow bob via the root rule.
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["bob@example.com"]
|
allow: ["bob@example.com"]
|
||||||
`)
|
`)
|
||||||
|
|
@ -270,40 +356,35 @@ func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
email string
|
email string
|
||||||
filename string
|
contextPath string
|
||||||
wantStatus int
|
filename string
|
||||||
why string
|
wantStatus int
|
||||||
|
why string
|
||||||
}{
|
}{
|
||||||
{"bob@example.com", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"},
|
{"bob@example.com", "/ProjectA", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"},
|
||||||
{"bob@example.com", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"},
|
{"bob@example.com", "/ProjectB", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"},
|
||||||
{"alice@example.com", "100.html", http.StatusFound, "alice rescued by ProjectA allow"},
|
{"alice@example.com", "/ProjectA", "100.html", http.StatusFound, "alice rescued by ProjectA allow"},
|
||||||
{"alice@example.com", "200.html", http.StatusNotFound, "alice not in ProjectB chain → 404"},
|
{"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"},
|
||||||
// mallory is denied EVERYWHERE — including the /ProjectA contextPath
|
// mallory denied everywhere; the contextPath gate fires first.
|
||||||
// — so she never reaches per-target evaluation; the contextPath
|
{"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"},
|
||||||
// gate returns 403. (404 leak-prevention only kicks in once the
|
|
||||||
// contextPath itself is reachable.)
|
|
||||||
{"mallory@example.com", "100.html", http.StatusForbidden, "mallory blocked at contextPath"},
|
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.email+"_"+c.filename, func(t *testing.T) {
|
t.Run(c.email+"_"+c.contextPath+"_"+c.filename, func(t *testing.T) {
|
||||||
// Use ProjectA as contextPath: alice is rescued there (so she
|
rec := callArchive(t, cfg, idx, c.email, c.contextPath, c.filename)
|
||||||
// passes the gate and we get to per-target ACL on the ProjectB
|
|
||||||
// resolve), and bob+mallory's behavior is governed by the root
|
|
||||||
// rules.
|
|
||||||
rec := callArchive(t, cfg, idx, c.email, "/ProjectA", c.filename)
|
|
||||||
if rec.Code != c.wantStatus {
|
if rec.Code != c.wantStatus {
|
||||||
t.Errorf("%s → %s: status %d, want %d (%s)", c.email, c.filename, rec.Code, c.wantStatus, c.why)
|
t.Errorf("%s @ %s → %s: status %d, want %d (%s)", c.email, c.contextPath, c.filename, rec.Code, c.wantStatus, c.why)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolved redirect Location header must be the absolute path to the actual
|
// Resolved redirect Location header is the absolute path to the actual file
|
||||||
// file under cfg.Root, regardless of which contextPath the caller used to
|
// under cfg.Root. From any depth within the same project, the resolver
|
||||||
// reach .archive. So /ProjectA/.archive/100.html and /.archive/100.html
|
// returns the same target — `/ProjectA/.archive/100.html` and
|
||||||
// both 302 to the same file.
|
// `/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/100.html` 302 to the same
|
||||||
func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T) {
|
// file because both look up project ProjectA.
|
||||||
|
func TestServeArchive_ResolveLocationStableAcrossDepthWithinProject(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["*"]
|
allow: ["*"]
|
||||||
|
|
@ -311,7 +392,11 @@ func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
wantLocPrefix := "/ProjectA/2025-01-01_T1 (IFR) - Title/100_A"
|
wantLocPrefix := "/ProjectA/2025-01-01_T1 (IFR) - Title/100_A"
|
||||||
for _, ctx := range []string{"/", "/ProjectA", "/ProjectB"} {
|
for _, ctx := range []string{
|
||||||
|
"/ProjectA",
|
||||||
|
"/ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
|
"/ProjectA/2025-02-01_T2 (RTN) - Comments",
|
||||||
|
} {
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
|
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
|
||||||
if rec.Code != http.StatusFound {
|
if rec.Code != http.StatusFound {
|
||||||
t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String())
|
t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String())
|
||||||
|
|
@ -324,6 +409,72 @@ func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-project: same tracking number issued under two projects. Each
|
||||||
|
// project's .archive/ resolves to its own copy, never the other's.
|
||||||
|
func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mk := func(rel string) {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mk("ProjectA/2025-01-01_T1 (IFR) - Title/123_A (IFR) - Title.pdf")
|
||||||
|
mk("ProjectB/2025-06-01_T9 (IFR) - Other Title/123_A (IFR) - Other Title.pdf")
|
||||||
|
idx, err := archive.BuildIndex(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildIndex: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeZddc(t, root, ".", `acl:
|
||||||
|
allow: ["*"]
|
||||||
|
`)
|
||||||
|
cfg := archiveCfg(root)
|
||||||
|
const email = "alice@example.com"
|
||||||
|
|
||||||
|
recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html")
|
||||||
|
if recA.Code != http.StatusFound {
|
||||||
|
t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String())
|
||||||
|
}
|
||||||
|
locA := recA.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(locA, "/ProjectA/") {
|
||||||
|
t.Errorf("ProjectA Location=%q, want /ProjectA/ prefix", locA)
|
||||||
|
}
|
||||||
|
|
||||||
|
recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html")
|
||||||
|
if recB.Code != http.StatusFound {
|
||||||
|
t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String())
|
||||||
|
}
|
||||||
|
locB := recB.Header().Get("Location")
|
||||||
|
if !strings.HasPrefix(locB, "/ProjectB/") {
|
||||||
|
t.Errorf("ProjectB Location=%q, want /ProjectB/ prefix", locB)
|
||||||
|
}
|
||||||
|
|
||||||
|
if locA == locB {
|
||||||
|
t.Errorf("cross-project leak: same Location for both projects: %q", locA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listing each project shows only its own.
|
||||||
|
for _, c := range []struct{ ctx, mustHave, mustNot string }{
|
||||||
|
{"/ProjectA", "ProjectA", "ProjectB"},
|
||||||
|
{"/ProjectB", "ProjectB", "ProjectA"},
|
||||||
|
} {
|
||||||
|
rec := callArchive(t, cfg, idx, email, c.ctx, "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("listing %s: status %d", c.ctx, rec.Code)
|
||||||
|
}
|
||||||
|
got := decodeListing(t, rec.Body.Bytes())
|
||||||
|
for _, e := range got {
|
||||||
|
if !strings.Contains(e.URL, "/"+c.mustHave+"/") {
|
||||||
|
t.Errorf("ctx=%s entry URL %q lacks /%s/ segment", c.ctx, e.URL, c.mustHave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default-deny: as soon as ANY .zddc exists in the chain, an unmatched
|
// Default-deny: as soon as ANY .zddc exists in the chain, an unmatched
|
||||||
// caller is denied. Verify this applies to listing entries too — a target
|
// caller is denied. Verify this applies to listing entries too — a target
|
||||||
// in a directory with a restrictive .zddc is not surfaced to outsiders even
|
// in a directory with a restrictive .zddc is not surfaced to outsiders even
|
||||||
|
|
@ -336,8 +487,8 @@ func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) {
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
// alice sees everything she's allowed to.
|
// alice sees everything she's allowed to in ProjectA.
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "")
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("alice listing: status %d, want 200", rec.Code)
|
t.Fatalf("alice listing: status %d, want 200", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -345,15 +496,14 @@ func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) {
|
||||||
t.Errorf("alice listing was empty, want entries")
|
t.Errorf("alice listing was empty, want entries")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charlie isn't on any list → default-deny at root → 403 even for the listing.
|
// Charlie isn't on any list → default-deny → 403 even for the listing.
|
||||||
rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "")
|
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("charlie listing: status %d, want 403", rec.Code)
|
t.Errorf("charlie listing: status %d, want 403", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct resolve also denied (404 to avoid leak).
|
// Direct resolve: contextPath ACL fires first → 403.
|
||||||
rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "100.html")
|
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "100.html")
|
||||||
// contextPath ACL fires first: at root, charlie is denied → 403.
|
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code)
|
t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
@ -368,8 +518,30 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchive(t, cfg, idx, "", "/", "")
|
rec := callArchive(t, cfg, idx, "", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("anonymous listing: status %d, want 403", rec.Code)
|
t.Errorf("anonymous listing: status %d, want 403", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// projectFromContextPath is the canonical place to derive the project key
|
||||||
|
// from the .archive contextPath. Pin the edge cases.
|
||||||
|
func TestProjectFromContextPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ctx string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/ProjectA", "ProjectA"},
|
||||||
|
{"/ProjectA/", "ProjectA"},
|
||||||
|
{"/ProjectA/sub/sub", "ProjectA"},
|
||||||
|
{"/", ""},
|
||||||
|
{"", ""},
|
||||||
|
{"ProjectA/sub", "ProjectA"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := projectFromContextPath(c.ctx)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("projectFromContextPath(%q) = %q, want %q", c.ctx, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,21 +66,25 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessView is the data the profile page renders in its top section and
|
// AccessView is the data the profile page lazy-loads from /.profile/access
|
||||||
// /.profile/access serves as JSON. It is derived from cfg + the caller's
|
// after first paint. The HTML shell renders only Email/EmailHeader/
|
||||||
// email; everything reuses existing helpers in package zddc.
|
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come
|
||||||
|
// in via JS. EditableParentChoices is what the create-project form's
|
||||||
|
// parent-selector renders — derived from AdminSubtrees on the client.
|
||||||
type AccessView struct {
|
type AccessView struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
EmailHeader string `json:"email_header"`
|
EmailHeader string `json:"email_header"`
|
||||||
IsSuperAdmin bool `json:"is_super_admin"`
|
IsSuperAdmin bool `json:"is_super_admin"`
|
||||||
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
||||||
Projects []ProjectInfo `json:"projects"`
|
Projects []ProjectInfo `json:"projects"`
|
||||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
||||||
|
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// enumerateAccess builds an AccessView for the given caller. Callable by
|
// enumerateAccess builds an AccessView for the given caller. Used by the
|
||||||
// both the HTML page (server-render) and the JSON endpoint without
|
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
|
||||||
// duplicating the access-walk logic.
|
// the request hot path — it ships a shell first and the client fetches the
|
||||||
|
// view after first paint.
|
||||||
func enumerateAccess(cfg config.Config, email string) AccessView {
|
func enumerateAccess(cfg config.Config, email string) AccessView {
|
||||||
view := AccessView{
|
view := AccessView{
|
||||||
Email: email,
|
Email: email,
|
||||||
|
|
@ -90,6 +94,11 @@ func enumerateAccess(cfg config.Config, email string) AccessView {
|
||||||
view.Projects, _ = EnumerateProjects(cfg, email)
|
view.Projects, _ = EnumerateProjects(cfg, email)
|
||||||
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
|
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
|
||||||
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
||||||
|
for _, t := range view.AdminSubtrees {
|
||||||
|
if t.CanEdit {
|
||||||
|
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,48 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestServeProfileHTMLLayered verifies server-side conditional rendering:
|
// stripTemplates removes every <template ...>...</template> block from the
|
||||||
// non-admin HTML contains zero admin markup, admin HTML adds the admin
|
// HTML body so substring assertions check only ACTIVE markup — i.e. live
|
||||||
// block, super-admin HTML adds the diagnostics block.
|
// DOM content the user (and their browser) actually sees, as opposed to
|
||||||
|
// inert content that JS may clone in based on a later access fetch.
|
||||||
|
//
|
||||||
|
// Naive but sufficient for the controlled output of profileTemplate (the
|
||||||
|
// template tags are unnested and well-formed). If the page ever grows
|
||||||
|
// nested templates, swap this for an html.Tokenizer-based pass.
|
||||||
|
func stripTemplates(body string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for {
|
||||||
|
i := strings.Index(body, "<template")
|
||||||
|
if i < 0 {
|
||||||
|
b.WriteString(body)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
b.WriteString(body[:i])
|
||||||
|
j := strings.Index(body[i:], "</template>")
|
||||||
|
if j < 0 {
|
||||||
|
// Unterminated <template> — bail; whatever's left is suspect.
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
body = body[i+j+len("</template>"):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServeProfileHTMLLayered pins the page-render contract after the
|
||||||
|
// lazy-load refactor:
|
||||||
|
//
|
||||||
|
// - The shell is the same byte-stream for every caller modulo the
|
||||||
|
// identity card and the super-admin diagnostics scaffold (gated by the
|
||||||
|
// cheap IsSuperAdmin check on the root .zddc).
|
||||||
|
// - Subtree-admin scaffolds (Editable .zddc files / Create new project)
|
||||||
|
// live ONLY inside <template id="tmpl-subtree-admin">. Pure non-admins
|
||||||
|
// receive the inert template but no live form, button handler, or
|
||||||
|
// event-bound markup.
|
||||||
|
// - Subtree-admin discovery moved to /.profile/access; the server-side
|
||||||
|
// render no longer needs to walk the .zddc tree.
|
||||||
|
//
|
||||||
|
// Subtree-admin and super-admin behaviour beyond identity + diagnostics is
|
||||||
|
// covered by TestServeProfileAccessJSON since it now flows through the
|
||||||
|
// JSON endpoint, not the shell.
|
||||||
func TestServeProfileHTMLLayered(t *testing.T) {
|
func TestServeProfileHTMLLayered(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
zf := "admins:\n - alice@example.com\n"
|
zf := "admins:\n - alice@example.com\n"
|
||||||
|
|
@ -239,43 +278,85 @@ func TestServeProfileHTMLLayered(t *testing.T) {
|
||||||
return rec.Body.String()
|
return rec.Body.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anonymous: identity says "Not signed in", no live admin markup, no
|
||||||
|
// diagnostics. The <template> still ships inertly so any caller could
|
||||||
|
// hydrate it after a successful /access fetch — but a non-admin's
|
||||||
|
// /access response carries empty AdminSubtrees and the JS skips
|
||||||
|
// instantiation. The active-markup check below proves the live DOM is
|
||||||
|
// admin-clean regardless.
|
||||||
anon := render("")
|
anon := render("")
|
||||||
if !strings.Contains(anon, "Not signed in") {
|
if !strings.Contains(anon, "Not signed in") {
|
||||||
t.Errorf("anonymous body missing 'Not signed in'")
|
t.Errorf("anonymous body missing 'Not signed in'")
|
||||||
}
|
}
|
||||||
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs"} {
|
anonActive := stripTemplates(anon)
|
||||||
if strings.Contains(anon, marker) {
|
for _, marker := range []string{
|
||||||
t.Errorf("anonymous body unexpectedly contains admin marker %q", marker)
|
`<form id="cp-form"`,
|
||||||
|
`id="diag-config"`,
|
||||||
|
`id="diag-logs"`,
|
||||||
|
`id="diag-whoami"`,
|
||||||
|
"Server config",
|
||||||
|
} {
|
||||||
|
if strings.Contains(anonActive, marker) {
|
||||||
|
t.Errorf("anonymous active markup unexpectedly contains admin marker %q", marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Inert <template> SHOULD ship — admins (and only admins) hydrate it.
|
||||||
|
if !strings.Contains(anon, `<template id="tmpl-subtree-admin">`) {
|
||||||
|
t.Errorf("anonymous body missing inert subtree-admin <template>")
|
||||||
|
}
|
||||||
|
|
||||||
nonAdmin := render("carol@example.com")
|
nonAdmin := render("carol@example.com")
|
||||||
if !strings.Contains(nonAdmin, "carol@example.com") {
|
if !strings.Contains(nonAdmin, "carol@example.com") {
|
||||||
t.Errorf("non-admin body missing email")
|
t.Errorf("non-admin body missing email")
|
||||||
}
|
}
|
||||||
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config"} {
|
nonAdminActive := stripTemplates(nonAdmin)
|
||||||
if strings.Contains(nonAdmin, marker) {
|
for _, marker := range []string{
|
||||||
t.Errorf("non-admin body unexpectedly contains admin marker %q", marker)
|
`<form id="cp-form"`,
|
||||||
|
`id="diag-config"`,
|
||||||
|
"Server config",
|
||||||
|
} {
|
||||||
|
if strings.Contains(nonAdminActive, marker) {
|
||||||
|
t.Errorf("non-admin active markup unexpectedly contains admin marker %q", marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subtree-admin (bob) gets the same shell as a non-admin — the
|
||||||
|
// scaffold lives in the <template> and JS hydrates it after fetching
|
||||||
|
// /.profile/access. The server-side render no longer differentiates
|
||||||
|
// these two roles, so its byte-output should match a non-admin's.
|
||||||
subtree := render("bob@example.com")
|
subtree := render("bob@example.com")
|
||||||
if !strings.Contains(subtree, "Editable .zddc files") {
|
subtreeActive := stripTemplates(subtree)
|
||||||
t.Errorf("subtree-admin body missing 'Editable .zddc files'")
|
for _, marker := range []string{
|
||||||
|
`<form id="cp-form"`,
|
||||||
|
`id="diag-config"`,
|
||||||
|
"Server config",
|
||||||
|
} {
|
||||||
|
if strings.Contains(subtreeActive, marker) {
|
||||||
|
t.Errorf("subtree-admin active markup unexpectedly contains admin marker %q (these are JS-hydrated)", marker)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(subtree, "Create new project folder") {
|
if !strings.Contains(subtree, `<template id="tmpl-subtree-admin">`) {
|
||||||
t.Errorf("subtree-admin body missing 'Create new project folder'")
|
t.Errorf("subtree-admin body missing the <template> the IIFE will hydrate")
|
||||||
}
|
|
||||||
if strings.Contains(subtree, "Server config") {
|
|
||||||
t.Errorf("subtree-admin body unexpectedly contains super-admin diagnostics")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Super-admin: diagnostics scaffold is rendered inline (cheap to
|
||||||
|
// gate), AND the subtree-admin <template> still ships for the IIFE to
|
||||||
|
// hydrate Editable + Create sections.
|
||||||
super := render("alice@example.com")
|
super := render("alice@example.com")
|
||||||
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs", "diag-whoami"} {
|
superActive := stripTemplates(super)
|
||||||
if !strings.Contains(super, marker) {
|
for _, marker := range []string{
|
||||||
t.Errorf("super-admin body missing %q", marker)
|
"Server config",
|
||||||
|
`id="diag-config"`,
|
||||||
|
`id="diag-logs"`,
|
||||||
|
`id="diag-whoami"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(superActive, marker) {
|
||||||
|
t.Errorf("super-admin active markup missing %q", marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(super, `<template id="tmpl-subtree-admin">`) {
|
||||||
|
t.Errorf("super-admin body missing subtree-admin <template> (still needs to hydrate Editable + Create)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServeProfileAccessJSON(t *testing.T) {
|
func TestServeProfileAccessJSON(t *testing.T) {
|
||||||
|
|
@ -297,6 +378,97 @@ func TestServeProfileAccessJSON(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subtree-admin discovery used to live in the HTML render; now it flows
|
||||||
|
// through /.profile/access. Verify the JSON endpoint exposes everything
|
||||||
|
// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
|
||||||
|
// for the read-only list, EditableParentChoices for the parent-selector
|
||||||
|
// options, and HasAnyAdminScope so the IIFE knows whether to clone the
|
||||||
|
// <template>. Pure non-admins get an empty access view and no scaffold.
|
||||||
|
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - alice@example.com\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write root .zddc: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n - bob@example.com\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write subtree .zddc: %v", err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
zddc.InvalidateCache(filepath.Join(root, "projects"))
|
||||||
|
zddc.InvalidateScanCache()
|
||||||
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
||||||
|
ring := NewLogRing(50)
|
||||||
|
|
||||||
|
fetchAccess := func(email string) AccessView {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var v AccessView
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
|
||||||
|
t.Fatalf("decode email=%q: %v", email, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure non-admin: no admin scope, IIFE skips the template hydration.
|
||||||
|
carol := fetchAccess("carol@example.com")
|
||||||
|
if carol.HasAnyAdminScope {
|
||||||
|
t.Errorf("carol HasAnyAdminScope = true, want false")
|
||||||
|
}
|
||||||
|
if len(carol.AdminSubtrees) != 0 {
|
||||||
|
t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees)
|
||||||
|
}
|
||||||
|
if len(carol.EditableParentChoices) != 0 {
|
||||||
|
t.Errorf("carol EditableParentChoices = %v, want empty", carol.EditableParentChoices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtree-admin: AdminSubtrees lists projects/ so the create-project
|
||||||
|
// parent dropdown can offer it; HasAnyAdminScope triggers template
|
||||||
|
// hydration. The projects/.zddc is NOT editable by bob — he cannot
|
||||||
|
// edit the file that grants him his own authority — so
|
||||||
|
// EditableParentChoices is empty and the Editable-files list will
|
||||||
|
// render its "None" placeholder.
|
||||||
|
bob := fetchAccess("bob@example.com")
|
||||||
|
if bob.IsSuperAdmin {
|
||||||
|
t.Errorf("bob IsSuperAdmin = true, want false")
|
||||||
|
}
|
||||||
|
if !bob.HasAnyAdminScope {
|
||||||
|
t.Errorf("bob HasAnyAdminScope = false, want true")
|
||||||
|
}
|
||||||
|
if len(bob.AdminSubtrees) == 0 {
|
||||||
|
t.Fatalf("bob AdminSubtrees empty; want projects/")
|
||||||
|
}
|
||||||
|
gotProjects := false
|
||||||
|
for _, s := range bob.AdminSubtrees {
|
||||||
|
if strings.HasSuffix(s.Path, "/projects") {
|
||||||
|
gotProjects = true
|
||||||
|
if s.CanEdit {
|
||||||
|
t.Errorf("bob's projects/ entry CanEdit = true; he should not be able to edit the .zddc granting his own authority")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !gotProjects {
|
||||||
|
t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
|
||||||
|
}
|
||||||
|
if len(bob.EditableParentChoices) != 0 {
|
||||||
|
t.Errorf("bob EditableParentChoices = %+v, want empty (his only subtree is one he can't edit)", bob.EditableParentChoices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Super-admin: AdminSubtrees enumerates every .zddc directory.
|
||||||
|
alice := fetchAccess("alice@example.com")
|
||||||
|
if !alice.IsSuperAdmin || !alice.HasAnyAdminScope {
|
||||||
|
t.Errorf("alice IsSuperAdmin=%v HasAnyAdminScope=%v, want both true", alice.IsSuperAdmin, alice.HasAnyAdminScope)
|
||||||
|
}
|
||||||
|
if len(alice.AdminSubtrees) < 2 {
|
||||||
|
t.Errorf("alice should see at least the root + projects/ subtrees; got %+v", alice.AdminSubtrees)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
|
||||||
// .zddc exists but has no admins list — page is still reachable,
|
// .zddc exists but has no admins list — page is still reachable,
|
||||||
// but the admin/super-admin sections are absent.
|
// but the admin/super-admin sections are absent.
|
||||||
|
|
|
||||||
|
|
@ -5,40 +5,51 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// profileView is the data passed to the profile template.
|
// profileView is the data passed to the profile template's HTML shell.
|
||||||
|
//
|
||||||
|
// Only cheap-to-compute fields appear here — Email comes from the request
|
||||||
|
// context, IsSuperAdmin reads the root .zddc only (single file + ACL chain
|
||||||
|
// cache hit), and HasCustomCSS is a single stat call. Everything else
|
||||||
|
// (visible projects, admin subtrees, editable scaffolds) is fetched lazily
|
||||||
|
// by the page's JS via /.profile/access after first paint, so the slow
|
||||||
|
// .zddc tree walk doesn't block the initial render. See AccessView and
|
||||||
|
// enumerateAccess for the JSON contract the client renders against.
|
||||||
type profileView struct {
|
type profileView struct {
|
||||||
AccessView
|
Email string
|
||||||
ProfilePathPrefix string
|
EmailHeader string
|
||||||
AssetsPathPrefix string
|
IsSuperAdmin bool
|
||||||
HasCustomCSS bool
|
ProfilePathPrefix string
|
||||||
HasEditableSubtrees bool
|
AssetsPathPrefix string
|
||||||
EditableParentChoices []treeEntry // AdminSubtrees filtered to CanEdit; used as create-project parents
|
HasCustomCSS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveProfilePage renders the universal profile page at GET /.profile/.
|
// serveProfilePage renders the universal profile page at GET /.profile/.
|
||||||
// Reachable to anyone (anonymous included); admin / super-admin sections
|
// Reachable to anyone (anonymous included). The shell is intentionally
|
||||||
// are conditionally rendered server-side based on the caller's effective
|
// minimal: identity card + theme + localStorage + super-admin diagnostic
|
||||||
// access — non-admin HTML contains zero admin markup.
|
// scaffolds (gated by the cheap IsSuperAdmin check) + a hidden
|
||||||
|
// <template id="tmpl-subtree-admin"> block. The client IIFE fetches
|
||||||
|
// /.profile/access on load and reveals the subtree-admin block when the
|
||||||
|
// caller has any subtree-admin scope. Pure non-admins receive no live
|
||||||
|
// admin form, button handler, or fetch URL — admin functionality only
|
||||||
|
// ever activates after enumerateAccess has confirmed the caller's scope.
|
||||||
func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
w.Header().Set("Allow", "GET")
|
w.Header().Set("Allow", "GET")
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
email := EmailFromContext(r)
|
||||||
view := profileView{
|
view := profileView{
|
||||||
AccessView: enumerateAccess(cfg, EmailFromContext(r)),
|
Email: email,
|
||||||
|
EmailHeader: cfg.EmailHeader,
|
||||||
|
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
|
||||||
ProfilePathPrefix: ProfilePathPrefix,
|
ProfilePathPrefix: ProfilePathPrefix,
|
||||||
AssetsPathPrefix: zddcAssetsPathPrefix,
|
AssetsPathPrefix: zddcAssetsPathPrefix,
|
||||||
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
HasCustomCSS: hasCustomProfileCSS(cfg.Root),
|
||||||
}
|
}
|
||||||
for _, t := range view.AdminSubtrees {
|
|
||||||
if t.CanEdit {
|
|
||||||
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
view.HasEditableSubtrees = len(view.EditableParentChoices) > 0
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
if err := profileTemplate.Execute(w, view); err != nil {
|
if err := profileTemplate.Execute(w, view); err != nil {
|
||||||
|
|
@ -46,11 +57,14 @@ func serveProfilePage(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// profileTemplate is the html/template for the profile page. Single page,
|
// profileTemplate is the html/template for the profile page. The shell is
|
||||||
// three layered blocks (universal / admin / super-admin), inline styles
|
// rendered server-side from cheap-only data (identity + IsSuperAdmin); the
|
||||||
// using the same custom-property naming as the editor so a future merge
|
// expensive bits (visible projects, admin subtrees, editable .zddc files,
|
||||||
// with shared/base.css stays trivial. One inline IIFE handles theme,
|
// create-project parent choices) are populated by the IIFE below after a
|
||||||
// localStorage, and the create-project AJAX submit.
|
// single fetch to /.profile/access. Subtree-admin scaffolds live inside a
|
||||||
|
// <template id="tmpl-subtree-admin"> so non-admins never receive the live
|
||||||
|
// form markup. Inline styles use the same custom-property naming as the
|
||||||
|
// editor so a future merge with shared/base.css stays trivial.
|
||||||
var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE html>
|
var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -142,23 +156,11 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
Super-admin: {{ if .IsSuperAdmin }}<span class="badge yes">yes</span>{{ else }}<span class="badge">no</span>{{ end }}
|
Super-admin: {{ if .IsSuperAdmin }}<span class="badge yes">yes</span>{{ else }}<span class="badge">no</span>{{ end }}
|
||||||
</p>
|
</p>
|
||||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
|
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
|
||||||
{{ if .Projects }}
|
<div id="projects-list"><p class="muted" id="projects-loading">loading…</p></div>
|
||||||
<ul class="bare">
|
<div id="admin-subtrees-block" hidden>
|
||||||
{{ range .Projects }}<li><a href="{{ .URL }}">{{ if .Title }}{{ .Title }}{{ else }}{{ .Name }}{{ end }}</a> <span class="muted">({{ .URL }})</span></li>{{ end }}
|
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Subtrees you administer</h3>
|
||||||
</ul>
|
<div id="admin-subtrees-list"></div>
|
||||||
{{ else }}
|
</div>
|
||||||
<p class="muted">No projects accessible.</p>
|
|
||||||
{{ end }}
|
|
||||||
{{ if .HasAnyAdminScope }}
|
|
||||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Subtrees you administer</h3>
|
|
||||||
{{ if .AdminSubtrees }}
|
|
||||||
<ul class="bare">
|
|
||||||
{{ range .AdminSubtrees }}<li><code>{{ .Path }}</code>{{ if .Title }} — {{ .Title }}{{ end }} {{ if .CanEdit }}<span class="muted">(editable)</span>{{ else }}<span class="muted">(read-only — you cannot edit the file granting your own authority)</span>{{ end }}</li>{{ end }}
|
|
||||||
</ul>
|
|
||||||
{{ else }}
|
|
||||||
<p class="muted">None.</p>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
@ -183,17 +185,13 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{ if .HasAnyAdminScope }}
|
<div id="subtree-admin-slot"></div>
|
||||||
|
|
||||||
|
<template id="tmpl-subtree-admin">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Editable .zddc files</h2>
|
<h2>Editable .zddc files</h2>
|
||||||
<p class="help">Open the form-based editor for any subtree you administer.</p>
|
<p class="help">Open the form-based editor for any subtree you administer.</p>
|
||||||
{{ if .HasEditableSubtrees }}
|
<div id="editable-list"></div>
|
||||||
<ul class="bare">
|
|
||||||
{{ range .EditableParentChoices }}<li><a href="{{ $.ProfilePathPrefix }}/zddc/edit?path={{ .Path }}"><code>{{ .Path }}/.zddc</code></a>{{ if .Title }} — {{ .Title }}{{ end }}</li>{{ end }}
|
|
||||||
</ul>
|
|
||||||
{{ else }}
|
|
||||||
<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority — only an admin from a higher level can.</p>
|
|
||||||
{{ end }}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
@ -202,10 +200,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
|
<div id="cp-ok" class="ok-banner" hidden>Created.</div>
|
||||||
<form id="cp-form" autocomplete="off">
|
<form id="cp-form" autocomplete="off">
|
||||||
<label>Parent
|
<label>Parent
|
||||||
<select name="parent" id="cp-parent">
|
<select name="parent" id="cp-parent"></select>
|
||||||
{{ if .IsSuperAdmin }}<option value="/">/ (root)</option>{{ end }}
|
|
||||||
{{ range .AdminSubtrees }}<option value="{{ .Path }}">{{ .Path }}</option>{{ end }}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<label>Name
|
<label>Name
|
||||||
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
|
<input type="text" name="name" id="cp-name" maxlength="64" placeholder="e.g. Site-3" required>
|
||||||
|
|
@ -228,7 +223,7 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{{ end }}
|
</template>
|
||||||
|
|
||||||
{{ if .IsSuperAdmin }}
|
{{ if .IsSuperAdmin }}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
|
|
@ -254,9 +249,14 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var prefix = {{ .ProfilePathPrefix }};
|
var prefix = {{ .ProfilePathPrefix }};
|
||||||
var hasAdminScope = {{ .HasAnyAdminScope }};
|
|
||||||
var isSuper = {{ .IsSuperAdmin }};
|
var isSuper = {{ .IsSuperAdmin }};
|
||||||
|
|
||||||
|
function escText(s) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.textContent = s == null ? "" : String(s);
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Theme ──────────────────────────────────────────────────────────────
|
// ── Theme ──────────────────────────────────────────────────────────────
|
||||||
var THEME_KEY = "zddc-theme";
|
var THEME_KEY = "zddc-theme";
|
||||||
function applyTheme(v) {
|
function applyTheme(v) {
|
||||||
|
|
@ -353,17 +353,99 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
|
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create project ────────────────────────────────────────────────────
|
// ── Lazy access view ──────────────────────────────────────────────────
|
||||||
if (hasAdminScope) {
|
// Fetch /.profile/access and populate the projects + admin-subtree
|
||||||
function rowFor(field) {
|
// sections after first paint. The slow .zddc tree walk happens here, off
|
||||||
var div = document.createElement("div"); div.className = "row";
|
// the request hot path. Subtree-admin scaffolds are cloned from the
|
||||||
var input = document.createElement("input");
|
// <template> only if the response shows the caller has any admin scope —
|
||||||
input.type = "text"; input.dataset.field = field;
|
// pure non-admins never see live admin form markup.
|
||||||
var del = document.createElement("button");
|
function renderProjects(projects) {
|
||||||
del.type = "button"; del.textContent = "−"; del.className = "del";
|
var host = document.getElementById("projects-list");
|
||||||
div.appendChild(input); div.appendChild(del);
|
if (!projects || projects.length === 0) {
|
||||||
return div;
|
host.innerHTML = '<p class="muted">No projects accessible.</p>';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
var html = '<ul class="bare">';
|
||||||
|
projects.forEach(function(p) {
|
||||||
|
var label = p.title ? escText(p.title) : escText(p.name);
|
||||||
|
html += '<li><a href="' + escText(p.url) + '">' + label + '</a> '
|
||||||
|
+ '<span class="muted">(' + escText(p.url) + ')</span></li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
host.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAdminSubtrees(subtrees) {
|
||||||
|
if (!subtrees || subtrees.length === 0) return;
|
||||||
|
document.getElementById("admin-subtrees-block").hidden = false;
|
||||||
|
var host = document.getElementById("admin-subtrees-list");
|
||||||
|
var html = '<ul class="bare">';
|
||||||
|
subtrees.forEach(function(s) {
|
||||||
|
html += '<li><code>' + escText(s.path) + '</code>';
|
||||||
|
if (s.title) html += ' — ' + escText(s.title);
|
||||||
|
if (s.can_edit) {
|
||||||
|
html += ' <span class="muted">(editable)</span>';
|
||||||
|
} else {
|
||||||
|
html += ' <span class="muted">(read-only — you cannot edit the file granting your own authority)</span>';
|
||||||
|
}
|
||||||
|
html += '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
host.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditableList(parents, hasAnyAdminScope) {
|
||||||
|
var host = document.getElementById("editable-list");
|
||||||
|
if (!host) return;
|
||||||
|
if (!parents || parents.length === 0) {
|
||||||
|
host.innerHTML = '<p class="muted">No <code>.zddc</code> files within your edit authority. Subtree admins cannot edit the file that grants their own authority — only an admin from a higher level can.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<ul class="bare">';
|
||||||
|
parents.forEach(function(p) {
|
||||||
|
var path = escText(p.path);
|
||||||
|
html += '<li><a href="' + escText(prefix) + '/zddc/edit?path=' + path + '">'
|
||||||
|
+ '<code>' + path + '/.zddc</code></a>';
|
||||||
|
if (p.title) html += ' — ' + escText(p.title);
|
||||||
|
html += '</li>';
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
host.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateParentChoices(adminSubtrees) {
|
||||||
|
var sel = document.getElementById("cp-parent");
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (isSuper) {
|
||||||
|
var optRoot = document.createElement("option");
|
||||||
|
optRoot.value = "/"; optRoot.textContent = "/ (root)";
|
||||||
|
sel.appendChild(optRoot);
|
||||||
|
}
|
||||||
|
(adminSubtrees || []).forEach(function(s) {
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = s.path; opt.textContent = s.path;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowFor(field) {
|
||||||
|
var div = document.createElement("div"); div.className = "row";
|
||||||
|
var input = document.createElement("input");
|
||||||
|
input.type = "text"; input.dataset.field = field;
|
||||||
|
var del = document.createElement("button");
|
||||||
|
del.type = "button"; del.textContent = "−"; del.className = "del";
|
||||||
|
div.appendChild(input); div.appendChild(del);
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
function collectList(field) {
|
||||||
|
var out = [];
|
||||||
|
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
|
||||||
|
if (i.value.trim()) out.push(i.value.trim());
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function wireCreateProjectForm() {
|
||||||
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
|
document.querySelectorAll("#cp-form button.add").forEach(function(btn) {
|
||||||
btn.addEventListener("click", function() {
|
btn.addEventListener("click", function() {
|
||||||
var field = btn.dataset.target;
|
var field = btn.dataset.target;
|
||||||
|
|
@ -375,13 +457,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
e.target.closest(".row").remove();
|
e.target.closest(".row").remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function collectList(field) {
|
|
||||||
var out = [];
|
|
||||||
document.querySelectorAll('#cp-form .list[data-field="' + field + '"] input').forEach(function(i) {
|
|
||||||
if (i.value.trim()) out.push(i.value.trim());
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
document.getElementById("cp-form").addEventListener("submit", function(ev) {
|
document.getElementById("cp-form").addEventListener("submit", function(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
document.getElementById("cp-name-err").textContent = "";
|
document.getElementById("cp-name-err").textContent = "";
|
||||||
|
|
@ -425,6 +500,32 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function instantiateAdminScaffold(view) {
|
||||||
|
if (!view.has_any_admin_scope) return;
|
||||||
|
var tmpl = document.getElementById("tmpl-subtree-admin");
|
||||||
|
if (!tmpl) return;
|
||||||
|
var slot = document.getElementById("subtree-admin-slot");
|
||||||
|
slot.appendChild(tmpl.content.cloneNode(true));
|
||||||
|
renderEditableList(view.editable_parent_choices, view.has_any_admin_scope);
|
||||||
|
populateParentChoices(view.admin_subtrees);
|
||||||
|
wireCreateProjectForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(prefix + "/access", { headers: { Accept: "application/json" }, credentials: "same-origin" })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function(view) {
|
||||||
|
if (!view) {
|
||||||
|
document.getElementById("projects-loading").textContent = "Could not load access view.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderProjects(view.projects);
|
||||||
|
renderAdminSubtrees(view.admin_subtrees);
|
||||||
|
instantiateAdminScaffold(view);
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById("projects-loading").textContent = "Could not load access view.";
|
||||||
|
});
|
||||||
|
|
||||||
// ── Super-admin diagnostics ───────────────────────────────────────────
|
// ── Super-admin diagnostics ───────────────────────────────────────────
|
||||||
if (isSuper) {
|
if (isSuper) {
|
||||||
function loadDiag(target, qs) {
|
function loadDiag(target, qs) {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,22 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// scanCache memoizes ScanZddcFiles results keyed by fsRoot.
|
||||||
|
//
|
||||||
|
// The walk is the dominant cost on every profile-page render in a real
|
||||||
|
// archive (thousands of directories). Caching is safe because:
|
||||||
|
// - The set of directories with .zddc files only changes via the writer
|
||||||
|
// helpers (CreateOrUpdateFile / DeleteFile) or external file events
|
||||||
|
// observed by the archive watcher; both call InvalidateScanCache.
|
||||||
|
// - Cache values are immutable []string slices; readers reuse them.
|
||||||
|
//
|
||||||
|
// Errors are NOT cached — callers always get a fresh attempt after a walk
|
||||||
|
// failure so a transient permissions blip doesn't poison the cache.
|
||||||
|
var scanCache sync.Map // map[string][]string
|
||||||
|
|
||||||
// ScanZddcFiles walks fsRoot and returns every directory that contains a
|
// ScanZddcFiles walks fsRoot and returns every directory that contains a
|
||||||
// .zddc file, sorted by path. Reserved-prefix directories ('.', '_') are
|
// .zddc file, sorted by path. Reserved-prefix directories ('.', '_') are
|
||||||
// pruned from the walk — they hide internal/scaffolding state from the
|
// pruned from the walk — they hide internal/scaffolding state from the
|
||||||
|
|
@ -16,9 +30,17 @@ import (
|
||||||
//
|
//
|
||||||
// The returned paths are absolute (rooted under fsRoot). On any walk
|
// The returned paths are absolute (rooted under fsRoot). On any walk
|
||||||
// error the partial result accumulated so far is returned alongside the
|
// error the partial result accumulated so far is returned alongside the
|
||||||
// error so callers can degrade gracefully.
|
// error so callers can degrade gracefully. Successful results are cached
|
||||||
|
// in memory; subsequent calls return the cached slice until
|
||||||
|
// InvalidateScanCache is called (the .zddc writer helpers and the archive
|
||||||
|
// watcher both invalidate on file events).
|
||||||
func ScanZddcFiles(fsRoot string) ([]string, error) {
|
func ScanZddcFiles(fsRoot string) ([]string, error) {
|
||||||
fsRoot = filepath.Clean(fsRoot)
|
fsRoot = filepath.Clean(fsRoot)
|
||||||
|
|
||||||
|
if cached, ok := scanCache.Load(fsRoot); ok {
|
||||||
|
return cached.([]string), nil
|
||||||
|
}
|
||||||
|
|
||||||
var dirs []string
|
var dirs []string
|
||||||
err := filepath.WalkDir(fsRoot, func(path string, d fs.DirEntry, walkErr error) error {
|
err := filepath.WalkDir(fsRoot, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
|
|
@ -45,5 +67,23 @@ func ScanZddcFiles(fsRoot string) ([]string, error) {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
sort.Strings(dirs)
|
sort.Strings(dirs)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
scanCache.Store(fsRoot, dirs)
|
||||||
|
}
|
||||||
return dirs, err
|
return dirs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvalidateScanCache drops every cached ScanZddcFiles result. Called by
|
||||||
|
// the writer helpers when a .zddc file is created or deleted, and by the
|
||||||
|
// archive watcher on .zddc filesystem events. Modifications to existing
|
||||||
|
// .zddc files don't change the scan output — but distinguishing create
|
||||||
|
// from modify in fsnotify is fragile, so we treat any event as
|
||||||
|
// invalidating; a single extra walk-on-next-call is cheap compared to
|
||||||
|
// silent staleness.
|
||||||
|
func InvalidateScanCache() {
|
||||||
|
scanCache.Range(func(key, _ any) bool {
|
||||||
|
scanCache.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
125
zddc/internal/zddc/scan_test.go
Normal file
125
zddc/internal/zddc/scan_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanFixture lays down a small tree with a few .zddc files and reserved-
|
||||||
|
// prefix directories that should be pruned. Returns the fsRoot.
|
||||||
|
func scanFixture(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
mk := func(rel, body string) {
|
||||||
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mk(".zddc", "admins:\n - alice@example.com\n")
|
||||||
|
mk("ProjectA/.zddc", "title: A\n")
|
||||||
|
mk("ProjectA/Sub/.zddc", "title: A-sub\n")
|
||||||
|
mk("ProjectB/.zddc", "title: B\n")
|
||||||
|
// Reserved-prefix subtrees must be pruned.
|
||||||
|
mk(".devshell/.zddc", "title: hidden\n")
|
||||||
|
mk("_template/.zddc", "title: scaffold\n")
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanZddcFiles_FindsAllAndPrunesReservedPrefixes(t *testing.T) {
|
||||||
|
InvalidateScanCache()
|
||||||
|
root := scanFixture(t)
|
||||||
|
|
||||||
|
dirs, err := ScanZddcFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanZddcFiles: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
root,
|
||||||
|
filepath.Join(root, "ProjectA"),
|
||||||
|
filepath.Join(root, "ProjectA", "Sub"),
|
||||||
|
filepath.Join(root, "ProjectB"),
|
||||||
|
}
|
||||||
|
if len(dirs) != len(want) {
|
||||||
|
t.Fatalf("got %d dirs, want %d: %v", len(dirs), len(want), dirs)
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if dirs[i] != w {
|
||||||
|
t.Errorf("dirs[%d] = %q, want %q", i, dirs[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache hit returns the same slice header as the first call (proves the
|
||||||
|
// walk wasn't repeated). Cache miss after invalidation returns a freshly-
|
||||||
|
// allocated slice.
|
||||||
|
func TestScanZddcFiles_CachesAndInvalidates(t *testing.T) {
|
||||||
|
InvalidateScanCache()
|
||||||
|
root := scanFixture(t)
|
||||||
|
|
||||||
|
first, err := ScanZddcFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first call: %v", err)
|
||||||
|
}
|
||||||
|
second, err := ScanZddcFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second call: %v", err)
|
||||||
|
}
|
||||||
|
if &first[0] != &second[0] {
|
||||||
|
t.Errorf("expected cached slice reuse — got fresh allocation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new .zddc file. Without invalidation the cache returns stale data.
|
||||||
|
newDir := filepath.Join(root, "ProjectC")
|
||||||
|
if err := os.MkdirAll(newDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(newDir, ".zddc"), []byte("title: C\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write .zddc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stale, _ := ScanZddcFiles(root)
|
||||||
|
if len(stale) != len(first) {
|
||||||
|
t.Errorf("expected stale cached result before invalidation; got %d entries (first had %d)", len(stale), len(first))
|
||||||
|
}
|
||||||
|
|
||||||
|
InvalidateScanCache()
|
||||||
|
fresh, err := ScanZddcFiles(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post-invalidate: %v", err)
|
||||||
|
}
|
||||||
|
if len(fresh) != len(first)+1 {
|
||||||
|
t.Errorf("expected fresh result with new entry; got %v", fresh)
|
||||||
|
}
|
||||||
|
wantNew := filepath.Join(root, "ProjectC")
|
||||||
|
found := false
|
||||||
|
for _, d := range fresh {
|
||||||
|
if d == wantNew {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("fresh scan missing %q; got %v", wantNew, fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk errors must NOT poison the cache — a transient permissions blip
|
||||||
|
// during the walk should leave the cache empty so the next call retries.
|
||||||
|
func TestScanZddcFiles_DoesNotCacheOnError(t *testing.T) {
|
||||||
|
InvalidateScanCache()
|
||||||
|
bogus := filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
_, err := ScanZddcFiles(bogus)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error walking nonexistent path")
|
||||||
|
}
|
||||||
|
// Cache should not have stored anything.
|
||||||
|
if _, ok := scanCache.Load(filepath.Clean(bogus)); ok {
|
||||||
|
t.Errorf("cache populated despite walk error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,7 @@ func WriteFile(dirPath string, zf ZddcFile) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
InvalidateCache(dirPath)
|
InvalidateCache(dirPath)
|
||||||
|
InvalidateScanCache()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,5 +83,6 @@ func DeleteFile(dirPath string) error {
|
||||||
return fmt.Errorf("remove: %w", err)
|
return fmt.Errorf("remove: %w", err)
|
||||||
}
|
}
|
||||||
InvalidateCache(dirPath)
|
InvalidateCache(dirPath)
|
||||||
|
InvalidateScanCache()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
162
zddc/release.sh
162
zddc/release.sh
|
|
@ -1,146 +1,34 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# release.sh — cut a zddc-server stable release: tag, cross-compile
|
# Deprecated — kept as a guard so muscle-memory invocations don't silently
|
||||||
# binaries, publish them as assets to a Codeberg release.
|
# do nothing.
|
||||||
#
|
#
|
||||||
# Usage:
|
# zddc-server is no longer released independently. The top-level
|
||||||
# sh zddc/release.sh # patch++ from latest stable tag
|
# `sh build.sh --release [version|alpha|beta]` is the canonical lockstep
|
||||||
# sh zddc/release.sh 0.1.0 # explicit version (X.Y.Z)
|
# cut: it bumps every tool (5 HTML + zddc-server) to the same version,
|
||||||
|
# cross-compiles the binaries, copies them into website/releases/ alongside
|
||||||
|
# the HTML tool artifacts, regenerates the matrix index, and tags every
|
||||||
|
# tool. No more Codeberg release-asset uploads — everything serves from
|
||||||
|
# zddc.varasys.io/releases/.
|
||||||
#
|
#
|
||||||
# Why stable-only: zddc-server publishes binaries only on stable cuts.
|
# See AGENTS.md "Releasing — lockstep, channels, layout" for the full
|
||||||
# Beta/alpha channels of zddc-server have no binary distribution — the
|
# release process.
|
||||||
# helm/zddc-server-{prod,dev} charts in this repo build from source at
|
|
||||||
# deploy time, so any commit on main is buildable. There's no
|
|
||||||
# cascade/symlink layer for binaries; if you need a specific build,
|
|
||||||
# pin the chart's commit ref.
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - Go 1.24+ on PATH.
|
|
||||||
# - $CODEBERG_TOKEN exported, scoped to write the VARASYS/ZDDC repo.
|
|
||||||
# - curl, jq, git.
|
|
||||||
#
|
|
||||||
# What it does:
|
|
||||||
# 1. Derive version: explicit arg, or patch-bumped from latest clean
|
|
||||||
# zddc-server-vX.Y.Z tag.
|
|
||||||
# 2. Tag the current commit zddc-server-v<version>.
|
|
||||||
# 3. Cross-compile binaries (linux/darwin/windows × amd64/arm64) into
|
|
||||||
# zddc/dist/zddc-server-<os>-<arch>[.exe]. Native Go.
|
|
||||||
# 4. Upload each binary as a release asset on Codeberg.
|
|
||||||
# 5. Print the operator's next steps (push the tag).
|
|
||||||
#
|
|
||||||
# The script does NOT push the tag itself — that's a deliberate `git push`
|
|
||||||
# you do after reviewing.
|
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
usage() {
|
cat >&2 <<'EOF'
|
||||||
cat >&2 <<'EOF'
|
zddc/release.sh is deprecated.
|
||||||
usage: release.sh [<version>]
|
|
||||||
|
|
||||||
No args patch-bump from the latest clean zddc-server-vX.Y.Z tag.
|
Use the top-level lockstep release instead:
|
||||||
<version> explicit X.Y.Z (e.g. 0.1.0).
|
|
||||||
|
sh build.sh --release # stable, coordinated next version
|
||||||
|
sh build.sh --release X.Y.Z # stable, explicit version
|
||||||
|
sh build.sh --release alpha # alpha cut for everything
|
||||||
|
sh build.sh --release beta # beta cut for everything
|
||||||
|
|
||||||
|
zddc-server binaries now ship under website/releases/ alongside the HTML
|
||||||
|
tools and serve from zddc.varasys.io/releases/. There's no Codeberg
|
||||||
|
release-asset publication anymore. See AGENTS.md "Releasing — lockstep,
|
||||||
|
channels, layout" for details.
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-}" in
|
exit 1
|
||||||
-h | --help) usage ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
EXPLICIT_VERSION="${1:-}"
|
|
||||||
|
|
||||||
if [ -z "${CODEBERG_TOKEN:-}" ]; then
|
|
||||||
echo "error: CODEBERG_TOKEN must be exported in the environment" >&2
|
|
||||||
echo " (Codeberg user → Settings → Applications → generate a token" >&2
|
|
||||||
echo " with scope sufficient to create releases on VARASYS/ZDDC.)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
|
||||||
TAG_PREFIX="zddc-server-v"
|
|
||||||
REPO="VARASYS/ZDDC"
|
|
||||||
|
|
||||||
# Source build-lib.sh for _validate_semver. It requires root_dir set;
|
|
||||||
# pointing at the repo root works.
|
|
||||||
root_dir="$SCRIPT_DIR"
|
|
||||||
. "$SCRIPT_DIR/shared/build-lib.sh"
|
|
||||||
. "$SCRIPT_DIR/shared/publish-codeberg-release.sh"
|
|
||||||
|
|
||||||
# --- Determine version -----------------------------------------------------
|
|
||||||
if [ -n "$EXPLICIT_VERSION" ]; then
|
|
||||||
_validate_semver "$EXPLICIT_VERSION"
|
|
||||||
VERSION="$EXPLICIT_VERSION"
|
|
||||||
else
|
|
||||||
_latest=$(git -C "$SCRIPT_DIR" tag --list "${TAG_PREFIX}*" 2>/dev/null \
|
|
||||||
| grep -E "^${TAG_PREFIX}[0-9]+\.[0-9]+\.[0-9]+\$" \
|
|
||||||
| sed "s|^${TAG_PREFIX}||" \
|
|
||||||
| sort -V \
|
|
||||||
| tail -1)
|
|
||||||
[ -n "$_latest" ] || _latest="0.0.0"
|
|
||||||
_major="${_latest%%.*}"
|
|
||||||
_rest="${_latest#*.}"
|
|
||||||
_minor="${_rest%%.*}"
|
|
||||||
_patch="${_rest#*.}"
|
|
||||||
VERSION="${_major}.${_minor}.$((_patch + 1))"
|
|
||||||
fi
|
|
||||||
|
|
||||||
GIT_TAG="${TAG_PREFIX}${VERSION}"
|
|
||||||
|
|
||||||
echo "=== zddc-server stable release ==="
|
|
||||||
echo "Version: $VERSION"
|
|
||||||
echo "Git tag: $GIT_TAG"
|
|
||||||
echo
|
|
||||||
|
|
||||||
# --- Tag the commit (idempotent: skip if the tag already points here) -----
|
|
||||||
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$GIT_TAG" >/dev/null; then
|
|
||||||
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$GIT_TAG")
|
|
||||||
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
|
|
||||||
if [ "$_existing" != "$_head" ]; then
|
|
||||||
echo "error: tag $GIT_TAG already exists at $_existing, but HEAD is $_head" >&2
|
|
||||||
echo " refusing to overwrite. Resolve manually." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "(tag $GIT_TAG already at HEAD)"
|
|
||||||
else
|
|
||||||
git -C "$SCRIPT_DIR" tag "$GIT_TAG"
|
|
||||||
echo "tagged $GIT_TAG"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Cross-compile binaries (native Go) ------------------------------------
|
|
||||||
if ! command -v go >/dev/null 2>&1; then
|
|
||||||
echo "error: go not found on PATH" >&2
|
|
||||||
echo " (install Go 1.24+, or run this script from inside a Go" >&2
|
|
||||||
echo " container — there's no podman fallback anymore.)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
DIST="$SCRIPT_DIR/zddc/dist"
|
|
||||||
mkdir -p "$DIST"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== Cross-compiling ==="
|
|
||||||
cd "$SCRIPT_DIR/zddc"
|
|
||||||
LDFLAGS="-s -w -X main.version=${VERSION}"
|
|
||||||
for target in linux/amd64 darwin/amd64 darwin/arm64 windows/amd64; do
|
|
||||||
os="${target%/*}"
|
|
||||||
arch="${target#*/}"
|
|
||||||
out="zddc-server-${os}-${arch}"
|
|
||||||
case "$os" in windows) out="${out}.exe" ;; esac
|
|
||||||
echo " building $out"
|
|
||||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
|
||||||
go build -trimpath -ldflags="$LDFLAGS" -o "$DIST/$out" ./cmd/zddc-server
|
|
||||||
done
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
|
|
||||||
# --- Publish to Codeberg ---------------------------------------------------
|
|
||||||
echo
|
|
||||||
echo "=== Publishing to Codeberg release $GIT_TAG ==="
|
|
||||||
publish_codeberg_release "$REPO" "$GIT_TAG" \
|
|
||||||
"$DIST/zddc-server-linux-amd64" \
|
|
||||||
"$DIST/zddc-server-darwin-amd64" \
|
|
||||||
"$DIST/zddc-server-darwin-arm64" \
|
|
||||||
"$DIST/zddc-server-windows-amd64.exe"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== Done ==="
|
|
||||||
echo "Release: https://codeberg.org/$REPO/releases/tag/$GIT_TAG"
|
|
||||||
echo "Git tag: $GIT_TAG (publish with: git push origin $GIT_TAG)"
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue