Compare commits
10 commits
adb6904397
...
7570fb7494
| Author | SHA1 | Date | |
|---|---|---|---|
| 7570fb7494 | |||
| 76e1e78c55 | |||
| 6167e99f3a | |||
| 76820fa8dd | |||
| d688e20dad | |||
| 8c2e65e4a2 | |||
| 6cc0d2ae27 | |||
| 17b0a4dff9 | |||
| 9fce18cd45 | |||
| 4ede42010a |
87 changed files with 4774 additions and 63729 deletions
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -3,6 +3,9 @@ examples/
|
|||
.env
|
||||
.vscode
|
||||
|
||||
# Per-project Claude Code state (planning files, agent transcripts, etc.)
|
||||
.claude/
|
||||
|
||||
# Session planning (never public)
|
||||
PLAN.md
|
||||
|
||||
|
|
@ -13,23 +16,19 @@ node_modules/
|
|||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Build artifacts
|
||||
# NOTE: dist/ is listed here but each tool's dist/*.html is force-tracked in git
|
||||
# (added with `git add -f tool/dist/tool.html`). This is intentional — built artifacts
|
||||
# are committed alongside source so users can download them directly from the repo.
|
||||
# New tool dist files must be force-added: git add -f tool/dist/tool.html
|
||||
dist/
|
||||
|
||||
# Release artifacts under website/releases/ ARE committed — per-version HTML
|
||||
# tool files (<tool>_v<X.Y.Z>.html) accumulate as immutable real files; partial
|
||||
# version pins (<tool>_v<X.Y>.html, <tool>_v<X>.html) and channels
|
||||
# (<tool>_<channel>.html) are checked-in symlinks. The build script
|
||||
# (shared/build-lib.sh promote_release) maintains the symlink chain on each
|
||||
# release. Caddy serves these as plain static files; no manifest, no proxy.
|
||||
# Build artifacts. dist/ is ignored everywhere: per-tool dist/<tool>.html
|
||||
# is a transient build output (and the canonical thing tests open via
|
||||
# file://), and dist/release-output/ is the local-only release bundle
|
||||
# produced by `./build alpha|beta|release`, then rsync'd to the live
|
||||
# site by `./deploy`. Nothing in dist/ should be committed.
|
||||
#
|
||||
# zddc-server binaries are NOT committed — they're per-platform multi-MB
|
||||
# binaries that ship as Codeberg release assets, attached to clean
|
||||
# zddc-server-vX.Y.Z tags by zddc/release.sh.
|
||||
# Hand-edited website content (index.html, reference.html, css/, js/,
|
||||
# img/) lives in a SEPARATE Codeberg repo at codeberg.org/VARASYS/
|
||||
# ZDDC-website, typically cloned at ~/src/zddc-website/. Release
|
||||
# artifacts are NOT in git history at all — they're produced by this
|
||||
# repo's build, rsync'd to /srv/zddc/releases/ on the deploy host,
|
||||
# and reproducible from any <tool>-vX.Y.Z tag.
|
||||
dist/
|
||||
|
||||
# IDE and project files
|
||||
.opencode/
|
||||
|
|
|
|||
171
AGENTS.md
171
AGENTS.md
|
|
@ -3,22 +3,36 @@
|
|||
## Commands
|
||||
|
||||
```bash
|
||||
# Build all tools (writes to dist/ only; also regenerates website/releases/index.html)
|
||||
sh build.sh
|
||||
# ── ./build subcommands ────────────────────────────────────────────────────
|
||||
# `./build` (no arg) is a source-side dev build only — assembles tool/dist/
|
||||
# + cross-compiles zddc-server. dist/release-output/ and the live site are
|
||||
# left alone. Channel + release subcommands produce a complete release
|
||||
# bundle in dist/release-output/ (gitignored). Run `./deploy` to publish.
|
||||
# Workflow: alpha = active dev → beta = ready for testing → release = ship.
|
||||
|
||||
# Build single tool
|
||||
sh tool/build.sh # archive | transmittal | classifier | mdedit | landing
|
||||
./build # dev build (no release bundle)
|
||||
./build alpha # cut alpha (cascades nothing)
|
||||
./build beta # cut beta (cascades alpha → beta)
|
||||
./build release # cut stable, coordinated next version
|
||||
# (cascades alpha + beta → new stable; tags all six)
|
||||
./build release 1.2.0 # cut stable at explicit version
|
||||
./build help
|
||||
|
||||
# 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>)
|
||||
sh tool/build.sh --release
|
||||
sh tool/build.sh --release 1.2.0 # explicit version
|
||||
# ── ./deploy subcommands ────────────────────────────────────────────────────
|
||||
# rsync the build output and content repo to /srv/zddc/ (Caddy's bind-mount).
|
||||
# --delete-after — the live tree exactly mirrors source.
|
||||
|
||||
# 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.)
|
||||
sh tool/build.sh --release alpha
|
||||
sh tool/build.sh --release beta
|
||||
./deploy # full sync (content + releases)
|
||||
./deploy --content # only ~/src/zddc-website/ → /srv/zddc/
|
||||
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
|
||||
|
||||
# Release all tools at once
|
||||
sh build.sh --release [version|alpha|beta]
|
||||
# Single-tool dev build for testing (does NOT touch dist/release-output/):
|
||||
sh tool/build.sh # archive|transmittal|classifier|mdedit|landing
|
||||
|
||||
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
|
||||
# don't drift between tools). Same flag form as before.
|
||||
sh tool/build.sh --release [<version>|alpha|beta]
|
||||
./freshen-channel <tool> <channel> # rebuild one tool's alpha/beta from its current stable tag
|
||||
|
||||
# Test all tools
|
||||
npm test
|
||||
|
|
@ -33,6 +47,17 @@ npx playwright test tool # archive | transmittal | classifier | mdedit
|
|||
|
||||
No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS.
|
||||
|
||||
Channel/release cuts seed `dist/release-output/` from the current
|
||||
`/srv/zddc/releases/` (preserving symlinks) before running per-tool
|
||||
promote, then mutate the channels being cut on top. The bundle is
|
||||
therefore always a complete intended-live snapshot, not a sparse diff.
|
||||
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 —
|
||||
because the bundle is complete, dangling-link errors mean a real bug.
|
||||
**Nothing is pushed automatically.** Run `./deploy` to publish; commit
|
||||
+ push source changes to `main` separately.
|
||||
|
||||
## 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.
|
||||
|
|
@ -54,16 +79,26 @@ shared/
|
|||
build-lib.sh POSIX sh helpers (ensure_exists, concat_files, build_timestamp)
|
||||
sourced by every tool's build.sh via: . "$root_dir/../shared/build-lib.sh"
|
||||
|
||||
website/
|
||||
index.html hand-edited intro page + install snippets (root URL)
|
||||
releases/
|
||||
index.html versions index, regenerated by build.sh from filesystem scan
|
||||
<tool>_v<X.Y.Z>.html real per-version files (committed, immutable)
|
||||
<tool>_v<X.Y>.html -> ... symlink: latest patch within X.Y.*
|
||||
<tool>_v<X>.html -> ... symlink: latest within X.*.*
|
||||
<tool>_stable.html -> ... symlink: current stable
|
||||
<tool>_beta.html -> ... symlink to stable (or real bytes if active beta)
|
||||
<tool>_alpha.html -> ... symlink to beta/stable (or real bytes if active alpha)
|
||||
# Hand-edited website content lives in a SEPARATE Codeberg repo
|
||||
# (codeberg.org/VARASYS/ZDDC-website), typically cloned at
|
||||
# ~/src/zddc-website/. Just content — no releases, no LFS:
|
||||
# index.html, reference.html, css/, js/, img/ hand-edited content
|
||||
# README.md, LICENSE repo housekeeping
|
||||
#
|
||||
# This repo's ./build produces a release bundle in dist/release-output/
|
||||
# (gitignored, local-only). ./deploy rsyncs both into /srv/zddc/ on
|
||||
# the deploy host (Caddy's bind-mount):
|
||||
# /srv/zddc/
|
||||
# index.html, reference.html, css/, js/, img/ ← from ~/src/zddc-website
|
||||
# releases/
|
||||
# index.html regenerated by `./build`
|
||||
# <tool>_v<X.Y.Z>.html per-version (immutable)
|
||||
# <tool>_v<X.Y>.html -> ... symlink chain
|
||||
# <tool>_stable.html -> ... channel mirror, follows latest stable
|
||||
# <tool>_{beta,alpha}.html -> ... channels (cascade to stable when idle)
|
||||
# zddc-server_v<X.Y.Z>_<platform> per-platform binary (raw bytes, no LFS)
|
||||
# zddc-server_<channel>_<platform> channel binary mirror (symlink)
|
||||
# zddc-server_<X>.html stub page surfacing 4 platform DLs
|
||||
|
||||
helm/
|
||||
zddc-server-prod/ production-shaped Helm chart (compiles from source via init container)
|
||||
|
|
@ -71,9 +106,9 @@ helm/
|
|||
README.md chart design rationale + quick-start
|
||||
```
|
||||
|
||||
**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. `tool/dist/<tool>.html` is the canonical built artifact for testing and the source for `--release` writes into `dist/release-output/`. `dist/release-output/` is the local-only release bundle. Neither is in git. 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`.
|
||||
**Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`); hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/`). The live site at `zddc.varasys.io` is `/srv/zddc/` on the deploy host (Caddy bind-mount), populated by `./deploy`. Release artifacts are NOT in git — they're produced by `./build alpha|beta|release` into `dist/release-output/` and rsync'd to `/srv/zddc/releases/` by `./deploy --releases`. Per-version files (HTML and zddc-server binaries) are real immutable bytes; partial-version pins (`_v<X.Y>`, `_v<X>`) and channel mirrors (`_stable`, `_beta`, `_alpha`) are symlinks. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `./build` seeds from live state, then calls them in lockstep. Older releases are reproducible from any `<tool>-vX.Y.Z` tag in this repo (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS.
|
||||
|
||||
## Shared CSS (`shared/base.css`)
|
||||
|
||||
|
|
@ -157,46 +192,52 @@ Format: `trackingNumber_revision (status) - title.extension`
|
|||
|
||||
- Feature-branch workflow; squash-merge feature branches to `main`
|
||||
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
||||
- Release tags: `archive-v1.0.0` (per-tool semver)
|
||||
- Commit dist files: `git add -f tool/dist/tool.html`
|
||||
- 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`)
|
||||
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
|
||||
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
||||
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
|
||||
|
||||
### Releasing — 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 on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build alpha|beta|release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
|
||||
|
||||
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.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (so cascades and the verifier see a complete world), forwards each HTML tool's build with the agreed version, then `promote_zddc_server` (in `shared/build-lib.sh`) copies the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain, then `write_zddc_server_stubs_all` regenerates every stub page, then `build_releases_index` rewrites the index, then `verify_channel_links` asserts nothing dangles. `./deploy --releases` then publishes the bundle.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **Alpha** (`sh tool/build.sh --release alpha`): Overwrites `<tool>_alpha.html` with the dist HTML bytes. 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`.
|
||||
- **Stable** (`./build 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** (`./build 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** (`./build`): Overwrites only the alpha mirrors, all six tools. No tag, no other side-effects.
|
||||
- **Plain dev builds** (`./build` with no arg): produce `tool/dist/<tool>.html` for HTML tools and `zddc/dist/zddc-server-<platform>` binaries; do NOT touch `dist/release-output/` or the live site. Use it to iterate without affecting deployable state.
|
||||
|
||||
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 beta`: `vX.Y.Z-beta · <date> · <sha>` (red).
|
||||
- `--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.
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
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.
|
||||
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).
|
||||
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.
|
||||
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If `v0.0.5` ships with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published per-version file in place. Stable per-version files are immutable.
|
||||
2. **Lockstep is the contract.** Don't cut a single tool's release without bumping the rest. The HTML tool's standalone `--release` flag still exists as an escape hatch but emits a tag that immediately drifts out of sync with the others.
|
||||
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 reproducibility, pin to a per-version URL — `<tool>_v0.0.5.html` or `zddc-server_v0.0.5.html`.
|
||||
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`.
|
||||
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.
|
||||
|
||||
|
|
@ -248,7 +289,7 @@ Use `git worktree` to run multiple agents on separate branches simultaneously wi
|
|||
|
||||
- Worktrees live at `~/src/zddc-<branch-name>` (sibling of the main clone)
|
||||
- Before starting work on a feature branch, check `git worktree list`; if no worktree exists, create one: `git worktree add ~/src/zddc-<branch-name> -b <branch-name>`
|
||||
- All edits, builds (`sh build.sh`), and tests (`npm test`) run from within the worktree directory — build scripts use relative paths so this works correctly
|
||||
- All edits, builds (`./build`), and tests (`npm test`) run from within the worktree directory — build scripts use relative paths so this works correctly
|
||||
- The `dist/` force-commit rule (`git add -f`) applies per-worktree
|
||||
- After the branch is merged, clean up: `git worktree remove ~/src/zddc-<branch-name>` then delete the branch
|
||||
- Never run `git checkout` or `git switch` inside a worktree that another agent may be using
|
||||
|
|
@ -272,7 +313,7 @@ Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --brow
|
|||
|
||||
### 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
|
||||
# Compile a local binary for the host platform (requires Go 1.24+)
|
||||
|
|
@ -282,7 +323,7 @@ zddc-server ships as a cross-compiled binary, not a container image. There's no
|
|||
(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 `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build alpha|beta|release` it also promotes those binaries to `dist/release-output/` with the matching symlink chain and stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
|
||||
|
||||
### Run (development)
|
||||
|
||||
|
|
@ -291,11 +332,13 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|||
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
|
||||
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/dist/zddc-server-linux-amd64
|
||||
./zddc-server_stable_linux-amd64
|
||||
```
|
||||
|
||||
### Key environment variables
|
||||
|
|
@ -311,22 +354,21 @@ ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
|
|||
|
||||
### 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. The top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set of symlinks per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable) tags `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags.
|
||||
|
||||
```sh
|
||||
sh zddc/release.sh # patch-bump from latest clean stable tag
|
||||
sh zddc/release.sh 0.1.0 # explicit version
|
||||
./build release # lockstep stable, coordinated next version
|
||||
./build release 1.2.0 # lockstep stable, explicit version
|
||||
./build alpha # lockstep alpha cut for everything
|
||||
./build beta # lockstep beta cut for everything
|
||||
./deploy --releases # publish the bundle to /srv/zddc/releases/
|
||||
```
|
||||
|
||||
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` (and run `./deploy` to put the artifacts on the live site).
|
||||
|
||||
**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.
|
||||
|
||||
Prerequisites:
|
||||
- Go 1.24+ on PATH (or run from a Go container).
|
||||
- `$CODEBERG_TOKEN` exported, scoped to write the VARASYS/ZDDC repo.
|
||||
**Binary distribution** — `/srv/zddc/releases/zddc-server_<X>_<platform>` (on the deploy host) are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror, no LFS. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.
|
||||
|
||||
There is no CI for this — solo workflow benefits from one canonical
|
||||
local path that fails loudly and visibly on the developer's terminal.
|
||||
|
|
@ -335,7 +377,8 @@ 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+)
|
||||
- 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`)
|
||||
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
|
||||
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by the dev-shell pod's Caddy to gate `/devshell/*` (code-server) on root-admin status without code-server learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
|
||||
- **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,26 +33,42 @@ tool/
|
|||
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) live on a **separate Codeberg repo** (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/`) for hand-edited content, plus the **deploy host's `/srv/zddc/`** for the assembled live site. The system Caddy bind-mounts `/srv/zddc/`. `./deploy` rsyncs both into it.
|
||||
|
||||
```
|
||||
website/
|
||||
~/src/zddc-website/ (clone of codeberg.org/VARASYS/ZDDC-website)
|
||||
index.html # hand-edited intro page + install snippets (root URL)
|
||||
releases/
|
||||
index.html # versions index, regenerated by build.sh from filesystem scan
|
||||
<tool>_v<X.Y.Z>.html # real per-version files (committed, immutable)
|
||||
reference.html # hand-edited file-naming convention spec
|
||||
css/, js/, img/ # hand-edited static assets
|
||||
README.md, LICENSE # repo housekeeping
|
||||
# NO releases/ — release artifacts are NOT in any git history.
|
||||
|
||||
~/src/zddc/dist/release-output/ (gitignored, produced by ./build alpha|beta|release)
|
||||
index.html # download page, regenerated by build
|
||||
<tool>_v<X.Y.Z>.html # real per-version HTML (immutable)
|
||||
<tool>_v<X.Y>.html → ... # symlink: latest patch within X.Y.*
|
||||
<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>_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 (raw bytes, no LFS)
|
||||
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: cell link → fans out 4 platform downloads
|
||||
|
||||
/srv/zddc/ (deploy host; Caddy bind-mount)
|
||||
index.html, reference.html, css/, js/, img/ ← rsync'd from ~/src/zddc-website/
|
||||
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
**zddc-server binaries are reproducible from a tag, not in git** — `./build alpha|beta|release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64`. The single cell link per release points at `zddc-server_<X>.html`, a small generated stub that surfaces all four platform downloads.
|
||||
|
||||
To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release with `./build alpha|beta|release` and then `./deploy`.
|
||||
|
||||
Vendor dependencies (bundled third-party libraries) live in `tool/vendor/` if present. The build script is responsible for inlining them into the output.
|
||||
|
||||
|
|
@ -64,16 +80,16 @@ Each topic has exactly one authoritative home; everything else links to it.
|
|||
|
||||
| Topic | Single home | Linked from |
|
||||
|---|---|---|
|
||||
| What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets | `website/index.html` (hand-edited intro for `zddc.varasys.io/`) | repo `README.md`, `zddc/README.md` |
|
||||
| File-naming convention spec (status codes, modifiers, folder format) | `website/reference.html` | repo `README.md`, in-tool help text |
|
||||
| Versions + channel builds index of every tool | `website/releases/index.html` (regenerated by `build.sh`) | website intro nav, "Browse all versions" link |
|
||||
| What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets | `~/src/zddc-website/index.html` (hand-edited intro for `zddc.varasys.io/`, in the `ZDDC-website` repo) | repo `README.md`, `zddc/README.md` |
|
||||
| File-naming convention spec (status codes, modifiers, folder format) | `~/src/zddc-website/reference.html` | repo `README.md`, in-tool help text |
|
||||
| Versions + channel builds index of every tool | `dist/release-output/index.html` (regenerated by `./build`; deployed to `/srv/zddc/releases/index.html`) | website intro nav, "Browse all versions" link |
|
||||
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; `.zddc apps:` cascade overrides; cache at `<root>/_app/`) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
|
||||
| zddc-server operations: env vars, ACL syntax, `.archive` URLs, container vs binary | `zddc/README.md` | `AGENTS.md`, website intro |
|
||||
| Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
|
||||
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
|
||||
| Per-tool internal design quirks | `<tool>/README.md` | (linked from website intro tool cards) |
|
||||
|
||||
`website/index.html` is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all five tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `website/releases/landing_v<X.Y.Z>.html`; the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
|
||||
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all five tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
|
||||
|
||||
When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to `reference.html` instead.
|
||||
|
||||
|
|
@ -83,27 +99,37 @@ When updating documentation, prefer linking over duplicating. If you find yourse
|
|||
|
||||
### How It Works
|
||||
|
||||
Each tool's `build.sh`:
|
||||
Each HTML tool's `build.sh`:
|
||||
|
||||
1. Reads CSS files in declaration order, concatenates them
|
||||
2. Reads JS files in declaration order, concatenates them
|
||||
3. Processes `template.html` with `awk`, replacing `{{PLACEHOLDER}}` markers with the concatenated content and stripping CDN `<script>`/`<link>` tags
|
||||
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 `dist/release-output/` (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` at the repository root is the canonical lockstep entry point. It:
|
||||
|
||||
1. On a channel/release cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** (preserving symlinks) so the bundle is a complete intended-live snapshot, not a sparse one-channel diff. Cascades and the verifier downstream see the same world the live site has.
|
||||
2. Forwards `--release [version|alpha|beta]` to every HTML tool's build, computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given.
|
||||
3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
|
||||
4. On a channel/release cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with the matching symlink chain (one set per platform) and tag `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags (stable cuts only).
|
||||
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + per-channel stub HTML pages from whatever artifacts are in `dist/release-output/`.
|
||||
6. Regenerates `dist/release-output/index.html` as the action-first download page.
|
||||
7. Calls `verify_channel_links` — fails the build if any channel link is dangling.
|
||||
|
||||
Then `./deploy --releases` rsyncs `dist/release-output/` → `/srv/zddc/releases/` with `--delete-after`.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **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>`.
|
||||
- **Stable** — versioned, immutable. `./build 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** — `./build beta` overwrites `<tool>_beta.html` for each HTML tool and `zddc-server_beta_<platform>` for each platform with fresh bytes. Cascades alpha → beta for both HTML and binaries (one symlink per platform). No tag — channel URLs are stable URLs by design.
|
||||
- **Alpha** — `./build` overwrites only the alpha mirrors, all 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 `./build` (no arg) is a dev build: it produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/` or the live site. The download index, stub pages, and verifier only run when a channel/release is being cut.
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -151,7 +177,7 @@ Independent of how the tool got installed. `archive` auto-detects from the URL a
|
|||
Every `build.sh` must:
|
||||
|
||||
- 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)
|
||||
- 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
|
||||
|
|
|
|||
55
CLAUDE.md
55
CLAUDE.md
|
|
@ -16,38 +16,61 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
|
|||
This is a **monorepo of independent tools**, not one application:
|
||||
|
||||
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/` — 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.
|
||||
- `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).
|
||||
- `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.
|
||||
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Serves `ZDDC_ROOT/index.html` at `GET /` as the landing page; `Accept: application/json` on `/` returns the ACL-filtered project list. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
|
||||
- `shared/` — `base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all 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.
|
||||
- `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
|
||||
|
||||
```bash
|
||||
sh build.sh # build all five HTML tools (dist/ only) + regen website/releases/index.html
|
||||
sh tool/build.sh # build one (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)
|
||||
./freshen-channel <tool> <channel> # rebuild alpha/beta from current stable tag (run after every stable release if you want to advance the channel mirror)
|
||||
# Source-side dev build only — assembles tool/dist/ + cross-compiles
|
||||
# zddc-server. Does NOT touch dist/release-output/ or the live site.
|
||||
./build
|
||||
|
||||
# Channel/release cuts — produce a complete release bundle in
|
||||
# dist/release-output/ (gitignored). Cuts seed from the live site
|
||||
# (/srv/zddc/releases/) so the bundle is a complete intended-live
|
||||
# snapshot, not a sparse diff. Run ./deploy to publish.
|
||||
./build alpha # cut alpha (cascades nothing)
|
||||
./build beta # cut beta (cascades alpha → beta)
|
||||
./build release # cut stable, coordinated next version
|
||||
# (cascades alpha + beta → new stable; tags all six tools)
|
||||
./build release X.Y.Z # cut stable at explicit version
|
||||
./build help # usage
|
||||
|
||||
# Deploy — atomic-ish rsync of the build output + content repo to
|
||||
# /srv/zddc/, where Caddy serves it. The build does NOT auto-deploy.
|
||||
./deploy # full sync: content + releases
|
||||
./deploy --content # only ~/src/zddc-website/ → /srv/zddc/
|
||||
./deploy --releases # only dist/release-output/ → /srv/zddc/releases/
|
||||
|
||||
sh tool/build.sh # iterate on one HTML tool's dist/
|
||||
sh tool/build.sh --release [...] # single-tool release (rare; prefer the lockstep ./build)
|
||||
./freshen-channel <tool> <channel> # rebuild one tool's alpha/beta from its current stable tag
|
||||
|
||||
npm test # all Playwright specs (build first!)
|
||||
npx playwright test <tool> # one spec
|
||||
./dev-server start # ./dev-server stop # cache-busting HTTP on :8000
|
||||
./dev-server start # 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+)
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- **`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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **`dist/` is gitignored.** `tool/dist/<tool>.html` is the canonical built artifact for testing and as the source for `--release` writes. `dist/release-output/` is the local-only release bundle written by `./build alpha|beta|release`. Never hand-edit a `dist/` file.
|
||||
- **Build vs deploy are separate verbs.** `./build` and `./build alpha|beta|release` produce artifacts under `dist/release-output/`. Nothing escapes the source tree until the operator runs `./deploy`, which rsyncs into `/srv/zddc/` (Caddy's bind-mount). This decouples local iteration from live state.
|
||||
- **Channel/release cuts seed from live state.** Before running per-tool promote, `./build alpha|beta|release` clears `dist/release-output/` and copies `/srv/zddc/releases/` into it (preserving symlinks). The cut then mutates the channels being cut on top. Result: `dist/release-output/` is always a complete intended-live snapshot, the verifier sees a complete world, and `./deploy --releases` (rsync `--delete-after`) replaces live state cleanly.
|
||||
- **Lockstep releases.** Every release cut bumps all 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 — `./build release` is the canonical path. Workflow: alpha = active dev, beta = ready for general testing, stable = ready to ship.
|
||||
- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`). HTML tools: per-version `<tool>_v<X.Y.Z>.html` (real immutable files) + partial-version pins (`<tool>_v<X.Y>.html`, `_v<X>.html`) + channel mirrors (`<tool>_{stable,beta,alpha}.html`) — all symlinks except per-version. zddc-server: `zddc-server_v<X.Y.Z>_<platform>` per-version binaries (raw bytes, no LFS), `_v<X.Y>_<platform>` / `_v<X>_<platform>` / `_<channel>_<platform>` symlinks, plus `zddc-server_<X>.html` stub pages that surface the four platform downloads in one matrix-cell link. Same cascade rule for both: stable cut → beta + alpha both reset to stable; beta cut → alpha cascades to beta.
|
||||
- **No tags for alpha/beta.** Channel URLs are stable URLs by design — appending counter tags would defeat the purpose. The on-page label encodes `<date> · <sha>` for traceability. Stable cuts get clean `<tool>-vX.Y.Z` tags for every tool (six tags per cut, all sharing the same X.Y.Z).
|
||||
- **Pre-release semver in the on-page label.** Plain dev builds and `--release alpha|beta` cuts embed `vX.Y.Z-{alpha,beta}` in `{{BUILD_LABEL}}` where X.Y.Z is the next-stable target. Plain dev adds a full timestamp + `-dirty` marker; `--release alpha|beta` is date-only.
|
||||
- **Channel-link verifier.** Every `./build alpha|beta|release` ends with a check that every `<tool>_{stable,beta,alpha}.html` (and zddc-server's per-platform binary mirrors + stub pages) resolves. Because cuts seed from live state, the verifier always sees a complete world; missing-link errors mean a real problem, not a sparse-bundle artifact.
|
||||
- **`./build` (no arg) is a source-side dev build.** Assembles `tool/dist/` + cross-compiled binaries; does NOT touch `dist/release-output/` or the live site. Use it to iterate without affecting anything. To produce a deployable bundle, run `./build alpha|beta|release`. To publish, run `./deploy`. Nothing is pushed to Codeberg automatically.
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
|
|||
| **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. |
|
||||
| **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
|
||||
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files committed under `website/releases/`, browsable at <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `<ZDDC_ROOT>/_app/`; drop a real `.html` file at any path to override entirely.
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `<ZDDC_ROOT>/_app/`; drop a real `.html` file at any path to override entirely.
|
||||
|
||||
## File-naming convention
|
||||
|
||||
|
|
|
|||
|
|
@ -504,27 +504,21 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Returns true if any path segment of the transmittal folder matches a selected
|
||||
// party name AND the segment immediately after it (if it's a folder-type name)
|
||||
// is in enabledFolderTypes. Segment-equality matching means a party "BM" selected
|
||||
// matches every "<...>/BM/<...>" path regardless of the prefix.
|
||||
// Returns true if the transmittal folder's path satisfies all three cascade
|
||||
// layers: project visibility, party selection (any path segment matches a
|
||||
// selected party name), and folder-type enablement (no segment is a
|
||||
// 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) {
|
||||
if (!pathIsInVisibleProject(folder.path)) return false;
|
||||
const parts = folder.path.split('/');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (!window.app.selectedGroupingFolders.has(parts[i])) continue;
|
||||
// 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;
|
||||
if (isUnderHiddenFolderType(folder.path)) return false;
|
||||
return folder.path.split('/').some(seg =>
|
||||
window.app.selectedGroupingFolders.has(seg)
|
||||
);
|
||||
}
|
||||
|
||||
// Render transmittal folders (rebuilds DOM)
|
||||
|
|
|
|||
705
build
Executable file
705
build
Executable file
|
|
@ -0,0 +1,705 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# build — ZDDC source build + lockstep release driver.
|
||||
#
|
||||
# ./build dev build: assemble tool dist/, cross-compile
|
||||
# zddc-server binaries. Nothing else is touched
|
||||
# — no release artifacts produced, no deploy.
|
||||
# ./build alpha cut alpha: produce a complete release bundle
|
||||
# in dist/release-output/ (cascades nothing).
|
||||
# ./build beta cut beta (cascades alpha → beta).
|
||||
# ./build release cut coordinated stable (cascades alpha + beta
|
||||
# → new stable; tags all six tools).
|
||||
# ./build release X.Y.Z same, explicit version.
|
||||
# ./build help this message.
|
||||
#
|
||||
# Lockstep: every channel/release cut bumps all six tools (5 HTML +
|
||||
# zddc-server) together. Coordinated next-stable = max(latest tag) + 1.
|
||||
#
|
||||
# Channel/release cuts write a complete intended-live snapshot to
|
||||
# ${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}. The build
|
||||
# does NOT touch the live site — run `./deploy` (or `./deploy --releases`)
|
||||
# to rsync the snapshot into /srv/zddc/. The snapshot is built by seeding
|
||||
# from the current live state (so cascades and the verifier see a
|
||||
# complete world), then mutating the channel(s) being cut on top.
|
||||
|
||||
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 subcommand ------------------------------------------------------
|
||||
# RELEASE_CHANNEL empty means dev mode (build only, no website worktree
|
||||
# writes); set means a channel/release cut that promotes to the website
|
||||
# worktree under $ZDDC_DEPLOY_RELEASES_DIR.
|
||||
RELEASE_CHANNEL=""
|
||||
RELEASE_VERSION=""
|
||||
|
||||
case "${1:-dev}" in
|
||||
dev|build)
|
||||
# Dev build: tool dist/ + zddc-server binaries only. Touches
|
||||
# nothing in the website worktree.
|
||||
;;
|
||||
alpha)
|
||||
RELEASE_CHANNEL="alpha"
|
||||
;;
|
||||
beta)
|
||||
RELEASE_CHANNEL="beta"
|
||||
;;
|
||||
release)
|
||||
RELEASE_CHANNEL="stable"
|
||||
if [ -n "${2:-}" ]; then
|
||||
_validate_semver "$2"
|
||||
RELEASE_VERSION="$2"
|
||||
echo "=== Lockstep stable release — explicit version: v$RELEASE_VERSION ==="
|
||||
else
|
||||
RELEASE_VERSION=$(_coordinated_next_stable)
|
||||
echo "=== Lockstep stable release — coordinated version: v$RELEASE_VERSION ==="
|
||||
fi
|
||||
;;
|
||||
help | -h | --help)
|
||||
sed -n '4,22p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "build: unknown subcommand '$1'. Try './build help'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Per-tool argument list. Pass --release flag only when we're cutting
|
||||
# a channel/release; dev builds invoke each tool with no args.
|
||||
if [ -z "$RELEASE_CHANNEL" ]; then
|
||||
TOOL_RELEASE_ARGS=""
|
||||
elif [ "$RELEASE_CHANNEL" = "stable" ]; then
|
||||
TOOL_RELEASE_ARGS="--release $RELEASE_VERSION"
|
||||
else
|
||||
TOOL_RELEASE_ARGS="--release $RELEASE_CHANNEL"
|
||||
fi
|
||||
|
||||
# Local-only build output. The release pipeline writes here; nothing
|
||||
# escapes the source tree until the operator runs `./deploy`. Default
|
||||
# is $SCRIPT_DIR/dist/release-output; override with
|
||||
# $ZDDC_DEPLOY_RELEASES_DIR. Exported so child per-tool build.sh
|
||||
# invocations see the same path.
|
||||
export ZDDC_DEPLOY_RELEASES_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}"
|
||||
RELEASES_DIR="$ZDDC_DEPLOY_RELEASES_DIR"
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
|
||||
# When cutting a channel/release, seed RELEASES_DIR from the current live
|
||||
# site so the resulting bundle is a complete intended-live snapshot, not
|
||||
# a sparse one-channel diff. Two reasons:
|
||||
# 1. Per-tool promote_release does cascade writes (beta cut → also
|
||||
# rewrites alpha to track beta; stable cut → resets alpha + beta).
|
||||
# The cascade itself is deterministic, but downstream artifacts that
|
||||
# were NOT touched by this cut (e.g. older versioned files, the
|
||||
# other channel mirrors, partial-version symlinks) still need to be
|
||||
# present in the bundle so `./deploy --releases` (rsync
|
||||
# --delete-after) doesn't wipe them off the live site.
|
||||
# 2. verify_channel_links cross-checks the full release tree; it
|
||||
# flags absent channels as missing. With seeding, a fresh
|
||||
# `dist/release-output/` matches live state, the cut mutates on
|
||||
# top, and the verifier sees a complete world.
|
||||
# Bootstrap case (no live site yet, or live releases dir empty) is
|
||||
# silently skipped — the very first stable cut populates everything.
|
||||
if [ -n "$RELEASE_CHANNEL" ]; then
|
||||
LIVE_RELEASES="${ZDDC_LIVE_DIR:-/srv/zddc}/releases"
|
||||
if [ -d "$LIVE_RELEASES" ] && [ -n "$(ls -A "$LIVE_RELEASES" 2>/dev/null)" ]; then
|
||||
echo "=== Seeding $RELEASES_DIR from $LIVE_RELEASES ==="
|
||||
rm -rf "$RELEASES_DIR"
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
# cp -a preserves the symlink graph (channel mirrors +
|
||||
# _v<X.Y> / _v<X> partial-version pins) so cascade decisions
|
||||
# downstream see the same world the live site has.
|
||||
cp -a "$LIVE_RELEASES/." "$RELEASES_DIR/"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Building ZDDC tools ==="
|
||||
|
||||
# Each tool's compute_build_label writes a sidecar `<tool>.label` here so
|
||||
# we can assemble zddc/internal/apps/embedded/versions.txt below.
|
||||
BUILD_LABELS_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded/.labels"
|
||||
rm -rf "$BUILD_LABELS_DIR"
|
||||
mkdir -p "$BUILD_LABELS_DIR"
|
||||
export BUILD_LABELS_DIR
|
||||
|
||||
# shellcheck disable=SC2086 # intentional word-splitting on TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/archive/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
|
||||
|
||||
echo ""
|
||||
echo "=== Assembling zddc/dist/web/ ==="
|
||||
# All five tool HTMLs ship inside the server bundle. landing and archive call
|
||||
# server APIs (GET / for the project list, directory listings for archive) and
|
||||
# are useless without zddc-server. transmittal, classifier, and mdedit are
|
||||
# pure client-side tools but are still bundled — the server uses these copies
|
||||
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
|
||||
# the cache is empty AND the upstream is unreachable.
|
||||
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
|
||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
|
||||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
|
||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
|
||||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
|
||||
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit}.html"
|
||||
|
||||
# Mirror the same five HTMLs into the Go embed source dir so the next
|
||||
# `go build` of zddc-server picks them up via //go:embed. Files are checked
|
||||
# into git as empty placeholders; the build always overwrites them with the
|
||||
# fresh dist/ output.
|
||||
EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded"
|
||||
mkdir -p "$EMBED_DIR"
|
||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$EMBED_DIR/index.html"
|
||||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$EMBED_DIR/archive.html"
|
||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html"
|
||||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||
|
||||
# Assemble the embedded versions manifest from the per-tool .label sidecars
|
||||
# written by shared/build-lib.sh's compute_build_label. The Go side reads
|
||||
# this via //go:embed in internal/apps/versions.go and surfaces it in
|
||||
# `zddc-server --version` output and the startup log line.
|
||||
VERSIONS_FILE="$EMBED_DIR/versions.txt"
|
||||
{
|
||||
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
|
||||
for _tool in archive transmittal classifier mdedit landing; do
|
||||
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
|
||||
if [ -f "$_label_file" ]; then
|
||||
_label=$(cat "$_label_file")
|
||||
else
|
||||
_label=""
|
||||
fi
|
||||
printf '%s=%s\n' "$_tool" "$_label"
|
||||
done
|
||||
} > "$VERSIONS_FILE"
|
||||
echo "Wrote $VERSIONS_FILE"
|
||||
rm -rf "$BUILD_LABELS_DIR"
|
||||
|
||||
# Cross-compiled zddc-server binaries for Linux/macOS/Windows. Always built
|
||||
# inside docker.io/golang:1.24-alpine via podman (or docker), matching the
|
||||
# helm/zddc-server-prod chart's `buildImage` so dev binaries are byte-for-byte
|
||||
# what production gets. The build container is downloaded on first run.
|
||||
echo ""
|
||||
echo "=== Building zddc-server binaries (containerized) ==="
|
||||
mkdir -p "$SCRIPT_DIR/zddc/dist"
|
||||
|
||||
# Pick a container runtime. Both work; podman is preferred (rootless default).
|
||||
GO_RUNNER=""
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
GO_RUNNER=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
GO_RUNNER=docker
|
||||
else
|
||||
echo "error: neither podman nor docker is available — cannot build zddc-server binaries." >&2
|
||||
echo " Install podman (preferred) or docker. zddc-server build is containerized as policy." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}"
|
||||
|
||||
# Cache the Go module + build cache across runs via named volumes that
|
||||
# persist between container invocations. Second build is fast.
|
||||
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
|
||||
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
||||
|
||||
# Compute the binary's own version. On a stable cut, hard-code the
|
||||
# coordinated version so the binary embeds the same string the rest of the
|
||||
# release cycle has agreed on. Otherwise fall back to git describe (clean
|
||||
# tag, or tag-N-gSHA[-dirty] for in-flight commits).
|
||||
if [ -n "$RELEASE_VERSION" ]; then
|
||||
ZDDC_BINARY_VERSION="$RELEASE_VERSION"
|
||||
else
|
||||
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
|
||||
echo " binary version: $ZDDC_BINARY_VERSION"
|
||||
|
||||
# Single container invocation, multiple cross-compile targets inside a
|
||||
# `for` loop — avoids paying image-startup overhead 4×.
|
||||
"$GO_RUNNER" run --rm \
|
||||
-v "$SCRIPT_DIR:/src:Z" \
|
||||
-v "${GO_MOD_VOL}:/go/pkg/mod" \
|
||||
-v "${GO_BUILD_VOL}:/root/.cache/go-build" \
|
||||
-w /src/zddc \
|
||||
-e GOFLAGS=-mod=mod \
|
||||
-e CGO_ENABLED=0 \
|
||||
-e ZDDC_BINARY_VERSION="$ZDDC_BINARY_VERSION" \
|
||||
"$GO_BUILD_IMAGE" \
|
||||
sh -c '
|
||||
set -e
|
||||
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"
|
||||
GOOS="$os" GOARCH="$arch" \
|
||||
go build -trimpath \
|
||||
-ldflags="-s -w -X main.version=${ZDDC_BINARY_VERSION}" \
|
||||
-o "dist/$out" ./cmd/zddc-server
|
||||
done
|
||||
'
|
||||
|
||||
# --- Promote zddc-server release artifacts ---------------------------------
|
||||
# On a channel/release cut, copy the freshly cross-compiled binaries to
|
||||
# the website worktree's releases/ under their canonical names +
|
||||
# symlinks. promote_zddc_server also re-runs write_zddc_server_stubs_all
|
||||
# internally, so the matrix-cell stub pages get regenerated in the same
|
||||
# call. On a plain dev build, skip — we don't touch the worktree.
|
||||
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"
|
||||
fi
|
||||
|
||||
# Latest stable version, by following archive_stable.html → versioned target.
|
||||
# Returns "" if no stable cut exists yet (bootstrap state). All HTML tools
|
||||
# move in lockstep so any one of them is a valid probe; archive is canonical.
|
||||
_latest_stable_version() {
|
||||
_link="$RELEASES_DIR/archive_stable.html"
|
||||
[ -L "$_link" ] || return 0
|
||||
_target=$(readlink "$_link")
|
||||
# archive_v0.0.8.html → 0.0.8
|
||||
_v="${_target#archive_v}"
|
||||
_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() {
|
||||
_out="$RELEASES_DIR/index.html"
|
||||
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
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Download ZDDC</title>
|
||||
<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">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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 rel="stylesheet" href="../css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container header-content">
|
||||
<a href="/" class="brand">
|
||||
<svg class="brand-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="brand-name">ZDDC</span>
|
||||
</a>
|
||||
<nav class="header-nav">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
<a href="../reference.html" class="nav-link">Docs</a>
|
||||
<a href="index.html" class="nav-link active">Download</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1>Download ZDDC</h1>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<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
|
||||
|
||||
# Channels — selectable directly so users can copy the channel-
|
||||
# mirror URLs (e.g. archive_stable.html) for bookmarks. stable is
|
||||
# the default. The label tells the truth about the channel's
|
||||
# current state: when stable is set, show which version it points
|
||||
# at; when alpha/beta is just a symlink to stable, mark as
|
||||
# "tracks stable" so picking it isn't surprising.
|
||||
printf ' <optgroup label="Channels (mutable URLs)">\n'
|
||||
if [ -n "$_latest" ] && [ "$_latest" != "0.0.0" ]; then
|
||||
printf ' <option value="stable" selected>stable — currently v%s</option>\n' "$_latest"
|
||||
else
|
||||
printf ' <option value="stable" selected>stable</option>\n'
|
||||
fi
|
||||
if [ "$_beta_active" = "1" ]; then
|
||||
printf ' <option value="beta">beta — general testing</option>\n'
|
||||
else
|
||||
printf ' <option value="beta">beta — 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 — tracks stable</option>\n'
|
||||
fi
|
||||
printf ' </optgroup>\n'
|
||||
|
||||
# Pinned per-version, latest first. These are the immutable URLs
|
||||
# for reproducibility. No "(current stable)" suffix because the
|
||||
# stable channel above already covers that.
|
||||
printf ' <optgroup label="Pinned versions (immutable URLs)">\n'
|
||||
printf '%s\n' "$_all_versions" | while read -r _v; do
|
||||
[ -n "$_v" ] || continue
|
||||
printf ' <option value="v%s">v%s</option>\n' "$_v" "$_v"
|
||||
done
|
||||
printf ' </optgroup>\n'
|
||||
|
||||
cat <<'PICKER_END'
|
||||
</select>
|
||||
<span class="picker-hint">Changes every download link below.</span>
|
||||
</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
|
||||
# Default href is the channel-mirror URL (zddc-server_stable_<plat>)
|
||||
# because "stable" is the dropdown's selected option. Picking a
|
||||
# pinned version from the dropdown rewrites these to the
|
||||
# immutable per-version URL via the IIFE.
|
||||
printf ' <a class="dl-primary"\n'
|
||||
printf ' data-tool="zddc-server"\n'
|
||||
printf ' data-platform="linux-amd64"\n'
|
||||
printf ' href="zddc-server_stable_linux-amd64"\n'
|
||||
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_stable_linux-amd64</span>\n'
|
||||
|
||||
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_stable_%s%s">%s</a>\n' \
|
||||
"$_plat" "$_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#*|}"
|
||||
# Default href is the stable-channel mirror; the dropdown
|
||||
# rewires these per selection.
|
||||
printf ' <a class="tool-card" data-tool="%s" href="%s_stable.html">\n' "$_t" "$_t"
|
||||
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>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-content">
|
||||
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
||||
</div>
|
||||
</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 isChannel(v) {
|
||||
return v === 'stable' || v === 'beta' || v === 'alpha';
|
||||
}
|
||||
function platBinaryName(slug, plat) {
|
||||
// slug is a channel name ("stable") or a pinned version ("v0.0.8").
|
||||
// The on-disk name uses the slug as-is in both cases since the
|
||||
// channel-mirror filenames are zddc-server_<channel>_<plat> and
|
||||
// per-version are zddc-server_v<X.Y.Z>_<plat>.
|
||||
var suf = (plat.indexOf('windows') === 0) ? '.exe' : '';
|
||||
return 'zddc-server_' + slug + '_' + plat + suf;
|
||||
}
|
||||
function htmlAssetName(tool, slug) {
|
||||
return tool + '_' + slug + '.html';
|
||||
}
|
||||
|
||||
// Promote the detected platform to the primary CTA. The secondary
|
||||
// row keeps all four; the matching one is hidden to avoid showing
|
||||
// the same download twice.
|
||||
if (primary) {
|
||||
primary.dataset.platform = detected;
|
||||
if (primaryLabel) primaryLabel.textContent = 'for ' + platLabel;
|
||||
}
|
||||
if (others) {
|
||||
others.querySelectorAll('a[data-platform="' + detected + '"]').forEach(function(a) {
|
||||
a.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Single source of truth: the dropdown's current value drives every
|
||||
// download link's href. Static markup ships with the stable-channel
|
||||
// mirror (`<tool>_stable.html`, `zddc-server_stable_<plat>`) so the
|
||||
// page works fully without JS — the JS just keeps things in sync
|
||||
// when the user picks a different channel or pins a version.
|
||||
var picker = document.getElementById('version-picker');
|
||||
if (!picker) return;
|
||||
|
||||
function rewire(slug) {
|
||||
// slug ∈ {"stable", "beta", "alpha"} | "v<X.Y.Z>". Every link with
|
||||
// a data-tool attribute is a download URL the dropdown owns.
|
||||
document.querySelectorAll('[data-tool]').forEach(function(a) {
|
||||
var tool = a.dataset.tool;
|
||||
var plat = a.dataset.platform || '';
|
||||
if (tool === 'zddc-server') {
|
||||
a.href = plat ? platBinaryName(slug, plat) : ('zddc-server_' + slug + '.html');
|
||||
} else {
|
||||
a.href = htmlAssetName(tool, slug);
|
||||
}
|
||||
});
|
||||
if (primary && primaryMeta) {
|
||||
primaryMeta.textContent = primary.getAttribute('href');
|
||||
}
|
||||
}
|
||||
|
||||
picker.addEventListener('change', function() { rewire(picker.value); });
|
||||
|
||||
// Run rewire once on load to apply the platform-detection result
|
||||
// (the static href for the primary button is for linux-amd64; on a
|
||||
// non-linux client, that needs to flip to the detected platform).
|
||||
rewire(picker.value);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
PIN_END
|
||||
} > "$_out"
|
||||
echo "Wrote $_out"
|
||||
}
|
||||
|
||||
# Matrix index + verifier only run when we touched the website
|
||||
# worktree. Dev builds leave the worktree alone.
|
||||
if [ -n "$RELEASE_CHANNEL" ]; then
|
||||
echo ""
|
||||
echo "=== Building releases/index.html ==="
|
||||
build_releases_index
|
||||
|
||||
echo ""
|
||||
echo "=== Verifying channel links ==="
|
||||
verify_channel_links "$RELEASES_DIR"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Build done ==="
|
||||
echo ""
|
||||
if [ -z "$RELEASE_CHANNEL" ]; then
|
||||
echo "Mode: dev (source-only build; live site untouched)"
|
||||
echo " tool/dist/*.html ready"
|
||||
echo " zddc/dist/zddc-server-* binaries ready"
|
||||
echo ""
|
||||
echo "To cut alpha into a deployable bundle: ./build alpha"
|
||||
else
|
||||
echo "Cut: $RELEASE_CHANNEL"
|
||||
if [ -n "$RELEASE_VERSION" ]; then
|
||||
echo "Version: v$RELEASE_VERSION"
|
||||
echo ""
|
||||
echo "Tags created locally on main (push when ready):"
|
||||
for _t in archive transmittal classifier mdedit landing zddc-server; do
|
||||
echo " ${_t}-v${RELEASE_VERSION}"
|
||||
done
|
||||
echo " git push origin main && git push origin --tags"
|
||||
fi
|
||||
echo ""
|
||||
echo "Snapshot ready at $RELEASES_DIR/"
|
||||
echo ""
|
||||
echo "To publish to the live site:"
|
||||
echo " ./deploy --releases # rsync the snapshot to /srv/zddc/releases/"
|
||||
echo " ./deploy # full sync (content + releases)"
|
||||
fi
|
||||
268
build.sh
268
build.sh
|
|
@ -1,268 +0,0 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Top-level build script — builds all ZDDC HTML tools, the zddc-server
|
||||
# binaries, and the website/releases/index.html versions index.
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
echo "=== Building ZDDC tools ==="
|
||||
|
||||
sh "$SCRIPT_DIR/transmittal/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/archive/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/classifier/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/mdedit/build.sh" "${1:-}" "${2:-}"
|
||||
sh "$SCRIPT_DIR/landing/build.sh" "${1:-}" "${2:-}"
|
||||
|
||||
echo ""
|
||||
echo "=== Assembling zddc/dist/web/ ==="
|
||||
# All five tool HTMLs ship inside the server bundle. landing and archive call
|
||||
# server APIs (GET / for the project list, directory listings for archive) and
|
||||
# are useless without zddc-server. transmittal, classifier, and mdedit are
|
||||
# pure client-side tools but are still bundled — the server uses these copies
|
||||
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
|
||||
# the cache is empty AND the upstream is unreachable.
|
||||
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
|
||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
|
||||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
|
||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
|
||||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
|
||||
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit}.html"
|
||||
|
||||
# Mirror the same five HTMLs into the Go embed source dir so the next
|
||||
# `go build` of zddc-server picks them up via //go:embed. Files are checked
|
||||
# into git as empty placeholders; the build always overwrites them with the
|
||||
# fresh dist/ output.
|
||||
EMBED_DIR="$SCRIPT_DIR/zddc/internal/apps/embedded"
|
||||
mkdir -p "$EMBED_DIR"
|
||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$EMBED_DIR/index.html"
|
||||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$EMBED_DIR/archive.html"
|
||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html"
|
||||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||
|
||||
# Cross-compiled zddc-server binaries for Linux/macOS/Windows. Always built
|
||||
# inside docker.io/golang:1.24-alpine via podman (or docker), matching the
|
||||
# helm/zddc-server-prod chart's `buildImage` so dev binaries are byte-for-byte
|
||||
# what production gets. The build container is downloaded on first run.
|
||||
echo ""
|
||||
echo "=== Building zddc-server binaries (containerized) ==="
|
||||
mkdir -p "$SCRIPT_DIR/zddc/dist"
|
||||
|
||||
# Pick a container runtime. Both work; podman is preferred (rootless default).
|
||||
GO_RUNNER=""
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
GO_RUNNER=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
GO_RUNNER=docker
|
||||
else
|
||||
echo "error: neither podman nor docker is available — cannot build zddc-server binaries." >&2
|
||||
echo " Install podman (preferred) or docker. zddc-server build is containerized as policy." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GO_BUILD_IMAGE="${ZDDC_GO_BUILD_IMAGE:-docker.io/golang:1.24-alpine}"
|
||||
|
||||
# Cache the Go module + build cache across runs via named volumes that
|
||||
# persist between container invocations. Second build is fast.
|
||||
GO_MOD_VOL="${ZDDC_GO_MOD_VOL:-zddc-go-mod}"
|
||||
GO_BUILD_VOL="${ZDDC_GO_BUILD_VOL:-zddc-go-cache}"
|
||||
|
||||
# Single container invocation, multiple cross-compile targets inside a
|
||||
# `for` loop — avoids paying image-startup overhead 4×.
|
||||
"$GO_RUNNER" run --rm \
|
||||
-v "$SCRIPT_DIR:/src:Z" \
|
||||
-v "${GO_MOD_VOL}:/go/pkg/mod" \
|
||||
-v "${GO_BUILD_VOL}:/root/.cache/go-build" \
|
||||
-w /src/zddc \
|
||||
-e GOFLAGS=-mod=mod \
|
||||
-e CGO_ENABLED=0 \
|
||||
"$GO_BUILD_IMAGE" \
|
||||
sh -c '
|
||||
set -e
|
||||
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"
|
||||
GOOS="$os" GOARCH="$arch" \
|
||||
go build -trimpath -ldflags="-s -w" -o "dist/$out" ./cmd/zddc-server
|
||||
done
|
||||
'
|
||||
|
||||
WEBSITE_DIR="$SCRIPT_DIR/website"
|
||||
RELEASES_DIR="$WEBSITE_DIR/releases"
|
||||
|
||||
mkdir -p "$WEBSITE_DIR"
|
||||
|
||||
# Regenerate website/releases/index.html from a filesystem scan of
|
||||
# website/releases/. Lists per-version files (real .html files, immutable
|
||||
# archives) plus channel mirrors and partial-version pins (symlinks).
|
||||
#
|
||||
# All URLs in the page resolve directly under <upstream>/releases/<file>
|
||||
# — no Codeberg API call, no manifest, no proxy magic. Page is static
|
||||
# and current as of the last `sh build.sh` run.
|
||||
#
|
||||
# zddc-server's section links to its Codeberg release pages directly
|
||||
# (different distribution model — per-platform binaries).
|
||||
build_releases_index() {
|
||||
_out="$RELEASES_DIR/index.html"
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
|
||||
{
|
||||
cat <<'HEAD'
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Releases — ZDDC</title>
|
||||
<meta name="description" content="All released versions and channel builds of every ZDDC tool.">
|
||||
<meta name="theme-color" content="#2a5a8a">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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 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>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container header-content">
|
||||
<a href="/" class="brand">
|
||||
<svg class="brand-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="brand-name">ZDDC</span>
|
||||
</a>
|
||||
<nav class="header-nav">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
<a href="../reference.html" class="nav-link">Docs</a>
|
||||
<a href="index.html" class="nav-link active">Releases</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1>Releases</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
||||
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.
|
||||
# Use find to filter out symlinks; grep + sort -Vr for ordering.
|
||||
_versioned=$(find "$RELEASES_DIR" -maxdepth 1 -type f -name "${_tool}_v*.html" 2>/dev/null \
|
||||
| sed "s|^${RELEASES_DIR}/||" \
|
||||
| sort -Vr)
|
||||
|
||||
if [ -z "$_versioned" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
# zddc-server section — links to Codeberg release pages directly,
|
||||
# since binaries don't live under website/releases/.
|
||||
printf ' <section class="rel-tool">\n'
|
||||
printf ' <h2>zddc-server (Go file server)</h2>\n'
|
||||
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 ' <p><a href="https://codeberg.org/VARASYS/ZDDC/releases">Browse zddc-server releases on Codeberg →</a></p>\n'
|
||||
printf ' </section>\n'
|
||||
|
||||
cat <<'TAIL'
|
||||
|
||||
<section style="margin-top: var(--spacing-2xl); color: var(--color-text-muted); font-size: 0.9rem;">
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-content">
|
||||
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
TAIL
|
||||
} > "$_out"
|
||||
echo "Wrote $_out"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== Building releases/index.html ==="
|
||||
build_releases_index
|
||||
|
||||
echo ""
|
||||
echo "=== All tools built successfully ==="
|
||||
echo ""
|
||||
echo "Server deployment package: zddc/dist/"
|
||||
echo " Binaries: zddc-server-{linux,darwin,windows}-*"
|
||||
echo " Web files: web/ (copy contents to ZDDC_ROOT)"
|
||||
echo ""
|
||||
echo "Operator install: see website/index.html 'Install on your server'."
|
||||
91
deploy
Executable file
91
deploy
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# deploy — sync built artifacts and/or hand-edited content to the live site.
|
||||
#
|
||||
# The build pipeline (`./build alpha|beta|release`) produces self-contained
|
||||
# bundles in dist/release-output/ but does NOT touch the live site. This
|
||||
# script is the explicit deploy step. Two sync paths, independent:
|
||||
#
|
||||
# ./deploy push everything: content + releases
|
||||
# ./deploy --content push only ~/src/zddc-website/ → /srv/zddc/
|
||||
# (excludes /releases/ so releases stay intact)
|
||||
# ./deploy --releases push only dist/release-output/ → /srv/zddc/releases/
|
||||
#
|
||||
# Both paths use rsync with --delete-after, so the live tree exactly
|
||||
# mirrors the source — files removed locally go away on the live site.
|
||||
# Mostly-atomic per-file; brief mixed-state during a sync is acceptable
|
||||
# for a low-traffic static site. Caddy bind-mounts /srv/zddc as :ro and
|
||||
# serves whatever is there at request time.
|
||||
#
|
||||
# Override the source paths via env if you want:
|
||||
# ZDDC_CONTENT_DIR default: ~/src/zddc-website
|
||||
# ZDDC_DEPLOY_RELEASES_DIR default: <this-script-dir>/dist/release-output
|
||||
# ZDDC_LIVE_DIR default: /srv/zddc
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
CONTENT_SRC="${ZDDC_CONTENT_DIR:-$HOME/src/zddc-website}"
|
||||
RELEASES_SRC="${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}"
|
||||
LIVE="${ZDDC_LIVE_DIR:-/srv/zddc}"
|
||||
|
||||
case "${1:-all}" in
|
||||
-h|--help|help)
|
||||
sed -n '4,21p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
--content|content)
|
||||
WHAT=content
|
||||
;;
|
||||
--releases|releases)
|
||||
WHAT=releases
|
||||
;;
|
||||
all|"")
|
||||
WHAT=all
|
||||
;;
|
||||
*)
|
||||
echo "deploy: unknown subcommand '$1'. Try './deploy help'." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ ! -d "$LIVE" ]; then
|
||||
echo "deploy: $LIVE does not exist. Create it and chown to your user first:" >&2
|
||||
echo " sudo mkdir -p $LIVE && sudo chown -R \$USER:\$USER $LIVE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$WHAT" = content ] || [ "$WHAT" = all ]; then
|
||||
if [ ! -d "$CONTENT_SRC" ]; then
|
||||
echo "deploy: content source $CONTENT_SRC does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Syncing content: $CONTENT_SRC/ → $LIVE/ ==="
|
||||
# --exclude=/releases/ keeps the live site's releases dir untouched
|
||||
# by content syncs. --exclude=.git so the .git dir doesn't end up
|
||||
# under /usr/share/caddy.
|
||||
rsync -av --delete-after \
|
||||
--exclude='/releases/' \
|
||||
--exclude='/.git*' \
|
||||
--exclude='/README.md' \
|
||||
--exclude='/LICENSE' \
|
||||
"$CONTENT_SRC/" "$LIVE/"
|
||||
fi
|
||||
|
||||
if [ "$WHAT" = releases ] || [ "$WHAT" = all ]; then
|
||||
if [ ! -d "$RELEASES_SRC" ] || [ -z "$(ls -A "$RELEASES_SRC" 2>/dev/null)" ]; then
|
||||
echo "deploy: releases source $RELEASES_SRC is empty or missing" >&2
|
||||
echo " Run ./build alpha|beta|release first to populate it." >&2
|
||||
if [ "$WHAT" = all ]; then
|
||||
echo " (Skipping releases sync; content was synced.)" >&2
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$LIVE/releases"
|
||||
echo "=== Syncing releases: $RELEASES_SRC/ → $LIVE/releases/ ==="
|
||||
rsync -av --delete-after "$RELEASES_SRC/" "$LIVE/releases/"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy done ==="
|
||||
echo "Live: https://zddc.varasys.io/"
|
||||
|
|
@ -79,20 +79,21 @@ trap cleanup EXIT INT TERM
|
|||
echo "Freshening ${TOOL} ${CHANNEL} from ${LATEST_TAG}"
|
||||
git -C "$REPO" worktree add --quiet --detach "$WT" "$LATEST_TAG"
|
||||
|
||||
# Build in the worktree. The tool's build.sh writes the channel artifact
|
||||
# to "$WT/website/releases/<tool>_<channel>.html"; we then copy it into
|
||||
# the main worktree.
|
||||
sh "$WT/${TOOL}/build.sh" --release "$CHANNEL"
|
||||
# Build in the worktree. The tool's build.sh resolves its release dir
|
||||
# from $ZDDC_DEPLOY_RELEASES_DIR (default $REPO/dist/release-output);
|
||||
# pass through whatever the parent process has set so freshen-channel
|
||||
# honors the same target as the regular build.
|
||||
DEPLOY_DIR="${ZDDC_DEPLOY_RELEASES_DIR:-$REPO/dist/release-output}"
|
||||
mkdir -p "$DEPLOY_DIR"
|
||||
ZDDC_DEPLOY_RELEASES_DIR="$DEPLOY_DIR" \
|
||||
sh "$WT/${TOOL}/build.sh" --release "$CHANNEL"
|
||||
|
||||
SRC="$WT/website/releases/${TOOL}_${CHANNEL}.html"
|
||||
DST="$REPO/website/releases/${TOOL}_${CHANNEL}.html"
|
||||
if [ ! -f "$SRC" ]; then
|
||||
echo "error: build did not produce $SRC" >&2
|
||||
DST="$DEPLOY_DIR/${TOOL}_${CHANNEL}.html"
|
||||
if [ ! -f "$DST" ]; then
|
||||
echo "error: build did not produce $DST" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$REPO/website/releases"
|
||||
cp "$SRC" "$DST"
|
||||
echo "Wrote $DST"
|
||||
echo "Done. ${CHANNEL} channel for ${TOOL} now reflects ${LATEST_TAG}."
|
||||
echo "Commit the change: git add $DST && git commit"
|
||||
echo "Run ./deploy --releases to push it to the live site."
|
||||
|
|
|
|||
|
|
@ -337,13 +337,25 @@
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* The text wrapper inside a tree-row label. For ZDDC-conforming files and
|
||||
folders, this wraps two stacked <div>s (filename-main + filename-secondary)
|
||||
so the row reads top-to-bottom as title + metadata — same shape the archive
|
||||
tool uses for its transmittal-folder list. For non-ZDDC entries it just
|
||||
contains a single line. flex column makes the two-line case work; min-width:0
|
||||
lets each line truncate independently. */
|
||||
.tree-row__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* ── New-file modal ─────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -23,3 +23,97 @@
|
|||
.toastui-editor-main .toastui-editor-md-preview {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Toast UI Editor — dark-theme overrides ───────────────────────────────
|
||||
Toast UI ships with light-mode chrome and edit surfaces by default. In
|
||||
mdedit's dark mode the editor's text (#222) falls onto the transparent
|
||||
md-container, which inherits var(--bg) dark = #1e1e1e → effectively
|
||||
black-on-black. Override the load-bearing surfaces with mdedit's tokens
|
||||
so the editor harmonises with the rest of the chrome.
|
||||
The selectors target both manual override (data-theme="dark") and the
|
||||
OS-pref auto fallback (prefers-color-scheme + no data-theme="light"). */
|
||||
|
||||
/* Manual dark override */
|
||||
[data-theme="dark"] .toastui-editor-defaultUI,
|
||||
[data-theme="dark"] .toastui-editor-md-container,
|
||||
[data-theme="dark"] .toastui-editor-md-preview,
|
||||
[data-theme="dark"] .toastui-editor-ww-container,
|
||||
[data-theme="dark"] .toastui-editor-mode-switch,
|
||||
[data-theme="dark"] .toastui-editor-main,
|
||||
[data-theme="dark"] .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-icons {
|
||||
/* Toast UI's icons are sprite-baked dark; invert flips them to light. */
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-popup,
|
||||
[data-theme="dark"] .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* OS-pref auto fallback (matches every selector above) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-preview,
|
||||
:root:not([data-theme="light"]) .toastui-editor-ww-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch,
|
||||
:root:not([data-theme="light"]) .toastui-editor-main,
|
||||
:root:not([data-theme="light"]) .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-icons {
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-popup,
|
||||
:root:not([data-theme="light"]) .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -494,6 +494,16 @@ a:hover {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Brand logo — sits left of the title in every tool's app-header.
|
||||
Self-contained: the SVG provides its own dark blue rounded background,
|
||||
so no extra wrapper styling is needed. */
|
||||
.app-header__logo {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Build timestamp ──────────────────────────────────────────────────────── */
|
||||
.build-timestamp {
|
||||
font-size: 0.55rem;
|
||||
|
|
@ -1056,13 +1066,25 @@ body.help-open .app-header {
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* The text wrapper inside a tree-row label. For ZDDC-conforming files and
|
||||
folders, this wraps two stacked <div>s (filename-main + filename-secondary)
|
||||
so the row reads top-to-bottom as title + metadata — same shape the archive
|
||||
tool uses for its transmittal-folder list. For non-ZDDC entries it just
|
||||
contains a single line. flex column makes the two-line case work; min-width:0
|
||||
lets each line truncate independently. */
|
||||
.tree-row__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* ── New-file modal ─────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
|
@ -1137,6 +1159,100 @@ body.help-open .app-header {
|
|||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Toast UI Editor — dark-theme overrides ───────────────────────────────
|
||||
Toast UI ships with light-mode chrome and edit surfaces by default. In
|
||||
mdedit's dark mode the editor's text (#222) falls onto the transparent
|
||||
md-container, which inherits var(--bg) dark = #1e1e1e → effectively
|
||||
black-on-black. Override the load-bearing surfaces with mdedit's tokens
|
||||
so the editor harmonises with the rest of the chrome.
|
||||
The selectors target both manual override (data-theme="dark") and the
|
||||
OS-pref auto fallback (prefers-color-scheme + no data-theme="light"). */
|
||||
|
||||
/* Manual dark override */
|
||||
[data-theme="dark"] .toastui-editor-defaultUI,
|
||||
[data-theme="dark"] .toastui-editor-md-container,
|
||||
[data-theme="dark"] .toastui-editor-md-preview,
|
||||
[data-theme="dark"] .toastui-editor-ww-container,
|
||||
[data-theme="dark"] .toastui-editor-mode-switch,
|
||||
[data-theme="dark"] .toastui-editor-main,
|
||||
[data-theme="dark"] .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-icons {
|
||||
/* Toast UI's icons are sprite-baked dark; invert flips them to light. */
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-popup,
|
||||
[data-theme="dark"] .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* OS-pref auto fallback (matches every selector above) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-preview,
|
||||
:root:not([data-theme="light"]) .toastui-editor-ww-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch,
|
||||
:root:not([data-theme="light"]) .toastui-editor-main,
|
||||
:root:not([data-theme="light"]) .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-icons {
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-popup,
|
||||
:root:not([data-theme="light"]) .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* Table of Contents styles */
|
||||
.toc-pane {
|
||||
height: 100%;
|
||||
|
|
@ -1648,9 +1764,17 @@ body.help-open .app-header {
|
|||
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp">v0.0.2</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
|
||||
</div>
|
||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||
</div>
|
||||
|
|
@ -2246,6 +2370,551 @@ body.help-open .app-header {
|
|||
}
|
||||
}());
|
||||
|
||||
/**
|
||||
* ZDDC — shared preview helpers
|
||||
*
|
||||
* Cross-tool helpers for previewing file types that need a decoder:
|
||||
* - TIFF (UTIF.js) — multi-page, browser-PDF-viewer-style toolbar
|
||||
* - ZIP listing (JSZip) — sortable file-list view
|
||||
*
|
||||
* Renderers operate on any document (parent window or popup window), so the
|
||||
* same code works for tools whose preview opens in a popup (classifier,
|
||||
* archive, transmittal) and tools that render inline (mdedit).
|
||||
*
|
||||
* Public API on window.zddc.preview:
|
||||
* loadLibrary(url) → Promise<void>
|
||||
* renderTiff(doc, container, arrayBuffer, opts) → Promise<void>
|
||||
* renderZipListing(doc, container, arrayBuffer, opts) → Promise<void>
|
||||
* TIFF_EXTENSIONS, IMAGE_EXTENSIONS, TEXT_EXTENSIONS, OFFICE_EXTENSIONS
|
||||
* isTiff(ext), isImage(ext), isText(ext), isZip(ext), isOffice(ext)
|
||||
*
|
||||
* Each tool keeps its own dispatcher; this lib only owns the heavy renderers.
|
||||
*/
|
||||
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
var TIFF_EXTENSIONS = ['tif', 'tiff'];
|
||||
var IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
|
||||
var TEXT_EXTENSIONS = [
|
||||
'txt', 'md', 'markdown', 'json', 'xml', 'csv', 'tsv', 'log',
|
||||
'html', 'htm', 'css', 'js', 'mjs', 'ts', 'tsx', 'jsx',
|
||||
'py', 'rb', 'sh', 'bash', 'zsh', 'bat', 'ps1',
|
||||
'yaml', 'yml', 'ini', 'cfg', 'conf', 'toml',
|
||||
'c', 'cc', 'cpp', 'h', 'hpp', 'go', 'rs', 'java', 'kt',
|
||||
'sql', 'env'
|
||||
];
|
||||
var OFFICE_EXTENSIONS = ['docx', 'xlsx', 'xls'];
|
||||
|
||||
function lowerExt(ext) { return (ext || '').toLowerCase(); }
|
||||
function isTiff(ext) { return TIFF_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||||
function isImage(ext) { return IMAGE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||||
function isText(ext) { return TEXT_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||||
function isZip(ext) { return lowerExt(ext) === 'zip'; }
|
||||
function isOffice(ext) { return OFFICE_EXTENSIONS.indexOf(lowerExt(ext)) !== -1; }
|
||||
|
||||
// ── CDN library loader (parent window cache) ─────────────────────────────
|
||||
|
||||
var _libCache = new Map();
|
||||
|
||||
function loadLibrary(url) {
|
||||
if (_libCache.has(url)) return _libCache.get(url);
|
||||
var p = new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = url;
|
||||
s.onload = function () { resolve(); };
|
||||
s.onerror = function () { reject(new Error('Failed to load: ' + url)); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
_libCache.set(url, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ── Style injection (idempotent per-document) ────────────────────────────
|
||||
|
||||
function injectStyles(doc, id, css) {
|
||||
if (doc.getElementById(id)) return;
|
||||
var style = doc.createElement('style');
|
||||
style.id = id;
|
||||
style.textContent = css;
|
||||
doc.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── TIFF renderer ────────────────────────────────────────────────────────
|
||||
|
||||
var TIFF_CSS =
|
||||
'.tiff-toolbar{display:flex;align-items:center;gap:.4rem;padding:.4rem .6rem;' +
|
||||
'background:#f5f5f5;border-bottom:1px solid #ddd;flex-wrap:wrap;font-size:.85rem;}' +
|
||||
'.tiff-toolbar .tiff-btn{padding:.25rem .55rem;border:1px solid #ccc;border-radius:3px;' +
|
||||
'background:#fff;cursor:pointer;font-size:.85rem;line-height:1;min-width:1.8rem;}' +
|
||||
'.tiff-toolbar .tiff-btn:hover:not(:disabled){background:#e8e8e8;}' +
|
||||
'.tiff-toolbar .tiff-btn:disabled{opacity:.4;cursor:default;}' +
|
||||
'.tiff-toolbar .tiff-page-info{display:inline-flex;align-items:center;gap:.3rem;}' +
|
||||
'.tiff-toolbar .tiff-page-input{width:3.2rem;padding:.2rem .3rem;border:1px solid #ccc;' +
|
||||
'border-radius:3px;text-align:center;font-size:.85rem;}' +
|
||||
'.tiff-toolbar .tiff-zoom-select{padding:.2rem .3rem;border:1px solid #ccc;border-radius:3px;' +
|
||||
'background:#fff;font-size:.85rem;}' +
|
||||
'.tiff-toolbar .tiff-spacer{flex:1;}' +
|
||||
'.tiff-viewport{flex:1;overflow:auto;background:#525659;display:flex;align-items:flex-start;' +
|
||||
'justify-content:center;padding:1rem;}' +
|
||||
'.tiff-canvas{background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.4);display:block;' +
|
||||
'image-rendering:auto;}' +
|
||||
'.tiff-error{flex:1;display:flex;align-items:center;justify-content:center;color:#900;' +
|
||||
'padding:2rem;text-align:center;}';
|
||||
|
||||
function renderTiff(doc, container, arrayBuffer, opts) {
|
||||
opts = opts || {};
|
||||
injectStyles(doc, 'zddc-tiff-styles', TIFF_CSS);
|
||||
|
||||
return loadLibrary('https://cdn.jsdelivr.net/npm/utif@3.1.0/UTIF.js').then(function () {
|
||||
var ifds;
|
||||
try {
|
||||
ifds = window.UTIF.decode(arrayBuffer);
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="tiff-error">Failed to parse TIFF: '
|
||||
+ escapeHtml(e.message || e) + '</div>';
|
||||
return;
|
||||
}
|
||||
if (!ifds || !ifds.length) {
|
||||
container.innerHTML = '<div class="tiff-error">No images found in TIFF.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset container to a flex column
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.minHeight = '0';
|
||||
container.style.height = '100%';
|
||||
container.style.overflow = 'hidden';
|
||||
|
||||
// Toolbar
|
||||
var toolbar = doc.createElement('div');
|
||||
toolbar.className = 'tiff-toolbar';
|
||||
|
||||
var btnPrev = doc.createElement('button');
|
||||
btnPrev.className = 'tiff-btn'; btnPrev.type = 'button';
|
||||
btnPrev.title = 'Previous page'; btnPrev.textContent = '◀';
|
||||
|
||||
var pageInfo = doc.createElement('span');
|
||||
pageInfo.className = 'tiff-page-info';
|
||||
var pageInput = doc.createElement('input');
|
||||
pageInput.type = 'number'; pageInput.min = '1'; pageInput.value = '1';
|
||||
pageInput.className = 'tiff-page-input';
|
||||
var pageOf = doc.createElement('span');
|
||||
pageOf.textContent = ' of ' + ifds.length;
|
||||
pageInfo.appendChild(doc.createTextNode('Page '));
|
||||
pageInfo.appendChild(pageInput);
|
||||
pageInfo.appendChild(pageOf);
|
||||
|
||||
var btnNext = doc.createElement('button');
|
||||
btnNext.className = 'tiff-btn'; btnNext.type = 'button';
|
||||
btnNext.title = 'Next page'; btnNext.textContent = '▶';
|
||||
|
||||
var spacer = doc.createElement('span');
|
||||
spacer.className = 'tiff-spacer';
|
||||
|
||||
var btnZoomOut = doc.createElement('button');
|
||||
btnZoomOut.className = 'tiff-btn'; btnZoomOut.type = 'button';
|
||||
btnZoomOut.title = 'Zoom out'; btnZoomOut.textContent = '−';
|
||||
|
||||
var zoomSelect = doc.createElement('select');
|
||||
zoomSelect.className = 'tiff-zoom-select';
|
||||
var zoomOptions = [
|
||||
['fit-width', 'Fit width'],
|
||||
['fit-page', 'Fit page'],
|
||||
['0.5', '50%'],
|
||||
['0.75', '75%'],
|
||||
['1', '100%'],
|
||||
['1.25', '125%'],
|
||||
['1.5', '150%'],
|
||||
['2', '200%'],
|
||||
['3', '300%'],
|
||||
['4', '400%']
|
||||
];
|
||||
zoomOptions.forEach(function (z) {
|
||||
var o = doc.createElement('option');
|
||||
o.value = z[0]; o.textContent = z[1];
|
||||
zoomSelect.appendChild(o);
|
||||
});
|
||||
zoomSelect.value = 'fit-width';
|
||||
|
||||
var btnZoomIn = doc.createElement('button');
|
||||
btnZoomIn.className = 'tiff-btn'; btnZoomIn.type = 'button';
|
||||
btnZoomIn.title = 'Zoom in'; btnZoomIn.textContent = '+';
|
||||
|
||||
toolbar.appendChild(btnPrev);
|
||||
toolbar.appendChild(pageInfo);
|
||||
toolbar.appendChild(btnNext);
|
||||
toolbar.appendChild(spacer);
|
||||
toolbar.appendChild(btnZoomOut);
|
||||
toolbar.appendChild(zoomSelect);
|
||||
toolbar.appendChild(btnZoomIn);
|
||||
|
||||
// Viewport with canvas
|
||||
var viewport = doc.createElement('div');
|
||||
viewport.className = 'tiff-viewport';
|
||||
var canvas = doc.createElement('canvas');
|
||||
canvas.className = 'tiff-canvas';
|
||||
viewport.appendChild(canvas);
|
||||
|
||||
container.appendChild(toolbar);
|
||||
container.appendChild(viewport);
|
||||
|
||||
// Render state
|
||||
var currentPage = 0;
|
||||
var zoom = 1;
|
||||
var fitMode = 'width'; // 'width' | 'page' | null
|
||||
var decoded = new Array(ifds.length);
|
||||
|
||||
function decodePage(i) {
|
||||
if (decoded[i]) return decoded[i];
|
||||
var ifd = ifds[i];
|
||||
window.UTIF.decodeImage(arrayBuffer, ifd);
|
||||
var rgba = window.UTIF.toRGBA8(ifd);
|
||||
decoded[i] = { rgba: rgba, w: ifd.width, h: ifd.height };
|
||||
return decoded[i];
|
||||
}
|
||||
|
||||
function applyZoom() {
|
||||
var page = decoded[currentPage];
|
||||
if (!page) return;
|
||||
var availW = viewport.clientWidth - 32; // padding
|
||||
var availH = viewport.clientHeight - 32;
|
||||
var scale;
|
||||
if (fitMode === 'width') {
|
||||
scale = availW / page.w;
|
||||
} else if (fitMode === 'page') {
|
||||
scale = Math.min(availW / page.w, availH / page.h);
|
||||
} else {
|
||||
scale = zoom;
|
||||
}
|
||||
if (!isFinite(scale) || scale <= 0) scale = 1;
|
||||
canvas.style.width = (page.w * scale) + 'px';
|
||||
canvas.style.height = (page.h * scale) + 'px';
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
var page;
|
||||
try {
|
||||
page = decodePage(currentPage);
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="tiff-error">Failed to decode page '
|
||||
+ (currentPage + 1) + ': ' + escapeHtml(e.message || e) + '</div>';
|
||||
return;
|
||||
}
|
||||
canvas.width = page.w;
|
||||
canvas.height = page.h;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var imgData = ctx.createImageData(page.w, page.h);
|
||||
imgData.data.set(page.rgba);
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
applyZoom();
|
||||
pageInput.value = String(currentPage + 1);
|
||||
btnPrev.disabled = currentPage <= 0;
|
||||
btnNext.disabled = currentPage >= ifds.length - 1;
|
||||
}
|
||||
|
||||
function setZoomFromSelect() {
|
||||
var v = zoomSelect.value;
|
||||
if (v === 'fit-width') { fitMode = 'width'; }
|
||||
else if (v === 'fit-page') { fitMode = 'page'; }
|
||||
else { fitMode = null; zoom = parseFloat(v) || 1; }
|
||||
applyZoom();
|
||||
}
|
||||
|
||||
function nudgeZoom(factor) {
|
||||
if (fitMode) {
|
||||
// capture current effective scale before leaving fit mode
|
||||
var page = decoded[currentPage];
|
||||
if (page) {
|
||||
var availW = viewport.clientWidth - 32;
|
||||
var availH = viewport.clientHeight - 32;
|
||||
zoom = fitMode === 'width'
|
||||
? availW / page.w
|
||||
: Math.min(availW / page.w, availH / page.h);
|
||||
} else {
|
||||
zoom = 1;
|
||||
}
|
||||
fitMode = null;
|
||||
}
|
||||
zoom = Math.max(0.1, Math.min(8, zoom * factor));
|
||||
// Match select option if any are close, else show as percent
|
||||
var matched = false;
|
||||
for (var i = 0; i < zoomSelect.options.length; i++) {
|
||||
var ov = zoomSelect.options[i].value;
|
||||
if (ov !== 'fit-width' && ov !== 'fit-page' && Math.abs(parseFloat(ov) - zoom) < 0.001) {
|
||||
zoomSelect.value = ov; matched = true; break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
// Nearest standard step
|
||||
var best = '1', bestDiff = Infinity;
|
||||
for (var j = 0; j < zoomSelect.options.length; j++) {
|
||||
var v2 = zoomSelect.options[j].value;
|
||||
if (v2 === 'fit-width' || v2 === 'fit-page') continue;
|
||||
var diff = Math.abs(parseFloat(v2) - zoom);
|
||||
if (diff < bestDiff) { bestDiff = diff; best = v2; }
|
||||
}
|
||||
zoom = parseFloat(best);
|
||||
zoomSelect.value = best;
|
||||
}
|
||||
applyZoom();
|
||||
}
|
||||
|
||||
btnPrev.addEventListener('click', function () {
|
||||
if (currentPage > 0) { currentPage--; renderPage(); }
|
||||
});
|
||||
btnNext.addEventListener('click', function () {
|
||||
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); }
|
||||
});
|
||||
pageInput.addEventListener('change', function () {
|
||||
var n = parseInt(pageInput.value, 10);
|
||||
if (!isNaN(n) && n >= 1 && n <= ifds.length) {
|
||||
currentPage = n - 1;
|
||||
renderPage();
|
||||
} else {
|
||||
pageInput.value = String(currentPage + 1);
|
||||
}
|
||||
});
|
||||
zoomSelect.addEventListener('change', setZoomFromSelect);
|
||||
btnZoomIn.addEventListener('click', function () { nudgeZoom(1.25); });
|
||||
btnZoomOut.addEventListener('click', function () { nudgeZoom(1 / 1.25); });
|
||||
|
||||
// Keyboard nav (only when toolbar/viewport in focus path)
|
||||
container.tabIndex = 0;
|
||||
container.addEventListener('keydown', function (e) {
|
||||
if (e.target === pageInput) return;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||||
if (currentPage > 0) { currentPage--; renderPage(); e.preventDefault(); }
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
if (currentPage < ifds.length - 1) { currentPage++; renderPage(); e.preventDefault(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fit on viewport resize
|
||||
if (typeof (doc.defaultView && doc.defaultView.ResizeObserver) === 'function') {
|
||||
var ro = new doc.defaultView.ResizeObserver(function () { applyZoom(); });
|
||||
ro.observe(viewport);
|
||||
} else if (doc.defaultView) {
|
||||
doc.defaultView.addEventListener('resize', function () { applyZoom(); });
|
||||
}
|
||||
|
||||
renderPage();
|
||||
});
|
||||
}
|
||||
|
||||
// ── ZIP listing renderer ─────────────────────────────────────────────────
|
||||
|
||||
var ZIP_CSS =
|
||||
'.zip-header{padding:.4rem .8rem;background:#f5f5f5;border-bottom:1px solid #ddd;' +
|
||||
'font-size:.85rem;color:#444;}' +
|
||||
'.zip-table-wrap{flex:1;overflow:auto;}' +
|
||||
'.zip-table{width:100%;border-collapse:collapse;font-size:.85rem;font-family:' +
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' +
|
||||
'.zip-table thead th{position:sticky;top:0;background:#f0f0f0;text-align:left;' +
|
||||
'padding:.4rem .6rem;border-bottom:1px solid #ccc;cursor:pointer;user-select:none;' +
|
||||
'font-weight:600;}' +
|
||||
'.zip-table thead th:hover{background:#e6e6e6;}' +
|
||||
'.zip-table thead th.zip-sort-asc::after{content:" ▲";font-size:.7rem;color:#888;}' +
|
||||
'.zip-table thead th.zip-sort-desc::after{content:" ▼";font-size:.7rem;color:#888;}' +
|
||||
'.zip-table tbody td{padding:.3rem .6rem;border-bottom:1px solid #eee;}' +
|
||||
'.zip-table tbody tr:hover{background:#f6faff;}' +
|
||||
'.zip-table .zip-folder{color:#888;}' +
|
||||
'.zip-table .zip-name{color:#222;}' +
|
||||
'.zip-table .zip-size,.zip-table .zip-date{font-variant-numeric:tabular-nums;' +
|
||||
'white-space:nowrap;color:#555;}' +
|
||||
'.zip-table .zip-col-size,.zip-table .zip-col-date{text-align:right;}' +
|
||||
'.zip-empty{padding:2rem;text-align:center;color:#888;}';
|
||||
|
||||
function renderZipListing(doc, container, arrayBuffer, opts) {
|
||||
opts = opts || {};
|
||||
injectStyles(doc, 'zddc-zip-styles', ZIP_CSS);
|
||||
|
||||
return loadLibrary('https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js').then(function () {
|
||||
return window.JSZip.loadAsync(arrayBuffer);
|
||||
}).then(function (zip) {
|
||||
var entries = [];
|
||||
zip.forEach(function (relativePath, zipEntry) {
|
||||
if (zipEntry.dir) return;
|
||||
var size = (zipEntry._data && zipEntry._data.uncompressedSize) || 0;
|
||||
entries.push({
|
||||
path: relativePath,
|
||||
name: relativePath.split('/').pop(),
|
||||
size: size,
|
||||
modified: zipEntry.date instanceof Date ? zipEntry.date : null
|
||||
});
|
||||
});
|
||||
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.minHeight = '0';
|
||||
container.style.height = '100%';
|
||||
container.style.overflow = 'hidden';
|
||||
|
||||
var totalSize = entries.reduce(function (s, e) { return s + e.size; }, 0);
|
||||
|
||||
var header = doc.createElement('div');
|
||||
header.className = 'zip-header';
|
||||
header.textContent = entries.length + ' file' + (entries.length === 1 ? '' : 's')
|
||||
+ (totalSize ? ' · ' + formatSize(totalSize) + ' uncompressed' : '');
|
||||
container.appendChild(header);
|
||||
|
||||
if (!entries.length) {
|
||||
var empty = doc.createElement('div');
|
||||
empty.className = 'zip-empty';
|
||||
empty.textContent = '(empty archive)';
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
var wrap = doc.createElement('div');
|
||||
wrap.className = 'zip-table-wrap';
|
||||
|
||||
var table = doc.createElement('table');
|
||||
table.className = 'zip-table';
|
||||
var thead = doc.createElement('thead');
|
||||
var trh = doc.createElement('tr');
|
||||
var cols = [
|
||||
{ key: 'path', label: 'Name', cls: 'zip-col-name' },
|
||||
{ key: 'size', label: 'Size', cls: 'zip-col-size' },
|
||||
{ key: 'modified', label: 'Modified', cls: 'zip-col-date' }
|
||||
];
|
||||
cols.forEach(function (c) {
|
||||
var th = doc.createElement('th');
|
||||
th.className = c.cls;
|
||||
th.dataset.key = c.key;
|
||||
th.textContent = c.label;
|
||||
trh.appendChild(th);
|
||||
});
|
||||
thead.appendChild(trh);
|
||||
table.appendChild(thead);
|
||||
|
||||
var tbody = doc.createElement('tbody');
|
||||
table.appendChild(tbody);
|
||||
|
||||
wrap.appendChild(table);
|
||||
container.appendChild(wrap);
|
||||
|
||||
var sortKey = 'path';
|
||||
var sortDir = 1;
|
||||
|
||||
function render() {
|
||||
var sorted = entries.slice().sort(function (a, b) {
|
||||
var av, bv;
|
||||
if (sortKey === 'size') { av = a.size; bv = b.size; }
|
||||
else if (sortKey === 'modified') {
|
||||
av = a.modified ? a.modified.getTime() : 0;
|
||||
bv = b.modified ? b.modified.getTime() : 0;
|
||||
} else {
|
||||
av = a.path.toLowerCase(); bv = b.path.toLowerCase();
|
||||
}
|
||||
if (av < bv) return -1 * sortDir;
|
||||
if (av > bv) return 1 * sortDir;
|
||||
return 0;
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
sorted.forEach(function (e) {
|
||||
var tr = doc.createElement('tr');
|
||||
var td1 = doc.createElement('td');
|
||||
var slash = e.path.lastIndexOf('/');
|
||||
if (slash >= 0) {
|
||||
var folder = doc.createElement('span');
|
||||
folder.className = 'zip-folder';
|
||||
folder.textContent = e.path.substring(0, slash + 1);
|
||||
td1.appendChild(folder);
|
||||
}
|
||||
var name = doc.createElement('span');
|
||||
name.className = 'zip-name';
|
||||
name.textContent = e.name;
|
||||
td1.appendChild(name);
|
||||
|
||||
var td2 = doc.createElement('td');
|
||||
td2.className = 'zip-size';
|
||||
td2.textContent = formatSize(e.size);
|
||||
|
||||
var td3 = doc.createElement('td');
|
||||
td3.className = 'zip-date';
|
||||
td3.textContent = formatDate(e.modified);
|
||||
|
||||
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// Update sort arrows
|
||||
var ths = thead.querySelectorAll('th');
|
||||
for (var i = 0; i < ths.length; i++) {
|
||||
ths[i].classList.remove('zip-sort-asc', 'zip-sort-desc');
|
||||
if (ths[i].dataset.key === sortKey) {
|
||||
ths[i].classList.add(sortDir > 0 ? 'zip-sort-asc' : 'zip-sort-desc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thead.querySelectorAll('th').forEach(function (th) {
|
||||
th.addEventListener('click', function () {
|
||||
var k = th.dataset.key;
|
||||
if (sortKey === k) sortDir = -sortDir;
|
||||
else { sortKey = k; sortDir = 1; }
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
container.innerHTML = '<div class="zip-empty">Failed to read ZIP: '
|
||||
+ escapeHtml(err.message || err) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
if (!root.zddc) root.zddc = {};
|
||||
root.zddc.preview = {
|
||||
TIFF_EXTENSIONS: TIFF_EXTENSIONS,
|
||||
IMAGE_EXTENSIONS: IMAGE_EXTENSIONS,
|
||||
TEXT_EXTENSIONS: TEXT_EXTENSIONS,
|
||||
OFFICE_EXTENSIONS: OFFICE_EXTENSIONS,
|
||||
isTiff: isTiff,
|
||||
isImage: isImage,
|
||||
isText: isText,
|
||||
isZip: isZip,
|
||||
isOffice: isOffice,
|
||||
loadLibrary: loadLibrary,
|
||||
renderTiff: renderTiff,
|
||||
renderZipListing: renderZipListing,
|
||||
formatSize: formatSize,
|
||||
formatDate: formatDate
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
|
||||
/**
|
||||
* Global application state and constants
|
||||
*/
|
||||
|
|
@ -3064,6 +3733,9 @@ async function openDirectory() {
|
|||
directoryHandle = await window.showDirectoryPicker();
|
||||
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
|
||||
|
||||
// Local picker wins over any active server-source mode.
|
||||
serverSourceMode = false;
|
||||
|
||||
updateDirectoryStatus(directoryHandle.name);
|
||||
await readDirectory(directoryHandle);
|
||||
|
||||
|
|
@ -3581,12 +4253,26 @@ async function readServerDirectory(dirUrl, parentNode, depth) {
|
|||
*/
|
||||
async function loadServerDirectory() {
|
||||
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
|
||||
serverSourceMode = true;
|
||||
|
||||
let href = window.location.href.split('?')[0].split('#')[0];
|
||||
const lastSlash = href.lastIndexOf('/');
|
||||
const baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
|
||||
// Only enter server-source mode if the host actually serves JSON directory
|
||||
// listings (zddc-server / Caddy). On a plain static host the probe fails
|
||||
// and we must leave "Select Directory" visible so the user can still load
|
||||
// local files.
|
||||
try {
|
||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (!resp.ok) return;
|
||||
const items = await resp.json();
|
||||
if (!Array.isArray(items)) return;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverSourceMode = true;
|
||||
|
||||
const rootName = (() => {
|
||||
const path = baseUrl.replace(/\/$/, '');
|
||||
const seg = path.substring(path.lastIndexOf('/') + 1);
|
||||
|
|
@ -3600,15 +4286,12 @@ async function loadServerDirectory() {
|
|||
entries: {},
|
||||
};
|
||||
|
||||
// Surface refresh, hide write-only controls
|
||||
// Surface refresh, hide write-only controls. "Select Directory" stays
|
||||
// visible so the user can switch to a local folder at any time.
|
||||
const refreshBtn = document.getElementById('refresh-directory');
|
||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
|
||||
const selectDirBtn = document.getElementById('select-directory');
|
||||
if (selectDirBtn) {
|
||||
selectDirBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
const stats = await readServerDirectory(baseUrl, fileTree, 0);
|
||||
renderFileTree();
|
||||
|
|
@ -3755,7 +4438,7 @@ function renderFileTree() {
|
|||
|
||||
const scratchLabel = document.createElement('span');
|
||||
scratchLabel.className = 'tree-row__label';
|
||||
scratchLabel.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div>';
|
||||
scratchLabel.innerHTML = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
|
||||
scratchpadElement.appendChild(scratchLabel);
|
||||
|
||||
const scratchActions = document.createElement('div');
|
||||
|
|
@ -3823,7 +4506,10 @@ function renderFileTree() {
|
|||
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
|
||||
} else {
|
||||
dirName.textContent = `📁 ${name}`;
|
||||
// Non-ZDDC folder: still wrap in filename-main so
|
||||
// typography matches the two-line entries (same font
|
||||
// size + weight; just no secondary line).
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const dirLabel = document.createElement('span');
|
||||
|
|
@ -3863,24 +4549,30 @@ function renderFileTree() {
|
|||
|
||||
const fileIcon = getFileTypeIcon(name);
|
||||
|
||||
let fileNameDisplay;
|
||||
// Build the inner two-line text inside a tree-row__name
|
||||
// wrapper (column-flex). ZDDC-conforming filenames split
|
||||
// into title + meta; "Title - filename.ext" pattern uses
|
||||
// the dash as the same split. Plain names get a single
|
||||
// line via filename-main only — same wrapper, just no
|
||||
// secondary div, so the layout stays consistent.
|
||||
let fileNameInner;
|
||||
const parsed = zddc.parseFilename(name);
|
||||
if (parsed && parsed.valid) {
|
||||
const titleDisplay = escapeHtml(parsed.title);
|
||||
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
} else if (name.includes(' - ')) {
|
||||
const dashIdx = name.lastIndexOf(' - ');
|
||||
const secondary = escapeHtml(name.substring(0, dashIdx));
|
||||
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
} else {
|
||||
fileNameDisplay = `<span>${fileIcon} ${escapeHtml(name)}</span>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement('span');
|
||||
fileLabel.className = 'tree-row__label';
|
||||
fileLabel.innerHTML = fileNameDisplay;
|
||||
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
|
||||
|
||||
const fileActions = createActionButtons(filePath, 'file');
|
||||
|
||||
|
|
@ -3948,16 +4640,25 @@ async function displayFileContent(fileHandle, filePath) {
|
|||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
||||
const isImage = imageExtensions.some(ext => fileName.toLowerCase().endsWith(ext));
|
||||
const lower = fileName.toLowerCase();
|
||||
const lastDot = lower.lastIndexOf('.');
|
||||
const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : '';
|
||||
|
||||
const isHtml = fileName.toLowerCase().endsWith('.html') || fileName.toLowerCase().endsWith('.htm');
|
||||
const isDocx = fileName.toLowerCase().endsWith('.docx');
|
||||
const isXlsx = fileName.toLowerCase().endsWith('.xlsx') || fileName.toLowerCase().endsWith('.xls');
|
||||
const isPdf = fileName.toLowerCase().endsWith('.pdf');
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
||||
const isImage = imageExtensions.some(e => lower.endsWith(e));
|
||||
const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext);
|
||||
const isZip = lower.endsWith('.zip');
|
||||
const isHtml = lower.endsWith('.html') || lower.endsWith('.htm');
|
||||
const isDocx = lower.endsWith('.docx');
|
||||
const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls');
|
||||
const isPdf = lower.endsWith('.pdf');
|
||||
|
||||
if (isImage) {
|
||||
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isTiff) {
|
||||
displayTiffPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isZip) {
|
||||
displayZipPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isHtml) {
|
||||
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isDocx) {
|
||||
|
|
@ -4041,6 +4742,94 @@ async function displayImagePreview(file, filePath, fileName, fileHandle, lastMod
|
|||
editorInstances.set(filePath, instanceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas).
|
||||
*/
|
||||
async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) return;
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existing = editorInstances.get(filePath);
|
||||
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const tiffContainer = document.createElement('div');
|
||||
tiffContainer.className = 'flex-1 min-h-0';
|
||||
tiffContainer.style.display = 'flex';
|
||||
tiffContainer.style.flexDirection = 'column';
|
||||
fileViewContainer.appendChild(tiffContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName });
|
||||
} catch (err) {
|
||||
console.error('Error rendering TIFF:', err);
|
||||
tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err);
|
||||
}
|
||||
|
||||
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display ZIP listing using shared zddc.preview.renderZipListing.
|
||||
*/
|
||||
async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) return;
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existing = editorInstances.get(filePath);
|
||||
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const zipContainer = document.createElement('div');
|
||||
zipContainer.className = 'flex-1 min-h-0';
|
||||
zipContainer.style.display = 'flex';
|
||||
zipContainer.style.flexDirection = 'column';
|
||||
fileViewContainer.appendChild(zipContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName });
|
||||
} catch (err) {
|
||||
console.error('Error rendering ZIP listing:', err);
|
||||
zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err);
|
||||
}
|
||||
|
||||
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display HTML preview in sandboxed iframe
|
||||
*/
|
||||
|
|
@ -101,7 +101,7 @@ function renderFileTree() {
|
|||
|
||||
const scratchLabel = document.createElement('span');
|
||||
scratchLabel.className = 'tree-row__label';
|
||||
scratchLabel.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div>';
|
||||
scratchLabel.innerHTML = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
|
||||
scratchpadElement.appendChild(scratchLabel);
|
||||
|
||||
const scratchActions = document.createElement('div');
|
||||
|
|
@ -169,7 +169,10 @@ function renderFileTree() {
|
|||
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
|
||||
} else {
|
||||
dirName.textContent = `📁 ${name}`;
|
||||
// Non-ZDDC folder: still wrap in filename-main so
|
||||
// typography matches the two-line entries (same font
|
||||
// size + weight; just no secondary line).
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const dirLabel = document.createElement('span');
|
||||
|
|
@ -209,24 +212,30 @@ function renderFileTree() {
|
|||
|
||||
const fileIcon = getFileTypeIcon(name);
|
||||
|
||||
let fileNameDisplay;
|
||||
// Build the inner two-line text inside a tree-row__name
|
||||
// wrapper (column-flex). ZDDC-conforming filenames split
|
||||
// into title + meta; "Title - filename.ext" pattern uses
|
||||
// the dash as the same split. Plain names get a single
|
||||
// line via filename-main only — same wrapper, just no
|
||||
// secondary div, so the layout stays consistent.
|
||||
let fileNameInner;
|
||||
const parsed = zddc.parseFilename(name);
|
||||
if (parsed && parsed.valid) {
|
||||
const titleDisplay = escapeHtml(parsed.title);
|
||||
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
} else if (name.includes(' - ')) {
|
||||
const dashIdx = name.lastIndexOf(' - ');
|
||||
const secondary = escapeHtml(name.substring(0, dashIdx));
|
||||
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
} else {
|
||||
fileNameDisplay = `<span>${fileIcon} ${escapeHtml(name)}</span>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement('span');
|
||||
fileLabel.className = 'tree-row__label';
|
||||
fileLabel.innerHTML = fileNameDisplay;
|
||||
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
|
||||
|
||||
const fileActions = createActionButtons(filePath, 'file');
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ export default defineConfig({
|
|||
name: 'archive',
|
||||
testMatch: 'archive.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'archive-cascade',
|
||||
testMatch: 'archive-cascade.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'landing',
|
||||
testMatch: 'landing.spec.js',
|
||||
|
|
|
|||
|
|
@ -22,22 +22,28 @@
|
|||
# is_release, is_red, channel.
|
||||
# See "Channels and release args" below.
|
||||
# promote_release <tool> — for stable / alpha / beta, copy the dist
|
||||
# HTML into website/releases/. Stable cuts
|
||||
# write the immutable per-version file +
|
||||
# HTML into the release-output bundle
|
||||
# (default $root_dir/../dist/release-output;
|
||||
# override $ZDDC_DEPLOY_RELEASES_DIR). Stable
|
||||
# cuts write the immutable per-version file +
|
||||
# refresh five symlinks (_v<X.Y>, _v<X>,
|
||||
# _stable, _beta, _alpha) and tag
|
||||
# <tool>-v<X.Y.Z>. Alpha/beta cuts
|
||||
# overwrite the channel mirror in place
|
||||
# and cascade alpha → beta. No git tags
|
||||
# for alpha/beta cuts; no Codeberg upload
|
||||
# for HTML tools. See ARCHITECTURE.md
|
||||
# for alpha/beta cuts. The bundle is a
|
||||
# complete intended-live snapshot — the
|
||||
# top-level ./build seeds it from
|
||||
# /srv/zddc/releases/ before per-tool
|
||||
# promote runs, then ./deploy --releases
|
||||
# rsyncs it back. See ARCHITECTURE.md
|
||||
# "Channels" for the full table.
|
||||
#
|
||||
# Channels and release args:
|
||||
# <none> dev build, dist/ only, label
|
||||
# <none> dev build, tool/dist/ only, label
|
||||
# "v<next-stable>-alpha · <ts> · <sha>[-dirty]" (red).
|
||||
# No website/releases/ side-effect. To publish, re-run
|
||||
# with `--release alpha`.
|
||||
# No release-output side-effect. To produce a deployable
|
||||
# bundle, re-run with `--release alpha`.
|
||||
# --release stable, auto-bump patch from latest tag (or 0.0.1).
|
||||
# Writes per-version file + symlinks; tags vX.Y.Z.
|
||||
# --release X.Y.Z stable, explicit version.
|
||||
|
|
@ -56,9 +62,11 @@ if [ -z "${root_dir:-}" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# NOTE: shared/publish-codeberg-release.sh is no longer sourced here.
|
||||
# HTML tools publish to website/releases/ as committed static files; only
|
||||
# zddc-server/release.sh uploads to Codeberg (it sources the helper directly).
|
||||
# NOTE: there's no Codeberg release-asset publication path anymore. All
|
||||
# release artifacts (HTML tools + zddc-server binaries) materialize in
|
||||
# dist/release-output/ via the lockstep ./build, then ./deploy rsyncs
|
||||
# them to /srv/zddc/ on the deploy host. The deprecated zddc/release.sh
|
||||
# is now a no-op guard that prints a redirection message.
|
||||
|
||||
# Fail hard on any missing source file
|
||||
ensure_exists() {
|
||||
|
|
@ -169,6 +177,7 @@ compute_build_label() {
|
|||
fi
|
||||
channel="alpha"
|
||||
build_label="v${_next_stable}-alpha · ${build_timestamp} · ${_sha}"
|
||||
_emit_build_label_sidecar "$_tool"
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
|
@ -180,6 +189,7 @@ compute_build_label() {
|
|||
_date=$(date -u +"%Y-%m-%d")
|
||||
_sha=$(git -C "$root_dir" rev-parse --short=7 HEAD 2>/dev/null || echo "unknown")
|
||||
build_label="v${_next_stable}-${channel} · ${_date} · ${_sha}"
|
||||
_emit_build_label_sidecar "$_tool"
|
||||
return 0
|
||||
;;
|
||||
'')
|
||||
|
|
@ -195,11 +205,30 @@ compute_build_label() {
|
|||
channel="stable"
|
||||
is_red=0
|
||||
build_label="v${build_version}"
|
||||
_emit_build_label_sidecar "$_tool"
|
||||
}
|
||||
|
||||
# Compute the next-stable target version for a tool — i.e., the patch-bump
|
||||
# of the latest clean <tool>-vX.Y.Z tag. Used by compute_build_label to
|
||||
# embed the target version in alpha/beta labels.
|
||||
# Write the resolved build label to a sidecar file the top-level build.sh
|
||||
# reads to assemble zddc/internal/apps/embedded/versions.txt. No-op when
|
||||
# BUILD_LABELS_DIR is not set in the env (tools built standalone).
|
||||
_emit_build_label_sidecar() {
|
||||
if [ -z "${BUILD_LABELS_DIR:-}" ]; then
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$BUILD_LABELS_DIR"
|
||||
printf '%s\n' "$build_label" > "$BUILD_LABELS_DIR/$1.label"
|
||||
}
|
||||
|
||||
# Tools that participate in the lockstep release. Source of truth — used
|
||||
# by helpers that enumerate "all release artifacts" (matrix render,
|
||||
# coordinated next-stable, channel-link verifier).
|
||||
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing 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() {
|
||||
_t="$1"
|
||||
_latest=$(git -C "$root_dir" tag --list "${_t}-v*" 2>/dev/null \
|
||||
|
|
@ -215,20 +244,46 @@ _next_stable_for_tool() {
|
|||
echo "${_major}.${_minor}.$((_patch + 1))"
|
||||
}
|
||||
|
||||
# Promote a built dist file to website/releases/. Reads from caller scope:
|
||||
# $channel ("stable" / "alpha" / "beta"), $build_version (stable only),
|
||||
# $output_html, $root_dir.
|
||||
# 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 the release-output bundle. Reads from caller
|
||||
# scope: $channel ("stable" / "alpha" / "beta"), $build_version (stable only),
|
||||
# $output_html, $root_dir. Bundle path resolves from $ZDDC_DEPLOY_RELEASES_DIR
|
||||
# (default $root_dir/../dist/release-output).
|
||||
#
|
||||
# Stable cuts:
|
||||
# 1. Skip if source unchanged since latest stable tag.
|
||||
# 2. Copy dist HTML → website/releases/<tool>_v<X.Y.Z>.html (immutable).
|
||||
# 2. Copy dist HTML → <bundle>/<tool>_v<X.Y.Z>.html (immutable).
|
||||
# 3. Refresh symlinks: _v<X.Y>, _v<X>, _stable, _beta, _alpha all → the
|
||||
# new versioned file. Cascade rule: stable cut means beta and alpha
|
||||
# reset to stable (no active dev on either downstream channel).
|
||||
# 4. Tag the commit <tool>-v<X.Y.Z>.
|
||||
#
|
||||
# Alpha/beta cuts:
|
||||
# 1. Overwrite website/releases/<tool>_<channel>.html with dist HTML
|
||||
# 1. Overwrite <bundle>/<tool>_<channel>.html with dist HTML
|
||||
# (replaces a symlink with real bytes if one was there).
|
||||
# 2. For beta: cascade <tool>_alpha.html → <tool>_beta.html (symlink),
|
||||
# since alpha defaults to beta when no active alpha.
|
||||
|
|
@ -241,7 +296,12 @@ _next_stable_for_tool() {
|
|||
# handles binary uploads to Codeberg directly (different distribution model).
|
||||
promote_release() {
|
||||
_tool="$1"
|
||||
_releases_dir="$root_dir/../website/releases"
|
||||
# The top-level `./build` exports $ZDDC_DEPLOY_RELEASES_DIR pointing
|
||||
# at $SCRIPT_DIR/dist/release-output. Single-tool standalone
|
||||
# invocations fall back to the same default — no inheritance from a
|
||||
# parent build run.
|
||||
_releases_dir="${ZDDC_DEPLOY_RELEASES_DIR:-$root_dir/../dist/release-output}"
|
||||
mkdir -p "$_releases_dir"
|
||||
|
||||
if [ ! -d "$_releases_dir" ]; then
|
||||
echo "promote_release: $_releases_dir not found" >&2
|
||||
|
|
@ -337,3 +397,308 @@ _promote_channel() {
|
|||
|
||||
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 the
|
||||
# release-output bundle. 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 cut (idempotent).
|
||||
#
|
||||
# $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 the
|
||||
# release-output bundle. Called by the top-level ./build 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=');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 9.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 KiB |
|
|
@ -1,27 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW X7 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="3.33333in" height="3.33333in" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 3333 3333"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil1 {fill:#0AB3F7}
|
||||
.fil2 {fill:#1C283F}
|
||||
.fil0 {fill:white}
|
||||
.fil3 {fill:#1C283F;fill-rule:nonzero}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<rect class="fil0" width="3333" height="3333"/>
|
||||
<g id="_434161592">
|
||||
<path class="fil1" d="M1732 1575l-1008 0 0 183 1008 0 0 -183zm-974 36l22 0 33 86 34 -86 21 0 -46 112 -18 0 -46 -112zm193 44l-10 23 0 0 -19 45 -20 0 40 -90 9 22zm0 -45l19 0 49 113 -18 0 -50 -113zm291 45l-10 23 0 0 -7 18 0 0 -12 27 -20 0 39 -90 10 22zm0 -45l18 0 50 113 -19 0 -49 -113zm151 114c-9,0 -17,-1 -25,-4 -8,-3 -15,-8 -22,-14l12 -14c5,5 11,8 16,11 6,2 12,4 19,4 6,0 11,-2 15,-4 3,-3 5,-6 5,-10l0 -1c0,-2 0,-3 -1,-5 -1,-2 -2,-3 -4,-4 -2,-2 -4,-3 -8,-4 -3,-1 -7,-3 -12,-4 -6,-1 -12,-3 -16,-4 -5,-2 -9,-4 -12,-7 -3,-2 -6,-5 -7,-9 -2,-4 -3,-8 -3,-13l0 0c0,-5 1,-10 3,-14 2,-4 5,-7 8,-10 3,-3 7,-5 12,-6 5,-2 10,-3 15,-3 9,0 16,1 22,4 7,2 13,6 19,10l-11 15c-5,-4 -10,-6 -15,-8 -5,-2 -10,-3 -15,-3 -6,0 -10,1 -13,3 -4,3 -5,6 -5,10l0 0c0,2 0,4 1,6 1,1 2,3 4,4 2,2 5,3 8,4 4,1 8,2 13,4 6,1 11,3 16,5 5,1 8,4 11,6 3,3 6,6 7,9 2,4 2,8 2,12l0 1c0,5 -1,10 -3,14 -1,4 -4,7 -8,10 -3,3 -7,5 -12,7 -5,1 -10,2 -16,2zm120 -46l-44 -67 23 0 41 67 0 45 -20 0 0 -45zm23 -38l19 -29 22 0 -30 46 -11 -17zm122 84c-8,0 -17,-1 -24,-4 -8,-3 -16,-8 -22,-14l12 -14c5,5 10,8 16,11 6,2 12,4 19,4 6,0 11,-2 14,-4 4,-3 6,-6 6,-10l0 -1c0,-2 -1,-3 -1,-5 -1,-2 -2,-3 -4,-4 -2,-2 -5,-3 -8,-4 -3,-1 -7,-3 -13,-4 -6,-1 -11,-3 -16,-4 -4,-2 -8,-4 -11,-7 -3,-2 -6,-5 -7,-9 -2,-4 -3,-8 -3,-13l0 0c0,-5 1,-10 3,-14 2,-4 4,-7 8,-10 3,-3 7,-5 12,-6 4,-2 10,-3 15,-3 8,0 16,1 22,4 7,2 13,6 19,10l-11 15c-5,-4 -10,-6 -15,-8 -5,-2 -10,-3 -15,-3 -6,0 -10,1 -14,3 -3,3 -4,6 -4,10l0 0c0,2 0,4 1,6 0,1 2,3 4,4 2,2 4,3 8,4 3,1 8,2 13,4 6,1 11,3 16,5 4,1 8,4 11,6 3,3 5,6 7,9 1,4 2,8 2,12l0 1c0,5 -1,10 -3,14 -2,4 -5,7 -8,10 -3,3 -8,5 -13,7 -4,1 -10,2 -16,2zm-547 -58c7,0 12,-2 17,-5 4,-3 6,-8 6,-14l0 0c0,-6 -2,-11 -6,-14 -4,-3 -10,-5 -17,-5l-49 0 0 -18 50 0c7,0 14,1 19,3 5,2 10,5 14,9 3,3 5,6 7,10 1,4 2,9 2,14l0 0c0,5 -1,9 -2,12 -1,4 -3,7 -6,10 -2,3 -5,6 -8,7 -3,2 -7,4 -11,5l30 43 -23 0 -27 -40 -1 0 -24 0 0 40 -20 0 0 -57 49 0z"/>
|
||||
<rect class="fil2" x="1778" y="1553" width="6.50317" height="228.228"/>
|
||||
<path class="fil3" d="M1836 1596c0,-7 6,-13 16,-13 8,0 16,3 23,9l5 -7c-8,-6 -16,-10 -27,-10 -15,0 -26,9 -26,22 0,13 8,18 26,22 16,4 20,8 20,15 0,8 -7,14 -17,14 -11,0 -19,-4 -27,-11l-5 6c9,8 19,12 32,12 15,0 26,-8 26,-22 0,-12 -8,-18 -25,-22 -17,-3 -21,-8 -21,-15zm71 -19l0 77 9 0 0 -77 -9 0zm104 14l0 63 9 0 0 -77 -9 0 -28 42 -29 -42 -9 0 0 77 9 0 0 -63 28 42 1 0 28 -42zm96 10c0,-15 -12,-24 -29,-24l-30 0 0 77 9 0 0 -27 19 0c17,0 31,-9 31,-26zm-9 1c0,10 -9,17 -22,17l-19 0 0 -34 20 0c12,0 21,5 21,17zm31 52l53 0 0 -8 -44 0 0 -69 -9 0 0 77zm76 -77l0 77 9 0 0 -77 -9 0zm94 0l-56 0 0 77 9 0 0 -34 42 0 0 -8 -42 0 0 -27 47 0 0 -8zm57 46l32 -46 -10 0 -26 38 -27 -38 -10 0 32 47 0 30 9 0 0 -31zm54 -46l0 77 8 0 0 -77 -8 0zm95 62l-49 -62 -8 0 0 77 8 0 0 -63 50 63 7 0 0 -77 -8 0 0 62zm104 5l0 -31 -32 0 0 8 24 0 0 19c-6,4 -14,8 -23,8 -19,0 -30,-14 -30,-33 0,-17 12,-32 29,-32 11,0 18,4 24,9l5 -6c-7,-7 -15,-11 -29,-11 -23,0 -39,19 -39,41 0,22 16,40 40,40 13,0 24,-6 31,-12z"/>
|
||||
<path class="fil3" d="M1894 1744l-6 -5c-7,7 -14,11 -25,11 -17,0 -30,-14 -30,-32 0,-18 13,-32 30,-32 11,0 18,4 25,10l6 -6c-8,-7 -17,-12 -31,-12 -23,0 -39,18 -39,40 0,22 16,40 39,40 14,0 23,-5 31,-14zm94 -26c0,-22 -16,-40 -40,-40 -24,0 -40,18 -40,40 0,21 16,40 40,40 24,0 40,-19 40,-40zm-9 0c0,18 -13,32 -31,32 -18,0 -31,-15 -31,-32 0,-18 13,-32 31,-32 18,0 31,14 31,32zm98 -24l0 63 8 0 0 -78 -9 0 -28 42 -28 -42 -9 0 0 78 8 0 0 -63 29 41 0 0 29 -41zm93 10c0,-16 -11,-25 -29,-25l-29 0 0 78 9 0 0 -28 19 0c16,0 30,-8 30,-25zm-9 0c0,10 -8,17 -21,17l-19 0 0 -34 19 0c13,0 21,6 21,17zm30 53l53 0 0 -8 -44 0 0 -70 -9 0 0 78zm130 -78l-57 0 0 78 57 0 0 -8 -48 0 0 -28 43 0 0 -8 -43 0 0 -26 48 0 0 -8zm87 78l-30 -40 29 -38 -10 0 -24 32 -24 -32 -10 0 29 38 -30 40 10 0 25 -34 25 34 10 0zm22 -78l0 78 8 0 0 -78 -8 0zm65 8l26 0 0 -8 -61 0 0 8 26 0 0 70 9 0 0 -70zm81 39l32 -47 -10 0 -27 39 -26 -39 -10 0 32 47 0 31 9 0 0 -31z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
|
@ -1,246 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC — Zero Day Document Control</title>
|
||||
<meta name="description" content="A file-naming convention and a small set of single-file HTML tools for managing project deliverables. Self-contained, offline-capable, dependency-free.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://zddc.varasys.io/">
|
||||
<meta property="og:title" content="ZDDC — Zero Day Document Control">
|
||||
<meta property="og:description" content="A file-naming convention and a small set of single-file HTML tools for managing project deliverables. Self-contained, offline-capable, dependency-free.">
|
||||
<meta name="theme-color" content="#2a5a8a">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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 rel="stylesheet" href="css/style.css">
|
||||
<style>
|
||||
.channel-row { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; font-size: 0.9rem; }
|
||||
.channel-row a { display: inline-block; padding: 0.25rem 0.625rem; border-radius: 999px; text-decoration: none; border: 1px solid var(--color-border); color: var(--color-text); }
|
||||
.channel-row a:hover { background: var(--color-bg-subtle); }
|
||||
.channel-row .channel-stable { border-color: var(--color-primary); color: var(--color-primary); font-weight: 600; }
|
||||
.channel-row .channel-beta, .channel-row .channel-alpha { color: var(--color-text-muted); }
|
||||
.install-grid { display: grid; grid-template-columns: 1fr; gap: var(--spacing-md); margin-top: var(--spacing-md); }
|
||||
.install-card { padding: var(--spacing-md); border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-bg-subtle); }
|
||||
.install-card h3 { margin-top: 0; margin-bottom: 0.25rem; }
|
||||
.install-card .when { color: var(--color-text-muted); font-size: 0.92em; margin: 0.1rem 0 0.6rem 0; }
|
||||
.install-card pre { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 6px; padding: 0.6rem 0.8rem; overflow-x: auto; font-size: 0.82em; line-height: 1.45; margin: 0; }
|
||||
.install-card pre code { font-family: "SF Mono", Menlo, Consolas, monospace; }
|
||||
.install-points { margin: 0.4rem 0 0.4rem 1.4rem; padding: 0; line-height: 1.65; }
|
||||
.install-points li { margin-bottom: 0.15rem; }
|
||||
.mode-grid { display: grid; grid-template-columns: 1fr; gap: var(--spacing-md); margin-top: var(--spacing-md); }
|
||||
@media (min-width: 720px) { .mode-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.mode-card { padding: var(--spacing-md); border: 1px solid var(--color-border); border-radius: 8px; }
|
||||
.mode-card h3 { margin-top: 0; }
|
||||
code.inline { background: var(--color-bg-subtle); padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.9em; }
|
||||
.feature-list { line-height: 1.8; padding-left: 1.5rem; color: var(--color-text); }
|
||||
.feature-list li { margin-bottom: 0.4rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="site-header">
|
||||
<div class="container header-content">
|
||||
<a href="/" class="brand">
|
||||
<svg class="brand-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="brand-name">ZDDC</span>
|
||||
</a>
|
||||
<nav class="header-nav">
|
||||
<div class="dropdown">
|
||||
<button class="dropdown-toggle" type="button" aria-haspopup="true">
|
||||
<span>Tools</span>
|
||||
<svg viewBox="0 0 24 24" style="width: 14px; height: 14px; fill: currentColor;">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<div class="dropdown-menu__inner">
|
||||
<a href="releases/archive_stable.html">
|
||||
<svg class="dropdown-menu-icon" viewBox="0 0 24 24"><path d="M20 6H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2zm0 12H4V8h16v10zM4 2h16v2H4z"/></svg>
|
||||
Archive Browser
|
||||
</a>
|
||||
<a href="releases/transmittal_stable.html">
|
||||
<svg class="dropdown-menu-icon" viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
Transmittal Creator
|
||||
</a>
|
||||
<a href="releases/classifier_stable.html">
|
||||
<svg class="dropdown-menu-icon" viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||
Document Classifier
|
||||
</a>
|
||||
<a href="releases/mdedit_stable.html">
|
||||
<svg class="dropdown-menu-icon" viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
Markdown Editor
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="reference.html" class="nav-link">Docs</a>
|
||||
<a href="releases/" class="nav-link">Releases</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1>Zero Day Document Control</h1>
|
||||
<p class="hero-subtitle">A file-naming convention and a small set of single-file HTML tools for managing project deliverables. Self-contained, offline-capable, dependency-free.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
||||
|
||||
<section style="margin-top: var(--spacing-xl);">
|
||||
<h2>What is it?</h2>
|
||||
<p>ZDDC is a convention, not a platform. Every deliverable's filename encodes its tracking number, revision, status, and title; every transmittal folder is date-prefixed and self-describing. A plain shared folder becomes a fully searchable, auditable information-management system — no server, no database, no software required to read the archive.</p>
|
||||
<p>The four tools below are <em>optional</em> interfaces around this structure. Each is a single self-contained HTML file that works two ways: open it locally and point it at a folder on your disk, or put it behind any web server (including the optional <code class="inline">zddc-server</code> described below) and use it over the network. Same on-disk layout either way.</p>
|
||||
<p style="margin-top: var(--spacing-md);"><a href="reference.html">Read the full specification →</a></p>
|
||||
</section>
|
||||
|
||||
<section style="margin-top: var(--spacing-2xl);">
|
||||
<h2>Try the tools</h2>
|
||||
<p>Each tool is published in three channels. <strong>Stable</strong> is versioned and immutable; <strong>beta</strong> and <strong>alpha</strong> are mutable previews of in-flight work.</p>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: var(--spacing-lg); margin-top: var(--spacing-lg);">
|
||||
|
||||
<div class="tool-card">
|
||||
<div class="tool-card__title">Archive Browser</div>
|
||||
<div class="tool-card__desc">Browse, search, and filter your project folder. Group by transmittal; filter by tracking number, revision, status, or free text. Export selections as ZIP.</div>
|
||||
<div class="channel-row">
|
||||
<a href="releases/archive_stable.html" class="channel-stable">stable</a>
|
||||
<a href="releases/archive_beta.html" class="channel-beta">beta</a>
|
||||
<a href="releases/archive_alpha.html" class="channel-alpha">alpha</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<div class="tool-card__title">Transmittal Creator</div>
|
||||
<div class="tool-card__desc">Fill in metadata, drag in files, publish a self-contained HTML transmittal record with SHA-256 checksums. Optional digital signatures. The published file <em>is</em> the transmittal record.</div>
|
||||
<div class="channel-row">
|
||||
<a href="releases/transmittal_stable.html" class="channel-stable">stable</a>
|
||||
<a href="releases/transmittal_beta.html" class="channel-beta">beta</a>
|
||||
<a href="releases/transmittal_alpha.html" class="channel-alpha">alpha</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<div class="tool-card__title">Document Classifier</div>
|
||||
<div class="tool-card__desc">Spreadsheet-like interface for bulk-renaming files into ZDDC format. Copy/paste with Excel. Point it at a folder, fill in the columns, save all at once.</div>
|
||||
<div class="channel-row">
|
||||
<a href="releases/classifier_stable.html" class="channel-stable">stable</a>
|
||||
<a href="releases/classifier_beta.html" class="channel-beta">beta</a>
|
||||
<a href="releases/classifier_alpha.html" class="channel-alpha">alpha</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card">
|
||||
<div class="tool-card__title">Markdown Editor</div>
|
||||
<div class="tool-card__desc">Browser-based markdown editor with live preview, YAML front matter, and table of contents. Direct local file access via the File System Access API.</div>
|
||||
<div class="channel-row">
|
||||
<a href="releases/mdedit_stable.html" class="channel-stable">stable</a>
|
||||
<a href="releases/mdedit_beta.html" class="channel-beta">beta</a>
|
||||
<a href="releases/mdedit_alpha.html" class="channel-alpha">alpha</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p style="margin-top: var(--spacing-lg); color: var(--color-text-muted);">Append <code class="inline">?v=alpha</code> (or <code class="inline">?v=0.0.4</code>, etc.) to any URL to switch versions for a single request — useful for sharing a link to an exact build. Direct local-folder access requires a Chromium-based browser (the File System Access API is unavailable in Firefox / Safari). <a href="releases/">Browse all versions →</a></p>
|
||||
</section>
|
||||
|
||||
<!-- zddc-server (inline, replaces the previous separate page) -->
|
||||
<section style="margin-top: var(--spacing-2xl);">
|
||||
<h2>zddc-server (optional)</h2>
|
||||
<p>The tools work two ways over the same on-disk archive. Pick whichever fits your team:</p>
|
||||
|
||||
<div class="mode-grid">
|
||||
<div class="mode-card">
|
||||
<h3>Local directory mode</h3>
|
||||
<p>Open a tool, click <em>Add Directory</em>, point it at a folder. The tool reads files via the File System Access API. No upload, no server, no account.</p>
|
||||
<p>Enough for individual users and small teams on a shared drive (network share, Dropbox, OneDrive, syncthing).</p>
|
||||
</div>
|
||||
<div class="mode-card">
|
||||
<h3>Online mode</h3>
|
||||
<p>Take the same local directory and put it behind any web server (nginx, Caddy, Apache, or <code class="inline">zddc-server</code>). The Archive Browser tool talks to the server's directory listings instead of the local filesystem — read-only, works in any browser.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: var(--spacing-md);"><strong><code class="inline">zddc-server</code></strong> is a small Go binary purpose-built to serve ZDDC archives. <em>Any</em> web server gives you online mode; <code class="inline">zddc-server</code> adds things a generic web server can't:</p>
|
||||
|
||||
<ul class="feature-list">
|
||||
<li><strong>Access control via <code class="inline">.zddc</code> files.</strong> Behind a reverse proxy that authenticates users and sets an <code class="inline">X-Auth-Request-Email</code> request header, <code class="inline">zddc-server</code> consults YAML <code class="inline">.zddc</code> files in directories — cascading bottom-up; deeper rules override. No database, no admin UI.</li>
|
||||
<li><strong>Virtual <code class="inline">.archive</code> URL space.</strong> <code class="inline">GET /Project/.archive/123-XYZ.html</code> resolves to the canonical revision file at request time. Computed from filenames; no cache, no separate index file.</li>
|
||||
<li><strong>Per-request access logging</strong> keyed to the authenticated user.</li>
|
||||
<li><strong>TLS, ETags, conditional GET, CORS, autoindex.</strong> The mundane glue.</li>
|
||||
</ul>
|
||||
|
||||
<p style="margin-top: var(--spacing-md);">The on-disk layout is the same in both modes. Stop the server and the directory is still a perfectly valid ZDDC archive that opens in local-directory mode. <strong>The server is convenience, not lock-in.</strong></p>
|
||||
|
||||
<p style="margin-top: var(--spacing-md);">Source, environment-variable contract, and ACL syntax: <a href="https://codeberg.org/VARASYS/ZDDC/src/branch/main/zddc">codeberg.org/VARASYS/ZDDC <code class="inline">zddc/</code></a>. Pre-built binaries are published as Codeberg release assets; example Helm charts (production + dev) live under <code class="inline">helm/</code> in the repo and compile from source at deploy time.</p>
|
||||
</section>
|
||||
|
||||
<section style="margin-top: var(--spacing-2xl);">
|
||||
<h2>Install on your server</h2>
|
||||
<p>Two paths, no install scripts. The server has built-in fetch-and-cache for the tool HTMLs; the local-file path needs nothing more than a download.</p>
|
||||
|
||||
<div class="install-grid">
|
||||
<div class="install-card">
|
||||
<h3>Server: just run zddc-server</h3>
|
||||
<p class="when">The binary has the current-stable build of all five tools baked in at compile time. They appear automatically at the right paths under <code class="inline">ZDDC_ROOT</code>:</p>
|
||||
<ul class="install-points">
|
||||
<li><strong>archive.html</strong> at every level (root, project, archive, vendor)</li>
|
||||
<li><strong>classifier.html</strong> in any <code class="inline">Incoming</code>, <code class="inline">Working</code>, or <code class="inline">Staging</code> directory and its subtree</li>
|
||||
<li><strong>mdedit.html</strong> in any <code class="inline">Working</code> directory and its subtree</li>
|
||||
<li><strong>transmittal.html</strong> in any <code class="inline">Staging</code> directory and its subtree</li>
|
||||
<li><strong>index.html</strong> (the project picker) at the deployment root</li>
|
||||
</ul>
|
||||
<pre><code>ZDDC_ROOT=/srv/zddc ./zddc-server</code></pre>
|
||||
<p class="when" style="margin-top: 0.6rem;"><strong>To override a tool</strong> at any path: drop a real <code class="inline">.html</code> file there — that file wins over the baked-in version. <strong>To pin a different version</strong>, write an <code class="inline">apps:</code> entry in any <code class="inline">.zddc</code> file along the path:</p>
|
||||
<pre><code># <project>/.zddc
|
||||
apps:
|
||||
classifier: stable # or beta / alpha / v0.0.4 / v0.0 / v0
|
||||
archive: https://my-fork.example/archive.html</code></pre>
|
||||
<p class="when" style="margin-top: 0.6rem;">URL sources are fetched once and cached in <code class="inline"><ZDDC_ROOT>/_app/</code>. To force a re-fetch, delete the cache file. Closer-to-leaf <code class="inline">.zddc</code> entries override parent ones.</p>
|
||||
</div>
|
||||
|
||||
<div class="install-card">
|
||||
<h3>Local: just download the .html file</h3>
|
||||
<p class="when">No server, no install — open in any modern browser.</p>
|
||||
<ul class="install-points">
|
||||
<li><a href="releases/archive_stable.html">archive.html</a></li>
|
||||
<li><a href="releases/transmittal_stable.html">transmittal.html</a></li>
|
||||
<li><a href="releases/classifier_stable.html">classifier.html</a></li>
|
||||
<li><a href="releases/mdedit_stable.html">mdedit.html</a></li>
|
||||
</ul>
|
||||
<p class="when" style="margin-top: 0.6rem;">Right-click → Save As. Each tool is a self-contained HTML file with everything inlined; works from <code class="inline">file://</code> or any static host.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style="margin-top: var(--spacing-2xl);">
|
||||
<h2>Learn more</h2>
|
||||
<ul style="line-height: 1.9;">
|
||||
<li><a href="reference.html">Technical Reference</a> — the full ZDDC convention: filename format, tracking numbers, revisions, status codes, folder naming, transmittal workflow.</li>
|
||||
<li><a href="releases/">All releases</a> — every version and channel build of every tool, with per-version pin URLs.</li>
|
||||
<li><a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a> — source code, issue tracker, contributor docs.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-content">
|
||||
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/layout.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
// Simple tab switching for ZDDC site
|
||||
// Include via <script src="/js/layout.js"></script> at end of <body>
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Theme system
|
||||
const themeKey = 'zddc-theme';
|
||||
const themes = ['system', 'light', 'dark'];
|
||||
let currentThemeIndex = 0;
|
||||
|
||||
// SVG icons for theme toggle
|
||||
const icons = {
|
||||
system: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><polyline points="8 21 12 17 16 21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
||||
light: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>',
|
||||
dark: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
|
||||
};
|
||||
|
||||
function getStoredTheme() {
|
||||
const stored = localStorage.getItem(themeKey);
|
||||
if (stored && themes.includes(stored)) {
|
||||
return stored;
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function setThemeIndex(index) {
|
||||
currentThemeIndex = index;
|
||||
const theme = themes[index];
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
} else if (theme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem(themeKey, theme);
|
||||
updateToggleIcon();
|
||||
}
|
||||
|
||||
function updateToggleIcon() {
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
if (!btn) return;
|
||||
const theme = themes[currentThemeIndex];
|
||||
btn.innerHTML = icons[theme];
|
||||
btn.setAttribute('aria-label', 'Toggle theme (current: ' + theme + ')');
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
currentThemeIndex = (currentThemeIndex + 1) % themes.length;
|
||||
setThemeIndex(currentThemeIndex);
|
||||
}
|
||||
|
||||
function createToggle() {
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'theme-toggle';
|
||||
toggle.setAttribute('aria-label', 'Toggle theme');
|
||||
toggle.onclick = cycleTheme;
|
||||
return toggle;
|
||||
}
|
||||
|
||||
// Apply stored theme early (before DOM queries)
|
||||
const storedTheme = getStoredTheme();
|
||||
currentThemeIndex = themes.indexOf(storedTheme);
|
||||
if (currentThemeIndex === -1) currentThemeIndex = 0;
|
||||
applyTheme(storedTheme);
|
||||
|
||||
// Create and inject toggle button after DOM ready
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const toggle = createToggle();
|
||||
const headerNav = document.querySelector('.header-nav');
|
||||
|
||||
if (headerNav) {
|
||||
// Priority 1: .header-nav — append as last child (after Docs link)
|
||||
headerNav.appendChild(toggle);
|
||||
} else {
|
||||
const navTabs = document.querySelector('.nav-tabs');
|
||||
if (navTabs) {
|
||||
// Priority 2: .nav-tabs — insert as sibling AFTER .nav-tabs
|
||||
navTabs.parentNode.insertBefore(toggle, navTabs.nextSibling);
|
||||
} else {
|
||||
const headerContent = document.querySelector('.header-content');
|
||||
if (headerContent) {
|
||||
// Priority 3: .header-content — append as last child
|
||||
headerContent.appendChild(toggle);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateToggleIcon();
|
||||
});
|
||||
|
||||
// Dropdown: click toggle for touch devices (CSS :hover handles desktop)
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('.dropdown-toggle').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var menu = btn.closest('.dropdown').querySelector('.dropdown-menu');
|
||||
if (menu) menu.classList.toggle('show');
|
||||
});
|
||||
});
|
||||
document.addEventListener('click', function () {
|
||||
document.querySelectorAll('.dropdown-menu.show').forEach(function (m) {
|
||||
m.classList.remove('show');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Tab switching (unchanged)
|
||||
document.querySelectorAll('button[data-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('button[data-tab]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const tab = btn.dataset.tab;
|
||||
if (tab === 'reference') {
|
||||
if (window.location.pathname.endsWith('index.html')) {
|
||||
window.location.hash = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
archive_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
archive_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
archive_v0.0.2.html
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
archive_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
archive_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
classifier_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
classifier_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
classifier_v0.0.2.html
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
classifier_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
classifier_v0.0.2.html
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Releases — ZDDC</title>
|
||||
<meta name="description" content="All released versions and channel builds of every ZDDC tool.">
|
||||
<meta name="theme-color" content="#2a5a8a">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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 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>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container header-content">
|
||||
<a href="/" class="brand">
|
||||
<svg class="brand-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="brand-name">ZDDC</span>
|
||||
</a>
|
||||
<nav class="header-nav">
|
||||
<a href="/" class="nav-link">Home</a>
|
||||
<a href="../reference.html" class="nav-link">Docs</a>
|
||||
<a href="index.html" class="nav-link active">Releases</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1>Releases</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="container" style="margin-bottom: var(--spacing-2xl);">
|
||||
<section class="rel-tool">
|
||||
<h2>Archive</h2>
|
||||
<div class="rel-channels">
|
||||
<a class="stable" href="archive_stable.html">stable</a>
|
||||
<a class="beta" href="archive_beta.html">beta</a>
|
||||
<a class="alpha" href="archive_alpha.html">alpha</a>
|
||||
</div>
|
||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
||||
<a href="archive_v0.0.2.html">v0.0.2</a>
|
||||
<a href="archive_v0.0.1.html">v0.0.1</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="rel-tool">
|
||||
<h2>Transmittal</h2>
|
||||
<div class="rel-channels">
|
||||
<a class="stable" href="transmittal_stable.html">stable</a>
|
||||
<a class="beta" href="transmittal_beta.html">beta</a>
|
||||
<a class="alpha" href="transmittal_alpha.html">alpha</a>
|
||||
</div>
|
||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
||||
<a href="transmittal_v0.0.2.html">v0.0.2</a>
|
||||
<a href="transmittal_v0.0.1.html">v0.0.1</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="rel-tool">
|
||||
<h2>Classifier</h2>
|
||||
<div class="rel-channels">
|
||||
<a class="stable" href="classifier_stable.html">stable</a>
|
||||
<a class="beta" href="classifier_beta.html">beta</a>
|
||||
<a class="alpha" href="classifier_alpha.html">alpha</a>
|
||||
</div>
|
||||
<div class="rel-versions"><strong>Pin to version:</strong>
|
||||
<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 style="margin-top: var(--spacing-2xl); color: var(--color-text-muted); font-size: 0.9rem;">
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="container footer-content">
|
||||
<span>ZDDC is open source — <a href="https://codeberg.org/VARASYS/ZDDC">codeberg.org/VARASYS/ZDDC</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1 +0,0 @@
|
|||
landing_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
landing_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
landing_v0.0.2.html
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
landing_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
landing_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
mdedit_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
mdedit_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
mdedit_v0.0.2.html
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
mdedit_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
mdedit_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
transmittal_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
transmittal_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
transmittal_v0.0.2.html
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
transmittal_v0.0.2.html
|
||||
|
|
@ -1 +0,0 @@
|
|||
transmittal_v0.0.2.html
|
||||
101
zddc/README.md
101
zddc/README.md
|
|
@ -226,6 +226,38 @@ endpoint returns **404** to every caller. Non-admin requests also receive 404
|
|||
(not 403) so the existence of the admin page is invisible to unauthorized
|
||||
callers.
|
||||
|
||||
### Forward-auth target for upstream proxies
|
||||
|
||||
`zddc-server` also exposes `GET /.auth/admin` — a machine-only endpoint that
|
||||
returns **200** if the caller's resolved email is in the root `.zddc` `admins:`
|
||||
list, **403** otherwise. No body, no redirect, no UI; it is a pure
|
||||
authorization decision intended to be polled by an upstream proxy's
|
||||
forward-auth directive (Caddy `forward_auth`, nginx `auth_request`, Traefik
|
||||
`ForwardAuth`, etc.).
|
||||
|
||||
The intended use case is gating *adjacent* services on the same pod / host
|
||||
that don't have their own ACL. Concretely: the dev-shell deployment runs
|
||||
both `zddc-server` and `code-server` behind one Caddy listener; Caddy uses
|
||||
`forward_auth` to ask `/.auth/admin` whether the caller is allowed to reach
|
||||
`/devshell/*` (the IDE) before forwarding. zddc-server's own routes (`/`,
|
||||
`/<project>/`, `/.archive/`, etc.) keep their existing `.zddc`-cascade ACL
|
||||
and don't go through this endpoint.
|
||||
|
||||
```caddy
|
||||
# example: protect /devshell/* with forward_auth on /.auth/admin
|
||||
handle_path /devshell/* {
|
||||
forward_auth 127.0.0.1:9090 {
|
||||
uri /.auth/admin
|
||||
copy_headers X-Auth-Request-Email
|
||||
}
|
||||
reverse_proxy 127.0.0.1:8443 # code-server
|
||||
}
|
||||
```
|
||||
|
||||
The check is cheap (one map lookup against the cached `PolicyChain`); calling
|
||||
it on every request is fine. Edits to `/srv/.zddc` propagate within the
|
||||
fsnotify watcher's debounce window (~2 s) — no service restart needed.
|
||||
|
||||
### Caveats
|
||||
|
||||
- Logs are in-memory and lost on restart. The buffer holds the most recent 500
|
||||
|
|
@ -330,27 +362,46 @@ Any URL path segment named `.archive` (configurable via `ZDDC_INDEX_PATH`) is in
|
|||
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
|
||||
maps each `(trackingNumber, revision, modifier)` tuple to the file from the
|
||||
**chronologically earliest** transmittal folder that contains it.
|
||||
maps each `(project, trackingNumber, revision, modifier)` tuple to the file from the
|
||||
**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 | Resolves to |
|
||||
|---|---|
|
||||
| `GET /Project/.archive/TRK-001.html` | Latest base revision of TRK-001 |
|
||||
| `GET /Project/.archive/TRK-001_A.html` | Base revision A of TRK-001 |
|
||||
| `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 |
|
||||
| `GET /Project/.archive/` | JSON listing of all resolvable trackingNumber.html entries |
|
||||
| `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 within Project |
|
||||
| `GET /Project/.archive/TRK-001_A+C1.html` | Modifier C1 of revision A of TRK-001 within Project |
|
||||
| `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
|
||||
the `.archive` context directory and the resolved target file.
|
||||
All successful responses are `302 Found` redirects to the actual file URL. ACL
|
||||
is enforced on both the `.archive` context directory and the resolved target file.
|
||||
|
||||
### Why "earliest" transmittal?
|
||||
|
||||
Any file claiming to be `TRK-001_A (IFC)` should be identical across transmittals
|
||||
(same content, same SHA-256). If the same tracking number and revision appears in multiple
|
||||
transmittals, the first one received chronologically is treated as the authoritative copy.
|
||||
A later arrival with a different hash is an error condition (to be detected separately).
|
||||
Within one project, any file claiming to be `TRK-001_A (IFC)` should be identical
|
||||
across transmittals (same content, same SHA-256). If the same tracking number and
|
||||
revision appears in multiple transmittals, the first one received chronologically is
|
||||
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
|
||||
|
||||
|
|
@ -437,7 +488,7 @@ Requires Go 1.24+.
|
|||
(cd zddc && go build -o zddc-server ./cmd/zddc-server)
|
||||
|
||||
# All four release platforms (cross-compiled, statically linked)
|
||||
sh build.sh # at the repo root — silently skips if Go isn't on PATH
|
||||
./build # at the repo root — silently skips if Go isn't on PATH
|
||||
# → outputs to zddc/dist/zddc-server-{linux,darwin,windows}-*
|
||||
```
|
||||
|
||||
|
|
@ -449,32 +500,28 @@ To run unit tests:
|
|||
|
||||
## Release tagging
|
||||
|
||||
`sh zddc/release.sh` is the canonical path. **Stable cuts only.** The script tags the commit, cross-compiles the four binaries (native Go), and uploads them as Codeberg release assets via the shared `publish-codeberg-release.sh` helper.
|
||||
zddc-server has no separate release script. The repo's top-level `./build alpha|beta|release [version]` is the canonical path: it cross-compiles the four binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with the lockstep symlink chain (one set per platform), regenerates the per-version + per-channel stub pages, refreshes the index, and (on stable cuts) tags `zddc-server-v<X.Y.Z>` alongside the five HTML-tool tags.
|
||||
|
||||
```sh
|
||||
sh zddc/release.sh # patch-bump from latest clean stable tag
|
||||
sh zddc/release.sh 0.1.0 # explicit version
|
||||
./build release # lockstep stable, coordinated next version
|
||||
./build release 1.2.0 # lockstep stable, explicit version
|
||||
./build alpha # lockstep alpha cut
|
||||
./build beta # lockstep beta cut
|
||||
./deploy --releases # publish dist/release-output/ to /srv/zddc/releases/
|
||||
```
|
||||
|
||||
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` (and run `./deploy` to put the artifacts on the live site).
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Go 1.24+ on PATH.
|
||||
- `$CODEBERG_TOKEN` exported, scoped to write the VARASYS/ZDDC repo. Generate one at <https://codeberg.org/user/settings/applications>.
|
||||
|
||||
After the script returns successfully, the website's versions index doesn't need updating for zddc-server (it links out to the Codeberg release page directly). Just push:
|
||||
|
||||
```sh
|
||||
git push origin main
|
||||
git push origin zddc-server-vX.Y.Z
|
||||
```
|
||||
- Go 1.24+ available inside the build container (downloaded automatically — `docker.io/golang:1.24-alpine`).
|
||||
- `podman` (preferred) or `docker` on PATH.
|
||||
|
||||
Single-developer / solo-release flow by design — no CI babysitting, no separate dashboard to debug. The script fails loudly and visibly on the developer's terminal if anything goes wrong.
|
||||
|
||||
### Versioning
|
||||
|
||||
Clean semver. Stable cuts get `<tool>-vX.Y.Z` tags. There are no alpha/beta channel tags for zddc-server — channel URLs are stable URLs by design (counters defeat that), and zddc-server has no static-asset distribution layer where channel mirrors would matter. Active dev runs via `helm/zddc-server-dev/`, which builds from source on each rollout.
|
||||
Clean semver, lockstep across all six tools (5 HTML + zddc-server). Stable cuts get `<tool>-vX.Y.Z` tags for every tool, all six sharing the same X.Y.Z. There are no alpha/beta tags — channel URLs are stable URLs by design (counters defeat that). Active dev runs via `helm/zddc-server-dev/`, which builds from source on each rollout.
|
||||
|
||||
The two existing `zddc-server-v0.0.8-alpha.1` and `zddc-server-v0.0.8-alpha.2` tags from a previous experiment stay as historical artifacts; no new alpha/beta tags are created going forward.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -20,16 +22,34 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// version is the binary's own version, injected at build time via
|
||||
// `-ldflags="-X main.version=..."`. Defaults to "dev" for unreleased
|
||||
// builds; release pipelines pass the result of `git describe --tags`.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
cfg, err := config.Load(os.Args[1:])
|
||||
if errors.Is(err, config.ErrHelpRequested) {
|
||||
config.Usage(os.Stderr)
|
||||
os.Exit(0)
|
||||
}
|
||||
if errors.Is(err, config.ErrVersionRequested) {
|
||||
printVersions(os.Stdout)
|
||||
os.Exit(0)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "configuration error: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "configuration error: %v\n\nRun with --help for usage.\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logRing := setupLogger(cfg.LogLevel)
|
||||
|
||||
slog.Info("zddc-server starting", "root", cfg.Root, "addr", cfg.Addr)
|
||||
embedded := apps.EmbeddedVersions()
|
||||
slog.Info("zddc-server starting",
|
||||
"version", version,
|
||||
"root", cfg.Root,
|
||||
"addr", cfg.Addr,
|
||||
"embedded_apps", embeddedVersionsForLog(embedded))
|
||||
|
||||
// Build archive index
|
||||
slog.Info("building archive index...")
|
||||
|
|
@ -135,7 +155,53 @@ func setupApps(cfg config.Config) (*apps.Server, error) {
|
|||
return nil, fmt.Errorf("create cache: %w", err)
|
||||
}
|
||||
fetcher := apps.NewFetcher(cache, slog.Default())
|
||||
return apps.NewServer(cfg.Root, cache, fetcher, cfg.BuildVersion), nil
|
||||
return apps.NewServer(cfg.Root, cache, fetcher, version), nil
|
||||
}
|
||||
|
||||
// printVersions writes the binary version + the build label of every app
|
||||
// embedded into the binary. Called by --version and reused for the
|
||||
// startup log line.
|
||||
func printVersions(w *os.File) {
|
||||
fmt.Fprintf(w, "zddc-server %s\n\n", version)
|
||||
embedded := apps.EmbeddedVersions()
|
||||
if len(embedded) == 0 {
|
||||
fmt.Fprintln(w, "Embedded tools: (none — run `sh build.sh` to populate)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Embedded tools:")
|
||||
keys := make([]string, 0, len(embedded))
|
||||
for k := range embedded {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(w, " %-12s %s\n", k, embedded[k])
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedVersionsForLog formats the embedded-versions map as a single
|
||||
// short string suitable for the startup `log/slog` line. Sorted by app
|
||||
// name for stable output.
|
||||
func embeddedVersionsForLog(embedded map[string]string) string {
|
||||
if len(embedded) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
keys := make([]string, 0, len(embedded))
|
||||
for k := range embedded {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
// Strip any " · timestamp · sha" suffix so the log line stays compact;
|
||||
// operators who want full detail run `zddc-server --version`.
|
||||
v := embedded[k]
|
||||
if i := strings.Index(v, " "); i > 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
parts = append(parts, k+"="+v)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// dispatch routes a request to the appropriate handler.
|
||||
|
|
@ -152,6 +218,16 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// Auth check endpoints — machine-only forward_auth targets used by
|
||||
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
|
||||
// code-server) to gate routes on root-admin status. Handled before
|
||||
// the reserved-prefix guard below so the .auth namespace passes
|
||||
// through without being 404'd by the dot-prefix rule.
|
||||
if urlPath == handler.AuthPathPrefix+"/admin" {
|
||||
handler.ServeAuthAdmin(cfg, w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Project list API: GET / with Accept: application/json
|
||||
if urlPath == "/" {
|
||||
accept := r.Header.Get("Accept")
|
||||
|
|
|
|||
|
|
@ -138,7 +138,6 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
Root: root,
|
||||
IndexPath: ".archive",
|
||||
EmailHeader: "X-Auth-Request-Email",
|
||||
BuildVersion: "test-build",
|
||||
}
|
||||
ring := handler.NewLogRing(10)
|
||||
|
||||
|
|
|
|||
|
|
@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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:14:53 · fedc365-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
@ -7996,27 +7996,21 @@ window.app.modules.filtering = {
|
|||
});
|
||||
}
|
||||
|
||||
// Returns true if any path segment of the transmittal folder matches a selected
|
||||
// party name AND the segment immediately after it (if it's a folder-type name)
|
||||
// is in enabledFolderTypes. Segment-equality matching means a party "BM" selected
|
||||
// matches every "<...>/BM/<...>" path regardless of the prefix.
|
||||
// Returns true if the transmittal folder's path satisfies all three cascade
|
||||
// layers: project visibility, party selection (any path segment matches a
|
||||
// selected party name), and folder-type enablement (no segment is a
|
||||
// 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) {
|
||||
if (!pathIsInVisibleProject(folder.path)) return false;
|
||||
const parts = folder.path.split('/');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (!window.app.selectedGroupingFolders.has(parts[i])) continue;
|
||||
// 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;
|
||||
if (isUnderHiddenFolderType(folder.path)) return false;
|
||||
return folder.path.split('/').some(seg =>
|
||||
window.app.selectedGroupingFolders.has(seg)
|
||||
);
|
||||
}
|
||||
|
||||
// Render transmittal folders (rebuilds DOM)
|
||||
|
|
|
|||
|
|
@ -1376,7 +1376,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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:14:53 · fedc365-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -866,7 +866,7 @@ body {
|
|||
</g>
|
||||
</svg>
|
||||
<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:14:53 · fedc365-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
|
|
|
|||
|
|
@ -1066,13 +1066,25 @@ body.help-open .app-header {
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* The text wrapper inside a tree-row label. For ZDDC-conforming files and
|
||||
folders, this wraps two stacked <div>s (filename-main + filename-secondary)
|
||||
so the row reads top-to-bottom as title + metadata — same shape the archive
|
||||
tool uses for its transmittal-folder list. For non-ZDDC entries it just
|
||||
contains a single line. flex column makes the two-line case work; min-width:0
|
||||
lets each line truncate independently. */
|
||||
.tree-row__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* ── New-file modal ─────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
|
|
@ -1147,6 +1159,100 @@ body.help-open .app-header {
|
|||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Toast UI Editor — dark-theme overrides ───────────────────────────────
|
||||
Toast UI ships with light-mode chrome and edit surfaces by default. In
|
||||
mdedit's dark mode the editor's text (#222) falls onto the transparent
|
||||
md-container, which inherits var(--bg) dark = #1e1e1e → effectively
|
||||
black-on-black. Override the load-bearing surfaces with mdedit's tokens
|
||||
so the editor harmonises with the rest of the chrome.
|
||||
The selectors target both manual override (data-theme="dark") and the
|
||||
OS-pref auto fallback (prefers-color-scheme + no data-theme="light"). */
|
||||
|
||||
/* Manual dark override */
|
||||
[data-theme="dark"] .toastui-editor-defaultUI,
|
||||
[data-theme="dark"] .toastui-editor-md-container,
|
||||
[data-theme="dark"] .toastui-editor-md-preview,
|
||||
[data-theme="dark"] .toastui-editor-ww-container,
|
||||
[data-theme="dark"] .toastui-editor-mode-switch,
|
||||
[data-theme="dark"] .toastui-editor-main,
|
||||
[data-theme="dark"] .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-icons {
|
||||
/* Toast UI's icons are sprite-baked dark; invert flips them to light. */
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-popup,
|
||||
[data-theme="dark"] .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* OS-pref auto fallback (matches every selector above) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-preview,
|
||||
:root:not([data-theme="light"]) .toastui-editor-ww-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch,
|
||||
:root:not([data-theme="light"]) .toastui-editor-main,
|
||||
:root:not([data-theme="light"]) .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-icons {
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-popup,
|
||||
:root:not([data-theme="light"]) .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* Table of Contents styles */
|
||||
.toc-pane {
|
||||
height: 100%;
|
||||
|
|
@ -1668,7 +1774,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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:14:53 · fedc365-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
|
||||
</div>
|
||||
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
|
||||
</div>
|
||||
|
|
@ -4332,7 +4438,7 @@ function renderFileTree() {
|
|||
|
||||
const scratchLabel = document.createElement('span');
|
||||
scratchLabel.className = 'tree-row__label';
|
||||
scratchLabel.innerHTML = '<div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div>';
|
||||
scratchLabel.innerHTML = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
|
||||
scratchpadElement.appendChild(scratchLabel);
|
||||
|
||||
const scratchActions = document.createElement('div');
|
||||
|
|
@ -4400,7 +4506,10 @@ function renderFileTree() {
|
|||
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
|
||||
} else {
|
||||
dirName.textContent = `📁 ${name}`;
|
||||
// Non-ZDDC folder: still wrap in filename-main so
|
||||
// typography matches the two-line entries (same font
|
||||
// size + weight; just no secondary line).
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const dirLabel = document.createElement('span');
|
||||
|
|
@ -4440,24 +4549,30 @@ function renderFileTree() {
|
|||
|
||||
const fileIcon = getFileTypeIcon(name);
|
||||
|
||||
let fileNameDisplay;
|
||||
// Build the inner two-line text inside a tree-row__name
|
||||
// wrapper (column-flex). ZDDC-conforming filenames split
|
||||
// into title + meta; "Title - filename.ext" pattern uses
|
||||
// the dash as the same split. Plain names get a single
|
||||
// line via filename-main only — same wrapper, just no
|
||||
// secondary div, so the layout stays consistent.
|
||||
let fileNameInner;
|
||||
const parsed = zddc.parseFilename(name);
|
||||
if (parsed && parsed.valid) {
|
||||
const titleDisplay = escapeHtml(parsed.title);
|
||||
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
} else if (name.includes(' - ')) {
|
||||
const dashIdx = name.lastIndexOf(' - ');
|
||||
const secondary = escapeHtml(name.substring(0, dashIdx));
|
||||
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
|
||||
fileNameDisplay = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
} else {
|
||||
fileNameDisplay = `<span>${fileIcon} ${escapeHtml(name)}</span>`;
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement('span');
|
||||
fileLabel.className = 'tree-row__label';
|
||||
fileLabel.innerHTML = fileNameDisplay;
|
||||
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
|
||||
|
||||
const fileActions = createActionButtons(filePath, 'file');
|
||||
|
||||
|
|
|
|||
|
|
@ -2210,7 +2210,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<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:14:53 · fedc365-dirty</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty</span></span>
|
||||
</div>
|
||||
<div class="app-header__spacer"></div>
|
||||
<div class="app-header__icons">
|
||||
|
|
|
|||
6
zddc/internal/apps/embedded/versions.txt
Normal file
6
zddc/internal/apps/embedded/versions.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
|
||||
transmittal=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
|
||||
classifier=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
|
||||
mdedit=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
|
||||
landing=v0.0.9-alpha · 2026-05-02 14:02:00 · 76e1e78-dirty
|
||||
39
zddc/internal/apps/versions.go
Normal file
39
zddc/internal/apps/versions.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package apps
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// embeddedVersionsRaw is the manifest written by the top-level build.sh
|
||||
// at compile time. Format is one `<app>=<build label>` line per app —
|
||||
// e.g. `archive=v0.0.5-alpha · 2026-05-01 14:00:00 · abc1234`. An empty
|
||||
// or missing value indicates the embedded slot was not populated (a fresh
|
||||
// clone where build.sh hasn't run yet).
|
||||
//
|
||||
//go:embed embedded/versions.txt
|
||||
var embeddedVersionsRaw []byte
|
||||
|
||||
// EmbeddedVersions returns the build label of each tool baked into the
|
||||
// binary, keyed by canonical app name. Apps with empty values are
|
||||
// omitted. Caller copies the map if mutation is needed.
|
||||
func EmbeddedVersions() map[string]string {
|
||||
out := make(map[string]string, 5)
|
||||
for _, line := range strings.Split(string(embeddedVersionsRaw), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
if val != "" {
|
||||
out[key] = val
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
|
@ -22,16 +23,28 @@ type TrackingEntry struct {
|
|||
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
|
||||
// fsRoot (the "project"). Each project is its own namespace — the same
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
ByTracking map[string]*TrackingEntry
|
||||
ByProject map[string]*ProjectEntry
|
||||
}
|
||||
|
||||
// NewIndex returns an empty Index.
|
||||
func NewIndex() *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) {
|
||||
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()
|
||||
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 {
|
||||
te = &TrackingEntry{
|
||||
ByRevision: make(map[string]*RevisionEntry),
|
||||
}
|
||||
idx.ByTracking[pf.trackingNumber] = te
|
||||
pe.ByTracking[pf.trackingNumber] = te
|
||||
}
|
||||
|
||||
re, ok := te.ByRevision[pf.baseRev]
|
||||
|
|
@ -175,10 +214,29 @@ func (idx *Index) recordFile(pf parsedFile) {
|
|||
}
|
||||
|
||||
if pf.modifier == "" {
|
||||
// Base revision file — record if this transmittal is older than current
|
||||
if re.BasePath == "" || pf.date < re.Date {
|
||||
switch {
|
||||
case re.BasePath == "":
|
||||
re.BasePath = pf.serverPath
|
||||
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 {
|
||||
// Modifier file — record if no entry yet or this transmittal is older
|
||||
|
|
@ -279,7 +337,8 @@ type Entry struct {
|
|||
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>_<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 "_"
|
||||
// in <tracking>_<rev>.html, so each tracking number's highest-rev shortcut
|
||||
// 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()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
pe, ok := idx.ByProject[project]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []Entry
|
||||
for tn, te := range idx.ByTracking {
|
||||
for tn, te := range pe.ByTracking {
|
||||
if te.HighestBaseRev != "" {
|
||||
if re, ok := te.ByRevision[te.HighestBaseRev]; ok && re.BasePath != "" {
|
||||
result = append(result, Entry{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
|
@ -48,7 +51,7 @@ func TestCompareRevisions_DraftOrdering(t *testing.T) {
|
|||
|
||||
func TestIndexAndResolve_DraftOnly(t *testing.T) {
|
||||
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",
|
||||
)
|
||||
|
||||
|
|
@ -57,72 +60,132 @@ func TestIndexAndResolve_DraftOnly(t *testing.T) {
|
|||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
|
||||
te, ok := idx.ByTracking["123"]
|
||||
pe, ok := idx.ByProject["ProjectA"]
|
||||
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" {
|
||||
t.Errorf("HighestBaseRev = %q, want ~A", te.HighestBaseRev)
|
||||
}
|
||||
|
||||
if _, ok := Resolve(idx, "123.html"); !ok {
|
||||
t.Errorf("Resolve(123.html) failed")
|
||||
if _, ok := Resolve(idx, "ProjectA", "123.html"); !ok {
|
||||
t.Errorf("Resolve(ProjectA, 123.html) failed")
|
||||
}
|
||||
if _, ok := Resolve(idx, "123_~A.html"); !ok {
|
||||
t.Errorf("Resolve(123_~A.html) failed")
|
||||
if _, ok := Resolve(idx, "ProjectA", "123_~A.html"); !ok {
|
||||
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) {
|
||||
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",
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
idx, _ := BuildIndex(root)
|
||||
if _, ok := Resolve(idx, "123_~A+C1.html"); !ok {
|
||||
t.Errorf("Resolve(123_~A+C1.html) failed")
|
||||
if _, ok := Resolve(idx, "ProjectA", "123_~A+C1.html"); !ok {
|
||||
t.Errorf("Resolve(ProjectA, 123_~A+C1.html) failed")
|
||||
}
|
||||
}
|
||||
|
||||
// "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) {
|
||||
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",
|
||||
)
|
||||
mkTransmittal(t, root, "2025-01-01_Early (IFR) - Title",
|
||||
mkTransmittal(t, root, "ProjectA/2025-01-01_Early (IFR) - Title",
|
||||
"123_A (IFR) - Title.pdf",
|
||||
)
|
||||
|
||||
idx, _ := BuildIndex(root)
|
||||
target, ok := Resolve(idx, "123_A.html")
|
||||
target, ok := Resolve(idx, "ProjectA", "123_A.html")
|
||||
if !ok {
|
||||
t.Fatalf("Resolve(123_A.html) failed")
|
||||
t.Fatalf("Resolve(ProjectA, 123_A.html) failed")
|
||||
}
|
||||
if !contains(target, "2025-01-01_Early") {
|
||||
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
|
||||
// <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) {
|
||||
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",
|
||||
)
|
||||
mkTransmittal(t, root, "2025-03-01_T3 (IFC) - Title",
|
||||
mkTransmittal(t, root, "ProjectA/2025-03-01_T3 (IFC) - Title",
|
||||
"123_A (IFC) - Title.pdf",
|
||||
"456_0 (IFR) - Other.pdf",
|
||||
)
|
||||
|
||||
idx, _ := BuildIndex(root)
|
||||
entries := idx.AllEntries()
|
||||
entries := idx.AllEntries("ProjectA")
|
||||
|
||||
got := make(map[string]string, len(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -164,18 +235,119 @@ func TestAllEntries_PerRevisionSurfaced(t *testing.T) {
|
|||
// through the resolver but are not surfaced in the listing.
|
||||
func TestAllEntries_ModifierOnlyNoBaseSkipped(t *testing.T) {
|
||||
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",
|
||||
)
|
||||
|
||||
idx, _ := BuildIndex(root)
|
||||
for _, e := range idx.AllEntries() {
|
||||
for _, e := range idx.AllEntries("ProjectA") {
|
||||
if e.URLName == "123.html" || e.URLName == "123_~A.html" {
|
||||
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 {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
|
|
@ -193,3 +365,12 @@ func sortedKeys(m map[string]string) []string {
|
|||
sort.Strings(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
|
||||
// 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):
|
||||
// - trackingNumber → highest base revision of trackingNumber
|
||||
// - trackingNumber_rev → base revision file for rev
|
||||
// - trackingNumber_rev+C1 → modifier file (C1, B1, N1, Q1)
|
||||
//
|
||||
// Returns ("", false) if the filename cannot be parsed or no match exists.
|
||||
func Resolve(idx *Index, filename string) (string, bool) {
|
||||
// Returns ("", false) if project is empty, the filename cannot be parsed, or
|
||||
// no match exists in the project.
|
||||
func Resolve(idx *Index, project, filename string) (string, bool) {
|
||||
if project == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Strip .html suffix
|
||||
stem := strings.TrimSuffix(filename, ".html")
|
||||
if stem == filename {
|
||||
|
|
@ -24,12 +34,17 @@ func Resolve(idx *Index, filename string) (string, bool) {
|
|||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
pe, ok := idx.ByProject[project]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Try to split off revision part (last _ segment)
|
||||
lastUnderscore := strings.LastIndex(stem, "_")
|
||||
if lastUnderscore < 0 {
|
||||
// No underscore — treat entire stem as tracking number
|
||||
tracking := stem
|
||||
te, ok := idx.ByTracking[tracking]
|
||||
te, ok := pe.ByTracking[tracking]
|
||||
if !ok || te.HighestBaseRev == "" {
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -54,7 +69,7 @@ func Resolve(idx *Index, filename string) (string, bool) {
|
|||
modifier = revPart[plusIdx+1:]
|
||||
}
|
||||
|
||||
te, ok := idx.ByTracking[tracking]
|
||||
te, ok := pe.ByTracking[tracking]
|
||||
if !ok {
|
||||
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" {
|
||||
dir := filepath.Dir(path)
|
||||
zddc.InvalidateCache(dir)
|
||||
zddc.InvalidateScanCache()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,85 +2,221 @@ package config
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration loaded from environment variables.
|
||||
// Config holds all runtime configuration. Each field can be set via a
|
||||
// command-line flag (--<name>) or environment variable (ZDDC_<NAME>);
|
||||
// the flag takes precedence when both are present.
|
||||
type Config struct {
|
||||
Root string // ZDDC_ROOT — absolute path to the served file tree
|
||||
Addr string // ZDDC_ADDR — bind address (default :8443)
|
||||
TLSCert string // ZDDC_TLS_CERT — path to PEM cert; empty = self-signed
|
||||
TLSKey string // ZDDC_TLS_KEY — path to PEM key; empty = self-signed
|
||||
TLSMode string // computed from TLSCert/TLSKey: none/selfsigned/provided
|
||||
LogLevel string // ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
|
||||
IndexPath string // ZDDC_INDEX_PATH — virtual segment name (default .archive)
|
||||
EmailHeader string // ZDDC_EMAIL_HEADER — header name for user email (default X-Auth-Request-Email)
|
||||
CORSOrigins []string // ZDDC_CORS_ORIGIN — comma-separated CORS allowlist; default https://zddc.varasys.io; empty disables
|
||||
|
||||
// BuildVersion is baked into the X-ZDDC-Source header on embedded
|
||||
// fallback responses so operators see exactly which binary's
|
||||
// embedded HTML they're getting. Set at build time via -ldflags.
|
||||
BuildVersion string
|
||||
Root string // --root / ZDDC_ROOT — served file tree (default: CWD)
|
||||
Addr string // --addr / ZDDC_ADDR — bind address (default :8443)
|
||||
TLSCert string // --tls-cert / ZDDC_TLS_CERT — PEM cert path; "none" = plain HTTP; empty = self-signed
|
||||
TLSKey string // --tls-key / ZDDC_TLS_KEY — PEM key path
|
||||
TLSMode string // computed: none/selfsigned/provided
|
||||
LogLevel string // --log-level / ZDDC_LOG_LEVEL — debug/info/warn/error (default info)
|
||||
IndexPath string // --index-path / ZDDC_INDEX_PATH — virtual archive prefix (default .archive)
|
||||
EmailHeader string // --email-header / ZDDC_EMAIL_HEADER — auth header name (default X-Auth-Request-Email)
|
||||
CORSOrigins []string // --cors-origin / ZDDC_CORS_ORIGIN — comma-separated allowlist; default https://zddc.varasys.io; empty disables
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates required fields.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: getEnv("ZDDC_ADDR", ":8443"),
|
||||
Root: os.Getenv("ZDDC_ROOT"),
|
||||
TLSCert: os.Getenv("ZDDC_TLS_CERT"),
|
||||
TLSKey: os.Getenv("ZDDC_TLS_KEY"),
|
||||
LogLevel: getEnv("ZDDC_LOG_LEVEL", "info"),
|
||||
IndexPath: getEnv("ZDDC_INDEX_PATH", ".archive"),
|
||||
EmailHeader: getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||
CORSOrigins: parseCORSOrigins(),
|
||||
BuildVersion: getEnv("ZDDC_BUILD_VERSION", "dev"),
|
||||
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
||||
// should print Usage() to stderr and exit 0.
|
||||
var ErrHelpRequested = errors.New("help requested")
|
||||
|
||||
// ErrVersionRequested is returned by Load when --version is passed; the
|
||||
// caller should print version info and exit 0.
|
||||
var ErrVersionRequested = errors.New("version requested")
|
||||
|
||||
// Load reads configuration from CLI flags + environment variables.
|
||||
//
|
||||
// Precedence (highest → lowest): command-line flag, environment variable,
|
||||
// hard-coded default. Special-cases:
|
||||
// - --root / ZDDC_ROOT default to the current working directory if both
|
||||
// are unset, so an operator can `cd /srv/zddc && zddc-server` with
|
||||
// zero config.
|
||||
// - --version and --help return distinguished sentinel errors; the caller
|
||||
// handles printing and exit. Pass nil for args to skip flag parsing
|
||||
// entirely (used by tests that set state via env vars only).
|
||||
//
|
||||
// Standard usage from main.go:
|
||||
//
|
||||
// cfg, err := config.Load(os.Args[1:])
|
||||
// if errors.Is(err, config.ErrHelpRequested) { os.Exit(0) }
|
||||
// if errors.Is(err, config.ErrVersionRequested) { /* print versions */ ; os.Exit(0) }
|
||||
// if err != nil { ... }
|
||||
func Load(args []string) (Config, error) {
|
||||
fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError)
|
||||
// Discard flag's own error output; we wrap and return our own.
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
rootFlag := fs.String("root", os.Getenv("ZDDC_ROOT"),
|
||||
"Path to the served file tree. Default: ZDDC_ROOT or the current directory.")
|
||||
addrFlag := fs.String("addr", getEnv("ZDDC_ADDR", ":8443"),
|
||||
"Listen address (host:port). Default: ZDDC_ADDR or :8443.")
|
||||
tlsCertFlag := fs.String("tls-cert", os.Getenv("ZDDC_TLS_CERT"),
|
||||
"Path to a PEM TLS certificate. \"none\" disables TLS (plain HTTP). Empty means self-signed.")
|
||||
tlsKeyFlag := fs.String("tls-key", os.Getenv("ZDDC_TLS_KEY"),
|
||||
"Path to the matching PEM TLS private key.")
|
||||
logLevelFlag := fs.String("log-level", getEnv("ZDDC_LOG_LEVEL", "info"),
|
||||
"Log level: debug, info, warn, error.")
|
||||
indexPathFlag := fs.String("index-path", getEnv("ZDDC_INDEX_PATH", ".archive"),
|
||||
"URL segment that triggers the virtual archive index (default \".archive\").")
|
||||
emailHeaderFlag := fs.String("email-header", getEnv("ZDDC_EMAIL_HEADER", "X-Auth-Request-Email"),
|
||||
"HTTP header carrying the authenticated user's email.")
|
||||
corsOriginFlag := fs.String("cors-origin", "",
|
||||
"Comma-separated CORS allowlist. Empty = CORS disabled. Default: ZDDC_CORS_ORIGIN or https://zddc.varasys.io.")
|
||||
insecureDirectFlag := fs.Bool("insecure-direct", os.Getenv("ZDDC_INSECURE_DIRECT") == "1",
|
||||
"Allow plain HTTP on non-loopback addresses (only safe behind an authenticating proxy).")
|
||||
helpFlag := fs.Bool("help", false, "Print this help and exit.")
|
||||
versionFlag := fs.Bool("version", false, "Print version info and exit.")
|
||||
|
||||
if args != nil {
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return Config{}, ErrHelpRequested
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
}
|
||||
if *helpFlag {
|
||||
return Config{}, ErrHelpRequested
|
||||
}
|
||||
if *versionFlag {
|
||||
return Config{}, ErrVersionRequested
|
||||
}
|
||||
|
||||
// CORS has special semantics: "unset" → default origin list; "set to
|
||||
// empty" → CORS disabled. The flag default is "" so we can't tell unset
|
||||
// from explicit-empty via the flag alone — fs.Visit catches explicit
|
||||
// flag use, and os.LookupEnv catches explicit env-var use.
|
||||
corsFlagSet := false
|
||||
if args != nil {
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "cors-origin" {
|
||||
corsFlagSet = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
Root: *rootFlag,
|
||||
Addr: *addrFlag,
|
||||
TLSCert: *tlsCertFlag,
|
||||
TLSKey: *tlsKeyFlag,
|
||||
LogLevel: *logLevelFlag,
|
||||
IndexPath: *indexPathFlag,
|
||||
EmailHeader: *emailHeaderFlag,
|
||||
CORSOrigins: resolveCORS(corsFlagSet, *corsOriginFlag),
|
||||
}
|
||||
|
||||
// Default Root to the current working directory.
|
||||
if cfg.Root == "" {
|
||||
return Config{}, errors.New("ZDDC_ROOT environment variable is required")
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("--root not set and could not determine current directory: %w", err)
|
||||
}
|
||||
cfg.Root = cwd
|
||||
}
|
||||
|
||||
info, err := os.Stat(cfg.Root)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("ZDDC_ROOT %q is not accessible: %w", cfg.Root, err)
|
||||
return Config{}, fmt.Errorf("--root %q is not accessible: %w", cfg.Root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return Config{}, fmt.Errorf("ZDDC_ROOT %q is not a directory", cfg.Root)
|
||||
return Config{}, fmt.Errorf("--root %q is not a directory", cfg.Root)
|
||||
}
|
||||
|
||||
// Determine TLS mode
|
||||
if cfg.TLSCert == "none" {
|
||||
// Determine TLS mode.
|
||||
switch {
|
||||
case cfg.TLSCert == "none":
|
||||
cfg.TLSMode = "none"
|
||||
} else if cfg.TLSCert == "" && cfg.TLSKey == "" {
|
||||
case cfg.TLSCert == "" && cfg.TLSKey == "":
|
||||
cfg.TLSMode = "selfsigned"
|
||||
} else {
|
||||
default:
|
||||
cfg.TLSMode = "provided"
|
||||
}
|
||||
|
||||
// Cert and key must both be set or both be empty only when TLSMode == "provided"
|
||||
if cfg.TLSMode == "provided" && (cfg.TLSCert == "") != (cfg.TLSKey == "") {
|
||||
return Config{}, errors.New("ZDDC_TLS_CERT and ZDDC_TLS_KEY must both be set or both be empty")
|
||||
return Config{}, errors.New("--tls-cert and --tls-key must both be set or both be empty")
|
||||
}
|
||||
|
||||
// Plain HTTP mode trusts the email header from any client. That is only
|
||||
// safe behind an authenticating reverse proxy, so refuse to start when
|
||||
// binding plain HTTP to a non-loopback interface unless the operator has
|
||||
// explicitly acknowledged the deployment shape via ZDDC_INSECURE_DIRECT=1.
|
||||
if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && os.Getenv("ZDDC_INSECURE_DIRECT") != "1" {
|
||||
// Plain HTTP mode trusts the email header from any client. Only safe
|
||||
// behind an authenticating reverse proxy. Refuse to start when binding
|
||||
// plain HTTP to a non-loopback interface unless the operator has
|
||||
// explicitly acknowledged the deployment shape.
|
||||
if cfg.TLSMode == "none" && !isLoopbackAddr(cfg.Addr) && !*insecureDirectFlag {
|
||||
return Config{}, fmt.Errorf(
|
||||
"ZDDC_TLS_CERT=none binds plain HTTP to %q which trusts %s headers from any client; "+
|
||||
"either use TLS (unset ZDDC_TLS_CERT or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+
|
||||
"or set ZDDC_INSECURE_DIRECT=1 to confirm an authenticating reverse proxy is in front",
|
||||
"--tls-cert=none binds plain HTTP to %q which trusts %s headers from any client; "+
|
||||
"either use TLS (omit --tls-cert or supply a cert), bind to loopback (127.0.0.1: or [::1]:), "+
|
||||
"or pass --insecure-direct to confirm an authenticating reverse proxy is in front",
|
||||
cfg.Addr, cfg.EmailHeader)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Usage prints the flag list to w (stderr is the conventional caller).
|
||||
// Returned format mirrors `flag.PrintDefaults` plus a one-line summary.
|
||||
func Usage(w io.Writer) {
|
||||
fmt.Fprintln(w, "Usage: zddc-server [flags]")
|
||||
fmt.Fprintln(w, "")
|
||||
fmt.Fprintln(w, "Each flag has an equivalent ZDDC_* environment variable; the flag wins on conflict.")
|
||||
fmt.Fprintln(w, "ZDDC_ROOT defaults to the current working directory.")
|
||||
fmt.Fprintln(w, "")
|
||||
fmt.Fprintln(w, "Flags:")
|
||||
fs := flag.NewFlagSet("zddc-server", flag.ContinueOnError)
|
||||
fs.SetOutput(w)
|
||||
// Re-register flags to populate Usage output (we discard the values).
|
||||
fs.String("root", "", "Path to the served file tree. Default: ZDDC_ROOT or the current directory.")
|
||||
fs.String("addr", ":8443", "Listen address (host:port). Default: ZDDC_ADDR or :8443.")
|
||||
fs.String("tls-cert", "", "Path to a PEM TLS certificate. \"none\" disables TLS. Empty = self-signed.")
|
||||
fs.String("tls-key", "", "Path to the matching PEM TLS private key.")
|
||||
fs.String("log-level", "info", "Log level: debug, info, warn, error.")
|
||||
fs.String("index-path", ".archive", "URL segment for the virtual archive index.")
|
||||
fs.String("email-header", "X-Auth-Request-Email", "HTTP header carrying the authenticated user's email.")
|
||||
fs.String("cors-origin", "", "Comma-separated CORS allowlist. Empty = CORS disabled.")
|
||||
fs.Bool("insecure-direct", false, "Allow plain HTTP on non-loopback addresses.")
|
||||
fs.Bool("help", false, "Print this help and exit.")
|
||||
fs.Bool("version", false, "Print version info and exit.")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
// resolveCORS implements the precedence rules for the CORS allowlist:
|
||||
// - flag explicitly set → use flag value (empty = disabled)
|
||||
// - else env var explicitly set → use env value (empty = disabled)
|
||||
// - else → default to the canonical upstream
|
||||
func resolveCORS(flagSet bool, flagValue string) []string {
|
||||
if flagSet {
|
||||
return parseCSV(flagValue)
|
||||
}
|
||||
if v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN"); ok {
|
||||
return parseCSV(v)
|
||||
}
|
||||
return []string{"https://zddc.varasys.io"}
|
||||
}
|
||||
|
||||
// parseCSV splits a comma-separated list and trims whitespace. Empty
|
||||
// returns nil (which the middleware treats as "CORS disabled").
|
||||
func parseCSV(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isLoopbackAddr reports whether addr binds only to a loopback interface.
|
||||
// addr is in net.Listen form: "host:port", ":port", or "[ipv6]:port".
|
||||
// ":port" means all interfaces, so it is NOT loopback.
|
||||
|
|
@ -108,25 +244,3 @@ func getEnv(key, fallback string) string {
|
|||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// parseCORSOrigins reads ZDDC_CORS_ORIGIN as a comma-separated allowlist.
|
||||
// Unset → default to https://zddc.varasys.io. Empty string → CORS disabled.
|
||||
// Origins are not validated as URLs here; the middleware does an exact-match
|
||||
// comparison against the request's Origin header.
|
||||
func parseCORSOrigins() []string {
|
||||
v, ok := os.LookupEnv("ZDDC_CORS_ORIGIN")
|
||||
if !ok {
|
||||
return []string{"https://zddc.varasys.io"}
|
||||
}
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,11 +58,20 @@ func TestLoad(t *testing.T) {
|
|||
check func(*testing.T, Config)
|
||||
}{
|
||||
{
|
||||
name: "missing root",
|
||||
name: "missing root defaults to CWD",
|
||||
env: envSet{},
|
||||
// ZDDC_ROOT not set
|
||||
wantErr: true,
|
||||
errContains: "ZDDC_ROOT",
|
||||
// ZDDC_ROOT not set → Load falls back to os.Getwd().
|
||||
check: func(t *testing.T, cfg Config) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd: %v", err)
|
||||
}
|
||||
// os.Stat resolves symlinks; so does Load via filepath behavior, so
|
||||
// just compare the resolved values.
|
||||
if cfg.Root != cwd {
|
||||
t.Errorf("Root = %q, want CWD %q", cfg.Root, cwd)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "root not a directory",
|
||||
|
|
@ -151,7 +160,7 @@ func TestLoad(t *testing.T) {
|
|||
"ZDDC_ADDR": ":8080",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "ZDDC_INSECURE_DIRECT",
|
||||
errContains: "--insecure-direct",
|
||||
},
|
||||
{
|
||||
name: "plain HTTP on 0.0.0.0 without insecure flag is rejected",
|
||||
|
|
@ -161,7 +170,7 @@ func TestLoad(t *testing.T) {
|
|||
"ZDDC_ADDR": "0.0.0.0:8080",
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "ZDDC_INSECURE_DIRECT",
|
||||
errContains: "--insecure-direct",
|
||||
},
|
||||
{
|
||||
name: "plain HTTP on loopback is allowed",
|
||||
|
|
@ -199,7 +208,7 @@ func TestLoad(t *testing.T) {
|
|||
"ZDDC_INSECURE_DIRECT": "true", // must be exactly "1"
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: "ZDDC_INSECURE_DIRECT",
|
||||
errContains: "--insecure-direct",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +217,7 @@ func TestLoad(t *testing.T) {
|
|||
apply(tc.env)
|
||||
defer clearAll()
|
||||
|
||||
cfg, err := Load()
|
||||
cfg, err := Load([]string{})
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("Load() = nil error, want error containing %q", tc.errContains)
|
||||
|
|
@ -227,3 +236,87 @@ func TestLoad(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFlags_OverrideEnv: --root flag wins over ZDDC_ROOT env var.
|
||||
func TestLoadFlags_OverrideEnv(t *testing.T) {
|
||||
envRoot := t.TempDir()
|
||||
flagRoot := t.TempDir()
|
||||
os.Setenv("ZDDC_ROOT", envRoot)
|
||||
defer os.Unsetenv("ZDDC_ROOT")
|
||||
|
||||
cfg, err := Load([]string{"--root", flagRoot})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Root != flagRoot {
|
||||
t.Errorf("Root = %q, want flag value %q", cfg.Root, flagRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFlags_AddrLogLevelFromFlags: arbitrary flags override env defaults.
|
||||
func TestLoadFlags_AddrLogLevelFromFlags(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg, err := Load([]string{
|
||||
"--root", root,
|
||||
"--addr", "127.0.0.1:9999",
|
||||
"--log-level", "debug",
|
||||
"--index-path", ".myindex",
|
||||
"--email-header", "X-User-Email",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Addr != "127.0.0.1:9999" {
|
||||
t.Errorf("Addr=%q", cfg.Addr)
|
||||
}
|
||||
if cfg.LogLevel != "debug" {
|
||||
t.Errorf("LogLevel=%q", cfg.LogLevel)
|
||||
}
|
||||
if cfg.IndexPath != ".myindex" {
|
||||
t.Errorf("IndexPath=%q", cfg.IndexPath)
|
||||
}
|
||||
if cfg.EmailHeader != "X-User-Email" {
|
||||
t.Errorf("EmailHeader=%q", cfg.EmailHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFlags_CORSExplicitEmptyDisables: --cors-origin="" explicitly disables CORS.
|
||||
func TestLoadFlags_CORSExplicitEmptyDisables(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg, err := Load([]string{"--root", root, "--cors-origin", ""})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cfg.CORSOrigins) != 0 {
|
||||
t.Errorf("CORSOrigins = %v, want empty (CORS disabled by explicit empty flag)", cfg.CORSOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFlags_HelpRequested: --help returns the sentinel error.
|
||||
func TestLoadFlags_HelpRequested(t *testing.T) {
|
||||
_, err := Load([]string{"--help"})
|
||||
if !strings.Contains(err.Error(), "help requested") && err != ErrHelpRequested {
|
||||
t.Errorf("got err=%v, want ErrHelpRequested", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFlags_VersionRequested: --version returns the sentinel error.
|
||||
func TestLoadFlags_VersionRequested(t *testing.T) {
|
||||
_, err := Load([]string{"--version"})
|
||||
if !strings.Contains(err.Error(), "version requested") && err != ErrVersionRequested {
|
||||
t.Errorf("got err=%v, want ErrVersionRequested", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadFlags_RootFlagDefaultsToCWD: with no --root and no ZDDC_ROOT, falls back to CWD.
|
||||
func TestLoadFlags_RootFlagDefaultsToCWD(t *testing.T) {
|
||||
os.Unsetenv("ZDDC_ROOT")
|
||||
cfg, err := Load([]string{})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
if cfg.Root != cwd {
|
||||
t.Errorf("Root=%q, want CWD=%q", cfg.Root, cwd)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,20 +18,27 @@ import (
|
|||
// .archive is exposed at every folder depth so HTML produced for offline use
|
||||
// can reference sibling tracking numbers via "../.archive/<tracking>.html".
|
||||
// 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
|
||||
// the contextPath it arrived under: the same global index is consulted, and
|
||||
// access is gated only by the cascading .zddc ACL.
|
||||
// server, so the contextPath the request arrives under is significant: its
|
||||
// FIRST segment is the project, and the .archive listing/resolver is scoped
|
||||
// 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
|
||||
// - used to gate the listing endpoint (caller must have ACL access to the
|
||||
// directory the .archive virtual entry sits in — otherwise just knowing
|
||||
// the folder exists would leak)
|
||||
// - first segment selects the project bucket
|
||||
// - used to gate the listing endpoint via cascading .zddc ACL
|
||||
// - 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)
|
||||
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
|
||||
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
|
||||
// directory hosting this .archive shouldn't be able to query it either.
|
||||
dirPath := strings.TrimPrefix(contextPath, "/")
|
||||
|
|
@ -47,11 +54,11 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
|||
}
|
||||
|
||||
if filename == "" {
|
||||
serveArchiveListing(cfg, idx, w, r, contextPath, email)
|
||||
serveArchiveListing(cfg, idx, w, r, contextPath, project, email)
|
||||
return
|
||||
}
|
||||
|
||||
target, ok := archive.Resolve(idx, filename)
|
||||
target, ok := archive.Resolve(idx, project, filename)
|
||||
if !ok {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
|
|
@ -73,8 +80,22 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
|||
http.Redirect(w, r, "/"+target, http.StatusFound)
|
||||
}
|
||||
|
||||
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, email string) {
|
||||
allEntries := idx.AllEntries()
|
||||
// projectFromContextPath returns the first non-empty segment of the
|
||||
// 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
|
||||
if !strings.HasSuffix(archiveBase, "/") {
|
||||
archiveBase += "/"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -16,8 +17,9 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// archiveTestRoot lays down a two-project tree so listings exercise scope and
|
||||
// ACL cascading. ACLs are written per-test in the helper that calls this.
|
||||
// archiveTestRoot lays down a two-project tree so listings exercise project
|
||||
// scoping, ACL cascading, and the per-project bucket boundary. ACLs are
|
||||
// written per-test in the helper that calls this.
|
||||
//
|
||||
// <root>/
|
||||
// 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 {
|
||||
t.Helper()
|
||||
urlPath := contextPath
|
||||
if !strings.HasSuffix(urlPath, "/") {
|
||||
// Build a syntactically valid URL by escaping each segment of the
|
||||
// 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 += ".archive/" + filename
|
||||
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -85,6 +92,21 @@ func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, con
|
|||
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 {
|
||||
t.Helper()
|
||||
var out []listing.FileInfo
|
||||
|
|
@ -111,11 +133,37 @@ func contains(xs []string, x string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// .archive at any depth serves the SAME global index (modulo ACL). Only the
|
||||
// URL prefix on the entries differs, so relative ../.archive/ links resolve
|
||||
// to a working server endpoint no matter which folder the source page sits
|
||||
// in.
|
||||
func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
|
||||
// /.archive/ at the very root has no project segment to scope by, so it's a
|
||||
// hard 404 — even for an admin. Stable references must include the project
|
||||
// directory; otherwise cross-project tracking-number collisions would silently
|
||||
// pick a winner.
|
||||
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)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
|
|
@ -127,14 +175,32 @@ func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
|
|||
name string
|
||||
contextPath string
|
||||
urlPrefix string
|
||||
wantNames []string
|
||||
denyNames []string
|
||||
}{
|
||||
{"root", "/", "/.archive/"},
|
||||
{"project depth", "/ProjectA", "/ProjectA/.archive/"},
|
||||
{"unrelated project depth", "/ProjectB", "/ProjectB/.archive/"},
|
||||
{
|
||||
"ProjectA top level",
|
||||
"/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 {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rec := callArchive(t, cfg, idx, email, c.contextPath, "")
|
||||
|
|
@ -143,11 +209,16 @@ func TestServeArchive_ListingIsGlobalAtEveryDepth(t *testing.T) {
|
|||
}
|
||||
got := decodeListing(t, rec.Body.Bytes())
|
||||
gotNames := names(got)
|
||||
for _, want := range wantNames {
|
||||
for _, want := range c.wantNames {
|
||||
if !contains(gotNames, want) {
|
||||
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 {
|
||||
if !strings.HasPrefix(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
|
||||
// subtree sees no entries from it — even when querying /.archive/ at the
|
||||
// root where they ARE allowed. Excluding a user from a subdir requires an
|
||||
// explicit deny there (the cascade is "first explicit match wins, bottom-
|
||||
// up", so a child allow list doesn't narrow a parent's allow:["*"]).
|
||||
// subtree's transmittal directory sees no entries whose target lives there.
|
||||
// Excluding a user from a subdir requires an explicit deny there (the
|
||||
// cascade is "first explicit match wins, bottom-up", so a child allow list
|
||||
// doesn't narrow a parent's allow:["*"]).
|
||||
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
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"]
|
||||
`)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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()))
|
||||
if !contains(gotNames, "200.html") {
|
||||
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
|
||||
// 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) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
|
|
@ -234,33 +309,44 @@ func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
|
|||
`)
|
||||
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 {
|
||||
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"} {
|
||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/", fn)
|
||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn)
|
||||
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 {
|
||||
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
|
||||
// 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
|
||||
// future refactors of the per-target ACL helper can't silently break one.
|
||||
// parent didn't mention. Both directions must be exercised so future
|
||||
// refactors of the per-target ACL helper can't silently break one.
|
||||
func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
// Root: deny default — only bob is on the list. ProjectA: explicitly
|
||||
// allow alice. So alice is rescued at the leaf, mallory stays out
|
||||
// everywhere, bob stays in everywhere.
|
||||
// allow alice. So alice is rescued at ProjectA, mallory stays out
|
||||
// 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:
|
||||
allow: ["bob@example.com"]
|
||||
`)
|
||||
|
|
@ -271,39 +357,34 @@ func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
|
|||
|
||||
cases := []struct {
|
||||
email string
|
||||
contextPath string
|
||||
filename string
|
||||
wantStatus int
|
||||
why string
|
||||
}{
|
||||
{"bob@example.com", "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"},
|
||||
{"alice@example.com", "100.html", http.StatusFound, "alice rescued by ProjectA allow"},
|
||||
{"alice@example.com", "200.html", http.StatusNotFound, "alice not in ProjectB chain → 404"},
|
||||
// mallory is denied EVERYWHERE — including the /ProjectA contextPath
|
||||
// — so she never reaches per-target evaluation; the 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"},
|
||||
{"bob@example.com", "/ProjectA", "100.html", http.StatusFound, "bob allowed at root → reaches ProjectA target"},
|
||||
{"bob@example.com", "/ProjectB", "200.html", http.StatusFound, "bob allowed at root → reaches ProjectB target"},
|
||||
{"alice@example.com", "/ProjectA", "100.html", http.StatusFound, "alice rescued by ProjectA allow"},
|
||||
{"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"},
|
||||
// mallory denied everywhere; the contextPath gate fires first.
|
||||
{"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.email+"_"+c.filename, func(t *testing.T) {
|
||||
// Use ProjectA as contextPath: alice is rescued there (so she
|
||||
// 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)
|
||||
t.Run(c.email+"_"+c.contextPath+"_"+c.filename, func(t *testing.T) {
|
||||
rec := callArchive(t, cfg, idx, c.email, c.contextPath, c.filename)
|
||||
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
|
||||
// file under cfg.Root, regardless of which contextPath the caller used to
|
||||
// reach .archive. So /ProjectA/.archive/100.html and /.archive/100.html
|
||||
// both 302 to the same file.
|
||||
func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T) {
|
||||
// Resolved redirect Location header is the absolute path to the actual file
|
||||
// under cfg.Root. From any depth within the same project, the resolver
|
||||
// returns the same target — `/ProjectA/.archive/100.html` and
|
||||
// `/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/100.html` 302 to the same
|
||||
// file because both look up project ProjectA.
|
||||
func TestServeArchive_ResolveLocationStableAcrossDepthWithinProject(t *testing.T) {
|
||||
root, idx := archiveTestRoot(t)
|
||||
writeZddc(t, root, ".", `acl:
|
||||
allow: ["*"]
|
||||
|
|
@ -311,7 +392,11 @@ func TestServeArchive_ResolveLocationIsAbsoluteAndStableAcrossDepth(t *testing.T
|
|||
cfg := archiveCfg(root)
|
||||
|
||||
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")
|
||||
if rec.Code != http.StatusFound {
|
||||
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
|
||||
// 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
|
||||
|
|
@ -336,8 +487,8 @@ func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) {
|
|||
`)
|
||||
cfg := archiveCfg(root)
|
||||
|
||||
// alice sees everything she's allowed to.
|
||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/", "")
|
||||
// alice sees everything she's allowed to in ProjectA.
|
||||
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
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")
|
||||
}
|
||||
|
||||
// Charlie isn't on any list → default-deny at root → 403 even for the listing.
|
||||
rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "")
|
||||
// Charlie isn't on any list → default-deny → 403 even for the listing.
|
||||
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("charlie listing: status %d, want 403", rec.Code)
|
||||
}
|
||||
|
||||
// Direct resolve also denied (404 to avoid leak).
|
||||
rec = callArchive(t, cfg, idx, "charlie@example.com", "/", "100.html")
|
||||
// contextPath ACL fires first: at root, charlie is denied → 403.
|
||||
// Direct resolve: contextPath ACL fires first → 403.
|
||||
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "100.html")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
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)
|
||||
|
||||
rec := callArchive(t, cfg, idx, "", "/", "")
|
||||
rec := callArchive(t, cfg, idx, "", "/ProjectA", "")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
zddc/internal/handler/authcheck.go
Normal file
47
zddc/internal/handler/authcheck.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// AuthPathPrefix is the URL prefix at which machine-only auth-check
|
||||
// endpoints live. Mirrors ProfilePathPrefix's dot-prefix convention so
|
||||
// the dispatch's reserved-prefix guard sees it as an internal namespace
|
||||
// rather than user content.
|
||||
const AuthPathPrefix = "/.auth"
|
||||
|
||||
// ServeAuthAdmin is a forward_auth target for upstream proxies (e.g. the
|
||||
// dev-shell pod's Caddy in front of code-server). It returns:
|
||||
//
|
||||
// - 200 OK — caller's resolved email is in the root .zddc's
|
||||
// admins: list, per zddc.IsAdmin.
|
||||
// - 403 Forbidden — anonymous, or email not in the admins: list, or
|
||||
// no root .zddc exists. Also covers the case where
|
||||
// the admins: field is empty/missing.
|
||||
//
|
||||
// The endpoint produces no body and does not redirect — it's a pure
|
||||
// authorization decision intended to be polled by Caddy's forward_auth
|
||||
// directive (or any equivalent in nginx, Traefik, oauth2-proxy, etc.).
|
||||
//
|
||||
// Performance: zddc.IsAdmin is a single map lookup against a cached
|
||||
// PolicyChain; the .zddc file is parsed once and re-read only when the
|
||||
// fsnotify watcher fires. Suitable to call on every request without
|
||||
// noticeable overhead.
|
||||
//
|
||||
// Scope: gates ON ROOT-ADMIN STATUS ONLY. This is intentionally
|
||||
// stricter than the regular acl.allow / acl.deny chain — admin-only
|
||||
// endpoints (the dev-shell IDE, future maintenance routes) shouldn't
|
||||
// fall through to subtree-level allowances. For per-route ACL, callers
|
||||
// continue using the existing handlers (archive, profile, etc.) which
|
||||
// consult AllowedWithChain.
|
||||
func ServeAuthAdmin(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||
email := EmailFromContext(r)
|
||||
if email == "" || !zddc.IsAdmin(cfg.Root, email) {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
64
zddc/internal/handler/authcheck_test.go
Normal file
64
zddc/internal/handler/authcheck_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestServeAuthAdmin pins the contract of the forward_auth endpoint
|
||||
// used by upstream proxies (Caddy in the dev-shell pod) to gate
|
||||
// admin-only routes:
|
||||
//
|
||||
// 200 → caller is in the root .zddc admins: list
|
||||
// 403 → anonymous, OR not in admins:, OR no admins configured
|
||||
//
|
||||
// Reuses the profileTestRoot fixture which materializes a temp .zddc
|
||||
// with the supplied admins, and the requestWithEmail helper that
|
||||
// injects the email into request context the same way ACLMiddleware
|
||||
// would in production.
|
||||
func TestServeAuthAdmin(t *testing.T) {
|
||||
cfg, _ := profileTestRoot(t, []string{"alice@example.com"})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
email string
|
||||
wantStatus int
|
||||
}{
|
||||
{"empty email is denied", "", http.StatusForbidden},
|
||||
{"non-admin is denied", "bob@example.com", http.StatusForbidden},
|
||||
{"admin is allowed", "alice@example.com", http.StatusOK},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", tc.email)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeAuthAdmin(cfg, rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d (body: %q)",
|
||||
rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServeAuthAdmin_NoZddcRootDeniesEverything covers the bootstrap-
|
||||
// state behaviour: when no /srv/.zddc exists, IsAdmin returns false for
|
||||
// everyone, which means /.auth/admin returns 403 universally. This is
|
||||
// the desired safe-default before an operator drops a root .zddc onto
|
||||
// the share.
|
||||
func TestServeAuthAdmin_NoZddcRootDeniesEverything(t *testing.T) {
|
||||
// profileTestRoot with nil admins skips writing the file entirely.
|
||||
cfg, _ := profileTestRoot(t, nil)
|
||||
|
||||
for _, email := range []string{"", "alice@example.com", "anyone@example.com"} {
|
||||
req := requestWithEmail(http.MethodGet, AuthPathPrefix+"/admin", email)
|
||||
rec := httptest.NewRecorder()
|
||||
ServeAuthAdmin(cfg, rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("email %q without .zddc: status %d, want 403",
|
||||
email, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -66,9 +66,11 @@ 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
|
||||
// /.profile/access serves as JSON. It is derived from cfg + the caller's
|
||||
// email; everything reuses existing helpers in package zddc.
|
||||
// AccessView is the data the profile page lazy-loads from /.profile/access
|
||||
// after first paint. The HTML shell renders only Email/EmailHeader/
|
||||
// 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 {
|
||||
Email string `json:"email"`
|
||||
EmailHeader string `json:"email_header"`
|
||||
|
|
@ -76,11 +78,13 @@ type AccessView struct {
|
|||
HasAnyAdminScope bool `json:"has_any_admin_scope"`
|
||||
Projects []ProjectInfo `json:"projects"`
|
||||
AdminSubtrees []treeEntry `json:"admin_subtrees"`
|
||||
EditableParentChoices []treeEntry `json:"editable_parent_choices"`
|
||||
}
|
||||
|
||||
// enumerateAccess builds an AccessView for the given caller. Callable by
|
||||
// both the HTML page (server-render) and the JSON endpoint without
|
||||
// duplicating the access-walk logic.
|
||||
// enumerateAccess builds an AccessView for the given caller. Used by the
|
||||
// JSON endpoint at /.profile/access; the HTML page no longer calls this on
|
||||
// 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 {
|
||||
view := AccessView{
|
||||
Email: email,
|
||||
|
|
@ -90,6 +94,11 @@ func enumerateAccess(cfg config.Config, email string) AccessView {
|
|||
view.Projects, _ = EnumerateProjects(cfg, email)
|
||||
view.AdminSubtrees = enumerateAdminSubtrees(cfg, email)
|
||||
view.HasAnyAdminScope = view.IsSuperAdmin || len(view.AdminSubtrees) > 0
|
||||
for _, t := range view.AdminSubtrees {
|
||||
if t.CanEdit {
|
||||
view.EditableParentChoices = append(view.EditableParentChoices, t)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,9 +207,48 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestServeProfileHTMLLayered verifies server-side conditional rendering:
|
||||
// non-admin HTML contains zero admin markup, admin HTML adds the admin
|
||||
// block, super-admin HTML adds the diagnostics block.
|
||||
// stripTemplates removes every <template ...>...</template> block from the
|
||||
// HTML body so substring assertions check only ACTIVE markup — i.e. live
|
||||
// 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) {
|
||||
root := t.TempDir()
|
||||
zf := "admins:\n - alice@example.com\n"
|
||||
|
|
@ -239,43 +278,85 @@ func TestServeProfileHTMLLayered(t *testing.T) {
|
|||
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("")
|
||||
if !strings.Contains(anon, "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"} {
|
||||
if strings.Contains(anon, marker) {
|
||||
t.Errorf("anonymous body unexpectedly contains admin marker %q", marker)
|
||||
anonActive := stripTemplates(anon)
|
||||
for _, marker := range []string{
|
||||
`<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")
|
||||
if !strings.Contains(nonAdmin, "carol@example.com") {
|
||||
t.Errorf("non-admin body missing email")
|
||||
}
|
||||
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config"} {
|
||||
if strings.Contains(nonAdmin, marker) {
|
||||
t.Errorf("non-admin body unexpectedly contains admin marker %q", marker)
|
||||
nonAdminActive := stripTemplates(nonAdmin)
|
||||
for _, marker := range []string{
|
||||
`<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")
|
||||
if !strings.Contains(subtree, "Editable .zddc files") {
|
||||
t.Errorf("subtree-admin body missing 'Editable .zddc files'")
|
||||
subtreeActive := stripTemplates(subtree)
|
||||
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") {
|
||||
t.Errorf("subtree-admin body missing 'Create new project folder'")
|
||||
}
|
||||
if strings.Contains(subtree, "Server config") {
|
||||
t.Errorf("subtree-admin body unexpectedly contains super-admin diagnostics")
|
||||
if !strings.Contains(subtree, `<template id="tmpl-subtree-admin">`) {
|
||||
t.Errorf("subtree-admin body missing the <template> the IIFE will hydrate")
|
||||
}
|
||||
|
||||
// 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")
|
||||
for _, marker := range []string{"Editable .zddc files", "Create new project folder", "Server config", "diag-config", "diag-logs", "diag-whoami"} {
|
||||
if !strings.Contains(super, marker) {
|
||||
t.Errorf("super-admin body missing %q", marker)
|
||||
superActive := stripTemplates(super)
|
||||
for _, marker := range []string{
|
||||
"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) {
|
||||
|
|
@ -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) {
|
||||
// .zddc exists but has no admins list — page is still reachable,
|
||||
// but the admin/super-admin sections are absent.
|
||||
|
|
|
|||
|
|
@ -5,40 +5,51 @@ import (
|
|||
"net/http"
|
||||
|
||||
"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 {
|
||||
AccessView
|
||||
Email string
|
||||
EmailHeader string
|
||||
IsSuperAdmin bool
|
||||
ProfilePathPrefix string
|
||||
AssetsPathPrefix string
|
||||
HasCustomCSS bool
|
||||
HasEditableSubtrees bool
|
||||
EditableParentChoices []treeEntry // AdminSubtrees filtered to CanEdit; used as create-project parents
|
||||
}
|
||||
|
||||
// serveProfilePage renders the universal profile page at GET /.profile/.
|
||||
// Reachable to anyone (anonymous included); admin / super-admin sections
|
||||
// are conditionally rendered server-side based on the caller's effective
|
||||
// access — non-admin HTML contains zero admin markup.
|
||||
// Reachable to anyone (anonymous included). The shell is intentionally
|
||||
// minimal: identity card + theme + localStorage + super-admin diagnostic
|
||||
// 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) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
email := EmailFromContext(r)
|
||||
view := profileView{
|
||||
AccessView: enumerateAccess(cfg, EmailFromContext(r)),
|
||||
Email: email,
|
||||
EmailHeader: cfg.EmailHeader,
|
||||
IsSuperAdmin: zddc.IsAdmin(cfg.Root, email),
|
||||
ProfilePathPrefix: ProfilePathPrefix,
|
||||
AssetsPathPrefix: zddcAssetsPathPrefix,
|
||||
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("Cache-Control", "no-store")
|
||||
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,
|
||||
// three layered blocks (universal / admin / super-admin), inline styles
|
||||
// using the same custom-property naming as the editor so a future merge
|
||||
// with shared/base.css stays trivial. One inline IIFE handles theme,
|
||||
// localStorage, and the create-project AJAX submit.
|
||||
// profileTemplate is the html/template for the profile page. The shell is
|
||||
// rendered server-side from cheap-only data (identity + IsSuperAdmin); the
|
||||
// expensive bits (visible projects, admin subtrees, editable .zddc files,
|
||||
// create-project parent choices) are populated by the IIFE below after a
|
||||
// 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>
|
||||
<html lang="en">
|
||||
<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 }}
|
||||
</p>
|
||||
<h3 style="font-size: 1em; margin: .8rem 0 .3rem;">Visible projects</h3>
|
||||
{{ if .Projects }}
|
||||
<ul class="bare">
|
||||
{{ range .Projects }}<li><a href="{{ .URL }}">{{ if .Title }}{{ .Title }}{{ else }}{{ .Name }}{{ end }}</a> <span class="muted">({{ .URL }})</span></li>{{ end }}
|
||||
</ul>
|
||||
{{ else }}
|
||||
<p class="muted">No projects accessible.</p>
|
||||
{{ end }}
|
||||
{{ if .HasAnyAdminScope }}
|
||||
<div id="projects-list"><p class="muted" id="projects-loading">loading…</p></div>
|
||||
<div id="admin-subtrees-block" hidden>
|
||||
<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 }}
|
||||
<div id="admin-subtrees-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
|
@ -183,17 +185,13 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{{ if .HasAnyAdminScope }}
|
||||
<div id="subtree-admin-slot"></div>
|
||||
|
||||
<template id="tmpl-subtree-admin">
|
||||
<section class="card">
|
||||
<h2>Editable .zddc files</h2>
|
||||
<p class="help">Open the form-based editor for any subtree you administer.</p>
|
||||
{{ if .HasEditableSubtrees }}
|
||||
<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 }}
|
||||
<div id="editable-list"></div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<form id="cp-form" autocomplete="off">
|
||||
<label>Parent
|
||||
<select name="parent" id="cp-parent">
|
||||
{{ if .IsSuperAdmin }}<option value="/">/ (root)</option>{{ end }}
|
||||
{{ range .AdminSubtrees }}<option value="{{ .Path }}">{{ .Path }}</option>{{ end }}
|
||||
</select>
|
||||
<select name="parent" id="cp-parent"></select>
|
||||
</label>
|
||||
<label>Name
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
</template>
|
||||
|
||||
{{ if .IsSuperAdmin }}
|
||||
<section class="card">
|
||||
|
|
@ -254,9 +249,14 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
<script>
|
||||
(function() {
|
||||
var prefix = {{ .ProfilePathPrefix }};
|
||||
var hasAdminScope = {{ .HasAnyAdminScope }};
|
||||
var isSuper = {{ .IsSuperAdmin }};
|
||||
|
||||
function escText(s) {
|
||||
var d = document.createElement("div");
|
||||
d.textContent = s == null ? "" : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ── Theme ──────────────────────────────────────────────────────────────
|
||||
var THEME_KEY = "zddc-theme";
|
||||
function applyTheme(v) {
|
||||
|
|
@ -353,8 +353,82 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
document.querySelectorAll('input[name="theme"]').forEach(function(r) { r.checked = (r.value === "auto"); });
|
||||
});
|
||||
|
||||
// ── Create project ────────────────────────────────────────────────────
|
||||
if (hasAdminScope) {
|
||||
// ── Lazy access view ──────────────────────────────────────────────────
|
||||
// Fetch /.profile/access and populate the projects + admin-subtree
|
||||
// sections after first paint. The slow .zddc tree walk happens here, off
|
||||
// the request hot path. Subtree-admin scaffolds are cloned from the
|
||||
// <template> only if the response shows the caller has any admin scope —
|
||||
// pure non-admins never see live admin form markup.
|
||||
function renderProjects(projects) {
|
||||
var host = document.getElementById("projects-list");
|
||||
if (!projects || projects.length === 0) {
|
||||
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");
|
||||
|
|
@ -364,6 +438,14 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
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) {
|
||||
btn.addEventListener("click", function() {
|
||||
var field = btn.dataset.target;
|
||||
|
|
@ -375,13 +457,6 @@ var profileTemplate = template.Must(template.New("profile").Parse(`<!DOCTYPE htm
|
|||
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) {
|
||||
ev.preventDefault();
|
||||
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 ───────────────────────────────────────────
|
||||
if (isSuper) {
|
||||
function loadDiag(target, qs) {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,22 @@ import (
|
|||
"path/filepath"
|
||||
"sort"
|
||||
"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
|
||||
// .zddc file, sorted by path. Reserved-prefix directories ('.', '_') are
|
||||
// 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
|
||||
// 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) {
|
||||
fsRoot = filepath.Clean(fsRoot)
|
||||
|
||||
if cached, ok := scanCache.Load(fsRoot); ok {
|
||||
return cached.([]string), nil
|
||||
}
|
||||
|
||||
var dirs []string
|
||||
err := filepath.WalkDir(fsRoot, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
|
|
@ -45,5 +67,23 @@ func ScanZddcFiles(fsRoot string) ([]string, error) {
|
|||
return nil
|
||||
})
|
||||
sort.Strings(dirs)
|
||||
|
||||
if err == nil {
|
||||
scanCache.Store(fsRoot, dirs)
|
||||
}
|
||||
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)
|
||||
InvalidateScanCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -82,5 +83,6 @@ func DeleteFile(dirPath string) error {
|
|||
return fmt.Errorf("remove: %w", err)
|
||||
}
|
||||
InvalidateCache(dirPath)
|
||||
InvalidateScanCache()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
162
zddc/release.sh
162
zddc/release.sh
|
|
@ -1,146 +1,34 @@
|
|||
#!/bin/sh
|
||||
# release.sh — cut a zddc-server stable release: tag, cross-compile
|
||||
# binaries, publish them as assets to a Codeberg release.
|
||||
# Deprecated — kept as a guard so muscle-memory invocations don't silently
|
||||
# do nothing.
|
||||
#
|
||||
# Usage:
|
||||
# sh zddc/release.sh # patch++ from latest stable tag
|
||||
# sh zddc/release.sh 0.1.0 # explicit version (X.Y.Z)
|
||||
# zddc-server is no longer released independently. The top-level
|
||||
# `./build alpha|beta|release [version]` is the canonical lockstep cut:
|
||||
# it bumps every tool (5 HTML + zddc-server) to the same version,
|
||||
# cross-compiles the binaries, writes a complete release bundle to
|
||||
# dist/release-output/, regenerates the index, and tags every tool.
|
||||
# Run ./deploy to publish to /srv/zddc/.
|
||||
#
|
||||
# Why stable-only: zddc-server publishes binaries only on stable cuts.
|
||||
# Beta/alpha channels of zddc-server have no binary distribution — the
|
||||
# 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.
|
||||
# See AGENTS.md "Releasing — lockstep, channels, layout" for the full
|
||||
# release process.
|
||||
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: release.sh [<version>]
|
||||
cat >&2 <<'EOF'
|
||||
zddc/release.sh is deprecated.
|
||||
|
||||
No args patch-bump from the latest clean zddc-server-vX.Y.Z tag.
|
||||
<version> explicit X.Y.Z (e.g. 0.1.0).
|
||||
Use the top-level lockstep release from the repo root instead:
|
||||
|
||||
./build release # stable, coordinated next version
|
||||
./build release X.Y.Z # stable, explicit version
|
||||
./build alpha # alpha cut for everything
|
||||
./build beta # beta cut for everything
|
||||
./deploy --releases # publish dist/release-output/ to /srv/zddc/
|
||||
|
||||
zddc-server binaries now ship in dist/release-output/ (gitignored,
|
||||
local-only) and are deployed to /srv/zddc/releases/ on the live host.
|
||||
No Codeberg release-asset publication, no LFS. See AGENTS.md
|
||||
"Releasing — lockstep, channels, layout" for details.
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-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)"
|
||||
exit 1
|
||||
|
|
|
|||
Loading…
Reference in a new issue