commit 2bc582fd9e57be123296e3e6ecca22aef93dddd9 Author: Me Here Date: Thu Jun 11 12:55:55 2026 -0500 ZDDC: document-control tools + zddc-server diff --git a/.forgejo/scripts/notify-chart-bump.sh b/.forgejo/scripts/notify-chart-bump.sh new file mode 100755 index 0000000..0f8a111 --- /dev/null +++ b/.forgejo/scripts/notify-chart-bump.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# notify-chart-bump.sh — bump appVersion on tnd-zddc-chart and push. +# +# Replaces the inline shell that previously lived in +# .forgejo/workflows/notify-chart-dev.yml and the notify-chart-prod +# job in deploy-release.yml. Extracting the logic to a real script +# means we can: +# 1. test it locally without going through the runner +# (CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN ./.forgejo/scripts/notify-chart-bump.sh beta) +# 2. invoke manually as an escape hatch when CI is broken +# 3. avoid runner-version shell-wrapper quirks (e.g. Forgejo +# runner v12.9.0 reporting phantom SIGPIPE on bare echo + set -eu) +# +# Usage: +# notify-chart-bump.sh beta +# Bump chart's develop branch with appVersion = -beta- +# (next-stable = max(zddc-server-v* tag) + 1). +# Self-skips if HEAD has a zddc-server-v* tag (the stable workflow +# owns the bump in that case). +# +# notify-chart-bump.sh stable VERSION +# Bump chart's main + develop branches with appVersion = VERSION. +# Called from deploy-release.yml's notify-chart-prod job, where +# VERSION = "${GITHUB_REF#refs/tags/zddc-server-v}". +# +# Requires: +# - run from the ZDDC repo root, with full git history (all tags) +# - $CHART_FORGEJO_TOKEN with repo:write on BMCD/tnd-zddc-chart + +set -eu + +CHANNEL="${1:?usage: $(basename "$0") [VERSION]}" +EXPLICIT_VERSION="${2:-}" + +if [ -z "${CHART_FORGEJO_TOKEN:-}" ]; then + echo "::error::CHART_FORGEJO_TOKEN env not set" >&2 + exit 1 +fi + +CHART_REPO="git.varasys.io/BMCD/tnd-zddc-chart.git" +CHART_URL="https://oauth2:${CHART_FORGEJO_TOKEN}@${CHART_REPO}" + +case "$CHANNEL" in + beta) + # Self-skip if HEAD has a stable tag — prod workflow owns the + # bump in that case (avoids both workflows racing on develop). + STABLE_TAGS=$(git tag --points-at HEAD --list 'zddc-server-v*') + if [ -n "$STABLE_TAGS" ]; then + echo "HEAD has stable tag ($STABLE_TAGS) — stable workflow handles chart bump; skipping" + exit 0 + fi + + LATEST_STABLE=$(git tag --list 'zddc-server-v*' --sort=-v:refname | head -1) + if [ -z "$LATEST_STABLE" ]; then + echo "::error::no zddc-server-v* tags exist; cannot derive next-stable target" >&2 + exit 1 + fi + MAJ=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f1) + MIN=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f2) + PAT=$(echo "${LATEST_STABLE#zddc-server-v}" | cut -d. -f3) + NEXT_STABLE="$MAJ.$MIN.$((PAT + 1))" + + # Pin to HEAD. The dev pipeline's Dockerfile fetches this SHA + # via `git fetch --depth=1 origin ` and runs `go build` + # against it; //go:embed at build time bakes whatever + # zddc/internal/apps/embedded/* and zddc/internal/handler/{form, + # tables}.html are at THAT commit. + # + # Since `./build beta` (build:952-995) now auto-commits the + # regenerated embedded artifacts before push, HEAD always + # contains the bytes the binary will serve. Earlier this script + # read the SHA from embedded/versions.txt to keep the served + # HTML's build label cosmetically matched to the chart's + # appVersion — but that read pinned the chart at the source- + # side commit (HEAD-1), which is the commit BEFORE the + # embedded refresh. The Dockerfile would then bake the previous + # cut's bytes. Manual chart-rebases were required on every beta + # cut. HEAD is the right anchor: substantively correct, even + # if the build-label SHA in the served HTML is one commit + # behind cosmetically (operators triaging "is this image + # current?" should compare chart appVersion to the running + # binary's `--version` output, not the HTML footer). + FULL_SHA=$(git rev-parse HEAD) + TARGET_VERSION="${NEXT_STABLE}-beta-${FULL_SHA}" + BRANCHES="develop" + TRIGGER_DESC="ZDDC beta cut" + TRAILER="Triggered by push to git.varasys.io/VARASYS/ZDDC main with embedded/* changes (a ./build beta cut). Bumps appVersion so the dev Docker image is tagged zddc:$TARGET_VERSION, ensuring kubelet pulls a fresh image on the next helm upgrade." + ;; + stable) + if [ -z "$EXPLICIT_VERSION" ]; then + echo "::error::stable channel requires an explicit VERSION arg" >&2 + exit 1 + fi + TARGET_VERSION="$EXPLICIT_VERSION" + # Bump both branches: main fires BMCD pipeline-prod (prod image + # rebuild), develop fires pipeline-dev so dev follows stable + # whenever no beta is active. + BRANCHES="main develop" + TRIGGER_DESC="ZDDC stable cut" + TRAILER="Triggered by zddc-server-v$TARGET_VERSION tag push on git.varasys.io/VARASYS/ZDDC. Bumps appVersion so prod (and dev tracking stable) rebuild against the new ZDDC stable." + ;; + *) + echo "::error::unknown channel '$CHANNEL' (expected: beta | stable)" >&2 + exit 1 + ;; +esac + +echo "Bumping tnd-zddc-chart appVersion → $TARGET_VERSION" +echo "Branches: $BRANCHES (HEAD=$(git rev-parse HEAD))" + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +cd "$TMP" + +for BRANCH in $BRANCHES; do + echo "" + echo "=== bumping $BRANCH ===" + rm -rf tnd-zddc-chart + git clone --depth=20 --branch="$BRANCH" "$CHART_URL" + cd tnd-zddc-chart + + CURRENT=$(grep '^appVersion:' chart/Chart.yaml \ + | sed -E 's/^appVersion: *"?([^"]*)"?.*/\1/') + if [ "$CURRENT" = "$TARGET_VERSION" ]; then + echo " $BRANCH already at $TARGET_VERSION; skipping" + cd .. + continue + fi + + sed -i "s/^appVersion: .*/appVersion: \"$TARGET_VERSION\"/" chart/Chart.yaml + OLD_CHART_VER=$(grep '^version:' chart/Chart.yaml | awk '{print $2}') + MAJC=$(echo "$OLD_CHART_VER" | cut -d. -f1) + MINC=$(echo "$OLD_CHART_VER" | cut -d. -f2) + PATC=$(echo "$OLD_CHART_VER" | cut -d. -f3) + # Chart-version bump strategy: + # stable cut → MINOR++, PATCH=0 (e.g. 0.2.7 → 0.3.0) + # beta cut → PATCH++ (e.g. 0.3.0 → 0.3.1) + # This keeps the patch number bounded (≈ #betas-per-stable, not + # all-time), while staying monotonically increasing — JFrog chart + # repos reject duplicate chart-version numbers, so a literal + # "reset to 0.2.0" cycle would break uploads after the first + # stable cut. The actual zddc-server version lives in appVersion; + # chart version is just JFrog packaging metadata. + if [ "$CHANNEL" = "stable" ]; then + NEW_CHART_VER="$MAJC.$((MINC + 1)).0" + else + NEW_CHART_VER="$MAJC.$MINC.$((PATC + 1))" + fi + sed -i "s/^version: .*/version: $NEW_CHART_VER/" chart/Chart.yaml + + echo " appVersion: $CURRENT → $TARGET_VERSION" + echo " version: $OLD_CHART_VER → $NEW_CHART_VER" + + git config user.name "ZDDC Release Bot" + git config user.email "noreply@zddc.varasys.io" + git add chart/Chart.yaml + git commit \ + -m "chore(chart): auto-bump appVersion to $TARGET_VERSION ($TRIGGER_DESC)" \ + -m "$TRAILER" \ + -m "Auto-generated by .forgejo/scripts/notify-chart-bump.sh. The next ZDDC beta or stable cut will overwrite this." + + git push origin "$BRANCH" + echo " pushed $BRANCH" + cd .. +done + +echo "" +echo "Done." diff --git a/.forgejo/workflows/deploy-release.yml b/.forgejo/workflows/deploy-release.yml new file mode 100644 index 0000000..bd77d97 --- /dev/null +++ b/.forgejo/workflows/deploy-release.yml @@ -0,0 +1,173 @@ +name: Build + deploy releases + +# Cuts a channel/release bundle (./build alpha|beta|release [version]) +# and rsyncs it to /srv/zddc/releases/ via ./deploy --releases. Runs on +# this host directly (label: host) — same shell environment the operator +# uses for manual cuts, so behavior is identical between the two paths. +# +# Triggers: +# - workflow_dispatch — pick channel + optional version from the UI. +# - push to a tag matching zddc-server-v[0-9]+.[0-9]+.[0-9]+ — +# the canonical "stable cut" tag in our six-tag lockstep set +# (one per tool: archive-vX.Y.Z, transmittal-vX.Y.Z, ..., zddc-server-vX.Y.Z). +# Filtering on zddc-server-v* ensures exactly one workflow run per cut +# even though six tags push together. Runner re-cuts from the tagged +# commit for reproducibility — _promote_stable in shared/build-lib.sh +# is idempotent re: tag creation, so rerunning at the same HEAD is a +# no-op for the tags. + +on: + workflow_dispatch: + inputs: + channel: + description: 'Channel to cut' + required: true + type: choice + default: alpha + options: + - alpha + - beta + - release + version: + description: 'Stable version (e.g. 0.1.0). Leave blank for coordinated next-stable. Ignored for alpha/beta.' + required: false + default: '' + push: + tags: + - 'zddc-server-v[0-9]+.[0-9]+.[0-9]+' + +jobs: + build-and-deploy: + runs-on: host + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # ./build's _coordinated_next_stable reads tags across all six + # tools; full history + tags are required. + fetch-depth: 0 + + - name: Resolve channel + version + id: meta + run: | + set -eu + if [ "$GITHUB_EVENT_NAME" = "push" ]; then + # Tag push: refs/tags/zddc-server-vX.Y.Z → channel=release, version=X.Y.Z + VERSION="${GITHUB_REF#refs/tags/zddc-server-v}" + echo "channel=release" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "channel=${{ inputs.channel }}" >> "$GITHUB_OUTPUT" + echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Build + run: | + set -eu + CH="${{ steps.meta.outputs.channel }}" + VER="${{ steps.meta.outputs.version }}" + if [ "$CH" = "release" ] && [ -n "$VER" ]; then + ./build release "$VER" + else + ./build "$CH" + fi + + - name: Deploy releases + run: ./deploy --releases + + - name: Verify channel mirror resolves + run: | + set -eu + CH="${{ steps.meta.outputs.channel }}" + MIRROR=$([ "$CH" = "release" ] && echo stable || echo "$CH") + # Runner is in a container on caddy-net; reach Caddy by container + # name (`caddy`). --connect-to keeps the SNI / Host as the real + # public hostname so the right vhost matches; -k skips cert + # verify (Caddy uses a self-signed `tls internal` cert). + curl -ksI --connect-to "zddc.varasys.io:8443:caddy:8443" \ + "https://zddc.varasys.io:8443/releases/archive_${MIRROR}.html" \ + | head -3 + + # On a stable cut (tag push), auto-bump tnd-zddc-chart's appVersion to + # match the new ZDDC version on BOTH chart branches: + # + # main → BMCD pipeline-prod fires → prod image rebuilt → prod rolled + # develop → BMCD pipeline-dev fires → dev image rebuilt → dev rolled + # + # Dev tracking stable is the project invariant for "no active beta" + # state — when stable advances, dev advances with it. The next beta + # cut (./build beta on ZDDC main) advances develop ahead of main via + # notify-chart-dev.yml; this stable cut catches develop back up. + # + # Net effect of one ZDDC stable cut: prod + dev + zddc.varasys.io all + # roll automatically with zero manual steps on either repo. Dispatch- + # only invocations of this workflow (workflow_dispatch with + # channel=release) skip — the chart bump is only meaningful when the + # actual git tag exists at refs/tags/. + notify-chart-prod: + needs: build-and-deploy + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/zddc-server-v') + runs-on: host + env: + # Push to Forgejo (BMCD/tnd-zddc-chart on git.varasys.io), NOT + # directly to GitHub. The chart repo is mirrored Forgejo→GitHub + # one-way; pushing directly to GitHub would be silently overwritten + # the next time Forgejo's mirror syncs (force-push semantics). + CHART_FORGEJO_TOKEN: ${{ secrets.CHART_FORGEJO_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Force-sync codeberg push-mirror + verify all 8 tags landed + # The chart Dockerfile fetches zddc-server-v from + # codeberg (BMC AKS has no egress to git.varasys.io). The + # varasys → codeberg push-mirror is sync_on_commit=true but + # occasionally hits a codeberg 504 mid-push, leaving the tag + # set partially replicated. When that happens, the bump in + # the next step triggers BMC pipelines that immediately fail + # at "git fetch refs/tags/zddc-server-v..." until the next + # mirror interval (8h) catches up. + # + # Force a synchronous sync, then poll codeberg until every + # tool's vX.Y.Z tag is visible. Fails the job (and prevents + # the chart bump) if codeberg is genuinely unreachable after + # 5 min — operator runs the sync API manually after. + env: + FORGEJO_TOKEN: ${{ github.token }} + run: | + set -eu + TAG_VER="${GITHUB_REF#refs/tags/zddc-server-v}" + echo "Triggering push-mirror sync for VARASYS/ZDDC..." + curl -fsS -X POST \ + -H "Authorization: token $FORGEJO_TOKEN" \ + "https://git.varasys.io/api/v1/repos/${GITHUB_REPOSITORY}/push_mirrors-sync" + echo "Sync triggered; polling codeberg for all 8 v${TAG_VER} tags..." + TOOLS="archive transmittal classifier landing form tables browse zddc-server" + for i in $(seq 1 60); do + MISSING="" + for T in $TOOLS; do + TAG="${T}-v${TAG_VER}" + if ! git ls-remote --tags https://codeberg.org/VARASYS/ZDDC.git \ + "refs/tags/${TAG}" 2>/dev/null | grep -q "${TAG}$"; then + MISSING="${MISSING} ${TAG}" + fi + done + if [ -z "$MISSING" ]; then + echo "✓ all 8 tags present on codeberg" + exit 0 + fi + echo " (poll $i/60) still missing:${MISSING}" + sleep 5 + done + echo "::error::tags still missing from codeberg after 5 min:${MISSING}" >&2 + curl -sS -H "Authorization: token $FORGEJO_TOKEN" \ + "https://git.varasys.io/api/v1/repos/${GITHUB_REPOSITORY}/push_mirrors" \ + | head -c 800 >&2 + exit 1 + - name: Bump chart for stable cut + # All bump logic lives in .forgejo/scripts/notify-chart-bump.sh + # — same script the dev workflow uses. See its header for + # behavior. Local invocation: + # CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN \ + # .forgejo/scripts/notify-chart-bump.sh stable X.Y.Z + run: ./.forgejo/scripts/notify-chart-bump.sh stable "${GITHUB_REF#refs/tags/zddc-server-v}" diff --git a/.forgejo/workflows/notify-chart-dev.yml b/.forgejo/workflows/notify-chart-dev.yml new file mode 100644 index 0000000..feda2f2 --- /dev/null +++ b/.forgejo/workflows/notify-chart-dev.yml @@ -0,0 +1,35 @@ +name: Notify chart dev on beta cut + +# Triggers when a push to ZDDC main touches zddc/internal/apps/embedded/* +# — i.e. a `./build beta` cut whose embedded artifacts the operator +# committed to main. Pushes a chart appVersion bump to the chart's +# develop branch, which fires BMCD's pipeline-dev → dev image rebuilt +# with the new beta-labeled bytes baked in. +# +# All logic lives in .forgejo/scripts/notify-chart-bump.sh — see that +# script's header for behavior. Workflows just provide checkout + +# secret + invocation. Local invocation is supported (and supported +# without --force-with-lease shenanigans): +# +# CHART_FORGEJO_TOKEN=$FORGEJO_TOKEN .forgejo/scripts/notify-chart-bump.sh beta + +on: + push: + branches: [main] + paths: + - 'zddc/internal/apps/embedded/**' + # Manual trigger — useful for re-firing without a no-op embedded/ + # change to satisfy the paths filter (e.g. after fixing the script + # or workflow itself). + workflow_dispatch: + +jobs: + notify-chart-dev: + runs-on: host + env: + CHART_FORGEJO_TOKEN: ${{ secrets.CHART_FORGEJO_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # script needs full tag history + - run: ./.forgejo/scripts/notify-chart-bump.sh beta diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18d2592 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +tossme/ +examples/ +.env +.vscode + +# Per-project Claude Code state (planning files, agent transcripts, etc.) +.claude/ + +# Session planning (never public) +PLAN.md + +# Node dependencies +node_modules/ + +# Test report and results +playwright-report/ +test-results/ + +# Build artifacts. dist/ is ignored everywhere: per-tool dist/.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. +# +# 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 -vX.Y.Z tag. +dist/ + +# Locally-compiled zddc-server binary. `(cd zddc && go build ./cmd/zddc-server)` +# drops the binary at zddc/zddc-server; the canonical released artifacts live +# under dist/release-output/zddc-server_* with platform suffixes and signing. +zddc/zddc-server +zddc/zddc-server.exe + +# IDE and project files +.opencode/ +opencode.json +package-lock.json +zddc-knowledge*.json +zddc-knowledge*.md +zddc-knowledge*.html + +# tests/data/test-archive.sh fixture output. Default is ~/zddc-test-data +# (outside the repo); these patterns catch in-repo redirects via +# TEST_ARCHIVE_DIR. Defense in depth — the real-archive CSV reference +# at ~/archive-export*.csv must NEVER end up in the repo. +/zddc-test-data/ +/tests/data/output/ +/archive-export*.csv diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2538576 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,771 @@ +# AGENTS.md — ZDDC + +## Commands + +```bash +# ── ./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. `./build beta` is an internal SHA snapshot for the BMC dev +# chart (no public artifacts). `./build release` is the canonical stable +# cut. Run `./deploy` to publish a stable cut. + +./build # dev build (no release bundle) +./build beta # internal SHA snapshot for BMC dev chart + # (regenerates embedded/* + chore commit; + # no public artifacts in dist/release-output/) +./build release # coordinated stable cut, next version + # (tags all 8 artifacts at release commit) +./build release 1.2.0 # coordinated stable cut, explicit version +./build help + +# ── ./deploy subcommands ──────────────────────────────────────────────────── +# rsync the build output and content repo to /srv/zddc/ (Caddy's bind-mount). +# --delete-after — the live tree exactly mirrors source. + +./deploy # full sync (content + releases) +./deploy --content # only ~/src/zddc-website/ → /srv/zddc/ +./deploy --releases # only dist/release-output/ → /srv/zddc/releases/ + +# Single-tool dev build for testing (does NOT touch dist/release-output/): +sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse + +# Single-tool stable cut (rare; prefer ./build release so versions don't +# drift between tools). +sh tool/build.sh --release [] + +# Test all tools +npm test + +# Test single tool +npx playwright test tool # archive | transmittal | classifier | browse | form-safety | tables + +# Dev server (cache-busting HTTP, on port 8000) +./dev-server start +./dev-server stop +``` + +No lint, typecheck, or format commands exist — the project is plain sh + vanilla JS. + +Stable cuts seed `dist/release-output/` from the current +`/srv/zddc/releases/` — copying only immutable per-version files +(`_v.html`, `zddc-server_v_`) + their `.sig` +sidecars + `pubkey.pem`. The cut writes this version's per-version +file + canonical `.html` / `zddc-server_` symlinks on top. +`./deploy --releases` (rsync `--delete-after`) cleanses any stale +files in the live tree that this cut didn't include. + +**Nothing is pushed automatically.** Run `./deploy` to publish; commit ++ push source changes to `main` separately. + +## Architecture + +Seven independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `.table.yaml` next to a sibling `/` rows-dir (see "Form-data system" and "Tables system" below). `browse` is the file-tree navigator and also hosts the in-place markdown editor (`browse/js/preview-markdown.js`); the dedicated `mdedit/` tool has been retired. + +``` +tool/ + css/ source stylesheets (concatenated in order) + js/ vanilla JS IIFEs (concatenated in order) + template.html placeholder markers: {{CSS_PLACEHOLDER}}, {{JS_PLACEHOLDER}}, {{BUILD_LABEL}} + build.sh assembles dist/tool.html + dist/tool.html generated output — committed with `git add -f` + +shared/ + base.css CSS tokens and primitives included first by every tool's CSS build + zddc.js canonical filename/folder/revision parsers, formatters, status validation + zddc-filter.js shared ZDDC project/status filter UI module + zddc-source.js HTTP source abstraction — FS Access API polyfill (HttpDirectoryHandle, + HttpFileHandle) backed by zddc-server's listing JSON + file API + (PUT/DELETE/POST). Tools that auto-load the current dir in HTTP mode + call window.zddc.source.detectServerRoot() at init. The probe + returns { handle, status }: status 200 → use handle; 403 → user + lacks `r` on this directory (show "no permission to list" + message); 0 → not http(s) or non-zddc-server. Tools must + handle the 403 case so a permission-locked path doesn't + silently render as an empty welcome screen. + hash.js SHA-256 helpers used by the file API + classifier hashes + theme.js light/dark theme switcher + help.js shared help dialog module + 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" + +# 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` +# _v.html per-version (immutable) +# _v.html -> ... symlink chain +# .html -> ... canonical symlink → current stable +# zddc-server_v_ per-platform binary (raw bytes, no LFS) +# zddc-server_ canonical per-platform symlink → current stable +# zddc-server_.html stub page surfacing 4 platform DLs + +helm/ + zddc-server-prod/ production-shaped Helm chart (compiles from source via init container) + zddc-server-dev/ dev-shaped variant (tracks main HEAD; debug-level logging; faster probes) + README.md chart design rationale + quick-start +``` + +**Critical:** `dist/` files are gitignored. `tool/dist/.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. + +**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 release` into `dist/release-output/` and rsync'd to `/srv/zddc/releases/` by `./deploy --releases`. Each tool has exactly one canonical URL (`.html`, symlink → current stable) and a set of per-version immutable files (`_v.html`). Same shape for zddc-server per platform. `shared/build-lib.sh` provides `promote_release` (HTML tools) and `promote_zddc_server` (binaries + matching stub pages); the top-level `./build` seeds per-version immutables from live state, then calls them in lockstep. Older releases are reproducible from any `-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`) + +Included as the **first** positional arg to every tool's `concat_files` CSS call. Provides: +- `:root` CSS custom properties — `--primary`, `--bg`, `--text`, `--border`, `--font`, etc. +- Brand color: `--primary: #2a5a8a` (matches zddc.varasys.io) +- Button primitive: `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-sm`, `.btn-lg`, `.btn-link` +- `.app-header` + `.app-header__title` chrome rules +- `.build-timestamp`, `.hidden`, `.truncate`, webkit scrollbars + +**Do not** define these in any tool's own CSS — they come from shared. + +**Toast CSS** lives in `classifier/css/base.css` only (classifier is the only tool that uses toasts). + +## Transmittal CSS quirks + +- `transmittal/css/base.css` overrides `html { font-size: 16px }` inside `@media screen` — this must stay. `shared/base.css` sets `14px`; transmittal's floating labels are rem-based and were designed for 16px. +- The floating label position is defined in `transmittal/css/forms.css`, not Tailwind classes. If adding new Tailwind classes to `template.html`, add them to `transmittal/css/utilities.css` too — there is no Tailwind build step. + +## Build system rules + +- Every `build.sh` sources `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`). Set `root_dir` before sourcing. +- Build scripts use **POSIX sh** (`#!/bin/sh` with `set -eu`), not bash. +- `concat_files` accepts **positional args only** (not array names). +- `awk` processes `template.html`, replacing `{{PLACEHOLDER}}` markers and stripping CDN `` to terminate the block — backslash escaping (`<\/script>`) does **not** prevent termination. Any JS source file or vendor library that contains `` sequences inside string literals or template literals will break the inline `` (not `<\/script>`) to close the `` or any `` in JS string literals | Breaks inline HTML embedding — escape with `'<' + '/tag>'` or use `<\/` in `sed` at build time | +| No external dependencies at runtime | Self-contained output requirement | +| No TypeScript, no bundlers | Keeps the build system auditable and simple | +| Only `window.app` and `window.zddc` are global | Keeps the global namespace clean; expose only what's needed for debugging | +| Defensive input validation | File System API handles and user-pasted data are untrusted | +| Update README.md when features ship | Documentation parity is a delivery requirement, not optional | + +--- + +## Git Workflow + +**Branching:** short-lived feature branches (`feature/`, `bugfix/`, `hotfix/`), squash-merged to `main` and immediately deleted. Quick fixes (typos, one-liners) go direct to `main`. + +**Commit messages:** Conventional Commits — `(): `. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`. See `AGENTS.md` for the full table and examples. + +**Releases:** Tag the commit after confirming `dist/` is current. Format: `{project}-v{version}` (e.g. `archive-v1.0.0`). Semantic versioning applies. There is no CI/CD — the built `.html` file is already committed to the repo. + +```bash +bash tool/build.sh # rebuild dist/ +git add -f tool/dist/tool.html # stage if needed +git commit -m "chore(tool): rebuild for vX.Y.Z" +git tag tool-vX.Y.Z +git push origin main --tags + +git tag -l "archive-v*" # list releases +git push origin :refs/tags/tag-name # delete a remote tag +``` + +--- + +## Adding a New Tool + +1. Create `tool/` with the standard directory layout +2. Write `template.html` with `{{CSS_PLACEHOLDER}}` and `{{JS_PLACEHOLDER}}` markers +3. Write `tool/build.sh` following the pattern of an existing tool +4. Add `bash "$SCRIPT_DIR/tool/build.sh"` to the root `build.sh` +5. Add a test project entry to `playwright.config.js` +6. Create a stub `tests/tool.spec.js` +7. Force-add the dist output: `git add -f tool/dist/tool.html` + +If the tool requires vendor dependencies, download them to `tool/vendor/`, add them to `.gitignore` exclusions if appropriate, and update `build.sh` to inline them (with the `.form.yaml` file in the tree becomes an editable form at `/.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer". +- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer ` validated against self-issued tokens at `/.zddc.d/tokens/` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream ` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time. +- `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory. +- **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 `-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`_v.html`) are immutable; partial-version pins (`_v.html`, `_v.html`) and channel mirrors (`_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v_` per-version binaries plus channel/partial-version symlinks plus `zddc-server_.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 seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in default tree (export it as a `.zddc.zip`: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `` — `archive` at `archive/`, `transmittal` at `archive//staging/`, `browse` at `archive//{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive//incoming/`, `tables` at `archive//{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables rollups across parties with a synthesized `$party` source-party column) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party clicks 302-redirect to `archive///`). Mkdir at project root is restricted to `archive` + `_`/`.`-prefixed system names; the six virtual names are rejected with 409. A `.zip` file is also a navigable directory (`GET …/Foo.zip/` → member listing; `…/Foo.zip/m.pdf` → that member); `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* by dropping a real `.html` at the path or adding an `.html` member to a `.zddc.zip` (resolution: on-disk file → `.zddc.zip` member → embedded; no fetch, no `apps:` key — removed). See AGENTS.md "URL handling"/"Install model" and ARCHITECTURE.md "Canonical folders, URL routing & the `.zddc` cascade". +- `helm/` — example Helm charts for zddc-server. Three flavors: `zddc-server-prod/` (production master), `zddc-server-dev/` (development master with OverlayFS isolation), `zddc-server-cache/` (downstream client running in proxy/cache/mirror mode against an upstream master, with bearer token from a Kubernetes Secret). All compile from source via init container. Operators copy `values.yaml.example` and customize. No secrets in repo — the cache chart references a separately-created Secret for the bearer token. +- `tests/` — Playwright specs (Chromium only, requires File System Access API). `tests/schema.spec.js` validates `transmittal.schema.json` against canonical fixtures via `ajv` (only dev dep besides Playwright) + +## Most-used commands + +```bash +# Source-side dev build only — assembles tool/dist/ + cross-compiles +# zddc-server. Does NOT touch dist/release-output/ or the live site. +./build + +# ./build beta — internal SHA snapshot for the BMC dev chart pipeline. +# Regenerates zddc/internal/apps/embedded/* and makes a +# `chore(embedded): cut v-beta` commit. NO public artifacts. +# The chart's appVersion pins to "-beta-"; its Dockerfile +# parses the suffix and fetches that SHA from git. +./build beta +# +# ./build release — coordinated stable cut. Regenerates embedded/, +# makes a release commit, tags all 8 artifacts, writes per-tool +# _v.html + .html canonical symlink, and zddc-server +# per-platform binaries + canonical symlinks into dist/release-output/. +# Bundle seeded from /srv/zddc/releases/ so prior immutable per-version +# artifacts survive. +./build release # coordinated next-stable version +./build release X.Y.Z # explicit stable 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 [X.Y.Z] # single-tool stable cut (rare; prefer ./build release) + +npm test # all Playwright specs (build first!) +npx playwright test # one spec +./dev-server start # stop # cache-busting HTTP on :8000 + +# zddc/ Go server (sub-project). Go is NOT on the host — run go test/build +# through the localhost/zddc-go:1.24 container (canonical wrapper, with the +# GOPROXY/GOPRIVATE env it needs, in AGENTS.md § Test). The bare command +# below fails on the host. +(cd zddc && go test ./...) # unit tests (Go 1.24+) — via the podman wrapper, not host +``` + +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/.html` is the canonical built artifact for testing and the source for `./build release` writes. `dist/release-output/` is the local-only release bundle. Never hand-edit a `dist/` file. +- **Build vs deploy are separate verbs.** `./build` and `./build 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. +- **Stable cuts seed from live state.** Before running per-tool promote, `./build release` clears `dist/release-output/` and copies only the per-version immutable files (`_v.html`, `zddc-server_v_`) plus their `.sig` sidecars from `/srv/zddc/releases/`. The cut writes this version's per-version files + refreshes the canonical `.html` / `zddc-server_` symlinks on top. `./deploy --releases` (rsync `--delete-after`) cleanses any stale files in the live tree that this cut didn't include. +- **Lockstep releases.** Every release cut bumps all 8 artifacts (7 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 gone — `./build release` is the canonical path. Stable cuts atomically (1) regenerate `zddc/internal/apps/embedded/` with stable-labeled bytes, (2) make a `release: vX.Y.Z lockstep` commit, (3) tag all 8 artifacts at that commit. Tags always point at a clean release commit. (Anchor fix May 2026; see git log around the v0.0.9 re-anchor.) +- **Bake-in invariant.** What zddc-server's binary embeds via `//go:embed`: prod images (chart's Dockerfile.prod fetches the latest stable tag) ship that cut's bytes. Dev images (chart's Dockerfile fetches `appVersion`, which is either a stable tag or a `-beta-` snapshot SHA) ship the bytes that ref carries. Plain `./build` (no arg) leaves `embedded/` untouched — local dev iteration uses `tool/dist/.html` opened directly, not the baked binary copy. +- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`): + - HTML tools: `_v.html` (real immutable file) + `.html` (symlink → current stable's per-version file). Each carries a sibling `.sig` (real for per-version, symlink for canonical). + - zddc-server: `zddc-server_v_` (real immutable binary, no LFS) + `zddc-server_` (symlink → current stable's per-version binary). Same `.sig` pairing. Plus a single `zddc-server.html` stub page that surfaces the four-platform downloads of the current stable. + - No channel mirrors (`_alpha`, `_beta`, `_stable`), no partial-version pins (`_v`, `_v`). Dropped in the May 2026 simplification. +- **On-page build label.** Plain dev builds: `v-dev · · [-dirty]` (red), where X.Y.Z is the next-stable target. `./build beta`: `v-beta · · ` (red) — only seen on the dev chart's compiled binary. Stable cuts: clean `v`. +- **`./build` (no arg) is a source-side dev build.** Assembles `tool/dist/` + cross-compiled binaries; does NOT touch `dist/release-output/`, embedded files, or the live site. Use it to iterate without affecting anything. `./build beta` adds the embedded regen + chore commit (BMC dev chart consumes the SHA via appVersion). `./build release` produces the deployable bundle. `./deploy` publishes. Nothing is pushed to Codeberg automatically. +- **Always build before running tests** — Playwright opens `dist/tool.html` via `file://`. +- **``** 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. +- **Two globals only**: `window.app` (per-tool app state + modules) and `window.zddc` (shared library). No others — anything that crosses tool boundaries goes through one of these. +- **Admin authority is layered: standing config-edit + additive sudo overrides.** Two distinct things — don't conflate them: + - **Config-edit is STANDING (no toggle).** A subtree admin (named in any `admins:` on the cascade) or anyone holding the `a` verb may *read and edit* the `.zddc` / `.zddc.zip` / role definitions of subtrees they administer without elevating — `zddc.IsConfigEditor(chain, email)`. The decider (`policy.InternalDecider.Allow`) grants `VerbA` on that basis *above* the WORM clamp (config isn't WORM-protected data, and `VerbA` only ever authorises config mutation, never write/delete of records). "Admin of X = owns X's policy," bounded to that subtree (authority cascades down only, never up). Plain `.zddc` reads are governed by directory read-ACL (`ServeZddcFile`), so **config is transparent** to anyone who can read the path. + - **Elevation is the sudo escape hatch — purely ADDITIVE.** It only unlocks "things you otherwise couldn't do": WORM bypass, recursive directory delete, rearranging records, acting outside your admin scope. `IsActiveAdmin = (admin authority on the chain) AND Elevated` is the single bypass site in the decider; `IsAdmin`/`IsSubtreeAdmin` stay elevation-gated (they guard the overrides). Carried in the `zddc-elevate=1` **session** cookie (no Max-Age; cleared on `pagehide`, so admin mode is per-page), armed by the on-page toggle every tool renders bottom-right *only for `can_elevate` users*, by `?admin=true|false`, or implicitly for bearer tokens. `shared/elevation.js` applies it in place + emits `zddc:elevationchange` (browse re-fetches verbs); `handler.ACLMiddleware` builds `zddc.Principal{Email, Elevated}` per request. `/.profile/access` exposes `can_elevate`; the access-log captures `elevated` per request. + - **Secrets stay locked:** `.zddc.d/` (bearer tokens, access logs) is reserved regardless of read-ACL. The `.zddc.zip` bundle is visible+editable to config-editors of its directory (not wide-read — one file packs many subtrees' policy). +- **Worktrees live at `~/src/zddc-`.** Check `git worktree list` before starting a feature branch; never `git checkout`/`switch` inside a worktree another agent might be using. +- **Build scripts are POSIX `sh` with `set -eu`**, not bash. `concat_files` takes positional args only. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..54424e0 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Zero Day Document Control (ZDDC) + +**The Universal Distributed Filing Cabinet** + +ZDDC is an information management convention plus a small set of single-file HTML tools. 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 archive — no server, no database, no software required to read it. + +The name "Zero Day Document Control" comes from the convention itself — adopt it on day zero of a project, with no setup time. The tools are *optional* interfaces around the structure; the structure works without them. + +> **For end users**: introduces the project, links to all tool channels (stable / beta / alpha), and prints copy-paste shell snippets to install on a self-hosted deployment. + +## Tools + +| Tool | What it does | +|------|--------------| +| **Archive Browser** | Browse, search, and filter a project archive folder. Group by transmittal, export selections as ZIP. | +| **Transmittal Creator** | Self-contained HTML transmittal records with SHA-256 checksums and optional digital signatures. | +| **Document Classifier** | Spreadsheet-like bulk-renamer that copy/pastes with Excel and writes back to disk. | +| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `.form.html`. | +| **Tables** | Sortable, filterable, in-place-editable grid view over a directory of YAML rows; click a row → edit in the form renderer. Auto-mounts on any directory containing a `table.yaml`. | +| **Browse** | File-tree navigator with previews and an in-place markdown editor (YAML front matter, outline, server-side DOCX/HTML/PDF download); the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. | +| **Landing** | The project picker served at the deployment root of a `zddc-server`. | + +Each tool is published in three channels (stable, beta, alpha) as static files served from . **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. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in default tree (export it as a `.zddc.zip` with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive//mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* by dropping a real `.html` file at the path or adding an `.html` member to a `.zddc.zip` (resolution order: on-disk file → `.zddc.zip` member → embedded; no fetch). + +## Deploy: bootstrap config + +> **A fresh `zddc-server` deployment grants no access to anyone until two config files are populated.** Without them, the server runs but every request returns 403. The embedded default tree ships with empty role members so deployments must opt-in to authorize anyone. + +**Step 1.** At the master root, create `/.zddc` (i.e. `/.zddc`) naming at least one admin: + +```yaml +admins: + - admin@example.com +``` + +`admins:` is honored only at the root file. Admins behave as normal users by default and elevate per-request via the `zddc-elevate=1` cookie (header toggle in every tool) or implicitly when authenticating with a bearer token. + +**Step 2.** In each project, create `/.zddc` to populate the `document_controller` and `project_team` role members: + +```yaml +title: "Project Phoenix" +roles: + document_controller: + members: + - dc1@example.com + project_team: + members: + - alice@example.com + - '*@acme.com' # external counterparty (glob) +``` + +That's it. The embedded cascade does the rest — `project_team` gets read across the project; `document_controller` gets write/create authority on the archive subtree, WORM filing rights on `received/issued`, and subtree-admin of `working/`/`staging/`/`reviewing/`. + +**Common footgun.** `acl: { allow: [...] }` is silently ignored (the YAML parses, but `ACLRules` only reads `permissions:`). The correct shape is: + +```yaml +acl: + permissions: + '': +``` + +Bits are any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny. Principals are emails, globs like `*@domain.com`, or role names (anything without an `@`). + +`zddc-server` prints a startup warning when the root `.zddc` grants nobody anything — watch for it on first boot. For the full schema, run `zddc-server show-defaults` (exports the embedded default tree as a `.zddc.zip`). + +## File-naming convention + +The full specification — filename format, tracking numbers, revision rules, status codes, folder naming, and the transmittal workflow — lives at . + +Quick example: `123456-EL-SPC-2623_A (IFR) - Specification For Switchgear.pdf` + +## Build & develop + +```bash +git clone https://codeberg.org/VARASYS/ZDDC.git && cd ZDDC + +./build # dev build of every tool (writes to dist/ only) +sh archive/build.sh # iterate on one HTML tool + +./build alpha # lockstep alpha cut for all nine artifacts +./build beta # lockstep beta cut +./build release # lockstep stable, coordinated next version +./build release 1.2.0 # lockstep stable at explicit version + +npm install && npx playwright install chromium && npm test # tests +./dev-server start # cache-busting HTTP on :8000 +``` + +Authoritative build/release docs are in [`AGENTS.md`](AGENTS.md). Architecture notes (single-file rationale, JS module pattern, security model) are in [`ARCHITECTURE.md`](ARCHITECTURE.md). zddc-server (optional Go HTTP server with ACL and a virtual archive index) is in [`zddc/README.md`](zddc/README.md). Example Helm charts for deploying zddc-server (production + dev) are under [`helm/`](helm/). + +## Contributing + +ZDDC is an open source project hosted on Codeberg at . Bug reports, feature requests, and pull requests welcome. + +ZDDC is designed for zero configuration to start and minimal configuration overall — feature proposals are filtered through that lens. + +## License + +[GNU Affero General Public License v3.0](https://www.gnu.org/licenses/agpl-3.0.en.html). Free to use, modify, and distribute, including commercially, under the terms of the license. Provided "as is" without warranty. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6955592 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,171 @@ +# Security Policy + +This document describes how to report vulnerabilities in ZDDC and what to expect +in response. Compatible with the OpenSSF [`SECURITY.md` template] and addresses +NIST 800-53 SI-5 (vulnerability disclosure) for federal evaluators. + +[`SECURITY.md` template]: https://github.com/ossf/oss-vulnerability-guide + +## Supported versions + +Security fixes are issued for: + +- **Latest stable release** of every artifact (six HTML tools + zddc-server) — + always supported. +- **Previous stable major-minor** (e.g. `v1.2.x` while `v1.3.x` is current) — + best-effort backports for issues with severity ≥ High (CVSS v3.1 ≥ 7.0). +- **Earlier versions** — out of scope. Reproduce the issue on a current + release before reporting. + +zddc-server's release tags (`zddc-server-vX.Y.Z`) and the HTML tool tags +(`-vX.Y.Z`) all share the same X.Y.Z on lockstep cuts; "current stable" +means the highest X.Y.Z tagged across the seven artifacts. + +## Reporting a vulnerability + +**Preferred channel: private email.** + +Send a description of the issue, reproduction steps, and (if known) a +suggested fix to: + +> **caseywitt@proton.me** + +Include in the email: + +1. The artifact and version affected (e.g. `zddc-server-v0.0.16` or + `archive-v0.0.16`). +2. The shortest reproduction you can produce — a single curl command, a + `.zddc` fixture that triggers the issue, etc. +3. Your assessment of severity (CVSS v3.1 vector if you can; otherwise a + short description of impact). +4. Whether you'd like attribution in the eventual advisory or prefer to + stay anonymous. + +PGP encryption is available on request — reply to the initial email asking +for the key; we'll send it from the same address. + +**Secondary channel: GitHub Security Advisories.** This repository's +upstream is at ; a mirror is published +on GitHub when one is needed for ecosystem compatibility. If you prefer +the GitHub workflow over email, use the "Report a vulnerability" link on +the GitHub mirror's Security tab. Codeberg does not yet have an +equivalent of GitHub Security Advisories, so for embargoed coordination +GitHub is the better path. + +**Do not** open a public issue on either Codeberg or the GitHub mirror +for security reports. Do not post reproduction details to any public +chat, mailing list, or social channel until the embargo lifts. + +## Response timeline + +| Time | What to expect | +|---|---| +| Within 3 business days of report | Acknowledgement that we received it and are looking at it | +| Within 14 days | Initial assessment: confirmed / not-a-vulnerability / need-more-info, with severity rating | +| Within 90 days | Fix released, or an updated timeline if the issue is genuinely complex | + +If you don't hear back within 3 business days, retry — the email may have +been mis-filed. Mention "ZDDC security" in the subject so it stands out. + +## Embargo and disclosure + +We coordinate disclosure via embargo when the issue is non-trivial: + +1. **Embargo agreed.** Reporter and maintainer agree on a public-disclosure + date (default 90 days from confirmation; sooner for severity Critical + issues with active exploitation). +2. **Fix developed.** Patch + tests in a private branch; reproduction case + added to the regression suite. +3. **Pre-disclosure notification.** ~1 week before the embargo lifts, we + notify known downstream redistributors (Helm-chart consumers, + container-image bakers) so they can stage updates. +4. **Coordinated release.** On the embargo date: release the fixed version, + publish the advisory (with reporter attribution if desired), request a + CVE if applicable. +5. **Public reproduction details.** Public proof-of-concept code is held + until 30 days after the fix release, to give operators a window to + upgrade. + +## CVE assignment + +For issues meeting the [MITRE CVE rules][cve-rules] (severity ≥ Medium, +publicly redistributed code, identifiable distinct vulnerability), we +request CVE IDs via: + +- **GitHub Security Advisories**, when the GitHub mirror is the + disclosure channel (GitHub is a CVE Numbering Authority and assigns + IDs automatically for advisories published through their flow). +- **MITRE direct request**, otherwise, with the original report + attached as evidence. + +[cve-rules]: https://www.cve.org/Resources/General/Counting-Rules.pdf + +The CVE ID, when assigned, appears in: + +- The release-notes section of the fixed version's tag annotation. +- The published advisory. +- The relevant entry in `CHANGELOG.md` (when one exists; not every + release ships with one). + +## In-scope vulnerabilities + +Examples of issues we want to hear about: + +- Authentication / authorization bypass — anything that lets a caller + reach a directory their `.zddc` chain or external OPA policy says + they shouldn't. +- Path traversal — directory listings, archive resolutions, or form + submissions that escape `ZDDC_ROOT`. +- Injection — XSS in tool HTML, command injection in shell scripts, + YAML deserialization issues in `.zddc` parsing. +- Cryptographic weaknesses — though the production binary uses Go + stdlib crypto and the transmittal-signing feature uses Web Crypto; + TLS hardening is configurable. +- Cross-tool data leaks — one tool's storage being accessible via + another tool's UI or URL. +- Rate-limiting / resource-exhaustion — if a single unauthenticated + request can pin CPU, exhaust file descriptors, or fill disk. +- Supply-chain — tampering with vendored libraries, the `apps:` URL + fetch path, or the cross-compile build. + +## Out of scope + +- **Email-header forgery via direct connection to the bind address.** + Documented behavior — `zddc-server` trusts whatever the upstream + proxy sets as `X-Auth-Request-Email`. Network isolation is the + operator's responsibility (see `zddc/README.md` § "Trust boundary"). + Reports of "I bypassed auth by curl-ing 127.0.0.1:8080 directly" + are not vulnerabilities; the documented deployment model requires + the bind address to be unreachable except via the proxy. +- **Anonymous information disclosure on `/` and `/.profile`.** + Documented as intentional — the public landing page is a project + picker filtered by ACL. Operators who can't tolerate disclosure + gate `/` behind their proxy's auth-required path. +- **`apps:` URL-fetch trust.** Documented — fetched-once-and-cached, + no integrity verification. Treat `.zddc` write authority as full + UI-mounting authority. (SHA-256 pinning is on the federal-readiness + roadmap; see `zddc/README.md` § "Federal-readiness gap analysis".) +- **Theoretical issues without a working reproduction** — we'll engage + with proof-of-concept code; not with hypothetical chains. +- **Issues in third-party dependencies that we vendor** — report those + to the upstream project. We'll backport their fix in the next + release cut. (Vendored libs: jszip, docx-preview, xlsx in some + HTML tools; gopkg.in/yaml.v3, fsnotify, lumberjack, klauspost + compress in zddc-server.) +- **Social engineering / phishing / misconfigured customer + deployments** unless the misconfiguration is plausibly the default. + +## Federal evaluators + +Key references for ATO conversations: + +- `zddc/README.md` § "Federal-readiness gap analysis" — every NIST + control we know is incomplete, with the planned remediation. +- `ARCHITECTURE.md` § "Server security model" — commercial-vs-federal + trust model side-by-side. +- `zddc/internal/policy/` — pluggable OPA-compatible policy decider + for federal customers running their own audited Rego policies. + +We do not currently hold an ATO, FedRAMP authorization, or any other +formal accreditation. The system is designed to be ATO-able by +customers; the documentation aims to make that path obvious. diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..571d838 --- /dev/null +++ b/archive/README.md @@ -0,0 +1,277 @@ +# Archive Browser + +[← Back to ZDDC](../README.md) + +Your digital filing cabinet - a complete document management system in a single HTML file. No installation, no updates, no cloud required. Just open it and start organizing. + +**[🔗 Open Archive Browser](dist/archive.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy forever. + +## What Makes This Special? + +This is a "record player with the record" - the entire application travels with the file. Save it to a USB drive, email it to a colleague, or archive it with your project files. It will work exactly the same way in 20 years as it does today. No dependencies, no obsolescence. + +## Quick Start + +1. **Click "Select Directory"** - Choose your project folder +2. **All folders auto-expand** - See everything at once +3. **Type to filter** - Use the search boxes to find files instantly +4. **Click to sort** - Any column header sorts your data +5. **Download selected** - Check boxes and download as ZIP + +## Overview + +The archive browser presents a two-pane interface: +- **Navigation Pane** (left): Your folder hierarchy with smart filtering +- **Content Area** (right): All your files in a searchable, sortable table + +## User Interface Layout + +### Navigation Pane +The navigation pane displays a hierarchical folder structure with: + +1. **Grouping Folders** (top level) + - Folders that don't match transmittal naming convention + - Used for organizational hierarchy (e.g., permissions, departments) + - Has its own autofilter input + - Supports multi-select (Shift+Click, Ctrl+Click) + - **Right-click context menu** for recursive select/deselect of folder trees + - **Collapsible section** with toggle button to hide/show when not needed + - **Resizable height** - drag the divider to adjust space allocation + - Default: 250px height, can be collapsed to header-only + - Folders named "incoming" (case-insensitive) are excluded from default selection + +2. **Transmittal Folders** (displayed in a flat sorted list in a separate section below the grouping folders) + - Follow naming convention: `YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE` + - Example: `2025-09-15_A101-203 (IFC) - Site Plan` + - Has its own autofilter input + - Supports multi-select + - Only folders within selected grouping folders are visible + - **Grouped by date** with collapsible date headers showing folder count + - **Expand/collapse all** toggle button in section header + - Date removed from individual folder display (shown in group header instead) + - All transmittal folders selected by default (except those under "incoming") + +### Content Area +Displays files from all selected transmittal folders in a unified table. + +**Table Columns**: +1. **Tracking Number** - Extracted from filename +2. **Title** - Extracted from filename +3. **Revisions** - Shows all available revisions/documents for a tracking number + - Each revision shows: revision identifier, status, and file links + - Multiple files per revision supported (e.g., PDF, DWG) + - Checkboxes for selecting individual files + +The Revisions column must provide an efficient way to both display all revisions and modifiers compactly while allowing for efficient selection of specific revisions. + + +## Core Features + +### 🔍 Smart Search & Filter +- **Find anything instantly** - Type in any filter box to narrow results +- **Power search** - Use `+must have` or `-exclude` for precise filtering +- **Excel-like sorting** - Click any column header to sort your data + +### 📁 Organize Your Files +- **Drag & drop** - Drop files onto folders to create organized transmittals +- **Smart naming** - Automatically extracts document info from ZDDC filenames +- **Version tracking** - See all revisions of a document in one place +- **Batch operations** - Select multiple files for download or export + +### 🔒 Data Integrity +- **SHA-256 checksums** - Verify files haven't changed +- **Hash caching** - Fast rescanning of large archives +- **Export to ZIP/CSV** - Take your data with you + +Files are sorted by tracking number first, then by revision in proper order (~A, A, B, C+C1, C, 1, 2, 3, etc.) + +## Technical Architecture + +### Frontend Stack +- **Vanilla JavaScript**: No framework dependencies +- **Inline CSS**: Self-contained styling +- **File System Access API**: Local directory access +- **Web Crypto API**: SHA-256 file hashing + +### Build System +- Modular architecture with separate CSS and JavaScript files +- Build script concatenates and inlines all assets +- Produces single self-contained HTML file +- No external dependencies in final output + +Uses the same build.sh structure as the transmittal project (requires Git Bash on Windows). + + +## Additional Features + +### File Operations +- **Download Selected**: Creates ZIP file of checked files +- **Export CSV**: Exports only visible/filtered files with metadata +- **Drag & Drop**: Drop files onto table rows to copy metadata +- **File Renaming**: Modal for fixing non-conforming filenames + +### Context Menu Operations (Right-click on Grouping Folders) +- **Select This & Subfolders**: Recursively select folder and all descendants +- **Deselect This & Subfolders**: Recursively deselect folder and all descendants +- **Select All Visible**: Select all currently visible grouping folders +- **Deselect All**: Clear all grouping folder selections + +Users can drag and drop files onto a grouping folder. The system creates a transmittal folder with the correct naming convention and displays a dialog where users can: + - Confirm/edit the transmittal folder name + - Review and correct file names to ensure ZDDC compliance + - See preview of final file organization before committing + +### Data Management +- **SHA-256 Hashing**: Integrity verification for all files +- **Hash Cache**: `.hashes.json` file to avoid re-hashing unchanged files +- **Refresh**: Re-scan directories for changes + +Implements hash caching by creating a `.hashes.json` file in each scanned directory (when writable) to store file hashes and modification times, significantly improving performance on subsequent scans. + +## Implementation Details + +### Resizable Interface +- **Column Resizing**: Draggable column borders with persistent widths +- **Navigation Pane Width**: Drag horizontal divider between nav pane and content area +- **Section Heights**: Drag vertical divider between grouping and transmittal sections +- Visual feedback (blue highlight) when hovering or actively resizing +- Minimum/maximum sizes enforced to prevent unusable layouts + +### Performance Optimizations +- Standard scrolling with sticky headers (no virtual scrolling needed) +- Debounced search inputs +- Progressive file scanning +- Cached file metadata + +The table uses standard scrolling with sticky headers for navigation. No pagination or virtual scrolling is needed. + +### Folder Hierarchy Logic +1. **Grouping Folders**: Any folder not matching transmittal convention +2. **Transmittal Folders**: Match `YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE` +3. **Selection Cascade** (strict enforcement): + - If grouping folders exist, at least one must be selected to see transmittal folders + - Selecting grouping folder shows only its transmittal folders + - At least one transmittal folder must be selected to see files + - Selecting transmittal folders shows only their files + - Multiple selections combine results + - **Default Selection**: All folders selected except "incoming" and its subfolders + +### File Grouping Logic +Files with the same tracking number are grouped together, showing: +- Base revisions (A, B, C, 1, 2, 3) +- Revision modifiers (+C1, +B1, +N1) +- Draft indicators (~A, ~B) +- Multiple file types per revision + + +## Filtering + +Each column has a text filter input. The syntax supports simple boolean logic per field: +- Required token: `+token` must be present in that field. +- Prohibited token: `-token` must not be present. +- Parentheses group sub‑expressions: `(+revA plan)`. +- Terms (without +/−) are OR’ed within the same field: `as-built asbuilt`. +- Wildcard support: `+token*` (starts with), `*token` (ends with), `token` (contains) +Examples: + +- Only PDFs: in EXT filter, type `+pdf`. +- Exclude superseded: in Status, type `-superseded`. +- Revisions A or B, but not Draft: in Revision, type `revA revB -draft`. +- Title contains both "floor" and "plan": Title `+floor +plan`. + +## UI/UX Considerations + +### Visual Design +- Clean, professional interface +- Hover states for interactive elements +- Clear visual hierarchy +- Consistent spacing and alignment +- Status color coding (optional) + +Status codes are prominently displayed alongside revisions without color coding. The status is always shown as part of the revision information for clarity. + +### Accessibility +- Keyboard navigation support (Ctrl+Click, Shift+Click, Ctrl+A) +- Right-click context menus for advanced operations +- ARIA labels for screen readers +- High contrast mode support +- Resizable text and interface elements +- Collapsible sections to reduce visual clutter + +### Error Handling +- Graceful handling of permission errors +- Clear error messages +- Recovery options +- Console logging for debugging + + +## Browser Compatibility + +- **Required**: Chromium-based browsers (Chrome, Edge, Brave) for File System Access API +- **Fallback**: Display message for unsupported browsers +- **Print Styles**: Optimized for US Letter (8.5×11") +- **Responsive Design**: Works on desktop and tablet screens + +## Windows Path Length Limitations + +The application includes safeguards for Windows' 260-character path limit: + +- **Path Length Monitoring**: Warns when paths exceed 240 characters +- **Depth Limits**: Stops scanning directories deeper than 10 levels +- **Path Truncation**: Long paths are truncated in the UI while maintaining full paths internally +- **Graceful Failure**: Files with paths too long are skipped with console warnings +- **Display Optimization**: Shows `...` with shortened paths for better readability + +To enable long path support in Windows 10 (1607+): +1. Run `gpedit.msc` as Administrator +2. Navigate to: Computer Configuration → Administrative Templates → System → Filesystem +3. Enable "Enable Win32 long paths" +4. Restart your computer + +## Security Considerations + +- No data leaves the browser +- All processing happens locally +- Directory permissions requested per-session +- No tracking or analytics + +## File Preview + +Clicking on a file link opens it in a new browser tab if the browser can display it (PDFs, images, text files), otherwise triggers a download. + +## Deliverables + +1. **Single HTML file** (`archive.html`) containing all functionality +2. **Modular source code** organized as: + - `js/` - JavaScript modules + - `css/` - CSS modules + - `template.html` - HTML template + - `build.sh` - Build script +3. **Documentation** embedded in the final HTML file + +## CSS/JS Architecture + +### CSS Files (loaded in dependency order) + +| File | Size | Purpose | +|------|------|---------| +| `css/base.css` | 1.5KB | Core styles, layout reset, typography, theme variables | +| `css/components.css` | 10KB | Button styles, inputs, modal dialogs, menu systems | +| `css/layout.css` | 3.7KB | Page structure, header/footer, container width | +| `css/table.css` | 3.4KB | Table styling, cell padding, border styles, sorting headers | +| `css/print.css` | 2.2KB | Print-specific styles, hide interactive elements | + +### JavaScript Modules (loaded in dependency order) + +| File | Size | Purpose | +|------|------|---------| +| `js/parser.js` | 7.4KB | ZDDC filename parsing, revision extraction, status validation | +| `js/hash.js` | 5.9KB | SHA-256 hashing for file integrity verification | +| `js/drag-drop.js` | 9.1KB | File system access API integration, drag-and-drop handling | +| `js/directory.js` | 11.9KB | Directory scanning, folder tree rendering, path handling | +| `js/filtering.js` | 8.8KB | Boolean filter logic, column filtering, show/hide rows | +| `js/table.js` | 26KB | Table rendering, row management, selection handling | +| `js/export.js` | 8.2KB | JSON export, file download with ZDDC naming | +| `js/events.js` | 18.9KB | Event bus, state change notifications, UI coordination | +| `js/app.js` | 19.6KB | Main entry point, initialization, state management | + diff --git a/archive/build.sh b/archive/build.sh new file mode 100755 index 0000000..4c0cf1a --- /dev/null +++ b/archive/build.sh @@ -0,0 +1,113 @@ +#!/bin/sh +set -eu + +root_dir=$(cd "$(dirname "$0")" && pwd) +. "$root_dir/../shared/build-lib.sh" + +src_html="$root_dir/template.html" +output_dir="$root_dir/dist" +output_html="$output_dir/archive.html" + +mkdir -p "$output_dir" +ensure_exists "$src_html" + +css_temp=$(mktemp) +js_raw=$(mktemp) +js_temp=$(mktemp) +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } +trap cleanup EXIT + +# CSS files to concatenate in order +concat_files \ + "../shared/fonts.css" \ + "../shared/base.css" \ + "../shared/toast.css" \ + "../shared/elevation.css" \ + "../shared/profile-menu.css" \ + "../shared/logo.css" \ + "css/base.css" \ + "css/layout.css" \ + "css/components.css" \ + "css/table.css" \ + "css/print.css" \ + > "$css_temp" + +# JavaScript files to concatenate in order. Vendored libraries first +# (jszip, docx-preview) so window.JSZip + window.docx are defined before +# any tool code runs — replaces the previous CDN loadLibrary() calls in +# table.js + export.js. xlsx is intentionally still CDN-loaded on demand +# (~900 KB; too large to inline). +concat_files \ + "../shared/vendor/jszip.min.js" \ + "../shared/vendor/docx-preview.min.js" \ + "../shared/vendor/xlsx.full.min.js" \ + "../shared/vendor/utif.min.js" \ + "../shared/zddc.js" \ + "../shared/hash.js" \ + "../shared/zip-source.js" \ + "../shared/theme.js" \ + "../shared/toast.js" \ + "../shared/logo.js" \ + "../shared/preview-lib.js" \ + "js/init.js" \ + "js/parser.js" \ + "js/source.js" \ + "js/hash.js" \ + "js/drag-drop.js" \ + "js/directory.js" \ + "../shared/zddc-filter.js" \ + "js/filtering.js" \ + "js/table.js" \ + "js/export.js" \ + "js/presets.js" \ + "js/url-state.js" \ + "js/events.js" \ + "js/app.js" \ + "../shared/help.js" \ + "../shared/elevation.js" \ + "../shared/profile-menu.js" \ + "../shared/cap.js" \ + > "$js_raw" + +# Escape ' tag. Required for any tool with template literals. +escape_js_close_tags "$js_raw" "$js_temp" + +compute_build_label "archive" "${1:-}" "${2:-}" + +# Process template: inject CSS/JS, substitute build label, strip CDN refs. +awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" ' + /\{\{CSS_PLACEHOLDER\}\}/ { + while ((getline line < css_file) > 0) print line + close(css_file) + next + } + /\{\{JS_PLACEHOLDER\}\}/ { + while ((getline line < js_file) > 0) print line + close(js_file) + next + } + /\{\{BUILD_LABEL\}\}/ { + if (is_red == "1") { + gsub(/\{\{BUILD_LABEL\}\}/, "" build_label "") + } else { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + } + print + next + } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } + / + + diff --git a/browse/README.md b/browse/README.md new file mode 100644 index 0000000..24abd86 --- /dev/null +++ b/browse/README.md @@ -0,0 +1,53 @@ +# browse — directory listing tool + +Generic file browser for any directory. Designed to work with ZDDC +archives but useful for any folder. Single-file HTML, no install. + +## How it's used + +Two modes, auto-detected at page load: + +1. **Online (zddc-server backed).** When this HTML is served by + zddc-server at a folder URL — which it is by default for any + directory under `ZDDC_ROOT` that doesn't have an `index.html` — + the JS queries the same URL with `Accept: application/json` to + load the directory's listing and renders it as a sortable, + filterable table. + +2. **Local (FileSystemAccessAPI).** Click "Select Directory" in the + header to pick any folder on your computer. Works in + Chromium-based browsers (Chrome, Edge, Brave, etc.). No server + required; the directory is read directly from disk. + +## What it does + +- Lists files and folders with name, size, type (extension), and + modified date. +- Click a folder to expand inline. Children load lazily on first + expand. +- Click a column header to sort by that column. Click again to + reverse. +- Type in the filter to narrow to entries whose name contains the + substring. +- Click any file to open it in a new tab — for server-backed pages, + this routes through zddc-server's normal handler (so an `.archive` + redirect, an apps cascade override, etc. all work as expected). + +## Design notes + +- **No ZDDC-specific filtering.** This tool is intentionally + domain-agnostic. The companion `archive` tool layers ZDDC + parsing (project / status / revision filters, tracking-number + resolution) on top of the same listing API. Use `archive` when + you want ZDDC semantics; use `browse` when you just want to see + what's in a folder. +- **Default at directory URLs.** zddc-server's `directory.go` + serves the embedded browse.html bytes for any directory request + with `Accept: text/html` and no `index.html` present. This + means a user navigating to any folder under `ZDDC_ROOT` gets a + usable browser without anyone having to drop a file into the + archive. +- **Apps cascade override.** Like every other ZDDC tool, the + served `browse.html` can be overridden per-folder via a `.zddc + apps:` entry. The default is the embedded copy from the binary; + operators can pin a specific version or URL if they want. diff --git a/browse/build.sh b/browse/build.sh new file mode 100755 index 0000000..51469bb --- /dev/null +++ b/browse/build.sh @@ -0,0 +1,151 @@ +#!/bin/sh +set -eu + +root_dir=$(cd "$(dirname "$0")" && pwd) +. "$root_dir/../shared/build-lib.sh" + +src_html="$root_dir/template.html" +output_dir="$root_dir/dist" +output_html="$output_dir/browse.html" + +mkdir -p "$output_dir" +ensure_exists "$src_html" + +css_temp=$(mktemp) +js_raw=$(mktemp) +js_temp=$(mktemp) +# Generated schema lives under dist/ (gitignored); concat_files resolves paths +# relative to $root_dir, so we pass the relative form. +schema_rel="dist/.zddc-schema.gen.js" +schema_js="$root_dir/$schema_rel" +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; } +trap cleanup EXIT + +# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover +# all share ONE grammar (no hand-kept key list to drift from the Go structs) +# AND work offline (file://), where /.api/zddc-schema is unreachable. This is +# the exact file the server serves at that endpoint. +schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json" +ensure_exists "$schema_src" +{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js" + +# CSS files: shared base first, then browse-specific. Toast UI's CSS +# is bundled because the markdown plugin uses Toast UI inside the +# preview pane (.md files render as a full editor). +concat_files \ + "../shared/fonts.css" \ + "../shared/base.css" \ + "../shared/toast.css" \ + "../shared/logo.css" \ + "../shared/vendor/toastui-editor.min.css" \ + "../shared/vendor/codemirror-yaml.min.css" \ + "../shared/vendor/codemirror-show-hint.min.css" \ + "../shared/context-menu.css" \ + "../shared/elevation.css" \ + "../shared/profile-menu.css" \ + "css/base.css" \ + "css/tree.css" \ + "css/preview-yaml.css" \ + "css/history.css" \ + "css/manage-access.css" \ + > "$css_temp" + +# JS files: shared canonical helpers, then browse modules. +# init.js must come first so window.app exists when later modules +# attach to it. JSZip is vendored (rather than CDN-loaded) so ZIP +# expansion in the tree works under restrictive networks / CSPs and +# without an external HTTP dependency. +concat_files \ + "../shared/vendor/jszip.min.js" \ + "../shared/vendor/docx-preview.min.js" \ + "../shared/vendor/xlsx.full.min.js" \ + "../shared/vendor/utif.min.js" \ + "../shared/vendor/js-yaml.min.js" \ + "../shared/vendor/codemirror-yaml.min.js" \ + "../shared/vendor/codemirror-show-hint.min.js" \ + "../shared/vendor/toastui-editor-all.min.js" \ + "../shared/zddc.js" \ + "../shared/zddc-filter.js" \ + "../shared/diff.js" \ + "../shared/zip-source.js" \ + "../shared/theme.js" \ + "../shared/toast.js" \ + "../shared/logo.js" \ + "../shared/help.js" \ + "../shared/preview-lib.js" \ + "../shared/context-menu.js" \ + "../shared/elevation.js" \ + "../shared/profile-menu.js" \ + "../shared/cap.js" \ + "../shared/icons.js" \ + "../shared/zddc-source.js" \ + "js/init.js" \ + "$schema_rel" \ + "js/util.js" \ + "js/yaml-complete.js" \ + "js/manage-access.js" \ + "js/conflict.js" \ + "js/menu-model.js" \ + "js/loader.js" \ + "js/tree.js" \ + "js/preview.js" \ + "js/preview-markdown.js" \ + "js/preview-yaml.js" \ + "js/hovercard.js" \ + "js/grid.js" \ + "js/upload.js" \ + "js/download.js" \ + "js/plan-review.js" \ + "js/accept-transmittal.js" \ + "js/stage.js" \ + "js/history.js" \ + "js/create-transmittal.js" \ + "js/events.js" \ + "js/app.js" \ + > "$js_raw" + +# Escape any literal ` block doesn't get terminated prematurely. +escape_js_close_tags "$js_raw" "$js_temp" + +tool=browse +compute_build_label "$tool" "$@" + +# Replace template placeholders with concatenated CSS/JS + label. +# Non-stable build labels (alpha/beta/dev-dirty) are wrapped in a red +# span — same convention as every other tool (compute_build_label +# sets $is_red=1 for non-stable cuts). Keeps the visual cue +# consistent across tool headers. +awk -v css_file="$css_temp" -v js_file="$js_temp" \ + -v build_label="$build_label" -v is_red="$is_red" \ + -v favicon="$favicon_data_uri" ' + /\{\{CSS_PLACEHOLDER\}\}/ { + while ((getline line < css_file) > 0) print line + close(css_file); next + } + /\{\{JS_PLACEHOLDER\}\}/ { + while ((getline line < js_file) > 0) print line + close(js_file); next + } + /\{\{BUILD_LABEL\}\}/ { + if (is_red == "1") { + gsub(/\{\{BUILD_LABEL\}\}/, "" build_label "") + } else { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + } + print; next + } + { + gsub(/\{\{FAVICON\}\}/, favicon) + print + } +' "$src_html" > "$output_html" + +echo "Wrote $output_html" + +# Promote AFTER the dist file exists so promote_release can copy from +# $output_html. (The order matters — _promote_stable does cp $output_html ...) +# Only fires on a release cut; plain dev builds leave release-output alone. +if [ "$is_release" = "1" ]; then + promote_release "$tool" +fi diff --git a/browse/css/base.css b/browse/css/base.css new file mode 100644 index 0000000..0f45292 --- /dev/null +++ b/browse/css/base.css @@ -0,0 +1,44 @@ +/* browse-specific layout on top of shared/base.css */ + +html, body { + height: 100%; + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +#appMain { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* .empty-state / .empty-state__inner live in shared/base.css. */ + +/* .hidden lives in shared/base.css; no per-tool override needed. */ + +/* Read-only banner for the YAML editor — surfaced by preview-yaml.js + when the listing's `writable` bit was false. CodeMirror's readOnly + mode has no built-in visual signal beyond the disabled caret, so a + banner here is the explicit cue. The markdown editor doesn't need + one because its read-only mount uses Toast UI's Viewer (no edit + toolbar at all). */ +.yaml-readonly-banner { + background: rgba(220, 53, 69, 0.10); + color: var(--text); + border-bottom: 1px solid rgba(220, 53, 69, 0.35); + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.4rem; +} diff --git a/browse/css/history.css b/browse/css/history.css new file mode 100644 index 0000000..7e23771 --- /dev/null +++ b/browse/css/history.css @@ -0,0 +1,131 @@ +/* history.css — markdown edit-history modal (browse/js/history.js). */ + +.md-history-box { + background: var(--bg, #fff); + color: var(--fg, #111); + padding: 1.1rem 1.35rem; + border-radius: 6px; + min-width: 30rem; + max-width: 56rem; + width: 80vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + font-family: inherit; +} + +.md-history-title { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + overflow-wrap: anywhere; +} + +.md-history-body { + display: flex; + flex-direction: column; + min-height: 0; /* allow inner scroll regions to shrink */ + overflow: hidden; +} + +.md-history-hint { + margin: 0 0 0.6rem 0; + font-size: 0.82rem; + color: var(--muted, #666); +} + +.md-history-empty { + margin: 1rem 0; + font-size: 0.9rem; + color: var(--muted, #666); +} + +/* ── version list ── */ +.md-history-list { + overflow-y: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; +} + +.md-history-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border, #eee); + font-size: 0.88rem; +} + +.md-history-row:last-child { border-bottom: none; } +.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); } + +.md-history-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.md-history-time { font-variant-numeric: tabular-nums; } +.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; } +.md-history-size { color: var(--muted, #888); font-size: 0.8rem; } + +.md-history-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.05rem 0.4rem; + border-radius: 10px; + background: var(--accent, #3c82f6); + color: #fff; +} + +.md-history-actions { display: flex; gap: 0.35rem; } + +/* ── single-version view ── */ +.md-history-pre { + flex: 1 1 auto; + overflow: auto; + margin: 0; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + background: var(--code-bg, #f7f7f8); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── diff view ── */ +.md-diff { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; } +.md-diff-text { flex: 1 1 auto; } + +.md-diff-add { background: rgba(46, 160, 67, 0.16); } +.md-diff-add .md-diff-gutter { color: #2ea043; } +.md-diff-del { background: rgba(248, 81, 73, 0.16); } +.md-diff-del .md-diff-gutter { color: #f85149; } +.md-diff-eq { color: var(--muted, #777); } + +.md-diff-old { color: #f85149; } +.md-diff-new { color: #2ea043; } + +/* ── footer ── */ +.md-history-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.85rem; +} diff --git a/browse/css/manage-access.css b/browse/css/manage-access.css new file mode 100644 index 0000000..fde2983 --- /dev/null +++ b/browse/css/manage-access.css @@ -0,0 +1,90 @@ +/* manage-access.js — guided "who can do what here" dialog. */ +.ma-overlay { + position: fixed; + inset: 0; + z-index: 9800; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); +} +.ma-box { + background: var(--bg-elevated, var(--bg, #fff)); + color: var(--text, #222); + border: 1px solid var(--border, #ccc); + border-radius: 8px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32); + padding: 1.1rem 1.25rem; + width: min(34rem, 94vw); + max-height: 90vh; + overflow: auto; +} +.ma-title { margin: 0 0 0.2rem; font-size: 1.15rem; } +.ma-sub { + margin: 0 0 0.8rem; + font-size: 0.82rem; + color: var(--text-muted, #777); + word-break: break-all; +} +.ma-list { display: flex; flex-direction: column; gap: 0.4rem; } +/* who fills the row and shrinks (min-width:0); level + delete size to content + so nothing overflows the dialog regardless of email/principal length. */ +.ma-row { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content max-content; + gap: 0.5rem; + align-items: center; +} +.ma-who, +.ma-level { + box-sizing: border-box; + padding: 0.4rem 0.5rem; + font: inherit; + border: 1px solid var(--border, #ccc); + border-radius: 4px; + background: var(--bg, #fff); + color: var(--text, #222); +} +.ma-who { width: 100%; min-width: 0; } +.ma-level { width: 8.5rem; cursor: pointer; } +.ma-legend { + margin: 0.5rem 0 0; + font-size: 0.74rem; + color: var(--text-muted, #888); +} +.ma-del { + border: none; + background: transparent; + color: var(--text-muted, #999); + cursor: pointer; + font-size: 1rem; + padding: 0.2rem 0.4rem; + border-radius: 4px; +} +.ma-del:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.06)); color: var(--danger, #c14242); } +.ma-add { + margin: 0.6rem 0 0; + border: 1px dashed var(--border, #bbb); + background: transparent; + color: var(--primary, #2868c8); + cursor: pointer; + padding: 0.35rem 0.6rem; + border-radius: 4px; + font: inherit; +} +.ma-add:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); } +.ma-inherit { + display: flex; + align-items: center; + gap: 0.3rem; + margin: 0.9rem 0 0; + font-size: 0.88rem; +} +.ma-err { color: var(--danger, #c14242); font-size: 0.82rem; margin: 0.5rem 0 0; min-height: 0; } +.ma-err:empty { display: none; } +.ma-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} diff --git a/browse/css/preview-yaml.css b/browse/css/preview-yaml.css new file mode 100644 index 0000000..6f0441e --- /dev/null +++ b/browse/css/preview-yaml.css @@ -0,0 +1,138 @@ +/* preview-yaml.css — YAML editor pane styling. Mirrors the + .md-shell info-header geometry; everything below is a CodeMirror 5 + host with dark-mode overrides so the editor blends into the theme + instead of fighting it. */ + +.yaml-shell { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--bg); +} + +.yaml-shell__editor { + min-height: 0; + overflow: hidden; + position: relative; +} + +/* Schema-label badge — extends .md-shell__source so it sits next to + "local"/"server"/"read-only (zip)" with the same chip styling. The + primary-colored variant distinguishes ".zddc schema" from the + plain "YAML" label. */ +.yaml-shell__schema { + font-style: normal; +} +.yaml-shell__schema:not(:empty) { + border-color: var(--primary); + color: var(--primary); +} +/* The ".zddc schema" badge is clickable — it opens the full JSON Schema. */ +.yaml-shell__schema--link { + cursor: pointer; +} +.yaml-shell__schema--link:hover, +.yaml-shell__schema--link:focus-visible { + background: var(--primary); + color: var(--bg); + outline: none; +} + +/* Hover-doc tooltip (yaml-complete.js) — appended to document.body, so it's + styled globally. Carries a key's schema description on hover. */ +.cm-doc-tip { + position: fixed; + z-index: 9700; + max-width: 360px; + padding: 6px 9px; + font-size: 0.75rem; + line-height: 1.4; + background: var(--bg-elevated, var(--bg, #fff)); + color: var(--text, #222); + border: 1px solid var(--border, #ccc); + border-radius: 4px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28); + pointer-events: none; + white-space: normal; +} + +/* CodeMirror has to fill the grid cell. The vendored CSS sets + `height: 300px` by default — we override to 100% so it grows with + the preview pane. */ +.yaml-shell__editor .CodeMirror { + height: 100%; + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.45; + background: var(--bg); + color: var(--text); +} + +.yaml-shell__editor .CodeMirror-gutters { + background: var(--bg-secondary); + border-right: 1px solid var(--border); +} + +.yaml-shell__editor .CodeMirror-linenumber { + color: var(--text-muted); +} + +.yaml-shell__editor .CodeMirror-cursor { + border-left-color: var(--text); +} + +.yaml-shell__editor .CodeMirror-selected { + background: var(--bg-selected); +} + +.yaml-shell__editor .CodeMirror-focused .CodeMirror-selected { + background: var(--primary-light); +} + +/* YAML token tints. CM5 emits semantic class names from the yaml + mode; map them onto our palette so themes flip with the OS / data + attribute. */ +.yaml-shell__editor .cm-keyword, +.yaml-shell__editor .cm-atom { color: var(--primary); font-weight: 600; } +.yaml-shell__editor .cm-string { color: #2e8b57; } +.yaml-shell__editor .cm-comment { color: var(--text-muted); font-style: italic; } +.yaml-shell__editor .cm-number { color: #b06000; } +.yaml-shell__editor .cm-meta { color: #6f42c1; } + +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) .yaml-shell__editor .cm-string { color: #98c379; } + html:not([data-theme="light"]) .yaml-shell__editor .cm-number { color: #e5c07b; } + html:not([data-theme="light"]) .yaml-shell__editor .cm-meta { color: #c678dd; } +} +[data-theme="dark"] .yaml-shell__editor .cm-string { color: #98c379; } +[data-theme="dark"] .yaml-shell__editor .cm-number { color: #e5c07b; } +[data-theme="dark"] .yaml-shell__editor .cm-meta { color: #c678dd; } + +/* Lint markers: keep CM's defaults for the gutter dots but make the + inline underline play nicely with our background. Errors stay red, + warnings amber. */ +.yaml-shell__editor .CodeMirror-lint-mark-error { + background-image: none; + border-bottom: 2px wavy var(--danger); +} +.yaml-shell__editor .CodeMirror-lint-mark-warning { + background-image: none; + border-bottom: 2px wavy var(--warning); +} + +/* Tooltip popping out of a lint marker — uses the shared menu shadow + so it doesn't look like a separate component. */ +.CodeMirror-lint-tooltip { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.10); + font-family: var(--font); + font-size: 0.82rem; + padding: 0.3rem 0.55rem; + max-width: 32rem; +} diff --git a/browse/css/tree.css b/browse/css/tree.css new file mode 100644 index 0000000..29affa9 --- /dev/null +++ b/browse/css/tree.css @@ -0,0 +1,1069 @@ +/* ── Layout ──────────────────────────────────────────────────────────────── */ + +html, body { + margin: 0; + padding: 0; + height: 100%; + font-family: var(--font); + color: var(--text); + background-color: var(--bg); +} + +/* Body is a flex column so the header (which may wrap to a second + row at narrow viewports), #appMain, and the status bar each get + their natural height — no more fixed-pixel calc() that breaks + when the header reflows. Horizontal overflow scrolls on the body + as a final fallback when content can't shrink any further. */ +body { + display: flex; + flex-direction: column; + height: 100vh; + overflow-x: auto; + overflow-y: hidden; + /* Hard floor for the body. Below this, the html-level scrollbar + picks up and the user can pan horizontally rather than seeing + the right edge clipped. */ + min-width: 320px; +} + +#appMain { + position: relative; + flex: 1 1 auto; + min-height: 0; + height: auto; /* override the old calc(100vh - 2.65rem) */ + display: flex; + flex-direction: column; + overflow: hidden; +} + +.browse-root { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + overflow: hidden; + background: var(--bg); +} + +/* ── Toolbar ─────────────────────────────────────────────────────────────── */ + +.browse-toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.4rem 1rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.view-mode-toggle { + display: inline-flex; + gap: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.view-mode-toggle .btn { + border-radius: 0; + border: none; + border-right: 1px solid var(--border); +} + +.view-mode-toggle .btn:last-child { + border-right: none; +} + +.view-mode-toggle .btn[aria-selected="true"] { + background: var(--primary); + color: var(--text-light); +} + +/* Breadcrumbs */ +.breadcrumbs { + flex: 1; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.15rem 0.4rem; + font-size: 0.85rem; + color: var(--text-muted); + min-width: 0; +} + +.breadcrumbs a, +.breadcrumbs button { + color: var(--text-muted); + background: none; + border: 0; + padding: 0.1rem 0.3rem; + border-radius: var(--radius); + cursor: pointer; + text-decoration: none; + font: inherit; +} + +.breadcrumbs a:hover, +.breadcrumbs button:hover { + color: var(--text); + background: var(--bg-hover); +} + +.breadcrumbs .bc-sep { + color: var(--text-muted); + user-select: none; +} + +.breadcrumbs .bc-current { + color: var(--text); + font-weight: 600; + padding: 0.1rem 0.3rem; +} + +.bc-home-icon { + width: 1em; + height: 1em; + vertical-align: -0.15em; +} + +/* ── Two-pane browse view ────────────────────────────────────────────────── */ + +.browse-view { + display: flex; + flex: 1; + overflow: hidden; + min-height: 0; +} + +.pane { + overflow: hidden; + background: var(--bg); + display: flex; + flex-direction: column; +} + +.tree-pane { + width: 360px; + min-width: 200px; + max-width: 60%; + border-right: 1px solid var(--border); + flex-shrink: 0; +} + +.tree-pane__toolbar { + padding: 0.4rem 0.5rem; + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +/* Single-input autofilter — same grammar as the archive app's column + filters (terms, quotes, !negation, multi-word AND). type=search so + the browser ships the native clear-X for free; the .filter-active + class amber-highlights the input while a query is set, matching + the archive `.column-filter.filter-active` cue. */ +.tree-filter { + width: 100%; + box-sizing: border-box; + padding: 0.3rem 0.5rem; + font-family: var(--font); + font-size: 0.85rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; + transition: border-color 0.12s, background 0.12s; +} + +.tree-filter:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light); +} + +.tree-filter.filter-active { + background: rgba(234, 179, 8, 0.18); + border-color: rgba(234, 179, 8, 0.7); +} + +.tree-pane__body { + flex: 1; + overflow: auto; + padding: 0.25rem 0; + font-size: 0.875rem; +} + +/* Pane resizer — 4px grab handle between tree and preview */ +.pane-resizer { + width: 4px; + background: transparent; + cursor: col-resize; + flex-shrink: 0; + position: relative; + z-index: 1; +} + +.pane-resizer:hover, +.pane-resizer.is-dragging { + background: var(--primary); +} + +.preview-pane { + flex: 1; + min-width: 0; +} + +.preview-pane__header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + min-height: 2.1rem; +} + +.preview-pane__title { + flex: 1; + font-size: 0.9rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.preview-pane__meta { + font-size: 0.8rem; + color: var(--text-muted); + white-space: nowrap; +} + +.preview-pane__body { + flex: 1; + min-height: 0; /* critical: lets the flex child shrink to fit + the viewport instead of growing to its + content's natural size (which clips the + YAML editor's bottom when there are many + lines, even with the editor's own scroll) */ + min-width: 0; + overflow: auto; + display: flex; + flex-direction: column; + background: var(--bg); +} + +/* The body's children fill the available space. Plugins inject + different content here — img, iframe, pre, custom markdown editor. + min-width:0 is load-bearing: a flex item defaults to min-width:auto + (its min-content width), so the markdown editor's wide internal + min-content would push the whole pane past the viewport's right edge + instead of shrinking. With min-width:0 the editor shrinks and its own + (and the grid's minmax(0)) scrolling takes over. */ +.preview-pane__body > * { + flex: 1; + min-height: 0; + min-width: 0; +} + +.preview-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 0.95rem; + padding: 2rem; + text-align: center; +} + +.preview-pane__body img.preview-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + margin: auto; + display: block; + flex: none; /* avoid flex sizing interfering with object-fit */ +} + +.preview-pane__body iframe.preview-iframe { + width: 100%; + height: 100%; + border: none; +} + +.preview-pane__body pre.preview-text { + padding: 1rem; + font-family: var(--font-mono); + font-size: 0.85rem; + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; + overflow: auto; + background: var(--bg); + color: var(--text); +} + +/* ── Tree (vertical, file-explorer style) ───────────────────────────────── */ + +.tree-row { + display: flex; + /* Top-aligned so the chevron + icon anchor to the title line on + two-line ZDDC rows. Single-line rows are unaffected because the + icon, chevron, and label all share a top edge. */ + align-items: flex-start; + gap: 0.25rem; + padding: 0.2rem 0.5rem; + cursor: pointer; + user-select: none; + border-radius: 0; + color: var(--text); +} + +.tree-row:hover { + background: var(--bg-hover); +} + +.tree-row.is-selected { + background: var(--bg-selected); + color: var(--text); +} + +/* Per-row "⋯" actions button — the visible affordance that a row has a + context menu. Hidden until the row is hovered/selected or the button + itself is keyboard-focused, so it stays out of the way during reading + but is discoverable without knowing to right-click. Pushed to the right + edge; never part of the tab order (rows use roving tabindex). */ +.tree-row__kebab { + margin-left: auto; + align-self: flex-start; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.4rem; + height: 1.4rem; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted, #888); + border-radius: var(--radius); + cursor: pointer; + opacity: 0; + transition: opacity 0.1s, background 0.1s, color 0.1s; +} +.tree-row__kebab svg { width: 1em; height: 1em; } +.tree-row:hover .tree-row__kebab, +.tree-row.is-selected .tree-row__kebab, +.tree-row__kebab:focus-visible { + opacity: 1; +} +.tree-row__kebab:hover, +.tree-row__kebab:focus-visible { + background: var(--bg-hover); + color: var(--text); +} + +/* Tree-pane toolbar controls row (New folder/file, Sort, Show hidden), + sitting under the filter input. */ +.tree-pane__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.4rem; +} +.tree-pane__controls .tp-control { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--text-muted, #888); +} +.tree-pane__controls .tp-control--check { cursor: pointer; } +.tree-pane__controls select { + font-family: var(--font); + font-size: 0.8rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.15rem 0.3rem; +} + +/* Per-row drop target highlight: applied while a file/folder drag is + hovering this row. The dashed outline reads as "drop here" without + shifting layout. */ +.tree-row.is-droptarget { + background: var(--primary-light); + outline: 2px dashed var(--primary); + outline-offset: -2px; +} + +.tree-row.is-selected .tree-name__label { + color: var(--text); +} + +.tree-name__chevron { + /* Fixed-width slot so leaf rows (empty chevron) still align with + expandable rows. The SVG inside is sized via the rule below. + Top-anchored to the title-line baseline by the row's flex-start + alignment + this small top offset. */ + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1.2em; + flex-shrink: 0; + color: var(--text-muted); +} + +.tree-name__chevron svg { + width: 0.85em; + height: 0.85em; + transition: transform 0.12s ease; +} + +/* Expanded state — rotate the same chevron 90° rather than swapping + to a second glyph. Smooth, single-sprite, and consistent with the + way most modern file trees indicate expand state. */ +.tree-row.expanded .tree-name__chevron svg { + transform: rotate(90deg); +} + +.tree-name__icon { + flex-shrink: 0; + /* Stacked column — glyph on top, extension chip below for files. + Wider min-width than the 1em glyph itself so common extensions + (pdf/docx/xlsx/json) don't push the label sideways. Height + grows with content; flex-start anchors to the title-line. */ + min-width: 2.2em; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + color: var(--text-muted); + gap: 1px; +} + +.tree-name__icon svg { + width: 1em; + height: 1em; + display: block; +} + +.tree-name__ext { + font-size: 0.58rem; + line-height: 1; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Folder rows get the primary accent so directories stand out from + files at a glance — same convention as macOS Finder / GNOME Files. */ +.tree-row[data-isdir="true"] .tree-name__icon, +.tree-row[data-iszip="true"] .tree-name__icon { + color: var(--primary); +} + +/* Selected rows tint icon to match the label color (the bg-selected + token already differentiates the row background). */ +.tree-row.is-selected .tree-name__icon { + color: var(--text); +} + +.tree-name__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + min-width: 0; +} + +/* Two-line ZDDC variant. Top line is monospace + small + muted so the + trackingNumber / revision / status fields line up vertically across + adjacent rows (every field has a fixed width by convention). Bottom + line is the human-readable title at normal weight. */ +.tree-name__label--zddc { + display: flex; + flex-direction: column; + line-height: 1.15; + /* Tight gap between meta and title; tweak by 1-2 px if the rows + feel crowded on dense lists. */ + gap: 0.05rem; +} + +.tree-name__meta { + font-family: var(--font-mono); + font-size: 0.7rem; + /* Explicit weight: the folder-row rule below bolds .tree-name__label, + which would otherwise inherit through to the meta span. We want + the meta to stay light + muted on every row. */ + font-weight: 400; + color: var(--text-muted); + /* Belt-and-braces: monospace already gives column-alignment, but + tabular-nums hardens it on the rare proportional fallback. */ + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-name__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.tree-row.is-selected .tree-name__title { + color: var(--text); +} + +.tree-row[data-isdir="true"] .tree-name__label, +.tree-row[data-iszip="true"] .tree-name__label { + font-weight: 500; +} + +/* ── Drag-drop upload overlay ─────────────────────────────────────────────── */ +/* Shown only while a drag is active over the page AND the current scope + accepts uploads. Pointer-events:none below dragover so the underlying + drop event still reaches the document handlers. */ +.upload-overlay { + position: fixed; + inset: 0; + z-index: 50; + pointer-events: none; + background: rgba(42, 90, 138, 0.18); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.12s ease; +} +.upload-overlay.is-active { + opacity: 1; +} +.upload-overlay__panel { + background: var(--bg); + border: 2px dashed var(--primary); + border-radius: var(--radius); + padding: 1.5rem 2.25rem; + text-align: center; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); + pointer-events: none; + color: var(--text); + max-width: 80vw; +} +.upload-overlay__icon { + font-size: 2.5rem; + line-height: 1; + color: var(--primary); +} +.upload-overlay__title { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + margin-top: 0.5rem; +} +.upload-overlay__path { + margin-top: 0.35rem; + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--text-muted); + word-break: break-all; +} + +/* Virtual rows: synthesized for folders/files declared by the + cascade but absent from disk. The visual language reads as + "expected, not yet materialized" — italic label, muted accent + color, dashed left rail, and an outlined icon. Hover/select + chrome still applies on top; the dashed rail sits inside the row + so it doesn't fight padding-left indentation. */ +.tree-row--virtual { + box-shadow: inset 2px 0 0 0 transparent; + position: relative; +} +.tree-row--virtual::before { + content: ''; + position: absolute; + top: 4px; + bottom: 4px; + left: 2px; + border-left: 2px dashed var(--accent-muted, #8aa4cc); + pointer-events: none; +} +.tree-row--virtual .tree-name__label { + font-style: italic; + color: var(--text-muted, #6b7280); +} +.tree-row--virtual .tree-name__icon { + /* Hollow out the filled Lucide glyph: reduce fill opacity so + the icon reads as an outline-only sketch — the conventional + "placeholder, not actual" cue across UI systems. */ + opacity: 0.5; +} +.tree-row--virtual .tree-name__icon svg { + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.tree-row--virtual.is-selected::before { + /* Selected virtual row: rail brightens to selection accent so the + row reads as both selected and placeholder. */ + border-left-color: var(--accent, #2868c8); +} + +.tree-name__hint { + margin-left: 0.5rem; + font-size: 0.78rem; + color: var(--accent-muted, #8aa4cc); + font-style: italic; +} + +/* ── Grid view (Phase C) ─────────────────────────────────────────────────── */ + +.grid-view { + flex: 1; + overflow: auto; + background: var(--bg); + padding: 0; +} + +.grid-empty { + padding: 3rem; + text-align: center; + color: var(--text-muted); +} + +/* ── Markdown plugin (right-pane internals when a .md is selected) ──────── */ +/* CSS-Grid shell mirroring mdedit's layout: sidebar on the LEFT + (front matter top + TOC bottom), content on the RIGHT (informational + header above the Toast UI editor). The grid gives every cell a + definite size, which Toast UI needs to compute its scroll regions + correctly. */ +.md-shell { + display: grid; + grid-template-rows: 1fr; + /* minmax(0, …) on BOTH tracks is load-bearing: a bare `1fr` is + `minmax(auto, 1fr)`, whose `auto` floor is the editor's min-content + width (Toast UI's toolbar). That floor stops the content track from + shrinking, so the whole shell overflows #previewBody as the window + narrows instead of the editor getting narrower. minmax(0, 1fr) drops + the floor so the editor reflows down to nothing. JS overrides the + column widths on drag — it preserves the minmax(0, …) form. */ + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); + grid-template-areas: "sidebar content"; + height: 100%; + min-height: 0; + background: var(--bg); + overflow: hidden; +} + +/* Sidebar (col 1): three stacked items — Front matter (fixed height, + drag-resizable), the horizontal resizer (between FM and TOC), then + the TOC section taking the remaining height. Flexbox keeps the + resizer position unambiguous; the previous grid-overlay approach + was hard to read and prone to misplacement. */ +.md-shell__sidebar { + grid-area: sidebar; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + border-right: 1px solid var(--border); + background: var(--bg); + position: relative; +} + +/* Vertical sidebar/content resizer. Sits absolutely on the column + boundary so it doesn't occupy a grid track. */ +.md-shell__resizer { + grid-area: sidebar; + align-self: stretch; + justify-self: end; + width: 6px; + margin-right: -3px; + cursor: col-resize; + background: transparent; + z-index: 2; + transition: background 0.12s; +} +.md-shell__resizer:hover, +.md-shell__resizer.is-dragging, +.md-shell__resizer:focus-visible { + background: var(--primary); + outline: none; +} + +/* Horizontal resizer — a real flex item between FM and TOC. Drag + it up/down to change the front-matter pane's height; the JS + handler updates fmSection.style.height directly. */ +.md-shell__fmresizer { + flex: 0 0 6px; + height: 6px; + cursor: row-resize; + background: var(--border); + transition: background 0.12s; + /* Subtle "grab" affordance — a slightly darker bar appears on + hover so users see this is the drag handle. */ +} +.md-shell__fmresizer:hover, +.md-shell__fmresizer.is-dragging, +.md-shell__fmresizer:focus-visible { + background: var(--primary); + outline: none; +} + +/* Content (col 2): informational header above the Toast UI editor. */ +.md-shell__content { + grid-area: content; + display: grid; + grid-template-rows: auto 1fr; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +/* Informational header above the editor: file name on the left, then + dirty marker, status, source hint, save button. Reads as a header + for the content panel — file metadata at a glance. */ +.md-shell__infohdr { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-size: 0.85rem; +} +.md-shell__title { + flex: 1; + font-family: var(--font-display); + font-size: 1rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.md-shell__dirty { + color: var(--text-muted); + font-size: 0.85rem; + min-width: 5.5rem; + text-align: right; +} +.md-shell__status { + color: var(--text-muted); + font-size: 0.85rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 14rem; +} +.md-shell__source { + color: var(--text-muted); + font-size: 0.75rem; + font-style: italic; + padding: 0.15rem 0.4rem; + border-radius: var(--radius); + background: var(--bg); + border: 1px solid var(--border); +} +.md-shell__download { + /* Slightly tighter than the Save button so a row of three doesn't + crowd the title. The base .btn styles still drive padding/color. */ + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.md-shell__download[disabled] { + opacity: 0.55; + cursor: progress; +} + +/* Editor host: a single grid cell with overflow:hidden so Toast UI's + internal scrollers handle the content. */ +.md-shell__editor { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.md-side { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +/* Front-matter section: fixed (resizable) height, set inline by the + markdown plugin's mount + drag-handler. flex:0 0 auto so the + explicit height wins over the parent flex layout. */ +.md-side--fm { + flex: 0 0 auto; +} + +/* TOC section: takes everything that's left. min-height:0 so the + inner body's overflow:auto kicks in instead of pushing the + resizer off-screen. */ +.md-side--toc { + flex: 1 1 auto; + min-height: 0; +} + +.md-side__header { + /* Header is its own flex item so the body can stretch to fill. */ + flex: 0 0 auto; + padding: 0.35rem 0.75rem; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.md-side__body { + /* Both axes — the textarea uses white-space:pre so long YAML + lines need horizontal scroll, and the TOC entries below now + extend their full width so deep headings need it too. */ + flex: 1 1 auto; + overflow: auto; + min-height: 0; + padding: 0.3rem 0; + font-size: 0.85rem; + line-height: 1.45; +} + +/* ── Outline list ───────────────────────────────────────────────────────── */ +.md-toc__empty { + color: var(--text-muted); + font-style: italic; + padding: 0.5rem 0.75rem; + margin: 0; + font-size: 0.82rem; +} +.md-toc__list { + list-style: none; + margin: 0; + padding: 0; +} +.md-toc__item { + margin: 0; + padding: 0.22rem 0.75rem; + color: var(--text); + cursor: pointer; + border-left: 2px solid transparent; + transition: background 0.1s, border-color 0.1s, color 0.1s; + /* Single-line items but no ellipsis — long headings extend the + item's intrinsic width, and the parent .md-side__body has + overflow:auto, so they create a horizontal scrollbar instead + of getting clipped. The title attribute still carries the + full text for SR users. */ + white-space: nowrap; +} +.md-toc__item:hover { + background: var(--bg-secondary); + border-left-color: var(--primary); +} +.md-toc__item:focus-visible { + outline: 2px solid var(--primary); + outline-offset: -2px; +} +.md-toc__item--l1 { padding-left: 0.75rem; font-weight: 600; } +.md-toc__item--l2 { padding-left: 1.4rem; } +.md-toc__item--l3 { padding-left: 2.05rem; font-size: 0.82rem; } +.md-toc__item--l4 { padding-left: 2.7rem; font-size: 0.8rem; color: var(--text-muted); } +.md-toc__item--l5 { padding-left: 3.35rem; font-size: 0.78rem; color: var(--text-muted); } +.md-toc__item--l6 { padding-left: 4rem; font-size: 0.78rem; color: var(--text-muted); } + +/* Flash on click — applied to the heading element in the editor pane. + The class is scoped to .md-toc__flash so it doesn't paint outside + this plugin. */ +.md-toc__flash { + background-color: rgba(95, 168, 224, 0.25) !important; + transition: background-color 0.3s ease; +} + +/* ── Front matter editor ────────────────────────────────────────────────── */ +.md-fm__body { + /* Body cell owns the CodeMirror editor; sized by the sidebar's grid row. */ + padding: 0; + display: block; + overflow: hidden; +} +/* Recognised-keys caption under the header (tooltip carries the full list). */ +.md-fm__hint { + padding: 2px 0.6rem 4px; + font-size: 0.72rem; + color: var(--text-muted); + cursor: help; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* CodeMirror YAML front-matter editor — fills the body cell + scrolls + internally, matching the .zddc previewer's editor styling. */ +.md-fm__editor, +.md-fm__editor .CodeMirror { + height: 100%; +} +.md-fm__editor .CodeMirror { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); + font-size: 0.8rem; + line-height: 1.45; + background: transparent; + color: var(--text); +} +.md-fm__editor .CodeMirror-gutters { + background: var(--bg-secondary); + border-right: 1px solid var(--border); +} +/* Schema-completion dropdown (show-hint add-on) — theme it to the app + palette so it reads in dark mode; show-hint.css ships light-only. */ +.CodeMirror-hints { + z-index: 9600; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); + font-size: 0.78rem; + background: var(--bg-elevated, var(--bg, #fff)); + border: 1px solid var(--border, #ccc); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28); +} +.CodeMirror-hint { + color: var(--text, #222); + padding: 2px 8px; +} +li.CodeMirror-hint-active { + background: var(--primary, #2868c8); + color: #fff; +} + +/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced + by the .md-shell BEM block above. */ + +/* ── Hover info card ────────────────────────────────────────────────────── */ +/* Singleton element appended to by browse/js/hovercard.js. + Replaces the native title="…" tooltip on tree rows with a rich + metadata view (ZDDC parse fields + filesystem info). */ +.tree-hovercard { + position: fixed; + z-index: 9000; + max-width: 28rem; + min-width: 17rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.10); + padding: 0.5rem 0.7rem 0.45rem; + font-family: var(--font); + font-size: 0.8rem; + line-height: 1.35; + opacity: 0; + visibility: hidden; + /* pointer-events:auto so the user can mouse into the card to + select text. The hide is delayed (HIDE_DELAY_MS in hovercard.js) + so the cursor has time to traverse the gap between row and card + before the card dismisses. */ + pointer-events: auto; + /* The tree rows set user-select:none — explicitly allow it here + so dragging across the card builds a real selection that can be + Ctrl/Cmd-C'd or right-click-Copied via the browser's native menu. */ + user-select: text; + cursor: default; + transition: opacity 0.1s ease; +} + +.tree-hovercard.is-visible { + opacity: 1; + visibility: visible; +} + +/* Highlight selected text inside the card with the primary accent so + it reads as "yes, you can copy this" rather than the default browser + selection color. */ +.tree-hovercard ::selection { + background: var(--primary-light); + color: var(--text); +} + +.tree-hovercard__header { + margin-bottom: 0.35rem; +} + +.tree-hovercard__title { + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; + color: var(--text); + word-break: break-word; +} + +.tree-hovercard__sub { + margin-top: 0.15rem; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-muted); + letter-spacing: 0.01em; +} + +.tree-hovercard__list { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.12rem 0.7rem; + align-items: baseline; +} + +.tree-hovercard__key { + color: var(--text-muted); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tree-hovercard__val { + color: var(--text); + font-size: 0.82rem; + word-break: break-word; +} + +.tree-hovercard__val--mono { + font-family: var(--font-mono); + font-size: 0.78rem; +} + +/* Archive-reference links inside the hovercard pick up the primary + accent so they read as clickable, and stay inline with the mono + font when they sit inside a mono cell. */ +.tree-hovercard__val a { + color: var(--primary, #2868c8); + text-decoration: none; +} +.tree-hovercard__val a:hover { + text-decoration: underline; +} + +/* Separator stretches across both grid columns. Bleed into the + card's padding so it visually reads as a divider, not a hairline. */ +.tree-hovercard__sep { + grid-column: 1 / -1; + border-top: 1px solid var(--border); + margin: 0.25rem -0.7rem; +} diff --git a/browse/js/accept-transmittal.js b/browse/js/accept-transmittal.js new file mode 100644 index 0000000..101b74b --- /dev/null +++ b/browse/js/accept-transmittal.js @@ -0,0 +1,295 @@ +// accept-transmittal.js — the doc-controller "Accept Transmittal" +// workflow modal. +// +// Surfaced by events.js as a right-click item on a transmittal folder +// inside archive//incoming/. The folder name must conform +// to the ZDDC transmittal grammar (date_tracking (status) - title); +// every file inside must conform to ZDDC filename grammar with the +// same tracking. Non-conformance is flagged in the modal and the user +// cancels to ask the sender to fix. +// +// On submit, the form assembles a YAML body (received_date plus an +// optional plan-review chain block) and POSTs it with +// X-ZDDC-Op: accept-transmittal to the transmittal-folder URL. The +// server validates everything, moves the folder into received/, +// renames it to tracking-only, and optionally chains Plan Review. + +(function () { + 'use strict'; + + var REVIEW_OFFSET_DAYS = 7; + var RESPONSE_OFFSET_DAYS = 14; + + function status(msg, level) { + var t = window.zddc && window.zddc.toast; + if (t) t(msg, level || 'info'); + } + + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; + var isoDateToday = util.isoDateToday; + var isoDatePlus = util.isoDatePlus; + + // Is this node a direct child of an incoming/ canonical folder + // AND a well-formed transmittal folder? The first half is the + // cascade-driven scope check (X-ZDDC-Canonical-Folder == 'incoming' + // on the current listing's parent context); the second is a + // structural folder-name parse against the ZDDC grammar. + function isAcceptableTransmittalFolder(node) { + if (!node || !node.isDir) return false; + if (node.virtual) return false; + // The cascade signal is on the PARENT directory's listing, which + // is the directory whose contents are currently shown — i.e. + // state.currentPath. When the listing's scope is incoming/, + // every direct child folder is a candidate (validated by name + // here and by the server again on POST). + if (window.app.state.scopeCanonicalFolder !== 'incoming') return false; + var parsed = window.zddc.parseFolder(node.name); + return !!(parsed && parsed.valid); + } + + // Scan the listing's tree node for files inside the transmittal + // folder and classify each as conforming (tracking matches the + // folder) or violating. Returns { ok: [...], violations: [...] }. + // Best-effort — operates only on already-loaded children. The + // server is authoritative; this is a UX hint. + function classifyChildren(node, folderTracking) { + var out = { ok: [], violations: [] }; + var children = (node && node.children) ? node.children : []; + children.forEach(function (c) { + if (c.virtual) return; + if (c.isDir) { + out.violations.push(c.name + ': nested directories are not permitted'); + return; + } + if (c.name.charAt(0) === '.') return; // dotfiles ignored + var parsed = window.zddc.parseFilename(c.name); + if (!parsed || !parsed.valid) { + out.violations.push(c.name + ': does not conform to ZDDC filename grammar'); + return; + } + if (parsed.trackingNumber !== folderTracking) { + out.violations.push(c.name + ': tracking "' + parsed.trackingNumber + + '" does not match folder tracking "' + folderTracking + '"'); + return; + } + out.ok.push(c.name); + }); + return out; + } + + var fetchPeopleSuggestions = util.fetchAccessEmails; + + function openForm(initial) { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;'; + + var violationsHtml = ''; + if (initial.violations && initial.violations.length) { + violationsHtml = '
' + + 'Non-conforming files detected:
    ' + + initial.violations.map(function (v) { return '
  • ' + escapeHtml(v) + '
  • '; }).join('') + + '

Cancel and contact the sender to correct these before re-uploading.

'; + } + + var planReviewFieldsHtml = + ''; + + box.innerHTML = + '

Accept Transmittal — ' + escapeHtml(initial.tracking) + '

' + + '

' + + 'This will file ' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' from ' + + '' + escapeHtml(initial.folder) + ' into the immutable received archive at ' + + 'archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/. ' + + 'Once filed, only document-control can add new files there; nothing can be edited or deleted.' + + '

' + + violationsHtml + + '
' + + '' + + '' + + '
' + + '' + + planReviewFieldsHtml + + '
' + + '' + + '' + + '
'; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + box.querySelector('#acc-received-date').value = isoDateToday(); + box.querySelector('#acc-review-date').value = isoDatePlus(REVIEW_OFFSET_DAYS); + box.querySelector('#acc-response-date').value = isoDatePlus(RESPONSE_OFFSET_DAYS); + + var prCheckbox = box.querySelector('#acc-setup-pr'); + var prFields = box.querySelector('#acc-pr-fields'); + prCheckbox.addEventListener('change', function () { + prFields.style.display = prCheckbox.checked ? '' : 'none'; + }); + + fetchPeopleSuggestions().then(function (emails) { + var dl = box.querySelector('#acc-people'); + if (!dl) return; + emails.forEach(function (e) { + var opt = document.createElement('option'); + opt.value = e; + dl.appendChild(opt); + }); + }); + + // Bind the Escape handler once and remove it in close() — every + // dismissal path (cancel, overlay-click, submit, Escape) routes + // through close(), so the document listener can't outlive the + // modal. + function onKeydown(e) { + if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } + } + function close() { + document.removeEventListener('keydown', onKeydown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + box.querySelector('#acc-cancel').addEventListener('click', function () { + close(); reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(); reject(new Error('cancelled')); } + }); + document.addEventListener('keydown', onKeydown); + + box.querySelector('#acc-submit').addEventListener('click', function () { + var values = { + receivedDate: box.querySelector('#acc-received-date').value, + setupPlanReview: prCheckbox.checked, + reviewLead: box.querySelector('#acc-review-lead').value.trim(), + approver: box.querySelector('#acc-approver').value.trim(), + planReviewDate: box.querySelector('#acc-review-date').value, + planResponseDate: box.querySelector('#acc-response-date').value + }; + if (!values.receivedDate) { status('Received date is required.', 'error'); return; } + if (values.setupPlanReview) { + if (!values.reviewLead || !values.approver + || !values.planReviewDate || !values.planResponseDate) { + status('Plan Review fields are required when the checkbox is on.', 'error'); + return; + } + } + close(); resolve(values); + }); + }); + } + + var quote = util.yamlQuote; + function buildBody(values) { + var lines = ['received_date: ' + values.receivedDate]; + if (values.setupPlanReview) { + lines.push('setup_plan_review: true'); + lines.push('review_lead: ' + quote(values.reviewLead)); + lines.push('approver: ' + quote(values.approver)); + lines.push('plan_review_complete_date: ' + values.planReviewDate); + lines.push('plan_response_date: ' + values.planResponseDate); + } + lines.push(''); + return lines.join('\n'); + } + + var busy = false; + + async function invoke(node) { + if (busy) return; + var tree = window.app.modules.tree; + if (!tree) return; + var url = tree.pathFor(node); + if (!url.endsWith('/')) url += '/'; + + var parsedFolder = window.zddc.parseFolder(node.name); + if (!parsedFolder || !parsedFolder.valid) { + status('Folder name does not conform to ZDDC transmittal grammar.', 'error'); + return; + } + + // Derive the party from the path: incoming///. + var parts = url.replace(/^\/+|\/+$/g, '').split('/'); + var incIdx = parts.indexOf('incoming'); + var party = (incIdx >= 0 && parts[incIdx + 1]) ? parts[incIdx + 1] : ''; + + var classification = classifyChildren(node, parsedFolder.trackingNumber); + + var values; + try { + values = await openForm({ + tracking: parsedFolder.trackingNumber, + folder: node.name, + party: party, + fileCount: classification.ok.length, + violations: classification.violations + }); + } catch (_e) { + return; + } + + busy = true; + try { + status('Accept Transmittal — submitting…'); + var resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'accept-transmittal', + 'Content-Type': 'application/yaml' + }, + body: buildBody(values), + credentials: 'same-origin' + }); + } catch (e) { + status('Accept failed: ' + (e && e.message ? e.message : e), 'error'); + return; + } + if (!resp.ok) { + var text = ''; + try { text = await resp.text(); } catch (_e) { /* ignore */ } + status('Accept failed (' + resp.status + '): ' + text, 'error'); + return; + } + var data; try { data = await resp.json(); } catch (_e) { data = null; } + var msg = 'Accepted ' + (data && data.moved_files ? data.moved_files : '?') + ' file(s) into ' + + (data && data.received_path ? data.received_path : 'received/'); + if (data && data.merged) msg += ' (merged with existing tracking)'; + if (data && data.plan_review) msg += ' · Plan Review scaffolded'; + status(msg, 'success'); + // Refresh the incoming/ listing so the now-moved folder drops out + // of the tree — the stale entry was the main re-trigger hazard. + var ev = window.app.modules.events; + if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); + } finally { + busy = false; + } + } + + window.app.modules.acceptTransmittal = { + isAcceptableTransmittalFolder: isAcceptableTransmittalFolder, + invoke: invoke + }; +})(); diff --git a/browse/js/app.js b/browse/js/app.js new file mode 100644 index 0000000..9bae039 --- /dev/null +++ b/browse/js/app.js @@ -0,0 +1,163 @@ +// app.js — bootstrap. Runs after every other module's IIFE has +// registered its functions on window.app.modules. +(function () { + 'use strict'; + + var state = window.app.state; + var loader = window.app.modules.loader; + var tree = window.app.modules.tree; + var events = window.app.modules.events; + + // Walk a `?file=` path segment-by-segment from the current root. + // Each non-leaf segment is matched against the parent's children + // by name; if found and it's a folder, expand+load it (so its + // children populate state.nodes) and recurse into them. The leaf + // segment becomes the selected/previewed entry. Silently no-ops + // when any segment doesn't resolve — deep links aren't a hard + // contract, just an affordance. + async function openDeepLink(path) { + var segs = path.split('/').filter(Boolean); + if (segs.length === 0) return; + var tree = window.app.modules.tree; + var prev = window.app.modules.preview; + + // Lookup helper: find a node by name within a given parent's + // immediate children. Top-level walk uses state.rootIds. + function findChild(parentIds, name) { + for (var i = 0; i < parentIds.length; i++) { + var n = window.app.state.nodes.get(parentIds[i]); + if (n && n.name === name) return n; + } + return null; + } + + var ids = window.app.state.rootIds; + for (var i = 0; i < segs.length; i++) { + var node = findChild(ids, segs[i]); + if (!node) return; // segment not present in this listing + if (i === segs.length - 1) { + // Leaf — select + preview. + window.app.state.selectedId = node.id; + window.app.state.lastPreviewedNodeId = node.id; + tree.render(); + if (prev && !node.isDir) prev.showFilePreview(node); + return; + } + // Intermediate — must be a folder we can expand into. + if (!(node.isDir || node.isZip)) return; + if (!node.loaded) { + await tree.toggleFolder(node.id); // loads + sets expanded + } else if (!node.expanded) { + node.expanded = true; + } + ids = node.childIds; + } + } + + async function bootstrap() { + events.init(); + + // Honor ?file= deep links: external clients (the profile + // page's "edit your .zddc files" list, future bookmarks, etc.) + // can link directly to "open browse at , with this entry + // selected and previewed". Single-segment names (?file=foo.md) + // match in the current directory; multi-segment paths + // (?file=a/b/foo.md) walk into a/ then b/ then open foo.md, + // loading intermediate directories on the way. + // + // When the LEAF (or any intermediate segment) is hidden + // (.zddc, .form.yaml, …), flip showHidden ON BEFORE the + // initial listing fetch so dotfiles appear in the tree. + var qs = new URLSearchParams(location.search); + var deepFile = qs.get('file'); + // Explicit ?hidden=1 in the URL: restore the show-hidden toggle + // on reload (the URL is the persistence layer for this flag — + // see events.js syncURLToSelection). + if (qs.get('hidden') === '1') state.showHidden = true; + if (deepFile) { + var segs = deepFile.split('/').filter(Boolean); + for (var si = 0; si < segs.length; si++) { + var c = segs[si].charAt(0); + if (c === '.' || c === '_') { state.showHidden = true; break; } + } + } + + // Try server auto-detect. If this page is served by zddc-server + // (or any server with a Caddy-shaped JSON listing), load the + // current directory automatically. Otherwise show the empty + // state with the "Select Directory" button. + var detected = await loader.autoDetectServerMode(); + if (detected) { + tree.setRoot(detected.entries); + events.showBrowseRoot(); + tree.render(); + if (events.prefetchScopeAccess) events.prefetchScopeAccess(); + events.statusInfo('Loaded ' + detected.entries.length + ' item' + + (detected.entries.length === 1 ? '' : 's') + + ' from ' + detected.path); + // The initial events.init() applied view mode before the + // cascade headers were available (no fetch yet). Now that + // state.scopeDefaultTool is set from the detection + // response, re-resolve so an /incoming URL auto-activates + // grid mode. + if (events.applyResolvedViewMode) events.applyResolvedViewMode(); + + // Final step of the deep link: walk the path segment by + // segment, expanding + loading intermediate directories + // before opening the leaf. Single-segment names use the + // same code path with one iteration. + if (deepFile) { + await openDeepLink(deepFile); + } + } + // Else: empty state stays visible; user can click Select Directory. + + // Browser back / forward: client-side rescope when the URL + // changes via popstate. We can't tell server-vs-fs mode from + // popstate alone, so only honor it in server mode. + window.addEventListener('popstate', async function () { + if (window.app.state.source !== 'server') return; + var path = location.pathname; + if (!path.endsWith('/')) path += '/'; + var popQS = new URLSearchParams(location.search); + if (popQS.get('hidden') === '1') window.app.state.showHidden = true; + else window.app.state.showHidden = false; + // Join the shared nav token: rapid back/forward (or back/forward + // while an in-tool rescope is mid-flight) must not apply a stale + // listing on top of a newer one. + var seq = events.beginNav ? events.beginNav() : 0; + try { + var es = await loader.fetchServerChildren(path); + if (events.isCurrentNav && !events.isCurrentNav(seq)) return; + window.app.state.currentPath = path; + window.app.state.selectedId = null; + window.app.state.lastPreviewedNodeId = null; + tree.setRoot(es); + tree.render(); + if (events.prefetchScopeAccess) events.prefetchScopeAccess(); + // Route through clearPreview so a live editor is disposed + // (not leaked) when back/forward swaps scope. + var pmod = window.app.modules.preview; + if (pmod && pmod.clearPreview) pmod.clearPreview(); + else { + var previewBody = document.getElementById('previewBody'); + if (previewBody) previewBody.innerHTML = ''; + } + var previewTitle = document.getElementById('previewTitle'); + if (previewTitle) previewTitle.textContent = 'No file selected'; + // Reapply view mode for the new URL (incoming/ → grid, etc). + if (events.applyResolvedViewMode) events.applyResolvedViewMode(); + // Re-walk ?file= so back/forward restores selection + + // expansion, not just scope. + var popFile = popQS.get('file'); + if (popFile) await openDeepLink(popFile); + } catch (_e) { /* swallow — leave the tree as-is */ } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootstrap); + } else { + bootstrap(); + } +})(); diff --git a/browse/js/conflict.js b/browse/js/conflict.js new file mode 100644 index 0000000..b9e6d91 --- /dev/null +++ b/browse/js/conflict.js @@ -0,0 +1,203 @@ +// conflict.js — shared conflict-resolution dialog for the browse tool. +// +// Surfaced when a save loses an optimistic-concurrency race: the file +// changed on the server since the user loaded it (the editor sends an +// If-Match precondition; the master replies 412). Rather than clobber the +// other writer, the editor opens this dialog showing a mine-vs-theirs diff +// and four choices. +// +// Deliberately CALLBACK-DRIVEN: it never calls saveFile / showFilePreview +// itself — the caller supplies onOverwrite / onReload / onSaveCopy. That +// keeps it reusable by a second consumer (the deferred Phase 5 cache-outbox +// conflict UI, which would resolve `.zddc-outbox/.conflict-/` entries +// against new server endpoints rather than the live file). +// +// Reuses the modal shell + diff markup conventions from history.js and the +// shared css/history.css classes (md-history-*, md-diff-*) — no new CSS. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + function toast(msg, level) { + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, level || 'info'); + } + } + + // Render a line diff of base→mine into `pane` (theirs treated as the + // base, so additions are what this save would introduce). Mirrors the + // history.js diff view. + function renderDiff(pane, theirsText, mineText) { + pane.innerHTML = ''; + var ops = (window.zddc && window.zddc.diff) + ? window.zddc.diff.lines(theirsText, mineText) + : null; + var diff = document.createElement('div'); + diff.className = 'md-diff'; + if (!ops) { + diff.textContent = 'Diff unavailable (diff module not loaded).'; + pane.appendChild(diff); + return; + } + var unchanged = true; + ops.forEach(function (op) { + if (op.type !== 'eq') unchanged = false; + var line = document.createElement('div'); + line.className = 'md-diff-line md-diff-' + op.type; + var g = document.createElement('span'); + g.className = 'md-diff-gutter'; + g.textContent = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' '); + var t = document.createElement('span'); + t.className = 'md-diff-text'; + t.textContent = op.text; + line.appendChild(g); + line.appendChild(t); + diff.appendChild(line); + }); + if (unchanged) { + var same = document.createElement('div'); + same.className = 'md-diff-line md-diff-eq'; + same.textContent = '(no differences — your copy matches the server)'; + diff.appendChild(same); + } + pane.appendChild(diff); + var s = window.zddc.diff.stats(ops); + var stat = document.createElement('p'); + stat.className = 'md-history-hint'; + stat.textContent = 'Your version vs. current server: +' + s.added + ' / −' + s.removed; + pane.appendChild(stat); + } + + // open(opts) → Promise<'overwrite' | 'reload' | 'savecopy' | 'cancel'> + // + // opts: + // filename — display name (e.g. node.name) + // mineText — the user's current (unsaved) content, for the diff + // theirsText — current server content (string), OR… + // fetchTheirs — async () => string — lazy fetch of current server content + // onOverwrite — async () => void — re-save, forcing past the conflict + // onReload — async () => void — discard mine, reload from server + // onSaveCopy — async () => void — write mine to a sibling path (optional) + // + // The matching callback runs when its button is clicked; on success the + // dialog closes and resolves with the action name. On callback error the + // dialog stays open (a toast explains) so the user can pick another path. + // Cancel / Esc / backdrop resolve 'cancel' and leave the editor untouched. + function open(opts) { + opts = opts || {}; + return new Promise(function (resolve) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay md-history-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.className = 'md-history-box'; + var title = document.createElement('h2'); + title.className = 'md-history-title'; + title.textContent = 'Conflict — ' + (opts.filename || 'file'); + var body = document.createElement('div'); + body.className = 'md-history-body'; + box.appendChild(title); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + var settled = false; + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKey); + } + function finish(result) { + if (settled) return; + settled = true; + close(); + resolve(result); + } + function onKey(e) { if (e.key === 'Escape') finish('cancel'); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) finish('cancel'); + }); + + var hint = document.createElement('p'); + hint.className = 'md-history-hint'; + hint.textContent = '"' + (opts.filename || 'This file') + + '" was changed by someone else since you opened it. ' + + 'Pick how to resolve — nothing is saved until you choose.'; + body.appendChild(hint); + + var diffPane = document.createElement('div'); + diffPane.textContent = 'Loading current server version…'; + body.appendChild(diffPane); + + var footer = document.createElement('div'); + footer.className = 'md-history-footer'; + body.appendChild(footer); + + function makeBtn(label, primary) { + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + if (primary) b.className = 'btn-primary'; + footer.appendChild(b); + return b; + } + var overwriteBtn = makeBtn('Overwrite (keep mine)'); + var reloadBtn = makeBtn('Discard mine — reload theirs'); + var copyBtn = opts.onSaveCopy ? makeBtn('Save a copy') : null; + var cancelBtn = makeBtn('Cancel', true); + + function setBusy(busy) { + [overwriteBtn, reloadBtn, copyBtn, cancelBtn].forEach(function (b) { + if (b) b.disabled = busy; + }); + } + + // Each action runs its callback; on success close+resolve, on + // error toast and re-enable so the user can try another path. + function wire(btn, fn, result) { + if (!btn) return; + btn.addEventListener('click', function () { + setBusy(true); + Promise.resolve() + .then(function () { return fn ? fn() : undefined; }) + .then(function () { finish(result); }) + .catch(function (e) { + toast('Could not ' + result + ': ' + (e && e.message ? e.message : e), 'error'); + setBusy(false); + }); + }); + } + wire(overwriteBtn, opts.onOverwrite, 'overwrite'); + wire(reloadBtn, opts.onReload, 'reload'); + wire(copyBtn, opts.onSaveCopy, 'savecopy'); + cancelBtn.addEventListener('click', function () { finish('cancel'); }); + + // Resolve the "theirs" text (eagerly provided or lazily fetched) + // then render the diff. A fetch failure leaves the actions usable + // — the diff is an aid, not a gate. + Promise.resolve() + .then(function () { + if (typeof opts.theirsText === 'string') return opts.theirsText; + if (opts.fetchTheirs) return opts.fetchTheirs(); + return null; + }) + .then(function (theirs) { + if (settled) return; + if (theirs == null) { + diffPane.textContent = 'Could not load the current server version for comparison.'; + return; + } + renderDiff(diffPane, theirs, opts.mineText || ''); + }) + .catch(function (e) { + if (settled) return; + diffPane.textContent = 'Could not load the current server version: ' + + (e && e.message ? e.message : e); + }); + }); + } + + window.app.modules.conflict = { open: open }; +})(); diff --git a/browse/js/create-transmittal.js b/browse/js/create-transmittal.js new file mode 100644 index 0000000..5bea4ff --- /dev/null +++ b/browse/js/create-transmittal.js @@ -0,0 +1,141 @@ +// create-transmittal.js — folder-creation plumbing for outgoing +// transmittals. +// +// Surfaced by events.js as a pane-menu item (right-click empty space) +// when state.scopeCanonicalFolder == 'staging'. The modal prompts for +// a ZDDC-conforming folder name (date_tracking (purpose) - subject) +// with live validation via zddc.parseFolder, then POSTs X-ZDDC-Op: +// mkdir. On success the client navigates to the new folder URL — the +// staging/ cascade serves the transmittal tool there, where the user +// builds the manifest, adds files, and publishes. +// +// No manifest assembly happens here. This is plumbing. + +(function () { + 'use strict'; + + function status(msg, level) { + var t = window.zddc && window.zddc.toast; + if (t) t(msg, level || 'info'); + } + var util = window.app.modules.util; + var escapeHtml = util.escapeHtml; + var isoDateToday = util.isoDateToday; + + function openForm() { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; + box.innerHTML = + '

Create Transmittal folder

' + + '

' + + "After it's created, the transmittal tool opens here so you can build the manifest — " + + 'add rows from the MDL, choose revisions, and associate files.' + + '

' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var input = box.querySelector('#ct-name'); + var submit = box.querySelector('#ct-submit'); + var feedback = box.querySelector('#ct-feedback'); + function revalidate() { + var v = input.value.trim(); + if (!v) { + feedback.textContent = ''; + submit.disabled = true; + return; + } + var parsed = window.zddc.parseFolder(v); + if (parsed && parsed.valid) { + feedback.style.color = '#2a8'; + feedback.textContent = '✓ tracking=' + parsed.trackingNumber + + ', status=' + parsed.status + ', title=' + parsed.title; + submit.disabled = false; + } else { + feedback.style.color = '#c33'; + feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT'; + submit.disabled = true; + } + } + input.addEventListener('input', revalidate); + revalidate(); + + // Escape handler bound once, removed in close() so it can't + // outlive a modal dismissed via cancel / overlay-click / submit. + function onKeydown(e) { + if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } + } + function close() { + document.removeEventListener('keydown', onKeydown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + box.querySelector('#ct-cancel').addEventListener('click', function () { + close(); reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { close(); reject(new Error('cancelled')); } + }); + document.addEventListener('keydown', onKeydown); + submit.addEventListener('click', function () { + var v = input.value.trim(); + var parsed = window.zddc.parseFolder(v); + if (!parsed || !parsed.valid) { + status('Folder name must conform to ZDDC convention.', 'error'); + return; + } + close(); resolve({ folderName: v }); + }); + + // Position cursor after the date prefix. + setTimeout(function () { + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }, 0); + }); + } + + async function invoke() { + if (window.app.state.scopeCanonicalFolder !== 'staging') { + status('Create Transmittal folder is only available inside staging/.', 'error'); + return; + } + var stagingUrl = window.app.state.currentPath || '/'; + if (!stagingUrl.endsWith('/')) stagingUrl += '/'; + + var choice; + try { choice = await openForm(); } catch (_e) { return; } + var newUrl = stagingUrl + encodeURIComponent(choice.folderName) + '/'; + + var resp; + try { + resp = await fetch(newUrl, { + method: 'POST', + headers: { 'X-ZDDC-Op': 'mkdir' }, + credentials: 'same-origin' + }); + } catch (e) { + status('Create failed: ' + (e && e.message ? e.message : e), 'error'); + return; + } + if (!resp.ok) { + var text = ''; try { text = await resp.text(); } catch (_e) {} + status('Create failed (' + resp.status + '): ' + text, 'error'); + return; + } + status('Created ' + choice.folderName + ' — opening transmittal tool…', 'success'); + // Navigate to the new folder (no-slash form → default_tool: transmittal). + window.location.href = stagingUrl + encodeURIComponent(choice.folderName); + } + + window.app.modules.createTransmittal = { invoke: invoke }; +})(); diff --git a/browse/js/download.js b/browse/js/download.js new file mode 100644 index 0000000..aa2af0f --- /dev/null +++ b/browse/js/download.js @@ -0,0 +1,245 @@ +// download.js — per-node downloads, surfaced through the tree's +// right-click menu (downloadFile / downloadFolder). +// +// downloadFile: a single file. Server mode lets the browser pull +// node.url (zddc-server emits Content-Disposition); FS-API mode +// reads bytes through the file handle and blob-downloads. +// +// downloadFolder: an arbitrary directory node as a .zip. Server +// mode points an at the virtual ".zip" +// URL — zddc-server recognises the suffix and streams an ACL- +// filtered archive without buffering on the client. FS-API mode +// walks the picked handle in two passes — metadata first, then +// bytes — so we can warn before loading a very large tree into +// memory. +(function () { + 'use strict'; + + var state = window.app.state; + + // Soft thresholds for the offline bundle: above either, confirm() + // before loading everything into memory. + var WARN_FILE_COUNT = 2000; + var WARN_TOTAL_BYTES = 500 * 1024 * 1024; + + function events() { return window.app.modules.events; } + + // Canonical document-conversion matrix — mirrors zddc/internal/convert + // Convert(): which target formats a given source extension can be exported + // to. PDF is markdown-only (md→pdf) because the server has no docx→pdf / + // html→pdf path. This is the SINGLE source of truth for both the Export + // context-menu (download.exportTargets) and the markdown editor's + // DOCX/HTML/PDF buttons (preview-markdown.js), so the two never drift. + var EXPORT_MATRIX = { + md: ['docx', 'html', 'pdf'], + docx: ['md', 'html'], + html: ['md', 'docx'] + }; + + // exportTargets returns the formats a file of extension `ext` can be + // exported to (excludes the source format itself), or [] if `ext` is not a + // convertible source. Case-insensitive. + function exportTargets(ext) { + return EXPORT_MATRIX[String(ext || '').toLowerCase()] || []; + } + + // convertUrl maps a source path/URL to its sibling virtual-conversion URL + // (foo.md → foo.pdf). zddc-server recognises the sibling-extension pattern + // and converts on the fly. Shared by exportFile and the editor buttons. + function convertUrl(path, fmt) { + return String(path || '').replace(/\.[^./]+$/, '') + '.' + fmt; + } + + function isHiddenName(name) { + return name.length === 0 || name[0] === '.' || name[0] === '_'; + } + + function fmtMB(bytes) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } + + // Trigger a browser download of a Blob (revokes the object URL after). + function downloadBlob(filename, blob) { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(function () { + URL.revokeObjectURL(a.href); + a.remove(); + }, 0); + } + + // Trigger a download from a same-origin server URL via Content-Disposition. + // NOTE: an click is fire-and-forget — a server error + // (401/403/404/5xx) can't be observed here, so failures surface only as + // the browser's own download error, not a toast. This is deliberate: the + // folder path points at zddc-server's streamed virtual ".zip" + // endpoint, and buffering it through fetch() to make errors catchable + // would defeat the streaming (the archive can be arbitrarily large). + function downloadUrl(filename, url) { + var a = document.createElement('a'); + a.href = url; + a.download = filename; // hint; the server's Content-Disposition wins + document.body.appendChild(a); + a.click(); + setTimeout(function () { a.remove(); }, 0); + } + + // Recursively collect every (non-hidden) file under dirHandle into + // `out` as { relPath, handle, size }, accumulating into `tally`. + // relPrefix is the slash-terminated path within the picked root + // ("" at the root). + async function collectFiles(dirHandle, relPrefix, out, tally) { + for await (var pair of dirHandle.entries()) { + var name = pair[0]; + var handle = pair[1]; + if (isHiddenName(name)) continue; + if (handle.kind === 'directory') { + await collectFiles(handle, relPrefix + name + '/', out, tally); + } else { + var size = 0; + try { + var f = await handle.getFile(); + size = f.size || 0; + } catch (_e) { /* permission lost — count it as 0 */ } + out.push({ relPath: relPrefix + name, handle: handle, size: size }); + tally.count++; + tally.bytes += size; + } + } + } + + async function downloadFsSubtree(rootHandle) { + var ev = events(); + ev.statusInfo('Scanning ' + rootHandle.name + '…'); + var files = []; + var tally = { count: 0, bytes: 0 }; + await collectFiles(rootHandle, '', files, tally); + if (files.length === 0) { + ev.statusInfo(rootHandle.name + ' is empty — nothing to download.'); + return; + } + if (tally.count > WARN_FILE_COUNT || tally.bytes > WARN_TOTAL_BYTES) { + var ok = window.confirm( + 'This folder has ' + tally.count + ' files (~' + fmtMB(tally.bytes) + ').\n\n' + + 'Building the zip loads them all into memory — it may be slow or crash the tab.\n\n' + + 'Continue?'); + if (!ok) { ev.statusClear(); return; } + } + var zip = new window.JSZip(); + for (var i = 0; i < files.length; i++) { + ev.statusInfo('Zipping ' + rootHandle.name + '… (' + (i + 1) + '/' + files.length + ')'); + // Hand JSZip the File (a Blob, backed by disk) rather than + // pre-reading every file's arrayBuffer — otherwise the whole + // tree's raw bytes sit in the JS heap at once before zipping. + // JSZip reads each Blob lazily during generateAsync. + var f = await files[i].handle.getFile(); + zip.file(rootHandle.name + '/' + files[i].relPath, f); + } + ev.statusInfo('Generating ' + rootHandle.name + '.zip…'); + var blob = await zip.generateAsync({ type: 'blob' }); + downloadBlob(rootHandle.name + '.zip', blob); + ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)'); + } + + var busy = false; + + // Download a single file node. Server mode: rely on the node's + // own URL (the server emits Content-Disposition). FS mode: read + // bytes through the handle and trigger a blob download. Works + // for ordinary files, for .zip members (the loader sets node.url + // for zip members in server mode and a ZipFileHandle offline), + // and for the .zip file itself. + async function downloadFile(node) { + if (busy) return; + if (!node || node.isDir) { + events().statusError('Not a file: ' + (node && node.name)); + return; + } + busy = true; + try { + if (node.url) { + events().statusInfo('Downloading ' + node.name + '…'); + downloadUrl(node.name, node.url); + setTimeout(function () { events().statusClear(); }, 2500); + } else if (node.handle && typeof node.handle.getFile === 'function') { + events().statusInfo('Preparing ' + node.name + '…'); + var f = await node.handle.getFile(); + var blob = new Blob([await f.arrayBuffer()]); + downloadBlob(node.name, blob); + events().statusInfo('Downloaded ' + node.name); + } else { + events().statusError('No download path for ' + node.name); + } + } catch (e) { + events().statusError('Download failed: ' + (e && e.message ? e.message : e)); + } finally { + busy = false; + } + } + + // Download an arbitrary folder node as a .zip. Server mode points + // an at the virtual ".zip" URL (the + // dispatcher recognises the suffix and streams the subtree). FS + // mode walks the directory handle. + async function downloadFolder(node) { + if (busy) return; + if (!node || !node.isDir) { + events().statusError('Not a folder: ' + (node && node.name)); + return; + } + busy = true; + try { + if (state.source === 'server') { + var tree = window.app.modules.tree; + var dir = tree.pathFor(node).replace(/\/$/, ''); + events().statusInfo('Preparing ' + node.name + '.zip…'); + downloadUrl(node.name + '.zip', dir + '.zip'); + setTimeout(function () { events().statusClear(); }, 2500); + } else if (state.source === 'fs' && node.handle + && node.handle.kind === 'directory') { + await downloadFsSubtree(node.handle); + } else { + events().statusError('Cannot download ' + node.name); + } + } catch (e) { + events().statusError('Download failed: ' + (e && e.message ? e.message : e)); + } finally { + busy = false; + } + } + + // Export a file converted to another format. Server-only: builds the + // sibling-extension URL (foo.docx → foo.md) and lets the browser pull it — + // zddc-server recognises the virtual path and converts on the fly, emitting + // Content-Disposition. fmt is a bare extension ("md" | "docx" | "html"). + function exportFile(node, fmt) { + if (!node || node.isDir) { + events().statusError('Not a file: ' + (node && node.name)); + return; + } + if (state.source !== 'server') { + events().statusError('Export to .' + fmt + ' needs a server connection'); + return; + } + var tree = window.app.modules.tree; + var path = tree && tree.pathFor ? tree.pathFor(node) : node.url; + if (!path) { + events().statusError('No path for ' + node.name); + return; + } + var url = convertUrl(path, fmt); + var name = convertUrl(node.name, fmt); + events().statusInfo('Exporting ' + name + '…'); + downloadUrl(name, url); + setTimeout(function () { events().statusClear(); }, 2500); + } + + window.app.modules.download = { + downloadFile: downloadFile, + downloadFolder: downloadFolder, + exportFile: exportFile, + exportTargets: exportTargets, + convertUrl: convertUrl + }; +})(); diff --git a/browse/js/events.js b/browse/js/events.js new file mode 100644 index 0000000..90bf600 --- /dev/null +++ b/browse/js/events.js @@ -0,0 +1,1280 @@ +// events.js — wires up DOM listeners. Idempotent so app.js can call +// init() once on load. +(function () { + 'use strict'; + + var state = window.app.state; + var tree = window.app.modules.tree; + var loader = window.app.modules.loader; + // preview module is loaded later (concat order); look it up at + // call time, not at IIFE-eval time. + function previewMod() { return window.app.modules.preview; } + + // Notifications route through the shared toast helper (shared/ + // toast.js) — there's no persistent footer strip in browse. Same + // signatures as before so the 70+ existing call sites work + // unchanged; statusClear is a no-op (toasts fade on their own and + // single-toast policy guarantees only the latest is visible). + function status(msg, kind) { + if (!msg) return; + if (!window.zddc || typeof window.zddc.toast !== 'function') return; + var level = kind === 'error' ? 'error' : 'info'; + window.zddc.toast(msg, level); + } + + function statusError(msg) { status(msg, 'error'); } + function statusInfo(msg) { status(msg, 'info'); } + function statusClear() { /* no-op — toasts fade on their own */ } + + async function pickLocalDir() { + if (typeof window.showDirectoryPicker !== 'function') { + statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.'); + return; + } + var handle; + try { + handle = await window.showDirectoryPicker({ mode: 'read' }); + } catch (e) { + // User cancelled — silent + return; + } + state.source = 'fs'; + state.rootHandle = handle; + state.currentPath = handle.name + '/'; + var raw; + try { + raw = await loader.fetchFsChildren(handle); + } catch (e) { + statusError('Failed to read directory: ' + e.message); + return; + } + tree.setRoot(raw); + showBrowseRoot(); + tree.render(); + statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's')); + } + + function showBrowseRoot() { + var empty = document.getElementById('emptyState'); + var root = document.getElementById('browseRoot'); + if (empty) empty.classList.add('hidden'); + if (root) root.classList.remove('hidden'); + applySourceUI(); + } + + // Visual state of the "Select Directory" button + the refresh + // button depends on the source. In server mode the user is + // already viewing a server-backed listing — Select Directory + // becomes a quiet "switch to local" affordance (subtle styling), + // and the refresh button is shown. In FS mode the button is + // primary (it's how you got here) and refresh is hidden (the + // listing was already a fresh enumeration). + function applySourceUI() { + var add = document.getElementById('addDirectoryBtn'); + var refresh = document.getElementById('refreshHeaderBtn'); + if (add) { + if (state.source === 'server') { + add.classList.remove('btn-primary'); + add.classList.add('btn--subtle'); + } else { + add.classList.add('btn-primary'); + add.classList.remove('btn--subtle'); + } + } + if (refresh) { + if (state.source) { + refresh.classList.remove('hidden'); + } else { + refresh.classList.add('hidden'); + } + } + } + + // syncURLToSelection reflects the current scope + selected node + + // show-hidden flag into the URL bar via history.replaceState, so: + // - bookmarks / copy-paste of the URL re-open the same view + // - reload (e.g. after toggling admin mode, which forces a hard + // reload to pick up the elevated cookie) lands the user back + // on the same selection + // + // Uses replaceState (not pushState) so a long click sequence doesn't + // pollute browser history. Scope changes (rescopeServer) still + // pushState — that's the only "intentional" navigation step in the + // SPA, and back/forward should walk between scopes, not selections. + // + // FS-API mode has no shareable URL, so this is a no-op there. + function syncURLToSelection() { + if (state.source !== 'server') return; + var scope = state.currentPath || '/'; + if (!scope.endsWith('/')) scope += '/'; + + var params = new URLSearchParams(); + var node = state.selectedId != null ? state.nodes.get(state.selectedId) : null; + if (node) { + var abs = tree.pathFor(node); + var prefix = scope.replace(/\/$/, ''); + var rel = abs; + if (prefix && abs.indexOf(prefix + '/') === 0) { + rel = abs.slice(prefix.length + 1); + } + // Directory selections get a trailing slash so the URL + // round-trips as a navigable folder reference. + if (node.isDir && rel && !rel.endsWith('/')) rel += '/'; + if (rel) params.set('file', rel); + } + if (state.showHidden) params.set('hidden', '1'); + + // URLSearchParams percent-encodes '/' to %2F; the server doesn't + // care, but the URL bar reads better with raw slashes. + var qs = params.toString().replace(/%2F/g, '/'); + var url = scope + (qs ? '?' + qs : ''); + try { + history.replaceState({ zddcBrowse: true, path: url }, '', url); + } catch (_e) { /* private browsing edge cases */ } + } + + // Navigation sequence token. Every async flow that ends by replacing + // the tree root (refresh, rescope, reload, back/forward popstate) + // captures a token before its fetch and bails if a newer navigation + // has started by the time it resolves — otherwise a slow listing can + // land on top of a newer one and leave the tree out of sync with + // state.currentPath / the URL bar. + var navSeq = 0; + function beginNav() { return ++navSeq; } + function isCurrentNav(seq) { return seq === navSeq; } + + async function refreshListing() { + // Snapshot expanded paths + selection BEFORE setRoot clears the + // tree, then re-apply after the new root is in place. Keeps + // the user's layout (which folders were open, which row was + // highlighted, what the preview was pinned to) stable across + // a refresh — including the auto-refresh triggered by the + // "Show hidden files" toggle. + var snap = tree.snapshotState(); + var seq = beginNav(); + if (state.source === 'server') { + var raw; + try { + raw = await loader.fetchServerChildren(state.currentPath); + } catch (e) { + statusError('Refresh failed: ' + e.message); + return; + } + if (!isCurrentNav(seq)) return; + tree.setRoot(raw); + await tree.restoreState(snap); + if (!isCurrentNav(seq)) return; + tree.render(); + prefetchScopeAccess(); + statusInfo('Refreshed (' + raw.length + ' item' + + (raw.length === 1 ? '' : 's') + ')'); + } else if (state.source === 'fs' && state.rootHandle) { + var raw2; + try { + raw2 = await loader.fetchFsChildren(state.rootHandle); + } catch (e) { + statusError('Refresh failed: ' + e.message); + return; + } + if (!isCurrentNav(seq)) return; + tree.setRoot(raw2); + await tree.restoreState(snap); + if (!isCurrentNav(seq)) return; + tree.render(); + statusInfo('Refreshed'); + } + } + + function init() { + // Inject the action implementations the declarative menu-model + // delegates to (avoids an events ↔ menu-model circular dependency). + var mm = window.app.modules.menuModel; + if (mm && mm.configure) { + mm.configure({ + createInDir: createInDir, + renameNode: renameNode, + deleteNode: deleteNode, + navigateIntoFolder: navigateIntoFolder, + refreshListing: refreshListing, + parentDirFor: parentDirFor, + canCreateHere: canCreateHere, + statusInfo: statusInfo, + statusError: statusError + }); + } + + // Header buttons + var btn = document.getElementById('addDirectoryBtn'); + if (btn) btn.addEventListener('click', pickLocalDir); + + var refresh = document.getElementById('refreshHeaderBtn'); + if (refresh) refresh.addEventListener('click', refreshListing); + + // Admin mode (shared/elevation.js) flipped on this page. Listing + // verbs + editor affordances (canSave) are computed against the + // server WITH the elevation cookie, so re-fetch the listing (which + // re-runs prefetchScopeAccess) and re-render the open preview — + // restoreState only restores the highlight, not the pane contents. + window.addEventListener('zddc:elevationchange', async function () { + if (state.source !== 'server') return; // FS mode has no server elevation + await refreshListing(); + var node = state.lastPreviewedNodeId && state.nodes.get(state.lastPreviewedNodeId); + var p = window.app.modules.preview; + if (node && !node.isDir && p && p.showFilePreview) p.showFilePreview(node); + }); + + // ── Tree-pane toolbar: Sort + Show hidden ────────────────────── + // View settings only. Create actions (new folder / file) live in + // the right-click context menu, not the toolbar. + var sortSelect = document.getElementById('sortSelect'); + if (sortSelect) { + // Reflect current state, then drive setSortExplicit on change. + sortSelect.value = state.sort.key + ':' + state.sort.dir; + sortSelect.addEventListener('change', function () { + var parts = sortSelect.value.split(':'); + tree.setSortExplicit(parts[0], parseInt(parts[1], 10) === -1 ? -1 : 1); + }); + } + var showHiddenChk = document.getElementById('showHiddenChk'); + if (showHiddenChk) { + showHiddenChk.checked = !!state.showHidden; + showHiddenChk.addEventListener('change', function () { + state.showHidden = showHiddenChk.checked; + syncURLToSelection(); + refreshListing(); + }); + } + + // Tree autofilter — parses input through zddc.filter.parse so + // the same query grammar that the archive app uses (terms, + // quotes, !negation, multi-word AND) works here. The AST is + // cached on state.filterAST; tree.render reads it and skips + // non-matching rows. Escape clears. + var filterInput = document.getElementById('treeFilter'); + if (filterInput) { + var filterDebounce = null; + var applyFilter = function () { + var raw = filterInput.value || ''; + state.filterText = raw; + state.filterAST = raw ? window.zddc.filter.parse(raw) : null; + filterInput.classList.toggle('filter-active', !!raw); + tree.render(); + }; + filterInput.addEventListener('input', function () { + if (filterDebounce) clearTimeout(filterDebounce); + filterDebounce = setTimeout(applyFilter, 80); + }); + filterInput.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && filterInput.value) { + e.preventDefault(); + filterInput.value = ''; + applyFilter(); + } + }); + } + + // No view-mode buttons; mode is derived from the URL on every + // scope change (resolveViewMode below). Pass-through for the + // initial path. + applyResolvedViewMode(); + + // Pop-out preview button — opens the current preview in a separate window. + var popout = document.getElementById('previewPopout'); + if (popout) popout.addEventListener('click', function () { + var p = previewMod(); + if (p && state.lastPreviewedNodeId != null) { + var n = state.nodes.get(state.lastPreviewedNodeId); + if (n) p.showFilePreview(n, { popup: true }); + } + }); + + // Pane resizer (tree pane width). Drag horizontally; clamps to + // [180, 60% of viewport]. State stays in-memory only — refresh + // resets to the default 360px. + var resizer = document.querySelector('.pane-resizer[data-resizer-for="tree-pane"]'); + var treePane = document.getElementById('treePane'); + if (resizer && treePane) { + var dragging = false; + var startX = 0; + var startWidth = 0; + resizer.addEventListener('mousedown', function (e) { + dragging = true; + resizer.classList.add('is-dragging'); + startX = e.clientX; + startWidth = treePane.getBoundingClientRect().width; + e.preventDefault(); + }); + document.addEventListener('mousemove', function (e) { + if (!dragging) return; + var dx = e.clientX - startX; + var w = Math.max(180, Math.min(window.innerWidth * 0.6, startWidth + dx)); + treePane.style.width = w + 'px'; + }); + document.addEventListener('mouseup', function () { + if (!dragging) return; + dragging = false; + resizer.classList.remove('is-dragging'); + }); + } + + // Tree-row clicks (event delegation on the tree body). + // Click semantics on a folder row: + // - plain click → toggle expand (deferred so dblclick wins) + // - shift-click → recursive expand/collapse of the subtree + // - alt-click → ALSO recursive + // - dblclick → navigate into the folder + // File rows: plain click → preview in right pane; modifier-click + // and middle-click open in new tab. + // + // The plain-click toggle for folders is intentionally deferred + // via setTimeout. Reason: toggling re-renders the tree, which + // replaces the clicked row element. The browser detects a + // double-click only when the second click lands on the same + // target element as the first; replacing the row breaks that + // continuity and the dblclick event never fires. The deferred + // toggle lets a pending dblclick cancel it. + var pendingFolderToggle = null; + var treeBody = document.getElementById('treeBody'); + if (treeBody) { + treeBody.addEventListener('click', function (e) { + var row = e.target.closest('.tree-row'); + if (!row) return; + // Kebab (⋯) button → open the row menu at the button; must run + // BEFORE the toggle/preview logic so it doesn't also fire those. + var kebab = e.target.closest('.tree-row__kebab'); + if (kebab) { + e.preventDefault(); + e.stopPropagation(); + var r = kebab.getBoundingClientRect(); + openRowMenuFor(row, r.right, r.bottom); + return; + } + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + + // Table-leaf dirs (mdl/rsk/ssr) are NOT expandable — they fall + // through to the preview path, which opens the tables tool. + var isExpandable = (row.dataset.isdir === 'true' || row.dataset.iszip === 'true') + && row.dataset.tableleaf !== 'true'; + + if (isExpandable) { + e.preventDefault(); + if (e.shiftKey || e.altKey) { + // Modifier-click skips the dblclick race — it's + // an explicit recursive toggle, never followed + // by a dblclick. + if (node.expanded) tree.collapseSubtree(id); + else tree.expandSubtree(id); + return; + } + // ZIPs don't navigate-into; toggle immediately. + if (row.dataset.iszip === 'true') { + tree.toggleFolder(id); + return; + } + // Folder: defer the toggle so a pending dblclick + // can pre-empt it. + if (pendingFolderToggle) { + clearTimeout(pendingFolderToggle.timer); + } + pendingFolderToggle = { + id: id, + timer: setTimeout(function () { + pendingFolderToggle = null; + tree.toggleFolder(id); + }, 220) + }; + return; + } + + // File row: modifier-click → open URL in new tab if + // available (server mode preserves the original URL, + // useful for direct download / sharing). + if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) { + if (node.url) window.open(node.url, '_blank', 'noopener'); + return; + } + // Plain click → preview in the right pane. + e.preventDefault(); + state.selectedId = id; + state.lastPreviewedNodeId = id; + tree.render(); // refresh selection highlight + syncURLToSelection(); + var p = previewMod(); + if (p) p.showFilePreview(node); + }); + + // Double-click on a folder → "navigate into" it. Distinct + // from single-click (which expands inline) so users keep + // both UX models. Server mode jumps to the folder URL — + // zddc-server returns a fresh browse instance scoped to + // that directory. FS-API mode swaps state.rootHandle to + // the folder's handle and re-loads, so the user sees + // only that subtree at the root level. + // + // Files: dblclick is left alone — the single-click preview + // is already a "look at this file" action; a separate + // navigate-into doesn't apply. + // ZIPs: skipped too — they're inspected via inline + // expansion (JSZip), not navigated into. + treeBody.addEventListener('dblclick', function (e) { + var row = e.target.closest('.tree-row'); + if (!row) return; + if (row.dataset.isdir !== 'true') return; + if (row.dataset.tableleaf === 'true') return; // leaf: single-click previews + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + e.preventDefault(); + // Pre-empt the deferred single-click toggle so the user + // doesn't see a flicker of expand/collapse before nav. + if (pendingFolderToggle) { + clearTimeout(pendingFolderToggle.timer); + pendingFolderToggle = null; + } + navigateIntoFolder(node); + }); + + // Keyboard navigation in the tree. Document-level listener so + // the user doesn't have to click into the tree first; bails + // out cleanly when focus is in an editable field or when a + // modal / context-menu owns the keys. Roving-tabindex-style + // semantics, matching the W3C tree-view pattern: + // + // ↓ / ↑ — move selection (auto-previews files) + // → — expand if collapsed; jump to first child + // if already expanded; no-op otherwise + // ← — collapse if expanded; jump to parent + // if collapsed/leaf + // Enter / Space — preview file / toggle folder + // Home / End — first / last visible row + // Keyboard menu key — ContextMenu key or Shift+F10 opens the row + // menu at the selected row (standard file-manager / a11y gesture). + document.addEventListener('keydown', function (e) { + var tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.target && e.target.isContentEditable) return; + if (document.querySelector('.modal-overlay, .zddc-menu')) return; + var isMenuKey = e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'); + if (!isMenuKey || state.selectedId == null) return; + var selRow = treeBody.querySelector('.tree-row[data-id="' + state.selectedId + '"]'); + if (!selRow) return; + e.preventDefault(); + var rr = selRow.getBoundingClientRect(); + openRowMenuFor(selRow, rr.left + 16, rr.bottom - 4); + }); + + document.addEventListener('keydown', function (e) { + // Skip editable contexts. + var tag = (e.target && e.target.tagName) || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (e.target && e.target.isContentEditable) return; + // Skip when a modal or context menu is open. + if (document.querySelector('.modal-overlay, .zddc-menu')) return; + // Skip if any modifier is pressed — lets Ctrl-F, Cmd-T, + // Alt-arrow back/forward etc. fall through unchanged. + if (e.ctrlKey || e.metaKey || e.altKey) return; + + var key = e.key; + var navKey = key === 'ArrowDown' || key === 'ArrowUp' + || key === 'ArrowLeft' || key === 'ArrowRight' + || key === 'Home' || key === 'End' + || key === 'Enter' || key === ' '; + if (!navKey) return; + + var visible = tree.visibleIds(); + if (!visible.length) return; + + // Commit to handling this key — preventDefault so the + // browser doesn't also scroll on arrows / page-down on + // Space. Selection / expand actions happen below. + e.preventDefault(); + + var curIdx = visible.indexOf(state.selectedId); + var node = state.selectedId != null + ? state.nodes.get(state.selectedId) : null; + // Table-leaf dirs aren't expandable: Enter/Space previews them + // (opens the table) rather than toggling. + var expandable = !!(node && (node.isDir || node.isZip) + && !window.app.modules.util.isTableLeaf(node)); + var nextId = null; + var previewModule = previewMod(); + + if (key === 'ArrowDown') { + nextId = curIdx < 0 + ? visible[0] + : visible[Math.min(curIdx + 1, visible.length - 1)]; + } else if (key === 'ArrowUp') { + nextId = curIdx < 0 + ? visible[visible.length - 1] + : visible[Math.max(curIdx - 1, 0)]; + } else if (key === 'Home') { + nextId = visible[0]; + } else if (key === 'End') { + nextId = visible[visible.length - 1]; + } else if (key === 'ArrowRight' && node) { + if (expandable && !node.expanded) { + tree.toggleFolder(node.id); + return; + } + if (expandable && node.expanded + && node.childIds && node.childIds.length) { + nextId = node.childIds[0]; + } + } else if (key === 'ArrowLeft' && node) { + if (expandable && node.expanded) { + tree.toggleFolder(node.id); + return; + } + if (node.parentId != null) { + nextId = node.parentId; + } + } else if ((key === 'Enter' || key === ' ') && node) { + if (expandable) { + tree.toggleFolder(node.id); + } else if (previewModule) { + previewModule.showFilePreview(node); + state.lastPreviewedNodeId = node.id; + } + return; + } + + if (nextId == null) return; + state.selectedId = nextId; + var nextNode = state.nodes.get(nextId); + tree.render(); + syncURLToSelection(); + // Auto-preview files as the keyboard cursor lands on them + // so the right pane keeps up with selection. Folders are + // selection-only; their preview is "expand to see inside". + if (nextNode && !nextNode.isDir && !nextNode.isZip + && previewModule) { + // auto:true — keyboard cursor walking the tree. If an + // editor has unsaved edits, the preview module leaves it + // in place rather than prompting on every keystroke. + previewModule.showFilePreview(nextNode, { auto: true }); + state.lastPreviewedNodeId = nextId; + } + // Scroll the now-selected row into view. + var newRow = treeBody.querySelector( + '.tree-row[data-id="' + nextId + '"]'); + if (newRow) newRow.scrollIntoView({ block: 'nearest' }); + }); + + // Right-click → context menu. Two surfaces: + // - on a tree row: per-row menu (Open, Rename, Delete, …) + // - on empty space in the pane: directory-scope menu + // (New folder, Refresh, Sort by, …) + treeBody.addEventListener('contextmenu', function (e) { + e.preventDefault(); + var row = e.target.closest('.tree-row'); + if (row) openRowMenuFor(row, e.clientX, e.clientY); + else openPaneMenu(e.clientX, e.clientY); + }); + + // Per-row drag-drop. Any row is a drop target — folders + // upload into themselves; files upload into their parent + // folder. Highlighting is purely visual; server-side ACL + // is the source of truth (a 403 surfaces as an error toast). + wirePerRowDrop(treeBody); + } + } + + // ── Per-row drag/drop targets ───────────────────────────────────────── + + // Translate a node into the directory that should receive uploads + // dropped onto its row. Folders → themselves; files → their parent. + // Returns a server path with a trailing slash, or null when there's + // no usable destination (offline mode, virtual node, etc.). + function targetDirForNode(node) { + if (!node || node.virtual) return null; + if (state.source !== 'server') return null; + if (node.isZip) return null; // can't upload INTO a zip via PUT + var dirNode = node; + if (!node.isDir) { + if (node.parentId == null) { + // Top-level file → upload to current scope. + return state.currentPath || '/'; + } + dirNode = state.nodes.get(node.parentId); + if (!dirNode) return null; + } + var p = tree.pathFor(dirNode); + if (!p.endsWith('/')) p += '/'; + return p; + } + + // True when this node is a file viewed through the synthetic + // /received/ window — the URL has a `received/` segment + // that's NOT preceded by `archive//` (the canonical record + // form). A drop here is a review-comment intent: server rewrites to + // /+C. + function isVirtualReceivedFile(node) { + if (!node || node.isDir || state.source !== 'server') return false; + var url = tree.pathFor(node); + var parts = url.replace(/^\/+/, '').split('/'); + var idx = parts.indexOf('received'); + if (idx < 2) return false; + // Canonical form: parts[idx - 2] === 'archive'. Virtual form: anything else. + return parts[idx - 2].toLowerCase() !== 'archive'; + } + + function dragHasFiles(e) { + if (!e.dataTransfer || !e.dataTransfer.types) return false; + var types = e.dataTransfer.types; + for (var i = 0; i < types.length; i++) { + if (types[i] === 'Files') return true; + } + return false; + } + + function wirePerRowDrop(treeBody) { + var lastOver = null; + function clearHighlight() { + if (lastOver) { + lastOver.classList.remove('is-droptarget'); + lastOver = null; + } + } + treeBody.addEventListener('dragover', function (e) { + if (!dragHasFiles(e)) return; + var row = e.target.closest('.tree-row'); + if (!row) { clearHighlight(); return; } + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + var dest = targetDirForNode(node); + if (!dest) { + if (e.dataTransfer) e.dataTransfer.dropEffect = 'none'; + clearHighlight(); + return; + } + e.preventDefault(); // signals "this is a drop target" + e.stopPropagation(); // suppress doc-level overlay + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (lastOver !== row) { + clearHighlight(); + row.classList.add('is-droptarget'); + lastOver = row; + } + }); + treeBody.addEventListener('dragleave', function (e) { + // dragleave fires on row crossings too — only clear when the + // pointer actually leaves the tree body. + if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) { + clearHighlight(); + } + }); + treeBody.addEventListener('drop', async function (e) { + if (!dragHasFiles(e)) return; + var row = e.target.closest('.tree-row'); + clearHighlight(); + if (!row) return; + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + // Comment-upload short-circuit: drop on a file that lives + // under the virtual /received/ window is a "comment + // on this file" intent. PUT to the target's URL — the server + // rewrites to /+C and the canonical + // record (WORM) stays untouched. Confirm first so the user + // sees what's about to happen. + if (!node.isDir && isVirtualReceivedFile(node)) { + e.preventDefault(); + e.stopPropagation(); + if (!window.confirm("Drop bytes here as a review comment on '" + node.name + "'? The server will save it in the workflow folder with a +C revision modifier.")) { + return; + } + var upMod = window.app.modules.upload; + if (!upMod) return; + var targetURL = tree.pathFor(node); + try { + await upMod.uploadCommentToTarget(targetURL, e.dataTransfer); + } catch (err) { + statusError('Comment upload failed: ' + (err.message || err)); + } + return; + } + var dest = targetDirForNode(node); + if (!dest) return; + e.preventDefault(); + e.stopPropagation(); // pre-empt doc-level handler + var up = window.app.modules.upload; + if (!up) return; + try { + await up.uploadToDir(dest, e.dataTransfer); + } catch (err) { + statusError('Upload failed: ' + (err.message || err)); + } + }); + } + + // ── Create new folder / file (server mode) ──────────────────────────── + + // Reject names with path separators, leading dots, or empty input — + // mirrors the server-side hidden-segment / no-traversal guards so + // the user sees the rejection without a round-trip. + function validateName(name) { + name = (name || '').trim(); + if (!name) return { ok: false, msg: 'Name required.' }; + if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' }; + if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' }; + if (name.charAt(0) === '.' || name.charAt(0) === '_') { + return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' }; + } + return { ok: true, name: name }; + } + + // Resolve "the directory new items go into" for a given row. + // Folders/zips: create inside them. Files: create alongside (in + // their parent). Used by the row-context New menu items. + function parentDirFor(node) { + var parentDir; + if (!node) { + parentDir = state.currentPath || '/'; + } else if (node.isDir || node.isZip) { + parentDir = tree.pathFor(node); + } else if (node.parentId != null) { + var parent = state.nodes.get(node.parentId); + parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/'); + } else { + parentDir = state.currentPath || '/'; + } + if (!parentDir.endsWith('/')) parentDir += '/'; + return parentDir; + } + + var escapeHtml = window.app.modules.util.escapeHtml; + + // Valid party folder name — mirrors zddc.ValidPartyName server-side + // (^[A-Za-z0-9][A-Za-z0-9.-]*$). + function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); } + + // The party-partitioned workspace peers. Each is a physical top-level + // directory // whose children are / folders. + // Creating something at a peer root means choosing a party — see + // createInAggregator. (mdl/rsk rows are created via the tables tool; + // archive is the WORM record; ssr is the flat registry — none of those + // use this picker.) + var PARTY_PEERS = { incoming: 1, working: 1, staging: 1, reviewing: 1 }; + + // aggregatorRoot returns { project, slot } when parentDir is a party- + // partitioned peer root (server mode only), else null. parentDir is a + // "///" URL. + function aggregatorRoot(parentDir) { + if (state.source !== 'server') return null; + var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); + if (segs.length !== 2 || !segs[0]) return null; + var peer = segs[1].toLowerCase(); + return PARTY_PEERS[peer] ? { project: segs[0], slot: peer } : null; + } + + // List the registered parties for a project — one ssr/.yaml per + // party (the authoritative registry). A party "exists" iff its ssr row + // exists, so this is the canonical source for the picker. Returns [] + // on error. + async function fetchParties(project) { + try { + var entries = await loader.fetchServerChildren('/' + project + '/ssr/'); + return entries + .filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); }) + .map(function (e) { return e.name.replace(/\.yaml$/i, ''); }) + .filter(function (n) { return n !== 'table' && n !== 'form'; }) + .sort(function (a, b) { return a.localeCompare(b); }); + } catch (_e) { return []; } + } + + // openPartyPicker resolves to { party, name } once the user picks a + // party (existing or new) and a name, or null on cancel. Mirrors the + // stage.js modal styling. New-party creation is offered but the server + // gates it to the document_controller (a 403 surfaces a clear message). + function openPartyPicker(opts) { + return new Promise(function (resolve) { + var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);'; + + var partyList = opts.parties.map(function (name) { + return ''; + }).join(''); + + box.innerHTML = + '

New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/

' + + '

' + + escapeHtml(opts.slot) + '/ is partitioned by party. ' + + 'Pick the party this ' + kindWord + ' belongs to — it lands under ' + escapeHtml(opts.slot) + '/<party>/.' + + '

' + + '
' + + (partyList || 'No parties yet — create one below.') + + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var newRow = box.querySelector('#pp-newparty-row'); + var newInput = box.querySelector('#pp-newparty'); + box.querySelectorAll('input[name="pp-party"]').forEach(function (r) { + r.addEventListener('change', function () { + var isNew = (r.value === '__new__' && r.checked); + newRow.style.display = isNew ? '' : 'none'; + if (isNew) newInput.focus(); + }); + }); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + function cancel() { close(); resolve(null); } + box.querySelector('#pp-cancel').addEventListener('click', cancel); + overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); }); + box.querySelector('#pp-submit').addEventListener('click', function () { + var sel = box.querySelector('input[name="pp-party"]:checked'); + if (!sel) { statusError('Pick a party.'); return; } + var party; + if (sel.value === '__new__') { + party = newInput.value.trim(); + if (!validPartyName(party)) { + statusError('Party name: a letter or digit, then letters/digits/dot/hyphen.'); + return; + } + } else { + party = sel.value; + } + var nv = validateName(box.querySelector('#pp-name').value); + if (!nv.ok) { statusError(nv.msg); return; } + close(); + resolve({ party: party, name: nv.name, isNew: sel.value === '__new__' }); + }); + }); + } + + // createInAggregator routes a New folder/file at a party-peer root to + // the physical /// after prompting for the + // party. A brand-new party is registered first by creating its + // ssr/.yaml row (the authoritative registry; party_source: ssr). + async function createInAggregator(agg, kind) { + var up = window.app.modules.upload; + if (!up) return; + var parties = await fetchParties(agg.project); + var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties }); + if (!choice) return; + // Party names are validated to a URL-safe charset, so no encoding + // needed for the party segment; makeDir/makeFile encode the leaf. + var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/'; + try { + if (choice.isNew) { + // Register the party: its existence is ssr/.yaml. + await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml', + 'kind: SSR\n', 'application/yaml; charset=utf-8'); + } + if (kind === 'folder') { + await up.makeDir(targetDir, choice.name); + statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/'); + } else { + var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md'; + var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; + await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8'); + statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/'); + } + } catch (e) { + var msg = (e && e.message) || String(e); + if (/\b403\b/.test(msg)) { + statusError('Not allowed — registering a new party requires the document-controller role.'); + } else if (/\b409\b/.test(msg)) { + statusError('Unknown party — register it first (document controller).'); + } else { + statusError('Create failed: ' + msg); + } + return; + } + await reloadDir('/' + agg.project + '/' + agg.slot + '/'); + } + + async function createInDir(parentDir, kind) { + var up = window.app.modules.upload; + if (!up) return; + // At a party-peer root (incoming/working/staging/reviewing) the + // create needs a party — route through the picker. Deeper paths + // (a party already chosen, e.g. working//…) are physical and + // created directly. + var agg = aggregatorRoot(parentDir); + if (agg) return createInAggregator(agg, kind); + var promptMsg = kind === 'folder' + ? 'New folder name (under ' + parentDir + '):' + : 'New markdown filename (under ' + parentDir + '):'; + var defaultName = kind === 'folder' ? 'new-folder' : 'new.md'; + var raw = window.prompt(promptMsg, defaultName); + if (raw == null) return; + var v = validateName(raw); + if (!v.ok) { + statusError(v.msg); + return; + } + try { + if (kind === 'folder') { + await up.makeDir(parentDir, v.name); + statusInfo('Created folder ' + v.name); + } else { + var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md'; + var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; + await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8'); + statusInfo('Created ' + name); + } + await reloadDir(parentDir); + } catch (e) { + statusError('Create failed: ' + (e.message || e)); + } + } + + + // Reload a directory's children in the tree so a create/delete/ + // rename is reflected. Works for both the current scope (root) + // and any expanded subdirectory. + async function reloadDir(dirPath) { + var loader = window.app.modules.loader; + if (!loader) return; + if (!dirPath.endsWith('/')) dirPath += '/'; + var seq = beginNav(); + // Root-scope reload — refresh the visible top-level listing. + if (dirPath === state.currentPath) { + var es; + try { + es = state.source === 'server' + ? await loader.fetchServerChildren(dirPath) + : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); + } catch (e) { + statusError('Reload failed: ' + (e.message || e)); + return; + } + if (!isCurrentNav(seq)) return; + tree.setRoot(es); + tree.render(); + return; + } + // Otherwise find the node whose path matches and reload it. + var noSlash = dirPath.replace(/\/$/, ''); + var hit = null; + state.nodes.forEach(function (n) { + if (hit || !n.isDir) return; + if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; + }); + if (hit) { + var raw; + try { + raw = state.source === 'server' + ? await loader.fetchServerChildren(dirPath) + : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []); + } catch (e) { + statusError('Reload failed: ' + (e.message || e)); + return; + } + if (!isCurrentNav(seq)) return; + tree.setChildren(hit.id, raw); + hit.expanded = true; + tree.render(); + } + } + + // ── Rename / Delete ─────────────────────────────────────────────────── + + async function renameNode(node) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return; + var raw = window.prompt('Rename "' + node.name + '" to:', node.name); + if (raw == null) return; + var v = validateName(raw); + if (!v.ok) { statusError(v.msg); return; } + if (v.name === node.name) return; + try { + await up.renameNode(node, v.name); + statusInfo('Renamed to ' + v.name); + var parentPath = node.parentId != null + ? tree.pathFor(state.nodes.get(node.parentId)) + : (state.currentPath || '/'); + await reloadDir(parentPath); + } catch (e) { + statusError('Rename failed: ' + (e.message || e)); + } + } + + async function deleteNode(node) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return; + var what = node.isDir ? 'folder' : 'file'; + // Native confirm() is intentional — destructive actions + // benefit from the browser's blocking, OS-styled dialog + // (signals "this is serious"). A custom modal would look + // friendlier; we want it to NOT look friendly. + var msg = 'Permanently delete this ' + what + '?\n\n' + node.name; + if (node.isDir) { + msg += '\n\nThis will remove every file inside it.'; + } + if (!window.confirm(msg)) return; + try { + await up.removeNode(node); + statusInfo('Deleted ' + node.name); + // Clear selection / preview when they pointed at the + // now-gone node, so the right pane doesn't keep a ghost. + if (state.selectedId === node.id) { + state.selectedId = null; + syncURLToSelection(); + } + if (state.lastPreviewedNodeId === node.id) { + state.lastPreviewedNodeId = null; + var pb = document.getElementById('previewBody'); + if (pb) pb.innerHTML = + '
Click a file in the tree to preview it.
'; + var pt = document.getElementById('previewTitle'); + if (pt) pt.textContent = 'No file selected'; + var pm = document.getElementById('previewMeta'); + if (pm) pm.textContent = ''; + } + var parentPath = node.parentId != null + ? tree.pathFor(state.nodes.get(node.parentId)) + : (state.currentPath || '/'); + await reloadDir(parentPath); + } catch (e) { + statusError('Delete failed: ' + (e.message || e)); + } + } + + // canCreateHere — whether New folder/file has a writable target: the + // server (ACL decides the rest) or a picked local folder (the + // filesystem permission decides, escalated on first write). + function canCreateHere() { + return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); + } + + // ── Menu opening (row / pane / kebab / keyboard) ────────────────────── + // The menu CONTENTS come from the declarative menu-model; this layer just + // resolves the target, syncs selection, and positions the menu. All four + // entry points (right-click row, right-click pane, kebab button, keyboard + // menu key) funnel through here so they stay identical. + + // The prefetched /.profile/access view for the current scope (set on every + // listing load — see prefetchScopeAccess). Returned synchronously; the + // menu never triggers a fetch at open time. null until prefetched / FS mode. + function prefetchedAccess() { return state.scopeAccess; } + + function menuModel() { return window.app.modules.menuModel; } + + function openRowMenuFor(row, x, y) { + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + // Select the row first so the highlight + menu target agree. + state.selectedId = id; + tree.render(); + syncURLToSelection(); + var mm = menuModel(); + if (!mm) return; + window.zddc.menu.open({ + x: x, y: y, + context: { node: node, row: row, surface: 'row' }, + items: function () { return mm.buildRowItems(node, row, prefetchedAccess()); } + }); + } + + function openPaneMenu(x, y) { + var mm = menuModel(); + if (!mm) return; + window.zddc.menu.open({ + x: x, y: y, + context: { dir: state.currentPath || '/', surface: 'pane' }, + items: function () { return mm.buildPaneItems(prefetchedAccess()); } + }); + } + + // Prefetch (memoised) the scope access view so the menu's create-gate and + // admin/sub-admin tier items resolve without a fetch. Server-mode only; + // cap.at returns null on file:// so FS mode leaves scopeAccess null. + function prefetchScopeAccess() { + if (state.source !== 'server' || !window.zddc || !window.zddc.cap || !window.zddc.cap.at) { + state.scopeAccess = null; + return; + } + var path = state.currentPath || '/'; + window.zddc.cap.at(path).then(function (view) { + // Ignore a stale resolution if the scope moved on. + if ((state.currentPath || '/') === path) { + state.scopeAccess = view || null; + applySourceUI(); + } + }, function () { /* best-effort; leave prior value */ }); + } + + + // View mode is URL-driven, not UI-driven. + // + // ?view=grid → embedded-tool view (only honored where the cascade's + // default_tool is an embeddable full-page tool — + // classifier/transmittal/archive; else falls back to browse) + // ?view=browse → browse listing (always) + // default → embedded-tool view when the dir's default_tool is one + // of those tools, browse listing everywhere else + // + // resolveViewMode reads the current location and returns the mode + // to render; applyResolvedViewMode toggles the panes accordingly. + // Called on initial load and on every client-side rescope. + function resolveViewMode() { + var qs = new URLSearchParams(window.location.search); + var explicit = (qs.get('view') || '').toLowerCase(); + var grid = window.app.modules.grid; + var toolHere = !!(grid && grid.availableHere && grid.availableHere()); + if (explicit === 'grid') return toolHere ? 'grid' : 'browse'; + if (explicit === 'browse') return 'browse'; + return toolHere ? 'grid' : 'browse'; + } + + function applyResolvedViewMode() { + var mode = resolveViewMode(); + state.viewMode = mode; + var browseView = document.getElementById('browseView'); + var gridView = document.getElementById('gridView'); + if (mode === 'grid') { + if (browseView) browseView.classList.add('hidden'); + if (gridView) gridView.classList.remove('hidden'); + var grid = window.app.modules.grid; + if (grid) { + if (grid.reset) grid.reset(); + if (grid.activate) grid.activate(); + } + } else { + if (browseView) browseView.classList.remove('hidden'); + if (gridView) gridView.classList.add('hidden'); + } + } + + async function navigateIntoFolder(node) { + if (state.source === 'server') { + // Rescope client-side rather than hard-navigating. A hard + // nav would let zddc-server's auto-serve kick in and swap + // us out of browse for canonical folders (e.g. /archive/ + // → archive tool, /staging/ → transmittal). Staying in + // browse is what the user asked for; pushState keeps the + // URL bar accurate so a reload would re-load browse at the + // new scope. + var url = window.app.modules.tree.pathFor(node); + if (!url.endsWith('/')) url += '/'; + await rescopeServer(url, node.name); + return; + } + if (state.source === 'fs') { + if (!node.handle || node.handle.kind !== 'directory') return; + var seq = beginNav(); + var raw; + try { + raw = await loader.fetchFsChildren(node.handle); + } catch (e) { + statusError('Failed to enter ' + node.name + ': ' + e.message); + return; + } + // Mutate scope state only after the fetch succeeds and only if + // we're still the latest navigation — a bail here leaves the + // previous scope intact rather than half-swapped. + if (!isCurrentNav(seq)) return; + state.rootHandle = node.handle; + state.currentPath = node.handle.name + '/'; + tree.setRoot(raw); + tree.render(); + statusInfo('Entered ' + node.name); + } + } + + // Client-side rescope for server mode. Updates the URL via + // history.pushState, fetches the new directory listing, and + // re-renders the tree from scratch. Page DOES NOT reload. + async function rescopeServer(url, displayName) { + var seq = beginNav(); + var entries; + try { + entries = await loader.fetchServerChildren(url); + } catch (e) { + statusError('Failed to enter ' + displayName + ': ' + (e.message || e)); + return; + } + // A newer navigation (another dblclick, a refresh, back/forward) + // started while this listing was in flight — drop this result so we + // don't pushState/setRoot on top of it. + if (!isCurrentNav(seq)) return; + state.currentPath = url; + prefetchScopeAccess(); + // Selection / preview belong to the old scope; clear them so + // the new root doesn't carry stale highlight state. + state.selectedId = null; + state.lastPreviewedNodeId = null; + // Virtual canonical folders are emitted by zddc-server itself + // (so .zddc display: overrides apply uniformly); no client-side + // merge needed. + tree.setRoot(entries); + tree.render(); + // Reset the preview pane so the user sees an "empty selection" + // state at the new scope instead of the previous file. Route + // through clearPreview so a live editor is disposed (not leaked). + var pmod = previewMod(); + if (pmod && pmod.clearPreview) pmod.clearPreview(); + else { + var previewBody = document.getElementById('previewBody'); + if (previewBody) previewBody.innerHTML = ''; + } + var previewTitle = document.getElementById('previewTitle'); + if (previewTitle) previewTitle.textContent = 'No file selected'; + var previewMeta = document.getElementById('previewMeta'); + if (previewMeta) previewMeta.textContent = ''; + // pushState so the URL bar reflects the new scope. A real + // reload would re-load browse at this URL (trailing slash → + // ServeDirectory → embedded browse SPA). Then immediately + // replaceState via syncURLToSelection so the new URL also + // carries ?hidden=1 if the toggle is on (selection is null + // at the new scope; the query gets only `hidden`). + try { + history.pushState({ zddcBrowse: true, path: url }, '', url); + } catch (_e) { /* private browsing edge cases */ } + syncURLToSelection(); + statusInfo('Entered ' + displayName); + // The new scope may have a different default view (grid inside + // incoming/, browse elsewhere). Re-resolve from the URL now + // that pushState has updated it. + applyResolvedViewMode(); + } + + // Public API + window.app.modules.events = { + init: init, + statusError: statusError, + statusInfo: statusInfo, + statusClear: statusClear, + showBrowseRoot: showBrowseRoot, + applyResolvedViewMode: applyResolvedViewMode, + // Re-fetch + re-render the current listing (restoring expansion + + // selection). Workflow modules call this after a move/accept so the + // tree reflects the change without a manual reload. upload.js already + // depends on it being present. + refreshListing: refreshListing, + // Shared navigation-sequence token so the popstate handler (app.js) + // can't race the in-tool navigations. beginNav() claims the latest + // token; isCurrentNav(seq) reports whether it's still latest. + beginNav: beginNav, + isCurrentNav: isCurrentNav, + // Prefetch the current scope's /.profile/access view into + // state.scopeAccess (memoised) so the menu's create-gate + admin-tier + // items resolve without a fetch. Called by app.js on initial load + + // back/forward. + prefetchScopeAccess: prefetchScopeAccess + }; +})(); diff --git a/browse/js/grid.js b/browse/js/grid.js new file mode 100644 index 0000000..7b720d7 --- /dev/null +++ b/browse/js/grid.js @@ -0,0 +1,77 @@ +// grid.js — in-pane tool embed for browse (the browse-as-shell bridge; see +// ARCHITECTURE.md's ADR). Loads a heavy, full-page tool as an iframe scoped to +// the current directory so the user gets that tool's full workflow without +// leaving the browse shell. browse stays the top-level app; the cascade's +// default_tool decides which tool embeds here. +// +// Availability: the cascade decides — `state.scopeDefaultTool` (the +// X-ZDDC-Default-Tool header) must name one of the EMBEDDABLE full-page tools: +// classifier (archive//incoming/), transmittal (…/staging/), archive +// (the archive index). tables/forms embed in the preview pane instead +// (table-leaf / form view); landing/browse don't self-embed. Operators extend +// by setting default_tool on a dir — no code change. Iframe src: +// /.html. Server mode only (file:// has no server). +(function () { + 'use strict'; + + var state = window.app.state; + var mounted = false; + + // Full-page tools that embed in the gridView pane when they're the dir's + // default_tool. (tables/form embed in the preview pane; landing/browse are + // not in-pane embeds.) + var EMBEDDABLE = { classifier: 1, transmittal: 1, archive: 1 }; + + // The cascade-resolved default tool for the current dir when it's an + // embeddable full-page tool; "" otherwise. + function embedToolHere() { + var t = state.scopeDefaultTool; + return (t && EMBEDDABLE[t]) ? t : ''; + } + + function activate() { + var host = document.getElementById('gridView'); + if (!host) return; + if (mounted) return; + var tool = embedToolHere(); + if (state.source !== 'server' || !tool) return; + + // Compute the iframe src: current page's directory + .html. + var pathname = window.location.pathname || '/'; + if (!pathname.endsWith('/')) { + var lastSlash = pathname.lastIndexOf('/'); + pathname = lastSlash >= 0 ? pathname.substring(0, lastSlash + 1) : '/'; + } + var src = pathname + tool + '.html'; + + host.innerHTML = ''; + var frame = document.createElement('iframe'); + frame.src = src; + frame.title = 'ZDDC ' + tool; + frame.style.cssText = 'width:100%;height:100%;border:0;display:block;' + + 'background:var(--bg);'; + host.appendChild(frame); + mounted = true; + } + + // When the user navigates between scopes (client-side rescope on + // dblclick), the iframe needs to be reloaded for the new path. + // Callers reset before re-activating. + function reset() { + mounted = false; + var host = document.getElementById('gridView'); + if (host) host.innerHTML = ''; + } + + window.app.modules.grid = { + activate: activate, + reset: reset, + // Hook for events.js's view-mode resolution: is an embeddable tool the + // default here? + availableHere: function () { + return state.source === 'server' && !!embedToolHere(); + }, + // The embeddable tool name (or "") — lets the shell label the view. + toolHere: embedToolHere + }; +})(); diff --git a/browse/js/history.js b/browse/js/history.js new file mode 100644 index 0000000..d17bf72 --- /dev/null +++ b/browse/js/history.js @@ -0,0 +1,395 @@ +// history.js — markdown edit-history viewer for the browse tool. +// +// Surfaced by events.js as a "History…" right-click item on files in a +// history:true cascade subtree (working/). Server mode only — the audit +// trail (who saved when) is stamped server-side, so there's no offline +// equivalent. +// +// Talks to the zddc-server history endpoints on the file's own URL: +// GET ?history=1 → JSON [{ts, by, id, bytes, current}] +// GET ?history= → that version's raw bytes (id = snapshot filename) +// Restore re-PUTs a chosen version's bytes to , which the server +// records as a new version (forward-only; never destructive). +// +// Diffs are computed client-side via window.zddc.diff (shared/diff.js). + +(function () { + 'use strict'; + + var escapeHtml = window.app.modules.util.escapeHtml; + + function toast(msg, kind) { + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, kind || 'info'); + } + } + + // Append ?history= (or &history=) to a file URL. + function histURL(baseURL, v) { + var sep = baseURL.indexOf('?') === -1 ? '?' : '&'; + return baseURL + sep + 'history=' + encodeURIComponent(v); + } + + function fmtTime(ts) { + var d = new Date(ts); + if (isNaN(d.getTime())) return ts || ''; + return d.toLocaleString(); + } + + var fmtBytes = window.app.modules.util.fmtSize; + + // Can the principal write (restore) to this file? Mirrors the + // events.js Rename/Delete gating: verbs===undefined means a non-zddc + // backend (no cascade signal) → allow the attempt; otherwise check w. + function canRestore(node) { + if (!node || !node.url) return false; + if (!window.zddc || !window.zddc.cap) return true; + if (typeof node.verbs !== 'string') return true; + return window.zddc.cap.has(node, 'w'); + } + + async function fetchList(node) { + var resp = await fetch(histURL(node.url, '1'), { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var data = await resp.json(); + return Array.isArray(data) ? data : []; + } + + async function fetchVersion(node, id) { + var resp = await fetch(histURL(node.url, id), { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.text(); + } + + // ── Modal shell ────────────────────────────────────────────────────── + // One overlay; its body is swapped between the list, a diff, and a + // single-version view. Returns { overlay, body, setTitle, close }. + function makeModal(titleText) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay md-history-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.className = 'md-history-box'; + + var title = document.createElement('h2'); + title.className = 'md-history-title'; + title.textContent = titleText; + + var body = document.createElement('div'); + body.className = 'md-history-body'; + + box.appendChild(title); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) close(); + }); + + return { + overlay: overlay, + body: body, + setTitle: function (t) { title.textContent = t; }, + close: close + }; + } + + function footerBar() { + var f = document.createElement('div'); + f.className = 'md-history-footer'; + return f; + } + + function button(label, opts) { + opts = opts || {}; + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + if (opts.primary) b.className = 'btn-primary'; + if (opts.disabled) b.disabled = true; + if (opts.onClick) b.addEventListener('click', opts.onClick); + return b; + } + + // ── List view ────────────────────────────────────────────────────── + function renderList(modal, node, entries) { + modal.setTitle('History — ' + node.name); + var body = modal.body; + body.innerHTML = ''; + + if (!entries.length) { + var empty = document.createElement('p'); + empty.className = 'md-history-empty'; + empty.textContent = 'No saved versions yet. Each save of this file is recorded here.'; + body.appendChild(empty); + var f0 = footerBar(); + f0.appendChild(button('Close', { onClick: modal.close })); + body.appendChild(f0); + return; + } + + var hint = document.createElement('p'); + hint.className = 'md-history-hint'; + hint.textContent = 'Newest first. Select two versions to diff.'; + body.appendChild(hint); + + var list = document.createElement('div'); + list.className = 'md-history-list'; + var selected = []; // shas, in click order (max 2) + + var diffBtn; + function syncDiffBtn() { + if (diffBtn) diffBtn.disabled = selected.length !== 2; + } + + entries.forEach(function (ent) { + var row = document.createElement('div'); + row.className = 'md-history-row' + (ent.current ? ' is-current' : ''); + + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'md-history-pick'; + cb.addEventListener('change', function () { + if (cb.checked) { + selected.push(ent.id); + // Keep at most two: drop the oldest selection. + if (selected.length > 2) { + var dropped = selected.shift(); + var others = list.querySelectorAll('.md-history-pick'); + others.forEach(function (o, i) { + if (o !== cb && entries[i] && entries[i].id === dropped) o.checked = false; + }); + } + } else { + selected = selected.filter(function (s) { return s !== ent.id; }); + } + syncDiffBtn(); + }); + + var meta = document.createElement('div'); + meta.className = 'md-history-meta'; + meta.innerHTML = + '' + escapeHtml(fmtTime(ent.ts)) + '' + + '' + escapeHtml(ent.by || '—') + '' + + '' + escapeHtml(fmtBytes(ent.bytes)) + '' + + (ent.current ? 'current' : ''); + + var actions = document.createElement('div'); + actions.className = 'md-history-actions'; + actions.appendChild(button('View', { + onClick: function () { renderView(modal, node, ent, entries); } + })); + if (!ent.current && canRestore(node)) { + actions.appendChild(button('Restore', { + onClick: function () { restore(modal, node, ent); } + })); + } + + row.appendChild(cb); + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + }); + + body.appendChild(list); + + var f = footerBar(); + diffBtn = button('Diff selected', { + primary: true, disabled: true, + onClick: function () { + if (selected.length !== 2) return; + // Order oldest→newest by the entries' position (newest + // first in the list), so the diff reads old → new. + var picks = entries.filter(function (e) { return selected.indexOf(e.id) !== -1; }); + picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); }); + renderDiff(modal, node, picks[0], picks[1], entries); + } + }); + f.appendChild(diffBtn); + f.appendChild(button('Close', { onClick: modal.close })); + body.appendChild(f); + } + + // ── Single-version view ────────────────────────────────────────────── + async function renderView(modal, node, ent, entries) { + modal.setTitle('Version — ' + fmtTime(ent.ts)); + var body = modal.body; + body.innerHTML = '

Loading…

'; + var text; + try { + text = await fetchVersion(node, ent.id); + } catch (e) { + body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load this version: ' + (e.message || e); + body.appendChild(err); + return; + } + body.innerHTML = ''; + var meta = document.createElement('p'); + meta.className = 'md-history-hint'; + meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts); + body.appendChild(meta); + + var pre = document.createElement('pre'); + pre.className = 'md-history-pre'; + pre.textContent = text; + body.appendChild(pre); + + var f = footerBar(); + f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } })); + if (!ent.current && canRestore(node)) { + f.appendChild(button('Restore this version', { + primary: true, onClick: function () { restore(modal, node, ent); } + })); + } + body.appendChild(f); + } + + // ── Diff view ───────────────────────────────────────────────────────── + async function renderDiff(modal, node, oldEnt, newEnt, entries) { + modal.setTitle('Diff'); + var body = modal.body; + body.innerHTML = '

Loading…

'; + var oldText, newText; + try { + oldText = await fetchVersion(node, oldEnt.id); + newText = await fetchVersion(node, newEnt.id); + } catch (e) { + body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load versions: ' + (e.message || e); + body.appendChild(err); + return; + } + body.innerHTML = ''; + + var hdr = document.createElement('p'); + hdr.className = 'md-history-hint'; + hdr.innerHTML = + '' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '' + + ' → ' + + '' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + ''; + body.appendChild(hdr); + + var ops = (window.zddc && window.zddc.diff) + ? window.zddc.diff.lines(oldText, newText) + : null; + + var pane = document.createElement('div'); + pane.className = 'md-diff'; + if (!ops) { + pane.textContent = 'Diff unavailable (diff module not loaded).'; + } else { + var unchanged = true; + ops.forEach(function (op) { + if (op.type !== 'eq') unchanged = false; + var line = document.createElement('div'); + line.className = 'md-diff-line md-diff-' + op.type; + var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' '); + var g = document.createElement('span'); + g.className = 'md-diff-gutter'; + g.textContent = gutter; + var t = document.createElement('span'); + t.className = 'md-diff-text'; + t.textContent = op.text; + line.appendChild(g); + line.appendChild(t); + pane.appendChild(line); + }); + if (unchanged) { + var same = document.createElement('div'); + same.className = 'md-diff-line md-diff-eq'; + same.textContent = '(no differences)'; + pane.appendChild(same); + } + } + body.appendChild(pane); + + if (window.zddc && window.zddc.diff && ops) { + var s = window.zddc.diff.stats(ops); + var statline = document.createElement('p'); + statline.className = 'md-history-hint'; + statline.textContent = '+' + s.added + ' / −' + s.removed; + body.appendChild(statline); + } + + var f = footerBar(); + f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } })); + body.appendChild(f); + } + + // ── Restore ─────────────────────────────────────────────────────────── + async function restore(modal, node, ent) { + if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) { + return; + } + // The restore itself (the PUT) is the operation that can "fail". + // Keep it in its own try so a later error while refreshing the UI + // can't surface a misleading "Restore failed" after the restore has + // already been persisted. + try { + var text = await fetchVersion(node, ent.id); + var resp = await fetch(node.url, { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'text/markdown' }, + body: text + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + } catch (e) { + toast('Restore failed: ' + (e.message || e), 'error'); + return; + } + toast('Restored version from ' + fmtTime(ent.ts), 'success'); + // Best-effort UI refresh — the restore already succeeded, so a + // failure here is logged but never reported as a restore failure. + try { + var entries = await fetchList(node); + renderList(modal, node, entries); + // If the file is open in the preview pane, reload it. + var preview = window.app && window.app.modules && window.app.modules.preview; + if (preview && typeof preview.showFilePreview === 'function') { + preview.showFilePreview(node); + } + } catch (_e) { /* refresh is best-effort; restore is done */ } + } + + // ── Entry point ───────────────────────────────────────────────────── + async function open(node) { + if (!node || !node.url) { + toast('History is only available in server mode.', 'error'); + return; + } + var modal = makeModal('History — ' + node.name); + modal.body.innerHTML = '

Loading…

'; + try { + var entries = await fetchList(node); + renderList(modal, node, entries); + } catch (e) { + modal.body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load history: ' + (e.message || e); + modal.body.appendChild(err); + var f = footerBar(); + f.appendChild(button('Close', { onClick: modal.close })); + modal.body.appendChild(f); + } + } + + window.app.modules.history = { open: open }; +})(); diff --git a/browse/js/hovercard.js b/browse/js/hovercard.js new file mode 100644 index 0000000..b1c6220 --- /dev/null +++ b/browse/js/hovercard.js @@ -0,0 +1,333 @@ +// hovercard.js — rich-metadata tooltip for tree rows. +// +// Replaces the native title="…" attribute with a custom card that +// surfaces every field we know about for a row: parsed ZDDC fields +// (trackingNumber / revision / status / title / date), type, size, +// modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps +// the card out of the way during fast traversal; it dismisses on +// any click, right-click, scroll, or row change. +// +// Singleton DOM element appended to ; positioned fixed. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + var SHOW_DELAY_MS = 350; + // Grace period after the cursor leaves the row before the card + // hides. Lets the user move INTO the card to select / copy text; + // the card cancels this timer on mouseenter. + var HIDE_DELAY_MS = 200; + + var state = window.app.state; + var card = null; + var showTimer = null; + var hideTimer = null; + var currentRow = null; + + function ensureCard() { + if (card) return card; + card = document.createElement('div'); + card.className = 'tree-hovercard'; + card.setAttribute('aria-hidden', 'true'); + // Mouse interaction inside the card: cancel any pending hide + // so the user can stay in it as long as they want, then re- + // schedule on leave. Pointer-events:auto in the CSS lets the + // mouse enter; user-select:text (default) lets them drag a + // selection; right-click inside fires the browser's native + // Copy menu since we never call preventDefault for it here. + card.addEventListener('mouseenter', cancelHide); + card.addEventListener('mouseleave', scheduleHide); + document.body.appendChild(card); + return card; + } + + function cancelHide() { + if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } + } + + function scheduleHide() { + cancelHide(); + hideTimer = setTimeout(hide, HIDE_DELAY_MS); + } + + function hide() { + if (showTimer) { clearTimeout(showTimer); showTimer = null; } + cancelHide(); + if (card) card.classList.remove('is-visible'); + currentRow = null; + } + + // ── Formatting ── + + var escapeHtml = window.app.modules.util.escapeHtml; + var fmtSize = window.app.modules.util.fmtSize; + + function fmtDate(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 typeLabelFor(node) { + if (node.isDir) return 'Folder'; + if (node.isZip) return 'Zip archive'; + if (node.ext) return node.ext.toUpperCase() + ' file'; + return 'File'; + } + + var VERB_NAMES = { r: 'read', w: 'write', c: 'create', d: 'delete', a: 'admin' }; + function verbsLabel(verbs) { + return ['r', 'w', 'c', 'd', 'a'] + .filter(function (v) { return verbs.indexOf(v) !== -1; }) + .map(function (v) { return VERB_NAMES[v]; }) + .join(', '); + } + // permsValue renders the per-entry verb set the principal holds here. + // Server mode: node.verbs ("rwcda" subset). Offline (FS-API) mode has + // no ACL — access is whatever the filesystem grants. + function permsValue(verbs) { + if (typeof verbs !== 'string') { + return state.source === 'fs' ? 'local folder (filesystem)' : 'unknown'; + } + if (!verbs) return 'none (read-only)'; + return verbsLabel(verbs) + ' (' + verbs + ')'; + } + + function buildRowsHtml(node) { + var tree = window.app.modules.tree; + var z = window.zddc; + var parsed = null; + if (z) { + parsed = node.isDir + ? z.parseFolder(node.name) + : z.parseFilename(node.name); + } + + var html = ''; + + // ZDDC fields first when the basename parses. + if (parsed && parsed.valid) { + if (parsed.date) html += kv('Date', parsed.date, true); + if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true); + if (parsed.revision) html += kv('Revision', parsed.revision, true); + if (parsed.status) html += kv('Status', parsed.status, true); + if (parsed.title) html += kv('Title', parsed.title); + + // Archive references — the //.archive/.html + // URL is the latest issued version (highest base rev), and + // //.archive/_.html pins the exact + // revision the user is currently hovering. The dispatcher + // canonicalises both forms to project-root so links work + // from any depth. + if (parsed.trackingNumber) { + var fullPath = tree ? tree.pathFor(node) : ''; + var rel = fullPath.replace(/^\/+|\/+$/g, ''); + var firstSeg = rel ? rel.split('/')[0] : ''; + if (firstSeg) { + var encProject = encodeURIComponent(firstSeg); + var encTracking = encodeURIComponent(parsed.trackingNumber); + var latestUrl = '/' + encProject + '/.archive/' + encTracking + '.html'; + var latestLbl = '.archive/' + parsed.trackingNumber + '.html'; + html += kvLink('Latest', latestUrl, latestLbl); + if (!node.isDir && parsed.revision) { + var encRev = encodeURIComponent(parsed.revision); + var inspectUrl = '/' + encProject + '/.archive/' + encTracking + '_' + encRev + '.html'; + var inspectLbl = '.archive/' + parsed.trackingNumber + '_' + parsed.revision + '.html'; + html += kvLink('This revision', inspectUrl, inspectLbl); + } + } + } + + html += '
'; + } else if (node.displayName) { + // Operator-supplied display name — only useful as info if + // it differs from the on-disk name. + html += kv('Display name', node.displayName); + } + + html += kv('Type', typeLabelFor(node)); + if (!node.isDir) html += kv('Filename', node.name, true); + if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size)); + if (node.modTime) html += kv('Modified', fmtDate(node.modTime)); + if (node.virtual) html += kv('Virtual', 'Not yet created on disk'); + + // ── Effective access for the current principal at this location ── + // "Your permissions" is the per-entry verb set (sync, from the + // listing). "Your roles" is cascade-scoped — it can differ by + // location — so it needs a path-scoped fetch; render a placeholder + // that fillRoles() updates once /.profile/access?path= resolves. + html += '
'; + html += kv('Your permissions', permsValue(node.verbs)); + if (state.source === 'server') { + html += 'Your roles' + + ''; + } + + // URL last (longest, most likely to wrap) — rendered as a clickable + // link the user can open or right-click to copy. The on-disk path is + // intentionally omitted; the URL is the shareable reference. + if (node.url) html += kvLink('URL', node.url, node.url); + + return html; + } + + function kv(key, val, mono) { + return '' + escapeHtml(key) + '' + + '' + escapeHtml(val) + ''; + } + + // kvLink — value rendered as an
the user can click (opens in + // a new tab so the hover context isn't lost) or right-click to + // copy. Used for the .archive references on ZDDC files. + function kvLink(key, href, label) { + return '' + escapeHtml(key) + '' + + '' + + '' + + escapeHtml(label) + + '' + + ''; + } + + function render(node) { + var z = window.zddc; + var parsed = z + ? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name)) + : null; + + var primary, secondary = ''; + if (parsed && parsed.valid) { + primary = parsed.title; + var parts = node.isDir + ? [parsed.date, parsed.trackingNumber, parsed.status] + : [parsed.trackingNumber, parsed.revision, parsed.status]; + secondary = parts.filter(Boolean).join(' · '); + } else if (node.displayName) { + primary = node.displayName; + } else { + primary = node.name; + } + + card.innerHTML = '' + + '
' + + '
' + escapeHtml(primary) + '
' + + (secondary + ? '
' + escapeHtml(secondary) + '
' + : '') + + '
' + + '
' + buildRowsHtml(node) + '
'; + } + + function position(row) { + // Two-pass measure: temporarily make visible-but-invisible so + // we can read offsetWidth / offsetHeight, compute placement, + // then reveal at the final coordinates. + card.style.left = '0px'; + card.style.top = '0px'; + card.style.visibility = 'hidden'; + card.classList.add('is-visible'); + var cw = card.offsetWidth; + var ch = card.offsetHeight; + var rect = row.getBoundingClientRect(); + var GAP = 8; + var x = rect.right + GAP; + if (x + cw > window.innerWidth - GAP) { + x = rect.left - cw - GAP; + } + if (x < GAP) { + // Fallback: anchor under the row (last resort when the + // pane is wide enough that neither side fits). + x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP)); + } + var y = rect.top; + if (y + ch > window.innerHeight - GAP) { + y = Math.max(GAP, window.innerHeight - ch - GAP); + } + if (y < GAP) y = GAP; + card.style.left = x + 'px'; + card.style.top = y + 'px'; + card.style.visibility = ''; + } + + function showFor(row, node) { + ensureCard(); + render(node); + position(row); + card.classList.add('is-visible'); + fillRoles(row, node); + } + + // Async-fill the "Your roles" row from the path-scoped access view + // (zddc.cap.at memoises per path, so repeat hovers are instant). + // Bails if the card has moved to another row before the fetch lands. + async function fillRoles(row, node) { + if (state.source !== 'server') return; + if (!window.zddc || !window.zddc.cap) return; + var tree = window.app.modules.tree; + var path = tree ? tree.pathFor(node) : ''; + if (!path) return; + var view; + try { view = await window.zddc.cap.at(path); } catch (_e) { return; } + if (currentRow !== row) return; + var el = card && card.querySelector('#hc-roles'); + if (!el) return; + var roles = (view && Array.isArray(view.path_roles)) ? view.path_roles : []; + el.textContent = roles.length ? roles.join(', ') : 'none'; + } + + function init() { + var treeBody = document.getElementById('treeBody'); + if (!treeBody) return; + + treeBody.addEventListener('mouseover', function (e) { + // Returning to the tree from the card cancels any pending + // hide; the show logic below handles row changes. + cancelHide(); + var row = e.target.closest('.tree-row'); + if (row === currentRow) return; + // Row → row or row → empty space — reset. + if (showTimer) { clearTimeout(showTimer); showTimer = null; } + if (card) card.classList.remove('is-visible'); + currentRow = row || null; + if (!row) return; + showTimer = setTimeout(function () { + if (currentRow !== row) return; + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (node) showFor(row, node); + }, SHOW_DELAY_MS); + }); + + // Leaving the tree schedules a hide rather than hiding + // immediately, so the cursor has time to traverse the gap to + // the card. The card's own mouseenter cancels the hide. + treeBody.addEventListener('mouseleave', scheduleHide); + treeBody.addEventListener('contextmenu', hide); + window.addEventListener('scroll', hide, true); + window.addEventListener('resize', hide); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') hide(); + }); + + // Click anywhere outside the card dismisses it. Clicks INSIDE + // the card are allowed through so the user can drag-select + // text, right-click for the browser's native Copy menu, or + // hit Ctrl/Cmd-C. + document.addEventListener('mousedown', function (e) { + if (!card || !card.classList.contains('is-visible')) return; + if (card.contains(e.target)) return; + hide(); + }, true); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + window.app.modules.hovercard = { hide: hide }; +})(); diff --git a/browse/js/init.js b/browse/js/init.js new file mode 100644 index 0000000..55f39d4 --- /dev/null +++ b/browse/js/init.js @@ -0,0 +1,101 @@ +// Bootstrap window.app for the browse tool. Mirrors the convention +// used by every other ZDDC tool — ./build's CSS/JS concat order means +// this file runs FIRST inside the IIFE-of-IIFEs. +(function () { + 'use strict'; + + if (!window.app) { + window.app = { modules: {}, state: {} }; + } + + // Mount the shared Lucide outline-icon sprite into before + // the tree first renders. The sprite is hidden (display:none on + // the outer ) — it only exists so per-row + // refs resolve. Falls back to deferring until DOMContentLoaded + // when isn't ready yet. + if (window.zddc && window.zddc.icons) { + window.zddc.icons.inject(); + } + + window.app.state = { + // Source: 'server' | 'fs' | null. Determines how the loader + // resolves entries. + source: null, + + // For server-source: the URL path of the directory currently + // being viewed. Always starts with '/' and ends with '/'. + // For fs-source: the displayed path string (no semantic + // meaning — just for the toolbar). + currentPath: '/', + + // FileSystemAccessAPI root handle (null in server mode). + rootHandle: null, + + // Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1. + sort: { key: 'name', dir: 1 }, + + // Currently-selected tree node id (for highlight + pop-out). + selectedId: null, + lastPreviewedNodeId: null, + + // View mode: 'browse' (tree + preview, default) | 'grid' (classifier). + viewMode: 'browse', + + // The tree's in-memory representation. Each node: + // { id, name, isDir, size, modTime, ext, url, handle, depth, + // parentId, expanded, loaded, childIds, isZip, + // _zipDirHandle, virtual } + // - isZip: the node IS a .zip file; expanding it lists + // the zip's members (server "<…>.zip/" listing + // online, JSZip behind a ZipDirectoryHandle + // offline). Members are ordinary dir/file nodes. + // - _zipDirHandle: cached ZipDirectoryHandle for an opened zip + // (offline / nested-in-zip path only). + // - handle: a FileSystemFileHandle/DirectoryHandle (fs + // mode) — or, inside an opened zip, a + // ZipFileHandle/ZipDirectoryHandle. + // Stored flat in a Map keyed by id; render order derived + // from a depth-first walk. + nodes: new Map(), + rootIds: [], + nextId: 1, + + // Single shared popup window for file preview (across + // multiple file clicks). Same pattern as archive's preview. + previewWindow: null, + + // Cascade-resolved scope flags, refreshed on each listing + // fetch from response headers. + // scopeDropTarget: cascade's drop_target at currentPath + // scopeDefaultTool: cascade's default_tool at currentPath + // (empty when no default declared) + // scopeCanonicalFolder: cascade's canonical-folder slot + // ('incoming'|'received'|'working'|'staging'|…), + // drives scope-aware menu items + // scopeOnPlanReview: cascade above has an on_plan_review block + // All refreshed by loader.js from response headers on each fetch. + scopeDropTarget: false, + scopeDefaultTool: '', + scopeCanonicalFolder: '', + scopeOnPlanReview: false, + + // Prefetched /.profile/access view for the CURRENT scope + // (state.currentPath), via cap.at() — memoised. Supplies + // path_verbs / path_is_admin / path_roles to the menu model for + // pane-scope create gating and the admin/sub-admin tier items, so + // the menu never fetches at open time. null until prefetched / in + // FS-Access (offline) mode. + scopeAccess: null, + + // Whether the listing includes dotfiles. Toggled by the + // "Show hidden files" menu item; URL-persisted via ?hidden=1. + showHidden: false, + + // Autofilter — when non-empty, the tree hides files that + // don't match and folders whose subtree has no matches. + // Parsed once on input change so visibleIds() / rowHtml() + // can run filter.matches(text, ast) cheaply per node. + filterText: '', + filterAST: null + }; +})(); diff --git a/browse/js/loader.js b/browse/js/loader.js new file mode 100644 index 0000000..06f60bc --- /dev/null +++ b/browse/js/loader.js @@ -0,0 +1,234 @@ +// loader.js — fetches directory entries for either source mode. +// +// Server mode: GET with Accept: application/json. zddc-server +// (and Caddy's built-in browse, which we mirror) returns an array of +// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}. +// +// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No +// network involved; works on local folders the user picked. +(function () { + 'use strict'; + + var state = window.app.state; + + // Lowercased extension (no leading dot), '' for dotfiles / no-ext / + // trailing-dot names. Delegates to the shared parser so the rule + // stays in one place (CLAUDE.md: all extension handling goes through + // window.zddc). + function splitExt(name) { + return window.zddc.splitExtension(name).extension; + } + + // Build a raw entry from the server's FileInfo shape. + function fromServerEntry(e) { + // Server returns directory names with a trailing "/". Strip + // it for display; the is_dir flag is the canonical signal. + var name = e.is_dir ? e.name.replace(/\/$/, '') : e.name; + // displayName is the friendlier label set by the parent .zddc + // `display:` map (when present). The on-disk basename stays in + // .name so URL composition (pathFor) and the chevron's title + // attribute still reflect the real folder name. + var displayName = (typeof e.display_name === 'string' && e.display_name) + ? e.display_name + : ''; + return { + name: name, + displayName: displayName, + isDir: e.is_dir, + size: e.size || 0, + modTime: e.mod_time ? new Date(e.mod_time) : null, + ext: e.is_dir ? '' : splitExt(name), + url: e.url || null, + // Server-computed write authority — true if the policy + // decider would allow a PUT for the calling principal. + // Absent / false means "save will 403"; preview editors + // read this to mount in read-only mode. Superseded by + // verbs (below); kept in lockstep during the transition. + writable: !!e.writable, + // Server-computed verb set: canonical "rwcda" subset the + // calling principal holds at this entry's URL. Per-entry + // gating in the context menu (Rename/Delete) reads this + // through zddc.cap.has(node, 'w'|'d'). + // + // "rw…" — zddc-server emitted explicit grant. + // "" — zddc-server emitted explicit zero grant + // (rare; usually the entry would have been + // filtered before reaching the client). + // undefined — the server didn't emit a verbs field at + // all (Caddy or any non-zddc backend). + // cap.has and the events.js gates treat + // this as "verbs unknown" and skip the + // per-entry cascade gate; canMutate + + // whatever the server enforces on the + // actual PUT/DELETE still apply. + verbs: typeof e.verbs === 'string' ? e.verbs : undefined, + // Server-computed: true when this file lives in a history:true + // cascade subtree, so every save is versioned and + // GET ?history lists prior versions. Drives the "History…" + // context-menu affordance (server mode only — offline has no + // authenticated identity to attribute saves to). + history: !!e.history, + // Server-computed: cascade-resolved default tool for a DIRECTORY + // entry (e.g. "tables", "classifier"). Browse renders a dir whose + // defaultTool=="tables" (mdl/rsk/ssr) as a click-to-table leaf — + // the table opens in the preview pane instead of the dir expanding. + defaultTool: (typeof e.default_tool === 'string') ? e.default_tool : '', + // FS-API specific (null in server mode): + handle: null + }; + } + + // Build a raw entry from a FileSystemHandle. + async function fromHandle(handle) { + var name = handle.name; + var isDir = handle.kind === 'directory'; + var size = 0; + var modTime = null; + if (!isDir) { + try { + var f = await handle.getFile(); + size = f.size; + modTime = new Date(f.lastModified); + } catch (_e) { + // permission lost; leave size/modTime defaults + } + } + return { + name: name, + isDir: isDir, + size: size, + modTime: modTime, + ext: isDir ? '' : splitExt(name), + url: null, + handle: handle + }; + } + + // Fetch children of a directory in server mode. + // path must end with '/' so the request hits the directory route. + // + // 404 is treated as "empty directory" rather than a hard error. + // A directory that doesn't exist on the server (e.g. a fresh + // project's working/ before any drafts have been created, or a + // dir deleted between listing and expand) is functionally + // indistinguishable from an empty one for tree-rendering purposes. + // Server-side, zddc-server already returns 200 + [] for canonical + // project folders that are missing on disk; this fallback covers + // the same UX for anything else and for non-zddc-server backends. + async function fetchServerChildren(path) { + if (!path.endsWith('/')) path += '/'; + // ?hidden=1 surfaces .-prefixed and _-prefixed entries when the + // user has flipped the "Show hidden" toggle. The server still + // ACL-gates per-entry, so this is purely additive — anyone + // without read on the parent dir already sees nothing. + var url = path; + if (window.app && window.app.state && window.app.state.showHidden) { + url += (url.indexOf('?') === -1 ? '?' : '&') + 'hidden=1'; + } + var resp = await fetch(url, { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + // Capture cascade-resolved scope flags from response headers + // before bailing on 404. zddc-server emits X-ZDDC-Drop-Target + // for directories the cascade marks as upload destinations + // (see zddc/internal/zddc/lookups.go DropTargetAt). The flag + // is leaf-only — it describes THIS path, not its descendants + // — so a rescope or popstate re-reads it from the new listing. + var dropTargetHdr = (resp.headers.get('X-ZDDC-Drop-Target') || '').toLowerCase(); + window.app.state.scopeDropTarget = dropTargetHdr === 'true'; + // X-ZDDC-Default-Tool surfaces the cascade-resolved default + // tool name for the current path. Browse uses it to decide + // grid-mode auto-activation (when default_tool==classifier) + // without re-implementing the cascade client-side. + window.app.state.scopeDefaultTool = + (resp.headers.get('X-ZDDC-Default-Tool') || '').toLowerCase(); + // X-ZDDC-On-Plan-Review surfaces whether the cascade above + // this path has an on_plan_review block. Drives visibility of + // the "Plan Review" right-click menu item on received// + // folders. + window.app.state.scopeOnPlanReview = + (resp.headers.get('X-ZDDC-On-Plan-Review') || '').toLowerCase() === 'true'; + // X-ZDDC-Canonical-Folder names the canonical project-layout + // slot this directory occupies — "incoming", "received", + // "working", "staging", etc. Drives scope-aware menu items: + // Accept Transmittal (folders under incoming), Stage/Unstage + // (files under working/staging), Create Transmittal folder + // (right-click in staging). + window.app.state.scopeCanonicalFolder = + (resp.headers.get('X-ZDDC-Canonical-Folder') || '').toLowerCase(); + if (resp.status === 404) { + return []; + } + if (!resp.ok) { + throw new Error('HTTP ' + resp.status + ' fetching ' + path); + } + var data = await resp.json(); + if (!Array.isArray(data)) { + throw new Error('Unexpected response shape from ' + path); + } + return data.map(fromServerEntry); + } + + // Enumerate a FileSystemDirectoryHandle's immediate children. + async function fetchFsChildren(dirHandle) { + var entries = []; + for await (var [_name, handle] of dirHandle.entries()) { + entries.push(await fromHandle(handle)); + } + return entries; + } + + // Probe whether THIS page is being served by zddc-server (or any + // server that responds to JSON listing requests). If so, switch to + // server mode automatically and load the current directory. + async function autoDetectServerMode() { + // Only attempt when running over http(s) and the location's + // path looks like a directory. Probing on file:// is pointless. + if (location.protocol !== 'http:' && location.protocol !== 'https:') { + return false; + } + // Strip any /.html from the path to get the directory. + var path = location.pathname; + // If the URL points at the browse.html itself, the directory + // is the parent. If it's a directory ending in '/', use it. + var dirPath; + if (path.endsWith('/')) { + dirPath = path; + } else { + // e.g. '/some/dir/browse.html' → '/some/dir/' + var slash = path.lastIndexOf('/'); + dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/'; + } + + try { + var entries = await fetchServerChildren(dirPath); + state.source = 'server'; + state.currentPath = dirPath; + return { entries: entries, path: dirPath }; + } catch (_e) { + // Not a server-backed page (e.g. opened via file://). + return null; + } + } + + // JSZip is vendored into the bundle (shared/vendor/jszip.min.js + // is concatenated ahead of init.js by build.sh), so it's always + // already attached to window.JSZip by the time any tree code runs. + // We keep the helper because tree.js calls it before reaching for + // window.JSZip; if the bundle is ever rebuilt without the vendor + // copy this will throw a clear error rather than silently failing. + function ensureJSZip() { + if (window.JSZip) return Promise.resolve(); + return Promise.reject(new Error( + 'JSZip not bundled — rebuild browse with shared/vendor/jszip.min.js')); + } + + // Public API + window.app.modules.loader = { + fetchServerChildren: fetchServerChildren, + fetchFsChildren: fetchFsChildren, + autoDetectServerMode: autoDetectServerMode, + ensureJSZip: ensureJSZip + }; +})(); diff --git a/browse/js/manage-access.js b/browse/js/manage-access.js new file mode 100644 index 0000000..80d863c --- /dev/null +++ b/browse/js/manage-access.js @@ -0,0 +1,243 @@ +// manage-access.js — guided "who can do what here" dialog. A task-first +// front door for a folder's .zddc acl: the user picks people + friendly access +// levels; we read the on-disk .zddc, merge ONLY the access bits (preserving +// every other key), and PUT it. No YAML, no schema knowledge required. The raw +// editor stays as the "Advanced" escape hatch. +// +// Friendly level → verbs (r read, w overwrite, c create, d delete, a admin): +// View → r Contribute → rc +// Edit → rwc Manage → admins: membership (not a verb string) +// "Custom" preserves a hand-written verb string we don't recognise. +(function (app) { + 'use strict'; + if (!app || !app.modules) return; + var util = app.modules.util; + + var LEVELS = [ + { id: 'view', label: 'View', hint: 'read only', verbs: 'r' }, + { id: 'contribute', label: 'Contribute', hint: 'read + add new files', verbs: 'rc' }, + { id: 'edit', label: 'Edit', hint: 'read, overwrite, add', verbs: 'rwc' }, + { id: 'manage', label: 'Manage', hint: 'full config + (elevated) bypass', verbs: null } + ]; + function verbsOfLevel(id) { + for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i].verbs; + return null; + } + function levelOfVerbs(verbs) { + verbs = String(verbs || ''); + if (verbs.indexOf('a') !== -1) return 'manage'; + if (verbs.indexOf('w') !== -1) return 'edit'; + if (verbs.indexOf('c') !== -1) return 'contribute'; + if (verbs.indexOf('r') !== -1) return 'view'; + return 'custom'; // empty (explicit deny) or non-standard + } + + function dirUrl(dir) { + var u = dir || '/'; + if (u.charAt(0) !== '/') u = '/' + u; + if (u.charAt(u.length - 1) !== '/') u += '/'; + return u; + } + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + + async function open(dir) { + if (!app.state || app.state.source !== 'server') { + toast('Access management needs the server.', 'error'); + return; + } + var base = dirUrl(dir); + var zddcUrl = base + '.zddc'; + var data = {}, etag = null; + try { + var r = await fetch(zddcUrl, { credentials: 'same-origin' }); + if (r.ok) { + etag = r.headers.get('ETag'); + var txt = await r.text(); + try { data = (window.jsyaml && window.jsyaml.load(txt)) || {}; } catch (_e) { data = {}; } + } else if (r.status !== 404) { + throw new Error('HTTP ' + r.status); + } + } catch (e) { + toast('Could not read access rules: ' + (e.message || e), 'error'); + return; + } + if (!data || typeof data !== 'object' || Array.isArray(data)) data = {}; + + // Build the principal → level model from admins (Manage) + acl.permissions. + var acl = (data.acl && typeof data.acl === 'object') ? data.acl : {}; + var perms = (acl.permissions && typeof acl.permissions === 'object') ? acl.permissions : {}; + var admins = Array.isArray(data.admins) ? data.admins : []; + var rows = []; + var seen = {}; + admins.forEach(function (p) { + if (typeof p === 'string' && !seen[p]) { seen[p] = 1; rows.push({ principal: p, level: 'manage', custom: '' }); } + }); + Object.keys(perms).forEach(function (p) { + if (seen[p]) return; + seen[p] = 1; + var lvl = levelOfVerbs(perms[p]); + rows.push({ principal: p, level: lvl, custom: lvl === 'custom' ? String(perms[p] || '') : '' }); + }); + var inherit = acl.inherit !== false; + + renderModal(base, zddcUrl, data, etag, rows, inherit); + } + + function toast(msg, kind) { if (window.zddc && window.zddc.toast) window.zddc.toast(msg, kind || 'info'); } + + function renderModal(base, zddcUrl, data, etag, rows, inherit) { + var overlay = el('div', 'ma-overlay'); + var box = el('div', 'ma-box'); + overlay.appendChild(box); + + box.appendChild(el('h2', 'ma-title', 'Manage access')); + var sub = el('p', 'ma-sub', 'Who can do what in ' + base + ' — changes here only.'); + box.appendChild(sub); + + var list = el('div', 'ma-list'); + box.appendChild(list); + + function addRow(model) { + var row = el('div', 'ma-row'); + var who = el('input', 'ma-who'); + who.type = 'text'; + who.value = model.principal || ''; + who.placeholder = 'email or *@domain or role name'; + who.addEventListener('input', function () { model.principal = who.value.trim(); }); + + var sel = el('select', 'ma-level'); + LEVELS.forEach(function (lv) { + var o = el('option', null, lv.label); + o.value = lv.id; + o.title = lv.hint; + sel.appendChild(o); + }); + if (model.level === 'custom') { + var o2 = el('option', null, 'Custom'); + o2.value = 'custom'; + o2.title = 'verbs: ' + model.custom; + sel.appendChild(o2); + } + sel.value = model.level; + sel.addEventListener('change', function () { model.level = sel.value; }); + + var del = el('button', 'ma-del', '✕'); + del.type = 'button'; + del.title = 'Remove'; + del.addEventListener('click', function () { row.remove(); model._removed = true; }); + + row.appendChild(who); + row.appendChild(sel); + row.appendChild(del); + list.appendChild(row); + return model; + } + rows.forEach(addRow); + + var addBtn = el('button', 'ma-add', '+ Add person or group'); + addBtn.type = 'button'; + addBtn.addEventListener('click', function () { + var m = { principal: '', level: 'view', custom: '' }; + rows.push(m); + addRow(m); + }); + box.appendChild(addBtn); + + var legend = el('p', 'ma-legend', + 'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin'); + box.appendChild(legend); + + // Inherit / make-private. + var inhWrap = el('label', 'ma-inherit'); + var inhBox = el('input'); + inhBox.type = 'checkbox'; + inhBox.checked = inherit; + inhWrap.appendChild(inhBox); + inhWrap.appendChild(el('span', null, ' Inherit access from parent folders')); + box.appendChild(inhWrap); + + var err = el('p', 'ma-err'); + box.appendChild(err); + + var actions = el('div', 'ma-actions'); + var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); + cancel.type = 'button'; + var save = el('button', 'btn btn-sm btn-primary', 'Save'); + save.type = 'button'; + actions.appendChild(cancel); + actions.appendChild(save); + box.appendChild(actions); + + function close() { + document.removeEventListener('keydown', onKey, true); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } } + document.addEventListener('keydown', onKey, true); + overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(); }); + cancel.addEventListener('click', close); + + save.addEventListener('click', function () { + err.textContent = ''; + // Rebuild perms + admins from the live rows (skip removed/blank). + var perms = {}, admins = [], bad = false; + rows.forEach(function (m) { + if (m._removed) return; + var p = (m.principal || '').trim(); + if (!p) return; + if (m.level === 'manage') { + if (admins.indexOf(p) === -1) admins.push(p); + } else if (m.level === 'custom') { + perms[p] = m.custom; // preserve the hand-written string + } else { + perms[p] = verbsOfLevel(m.level); + } + }); + + // Merge into the existing doc, preserving every unmanaged key. + var out = {}; + Object.keys(data).forEach(function (k) { out[k] = data[k]; }); + var acl = (out.acl && typeof out.acl === 'object') ? Object.assign({}, out.acl) : {}; + if (Object.keys(perms).length) acl.permissions = perms; else delete acl.permissions; + if (!inhBox.checked) acl.inherit = false; else delete acl.inherit; + if (Object.keys(acl).length) out.acl = acl; else delete out.acl; + if (admins.length) out.admins = admins; else delete out.admins; + + var content; + try { content = window.jsyaml.dump(out); } + catch (e2) { err.textContent = 'Could not serialize: ' + (e2.message || e2); return; } + + save.disabled = true; + save.textContent = 'Saving…'; + var node = { url: zddcUrl, name: '.zddc', ext: '' }; + util.saveFile(node, content, 'application/yaml; charset=utf-8', etag ? { etag: etag } : {}) + .then(function () { + toast('Access updated for ' + base, 'success'); + var ev = app.modules.events; + if (ev && ev.refreshListing) { try { ev.refreshListing(); } catch (_e) { /* ignore */ } } + close(); + }) + .catch(function (e3) { + save.disabled = false; + save.textContent = 'Save'; + if (e3 && e3.status === 412) { + err.textContent = 'These rules changed on the server since you opened this. Close and reopen to get the latest, then redo your change.'; + } else { + err.textContent = 'Save failed: ' + (e3 && e3.message ? e3.message : e3); + } + }); + }); + + document.body.appendChild(overlay); + var first = box.querySelector('.ma-who'); + if (first) first.focus(); + } + + app.modules.manageAccess = { open: open }; +})(window.app); diff --git a/browse/js/menu-model.js b/browse/js/menu-model.js new file mode 100644 index 0000000..024da8e --- /dev/null +++ b/browse/js/menu-model.js @@ -0,0 +1,469 @@ +// menu-model.js — the declarative source of truth for the browse tool's +// action menus (right-click row menu, right-click pane menu, the keyboard +// menu key, and the hover kebab). +// +// Every action is declared ONCE as a descriptor. The row/pane menus are +// projections over that list, filtered by surface + an `appliesTo` TYPE +// predicate and annotated with an `enabled` CAPABILITY predicate: +// +// appliesTo(ctx) === false → the item is OMITTED (it doesn't make sense +// for this target — e.g. "New folder" on a +// file row, "Expand" on a file). +// appliesTo true, enabled +// (ctx) === false → the item is SHOWN DISABLED with a tooltip +// naming what's required (write access / +// create access / project-admin / site-admin). +// +// That hybrid realizes the cumulative guest ⊂ project-team ⊂ sub-admin ⊂ +// admin menus: a lower tier SEES higher-tier actions greyed and learns they +// exist, while type-irrelevant noise is hidden. +// +// Roles are NOT hardcoded: ordinary actions gate on the verbs the server +// returns per entry (node.verbs) or per scope (cap.at → path_verbs), so any +// operator-defined role works. Only two intrinsically-special tiers are +// recognised by name — site admin (is_super_admin / IsAdmin) and project / +// subtree admin (path_is_admin / IsSubtreeAdmin) — because they govern +// administration itself and can't be expressed as a plain verb bundle. +// +// Deliberately data-shaped so a future server-sourced manifest (zddc.zip) +// can supply or extend the descriptors without touching the tool code. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + var state = window.app.state; + + // Action implementations are injected by events.init() via configure() + // to avoid an events ↔ menu-model circular dependency. Everything else + // (tree, preview, download, workflow modules) is reached through + // window.app.modules at call time. + var act = {}; + function configure(a) { act = a || {}; } + + // ── Predicates ──────────────────────────────────────────────────────── + + function isServer() { return state.source === 'server'; } + function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); } + function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); } + + // The Export submenu's convertible-format set comes from the download + // module's canonical matrix (download.exportTargets), which mirrors the + // server's conversion matrix — the single source of truth shared with the + // markdown editor's DOCX/HTML/PDF buttons. exportTargets(ext) returns the + // target formats for a source extension (e.g. md → docx, html, pdf), or [] + // when the extension isn't a convertible source. + function exportTargets(ext) { + var d = window.app.modules.download; + return (d && d.exportTargets) ? d.exportTargets(ext) : []; + } + function cap() { return window.zddc && window.zddc.cap; } + + function canVerb(node, verb) { + return !!(node && cap() && cap().has(node, verb)); + } + function pathHasVerb(access, verb) { + return !!(access && typeof access.path_verbs === 'string' + && access.path_verbs.indexOf(verb) !== -1); + } + function isSiteAdmin(access) { return !!(access && access.is_super_admin); } + function isSubtreeAdminHere(access) { return !!(access && access.path_is_admin); } + + // Create / mutate / admin actions are HIDDEN when the user can't perform + // them (capability folded into appliesTo), so these gates only need the + // boolean — the `missing` field is retained for potential future tooltips. + + // Rename/Delete gate — preserves today's compose exactly: canMutate rules + // out un-writable sources (offline FS without a handle, zip members, + // virtual placeholders) with no tooltip; when the server cascade reports + // verbs, the per-entry ACL bit gates with a tooltip. FS / Caddy (no verbs + // field) fall back to canMutate alone. Returns { enabled, missing }. + function verbGate(node, verb) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return { enabled: false, missing: '' }; + if (!isServer() || !cap()) return { enabled: true, missing: '' }; + if (typeof node.verbs !== 'string') return { enabled: true, missing: '' }; + if (cap().has(node, verb)) return { enabled: true, missing: '' }; + return { enabled: false, missing: verb }; + } + + // Create gate (New folder / New file). canCreateHere() rules out the + // no-target case (offline FS without a picked handle) — no tooltip there. + // In server mode, gate on the 'c' verb: per-node for a folder row, per + // scope for the pane. Unknown verbs → optimistic (server is the final + // arbiter, surfacing 403 via cap.handleForbidden, exactly as today). + function createGate(ctx) { + if (!act.canCreateHere || !act.canCreateHere()) return { enabled: false, missing: '' }; + if (!isServer()) return { enabled: true, missing: '' }; + if (ctx.node) { // folder-row create → inside this folder + if (typeof ctx.node.verbs === 'string') { + return canVerb(ctx.node, 'c') + ? { enabled: true, missing: '' } + : { enabled: false, missing: 'c' }; + } + return { enabled: true, missing: '' }; + } + // pane create → current scope + if (ctx.access && typeof ctx.access.path_verbs === 'string') { + return pathHasVerb(ctx.access, 'c') + ? { enabled: true, missing: '' } + : { enabled: false, missing: 'c' }; + } + return { enabled: true, missing: '' }; + } + + // "Edit access rules" (.zddc) — the sub-admin / site-admin tier item. + // Enabled per-node when the entry grants the admin verb 'a', else by the + // scope's subtree-admin / site-admin status (admin authority cascades + // down a subtree). Returns { enabled, missing }. + function manageAccessGate(ctx) { + if (ctx.node && canVerb(ctx.node, 'a')) return { enabled: true, missing: '' }; + if (isSubtreeAdminHere(ctx.access) || isSiteAdmin(ctx.access)) return { enabled: true, missing: '' }; + return { enabled: false, missing: 'subtree-admin' }; + } + + function insideZip(node) { + // Creating inside a zip member is impossible — the server can't PUT + // into an archive. Mirror tree.zipNestedInsideZip's URL heuristic. + if (!node) return false; + if (node.url && /\.zip\//i.test(node.url)) return true; + if (node.handle && node.handle.isZipEntry) return true; + return false; + } + + // ── Descriptors ───────────────────────────────────────────────────────── + // group order = visual order; a separator is inserted on each group change + // among the items that actually render (context-menu.js collapses extras). + var DESCRIPTORS = [ + // ── open ── + { + id: 'open', group: 'open', surfaces: ['row'], + label: function (ctx) { + if (ctx.node.isDir) return 'Open'; + if (ctx.node.isZip) return 'Open archive'; + return 'Preview'; + }, + appliesTo: function (ctx) { return !ctx.node.virtual; }, + action: function (ctx) { + if (ctx.node.isDir) { + // Open = navigate into the folder (rescope). Inline + // expand stays on single-click / chevron / arrow keys. + if (act.navigateIntoFolder) act.navigateIntoFolder(ctx.node); + } else if (ctx.node.isZip) { + // A zip can't be navigated into — expand it inline. + var t = window.app.modules.tree; + if (t) t.toggleFolder(ctx.node.id); + } else { + var p = window.app.modules.preview; + if (p) p.showFilePreview(ctx.node); + } + } + }, + { + id: 'open-new-tab', group: 'open', surfaces: ['row'], + label: 'Open in new tab', accel: 'Ctrl+Click', + appliesTo: function (ctx) { return !!ctx.node.url; }, + action: function (ctx) { window.open(ctx.node.url, '_blank', 'noopener'); } + }, + { + id: 'popout', group: 'open', surfaces: ['row'], + label: 'Pop out preview', + appliesTo: function (ctx) { return appliesToFile(ctx.node) && !ctx.node.virtual; }, + action: function (ctx) { + var p = window.app.modules.preview; + if (p) p.showFilePreview(ctx.node, { popup: true }); + } + }, + + // ── io ── + { + id: 'download', group: 'io', surfaces: ['row'], + label: function (ctx) { return ctx.node.isDir ? 'Download ZIP' : 'Download'; }, + appliesTo: function (ctx) { return !ctx.node.virtual; }, + action: function (ctx) { + var d = window.app.modules.download; + if (!d) return; + if (ctx.node.isDir) d.downloadFolder(ctx.node); + else d.downloadFile(ctx.node); + } + }, + { + // Export submenu: a folder offers ".zip" (both modes); a convertible + // file (md/docx/html) offers its server-side conversion targets — + // md → docx/html/pdf, docx → md/html, html → md/docx (server mode + // only). A zip is already an archive — no Export. + id: 'export', group: 'io', surfaces: ['row'], + label: 'Export', + appliesTo: function (ctx) { + var n = ctx.node; + if (!n || n.virtual) return false; + if (n.isDir) return true; + if (n.isZip) return false; + return isServer() && exportTargets(n.ext).length > 0; + }, + items: function (ctx) { + var n = ctx.node; + var d = window.app.modules.download; + if (!d) return []; + if (n.isDir) { + return [{ label: '.zip', action: function () { d.downloadFolder(n); } }]; + } + // exportTargets already excludes the source format. + return exportTargets(n.ext).map(function (fmt) { + return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } }; + }); + } + }, + + // ── create (folder rows + pane; NOT file rows) ── + // Create actions are HIDDEN unless the user can create here (the + // capability is folded into appliesTo, not greyed). On a row they + // apply to folders only (create inside); on the pane, to the scope. + { + id: 'new-folder', group: 'create', surfaces: ['row', 'pane'], + label: 'New folder', + appliesTo: function (ctx) { + var typeOk = ctx.surface === 'pane' + || (appliesToFolderLike(ctx.node) && !insideZip(ctx.node)); + return typeOk && createGate(ctx).enabled; + }, + action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'folder'); } + }, + { + id: 'new-file', group: 'create', surfaces: ['row', 'pane'], + label: 'New file', + appliesTo: function (ctx) { + var typeOk = ctx.surface === 'pane' + || (appliesToFolderLike(ctx.node) && !insideZip(ctx.node)); + return typeOk && createGate(ctx).enabled; + }, + action: function (ctx) { if (act.createInDir) act.createInDir(ctx.dir, 'markdown'); } + }, + { + id: 'create-transmittal', group: 'create', surfaces: ['pane'], + label: 'Create Transmittal folder…', + appliesTo: function () { return isServer() && state.scopeCanonicalFolder === 'staging'; }, + action: function () { + var ct = window.app.modules.createTransmittal; + if (ct) ct.invoke(); + } + }, + + // ── mutate (HIDDEN unless permitted — capability folded into appliesTo) ── + { + id: 'rename', group: 'mutate', surfaces: ['row'], + label: 'Rename…', + appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'w').enabled; }, + action: function (ctx) { if (act.renameNode) act.renameNode(ctx.node); } + }, + { + id: 'delete', group: 'mutate', surfaces: ['row'], danger: true, + label: 'Delete…', + appliesTo: function (ctx) { return !ctx.node.virtual && verbGate(ctx.node, 'd').enabled; }, + action: function (ctx) { if (act.deleteNode) act.deleteNode(ctx.node); } + }, + + // ── treeops (folder/zip rows only) ── + { + id: 'expand-subtree', group: 'treeops', surfaces: ['row'], + label: 'Expand subtree', accel: 'Shift+Click', + appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); }, + action: function (ctx) { + var t = window.app.modules.tree; + if (t) t.expandSubtree(ctx.node.id); + } + }, + { + id: 'collapse-subtree', group: 'treeops', surfaces: ['row'], + label: 'Collapse subtree', + appliesTo: function (ctx) { return appliesToFolderLike(ctx.node); }, + action: function (ctx) { + var t = window.app.modules.tree; + if (t) t.collapseSubtree(ctx.node.id); + } + }, + + // ── workflow (already type+scope gated → omitted when N/A) ── + { + id: 'plan-review', group: 'workflow', surfaces: ['row'], + label: 'Plan Review…', + appliesTo: function (ctx) { + if (!isServer() || !state.scopeOnPlanReview) return false; + var pr = window.app.modules.planReview; + return !!(pr && pr.isReceivedTrackingFolder(ctx.node)); + }, + action: function (ctx) { + var pr = window.app.modules.planReview; + if (pr) pr.invoke(ctx.node); + } + }, + { + id: 'accept-transmittal', group: 'workflow', surfaces: ['row'], + label: 'Accept Transmittal…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var at = window.app.modules.acceptTransmittal; + return !!(at && at.isAcceptableTransmittalFolder(ctx.node)); + }, + action: function (ctx) { + var at = window.app.modules.acceptTransmittal; + if (at) at.invoke(ctx.node); + } + }, + { + id: 'stage', group: 'workflow', surfaces: ['row'], + label: 'Stage to…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var s = window.app.modules.stage; + return !!(s && s.isStageableFile(ctx.node)); + }, + action: function (ctx) { + var s = window.app.modules.stage; + if (s) s.invokeStage(ctx.node); + } + }, + { + id: 'unstage', group: 'workflow', surfaces: ['row'], + label: 'Unstage to working/', + appliesTo: function (ctx) { + if (!isServer()) return false; + var s = window.app.modules.stage; + return !!(s && s.isUnstageableFile(ctx.node)); + }, + action: function (ctx) { + var s = window.app.modules.stage; + if (s) s.invokeUnstage(ctx.node); + } + }, + { + id: 'history', group: 'workflow', surfaces: ['row'], + label: 'History…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var n = ctx.node; + return appliesToFile(n) && !n.virtual && !!n.history; + }, + action: function (ctx) { + var h = window.app.modules.history; + if (h) h.open(ctx.node); + } + }, + + // ── admin / sub-admin tier ── + { + // Guided "who can do what here" dialog — the front door for access. + // HIDDEN unless the user can administer here (admin verb 'a', or + // subtree/site admin). + id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], + label: 'Manage access…', + appliesTo: function (ctx) { + if (!isServer()) return false; // server-only tier + var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node); + return typeOk && manageAccessGate(ctx).enabled + && !!(window.app.modules.manageAccess); + }, + action: function (ctx) { + var m = window.app.modules.manageAccess; + if (m) m.open(ctx.dir); + } + }, + { + // The raw-YAML escape hatch — same authority gate, demoted to + // "advanced" since the guided dialog covers the common case. + id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'], + label: 'Edit raw policy (.zddc)…', + appliesTo: function (ctx) { + if (!isServer()) return false; + var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node); + return typeOk && manageAccessGate(ctx).enabled; + }, + action: function (ctx) { openZddcEditor(ctx.dir); } + }, + + // ── view (pane) ── + { + id: 'refresh', group: 'view', surfaces: ['pane'], + label: 'Refresh', accel: 'F5', + action: function () { if (act.refreshListing) act.refreshListing(); } + } + ]; + + // Open the `.zddc` for `dir` in the YAML editor. Prefer an existing tree + // node (carries verbs/virtual flags) else synthesize one; the yaml plugin + // recognises name === '.zddc' and gates the save on the admin verb 'a'. + function openZddcEditor(dir) { + var url = (dir || '/'); + if (!url.endsWith('/')) url += '/'; + url += '.zddc'; + var found = null; + var t = window.app.modules.tree; + state.nodes.forEach(function (n) { + if (found || n.name !== '.zddc' || !t) return; + if (t.pathFor(n) === url) found = n; + }); + var node = found || { url: url, name: '.zddc', ext: '' }; + var p = window.app.modules.preview; + if (p) p.showFilePreview(node); + } + + // ── Projection ──────────────────────────────────────────────────────── + + function resolve(v, ctx) { return typeof v === 'function' ? v(ctx) : v; } + function resolveBool(v, ctx, dflt) { + if (v === undefined) return dflt; + return !!(typeof v === 'function' ? v(ctx) : v); + } + + function toMenuItem(d, ctx) { + var item = { + label: resolve(d.label, ctx), + accel: d.accel, + danger: d.danger, + // disabled / tooltip ignore the menu's own context arg — ctx is + // already captured here with the richer browse context. + disabled: function () { return !resolveBool(d.enabled, ctx, true); }, + tooltip: function () { + return resolveBool(d.enabled, ctx, true) ? '' : (resolve(d.tooltip, ctx) || ''); + } + }; + // A descriptor with `items` becomes a submenu (resolved against the + // captured browse ctx); otherwise it's a normal action row. + if (d.items) { + item.items = function () { return resolve(d.items, ctx); }; + } else { + item.action = function () { if (d.action) d.action(ctx); }; + } + return item; + } + + function project(surface, ctx) { + var out = []; + var lastGroup = null; + for (var i = 0; i < DESCRIPTORS.length; i++) { + var d = DESCRIPTORS[i]; + if (d.surfaces.indexOf(surface) === -1) continue; + if (!resolveBool(d.appliesTo, ctx, true)) continue; + if (lastGroup !== null && d.group !== lastGroup) out.push({ separator: true }); + lastGroup = d.group; + out.push(toMenuItem(d, ctx)); + } + return out; // context-menu.js collapses leading/trailing/dup separators + } + + function buildRowItems(node, row, access) { + var dir = act.parentDirFor ? act.parentDirFor(node) : (state.currentPath || '/'); + return project('row', { node: node, row: row, surface: 'row', dir: dir, access: access }); + } + function buildPaneItems(access) { + var dir = state.currentPath || '/'; + return project('pane', { node: null, row: null, surface: 'pane', dir: dir, access: access }); + } + + window.app.modules.menuModel = { + configure: configure, + buildRowItems: buildRowItems, + buildPaneItems: buildPaneItems, + DESCRIPTORS: DESCRIPTORS // exposed for tests + }; +})(); diff --git a/browse/js/plan-review.js b/browse/js/plan-review.js new file mode 100644 index 0000000..1e6f2d9 --- /dev/null +++ b/browse/js/plan-review.js @@ -0,0 +1,250 @@ +// plan-review.js — the doc-controller "Plan Review" workflow modal. +// +// Surfaced by events.js as a right-click menu item on +// archive//received// folders when the cascade above +// has an on_plan_review block (X-ZDDC-On-Plan-Review header on the +// listing). +// +// The modal collects four fields: +// +// - review_lead (becomes sub-admin of reviewing/<…>/) +// - plan_review_complete_date (the committed review-done date) +// - approver (becomes sub-admin of staging/<…>/) +// - plan_response_date (the committed response-issue date) +// +// The planned dates are immutable from the sub-admins' perspective — +// they live in the canonical submittal's .zddc +// (received//.zddc) where only the doc controller (via Plan +// Review re-run) can change them. The workflow folders' .zddc files +// carry only the back-link + per-folder ACL. +// +// Title is auto-derived server-side from the first ZDDC-parseable +// file in received//. Forecast dates default to the planned +// dates at scaffolding time; the user renames the workflow folder +// directly to update the forecast later. +// +// On submit, the form assembles a YAML body and POSTs it with +// X-ZDDC-Op: plan-review to the received// URL. + +(function () { + 'use strict'; + + var REVIEW_OFFSET_DAYS = 7; + var RESPONSE_OFFSET_DAYS = 14; + + // Notifications go through the shared toast helper — there's no + // persistent footer strip in browse anymore. + function statusInfo(msg) { + if (msg && window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, 'info'); + } + } + function statusError(msg) { + if (msg && window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, 'error'); + } + } + + var util = window.app.modules.util; + var isoDatePlus = util.isoDatePlus; + + // Fetch suggestion emails from /.profile/access so the originator + // field has a datalist of likely values. Best-effort — silent on + // failure (the field still accepts free text). + var fetchOriginatorSuggestions = util.fetchAccessEmails; + + // Build the YAML body for the plan-review POST. Quoting is minimal + // (just enough for emails with special chars). + function buildBody(values) { + var yamlString = util.yamlQuote; + return [ + 'review_lead: ' + yamlString(values.reviewLead), + 'approver: ' + yamlString(values.approver), + 'plan_review_complete_date: ' + values.planReviewDate, + 'plan_response_date: ' + values.planResponseDate, + '' + ].join('\n'); + } + + // Render the modal. Returns a Promise that resolves on submit + // (with the collected values) or rejects on cancel. + function openForm(initial) { + return new Promise(function (resolve, reject) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:24rem;max-width:32rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);font-family:inherit;'; + + box.innerHTML = + '

Plan Review — ' + escapeHtml(initial.tracking) + '

' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '

Planned dates seal at first submission — they become part of the canonical record (received//.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.

' + + '
' + + '' + + '' + + '
'; + + overlay.appendChild(box); + document.body.appendChild(overlay); + + var reviewLeadInput = box.querySelector('#pr-review-lead'); + var approverInput = box.querySelector('#pr-approver'); + var reviewDateInput = box.querySelector('#pr-review-date'); + var responseDateInput = box.querySelector('#pr-response-date'); + + reviewDateInput.value = isoDatePlus(REVIEW_OFFSET_DAYS); + responseDateInput.value = isoDatePlus(RESPONSE_OFFSET_DAYS); + + // Populate the datalist with people suggestions (best + // effort — silent on failure). + fetchOriginatorSuggestions().then(function (emails) { + var dl = box.querySelector('#pr-people-list'); + if (!dl) return; + emails.forEach(function (e) { + var opt = document.createElement('option'); + opt.value = e; + dl.appendChild(opt); + }); + }); + + // Escape handler bound once, removed in close() — every + // dismissal path routes through close() so the document + // listener never outlives the modal. + function onKeydown(e) { + if (e.key === 'Escape') { close(); reject(new Error('cancelled')); } + } + function close() { + document.removeEventListener('keydown', onKeydown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + box.querySelector('#pr-cancel').addEventListener('click', function () { + close(); + reject(new Error('cancelled')); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) { + close(); + reject(new Error('cancelled')); + } + }); + document.addEventListener('keydown', onKeydown); + + box.querySelector('#pr-submit').addEventListener('click', function () { + var values = { + reviewLead: reviewLeadInput.value.trim(), + approver: approverInput.value.trim(), + planReviewDate: reviewDateInput.value, + planResponseDate: responseDateInput.value + }; + if (!values.reviewLead || !values.approver + || !values.planReviewDate || !values.planResponseDate) { + statusError('All fields are required.'); + return; + } + close(); + resolve(values); + }); + + reviewLeadInput.focus(); + }); + } + + var escapeHtml = util.escapeHtml; + + // Detect whether a tree node is an archive//received// + // folder. The path is path-shaped, not content-based — tracking-number + // content is not inspected (per design). + function isReceivedTrackingFolder(node) { + if (!node || !node.isDir) return false; + var tree = window.app.modules.tree; + if (!tree) return false; + var p = tree.pathFor(node).replace(/\/$/, ''); + var rel = p.replace(/^\/+/, ''); + var parts = rel.split('/'); + return parts.length === 5 + && parts[1].toLowerCase() === 'archive' + && parts[3].toLowerCase() === 'received'; + } + + var busy = false; + + // Run the Plan Review flow: open the modal, POST the result. + async function invoke(node) { + if (busy) return; + var tree = window.app.modules.tree; + if (!tree) return; + var url = tree.pathFor(node); + if (!url.endsWith('/')) url += '/'; + var parts = url.replace(/^\/+/, '').replace(/\/$/, '').split('/'); + var tracking = parts[parts.length - 1]; + + var values; + try { + values = await openForm({ tracking: tracking }); + } catch (_e) { + return; // cancelled + } + + busy = true; + try { + statusInfo('Plan Review — submitting…'); + var body = buildBody(values); + var resp; + try { + resp = await fetch(url, { + method: 'POST', + headers: { + 'X-ZDDC-Op': 'plan-review', + 'Content-Type': 'application/yaml' + }, + body: body, + credentials: 'same-origin' + }); + } catch (e) { + statusError('Plan Review failed: ' + (e && e.message ? e.message : e)); + return; + } + if (!resp.ok) { + var text = ''; + try { text = await resp.text(); } catch (_e) { /* ignore */ } + statusError('Plan Review failed (' + resp.status + '): ' + text); + return; + } + var data; + try { data = await resp.json(); } catch (_e) { data = null; } + if (data && data.reviewing && data.staging) { + var rPart = data.reviewing.created ? 'created' : 'updated'; + var sPart = data.staging.created ? 'created' : 'updated'; + var seal = (data.received && data.received.created) + ? ' Canonical record sealed.' + : (data.received && !data.received.zddc_written) + ? ' Canonical dates left untouched (already sealed).' + : ''; + statusInfo('Plan Review: reviewing ' + rPart + ', staging ' + sPart + '.' + seal + + ' Reload the relevant folder to see the new entries.'); + } else { + statusInfo('Plan Review complete.'); + } + } finally { + busy = false; + } + } + + window.app.modules.planReview = { + isReceivedTrackingFolder: isReceivedTrackingFolder, + invoke: invoke + }; +})(); diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js new file mode 100644 index 0000000..10222b0 --- /dev/null +++ b/browse/js/preview-markdown.js @@ -0,0 +1,1148 @@ +// preview-markdown.js — markdown plugin for the browse preview pane. +// +// Layout (CSS Grid): +// ┌─────────────────────────────────────────────────────────────────┐ +// │ info: name | dirty | status | source | DOCX HTML PDF | Save │ +// ├────────────────────────┬────────────────────────────────────────┤ +// │ YAML front matter │ │ +// │ ┌──────────────────┐ │ │ +// │ │ title: Foo │ │ Toast UI Editor │ +// │ │ revision: A │ │ (md / wysiwyg / preview) │ +// │ └──────────────────┘ │ │ +// ├────────────────────────┤ │ +// │ Outline │ │ +// │ • Heading 1 │ │ +// │ • Subheading │ │ +// │ • Heading 2 │ │ +// └────────────────────────┴────────────────────────────────────────┘ +// Grid keeps every cell's size definite, which is what Toast UI needs +// to compute its inner scroll regions correctly. The previous nested- +// flexbox layout produced indeterminate heights and a fragile TOC +// pane width — grid fixes both. +// +// Front matter is edited in a dedicated +
+ + +
+ + +

Tip: leave a tool on stable if you don't have a reason to pin it. Pin specific versions when you depend on a behavior in that release. The server's resolution order is: a real file in your archive → .zddc apps: entry (closer-to-leaf wins) → embedded fallback.

+ + + +
+

Verify your downloads

+

Every artifact published here is signed with a long-lived Ed25519 key. You can verify any download against the public key using stock openssl — no special tooling. zddc-server verifies automatically when fetching tools via the apps: cascade once the operator configures ZDDC_APPS_PUBKEY — same posture as TLS certificates: the binary bakes nothing in, the operator points it at the public key they trust.

+
+
+

Public key

+

Download pubkey.pem

+

SHA-256 fingerprint of the DER-encoded SubjectPublicKeyInfo:

+
7766dc8cf963f32156ddcc96825c52ba0333ffe4c243ad54f9eaf26195b065ab
+

Verify the fingerprint after downloading:

+
openssl pkey -pubin -in pubkey.pem -outform DER | sha256sum
+
+
+

Verify a download

+

Each artifact has a matching .sig file alongside it (archive.htmlarchive.html.sig, etc.). Fetch both, then:

+
curl -O https://zddc.varasys.io/releases/archive.html
+curl -O https://zddc.varasys.io/releases/archive.html.sig
+openssl pkeyutl -verify -pubin -inkey pubkey.pem \
+    -rawin -in archive.html \
+    -sigfile archive.html.sig
+

Output is Signature Verified Successfully on a clean download. Any other output (or no output and a non-zero exit) means the bytes do not match the published signature — do not trust them.

+
+
+

Configure zddc-server to auto-verify

+

For server deployments, configure the public key once and the apps fetcher verifies every apps:-pinned URL automatically. Two equivalent ways — pick whichever matches how you already manage config:

+
+
+

Env var (k8s, systemd, Docker)

+

Save pubkey.pem on your server, then point zddc-server at it:

+
curl -o /etc/zddc/pubkey.pem \
+  https://zddc.varasys.io/pubkey.pem
+
+ZDDC_APPS_PUBKEY=/etc/zddc/pubkey.pem \
+ZDDC_ROOT=/srv/zddc ./zddc-server
+

Fits the deployment shape where env vars are already the config plumbing — Helm chart values, systemd unit-file Environment=, Docker -e.

+
+
+

Inline in root .zddc

+

Paste the PEM contents under apps_pubkey: in the root .zddc:

+
# <ZDDC_ROOT>/.zddc
+admins: [you@yourcompany.com]
+acl:
+  permissions:
+    '*@yourcompany.com': r
+apps_pubkey: |
+  -----BEGIN PUBLIC KEY-----
+  MCowBQYDK2VwAyEAXXaxIUIyOFnhD1eZs02nEt3xZ8izOi7bURFcpJ9iWZY=
+  -----END PUBLIC KEY-----
+

Honored only at the root .zddc (root-only, like admins:). When both forms are set, the env var wins. Subtree .zddc files with apps_pubkey: are silently ignored — the trust anchor doesn't cascade.

+
+
+

When configured, the resolver fetches the .sig automatically on every URL-pinned apps: entry and rejects any unsigned or invalid-signature artifact, falling back to the embedded copy. Operators enforcing signature verification on locally-saved artifacts (Path A, "drop a copy into your archive") run the openssl verify command above as part of their save workflow.

+
+ + + + + + + + +PIN_END + } > "$_out" + echo "Wrote $_out" +} + +# Sign artifacts + regenerate releases/index.html on stable cuts. +# Beta cuts produce no public artifact, so nothing to sign or index. +if [ "$RELEASE_CHANNEL" = "stable" ]; then + # Source ZDDC_SIGNING_KEY from ~/.config/zddc-signing/env if it + # isn't already in env. Mirrors ~/.bashrc's auto-sourcing pattern + # for ~/.config/{codeberg,forgejo,github}/env, but inside the + # build script so non-interactive callers pick it up without + # needing systemd EnvironmentFile or workflow yaml. + # + # Two candidates: $HOME first (operator's normal shell), then + # /home/user as an explicit fallback for the Forgejo runner + # container — $HOME there is /var/lib/forgejo-runner (uid 1001's + # in-container passwd entry), but the runner quadlet bind-mounts + # /home/user/.config/zddc-signing/ at the same absolute path so + # the operator's single source of truth is visible to the runner. + if [ -z "${ZDDC_SIGNING_KEY:-}" ]; then + for _zsk_env in "$HOME/.config/zddc-signing/env" /home/user/.config/zddc-signing/env; do + if [ -f "$_zsk_env" ]; then + . "$_zsk_env" + break + fi + done + unset _zsk_env + fi + echo "" + echo "=== Signing release artifacts ===" + sign_release_artifacts "$RELEASES_DIR" + + echo "" + echo "=== Building releases/index.html ===" + build_releases_index +fi + +# --- Embedded commit (stable + beta cuts) --------------------------------- +# On both stable and beta cuts, fold the regenerated embedded artifacts +# into a single commit on main. Two reasons: +# +# 1. Stable: the next tag block needs HEAD to point at the bytes the +# stable binary will serve. Without this commit, tags would land on +# the source-side commit (with stale embedded/*) and prod images +# compiled from `git checkout zddc-server-vX.Y.Z` would ship stale +# bytes. (Original justification — preserved.) +# +# 2. Beta: the dev chart pipeline pins appVersion to a SHA. For that +# pin to point at a SHA where embedded/* matches what the binary +# will serve, HEAD has to advance past the source-side commit. +# Without this commit, the chart pin lags one commit and the dev +# image bakes the previous beta cut's bytes — exactly the failure +# mode that required manual chart-rebases on the v0.0.16-beta cuts. +# +# Idempotent: if there are no embedded changes, no commit is made. +if [ "$RELEASE_CHANNEL" = "stable" ] || [ "$RELEASE_CHANNEL" = "beta" ]; then + echo "" + echo "=== Embedded commit ===" + + # Stage the artifacts that are part of the release. dist/ is + # gitignored everywhere — none of the tools' dist/.html files + # are tracked. The release commit only carries the bake-in artifacts + # that the binary needs at //go:embed time + the unified form/tables + # template (form-mode is hosted by tables.html via the zddcMode + # dispatcher; there is no separate form.html //go:embed target). + git -C "$SCRIPT_DIR" add "$EMBED_DIR/" \ + "$SCRIPT_DIR/zddc/internal/handler/tables.html" + + if ! git -C "$SCRIPT_DIR" diff --cached --quiet; then + if [ "$RELEASE_CHANNEL" = "stable" ]; then + git -C "$SCRIPT_DIR" commit -m "release: v${RELEASE_VERSION} lockstep" + else + # Beta cuts don't carry an explicit version — derive the + # next-stable target from the lockstep helper so the commit + # message reflects "cut v-beta" the same way + # versions.txt and the release-output filenames do. + _embed_ver=$(_coordinated_next_stable) + git -C "$SCRIPT_DIR" commit -m "chore(embedded): cut v${_embed_ver}-beta" + fi + echo " embedded commit created at HEAD: $(git -C "$SCRIPT_DIR" rev-parse --short HEAD)" + else + echo " no embedded changes to commit (re-run on same source state)" + fi +fi + +# --- Release tag (stable cut only) ---------------------------------------- +# Beta channels never get tags — channel mirrors are by-design moving +# targets. Tags only exist for stable, where they pin a specific +# X.Y.Z to an immutable commit (the release commit produced above). +if [ "$RELEASE_CHANNEL" = "stable" ]; then + echo "" + echo "=== Release tag ===" + + # Tag the nine artifacts at HEAD. Pre-flight already validated that + # any pre-existing tag is in HEAD's history, so this is safe. + _head=$(git -C "$SCRIPT_DIR" rev-parse HEAD) + for _t in archive transmittal classifier landing form tables browse zddc-server; do + _tag="${_t}-v${RELEASE_VERSION}" + if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then + _existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag") + if [ "$_existing" = "$_head" ]; then + echo " $_tag already at HEAD" + continue + fi + # Pre-flight in promote_release confirmed _existing is an + # ancestor of HEAD. Move it forward to HEAD (the release commit). + git -C "$SCRIPT_DIR" tag -f "$_tag" "$_head" >/dev/null + echo " $_tag advanced to release commit" + else + git -C "$SCRIPT_DIR" tag "$_tag" "$_head" + echo " $_tag created at release commit" + fi + done +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 "For an internal SHA snapshot (BMC dev chart): ./build beta" + echo "To cut a stable release: ./build release" +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 landing form tables browse 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 diff --git a/classifier/README.md b/classifier/README.md new file mode 100644 index 0000000..ca1e897 --- /dev/null +++ b/classifier/README.md @@ -0,0 +1,244 @@ +# Document Classifier + +[← Back to ZDDC](../README.md) + +Turn chaos into order - a spreadsheet-like tool for bulk renaming files to ZDDC format. Copy/paste with Excel for lightning-fast text operations. The entire app fits in a single HTML file that works forever. + +**[🔗 Open Document Classifier](dist/classifier.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy. + +## Why This Tool? + +Got a folder full of "spec_final_v2_REALLY_FINAL.pdf" files? This tool transforms them into properly named, searchable documents. It's like Excel for file names - edit hundreds at once, paste from spreadsheets, and save hours of manual renaming. + +## What You Can Do + +📋 **Excel-Like Editing** +- Edit file names like a spreadsheet +- Copy/paste entire columns to/from Excel +- Select ranges just like Excel (click and drag) +- Tab through cells, sort columns, resize as needed + +🚀 **Bulk Operations** +- Rename hundreds of files in seconds +- Auto-populate from existing ZDDC names +- Hide already-compliant files to focus on the rest +- Save all changes with one click + +🎯 **Smart Features** +- Real-time validation shows errors instantly +- Files stay in their folders - just get new names +- Preview any file with a single click +- Automatic folder expansion shows everything at once + +## Quick Start + +1. **Click "Select Directory"** - Pick the folder with messy file names +2. **See the magic** - Files appear in a spreadsheet, auto-parsed if already ZDDC +3. **Double-click to edit** - Just like Excel, or paste from a spreadsheet +4. **Click "Save All"** - All files renamed instantly +5. **That's it!** - Your files are now organized and searchable + +## ZDDC Naming Convention + +### File Format +``` +trackingNumber_revision (status) - title.extension +``` + +**Example:** +``` +CE-BYR-ALL-EL-SPC-0001_A (IFC) - Cable Specification.pdf +``` + +**Components:** +- **Tracking Number**: Project identifier (e.g., CE-BYR-ALL-EL-SPC-0001) +- **Revision**: Document revision (e.g., A, B, 0, 1, A+C1) +- **Status**: Status code (IFC, IFR, IFI, AFD, AFC, ASB, etc.) +- **Title**: Descriptive title +- **Extension**: File type (.pdf, .docx, .dwg, etc.) + +### Folder Format (Transmittal) +``` +YYYY-MM-DD_trackingNumber (status) - title +``` + +**Example:** +``` +2024-10-15_CE-BYR-ALL (IFC) - October Specifications +``` + +## How to Use + +### 1. Select Directory +Click "Select Directory" and choose the root folder containing files to organize. The tool will: +- Scan all subdirectories +- Expand all folders automatically +- Load all files into the spreadsheet +- Parse existing ZDDC filenames into editable fields (shown in gray) + +### 2. Navigate and Filter + +**Folder Tree:** +- All folders start expanded +- Click folder names to select/deselect +- Click ▼/▶ icons to collapse/expand +- Selected folders show their files in the spreadsheet + +**Filtering:** +- Type in column header filter boxes to filter rows +- Check "Hide Compliant Files" to focus on non-compliant files only +- Sort by clicking column headers (Shift+Click for multi-column sort) + +### 3. Edit Files + +**Spreadsheet Interface:** +- Auto-populated fields appear in gray italic text +- Double-click any cell to edit +- Press Enter or Tab to move to next cell +- Changes mark the row as modified (✓ ✗ buttons appear) + +**Excel Integration:** +1. Select cells (click and drag) +2. Copy (Ctrl+C) and paste into Excel +3. Edit in Excel (e.g., convert to proper case) +4. Copy from Excel and paste back (Ctrl+V) +5. Click "Save All" to apply all changes + +### 4. Save Changes + +**Individual Files:** +- Edit fields for a file +- Click ✓ button to save that file +- Click ✗ button to cancel changes + +**Batch Save:** +- Edit multiple files +- Click "Save All" button to rename all modified files at once +- Click "Cancel All" to discard all changes + +### 5. Preview Files +- Click any filename link to open the file in a new tab +- Works with PDFs, images, and browser-viewable files + +### 6. Keyboard Shortcuts +- **Tab**: Move to next cell +- **Shift+Tab**: Move to previous cell +- **Enter**: Move down one row +- **Escape**: Cancel editing +- **Ctrl+A** (in tree): Select all visible folders +- **Ctrl+S**: Save all (when files are modified) + +## Common Status Codes + +- **IFR** - Issued for Review +- **IFC** - Issued for Construction +- **IFI** - Issued for Information +- **AFD** - Approved for Design +- **AFC** - Approved for Construction +- **ASB** - As-Built + +## Real-World Examples + +### 🔧 Fix ALL CAPS Titles +Have files like "CABLE SPECIFICATION.PDF"? Use the Excel trick: +1. Select the Title column → Copy to Excel +2. Use `=PROPER(A1)` to fix casing +3. Paste back → Save All → Done! + +### 📝 Organize Random Files +Transform `spec001.pdf` → `CE-BYR-ALL-EL-SPC-0001_A (IFC) - Cable Specification.pdf` +- Just fill in the spreadsheet cells +- Copy/paste common values +- Save All when ready + +### 🔄 Update Revisions +Change revision A to B across multiple files: +- Gray text = current values +- Edit only what needs changing +- Batch save or save individually + +## Browser Compatibility + +Requires: +- Modern Chromium-based browser (Chrome, Edge, Brave, etc.) +- File System Access API support +- JavaScript enabled + +## Privacy & Security + +- All processing happens locally in your browser +- No data transmitted to any server +- File System Access API requires explicit user permission +- No tracking or analytics + +## Limitations + +### Folder Renaming +Due to browser API limitations, folders cannot be renamed directly. The tool will provide the correct folder name format for manual renaming in your file system. + +### File Preview +- PDFs open in browser tab +- Other file types download for viewing in native applications +- Browser cannot preview most file formats + +### File System Access +- Requires user permission for each directory +- Some file systems may have restrictions +- Network drives may not be fully supported + +## Tips for Efficient Use + +1. **Use Hide Compliant Filter**: Check to focus only on non-compliant files +2. **Excel Integration**: Copy/paste columns to Excel for bulk text operations +3. **Auto-Population**: Gray fields are auto-parsed - edit only what needs changing +4. **Column Sorting**: Click headers to sort, Shift+Click for multi-level sorting +5. **Column Filtering**: Type in header filter boxes to narrow down files +6. **Resize Columns**: Drag column borders to see full content +7. **Save All**: Edit multiple files then save all at once + +## Troubleshooting + +### "Permission denied" errors +- Ensure you've granted browser permission +- Try selecting the directory again +- Check file system permissions + +### Files not appearing +- Click "Refresh" button to rescan +- Ensure files aren't hidden by OS +- Check that directory handle is valid + +### Rename fails +- Check if file with that name already exists +- Ensure file isn't open in another application +- Verify you have write permissions + +### Gray fields not appearing +- Fields only auto-populate if filename matches ZDDC pattern +- Pattern: `TRACKING_REV (STATUS) - TITLE.ext` + +## Technical Details + +- **Architecture**: Single-page HTML application with centralized state management +- **API**: File System Access API (Chromium browsers only) +- **Build**: Concatenated from modular source files +- **No Dependencies**: Pure vanilla JavaScript +- **State Management**: Event-driven store pattern for predictable updates + +## Development + +Build: `sh build.sh` produces `dist/classifier.html` — a single self-contained file with all CSS, JS, and shared modules inlined. + +The canonical CSS and JS load order lives in `build.sh`. See the root `ARCHITECTURE.md` for the build system and module pattern, and `AGENTS.md` for shared helpers (`shared/zddc.js`, `shared/zddc-filter.js`, `shared/theme.js`, `shared/help.js`) and ZDDC parser API. + +## Design Philosophy + +This tool follows simple, reliable best practices: +- **Single Purpose**: Classify and rename files to ZDDC format +- **Spreadsheet Paradigm**: Familiar Excel-like interface for batch editing +- **Simplicity = Reliability**: Centralized state, unidirectional data flow +- **No Magic**: Deterministic, user-controlled operations with live validation +- **Portable**: Single HTML file, works offline, no server required +- **Excel Integration**: Copy/paste workflow for bulk text operations + +Use this tool to prepare files for the ZDDC Archive Browser. diff --git a/classifier/build.sh b/classifier/build.sh new file mode 100755 index 0000000..52d8df0 --- /dev/null +++ b/classifier/build.sh @@ -0,0 +1,120 @@ +#!/bin/sh +set -eu + +root_dir=$(cd "$(dirname "$0")" && pwd) +. "$root_dir/../shared/build-lib.sh" + +src_html="$root_dir/template.html" +output_dir="$root_dir/dist" +output_html="$output_dir/classifier.html" + +mkdir -p "$output_dir" +ensure_exists "$src_html" + +css_temp=$(mktemp) +js_raw=$(mktemp) +js_temp=$(mktemp) +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } +trap cleanup EXIT + +# CSS files to concatenate in order +concat_files \ + "../shared/fonts.css" \ + "../shared/base.css" \ + "../shared/toast.css" \ + "../shared/elevation.css" \ + "../shared/profile-menu.css" \ + "../shared/logo.css" \ + "css/base.css" \ + "css/layout.css" \ + "css/spreadsheet.css" \ + > "$css_temp" + +# JavaScript files to concatenate in order. Vendored libraries first +# (jszip, docx-preview) so window.JSZip + window.docx are defined before +# any tool code runs. Replaces the previous tag. Required for any tool with template literals. +escape_js_close_tags "$js_raw" "$js_temp" + +compute_build_label "classifier" "${1:-}" "${2:-}" + +# Process template: inject CSS/JS, substitute build label, strip CDN refs. +awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" ' + /\{\{CSS_PLACEHOLDER\}\}/ { + while ((getline line < css_file) > 0) print line + close(css_file) + next + } + /\{\{JS_PLACEHOLDER\}\}/ { + while ((getline line < js_file) > 0) print line + close(js_file) + next + } + /\{\{BUILD_LABEL\}\}/ { + if (is_red == "1") { + gsub(/\{\{BUILD_LABEL\}\}/, "" build_label "") + } else { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + } + print + next + } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } + / + + diff --git a/deploy b/deploy new file mode 100755 index 0000000..65db404 --- /dev/null +++ b/deploy @@ -0,0 +1,94 @@ +#!/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: /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. --exclude=.claude keeps local Claude Code + # tooling state (settings.json, settings.local.json, etc.) off the + # public site. + rsync -av --delete-after \ + --exclude='/releases/' \ + --exclude='/.git*' \ + --exclude='/.claude/' \ + --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/" diff --git a/dev-server b/dev-server new file mode 100755 index 0000000..7ee5805 --- /dev/null +++ b/dev-server @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +Development HTTP server with cache-busting headers +Supports start, status, and stop commands for process management. +""" + +import argparse +import os +import signal +import socketserver +import sys +import time +import urllib.request +import urllib.error +from http.server import SimpleHTTPRequestHandler +from pathlib import Path + +class NoCacheHTTPRequestHandler(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate') + self.send_header('Pragma', 'no-cache') + self.send_header('Expires', '0') + super().end_headers() + +class DevServer: + def __init__(self, port=8000, directory=None): + self.port = port + self.directory = directory or os.getcwd() + self.runtime_dir = self._get_runtime_dir() + # Port-specific PID files to allow multiple servers + self.pidfile = self.runtime_dir / f"zddc-dev-server-{port}.pid" + + def _get_runtime_dir(self): + """Get appropriate runtime directory for PID files""" + # Try user runtime directory first (systemd) + import getpass + uid = os.getuid() + user_runtime = Path(f"/run/user/{uid}") + if user_runtime.exists() and user_runtime.is_dir(): + runtime_dir = user_runtime / "zddc" + runtime_dir.mkdir(exist_ok=True) + return runtime_dir + + # Fall back to user's cache directory + home = Path.home() + cache_dir = home / ".cache" / "zddc" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + def get_server_pid(self): + """Get the PID of running server from pidfile""" + pid, _ = self._get_server_info() + return pid + + def _get_server_info(self): + """Get PID and serving directory from pidfile""" + if not self.pidfile.exists(): + return None, None + + try: + with open(self.pidfile, 'r') as f: + content = f.read().strip() + + # Handle different formats: PID, PID:PORT, PID:DIRECTORY + if ':' in content: + pid_str, rest = content.split(':', 1) + pid = int(pid_str) + # If rest is numeric, it's old PID:PORT format + try: + int(rest) + serving_dir = None # Old format, directory unknown + except ValueError: + serving_dir = rest # New PID:DIRECTORY format + else: + pid = int(content) + serving_dir = None # Old format, directory unknown + + # Check if process is actually running using /proc + if self._is_process_running(pid): + return pid, serving_dir + else: + # Clean up stale pidfile + print(f" Cleaning up stale PID file for process {pid}") + self._cleanup() + return None, None + + except (ValueError, IOError) as e: + print(f" Error reading PID file: {e}") + self._cleanup() + return None, None + + def _is_process_running(self, pid): + """Check if a process with given PID is running and is our process""" + try: + # Check if process exists + with open(f"/proc/{pid}/comm", 'r') as f: + comm = f.read().strip() + + # Verify it's a python process + if 'python' not in comm: + return False + + # Check command line to verify it's our dev-server + try: + with open(f"/proc/{pid}/cmdline", 'r') as f: + cmdline = f.read() + return 'dev-server.py' in cmdline + except (FileNotFoundError, PermissionError): + # If we can't read cmdline, assume it's our process if it's python + return True + + except (FileNotFoundError, PermissionError): + return False + + def is_server_running(self): + """Check if server is running by making HTTP request""" + try: + with urllib.request.urlopen(f"http://localhost:{self.port}/", timeout=2) as response: + return response.status == 200 or response.status == 403 # 403 for directory listing disabled + except (urllib.error.URLError, urllib.error.HTTPError, OSError): + return False + + def start(self, daemon=False): + """Start the development server""" + # Check if server is already running on this port + existing_pid = self.get_server_pid() + if existing_pid: + print(f"Server is already running (PID: {existing_pid}) at http://localhost:{self.port}") + return True # Exit without error as requested + + # Check if port is in use by another process + if self.is_server_running(): + print(f"Port {self.port} is already in use by another process") + return False + + if daemon: + self._start_daemon() + else: + self._start_foreground() + + return True + + def _start_foreground(self): + """Start server in foreground mode""" + print(f"Starting dev server on port {self.port}...") + print(f"Serving directory: {self.directory}") + print("Press Ctrl+C to stop") + + # Change to the specified directory + try: + os.chdir(self.directory) + except OSError as e: + print(f"Failed to change to directory {self.directory}: {e}") + return False + + # Set up signal handler for graceful shutdown + def signal_handler(signum, frame): + print("\nShutting down server...") + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + httpd = socketserver.TCPServer(("", self.port), NoCacheHTTPRequestHandler) + httpd.serve_forever() + except OSError as e: + if e.errno == 98: # Address already in use + print(f"Port {self.port} is already in use") + else: + print(f"Error starting server: {e}") + return False + except KeyboardInterrupt: + print("\nServer stopped") + return True + except Exception as e: + print(f"Error starting server: {e}") + finally: + self._cleanup() + + def _start_daemon(self): + """Start server in daemon mode (background)""" + try: + # Fork the first time (detach from parent) + pid = os.fork() + if pid > 0: + # Parent process - wait a moment then exit + time.sleep(0.5) + return True + except OSError as e: + print(f"Fork #1 failed: {e}") + return False + + # Change to the specified directory before daemonizing + try: + os.chdir(self.directory) + except OSError as e: + print(f"Failed to change to directory {self.directory}: {e}") + return False + + # Decouple from parent environment + os.setsid() + os.umask(0) + + # Fork the second time (prevent zombie processes) + try: + pid = os.fork() + if pid > 0: + # Parent process - exit immediately + os._exit(0) + except OSError as e: + print(f"Fork #2 failed: {e}") + os._exit(1) + + # Redirect standard file descriptors + sys.stdout.flush() + sys.stderr.flush() + + with open('/dev/null', 'r') as si: + os.dup2(si.fileno(), sys.stdin.fileno()) + with open('/dev/null', 'w') as so: + os.dup2(so.fileno(), sys.stdout.fileno()) + with open('/dev/null', 'w') as se: + os.dup2(se.fileno(), sys.stderr.fileno()) + + # Write PID to file with directory information + try: + with open(self.pidfile, 'w') as f: + f.write(f"{os.getpid()}:{self.directory}") + print(f"Dev server started on port {self.port} serving {self.directory}") + return True + except IOError as e: + print(f"Failed to write PID file: {e}") + return False + + # Start the server + try: + with socketserver.TCPServer(("", self.port), NoCacheHTTPRequestHandler) as httpd: + httpd.serve_forever() + except Exception: + # In daemon mode, errors just cause the process to exit + pass + finally: + self._cleanup() + + def status(self): + """Check server status by testing HTTP connection""" + # Check for stale PID files first + self._cleanup_if_stale() + + pid, serving_dir = self._get_server_info() + if pid: + # Test if the server is actually responding on its port + server_responding = self.is_server_running() + + print(f"Dev server is running") + print(f" PID: {pid}") + print(f" Port: {self.port}") + print(f" URL: http://localhost:{self.port}") + print(f" Directory: {serving_dir or 'Unknown (old PID file format)'}") + print(f" PID file: {self.pidfile}") + + if server_responding: + print(f" Status: Responding to HTTP requests") + else: + print(f" Status: Process running but not responding to HTTP (may be starting up)") + + # Get process uptime + try: + uptime_seconds = self._get_process_uptime(pid) + print(f" Uptime: {self._format_uptime(uptime_seconds)}") + except (FileNotFoundError, IndexError, ValueError): + print(f" Uptime: Unable to determine") + + return True + else: + print("Dev server is not running") + return False + + def _get_process_uptime(self, pid): + """Get the actual uptime of a process in seconds""" + with open(f"/proc/{pid}/stat", 'r') as f: + stat_data = f.read().split() + starttime_ticks = int(stat_data[21]) # Process start time in ticks since boot + + # Get system clock ticks per second + clock_ticks = os.sysconf(os.sysconf_names['SC_CLK_TCK']) + + # Get system boot time + with open("/proc/stat", 'r') as f: + for line in f: + if line.startswith('btime '): + boot_time = int(line.split()[1]) + break + + # Calculate process start time in seconds since epoch + process_start_time = boot_time + (starttime_ticks / clock_ticks) + + # Calculate uptime + return time.time() - process_start_time + + + + def _cleanup_if_stale(self): + """Check for and clean up stale PID files""" + if self.pidfile.exists(): + try: + with open(self.pidfile, 'r') as f: + content = f.read().strip() + + # Handle different formats: PID, PID:PORT, PID:DIRECTORY + if ':' in content: + pid = int(content.split(':', 1)[0]) + else: + pid = int(content) + + if not self._is_process_running(pid): + print(f" Found stale PID file for process {pid}, cleaning up") + self._cleanup() + except (ValueError, IOError): + print(f" Found corrupted PID file, cleaning up") + self._cleanup() + + def stop(self): + """Stop the development server""" + pid = self.get_server_pid() + if not pid: + if self.is_server_running(): + print("Server is running but not managed by this script") + print("Cannot stop server started by another process") + return False + else: + print("Dev server is not running") + return False + + try: + print(f"Stopping dev server (PID: {pid})...") + os.kill(pid, signal.SIGTERM) + + # Wait for process to stop + for i in range(30): # Wait up to 3 seconds + time.sleep(0.1) + try: + os.kill(pid, 0) # Test if process still exists + except OSError: + # Process no longer exists + break + else: + # Force kill if still running + print("Process didn't stop gracefully, force killing...") + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass # Process might have died already + + self._cleanup() + print("Dev server stopped") + return True + + except OSError as e: + print(f"Error stopping server: {e}") + self._cleanup() + return False + + def _cleanup(self): + """Clean up PID file""" + if self.pidfile.exists(): + try: + self.pidfile.unlink() + except OSError as e: + print(f"Warning: Could not remove PID file {self.pidfile}: {e}") + + def _format_uptime(self, seconds): + """Format uptime in human readable format""" + if seconds < 60: + return f"{int(seconds)}s" + elif seconds < 3600: + return f"{int(seconds // 60)}m {int(seconds % 60)}s" + else: + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + return f"{hours}h {minutes}m" + +def main(): + parser = argparse.ArgumentParser( + description="ZDDC Development Server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Commands: + start Start the development server (default) + status Show server status + stop Stop the development server + +Examples: + %(prog)s # Start server in foreground (default) + %(prog)s start -d # Start server in background + %(prog)s status # Check if server is running + %(prog)s stop # Stop the server + %(prog)s -p 8080 start # Start on port 8080 + %(prog)s start ~/docs # Start serving ~/docs directory + %(prog)s -p 9000 ~/src # Start serving ~/src on port 9000""" + ) + + parser.add_argument('command', nargs='?', default='start', + choices=['start', 'status', 'stop'], + help='Command to execute (default: start)') + parser.add_argument('-p', '--port', type=int, default=8000, + help='Port to run server on (default: 8000)') + parser.add_argument('-d', '--daemon', action='store_true', + help='Run server in background (daemon mode)') + parser.add_argument('directory', nargs='?', default=None, + help='Directory to serve (default: current directory)') + + args = parser.parse_args() + + server = DevServer(port=args.port, directory=args.directory) + + if args.command == 'start': + if not server.start(daemon=args.daemon): + sys.exit(1) + # After starting (or if already running), show status + if not server.status(): + sys.exit(1) + elif args.command == 'status': + if not server.status(): + sys.exit(1) + elif args.command == 'stop': + if not server.stop(): + sys.exit(1) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nOperation cancelled") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) diff --git a/form/build.sh b/form/build.sh new file mode 100755 index 0000000..b373844 --- /dev/null +++ b/form/build.sh @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +root_dir=$(cd "$(dirname "$0")" && pwd) +. "$root_dir/../shared/build-lib.sh" + +src_html="$root_dir/template.html" +output_dir="$root_dir/dist" +output_html="$output_dir/form.html" + +mkdir -p "$output_dir" +ensure_exists "$src_html" + +css_temp=$(mktemp) +js_raw=$(mktemp) +js_temp=$(mktemp) +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } +trap cleanup EXIT + +concat_files \ + "../shared/fonts.css" \ + "../shared/base.css" \ + "../shared/toast.css" \ + "../shared/elevation.css" \ + "../shared/profile-menu.css" \ + "../shared/logo.css" \ + "css/form.css" \ + > "$css_temp" + +concat_files \ + "../shared/theme.js" \ + "../shared/toast.js" \ + "../shared/logo.js" \ + "../shared/help.js" \ + "../shared/elevation.js" \ + "../shared/profile-menu.js" \ + "../shared/cap.js" \ + "js/app.js" \ + "js/context.js" \ + "js/util.js" \ + "js/widgets.js" \ + "js/object.js" \ + "js/array.js" \ + "js/render.js" \ + "js/serialize.js" \ + "js/errors.js" \ + "js/post.js" \ + "js/main.js" \ + > "$js_raw" + +escape_js_close_tags "$js_raw" "$js_temp" + +compute_build_label "form" "${1:-}" "${2:-}" + +awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" ' + /\{\{CSS_PLACEHOLDER\}\}/ { + while ((getline line < css_file) > 0) print line + close(css_file) + next + } + /\{\{JS_PLACEHOLDER\}\}/ { + while ((getline line < js_file) > 0) print line + close(js_file) + next + } + /\{\{BUILD_LABEL\}\}/ { + if (is_red == "1") { + gsub(/\{\{BUILD_LABEL\}\}/, "" build_label "") + } else { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + } + print + next + } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } + / + + + + diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 0000000..3a51543 --- /dev/null +++ b/helm/README.md @@ -0,0 +1,150 @@ +# Helm charts + +Three example charts for deploying [zddc-server](../zddc/) on Kubernetes. +All compile zddc-server from source via an init container — no +container image needs to be pulled from a registry, and no binary needs +to be built ahead of time. The init container clones the repo at a +configured git ref and runs `go build`; the main container is plain +alpine + the freshly built static binary. + +## Charts + +| Chart | When to use | +|---|---| +| **`zddc-server-prod/`** | Production **master**. Pin `zddc.gitRef` to a stable tag (`zddc-server-vX.Y.Z`). Slower probe cadence; image-pull policy `IfNotPresent`. Mounts the data PVC directly RW at `ZDDC_ROOT`. The token system is enabled automatically (tokens persist on the data PVC at `/.zddc.d/tokens/`); operators visit `/.tokens` to issue them. | +| **`zddc-server-dev/`** | Development / soak **master**. Tracks `main` by default; `helm upgrade` triggers a pod recreate so each rollout pulls the latest commit. Faster probes; debug-level logging (request headers logged — sensitive). Wraps the data PVC in **OverlayFS** (lower = PVC mounted RO, upper = ephemeral `emptyDir`) so dev-side writes never mutate the underlying store. Use this shape when the dev replica points at the same data as prod. | +| **`zddc-server-cache/`** | Downstream **client** (proxy / cache / mirror) of an upstream master. Set `zddc.upstream.url` + `zddc.upstream.mode`; the binary skips master-side machinery and forwards all requests to the master, persisting responses under the cache PVC (in cache or mirror modes). Bearer auth via a separately-created Kubernetes Secret. Use cases: corporate-master → DR-mirror, vendor-scoped mirror in a vendor's own cluster, regional edge cache, dev environment that mirrors prod read-only. Mirror mode adds an access-triggered subtree walker. | + +The prod and dev chart values are nearly identical; the differences +are encoded as defaults in each chart's `values.yaml.example`. The +dev chart's overlay-isolation layer is a structural difference, not a +values-level toggle — see `zddc-server-dev/templates/deployment.yaml` +for the privileged init container and the `data-readonly` / +`overlay-scratch` / `data` volume sandwich. + +The cache chart shares the same source-build pattern but adds +client-mode env wiring (`ZDDC_UPSTREAM`, `ZDDC_MODE`, `ZDDC_BEARER_FILE`, +`ZDDC_NO_AUTH`, `ZDDC_SKIP_TLS_VERIFY`, mirror-mode subtree config), +a Recreate strategy (single-instance — multiple replicas would race +the cache directory), and TCP-socket probes (HTTP probes against `/` +would fail when both upstream is down AND the cache is empty). + +## Quick start + +```sh +# Pre-requisite: a PersistentVolumeClaim for ZDDC_ROOT data +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: zddc-root +spec: + accessModes: [ReadWriteMany] # or RWO if single replica is fine + resources: { requests: { storage: 100Gi } } + storageClassName: your-shared-fs # NFS, CephFS, SMB, etc. +EOF + +# Production install +cp helm/zddc-server-prod/values.yaml.example my-prod-values.yaml +$EDITOR my-prod-values.yaml # set zddc.gitRef, hostnames, etc. +helm install zddc-server-prod helm/zddc-server-prod/ -f my-prod-values.yaml + +# Dev install (tracks main HEAD) +cp helm/zddc-server-dev/values.yaml.example my-dev-values.yaml +$EDITOR my-dev-values.yaml +helm install zddc-server-dev helm/zddc-server-dev/ -f my-dev-values.yaml + +# Trigger a rebuild from latest main HEAD (dev chart) +helm upgrade zddc-server-dev helm/zddc-server-dev/ -f my-dev-values.yaml + +# Cache install (downstream client of an upstream master) +# +# 1) Issue a bearer token on the master at https:///.tokens +# 2) Create the Secret (do NOT put the token in values.yaml): +kubectl create secret generic zddc-cache-bearer \ + --from-literal=token= + +# 3) Create a cache PVC (separate from the master's data PVC; can +# be smaller — sized to the working set you expect to mirror): +kubectl apply -f - <<'PVC' +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: { name: zddc-cache } +spec: + accessModes: [ReadWriteOnce] + resources: { requests: { storage: 50Gi } } + storageClassName: your-block-storage +PVC + +# 4) Install the chart, pointing at your master: +cp helm/zddc-server-cache/values.yaml.example my-cache-values.yaml +$EDITOR my-cache-values.yaml # set zddc.upstream.url, mode, etc. +helm install zddc-server-cache helm/zddc-server-cache/ -f my-cache-values.yaml +``` + +## What the chart does and doesn't do + +**Does:** + +- Clones the configured `zddc.gitRepo` at `zddc.gitRef` in an init + container, builds the Go binary, copies it to a shared `emptyDir`, + and starts the main container against that binary. +- Wires the `ZDDC_*` environment-variable contract (root path, addr, + email header, CORS allowlist, log level, index path). +- Mounts a caller-supplied PersistentVolumeClaim at `ZDDC_ROOT` (prod + chart) or as the OverlayFS lowerdir behind a merged `ZDDC_ROOT` + (dev chart). +- Optionally creates an Ingress (`ingress.enabled: true`). + +**Does not:** + +- Create the PVC. Operators provision storage themselves; the chart + only references it by name. +- Manage TLS for the pod. zddc-server runs in plain HTTP mode behind + whatever ingress / authenticating reverse proxy the cluster already + has. `ZDDC_TLS_CERT=none` and `ZDDC_INSECURE_DIRECT=1` are hardcoded + in the templates because the chart is opinionated about the + TLS-terminated-upstream deployment shape. +- Authenticate users. zddc-server reads the user's email from a header + set by the upstream proxy (`X-Auth-Request-Email` by default). The + chart does not deploy oauth2-proxy / nginx-auth-request / Pomerium / + etc. — bring your own. +- Manage secrets. `values.yaml.example` contains no secrets and never + should. ACL email lists belong in `.zddc` files inside the data + volume; image-pull credentials and TLS certs (if you enable ingress + TLS) reference Kubernetes secrets you've created separately. + +## Why build from source instead of using a registry image + +Three reasons: + +1. **Reproducibility.** The init container's logs show exactly which + git ref was built. There's no opaque "what did I deploy" question + that a registry tag can introduce. +2. **One distribution channel.** Codeberg release-asset binaries + already exist for direct downloads; the chart compiles its own + binary from the same source git ref so there's nothing extra to + maintain (no separate image registry, no image-promotion pipeline). +3. **Smaller blast radius.** A compromised build image affects only + pods that pull during the compromise window. A compromised registry + image stays compromised across rollbacks until the digest is rotated. + +The cost: every pod start takes 30-60s to clone + `go build` instead +of pulling a pre-baked image. Acceptable for both chart audiences +(production rollouts are infrequent; dev rollouts trade build time +for tracking-main convenience). + +## Linting + +```sh +helm lint helm/zddc-server-prod/ +helm lint helm/zddc-server-dev/ +helm lint helm/zddc-server-cache/ + +# Render to inspect (uses default values from values.yaml.example): +helm template test-prod helm/zddc-server-prod/ \ + --values helm/zddc-server-prod/values.yaml.example + +helm template test-cache helm/zddc-server-cache/ \ + --values helm/zddc-server-cache/values.yaml.example +``` diff --git a/helm/zddc-server-cache/Chart.yaml b/helm/zddc-server-cache/Chart.yaml new file mode 100644 index 0000000..a89bfef --- /dev/null +++ b/helm/zddc-server-cache/Chart.yaml @@ -0,0 +1,32 @@ +apiVersion: v2 +name: zddc-server-cache +description: | + Downstream cache / mirror deployment of zddc-server. Compiles from + source via an init container at deploy time (no image pull from a + registry); the main container is alpine + the freshly-built binary. + Runs in client mode against an upstream zddc-server master, caching + every accessed file (and, in mirror mode, proactively walking + configured subtrees). + + Use cases: corporate-master → DR-mirror, vendor-scoped mirror in a + vendor's own cluster, regional edge cache, dev/staging environment + that mirrors prod. Distinct from `zddc-server-prod` (which IS a + master) and `zddc-server-dev` (a master with overlay isolation). + + TLS upstream is verified by default (set --skip-tls-verify only for + self-signed dev masters or internal CAs you haven't yet added to + the trust store). +type: application +version: 0.1.0 +appVersion: "0.0.7" # zddc-server git tag this chart was last verified against +home: https://zddc.varasys.io/ +sources: + - https://codeberg.org/VARASYS/ZDDC +maintainers: + - name: VARASYS +keywords: + - zddc + - cache + - mirror + - file-server + - document-control diff --git a/helm/zddc-server-cache/templates/_helpers.tpl b/helm/zddc-server-cache/templates/_helpers.tpl new file mode 100644 index 0000000..eaeaa72 --- /dev/null +++ b/helm/zddc-server-cache/templates/_helpers.tpl @@ -0,0 +1,33 @@ +{{/* +Common labels and the fullname helper. Stays minimal; chart consumers +who want richer labels can override via metadata.labels in their +values.yaml or post-render kustomize. +*/}} + +{{- define "zddc-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "zddc-server.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "zddc-server.labels" -}} +app.kubernetes.io/name: {{ include "zddc-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Values.zddc.gitRef | quote }} +app.kubernetes.io/component: cache +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "zddc-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "zddc-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: cache +{{- end -}} diff --git a/helm/zddc-server-cache/templates/deployment.yaml b/helm/zddc-server-cache/templates/deployment.yaml new file mode 100644 index 0000000..cc6c87b --- /dev/null +++ b/helm/zddc-server-cache/templates/deployment.yaml @@ -0,0 +1,162 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + # Cache writes serialize through the local filesystem; running two + # replicas would race the cache directory + double the upstream + # walker traffic. Recreate strategy ensures only one pod holds the + # cache PVC at a time. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "zddc-server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "zddc-server.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: zddc-bin + emptyDir: {} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.data.pvcName }} + {{- if .Values.bearer.secretName }} + - name: bearer + secret: + secretName: {{ .Values.bearer.secretName | quote }} + defaultMode: 0400 + items: + - key: {{ .Values.bearer.secretKey | quote }} + path: token + {{- end }} + initContainers: + # Build zddc-server from the pinned git ref. Same flow as the + # master charts — the binary is the same; client mode is + # selected at runtime via ZDDC_UPSTREAM. + - name: build-zddc-server + image: {{ printf "%s:%s" .Values.buildImage.repository .Values.buildImage.tag | quote }} + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + set -eu + apk add --no-cache git + git clone --depth 1 --branch "$GIT_REF" "$GIT_REPO" /workspace + cd /workspace/zddc + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath \ + -ldflags="-s -w -X main.version=$GIT_REF" \ + -o /out/zddc-server \ + ./cmd/zddc-server + echo "built /out/zddc-server from $GIT_REF" + env: + - name: GIT_REPO + value: {{ .Values.zddc.gitRepo | quote }} + - name: GIT_REF + value: {{ .Values.zddc.gitRef | quote }} + volumeMounts: + - name: zddc-bin + mountPath: /out + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + containers: + - name: zddc-server + image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }} + imagePullPolicy: IfNotPresent + command: ["/zddc/zddc-server"] + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: ZDDC_ROOT + value: {{ .Values.zddc.env.rootPath | quote }} + - name: ZDDC_ADDR + value: {{ .Values.zddc.env.addr | quote }} + - name: ZDDC_TLS_CERT + value: "none" + - name: ZDDC_INSECURE_DIRECT + value: "1" + - name: ZDDC_EMAIL_HEADER + value: {{ .Values.zddc.env.emailHeader | quote }} + - name: ZDDC_CORS_ORIGIN + value: {{ .Values.zddc.env.corsOrigin | quote }} + - name: ZDDC_LOG_LEVEL + value: {{ .Values.zddc.env.logLevel | quote }} + - name: ZDDC_INDEX_PATH + value: {{ .Values.zddc.env.indexPath | quote }} + {{- if .Values.zddc.env.noAuth }} + - name: ZDDC_NO_AUTH + value: "1" + {{- end }} + # Client-mode flags. ZDDC_UPSTREAM activates client mode + # in cmd/zddc-server/main.go's runClient short-circuit. + - name: ZDDC_UPSTREAM + value: {{ .Values.zddc.upstream.url | quote }} + - name: ZDDC_MODE + value: {{ .Values.zddc.upstream.mode | quote }} + {{- if .Values.zddc.upstream.skipTLSVerify }} + - name: ZDDC_SKIP_TLS_VERIFY + value: "1" + {{- end }} + {{- if .Values.bearer.secretName }} + - name: ZDDC_BEARER_FILE + value: "/etc/zddc/bearer/token" + {{- end }} + {{- if eq .Values.zddc.upstream.mode "mirror" }} + {{- with .Values.zddc.upstream.mirrorSubtree }} + - name: ZDDC_MIRROR_SUBTREE + value: {{ . | quote }} + {{- end }} + {{- with .Values.zddc.upstream.mirrorMinInterval }} + - name: ZDDC_MIRROR_MIN_INTERVAL + value: {{ . | quote }} + {{- end }} + {{- end }} + volumeMounts: + - name: zddc-bin + mountPath: /zddc + - name: data + mountPath: {{ .Values.zddc.env.rootPath }} + {{- with .Values.data.subPath }} + subPath: {{ . | quote }} + {{- end }} + {{- if .Values.bearer.secretName }} + - name: bearer + mountPath: /etc/zddc/bearer + readOnly: true + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + # TCP-socket probes only — HTTP probes against `/` would + # fail when both upstream is unreachable AND the cache is + # empty (the cache layer returns 503 in that state). TCP + # probes verify the server process is alive without + # depending on upstream reachability or cache contents. + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 5 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + timeoutSeconds: 3 diff --git a/helm/zddc-server-cache/templates/ingress.yaml b/helm/zddc-server-cache/templates/ingress.yaml new file mode 100644 index 0000000..bcd3d72 --- /dev/null +++ b/helm/zddc-server-cache/templates/ingress.yaml @@ -0,0 +1,29 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "zddc-server.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/zddc-server-cache/templates/service.yaml b/helm/zddc-server-cache/templates/service.yaml new file mode 100644 index 0000000..8ded8b6 --- /dev/null +++ b/helm/zddc-server-cache/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "zddc-server.selectorLabels" . | nindent 4 }} diff --git a/helm/zddc-server-cache/values.yaml.example b/helm/zddc-server-cache/values.yaml.example new file mode 100644 index 0000000..104629a --- /dev/null +++ b/helm/zddc-server-cache/values.yaml.example @@ -0,0 +1,159 @@ +# values.yaml.example — zddc-server-cache +# +# Copy to values.yaml (or pass via --values) and customize for your +# environment. Contains NO secrets — the upstream bearer token MUST be +# provided via a separately-created Kubernetes Secret (see `bearer:` +# below). Do not paste the token value here. + +# Source-build configuration. The init container clones the repo at +# `gitRef` and compiles cmd/zddc-server. Pin gitRef to a stable tag +# (zddc-server-vX.Y.Z) for production caches; tracking main is fine +# for dev mirrors. +zddc: + gitRepo: https://codeberg.org/VARASYS/ZDDC.git + gitRef: zddc-server-v0.0.7 # pin to a stable tag + + # ZDDC environment-variable contract — see zddc/README.md "Client mode". + env: + # Local cache directory (mounted from the cache PVC; see `data:` + # below). The cache layer writes files here as they're fetched. + rootPath: /srv + + # Listening address for incoming requests to this cache instance. + # Plain HTTP — ingress / mesh terminates TLS upstream of the pod. + # + # NOTE: in client mode the binary refuses to start with a non- + # loopback bind AND a configured bearer UNLESS ZDDC_INSECURE_DIRECT=1 + # is also set. The cache forwards the bearer to upstream without + # authenticating the local caller, so a bare bind would be an open + # proxy. The chart's deployment.yaml sets ZDDC_INSECURE_DIRECT=1 + # and relies on the Kubernetes-namespaced pod network + ingress + # auth proxy for that gating. If you remove either you must + # redirect the bind to 127.0.0.1. + addr: ":8080" + + # Email-header convention from your authenticating reverse proxy. + # Used for AccessLog only in client mode (auth flows to upstream + # as a bearer; the cache layer doesn't enforce ACL locally when + # noAuth: true). + emailHeader: X-Auth-Request-Email + + # CORS allowlist for the local instance. Same semantics as the + # master chart — empty disables CORS, which is the right default + # for embedded-tools / same-origin browsing. + corsOrigin: "" + + # info / warn / error / debug. + logLevel: info + + indexPath: ".archive" + + # Skip ACL enforcement on incoming requests. Almost always true + # for a personal/field-engineer cache (the laptop is single-user- + # trust and the upstream master already filtered). Set to false + # only if you've put your own auth proxy in front of this cache + # AND want it to re-evaluate ACLs against cached `.zddc` files. + noAuth: true + + # Upstream master configuration. + upstream: + # The master URL. Required. Don't include a trailing slash. + url: "https://zddc.example.com" + + # proxy / cache / mirror. + # proxy — forward live, no disk persistence + # cache — persist responses on access (default; field-engineer use) + # mirror — cache + access-triggered subtree warmer (vendor / + # backup / complete-offline use) + mode: cache + + # Accept self-signed / untrusted upstream TLS certs. Distinct from + # noAuth. Use only for dev masters with self-signed certs or for + # internal CAs your cluster's trust store doesn't yet have. + skipTLSVerify: false + + # Mirror-mode only. Comma-separated URL subtrees the access- + # triggered walker keeps current. Empty + mode=mirror = full + # mirror ("/"). Ignored when mode != mirror. + mirrorSubtree: "" + + # Mirror-mode only. Min gap between walks of the same subtree. + # Idle subtrees generate zero upstream traffic until next access. + # Default 1h. + mirrorMinInterval: 1h + +# Bearer token — required when the upstream master enforces auth. +# Create a Secret separately (do NOT paste the token here): +# +# 1. On the master, sign in via your auth proxy and visit +# https:///.tokens to issue a token. +# 2. Wrap it in a Kubernetes Secret: +# +# kubectl create secret generic zddc-cache-bearer \ +# --from-literal=token= +# +# 3. Reference the Secret here. +# +# Set `secretName: ""` to disable bearer auth (only valid when the +# upstream is `--no-auth` or behind your own auth proxy that doesn't +# require bearer auth from internal callers). +bearer: + secretName: zddc-cache-bearer + secretKey: token + +# Cache-storage PVC. Sized for the working set you expect to mirror — +# can be smaller than the master's data volume since only accessed +# files (or, in mirror mode, files under configured subtrees) get +# cached. Operators provision the PVC themselves; this chart only +# references it by name. ReadWriteOnce is fine — the cache is single- +# instance by design. +data: + pvcName: zddc-cache # name of an existing PersistentVolumeClaim + subPath: "" + +# Service exposure. The cache listens on a plain HTTP port; ingress +# (or mesh sidecar) terminates TLS and forwards to this service. +service: + type: ClusterIP + port: 8080 + +# Ingress is optional — disabled by default since most cache +# deployments wire into an existing ingress / auth-proxy stack. +ingress: + enabled: false + className: "" + host: zddc-cache.example.com + tls: + enabled: false + secretName: zddc-cache-tls + +# Pod resource limits. Cache instances are mostly I/O bound; the +# defaults below suit a small mirror (~1k files in working set). +# Bump cpu/memory for mirror mode against larger trees. +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# Replicas. Cache instances are single-instance by design — multiple +# replicas would race on writes to the same cache directory and +# duplicate the upstream walker traffic. Use a separate cache +# deployment per region/tenant if you need fan-out. +replicaCount: 1 + +# Build-stage Go image (init container). +buildImage: + repository: docker.io/golang + tag: 1.24-alpine + +# Runtime image (main container). +runtimeImage: + repository: docker.io/alpine + tag: "3.19" + +# Image pull credentials, if your registry requires them. +imagePullSecrets: [] +# - name: regcred diff --git a/helm/zddc-server-dev/Chart.yaml b/helm/zddc-server-dev/Chart.yaml new file mode 100644 index 0000000..5c0b07d --- /dev/null +++ b/helm/zddc-server-dev/Chart.yaml @@ -0,0 +1,23 @@ +apiVersion: v2 +name: zddc-server-dev +description: | + Development deployment of zddc-server. Tracks main HEAD (rebuilt on + every pod restart), runs at debug log level, has faster probe + cadence for quick feedback, and uses smaller resource limits. + Wraps the data PVC in OverlayFS — lower (PVC) mounted RO, upper an + ephemeral emptyDir — so dev-side writes never mutate the underlying + store, making it safe to point at the same data PVC as prod. + Intended for ephemeral / soak / preview environments — not for + production traffic. +type: application +version: 0.1.0 +appVersion: "main" +home: https://zddc.varasys.io/ +sources: + - https://codeberg.org/VARASYS/ZDDC +maintainers: + - name: VARASYS +keywords: + - zddc + - file-server + - development diff --git a/helm/zddc-server-dev/templates/_helpers.tpl b/helm/zddc-server-dev/templates/_helpers.tpl new file mode 100644 index 0000000..8512572 --- /dev/null +++ b/helm/zddc-server-dev/templates/_helpers.tpl @@ -0,0 +1,31 @@ +{{/* +Common labels and the fullname helper. Stays minimal; chart consumers +who want richer labels can override via metadata.labels in their +values.yaml or post-render kustomize. +*/}} + +{{- define "zddc-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "zddc-server.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "zddc-server.labels" -}} +app.kubernetes.io/name: {{ include "zddc-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Values.zddc.gitRef | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "zddc-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "zddc-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/helm/zddc-server-dev/templates/deployment.yaml b/helm/zddc-server-dev/templates/deployment.yaml new file mode 100644 index 0000000..63b2033 --- /dev/null +++ b/helm/zddc-server-dev/templates/deployment.yaml @@ -0,0 +1,179 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + # Dev: always re-pull the build image and re-clone source, so a kubectl + # rollout restart picks up new commits on the tracked ref. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "zddc-server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "zddc-server.selectorLabels" . | nindent 8 }} + annotations: + # Forces pod recreation on every helm upgrade, ensuring the init + # container re-clones the tracked ref. Useful in dev where you + # want `helm upgrade` to pick up new main HEAD without changing + # values. + zddc.varasys.io/build-time: {{ now | quote }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: zddc-bin + emptyDir: {} + # Production data volume — mounted READ-ONLY so the dev pod + # cannot corrupt prod even with a bug. Becomes the lowerdir of + # the OverlayFS mount below. + - name: data-readonly + persistentVolumeClaim: + claimName: {{ .Values.data.pvcName }} + readOnly: true + # Writable scratch for OverlayFS upperdir + workdir. emptyDir + # is ephemeral by default — dev tweaks evaporate on pod restart, + # which is usually right for a dev replica. Replace with a + # small PVC if persistence across restarts matters. + - name: overlay-scratch + emptyDir: {} + # The composed read-write view zddc-server reads from. Populated + # by the setup-overlay init container; passed through to the main + # container as ZDDC_ROOT. + - name: data + emptyDir: {} + initContainers: + # OverlayFS sandwich: + # lowerdir = /mnt/data-readonly (prod data, RO) + # upperdir = /mnt/overlay-scratch/upper + # workdir = /mnt/overlay-scratch/work + # merged = /mnt/data (what main container sees) + # + # Why this exists: dev runs against the same on-disk dataset as + # prod, but its writes (anything zddc-server writes — index + # state, form submissions during testing, .zddc edits via the + # admin page, etc.) MUST NOT mutate prod data. OverlayFS solves + # this at the filesystem layer: prod data is RO, dev's writes + # land in upperdir, the dev container sees the merged view. No + # zddc-server code change required. + # + # Requires CAP_SYS_ADMIN (the overlay mount syscall is + # privileged). Stays scoped to this one init container; the main + # container runs without elevated privs. + - name: setup-overlay + image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }} + securityContext: + privileged: true + command: ["/bin/sh", "-c"] + args: + - | + set -eu + mkdir -p /mnt/overlay-scratch/upper /mnt/overlay-scratch/work + mount -t overlay overlay \ + -o lowerdir=/mnt/data-readonly,upperdir=/mnt/overlay-scratch/upper,workdir=/mnt/overlay-scratch/work \ + /mnt/data + echo "OverlayFS mounted: /mnt/data-readonly (RO) + /mnt/overlay-scratch (RW) -> /mnt/data" + ls -la /mnt/data | head -10 + volumeMounts: + - name: data-readonly + mountPath: /mnt/data-readonly + readOnly: true + - name: overlay-scratch + mountPath: /mnt/overlay-scratch + - name: data + mountPath: /mnt/data + mountPropagation: Bidirectional + - name: build-zddc-server + image: {{ printf "%s:%s" .Values.buildImage.repository .Values.buildImage.tag | quote }} + imagePullPolicy: Always + command: ["/bin/sh", "-c"] + args: + - | + set -eu + apk add --no-cache git + git clone --depth 1 --branch "$GIT_REF" "$GIT_REPO" /workspace + cd /workspace/zddc + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath \ + -ldflags="-s -w -X main.version=$GIT_REF" \ + -o /out/zddc-server \ + ./cmd/zddc-server + echo "built /out/zddc-server from $GIT_REF ($(git -C /workspace rev-parse --short HEAD))" + env: + - name: GIT_REPO + value: {{ .Values.zddc.gitRepo | quote }} + - name: GIT_REF + value: {{ .Values.zddc.gitRef | quote }} + volumeMounts: + - name: zddc-bin + mountPath: /out + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1000m + memory: 512Mi + containers: + - name: zddc-server + image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }} + imagePullPolicy: IfNotPresent + command: ["/zddc/zddc-server"] + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: ZDDC_ROOT + value: {{ .Values.zddc.env.rootPath | quote }} + - name: ZDDC_ADDR + value: {{ .Values.zddc.env.addr | quote }} + - name: ZDDC_TLS_CERT + value: "none" + - name: ZDDC_INSECURE_DIRECT + value: "1" + - name: ZDDC_EMAIL_HEADER + value: {{ .Values.zddc.env.emailHeader | quote }} + - name: ZDDC_CORS_ORIGIN + value: {{ .Values.zddc.env.corsOrigin | quote }} + - name: ZDDC_LOG_LEVEL + value: {{ .Values.zddc.env.logLevel | quote }} + - name: ZDDC_INDEX_PATH + value: {{ .Values.zddc.env.indexPath | quote }} + {{- if .Values.zddc.env.noAuth }} + - name: ZDDC_NO_AUTH + value: "1" + {{- end }} + volumeMounts: + - name: zddc-bin + mountPath: /zddc + - name: data + mountPath: {{ .Values.zddc.env.rootPath }} + {{- with .Values.data.subPath }} + subPath: {{ . | quote }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + # Tighter probe cadence than prod — fail fast in dev so issues + # surface immediately during testing. + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + timeoutSeconds: 3 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 1 + periodSeconds: 5 + timeoutSeconds: 2 diff --git a/helm/zddc-server-dev/templates/ingress.yaml b/helm/zddc-server-dev/templates/ingress.yaml new file mode 100644 index 0000000..bcd3d72 --- /dev/null +++ b/helm/zddc-server-dev/templates/ingress.yaml @@ -0,0 +1,29 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "zddc-server.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/zddc-server-dev/templates/service.yaml b/helm/zddc-server-dev/templates/service.yaml new file mode 100644 index 0000000..8ded8b6 --- /dev/null +++ b/helm/zddc-server-dev/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "zddc-server.selectorLabels" . | nindent 4 }} diff --git a/helm/zddc-server-dev/values.yaml.example b/helm/zddc-server-dev/values.yaml.example new file mode 100644 index 0000000..83702a5 --- /dev/null +++ b/helm/zddc-server-dev/values.yaml.example @@ -0,0 +1,79 @@ +# values.yaml.example — zddc-server-dev +# +# Copy to values.yaml (or pass via --values) and customize for your +# environment. Same as the prod chart's example, but defaults are +# tuned for active development: +# +# - gitRef defaults to "main" (rebuilt on every pod restart) +# - ZDDC_LOG_LEVEL=debug (every request's full header map gets logged +# — this includes auth tokens and cookies; debug builds belong in +# trusted/private namespaces only) +# - Faster liveness/readiness probes +# - Smaller resource limits (single-developer test cluster) +# +# Contains NO secrets — see helm/zddc-server-prod/values.yaml.example +# for the secrets-management note. + +zddc: + gitRepo: https://codeberg.org/VARASYS/ZDDC.git + gitRef: main # tracks the latest commit; rebuilt on pod restart + + env: + rootPath: /srv + addr: ":8080" + emailHeader: X-Auth-Request-Email + # Empty (default) disables CORS — fine for embedded-tools / same-origin. + # Dev typically keeps localhost in here for the iterate-on-tool-builds + # workflow where you load a tool from `./dev-server start` (8000) and + # point it at this server. Add other tool-host origins as needed. + corsOrigin: "http://localhost:8000" + logLevel: debug # full request headers logged; sensitive! + indexPath: ".archive" + + # Skip ACL enforcement entirely. Useful in trusted-LAN dev clusters + # where authentication isn't needed and you want to iterate without + # configuring an upstream auth proxy. Default false. + noAuth: false + + # Token system: enabled automatically — tokens persist at + # /.zddc.d/tokens/ on the data PVC. Sign in via your + # cluster's auth proxy and visit /.tokens to issue one. + +data: + pvcName: zddc-root-dev # name of an existing PVC in your dev namespace + subPath: "" + +service: + type: ClusterIP + port: 8080 + +ingress: + enabled: false + className: "" + host: zddc-dev.example.com + tls: + enabled: false + secretName: zddc-dev-tls + +# Smaller than prod — dev clusters are usually resource-constrained. +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + +# Dev runs single-replica. The init container always pulls main HEAD, +# so two replicas would race on different SHAs. +replicaCount: 1 + +buildImage: + repository: docker.io/golang + tag: 1.24-alpine + +runtimeImage: + repository: docker.io/alpine + tag: "3.19" + +imagePullSecrets: [] diff --git a/helm/zddc-server-prod/Chart.yaml b/helm/zddc-server-prod/Chart.yaml new file mode 100644 index 0000000..028108d --- /dev/null +++ b/helm/zddc-server-prod/Chart.yaml @@ -0,0 +1,20 @@ +apiVersion: v2 +name: zddc-server-prod +description: | + Production deployment of zddc-server. Compiles from source via an + init container at deploy time (no image pull from a registry); the + main container is alpine + the freshly-built binary. TLS is expected + to be terminated upstream (ingress / sidecar / load balancer); the + pod listens on plain HTTP and requires ZDDC_INSECURE_DIRECT=1. +type: application +version: 0.1.0 +appVersion: "0.0.7" # zddc-server git tag this chart was last verified against +home: https://zddc.varasys.io/ +sources: + - https://codeberg.org/VARASYS/ZDDC +maintainers: + - name: VARASYS +keywords: + - zddc + - file-server + - document-control diff --git a/helm/zddc-server-prod/templates/_helpers.tpl b/helm/zddc-server-prod/templates/_helpers.tpl new file mode 100644 index 0000000..8512572 --- /dev/null +++ b/helm/zddc-server-prod/templates/_helpers.tpl @@ -0,0 +1,31 @@ +{{/* +Common labels and the fullname helper. Stays minimal; chart consumers +who want richer labels can override via metadata.labels in their +values.yaml or post-render kustomize. +*/}} + +{{- define "zddc-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "zddc-server.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "zddc-server.labels" -}} +app.kubernetes.io/name: {{ include "zddc-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Values.zddc.gitRef | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{- define "zddc-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "zddc-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} diff --git a/helm/zddc-server-prod/templates/deployment.yaml b/helm/zddc-server-prod/templates/deployment.yaml new file mode 100644 index 0000000..be54e27 --- /dev/null +++ b/helm/zddc-server-prod/templates/deployment.yaml @@ -0,0 +1,145 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "zddc-server.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "zddc-server.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: zddc-bin + emptyDir: {} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.data.pvcName }} + initContainers: + # Build zddc-server from the pinned git ref. The static binary + # lands in the shared zddc-bin volume that the main container + # mounts. No image pull from a custom registry — the build + # image is golang upstream + the runtime image is alpine. + - name: build-zddc-server + image: {{ printf "%s:%s" .Values.buildImage.repository .Values.buildImage.tag | quote }} + imagePullPolicy: IfNotPresent + command: ["/bin/sh", "-c"] + args: + - | + set -eu + apk add --no-cache git + git clone --depth 1 --branch "$GIT_REF" "$GIT_REPO" /workspace + cd /workspace/zddc + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath \ + -ldflags="-s -w -X main.version=$GIT_REF" \ + -o /out/zddc-server \ + ./cmd/zddc-server + echo "built /out/zddc-server from $GIT_REF" + env: + - name: GIT_REPO + value: {{ .Values.zddc.gitRepo | quote }} + - name: GIT_REF + value: {{ .Values.zddc.gitRef | quote }} + volumeMounts: + - name: zddc-bin + mountPath: /out + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + containers: + - name: zddc-server + image: {{ printf "%s:%s" .Values.runtimeImage.repository .Values.runtimeImage.tag | quote }} + imagePullPolicy: IfNotPresent + # zddc-cgroup-init prepares cgroup v2 subtree_control then + # exec's zddc-server. Required because cgroup v2 forbids + # processes in a cgroup that has child cgroups; the per- + # conversion wrapper (zddc-sandbox-exec) creates child + # cgroups for resource caps, so the init script has to + # move zddc-server itself out of the root cgroup first. + # See zddc/runtime/zddc-cgroup-init in the source repo. + command: ["/usr/local/libexec/zddc-cgroup-init", "/zddc/zddc-server"] + # The conversion sandbox (bwrap, invoked per-call by + # /usr/local/bin/{pandoc,chromium-browser}) needs to create + # user + mount namespaces inside the container. Pod Security + # Standards default policies forbid this; the chart sets the + # minimum securityContext that lets bwrap function. If your + # cluster's admission controller rejects these settings, you + # have two choices: ask the platform team to allow this pod, + # or accept that /.convert serves 503 (the rest of zddc- + # server still works fine without conversion). + securityContext: + capabilities: + add: ["SYS_ADMIN"] + # cap-add SYS_ADMIN alone isn't enough — see the + # zddc/runtime/zddc-sandbox-exec docstring for the full + # set of LSM relaxations required. K8s 1.30+ supports + # specifying seccompProfile + appArmorProfile fields; + # if your cluster is older, you'll need annotations: + # container.apparmor.security.beta.kubernetes.io/zddc-server: unconfined + seccompProfile: + type: Unconfined + appArmorProfile: + type: Unconfined + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: ZDDC_ROOT + value: {{ .Values.zddc.env.rootPath | quote }} + - name: ZDDC_ADDR + value: {{ .Values.zddc.env.addr | quote }} + - name: ZDDC_TLS_CERT + value: "none" + - name: ZDDC_INSECURE_DIRECT + value: "1" + - name: ZDDC_EMAIL_HEADER + value: {{ .Values.zddc.env.emailHeader | quote }} + - name: ZDDC_CORS_ORIGIN + value: {{ .Values.zddc.env.corsOrigin | quote }} + - name: ZDDC_LOG_LEVEL + value: {{ .Values.zddc.env.logLevel | quote }} + - name: ZDDC_INDEX_PATH + value: {{ .Values.zddc.env.indexPath | quote }} + {{- if .Values.zddc.env.noAuth }} + - name: ZDDC_NO_AUTH + value: "1" + {{- end }} + volumeMounts: + - name: zddc-bin + mountPath: /zddc + - name: data + mountPath: {{ .Values.zddc.env.rootPath }} + {{- with .Values.data.subPath }} + subPath: {{ . | quote }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 30 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 2 + periodSeconds: 10 + timeoutSeconds: 3 diff --git a/helm/zddc-server-prod/templates/ingress.yaml b/helm/zddc-server-prod/templates/ingress.yaml new file mode 100644 index 0000000..bcd3d72 --- /dev/null +++ b/helm/zddc-server-prod/templates/ingress.yaml @@ -0,0 +1,29 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "zddc-server.fullname" . }} + port: + number: {{ .Values.service.port }} +{{- end }} diff --git a/helm/zddc-server-prod/templates/service.yaml b/helm/zddc-server-prod/templates/service.yaml new file mode 100644 index 0000000..8ded8b6 --- /dev/null +++ b/helm/zddc-server-prod/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "zddc-server.fullname" . }} + labels: + {{- include "zddc-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "zddc-server.selectorLabels" . | nindent 4 }} diff --git a/helm/zddc-server-prod/values.yaml.example b/helm/zddc-server-prod/values.yaml.example new file mode 100644 index 0000000..28b18a8 --- /dev/null +++ b/helm/zddc-server-prod/values.yaml.example @@ -0,0 +1,126 @@ +# values.yaml.example — zddc-server-prod +# +# Copy to values.yaml (or pass via --values) and customize for your +# environment. Contains NO secrets — secrets like the .zddc admin email +# list, TLS certs (if used), and image-pull credentials must be +# materialised from your secret-management system (sealed-secrets, +# external-secrets, kubectl create secret, etc.) and referenced by name +# below. + +# Source-build configuration. The init container clones the repo at +# `gitRef` and compiles cmd/zddc-server. Pin gitRef to a stable tag +# (zddc-server-vX.Y.Z) for production; trying main HEAD risks pulling +# unreleased changes. +zddc: + gitRepo: https://codeberg.org/VARASYS/ZDDC.git + gitRef: zddc-server-v0.0.7 # pin to a stable tag + + # ZDDC environment-variable contract — see zddc/README.md + env: + # Path inside the container where ZDDC_ROOT data is mounted. + # The chart wires the data PVC to this path automatically. + rootPath: /srv + + # Listening address (plain HTTP — ingress terminates TLS). + addr: ":8080" + + # Email-header convention from your authenticating reverse proxy. + emailHeader: X-Auth-Request-Email + + # Comma-separated CORS allowlist. Empty (default) disables CORS — + # appropriate for the embedded-tools install path where tools are + # served same-origin by zddc-server itself. Set to a specific origin + # only if browser-loaded pages from a different host call back into + # this server (e.g. self-hosted tools at https://tools.acme.com, + # or the CDN-bootstrap pattern from https://zddc.varasys.io). + corsOrigin: "" + + # info / warn / error / debug. Production stays on info; debug logs + # every request's full header map (includes cookies/auth tokens). + logLevel: info + + # Index URL segment for the virtual archive index. Default fits + # most deployments; only change if you have a tracking-number + # collision with a real directory named ".archive". + indexPath: ".archive" + + # Skip ACL enforcement entirely on this instance. Anyone hitting + # the port reads everything in scope. Only enable for genuinely- + # public archives (and even then, only behind an authenticating + # ingress that doesn't gate on identity for /). Distinct from + # --insecure (which gates the startup check requiring a root .zddc). + # Default false. + noAuth: false + + # Bearer-token system. Master automatically self-issues tokens via + # /.tokens (browser) and /.api/tokens (JSON). The token store lives + # at /.zddc.d/tokens/ on the data PVC; no Helm + # configuration required. Operators sign in via the upstream auth + # proxy, visit /.tokens, copy the displayed token into a 0600 file, + # and pass --bearer-file to any CLI / cache / mirror that needs to + # authenticate against this master. See zddc/README.md "Bearer + # tokens" for the full lifecycle. + +# Persistent storage for ZDDC_ROOT. Operators provide their own PVC, +# typically backed by a shared filesystem (NFS, CephFS, SMB) so multiple +# replicas of zddc-server (and your sync tooling) see the same tree. +# This chart does NOT create the PVC — it only references it by name. +data: + pvcName: zddc-root # name of an existing PersistentVolumeClaim + subPath: "" # optional subPath within the PVC + +# Service exposure. zddc-server listens on a plain HTTP port; ingress +# (or whatever reverse proxy you put in front) terminates TLS and +# enforces authentication, then forwards to this service. +service: + type: ClusterIP + port: 8080 + +# Ingress is optional — disabled by default since most deployments wire +# zddc-server into an existing ingress / auth-proxy stack. Enable here +# only if this chart is the only thing in front of the pod. +ingress: + enabled: false + className: "" + host: zddc.example.com + tls: + enabled: false + secretName: zddc-tls # secret you create separately + +# Pod resource limits. Sized for a small/medium archive (~10k files). +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +# Replicas. zddc-server is read-only stateless given a shared filesystem +# behind it, so multiple replicas are safe. +replicaCount: 1 + +# Build-stage Go image (init container). Pinned digest is recommended +# in production for reproducibility; using a tag means upstream changes +# break your deploy. +buildImage: + repository: docker.io/golang + tag: 1.24-alpine + # digest: sha256:... + +# Runtime image (main container). Hosts the zddc-server binary copied +# in by the init container, plus the conversion toolchain (pandoc, +# chromium, bubblewrap) used by the /.convert endpoint. Build from +# `zddc/runtime.Containerfile` and publish to your registry; the +# Containerfile documents the build/publish commands. Plain alpine +# does NOT have the conversion tools — the /.convert endpoint will +# serve 503 until you swap in a runtime image that bundles them. +runtimeImage: + repository: codeberg.org/varasys/zddc-server-runtime + tag: "latest" + # digest: sha256:... + +# Image pull credentials, if your registry requires them. Reference a +# secret you've created separately; do not put credentials in values. +imagePullSecrets: [] +# - name: regcred diff --git a/landing/build.sh b/landing/build.sh new file mode 100755 index 0000000..d29803a --- /dev/null +++ b/landing/build.sh @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +root_dir=$(cd "$(dirname "$0")" && pwd) +. "$root_dir/../shared/build-lib.sh" + +src_html="$root_dir/template.html" +output_dir="$root_dir/dist" +output_html="$output_dir/index.html" + +mkdir -p "$output_dir" +ensure_exists "$src_html" + +css_temp=$(mktemp) +js_raw=$(mktemp) +js_temp=$(mktemp) +cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } +trap cleanup EXIT + +concat_files \ + "../shared/fonts.css" \ + "../shared/base.css" \ + "../shared/toast.css" \ + "../shared/elevation.css" \ + "../shared/profile-menu.css" \ + "../shared/logo.css" \ + "css/landing.css" \ + > "$css_temp" + +concat_files \ + "../shared/zddc.js" \ + "../shared/zddc-filter.js" \ + "../shared/theme.js" \ + "../shared/toast.js" \ + "../shared/logo.js" \ + "../shared/help.js" \ + "../shared/elevation.js" \ + "../shared/profile-menu.js" \ + "../shared/cap.js" \ + "js/landing.js" \ + > "$js_raw" + +# Escape ' tag. +escape_js_close_tags "$js_raw" "$js_temp" + +compute_build_label "landing" "${1:-}" "${2:-}" + +# Process template: inject CSS/JS, substitute build label, strip CDN refs +awk -v css_file="$css_temp" -v js_file="$js_temp" -v build_label="$build_label" -v is_red="$is_red" -v favicon_uri="$favicon_data_uri" ' + /\{\{CSS_PLACEHOLDER\}\}/ { + while ((getline line < css_file) > 0) print line + close(css_file) + next + } + /\{\{JS_PLACEHOLDER\}\}/ { + while ((getline line < js_file) > 0) print line + close(js_file) + next + } + /\{\{BUILD_LABEL\}\}/ { + if (is_red == "1") { + gsub(/\{\{BUILD_LABEL\}\}/, "" build_label "") + } else { + gsub(/\{\{BUILD_LABEL\}\}/, build_label) + } + print + next + } + /\{\{FAVICON\}\}/ { + gsub(/\{\{FAVICON\}\}/, favicon_uri) + print + next + } + / + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0dfb5e3 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "zddc", + "version": "0.1.0", + "description": "Zero Day Document Control - lightweight project management tools", + "private": true, + "scripts": { + "test": "npx playwright test", + "test:headed": "npx playwright test --headed", + "test:debug": "npx playwright test --debug", + "test:install": "npx playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" + }, + "repository": { + "type": "git", + "url": "https://codeberg.org/VARASYS/ZDDC.git" + }, + "keywords": [ + "project-management", + "document-control", + "markdown-editor", + "document-transmittal" + ], + "license": "AGPL-3.0" +} diff --git a/pandoc/README.md b/pandoc/README.md new file mode 100644 index 0000000..afe7207 --- /dev/null +++ b/pandoc/README.md @@ -0,0 +1,167 @@ +# ZDDC Pandoc Tools + +A collection of tools for converting Markdown documents to HTML with a professional viewer interface, optimized for technical documentation and engineering documents. + +## Server-side conversion (`zddc-server`) + +> The shell scripts in this folder are standalone CLI/batch tools. `zddc-server` +> implements its **own** on-demand conversion (Go package `zddc/internal/convert`) +> and does **not** call these scripts. It does, however, reuse the same +> `templates/` (embedded at build time). See AGENTS.md → "Server-side document +> conversion" for the authoritative reference. + +zddc-server can render any served `.md` on demand: requesting the sibling URL +`/foo.docx` (or `.html` / `.pdf`) returns the converted bytes — no query +string. A real on-disk file of that name always wins; the virtual conversion +only fires when the requested file doesn't exist but `foo.md` does. The browse +app's markdown editor surfaces these as DOCX/HTML/PDF download links (auto-saving +a dirty buffer first so the output matches what's on screen). + +**Architecture.** The Go code does the minimum — it `exec`s `pandoc` and +`chromium-browser` directly. The sandbox and resource caps live in the runtime +**image**, where `/usr/local/bin/{pandoc,chromium-browser}` are wrapper scripts +that run the real binary inside a per-conversion bubblewrap sandbox +(`--unshare-all`, read-only binds, `--tmpfs /tmp`, `--clearenv`) under cgroup v2 +memory/PID caps. I/O is via stdin/stdout plus a per-call scratch dir. There is no +container runtime and no image pulling at request time. + +The PDF flow is two-stage: pandoc renders the markdown through the selected +`templates/.html` to standalone HTML, then headless Chromium prints that +HTML to PDF — preserving the template's print-media CSS rather than going through +pandoc's LaTeX template. + +Converted bytes are cached at `/.zddc.d/converted/.` with mtime +synced to the source, so a fresh cache hit is a stat-and-serve with no `exec`. +A PUT/DELETE/MOVE on the source `.md` purges the sidecars. Per-project header +metadata (client/project/contractor/project_number) comes from the `.zddc` +`convert:` cascade; title/tracking_number/revision/status are derived from the +filename via `zddc.ParseFilename`. + +Relevant flags (defaults in parens): + +- `--convert-pandoc-binary` (`pandoc`) / `--convert-chromium-binary` + (`chromium-browser`; `chromium` on Debian) — PATH-resolved name or absolute path +- `--convert-scratch-dir` (`$TMPDIR`) — host scratch root for template + intermediates +- `--convert-mem-mib` (`1024`) — per-conversion memory cap (cgroup `memory.max`) +- `--convert-pids` (`256`) — per-conversion PID cap (cgroup `pids.max`) +- `--convert-timeout` (`60s`) — per-conversion wall clock (Go `context.WithTimeout`) + +If `pandoc`/`chromium` aren't on PATH (e.g. running zddc-server outside the runtime +image) the endpoint serves 503 with a `Retry-After`; the rest of the server keeps +working. Running against raw pandoc/chromium with no wrapper gives a working but +**unsandboxed** endpoint — fine for dev iteration. + +## Features + +### Document Conversion (`convert`) +- **Batch processing**: Convert multiple Markdown files at once +- **Force overwrite**: `-f` flag to overwrite existing output files +- **Custom output directory**: `-o` flag to specify output location +- **Configuration-driven**: Uses `zddc.conf` for project-specific settings +- **Template integration**: Automatically applies the viewer template +- **Progress tracking**: Real-time conversion status and summary + +### Professional templates (`templates/`) + +Named doctype templates — `report.html`, `letter.html`, `specification.html` — +share `_head.html` / `_doc.html` / `_scripts.html` partials. A document selects one +with a `template:` field in its YAML front matter (default `report`), and turns on +legal-style heading numbering with `numbering: true` (default off). Both fields are +read by pandoc straight from the front matter. Server deployments additionally +resolve per-project/per-party overrides from `.zddc.d/templates/.html`. + +- **Modern responsive design**: Works on desktop, tablet, and mobile +- **Table of Contents (TOC)**: Auto-generated sidebar navigation with smooth scrolling +- **Print optimization**: Professional formatting for PDF generation + - Page break controls for tables + - Repeating table headers + - Proper page numbering + - Clean print layout +- **URL hash navigation**: Shareable links to specific document sections +- **Mobile-friendly**: Collapsible sidebar and touch-optimized interface +- **Professional styling**: Clean typography optimized for technical documents + +## Usage + +### Basic Conversion +```bash +# Convert all Markdown files in current directory +./convert *.md + +# Convert with force overwrite +./convert -f *.md + +# Convert to specific output directory +./convert -o rendered/ *.md + +# Combine flags +./convert -f -o rendered/ *.md +``` + +### Configuration (`zddc.conf`) +Create a `zddc.conf` file in your project directory. It is **sourced as shell**, +so use `var="value"` syntax (no spaces around `=`). Only these four variables are +read; all are optional and feed the document header via pandoc `--variable`: +```sh +contractor="Contractor Name" # contracting organization (header) +client="Client Name" # client org (header, paired with project) +project="Project Name" # full project name +project_number="AR 28088" # shown in parentheses after the project name +``` +The template path is discovered automatically (input dir → script dir → +symlink target) or set per-run with `-T`; the output directory is set with `-o`. +They are **not** `zddc.conf` keys. + +### Directory Structure +``` +your-project/ +├── zddc.conf # Configuration file +├── document1.md # Source Markdown files +├── document2.md +└── rendered/ # Generated HTML files + ├── document1.html + └── document2.html +``` + +## Template Features + +### Navigation +- **TOC Generation**: Automatically creates navigation from document headings +- **Smooth Scrolling**: Click TOC items for smooth navigation to sections +- **Hash URLs**: Address bar updates with section anchors for sharing +- **Mobile Menu**: Collapsible sidebar for mobile devices + +### Print Styling +- **Page Breaks**: Tables won't split across pages +- **Header Repetition**: Table headers repeat on each page +- **Professional Layout**: Optimized margins and typography +- **Page Numbers**: Sequential page numbering in footer + +### Responsive Design +- **Desktop**: Full sidebar with TOC always visible +- **Tablet**: Collapsible sidebar with overlay +- **Mobile**: Hamburger menu with full-screen TOC overlay + +## File Types Supported + +- **Input**: Markdown (`.md`), DOCX (`.docx`), and HTML (`.html`/`.htm`) files + (auto-detected: DOCX→MD, MD→HTML, HTML→MD; override with `-t md|html|docx`). + Direct DOCX→HTML is not supported — convert to MD first. +- **Output**: HTML files with embedded CSS and JavaScript (plus MD and DOCX targets) +- **Images**: Supports embedded images and diagrams +- **Tables**: Full table support with print optimization +- **Code**: Syntax highlighting for code blocks + +## Dependencies + +- **pandoc**: Document conversion engine +- **Modern browser**: For viewing generated HTML files +- **Optional**: Web server for serving files (prevents CORS issues) + +## Troubleshooting + +### Common Issues +1. **Template not found**: Keep the `templates/` directory beside the script (or input), or pass `-T /path/to/template.html` +2. **Permission errors**: Make sure `convert` script is executable (`chmod +x convert`) +3. **Missing output**: Check that output directory exists or use `-o` to create it +4. **Print issues**: Use "Print to PDF" in browser for best results diff --git a/pandoc/convert b/pandoc/convert new file mode 100644 index 0000000..de8591e --- /dev/null +++ b/pandoc/convert @@ -0,0 +1,577 @@ +#!/bin/bash + +# Function to show help +show_help() { + echo "Universal File Converter" + echo "Supported conversions: DOCX→MD, MD→HTML, HTML→MD, MD→DOCX, HTML→DOCX" + echo "Usage: $0 [-f] [-o outputdir] [-t format] [-T template] [--no-toc] input1.ext [input2.ext ...]" + echo " -f: Force overwrite existing output files" + echo " -o: Output directory (default: same as input)" + echo " -t: Target format (md, html, docx) - overrides auto-detection" + echo " -T: Template file path (default: templates/