ZDDC: document-control tools + zddc-server

This commit is contained in:
Me Here 2026-06-11 12:55:55 -05:00
commit 87bdfcd033
500 changed files with 189337 additions and 0 deletions

View file

@ -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 = <next-stable>-beta-<sha>
# (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") <beta|stable> [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 <sha>` 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."

View file

@ -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<X.Y.Z> 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}"

View file

@ -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

53
.gitignore vendored Normal file
View file

@ -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/<tool>.html
# is a transient build output (and the canonical thing tests open via
# file://), and dist/release-output/ is the local-only release bundle
# produced by `./build alpha|beta|release`, then rsync'd to the live
# site by `./deploy`. Nothing in dist/ should be committed.
#
# Hand-edited website content (index.html, reference.html, css/, js/,
# img/) lives in a SEPARATE Codeberg repo at codeberg.org/VARASYS/
# ZDDC-website, typically cloned at ~/src/zddc-website/. Release
# artifacts are NOT in git history at all — they're produced by this
# repo's build, rsync'd to /srv/zddc/releases/ on the deploy host,
# and reproducible from any <tool>-vX.Y.Z tag.
dist/
# 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

771
AGENTS.md Normal file
View file

@ -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 [<version>]
# 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
(`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) + their `.sig`
sidecars + `pubkey.pem`. The cut writes this version's per-version
file + canonical `<tool>.html` / `zddc-server_<plat>` 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 `<name>.table.yaml` next to a sibling `<name>/` 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`
# <tool>_v<X.Y.Z>.html per-version (immutable)
# <tool>_v<X.Y>.html -> ... symlink chain
# <tool>.html -> ... canonical symlink → current stable
# zddc-server_v<X.Y.Z>_<platform> per-platform binary (raw bytes, no LFS)
# zddc-server_<platform> canonical per-platform symlink → current stable
# zddc-server_<X>.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/<tool>.html` is the canonical built artifact for testing and the source for `--release` writes into `dist/release-output/`. `dist/release-output/` is the local-only release bundle. Neither is in git. Never edit them directly.
**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 (`<tool>.html`, symlink → current stable) and a set of per-version immutable files (`<tool>_v<X.Y.Z>.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 `<tool>-vX.Y.Z` tag in this repo (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS.
## Shared CSS (`shared/base.css`)
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 `<script>`/`<link>` tags (pattern: `https?://`)
- `{{BUILD_LABEL}}` is substituted in all seven HTML tools via `gsub` in awk (use `gsub`, not `print` — the placeholder is inline in an HTML line). Value is `v<next>-dev · <ts> · <sha>[-dirty]` for plain dev builds, `v<next>-beta · <ts> · <sha>` for `./build beta` snapshot cuts, and `v<X.Y.Z>` for stable releases; computed before the awk step. The shared `is_red` flag controls whether the label is wrapped in a red+bold `<span>` (true for dev/beta, false for stable).
- Cleans up temp files via `trap cleanup EXIT`
**`</` escaping is mandatory.** Any JS containing `</tag>` inside string or template literals will break inline `<script>` embedding. Run:
```bash
sed 's#</#<\\/#g' "$input_js" > "$safe_js"
```
Required for any new tool with vendor JS or JS containing HTML template literals.
## JS module pattern
All JS is vanilla, no bundlers. Files are IIFEs, registered on `window.app.modules`. Load order = declaration order in `build.sh`. `window.app` is the only global.
```javascript
(function() {
window.app.modules.mymodule = { ... };
})();
```
**Exception:** archive uses plain globals (`APP_STATE`, top-level functions) — not the IIFE/modules pattern.
**State values used inside event handlers must be read fresh from the source of truth, never captured at mount.** No bundler, no reactivity layer — closures don't get refreshed when the underlying state mutates. Cache the *node*, re-read the *bit* at click time:
```javascript
// Wrong — `writable` is whatever canSave returned at mount, even if
// the tree node's bit later flips to true (e.g. admin toggle reload
// re-fetched the listing).
var writable = canSave(node);
saveBtn.addEventListener('click', function () {
if (!writable) return; // STALE
});
// Right — re-read at click time.
saveBtn.addEventListener('click', function () {
if (!canSave(node)) return; // current
});
```
It's fine to use mount-time captures for *initial UI shape* (read-only banner, CodeMirror `readOnly:'nocursor'`, etc.) — those decisions are correct at the moment they're applied. The rule is specifically about gating logic in handlers that fire *after* mount.
This pattern bit twice in the markdown + YAML editors before we caught it: the typo `writable` (undefined) vs `writableMode` (captured) made every save click a no-op. Re-reading the source of truth would have surfaced the bug at click time instead of silently disabling save.
## ZDDC filename parsers
All parsing/formatting goes through `shared/zddc.js`, exposed as `window.zddc`. Tools call it directly — no per-tool wrappers.
`window.zddc` exports:
- `parseFilename(name)``{ trackingNumber, revision, status, title, extension, valid } | null` (extension WITHOUT leading dot)
- `parseFolder(name)``{ date, trackingNumber, status, title, valid } | null`
- `parseRevision(rev)``{ base, modifier, modifierType, modifierNumber, isDraft, modifierIsDraft, full }`
- `compareRevisions(a, b)` → number (canonical sort order)
- `formatFilename(parts)` / `formatFolder(parts)` — round-trips parsed output
- `isValidStatus(code)` — accepts known status codes plus `---`
All file objects across tools use `file.trackingNumber` (string) and `file.extension` (string, **no leading dot**, e.g. `'pdf'` not `'.pdf'`). When concatenating into a filename, write `name + '.' + ext`.
Coverage lives in `tests/zddc.spec.js` (47 cases). Add new edge cases there, not in tool tests.
## Testing quirks
- Playwright + Chromium only (File System Access API requirement)
- Tests open `dist/tool.html` via `file://` protocol — **always build before testing**
- File System Access API is mocked via `page.addInitScript()` using `tests/fixtures/mock-fs-api.js`
- Use `waitUntil: 'load'` or `'domcontentloaded'` not `'networkidle'` — bundled scripts keep the network "active"
- Archive's `#noDirectoryMessage` empty-state overlay is `position: absolute; top: 50px` — it must clear the header or it will block button clicks in tests
## ZDDC filename convention
Format: `trackingNumber_revision (status) - title.extension`
- `trackingNumber`: no spaces or underscores (e.g. `123456-EL-SPC-2623`)
- `revision`: `A`, `B`, `0`; draft prefix `~`; modifiers `+C1`, `+B1`, `+N1`, `+Q1`
- `status`: `IFA IFB IFC IFD IFI IFP IFR IFU REC RSA RSB RSC RSD RSI` or `---`
- Folder names prefix with date: `2025-10-31_trackingNumber (status) - title`
## Git workflow
- Feature-branch workflow; squash-merge feature branches to `main`
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
- Release tags: `<tool>-v<X.Y.Z>` per tool, all eight sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
### Releasing — lockstep stable + beta snapshot
**Lockstep convention.** Every stable cut bumps all 8 artifacts (7 HTML tools + zddc-server) to the same version, even if a tool didn't change. Per-tool independent versions are gone. The coordinated next-stable target is `max(latest tag across all 8 tools) + 1``_coordinated_next_stable` in `shared/build-lib.sh`.
**No alpha or beta channels in the public release surface.** Simplified in May 2026 — channel mirrors (`_stable`, `_beta`, `_alpha`) and partial-version pins (`_v<X.Y>`, `_v<X>`) are gone. Each tool has exactly one canonical URL (`<tool>.html`, symlink → current stable) and a set of immutable per-version files (`<tool>_v<X.Y.Z>.html`). Same shape for zddc-server per platform.
**Storage model.** All release artifacts live on the deploy host at `/srv/zddc/releases/` (Caddy bind-mount, served as `https://zddc.varasys.io/releases/`). Locally they materialize in this repo's `dist/release-output/` (gitignored) when `./build release` runs; `./deploy` rsyncs them out. **No git history holds release artifacts** — older versions are reproducible from any `<tool>-vX.Y.Z` tag (`git checkout zddc-server-v0.0.8 && ./build release 0.0.8`). No Codeberg release assets, no LFS, no third-party mirrors.
| Artifact | Type | Layout |
|---|---|---|
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, landing, form, tables, browse |
| `<tool>.html` | symlink | canonical "current stable" URL per tool — always points at the latest cut's per-version file |
| `<tool>_v<X.Y.Z>.html.sig` | real, immutable | Ed25519 detached signature |
| `<tool>.html.sig` | symlink | canonical .sig URL (symlink → matching `.sig` of the symlinked target) |
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
| `zddc-server_<platform>` | symlink | canonical "current stable" per platform |
| `zddc-server_v<X.Y.Z>_<platform>.sig` | real | matching detached signature |
| `zddc-server_<platform>.sig` | symlink | canonical .sig URL |
| `zddc-server.html` | generated stub | current-stable four-platform download page |
| `zddc-server_v<X.Y.Z>.html` | generated stub | per-version four-platform download page |
| `index.html` | regenerated by `build` | downloads landing page (version dropdown, tool cards, apps composer) |
**Single point of truth.** `./build release` is the canonical lockstep cut. It seeds `dist/release-output/` from `/srv/zddc/releases/` (only per-version immutables + `.sig` + `pubkey.pem`), forwards each HTML tool's build with the agreed version, calls `promote_zddc_server` (in `shared/build-lib.sh`) to copy the freshly cross-compiled binaries with their canonical symlinks, then `write_zddc_server_stubs_all` regenerates stub pages, then `sign_release_artifacts` produces `.sig` for every new per-version file, then `build_releases_index` rewrites the downloads page. **Then** the top-level build folds the regenerated `zddc/internal/apps/embedded/*` files into a `release: vX.Y.Z lockstep` commit and tags all 8 artifacts at that commit. `./deploy --releases` publishes the bundle.
- **Stable** (`./build release` or `--release X.Y.Z`): Writes per-version HTML for the seven HTML tools + per-version binaries for zddc-server (real bytes, immutable) + canonical `<tool>.html` and `zddc-server_<platform>` symlinks. Updates `zddc/internal/apps/embedded/*` to stable-labeled bytes, makes a release commit, tags all 8 (`<tool>-v<X.Y.Z>`) **at that commit** so binaries built from the tag embed clean stable bytes.
- **Beta** (`./build beta`): Internal SHA snapshot for the BMC dev chart pipeline. Regenerates `zddc/internal/apps/embedded/*` with beta-labeled bytes and makes a `chore(embedded): cut v<X.Y.Z>-beta` commit. **NO public artifact in `dist/release-output/`.** The chart's appVersion gets set to `"<X.Y.Z>-beta-<sha>"`; chart's Dockerfile parses the suffix and `git fetch`-es that SHA. The chart compiles its own binary from the fetched source — the binary's embedded HTML tools are whatever this commit wrote. No tag.
- **Plain dev builds** (`./build` with no arg): produce `tool/dist/<tool>.html` for HTML tools and `zddc/dist/zddc-server-<platform>` binaries; do NOT touch `dist/release-output/`, the live site, or `embedded/`. Use it to iterate without affecting deployable state.
**Bake-in invariant** — what zddc-server's binary embeds via `//go:embed` from `zddc/internal/apps/embedded/`:
| Image | Chart pin | Embeds |
|---|---|---|
| Prod (Dockerfile.prod, BMCD) | `appVersion: "X.Y.Z"` → tag `zddc-server-v<X.Y.Z>` | Stable-labeled bytes from the tagged release commit |
| Dev (Dockerfile) | `appVersion: "X.Y.Z"` or `"X.Y.Z-beta-<sha>"` → tag or SHA | Stable or beta-snapshot bytes (whichever the chart points at) |
| Local dev iteration | n/a | Use `tool/dist/<tool>.html` directly; binary's embedded copy lags |
On-page `{{BUILD_LABEL}}` format (HTML tools only — zddc-server's version comes from the binary itself):
- Plain dev: `vX.Y.Z-dev · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the next-stable target.
- `./build beta`: `vX.Y.Z-beta · <full-ts> · <sha>` (red). Only seen on the dev chart's compiled binary.
- `./build release [X.Y.Z]`: `v<X.Y.Z>` (black).
After cutting a stable release, `git push origin main && git push origin --tags` to publish the new release commit + every per-tool tag in lockstep.
### Release discipline (MUST rules)
The build enforces lockstep mechanically (one command bumps all 8). The rules below are still on you.
1. **Stable doesn't regress.** No known-broken features that worked in the previous stable. If `v0.0.5` ships with a bug, the path forward is `v0.0.6` with a fix — never edit a previously-published per-version file in place. Per-version files are immutable.
2. **Lockstep is the contract.** Don't cut a single tool's stable without bumping the rest. The HTML tool's standalone `sh tool/build.sh --release X.Y.Z` flag still exists as an escape hatch but emits a tag that immediately drifts out of sync with the others.
3. **No backports.** Always cut a new stable at a higher version. Users pinned to an old version stay pinned by choice.
4. **Beta is internal.** Don't advertise `./build beta` snapshots to users — they're a BMC dev pipeline plumbing concept, not a "preview" release. The canonical URL `<tool>.html` always points at the latest stable.
5. **Hotfix path.** For critical bugs: fix on `main`, cut a new stable. Tag the commit message `fix:` or include "hotfix" so intent is visible in `git log`.
6. **Beta soak before promoting (recommended).** Give a beta-snapshotted build a few days on the dev chart before cutting the same code as stable. Not enforced; use judgment for trivial changes.
### Install model
No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults/`, exported as a `.zddc.zip` via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. **Project shape (May 2026 reshape):** `archive/` is the only physical project-root directory. Six top-level URLs are virtual aggregators: `ssr/mdl/rsk` (tables row-rollups across parties, with a synthesised `$party` source-party column the tables tool renders read-only and strips before write) and `working/staging/reviewing` (browse folder-nav listings of parties with non-empty content; per-party URLs 302-redirect to `archive/<party>/<slot>/`). Mkdir directly at the project root is restricted to `archive` and `_`/`.`-prefixed system names — virtual aggregator names and ad-hoc folders return 409. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
To override a tool's HTML (local-only — no fetch, no channels/versions):
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
2. Add an `<app>.html` member to the site bundle `<ZDDC_ROOT>/.zddc.zip` (a local zip read server-side via `internal/zipfs`; overrides that tool everywhere, and lets you add new `<name>.html` tools). To route a *different* tool at a path, change `default_tool` / `dir_tool` / `available_tools`.
Otherwise the embedded build-time copy is served. There is no `apps:` `.zddc` key, no upstream fetch, and no signature verification (all removed). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` (bare, `/`-listing, or `/<member>`) is **404 except for a standing config-editor over the bundle's directory** (a subtree admin / `a`-verb holder — no elevation required; `configEditorForBundle` in `cmd/zddc-server/main.go`), who may browse and edit it in place. It is deliberately NOT wide-readable even to plain readers, because one file packs many subtrees' policy — per-level transparency is `ServeZddcFile`'s job. The server reads its members from the filesystem internally regardless.
Operators audit by reading the `X-ZDDC-Source` response header: `bundle:<app>.html` / `embedded:<app>@<build>` (an on-disk override is served by the static handler with its own headers).
**Runtime mode detection** in archive is independent of install: it auto-detects multi-project / project-root / in-archive from `?projects=` plus folder shape. The other tools don't care where they live.
### Worktrees
Use `git worktree` to run multiple agents on separate branches simultaneously without filesystem collisions.
- Worktrees live at `~/src/zddc-<branch-name>` (sibling of the main clone)
- Before starting work on a feature branch, check `git worktree list`; if no worktree exists, create one: `git worktree add ~/src/zddc-<branch-name> -b <branch-name>`
- All edits, builds (`./build`), and tests (`npm test`) run from within the worktree directory — build scripts use relative paths so this works correctly
- The `dist/` force-commit rule (`git add -f`) applies per-worktree
- After the branch is merged, clean up: `git worktree remove ~/src/zddc-<branch-name>` then delete the branch
- Never run `git checkout` or `git switch` inside a worktree that another agent may be using
## Transmittal-specific
- Two-phase hydration: `populateStatic()` before publish, `hydrate()` on load of published file
- Reactive state via Proxy — `app.state.mode = 'view'` auto-notifies subscribers
- No runtime CDN loads. Every vendor library (jszip, docx-preview, xlsx, UTIF, Toast UI) is bundled at build time via `concat_files`. The dist HTML is fully self-contained — "ship the record player with the record."
- Published payload stored in `<script id="transmittal-data" type="application/json">`
## Markdown editor (inside browse)
The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as the preview plugin for `.md`/`.markdown` files by `browse/js/preview.js`. The standalone `mdedit/` tool has been retired — `browse` is the editor.
- Toast UI Editor v3.2.2 is vendored at `shared/vendor/toastui-editor-all.min.js` and concatenated into `browse/dist/browse.html` at build time. No runtime CDN.
- YAML front matter (`---\n…\n---`) is split off on load and edited in a dedicated `<textarea>` in the sidebar; on save it's recombined onto the body. Always present (no "empty pane") so authoring new FM is a single click.
- In server mode (HTTP-backed file handles), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` and triggering a browser download. The buttons auto-save the dirty buffer first so the converted bytes reflect what's on screen.
## Server-side document conversion (`zddc/internal/convert`)
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`.
**Architecture.** zddc-server's Go code does the bare minimum: it `exec.Command("pandoc", args...)` or `exec.Command("chromium-browser", args...)`. **The sandbox + resource caps live in the IMAGE**, not in Go. In the production runtime image (`zddc/runtime.Containerfile`), `/usr/local/bin/pandoc` and `/usr/local/bin/chromium-browser` are symlinks to `zddc-sandbox-exec` — a shell wrapper that:
1. Creates a transient cgroup v2 (memory + pids cap from `ZDDC_CONV_MEM_MAX` / `ZDDC_CONV_PIDS_MAX` env), moves itself in.
2. Wraps the real binary at `/usr/bin/<name>` in a bubblewrap sandbox (`--unshare-all --unshare-user-try --die-with-parent --ro-bind /usr /usr ... --proc /proc --dev /dev --tmpfs /tmp --clearenv`).
3. exec's `/usr/bin/<name>` with the original argv.
Why this shape: swapping isolation strategies (firejail, systemd-nspawn, podman-run, raw exec for dev) is purely an image concern. The Go code never changed. A separate `zddc-cgroup-init` script runs at container start to delegate cgroup v2 `subtree_control` (the "no internal processes" constraint), then exec's zddc-server. Both scripts live in `zddc/runtime/`.
**Outer-container privileges.** Nested bwrap needs the outer container to permit user + mount namespace creation. Pod Security Standards defaults block this. The helm chart sets `securityContext: capabilities.add: [SYS_ADMIN]`, `seccompProfile.type: Unconfined`, `appArmorProfile.type: Unconfined`. Trade-off: a zddc-server RCE has near-root power within the container's namespace, but the bind-mount layout (overlay fs, no host /home or /usr visible) still bounds the blast radius. The per-conversion bwrap sandbox is the real isolation boundary between zddc-server and untrusted pandoc/chromium.
**Config knobs** (all in `cmd/zddc-server`):
- `--convert-pandoc-binary` (default `pandoc`) / `--convert-chromium-binary` (default `chromium-browser`; `chromium` on debian)
- `--convert-scratch-dir` (default `$TMPDIR`) — host scratch root; the wrapper bind-mounts the per-call subdir
- `--convert-mem-mib` (default 1024) → wrapper's `memory.max`
- `--convert-pids` (default 256) → wrapper's `pids.max`
- `--convert-timeout` (default 60s) → enforced in Go via `context.WithTimeout`
**Other plumbing.**
- I/O via stdin/stdout + scratch dir. Pandoc reads markdown from stdin, writes to stdout. Templates + intermediate HTML + output PDF live in a per-call subdir under the scratch root; the dir's host path is passed to the child via `ZDDC_SCRATCH` so the wrapper bind-mounts it into the sandbox at the same path (no path translation).
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
- **HTML/PDF templates** are named doctype files — `report`, `letter`, `specification` — plus shared partials (`_head.html`, `_doc.html`, `_scripts.html`), living in `pandoc/templates/` (single source of truth; `./build` mirrors them into `zddc/internal/convert/templates/` for `//go:embed`, guarded by `convert.TestEmbeddedTemplatesMatchSource`). A document picks one with `template: <name>` in its YAML front matter (default `report`) and turns on legal heading numbering with `numbering: true` (default off) — both flow straight from the front matter to the template, no converter code. The handler resolves overrides from the `.zddc.d/templates/<name>.html` cascade (`resolveTemplateSet` in `converttemplate.go`): a nearer level (`working/<party>/.zddc.d/templates/`) overrides a farther one (`working/.zddc.d/templates/`), which overrides the embedded default; an override may replace a doctype, a partial, or add a new doctype. NOTE: the per-doc converted cache keys on source mtime only, so editing a template override doesn't invalidate already-cached HTML — purge `.zddc.d/converted/` or touch the source to re-render.
- If pandoc/chromium aren't on PATH (operator running zddc-server outside the runtime image), the endpoint serves 503 with a Retry-After. The rest of the server keeps working. Operators who run zddc-server with raw pandoc/chromium (no wrapper) get a working but unsandboxed conversion endpoint — useful for dev iteration.
## Form-data system (`form/` + zddc-server form handler)
A schema-driven form renderer used to collect structured data into YAML files in the file tree. The form tool (`form/`) is the renderer; the server-side endpoints live in `zddc/internal/handler/formhandler.go`; the validator is `zddc/internal/jsonschema/`.
**Form spec**: `<name>.form.yaml` — top-level envelope is `{title, description, schema, ui, mode}`. `schema` is JSON Schema 2020-12 (subset; see "Validator subset" below). `ui` is RJSF-style (`ui:widget`, `ui:order`, `ui:autofocus`, `ui:placeholder`, `ui:help`, `ui:readonly`, `ui:options.{addable,removable}`). LLMs author this dialect well.
**URL conventions** (form posts back to its own URL; server strips `.html`). The spec lives **inside** the rows-dir alongside the row YAMLs, so the whole form (spec + every submission) is a single self-contained directory:
- `GET /<dir>/form.html` — render empty form
- `POST /<dir>/form.html` — create new submission → 201 + Location capability URL pointing at the new `<dir>/<id>.yaml`
- `GET /<dir>/<id>.yaml.html` — render form pre-filled from `<id>.yaml`
- `POST /<dir>/<id>.yaml.html` — overwrite that submission → 200
**Storage**: spec at `<dir>/form.yaml`. Submission filenames depend on whether the directory has a cascade-declared `records:` rule (see "Records, audit, and history" below):
- **No matching `records:` rule** — submissions land at `<dir>/<YYYY-MM-DD>-<email-sanitized>.yaml` (the legacy date+email scheme; still the path for ad-hoc operator-defined forms).
- **Matching `records:` rule** (mdl/rsk/ssr and operator-declared records) — filename is composed from body fields via the rule's `filename_format`; for rsk-style rules the server also auto-assigns a per-row sequence within the table-scope group.
Copying `<dir>` elsewhere copies the spec plus every submission together. ACL applies via the existing `.zddc` cascade.
**Round-trip**: v0 is form-as-truth — submission YAML is regenerated from form state on every save; comments in submissions are not preserved. File-as-truth mode (lossless YAML round-trip via the eemeli/yaml Document API) is a v1 feature, needed for hand-edited files like `.zddc` itself.
**Validator subset** (`zddc/internal/jsonschema/`): `type` (string/number/integer/boolean/array/object), `enum`, `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `required`, `additionalProperties: false`, `properties`, `items`, `format` (`date`, `email`). Schema also carries three client-facing extensions that survive round-trip but aren't enforced by the validator (the server enforces them via cascade or strip-on-write): `readOnly: true` (UI renders disabled), `x-labels: { code → label }` (paired display text for enum dropdowns). NOT supported in v0: `$ref`, `$defs`, `if/then/else`, `oneOf`/`anyOf`/`allOf`, conditional visibility. The form-spec meta-schema enforces that authors stay in the supported subset.
**Renderer subset** (`form/js/`): types listed above, enum (select / `ui:widget: radio`), `format: date|email`, textarea, nested objects, arrays of primitives, arrays of objects with add/remove rows. `ui:show-when` and reorder are v1.
**Adding a new form**: create a directory `<dir>/` and drop `form.yaml` into it (per `.zddc` ACL). No code change required. Visit `<dir>/form.html`.
## Tables system (`tables/` + zddc-server table handler)
Read/aggregate counterpart to the form system. Renders a directory of YAML row files as a sortable, filterable table; each row clicks through to its `<id>.yaml.html` form editor. The tables tool (`tables/`) is the renderer; the server-side recognizer is `zddc/internal/handler/tablehandler.go RecognizeTableRequest`.
**Discovery is presence-based**, the same convention as forms: a `<dir>/table.yaml` on disk auto-mounts at `<dir>/table.html`. The directory is the table.
**Storage** (self-contained directory):
```
<dir>/
table.yaml ← spec
form.yaml ← row-edit form (paired with table.yaml)
<id>.yaml ... ← rows
```
`table.yaml` and `form.yaml` are excluded from the rows list. Each row is also a form submission — the same files the form system reads — so the table view and the per-row form editor are two views of one folder of YAMLs. Copying `<dir>/` elsewhere copies the entire table (spec + form + every row) — that's the whole point of the in-dir layout.
**One table per directory** by construction (the spec is the singleton `table.yaml`). No `.zddc` reference needed; presence-based discovery is the entire rule. To make a directory a table, drop a `table.yaml` in it — that's it.
**Subfolders inside a table dir are allowed and silently ignored as rows.** The rows iterator filters non-`.yaml` entries, so directories don't show up in the table view. Legitimate subfolder use cases:
- **Nested sub-tables**`<dir>/sub-list/table.yaml` is its own self-contained table at `<dir>/sub-list/table.html`. Composition, not violation.
- **Per-row attachments**`<dir>/<id>.attachments/file.pdf`. Natural sidecar pattern; the row YAML can reference its attachments by relative path.
- **Drafts / staging**`<dir>/.drafts/<id>.yaml` (dot-prefix → hidden from listings as well as from the table).
- **Per-row history**`<dir>/.zddc.d/history/<base-without-ext>/<RFC3339Nano>-<sha8>.yaml`. Server-managed; one directory per record, one file per archived revision. See "Records, audit, and history" below.
**Default-MDL fallback at `archive/<party>/mdl/`**: when no `table.yaml` (or `form.yaml`) exists on disk in this exact location, the server serves embedded default bytes. The `mdl/` directory itself doesn't even need to exist — the URL renders the default MDL view fully virtually so a fresh archive surfaces the master document list without operator setup. Outside `archive/<party>/mdl/`, presence-based discovery is the rule.
### Server-injected collections (`apiActions`) — dynamic/virtual tables
The tables renderer also accepts a **fully pre-assembled, server-injected** `#table-context` (`{title, description, columns[], rows[]}` — used as-is, no directory walk; see `tables/js/context.js` and `handler.injectTableContextObj`). This lets a server handler render a *dynamic or virtual* record collection through the same engine + header chrome as an on-disk table, instead of a bespoke page. When the injected context also carries an **`apiActions`** block, the generic `tables/js/api-actions.js` layer turns the read-only table into a managed collection backed by a REST endpoint — **without touching the file-save/row-ops machinery** (which is bound to `<dir>/*.yaml` row files):
```
apiActions: {
create: { url, title?, fixed?{k:v}, fields:[{name,label,placeholder?,type?,required?}], secretField?, secretLabel? },
deleteRow: { urlTemplate (with {id} ← row data-url), label?, confirm? },
rowNav: true // clicking a row navigates to its data-url (capture-phase)
}
```
`create` → modal form → `POST` (date fields → RFC3339; `fixed` adds constants; a `secretField` in the response is shown once); `deleteRow` → per-row button → `DELETE`; both reload on success. It also hides the file-model toolbar buttons (`+ Add row`, `Save`).
**Consumers:** `/.tokens` (`handler.buildTokensTableContext` → `/.api/tokens`) and `/.profile` (`handler.buildProfileTableContext` → effective access + `POST /.profile/projects` + super-admin diagnostic rows). Per-role correctness is enforced **server-side** — a row/action only appears when the caller is authorized (e.g. profile diagnostics gated on elevated super-admin), so a non-admin's bytes never name a capability they lack. This is the "any dynamic collection is a declarative table, not a bespoke page" primitive from ARCHITECTURE.md's browse-as-shell ADR.
**Default-MDL columns mirror the tracking-number components** documented at `zddc.varasys.io/reference.html#tracking-numbers`. The default ships the five required components + an optional per-deliverable `suffix`: `originator`, `project`, `discipline`, `type`, `sequence`, `suffix` — each a slot of the deliverable's permanent identifier — plus `title`, `plannedRevision`, `plannedDate`, `status`, `owner`. The project-wide `phase` / `area` components are shipped only as commented-out templates in the default form/table YAML (a project that uses them must enable them on *every* deliverable to keep filenames lexically consistent, so the simplest default omits them). `originator` is **folder-bound**: the cascade's `folder_fields` pins it to the party-folder name, so the form renders it read-only and the server sets it from the path. The form schema accepts free-text on every other component by default; projects narrow the vocabulary via the cascade's `field_codes:` (see below). Operator overrides at `archive/<party>/mdl/{table,form}.yaml` still win atomically over the embedded defaults. Source: `zddc/internal/handler/default-mdl.{table,form}.yaml`.
**Adding a new table**: create a directory `<dir>/` and drop `table.yaml` (and optionally `form.yaml` for row editing) into it. No code change required. Visit `<dir>/table.html`.
## Records, audit, and history
The "records" subset of the tables system carries three guarantees the generic form/table flow doesn't: server-stamped audit fields, immutable per-record history, and cascade-driven filename composition. The mechanism lives in `zddc/internal/handler/history.go` (`WriteWithHistory`) and `zddc/internal/zddc/field_codes.go`. Three record types ship out of the box:
| Type | Folder | Filename | Identity carrier |
|---|---|---|---|
| **MDL** (deliverables) | `archive/<party>/mdl/` (many siblings) | Composed tracking number, e.g. `ACM-PRJ-EL-SPC-0001.yaml` | Body's component fields |
| **RSK** (risk register) | `archive/<party>/rsk/` (many siblings, multiple tables) | `<table-tracking>-<row>.yaml`, e.g. `ACM-PRJ-EL-RSK-0001-001.yaml` | Body's components + server-assigned row sequence |
| **SSR** (parties register) | `archive/<party>/ssr.yaml` (one per party folder) | Always literal `ssr.yaml` | Parent folder name (existing `name` strip/inject in `ssrhandler.go`) |
**Two new `.zddc` keys** carry the rules (see `zddc/internal/zddc/file.go` + `field_codes.go`):
- `field_codes:` — vocabulary for the components used in filename composition and constrained body fields. Each entry is a discriminated union over `kind: enum|pattern|free` (`{kind: enum, codes: {ACM: Acme Inc, …}}` / `{kind: pattern, pattern: "^[0-9]{4}$"}` / `{kind: free, description: "..."}`). Map-merge across the cascade (like `display:`/`tables:`) — a deeper level can narrow or replace a single code's vocabulary without dropping unrelated codes.
- `records:` — per-pattern rules keyed by filename basename (literal `ssr.yaml` or glob `*.yaml`). Each entry carries `filename_format` (composition template with `{field}` and `{field?}` placeholders), `field_defaults`, `locked`, `folder_fields`, plus `row_field` + `row_scope_fields` for RSK-style tables-of-rows. Filename-pattern scoping is what lets the SSR rule live at the party-folder level without affecting `mdl/`, `rsk/`, `received/`, etc., siblings.
- `folder_fields:` — map of `field → parent-distance` that binds a body field to an ancestor folder name (the folder is the sole source of truth). The map value is how many directories ABOVE the record's own directory the source folder sits (`originator: 1` under `archive/<party>/mdl/` resolves to the `<party>` folder). The server overwrites the body field with the derived name before validation + composition (so a client value can never disagree; a mismatched URL still trips the `filename_format` check), and the form renderer marks the field read-only and pre-fills it.
Defaults are baked into the embedded default tree; `field_codes:` ships empty (every deployment writes its own vocabulary). The default mdl/rsk records bind `originator` via `folder_fields: {originator: 1}` so the party folder is the originator's source of truth — `originator` is therefore *not* a `field_codes:` entry by default.
**Six server-managed audit fields** are injected on every write and stripped from incoming bodies before validation (snake_case to match `.zddc`'s existing `created_by:`):
- `created_at`, `created_by` — stamped on create; preserved untouched on every update
- `updated_at`, `updated_by` — refreshed on every write
- `revision``1` on create, `+1` per update
- `previous_sha` — first 8 hex chars of SHA-256 of the prior revision's bytes; absent on create. Forms a hash chain for tamper evidence
**History layout**: for any record at `<dir>/<base>.<ext>`, the prior version is archived at `<dir>/.zddc.d/history/<base>/<RFC3339Nano-UTC>-<sha8>.<ext>` before the live file is overwritten. Per-record subfolder under `.zddc.d/history/` keeps `readdir` cheap and makes party-folder rename move SSR history along atomically (the dot-folder is inside the party folder, so `os.Rename` carries it).
**Write ordering**: history first, then live. A crash between the two leaves the prior version safely archived; the retry is idempotent because the history filename is deterministic (timestamp + sha of prior bytes).
**Strip-and-stamp policy**: clients can't forge audit fields. `WriteWithHistory` strips all six keys from the incoming body BEFORE schema validation runs, then injects authoritative values from request context. A client that sends `created_by: eve@evil` finds it silently overwritten with the request principal.
**Wire surface** — every record-write entry point converges on `WriteWithHistory`:
- `PUT /<record>.yaml` — routed through `WriteWithHistory` automatically when the basename matches a `records:` rule. Response echoes the stamped YAML as the body (Content-Type: application/yaml) so the tables client can update its row state without a re-GET.
- `POST /<dir>/form.html` (in-dir create) and `POST /<dir>/<id>.yaml.html` (in-dir update), plus the project rollup `POST /<project>/(mdl|rsk)/form.html` — when a `records:` rule with a `filename_format` applies in the target directory, these compose the filename (shared `recordCreatePrep`: field_defaults + folder_fields + row-assign + compose), then route through `WriteWithHistory` for audit + history + the composed-name match check. So "+ Add row" from a per-party table no longer drops un-stamped, date+email-named rows. Directories with no record rule keep the generic date+email submission write.
- `GET /<record>.yaml?history=1` — JSON list of prior revisions: `[{revision, ts, by, sha, path}, …]`. ACL gates against the live record (read it → read its history).
**Record-vs-config distinction**: `WriteWithHistory` fires only for genuine record paths. The gate (`isRecordPath` in `fileapi.go`) excludes `table.yaml`, `form.yaml`, `.zddc`, and the spec naming variants `*.table.yaml` / `*.form.yaml`. Those bypass audit stamping (they're configuration, not data) and go through plain `WriteAtomic`.
**Operator customization**:
- To narrow a deployment's discipline codes: write `field_codes: discipline: {kind: enum, codes: {EL: Electrical, ME: Mechanical, …}}` at the project root `.zddc`. (`originator` is folder-bound by default — see `folder_fields` above — so it's set from the party folder rather than constrained by a code list.)
- To add a new table type: declare a `records:` entry under the appropriate `paths:` level (or a sibling `.zddc` in the folder) with a `filename_format` referencing fields the body carries.
- To inspect a record's revision history: `curl https://<host>/<path>.yaml?history=1 -H 'Authorization: Bearer …'`.
**Server-side only (offline gap)**: every record guarantee — audit stamping, immutable history, `filename_format` composition, `field_codes`/`locked` validation, and `folder_fields` binding — runs in zddc-server (`WriteWithHistory` + the form handlers). The tools opened offline (`file://` or the File-System-Access picker, no server) **cannot** enforce any of it: a record write needs the server. This is by design — the server is the authority — but it means folder-bound originator, composed filenames, and audit fields don't materialize for purely-offline edits.
**Upgrading a pre-folder-binding deployment** (records created before these defaults):
- Stored `suffix:` values that carried a leading dash under the old `-A` convention now compose a doubled dash (`…0001--A`) and 422 on next edit. Strip the leading dash from `suffix:` values (`-A` → `A`); the cascade's `filename_format` supplies the separator now.
- A row whose `originator` differs from its party-folder name is silently rewritten to the folder name on the next write (the folder is the source of truth). Filenames whose originator segment disagrees with the folder will 422 until the file is renamed to match.
- Deployments that used the project-wide `phase`/`area` components already supplied a custom `form.yaml` + `.zddc` override (the prior default couldn't compose those slots otherwise), so the phase/area removal from the embedded defaults doesn't affect them.
Source: `zddc/internal/handler/history.go`, `zddc/internal/zddc/field_codes.go`, `zddc/internal/zddc/walker.go`, `zddc/internal/zddc/cascade.go`, `zddc/internal/zddc/defaults/`. Tests: `zddc/internal/handler/history_test.go`, `zddc/internal/zddc/field_codes_test.go`.
## Implementation-vs-dependency policy
Match implementation cost to actual surface used. Reimplement focused subsets when a dep's surface area is much larger than what we consume; adopt for genuinely large specs (YAML parsing, etc.) where reimplementing is foolish. Examples in this codebase:
- **`zddc/internal/jsonschema/`** — focused 2020-12 validator (~300 LoC) covering only the v0 form-spec subset. A full library (e.g. `santhosh-tekuri/jsonschema/v6`) brings 70%+ surface we don't use.
- **`gopkg.in/yaml.v3`** — adopted as a dep. Reimplementing YAML is foolish.
This is a guideline, not a rule. Revisit per-feature: when v1+ form-spec adds `$ref` + `oneOf` + `if/then/else`, the validator's "savings" evaporate and adopting becomes cheaper.
## zddc-server
Go HTTP server sub-project living at `zddc/`. Replaces `caddy file-server --browse` for ZDDC archives.
### Bootstrap config (REQUIRED — unlocks the server)
zddc-server grants no access to anyone until two operator files are populated. The embedded default tree ships with empty role members and references those roles throughout its cascade, so a fresh deployment refuses every request until the operator opts in. `zddc-server` logs a startup warning (see `warnIfNoBootstrap` in `zddc/cmd/zddc-server/main.go`) when the root `.zddc` grants nobody anything — skipped under `--no-auth`.
**Root `<ZDDC_ROOT>/.zddc`** — at minimum, declare an admin:
```yaml
admins:
- cwitt@example.com
```
`admins:` at the **root** confers super-admin (`IsAdmin`, root-only — subdir `admins:` are ignored by `IsAdmin`, see `zddc/internal/zddc/file.go:109-112`). `admins:` at **any level** confers *subtree* admin over that level and below (`IsSubtreeAdmin` / `IsConfigEditor`). Config-edit (editing `.zddc`/roles you administer) is **standing** — no elevation. Only the override powers (WORM bypass, recursive delete, rearrange, out-of-scope) gate on the `zddc-elevate=1` cookie or implicit bearer-token elevation. See "Admin authority: standing config-edit + additive elevation".
**Per-project `<project>/.zddc`** — populate role members:
```yaml
title: "Project Phoenix"
roles:
document_controller:
members:
- dc1@example.com
project_team:
members:
- alice@example.com
- '*@acme.com'
observer:
members:
- auditor@regulator.gov
```
The embedded cascade already grants `project_team: r` and `observer: r` project-wide, and `document_controller: rw` (+ `rwc` on `archive/`, WORM filing on `received/issued`, `rwcd` at `incoming/` and `staging/` for the QC + transfer workflows). When DC creates an `archive/<party>/` folder the auto-own `.zddc` written there grants both their email AND the `document_controller` role `rwcda` (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Plan-Review approval is part of the `document_controller` role by design — there is no separate `approver` role; two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides. The three standard roles' invariants are locked down in `zddc/internal/zddc/standardroles_test.go`.
The in-flight lifecycle slots form a one-way ratchet:
`working/``staging/``issued/` (WORM)
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside the `<party>/` folder they create (auto-owned but **unfenced**`working/` is a shared team space, so peers keep their `cr` there). At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/``received/` (WORM).
Pick a role per persona:
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list).
- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Auto-owns (`rwcda`) the `working/<party>/` folder they create; it is **unfenced**, so `working/` stays a shared team space (every `project_team` member keeps cascade `cr` there). A per-directory `auto_own_fenced` opt-in (not set in the default tree) would make it private.
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
**Roles overlap on purpose.** DCs are typically internal employees and ARE in `project_team` (often defined as `*@example.com`). The cascade is "deepest level that has any matching principal wins for that level, with within-level UNION of all matched principals". To prevent a `project_team: cr` grant at the slot from shadowing a DC's role-level `rwcda` inherited from the party folder, the embedded defaults RESTATE `document_controller: rwcda` at every slot that has a project_team-specific grant (`working/`, `staging/`, `reviewing/`). Within-level union → DC gets `rwcda` `cr` = `rwcda`. Operators adding new slot-level project_team grants in their own `.zddc` files should follow the same pattern. (Internal `observer` users matched by the project_team wildcard would still be lifted to `cr` by the union — observer is intended for EXTERNAL auditors whose emails don't match the wildcard. Deployments with internal observers should use explicit project_team membership instead of a wildcard.)
**Schema** (source of truth: `zddc/internal/zddc/file.go:43-49`, `:74-77`, `:139-145`):
- `acl: { permissions: { <principal>: <bits> }, inherit: <bool>? }` — there is no `allow:` key; an `allow:` block parses cleanly but is silently dropped during unmarshal. Real footgun — easy to write `acl: { allow: [...] }` and assume it works.
- Bits: any subset of `r w c d a` (read / write / create / delete / admin); empty string is an explicit deny.
- Principals: email (must contain `@`), glob (`*@domain.com`), or role name (no `@`).
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
- `admins: [<email>, ...]` — root only; sudo-style elevation per request.
- `auto_own: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir.
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc`, making the directory private to its creator (ancestor grants don't cascade in). **Opt-in — not set anywhere in the default tree**, so the default working/staging/incoming/reviewing party homes are unfenced/shared. No effect without `auto_own: true`.
- `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation).
- `title:` — read only from the per-project `.zddc`; surfaces on the landing-page picker.
**Two `inherit` scopes, one word.** `ZddcFile.Inherit` (top-level) drops the embedded baseline AND fences ancestor on-disk `.zddc` files from this point of the cascade. `ACLRules.Inherit` (nested under `acl:`) is narrower — it only fences ACL evaluation; embedded roles, paths-tree contributions, WORM lists, and other non-ACL keys still cascade through. Concretely:
- To opt out of embedded defaults at deployment, set `inherit: false` at the root `<ZDDC_ROOT>/.zddc` (top-level).
- To make a per-user home private (block ancestor read grants) but keep cascade-derived behaviour like default_tool, set `acl: { inherit: false }`. The auto-own-fenced mechanism uses this form.
These are NOT interchangeable. A note about which one operators want lives in `cascade.go:13-21` (the `PolicyChain` doc) and the relevant struct fields in `file.go`.
Run `zddc-server show-defaults` to export the embedded default tree as a `.zddc.zip` of per-depth files — those files are the full schema with all the cascade keys (`worm:`, `auto_own:`, `drop_target:`, `convert:`, `on_plan_review:`, `records:`, `available_tools:`, `default_tool:`, `dir_tool:`, etc.).
### Build
zddc-server ships as a cross-compiled binary, not a container image. There's no Containerfile or compose file in this repo (the chart Dockerfiles compile from source at deploy time at the right tag).
```sh
# Compile a local binary for the host platform via the build image.
# Same flag pattern as Test below — see that subsection for why.
podman run --rm --network=host -v "$PWD":/src:Z -v /tmp/gocache:/root/go/pkg/mod:Z \
-w /src/zddc -e GOPROXY=https://proxy.golang.org -e GOSUMDB=off -e GOPRIVATE= \
localhost/zddc-go:1.24 go build -o zddc-server ./cmd/zddc-server
# `go run` is normally a one-liner but in-container `go run` of a network
# server is awkward — for dev iteration, build with the line above and
# launch the binary on the host (`./zddc/zddc-server`).
```
The repo's top-level `./build` cross-compiles the four release binaries (linux/amd64, darwin/amd64, darwin/arm64, windows/amd64) into `zddc/dist/` via a containerized Go toolchain (podman or docker). On `./build release` it also promotes those binaries to `dist/release-output/` with their per-platform canonical symlinks + stub pages — same lockstep flow as the HTML tools. `./deploy` rsyncs the bundle to `/srv/zddc/releases/`.
### Test
Go is **not** installed on the dev shell host directly — run `go test` (and `go build`) through a golang-alpine image. `./build`'s containerized cross-compile already pulls `golang:1.24-alpine` as a build stage; tag it for reuse:
```sh
# One-time: locate the golang-alpine image (~810 MB, untagged after a build run)
# and give it a stable name. The size and `golang/.../go test` lines distinguish
# it from the small ~18 MB zddc-server runtime image.
podman images --filter dangling=true --format '{{.Size}}\t{{.ID}}' | sort -h | tail
podman tag <id> localhost/zddc-go:1.24
# If you have no <none> 810 MB image (fresh machine), pull directly:
podman pull docker.io/library/golang:1.24-alpine
podman tag docker.io/library/golang:1.24-alpine localhost/zddc-go:1.24
```
Canonical invocation:
```sh
podman run --rm --network=host \
-v "$PWD":/src:Z \
-v /tmp/gocache:/root/go/pkg/mod:Z \
-w /src/zddc \
-e GOPROXY=https://proxy.golang.org \
-e GOSUMDB=off \
-e GOPRIVATE= \
localhost/zddc-go:1.24 \
go test ./...
```
Why each flag:
- `--network=host` — the alpine image's TLS chain can't shake hands with `gopkg.in` directly (sandbox hits "Connection reset by peer"); host networking + the proxy below works around it.
- `GOPROXY=https://proxy.golang.org` — fetch via the public proxy. Without this, the build-image's baked `GOPRIVATE=*` forces direct VCS, which fails on `gopkg.in/natefinch/lumberjack.v2`.
- `GOSUMDB=off` — sum.golang.org isn't reachable from the sandbox either; we already trust the proxy.
- `GOPRIVATE=` (empty) — explicit override of the image's `GOPRIVATE=*`, which is a leftover from how `./build` does in-container compilation and would otherwise re-trigger direct fetch.
- `/tmp/gocache` mount — persistent module cache across runs.
Run-it-once-per-session pattern: alias it. Do **not** `apt install golang` on the host — the image is the source of truth for the version pin, so dev and CI compile against the same Go.
### Run (development)
```sh
ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
go run ./cmd/zddc-server
```
For a release binary downloaded from `zddc.varasys.io/releases/`:
```sh
curl -O https://zddc.varasys.io/releases/zddc-server_stable_linux-amd64
chmod +x zddc-server_stable_linux-amd64
ZDDC_ROOT=/path/to/your/archive ZDDC_TLS_CERT=none ZDDC_ADDR=:8080 \
./zddc-server_stable_linux-amd64
```
### Key environment variables
| Variable | Default | Purpose |
|---|---|---|
| `ZDDC_ROOT` | *(required)* | Path to served file tree |
| `ZDDC_ADDR` | `:8443` | Bind address |
| `ZDDC_EMAIL_HEADER` | `X-Auth-Request-Email` | Header set by upstream proxy with user email (oauth2-proxy / nginx auth-request convention) |
| `ZDDC_INDEX_PATH` | `.archive` | Virtual archive index URL segment |
| `ZDDC_LOG_LEVEL` | `info` | Logging verbosity |
| `ZDDC_CORS_ORIGIN` | *(empty)* | Comma-separated CORS allowlist; empty (default) disables CORS — appropriate for embedded-tools deployments where tools and data are same-origin. Set explicitly only for self-hosted tools at a different host (e.g. `https://tools.acme.com`) or the CDN-bootstrap pattern (`https://zddc.varasys.io`). |
| `ZDDC_INSECURE` | *(empty)* | Must be `1` to allow startup with no `<ZDDC_ROOT>/.zddc`. Without it, the server refuses to start because no `.zddc` files anywhere → public-by-default. Set only for deliberately-public archives. |
| `ZDDC_NO_AUTH` | *(empty)* | `1` skips ACL enforcement entirely on this instance. On a master: anyone reads everything (dev / trusted-LAN read-only deployments). On a downstream proxy/cache/mirror: trust upstream's filtering, don't re-evaluate ACLs locally. **Distinct from `ZDDC_INSECURE`** (which gates a startup safety check). |
| `ZDDC_UPSTREAM` | *(empty)* | Master URL (`https://master.example.com`). When set, the binary runs as a **client** (downstream proxy/cache/mirror) instead of a master — the master-side machinery (archive index, apps server, watcher, OPA, ACL middleware, token store) is replaced by the cache layer in `zddc/internal/cache/`. `--root` becomes the cache directory. **Setting this also downgrades the `--addr` default to `127.0.0.1:8443` (loopback)** — the cache forwards a bearer to upstream without authenticating the local caller, so non-loopback binds with `ZDDC_BEARER_FILE` set are refused unless `ZDDC_INSECURE_DIRECT=1` is also set. |
| `ZDDC_MODE` | `cache` | Client mode: `proxy` (forward live, no persistence), `cache` (default; persist responses on access), `mirror` (phase 3 — currently behaves like `cache`). Ignored when `ZDDC_UPSTREAM` is empty. |
| `ZDDC_BEARER_FILE` | *(empty)* | Path to a 0600 file containing the master-issued token (see `/.tokens` on the master). Forwarded as `Authorization: Bearer …` to upstream on every request. Ignored when `ZDDC_UPSTREAM` is empty. |
| `ZDDC_SKIP_TLS_VERIFY` | *(empty)* | `1` accepts self-signed / untrusted upstream certs. Distinct from `ZDDC_NO_AUTH`. Dev / internal-CA scenarios only. |
| `ZDDC_MIRROR_SUBTREE` | *(empty)* | Comma-separated URL subtrees the access-triggered mirror walker keeps current (e.g. `/Vendors/Acme,/Public`). Empty + `ZDDC_MODE=mirror` = full mirror (`/`). Ignored when `ZDDC_MODE != mirror`. |
| `ZDDC_MIRROR_MIN_INTERVAL` | `1h` | Minimum gap between walks of the same mirror subtree. Idle subtrees generate zero upstream traffic until next access. Format is Go `time.ParseDuration`. |
| `ZDDC_OPA_URL` | `internal` | Policy decider endpoint. `internal` (default) = in-process Go evaluator (same `.zddc` cascade we always had). `http(s)://...` or `unix:///...` = external OPA — every access decision becomes a `POST /v1/data/zddc/access/allow` to the configured endpoint. Federal customers with their own audited Rego use this; commercial deployments leave it `internal`. |
| `ZDDC_OPA_FAIL_OPEN` | *(empty)* | External OPA only. `1` = allow on transport error; default = fail closed (deny). |
| `ZDDC_OPA_CACHE_TTL` | `1s` | External OPA only. Per-decision cache TTL — amortizes round-trips on bursty patterns (e.g. `.archive` listings hit the same `(email, dir)` tuple many times). `0` disables. Format is Go `time.ParseDuration`. |
| `ZDDC_ACCESS_LOG` | `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` | JSON-line audit log (lumberjack-rotated, 100 MB / 10 backups / 90 days, gzipped). Server auto-mkdirs the parent. Set explicitly to empty (`--access-log=`) to disable. Per-host filename + `host` field in every record so multi-replica deployments writing to the same `.zddc.d/` dir disambiguate cleanly. |
### URL handling
**URLs are case-insensitive.** The dispatcher canonicalizes `r.URL.Path` against on-disk casing before any handler runs (`zddc/internal/fs/resolve.go ResolveCanonical`). Per segment: lowercase variant wins if it exists on disk; otherwise exact-case wins; otherwise readdir+CI scan with the lowercase variant winning the tiebreak when multiple case variants are siblings on disk. Walk stops at the first segment that doesn't exist so virtual prefixes (`.archive`, `.profile`, `.tokens`, `.api`, `.auth`) and 404 paths flow through with their tail preserved verbatim.
**File and folder names preserve case on disk.** The canonicalization is purely a URL→filesystem-name mapping; nothing renames anything. Lowercase is the *project-wide canonical* convention, and auto-created folders in `internal/zddc/ensure.go` (the per-party `archive/<party>/{working,staging,reviewing,incoming}/`) and the server's own state dirs (`.zddc.d/tokens/`, `.zddc.d/outbox/`, `.zddc.d/logs/`) are all lowercase by string literal. Operators can drop a `Mixed-Case-Folder/` and it stays mixed-case.
**Audit log captures the as-typed path.** `AccessLogMiddleware` snapshots `r.URL.Path` before dispatch rewrites it; the audit record's `path` field is what the client sent. When canonicalization changed it, a `resolved_path` field is added.
**`.zip` files are navigable directories.** `GET …/Foo.zip/` → JSON listing of the zip's members (or browse HTML); `GET …/Foo.zip/sub/doc.pdf` → that one member, extracted and streamed (Range/ETag via `http.ServeContent`); `GET …/Foo.zip` (no slash) → the raw `.zip` download, unchanged. Write methods to a path inside a `.zip` → 405 (read-only). ACL = the chain of the directory *containing* the zip (a zip has no `.zddc`, like `.archive`). Code: `internal/zipfs` (member listing/extraction with a zip-slip guard) + `handler.ServeZip`, routed by `splitZipPath` in `dispatch` *before* the file-API branch (gated by a cheap `.zip/` substring check so ordinary requests don't pay an `os.Stat`-per-segment walk). Client-side, `shared/zip-source.js` (`ZipDirectoryHandle`/`ZipFileHandle` over JSZip) gives the archive and browse tools the same navigation offline. Archive treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder (`isTransmittalFolderZip` in `archive/js/parser.js`); browse expands any `.zip`. Nested zips: the server serves one level (`…/Foo.zip/inner.zip` is the inner zip's bytes; `…/Foo.zip/inner.zip/` isn't a listing) — clients that need deeper nesting fetch the inner zip whole and recurse with JSZip.
**`GET /dir/?zip=1` — subtree download.** Streams an `application/zip` of every readable file under `/dir/` (recursively), `Content-Disposition: attachment; filename="<dir>.zip"`, `X-ZDDC-Source: subtree-zip`. ACL-filtered per file's containing-dir `.zddc` chain (per-dir decision cache, same as `serveArchiveListing`); skips `.`/`_`-prefixed entries (`.zddc`, `_template`, `_app`); adds a `.zip` *file* it meets as opaque bytes (does not recurse). Streamed, so an empty/fully-denied subtree is a valid empty zip, not a 403. The query check is in `dispatch`'s `info.IsDir()` branch right after the directory ACL gate (so it works on `/dir` and `/dir/`); code: `handler.ServeSubtreeZip`. The browse tool's toolbar "Download (zip)" button uses it in server mode; offline it bundles the picked folder with JSZip (`confirm()` above ~2000 files / ~500 MB).
### Client mode (proxy / cache / mirror)
When `--upstream <url>` is set, the binary runs as a **downstream client** of another zddc-server instead of a master. `cmd/zddc-server/main.go` short-circuits to `runClient(cfg)`, which builds a `*cache.Cache` from `zddc/internal/cache/` and uses it as the entire request handler — no archive index, no apps server, no watcher, no OPA decider, no ACL middleware, no token store.
Three modes via `--mode <proxy|cache|mirror>` (default `cache`). Cache directory layout is intentionally a normal ZDDC root: `<master>/foo/bar.txt``<root>/foo/bar.txt`. Unset `--upstream` and the same root serves as a plain master, useful for portable offline snapshots.
Pipeline:
- Cache hit → serve immediately + background `If-Modified-Since` revalidate (304 no-op, 200 overwrite, 403/404 purge).
- Cache miss → forward to upstream; stream response simultaneously to client and a tmp-file atomically renamed into the cache.
- Network error + cached version → serve stale + `X-ZDDC-Cache: offline`.
- Network error + no cache → 503 + `X-ZDDC-Cache: offline`.
- Directory listings cached as `<dir>/.zddc-listing.<html|json>` sidecars (Accept-varied).
- `Cache-Control: no-store` / `private` responses pass through but are not persisted.
- **Writes** (PUT / POST / DELETE) forward to upstream when online; on transport error, queue in `<root>/.zddc-outbox/<id>/` (meta + body) and return `202 Accepted` + `X-ZDDC-Cache: queued`. Background loop replays in order — 2xx deletes the entry, 412 → `<id>.conflict-<ts>/`, 4xx-other drops, 5xx defers. PUT/DELETE include `If-Unmodified-Since` from the cached mtime so the master can reject conflicting writes.
- **Mirror mode** (`--mode mirror`): adds an access-triggered subtree walker (rate-limited via `--mirror-min-interval`, default 1h) that recursively pre-fetches under `--mirror-subtree`s; idle mirrors generate zero upstream traffic.
Two-instance smoke test recipe:
```sh
# Master.
mkdir -p /tmp/m && echo 'admins: [you@example.com]' > /tmp/m/.zddc
echo "hello" > /tmp/m/hello.txt
zddc-server --root /tmp/m --addr 127.0.0.1:18443 --tls-cert=none --no-auth &
# Client (cache mode).
mkdir -p /tmp/c
zddc-server --root /tmp/c --addr 127.0.0.1:18444 --tls-cert=none \
--upstream http://127.0.0.1:18443 --mode cache --no-auth &
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → miss
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → hit
ls /tmp/c # → hello.txt + .zddc-upstream marker
kill %1; sleep 1
curl -sI http://127.0.0.1:18444/hello.txt | grep -i x-zddc-cache # → hit (still served from disk)
curl -si http://127.0.0.1:18444/never.txt | head -1 # → 503
```
`X-ZDDC-Cache` response header values: `miss`, `hit`, `proxy` (no-persist or directory), `offline` (network unreachable). Useful for browser-side freshness UI.
Implementation: `zddc/internal/cache/cache.go` (a single file). Tests in `zddc/internal/cache/cache_test.go` use `httptest.NewServer` as a fake upstream and cover hit/miss/offline/range/bearer-forwarding/no-store paths.
### Bearer tokens (CLI auth)
zddc-server self-issues bearer tokens for CLI / non-browser callers. No external IDP, no JWKS rotation. Source of truth: `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` — a YAML file per token with `email`, `created`, `expires`, `description`. Filename is the **hash** of the token; the plaintext is never persisted.
User flow: sign in to the master in a browser, visit `/.tokens`, click "Create token," copy the value (shown once). Store in a 0600 file and pass `--bearer-file <path>` to a CLI that calls back into zddc-server, or send `Authorization: Bearer <token>` directly from scripts.
Endpoints:
- `GET /.tokens` — HTML self-service page (gated by browser auth).
- `GET/POST /.api/tokens` — list / create. Plaintext returned **only** on POST response.
- `DELETE /.api/tokens/<id>` — revoke. `<id>` is the 8-char short ID or full hash.
Validation flow inside the request path: `ACLMiddleware` checks for `Authorization: Bearer …` first; on success, sets the request email from the token file. On any failure (unknown / expired / store unavailable), returns 401 — there is no fallback to header-based auth, so a misconfigured client can't silently masquerade as anonymous. If no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
The tokens directory inherits the existing `.zddc.d/` exclusion: dot-prefix segments 404 from direct GETs, and `fs.ListDirectory` filters them from listings. **Verify on any new deployment by attempting `GET /.zddc.d/tokens/anything` and confirming 404.**
Implementation: `zddc/internal/auth/` (storage), `zddc/internal/handler/tokenhandler.go` (HTTP layer), middleware extension in `zddc/internal/handler/middleware.go`.
### Admin authority: standing config-edit + additive elevation
Two distinct layers — keep them straight:
**Standing config-edit (no toggle).** Editing configuration is a *standing* permission, not a sudo escape hatch. `zddc.IsConfigEditor(chain, email)` — being a subtree admin (any `admins:` grant on the cascade) OR holding the `a` verb — lets a principal read AND edit the `.zddc` / `.zddc.zip` / role definitions of the subtrees they administer *without elevating*. The decider (`policy.InternalDecider.Allow`) grants `VerbA` on that basis **above the WORM clamp**: config is not WORM-protected data, and `VerbA` only ever authorises config mutation (never write/delete/create of records). Plain `.zddc` reads are gated by directory read-ACL (`ServeZddcFile`), so config is transparent to anyone who can read the path. The blast radius of config-edit is exactly "this subtree and down" — authority cascades downward only (editing `/A/B/.zddc` needs admin over `/A/B`, which never appears in `/A`'s chain), and `ActionAdmin` requires `VerbA`, so a plain `w`/`c` grant can't write a self-promoting `.zddc`.
**Elevation is the additive sudo layer.** It unlocks only "things you otherwise couldn't do": WORM bypass, recursive directory delete, rearranging records, auto-own takeover, acting outside your admin scope, profile admin scaffolds. `IsActiveAdmin = (admin authority on the chain) AND Elevated` is the **single bypass site** in the decider. Carried in a `zddc-elevate=1` **session** cookie (no Max-Age, SameSite=Lax; cleared on `pagehide` so admin mode is scoped to the page you armed it on). Armed by the on-page toggle every tool renders bottom-right *only for `can_elevate` users*, by `?admin=true|false` (honored per-request server-side too), or implicitly for bearer tokens (CLI/mirror can't toggle a cookie; their authority is the bearer's full grant). `shared/elevation.js` applies state in place (no reload — a reload would race the pagehide-clear) and emits a `zddc:elevationchange` event so SPAs (browse) re-fetch verbs.
Server-side `zddc.Principal{Email, Elevated}` is built once per request by `ACLMiddleware`; `IsAdmin` / `IsSubtreeAdmin` take a `Principal` and stay elevation-gated (they guard the overrides), while `IsConfigEditor` is ungated (the standing config-edit path). `PrincipalFromContext(r)` is the bundling helper. `/.profile/access` exposes `can_elevate` (elevation-independent "does this email have any admin grant anywhere?"); the access log captures `elevated=<true|false>` per request.
Implementation: `zddc/internal/zddc/admin.go` (Principal + `IsConfigEditor`/`IsSubtreeAdmin`/`IsAdmin`), `zddc/internal/policy/policy.go` (decider: `IsActiveAdmin` bypass + standing `VerbA` branch above the WORM clamp), `zddc/internal/handler/middleware.go` (cookie/bearer/`?admin` → ElevatedKey), `shared/elevation.{js,css}` (on-page toggle + ephemeral cookie, concat'd into every tool).
### Release tagging
zddc-server has no separate release script. The top-level `./build release [version]` is the canonical path: it cross-compiles the binaries inside the containerized Go toolchain, copies them into `dist/release-output/` with their per-platform canonical symlinks (`zddc-server_<platform>` → `zddc-server_v<X.Y.Z>_<platform>`), regenerates the per-version + canonical stub pages, refreshes the index, and tags `zddc-server-v<X.Y.Z>` alongside the seven HTML-tool tags.
```sh
./build release # lockstep stable, coordinated next version
./build release 1.2.0 # lockstep stable, explicit version
./build beta # internal SHA snapshot for the BMC dev chart
./deploy --releases # publish the bundle to /srv/zddc/releases/
```
The script tags every tool but does NOT push — finish with `git push origin main && git push origin --tags` (and run `./deploy` to put the artifacts on the live site).
**Versioning** — clean semver. Stable cuts emit one `<tool>-vX.Y.Z` tag per tool, all 8 sharing the same X.Y.Z. No `-alpha.N` / `-beta.N` counter tags — the canonical URL `<tool>.html` is the stable URL; counters would defeat that. Historical per-tool independent tags (`archive-v0.0.2`, `zddc-server-v0.0.7`, etc.) stay as artifacts; the next coordinated cut jumps every tool to the same number.
**Binary distribution** — `/srv/zddc/releases/zddc-server_<X>_<platform>` (on the deploy host) are real static files served from `zddc.varasys.io/releases/`. No Codeberg release assets, no `$CODEBERG_TOKEN`, no third-party mirror, no LFS. The matrix-cell link points at `zddc-server_<X>.html`, a generated stub page that surfaces the four platform downloads in one click.
There is no CI for this — solo workflow benefits from one canonical
local path that fails loudly and visibly on the developer's terminal.
### Notes
- No external test framework yet — Go unit tests run with `go test ./...` inside `zddc/`. The Go toolchain is not on the host; use the `localhost/zddc-go:1.24` image as documented in the **Test** subsection above.
- Portfolio files (`*.portfolio`) in the served tree appear as virtual group directories
- Every folder under a project exposes a `.archive` virtual directory backed by that **project's** index bucket — the project is the first slash-separated segment of the contextPath. Depth within a project doesn't change scope: `/ProjectA/sub/sub/.archive/X.html` resolves the same as `/ProjectA/.archive/X.html`, just with a different URL prefix on the listing entries. The flat listing emits two entries per tracking number: `<tracking>.html` (highest base rev) and `<tracking>_<rev>.html` (each specific base rev). Both **serve in place** — the handler streams the first chronologically received copy's bytes back at the `.archive/` URL without redirecting. The per-transmittal URL is intentionally hidden so external links of the form `.archive/<tracking>.html#section` keep tracking the latest revision (a redirect would expose the snapshot URL and people would forward THAT instead). Cache-Control is `no-cache` so each load revalidates against the on-disk file's Last-Modified/ETag; when a new revision lands the resolver picks it and the browser refetches. Modifier files (`<tracking>_<rev>+C1.html` etc.) remain reachable via the resolver but are not surfaced in the listing — they're return traffic, not primary documents. `/.archive/` at the very root has no project segment and returns 404 — stable references must include the project directory. Within one project, two different files claiming the same `(tracking, rev)` are an authoring mistake; chronological winner still wins, but a `WARN` is emitted with both paths. ACL is enforced twice: the listing endpoint is gated by the contextPath's `.zddc` chain, and each entry is then filtered against the ACL of its resolved file's directory — per-target denials return 404 rather than 403 to avoid leaking that the tracking number exists in another subtree.
- ACL is enforced via cascading `.zddc` YAML files — first-explicit-match-wins evaluated bottom-up (deepest level first), with deny checked before allow within a single `.zddc`; default-deny when any `.zddc` exists in the chain. Authentication is delegated to the upstream proxy via the `X-Auth-Request-Email` header (configurable with `ZDDC_EMAIL_HEADER`). Operator-facing detail, anti-patterns, worked layouts, the verify-it-works recipe, and the federal-readiness gap analysis are in `zddc/README.md` § "Access control: the `.zddc` cascade." The architectural framing (cooperating layers, commercial-vs-federal trust model, why archive auto-serves at every directory) is in `ARCHITECTURE.md` § "Server security model."
- `.zddc` schema also supports a top-level `admins:` glob list, peer to `acl.allow`/`acl.deny`. Honored **only** at the root `.zddc` (subdir `admins` entries are ignored to prevent privilege escalation via subtree write access). Drives the built-in debug dashboard at `/.admin/` (sub-routes: `/whoami`, `/config`, `/logs`); non-admin requests get 404 so the page is invisible. See `zddc/README.md` § "Admin Debug Page".
- `GET /.auth/admin` is a **forward_auth target** for upstream proxies — returns 200 if the request's `X-Auth-Request-Email` is in the root `.zddc` `admins:` list, 403 otherwise. No body, no UI. Used by an upstream proxy to gate an admin-only sub-app on root-admin status without that app learning about auth. zddc-server's own routes use the regular `.zddc` cascade ACL — they do NOT go through this endpoint.
- **Reserved entry prefixes** under `ZDDC_ROOT`: `.`-prefixed entries are excluded from listings AND 404 on direct fetch (only `.archive` and `.admin` are exempt) — for invisible side-state like dev-shell home dirs. `_`-prefixed entries are excluded from listings only — for operator scaffolding like the `_template/` directory created by the self-contained install snippet, still reachable by direct URL. Drop side-state under `_` if it should be linkable; under `.` if it should be unreachable.
- **Caching on embedded tool HTMLs** (landing, browse served at `/`, plus the five canonical app HTMLs at `<dir>/<app>.html`): `Cache-Control: public, max-age=0, must-revalidate` + content-addressed `ETag` (sha256 hex prefix). Browser revalidates on every load; matching ETag returns `304 Not Modified` with empty body. ETag changes only when the binary is redeployed (computed once at startup from `EmbeddedBytes` + `BuildVer`, memoized).
- **Compression**: gzip middleware (`github.com/klauspost/compress/gzhttp`) wraps the entire mux. Skipped for bodies under 1 KB and for 304 responses. Roughly 75% size reduction on tool HTMLs and JSON listings.
- **Public landing page**: `GET /` (HTML or JSON) bypasses the directory-level ACL gate so anonymous callers see the project picker. Per-project filtering inside `fs.ListDirectory` still hides projects the caller can't reach. Subdirectory ACL gates remain in force.
- **Audit log**: every request is mirrored to a JSON-line file under `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log` (configurable via `--access-log` / `ZDDC_ACCESS_LOG`, opt out with empty). Lumberjack rotation (100 MB / 10 backups / 90 days, gzip). Hostname is in both the filename and every record's `host` field — multi-replica deployments sharing one `.zddc.d/` dir disambiguate cleanly.
- **HTTP timeouts**: `ReadHeaderTimeout: 10s, ReadTimeout: 60s, WriteTimeout: 60s, IdleTimeout: 120s`. Slowloris-resistant; legit traffic completes in milliseconds even with gzip.

956
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,956 @@
# ZDDC Architecture
This document is the single authoritative reference for how ZDDC tools are designed and built. It covers the shared single-file HTML application pattern, the build system, tool-specific architectural decisions, and contribution guidelines.
---
## Why Single-File HTML Applications
Every ZDDC tool compiles to a single self-contained `.html` file — no servers, no installers, no subscriptions.
| Principle | Rationale |
|-----------|-----------|
| **Reliability** | Opens in any modern Chromium-based browser without network access or external services |
| **Portability** | Can be emailed, archived, or deployed to air-gapped environments with no tooling |
| **Auditability** | Source, embedded data, and output travel together, satisfying ZDDC traceability requirements |
| **Longevity** | Static assets remain functional long after build environments have changed |
| **Simplicity** | A single `.html` file eliminates deployment steps and brittle dependency chains |
---
## ADR: Browse-as-shell with preview-pane plugins (target architecture)
**Status:** accepted; migrating incrementally (2026-06).
**Context.** The seven tools have started converging on browse: it already hosts classifier (grid iframe), tables (the table-leaf iframe), forms, and the md / yaml / `.zddc`-form editors in its preview pane, and the header chrome (profile menu + elevation) is shared across every tool. Rather than maintain seven parallel apps, the target is **one shell with a plugin content pane**.
**Decision.** Browse is the shell — header + tree + preview pane, one top-level document. Content tools render into the preview pane as **plugins**. Server-only behaviour (the account menu, permission-gated affordances) is **progressive enhancement**: it activates when zddc-server serves the page and `/.profile/access` answers, and is simply absent on `file://`. We do **not** iframe browse inside a server-rendered header — browse owns its header and the server enhances it in place. (So "browse opened locally is missing the server header" resolves to "the same header with its server-only items dormant," not a separate page.)
- **Server mode** is the security boundary: browse fetches ACL-gated listings + per-entry verbs; plugins act through a capability object and can't exceed what the server grants.
- **Local mode** (`file://`) is unrestricted: a picked FS-Access directory handle, no server, no account menu — by design.
**Plugin contract.** A plugin is a module on `window.app.modules`; the shell dispatches to the first whose `handles` returns true:
```
handles(node, ctx) -> bool // claim this node / selection?
render(node, container, ctx) // mount into the preview pane (or a host element)
dispose?() // tear down (called before switching away)
isDirty?() / currentNode?() // optional: unsaved-edit guard + re-render hooks
```
`ctx` is the capability object the shell supplies — the ONLY thing that differs between server and local mode, so a plugin is written once:
```
ctx = {
mode: 'server' | 'fs',
getArrayBuffer(node), getContentWithVersion(node), // read (etag/lastmod → optimistic concurrency)
saveFile(node, bytes, contentType, opts), // write: ACL-enforced (server) / FS-Access (local)
cap.has(node, verb), // 'rwcda' subset; '' or unknown offline
// server-only (undefined offline): access(path), elevation, history(node)
}
```
The md / yaml / `.zddc`-form editors already follow this shape (`handles` / `render` / `isDirty` / `currentNode` + a ctx with `getArrayBuffer` / `getContentWithVersion`); table-leaf and classifier-grid are the same idea via an iframe bridge. Formalising `ctx` makes the contract explicit and lets the heavy tools migrate from iframe to in-pane module — preferred, for shared selection / theme / permission state with no `postMessage`.
**Migration (incremental; standalone tools keep working throughout).**
1. ✓ Editors are in-pane modules; classifier / tables / forms embed in the pane; the shell header carries the profile menu + progressive-enhancement elevation.
2. ✓ The two bespoke, chrome-less server pages — `/.tokens` and `/.profile` — now render through the tables engine via server-injected `#table-context` + the generic `apiActions` layer (see AGENTS.md "Server-injected collections"). That's the "dynamic collection → declarative table, not a bespoke page" half proven.
3. Fold `archive` into the tree + a listing plugin.
4. Make `landing` the shell's root ("no project selected") view — note `landing` is feature-rich (saved groups, multi-select, filters), so this is a *plugin* migration that preserves those, NOT a tables-fication.
5. Move `transmittal` into a workflow plugin.
6. Flip `default_tool` routing to "browse + plugin X"; retire each standalone `<app>.html` only once its plugin lands. (`archive`/`landing`/`transmittal` are all feature-rich — each fold is a deliberate, scoped effort, not a quick tables swap.)
**Consequences / tradeoffs.**
- Preserves the single-file + offline value: the shell still builds to one `browse.html` that runs from `file://`. Heavy plugins should lazy-load in server mode to keep the bundle reasonable.
- The server stays the only security boundary; local is unrestricted by definition.
- Seven lockstep release artifacts collapse toward one shell (plus optionally-separate plugins).
- Not every tool is a clean pane plugin — `transmittal` is workflow-heavy, `landing` is really the root view — called out above.
---
## Repository Structure
Every HTML tool follows the same directory layout:
```
tool/
README.md # Feature scope, UI design, domain rules, help content
css/ # Logically separated stylesheets (one responsibility per file)
js/ # Vanilla ES modules (one responsibility per file)
template.html # Shell markup with {{PLACEHOLDER}} markers for development
build.sh # Inlines css/ and js/ into dist/tool.html
dist/
tool.html # Generated output — never edit this manually
```
Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo** (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/`) for hand-edited content, plus the **deploy host's `/srv/zddc/`** for the assembled live site. The system Caddy bind-mounts `/srv/zddc/`. `./deploy` rsyncs both into it.
```
~/src/zddc-website/ (clone of codeberg.org/VARASYS/ZDDC-website)
index.html # hand-edited intro page + install snippets (root URL)
reference.html # hand-edited file-naming convention spec
css/, js/, img/ # hand-edited static assets
README.md, LICENSE # repo housekeeping
# NO releases/ — release artifacts are NOT in any git history.
~/src/zddc/dist/release-output/ (gitignored, produced by ./build release)
index.html # download page, regenerated by build
<tool>_v<X.Y.Z>.html # real per-version HTML (immutable)
<tool>_v<X.Y.Z>.html.sig # detached Ed25519 signature
<tool>.html → <tool>_v<X.Y.Z>.html # canonical "current stable" symlink
<tool>.html.sig → <tool>_v<X.Y.Z>.html.sig # canonical .sig symlink (chains to per-version .sig)
zddc-server_v<X.Y.Z>_<platform> # real per-version cross-compiled binary (raw bytes, no LFS)
zddc-server_v<X.Y.Z>_<platform>.sig # detached signature
zddc-server_<platform> → ... # canonical per-platform symlink → current stable
zddc-server_<platform>.sig → ... # canonical .sig symlink
zddc-server_v<X.Y.Z>.html # per-version stub: 4 platform downloads for that version
zddc-server.html # canonical stub: 4 platform downloads for current stable
pubkey.pem # signing pubkey (seeded from live)
/srv/zddc/ (deploy host; Caddy bind-mount)
index.html, reference.html, css/, js/, img/ ← rsync'd from ~/src/zddc-website/
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
```
`<tool>` ∈ {archive, transmittal, classifier, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. Two URL shapes per tool: `<tool>.html` (canonical, mutable symlink → current stable) and `<tool>_v<X.Y.Z>.html` (immutable per-version pin). Same for zddc-server per platform. The May 2026 simplification dropped channel mirrors (`_stable`, `_beta`, `_alpha`) and partial-version pins (`_v<X.Y>`, `_v<X>`) — operators pin to exact versions when they want stability, otherwise track the canonical URL.
**zddc-server binaries are reproducible from a tag, not in git** — `./build release` cross-compiles them into `dist/release-output/`, `./deploy` rsyncs them to `/srv/zddc/releases/`, Caddy serves from there. Older versions: `git checkout zddc-server-v0.0.8 && ./build release 0.0.8`. The `helm/zddc-server-{prod,dev,cache}/` charts build from source via init container, but operators who want a prebuilt binary just `curl -O https://zddc.varasys.io/releases/zddc-server_linux-amd64`. The four-platform fan-out lives at `zddc-server.html` (current stable) or `zddc-server_v<X.Y.Z>.html` (per-version).
To preview a build locally, open `dist/tool.html` directly via the dev server. To publish on `zddc.varasys.io`, cut a release with `./build release` and then `./deploy`.
Vendor dependencies (bundled third-party libraries) live in `tool/vendor/` if present. The build script is responsible for inlining them into the output.
---
## Documentation ownership
Each topic has exactly one authoritative home; everything else links to it.
| Topic | Single home | Linked from |
|---|---|---|
| What ZDDC is + tool channel links + dual-mode (local/server) overview + install snippets | `~/src/zddc-website/index.html` (hand-edited intro for `zddc.varasys.io/`, in the `ZDDC-website` repo) | repo `README.md`, `zddc/README.md` |
| File-naming convention spec (status codes, modifiers, folder format) | `~/src/zddc-website/reference.html` | repo `README.md`, in-tool help text |
| Versions + channel builds index of every tool | `dist/release-output/index.html` (regenerated by `./build`; deployed to `/srv/zddc/releases/index.html`) | website intro nav, "Browse all versions" link |
| Customer-deployment install (`zddc-server` binary embeds current-stable tools; local override via an on-disk `<app>.html` or the site `<root>/.zddc.zip` bundle — no fetch) | `zddc/README.md` "Apps: virtual tool HTMLs" section | website intro, `AGENTS.md` |
| zddc-server operations: env vars, ACL syntax, `.archive` URLs, container vs binary | `zddc/README.md` | `AGENTS.md`, website intro |
| Build / release / channel commands | `AGENTS.md` | repo `README.md` ("see AGENTS.md") |
| Architecture & internal patterns | `ARCHITECTURE.md` (this file) | `AGENTS.md` |
| Per-tool internal design quirks | `<tool>/README.md` | (linked from website intro tool cards) |
`index.html` in the `ZDDC-website` repo (working dir `~/src/zddc-website/index.html`) is **hand-edited static content** (analogous to `reference.html`), not the landing-tool output. The install section points operators at two paths: **local** (download a `.html` file from `/releases/`) and **server** (run `zddc-server`; current-stable builds of all eight HTML tools are baked into the binary at compile time via `//go:embed`). The landing tool's released bytes live at `/srv/zddc/releases/landing_v<X.Y.Z>.html` (rsync'd from `dist/release-output/`); the embedded copy serves at the deployment root by default. The public website at `zddc.varasys.io/` is the same hand-edited `index.html` — its root URL is the introduction page, not the project picker (because there are no projects to pick from a static site).
When updating documentation, prefer linking over duplicating. If you find yourself rewriting the file-naming convention in a tool's README, link to `reference.html` instead.
---
## Build System
### How It Works
Each HTML tool's `build.sh`:
1. Reads CSS files in declaration order, concatenates them
2. Reads JS files in declaration order, concatenates them
3. Processes `template.html` with `awk`, replacing `{{PLACEHOLDER}}` markers with the concatenated content and stripping CDN `<script>`/`<link>` tags
4. Writes the result to `dist/tool.html`
5. If `--release [<version>]` was passed (stable cut), calls `promote_release` to write into `dist/release-output/`: per-version immutable file + canonical `<tool>.html` symlink + .sig companion. Beta cuts skip — `./build beta` is internal-only (embedded regen + chore commit), no public artifact.
The top-level `./build` at the repository root is the canonical lockstep entry point. It:
1. On a stable cut, **seeds `dist/release-output/` from `/srv/zddc/releases/`** — copying only the immutable per-version files (`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) + their `.sig` sidecars + `pubkey.pem`. The canonical symlinks get rewritten by this cut; any stale files in the live tree are cleaned by deploy's `--delete-after`.
2. Forwards `--release [version]` to every HTML tool's build (or `--release beta` for the snapshot path), computing a coordinated next-stable target via `_coordinated_next_stable` (max of every tool's latest tag + 1) when no explicit version is given.
3. Cross-compiles zddc-server for the four target platforms inside a containerized Go toolchain (podman/docker).
4. On a stable cut, calls `promote_zddc_server` to copy the freshly cross-compiled binaries into `dist/release-output/` with per-platform canonical symlinks (`zddc-server_<platform>`) and `.sig` companions; tagging `zddc-server-v<X.Y.Z>` is deferred to the embedded-commit block at the end.
5. Calls `write_zddc_server_stubs_all` to refresh the per-version + canonical stub HTML pages from whatever artifacts are in `dist/release-output/`.
6. Regenerates `dist/release-output/index.html` as the action-first download page.
Then `./deploy --releases` rsyncs `dist/release-output/``/srv/zddc/releases/` with `--delete-after`.
### Release verbs
Two release verbs (plus dev). The May 2026 simplification dropped alpha and made beta internal-only.
- **Stable** — versioned, immutable. `./build release [version]` writes per-version HTML for the seven HTML tools and per-version binaries for zddc-server (real bytes), writes the canonical `<tool>.html` and `zddc-server_<platform>` symlinks → the new version, signs every per-version artifact (and companion `.sig` symlink for the canonical URL), and tags `<tool>-v<X.Y.Z>` for every tool at the release commit.
- **Beta**`./build beta` is the BMC dev chart's plumbing. It regenerates `zddc/internal/apps/embedded/*` with beta-labeled HTML bytes and makes a `chore(embedded): cut v<X.Y.Z>-beta` commit. No public artifact in `dist/release-output/`. The chart's appVersion pins to `"<X.Y.Z>-beta-<sha>"`; its Dockerfile parses the suffix and `git fetch`-es that SHA, compiling its own binary from the fetched source.
- **Dev** — plain `./build` (no arg) produces `dist/<tool>.html` and `zddc/dist/zddc-server-<platform>` binaries; doesn't touch `dist/release-output/`, the live site, or `embedded/`.
The on-page `{{BUILD_LABEL}}` is rendered red+bold for dev/beta builds (`is_red=1`) and black for stable releases. The label format is:
| Build | Label |
|--------------------|--------------------------------------------------------|
| dev (no `--release`) | `v0.0.6-dev · 2026-04-27 14:00:00 · abc1234[-dirty]` |
| `--release beta` | `v0.0.6-beta · 2026-04-27 14:00:00 · abc1234` |
| `--release [ver]` | `v0.0.5` |
`X.Y.Z` for non-stable labels is the **next-stable target** — patch+1 from the latest clean `<tool>-vX.Y.Z` tag. Dev builds use the `-dirty` marker so iterative work is distinguishable from a formal beta cut.
### Install distribution model
Two orthogonal axes: how the bytes get there (this section), and what runtime mode the tool ends up in (next section).
**Local mode** (no server): download a tool `.html` from <https://zddc.varasys.io/releases/> and open it. Tools are self-contained — no install, no install script.
**Server mode** (`zddc-server`): the binary `//go:embed`s the current-stable build of every tool at compile time. That's the default — no fetching happens out of the box. The server virtually serves each tool only at directories where the convention says it belongs (`internal/apps/availability.go`):
| App | Available at |
|---------------|-------------------------------------------------------------------------|
| `archive` | every directory (multi-project, project, archive, vendor) |
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
| `browse` | every directory (hosts the markdown editor as a preview plugin) |
| `transmittal` | any `Staging` directory and its subtree |
| `landing` | only at the deployment root |
Resolution order at a request to `<dir>/<app>.html` where the app is available:
1. **On-disk override** — real `.html` file at the path → static handler.
2. **Site bundle** — an `<app>.html` member of `<ZDDC_ROOT>/.zddc.zip`, read server-side via `internal/zipfs` (see `internal/apps/bundle.go`). Local file, no fetch, no signature; re-stat'd each request for free hot-reload.
3. **Embedded** — the build-time HTML compiled into the binary.
Resolution is LOCAL-ONLY — no network fetch, no signatures, no channels/versions, and no `apps:` `.zddc` key (all removed in favour of this model). `.zddc.zip` is config, not content: a direct `GET /.zddc.zip` is 404 for everyone, while the server reads its members from the filesystem internally. To change a tool's HTML: drop a file at the path, add `<app>.html` to `.zddc.zip`, or rebuild the binary.
The `X-ZDDC-Source` response header always reports what was served: `bundle:<app>.html`, `embedded:<app>@<build>`, or (for an on-disk override) the static handler's own headers.
### Runtime mode detection
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `browse` work the same regardless of where they live.
### Build Script Requirements
Every `build.sh` must:
- Begin with `#!/bin/sh` and `set -eu` (POSIX sh, not bash)
- Source `shared/build-lib.sh` first (provides `ensure_exists`, `concat_files`, `build_timestamp`, `compute_build_label`, `promote_release`, plus the lockstep helpers `_coordinated_next_stable`, `promote_zddc_server`, `write_zddc_server_stubs_all`)
- Fail immediately on missing source files (`ensure_exists` pattern)
- Clean up temp files on exit (use `trap cleanup EXIT`)
- Accept `--release [<version>]` for stable cuts or `--release beta` for snapshot cuts; otherwise produce a dev build
### HTML Embedding Safety
When inlining JavaScript into a `<script>` block, the HTML parser scans for the exact string `</script>` to terminate the block — backslash escaping (`<\/script>`) does **not** prevent termination. Any JS source file or vendor library that contains `</tag>` sequences inside string literals or template literals will break the inline `<script>` block.
The rule is:
> **All `</` sequences in inlined JavaScript must be escaped as `<\/` using `sed`.**
Both the app JS concatenation step and any vendor JS bundling step must run through:
```bash
sed 's#</#<\\/#g' "$input_js" > "$safe_js"
```
Then use `</script>` (not `<\/script>`) to close the `<script>` block, since the content no longer contains any `</` sequences that the parser could misread.
This is already enforced for browse's Toast UI bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
### Vendor Dependencies
Some tools bundle third-party libraries. These live in `tool/vendor/` and are committed to the repository. The build script inlines them into `dist/tool.html`.
**Current vendor files:**
| Tool | Library | File | Notes |
|------|---------|------|-------|
| browse | Toast UI Editor v3.2.2 | `shared/vendor/toastui-editor-all.min.js` | Markdown editor (loaded by `browse/js/preview-markdown.js`) |
| browse | Toast UI Editor CSS | `shared/vendor/toastui-editor.min.css` | Editor stylesheet |
| shared | jszip | `shared/vendor/jszip.min.js` | ZIP read for previews + classifier hash-export |
| shared | docx-preview | `shared/vendor/docx-preview.min.js` | DOCX preview |
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
**No runtime CDN loads.** Every external dependency is vendored into
`shared/vendor/` and concatenated into each tool's bundle at build
time. Tools that need a given library include the vendor path in
their `build.sh`'s `concat_files` JS list. The "ship the record
player with the record" philosophy: a downloaded `.html` file works
offline against any file the user can open, with no network
dependency at runtime.
Trade-off accepted: bundle sizes are larger. archive, classifier,
transmittal land around 1.5 MB after gzip; browse lands around 2 MB
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF
for the in-place markdown editor and the preview pane.
Justified by the offline-first guarantee: any tool downloaded from
`/releases/` works without network, against air-gapped archives,
forever. See ARCHITECTURE.md § "Why Single-File HTML Applications"
for the longer rationale.
`template.html` for tools with vendor deps loads those deps from CDN
purely for **dev convenience** — opening a template.html directly in
Chromium gives you a working tool without running a build. The build
script strips/replaces those CDN tags so the dist HTML has every
dependency inlined. No CDN URLs survive into the dist.
### Development vs Production
| Context | Tailwind / Vendor | How to run |
|---------|-------------------|-----------|
| Development | CDN (live, from `template.html`) | Open `template.html` directly in Chromium |
| Production | Bundled / Static CSS | Run `bash tool/build.sh`, open `dist/tool.html` |
For browse specifically: `template.html` loads Toast UI from CDN for dev convenience. The build replaces it with the bundled vendor file (`shared/vendor/toastui-editor-all.min.js`).
---
## JavaScript Architecture
### Vanilla JS Only
All tools use plain JavaScript — no TypeScript, no frameworks, no bundlers. Dependencies are managed manually via vendor files.
### Module Pattern
Each JS file wraps its code in an IIFE or module-scope block and registers its API on `window.app.modules`:
```javascript
// js/mymodule.js
(function() {
function doSomething() { ... }
window.app.modules.mymodule = { doSomething };
})();
```
Two top-level globals:
- `window.app` — per-tool app state, modules, and debug surface (every tool)
- `window.zddc` — shared filename/folder/revision parsers from `shared/zddc.js` (every tool)
No other globals. Never expose implementation internals beyond what's needed for testing.
### Module Load Order
JS files are concatenated in the order declared in `build.sh`. Each file can assume earlier files' modules are available on `window.app`. Circular dependencies are not permitted — modules must be layered.
Typical ordering:
```
app.js ← Declares window.app and top-level state
utils.js ← Stateless helpers (no dependencies)
store.js ← State management (depends on app.js)
[domain].js ← Feature modules (depend on store/utils)
main.js ← Initialization (depends on all modules)
```
### State Management
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, browse, form, tables), and it doesn't hide control flow.
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
```javascript
window.app = { files: [], selectedFolders: new Set(), modules: {}, ... };
// Mutate then re-render:
window.app.files.push(newFile);
window.app.modules.table.render();
```
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, browse, form, tables, landing.
**2. Pub-sub store on top of #1** (classifier)
```javascript
store.set('files', newFiles);
store.on('files', render);
```
Adds a tiny `store.on(key, fn)` / `store.notify(key)` layer in `classifier/js/store.js`. Justification: classifier has multiple independent panels (file list, spreadsheet, validation pane) that all need to react to the same state changes; calling three separate `render*()` functions from every mutation site would invite forgetting one.
**3. Proxy-based reactive state** (transmittal)
```javascript
const state = createReactiveState({ mode: 'edit', published: false });
state.subscribe((prop, newVal) => { /* auto-update UI */ });
state.mode = 'view'; // Proxy notifies all subscribers automatically
```
Used by transmittal because a single state change (e.g. `mode`) drives ≥3 independent UI regions (header chrome, body editability, action toolbar). Reactive shines when the cross-cutting wiring would otherwise be tedious. **Don't reach for this pattern unless you have at least three subscribers per state property.**
### `zddcMode` dispatcher (form / tables unified bundle)
The form and tables tools share a single compiled bundle (`tables/dist/tables.html`, also `//go:embed`d into `zddc-server` at `zddc/internal/handler/tables.html`). One window, two views. The bundle holds both `window.tablesApp` and `window.formApp`; whichever app paints is decided by a single global:
```javascript
// Set by the server-injected context (or absent for standalone form.html):
window.zddcMode = 'form' // → form renderer paints; tables app no-ops
window.zddcMode = 'table' // → tables app paints; form app no-ops
window.zddcMode = undefined // → standalone form.html, treated as 'form'
```
Each app's `main.js` checks `window.zddcMode` first and returns early when it's not their mode (see `form/js/main.js:10`, `tables/js/mode.js`). Rules for adding a third mode:
1. Set `window.zddcMode = '<new>'` in `tables/js/context.js` based on server context shape.
2. Add the new app's main module with the same early-return guard.
3. Keep the standalone-fallback rule consistent: undefined `zddcMode` should still mean "the lightest, most common mode for this bundle's standalone HTML."
Standalone `form/dist/form.html` uses this contract too — it has no `zddcMode` set, so form's main runs unconditionally and renders either the schema (when injected) or a friendly empty-state welcome (`form/js/main.js renderStandaloneWelcome`).
---
## Tool-Specific Architecture
### Archive Browser
**Pattern:** Direct mutation of `window.app.{directories, files, filteredFiles, selectedFiles, ...}`, helper modules namespaced under `window.app.modules.{events, table, urlState, source, ...}`. Supports two source modes (`window.app.sourceMode`): `'local'` (File System Access API) and `'http'` (zddc-server JSON browse).
**Two-level directory structure required:**
```
root-directory/
transmittal-folder/ ← "grouping folder" — must be a subdirectory
123456-EL-SPC-0001_A (IFC) - Spec.pdf
...
```
Files at the root level are ignored. The grouping folder list and transmittal folder list are populated from the first two levels of the selected directory. Files are only counted in `filteredFiles` after ZDDC filename parsing succeeds.
**Key DOM IDs:** `#addDirectoryBtn`, `#noDirectoryMessage`, `.main-container`, `#filesTableBody`, `#fileCount`, `#selectedCount`, `#selectAllGroupingCheckbox`.
---
### Document Classifier
**Pattern:** Event-driven store (`store.js`) with `notify()` / `on()` pub-sub, spreadsheet rendering on `'files'` events.
**File object shape** (as produced by `scanner.js`):
```javascript
{
trackingNumber: '123456-EL-SPC-2623',
title: 'Specification',
revision: 'A',
status: 'IFC',
extension: 'pdf', // no leading dot
originalFilename: '...', // filename without extension
name: '...', // full filename with extension
path: 'folder/filename.pdf',
size: 45000,
isDirectory: false,
manualFilename: null // set if user overrides computed name
}
```
**`computeNewFilename(file)`** (in `utils.js`) returns `file.originalFilename + '.' + file.extension` if any required field is missing.
**Main app panel** (`#mainApp`) stays hidden (class `hidden`) until a real directory is opened via `showDirectoryPicker`. State can be injected via `store.setFolderTree()` + `store.setSelectedFolders()` for testing without triggering the picker.
---
### Markdown Editor (browse preview plugin)
**Lives at:** `browse/js/preview-markdown.js`, registered on `window.app.modules.markdown` and invoked by `browse/js/preview.js` for `.md`/`.markdown` files. The standalone `mdedit/` tool was retired in favour of this plugin.
**Pattern:** Editor instances per-file (constructed by `render(node, container, ctx)`, disposed by `dispose()`). CSS Grid layout for the shell — sidebar (FM textarea on top, outline below) on the left, content (info header + Toast UI editor) on the right.
**Front matter:** Parsed off the file on load by `parseFrontMatter()` (a small `---\n…\n---` parser); the FM body goes into a sidebar `<textarea>`, the markdown body into the Toast UI editor. On save, `assembleContent()` recombines them with the envelope on top. The textarea is always present so authoring brand-new FM is a single click; dirty tracking covers both halves via a SHA-256 hash of the assembled bytes.
**Dependencies:** Toast UI Editor v3.2.2 (vendored at `shared/vendor/toastui-editor-all.min.js`, concatenated into `browse/dist/browse.html` at build time). No runtime CDN, no Tailwind.
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. The server-side engine is in `zddc/internal/convert`: zddc-server `exec.Command`s `pandoc` and `chromium-browser` directly, and the runtime image's wrapper at `/usr/local/bin/<name>` (see `zddc/runtime.Containerfile` + `zddc/runtime/zddc-sandbox-exec`) handles the per-call cgroup v2 + bubblewrap sandbox between that exec and the real binary at `/usr/bin/<name>`. Isolation strategy lives entirely in the image; swap the wrapper for firejail / nspawn / podman-run and Go doesn't change.
---
### Transmittal Creator
**Pattern:** Proxy-based reactive state, two-phase hydration, ECDSA digital signatures, SHA-256 file integrity.
**Two-phase hydration:**
1. **`populateStatic()`** — called before publishing. Fills all form fields and the file table into the HTML so the output is readable without JavaScript (progressive enhancement for SharePoint, email clients, etc.).
2. **`hydrate()`** — called on page load of a published transmittal. Hides the "Not Validated" static warning, runs signature verification, and enables interactive features.
**Progressive enhancement matrix:**
| Feature | No JavaScript | With JavaScript |
|---------|--------------|-----------------|
| Content display | ✅ Full | ✅ Full |
| File table | ✅ Shown | ✅ Shown |
| Digest / signatures | ✅ Listed | ✅ Listed + cryptographically verified |
| Validation status | ⚠️ "Not Validated" badge | ✅ "Verified" / ❌ "Invalid" |
| Editing | ❌ Disabled | ✅ Enabled (if draft) |
| Column filtering | ❌ No | ✅ Yes |
**Data store:** A `<script id="transmittal-data" type="application/json">` element embedded in the published HTML holds the full transmittal payload. On load, `data.js` reads and parses it; all UI state derives from this JSON.
**Reactive state:**
```javascript
// app.state is a Proxy — assignments auto-notify subscribers
app.state.mode = 'view'; // Triggers UI updates automatically
```
Subscribe for cross-cutting concerns:
```javascript
app.state.subscribe((property, newValue) => {
if (property === 'mode') updateModeToggleLabel(newValue);
});
```
**Security model:** ECDSA P-256 signing of the SHA-256 digest. Signatures are stored in the JSON payload. Any number of signers can co-sign. Verification runs client-side in the browser's Web Crypto API — no server required.
**Key module globals:** `window.transmittalApp` exposes `app.data`, `app.state`, and `app.modules` for debugging and testing.
---
### Form Renderer (`form/`)
**Pattern:** Schema-driven renderer for the form-data system. Reads a JSON Schema 2020-12 + RJSF-style `ui:*` hints from a server-injected `<script id="form-context">` block; recursively walks the schema and mounts a tree of widgets; on submit, walks the widget tree to serialize back to JSON and POSTs to the URL the form was loaded from.
**Why schema-driven** (vs. transmittal's hardcoded HTML): the form tool is generic — one renderer serves any form spec a user (or LLM) drops into the file tree. Adding a new form requires no code change; adding a new field type to an *existing* form requires only a YAML edit.
**Widget interface** — every widget exposes:
- `el` — DOM root
- `read()` — current value (recurses into children for object / array)
- `setError(msg)` / `clearErrors()` — show / clear field-level errors
- `child(name|idx)` — for container widgets, look up nested widget by JSON-Pointer segment (used by `errors.js` to attach server-side validation messages by path)
**Module layout:**
- `js/app.js``window.formApp = { context, rootWidget, modules }`
- `js/context.js` — read injected `#form-context` JSON
- `js/util.js``h()` DOM builder, JSON-Pointer encode/parse
- `js/widgets.js` — primitives (string/number/integer/boolean/enum, format date/email, textarea)
- `js/object.js` — fieldset rendering with `ui:order` resolution
- `js/array.js` — repeating-row UX (add/remove)
- `js/render.js` — type-triage dispatcher
- `js/serialize.js` — read tree → JSON
- `js/errors.js` — distribute errors by JSON Pointer path
- `js/post.js` — POST + handle 200/201/422/403/409 responses
- `js/main.js` — boot: load context, mount root widget, wire submit
**Server-side counterpart:** `zddc/internal/handler/formhandler.go` recognizes `*.form.html` and `*.yaml.html` URLs, parses the spec, validates submissions via `zddc/internal/jsonschema/`, writes via `zddc.WriteAtomic` (plain submissions) or `zddc/internal/handler/history.go` `WriteWithHistory` (record-typed YAML — mdl rows, rsk rows, ssr.yaml). Existence of `<name>.form.yaml` is the trigger; without it, the URL falls through to static-file serving.
**Record-vs-submission distinction.** "Records" are the three table-store types (mdl/rsk/ssr); everything else is a "submission." Records get server-stamped audit fields (`created_at`/`_by`, `updated_at`/`_by`, `revision`, `previous_sha`), an immutable per-record history at `<dir>/.zddc.d/history/<base>/<RFC3339Nano>-<sha8>.<ext>`, cascade-driven filename composition (via the `records:` + `field_codes:` `.zddc` keys), per-folder field locking (e.g. type=RSK in rsk/), and folder-bound fields (`folder_fields`, e.g. originator = party-folder name). The mechanism intercepts at every write entry point — the file-API `serveFilePut` (if `isRecordPath` matches → `WriteWithHistory`, else `WriteAtomic`), the in-dir form create/update (`serveFormCreate`/`serveFormUpdate`), and the project rollup (`serveFormCreateRollup`). Each resolves the `records:` rule for the target directory and, when one with a `filename_format` applies, composes the name via the shared `recordCreatePrep` and routes through `WriteWithHistory`; non-record paths keep the historical date+email `WriteAtomic` write. The convergence means there's no back door that writes an un-stamped, un-composed record. All of it is server-side: the tools opened offline (`file://` / FS-Access, no server) can't enforce audit, composition, `field_codes`, or `folder_fields` — record writes need zddc-server. See AGENTS.md "Records, audit, and history" for the operator surface (incl. the offline gap and pre-folder-binding upgrade notes); `zddc/internal/handler/history.go` for the orchestration.
**Round-trip philosophy:** v0 is "form-as-truth" — submission YAML is regenerated from form state on every save. Hand-edits to submission files are not preserved across re-edit→re-submit. v1 will add an opt-in "file-as-truth" mode (eemeli/yaml Document API) for forms like `.zddc` itself where users hand-edit and comments must survive.
---
## Server security model
zddc-server is the deployable surface — it gates access to the file tree that
all the HTML tools work against. The trust story is intentionally narrow,
delegated, and small enough to audit. This section frames it; operator detail
(syntax, anti-patterns, worked layouts, verification recipe, federal-readiness
gap analysis) lives in [`zddc/README.md`](zddc/README.md) § "Access control:
the `.zddc` cascade."
### Cooperating layers
Six layers cooperate to bound what a request can reach. Each does one job;
none of them is load-bearing alone.
| Layer | Job | Implementation |
|---|---|---|
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego the operator writes (e.g. ancestor-deny-absolute for NIST AC-6) while keeping the same `.zddc` files as input data; zddc-server ships only a fail-closed read-ACL skeleton (`--print-rego`) as a starting point |
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into the embedded default tree): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; the embedded default tree |
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
| Audit log | Reconstruct who did what after the fact | JSON-line tee per request to `<ZDDC_ROOT>/.zddc.d/logs/access-<host>.log`; writes also emit `file_write` op records |
| File API | Authenticated CRUD over the served tree | `zddc/internal/handler/fileapi.go` — PUT/DELETE/POST routed through the same ACL chain as GET, with per-method verbs (`r`/`w`/`c`/`d`/`a`). Mkdir under `Incoming`/`Working`/`Staging` writes a creator-owned `.zddc` automatically |
### Master + proxy / cache / mirror
The same `zddc-server` binary runs in two distinct topologies:
- **Master mode** (default): the binary owns a file tree under `--root`, applies `.zddc` ACL cascades to incoming requests, serves files / virtual app HTML / archive listings / form submissions / table views. The "normal" zddc-server. All of `cmd/zddc-server/main.go` lives here.
- **Client mode** (`--upstream <url>` set): the binary becomes a downstream proxy/cache/mirror against another zddc-server. The master-side machinery (archive index, apps server, watcher, OPA decider, ACL middleware, token store) is **bypassed entirely**. `zddc/internal/cache/` is the entire request handler.
Three sub-modes within client mode, controlled by `--mode <proxy|cache|mirror>`:
| Mode | Persists responses? | Subtree warmer? | Use case |
|---|---|---|---|
| `proxy` | no | no | thin pass-through; nothing on local disk |
| `cache` (default) | yes | no | field engineer — what you've viewed is available offline |
| `mirror` | yes | yes (access-triggered, subtree-scoped) | vendor mirrors of their subtree; admin backups; complete offline working set |
Internally the modes collapse to two switches on a single request-handling pipeline (`persist`, `warm`). Proxy is cache without disk writes; mirror is cache plus an access-triggered walker. Implementation factor: `cache.New` reads `cfg.Mode` once and sets `c.persist = mode != "proxy"`; the warmer is the only path that doesn't yet exist (phase 3).
**Mirror scope falls out of auth.** Whatever the client's bearer can see at upstream is what the cache can populate. Admin's bearer → mirror gets everything (full backup). Vendor's bearer → mirror is exactly that vendor's permitted subtree. No code distinguishes admin-vs-user — master-side ACL filtering does it.
#### Cache directory IS a normal ZDDC root
The cache directory layout is intentionally a regular ZDDC root: `<master>/foo/bar.txt` is stored at `<root>/foo/bar.txt`. No sidecar metadata files. The local file's `mtime` is set to the upstream's `Last-Modified` header (so revalidation via `If-Modified-Since` reflects the master's notion of file age, not local fetch time). A small `.zddc-upstream` marker file at the root records the upstream URL and first-cached-at timestamp, written once by `sync.Once` on first persist.
Two consequences:
- `zddc-server --root <cache-dir>` (without `--upstream`) serves whatever's been cached as a plain master. Useful for portable offline snapshots — tar the directory, hand it to a colleague, they have a working ZDDC.
- The master/client boundary is one flag: setting/unsetting `--upstream` switches behavior on the same on-disk root.
#### Pipeline
Phase 2 ships GET/HEAD only; writes are deferred to a later phase. For each incoming request:
1. **Directory request** (URL ends in `/`): always proxied live. Listing-cache support belongs with the mirror walker (phase 3) — the bare cache directory's contents only reflect visited files, so a local-walk listing would be misleading.
2. **File request, cache hit** (`persist` mode): serve cached bytes via `http.ServeContent` (which handles `Range` natively + 304 conditional GETs). Header `X-ZDDC-Cache: hit`. Background goroutine fires an `If-Modified-Since` revalidate; on `304` no-op, on `200` overwrite the cache atomically, on `403`/`404` purge.
3. **File request, cache miss**: build an upstream request preserving `Range`, `If-Range`, `Accept`, `Accept-Encoding`; attach the configured bearer. Stream the response simultaneously to the client AND to a tmp file in the cache directory; rename atomically only on success. Header `X-ZDDC-Cache: miss`.
4. **Proxy mode** (no persist): same as miss but skip the tmp-file teeing. Header `X-ZDDC-Cache: proxy`.
5. **Network error + cached version exists**: serve the cached bytes with `X-ZDDC-Cache: offline`. (When the cache hits before any network attempt, the header is `hit` — there's no way to distinguish "hit while online" from "hit while offline" without an extra round-trip; the header tells the user "this is from disk," and the user infers freshness from context or a future explicit freshness probe.)
6. **Network error + no cached version**: `503 Service Unavailable` + `X-ZDDC-Cache: offline`.
Responses with `Cache-Control: no-store` or `Cache-Control: private` pass through but are not persisted. Non-200 responses (including 206 partial content) are forwarded but not persisted — caching a partial body would corrupt subsequent full-body reads.
Hop-by-hop headers per RFC 7230 §6.1 (`Connection`, `Keep-Alive`, `Transfer-Encoding`, etc.) are dropped from forwarded responses; Go's transport drops most automatically, but the cache layer adds a guard for the cases that slip through.
#### Mirror walker (access-triggered)
`--mode mirror` adds an access-triggered subtree warmer (`zddc/internal/cache/walker.go`) on top of the cache pipeline. Naive design ("walk on a fixed timer") would scale poorly: many vendor mirrors against one master would generate thundering-herd polls of subtrees no human has looked at in months. Instead, walks are demand-triggered, rate-limited per-subtree.
Trigger policy (`MirrorScheduler.Trigger(urlPath)` is installed as the cache layer's `onAccess` hook, called in a goroutine on every authenticated request):
1. Match `urlPath` against the configured `--mirror-subtree`s. Longest prefix wins; `/` is a catch-all (full mirror).
2. If a walk is already in flight for that subtree, no-op.
3. If `now - last_walk_at < --mirror-min-interval` (default 1h), no-op.
4. Otherwise, mark in-flight and kick a walk goroutine.
Walk:
1. Recursively fetch JSON listings under the subtree, persisting each as `<dir>/.zddc-listing.json` (so directory browsing works offline for walked subtrees).
2. For each file, fire a conditional `If-Modified-Since` GET (bounded parallelism — default 4 concurrent, configurable). 304 = no-op; 200 = overwrite; 403/404 = purge.
3. Per-directory orphan purge: any local file present locally but absent from the upstream listing is removed (handles upstream deletes + ACL revocations).
State persists at `<root>/.zddc-mirror-state.json` as `{subtrees: {<path>: {last_walk_at}}}`. In-flight tracking is in-memory only — a crash mid-walk lets the next access retry without manual cleanup.
Properties:
- **Idle mirrors are quiet.** No requests means no walks means zero upstream traffic.
- **Active mirrors stay current as a side effect of normal use** (no explicit refresh gesture).
- **Revocation latency** is bounded by access frequency. Documented behavior, not a guarantee.
- **Bounded concurrency** keeps walks from starving the user's interactive requests on the same connection pool.
#### Writes: outbox + offline replay
`PUT` / `POST` / `DELETE` are handled by `cache.handleWrite`. Online: forwarded to upstream; on success the cached entry for the path (if any) is dropped so the next read fetches fresh. PUT/DELETE include `If-Unmodified-Since` from the cached file's mtime — the master returns `412 Precondition Failed` if its file changed since the cache observed it, so concurrent writes can't silently clobber.
When upstream is unreachable, the request is captured in the **outbox** (`zddc/internal/cache/outbox.go`) under `<root>/.zddc-outbox/<id>/``meta.json` (method, raw URI, content-type, base mtime, queued-at) + `body.bin` (request body, capped at `MaxOutboxBodyBytes` = 256 MiB). The client gets back `202 Accepted` + `X-ZDDC-Cache: queued` and a JSON envelope referencing the queued entry.
A background `RunReplayLoop` started by `runClient` in main.go replays in queue order:
- `2xx` → entry deleted; cached entry for the path (if any) dropped so the next read fetches fresh.
- `412` → entry renamed to `<id>.conflict-<RFC3339>/`. The conflict directory keeps both `meta.json` and `body.bin` intact for manual reconciliation.
- `4xx` other than `412` → entry dropped (won't succeed on retry; logged at `WARN`).
- `5xx` / transport error → left in place for the next pass.
Replay schedule: an eager pass at startup, then 30s while pending, 5min while idle. Honors graceful-shutdown context cancellation. Disabled in `--mode=proxy` (proxy mode persists nothing by design — offline writes just return `503`).
ID encoding (`<unix-nano-base16>-<hex-random>`) is lex-sortable so directory iteration replays in queue order without an explicit index. `MarkConflict` appends `.conflict-<ts>` to the directory name; if a same-second conflict collides (unlikely), a 4-char random suffix is appended.
The local cache is not updated for offline writes by design — until upstream confirms, the user reads still see the upstream-cached version (or 503 if uncached). Trade-off: the user doesn't see their own offline edits immediately, but no "did the queued write actually win?" ambiguity. Phase 5 will add a conflict-resolution UI that surfaces `.conflict-<ts>/` directories alongside the cached files in browse views.
#### Multi-tenancy: explicitly out of scope (v1)
The local instance forwards a single bearer (loaded from `--bearer-file` at startup) regardless of who's calling locally. Single-user-trust on a laptop. For multi-user scenarios, run multiple instances on the same host, or front the local server with your own auth proxy that injects per-user bearers downstream — both options keep the cache layer's design surface minimal.
#### Confused-deputy guard at startup
Because the cache forwards a bearer upstream without authenticating the local caller, exposing the bind on a non-loopback interface would turn the binary into an open-proxy laundering anyone's request through the master. The config layer (`zddc/internal/config/config.go`) enforces two defenses:
1. **Loopback default in client mode.** When `--upstream` is set, `--addr` defaults to `127.0.0.1:8443` instead of `:8443` — but only when `--addr` / `ZDDC_ADDR` was *not* set explicitly. CLI users on a laptop get safe-by-default; operators who want a non-loopback bind opt in explicitly.
2. **Refuse non-loopback bind + bearer without acknowledgement.** A non-loopback `--addr` *with* a configured `--bearer-file` *without* `--insecure-direct` (`ZDDC_INSECURE_DIRECT=1`) refuses to start. The error message names the bind, names the flag to acknowledge, and names the threat (open proxy confused-deputy). The helm `zddc-server-cache/` chart sets `ZDDC_INSECURE_DIRECT=1` and relies on Kubernetes-namespaced networking for the gating — that path is unaffected. The guard is bearer-file-conditional because proxy mode without a bearer doesn't have a credential to launder, and refusing it would needlessly block proxy-without-auth deployments.
### Bearer token issuance
zddc-server issues its own bearer tokens for non-browser callers (CLI tools, scripts, downstream proxy/cache/mirror instances). The master is the identity provider; no external IDP, no JWKS rotation.
**Storage** — `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` per token. Filename is the **hash** of the token, never the plaintext value. File contents are YAML (`email`, `created`, `expires`, `description`). Mode 0600, directory mode 0700, atomic writes via temp+rename.
**Why hash-as-filename**: a leak of the tokens directory (backup tools, FS-level audit logs, accidental `ls` in a screen recording) exposes hashes, not credentials. Same posture as `/etc/shadow` storing password hashes rather than passwords. The plaintext exists only in transit (HTTP `Authorization` header) and on the operator's disk (a 0600 file they manage).
**Self-service flow**:
1. User signs in via the browser (master's normal upstream auth).
2. Visits `/.tokens` — small HTML page (`zddc/internal/handler/tokenhandler.go`) listing existing tokens and offering a creation form.
3. JS fetches the JSON API (`/.api/tokens`), POSTs a new token, displays the plaintext **once**.
4. User copies into a 0600 file; passes `--bearer-file <path>` to a CLI.
**API**:
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/.api/tokens` | list current user's tokens (no plaintext) |
| `POST` | `/.api/tokens` | create; plaintext returned exactly once |
| `DELETE` | `/.api/tokens/<id>` | revoke (8-char ID or full 64-char hash) |
**Validation in the request path**: `ACLMiddleware` in `zddc/internal/handler/middleware.go` checks `Authorization: Bearer …` first; on success, sets the request email from the token file and falls through. Any failure (missing / malformed / expired) → `401`. There is no silent fallback to anonymous on Bearer failure — a misconfigured client must fail loudly rather than escalate to "no auth at all." When no Bearer is present, the existing `cfg.EmailHeader` path runs unchanged.
**Directory shielding**: the tokens path is shielded by the existing `.`-prefix rules — `dispatch()` 404s any URL containing a dot-prefixed segment (other than the recognized virtual prefixes), and `fs.ListDirectory` filters dot entries from listings. The token system relies on this; a regression here is a credentials-leak vector. The token-handler test suite (`tokenhandler_test.go`) exercises the auth path; verifying the URL-level guard is the responsibility of `main_test.go` (`TestDispatchHidesDotPrefixedSegments`).
### `--no-auth` / "this instance is not the ACL boundary"
A symmetric flag, used in two distinct deployment shapes:
- **Master with `--no-auth`**: no ACL enforcement, no auth required. Anyone hitting the port reads everything in scope. Suitable for dev, internal trusted-LAN read-only tooling, or genuinely public archives.
- **Client with `--no-auth`** (downstream proxy/cache/mirror — see "Master + proxy / cache / mirror" below for context): the client trusts upstream's ACL filtering. Whatever the upstream returned is what the client serves; no per-request re-evaluation against `.zddc` files in the cache directory. Single-user-trust model on a laptop.
Implementation is a single swap: `policy.AllowAllDecider{}` replaces the configured decider when `cfg.NoAuth` is true. All existing handlers continue to call `policy.AllowFromChain` (or equivalent) unchanged; they just always get `allowed=true`. Logged at `WARN` on every restart so operators who set the flag inadvertently see it on stderr.
Distinct from `--insecure`, which only relaxes a startup-time safety check (refuse to start when no root `.zddc` exists). The two flags are independent.
### Commercial vs federal trust model
The current implementation is well-shaped for a commercial-tenant model with
delegated auth. Federal-grade qualification (FedRAMP Moderate, NIST 800-53,
FIPS 140-3, DoD STIG) requires several layers to harden. Operators deciding
whether to deploy the system should know which column they're in.
| Property | Commercial trust model (current) | Federal trust model (gaps to close) |
|---|---|---|
| Identity | Email from upstream proxy header | mTLS or signed forwarding token; PIV/CAC via IdP |
| Cryptography | Go stdlib defaults | FIPS 140-3 validated module (microsoft/go or RHEL FIPS) |
| TLS | Go stdlib defaults | Explicit MinVersion ≥ TLS 1.2, DoD-approved cipher allowlist, OCSP stapling, HSTS |
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with the operator's own ancestor-deny-absolute Rego (NIST AC-6) | (closed; federal posture is the OPA path) |
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
| Information disclosure | Anonymous reaches `/` and `/.profile` (project picker, public-projects names) | All endpoints behind authenticated proxy; no anonymous discovery |
| Apps URL fetches | Fetch-once-cached, no integrity check | SHA-256 pin + signature verification |
| Disclosure process | Not formalized | `SECURITY.md`, embargoed-fix workflow, CVE assignment |
The full bullet list with NIST control references is in
[`zddc/README.md`](zddc/README.md) § "Federal-readiness gap analysis."
### Permission model: roles + verbs
Five permission verbs gate every read and write:
| Verb | Allows |
|---|---|
| `r` | read file bytes; list directory |
| `w` | overwrite an existing file; rename existing file |
| `c` | create a new file or directory |
| `d` | delete a file |
| `a` | modify the ACL of this subtree (write `.zddc`) |
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny.
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with their own Rego (zddc-server ships only the fail-closed read-ACL skeleton at `--print-rego`).
The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below:
- **Standing config-edit (no elevation):** an admin — or anyone with the `a` verb — may edit the `.zddc`/roles of subtrees they administer. `IsConfigEditor` grants `VerbA` above the WORM clamp; it owns the subtree's policy but cannot write/delete records.
- **The unconditional `rwcda` + WORM/cascade bypass requires elevation:** `IsActiveAdmin = admin-on-chain AND Elevated` is the single bypass site. Un-elevated, an admin is a config-editor, not a WORM-bypassing superuser.
#### Canonical folders, URL routing & the `.zddc` cascade
There are **no hardcoded folder names** — the canonical project structure is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults/`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` exports it as a `.zddc.zip`; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. All three are purely virtual (no on-disk presence); they list the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. Sharing/bookmarks land on the canonical path after the redirect.
**Creating in an aggregator → party picker.** Because content is party-scoped, there's nowhere to put a folder/file at `<project>/<slot>/` itself. `EnsureCanonicalAncestors` rejects any physical write under all six aggregator names, and `serveFileMkdir` additionally 409s a mkdir *inside* one (`rejectProjectAggregatorMkdir`) with a message pointing at `archive/<party>/<slot>/` — rather than silently materialising an unreachable shadow folder. The browse "New folder" / "New file" action detects an aggregator root client-side (`events.js: aggregatorRoot`) and opens a **party picker** (`openPartyPicker`, styled like the stage modal): the user chooses an existing party — or "+ New party…", which issues `mkdir archive/<newparty>/…` and is gated to the `document_controller` by the existing `archive/` create ACL (a 403 surfaces a clear message). The chosen folder/file is then created at `archive/<party>/<slot>/<name>`, where the per-party `working/` slot carries its own `history: true` + auto-own convention. Right-clicking a party row shown in an aggregator listing rewrites to the canonical path directly (`rewriteAggregatorPath`), so it never re-prompts. (An earlier iteration made `<project>/working/` materialise project-level creator-owned folders directly; that was dropped in favour of this uniform party-scoped model.)
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.
Plan Review (`X-ZDDC-Op: plan-review`) hardcodes the scaffold convention: workflow folders always land at `<project>/archive/<party>/{reviewing,staging}/<tracking>/`, derived from the originating submittal's path. The pre-reshape `on_plan_review.reviewing_root` / `staging_root` cascade keys were dropped — one convention, no per-project override surface. The `X-ZDDC-On-Plan-Review` response header (set by `directory.go`) lights up on every `/<project>/archive/<party>/received/<tracking>/` URL via the structural `zddc.IsPlanReviewURL` test, so the browse client knows when to show the menu item without re-implementing the cascade.
The schema keys that drive built-in behavior:
| Key | Effect | Cascade rule |
|---|---|---|
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app" | leaf→root (parent applies to descendants) |
| `dir_tool` | tool served at `<dir>/` (trailing slash) — the directory view; floors at `browse` | leaf→root |
| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`); fenced adds `acl.inherit:false` (private) | leaf-only |
| `virtual` | never materialise on disk; requests are virtual routes (`reviewing/`, `mdl`) | leaf-only |
| `drop_target` | browse shows a drag-drop upload overlay (surfaced via `X-ZDDC-Drop-Target`) | leaf-only |
| `worm` | list of principals — see WORM below | union across cascade (no reset) |
| `available_tools` | tools the server may auto-serve / browse may offer here | union leaf→root |
| `admins` | subtree-admin principals (email globs or role names) | concat-dedupe across cascade |
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` rollups). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
**Subtree download.** `GET /some/dir/?zip=1` (the query form works on both `/dir` and `/dir/`) streams an `application/zip` of every readable file under that directory, recursively — `Content-Disposition: attachment; filename="<dir>.zip"`. It's `handler.ServeSubtreeZip`: a `filepath.WalkDir` that ACL-gates each file by the `.zddc` chain of its containing directory (the same per-directory decision cache `serveArchiveListing` uses), skips hidden entries (`.`/`_`-prefixed: `.zddc`, `_template`, `_app`), and adds any `.zip` *file* it meets as opaque bytes (it does **not** recurse into it — that's the navigable-surface above, a different feature). The response is streamed straight onto the `ResponseWriter` (`zip.Store` for already-compressed extensions, `zip.Deflate` otherwise), so a fully-ACL-denied or empty subtree yields a valid empty zip rather than a 403 (a stream can't change status after the headers go out). The browse tool's toolbar **Download (zip)** button hits this for the directory in view in server mode; offline (file://) it walks the picked folder itself with JSZip (with a `confirm()` above ~2000 files / ~500 MB, since the whole tree is buffered in browser memory).
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Two carve-outs: an **elevated** admin (root / subtree) bypasses the clamp entirely — the escape hatch for mis-filed documents — and a **standing** config-editor keeps `a` (so a subtree admin can edit the `.zddc` that *governs* a WORM zone without elevating; that grants only config mutation, never record write/delete). the embedded default tree puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** the embedded default tree references three roles (all shipped empty — a fresh deployment grants nothing until an operator populates them):
- `document_controller` — read/write across a project, `rwc` at `archive/`. When a DC mkdir's `archive/<party>/`, the auto-own `.zddc` grants both their email AND the `document_controller` role `rwcda` at that party (via `auto_own_roles: [document_controller]` in the defaults) — so any peer DC has full authority at every party without needing subtree-admin status. Explicit `rwcd` at `incoming/` and `staging/` shadows the inherited `rwcda` to make the transfer-workflow's `d` requirement obvious. WORM-create principal in `received/issued` via the `worm:` list. NOT a subtree-admin anywhere — admin elevation is reserved for the root `admins:` list (the human escape hatch). Plan-Review approval is part of this role; there is no separate `approver` — two-person sign-off, when needed, is expressed via per-folder `.zddc` overrides rather than baked-in roles.
- `project_team` — read-only across the project; their own `archive/<party>/working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule.
- `observer` — pure read-only across the project. Distinct from `project_team` in that the role itself carries no `c` anywhere, so an observer can't bring a working home into existence under auto-own. Intended for auditors, regulators, and external read-only viewers who must not contribute content.
The role invariants (verb sets at each canonical path, subtree-admin scope) are locked down in `zddc/internal/zddc/standardroles_test.go`. New roles, when added, should ship with a parallel test in that file.
#### Client-side capability gating
Three server surfaces feed the front-end's hide/disable model:
- **Per-entry `verbs` on every directory listing item** (`zddc/internal/listing/types.go`). Canonical `"rwcda"` subset granted to the calling principal at that entry's URL. For files it reflects the parent dir's chain (matches Writable's gate); for directories it reflects the subdir's OWN chain. `Writable` stays in lockstep during the transition window; new clients should read `verbs` and let `writable` wither.
- **`GET /.profile/access?path=<urlpath>`** returns the global view (Email, IsSuperAdmin, CanElevate, …) plus three path-scoped fields: `path_verbs` (verbs at the requested path under the caller's CURRENT elevation), `path_is_admin` (subtree-admin authority at that path under current elevation), and `path_can_elevate_grant` (verbs the caller WOULD hold at that path if they elevated, empty when elevation wouldn't change anything). Each tool fetches its current directory once on load to gate top-of-page affordances.
- **403 ACL-deny responses carry a JSON body** `{"error": "Forbidden", "missing_verb": "<r|w|c|d|a>"}` (`zddc/internal/handler/errors.go writeForbidden`). Other 403 conditions (no authenticated principal, existence-leak guards) keep plain-text bodies — `missing_verb` only applies to ACL denies.
Client side, `shared/cap.js` consumes all three: `zddc.cap.has(node, verb)` reads the listing's verbs string (falling back to `node.writable` for `w` on offline FS-API listings); `zddc.cap.at(path)` memo-fetches the path-scoped profile view; `zddc.cap.handleForbidden(resp, opts)` renders an error toast naming the missing verb and offers an Elevate button when `path_can_elevate_grant` covers it.
Each tool gates per the hide/disable rules:
- **Hide** admin-only actions (`a`), WORM-zone destructive items, and flow-terminal steps (Publish, advance state) when the verb is unattainable.
- **Disable + tooltip** everyday write affordances (Rename/Delete in the context menu, Save in editors, `+ Add row`, `+ New folder`, `Submit`) so the user discovers what permission is missing and can elevate if applicable.
- **Optimistic** for bulk / cross-directory operations — let the server return 403 and surface it via `cap.handleForbidden`.
Browse implements the per-entry gating (rename/delete + editor save); tables and form pre-flight their primary writes via `cap.at` + route 403s through `cap.handleForbidden`. Transmittal and classifier write through the FS Access API rather than the server, so server-side gating doesn't apply to their UI controls.
### File API (authenticated CRUD)
zddc-server exposes write methods on the same URL space as GET. Each method maps to a specific verb and is gated against the cascade-derived verb set:
| Method | URL | Headers | Action verb | Status |
|---|---|---|---|---|
| `PUT` | `/<new-path>` | `If-Match: "<etag>"` (optional) | `c` | 201 created |
| `PUT` | `/<existing-path>` | `If-Match: "<etag>"` (optional) | `w` | 200 overwritten |
| `PUT` | `/<dir>/.zddc` | — | `a` | 200/201 |
| `DELETE` | `/<path>` | `If-Match: "<etag>"` (optional) | `d` | 204 |
| `POST` | `/<path>` | `X-ZDDC-Op: move` + `X-ZDDC-Destination: /new/path` | `w` (src) + `c` (dst) | 200 |
| `POST` | `/<path>/` | `X-ZDDC-Op: mkdir` | `c` | 201 created / 200 idempotent |
Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs.
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
#### `zddc-source.js` known gaps
The polyfill covers the FS Access surface tools actually use. A few corners are intentionally unimplemented — note them when adding new tool features:
- **Recursive directory removal is not implemented.** `HttpDirectoryHandle.removeEntry(name, { recursive: true })` is a no-op against the server because there is no recursive-DELETE endpoint. Tools that rename a non-empty directory by copy + remove (the FS-Access idiom) will leave the source directory orphaned in HTTP mode. Detect this case and either guard the operation or implement server-side `POST X-ZDDC-Op: move` for the directory.
- **Writes have no truncate semantics.** Each PUT replaces the whole file. There's no `FileSystemWritableFileStream.truncate(size)` analogue; partial-write support means partial-overwrite-via-streaming is the polyfill's only write path.
- **Directory listings are not cached on the client side.** Cache mode does cache file responses (and persists `.zddc-listing.<json|html>` sidecars on the *server* side), but the polyfill itself re-fetches `?json=1` listings on every traversal. Tools that re-enter the same directory many times in quick succession should cache results in tool state.
These are deliberate scope decisions, not bugs. Lift any of them only when a concrete tool feature pays for the implementation cost.
### Why the tool-rooted view matters for third-party containment
A vendor given access to `/Archive/Acme/Incoming/` lands at the archive tool
served at that URL, with that subtree as its world. There is no breadcrumb to
`/Archive/`, no "go to root" button, no listing of sibling vendor folders.
This is **UX containment** — it makes the deployment feel self-contained and
prevents accidental discovery of out-of-scope paths. It is **not** the
security mechanism. Even if a vendor hand-crafts a URL to `/Archive/` or
`/`, the cascade's default-deny rejects them before any byte is served. The
tool-rooted view exists so a vendor reading their email link doesn't have to
choose between "click something I shouldn't" and "click nothing"; the
cascade exists so that choice never matters.
This pairing — UI affordance contained, ACL enforced — is why the server
auto-serves `archive.html` at *every* directory under `ZDDC_ROOT`. If the
archive tool only worked at root, every locked-down subtree would need
either a hand-crafted entrypoint or a tool that knew how to scope itself.
Auto-serving makes "vendor's world starts at `/Archive/<their-name>/`" the
out-of-the-box behavior with no per-deployment configuration.
---
## CSS Architecture
All tools use vanilla CSS. No frameworks at build time.
**Common conventions:**
- CSS variables for theme colors and spacing in `base.css`
- Component-scoped class names
- `.hidden` class uses `display: none !important` for JavaScript show/hide
- Print styles in a separate `print.css`
---
## Testing
Tests use Playwright with Chromium only (File System Access API requires it).
### Running Tests
```bash
npm test # all tools
npx playwright test archive # single tool
npx playwright test --debug # debug mode
```
### Test Structure
Each tool has a spec file in `tests/`:
```
tests/
archive.spec.js ← 2 tests: load + directory scan
classifier.spec.js ← 2 tests: load + store injection
browse.spec.js ← load + file tree render + markdown editor mount
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
fixtures/
mock-fs-api.js ← Reusable File System Access API mock
transmittal-data.js
zddc-filenames.js
```
### Mock File System API
`MOCK_FS_INIT_SCRIPT` (from `tests/fixtures/mock-fs-api.js`) overrides `showDirectoryPicker`, `showOpenFilePicker`, and `showSaveFilePicker`. Inject it via `page.addInitScript` before navigating.
```javascript
// Flat directory
window.__setMockDirectory('name', [{ name: 'file.pdf', content: '...', size: 100 }]);
// Nested directory tree
window.__setMockDirectoryTree('name', {
'subfolder': { 'file.pdf': 'content' },
'root-file.md': 'content',
});
```
### Writing Tests
Follow the pattern in `tests/transmittal.spec.js`:
- Use ESM `import` syntax
- Inject `MOCK_FS_INIT_SCRIPT` in `test.beforeEach` for any test that navigates to a tool page
- Use `waitUntil: 'domcontentloaded'` or `'load'` (not `'networkidle'` — the bundled scripts may keep the network active)
- Prefer `page.waitForFunction` over `page.waitForSelector` for app-state readiness
- Assert through the store/module API for tests that don't need visible DOM
---
## Code Standards
| Rule | Rationale |
|------|-----------|
| No `</script>` or any `</tag>` 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/<name>`, `bugfix/<name>`, `hotfix/<name>`), squash-merged to `main` and immediately deleted. Quick fixes (typos, one-liners) go direct to `main`.
**Commit messages:** Conventional Commits — `<type>(<scope>): <description>`. 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 `</` escaping step).

99
CLAUDE.md Normal file
View file

@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commits and pushes
- **Commit freely** — make commits as appropriate for the work being performed. Each commit should be a coherent, reviewable unit (no WIP/checkpoint noise). The default rule "never commit without explicit ask" does NOT apply in this repo.
- **Push only when explicitly told**`git push` requires a fresh request from the user every time. Approval to commit does not carry forward to push, and approval to push once does not carry forward to a later push.
- **No squashing on push** — keep granular history. Each commit should already be meaningful (per the rule above), so squashing erases useful detail rather than removing noise. Multi-commit branches with a clean history are preferred over force-pushed squash-merges.
## Authoritative docs — read these first
This repo already has two thorough agent-facing references. **Always consult them before working** — they cover details intentionally omitted here:
- **`AGENTS.md`** — commands, build-system rules, per-tool parser quirks, testing gotchas, git/worktree workflow, release process, zddc-server notes
- **`ARCHITECTURE.md`** — single-file HTML pattern rationale, JS module/state patterns, per-tool architecture, security model
If something in this CLAUDE.md conflicts with those, those win — and please update them rather than letting drift accumulate.
## Repo shape
This is a **monorepo of independent tools**, not one application:
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/` — seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator 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 <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
- `shared/` — 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 `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all 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 `<dir>``archive` at `archive/`, `transmittal` at `archive/<party>/staging/`, `browse` at `archive/<party>/{working,reviewing}/` (browse hosts the markdown editor), `classifier` at `archive/<party>/incoming/`, `tables` at `archive/<party>/{mdl,rsk}` and at the project-level `ssr/mdl/rsk` virtual rollups, `landing` at root), `dir_tool` (served at `<dir>/`; 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/<party>/<slot>/`). 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 `<app>.html` at the path or adding an `<app>.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<X.Y.Z>-beta` commit. NO public artifacts.
# The chart's appVersion pins to "<X.Y.Z>-beta-<sha>"; 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
# <tool>_v<X.Y.Z>.html + <tool>.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 <tool> # 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/<tool>.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 (`<tool>_v<X.Y.Z>.html`, `zddc-server_v<X.Y.Z>_<plat>`) plus their `.sig` sidecars from `/srv/zddc/releases/`. The cut writes this version's per-version files + refreshes the canonical `<tool>.html` / `zddc-server_<plat>` 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 `<X.Y.Z>-beta-<sha>` snapshot SHA) ship the bytes that ref carries. Plain `./build` (no arg) leaves `embedded/` untouched — local dev iteration uses `tool/dist/<tool>.html` opened directly, not the baked binary copy.
- **Release artifact layout** (in `dist/release-output/`, mirrored to `/srv/zddc/releases/`):
- HTML tools: `<tool>_v<X.Y.Z>.html` (real immutable file) + `<tool>.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<X.Y.Z>_<platform>` (real immutable binary, no LFS) + `zddc-server_<platform>` (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<X.Y>`, `_v<X>`). Dropped in the May 2026 simplification.
- **On-page build label.** Plain dev builds: `v<X.Y.Z>-dev · <full-ts> · <sha>[-dirty]` (red), where X.Y.Z is the next-stable target. `./build beta`: `v<X.Y.Z>-beta · <full-ts> · <sha>` (red) — only seen on the dev chart's compiled binary. Stable cuts: clean `v<X.Y.Z>`.
- **`./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://`.
- **`</` in JS string/template literals breaks inline `<script>`** embedding. `shared/build-lib.sh` provides `escape_js_close_tags`; every tool's `build.sh` runs JS through it before inlining.
- **All ZDDC parsing/formatting/hashing goes through `window.zddc`** (from `shared/zddc.js` + `shared/hash.js` + `shared/zddc-filter.js`). API: `parseFilename`, `parseFolder`, `parseRevision`, `formatFilename`, `formatFolder`, `compareRevisions`, `isValidStatus`, `splitExtension`, `joinExtension`, `crypto.{sha256Hex, sha256String, sha256File, bytesToHex}`, `filter.{parse, matches}`. File objects across tools use `trackingNumber` (string) and `extension` (string, **no leading dot** — use `zddc.joinExtension(name, ext)` to build a filename). Add edge cases to `tests/zddc.spec.js`, not per-tool tests.
- **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-<branch>`.** 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.

661
LICENSE.txt Normal file
View file

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.

99
README.md Normal file
View file

@ -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**: <https://zddc.varasys.io/> 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 `<name>.form.html`. |
| **Tables** | Sortable, filterable, in-place-editable grid view over a directory of YAML rows; click a row → edit in the form renderer. Auto-mounts on any directory containing a `table.yaml`. |
| **Browse** | File-tree navigator with previews 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 <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. 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/<party>/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 `<app>.html` file at the path or adding an `<app>.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_ROOT>/.zddc`) naming at least one admin:
```yaml
admins:
- cwitt@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 `<project>/.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:
'<principal>': <bits>
```
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 <https://zddc.varasys.io/reference.html>.
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 <https://codeberg.org/VARASYS/ZDDC>. 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.

171
SECURITY.md Normal file
View file

@ -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
(`<tool>-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 <https://codeberg.org/VARASYS/ZDDC>; 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.

277
archive/README.md Normal file
View file

@ -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 subexpressions: `(+revA plan)`.
- Terms (without +/) are ORed 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 |

113
archive/build.sh Executable file
View file

@ -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 '</' in inlined JS so the HTML parser cannot mistake string contents
# for a closing </script> 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\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https?:\/\// { next }
/<link rel="stylesheet" href="https?:\/\// { next }
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html"
if [ "$is_release" = "1" ]; then
promote_release "archive"
fi

32
archive/css/base.css Normal file
View file

@ -0,0 +1,32 @@
/* Archive-specific base overrides
Reset, tokens, and font are provided by shared/base.css */
#appContainer {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.note {
font-size: 0.9em;
color: var(--text-muted);
font-style: italic;
}
/* Scan spinner */
.scan-spinner {
display: inline-block;
width: 0.85em;
height: 0.85em;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-left: 0.4rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

903
archive/css/components.css Normal file
View file

@ -0,0 +1,903 @@
/* Archive component styles — tokens from shared/base.css */
/* Select All checkbox label */
.select-all-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
}
.select-all-label input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
/* One-line bar variant — sits below the section header */
.select-all-bar {
padding: 0.2rem 0;
margin-bottom: 0.35rem;
}
/* Filter + Select All inline row */
.filter-select-row {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.35rem;
}
.filter-select-row .filter-input {
flex: 1;
margin-bottom: 0;
}
/* Inline variant: label to the right of the filter, text above checkbox */
.select-all-inline {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
font-size: 0.7rem;
line-height: 1.1;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
text-align: center;
}
.select-all-inline input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
/* Form Inputs */
.filter-input,
.form-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: var(--font);
background: var(--bg);
color: var(--text);
transition: border-color 0.2s;
}
.filter-input:focus,
.form-input:focus {
outline: none;
border-color: var(--primary);
}
.filter-input.filter-active {
background: rgba(234, 179, 8, 0.18);
border-color: rgba(234, 179, 8, 0.7);
}
/* Form Groups */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-help {
display: block;
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Checkboxes */
input[type="checkbox"] {
margin-right: 0.5rem;
cursor: pointer;
}
/* Folder Tree Chevrons */
.folder-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
font-size: 0.6rem;
color: var(--text-muted);
cursor: pointer;
transition: transform 0.15s ease;
flex-shrink: 0;
margin-right: 0.25rem;
}
.folder-chevron:not(.collapsed) {
transform: rotate(90deg);
}
.folder-chevron:hover {
color: var(--primary);
}
.folder-chevron-placeholder {
width: 1rem;
flex-shrink: 0;
margin-right: 0.25rem;
}
/* Folder Items */
.folder-item {
display: flex;
align-items: center;
padding: 0.25rem 0.5rem;
cursor: pointer;
user-select: none;
border-radius: 3px;
border-left: 3px solid transparent;
}
.folder-item:hover {
background: var(--bg-hover);
}
.folder-item.selected {
background: var(--bg-selected);
color: inherit;
border-left: 3px solid var(--primary);
padding-left: calc(0.5rem - 3px);
}
.folder-item.selected:hover {
background: var(--bg-hover);
}
.folder-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
cursor: text;
}
/* Transmittal folder formatting */
.transmittal-folder-content {
flex: 1;
overflow: hidden;
user-select: text;
cursor: text;
}
[data-folder-type="transmittal"] {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.transmittal-first-line {
font-size: 0.9em;
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.transmittal-second-line {
font-size: 0.85em;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Empty filter message in folder lists */
.folder-list-empty {
padding: 0.75rem 0.5rem;
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
/* Focus styles for keyboard navigation */
.folder-list:focus {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.folder-list:focus .folder-item:focus {
outline: 1px dotted var(--primary);
outline-offset: -1px;
}
/* ── Folder type toggle bar ─────────────────────────────────────────────── */
.folder-type-bar {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding: 0.3rem 0 0.4rem;
flex-shrink: 0;
}
.folder-type-toggle {
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
font-family: var(--font);
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
color: var(--text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
line-height: 1.4;
white-space: nowrap;
}
.folder-type-toggle:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-dark);
}
.folder-type-toggle.active {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.folder-type-toggle.active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Date Group Headers */
.date-group-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
font-weight: 600;
color: var(--text);
position: sticky;
top: 0;
z-index: 1;
}
.date-group-header:hover {
background: var(--bg-hover);
}
.date-group-toggle {
font-size: 0.8em;
width: 1rem;
text-align: center;
}
.date-group-date {
flex: 1;
}
.date-group-count {
font-size: 0.85em;
color: var(--text-muted);
font-weight: normal;
}
/* Nav section header with button */
.nav-section-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
margin: -1rem -1rem 0.75rem -1rem;
padding: 0.4rem 1rem;
border-bottom: 1px solid var(--border);
}
.nav-section-header h3 {
margin-bottom: 0;
}
.btn-icon {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
border-radius: 3px;
transition: background 0.2s;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text);
}
/* Modals */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: var(--bg);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-large {
width: 80vw;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.modal-header h2 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
padding: 0;
width: 2rem;
height: 2rem;
cursor: pointer;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
/* Preview Table */
.preview-table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
.preview-table th,
.preview-table td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
.preview-table th {
font-weight: 600;
background: var(--bg-secondary);
}
/* Drag & Drop */
.drag-over {
background: var(--bg-selected) !important;
border-color: var(--primary) !important;
}
/* Loading Spinner */
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid var(--border);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Revision Title Styling */
.titles-container {
display: flex;
flex-direction: column;
}
.revision-title-base,
.revision-title-modifier {
margin-bottom: 0.5rem;
}
.revision-title-base:last-child,
.revision-title-modifier:last-child {
margin-bottom: 0;
}
.revision-title-base {
color: var(--text);
}
.revision-title-modifier {
color: var(--text-muted);
}
/* Modifier Filter Dropdown */
.modifier-filter-container {
position: relative;
display: inline-block;
}
.modifier-filter-btn {
min-width: 100px;
}
.modifier-filter-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 180px;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-top: 4px;
}
.modifier-filter-header {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.modifier-filter-header label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.modifier-filter-list {
max-height: 250px;
overflow-y: auto;
padding: 0.25rem 0;
}
.modifier-filter-item {
padding: 0.4rem 0.75rem;
}
.modifier-filter-item label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.modifier-filter-item:hover {
background: var(--bg-hover);
}
.modifier-base {
font-weight: 500;
color: var(--text);
}
.modifier-type {
color: var(--text-muted);
}
/* Active toggle button state */
.btn-active {
background: var(--primary);
color: var(--text-light);
border-color: var(--primary);
}
.btn-active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* Path Error Row Warning */
.file-row-path-error {
background: rgba(217, 119, 6, 0.08) !important;
}
.file-row-path-error:hover {
background: rgba(217, 119, 6, 0.15) !important;
}
.path-error-indicator {
color: var(--warning);
cursor: help;
margin-right: 0.25rem;
}
.file-link-disabled {
color: var(--text-muted);
text-decoration: none;
cursor: not-allowed;
}
.file-link-disabled:hover {
text-decoration: none;
color: var(--text-muted);
}
/* PDF Preview Toggle */
.preview-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text);
cursor: pointer;
padding: 0.4rem 0.85rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
transition: background 0.15s;
}
.preview-toggle-label:hover {
background: var(--bg-secondary);
}
.preview-toggle-label input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.preview-toggle-label input[type="checkbox"]:checked + span {
color: var(--primary);
font-weight: 500;
}
/* ── Download progress indicator ────────────────────────────────────────── */
.progress-indicator {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 300px;
}
.progress-indicator__message {
margin-bottom: 10px;
}
.progress-indicator__track {
background: var(--bg-secondary);
height: 20px;
border-radius: 10px;
overflow: hidden;
}
.progress-indicator__fill {
background: var(--primary);
height: 100%;
transition: width 0.3s;
}
.progress-indicator__label {
text-align: center;
margin-top: 5px;
font-size: 0.9em;
color: var(--text-muted);
}
/* .welcome-list lives in shared/base.css. */
/* ── Windows path tip (inside welcome screen) ────────────────────────────── */
.windows-tip {
text-align: left;
margin: 1rem auto;
max-width: 500px;
font-size: 0.9rem;
}
.windows-tip summary {
cursor: pointer;
color: var(--text-muted);
}
.windows-tip__body {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border: 1px solid var(--warning);
border-radius: var(--radius);
}
.windows-tip__body > p:first-child {
margin: 0 0 0.5rem 0;
}
.windows-tip__body ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.windows-tip__code {
display: block;
margin: 0.25rem 0;
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--radius);
font-family: var(--font-mono);
font-size: 0.85em;
}
.windows-tip__note {
margin: 0.5rem 0 0 0;
font-size: 0.85rem;
color: var(--text-muted);
}
/* Outstanding virtual transmittal — pinned at top of transmittal list */
.outstanding-transmittal {
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
margin-bottom: 0.25rem;
}
.outstanding-label {
font-style: italic;
color: var(--text-muted);
}
.outstanding-transmittal.selected .outstanding-label {
color: var(--text);
}
/* Reset Filters Button */
.btn-icon-only {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
font-size: 1.1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.btn-icon-only:hover {
background: var(--bg-hover);
border-color: var(--primary);
}
.btn-icon-only:active {
background: var(--primary);
border-color: var(--primary);
color: var(--text-light);
}
/* Toolbar separator */
.toolbar-separator {
width: 1px;
height: 1.5rem;
background: var(--border);
margin: 0 0.25rem;
align-self: center;
flex-shrink: 0;
}
/* ── Preset dropdown ─────────────────────────────────────────────────────── */
.preset-section {
position: relative;
display: inline-flex;
align-items: center;
}
.preset-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
min-width: 350px;
max-height: 400px;
background: var(--bg);
border: 1px solid var(--border-dark);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.preset-section-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
padding: 0.5rem 0.75rem 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preset-list {
padding: 0.25rem 0;
max-height: 200px;
overflow-y: auto;
}
.preset-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
cursor: pointer;
user-select: none;
}
.preset-item:hover {
background: var(--bg-hover);
}
.preset-item .preset-delete {
background: none;
border: none;
color: var(--text-muted);
font-size: 1rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
border-radius: 3px;
line-height: 1;
}
.preset-item .preset-delete:hover {
background: rgba(255, 0, 0, 0.1);
color: var(--error);
}
.preset-no-presets {
padding: 0.75rem 0.75rem;
color: var(--text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
}
.preset-divider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.preset-projects-list {
padding: 0.25rem 0;
max-height: 200px;
overflow-y: auto;
}
.preset-project-item {
padding: 0.25rem 0.75rem;
}
.preset-project-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
user-select: none;
}
.preset-project-label input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
/* Folder-name hint after the friendly title shown only when the
project's .zddc declares a different `title:`. Muted so the title
reads first; the folder name is reference info. */
.preset-project-folder {
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
.preset-footer-actions {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preset-footer-naming {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
display: flex;
gap: 0.5rem;
}
.preset-name-input {
flex: 1;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: var(--font);
background: var(--bg);
color: var(--text);
}
.preset-name-input:focus {
outline: none;
border-color: var(--primary);
}
.preset-section-top,
.preset-section-bottom {
padding: 0.25rem 0;
}
.preset-section-bottom {
flex: 1;
overflow-y: auto;
}

240
archive/css/layout.css Normal file
View file

@ -0,0 +1,240 @@
/* Archive layout — tokens from shared/base.css */
/* Header — shared/base.css provides base .app-header; add archive-specific overrides */
.app-header {
padding: 0.5rem 1rem;
}
.preview-toggle-label {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
cursor: pointer;
white-space: nowrap;
}
/* Main Container */
.main-container {
display: flex;
flex: 1;
overflow: hidden;
}
/* Navigation Pane */
.nav-pane {
width: 300px;
min-width: 200px;
background: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
position: relative;
}
.nav-section {
display: flex;
flex-direction: column;
padding: 1rem;
border-bottom: 1px solid var(--border);
overflow: hidden;
position: relative;
}
/* Grouping section - larger default size */
.nav-section:first-child {
flex: 0 0 auto;
height: 250px;
min-height: 50px;
}
/* Grouping section when collapsed */
.nav-section:first-child.collapsed {
height: auto;
flex: 0 0 auto;
}
/* Transmittal section takes remaining space */
.nav-section:last-child {
flex: 1;
min-height: 150px;
border-bottom: none;
}
/* Nav section content wrapper */
.nav-section-content {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* Hide content when collapsed */
.nav-section.collapsed .nav-section-content {
display: none;
}
/* Resize handles — persistent 1px divider; grab cursor on hover */
.resize-handle-horizontal {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: ew-resize;
z-index: 10;
/* Persistent 1px right-edge indicator */
border-right: 1px solid var(--border-dark);
}
.resize-handle-horizontal:hover,
.resize-handle-horizontal.resizing {
background: rgba(42, 90, 138, 0.25);
cursor: col-resize;
}
.resize-handle-vertical {
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 6px;
cursor: ns-resize;
z-index: 10;
/* Persistent 1px bottom-edge indicator */
border-bottom: 1px solid var(--border-dark);
}
.resize-handle-vertical:hover,
.resize-handle-vertical.resizing {
background: rgba(42, 90, 138, 0.25);
cursor: row-resize;
}
.nav-section h3 {
font-size: 1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.nav-section h3 {
font-size: 1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0;
flex-shrink: 0;
}
.folder-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin-top: 0.5rem;
min-height: 0;
}
/* Content Area */
.content-area {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
overflow: hidden;
}
.content-header {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.content-header .content-actions {
margin-left: auto;
}
.content-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Table Container */
.table-container {
flex: 1;
overflow: auto;
background: var(--bg);
margin: 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Status Bar */
.status-bar {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0.35rem 1rem;
background: var(--bg);
border-top: 1px solid var(--border);
font-size: 0.85em;
color: var(--text-muted);
gap: 1rem;
}
/* Empty State — positioned below the app header */
/* .empty-state / .empty-state__inner / .welcome-list live in shared/base.css. */
/* Project warning banner */
.project-warning-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #fff3cd;
border-bottom: 1px solid #ffc107;
color: #664d03;
font-size: 0.875rem;
gap: 12px;
}
.project-warning-banner.hidden { display: none; }
.project-warning-dismiss {
background: none;
border: none;
cursor: pointer;
color: #664d03;
font-size: 1rem;
padding: 0 4px;
flex-shrink: 0;
}
/* Project access warning banner */
.project-warning-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: #fff3cd;
border-bottom: 1px solid #ffc107;
color: #664d03;
font-size: 0.875rem;
gap: 12px;
}
.project-warning-banner.hidden { display: none; }
.project-warning-dismiss {
background: none;
border: none;
cursor: pointer;
color: #664d03;
font-size: 1rem;
padding: 0 4px;
flex-shrink: 0;
}

121
archive/css/print.css Normal file
View file

@ -0,0 +1,121 @@
/* Print styles */
@media print {
/* Hide UI elements */
.app-header,
.nav-pane,
.content-header,
.status-bar,
.modal,
.btn,
.filter-input,
.global-search,
.column-filter,
input[type="checkbox"],
.resize-handle,
.sort-indicator {
display: none !important;
}
/* Reset layout */
body {
font-size: 10pt;
line-height: 1.4;
}
#appContainer {
height: auto;
}
.main-container {
display: block;
}
.content-area {
background: white;
}
.table-container {
margin: 0;
border: none;
overflow: visible;
}
/* Table adjustments */
.files-table {
font-size: 9pt;
border: 1px solid #000;
}
.files-table thead {
position: static;
}
.files-table th {
background: #f0f0f0;
border: 1px solid #000;
padding: 4pt 6pt;
font-weight: bold;
}
.files-table td {
border: 1px solid #000;
padding: 3pt 6pt;
}
.files-table tbody tr:hover {
background: transparent;
}
/* Show only text content for revisions */
.revision-item {
display: inline;
margin-right: 0.5em;
}
.file-link {
color: black;
text-decoration: none;
}
.file-link::after {
content: " (" attr(href) ")";
font-size: 8pt;
color: #666;
}
/* Page breaks */
.files-table {
page-break-inside: auto;
}
.files-table tr {
page-break-inside: avoid;
page-break-after: auto;
}
/* Header on each page */
@page {
size: letter portrait;
margin: 0.5in;
}
/* Add document title */
body::before {
content: "Archive Browser Report";
display: block;
font-size: 16pt;
font-weight: bold;
margin-bottom: 12pt;
}
/* Add timestamp */
body::after {
content: "Generated: " attr(data-print-date);
display: block;
margin-top: 12pt;
font-size: 9pt;
color: #666;
text-align: right;
}
}

258
archive/css/table.css Normal file
View file

@ -0,0 +1,258 @@
/* Table styles */
.files-table {
width: 100%;
border-collapse: collapse;
background: var(--bg);
}
/* Table Header */
.files-table thead {
position: sticky;
top: 0;
background: var(--bg);
z-index: 10;
}
.files-table th {
position: relative;
text-align: left;
font-weight: 600;
background: var(--bg-secondary);
border-bottom: 2px solid var(--border);
user-select: none;
}
.th-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: default;
}
/* Variant: content packs at the start instead of distributing across the
column header. Used by the Revisions column where a leading "select all"
checkbox sits beside the column title. */
.th-content--start {
justify-content: flex-start;
}
.th-content--start .select-all-checkbox {
margin-right: 0.5rem;
}
.sortable .th-content {
cursor: pointer;
}
.sortable .th-content:hover {
background: var(--bg-hover);
}
/* Sort Indicators */
.sort-indicator {
display: inline-block;
width: 0.75rem;
height: 1rem;
margin-left: 0.5rem;
position: relative;
}
.sort-indicator::before,
.sort-indicator::after {
content: '';
position: absolute;
left: 0;
width: 0;
height: 0;
border-style: solid;
}
.sort-indicator::before {
top: 0;
border-width: 0 0.375rem 0.375rem 0.375rem;
border-color: transparent transparent var(--border-dark) transparent;
}
.sort-indicator::after {
bottom: 0;
border-width: 0.375rem 0.375rem 0 0.375rem;
border-color: var(--border-dark) transparent transparent transparent;
}
th[data-sort="asc"] .sort-indicator::before {
border-bottom-color: var(--text);
}
th[data-sort="desc"] .sort-indicator::after {
border-top-color: var(--text);
}
/* Resize Handle */
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 4px;
cursor: col-resize;
background: transparent;
}
.resize-handle:hover {
background: var(--primary);
}
/* Table Body */
.files-table tbody tr {
transition: background-color 0.1s;
}
.files-table tbody tr.group-last {
border-bottom: 1px solid var(--border);
}
.files-table tbody tr:hover {
background: var(--bg-hover);
}
/* Preview-active highlight: marks the row + specific file (when there are
multiple files per revision) that the preview popup is currently showing,
so the user can match what's on screen to its location in the table. */
.files-table tbody tr.is-previewing {
background: var(--bg-selected, rgba(42, 90, 138, 0.10));
box-shadow: inset 3px 0 0 var(--primary);
}
.files-table tbody tr.is-previewing:hover {
background: var(--bg-selected-hover, rgba(42, 90, 138, 0.18));
}
.revision-file.is-previewing {
outline: 1.5px solid var(--primary);
outline-offset: 2px;
border-radius: 3px;
}
.files-table td {
padding: 0.25rem 1rem;
vertical-align: top;
}
/* Tracking Number Column */
td[data-field="trackingNumber"],
th[data-sort="trackingNumber"] {
white-space: nowrap;
}
td[data-field="trackingNumber"] {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
/* Revisions Column */
.revision-group {
margin-bottom: 0.5rem;
}
.revision-group:last-child {
margin-bottom: 0;
}
.revision-item {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.revision-item:last-child {
margin-bottom: 0;
}
.revision-info {
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
.revision-id {
font-weight: 600;
margin-right: 0.25rem;
}
.revision-status {
color: var(--text-muted);
font-size: 0.85em;
}
.revision-file {
display: inline-flex;
align-items: center;
margin-left: 0.25rem;
}
.file-link,
.file-link-disabled {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
margin-right: 0.25rem;
line-height: 1.1;
}
.file-link {
color: var(--primary);
text-decoration: none;
}
.file-link:hover {
text-decoration: underline;
}
.file-ext {
color: var(--text-muted);
font-size: 0.85em;
text-transform: uppercase;
}
/* Empty Table State */
.empty-table {
text-align: center;
padding: 3rem;
color: var(--text-muted);
}
/* Column Widths */
.files-table th:nth-child(1),
.files-table td:nth-child(1) {
width: 240px;
min-width: 220px;
}
.files-table th:nth-child(2),
.files-table td:nth-child(2) {
width: 40%;
min-width: 200px;
}
.files-table th:nth-child(3),
.files-table td:nth-child(3) {
width: auto;
min-width: 300px;
}
/* File size — half the height of the extension badge, left-aligned below it */
.file-size {
color: var(--text-muted);
font-size: 0.5em;
line-height: 1;
margin-top: 0.15em;
}
/* Active column filter highlight */
.column-filter.filter-active {
background: rgba(234, 179, 8, 0.18);
border-color: rgba(234, 179, 8, 0.7);
}

1014
archive/js/app.js Normal file

File diff suppressed because it is too large Load diff

190
archive/js/directory.js Normal file
View file

@ -0,0 +1,190 @@
(function() {
'use strict';
// Directory selection and scanning functionality
// Add directory
async function addDirectory() {
try {
const dirHandle = await window.showDirectoryPicker();
// Check if already added
const exists = window.app.directories.some(d => d.name === dirHandle.name);
if (exists) {
alert('This directory has already been added.');
return;
}
// Add to directories
window.app.directories.push({
handle: dirHandle,
name: dirHandle.name,
path: dirHandle.name // Root path
});
// Hide empty state if this is the first directory
if (window.app.directories.length === 1) {
window.app.modules.app.hideEmptyState();
}
// Scan the new directory
await scanDirectory(dirHandle, dirHandle.name);
window.app.modules.app.updateUI();
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error selecting directory:', err);
alert('Error selecting directory: ' + err.message);
}
}
}
// Scan directory recursively (local mode — delegates to local source in source.js)
async function scanDirectory(dirHandle, path) {
window.app.isScanning = true;
window.app.scanProgress = 'Scanning ' + path + '...';
window.app.modules.app.updateStatusBar();
const source = window.app.modules.source.createSource('local', {});
const callbacks = {
onGroupingFolder: function(folder) {
window.app.groupingFolders.push(folder);
},
onTransmittalFolder: function(folder) {
window.app.transmittalFolders.push(folder);
},
onFile: function(file) {
window.app.files.push(file);
},
onProgress: function(message) {
window.app.scanProgress = message;
window.app.modules.app.updateStatusBar();
}
};
try {
await source.scan(dirHandle, callbacks);
// Only auto-select top-level party folders (shallowest depth).
// Selection is keyed by party NAME so the same-named third-party folder
// appearing under multiple projects is selected/deselected as a unit.
const groupingDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
const minGroupingDepth = groupingDepths.length > 0 ? Math.min(...groupingDepths) : 1;
window.app.groupingFolders.forEach(folder => {
if (folder.path.split('/').length === minGroupingDepth) {
window.app.selectedGroupingFolders.add(folder.name);
}
});
window.app.transmittalFolders.forEach(folder => {
if (!window.app.modules.app.isUnderHiddenFolderType(folder.path)) {
window.app.selectedTransmittalFolders.add(folder.path);
}
});
window.app.modules.app.ensureOutstandingTransmittal();
// Auto-select Outstanding if selectAllTransmittals is active
if (window.app.selectAllTransmittals) {
window.app.selectedTransmittalFolders.add('__outstanding__');
}
window.app.modules.app.collectModifiers();
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
if (window.app.modules.presets) {
window.app.modules.presets.init();
}
} catch (err) {
console.error('Error scanning directory:', err);
alert('Error scanning directory: ' + err.message);
} finally {
window.app.isScanning = false;
window.app.scanProgress = '';
window.app.modules.app.updateStatusBar();
}
}
// Refresh all directories
async function refreshDirectories() {
// Clear existing data
window.app.groupingFolders = [];
window.app.transmittalFolders = [];
window.app.files = [];
window.app.filteredFiles = [];
if (window.app.sourceMode === 'http') {
// Re-scan all HTTP sources
const dirs = window.app.directories.slice();
window.app.directories = [];
for (const dir of dirs) {
await window.app.modules.app.addHttpSource(dir.url);
}
} else {
// Re-scan all local directories
for (const dir of window.app.directories) {
await scanDirectory(dir.handle, dir.name);
}
}
window.app.modules.app.updateUI();
}
// Remove directory
function removeDirectory(dirName) {
const index = window.app.directories.findIndex(d => d.name === dirName);
if (index !== -1) {
window.app.directories.splice(index, 1);
// Remove associated folders and files
window.app.groupingFolders = window.app.groupingFolders.filter(f =>
!f.path.startsWith(dirName)
);
window.app.transmittalFolders = window.app.transmittalFolders.filter(f =>
!f.path.startsWith(dirName)
);
window.app.files = window.app.files.filter(f =>
!f.path.startsWith(dirName)
);
// Clean up the Outstanding virtual transmittal if no outstanding files remain
const hasAnyOutstanding = window.app.files.some(f => f.folderPath === '__outstanding__');
if (!hasAnyOutstanding) {
window.app.transmittalFolders = window.app.transmittalFolders.filter(f => f.path !== '__outstanding__');
window.app.selectedTransmittalFolders.delete('__outstanding__');
}
// Show empty state if no directories left
if (window.app.directories.length === 0) {
window.app.modules.app.showEmptyState();
}
window.app.modules.app.updateUI();
}
}
// Request permission for directory
async function requestPermission(dirHandle) {
const options = { mode: 'read' };
// Check current permission state
if ((await dirHandle.queryPermission(options)) === 'granted') {
return true;
}
// Request permission
if ((await dirHandle.requestPermission(options)) === 'granted') {
return true;
}
return false;
}
window.app.modules.directory = {
addDirectory,
scanDirectory,
refreshDirectories,
removeDirectory,
requestPermission
};
})();

282
archive/js/drag-drop.js Normal file
View file

@ -0,0 +1,282 @@
(function() {
'use strict';
// Drag and drop functionality
let draggedFiles = [];
let targetGroupingFolder = null;
// Setup drag and drop
function setupDragAndDrop() {
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, preventDefaults, false);
});
// Highlight drop zones
['dragenter', 'dragover'].forEach(eventName => {
document.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
document.addEventListener(eventName, unhighlight, false);
});
// Handle drops on grouping folders (for creating transmittals)
document.getElementById('groupingFoldersList').addEventListener('drop', handleDrop, false);
// Handle drops on the main app area (for adding directories).
// Note: the root element is id="appContainer" (id="app" was a
// stale reference that crashed dragDrop init in local mode).
document.getElementById('appContainer').addEventListener('drop', handleAppDrop, false);
}
// Prevent default behaviors
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop zone
function highlight(e) {
const folderItem = e.target.closest('.folder-item');
if (folderItem && folderItem.parentElement.id === 'groupingFoldersList') {
folderItem.classList.add('drag-over');
}
}
// Remove highlight
function unhighlight(e) {
document.querySelectorAll('.drag-over').forEach(item => {
item.classList.remove('drag-over');
});
}
// Handle directory drop on main app area (for adding directories)
async function handleAppDrop(e) {
// Check if this is a drop on a grouping folder (handled separately)
const folderItem = e.target.closest('.folder-item');
if (folderItem && folderItem.parentElement.id === 'groupingFoldersList') {
return; // Let handleDrop handle this
}
// Check if dataTransfer has directory items
const items = e.dataTransfer.items;
if (!items || items.length === 0) return;
// Process each dropped item
for (let i = 0; i < items.length; i++) {
const item = items[i];
// Check if it's a directory using the File System Access API
if (item.kind === 'file') {
const entry = await item.getAsFileSystemHandle();
if (entry && entry.kind === 'directory') {
// Check if already added
const exists = window.app.directories.some(d => d.name === entry.name);
if (exists) {
continue;
}
// Add to directories
window.app.directories.push({
handle: entry,
name: entry.name,
path: entry.name
});
// Hide empty state if this is the first directory
if (window.app.directories.length === 1) {
window.app.modules.app.hideEmptyState();
}
// Scan the new directory
await window.app.modules.directory.scanDirectory(entry, entry.name);
}
}
}
// Update UI after processing all dropped directories
if (window.app.directories.length > 0) {
window.app.modules.app.updateUI();
}
}
// Handle file drop on grouping folder (for creating transmittals)
async function handleDrop(e) {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return;
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// Find the grouping folder
const folderPath = folderItem.getAttribute('data-path');
const groupingFolder = window.app.groupingFolders.find(f => f.path === folderPath);
if (!groupingFolder) return;
targetGroupingFolder = groupingFolder;
draggedFiles = files;
// Show transmittal creation dialog
showTransmittalDialog();
}
// Show transmittal creation dialog
function showTransmittalDialog() {
const modal = document.getElementById('dropModal');
// Generate default transmittal name
const today = new Date();
const dateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
// Try to extract tracking number from first file
let trackingNumber = 'TRACKING';
let title = 'Transmittal';
if (draggedFiles.length > 0) {
const firstFile = draggedFiles[0];
const parsed = zddc.parseFilename(firstFile.name) || {};
if (parsed.trackingNumber) {
trackingNumber = parsed.trackingNumber;
}
if (parsed.title) {
title = parsed.title;
}
}
const defaultName = `${dateStr}_${trackingNumber} (IFI) - ${title}`;
document.getElementById('transmittalName').value = defaultName;
// Show file preview
updateFilePreview();
modal.classList.remove('hidden');
}
// Update file preview in dialog
function updateFilePreview() {
const tbody = document.getElementById('filesPreviewBody');
const rows = draggedFiles.map(file => {
const parsed = zddc.parseFilename(file.name) || {};
// Generate ZDDC-compliant name
let newName = file.name;
if (parsed.trackingNumber) {
newName = `${parsed.trackingNumber}_${parsed.revision || 'A'} (${parsed.status || 'IFI'}) - ${parsed.title}.${parsed.extension}`;
}
const isValid = !!parsed.trackingNumber;
return `
<tr>
<td>${window.app.modules.app.escapeHtml(file.name)}</td>
<td>
<input type="text"
class="form-input"
value="${window.app.modules.app.escapeHtml(newName)}"
data-original="${window.app.modules.app.escapeHtml(file.name)}"
style="width: 100%;">
</td>
<td style="color: ${isValid ? 'green' : 'red'};">
${isValid ? '✓ Valid' : '✗ Invalid'}
</td>
</tr>
`;
}).join('');
tbody.innerHTML = rows;
}
// Confirm transmittal creation
async function confirmTransmittal() {
const transmittalName = document.getElementById('transmittalName').value.trim();
// Validate transmittal folder name
if (!window.app.modules.parser.isTransmittalFolder(transmittalName)) {
alert('Invalid transmittal folder name. Must follow format: YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE');
return;
}
try {
// Create transmittal folder
const transmittalHandle = await targetGroupingFolder.handle.getDirectoryHandle(transmittalName, { create: true });
// Get file mappings from preview
const fileMappings = [];
const inputs = document.querySelectorAll('#filesPreviewBody input');
inputs.forEach((input, index) => {
fileMappings.push({
originalFile: draggedFiles[index],
newName: input.value.trim()
});
});
// Save files with new names
for (const mapping of fileMappings) {
const fileHandle = await transmittalHandle.getFileHandle(mapping.newName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(mapping.originalFile);
await writable.close();
}
// Close modal
document.getElementById('dropModal').classList.add('hidden');
// Refresh to show new files
await window.app.modules.directory.refreshDirectories();
alert(`Transmittal created successfully with ${fileMappings.length} files.`);
} catch (err) {
console.error('Error creating transmittal:', err);
alert('Error creating transmittal: ' + err.message);
}
}
// Handle drag and drop on table rows (for metadata copy)
function setupTableRowDragDrop() {
document.getElementById('filesTableBody').addEventListener('dragover', (e) => {
const tr = e.target.closest('tr');
if (tr) {
e.preventDefault();
tr.classList.add('drag-over');
}
});
document.getElementById('filesTableBody').addEventListener('dragleave', (e) => {
const tr = e.target.closest('tr');
if (tr) {
tr.classList.remove('drag-over');
}
});
document.getElementById('filesTableBody').addEventListener('drop', async (e) => {
const tr = e.target.closest('tr');
if (!tr) return;
tr.classList.remove('drag-over');
// Get tracking number and title from the row
const trackingNumber = tr.querySelector('td[data-field="trackingNumber"]').textContent;
const title = tr.querySelector('td[data-field="title"]').textContent;
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
// For table row drops, just copy metadata
alert(`Would copy metadata:\nTracking Number: ${trackingNumber}\nTitle: ${title}\n\nTo ${files.length} file(s)`);
});
}
window.app.modules.dragDrop = {
setupDragAndDrop,
showTransmittalDialog,
updateFilePreview,
confirmTransmittal,
setupTableRowDragDrop
};
})();

579
archive/js/events.js Normal file
View file

@ -0,0 +1,579 @@
(function() {
'use strict';
// Event handling
// Set up all event listeners
function setupEventListeners() {
// Header buttons
document.getElementById('addDirectoryBtn').addEventListener('click', () => window.app.modules.directory.addDirectory());
document.getElementById('refreshHeaderBtn').addEventListener('click', () => window.app.modules.directory.refreshDirectories());
// Content area buttons
document.getElementById('filterSelectedBtn').addEventListener('click', () => window.app.modules.app.toggleFilterSelected());
document.getElementById('downloadSelectedBtn').addEventListener('click', () => window.app.modules.export.downloadSelected());
document.getElementById('exportCsvBtn').addEventListener('click', () => window.app.modules.export.exportCSV());
// Search and filter inputs
document.getElementById('groupingFilter').addEventListener('input', (e) => {
window.app.groupingFilter = e.target.value;
e.target.classList.toggle('filter-active', e.target.value.length > 0);
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
});
document.getElementById('transmittalFilter').addEventListener('input', (e) => {
window.app.transmittalFilter = e.target.value;
e.target.classList.toggle('filter-active', e.target.value.length > 0);
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters(); // Re-filter files when transmittal filter changes
window.app.modules.urlState.push();
});
// Select All Grouping Folders checkbox
document.getElementById('selectAllGroupingCheckbox').addEventListener('change', (e) => {
window.app.selectAllGroupingFolders = e.target.checked;
window.app.modules.app.renderGroupingFolders();
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
});
// Folder type toggle bar — global click delegation
document.addEventListener('click', (e) => {
const btn = e.target.closest('.folder-type-toggle');
if (btn) {
const type = btn.getAttribute('data-type');
if (type) window.app.modules.app.toggleFolderType(type);
}
});
// Select All Transmittals checkbox
document.getElementById('selectAllTransmittalsCheckbox').addEventListener('change', (e) => {
window.app.selectAllTransmittals = e.target.checked;
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
});
// Modifier filter dropdown
document.getElementById('modifierFilterBtn').addEventListener('click', () => window.app.modules.app.toggleModifierDropdown());
document.getElementById('modifierSelectAll').addEventListener('change', (e) => {
window.app.modules.app.toggleAllModifiers(e.target.checked);
});
// Close modifier dropdown when clicking outside
document.addEventListener('click', (e) => {
const container = document.querySelector('.modifier-filter-container');
const dropdown = document.getElementById('modifierFilterDropdown');
if (container && dropdown && !container.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
// Select all visible files checkbox
document.getElementById('selectAllVisibleCheckbox').addEventListener('change', (e) => {
e.stopPropagation();
window.app.modules.table.toggleSelectAllVisible(e.target.checked);
});
// Reset filters button
document.getElementById('resetFiltersBtn').addEventListener('click', () => window.app.modules.filtering.clearFilters());
// Column filters — delegated from thead
const thead = document.querySelector('thead');
if (thead) {
thead.addEventListener('input', (e) => {
if (e.target.matches('.column-filter[data-filter-field]')) {
const field = e.target.getAttribute('data-filter-field');
const raw = e.target.value.trim();
window.app.columnFilters[field] = raw;
window.app.columnFilterASTs[field] = zddc.filter.parse(raw);
// Add/remove filter-active class based on non-empty value
if (raw) {
e.target.classList.add('filter-active');
} else {
e.target.classList.remove('filter-active');
}
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
}
});
thead.addEventListener('keydown', (e) => {
if (!e.target.matches('.column-filter[data-filter-field]')) return;
if (e.key === 'Escape') {
e.target.value = '';
e.target.classList.remove('filter-active');
const field = e.target.getAttribute('data-filter-field');
window.app.columnFilters[field] = '';
window.app.columnFilterASTs[field] = null;
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
e.preventDefault();
} else if (e.key === 'Enter') {
e.preventDefault();
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
const idx = inputs.indexOf(e.target);
if (idx !== -1) {
inputs[(idx + 1) % inputs.length].focus();
}
}
});
thead.addEventListener('click', (e) => {
if (e.target.matches('.column-filter')) {
e.stopPropagation();
}
});
}
// Table sorting
document.querySelectorAll('.sortable').forEach(th => {
th.querySelector('.th-content').addEventListener('click', () => {
const field = th.getAttribute('data-field');
window.app.modules.table.sortTable(field);
});
});
// Initialize column resize
window.app.modules.table.initializeColumnResize();
// Modal close buttons
document.querySelectorAll('.modal-close').forEach(btn => {
btn.addEventListener('click', closeModal);
});
// Modal backdrop clicks
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
backdrop.addEventListener('click', closeModal);
});
// Drop modal buttons
const dropModal = document.getElementById('dropModal');
dropModal.querySelector('.modal-cancel').addEventListener('click', closeModal);
dropModal.querySelector('.modal-confirm').addEventListener('click', () => window.app.modules.dragDrop.confirmTransmittal());
// Drag and drop (local mode only — requires write access)
if (window.app.sourceMode === 'local') {
window.app.modules.dragDrop.setupDragAndDrop();
}
// Multi-select for folders
setupFolderMultiSelect();
// Date group toggle handlers
setupDateGroupToggles();
// Grouping section collapse toggle
setupGroupingToggle();
// Resizable panes
setupResizablePanes();
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyboardShortcuts);
}
// Handle grouping filter
function handleGroupingFilter(e) {
window.app.groupingFilter = e.target.value;
window.app.modules.app.renderGroupingFolders();
// Re-render transmittal folders as they depend on grouping selection
window.app.modules.app.renderTransmittalFolders();
// Re-filter files based on updated folder selections
window.app.modules.filtering.applyFilters();
}
// Handle transmittal filter
function handleTransmittalFilter(e) {
window.app.transmittalFilter = e.target.value;
window.app.modules.app.renderTransmittalFolders();
// Re-filter files based on updated folder selections
window.app.modules.filtering.applyFilters();
}
// Close modal
function closeModal(e) {
const modal = e.target.closest('.modal');
if (modal) {
modal.classList.add('hidden');
}
}
// Handle keyboard shortcuts
function handleKeyboardShortcuts(e) {
// Escape closes modals
if (e.key === 'Escape') {
document.querySelectorAll('.modal:not(.hidden)').forEach(modal => {
modal.classList.add('hidden');
});
}
// Ctrl+A selects all visible files
if (e.ctrlKey && e.key === 'a' && e.target.tagName !== 'INPUT') {
e.preventDefault();
toggleSelectAll();
}
// F5 refreshes
if (e.key === 'F5') {
e.preventDefault();
window.app.modules.directory.refreshDirectories();
}
}
// Multi-select handling for folder lists
function setupFolderMultiSelect() {
let lastSelectedGroupingIndex = -1;
let lastSelectedTransmittalIndex = -1;
// Handle grouping folders
const groupingList = document.getElementById('groupingFoldersList');
groupingList.addEventListener('click', (e) => {
const result = handleFolderClick(e, window.app.selectedGroupingFolders, lastSelectedGroupingIndex);
if (result !== undefined) {
lastSelectedGroupingIndex = result;
// Turn off "Select All" mode when user manually selects
if (window.app.selectAllGroupingFolders) {
window.app.selectAllGroupingFolders = false;
document.getElementById('selectAllGroupingCheckbox').checked = false;
}
// Update selection state first
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
// Then update transmittal folder list based on new selection
window.app.modules.app.renderTransmittalFolders();
window.app.modules.filtering.applyFilters(); // Re-filter files
// Check presets dirty state
if (window.app.modules.presets) {
window.app.modules.presets.checkDirty();
}
// Reset transmittal index since list may have changed
lastSelectedTransmittalIndex = -1;
}
});
// Handle transmittal folders
const transmittalList = document.getElementById('transmittalFoldersList');
transmittalList.addEventListener('click', (e) => {
const result = handleFolderClick(e, window.app.selectedTransmittalFolders, lastSelectedTransmittalIndex);
if (result !== undefined) {
lastSelectedTransmittalIndex = result;
// Turn off "Select All" mode when user manually selects
if (window.app.selectAllTransmittals) {
window.app.selectAllTransmittals = false;
document.getElementById('selectAllTransmittalsCheckbox').checked = false;
}
// Update selection state without rebuilding DOM
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
window.app.modules.filtering.applyFilters(); // Update file display
}
});
// Handle Ctrl+A for folder lists
groupingList.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
selectAllVisibleFolders('grouping');
}
});
transmittalList.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
selectAllVisibleFolders('transmittal');
}
});
// Make lists focusable
groupingList.setAttribute('tabindex', '0');
transmittalList.setAttribute('tabindex', '0');
}
/**
* Handle folder click with multi-select support (Shift/Ctrl)
* @param {Event} e - Click event
* @param {Set} selectedSet - Set of selected folder paths
* @param {number} lastIndex - Index of last clicked item
* @returns {number|undefined} Current index if valid click, undefined otherwise
*/
function handleFolderClick(e, selectedSet, lastIndex) {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return undefined;
const path = folderItem.getAttribute('data-path');
if (!path) return undefined;
const container = folderItem.parentElement;
const items = Array.from(container.children);
const currentIndex = items.indexOf(folderItem);
if (e.shiftKey && lastIndex !== -1 && lastIndex < items.length) {
// Shift+click: select range from last to current
e.preventDefault();
const start = Math.min(lastIndex, currentIndex);
const end = Math.max(lastIndex, currentIndex);
if (!e.ctrlKey) {
selectedSet.clear();
}
for (let i = start; i <= end; i++) {
const itemPath = items[i]?.getAttribute('data-path');
if (itemPath) {
selectedSet.add(itemPath);
}
}
} else if (e.ctrlKey || e.metaKey) {
// Ctrl+click: toggle individual selection
e.preventDefault();
if (selectedSet.has(path)) {
selectedSet.delete(path);
} else {
selectedSet.add(path);
}
} else {
// Regular click: clear and select single item
selectedSet.clear();
selectedSet.add(path);
}
return currentIndex;
}
/**
* Toggle expand/collapse state of a grouping folder
* @param {string} path - Folder path to toggle
* @param {boolean} recursive - If true, also toggle all descendants
*/
function toggleGroupingFolder(path, recursive) {
const isCurrentlyCollapsed = window.app.collapsedGroupingFolders.has(path);
if (recursive) {
// Get all descendant folder paths
const descendants = window.app.groupingFolders
.filter(f => f.path.startsWith(path + '/'))
.map(f => f.path);
if (isCurrentlyCollapsed) {
// Expand this folder and all descendants
window.app.collapsedGroupingFolders.delete(path);
descendants.forEach(p => window.app.collapsedGroupingFolders.delete(p));
} else {
// Collapse this folder and all descendants
window.app.collapsedGroupingFolders.add(path);
descendants.forEach(p => window.app.collapsedGroupingFolders.add(p));
}
} else {
// Just toggle this folder
if (isCurrentlyCollapsed) {
window.app.collapsedGroupingFolders.delete(path);
} else {
window.app.collapsedGroupingFolders.add(path);
}
}
window.app.modules.app.renderGroupingFolders();
}
// Select all visible folders
function selectAllVisibleFolders(folderType) {
const container = folderType === 'grouping' ?
document.getElementById('groupingFoldersList') :
document.getElementById('transmittalFoldersList');
const selectedSet = folderType === 'grouping' ?
window.app.selectedGroupingFolders :
window.app.selectedTransmittalFolders;
selectedSet.clear();
const items = container.querySelectorAll('.folder-item');
items.forEach(item => {
const path = item.getAttribute('data-path');
if (path) {
selectedSet.add(path);
}
});
if (folderType === 'grouping') {
// Update UI to reflect grouping changes
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
} else {
// For transmittal folders, just update selection state
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
window.app.modules.filtering.applyFilters();
}
}
// Setup date group toggle handlers
function setupDateGroupToggles() {
// Toggle all dates button
const toggleAllBtn = document.getElementById('toggleAllDatesBtn');
if (toggleAllBtn) {
toggleAllBtn.addEventListener('click', toggleAllDateGroups);
}
// Individual date group headers (using event delegation)
const transmittalList = document.getElementById('transmittalFoldersList');
transmittalList.addEventListener('click', (e) => {
const header = e.target.closest('.date-group-header');
if (header) {
const date = header.getAttribute('data-date');
if (date) {
toggleDateGroup(date);
}
}
});
}
// Toggle a single date group
function toggleDateGroup(date) {
if (window.app.collapsedDateGroups.has(date)) {
window.app.collapsedDateGroups.delete(date);
} else {
window.app.collapsedDateGroups.add(date);
}
window.app.modules.app.renderTransmittalFolders();
updateToggleAllIcon();
}
// Toggle all date groups
function toggleAllDateGroups() {
const headers = document.querySelectorAll('.date-group-header');
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
// If all are collapsed, expand all. Otherwise, collapse all.
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
if (allCollapsed) {
// Expand all
window.app.collapsedDateGroups.clear();
} else {
// Collapse all
allDates.forEach(date => window.app.collapsedDateGroups.add(date));
}
window.app.modules.app.renderTransmittalFolders();
updateToggleAllIcon();
}
// Update the toggle all icon based on current state
function updateToggleAllIcon() {
const icon = document.getElementById('toggleAllDatesIcon');
if (!icon) return;
const headers = document.querySelectorAll('.date-group-header');
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
icon.textContent = allCollapsed ? '▶' : '▼';
}
// Setup grouping section collapse toggle
function setupGroupingToggle() {
const toggleBtn = document.getElementById('toggleGroupingBtn');
const groupingSection = document.getElementById('groupingSection');
const icon = document.getElementById('toggleGroupingIcon');
if (toggleBtn && groupingSection && icon) {
toggleBtn.addEventListener('click', () => {
groupingSection.classList.toggle('collapsed');
icon.textContent = groupingSection.classList.contains('collapsed') ? '▶' : '▼';
});
}
}
// Setup resizable panes
function setupResizablePanes() {
// Resize nav sections (vertical divider between grouping and transmittal)
const navSectionsHandle = document.querySelector('[data-resize="nav-sections"]');
if (navSectionsHandle) {
let isResizing = false;
let startY = 0;
let startHeight = 0;
let groupingSection = null;
navSectionsHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startY = e.clientY;
groupingSection = document.getElementById('groupingSection');
startHeight = groupingSection.offsetHeight;
navSectionsHandle.classList.add('resizing');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaY = e.clientY - startY;
const newHeight = startHeight + deltaY;
// Set min/max heights
if (newHeight >= 100 && newHeight <= window.innerHeight - 250) {
groupingSection.style.flex = 'none';
groupingSection.style.height = newHeight + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
navSectionsHandle.classList.remove('resizing');
}
});
}
// Resize nav pane (horizontal divider between nav and content)
const navPaneHandle = document.querySelector('[data-resize="nav-pane"]');
if (navPaneHandle) {
let isResizing = false;
let startX = 0;
let startWidth = 0;
let navPane = null;
navPaneHandle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
navPane = document.getElementById('navigationPane');
startWidth = navPane.offsetWidth;
navPaneHandle.classList.add('resizing');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const newWidth = startWidth + deltaX;
// Set min/max widths
if (newWidth >= 200 && newWidth <= window.innerWidth - 400) {
navPane.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
navPaneHandle.classList.remove('resizing');
}
});
}
}
window.app.modules.events = {
setupEventListeners,
handleFolderClick,
toggleGroupingFolder,
selectAllVisibleFolders,
setupDateGroupToggles,
toggleDateGroup,
toggleAllDateGroups,
updateToggleAllIcon,
setupGroupingToggle,
setupResizablePanes
};
})();

262
archive/js/export.js Normal file
View file

@ -0,0 +1,262 @@
(function() {
'use strict';
// Export functionality
// Escape a single value for RFC-4180 CSV
function csvCell(value) {
const str = String(value == null ? '' : value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
// Convert an array of row arrays to a CSV string
function rowsToCSV(rows) {
return rows.map(row => row.map(csvCell).join(',')).join('\n');
}
// Export selected files to CSV
function exportCSV() {
if (window.app.selectedFiles.size === 0) {
alert('No files selected for export.');
return;
}
const headers = ['Tracking Number', 'Title', 'Revision', 'Status', 'Extension', 'Size', 'Size (bytes)', 'Path', 'Modified'];
const rows = [headers];
// Add data rows for selected files only
window.app.files.forEach(file => {
if (!window.app.selectedFiles.has(file.id)) return;
rows.push([
file.trackingNumber || '',
file.title || '',
file.revision || '',
file.status || '',
file.extension || '',
formatFileSize(file.size),
file.size != null ? file.size : '',
file.path,
file.modified ? new Date(file.modified).toLocaleString() : '—'
]);
});
downloadFile(rowsToCSV(rows), 'archive-export.csv', 'text/csv');
}
// Download selected files as ZIP
async function downloadSelected() {
if (window.app.selectedFiles.size === 0) {
alert('No files selected for download.');
return;
}
// JSZip is vendored (concat'd by build.sh), so window.JSZip is
// already defined. Defensive check in case a future refactor
// reorders things.
if (typeof JSZip === 'undefined') {
alert('JSZip library not bundled — rebuild archive with shared/vendor/jszip.min.js');
return;
}
const zip = new JSZip();
const selectedFiles = [];
// Get selected file objects
window.app.files.forEach(file => {
if (window.app.selectedFiles.has(file.id)) {
selectedFiles.push(file);
}
});
// Show progress
showProgress('Preparing ZIP file...', 0, selectedFiles.length);
try {
// Add files to ZIP
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
showProgress(`Adding ${file.name}...`, i + 1, selectedFiles.length);
try {
let arrayBuffer;
if (file.handle) {
// Local mode: read via File System Access API
const fileData = await file.handle.getFile();
arrayBuffer = await fileData.arrayBuffer();
} else if (file.url) {
// HTTP mode: fetch from server
const resp = await fetch(file.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
arrayBuffer = await resp.arrayBuffer();
} else {
throw new Error('No file handle or URL available');
}
// Create folder structure in ZIP
const relativePath = file.path.substring(file.path.indexOf('/') + 1); // Remove root directory
zip.file(relativePath, arrayBuffer);
} catch (err) {
console.error(`Error adding file ${file.name}:`, err);
}
}
showProgress('Generating ZIP...', selectedFiles.length, selectedFiles.length);
// Generate ZIP
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
// Download
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
downloadBlob(blob, `archive-${timestamp}.zip`);
hideProgress();
} catch (err) {
hideProgress();
console.error('Error creating ZIP:', err);
alert('Error creating ZIP file: ' + err.message);
}
}
// Show progress indicator
function showProgress(message, current, total) {
let progressDiv = document.getElementById('progressIndicator');
if (!progressDiv) {
progressDiv = document.createElement('div');
progressDiv.id = 'progressIndicator';
progressDiv.className = 'progress-indicator';
document.body.appendChild(progressDiv);
}
const percentage = Math.round((current / total) * 100);
progressDiv.innerHTML =
'<div class="progress-indicator__message">' + window.app.modules.app.escapeHtml(message) + '</div>' +
'<div class="progress-indicator__track">' +
'<div class="progress-indicator__fill" style="width:' + percentage + '%"></div>' +
'</div>' +
'<div class="progress-indicator__label">' + current + ' / ' + total + '</div>';
}
// Hide progress indicator
function hideProgress() {
const progressDiv = document.getElementById('progressIndicator');
if (progressDiv) {
progressDiv.remove();
}
}
// Download file utility
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
}
// Download blob utility
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Export to HTML report
function exportHTMLReport() {
// Group files by tracking number
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Archive Report - ${new Date().toLocaleDateString()}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; font-weight: bold; }
tr:nth-child(even) { background-color: #f9f9f9; }
.revision { font-family: monospace; }
.status { color: #666; font-size: 0.9em; }
@media print {
body { margin: 0; }
h1 { font-size: 18pt; }
table { font-size: 10pt; }
}
</style>
</head>
<body>
<h1>Archive Report</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
<p>Total Files: ${window.app.filteredFiles.length}</p>
<table>
<thead>
<tr>
<th>Tracking Number</th>
<th>Title</th>
<th>Revisions</th>
</tr>
</thead>
<tbody>
${sorted.map(group => `
<tr>
<td>${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
<td>${window.app.modules.app.escapeHtml(group.title)}</td>
<td>
${group.sortedRevisions.map(rev => `
<div>
<span class="revision">${window.app.modules.app.escapeHtml(rev.revision)}</span>
<span class="status">(${window.app.modules.app.escapeHtml(rev.status)})</span>
${rev.files.map(f => f.extension.toUpperCase()).join(', ')}
</div>
`).join('')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</body>
</html>`;
downloadFile(html, 'archive-report.html', 'text/html');
}
window.app.modules.export = {
csvCell,
rowsToCSV,
exportCSV,
downloadSelected,
showProgress,
hideProgress,
downloadFile,
downloadBlob,
formatFileSize,
exportHTMLReport
};
})();

149
archive/js/filtering.js Normal file
View file

@ -0,0 +1,149 @@
// Filtering functionality
// Apply all filters
function applyFilters() {
// Start with files from selected transmittal folders AND selected grouping folders
let filtered = window.app.files.filter(file => {
// Must have at least one grouping folder selected (if grouping folders exist)
if (window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size === 0) {
return false;
}
// Must have at least one transmittal folder selected
if (window.app.selectedTransmittalFolders.size === 0) {
return false;
}
// Multi-project visibility filter — files under unchecked projects are
// hidden from view (without re-scanning).
if (!window.app.modules.app.pathIsInVisibleProject(file.path)) {
return false;
}
// File must be in a selected transmittal folder
if (!window.app.selectedTransmittalFolders.has(file.folderPath)) {
return false;
}
// Outstanding files: actualPath must be under a selected grouping folder that is
// itself visible (not hidden by folder type toggles).
if (file.folderPath === '__outstanding__') {
if (!window.app.modules.app.outstandingFileIsVisible(file)) return false;
}
// If grouping folders exist and are selected, the file's transmittal folder
// path must contain a path segment matching one of the selected party names.
// Outstanding files are exempt — their grouping scope is enforced by the
// actualPath check above.
if (file.folderPath !== '__outstanding__' && window.app.groupingFolders.length > 0 && window.app.selectedGroupingFolders.size > 0) {
const inSelectedGrouping = file.folderPath.split('/').some(seg =>
window.app.selectedGroupingFolders.has(seg)
);
if (!inSelectedGrouping) {
return false;
}
}
return true;
});
// Apply column filters
filtered = applyColumnFilters(filtered);
// Apply modifier filter
if (window.app.selectedModifiers.size < window.app.availableModifiers.size) {
filtered = filtered.filter(file => window.app.modules.app.filePassesModifierFilter(file));
}
updateResetFiltersBtn();
// Apply selected-only filter
if (window.app.showSelectedOnly) {
filtered = filtered.filter(file => window.app.selectedFiles.has(file.id));
}
window.app.filteredFiles = filtered;
window.app.modules.table.updateFileTable();
window.app.modules.app.updateStatusBar();
window.app.modules.table.updateSelectAllVisibleCheckbox();
}
// Apply column filters using stored ASTs
function applyColumnFilters(files) {
const asts = window.app.columnFilterASTs;
if (asts.trackingNumber && asts.trackingNumber.length > 0) {
files = files.filter(file =>
zddc.filter.matches(file.trackingNumber || '', asts.trackingNumber)
);
}
if (asts.title && asts.title.length > 0) {
files = files.filter(file =>
zddc.filter.matches(file.title || '', asts.title)
);
}
if (asts.revisions && asts.revisions.length > 0) {
files = files.filter(file => {
const revisionText = [
file.revision,
file.status,
file.extension
].join(' ');
return zddc.filter.matches(revisionText, asts.revisions);
});
}
return files;
}
// Clear all filters
function clearFilters() {
window.app.columnFilters = {
trackingNumber: '',
title: '',
revisions: ''
};
window.app.columnFilterASTs = {
trackingNumber: null,
title: null,
revisions: null
};
window.app.groupingFilter = '';
window.app.transmittalFilter = '';
// Clear UI inputs
const groupingFilterEl = document.getElementById('groupingFilter');
groupingFilterEl.value = '';
groupingFilterEl.classList.remove('filter-active');
const transmittalFilterEl = document.getElementById('transmittalFilter');
transmittalFilterEl.value = '';
transmittalFilterEl.classList.remove('filter-active');
document.querySelectorAll('.column-filter').forEach(input => {
input.value = '';
input.classList.remove('filter-active');
});
window.app.modules.app.toggleAllModifiers(true);
updateResetFiltersBtn();
applyFilters();
window.app.modules.urlState.push();
}
// Update reset filters button visibility
function updateResetFiltersBtn() {
// Button is always visible — no show/hide logic needed
}
// Register filtering module
window.app.modules.filtering = {
applyFilters,
applyColumnFilters,
clearFilters,
updateResetFiltersBtn
};

194
archive/js/hash.js Normal file
View file

@ -0,0 +1,194 @@
(function() {
'use strict';
// SHA-256 hashing and cache management
const HASH_CACHE_FILENAME = '.hashes.json';
// Calculate SHA-256 hash for a file (delegates to shared/hash.js)
async function calculateFileHash(file) {
return zddc.crypto.sha256File(file);
}
// Load hash cache for a directory
async function loadHashCache(dirHandle) {
try {
const cacheHandle = await dirHandle.getFileHandle(HASH_CACHE_FILENAME);
const cacheFile = await cacheHandle.getFile();
const cacheText = await cacheFile.text();
return JSON.parse(cacheText);
} catch (err) {
// Cache doesn't exist or can't be read
return {};
}
}
// Save hash cache for a directory
async function saveHashCache(dirHandle, cache) {
try {
const cacheHandle = await dirHandle.getFileHandle(HASH_CACHE_FILENAME, { create: true });
const writable = await cacheHandle.createWritable();
await writable.write(JSON.stringify(cache, null, 2));
await writable.close();
return true;
} catch (err) {
console.warn('Unable to save hash cache:', err);
return false;
}
}
// Hash files in a directory with caching
async function hashDirectoryFiles(dirHandle, files) {
const cache = await loadHashCache(dirHandle);
const updatedCache = {};
const results = {};
for (const fileInfo of files) {
try {
const file = await fileInfo.handle.getFile();
const cacheKey = file.name;
const lastModified = file.lastModified;
// Check if we have a cached hash
if (cache[cacheKey] && cache[cacheKey].lastModified === lastModified) {
// Use cached hash
results[fileInfo.id] = cache[cacheKey].hash;
updatedCache[cacheKey] = cache[cacheKey];
} else {
// Calculate new hash
const hash = await calculateFileHash(file);
results[fileInfo.id] = hash;
updatedCache[cacheKey] = {
hash: hash,
lastModified: lastModified,
size: file.size
};
}
} catch (err) {
console.error(`Error hashing file ${fileInfo.name}:`, err);
}
}
// Try to save updated cache
await saveHashCache(dirHandle, updatedCache);
return results;
}
// Add hash information to files
async function addHashesToFiles() {
if (!window.app.files.length) return;
// Hash operations require local file handles — not available in HTTP mode
if (window.app.sourceMode === 'http') {
console.log('Hash operations not available in HTTP mode.');
return;
}
window.app.modules.export.showProgress('Calculating file hashes...', 0, window.app.files.length);
try {
// Group files by directory
const filesByDir = {};
window.app.files.forEach(file => {
const dirPath = file.folderPath;
if (!filesByDir[dirPath]) {
filesByDir[dirPath] = {
handle: null,
files: []
};
}
filesByDir[dirPath].files.push(file);
});
// Find directory handles
for (const dirPath in filesByDir) {
const folder = window.app.transmittalFolders.find(f => f.path === dirPath);
if (folder) {
filesByDir[dirPath].handle = folder.handle;
}
}
// Hash files in each directory
let processed = 0;
for (const dirPath in filesByDir) {
const dirInfo = filesByDir[dirPath];
if (dirInfo.handle) {
const hashes = await hashDirectoryFiles(dirInfo.handle, dirInfo.files);
// Update file objects with hashes
dirInfo.files.forEach(file => {
if (hashes[file.id]) {
file.hash = hashes[file.id];
}
});
}
processed += dirInfo.files.length;
window.app.modules.export.showProgress('Calculating file hashes...', processed, window.app.files.length);
}
window.app.modules.export.hideProgress();
} catch (err) {
window.app.modules.export.hideProgress();
console.error('Error calculating hashes:', err);
}
}
// Verify file integrity
async function verifyFileIntegrity(fileId) {
const file = window.app.files.find(f => f.id === fileId);
if (!file || !file.hash) {
alert('No hash available for this file.');
return;
}
if (!file.handle) {
alert('File integrity verification is not available in HTTP mode.');
return;
}
try {
const fileData = await file.handle.getFile();
const currentHash = await calculateFileHash(fileData);
if (currentHash === file.hash) {
alert('File integrity verified. Hash matches.');
} else {
alert('WARNING: File has been modified! Hash does not match.');
}
} catch (err) {
alert('Error verifying file: ' + err.message);
}
}
// Export hash report
function exportHashReport() {
const headers = ['File Path', 'SHA-256 Hash', 'Size', 'Last Modified'];
const rows = [headers];
window.app.filteredFiles.forEach(file => {
if (file.hash) {
rows.push([
file.path,
file.hash,
window.app.modules.export.formatFileSize(file.size),
new Date(file.modified).toISOString()
]);
}
});
window.app.modules.export.downloadFile(window.app.modules.export.rowsToCSV(rows), 'file-hashes.csv', 'text/csv');
}
window.app.modules.hash = {
calculateFileHash,
loadHashCache,
saveHashCache,
hashDirectoryFiles,
addHashesToFiles,
verifyFileIntegrity,
exportHashReport
};
})();

45
archive/js/init.js Normal file
View file

@ -0,0 +1,45 @@
/**
* ZDDC Archive - Initialization
* Sets up window.app and window.app.modules before other modules run.
* Must be the first JS file in the build.
*/
(function() {
'use strict';
window.app = {
sourceMode: null,
directories: [],
groupingFolders: [],
transmittalFolders: [],
files: [],
filteredFiles: [],
selectedFiles: new Set(),
isScanning: false,
scanProgress: '',
columnFilters: { trackingNumber: '', title: '', revisions: '' },
columnFilterASTs: { trackingNumber: null, title: null, revisions: null },
groupingFilter: '',
transmittalFilter: '',
enabledFolderTypes: new Set(['issued', 'received']),
sortField: 'trackingNumber',
sortDirection: 'asc',
selectedGroupingFolders: new Set(),
selectedTransmittalFolders: new Set(),
collapsedDateGroups: new Set(),
collapsedGroupingFolders: new Set(),
selectAllGroupingFolders: true,
selectAllTransmittals: true,
availableModifiers: new Set(),
selectedModifiers: new Set(),
showSelectedOnly: false,
projectFilter: new Set(), // URL-derived; controls what gets SCANNED
visibleProjects: new Set(), // dropdown-derived; controls VISIBILITY of already-scanned data
availableProjects: [], // populated by autoConnectHttpSource from server's ProjectInfo[]
isMultiProject: false, // true when ?projects= is set OR server returns ProjectInfo
FOLDER_TYPE_NAMES: ['issued', 'received', 'mdl', 'incoming'],
ARCHIVE_STAGE_NAME: 'archive',
modules: {}
};
})();

75
archive/js/parser.js Normal file
View file

@ -0,0 +1,75 @@
// Archive grouping/sorting helpers — ZDDC parsing comes from window.zddc directly.
(function() {
'use strict';
function isTransmittalFolder(name) {
var parsed = zddc.parseFolder(name);
return !!(parsed && parsed.valid);
}
// A .zip whose name (minus the .zip extension) parses as a
// transmittal-folder name is treated as that transmittal folder —
// its members are scanned the same as an uncompressed folder's
// files. (A plain `archive.zip` etc. is just a file.)
function isTransmittalFolderZip(name) {
var parts = zddc.splitExtension(name);
return parts.extension === 'zip' && isTransmittalFolder(parts.name);
}
function groupFilesByTrackingNumber(files) {
const groups = {};
files.forEach(file => {
if (!file.trackingNumber) return;
if (!groups[file.trackingNumber]) {
groups[file.trackingNumber] = { trackingNumber: file.trackingNumber, title: file.title, revisions: {} };
}
if (file.title.length > groups[file.trackingNumber].title.length) {
groups[file.trackingNumber].title = file.title;
}
const revKey = `${file.revision}_${file.status}`;
if (!groups[file.trackingNumber].revisions[revKey]) {
groups[file.trackingNumber].revisions[revKey] = {
revision: file.revision, status: file.status, title: file.title,
hasModifier: file.revision.includes('+'), files: []
};
}
if (file.title.length > groups[file.trackingNumber].revisions[revKey].title.length) {
groups[file.trackingNumber].revisions[revKey].title = file.title;
}
groups[file.trackingNumber].revisions[revKey].files.push(file);
});
return groups;
}
function sortGroupedFiles(groups) {
const field = window.app.sortField || 'trackingNumber';
const direction = window.app.sortDirection === 'desc' ? -1 : 1;
const sorted = Object.values(groups).sort((a, b) => {
let comparison = 0;
if (field === 'trackingNumber') comparison = a.trackingNumber.localeCompare(b.trackingNumber);
else if (field === 'title') comparison = a.title.localeCompare(b.title);
else if (field === 'revisions') {
const aRevs = Object.keys(a.revisions), bRevs = Object.keys(b.revisions);
comparison = zddc.compareRevisions(
aRevs.length > 0 ? aRevs[aRevs.length - 1] : '',
bRevs.length > 0 ? bRevs[bRevs.length - 1] : ''
);
}
return comparison * direction;
});
sorted.forEach(group => {
const revisions = Object.values(group.revisions);
revisions.sort((a, b) => zddc.compareRevisions(b.revision, a.revision));
group.sortedRevisions = revisions;
});
return sorted;
}
window.app.modules.parser = {
isTransmittalFolder,
isTransmittalFolderZip,
groupFilesByTrackingNumber,
sortGroupedFiles,
};
})();

158
archive/js/presets.js Normal file
View file

@ -0,0 +1,158 @@
(function() {
'use strict';
// Project-picker dropdown for the archive browser.
//
// In multi-project mode (HTTP source against zddc-server, OR ?projects=
// present in the URL), this dropdown lets the user toggle which projects
// are scanned. Toggling a checkbox updates window.app.projectFilter, pushes
// the new ?projects= state to the URL, and triggers a re-scan.
//
// In single-project mode the dropdown is hidden — only one project is ever
// in scope, so picking is meaningless.
let isOpen = false;
// The set of project names currently shown in the dropdown.
function getKnownProjects() {
if (window.app.availableProjects && window.app.availableProjects.length > 0) {
return window.app.availableProjects.slice();
}
// Fall back to whatever is in the URL filter — useful when the server's
// ProjectInfo endpoint isn't reachable but ?projects= names the set.
return Array.from(window.app.projectFilter || []);
}
// Visibility-only filter: change visibleProjects, push URL state, re-render
// UI. No rescan — already-scanned data stays in memory. URL is updated via
// history.replaceState (same mechanism as every other UI control).
function applyVisibility(names) {
window.app.visibleProjects = new Set(names);
window.app.modules.urlState.push();
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
renderDropdown();
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (!dropdown) return;
var selected = new Set(window.app.visibleProjects || []);
var known = getKnownProjects().slice().sort();
// Show the human-friendly title from each project's .zddc
// when present (captured during auto-detect into
// window.app.projectTitles), falling back to the folder name.
// The data-name attribute always carries the canonical folder
// name so URL state stays stable regardless of label.
var titles = window.app.projectTitles || {};
var projectsHtml = known.map(name => {
var checked = selected.has(name) ? ' checked' : '';
var label = titles[name] || name;
var nAttr = escapeHtml(name);
var nLabel = escapeHtml(label);
var hint = (label !== name)
? ' <span class="preset-project-folder">(' + escapeHtml(name) + ')</span>'
: '';
return '<div class="preset-project-item">'
+ '<label class="preset-project-label">'
+ '<input type="checkbox" class="preset-checkbox" data-name="' + nAttr + '"' + checked + '>'
+ ' ' + nLabel + hint
+ '</label>'
+ '</div>';
}).join('');
if (!projectsHtml) {
projectsHtml = '<div class="preset-no-presets"><i>No projects available</i></div>';
}
dropdown.innerHTML =
'<div class="preset-section-bottom">'
+ '<div class="preset-section-label">Projects:</div>'
+ '<div class="preset-projects-list">' + projectsHtml + '</div>'
+ '</div>';
}
function toggleDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (isOpen) { closeDropdown(); return; }
isOpen = true;
if (dropdown) dropdown.classList.remove('hidden');
renderDropdown();
}
function closeDropdown() {
isOpen = false;
var dropdown = document.getElementById('presetDropdown');
if (dropdown) dropdown.classList.add('hidden');
}
function setupDropdownDelegation() {
var dropdown = document.getElementById('presetDropdown');
if (!dropdown) return;
dropdown.addEventListener('click', function(e) {
e.stopPropagation();
var checkbox = e.target.closest('.preset-checkbox');
if (!checkbox) return;
var projectName = checkbox.getAttribute('data-name');
if (!projectName) return;
var sel = new Set(window.app.visibleProjects || []);
if (checkbox.checked) sel.add(projectName);
else sel.delete(projectName);
applyVisibility(Array.from(sel));
});
}
function setupOutsideClickHandler() {
document.addEventListener('click', function(e) {
var section = document.getElementById('presetSection');
var dropdown = document.getElementById('presetDropdown');
if (isOpen && section && dropdown && !section.contains(e.target)) {
closeDropdown();
}
});
}
function init() {
var section = document.getElementById('presetSection');
if (!section) return;
// Hide the dropdown entirely outside multi-project mode.
if (!window.app.isMultiProject) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
var btn = document.getElementById('presetBtn');
if (!btn || btn.dataset.presetInit) return;
btn.dataset.presetInit = '1';
btn.title = 'Project picker';
btn.textContent = '▾ Projects';
btn.addEventListener('click', function(e) {
e.stopPropagation();
toggleDropdown();
});
setupDropdownDelegation();
setupOutsideClickHandler();
}
window.app.modules.presets = {
init: init,
toggleDropdown: toggleDropdown,
closeDropdown: closeDropdown,
// No-op kept so existing callers (events.js after grouping-folder click)
// don't need to null-check; preset dirty state was removed with the
// saved-presets feature.
checkDirty: function() {}
};
})();

603
archive/js/source.js Normal file
View file

@ -0,0 +1,603 @@
(function() {
'use strict';
// Source abstraction — local (File System Access API) and HTTP (Caddy JSON browse)
//
// Three scan modes, decided once at the entry point:
// 1. Multi-project — ?projects=A,B URL param non-empty. The scan root holds project
// folders; for each in the filter, descend into its `archive/` subfolder (case-
// insensitive) and scan from there.
// 2. Project-root — scan root has an `archive/` child (case-insensitive). Descend
// into it and scan from there. Other stage folders (reviewing/staging/mdl/working)
// are not entered.
// 3. In-archive (default) — scan root's children are third-party (grouping) folders.
// Today's behavior, unchanged.
//
// The recursion below the entry point never re-applies the mode check: once we are
// inside the archive folder for a given project, descent is uniform across modes.
//
// Listing skip: at any depth, a directory child whose lowercased name is a member of
// FOLDER_TYPE_NAMES (issued/received/mdl/incoming) AND not currently in
// enabledFolderTypes is skipped entirely — we do not even fetch its listing. Toggling
// a type back on triggers a refresh in app.js.
// Shared utility used by both source implementations
function getDisplayPath(fullPath) {
if (fullPath.length <= 100) {
return fullPath;
}
const parts = fullPath.split('/');
if (parts.length > 3) {
return parts[0] + '/.../' + parts.slice(-2).join('/');
}
return '...' + fullPath.substring(fullPath.length - 80);
}
// True if a directory child should be skipped entirely (don't fetch its listing).
function isHiddenFolderTypeName(rawName) {
const lower = rawName.toLowerCase();
return window.app.FOLDER_TYPE_NAMES.includes(lower)
&& !window.app.enabledFolderTypes.has(lower);
}
// createSource(type, options) returns a source object:
// source.type — 'local' | 'http'
// source.canWrite — boolean
// source.scan(rootIdentifier, callbacks) — Promise; walks tree calling:
// callbacks.onGroupingFolder(folder) folder: { name, path, displayPath, handle? }
// callbacks.onTransmittalFolder(folder) folder: { name, path, displayPath, handle?, url? }
// callbacks.onFile(file) file: full file object (parsed + metadata)
// callbacks.onProgress(message)
// source.fetchFile(fileRef) — Promise<ArrayBuffer>
function createSource(type, options) {
if (type === 'local') {
return createLocalSource();
} else if (type === 'http') {
return createHttpSource(options.baseUrl);
}
throw new Error('Unknown source type: ' + type);
}
// ---------------------------------------------------------------------------
// Local source — wraps File System Access API
// ---------------------------------------------------------------------------
function createLocalSource() {
return {
type: 'local',
canWrite: true,
scan: function(dirHandle, callbacks) {
return scanLocalRoot(dirHandle, dirHandle.name, callbacks);
},
fetchFile: function(fileRef) {
return fileRef.handle.getFile().then(function(f) {
return f.arrayBuffer();
});
}
};
}
async function listLocalEntries(dirHandle, currentPath) {
const entries = [];
try {
for await (const entry of dirHandle.values()) {
entries.push(entry);
}
} catch (err) {
console.warn('Could not read directory ' + currentPath + ':', err);
return null;
}
return entries;
}
function findArchiveInEntries(entries) {
const stage = window.app.ARCHIVE_STAGE_NAME;
for (const entry of entries) {
if (entry.kind === 'directory' && entry.name.toLowerCase() === stage) {
return entry;
}
}
return null;
}
async function scanLocalRoot(dirHandle, rootPath, callbacks) {
callbacks.onProgress('Scanning ' + rootPath + '...');
const entries = await listLocalEntries(dirHandle, rootPath);
if (!entries) return;
// Mode 1 — multi-project (?projects= set)
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
for (const entry of entries) {
if (entry.kind !== 'directory') continue;
if (!window.app.projectFilter.has(entry.name)) continue;
const projPath = rootPath + '/' + entry.name;
const projEntries = await listLocalEntries(entry, projPath);
if (!projEntries) continue;
const archive = findArchiveInEntries(projEntries);
if (!archive) continue;
const archivePath = projPath + '/' + archive.name;
await scanLocalRecursive(archive, archivePath, 0, callbacks);
}
return;
}
// Mode 2 — project-root (scan root has an archive/ child)
const archive = findArchiveInEntries(entries);
if (archive) {
const archivePath = rootPath + '/' + archive.name;
await scanLocalRecursive(archive, archivePath, 0, callbacks);
return;
}
// Mode 3 — in-archive (default)
await processLocalEntries(entries, rootPath, 0, callbacks);
}
async function scanLocalRecursive(dirHandle, currentPath, depth, callbacks) {
if (currentPath.length > 200) {
console.warn('Path too long, skipping deeper scan: ' + currentPath);
return;
}
if (depth > 10) {
console.warn('Directory depth limit reached at: ' + currentPath);
return;
}
callbacks.onProgress('Scanning ' + currentPath + '...');
const entries = await listLocalEntries(dirHandle, currentPath);
if (!entries) return;
await processLocalEntries(entries, currentPath, depth, callbacks);
}
async function processLocalEntries(entries, currentPath, depth, callbacks) {
for (const entry of entries) {
if (entry.kind === 'directory') {
if (isHiddenFolderTypeName(entry.name)) continue;
const subPath = currentPath + '/' + entry.name;
try {
if (window.app.modules.parser.isTransmittalFolder(entry.name)) {
const folder = {
name: entry.name,
path: subPath,
displayPath: getDisplayPath(subPath),
handle: entry
};
callbacks.onTransmittalFolder(folder);
await scanLocalTransmittalFolder(entry, subPath, 0, subPath, callbacks);
} else {
const folder = {
name: entry.name,
path: subPath,
displayPath: entry.name,
handle: entry
};
callbacks.onGroupingFolder(folder);
await scanLocalRecursive(entry, subPath, depth + 1, callbacks);
}
} catch (err) {
console.warn('Could not process directory ' + entry.name + ':', err);
}
} else if (entry.kind === 'file') {
// A zipped transmittal folder (e.g.
// "2025-05-12_DOC-001 (IFI) - Title.zip") is treated as
// that transmittal folder: open the zip in the browser
// and scan its members like an uncompressed folder's
// files. The .zip stays in the recorded path so it's
// unambiguous; the displayed name drops it.
if (window.app.modules.parser.isTransmittalFolderZip(entry.name)) {
const base = zddc.splitExtension(entry.name).name;
const zipPath = currentPath + '/' + entry.name;
try {
const zh = await window.zddc.zip.fromFileHandle(entry);
callbacks.onTransmittalFolder({
name: base,
path: zipPath,
displayPath: getDisplayPath(zipPath),
handle: zh
});
await scanLocalTransmittalFolder(zh, zipPath, 0, zipPath, callbacks);
} catch (zipErr) {
console.warn('Could not open zip transmittal ' + entry.name + ':', zipErr);
}
continue;
}
// File directly in a grouping folder — assign to the Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering.
try {
const file = await entry.getFile();
const parsed = zddc.parseFilename(file.name) || {};
const fullPath = currentPath + '/' + file.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
callbacks.onFile({
id: crypto.randomUUID(),
name: file.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: file.size,
modified: file.lastModified,
handle: entry,
folderPath: '__outstanding__',
actualPath: currentPath,
hasPathError: false,
...parsed
});
} catch (fileErr) {
const fullPath = currentPath + '/' + entry.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
const parsed = zddc.parseFilename(entry.name) || {};
callbacks.onFile({
id: crypto.randomUUID(),
name: entry.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: null,
modified: null,
handle: null,
folderPath: '__outstanding__',
actualPath: currentPath,
hasPathError: true,
pathErrorMessage: fileErr.message || 'File access error',
...parsed
});
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
}
}
}
}
async function scanLocalTransmittalFolder(dirHandle, folderPath, depth, transmittalPath, callbacks) {
if (depth > 10) {
console.warn('Directory depth limit reached in transmittal folder: ' + folderPath);
return;
}
try {
if (folderPath.length > 240) {
console.warn('Path approaching Windows limit (' + folderPath.length + ' chars): ' + folderPath);
}
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file') {
try {
const file = await entry.getFile();
const parsed = zddc.parseFilename(file.name) || {};
const fullPath = folderPath + '/' + file.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
callbacks.onFile({
id: crypto.randomUUID(),
name: file.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: file.size,
modified: file.lastModified,
handle: entry,
folderPath: transmittalPath,
actualPath: folderPath,
hasPathError: false,
...parsed
});
} catch (fileErr) {
const fullPath = folderPath + '/' + entry.name;
const displayPath = fullPath.length > 250
? '...' + fullPath.substring(fullPath.length - 200)
: fullPath;
const parsed = zddc.parseFilename(entry.name) || {};
callbacks.onFile({
id: crypto.randomUUID(),
name: entry.name,
path: fullPath,
displayPath: displayPath,
url: null,
size: null,
modified: null,
handle: null,
folderPath: transmittalPath,
actualPath: folderPath,
hasPathError: true,
pathErrorMessage: fileErr.message || 'File access error',
...parsed
});
console.warn('Could not access file ' + entry.name + ' (path error):', fileErr.message);
}
} else if (entry.kind === 'directory') {
const subPath = folderPath + '/' + entry.name;
try {
await scanLocalTransmittalFolder(entry, subPath, depth + 1, transmittalPath, callbacks);
} catch (err) {
console.warn('Could not scan subdirectory ' + entry.name + ' in ' + folderPath + ':', err);
}
}
}
} catch (err) {
console.error('Error scanning folder ' + folderPath + ':', err);
}
}
// ---------------------------------------------------------------------------
// HTTP source — uses Caddy JSON browse (Accept: application/json)
// ---------------------------------------------------------------------------
function createHttpSource(baseUrl) {
// Normalise: ensure baseUrl ends with /
const root = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
return {
type: 'http',
canWrite: false,
scan: function(rootUrl, callbacks) {
const scanRoot = (rootUrl && rootUrl !== root) ? rootUrl : root;
return scanHttpRoot(scanRoot, root, callbacks);
},
fetchFile: function(fileRef) {
return fetch(fileRef.url).then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status + ' fetching ' + fileRef.url);
return r.arrayBuffer();
});
}
};
}
async function fetchHttpListing(dirUrl) {
try {
const resp = await fetch(dirUrl, {
headers: { 'Accept': 'application/json' }
});
if (!resp.ok) {
// 403/404 on a sub-path is expected when ACLs deny access or a
// listed dir doesn't exist — log at info level to avoid alarming
// users in the console.
console.info('skip ' + dirUrl + ' (' + resp.status + ')');
return null;
}
const items = await resp.json();
if (!Array.isArray(items)) {
// Server returned 200 but the body wasn't a JSON array — most
// commonly Caddy serving an HTML error page or an index.html
// when file_browse isn't enabled at that path. Silent skip.
return null;
}
return items;
} catch (err) {
// JSON parse failures, network errors, etc. — single concise line.
console.info('skip ' + dirUrl + ': ' + (err.message || err));
return null;
}
}
function rawNameOf(item) {
return item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
}
function findArchiveInItems(items) {
const stage = window.app.ARCHIVE_STAGE_NAME;
for (const item of items) {
if (!item.is_dir) continue;
const name = rawNameOf(item);
if (name.toLowerCase() === stage) return { item: item, name: name };
}
return null;
}
async function scanHttpRoot(scanRootUrl, rootUrl, callbacks) {
// Mode 1 — multi-project (?projects= set). Skip listing scanRootUrl
// entirely: project URLs are deterministic, so go straight to each one
// (the names in projectFilter, slash-normalised in app.js against the
// server's root listing). Avoids depending on the root listing's shape.
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
const tasks = [];
for (const name of window.app.projectFilter) {
if (!name || name.startsWith('.')) continue;
const projUrl = resolveHttpUrl(scanRootUrl, name, true);
tasks.push((async () => {
const projItems = await fetchHttpListing(projUrl);
if (!projItems) return;
const found = findArchiveInItems(projItems);
if (!found) return;
const archiveUrl = resolveHttpUrl(projUrl, found.name, true);
await scanHttpRecursive(archiveUrl, rootUrl, 0, null, callbacks);
})());
}
await Promise.all(tasks);
return;
}
const items = await fetchHttpListing(scanRootUrl);
if (!items) return;
// Mode 2 — project-root (scan root has archive/ child)
const found = findArchiveInItems(items);
if (found) {
const archiveUrl = resolveHttpUrl(scanRootUrl, found.name, true);
await scanHttpRecursive(archiveUrl, rootUrl, 0, null, callbacks);
return;
}
// Mode 3 — in-archive (default)
await processHttpItems(items, scanRootUrl, rootUrl, 0, null, callbacks);
}
async function scanHttpRecursive(dirUrl, rootUrl, depth, transmittalPath, callbacks) {
if (depth > 10) {
console.warn('HTTP directory depth limit reached at: ' + dirUrl);
return;
}
const items = await fetchHttpListing(dirUrl);
if (!items) return;
await processHttpItems(items, dirUrl, rootUrl, depth, transmittalPath, callbacks);
}
async function processHttpItems(items, dirUrl, rootUrl, depth, transmittalPath, callbacks) {
// Collect subdirectory scan promises so siblings run in parallel
const subdirPromises = [];
for (const item of items) {
// Caddy appends "/" to directory names; strip it to get the bare name for matching
const rawName = rawNameOf(item);
// Skip hidden files
if (rawName.startsWith('.')) continue;
const isDir = item.is_dir === true;
const itemUrl = resolveHttpUrl(dirUrl, rawName, isDir);
const logicalPath = urlToLogicalPath(itemUrl, rootUrl);
if (isDir) {
// Skip listings for folder-types that are toggled off — applies at any depth.
if (transmittalPath === null && isHiddenFolderTypeName(rawName)) continue;
if (transmittalPath !== null) {
// Inside a transmittal folder — recurse into subdirectories
subdirPromises.push(
scanHttpRecursive(itemUrl, rootUrl, depth + 1, transmittalPath, callbacks)
);
} else if (window.app.modules.parser.isTransmittalFolder(rawName)) {
const folder = {
name: rawName,
path: logicalPath,
displayPath: getDisplayPath(logicalPath),
handle: null,
url: itemUrl
};
callbacks.onTransmittalFolder(folder);
subdirPromises.push(
scanHttpRecursive(itemUrl, rootUrl, depth + 1, logicalPath, callbacks)
);
} else {
const folder = {
name: rawName,
path: logicalPath,
displayPath: rawName,
handle: null,
url: itemUrl
};
callbacks.onGroupingFolder(folder);
subdirPromises.push(
scanHttpRecursive(itemUrl, rootUrl, depth + 1, null, callbacks)
);
}
} else {
// It's a file
// A zipped transmittal folder at the grouping level:
// zddc-server serves "<…>.zip/" as a virtual directory
// of the zip's members, so recurse into it like an
// uncompressed transmittal folder. Members come back
// with URLs like "<…>.zip/<member>" that the server
// extracts on demand — no whole-zip download.
if (transmittalPath === null && window.app.modules.parser.isTransmittalFolderZip(rawName)) {
const base = zddc.splitExtension(rawName).name;
const zipDirUrl = itemUrl + '/'; // itemUrl is the .zip file URL (no trailing slash)
callbacks.onTransmittalFolder({
name: base,
path: logicalPath,
displayPath: getDisplayPath(logicalPath),
handle: null,
url: zipDirUrl
});
subdirPromises.push(
scanHttpRecursive(zipDirUrl, rootUrl, depth + 1, logicalPath, callbacks)
);
continue;
}
if (transmittalPath === null) {
// File directly in a grouping folder — assign to Outstanding virtual transmittal.
// actualPath records the real containing folder for grouping-folder-scoped filtering.
const dirLogicalPath = urlToLogicalPath(dirUrl, rootUrl);
const parsed = zddc.parseFilename(rawName) || {};
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
callbacks.onFile({
id: crypto.randomUUID(),
name: rawName,
path: logicalPath,
displayPath: logicalPath,
url: itemUrl,
size: item.size || null,
modified: modified,
handle: null,
folderPath: '__outstanding__',
actualPath: dirLogicalPath,
hasPathError: false,
...parsed
});
} else {
// Inside a transmittal folder
const parsed = zddc.parseFilename(rawName) || {};
// mod_time is an ISO 8601 string from Go's time.Time.UTC()
const modified = item.mod_time ? new Date(item.mod_time).getTime() : null;
callbacks.onFile({
id: crypto.randomUUID(),
name: rawName,
path: logicalPath,
displayPath: logicalPath,
url: itemUrl,
size: item.size || null,
modified: modified,
handle: null,
folderPath: transmittalPath,
actualPath: logicalPath.substring(0, logicalPath.lastIndexOf('/')),
hasPathError: false,
...parsed
});
}
}
}
// Wait for all sibling subdirectory scans to complete in parallel
if (subdirPromises.length > 0) {
await Promise.all(subdirPromises);
}
}
// Build an absolute URL for an item inside a directory listing URL
function resolveHttpUrl(dirUrl, name, isDir) {
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
const encoded = encodeURIComponent(name);
return base + encoded + (isDir ? '/' : '');
}
// Convert an absolute item URL to a logical relative path (for display / filtering)
function urlToLogicalPath(itemUrl, rootUrl) {
const root = rootUrl.endsWith('/') ? rootUrl : rootUrl + '/';
let rel = itemUrl;
if (rel.startsWith(root)) {
rel = rel.substring(root.length);
}
// Decode percent-encoding for display
try { rel = decodeURIComponent(rel); } catch (e) { /* leave encoded */ }
// Strip trailing slash for directories
if (rel.endsWith('/')) rel = rel.slice(0, -1);
return rel;
}
window.app.modules.source = {
getDisplayPath,
createSource
};
})();

1077
archive/js/table.js Normal file

File diff suppressed because it is too large Load diff

224
archive/js/url-state.js Normal file
View file

@ -0,0 +1,224 @@
(function() {
'use strict';
// URL state sync module for ZDDC Archive
// Default values for URL params
var DEFAULT_SORT_FIELD = 'trackingNumber';
var DEFAULT_SORT_DIRECTION = 'asc';
var DEFAULT_ENABLED_TYPES = ['issued', 'received'];
// Map URL param names to state paths
var PARAM_MAP = {
sort: 'sortField',
dir: 'sortDirection',
tn: 'columnFilters.trackingNumber',
ti: 'columnFilters.title',
rv: 'columnFilters.revisions',
types: 'enabledFolderTypes',
gf: 'groupingFilter',
tf: 'transmittalFilter',
projects: 'projectFilter',
show: 'visibleProjects'
};
// Serialize current state to URL query string
function serialize() {
var params = new URLSearchParams();
// Sort field
if (window.app.sortField !== DEFAULT_SORT_FIELD) {
params.set('sort', window.app.sortField);
}
// Sort direction
if (window.app.sortDirection !== DEFAULT_SORT_DIRECTION) {
params.set('dir', window.app.sortDirection);
}
// Column filters
if (window.app.columnFilters.trackingNumber !== '') {
params.set('tn', window.app.columnFilters.trackingNumber);
}
if (window.app.columnFilters.title !== '') {
params.set('ti', window.app.columnFilters.title);
}
if (window.app.columnFilters.revisions !== '') {
params.set('rv', window.app.columnFilters.revisions);
}
// Folder types (only if different from default [issued, received])
var enabledTypes = Array.from(window.app.enabledFolderTypes).sort();
var defaultTypes = DEFAULT_ENABLED_TYPES.slice().sort();
if (JSON.stringify(enabledTypes) !== JSON.stringify(defaultTypes)) {
params.set('types', enabledTypes.join(','));
}
// Grouping filter
if (window.app.groupingFilter !== '') {
params.set('gf', window.app.groupingFilter);
}
// Transmittal filter
if (window.app.transmittalFilter !== '') {
params.set('tf', window.app.transmittalFilter);
}
// Project filter — always preserved if set (for shareable URLs)
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
params.set('projects', Array.from(window.app.projectFilter).join(','));
}
// Visibility filter (project picker). Emit only when it's a strict subset
// of projectFilter — the common "everything visible" case keeps URLs clean.
if (window.app.visibleProjects && window.app.projectFilter
&& window.app.projectFilter.size > 0) {
var pfSize = window.app.projectFilter.size;
var vp = Array.from(window.app.visibleProjects).filter(function(n) {
return window.app.projectFilter.has(n);
});
if (vp.length < pfSize) {
params.set('show', vp.slice().sort().join(','));
}
}
// Build query string
var qs = params.toString();
return qs ? '?' + qs : '';
}
// Push state to URL without triggering popstate
function push() {
var result = serialize();
if (result === location.search) {
return;
}
try {
history.replaceState(null, '', location.pathname + result);
} catch (e) {
// Silently swallow errors (e.g., file:// protocol restrictions)
}
}
// Restore state from URL query string
function restore() {
var params = new URLSearchParams(location.search);
// Restore sort field
if (params.has('sort')) {
var sortValue = params.get('sort');
if (sortValue === 'trackingNumber' || sortValue === 'title') {
window.app.sortField = sortValue;
}
}
// Restore sort direction
if (params.has('dir')) {
var dirValue = params.get('dir');
if (dirValue === 'asc' || dirValue === 'desc') {
window.app.sortDirection = dirValue;
}
}
// Restore column filters with AST parsing
if (params.has('tn')) {
var tnValue = params.get('tn');
window.app.columnFilters.trackingNumber = tnValue;
window.app.columnFilterASTs.trackingNumber = zddc.filter.parse(tnValue);
}
if (params.has('ti')) {
var tiValue = params.get('ti');
window.app.columnFilters.title = tiValue;
window.app.columnFilterASTs.title = zddc.filter.parse(tiValue);
}
if (params.has('rv')) {
var rvValue = params.get('rv');
window.app.columnFilters.revisions = rvValue;
window.app.columnFilterASTs.revisions = zddc.filter.parse(rvValue);
}
// Restore folder types
if (params.has('types')) {
var typesValue = params.get('types');
var typeValues = typesValue.split(',').map(function(t) { return t.trim(); });
// Validate against app.FOLDER_TYPE_NAMES
var validTypes = typeValues.filter(function(t) {
return window.app.FOLDER_TYPE_NAMES.indexOf(t) !== -1;
});
window.app.enabledFolderTypes = new Set(validTypes);
}
// Restore grouping filter
if (params.has('gf')) {
window.app.groupingFilter = params.get('gf');
}
// Restore transmittal filter
if (params.has('tf')) {
window.app.transmittalFilter = params.get('tf');
}
// Restore project filter
if (params.has('projects')) {
var projValue = params.get('projects');
var projNames = projValue.split(',').map(function(p) { return p.trim(); }).filter(Boolean);
window.app.projectFilter = new Set(projNames);
}
// Restore visibility filter. autoConnectHttpSource will intersect against
// projectFilter / availableProjects after the project list resolves, so
// dropping bogus names is handled there. We just parse here.
if (params.has('show')) {
var showValue = params.get('show');
var showNames = showValue.split(',').map(function(p) { return p.trim(); }).filter(Boolean);
window.app.visibleProjects = new Set(showNames);
}
// Update DOM inputs to reflect restored values
updateFilterInputs();
}
// Update DOM filter inputs to match restored state
function updateFilterInputs() {
// Column filter inputs
document.querySelectorAll('.column-filter[data-filter-field]').forEach(function(input) {
var field = input.getAttribute('data-filter-field');
var filterValue = window.app.columnFilters[field] || '';
input.value = filterValue;
if (filterValue !== '') {
input.classList.add('filter-active');
} else {
input.classList.remove('filter-active');
}
});
// Grouping filter
var groupingFilterEl = document.getElementById('groupingFilter');
if (groupingFilterEl) {
groupingFilterEl.value = window.app.groupingFilter;
if (window.app.groupingFilter !== '') {
groupingFilterEl.classList.add('filter-active');
} else {
groupingFilterEl.classList.remove('filter-active');
}
}
// Transmittal filter
var transmittalFilterEl = document.getElementById('transmittalFilter');
if (transmittalFilterEl) {
transmittalFilterEl.value = window.app.transmittalFilter;
if (window.app.transmittalFilter !== '') {
transmittalFilterEl.classList.add('filter-active');
} else {
transmittalFilterEl.classList.remove('filter-active');
}
}
}
// Register module
window.app.modules.urlState = {
serialize: serialize,
push: push,
restore: restore
};
})();

368
archive/template.html Normal file
View file

@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Archive</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<div id="appContainer">
<!-- Project access warning banner (shown when URL contains inaccessible projects) -->
<div id="projectWarningBanner" class="project-warning-banner hidden" role="alert">
<span class="project-warning-text"></span>
<button class="project-warning-dismiss" onclick="dismissProjectWarning()" aria-label="Dismiss">×</button>
</div>
<!-- Header -->
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
<!-- Main Container -->
<div class="main-container">
<!-- Navigation Pane -->
<nav id="navigationPane" class="nav-pane">
<!-- Grouping Folders Section -->
<div class="nav-section" id="groupingSection">
<div class="nav-section-header">
<h3>Parties</h3>
<div class="preset-section" id="presetSection">
<button id="presetBtn" class="btn btn-secondary btn-sm" title="Party presets">▾ Presets</button>
<div id="presetDropdown" class="preset-dropdown hidden"></div>
</div>
<button id="toggleGroupingBtn" class="btn-icon" title="Collapse/Expand">
<span id="toggleGroupingIcon"></span>
</button>
</div>
<div id="groupingContent" class="nav-section-content">
<!-- Global folder type toggle bar -->
<div id="folderTypeBar" class="folder-type-bar">
<!-- Dynamically populated by renderFolderTypeBar() -->
</div>
<div class="filter-select-row">
<input type="text"
id="groupingFilter"
class="filter-input"
placeholder="Filter parties...">
<label class="select-all-label select-all-inline" title="Auto-select all visible parties">
<span>Select<br>All</span>
<input type="checkbox" id="selectAllGroupingCheckbox" checked>
</label>
</div>
<div id="groupingFoldersList" class="folder-list">
<!-- Dynamically populated -->
</div>
</div>
<div class="resize-handle-vertical" data-resize="nav-sections"></div>
</div>
<!-- Transmittal Folders Section -->
<div class="nav-section" id="transmittalSection">
<div class="nav-section-header">
<h3>Transmittal Folders</h3>
<button id="toggleAllDatesBtn" class="btn-icon" title="Expand/Collapse All">
<span id="toggleAllDatesIcon"></span>
</button>
</div>
<div class="filter-select-row">
<input type="text"
id="transmittalFilter"
class="filter-input"
placeholder="Filter transmittal folders...">
<label class="select-all-label select-all-inline" title="Auto-select all visible transmittals">
<span>Select<br>All</span>
<input type="checkbox" id="selectAllTransmittalsCheckbox" checked>
</label>
</div>
<div id="transmittalFoldersList" class="folder-list">
<!-- Dynamically populated -->
</div>
</div>
<div class="resize-handle-horizontal" data-resize="nav-pane"></div>
</nav>
<!-- Content Area -->
<main class="content-area">
<!-- Content Header -->
<div class="content-header">
<!-- Reset Filters -->
<button id="resetFiltersBtn" class="btn btn-secondary btn-icon-only" title="Reset all column filters"></button>
<!-- Preview toggle (default on; users can opt out for direct downloads) -->
<label class="preview-toggle-label" title="Preview PDF, Word, and Excel files in a popup window instead of downloading">
<input type="checkbox" id="filePreviewToggle" checked>
<span>Preview</span>
</label>
<!-- Modifier Filter Dropdown -->
<div class="modifier-filter-container">
<button id="modifierFilterBtn" class="btn btn-secondary modifier-filter-btn">
Modifiers ▼
</button>
<div id="modifierFilterDropdown" class="modifier-filter-dropdown hidden">
<div class="modifier-filter-header">
<label><input type="checkbox" id="modifierSelectAll" checked> Select All</label>
</div>
<div id="modifierFilterList" class="modifier-filter-list">
<!-- Dynamically populated -->
</div>
</div>
</div>
<div class="toolbar-separator"></div>
<div class="content-actions">
<button id="filterSelectedBtn" class="btn btn-secondary">Filter Selected</button>
<button id="downloadSelectedBtn" class="btn btn-secondary">Download (ZIP)</button>
<button id="exportCsvBtn" class="btn btn-secondary">Export (CSV)</button>
</div>
</div>
<!-- Files Table -->
<div class="table-container">
<table id="filesTable" class="files-table">
<thead>
<tr>
<th class="sortable resizable" data-field="trackingNumber">
<div class="th-content">
<span>Tracking Number</span>
<span class="sort-indicator"></span>
</div>
<input type="text"
class="column-filter"
data-filter-field="trackingNumber"
placeholder="Filter...">
<div class="resize-handle"></div>
</th>
<th class="sortable resizable" data-field="title">
<div class="th-content">
<span>Title</span>
<span class="sort-indicator"></span>
</div>
<input type="text"
class="column-filter"
data-filter-field="title"
placeholder="Filter...">
<div class="resize-handle"></div>
</th>
<th class="resizable" data-field="revisions">
<div class="th-content th-content--start">
<input type="checkbox"
id="selectAllVisibleCheckbox"
class="select-all-checkbox"
title="Select/deselect all visible files">
<span>Revisions</span>
</div>
<input type="text"
class="column-filter"
data-filter-field="revisions"
placeholder="Filter...">
<div class="resize-handle"></div>
</th>
</tr>
</thead>
<tbody id="filesTableBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
<!-- Status Bar -->
<div class="status-bar">
<span id="fileCount">0 files</span>
<span id="selectedCount">0 selected</span>
<span id="scanStatus"></span><span id="scanSpinner" class="scan-spinner hidden"></span>
</div>
</main>
</div>
<!-- Drop Modal -->
<div id="dropModal" class="modal hidden">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-header">
<h2>Create Transmittal</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Transmittal Folder Name:</label>
<input type="text" id="transmittalName" class="form-input">
<small class="form-help">Format: YYYY-MM-DD_TRACKINGNUMBER (STATUS) - TITLE</small>
</div>
<div class="files-preview">
<h3>Files to Add:</h3>
<table class="preview-table">
<thead>
<tr>
<th>Original Name</th>
<th>New Name</th>
<th>Status</th>
</tr>
</thead>
<tbody id="filesPreviewBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary modal-cancel">Cancel</button>
<button class="btn btn-primary modal-confirm">Create Transmittal</button>
</div>
</div>
</div>
<!-- No Directory Selected Message -->
<div id="noDirectoryMessage" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>Welcome to ZDDC Archive</h2>
<p>Click <strong>Use Local Directory</strong> to select an archive folder to browse.</p>
<p>This browser provides a convenient interface for searching and retrieving files from ZDDC-compliant archives.</p>
<p><strong>How to navigate:</strong></p>
<ul class="welcome-list">
<li>Select a party to see their transmittal folders; toggle folder types (Issued, Received, MDL, Incoming) above the list</li>
<li>Select transmittal folders to see their files</li>
<li>Use <kbd>Ctrl+Click</kbd> to select multiple folders</li>
<li>Use <kbd>Shift+Click</kbd> to select a range</li>
<li><kbd>Ctrl+Click</kbd> chevrons to recursively expand/collapse</li>
</ul>
<details class="windows-tip">
<summary><strong>⚠️ Windows Path Length Deficiency</strong></summary>
<div class="windows-tip__body">
<p>Microsoft Windows has a legacy 260-character path limit that affects most applications. If you see "files skipped" warnings, use Microsoft's own workaround:</p>
<ol>
<li>Open Command Prompt as Administrator</li>
<li>Map your archive to a short drive letter:<br>
<code class="windows-tip__code">subst Z: "C:\Your\Long\Path\To\Archive"</code>
</li>
<li>Use the <strong>Z:</strong> drive in Archive Browser</li>
<li>To remove later: <code>subst Z: /d</code></li>
</ol>
<p class="windows-tip__note">This limitation dates back to Windows 95. The mapping persists until reboot.</p>
</div>
</details>
<p class="note">Note: This application works entirely in your browser and does not transmit any data.</p>
</div>
</div>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Archive</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is the Archive Browser?</h3>
<p>The Archive Browser lets you search and retrieve files from a ZDDC-compliant archive stored on your local file system. Everything runs in your browser — no data is transmitted anywhere.</p>
<h3>Getting Started</h3>
<ol>
<li>When opened from a web server, the archive loads automatically from that server.</li>
<li>Click <strong>Use Local Directory</strong> to open a local archive folder — works in both offline and online modes, and local files are merged with any server files already loaded.</li>
<li>The browser scans for grouping folders and transmittal folders automatically.</li>
<li>Select folders in the left panel to see their files in the main table.</li>
</ol>
<h3>Navigating Folders</h3>
<p>The left panel has two sections:</p>
<dl>
<dt>Parties</dt>
<dd>Top-level folders representing other parties. Select one or more to filter which transmittals are shown. Use the folder type buttons above the list to show or hide Issued, Received, MDL, and Incoming folder content.</dd>
<dt>Transmittal Folders</dt>
<dd>Grouped by date. Select one or more to filter which files appear in the table.</dd>
</dl>
<p><strong>Multi-select:</strong> Hold <kbd>Ctrl</kbd> and click to toggle individual folders. Hold <kbd>Shift</kbd> and click to select a range. <kbd>Ctrl+Click</kbd> a chevron (&#9654;) to recursively expand or collapse all sub-folders.</p>
<h3>Searching and Filtering</h3>
<dl>
<dt>Column Filters</dt>
<dd>Type in the filter row under each column header to filter by tracking number, title, or revision/status/extension. Filters support the expression syntax below. Active filters are highlighted in blue; use the ↺ reset button in the toolbar to clear all filters at once.</dd>
</dl>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain "term"</dd>
<dt><code>^term</code></dt>
<dd>Starts with "term"</dd>
<dt><code>term$</code></dt>
<dd>Ends with "term"</dd>
<dt><code>a b</code></dt>
<dd>Matches both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Matches either (OR)</dd>
<dt><code>^IFA | ^IFB</code></dt>
<dd>Starts with IFA or IFB</dd>
<dt><code>pdf !draft</code></dt>
<dd>Contains "pdf" and not "draft"</dd>
<dt><code>!^~</code></dt>
<dd>Does not start with ~ (excludes drafts)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex: contains "el" followed by "spc" (use <code>.</code> for any char, <code>.*</code> for any sequence)</dd>
<dt><code>[ei]fa</code></dt>
<dd>Regex character class: matches "efa" or "ifa"</dd>
</dl>
<dl>
<dt>Modifiers</dt>
<dd>Use the Modifiers dropdown to show or hide files by revision modifier type (+B, +C, +N, +Q, or base).</dd>
</dl>
<h3>Downloading Files</h3>
<dl>
<dt>Download Selected (ZIP)</dt>
<dd>Packages all checked files into a ZIP archive for download.</dd>
<dt>Export Selected (CSV)</dt>
<dd>Exports the visible file list as a CSV spreadsheet.</dd>
<dt>File Preview</dt>
<dd>When enabled, clicking a PDF, Word, or Excel file opens a preview popup instead of downloading it.</dd>
</dl>
<h3>Keyboard Shortcuts</h3>
<dl>
<dt><kbd>Ctrl+A</kbd></dt>
<dd>Select / deselect all visible files in the table.</dd>
<dt><kbd>F5</kbd></dt>
<dd>Refresh — rescan the current directory.</dd>
<dt><kbd>Escape</kbd></dt>
<dd>Close this help panel (or any open modal).</dd>
</dl>
<h3>Windows Path Length Note</h3>
<p>Windows limits file paths to 260 characters by default. If files are skipped during scanning, map your archive to a short drive letter using <code>subst Z: "C:\Your\Long\Path"</code> in an Administrator Command Prompt, then open the <strong>Z:</strong> drive in the Archive Browser.</p>
</div>
</aside>
</div>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

53
browse/README.md Normal file
View file

@ -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.

151
browse/build.sh Executable file
View file

@ -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 `</` inside JS string/template literals so the
# inlined <script> 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\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} 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

44
browse/css/base.css Normal file
View file

@ -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;
}

131
browse/css/history.css Normal file
View file

@ -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;
}

View file

@ -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;
}

138
browse/css/preview-yaml.css Normal file
View file

@ -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;
}

1069
browse/css/tree.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -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/<their-party>/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 = '<div style="margin:0.5rem 0;padding:0.5rem 0.75rem;background:#fff3cd;border-left:3px solid #d39e00;font-size:0.85rem;">'
+ '<strong>Non-conforming files detected:</strong><ul style="margin:0.25rem 0 0 1rem;padding:0;">'
+ initial.violations.map(function (v) { return '<li>' + escapeHtml(v) + '</li>'; }).join('')
+ '</ul><p style="margin:0.4rem 0 0 0;">Cancel and contact the sender to correct these before re-uploading.</p></div>';
}
var planReviewFieldsHtml =
'<div id="acc-pr-fields" style="display:none;margin-top:0.6rem;padding:0.5rem 0.75rem;background:rgba(0,0,0,0.03);border-radius:4px;">' +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.4rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="acc-review-lead">Review lead</label>' +
'<input id="acc-review-lead" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
'<label for="acc-review-date">Plan review complete date</label>' +
'<input id="acc-review-date" type="date">' +
'<label for="acc-approver">Approver</label>' +
'<input id="acc-approver" type="email" list="acc-people" style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
'<label for="acc-response-date">Plan response date</label>' +
'<input id="acc-response-date" type="date">' +
'<datalist id="acc-people"></datalist>' +
'</div>' +
'</div>';
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Accept Transmittal — ' + escapeHtml(initial.tracking) + '</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'This will file <strong>' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</strong> from ' +
'<code>' + escapeHtml(initial.folder) + '</code> into the immutable received archive at ' +
'<code>archive/' + escapeHtml(initial.party) + '/received/' + escapeHtml(initial.tracking) + '/</code>. ' +
'Once filed, only document-control can add new files there; nothing can be edited or deleted.' +
'</p>' +
violationsHtml +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="acc-received-date">Received date</label>' +
'<input id="acc-received-date" type="date" required>' +
'</div>' +
'<label style="display:flex;align-items:center;gap:0.4rem;margin-top:0.8rem;font-size:0.9rem;">' +
'<input type="checkbox" id="acc-setup-pr">' +
'<span>Set up Plan Review now — scaffold the reviewing/ and staging/ folders for the response</span>' +
'</label>' +
planReviewFieldsHtml +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="acc-cancel">Cancel</button>' +
'<button type="button" id="acc-submit" class="btn-primary"' +
(initial.violations && initial.violations.length ? ' disabled' : '') + '>Accept</button>' +
'</div>';
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/<party>/<folder>/.
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
};
})();

163
browse/js/app.js Normal file
View file

@ -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=<path> deep links: external clients (the profile
// page's "edit your .zddc files" list, future bookmarks, etc.)
// can link directly to "open browse at <dir>, 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();
}
})();

203
browse/js/conflict.js Normal file
View file

@ -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/<id>.conflict-<ts>/` 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 };
})();

View file

@ -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 =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Create Transmittal folder</h2>' +
'<p style="margin:0 0 0.6rem 0;font-size:0.85rem;color:#666;">' +
"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.' +
'</p>' +
'<label for="ct-name" style="font-size:0.9rem;">Folder name (ZDDC convention)</label>' +
'<input id="ct-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT" value="' + escapeHtml(isoDateToday() + '_') + '">' +
'<div id="ct-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;min-height:1.1em;"></div>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="ct-cancel">Cancel</button>' +
'<button type="button" id="ct-submit" class="btn-primary" disabled>Create</button>' +
'</div>';
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 };
})();

245
browse/js/download.js Normal file
View file

@ -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 <a download> at the virtual "<node-path>.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 <a download> 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 "<dir>.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 <a download> at the virtual "<node-path>.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
};
})();

1280
browse/js/events.js Normal file

File diff suppressed because it is too large Load diff

77
browse/js/grid.js Normal file
View file

@ -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/<party>/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:
// <currentDirURL>/<tool>.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 + <tool>.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
};
})();

395
browse/js/history.js Normal file
View file

@ -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 <url>?history=1 → JSON [{ts, by, id, bytes, current}]
// GET <url>?history=<id> → that version's raw bytes (id = snapshot filename)
// Restore re-PUTs a chosen version's bytes to <url>, 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=<v> (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 =
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
(ent.current ? '<span class="md-history-badge">current</span>' : '');
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 = '<p class="md-history-hint">Loading…</p>';
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 = '<p class="md-history-hint">Loading…</p>';
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 =
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
' → ' +
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
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 = '<p class="md-history-hint">Loading…</p>';
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 };
})();

333
browse/js/hovercard.js Normal file
View file

@ -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 <body>; 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 /<project>/.archive/<tracking>.html
// URL is the latest issued version (highest base rev), and
// /<project>/.archive/<tracking>_<rev>.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 += '<div class="tree-hovercard__sep"></div>';
} 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 += '<div class="tree-hovercard__sep"></div>';
html += kv('Your permissions', permsValue(node.verbs));
if (state.source === 'server') {
html += '<span class="tree-hovercard__key">Your roles</span>'
+ '<span class="tree-hovercard__val" id="hc-roles">…</span>';
}
// 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 '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val'
+ (mono ? ' tree-hovercard__val--mono' : '')
+ '">' + escapeHtml(val) + '</span>';
}
// kvLink — value rendered as an <a> 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 '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val tree-hovercard__val--mono">'
+ '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener">'
+ escapeHtml(label)
+ '</a>'
+ '</span>';
}
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 = ''
+ '<div class="tree-hovercard__header">'
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
+ (secondary
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
: '')
+ '</div>'
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
}
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 };
})();

101
browse/js/init.js Normal file
View file

@ -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 <body> before
// the tree first renders. The sprite is hidden (display:none on
// the outer <svg>) — it only exists so per-row <use href="#…"/>
// refs resolve. Falls back to deferring until DOMContentLoaded
// when <body> 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
};
})();

234
browse/js/loader.js Normal file
View file

@ -0,0 +1,234 @@
// loader.js — fetches directory entries for either source mode.
//
// Server mode: GET <urlPath> 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 <url>?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/<tracking>/
// 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 /<tool>.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
};
})();

243
browse/js/manage-access.js Normal file
View file

@ -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);

469
browse/js/menu-model.js Normal file
View file

@ -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
};
})();

250
browse/js/plan-review.js Normal file
View file

@ -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/<party>/received/<tracking>/ 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/<tracking>/.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/<tracking>/. 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/<tracking>/ 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 =
'<h2 style="margin:0 0 0.75rem 0;font-size:1.1rem;">Plan Review — ' + escapeHtml(initial.tracking) + '</h2>' +
'<div style="display:grid;grid-template-columns:max-content 1fr;gap:0.5rem 0.75rem;align-items:center;font-size:0.9rem;">' +
'<label for="pr-review-lead">Review lead</label>' +
'<input id="pr-review-lead" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of reviewing/<…>">' +
'<label for="pr-review-date">Plan review complete date</label>' +
'<input id="pr-review-date" type="date" required>' +
'<label for="pr-approver">Approver</label>' +
'<input id="pr-approver" type="email" list="pr-people-list" required style="padding:0.25rem 0.4rem;" placeholder="email — becomes sub-admin of staging/<…>">' +
'<label for="pr-response-date">Plan response date</label>' +
'<input id="pr-response-date" type="date" required>' +
'<datalist id="pr-people-list"></datalist>' +
'</div>' +
'<p style="margin:0.75rem 0 0 0;font-size:0.8rem;color:#666;">Planned dates seal at first submission — they become part of the canonical record (received/<tracking>/.zddc) and the WORM zone prevents further edits. Subsequent Plan Reviews can swap the review lead or approver without changing the dates.</p>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="pr-cancel">Cancel</button>' +
'<button type="button" id="pr-submit" class="btn-primary">Plan Review</button>' +
'</div>';
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/<party>/received/<tracking>/
// 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
};
})();

File diff suppressed because it is too large Load diff

584
browse/js/preview-yaml.js Normal file
View file

@ -0,0 +1,584 @@
// preview-yaml.js — YAML editor plugin for the browse preview pane.
//
// Routes any .yaml / .yml file, plus the .zddc cascade files
// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with
// syntax highlighting and live linting. js-yaml.loadAll feeds parse
// errors into CM's lint gutter; for .zddc files an additional
// schema-aware pass flags unknown keys, bad enum values, and wrong
// types.
//
// Layout (single column):
// ┌─────────────────────────────────────────────────────────────┐
// │ name | dirty | status | source | [Save] │
// ├─────────────────────────────────────────────────────────────┤
// │ CodeMirror editor (line numbers + lint gutter) │
// └─────────────────────────────────────────────────────────────┘
//
// Save (Ctrl+S) writes back via PUT (server mode) or
// FileSystemWritableFileStream (FS-API). Zip members and
// virtual nodes are read-only — Save stays disabled.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
// ── Filename routing ────────────────────────────────────────────────────
// True for .zddc cascade files — `.zddc` (literal name, no ext)
// and `<anything>.zddc.yaml` (e.g. `defaults.zddc.yaml`). These
// get the schema-aware lint layer.
function isZddcFile(name) {
if (!name) return false;
if (name === '.zddc') return true;
return /\.zddc\.ya?ml$/i.test(name);
}
function isYamlFile(node) {
if (!node || !node.name) return false;
if (isZddcFile(node.name)) return true;
var ext = (node.ext || '').toLowerCase();
return ext === 'yaml' || ext === 'yml';
}
// The CodeMirror editor is the general editor for editable TEXT files that
// aren't markdown (markdown has its own editor). Syntax highlighting is
// YAML-only — that's the one CM mode in the vendored bundle — so every
// other type opens as a plaintext editor (still line numbers, find,
// selection, save). svg/json-as-image etc. stay with their preview
// renderers; this set is deliberately the "edit the source" types.
var CODE_EXTS = {
yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1,
json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1,
log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1,
sh: 1, bash: 1, properties: 1
};
function isCodeFile(node) {
if (!node || node.isDir || node.isZip) return false;
if (isYamlFile(node)) return true;
return !!CODE_EXTS[(node.ext || '').toLowerCase()];
}
// CodeMirror mode by extension — only yaml is vendored; others plaintext.
function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; }
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content, opts) {
// Via the shared saveFile so local (FS-Access) saves escalate to
// readwrite the same as the markdown editor — previously this path
// skipped ensureWritable and failed on read-only-picked folders.
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8', opts);
}
var isZipMemberNode = util.isZipMemberNode;
var isEditableZipMember = util.isEditableZipMember;
function canSave(node) {
// A .zddc.zip bundle member is saveable iff editable (elevated admin);
// the server's ServeZipWrite is the real gate. Other zip members are
// read-only.
if (isZipMemberNode(node)) return isEditableZipMember(node);
// Virtual .zddc placeholders are designed to be saved — a PUT
// materializes the file from the synthetic body and the next
// listing serves a real entry. Every other virtual node (per-
// user home, canonical-folder virtuals) is just a tree
// affordance, not a writable file.
if (node.virtual && node.name !== '.zddc') return false;
// Server-computed authority gate. The virtual .zddc entry
// requires the admin verb 'a' (matches fileapi.go's
// ActionAdmin gate at the .zddc URL); regular YAML files
// require write 'w'. cap.has falls back to node.writable for
// 'w' when verbs is absent (offline FS-API listings).
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
var needed = node.name === '.zddc' ? 'a' : 'w';
if (!window.zddc.cap.has(node, needed)) return false;
}
if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true;
return false;
}
var hashContent = util.hashContent;
// ── .zddc schema ────────────────────────────────────────────────────────
//
// Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed
// tool names are the embedded set (always available) plus the
// composable ones served when declared in apps:. Unknown keys at
// any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them.
// The valid keys, types, enums and nesting are NOT hand-listed here any
// more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
// the same grammar the server serves at /.api/zddc-schema and that drives
// completion + hover). One source, no drift. See validateZddcSchema below.
function typeOf(v) {
if (v === null || v === undefined) return 'null';
if (Array.isArray(v)) return 'array';
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
}
// The .zddc JSON Schema, baked into the bundle at build time
// (window.__ZDDC_SCHEMA__ — the same file the server serves at
// /.api/zddc-schema). Single source for lint, completion and hover; works
// offline. Synchronous, so the lint helper can use it directly.
function getZddcSchema() {
return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties)
? window.__ZDDC_SCHEMA__ : {};
}
// Validate a parsed .zddc document against the JSON Schema, producing
// { keyPath, severity, message } issues (mapped to source lines by
// findLine). Covers the draft-2020-12 subset .zddc uses: type, enum,
// properties, additionalProperties (false | schema), patternProperties,
// items, pattern, and the recursive $ref:"#" (paths:).
function validateZddc(doc) {
var schema = getZddcSchema();
var issues = [];
if (!schema || !schema.properties) return issues; // schema unavailable
if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues;
}
function deref(n) { return (n && n.$ref === '#') ? schema : n; }
function typeOk(t, want) {
if (Array.isArray(want)) {
for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
return false;
}
if (want === 'integer' || want === 'number') return t === 'number';
return t === want;
}
function walk(value, sch, path) {
sch = deref(sch);
if (!sch) return;
var t = typeOf(value);
if (t === 'null') return; // empty value mid-edit — don't flag
if (sch.type && !typeOk(t, sch.type)) {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
+ ', got ' + t + '.' });
return;
}
if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' });
}
if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) {
issues.push({ keyPath: path, severity: 'error',
message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
}
if (t === 'object') {
var props = sch.properties || {};
for (var k in value) {
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
var kp = path.concat([k]);
if (props[k]) { walk(value[k], props[k], kp); continue; }
var ap = sch.additionalProperties;
if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
if (sch.patternProperties) {
var matched = null;
for (var p in sch.patternProperties) {
if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p)
&& new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; }
}
if (matched) { walk(value[k], matched, kp); continue; }
}
if (ap === false) {
issues.push({ keyPath: kp, severity: 'warning',
message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' });
}
}
} else if (t === 'array' && sch.items) {
for (var i = 0; i < value.length; i++) {
walk(value[i], sch.items, path.concat([String(i)]));
}
}
}
walk(doc, schema, []);
return issues;
}
// Locate the source line for a key path. .zddc files are
// block-style YAML in practice (no flow style, no anchors), so a
// simple indent-aware scan works: for each segment, find a line
// matching "<indent><key>:" whose indent is deeper than the
// previously-matched line. Falls back to line 0 if no match.
function findLine(source, keyPath) {
if (!keyPath || keyPath.length === 0) return 0;
var lines = source.split('\n');
var prevIndent = -1;
var prevLine = 0;
for (var i = 0; i < keyPath.length; i++) {
var key = keyPath[i];
var found = -1;
// Escape regex metachars in the key.
var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:');
for (var j = prevLine; j < lines.length; j++) {
var m = lines[j].match(re);
if (m && m[1].length > prevIndent) {
found = j;
prevIndent = m[1].length;
prevLine = j + 1;
break;
}
}
if (found === -1) return prevLine > 0 ? prevLine - 1 : 0;
}
return prevLine > 0 ? prevLine - 1 : 0;
}
// ── CodeMirror lint helper ──────────────────────────────────────────────
function registerLinter(CM) {
// The lint helper signature: function(text, options, editor) → annotations[]
// Each annotation: { from, to, message, severity }.
CM.registerHelper('lint', 'yaml', function (text, _opts, editor) {
var out = [];
if (!window.jsyaml) return out;
var parsed;
try {
// loadAll handles multi-doc YAML; we only validate the
// first doc against the schema (the .zddc cascade reads
// only the first document).
var docs = [];
window.jsyaml.loadAll(text, function (d) { docs.push(d); });
parsed = docs[0];
} catch (e) {
var mark = e.mark;
var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0);
out.push({ from: pos, to: pos, severity: 'error',
message: e.message || String(e) });
return out;
}
// Schema layer — only for .zddc cascade files.
var node = editor._zddcNode;
if (node && isZddcFile(node.name)) {
var issues = validateZddc(parsed);
for (var i = 0; i < issues.length; i++) {
var ln = findLine(text, issues[i].keyPath);
out.push({
from: CM.Pos(ln, 0),
to: CM.Pos(ln, (editor.getLine(ln) || '').length),
severity: issues[i].severity,
message: issues[i].message
});
}
}
return out;
});
}
// ── Mount ───────────────────────────────────────────────────────────────
var currentEditor = null;
var currentDirty = false;
var currentNodeRef = null;
// Server version token for the loaded file — sent as If-Match on save
// and refreshed from each successful PUT's response ETag.
var currentEtag = null;
var currentLastModified = null;
function dispose() {
// CM doesn't have an explicit destroy(); GC handles it once
// the host element is removed. Clear our reference so a stale
// editor doesn't keep handlers alive.
currentEditor = null;
currentDirty = false;
currentNodeRef = null;
currentEtag = null;
currentLastModified = null;
}
function isDirty() {
return currentDirty;
}
function currentNode() {
return currentNodeRef;
}
async function render(node, container, ctx) {
if (typeof window.CodeMirror === 'undefined') {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'CodeMirror isn\'t bundled in this build.</div>';
return;
}
dispose();
var text, loadedEtag = null, loadedLastModified = null;
try {
if (ctx.getContentWithVersion) {
var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
loadedEtag = loaded.etag;
loadedLastModified = loaded.lastModified;
} else {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
}
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': '
+ escapeHtml(e.message || String(e)) + '</div>';
return;
}
container.innerHTML = '';
var shell = document.createElement('div');
shell.className = 'yaml-shell';
container.appendChild(shell);
// Info header — same look as the markdown plugin's info-header
// so the two editors feel like one family.
var infohdr = document.createElement('div');
infohdr.className = 'md-shell__infohdr yaml-shell__infohdr';
var titleEl = document.createElement('span');
titleEl.className = 'md-shell__title';
titleEl.textContent = node.name;
titleEl.title = node.name;
var schemaTag = document.createElement('span');
schemaTag.className = 'md-shell__source yaml-shell__schema';
if (isZddcFile(node.name)) {
schemaTag.textContent = '.zddc schema ↗';
schemaTag.title = 'Linted against the .zddc cascade schema '
+ '(unknown keys, bad enums, and wrong types are flagged). '
+ 'Click to view the full JSON Schema.';
// Clickable → opens the canonical machine grammar the lint mirrors.
schemaTag.classList.add('yaml-shell__schema--link');
schemaTag.setAttribute('role', 'link');
schemaTag.setAttribute('tabindex', '0');
var openSchema = function () {
window.open('/.api/zddc-schema', '_blank', 'noopener');
};
schemaTag.addEventListener('click', openSchema);
schemaTag.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
});
} else if (isYamlFile(node)) {
schemaTag.textContent = 'YAML';
} else {
schemaTag.textContent = (node.ext || 'text').toUpperCase();
}
var dirtyEl = document.createElement('span');
dirtyEl.className = 'md-shell__dirty';
var statusEl = document.createElement('span');
statusEl.className = 'md-shell__status';
var sourceEl = document.createElement('span');
sourceEl.className = 'md-shell__source';
if (isZipMemberNode(node)) sourceEl.textContent = isEditableZipMember(node) ? 'config bundle' : 'read-only (zip)';
else if (node.handle) sourceEl.textContent = 'local';
else if (node.url) sourceEl.textContent = 'server';
var saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-sm btn-primary md-shell__save';
saveBtn.type = 'button';
saveBtn.textContent = 'Save';
saveBtn.disabled = true;
infohdr.appendChild(titleEl);
infohdr.appendChild(schemaTag);
infohdr.appendChild(dirtyEl);
infohdr.appendChild(statusEl);
infohdr.appendChild(sourceEl);
infohdr.appendChild(saveBtn);
shell.appendChild(infohdr);
var editorHost = document.createElement('div');
editorHost.className = 'yaml-shell__editor';
shell.appendChild(editorHost);
// Register the lint helper once per page lifetime.
if (!window.CodeMirror.__zddcYamlLinterReady) {
registerLinter(window.CodeMirror);
window.CodeMirror.__zddcYamlLinterReady = true;
}
var writable = canSave(node);
var mode = codeMode(node);
// Lint (js-yaml + the .zddc schema) only applies to YAML; other text
// types are plaintext, so skip the lint gutter for them.
var yamlMode = mode === 'yaml';
var editor = window.CodeMirror(editorHost, {
value: text,
mode: mode,
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: false,
gutters: yamlMode
? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
: ['CodeMirror-linenumbers'],
lint: yamlMode ? { hasGutters: true } : false,
// autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User
// clicks (or tabs) into the editor when they want to type.
autofocus: false,
// Read-only uses readOnly:true (NOT "nocursor"): the editor
// stays focusable so the user can click in, select text, and
// copy — they just can't edit. "nocursor" removes the textarea
// from focus, which also kills click-drag selection (the whole
// reason a viewer would otherwise force admin mode just to copy
// a .zddc snippet). autofocus:false keeps arrow-key tree nav
// intact until the user deliberately clicks into the editor.
readOnly: !writable,
});
// Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer.
editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set (YAML only).
if (yamlMode) editor.performLint();
// Schema completion + hover docs for .zddc files (the machine grammar
// drives keys, enum/boolean values, and nested paths via $ref:"#").
// Plain .yaml gets no schema (lint + highlighting only).
var yc = window.app.modules.yamlComplete;
if (yc && isZddcFile(node.name)) {
yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable });
}
currentEditor = editor;
currentNodeRef = node;
currentDirty = false;
currentEtag = loadedEtag;
currentLastModified = loadedLastModified;
if (!writable) {
saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.';
// Read-only banner above the editor explains why.
var roBanner = document.createElement('div');
roBanner.className = 'yaml-readonly-banner';
roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
+ ' Read-only — you don\'t have write access to this file.';
editorHost.insertBefore(roBanner, editorHost.firstChild);
}
var initialHash = await hashContent(text);
function markDirty(isDirty) {
if (currentEditor !== editor) return; // editor replaced
currentDirty = isDirty;
saveBtn.disabled = !isDirty || !canSave(node);
dirtyEl.textContent = isDirty ? '● modified' : '';
}
editor.on('change', async function () {
if (currentEditor !== editor) return; // switched away
var h = await hashContent(editor.getValue());
if (currentEditor !== editor) return; // replaced during await
markDirty(h !== initialHash);
});
// Adopt the new server ETag + refresh the dirty baseline after a
// successful write so save→edit→save doesn't false-conflict.
async function markSaved(content, res) {
if (currentEditor !== editor) return;
if (res && res.etag) currentEtag = res.etag;
initialHash = await hashContent(content);
if (currentEditor !== editor) return;
markDirty(false);
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved ' + node.name, 'success');
}
}
// 412 → file changed on the server since load. Open the shared
// conflict dialog instead of clobbering.
async function resolveConflict(content) {
var conflict = window.app.modules.conflict;
var prev = window.app.modules.preview;
if (!conflict || !prev) return;
await conflict.open({
filename: node.name,
mineText: content,
fetchTheirs: function () {
return prev.getContentWithVersion(node).then(function (r) {
return new TextDecoder('utf-8', { fatal: false }).decode(r.buf);
});
},
onOverwrite: function () {
return prev.getContentWithVersion(node).then(function (cur) {
return saveContent(node, content, { etag: cur.etag, lastModified: cur.lastModified });
}).then(function (res) { return markSaved(content, res); });
},
onReload: function () {
markDirty(false);
currentDirty = false;
return prev.showFilePreview(node);
},
onSaveCopy: function () {
return util.saveCopy(node, content, 'application/x-yaml; charset=utf-8')
.then(function (name) {
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Saved your version as ' + name, 'success');
}
});
}
});
if (currentEditor === editor) statusEl.textContent = '';
}
async function save() {
if (saveBtn.disabled) return;
// Re-check authority at click time, not via the mount-time
// `writable` capture — the listing may have re-evaluated
// (e.g. user toggled admin mode without a hard reload).
if (!canSave(node)) return;
var content = editor.getValue();
try {
statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, {
etag: currentEtag, lastModified: currentLastModified
});
await markSaved(content, res);
} catch (e) {
if (e && e.status === 412) {
if (currentEditor !== editor) return;
statusEl.textContent = 'Conflict — resolving…';
await resolveConflict(content);
return;
}
statusEl.textContent = 'Save failed: ' + (e.message || e);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
}
}
saveBtn.addEventListener('click', save);
editor.setOption('extraKeys', {
'Ctrl-S': save,
'Cmd-S': save
});
// CM defers layout until its host has a size — refresh after
// mount so the gutters and viewport sync to the grid cell.
setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0);
}
function handles(node) {
return isCodeFile(node);
}
window.app.modules.yamledit = {
handles: handles,
render: render,
dispose: dispose,
isDirty: isDirty,
currentNode: currentNode
};
})();

595
browse/js/preview.js Normal file
View file

@ -0,0 +1,595 @@
// preview.js — file-preview rendering for the browse tool's right pane.
//
// Default flow: showFilePreview(node) renders into the inline preview
// pane (#previewBody). Popup flow: showFilePreview(node, {popup:true})
// opens a separate window — kept for users who want previews on a
// second monitor.
//
// Rendering uses shared/preview-lib.js for content types it handles
// (TIFF, ZIP listing, image-mime detection). PDF / HTML go in iframes;
// text into a <pre>; markdown into the dedicated markdown plugin
// (preview-markdown.js); unknown extensions show a download button.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var preview = window.zddc && window.zddc.preview;
if (!preview) {
console.error('[browse] zddc.preview not loaded — preview disabled.');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var MIME = {
'pdf': 'application/pdf',
'html': 'text/html', 'htm': 'text/html',
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
'tif': 'image/tiff', 'tiff': 'image/tiff',
'zip': 'application/zip',
'txt': 'text/plain', 'md': 'text/markdown', 'json': 'application/json',
'xml': 'application/xml', 'csv': 'text/csv', 'log': 'text/plain',
'js': 'text/javascript', 'css': 'text/css',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls': 'application/vnd.ms-excel'
};
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
var fmtSize = util.fmtSize;
async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so
// it falls through the same getFile() path as any local file.
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return await resp.arrayBuffer();
}
if (node.handle) {
var f = await node.handle.getFile();
return await f.arrayBuffer();
}
throw new Error('no source for file');
}
// Like getArrayBuffer, but also returns the server version token
// ({etag, lastModified}) captured from the content GET. The editors use
// it to send an If-Match precondition on save so a concurrent edit is
// rejected (412) instead of silently clobbered. FS-Access mode has no
// server version — etag/lastModified are null and the precondition is a
// clean no-op (a single locally-picked file has no concurrency).
async function getContentWithVersion(node) {
if (state.source === 'server' && node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var buf = await resp.arrayBuffer();
return {
buf: buf,
etag: resp.headers.get('ETag') || null,
lastModified: resp.headers.get('Last-Modified') || null
};
}
if (node.handle) {
var f = await node.handle.getFile();
return { buf: await f.arrayBuffer(), etag: null, lastModified: null };
}
throw new Error('no source for file');
}
async function getBlobUrl(node) {
// Server-served files (including zip members at "<…>.zip/<member>"
// URLs) load straight from the server — preserves Content-Type
// and lets relative links inside HTML resolve back to the server.
if (state.source === 'server' && node.url) {
return { url: node.url, fromServer: true };
}
var buf = await getArrayBuffer(node);
var blob = new Blob([buf], { type: getMime(node.ext) });
return { url: URL.createObjectURL(blob), fromServer: false };
}
// ── Editor lifecycle helpers ─────────────────────────────────────────────
// The markdown and YAML plugins each mount a long-lived editor into the
// preview pane. Switching files (or clearing the pane) must dispose the
// live editor first — otherwise the Toast UI instance, its DOM, and its
// document-level resizer listeners leak when we overwrite the container.
function editorModules() {
var m = window.app.modules;
return [m.markdown, m.yamledit].filter(Boolean);
}
function disposeEditors() {
editorModules().forEach(function (mod) {
if (mod.dispose) { try { mod.dispose(); } catch (_) { /* ignore */ } }
});
}
// The editor module (if any) holding unsaved edits, else null.
function dirtyEditor() {
var mods = editorModules();
for (var i = 0; i < mods.length; i++) {
if (mods[i].isDirty && mods[i].isDirty()) return mods[i];
}
return null;
}
function samePreviewNode(a, b) {
if (!a || !b) return false;
if (a === b) return true;
if (a.url && b.url) return a.url === b.url;
return a.name === b.name && a.parentId === b.parentId;
}
// Tear down any live editor and blank the pane. Used by callers that
// reset the preview directly (rescope, popstate) so they don't leak the
// editor or strand its dirty state.
function clearPreview() {
disposeEditors();
var container = document.getElementById('previewBody');
if (container) container.innerHTML = '';
toggleTargetNode = null;
var tb = document.getElementById('previewViewToggle');
if (tb) tb.classList.add('hidden');
}
// Warn before a full page unload (reload / close / external nav) drops
// unsaved editor changes. SPA-internal switches are guarded in
// renderInline; this catches the browser-level exit.
window.addEventListener('beforeunload', function (e) {
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
});
// ── Rendered ⇄ Source toggle ─────────────────────────────────────────────
// Some types we can RENDER, not just edit (.html). Those show rendered by
// default (sandboxed — no scripts, no same-origin) with a toggle to the
// CodeMirror source view. Markdown has its own rendered/source toggle, so
// it's not here. Extend RENDERABLE to add more (svg already previews as an
// image; csv could render as a table later).
var RENDERABLE = { html: 1, htm: 1 };
function isRenderable(ext) { return !!RENDERABLE[(ext || '').toLowerCase()]; }
function nodeKey(node) { return (node && (node.url || node.name)) || ''; }
// Per-node mode; 'rendered' is the default. Only the node the user last
// toggled is remembered, so switching files resets to rendered.
var viewToggle = { key: null, mode: 'rendered' };
var toggleTargetNode = null;
function effectiveMode(node) {
return (viewToggle.key && viewToggle.key === nodeKey(node)) ? viewToggle.mode : 'rendered';
}
function ensureViewToggleBtn() {
var btn = document.getElementById('previewViewToggle');
if (btn) return btn;
var popout = document.getElementById('previewPopout');
if (!popout || !popout.parentNode) return null;
btn = document.createElement('button');
btn.id = 'previewViewToggle';
btn.type = 'button';
btn.className = 'btn btn-sm btn-secondary hidden';
popout.parentNode.insertBefore(btn, popout);
btn.addEventListener('click', function () {
if (!toggleTargetNode) return;
var next = effectiveMode(toggleTargetNode) === 'rendered' ? 'source' : 'rendered';
viewToggle = { key: nodeKey(toggleTargetNode), mode: next };
renderInline(toggleTargetNode, { toggle: true });
});
return btn;
}
// ── Inline rendering ────────────────────────────────────────────────────
// Bumped on every renderInline entry; a render that loses the race
// (a newer selection started while its bytes were in flight) bails
// before writing stale content into the shared pane.
var renderSeq = 0;
function renderEmpty(container, msg) {
container.innerHTML = '<div class="preview-empty">' + escapeHtml(msg) + '</div>';
}
function renderError(container, msg) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ escapeHtml(msg) + '</div>';
}
async function renderInline(node, opts) {
opts = opts || {};
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
if (!container) return;
// Guard unsaved editor edits before we tear the editor down.
var dm = dirtyEditor();
if (dm) {
var cur = dm.currentNode ? dm.currentNode() : null;
if (samePreviewNode(cur, node) && !opts.toggle) {
// Re-selecting the file we're already editing — don't reload
// and clobber the in-progress edits. (A deliberate view toggle
// falls through to the discard prompt below.)
return;
}
if (opts.auto) {
// Keyboard/auto preview (cursor walking the tree): leave the
// dirty editor in place rather than prompting on every key.
return;
}
var label = cur ? cur.name : 'this file';
if (!window.confirm('Discard unsaved changes to ' + label + '?')) return;
}
// Safe to replace the pane now: dispose any live editor so its
// instance + document-level listeners don't leak.
disposeEditors();
var seq = ++renderSeq;
if (titleEl) titleEl.textContent = node.name;
if (metaEl) {
var meta = [];
if (!node.isDir && !node.isZip) meta.push(fmtSize(node.size));
if (node.ext) meta.push(node.ext.toUpperCase());
metaEl.textContent = meta.join(' · ');
}
if (popoutBtn) popoutBtn.classList.remove('hidden');
var ext = (node.ext || '').toLowerCase();
// Rendered ⇄ Source toggle button — shown only for renderable types.
var toggleBtn = ensureViewToggleBtn();
if (toggleBtn) {
if (isRenderable(ext)) {
toggleTargetNode = node;
toggleBtn.classList.remove('hidden');
toggleBtn.textContent = effectiveMode(node) === 'rendered' ? '⟨⟩ Source' : '◱ Preview';
} else {
toggleBtn.classList.add('hidden');
}
}
// Renderable types (.html) — show rendered by default, sandboxed for
// safety (no scripts, no same-origin). The toggle flips to source.
if (isRenderable(ext) && effectiveMode(node) === 'rendered') {
try {
var rinfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<iframe class="preview-iframe" sandbox src="'
+ escapeHtml(rinfo.url) + '"></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown &&
typeof window.app.modules.markdown.render === 'function') {
try {
await window.app.modules.markdown.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'Markdown render failed: ' + (e.message || e));
}
return;
}
// CodeMirror editor: the general editor for editable text files that
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
// Guided dialogs (Manage access, …) are the front door for the common
// .zddc tasks; this is the full/raw edit surface.
var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) {
try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, 'Editor failed: ' + (e.message || e));
}
return;
}
// PDF → iframe (HTML now routes to the editor above).
if (ext === 'pdf') {
try {
var info = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Plain images (jpg/png/gif/webp/svg) → <img>. TIFF goes through preview-lib.
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
try {
var imgInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<img class="preview-image" alt="' + escapeHtml(node.name)
+ '" src="' + escapeHtml(imgInfo.url) + '">';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
if (preview && preview.isTiff(ext)) {
try {
var tiffBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
await preview.renderTiff(document, container, tiffBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to render TIFF: ' + (e.message || e));
}
return;
}
if (preview && preview.isZip(ext)) {
try {
var zipBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
await preview.renderZipListing(document, container, zipBuf, { fileName: node.name });
} catch (e) {
renderError(container, 'Failed to read ZIP: ' + (e.message || e));
}
return;
}
// Office docs (.docx via docx-preview, .xlsx/.xls via SheetJS) →
// shared/preview-lib renderers. .doc/.ppt etc. fall through to the
// download fallback below.
if (preview && preview.isOffice(ext)) {
try {
var officeBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
container.innerHTML = '';
if (ext === 'docx') {
await preview.renderDocx(document, container, officeBuf, { fileName: node.name });
} else {
await preview.renderXlsx(document, container, officeBuf, { fileName: node.name });
}
} catch (e) {
renderError(container, 'Failed to render ' + ext.toUpperCase() + ': ' + (e.message || e));
}
return;
}
if (preview && preview.isText(ext)) {
try {
var txtBuf = await getArrayBuffer(node);
if (seq !== renderSeq) return;
var text = new TextDecoder('utf-8', { fatal: false }).decode(txtBuf);
var MAX = 200000;
if (text.length > MAX) {
text = text.substring(0, MAX) + '\n\n... (truncated, '
+ (text.length - MAX) + ' more chars)';
}
container.innerHTML = '';
var pre = document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
container.appendChild(pre);
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Unknown type — offer a download link.
try {
var fallbackInfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML =
'<div class="preview-empty">'
+ 'No inline preview for <code>.' + escapeHtml(ext) + '</code>. '
+ '<br><a class="btn btn-primary btn-sm" download="' + escapeHtml(node.name)
+ '" href="' + escapeHtml(fallbackInfo.url) + '" style="margin-top:1rem">'
+ 'Download ' + escapeHtml(node.name) + '</a>'
+ '</div>';
} catch (e) {
renderError(container, 'No source for ' + node.name);
}
}
// ── Popup window (kept for "Pop out" button) ────────────────────────────
function popupShell(node, primaryUrl) {
var safeName = escapeHtml(node.name);
var safeHref = escapeHtml(primaryUrl);
var ext = (node.ext || '').toLowerCase();
var contentHtml;
if (ext === 'pdf') {
contentHtml = '<iframe src="' + safeHref + '"></iframe>';
} else if (ext === 'html' || ext === 'htm') {
contentHtml = '<iframe src="' + safeHref + '" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>';
} else if (preview && preview.isImage(ext) && !preview.isTiff(ext)) {
contentHtml = '<img class="preview-image" src="' + safeHref + '" alt="' + safeName + '">';
} else {
contentHtml = '<div id="previewContent"><div class="loading">Loading preview…</div></div>';
}
return '<!DOCTYPE html><html><head><meta charset="UTF-8">'
+ '<title>' + safeName + ' — preview</title><style>'
+ '*{margin:0;padding:0;box-sizing:border-box;}'
+ 'body{display:flex;flex-direction:column;height:100vh;'
+ 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}'
+ '.toolbar{display:flex;align-items:center;gap:.5rem;padding:.5rem 1rem;'
+ 'background:#f5f5f5;border-bottom:1px solid #ddd;}'
+ '.toolbar h1{flex:1;font-size:.95rem;font-weight:500;overflow:hidden;'
+ 'text-overflow:ellipsis;white-space:nowrap;}'
+ '.btn{padding:.4rem .8rem;font-size:.85rem;border:1px solid #ccc;'
+ 'border-radius:4px;background:white;cursor:pointer;}'
+ '.btn:hover{background:#e8e8e8;}'
+ 'iframe{flex:1;width:100%;border:none;}'
+ '#previewContent{flex:1;overflow:auto;display:flex;flex-direction:column;}'
+ '.loading{display:flex;align-items:center;justify-content:center;height:100%;'
+ 'color:#666;font-size:1.1rem;}'
+ 'img.preview-image{max-width:100%;max-height:100%;object-fit:contain;'
+ 'margin:auto;display:block;}'
+ 'pre.preview-text{padding:1rem;font-family:Consolas,Monaco,monospace;'
+ 'font-size:.85rem;white-space:pre-wrap;word-wrap:break-word;}'
+ '</style></head><body>'
+ '<div class="toolbar"><h1>' + safeName + '</h1>'
+ '<button class="btn" onclick="downloadFile()">Download</button></div>'
+ contentHtml
+ '<script>'
+ 'var blobUrl=' + JSON.stringify(primaryUrl) + ';'
+ 'var fileName=' + JSON.stringify(node.name) + ';'
+ 'function downloadFile(){var a=document.createElement("a");'
+ 'a.href=blobUrl;a.download=fileName;document.body.appendChild(a);'
+ 'a.click();document.body.removeChild(a);}'
+ '</' + 'script></body></html>';
}
async function renderInPopupWindow(node, win, info) {
var ext = (node.ext || '').toLowerCase();
if (ext === 'pdf' || ext === 'html' || ext === 'htm') return;
if (preview && preview.isImage(ext) && !preview.isTiff(ext)) return;
var c = win.document.getElementById('previewContent');
if (!c) return;
try {
if (preview && preview.isTiff(ext)) {
var tb = await getArrayBuffer(node);
await preview.renderTiff(win.document, c, tb, { fileName: node.name });
} else if (preview && preview.isZip(ext)) {
var zb = await getArrayBuffer(node);
await preview.renderZipListing(win.document, c, zb, { fileName: node.name });
} else if (preview && preview.isOffice(ext)) {
var ob = await getArrayBuffer(node);
if (ext === 'docx') {
await preview.renderDocx(win.document, c, ob, { fileName: node.name });
} else {
await preview.renderXlsx(win.document, c, ob, { fileName: node.name });
}
} else if (preview && preview.isText(ext)) {
var txb = await getArrayBuffer(node);
var text = new TextDecoder('utf-8', { fatal: false }).decode(txb);
var MAX = 200000;
if (text.length > MAX) text = text.substring(0, MAX) + '\n\n... (truncated)';
var pre = win.document.createElement('pre');
pre.className = 'preview-text';
pre.textContent = text;
c.innerHTML = '';
c.appendChild(pre);
} else {
c.innerHTML = '<div class="loading">No inline preview for .'
+ escapeHtml(ext) + ' — click Download.</div>';
}
} catch (e) {
c.innerHTML = '<div class="loading">Error: ' + escapeHtml(e.message || e) + '</div>';
}
}
async function renderInPopup(node) {
// Editor-type files (markdown, yaml/.zddc, code text) can't be hosted
// in the lightweight popup window — they need the bundled editor. Pop
// them out as the FULL browse app deep-linked to the file, which loads
// the real editor in a new window. Server mode only; HTML keeps its
// rendered popup. Falls through to the lightweight popup otherwise.
var pext = (node.ext || '').toLowerCase();
var ym = window.app.modules.yamledit;
var isEditorType = pext === 'md' || pext === 'markdown'
|| (ym && ym.handles && ym.handles(node) && pext !== 'html' && pext !== 'htm');
if (isEditorType && window.app.state.source === 'server' && node.url) {
var slash = node.url.lastIndexOf('/');
var pdir = slash >= 0 ? node.url.slice(0, slash + 1) : '/';
var pbase = slash >= 0 ? node.url.slice(slash + 1) : node.url;
var pp = new URLSearchParams();
try { pp.set('file', decodeURIComponent(pbase)); } catch (_e) { pp.set('file', pbase); }
if (window.app.state.showHidden) pp.set('hidden', '1');
window.open(pdir + '?' + pp.toString(), '_blank', 'noopener');
return;
}
var info;
try {
info = await getBlobUrl(node);
} catch (e) {
window.app.modules.events.statusError('Pop-out failed: ' + e.message);
return;
}
var html = popupShell(node, info.url);
var win = state.previewWindow;
if (win && !win.closed) {
win.document.open();
win.document.write(html);
win.document.close();
win.focus();
} else {
var w = Math.round(screen.width * 0.6);
var h = Math.round(screen.height * 0.8);
var left = Math.round((screen.width - w) / 2);
var top = Math.round((screen.height - h) / 2);
win = window.open('', 'browseFilePreview',
'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top
+ ',resizable=yes,scrollbars=yes');
if (!win) {
window.open(info.url, '_blank', 'noopener');
return;
}
win.document.write(html);
win.document.close();
win.focus();
state.previewWindow = win;
}
await renderInPopupWindow(node, win, info);
}
// ── Public entry ────────────────────────────────────────────────────────
async function showFilePreview(node, opts) {
opts = opts || {};
// Table-leaf dirs (mdl/rsk/ssr, default_tool=tables) open the tables
// tool inline in the preview pane instead of expanding/navigating.
if (window.app.modules.util.isTableLeaf(node)) return renderTableLeaf(node);
if (node.isDir) return;
if (opts.popup) return renderInPopup(node);
return renderInline(node, opts);
}
// renderTableLeaf embeds the tables tool for a default_tool=tables
// directory as an iframe scoped to that dir — the same in-pane tool
// embed pattern grid.js uses for classifier. Server mode only (the
// default_tool listing hint that flags a table-leaf is absent offline,
// so this never fires on file:// — the dir stays an ordinary folder).
function renderTableLeaf(node) {
disposeEditors();
var container = document.getElementById('previewBody');
var titleEl = document.getElementById('previewTitle');
var metaEl = document.getElementById('previewMeta');
var popoutBtn = document.getElementById('previewPopout');
if (!container) return;
if (titleEl) titleEl.textContent = node.displayName || node.name;
if (metaEl) metaEl.textContent = 'table';
if (popoutBtn) popoutBtn.classList.add('hidden');
if (window.app.state.source !== 'server' || !node.url) {
renderEmpty(container, 'Table view is available in server mode.');
return;
}
// The tables tool is served at the dir's NO-SLASH URL (the cascade's
// default_tool routing). The trailing-slash form would serve the
// browse listing instead, and <dir>/tables.html 404s for a virtual
// dir (mdl/rsk/ssr have no on-disk folder). So strip the slash.
var src = node.url.replace(/\/+$/, '');
container.innerHTML = '';
var frame = document.createElement('iframe');
frame.className = 'preview-iframe';
frame.src = src;
frame.setAttribute('title', 'Table: ' + (node.displayName || node.name));
container.appendChild(frame);
}
window.app.modules.preview = {
showFilePreview: showFilePreview,
// Tear down any live editor + blank the pane (rescope / popstate).
clearPreview: clearPreview,
// Expose for the markdown plugin so it can read file bytes.
getArrayBuffer: getArrayBuffer,
// Like getArrayBuffer but also returns the {etag, lastModified}
// version token — the editors use it for optimistic-concurrency saves.
getContentWithVersion: getContentWithVersion
};
})();

364
browse/js/stage.js Normal file
View file

@ -0,0 +1,364 @@
// stage.js — Stage and Unstage workflow modals.
//
// In the flat-peer layout working/ and staging/ are top-level peers,
// each partitioned by party: working/<party>/<file> and
// staging/<party>/<batch>/<file>. Stage and Unstage are per-party — the
// destination batch is always inside the SAME party's staging peer. The
// party context is read from the source file's path.
//
// Stage: move a file from working/<party>/<…> into a transmittal folder
// under staging/<party>/<…>. Modal lists existing transmittal folders in
// the party's staging/ plus a "New transmittal folder…" option that
// prompts for a ZDDC-conforming name and mkdirs it before the move.
//
// Unstage: move a file from staging/<party>/<transmittal>/ back to
// working/<party>/ (overridable).
//
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
// endpoint is needed; the client just orchestrates one POST per file
// (a multi-file selection iterates and reports aggregate status).
(function () {
'use strict';
function status(msg, level) {
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
// Re-fetch the current listing so the moved file appears/disappears
// without a manual reload. Best-effort: absent on older builds.
function refreshListing() {
var ev = window.app.modules.events;
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
}
// Guard against a second invocation while a move is mid-flight (e.g. a
// double menu click). The picker modal also blocks re-entry while open.
var busy = false;
var escapeHtml = window.app.modules.util.escapeHtml;
// ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its path matches
// /<project>/working/<party>/<…>. Unstageable if it matches
// /<project>/staging/<party>/<transmittal>/<…>. Both are path-shape
// queries — content/ACL is enforced server-side.
var WORKSPACE_PEERS = { working: 1, staging: 1, reviewing: 1, incoming: 1 };
// projectPartySlot returns { project, party, slot, rest } when path
// matches /<project>/<slot>/<party>/<rest…> for a workspace peer, or
// null on non-match.
function projectPartySlot(path) {
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
if (rel.length < 3) return null;
var slot = rel[1].toLowerCase();
if (!WORKSPACE_PEERS[slot]) return null;
return { project: rel[0], slot: slot, party: rel[2], rest: rel.slice(3) };
}
function isStageableFile(node) {
if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree;
if (!tree) return false;
var p = projectPartySlot(tree.pathFor(node));
return !!(p && p.slot === 'working' && p.rest.length >= 1);
}
function isUnstageableFile(node) {
if (!node || node.isDir || node.virtual) return false;
var tree = window.app.modules.tree;
if (!tree) return false;
var p = projectPartySlot(tree.pathFor(node));
// archive/<party>/staging/<transmittal-folder>/<file> — at
// least one folder segment between staging/ and the file.
return !!(p && p.slot === 'staging' && p.rest.length >= 2);
}
// ── Server helpers ─────────────────────────────────────────────────
// Fetch directory listing JSON. Returns [] on 404.
async function listDir(absUrl) {
if (!absUrl.endsWith('/')) absUrl += '/';
var resp = await fetch(absUrl, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (resp.status === 404) return [];
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + absUrl);
var data = await resp.json();
return Array.isArray(data) ? data : [];
}
async function fetchStagingFolders(project, party) {
var entries = await listDir(
'/' + project + '/staging/' + encodeURIComponent(party) + '/');
return entries
.filter(function (e) { return e && e.isDir; })
.map(function (e) { return e.name; });
}
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
async function mkdir(absUrl) {
var resp = await fetch(absUrl, {
method: 'POST',
headers: { 'X-ZDDC-Op': 'mkdir' },
credentials: 'same-origin'
});
if (!resp.ok) {
var text = ''; try { text = await resp.text(); } catch (_e) {}
throw new Error('mkdir ' + absUrl + ' failed (' + resp.status + '): ' + text);
}
}
// POST X-ZDDC-Op: move + X-ZDDC-Destination header. Reuses the
// file-API move primitive (atomic os.Rename, dual ACL gates).
async function moveFile(srcUrl, dstUrl) {
var resp = await fetch(srcUrl, {
method: 'POST',
headers: {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dstUrl
},
credentials: 'same-origin'
});
if (!resp.ok) {
var text = ''; try { text = await resp.text(); } catch (_e) {}
throw new Error('move ' + srcUrl + ' → ' + dstUrl + ' failed (' + resp.status + '): ' + text);
}
}
// ── Stage picker modal ─────────────────────────────────────────────
function openStagePicker(initial) {
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);';
var folderList = initial.folders.map(function (name) {
return '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
'<input type="radio" name="stage-target" value="' + escapeHtml(name) + '">' +
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span>' +
'</label>';
}).join('');
box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Stage ' +
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'Pick the transmittal folder in <code>staging/</code> these files should join. ' +
'You can move them back to <code>working/</code> later if they need correction.' +
'</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(folderList || '<em style="color:#888;">No existing transmittal folders in staging/.</em>') +
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
'<input type="radio" name="stage-target" value="__new__">' +
'<span><strong>New transmittal folder…</strong></span>' +
'</label>' +
'</div>' +
'<div id="stage-newname-row" style="display:none;font-size:0.9rem;">' +
'<label for="stage-newname">Folder name (ZDDC convention)</label><br>' +
'<input id="stage-newname" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT">' +
'<div id="stage-newname-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;"></div>' +
'</div>' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="stage-cancel">Cancel</button>' +
'<button type="button" id="stage-submit" class="btn-primary">Stage</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var newRow = box.querySelector('#stage-newname-row');
var newInput = box.querySelector('#stage-newname');
var feedback = box.querySelector('#stage-newname-feedback');
box.querySelectorAll('input[name="stage-target"]').forEach(function (r) {
r.addEventListener('change', function () {
newRow.style.display = (r.value === '__new__' && r.checked) ? '' : 'none';
if (r.value === '__new__' && r.checked) newInput.focus();
});
});
newInput.addEventListener('input', function () {
var v = newInput.value.trim();
if (!v) { feedback.textContent = ''; 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;
} else {
feedback.style.color = '#c33';
feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT';
}
});
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
box.querySelector('#stage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#stage-submit').addEventListener('click', function () {
var sel = box.querySelector('input[name="stage-target"]:checked');
if (!sel) { status('Pick a destination folder.', 'error'); return; }
if (sel.value === '__new__') {
var name = newInput.value.trim();
var parsed = window.zddc.parseFolder(name);
if (!parsed || !parsed.valid) {
status('Folder name must conform to ZDDC convention.', 'error');
return;
}
close(); resolve({ create: true, folderName: name });
} else {
close(); resolve({ create: false, folderName: sel.value });
}
});
});
}
// ── Unstage picker modal ───────────────────────────────────────────
function openUnstagePicker(initial) {
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 =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Unstage ' +
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
'Move these files back into your drafting workspace under <code>working/</code> ' +
'so they can be corrected. Stage them again when ready.' +
'</p>' +
'<label for="unstage-target" style="font-size:0.9rem;">Destination folder</label>' +
'<input id="unstage-target" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' +
escapeHtml(initial.defaultTarget) + '">' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="unstage-cancel">Cancel</button>' +
'<button type="button" id="unstage-submit" class="btn-primary">Unstage</button>' +
'</div>';
overlay.appendChild(box);
document.body.appendChild(overlay);
var input = box.querySelector('#unstage-target');
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
box.querySelector('#unstage-cancel').addEventListener('click', function () {
close(); reject(new Error('cancelled'));
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
});
box.querySelector('#unstage-submit').addEventListener('click', function () {
var target = input.value.trim();
if (!target) { status('Destination is required.', 'error'); return; }
close(); resolve({ target: target });
});
});
}
// ── Action drivers ─────────────────────────────────────────────────
async function invokeStage(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var srcUrl = tree.pathFor(node);
var info = projectPartySlot(srcUrl);
if (!info || info.slot !== 'working') {
status('Stage applies only to files under working/<party>/.', 'error');
return;
}
var stagingBase = '/' + info.project + '/staging/' +
encodeURIComponent(info.party) + '/';
var folders;
try { folders = await fetchStagingFolders(info.project, info.party); }
catch (e) {
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
return;
}
var choice;
try {
choice = await openStagePicker({ fileCount: 1, folders: folders });
} catch (_e) { return; }
busy = true;
try {
// Stage is a non-atomic mkdir-then-move (no single composite op).
// Track whether the folder was freshly created so that, if the
// move then fails, we can tell the user the folder exists but the
// file didn't make it — otherwise an empty folder appears with a
// generic "move failed" and no explanation.
var createdFolder = false;
if (choice.create) {
try {
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
createdFolder = true;
} catch (e) {
status((e && e.message) || 'mkdir failed', 'error');
return;
}
}
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
var msg = (e && e.message) || 'move failed';
if (createdFolder) {
msg += ' — the new folder "' + choice.folderName
+ '" was created but ' + node.name + ' was not moved into it.';
}
status(msg, 'error');
refreshListing(); // surface the (possibly empty) new folder
return;
}
status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success');
refreshListing();
} finally {
busy = false;
}
}
async function invokeUnstage(node) {
if (busy) return;
var tree = window.app.modules.tree;
if (!tree) return;
var srcUrl = tree.pathFor(node);
var info = projectPartySlot(srcUrl);
if (!info || info.slot !== 'staging') {
status('Unstage applies only to files under staging/<party>/.', 'error');
return;
}
var defaultTarget = '/' + info.project + '/working/' +
encodeURIComponent(info.party) + '/';
var choice;
try {
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
} catch (_e) { return; }
var target = choice.target;
if (!target.endsWith('/')) target += '/';
var dstUrl = target + encodeURIComponent(node.name);
busy = true;
try {
try {
await moveFile(srcUrl, dstUrl);
} catch (e) {
status((e && e.message) || 'move failed', 'error');
return;
}
status('Unstaged ' + node.name + ' → ' + target, 'success');
refreshListing();
} finally {
busy = false;
}
}
window.app.modules.stage = {
isStageableFile: isStageableFile,
isUnstageableFile: isUnstageableFile,
invokeStage: invokeStage,
invokeUnstage: invokeUnstage
};
})();

718
browse/js/tree.js Normal file
View file

@ -0,0 +1,718 @@
// tree.js — in-memory tree model + DOM rendering.
//
// Nodes are stored flat in state.nodes (Map by id). The visible
// render is a depth-first walk starting from state.rootIds, skipping
// children of unexpanded folders. This decouples model from DOM and
// keeps re-renders linear in the visible-row count.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
// ── Model helpers ────────────────────────────────────────────────────
function newNode(raw, parentId, depth) {
var id = state.nextId++;
// A .zip file is treated as a folder for tree purposes — the
// chevron expands it. On expand, server mode fetches the
// server's "<…>.zip/" virtual-directory listing; offline mode
// opens the zip with JSZip behind a ZipDirectoryHandle. Either
// way the zip's members become ordinary directory/file nodes.
var isZip = !raw.isDir && raw.ext === 'zip';
var node = {
id: id,
name: raw.name,
// displayName is the rendered label when set by the parent
// .zddc display: map. Sort + lookup continues to use .name
// (the on-disk basename) so URL composition stays canonical.
displayName: raw.displayName || '',
isDir: raw.isDir,
size: raw.size,
modTime: raw.modTime,
ext: raw.ext,
url: raw.url,
handle: raw.handle,
depth: depth,
parentId: parentId,
expanded: false,
loaded: false,
childIds: [],
isZip: isZip,
_zipDirHandle: null, // cached ZipDirectoryHandle (offline / nested zips)
// True when this entry was synthesized client-side (e.g.
// canonical project folders that don't exist on disk yet).
// Rendered with a muted style + an "(empty)" hint.
virtual: !!raw.virtual,
// Server-computed write authority. Editors (preview-yaml,
// preview-markdown) consult this via canSave() to decide
// whether to mount read-only. Dropping the field here
// silently makes every node read-only — the actual root
// cause behind "I'm admin but the editor says read-only".
writable: !!raw.writable,
// Server-computed verb set (canonical "rwcda" subset).
// Per-entry permission gating reads this via
// zddc.cap.has(node, verb). Three states:
// "rw…" — zddc-server explicit grant
// "" — zddc-server explicit zero grant
// undefined — Caddy / FS-API listings (no verbs field).
// Per-entry gates skip the cascade check
// and fall back to canMutate / writable.
verbs: typeof raw.verbs === 'string' ? raw.verbs : undefined,
// Cascade default tool for a directory entry. When "tables"
// (mdl/rsk/ssr), the node is a TABLE LEAF: rendered without a
// chevron and, on click, opens the tables tool in the preview
// pane instead of expanding/navigating. See isTableLeaf().
defaultTool: raw.defaultTool || ''
};
state.nodes.set(id, node);
return node;
}
function clearTree() {
state.nodes.clear();
state.rootIds = [];
state.nextId = 1;
}
// Sort an array of nodes by current sort key. Folders always come
// first within a level (mimics common file managers).
function sortNodes(ids) {
var key = state.sort.key;
var dir = state.sort.dir;
ids.sort(function (a, b) {
var na = state.nodes.get(a);
var nb = state.nodes.get(b);
// Folders before files
if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1;
var av, bv;
switch (key) {
case 'size':
av = na.size; bv = nb.size; break;
case 'ext':
av = na.ext; bv = nb.ext; break;
case 'date':
av = na.modTime ? na.modTime.getTime() : 0;
bv = nb.modTime ? nb.modTime.getTime() : 0;
break;
default:
av = na.name.toLowerCase();
bv = nb.name.toLowerCase();
}
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return na.name.toLowerCase().localeCompare(nb.name.toLowerCase());
});
}
// Populate state with the root listing.
function setRoot(rawEntries) {
clearTree();
rawEntries.forEach(function (raw) {
var n = newNode(raw, null, 0);
state.rootIds.push(n.id);
});
sortNodes(state.rootIds);
}
// Populate a folder's children. Caller passes raw entries in any order.
function setChildren(parentId, rawEntries) {
var parent = state.nodes.get(parentId);
if (!parent) return;
// Drop any existing children first (re-load case).
parent.childIds.forEach(function (id) { state.nodes.delete(id); });
parent.childIds = [];
rawEntries.forEach(function (raw) {
var n = newNode(raw, parentId, parent.depth + 1);
parent.childIds.push(n.id);
});
sortNodes(parent.childIds);
parent.loaded = true;
}
// Walk nodes in render order. Skips the children of a collapsed
// expandable. When state.filterAST is set, also skips nodes that
// don't match (files) or whose subtree has no matches (folders),
// and force-walks into folders that have matching descendants so
// those matches are visible even when the user hadn't expanded
// the folder. The user's actual node.expanded flag stays untouched
// so clearing the filter restores their original layout.
function visibleIds() {
var out = [];
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
if (state.filterAST && !passesFilter(n)) continue;
out.push(ids[i]);
if (n.isDir || n.isZip) {
var forceWalk = !!state.filterAST;
if (forceWalk || n.expanded) walk(n.childIds);
}
}
}
// Re-sort everything at all levels so a sort change reorders
// already-loaded children consistently.
sortNodes(state.rootIds);
state.nodes.forEach(function (n) {
if ((n.isDir || n.isZip) && n.loaded) sortNodes(n.childIds);
});
walk(state.rootIds);
return out;
}
// ── Filter ─────────────────────────────────────────────────────────────
// Build the haystack string we run the filter AST against. We
// concatenate every searchable field — name, displayName, plus any
// ZDDC parts the basename parses to — so users can type a tracking
// number, a status code, a date, or a piece of the title.
function filterHaystack(node) {
var parts = [node.name];
if (node.displayName) parts.push(node.displayName);
var z = window.zddc;
if (z) {
var parsed = node.isDir ? z.parseFolder(node.name)
: z.parseFilename(node.name);
if (parsed && parsed.valid) {
if (parsed.trackingNumber) parts.push(parsed.trackingNumber);
if (parsed.title) parts.push(parsed.title);
if (parsed.status) parts.push(parsed.status);
if (parsed.revision) parts.push(parsed.revision);
if (parsed.date) parts.push(parsed.date);
}
}
return parts.join(' ');
}
function nodeMatchesFilter(node) {
if (!state.filterAST) return true;
return window.zddc.filter.matches(filterHaystack(node), state.filterAST);
}
// True when this node should appear in the filtered view: either
// the node itself matches, or it's an expandable with at least
// one matching descendant (so we keep the path to a match visible).
function passesFilter(node) {
if (!state.filterAST) return true;
if (nodeMatchesFilter(node)) return true;
if (!(node.isDir || node.isZip)) return false;
if (!node.loaded) return false; // unloaded subtrees aren't searched
for (var i = 0; i < node.childIds.length; i++) {
var child = state.nodes.get(node.childIds[i]);
if (child && passesFilter(child)) return true;
}
return false;
}
// Is this folder being "forced open" by an active filter because
// a descendant matches? Used by rowHtml to render the chevron as
// expanded without mutating node.expanded.
function filterForcesOpen(node) {
if (!state.filterAST) return false;
if (!(node.isDir || node.isZip)) return false;
return passesFilter(node) && !nodeMatchesFilter(node);
}
// ── Rendering ────────────────────────────────────────────────────────
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());
}
var escapeHtml = window.app.modules.util.escapeHtml;
// Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id),
// which inlines `<svg><use href="#id"/></svg>` so the page CSS
// can size and tint via currentColor.
//
// book-marked PDF file-pen markdown
// file-text word / txt file-spreadsheet spreadsheet
// presentation slides file-image image
// file-video video file-audio audio
// ruler CAD / drawing globe web
// file-cog config / .zddc file-code source code
// file-archive non-nav archive folder-archive .zip (navigable)
// file generic folder directory
var ICON_BY_EXT = {
pdf: 'icon-book-marked',
md: 'icon-file-pen', markdown: 'icon-file-pen',
doc: 'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text',
xls: 'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet',
csv: 'icon-file-spreadsheet', ods: 'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet',
ppt: 'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation',
txt: 'icon-file-text', log: 'icon-file-text',
jpg: 'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image',
gif: 'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image',
bmp: 'icon-file-image', tif: 'icon-file-image', tiff: 'icon-file-image',
ico: 'icon-file-image', heic: 'icon-file-image',
mp4: 'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video',
mkv: 'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video',
mp3: 'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio',
ogg: 'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio',
dwg: 'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler',
stp: 'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler',
html: 'icon-globe', htm: 'icon-globe',
yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog',
toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog',
conf: 'icon-file-cog', cfg: 'icon-file-cog',
'7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive',
gz: 'icon-file-archive', tgz: 'icon-file-archive',
bz2: 'icon-file-archive', xz: 'icon-file-archive',
// Code — share one glyph across languages so users build the
// "this is source" pattern. Distinguishing per language would
// be visual noise without much added signal.
js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code',
ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code',
py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code',
c: 'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code',
h: 'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code',
rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code',
bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code',
swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code',
css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code'
};
function symbolForNode(node) {
// Table-leaf dirs (mdl/rsk/ssr) read as a table, not a folder.
if (window.app.modules.util.isTableLeaf(node)) return 'icon-file-spreadsheet';
if (node.isDir) return 'icon-folder';
if (node.isZip) return 'icon-folder-archive';
// `.zddc` (no extension) is the cascade config — same family
// as yaml. Match the literal basename before falling through
// to the extension table.
if (node.name === '.zddc') return 'icon-file-cog';
var ext = (node.ext || '').toLowerCase();
return ICON_BY_EXT[ext] || 'icon-file';
}
function iconForNode(node) {
return window.zddc.icons.html(symbolForNode(node));
}
// Render the label cell for a row. When the basename parses as a
// ZDDC-conformant filename (files) or transmittal folder name
// (directories), split into a two-line layout:
// top — trackingNumber · [revision · ]status (small, muted)
// bot — title (normal weight)
// Otherwise fall back to a single line.
//
// .zddc `display:` overrides always render as a single line — the
// operator chose that string for a reason; we don't try to second-
// guess it by parsing for ZDDC structure.
function labelHtml(node) {
// No native title="…" — the rich hovercard (browse/js/hovercard.js)
// replaces the browser tooltip with a metadata view that's
// both more informative and styled to match the rest of the UI.
if (node.displayName) {
return '<span class="tree-name__label">'
+ escapeHtml(node.displayName)
+ '</span>';
}
var z = window.zddc;
var parsed = null;
if (z) {
parsed = node.isDir
? z.parseFolder(node.name)
: z.parseFilename(node.name);
}
if (parsed && parsed.valid) {
// Folders carry a date (no revision); files carry a
// revision (no date). Status is present on both.
var parts;
if (node.isDir) {
parts = [parsed.date, parsed.trackingNumber, parsed.status];
} else {
parts = [parsed.trackingNumber, parsed.revision, parsed.status];
}
var metaText = parts.filter(Boolean).join(' · ');
// Title-first: primary content on the top line so the row
// reads like a normal file manager / mail list. Meta sits
// below as the supporting "subtitle" — same hierarchy
// pattern as Gmail, Linear, Notion file rows.
return '<span class="tree-name__label tree-name__label--zddc">'
+ '<span class="tree-name__title">'
+ escapeHtml(parsed.title)
+ '</span>'
+ '<span class="tree-name__meta">'
+ escapeHtml(metaText)
+ '</span>'
+ '</span>';
}
return '<span class="tree-name__label">'
+ escapeHtml(node.name)
+ '</span>';
}
// Render a single tree row as a flat <div>. Indentation via
// padding-left so the row's hover background spans the full
// pane width. Files are rendered as plain rows (no anchor) —
// the preview pane handles click navigation, and a Ctrl/Cmd-
// click can fall back to opening the file's url in a new tab
// via the events.js click handler (it sees the modifier key).
function rowHtml(node) {
var indent = 0.4 + node.depth * 1.0;
// Table-leaf dirs render like a file: no chevron, click opens the
// table in the preview pane (handled by events.js / preview.js).
var tableLeaf = window.app.modules.util.isTableLeaf(node);
var expandable = (node.isDir || node.isZip) && !tableLeaf;
var iconChar = iconForNode(node);
var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf');
// Outline Lucide chevron — single sprite glyph, rotated 90°
// via CSS for the expanded state. Leaf rows ship an empty
// chevron span so the icon column stays aligned.
var chevronGlyph = expandable
? window.zddc.icons.html('icon-chevron-right')
: '';
// While a filter is active, folders that contain a matching
// descendant are rendered as visually expanded so the user
// can see the match — even if node.expanded is still false.
// The actual flag stays untouched so clearing the filter
// restores the user's original tree shape.
var visuallyExpanded = node.expanded || filterForcesOpen(node);
var selected = state.selectedId === node.id ? ' is-selected' : '';
var virtualCls = node.virtual ? ' tree-row--virtual' : '';
// No native title — the hovercard surfaces a dedicated
// "Virtual: Not yet created on disk" row for these nodes.
var virtualHint = node.virtual
? '<span class="tree-name__hint">(empty)</span>'
: '';
// Extension chip stacked under the file icon. Files with a
// non-empty ext get a small uppercase label; folders / zips
// skip it (the chevron + icon glyph carries enough info).
var extChip = (!node.isDir && !node.isZip && node.ext)
? '<span class="tree-name__ext">' + escapeHtml(String(node.ext)) + '</span>'
: '';
return ''
+ '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
+ '" data-id="' + node.id
+ '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"'
+ (tableLeaf ? ' data-tableleaf="true"' : '')
+ (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
+ '<span class="tree-name__icon">' + iconChar + extChip + '</span>'
+ labelHtml(node)
+ virtualHint
// Kebab (⋯) — visible affordance that the row has actions; opens
// the same context menu. Revealed on hover/selection/focus (CSS).
// tabindex -1 keeps it out of the tab order (roving tabindex on
// the rows); reachable via right-click / the keyboard menu key.
+ '<button type="button" class="tree-row__kebab" tabindex="-1"'
+ ' aria-label="Row actions">'
+ window.zddc.icons.html('icon-ellipsis')
+ '</button>'
+ '</div>';
}
function render() {
var body = document.getElementById('treeBody');
if (!body) return;
var ids = visibleIds();
var html = '';
for (var i = 0; i < ids.length; i++) {
html += rowHtml(state.nodes.get(ids[i]));
}
body.innerHTML = html;
renderBreadcrumbs();
}
// ── Breadcrumbs ──────────────────────────────────────────────────────
// Inline outline home icon. Stroke-based so it tints with the
// current text color rather than depending on emoji rendering.
var HOME_SVG = '<svg class="bc-home-icon" xmlns="http://www.w3.org/2000/svg" '
+ 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M3 12l9-9 9 9"/>'
+ '<path d="M5 10v10h14V10"/>'
+ '<path d="M10 20v-6h4v6"/></svg>';
function renderBreadcrumbs() {
var el = document.getElementById('breadcrumbs');
if (!el) return;
var html = '';
if (state.source === 'server') {
// Server mode: every segment links to its directory URL.
// The browser navigates → server returns embedded browse →
// the new instance auto-loads that directory's listing.
var path = state.currentPath || '/';
var parts = path.split('/').filter(Boolean);
html += '<a class="bc-link bc-root" href="/" title="Site root">'
+ HOME_SVG + '</a>';
var sofar = '';
for (var i = 0; i < parts.length; i++) {
sofar += '/' + parts[i];
var isLast = i === parts.length - 1;
html += '<span class="bc-sep">/</span>';
if (isLast) {
html += '<span class="bc-link bc-link--current">'
+ escapeHtml(parts[i]) + '</span>';
} else {
html += '<a class="bc-link" href="' + escapeHtml(sofar + '/') + '">'
+ escapeHtml(parts[i]) + '</a>';
}
}
html += '<span class="bc-sep">/</span>';
} else if (state.source === 'fs') {
// FS-API mode: ancestor handles weren't retained when the
// user picked the root, so we can't navigate up. Show the
// root icon + handle name without links.
var name = state.rootHandle ? state.rootHandle.name : '';
html += '<span class="bc-link bc-root" title="Local directory">'
+ HOME_SVG + '</span>';
if (name) {
html += '<span class="bc-sep">/</span>';
html += '<span class="bc-link bc-link--current">' + escapeHtml(name) + '</span>';
}
html += '<span class="bc-sep">/</span>';
}
el.innerHTML = html;
}
// True when this .zip node lives inside another zip, so its bytes
// can't be fetched as a standalone server resource: we read them
// through the containing handle (offline / nested) or by fetching
// the inner-zip member URL. In server mode a zip-inside-a-zip's URL
// contains ".zip/"; offline it has a handle that is itself a zip
// entry.
function zipNestedInsideZip(node) {
if (state.source === 'server') {
return pathFor(node).toLowerCase().indexOf('.zip/') !== -1;
}
return !!(node.handle && node.handle.isZipEntry);
}
// Open a .zip node as a directory handle (a ZipDirectoryHandle over
// a JSZip instance), cached on the node. Bytes come from a real
// FileSystemFileHandle / ZipFileHandle when present (offline, or a
// zip nested in a zip), else from a server URL — zddc-server returns
// the raw .zip for "<…>.zip" and the inner-zip bytes for
// "<outer>.zip/inner.zip".
async function zipDirHandle(node) {
if (node._zipDirHandle) return node._zipDirHandle;
await loader.ensureJSZip();
var zh;
if (node.handle) {
zh = await window.zddc.zip.fromFileHandle(node.handle);
} else if (node.url) {
var resp = await fetch(node.url, { credentials: 'same-origin' });
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + node.url);
zh = await window.zddc.zip.fromBlob(await resp.arrayBuffer(), node.name);
} else {
throw new Error('cannot open zip ' + node.name + ' (no handle or URL)');
}
node._zipDirHandle = zh;
return zh;
}
// Load a folder's children (lazy; idempotent re-loads). Dispatches
// by node kind:
// - regular folder → server JSON listing OR FS-API entries
// - top-level .zip, server mode → the server's "<…>.zip/" virtual-
// directory listing (no whole-zip
// download — zddc-server extracts a
// member only when one is requested)
// - .zip otherwise (offline, or a zip nested in a zip)
// → open it with JSZip and enumerate
// it as a directory handle; members
// become ordinary dir/file nodes
async function loadChildren(node) {
if (node.loaded || node.loading) return;
// In-flight guard: a folder can be (re)toggled while its first
// load is still pending — rapid Enter/ArrowRight key-repeat, or a
// double-click landing during a single-click's load. Without this,
// both calls pass the !loaded check and fire duplicate fetches that
// race in setChildren. The flag serializes per-node so the second
// caller is a no-op until the first resolves.
node.loading = true;
try {
if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) {
setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/'));
} else if (node.isZip) {
setChildren(node.id, await loader.fetchFsChildren(await zipDirHandle(node)));
} else if (node.isDir) {
var raw;
if (state.source === 'server') {
raw = await loader.fetchServerChildren(pathFor(node) + '/');
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(node.handle);
} else {
return;
}
setChildren(node.id, raw);
}
} catch (e) {
window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message);
} finally {
node.loading = false;
}
}
// Toggle a folder's expanded state. Loads children on first expand.
// Treats "expandable" as either a real directory OR a zip file
// (zip files act like folders for tree purposes — the chevron
// expands them and the contents come from JSZip).
async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId);
if (!n || !(n.isDir || n.isZip)) return;
if (!n.expanded && !n.loaded) {
await loadChildren(n);
if (!n.loaded) return; // load failed (statusError already set)
}
n.expanded = !n.expanded;
render();
}
// Recursive expand: load + expand all descendants of nodeId. Used
// for Shift-click on a folder. Walks breadth-first, fanning out
// through children, grand-children, etc. until every reachable
// expandable node (folder OR zip) is loaded and marked expanded.
// Skips zip-EXPANSION recursion to avoid auto-loading every
// archive in the tree (those can be huge); plain folders only.
async function expandSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !(root.isDir || root.isZip)) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtree…');
var processed = 0;
var queue = [root];
while (queue.length) {
var batch = queue;
queue = [];
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
for (var i = 0; i < batch.length; i++) {
var n = batch[i];
n.expanded = true;
processed++;
for (var j = 0; j < n.childIds.length; j++) {
var c = state.nodes.get(n.childIds[j]);
// Recurse into plain folders only — don't auto-
// expand zip archives during a subtree expand
// (they can be very large).
if (c && c.isDir && !c.isZip) queue.push(c);
}
}
render();
status('Expanding subtree… (' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !(root.isDir || root.isZip)) return;
function walk(n) {
n.expanded = false;
for (var i = 0; i < n.childIds.length; i++) {
var c = state.nodes.get(n.childIds[i]);
if (c && (c.isDir || c.isZip)) walk(c);
}
}
walk(root);
render();
}
// Compute the URL/path for a node by walking parents.
function pathFor(node) {
var parts = [];
var cur = node;
while (cur) {
parts.unshift(cur.name);
cur = cur.parentId == null ? null : state.nodes.get(cur.parentId);
}
if (state.source === 'server') {
// currentPath is the dir containing rootIds — root nodes
// sit DIRECTLY under it.
return state.currentPath.replace(/\/$/, '') + '/' + parts.join('/');
}
return parts.join('/');
}
// ── State snapshot / restore ───────────────────────────────────────────
//
// Used by refresh + show-hidden so the user doesn't lose their
// tree layout when the listing reloads. The key is the absolute
// path of each node, computed by pathFor; on restore we walk the
// new tree and re-apply expansion + selection to nodes whose
// paths match.
function snapshotState() {
var expanded = {};
var selectedPath = null;
var previewPath = null;
state.nodes.forEach(function (n) {
if ((n.isDir || n.isZip) && n.expanded) {
expanded[pathFor(n)] = true;
}
if (n.id === state.selectedId) selectedPath = pathFor(n);
if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n);
});
return {
expanded: expanded,
selectedPath: selectedPath,
previewPath: previewPath
};
}
// Walk the current tree (already populated by setRoot) and re-
// load + expand every folder whose path appears in snapshot.expanded.
// Sets selectedId and lastPreviewedNodeId by matching the snapshot
// paths to the freshly-issued node IDs.
async function restoreState(snap) {
if (!snap) return;
async function walk(ids) {
for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]);
if (!n) continue;
var p = pathFor(n);
if (snap.selectedPath && p === snap.selectedPath) {
state.selectedId = n.id;
}
if (snap.previewPath && p === snap.previewPath) {
state.lastPreviewedNodeId = n.id;
}
if ((n.isDir || n.isZip) && snap.expanded[p]) {
await loadChildren(n);
if (n.loaded) {
n.expanded = true;
await walk(n.childIds);
}
}
}
}
await walk(state.rootIds);
}
// Public API
window.app.modules.tree = {
setRoot: setRoot,
setChildren: setChildren,
render: render,
toggleFolder: toggleFolder,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
loadChildren: loadChildren,
snapshotState: snapshotState,
restoreState: restoreState,
// Set both key and direction explicitly. dir: 1 (asc) or -1 (desc).
// Used by the toolbar's sort dropdown.
setSortExplicit: function (key, dir) {
state.sort.key = key;
state.sort.dir = (dir === -1 ? -1 : 1);
render();
},
pathFor: pathFor,
visibleIds: visibleIds
};
})();

596
browse/js/upload.js Normal file
View file

@ -0,0 +1,596 @@
// upload.js — drag-drop file upload into the current scope.
//
// Active only in server mode and only at paths where the cascade
// declares drop_target: true (see zddc/internal/zddc/lookups.go
// DropTargetAt + defaults.zddc.yaml). The loader captures the
// X-ZDDC-Drop-Target response header on every directory listing
// fetch and stamps state.scopeDropTarget; this module just reads it.
//
// At scopes where drop_target is false (or unset), the handlers
// stay armed but ignore drops silently — no visible drop-zone
// overlay. An operator can flip working/staging/incoming on or
// extend the cascade to mark additional dirs as drop targets via
// .zddc; the client follows automatically without code change.
//
// Wire model:
// - dragenter on the document raises a counter; first-enter shows
// the overlay.
// - dragleave decrements; reaching zero hides the overlay.
// - drop short-circuits: prevent default, PUT each file under the
// current state.currentPath, surface per-file toast results,
// refetch the listing on completion.
//
// The PUT uses fetch(`<currentPath><filename>`, method: 'PUT'). The
// server's authorizeAction enforces write ACL on the parent; a 403
// surfaces as an error toast and the rest of the batch proceeds.
//
// Per-file size cap (UPLOAD_MAX_BYTES): files larger than the cap
// are rejected client-side with a clear toast — the server would
// accept them in chunks but browse's v1 PUT is a single body, and
// dropping a 4 GB CAD bundle into the browser tab as a Blob is a
// poor experience. Operators with larger uploads should use a
// dedicated client (zddc-cli or the cache/mirror downstream).
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
var UPLOAD_MAX_BYTES = 256 * 1024 * 1024; // 256 MiB per file
var state = window.app.state;
var enterCount = 0;
var overlayEl = null;
function ensureOverlay() {
if (overlayEl) return overlayEl;
overlayEl = document.createElement('div');
overlayEl.className = 'upload-overlay';
overlayEl.setAttribute('aria-hidden', 'true');
overlayEl.innerHTML =
'<div class="upload-overlay__panel">'
+ '<div class="upload-overlay__icon">⤴</div>'
+ '<div class="upload-overlay__title">Drop to upload</div>'
+ '<div class="upload-overlay__path" id="uploadOverlayPath"></div>'
+ '</div>';
document.body.appendChild(overlayEl);
return overlayEl;
}
function currentScopeAllows() {
if (!state || state.source !== 'server') return false;
// state.scopeDropTarget is set by the loader on every listing
// fetch from the X-ZDDC-Drop-Target response header; it's a
// boolean read of the cascade's effective drop_target flag at
// the current path. Defaults to false when the header is
// absent (older server or non-server response).
return !!state.scopeDropTarget;
}
function showOverlay() {
var el = ensureOverlay();
var pathEl = el.querySelector('#uploadOverlayPath');
if (pathEl) pathEl.textContent = state.currentPath || '/';
el.classList.add('is-active');
}
function hideOverlay() {
if (overlayEl) overlayEl.classList.remove('is-active');
}
function dragHasFiles(e) {
if (!e.dataTransfer || !e.dataTransfer.types) return false;
var types = e.dataTransfer.types;
for (var i = 0; i < types.length; i++) {
if (types[i] === 'Files') return true;
}
return false;
}
// Join a directory path and a relative path safely. dir is expected
// to be /-prefixed and may or may not have a trailing /; rel is a
// forward-slash relative path (no leading /). Each segment is
// URI-encoded so spaces and friends survive the round trip.
function joinUrl(dir, rel) {
var base = dir || '/';
if (!base.endsWith('/')) base += '/';
return base + rel.split('/').map(encodeURIComponent).join('/');
}
async function uploadOne(file, destDir, relPath) {
if (file.size > UPLOAD_MAX_BYTES) {
return {
file: file,
ok: false,
status: 0,
message: 'too large (max ' + Math.round(UPLOAD_MAX_BYTES / 1024 / 1024) + ' MiB)'
};
}
try {
var resp = await fetch(joinUrl(destDir, relPath), {
method: 'PUT',
body: file,
credentials: 'same-origin',
headers: {
'Content-Type': file.type || 'application/octet-stream'
}
});
return {
file: file,
ok: resp.ok,
status: resp.status,
message: resp.ok ? '' : ('HTTP ' + resp.status)
};
} catch (e) {
return {
file: file,
ok: false,
status: 0,
message: (e && e.message) ? e.message : 'network error'
};
}
}
// ── Folder-upload helpers (webkitGetAsEntry recursion) ─────────────────
// Browsers expose dropped folders only through the entries API.
// walkEntry flattens a tree into [{ relPath, file }] so uploadOne
// can PUT each file individually. The server's PUT auto-creates
// intermediate directories, so no explicit mkdir is needed.
function readAllEntries(reader) {
return new Promise(function (resolve, reject) {
var collected = [];
function loop() {
reader.readEntries(function (batch) {
if (batch.length === 0) return resolve(collected);
collected = collected.concat(batch);
loop();
}, reject);
}
loop();
});
}
function entryToFile(entry) {
return new Promise(function (resolve, reject) {
entry.file(resolve, reject);
});
}
async function walkEntry(entry, prefix, out) {
if (entry.isFile) {
try {
var f = await entryToFile(entry);
out.push({ relPath: prefix + entry.name, file: f });
} catch (_e) { /* skip unreadable file */ }
} else if (entry.isDirectory) {
var reader = entry.createReader();
var kids = await readAllEntries(reader);
for (var i = 0; i < kids.length; i++) {
await walkEntry(kids[i], prefix + entry.name + '/', out);
}
}
}
// Extract { relPath, file } pairs from a DataTransfer. Uses
// webkitGetAsEntry when available (so folder uploads work);
// falls back to dataTransfer.files for cases where entries
// aren't exposed (some browsers / cross-origin).
async function collectUploads(dt) {
var out = [];
if (dt.items && dt.items.length) {
var entries = [];
for (var i = 0; i < dt.items.length; i++) {
var item = dt.items[i];
if (item.kind !== 'file') continue;
var entry = typeof item.webkitGetAsEntry === 'function'
? item.webkitGetAsEntry()
: null;
if (entry) {
entries.push(entry);
} else {
var f = item.getAsFile();
if (f) out.push({ relPath: f.name, file: f });
}
}
for (var j = 0; j < entries.length; j++) {
await walkEntry(entries[j], '', out);
}
if (out.length) return out;
}
if (dt.files) {
for (var k = 0; k < dt.files.length; k++) {
out.push({ relPath: dt.files[k].name, file: dt.files[k] });
}
}
return out;
}
// Run a batch of uploads against an arbitrary destination directory.
// Surfaces per-file errors as toasts; refreshes the tree afterward
// so newly-uploaded entries appear. Returns { ok, fail } counts.
async function uploadBatch(uploads, destDir) {
var note = window.zddc && window.zddc.toast;
if (note) {
note('Uploading ' + uploads.length + ' item'
+ (uploads.length === 1 ? '' : 's') + '…', 'info');
}
var ok = 0, fail = 0;
for (var i = 0; i < uploads.length; i++) {
var u = uploads[i];
var res = await uploadOne(u.file, destDir, u.relPath);
if (res.ok) ok++;
else {
fail++;
if (note) {
note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error');
}
}
}
if (note) {
if (fail === 0) {
note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's')
+ ' → ' + destDir, 'success');
} else if (ok === 0) {
note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error');
} else {
note(ok + ' uploaded, ' + fail + ' failed', 'warning');
}
}
return { ok: ok, fail: fail };
}
// Comment upload: PUT each dropped file's bytes to the target URL.
// The server detects the virtual <workflow>/received/ context and
// rewrites the destination to <workflow>/<base>+C<n><suffix>, surfacing
// the resolved path in X-ZDDC-Resolved-Path so the status line can
// tell the user where the bytes landed.
async function uploadCommentToTarget(targetURL, dataTransfer) {
var note = window.zddc && window.zddc.toast;
var files = [];
if (dataTransfer.files && dataTransfer.files.length) {
for (var k = 0; k < dataTransfer.files.length; k++) {
files.push(dataTransfer.files[k]);
}
}
if (files.length === 0) {
if (note) note('No files to upload.', 'warning');
return;
}
var ok = 0;
var lastResolved = '';
for (var i = 0; i < files.length; i++) {
var f = files[i];
if (f.size > UPLOAD_MAX_BYTES) {
if (note) note('Skipped (too large): ' + f.name, 'error');
continue;
}
try {
var resp = await fetch(targetURL, {
method: 'PUT',
body: f,
credentials: 'same-origin',
headers: { 'Content-Type': f.type || 'application/octet-stream' }
});
if (resp.ok) {
ok++;
var hdr = resp.headers.get('X-ZDDC-Resolved-Path') || '';
if (hdr) lastResolved = hdr;
} else if (note) {
note('Comment upload failed (' + resp.status + ')', 'error');
}
} catch (e) {
if (note) note('Comment upload error: ' + (e && e.message), 'error');
}
}
if (note && ok > 0) {
var msg = 'Saved ' + ok + ' comment' + (ok === 1 ? '' : 's');
if (lastResolved) msg += ' — last at ' + lastResolved;
note(msg, 'success');
}
// Reload the current listing so the new +Cn file appears in the
// tree. Best-effort.
try {
var ev = window.app.modules.events;
if (ev && typeof ev.refreshListing === 'function') ev.refreshListing();
} catch (_e) { /* refresh is best-effort */ }
}
// ── Write-permission escalation (FS-API mode) ──────────────────────────
// The local folder is picked read-only (showDirectoryPicker mode:read)
// so browsing never prompts. The first mutation escalates to readwrite
// via the FS-Access permission prompt; granting on the picked root
// covers every descendant handle. Must run under a user gesture — every
// caller is reached from a click/menu action. No-op in server mode or
// on browsers without the permission API.
async function ensureWritable() {
if (state.source !== 'fs') return;
var root = state.rootHandle;
if (!root || typeof root.requestPermission !== 'function') return;
var opts = { mode: 'readwrite' };
if ((await root.queryPermission(opts)) === 'granted') return;
if ((await root.requestPermission(opts)) === 'granted') return;
throw new Error('Write permission denied — grant edit access to the folder when prompted.');
}
// handleForDir resolves a directory PATH (FS-API mode) to its
// FileSystemDirectoryHandle: the picked root for the current scope,
// else the matching expanded node's handle. Returns null if unknown.
function handleForDir(dirPath) {
var tree = window.app.modules.tree;
if (!dirPath.endsWith('/')) dirPath += '/';
if (dirPath === state.currentPath) return state.rootHandle;
var noSlash = dirPath.replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir || !n.handle) return;
if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n;
});
return hit ? hit.handle : null;
}
// ── Create-new helpers ────────────────────────────────────────────────
// Server mode: PUT for files (empty/template body) and POST +
// X-ZDDC-Op: mkdir for directories; the server's ACL is the source of
// truth. FS-API mode: create directly in the picked tree via
// getDirectoryHandle/getFileHandle({create:true}) + createWritable —
// limited only by the filesystem permission the user granted.
async function makeDir(parentDir, name) {
if (state.source === 'server') {
var url = joinUrl(parentDir, name);
if (!url.endsWith('/')) url += '/';
var resp = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-ZDDC-Op': 'mkdir' }
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
var parent = handleForDir(parentDir);
if (!parent) throw new Error('No directory handle for ' + parentDir);
await ensureWritable();
await parent.getDirectoryHandle(name, { create: true });
}
async function makeFile(parentDir, name, body, contentType) {
if (state.source === 'server') {
var resp = await fetch(joinUrl(parentDir, name), {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': contentType || 'application/octet-stream' },
body: body == null ? '' : body
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
var parent = handleForDir(parentDir);
if (!parent) throw new Error('No directory handle for ' + parentDir);
await ensureWritable();
var fh = await parent.getFileHandle(name, { create: true });
var w = await fh.createWritable();
await w.write(body == null ? '' : body);
await w.close();
}
// ── Delete + rename ─────────────────────────────────────────────────────
// Both run through the same FS Access API + file-API endpoints used
// by the create helpers above:
// - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced
// server-side; a 403/405 surfaces as an error toast.
// - FS-API mode: FileSystemHandle.remove({recursive:true}) and
// .move(newName) — both are Chromium-110+ features. We feature-
// detect at the handle level; callers see a clear "not supported"
// error message if the browser is too old.
function pathForNode(node) {
var tree = window.app.modules.tree;
return tree ? tree.pathFor(node) : '';
}
function isZipMember(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) {
return true;
}
return false;
}
// True when this node's write API is reachable. The server can
// still refuse the action on ACL grounds; this only gates the
// menu's disabled-state for the cases where there's clearly no
// write target at all.
function canMutate(node) {
if (!node || node.virtual) return false;
if (isZipMember(node)) return false;
if (state.source === 'server') return true;
if (node.handle && typeof node.handle.remove === 'function') return true;
return false;
}
async function removeNode(node) {
if (!node) throw new Error('no node');
if (isZipMember(node)) {
throw new Error('Cannot delete a file inside a zip archive.');
}
if (node.virtual) {
throw new Error('Virtual folder — nothing on disk to delete.');
}
if (state.source === 'server') {
var url = pathForNode(node);
if (node.isDir && !url.endsWith('/')) url += '/';
var resp = await fetch(url, {
method: 'DELETE',
credentials: 'same-origin'
});
if (!resp.ok) {
if (resp.status === 403) throw new Error('Permission denied (403).');
if (resp.status === 405) throw new Error('Delete not allowed for this entry.');
throw new Error('HTTP ' + resp.status);
}
return;
}
// FS-API path. FileSystemHandle.remove() is Chromium 110+
// (browsers that didn't ship it expose no equivalent — the
// legacy removeEntry() lives on the PARENT directory handle
// and we don't retain ancestor handles).
if (node.handle && typeof node.handle.remove === 'function') {
await ensureWritable();
await node.handle.remove({ recursive: !!node.isDir });
return;
}
throw new Error('Delete not supported by this browser in offline mode.');
}
async function renameNode(node, newName) {
if (!node) throw new Error('no node');
if (!newName) throw new Error('Name required.');
if (newName === node.name) return;
if (isZipMember(node)) {
throw new Error('Cannot rename a file inside a zip archive.');
}
if (node.virtual) {
throw new Error('Virtual folder — nothing on disk to rename.');
}
if (state.source === 'server') {
var src = pathForNode(node);
if (node.isDir && !src.endsWith('/')) src += '/';
// Destination = same parent, new basename.
var lastSlash = src.replace(/\/$/, '').lastIndexOf('/');
var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/';
var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : '');
var resp = await fetch(src, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-ZDDC-Op': 'move',
'X-ZDDC-Destination': dst
}
});
if (!resp.ok) {
if (resp.status === 403) throw new Error('Permission denied (403).');
if (resp.status === 409) throw new Error('A file with that name already exists.');
throw new Error('HTTP ' + resp.status);
}
return;
}
// FS-API: handle.move(newName) is Chromium 110+.
if (node.handle && typeof node.handle.move === 'function') {
await ensureWritable();
await node.handle.move(newName);
return;
}
throw new Error('Rename not supported by this browser in offline mode.');
}
// Refresh either the root listing (when the upload targeted the
// current scope) or just one folder node's children (when the
// upload targeted a subfolder via a per-row drop).
async function refreshAfterUpload(targetDir) {
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
if (!loader || !tree) return;
if (state.currentPath && targetDir === state.currentPath) {
try {
var es = await loader.fetchServerChildren(state.currentPath);
tree.setRoot(es);
tree.render();
} catch (_e) { /* swallow */ }
return;
}
// Find any tree node whose path matches targetDir and reload
// its children. Walks state.nodes flat — n is small enough for
// a linear scan.
var dirNoSlash = (targetDir || '').replace(/\/$/, '');
var hit = null;
state.nodes.forEach(function (n) {
if (hit || !n.isDir) return;
if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n;
});
if (hit && hit.expanded) {
try {
var raw = await loader.fetchServerChildren(targetDir);
tree.setChildren(hit.id, raw);
tree.render();
} catch (_e) { /* swallow */ }
}
}
// Document-level drop: targets the currently-viewed scope. The
// per-row drop (events.js) calls uploadToDir directly with a
// different destination.
async function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
enterCount = 0;
hideOverlay();
if (!currentScopeAllows()) return;
var dt = e.dataTransfer;
if (!dt) return;
var uploads = await collectUploads(dt);
if (!uploads.length) return;
await uploadBatch(uploads, state.currentPath);
await refreshAfterUpload(state.currentPath);
}
// Public entry for per-row drops or programmatic uploads. destDir
// must be a server path (/-prefixed, slash-terminated optional).
async function uploadToDir(destDir, dataTransfer) {
var uploads = await collectUploads(dataTransfer);
if (!uploads.length) return { ok: 0, fail: 0 };
var res = await uploadBatch(uploads, destDir);
await refreshAfterUpload(destDir);
return res;
}
function onEnter(e) {
if (!dragHasFiles(e)) return;
enterCount++;
if (enterCount === 1 && currentScopeAllows()) {
showOverlay();
}
}
function onLeave(e) {
if (!dragHasFiles(e)) return;
enterCount = Math.max(0, enterCount - 1);
if (enterCount === 0) hideOverlay();
}
function onOver(e) {
if (!dragHasFiles(e)) return;
// preventDefault on dragover is required for drop to fire.
e.preventDefault();
if (e.dataTransfer && currentScopeAllows()) {
e.dataTransfer.dropEffect = 'copy';
} else if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'none';
}
}
function init() {
document.addEventListener('dragenter', onEnter, false);
document.addEventListener('dragleave', onLeave, false);
document.addEventListener('dragover', onOver, false);
document.addEventListener('drop', handleDrop, false);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.app.modules.upload = {
currentScopeAllows: currentScopeAllows,
uploadToDir: uploadToDir,
uploadCommentToTarget: uploadCommentToTarget,
makeDir: makeDir,
makeFile: makeFile,
removeNode: removeNode,
renameNode: renameNode,
canMutate: canMutate,
ensureWritable: ensureWritable,
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
};
})();

226
browse/js/util.js Normal file
View file

@ -0,0 +1,226 @@
// util.js — small browse-local helpers shared across the tool's modules.
//
// Consolidates copies that had drifted across modules: escapeHtml (some
// variants escaped single-quotes and handled null, others didn't), the
// SHA-256 content hasher (byte-identical in both editors), ISO-date and
// YAML-quote helpers (duplicated across the workflow modals), the
// /.profile/access email lookup, byte-size formatting, and the editor
// save/zip-member primitives. Attaches to window.app.modules.util — no new
// global (per the two-globals rule). Concatenated right after init.js so
// it's present when every later module's IIFE runs.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
// Escape a value for HTML text/attribute insertion. Escapes all five
// significant characters (including the single quote, which some call
// sites need for single-quoted attributes) and treats null/undefined
// as an empty string. Strict superset of every previous local copy.
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// SHA-256 hex of a string, or null when WebCrypto is unavailable.
// Used to gate editor dirty-state.
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
var buf = await window.crypto.subtle.digest('SHA-256', enc);
var bytes = new Uint8Array(buf);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
function pad2(n) { return ('0' + n).slice(-2); }
function fmtIsoDate(d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// YYYY-MM-DD for today / today + N days (local time).
function isoDateToday() { return fmtIsoDate(new Date()); }
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return fmtIsoDate(d);
}
// Double-quoted YAML scalar with backslash + quote escaping. Enough for
// the email/string fields the workflow modals emit.
function yamlQuote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
// GET /.profile/access → [email] for datalist suggestions. Best-effort:
// returns [] on any error so callers can populate a datalist blind.
async function fetchAccessEmails() {
try {
var r = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!r.ok) return [];
var d = await r.json();
return (d && d.email) ? [d.email] : [];
} catch (_e) { return []; }
}
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// A file living inside a .zip is read-only: a ZipFileHandle refuses
// createWritable (offline / nested) and zddc-server refuses writes to a
// "<…>.zip/<member>" URL (405).
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server'
&& /\.zip\//i.test(node.url)) return true;
return false;
}
// isEditableZipMember reports whether node is a member of the .zddc.zip
// config bundle — the one case where the server accepts a write into a zip
// (ServeZipWrite). The server gates BOTH browsing and writing the bundle on
// standing config-edit authority (a subtree admin / `a`-verb holder, no
// elevation), so if this member is even visible the session can edit it —
// no elevation check needed here. Every other zip member (content archives,
// WORM records) stays read-only. The server is the real gate; this drives
// editor UX.
function isEditableZipMember(node) {
if (!node || !node.url || window.app.state.source !== 'server') return false;
return /\.zddc\.zip\//i.test(node.url);
}
// Thrown by saveFile when the server rejects a write with 412
// Precondition Failed — the file changed under us since we loaded it.
// Callers branch on `.status === 412` to open the conflict UI instead
// of treating it as a generic save failure.
function ConflictError(message) {
var e = new Error(message || 'Conflict: file changed on server');
e.name = 'ConflictError';
e.status = 412;
return e;
}
// Write content back to a file's source, returning { etag } (the new
// server ETag, or null in FS-Access mode). Local (FS-Access) folders are
// picked read-only, so the first write escalates to readwrite via
// upload.ensureWritable (one permission prompt, then granted for the
// session). contentType sets the PUT Content-Type for server files.
//
// opts (server mode only):
// etag — send as `If-Match` so the master 412s if the file
// changed since we observed this version (optimistic
// concurrency; preferred — exact).
// lastModified — fallback precondition sent as `If-Unmodified-Since`
// (raw HTTP-date string) when no etag is available.
// force — skip the precondition entirely (deliberate overwrite).
//
// Throws ConflictError (.status===412) on a precondition failure, a
// plain Error('HTTP <status>') on any other non-2xx, or "no write
// target" when the source is read-only.
async function saveFile(node, content, contentType, opts) {
opts = opts || {};
if (node.handle && typeof node.handle.createWritable === 'function') {
var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return { etag: null };
}
if (node.url && window.app.state.source === 'server') {
var headers = { 'Content-Type': contentType };
if (!opts.force) {
if (opts.etag) headers['If-Match'] = opts.etag;
else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified;
}
var resp = await fetch(node.url, {
method: 'PUT',
headers: headers,
body: content,
credentials: 'same-origin'
});
if (resp.status === 412) throw ConflictError();
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return { etag: resp.headers.get('ETag') || null };
}
throw new Error('No write target for this file (read-only source).');
}
// Write `content` to a NEW sibling of `node` named
// `<stem>-conflict-<YYYYMMDD-HHMMSS>.<ext>` (server mode only), so a
// conflicting edit can be parked without losing either version. Probes
// for a free name (numeric-suffix bump, capped) so a same-second retry
// doesn't clobber a prior copy. Returns the created filename. The PUT
// uses no precondition — it's a brand-new path.
async function saveCopy(node, content, contentType) {
if (!(node.url && window.app.state.source === 'server')) {
throw new Error('Save a copy is only available for server files.');
}
var split = window.zddc.splitExtension(node.name);
var stem = split.name || node.name;
var ext = split.extension;
var d = new Date();
var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate())
+ '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
var base = stem + '-conflict-' + stamp;
var slash = node.url.lastIndexOf('/');
var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : '';
var name = '', candidateUrl = '';
for (var i = 0; i < 20; i++) {
name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext);
candidateUrl = dirUrl + encodeURIComponent(name);
var head;
try {
head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' });
} catch (_e) {
break; // network unknown — attempt the write rather than spin
}
if (head.status === 404) break; // free slot
if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway
if (i === 19) throw new Error('Could not find a free filename for the copy.');
}
await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true });
return name;
}
// isTableLeaf reports whether a directory node should behave as a
// click-to-table LEAF rather than an expandable folder — i.e. the
// cascade resolved its default tool to "tables" (mdl/rsk/ssr and any
// operator-configured table dir). The tree renders it without a
// chevron and the preview pane opens the tables tool for it. Server
// mode only: defaultTool is a server-computed listing hint, absent
// offline (file:// folders stay ordinary expandable dirs).
function isTableLeaf(node) {
return !!(node && node.isDir && node.defaultTool === 'tables');
}
window.app.modules.util = {
escapeHtml: escapeHtml,
hashContent: hashContent,
isoDateToday: isoDateToday,
isoDatePlus: isoDatePlus,
yamlQuote: yamlQuote,
fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode,
isEditableZipMember: isEditableZipMember,
isTableLeaf: isTableLeaf,
saveFile: saveFile,
saveCopy: saveCopy,
ConflictError: ConflictError
};
})();

275
browse/js/yaml-complete.js Normal file
View file

@ -0,0 +1,275 @@
// yaml-complete.js — deterministic, schema-driven completion + hover docs for
// the browse YAML editors (markdown front matter + .zddc). NO heuristics, no
// AI: every candidate and doc string comes from a PROVIDER backed by the
// converter's field list or the .zddc JSON Schema.
//
// A provider answers three questions about a position, identified by its key
// PATH (the array of parent keys):
// keysAt(path) → [{name, hint, values}] valid child keys here
// valuesFor(path, key) → [string] | null enum/boolean values
// describe(path, key) → string | null doc text (for hover)
// The CodeMirror plumbing (indent→path, sibling scan, show-hint, hover) is
// shared; only the provider differs between the flat front matter and the
// nested .zddc schema. Requires CodeMirror 5 + the show-hint add-on.
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function indentOf(line) { var m = line.match(/^(\s*)/); return m ? m[1].length : 0; }
function isBlankOrComment(line) { return /^\s*$/.test(line) || /^\s*#/.test(line); }
function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
// Parent key-path for a line, derived from YAML indentation: walk upward
// collecting each "key:" line at a strictly smaller indent.
function pathAt(cm, lineNo) {
var path = [];
var target = indentOf(cm.getLine(lineNo));
for (var ln = lineNo - 1; ln >= 0 && target > 0; ln--) {
var line = cm.getLine(ln);
if (isBlankOrComment(line)) continue;
var ind = indentOf(line);
if (ind < target) {
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (m) { path.unshift(m[1]); target = ind; }
}
}
return path;
}
// Sibling keys already present at the same indent within this block, so we
// don't re-suggest a key the author already wrote.
function presentSiblings(cm, lineNo, indent) {
var present = {};
[-1, 1].forEach(function (dir) {
for (var ln = lineNo + dir; ln >= 0 && ln < cm.lineCount(); ln += dir) {
var line = cm.getLine(ln);
if (isBlankOrComment(line)) continue;
var ind = indentOf(line);
if (ind < indent) break; // left the block
if (ind === indent) {
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (m) present[m[1]] = true;
}
}
});
return present;
}
function keyItem(k, hinter) {
var item = {
text: k.name + ': ',
displayText: k.name + (k.hint ? ' — ' + truncate(k.hint, 64) : '')
};
// An enum key inserts "key: " then immediately opens its value menu.
if (k.values && k.values.length) {
item.hint = function (cmi, data, comp) {
cmi.replaceRange(comp.text, data.from, data.to);
setTimeout(function () { cmi.showHint({ hint: hinter, completeSingle: false }); }, 0);
};
}
return item;
}
function makeHinter(provider) {
function hinter(cm) {
var CM = window.CodeMirror;
if (!CM) return null;
var cur = cm.getCursor();
var before = cm.getLine(cur.line).slice(0, cur.ch);
var colon = before.indexOf(':');
var path = pathAt(cm, cur.line);
if (colon === -1) {
// KEY context.
var m = before.match(/^(\s*)([\w.\-]*)$/);
if (!m) return null;
var indent = m[1], typed = m[2];
var keys = provider.keysAt(path) || [];
if (!keys.length) return null;
var present = presentSiblings(cm, cur.line, indent.length);
var list = [];
keys.forEach(function (k) {
if (present[k.name]) return;
if (typed && k.name.indexOf(typed) !== 0) return;
list.push(keyItem(k, hinter));
});
if (!list.length) return null;
return { list: list, from: CM.Pos(cur.line, indent.length), to: cur };
}
// VALUE context.
var key = before.slice(0, colon).trim();
var values = provider.valuesFor(path, key) || [];
if (!values.length) return null;
var rest = before.slice(colon + 1);
var valTyped = rest.replace(/^\s*/, '');
var valStart = colon + 1 + (rest.length - valTyped.length);
var vlist = [];
values.forEach(function (v) {
if (valTyped && v.indexOf(valTyped) !== 0) return;
vlist.push({ text: v, displayText: v });
});
if (!vlist.length) return null;
return { list: vlist, from: CM.Pos(cur.line, valStart), to: cur };
}
return hinter;
}
// Lightweight hover docs: hover a "key:" → its schema description. No
// add-on — a debounced mousemove over the editor + a fixed-position tip.
function attachHover(cm, provider) {
var tip = null, timer = null;
function hide() { if (tip && tip.parentNode) tip.parentNode.removeChild(tip); tip = null; }
function show(text, x, y) {
hide();
tip = document.createElement('div');
tip.className = 'cm-doc-tip';
tip.textContent = text;
document.body.appendChild(tip);
tip.style.left = x + 'px';
tip.style.top = (y + 16) + 'px';
}
var wrap = cm.getWrapperElement();
wrap.addEventListener('mousemove', function (e) {
if (timer) clearTimeout(timer);
var ex = e.clientX, ey = e.clientY;
timer = setTimeout(function () {
if (!wrap.isConnected) { hide(); return; }
try {
var pos = cm.coordsChar({ left: ex, top: ey }, 'window');
var line = cm.getLine(pos.line) || '';
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (!m) { hide(); return; }
var keyStart = line.indexOf(m[1]);
if (pos.ch < keyStart || pos.ch > keyStart + m[1].length) { hide(); return; }
var doc = provider.describe(pathAt(cm, pos.line), m[1]);
if (doc) show(doc, ex, ey); else hide();
} catch (_e) { hide(); }
}, 350);
});
wrap.addEventListener('mouseleave', function () { if (timer) clearTimeout(timer); hide(); });
cm.on('cursorActivity', hide);
cm.on('changes', hide);
}
// Wire completion (Ctrl-Space + auto-trigger as you type) and hover docs
// onto a CodeMirror instance. opts.readOnly skips the typing trigger;
// opts.hover:false skips hover.
function attach(cm, provider, opts) {
opts = opts || {};
var hinter = makeHinter(provider);
var keys = Object.assign({}, cm.getOption('extraKeys') || {}, {
'Ctrl-Space': function (c) { c.showHint({ hint: hinter, completeSingle: false }); }
});
cm.setOption('extraKeys', keys);
if (!opts.readOnly) {
cm.on('inputRead', function (c, change) {
if (!change.text || change.text.length !== 1) return; // skip paste/delete
if (!/[\w.\-]/.test(change.text[0])) return;
c.showHint({ hint: hinter, completeSingle: false });
});
}
if (opts.hover !== false) attachHover(cm, provider);
return hinter;
}
// ── Providers ───────────────────────────────────────────────────────────
// Flat: a fixed field list [{name, hint, values}] at the root, nothing
// nested (front matter). opts.exclude = names never suggested.
function flatProvider(getFields, opts) {
opts = opts || {};
var exclude = {};
(opts.exclude || []).forEach(function (n) { exclude[n] = true; });
function fields() { return getFields() || []; }
function find(name) {
var fs = fields();
for (var i = 0; i < fs.length; i++) if (fs[i].name === name) return fs[i];
return null;
}
return {
keysAt: function (path) {
if (path.length) return [];
return fields().filter(function (f) { return !exclude[f.name]; })
.map(function (f) { return { name: f.name, hint: f.hint, values: f.values }; });
},
valuesFor: function (path, key) {
if (path.length) return null;
var f = find(key); return f ? f.values : null;
},
describe: function (path, key) {
if (path.length) return null;
var f = find(key); return f ? f.hint : null;
}
};
}
// Schema: a JSON Schema (draft-2020-12 subset). Resolves nested key-paths
// through properties / additionalProperties / patternProperties and the
// recursive $ref:"#" .zddc uses for paths:. Keys = object property names;
// values = enum / boolean.
function schemaProvider(getSchema) {
function root() { return getSchema(); }
function deref(node) { return (node && node.$ref === '#') ? root() : node; }
function stepInto(node, seg) {
node = deref(node);
if (!node || node.type !== 'object') return null;
if (node.properties && node.properties[seg]) return node.properties[seg];
if (node.additionalProperties && typeof node.additionalProperties === 'object') {
return node.additionalProperties;
}
if (node.patternProperties) {
for (var p in node.patternProperties) {
if (Object.prototype.hasOwnProperty.call(node.patternProperties, p)) {
return node.patternProperties[p];
}
}
}
return null;
}
function containerAt(path) {
var node = deref(root());
for (var i = 0; i < path.length; i++) {
node = stepInto(node, path[i]);
if (!node) return null;
node = deref(node);
}
return node;
}
function valuesOf(node) {
node = deref(node);
if (!node) return null;
if (Array.isArray(node.enum)) return node.enum.map(String);
if (node.type === 'boolean') return ['true', 'false'];
return null;
}
function keyNodeAt(path, key) {
var c = containerAt(path);
if (!c || !c.properties) return null;
return c.properties[key] || null;
}
return {
keysAt: function (path) {
var c = containerAt(path);
if (!c || c.type !== 'object' || !c.properties) return [];
return Object.keys(c.properties).map(function (name) {
var n = deref(c.properties[name]) || {};
return { name: name, hint: n.description, values: valuesOf(n) };
});
},
valuesFor: function (path, key) { return valuesOf(keyNodeAt(path, key)); },
describe: function (path, key) {
var n = deref(keyNodeAt(path, key));
return n ? n.description : null;
}
};
}
window.app.modules.yamlComplete = {
attach: attach,
makeHinter: makeHinter,
flatProvider: flatProvider,
schemaProvider: schemaProvider
};
})();

185
browse/template.html Normal file
View file

@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Browse</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div>
</header>
<main id="appMain">
<div id="emptyState" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>ZDDC Browse</h2>
<p>A two-pane file browser for ZDDC archives — and any directory.</p>
<ul class="welcome-list">
<li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Use Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li>
</ul>
<p>Once loaded: click folders to expand, click files to preview them in
the right pane. Markdown files open in a full editor with TOC.
Switch to <b>Grid</b> mode to bulk-rename ZDDC files
spreadsheet-style.</p>
</div>
</div>
<div id="browseRoot" class="browse-root hidden">
<div class="browse-toolbar">
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
</div>
<!-- Browse mode (default): two-pane tree + preview -->
<div id="browseView" class="browse-view">
<div class="pane tree-pane" id="treePane">
<div class="tree-pane__toolbar">
<!-- Sort + Hidden sit above the autofilter box. Create
actions (New folder / New file) live in the
right-click context menu, not here. -->
<div class="tree-pane__controls">
<label class="tp-control" title="Sort order">
<span class="tp-control__label">Sort</span>
<select id="sortSelect" aria-label="Sort order">
<option value="name:1">Name</option>
<option value="date:-1">Modified</option>
<option value="size:-1">Size</option>
<option value="ext:1">Type</option>
</select>
</label>
<label class="tp-control tp-control--check" title="Show hidden files (dot/underscore names)">
<input type="checkbox" id="showHiddenChk">
<span class="tp-control__label">Hidden</span>
</label>
</div>
<input type="search"
id="treeFilter"
class="tree-filter"
placeholder="Filter files…"
aria-label="Filter the tree by name, tracking number, status, revision, or title"
autocomplete="off"
spellcheck="false">
</div>
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div>
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
<div class="pane preview-pane" id="previewPane">
<div class="preview-pane__header">
<span class="preview-pane__title" id="previewTitle">No file selected</span>
<span class="preview-pane__meta" id="previewMeta"></span>
<button id="previewPopout" class="btn btn-sm btn-secondary hidden" title="Pop out into a separate window" aria-label="Pop out into a separate window">⤴ Pop out</button>
</div>
<div class="preview-pane__body" id="previewBody">
<div class="preview-empty">Click a file in the tree to preview it.</div>
</div>
</div>
</div>
<!-- Grid mode: classifier-style spreadsheet rooted at the current dir -->
<div id="gridView" class="grid-view hidden">
<div class="grid-empty">
Grid view is loading…
</div>
</div>
</div>
</main>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Browse</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is Browse?</h3>
<p>Browse is the ZDDC file experience. Two top-level modes:</p>
<dl>
<dt>Browse mode</dt>
<dd>File tree on the left, preview on the right. Click a folder to
expand, click a file to preview. Markdown files open in a full editor;
PDF, image, ZIP, XLSX, DOCX, TIFF all render inline.</dd>
<dt>Grid mode</dt>
<dd>Spreadsheet view of the current subtree's files for bulk
ZDDC renaming. Edit cells directly, copy/paste with Excel,
save back to disk.</dd>
</dl>
<h3>Tree navigation (Browse mode)</h3>
<dl>
<dt>Click a folder</dt>
<dd>Expand or collapse it inline.</dd>
<dt>Shift-click a folder</dt>
<dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt>
<dd>Preview it in the right pane.</dd>
<dt>Row actions — right-click, ⋯, or the menu key</dt>
<dd>Right-click a row, click the ⋯ button that appears on hover, or
press the menu key (or Shift+F10) on the selected row. The menu only
lists actions that apply to that item; actions you can see but can't
use yet (you lack write/create access, or they're for project or site
administrators) appear greyed with a reason — so you can see what a
higher role unlocks.</dd>
<dt>Toolbar (above the tree)</dt>
<dd>Filter, New folder / New file (created in the current directory),
Sort order, and Show hidden files all live here.</dd>
<dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second
monitor.</dd>
<dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd>
<dt>Download / Download ZIP</dt>
<dd>Right-click a file for <b>Download</b>, or a folder for
<b>Download ZIP</b> (everything under it that you're allowed to see,
bundled into one archive). Online, the server streams it; locally,
the browser bundles the picked folder (a confirmation appears if it's
very large).</dd>
<dt>Refresh</dt>
<dd>Re-fetches the current directory listing — works for both
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
</dl>
<h3>Header buttons</h3>
<dl>
<dt>Use Local Directory</dt>
<dd>Pick a folder from your computer. Works in both modes; in online
mode it's de-emphasized but still available.</dd>
<dt>⟳ Refresh</dt>
<dd>Re-load the current directory listing.</dd>
<dt>◐ Theme</dt>
<dd>Cycle auto / light / dark.</dd>
</dl>
</div>
</aside>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

1052
build Executable file

File diff suppressed because it is too large Load diff

244
classifier/README.md Normal file
View file

@ -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.

120
classifier/build.sh Executable file
View file

@ -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 <script src="cdn..."> tag in
# template.html plus the loadLibrary CDN calls in preview.js. xlsx stays
# 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/vendor/js-yaml.min.js" \
"../shared/zddc.js" \
"../shared/hash.js" \
"../shared/zddc-source.js" \
"../shared/theme.js" \
"../shared/toast.js" \
"../shared/logo.js" \
"../shared/preview-lib.js" \
"js/app.js" \
"js/utils.js" \
"../shared/zddc-filter.js" \
"js/store.js" \
"js/persist.js" \
"js/classify.js" \
"js/workspace.js" \
"js/dnd.js" \
"js/seltable.js" \
"js/validator.js" \
"js/scanner.js" \
"js/tree.js" \
"js/target-tree.js" \
"js/copy.js" \
"js/mdl-instantiate.js" \
"js/spreadsheet.js" \
"js/selection.js" \
"js/preview.js" \
"js/resize.js" \
"js/filter.js" \
"js/sort.js" \
"js/excel.js" \
"../shared/help.js" \
"../shared/elevation.js" \
"../shared/profile-menu.js" \
"../shared/cap.js" \
> "$js_raw"
# Escape '</' in inlined JS so the HTML parser cannot mistake string contents
# for a closing </script> 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\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https?:\/\// { next }
/<link rel="stylesheet" href="https?:\/\// { next }
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html"
if [ "$is_release" = "1" ]; then
promote_release "classifier"
fi

32
classifier/css/base.css Normal file
View file

@ -0,0 +1,32 @@
/* Classifier-specific base overrides
Reset, tokens, buttons, and font are provided by shared/base.css */
#app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Utility */
.text-muted { color: var(--text-muted); }
.text-success { color: var(--success); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
/* Checkbox label */
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
/* Toast notifications come from shared/toast.css (.zddc-toast); the
classifier-local .toast block was promoted there. */

688
classifier/css/layout.css Normal file
View file

@ -0,0 +1,688 @@
/* Classifier layout — tokens from shared/base.css */
/* .empty-state / .empty-state__inner / .welcome-list live in
shared/base.css. Classifier keeps the .drag-over modifier locally
because it's the only tool whose empty state is a drop target. */
.empty-state.drag-over {
background: var(--primary-light);
outline: 2px dashed var(--primary);
outline-offset: -4px;
}
/* Browser Warning */
.browser-warning {
background-color: rgba(217, 119, 6, 0.08);
border: 2px solid var(--warning);
border-radius: var(--radius);
padding: 1.5rem;
margin: 1.5rem 0;
text-align: left;
}
.browser-warning h3 {
color: var(--warning);
margin-top: 0;
}
.browser-warning ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
/* Main App */
.main-app {
display: flex;
flex-direction: column;
height: 100vh;
background-color: var(--bg);
position: relative;
}
/* Header — shared/base.css provides .app-header base */
.app-header {
padding: 0.5rem 1rem;
}
.header-divider {
color: var(--border);
margin: 0 0.25rem;
}
/* Main Content */
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* Folder Tree Pane */
.folder-tree-pane {
width: 300px;
min-width: 150px;
display: flex;
flex-direction: column;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
flex-shrink: 0;
position: relative;
transition: width 0.2s ease, min-width 0.2s ease;
}
.folder-tree-pane.collapsed {
width: 40px !important;
min-width: 40px !important;
max-width: 40px !important;
overflow: hidden;
}
.folder-tree-pane.collapsed .pane-header-controls,
.folder-tree-pane.collapsed .classify-filters,
.folder-tree-pane.collapsed .tree-filter,
.folder-tree-pane.collapsed .folder-tree,
.folder-tree-pane.collapsed .pane-header h3 {
display: none;
}
.folder-tree-pane.collapsed .pane-header {
padding: 0.5rem;
justify-content: center;
}
.folder-tree-pane.collapsed .pane-header-title {
flex-direction: column;
}
.pane-header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.collapse-tree-btn {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Resize Handle */
.resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
background-color: transparent;
z-index: 10;
}
.resize-handle:hover {
background-color: var(--primary);
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background-color: var(--bg);
border-bottom: 1px solid var(--border);
}
.pane-header-left,
.pane-header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.pane-header h3 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.pane-header-controls {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.75rem;
align-items: center;
justify-content: flex-end;
}
/* Classify-mode filter row, laid out as a toolbar under the pane header. */
.tree-toolbar {
display: flex; flex-wrap: wrap; align-items: center;
gap: 0.2rem 0.7rem; padding: 0.3rem 1rem;
border-bottom: 1px solid var(--border);
}
.tree-toolbar__label { color: var(--text-muted); font-size: 0.8rem; font-weight: 600; }
.classify-filters .filter-count { color: var(--text-muted); font-size: 0.85em; }
/* Live filter box above a file tree. */
.tree-filter {
width: 100%; box-sizing: border-box; margin: 0.25rem 0;
padding: 0.25rem 0.5rem; font: inherit; font-size: 0.85rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text);
}
.tree-filter:focus { outline: none; border-color: var(--primary); }
.folder-stats,
.file-stats {
display: flex;
gap: 1rem;
font-size: 12px;
color: var(--text-muted);
}
.folder-tree {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* Folder Item */
.folder-item {
display: flex;
align-items: center;
padding: 0.5rem;
cursor: pointer;
border-radius: var(--radius);
user-select: none;
transition: background-color 0.15s;
}
.folder-item:hover {
background-color: var(--bg-hover);
}
/* Counts read "direct+total". Completed numbers are blue var(--primary),
which is theme-aware (medium blue in light, lighter blue in dark). The
direct number is always completed (known the moment the folder is read).
The "+total" subtree count stays muted grey + pulses while still scanning,
then turns blue once final. Once the row is fully scanned (both numbers
blue) the folders/files labels turn blue too (.folder-count.done). */
.folder-count .ct-direct,
.folder-count .ct-total {
color: var(--primary);
}
.folder-count .ct-total.pending {
color: var(--text-muted, #9aa0a6);
font-style: italic;
animation: scan-pulse 1.2s ease-in-out infinite;
}
.folder-count.done .ct-label {
color: var(--primary);
}
@keyframes scan-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* Page footer — hosts the live scan status. */
.app-footer {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0.2rem 0.75rem;
border-top: 1px solid var(--border, #e2e2e2);
background: var(--bg-secondary, #f5f5f5);
font-size: 0.75rem;
color: var(--text-muted, #8a8a8a);
min-height: 1.4em;
}
.scan-status {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scan-status.scanning { color: var(--primary, #2868c8); }
.folder-item.selected {
background-color: var(--bg-selected);
font-weight: 500;
}
.folder-item.folder-hover-highlight {
background-color: rgba(217, 119, 6, 0.12);
border-left: 3px solid var(--warning);
transition: background-color 0.2s, border-left 0.2s;
}
.folder-item.has-unsaved {
border-left: 3px solid var(--warning);
}
.folder-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
color: var(--text-muted);
}
.folder-icon {
margin-right: 0.5rem;
color: var(--text-muted);
}
.folder-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-count {
font-size: 11px;
color: var(--text-muted);
margin-left: 0.5rem;
}
.folder-children {
margin-left: 1.5rem;
}
/* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */
/* Scroll when the viewport is short. The inner card uses auto margins instead
of the base .empty-state's align-items:center so it centers when it fits but
collapses to the top when taller than the viewport otherwise centering
clips the top of the card and it can't be scrolled into view. */
.empty-state--overlay { overflow-y: auto; }
.empty-state--overlay > .empty-state__inner { margin: auto; }
.welcome { max-width: 900px; padding: 1.5rem 0.5rem 2.5rem; }
.welcome__title { font-size: 2.6rem; line-height: 1.1; margin: 0 0 0.6rem; }
.welcome__lede {
font-size: 1.2rem; line-height: 1.55; color: var(--text);
margin: 0 auto 2rem; max-width: 62ch;
}
.welcome__methods {
display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;
margin: 1.75rem 0 0; text-align: left;
}
@media (max-width: 780px) { .welcome__methods { grid-template-columns: 1fr; } }
.method {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 1rem 1.15rem; background: var(--bg);
}
.method--primary { border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
.method__title { font-size: 1.1rem; margin: 0 0 0.5rem; }
.method__tag {
display: inline-block; font-size: 0.68rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: var(--primary);
margin-left: 0.4rem; vertical-align: middle;
}
.method__tag--warn { color: var(--warning); }
.method__what { font-size: 0.95rem; color: var(--text-muted); margin: 0 0 0.7rem; }
.method__steps { margin: 0; padding-left: 1.25rem; font-size: 0.95rem; line-height: 1.6; }
.method__steps li { margin: 0.35rem 0; }
.method__steps code {
background: var(--bg-secondary); padding: 0.05rem 0.35rem;
border-radius: 4px; font-size: 0.85em;
}
.welcome__note { font-size: 0.9rem; color: var(--text-muted); margin-top: 1.5rem; }
/* ── Workspaces (welcome manager) ──────────────────────────────────────── */
.workspaces { text-align: left; margin: 1.5rem 0 0.5rem; }
.ws-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.ws-head__actions { display: flex; gap: 0.5rem; }
.ws-head h2 { margin: 0; font-size: 1.4rem; }
.ws-list { display: flex; flex-direction: column; gap: 0.4rem; }
.ws-empty { color: var(--text-muted); font-size: 0.85rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); }
.ws-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg);
}
.ws-row__main { flex: 1; min-width: 0; }
.ws-row__name { font-weight: 600; }
.ws-row__meta { font-size: 0.78rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-row__actions { display: flex; gap: 0.3rem; flex-shrink: 0; }
.ws-or { font-size: 0.82rem; color: var(--text-muted); margin: 1rem 0 0.5rem; }
/* ── Workflow mode switch (header) ─────────────────────────────────────── */
.mode-switch {
display: inline-flex;
margin-left: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.mode-btn {
border: none;
background: var(--bg);
color: var(--text-muted);
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
}
.mode-btn + .mode-btn { border-left: 1px solid var(--border); }
.mode-btn.active {
background: var(--primary);
color: var(--bg);
font-weight: 600;
}
/* ── Target pane (Classify & Copy) ─────────────────────────────────────── */
.target-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.target-pane[hidden], .spreadsheet-pane[hidden] { display: none; }
.target-tabs { display: flex; gap: 0.25rem; }
.target-tab {
border: 1px solid var(--border);
border-bottom: none;
background: var(--bg-secondary);
color: var(--text-muted);
padding: 0.3rem 0.8rem;
font-size: 0.85rem;
border-radius: var(--radius) var(--radius) 0 0;
cursor: pointer;
}
.target-tab.active {
background: var(--bg);
color: var(--primary);
font-weight: 600;
}
.target-body { flex: 1; overflow: hidden; }
.target-panel { height: 100%; display: flex; flex-direction: column; }
.target-panel[hidden] { display: none; }
.target-panel__toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.target-hint { font-size: 0.75rem; color: var(--text-muted); }
.target-tree { flex: 1; overflow: auto; padding: 0.5rem 0.75rem; }
.target-empty { color: var(--text-muted); font-size: 0.85rem; padding: 1rem 0.25rem; }
/* tree nodes */
.tnode { margin: 0.1rem 0; }
.tnode__children { margin-left: 1.25rem; border-left: 1px dashed var(--border); padding-left: 0.5rem; }
.tnode__row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0.3rem;
border-radius: var(--radius);
}
.tnode__row:hover { background: var(--bg-hover); }
.tnode__toggle {
border: none; background: none; cursor: pointer;
color: var(--text-muted); width: 1.1em; font-size: 0.8rem; padding: 0;
}
.tnode__icon { font-size: 0.85rem; }
.tnode__name { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tnode--leaf > .tnode__row > .tnode__name { color: var(--primary); font-weight: 600; }
.tnode--party > .tnode__row > .tnode__name { font-weight: 700; }
.tnode--bin > .tnode__row > .tnode__name { color: var(--primary); }
.tnode__badge {
background: var(--primary); color: var(--bg);
border-radius: 999px; padding: 0 0.4rem; font-size: 0.7rem; font-weight: 600;
}
/* Node CRUD controls sit to the right of the level name, revealed on hover. */
.tnode__actions { display: inline-flex; gap: 0.1rem; margin-left: 0.3rem; flex: 0 0 auto; opacity: 0; transition: opacity 0.12s; }
.tnode__row:hover .tnode__actions, .tslot__row:hover .tnode__actions { opacity: 1; }
.tnode__act {
border: 1px solid var(--border); background: var(--bg);
border-radius: var(--radius); cursor: pointer;
font-size: 0.72rem; padding: 0.05rem 0.35rem; color: var(--text);
}
.tnode__act:hover { background: var(--bg-hover); }
/* placed files under a node */
.tnode__files { margin: 0.1rem 0 0.2rem 1.6rem; }
.tfile { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.75rem; padding: 0.05rem 0; cursor: grab; }
.tfile[draggable="true"]:active { cursor: grabbing; }
.tfile__remove { opacity: 0; flex: 0 0 auto; align-self: center; line-height: 1; }
.tfile:hover .tfile__remove { opacity: 1; }
.tfile__remove:hover { color: var(--danger); border-color: var(--danger); }
.tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; }
.tfile__arrow { color: var(--text-muted); }
.tfile__name { color: var(--text); }
.tfile--err .tfile__name { color: var(--danger); }
.tfile--err::before { content: "⚠"; color: var(--danger); }
/* transmittal slots + bin form */
.tslot { margin: 0.15rem 0 0.15rem 1.1rem; }
.tslot__row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0.3rem; }
.tslot__name { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; }
.tnode--bin { margin-left: 1.1rem; }
.binform {
display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
margin: 0.2rem 0 0.3rem 1.1rem; padding: 0.4rem; background: var(--bg-secondary);
border: 1px solid var(--border); border-radius: var(--radius);
}
.binform input, .binform select {
font-size: 0.78rem; padding: 0.2rem 0.3rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text);
}
.binform__seq { width: 7rem; }
.binform__title { width: 11rem; }
/* drop-target affordance */
.tnode__row.drop-hover, .tslot.drop-hover { outline: 2px dashed var(--primary); outline-offset: -2px; background: var(--primary-light); }
/* ── Source-tree file rows (classify mode) ─────────────────────────────── */
.file-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0.5rem;
cursor: grab;
border-radius: var(--radius);
font-size: 0.85rem;
user-select: none;
}
.file-item:hover { background: var(--bg-hover); }
.file-item:active { cursor: grabbing; }
.file-item.match-highlight { background: var(--primary-light); outline: 1px solid var(--primary); }
.folder-item[draggable="true"] { cursor: grab; }
.file-icon { color: var(--text-muted); }
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* classification state dot */
.cl-dot {
width: 0.55rem; height: 0.55rem; border-radius: 999px; flex-shrink: 0;
border: 1px solid var(--border); background: transparent;
}
.cl-dot--none { background: transparent; }
.cl-dot--tracking,
.cl-dot--transmittal { background: var(--warning); border-color: var(--warning); }
.cl-dot--partial { background: var(--warning); border-color: var(--warning); }
.cl-dot--done { background: var(--success); border-color: var(--success); }
.cl-dot--excluded { background: var(--text-muted); border-color: var(--text-muted); opacity: 0.6; }
.file-item.excluded .file-name { text-decoration: line-through; color: var(--text-muted); }
.folder-item.excluded .folder-name { text-decoration: line-through; color: var(--text-muted); }
/* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; }
.tfile:hover .tfile__orig { text-decoration: underline; } /* click row (not the name input) → preview */
input.tfile__name {
flex: 1 1 auto; min-width: 10rem; font: inherit; color: var(--text);
border: 1px solid transparent; background: transparent; border-radius: 3px; padding: 0 0.2rem;
}
input.tfile__name:hover { border-color: var(--border); }
input.tfile__name:focus { border-color: var(--primary); background: var(--bg); outline: none; }
/* cross-tree reveal flash */
.reveal-flash, .match-highlight { animation: cl-flash 1.5s ease-out; }
@keyframes cl-flash {
0%, 40% { background: var(--primary-light); outline: 2px solid var(--primary); outline-offset: -2px; }
100% { background: transparent; outline-color: transparent; }
}
/* exclude/include context menu */
.cl-menu {
position: fixed; z-index: 9500;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 6px 18px rgba(0,0,0,0.18);
padding: 0.25rem; min-width: 11rem;
}
.cl-menu__item {
display: block; width: 100%; text-align: left;
border: none; background: none; color: var(--text);
padding: 0.4rem 0.6rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius);
}
.cl-menu__item:hover { background: var(--bg-hover); }
/* Spreadsheet Pane */
.spreadsheet-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.spreadsheet-container {
flex: 1;
overflow: auto;
background-color: var(--bg);
}
/* ZIP Extract Button in Tree */
.zip-extract-btn {
margin-left: auto;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.15s;
}
.folder-item:hover .zip-extract-btn {
opacity: 1;
}
.zip-extract-btn:disabled {
opacity: 0.5;
cursor: wait;
}
/* ── By-MDL panel (seltable rows = deliverable drop targets) ─────────────── */
#mdlPanel .seltable { height: 100%; }
.mdl-rev__input {
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
}
.seltable__extra { white-space: normal; }
.mdlfile__name { font-size: 0.78rem; }
#mdlPanel .tfile { gap: 0.3rem; align-items: center; padding: 0.05rem 0; cursor: grab; }
#mdlPanel .tfile--err .mdlfile__name { color: var(--danger); }
#mdlPanel .tfile__remove { opacity: 0.6; }
#mdlPanel .tfile:hover .tfile__remove { opacity: 1; }
/* ── MDL-from-archive overlay ───────────────────────────────────────────── */
.mdl-overlay { position: fixed; inset: 0; z-index: 1100; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
.mdl-overlay__box { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 10px 40px rgba(0,0,0,0.3); width: 100%; max-width: 1000px; height: 80vh; display: flex; flex-direction: column; }
.mdl-overlay__head { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__head h2 { margin: 0; font-size: 1.1rem; }
.mdl-overlay__close { background: none; border: none; font-size: 1.6rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
.mdl-overlay__close:hover { color: var(--text); }
.mdl-overlay__status { padding: 0.4rem 1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); }
.mdl-overlay__table { flex: 1; min-height: 0; }
.mdl-overlay__foot { display: flex; justify-content: flex-end; gap: 0.5rem; padding: 0.75rem 1rem; border-top: 1px solid var(--border); }
/* ── Shared selectable + autofilter table (seltable) ────────────────────── */
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
.seltable__filter {
flex: 1; min-width: 8rem; padding: 0.3rem 0.5rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.85rem;
}
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
.seltable__table thead th {
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}
.seltable__row { cursor: pointer; user-select: none; }
.seltable__row:hover { background: var(--bg-hover); }
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
/* ── Copy destination dialog ────────────────────────────────────────────── */
.copy-choice__backdrop {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1rem;
}
.copy-choice {
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
max-width: 460px; width: 100%; padding: 1.25rem 1.5rem;
}
.copy-choice h3 { margin: 0 0 0.5rem; font-size: 1.15rem; }
.copy-choice p { margin: 0 0 1.1rem; color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; }
.copy-choice code { font-size: 0.82em; }
.copy-choice__select {
width: 100%; margin: 0 0 1rem; padding: 0.45rem 0.55rem;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem;
}
.copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
.ttable th, .ttable td {
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
vertical-align: top;
padding: 0;
}
.ttable thead th {
position: sticky; top: 0; z-index: 3;
background: var(--bg-secondary, var(--bg));
color: var(--text-muted);
font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
text-align: left; padding: 0.3rem 0.5rem; white-space: nowrap;
border-top: 1px solid var(--border);
}
.ttable__rh { color: var(--primary); }
.ttable__fileh { width: 99%; } /* the files column soaks up remaining width */
.ttable__cell--empty { background: var(--bg-secondary, var(--bg)); }
/* The merged-cell value stays pinned just under the header while you scroll the
group, so a tall rowspan never reads as a blank column. */
.tcell__inner {
position: sticky; top: 1.6rem;
display: flex; align-items: center; gap: 0.3rem;
padding: 0.25rem 0.5rem; white-space: nowrap;
}
.tcell__name { font-weight: 600; }
.trev__inner .tcell__name { color: var(--primary); }
.tcell__preview { text-decoration: none; cursor: pointer; }
.tcell__preview:hover { text-decoration: underline; }
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
.ttable__file { padding: 0.1rem 0.4rem; }
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
.ttable .tfile { gap: 0.3rem; align-items: center; }
.ttable .tfile__name {
flex: 1; min-width: 8rem; max-width: 24rem;
padding: 0.15rem 0.35rem; border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text); font-size: 0.8rem;
}
.ttable .tfile__name:hover, .ttable .tfile__name:focus { border-color: var(--border); background: var(--bg); }
.ttable .tfile__name--err { color: var(--danger); }
.ttable .tfile--err::before { content: none; } /* we render our own badge instead */
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
.tfile__badge--ok { color: var(--success, #16a34a); }
.tfile__badge--err { color: var(--danger); }

View file

@ -0,0 +1,465 @@
/**
* Spreadsheet Styles
* Table, cells, editing, and row states
*/
.spreadsheet {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
background-color: var(--bg);
}
/* Selected cells */
.selected-cell {
background-color: rgba(0, 123, 255, 0.2) !important;
outline: 1px solid var(--primary);
}
/* Auto-populated cells (gray text to indicate matches filename) */
.cell-editable.auto-populated {
color: var(--text-muted);
}
/* Changed fields (blue text to indicate value differs from original filename) */
.cell-editable.field-changed {
color: var(--primary);
font-weight: 500;
}
.spreadsheet thead {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--bg-secondary);
}
.spreadsheet th {
padding: 0.75rem 0.5rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--border);
background-color: var(--bg-secondary);
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spreadsheet th:hover:not(.col-row-num) {
background-color: var(--border);
}
/* Sort indicator */
.sort-indicator {
display: inline-block;
font-size: 0.75rem;
color: var(--primary);
margin-left: 0.25rem;
margin-right: 0.25rem;
font-weight: bold;
vertical-align: middle;
}
.spreadsheet td {
padding: 0.5rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
white-space: nowrap;
}
/* Column resizer */
.column-resizer {
position: absolute;
top: 0;
right: 0;
width: 5px;
height: 100%;
cursor: col-resize;
user-select: none;
z-index: 1;
}
.column-resizer:hover {
background-color: var(--primary);
}
/* Column Widths */
.col-row-num {
width: 50px;
text-align: center;
background-color: var(--bg-secondary);
font-weight: 600;
color: var(--text-muted);
user-select: none;
-webkit-user-select: none;
}
.col-original {
min-width: 250px;
}
.col-extension {
width: 60px;
text-align: center;
}
.col-new {
min-width: 250px;
}
.col-trackingNumber {
min-width: 200px;
width: 200px;
}
.col-revision {
width: 80px;
}
.col-status {
width: 100px;
}
.col-title {
min-width: 200px;
}
.col-sha256 {
min-width: 150px;
font-family: var(--font-mono);
font-size: 11px;
}
.col-actions {
width: 100px;
text-align: center;
}
/* Row States */
.spreadsheet tbody tr {
transition: background-color 0.15s;
}
.spreadsheet tbody tr:hover {
background-color: var(--bg-hover);
}
.spreadsheet tbody tr.modified {
border-left: 3px solid var(--warning);
}
.spreadsheet tbody tr.error {
border-left: 3px solid var(--danger);
background-color: rgba(220, 53, 69, 0.08);
}
.spreadsheet tbody tr.saving {
opacity: 0.6;
pointer-events: none;
}
/* Cell Content */
.cell-content {
display: block;
width: 100%;
}
.cell-link {
color: var(--primary);
text-decoration: none;
}
.cell-link:hover {
text-decoration: underline;
}
.cell-extension {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
.cell-computed {
font-style: italic;
color: var(--text-muted);
}
/* Editable Cells */
.cell-editable {
cursor: text;
position: relative;
}
.cell-editable:hover {
background-color: var(--bg-hover);
}
.cell-content[contenteditable="true"] {
outline: 2px solid var(--primary);
outline-offset: 0;
background-color: var(--bg);
min-height: 1.5em;
}
.cell-content[contenteditable="true"]:focus {
outline: 2px solid var(--primary);
outline-offset: 0;
}
.cell-content.editing {
white-space: pre-wrap;
word-break: break-word;
}
/* Computed cells */
.cell-editable.computed {
font-style: italic;
color: var(--text-muted);
}
.cell-editable.computed:hover {
font-style: normal;
color: var(--text);
}
/* Validation states */
.validation-error {
background-color: rgba(220, 53, 69, 0.1);
border-left: 3px solid var(--danger);
}
.validation-warning {
background-color: rgba(255, 193, 7, 0.1);
border-left: 3px solid #ffc107;
}
/* Inline Actions */
.inline-actions {
position: absolute;
right: 0.25rem;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 0.25rem;
background-color: var(--bg);
padding: 0.125rem;
border-radius: 3px;
}
.cell-editable {
position: relative;
padding-right: 3.5rem; /* Space for buttons */
}
.btn-inline {
border: none;
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 14px;
padding: 0.25rem 0.5rem;
border-radius: 3px;
transition: all 0.15s;
font-weight: bold;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.btn-save {
color: var(--success);
}
.btn-save:hover {
background-color: var(--success);
color: white;
}
.btn-cancel {
color: var(--danger);
}
.btn-cancel:hover {
background-color: var(--danger);
color: white;
}
/* Formula Preview */
.formula-preview {
position: absolute;
bottom: 100%;
left: 0;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
font-size: 12px;
min-width: 200px;
z-index: 100;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.formula-preview-label {
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.formula-preview-value {
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.formula-preview-value.valid {
color: var(--success);
}
.formula-preview-value.invalid {
color: var(--danger);
}
.preview-check {
color: var(--success);
font-size: 16px;
cursor: pointer;
padding: 0.25rem;
border-radius: 3px;
transition: background-color 0.15s;
}
.preview-check:hover {
background-color: rgba(40, 167, 69, 0.1);
}
.preview-error {
color: var(--danger);
font-size: 16px;
}
.formula-preview-errors {
margin-top: 0.25rem;
font-size: 11px;
color: var(--danger);
white-space: normal;
}
/* Validation States */
.cell-warning {
background-color: rgba(255, 193, 7, 0.08);
border-left: 3px solid var(--warning);
}
.cell-error {
background-color: rgba(220, 53, 69, 0.08);
border-left: 3px solid var(--danger);
}
.validation-icon {
display: inline-block;
margin-left: 0.25rem;
cursor: help;
}
/* SHA256 Column */
.sha256-hash {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
}
.sha256-calculating {
font-style: italic;
color: var(--text-muted);
}
/* Action Buttons */
.row-actions {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.btn-icon {
width: 28px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 4px;
background-color: var(--bg);
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover:not(:disabled) {
background-color: var(--bg-hover);
transform: scale(1.1);
}
.btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-save {
color: var(--success);
}
.btn-cancel {
color: var(--danger);
}
/* Inline tree-empty placeholder shown by tree.js when the folder
list is empty. Distinct from the top-level .empty-state overlay
(shared/base.css) which is the welcome screen. */
.tree-empty {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.tree-empty h3 {
margin-bottom: 0.5rem;
}
/* Spreadsheet Empty State */
.spreadsheet-empty {
text-align: center;
color: var(--text-muted);
padding: 1.5rem;
}
/* Selection Highlight */
.cell-selected {
outline: 2px solid var(--primary);
outline-offset: -2px;
z-index: 1;
}
/* Scrollbar Styling */
.spreadsheet-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.spreadsheet-container::-webkit-scrollbar-track {
background-color: var(--bg-secondary);
}
.spreadsheet-container::-webkit-scrollbar-thumb {
background-color: var(--border-dark);
border-radius: 6px;
}
.spreadsheet-container::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
/* Preview button active state */
#togglePreviewBtn.preview-active {
background-color: var(--primary);
color: white;
border-color: var(--primary);
}

747
classifier/js/app.js Normal file
View file

@ -0,0 +1,747 @@
/**
* ZDDC Classifier - Main Application
* Spreadsheet-based file renaming with Excel-like formulas
*/
(function() {
'use strict';
// Global application state
window.app = {
// File System
rootHandle: null,
// Data
folderTree: [],
selectedFolders: new Set(), // Multi-select support
lastSelectedFolderPath: null,
hideCompliant: false,
calculateSha256: false,
// DOM elements (populated on init)
dom: {},
// Modules (populated by other files)
modules: {}
};
/**
* Initialize the application
*/
function init() {
// Cache DOM elements + wire events first so the welcome screen
// (and the HTTP-mode auto-load below) can use them.
cacheDOMElements();
setupEventListeners();
// Workspace manager (renders the welcome list, owns new/open/autosave).
if (app.modules.workspace) app.modules.workspace.init();
// Browser-compatibility branch:
// HTTP mode (served by zddc-server) — works everywhere; the
// HTTP polyfill stands in for the FS Access API. Auto-load
// the directory the page lives in.
// Local mode (file://) — requires FS Access API for write
// access to the user-picked folder. Show the warning if
// the API is missing.
if (location.protocol === 'http:' || location.protocol === 'https:') {
// Don't disable the picker button — even in HTTP mode the
// user might want to add a local folder. But the auto-load
// below means the welcome screen usually never shows.
(async function () {
try {
var probe = await window.zddc.source.detectServerRoot();
if (probe.handle) {
await openDirectory(probe.handle);
return;
}
if (probe.status === 403) {
showHttpForbiddenMessage();
return;
}
} catch (err) {
console.warn('classifier: server-mode auto-load failed:', err);
}
// Server-mode probe inconclusive — fall through to welcome.
if (!checkBrowserCompatibility()) {
showBrowserWarning();
return;
}
showWelcomeScreen();
})();
return;
}
if (!checkBrowserCompatibility()) {
showBrowserWarning();
return;
}
showWelcomeScreen();
}
/**
* Check if browser supports File System Access API. Used in local
* (file://) mode only — HTTP mode runs through the HTTP polyfill,
* which has no browser dependency beyond fetch.
*/
function checkBrowserCompatibility() {
return 'showDirectoryPicker' in window;
}
/**
* Show a clear "no permission to list" message for HTTP-mode users
* who land on a path their ACL doesn't allow them to list. Distinct
* from the welcome screen so the user understands why the file tree
* is empty rather than wondering if they need to pick a folder.
*/
function showHttpForbiddenMessage() {
var screen = document.getElementById('welcomeScreen');
if (!screen) return;
screen.classList.remove('hidden');
var msg = document.createElement('div');
msg.className = 'classifier-forbidden-message';
msg.style.cssText = 'padding: 1.5rem; max-width: 36rem; margin: 0 auto; text-align: center;';
msg.innerHTML =
'<h2 style="margin-bottom: 0.75rem;">No permission to list this directory</h2>' +
'<p>Your account does not have read access to this folder. ' +
'You may still be able to upload files if your role allows it; ' +
'contact the document controller if you believe this is wrong.</p>';
screen.appendChild(msg);
var addBtn = document.getElementById('addDirectoryBtn');
if (addBtn) addBtn.disabled = true;
}
/**
* Show browser compatibility warning
*/
function showBrowserWarning() {
const warning = document.getElementById('browserWarning');
const selectBtn = document.getElementById('addDirectoryBtn');
if (warning) {
warning.classList.remove('hidden');
}
if (selectBtn) {
selectBtn.disabled = true;
selectBtn.textContent = 'Browser Not Supported';
}
}
/**
* Cache DOM element references
*/
function cacheDOMElements() {
app.dom = {
// Screens
welcomeScreen: document.getElementById('welcomeScreen'),
mainApp: document.getElementById('mainApp'),
// Header buttons
addDirectoryBtn: document.getElementById('addDirectoryBtn'),
refreshHeaderBtn: document.getElementById('refreshHeaderBtn'),
saveAllBtn: document.getElementById('saveAllBtn'),
cancelAllBtn: document.getElementById('cancelAllBtn'),
exportHashesBtn: document.getElementById('exportHashesBtn'),
sha256Checkbox: document.getElementById('sha256Checkbox'),
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
classifyFilters: document.getElementById('classifyFilters'),
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
showPartialCheckbox: document.getElementById('showPartialCheckbox'),
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
importDatasetBtn: document.getElementById('importDatasetBtn'),
importDatasetInput: document.getElementById('importDatasetInput'),
resetDatasetBtn: document.getElementById('resetDatasetBtn'),
treeFilterInput: document.getElementById('treeFilterInput'),
trackingFilterInput: document.getElementById('trackingFilterInput'),
transmittalFilterInput: document.getElementById('transmittalFilterInput'),
// Folder tree
folderTree: document.getElementById('folderTree'),
folderTreePane: document.getElementById('folderTreePane'),
collapseTreeBtn: document.getElementById('collapseTreeBtn'),
autoScrollCheckbox: document.getElementById('autoScrollCheckbox'),
selectedFoldersCount: document.getElementById('selectedFoldersCount'),
// Spreadsheet
spreadsheet: document.getElementById('spreadsheet'),
spreadsheetBody: document.getElementById('spreadsheetBody'),
sha256Column: document.getElementById('sha256Column'),
// Stats
totalFiles: document.getElementById('totalFiles'),
modifiedFiles: document.getElementById('modifiedFiles'),
errorFiles: document.getElementById('errorFiles'),
// Preview
togglePreviewBtn: document.getElementById('togglePreviewBtn'),
// Mode switch + Classify & Copy panes
modeRenameBtn: document.getElementById('modeRenameBtn'),
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
spreadsheetPane: document.getElementById('spreadsheetPane'),
targetPane: document.getElementById('targetPane'),
copyOutputBtn: document.getElementById('copyOutputBtn'),
checkDuplicatesBtn: document.getElementById('checkDuplicatesBtn')
};
}
/**
* Switch between "Rename" (in-place grid) and "Classify & Copy" (map files
* onto target trees, copy renamed copies out). The source tree (left) stays
* in both modes; only the right pane swaps.
*/
function setMode(mode) {
const classify = mode === 'classify';
app.dom.modeRenameBtn.classList.toggle('active', !classify);
app.dom.modeClassifyBtn.classList.toggle('active', classify);
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify;
if (app.dom.targetPane) app.dom.targetPane.hidden = !classify;
// Mode-specific source-tree filters: "Hide Compliant" is for the rename
// grid; "Hide Assigned" is for the classify workflow.
if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify;
if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify;
app.modules.classify.setEnabled(classify);
if (classify && app.modules.targetTree) {
app.modules.targetTree.init();
app.modules.targetTree.render();
}
// Re-render the source tree so its per-file markers appear/disappear.
if (app.modules.tree) app.modules.tree.render();
}
// ── dataset export / import (one record per file) ──────────────────────
// Round-trip the classification as a flat list of files, each carrying its
// full ZDDC filename (and optional transmittal). An external editor (e.g. an
// AI) just sets filenames; on import the app parses each filename and
// rebuilds the tracking tree (no node ids to manage).
function eachSourceFile(cb) {
(function walk(nodes) {
(nodes || []).forEach(function (n) { (n.files || []).forEach(cb); walk(n.children); });
})(app.folderTree || []);
}
function exportDataset() {
var c = app.modules.classify, files = [];
eachSourceFile(function (f) {
var key = c.srcKeyForFile(f);
var a = c.getAssignment(key) || {};
var d = c.deriveTarget(f);
var rec = {
source: key,
originalName: window.zddc.joinExtension(f.originalFilename, f.extension),
filename: a.excluded ? '' : (d.filename || ''),
excluded: !!a.excluded,
};
if (!a.excluded && a.transmittalNodeId) {
var t = c.transmittalRecord(a.transmittalNodeId);
if (t) rec.transmittal = t;
}
files.push(rec);
});
var payload = {
zddcClassifierFiles: 1,
exportedAt: new Date().toISOString(),
_format: 'One record per input file. Set "filename" to its full ZDDC name '
+ '"TRACKING_REV (STATUS) - Title.ext" — on import the app splits TRACKING on "-" and the '
+ 'final "_" into nested folders, and files in shared paths share ancestors. Set '
+ '"excluded": true for non-documents (filename then ignored). "transmittal" is optional: '
+ '{party, slot:"received"|"issued", date:"YYYY-MM-DD", type:"TRN"|"SUB", seq, status, title}. '
+ 'Classify every "source" key; do not invent files.',
outputName: c.serialize().outputName || null,
files: files,
};
var name = 'classifier-dataset';
try {
if (app.modules.workspace && typeof app.modules.workspace.activeName === 'function') {
name = app.modules.workspace.activeName() || name;
}
} catch (_) { /* ok */ }
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = String(name).replace(/[^\w.-]+/g, '_') + '.zddc-classification.json';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function importDataset(file) {
var reader = new FileReader();
reader.onload = function () {
var obj;
try { obj = JSON.parse(reader.result); }
catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); return; }
if (!obj || !Array.isArray(obj.files)) {
window.zddc.toast('Import failed — expected a classifier dataset with a "files" list.', 'error'); return;
}
var c = app.modules.classify;
var hasData = c.getTrackingTree().length || c.getTransmittalTree().length
|| Object.keys(c.serialize().assignments || {}).length;
if (hasData && !confirm('Replace the current classification with the imported dataset?')) return;
c.reset();
var ok = 0, bad = 0;
obj.files.forEach(function (rec) {
if (!rec || !rec.source) return;
var key = rec.source;
if (rec.excluded) { c.setExcluded([key], true); ok++; return; }
if (rec.filename) {
var p = window.zddc.parseFilename(String(rec.filename).trim());
if (p && p.valid) {
var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')';
c.place([key], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking');
if (p.title != null) c.setTitleOverride(key, p.title);
ok++;
} else { bad++; }
}
if (rec.transmittal && rec.transmittal.party) {
var t = rec.transmittal;
var pid = c.findOrAddParty(t.party);
var bid = c.findOrAddTransmittalBin(pid, t.slot || 'received', {
date: t.date, type: t.type || 'TRN', seq: t.seq, status: t.status, title: t.title,
});
if (bid) c.place([key], bid, 'transmittal');
}
});
window.zddc.toast('Imported ' + ok + ' file' + (ok === 1 ? '' : 's')
+ (bad ? (' — ' + bad + ' had an unparseable filename') : '') + '.', bad ? 'warning' : 'success');
};
reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); };
reader.readAsText(file);
}
// Reset to a clean state: keep the scanned source tree (the raw input), but
// discard every classification — trees, assignments, excludes, overrides.
// Destructive and irreversible, so warn and steer the user to Export first.
function resetDataset() {
var c = app.modules.classify;
var n = Object.keys(c.serialize().assignments || {}).length;
if (!n && !c.getTrackingTree().length && !c.getTransmittalTree().length) {
window.zddc.toast('Nothing to reset — already at the raw input.', 'info');
return;
}
if (!confirm('Reset to a clean state?\n\nThis discards ALL classifications ('
+ n + ' assigned file' + (n === 1 ? '' : 's') + ', plus the tracking and '
+ 'transmittal trees) and returns to just the raw scanned input. Your actual '
+ 'files are not touched.\n\nThis cannot be undone — Export first if you might '
+ 'need this data.')) return;
c.reset();
window.zddc.toast('Reset to the raw scanned input.', 'success');
}
/**
* Set up event listeners
*/
function setupEventListeners() {
// Directory selection
app.dom.addDirectoryBtn.addEventListener('click', handleSelectDirectory);
app.dom.refreshHeaderBtn.addEventListener('click', handleRefresh);
// Drag and drop on welcome screen
setupWelcomeDragDrop();
// Bulk actions
app.dom.saveAllBtn.addEventListener('click', handleSaveAll);
app.dom.cancelAllBtn.addEventListener('click', handleCancelAll);
// Export hashes
app.dom.exportHashesBtn.addEventListener('click', handleExportHashes);
// SHA256 toggle
app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle);
// Hide compliant toggle
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
// Classify-mode source-tree filters: show/hide unassigned, assigned, excluded.
function pushClassifyFilters() {
if (app.modules.tree && app.modules.tree.setShowFilters) {
app.modules.tree.setShowFilters({
unassigned: app.dom.showUnassignedCheckbox.checked,
partial: app.dom.showPartialCheckbox.checked,
assigned: app.dom.showAssignedCheckbox.checked,
excluded: app.dom.showExcludedCheckbox.checked,
empty: app.dom.showEmptyCheckbox.checked,
});
}
}
[app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
// Collapse tree button
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
// Workflow mode switch
if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); });
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
var mdlBtn = document.getElementById('mdlInstantiateBtn');
if (mdlBtn) mdlBtn.addEventListener('click', function () { app.modules.mdlInstantiate.open(); });
// Live source-tree filter (matches file path + name; reveals the hierarchy).
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
if (app.modules.tree && app.modules.tree.setNameFilter) app.modules.tree.setNameFilter(this.value);
});
// Target-tree filter — both tabs share one query (mirrored across inputs).
function targetFilter(val) {
if (app.dom.trackingFilterInput) app.dom.trackingFilterInput.value = val;
if (app.dom.transmittalFilterInput) app.dom.transmittalFilterInput.value = val;
if (app.modules.targetTree && app.modules.targetTree.setNameFilter) app.modules.targetTree.setNameFilter(val);
}
[app.dom.trackingFilterInput, app.dom.transmittalFilterInput].forEach(function (inp) {
if (inp) inp.addEventListener('input', function () { targetFilter(this.value); });
});
// Dataset export / import (round-trip the classification through a JSON file).
if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset);
if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); });
if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset);
if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () {
if (this.files && this.files[0]) importDataset(this.files[0]);
this.value = ''; // allow re-importing the same file
});
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyDown);
// Resize handle
setupResizeHandle();
// Re-render the source tree when classify state changes (so file dots
// and placements stay in sync after a drop). Cheap no-op outside
// classify mode.
if (app.modules.classify) {
app.modules.classify.on(function () {
if (app.modules.classify.isEnabled() && app.modules.tree) app.modules.tree.render();
});
}
}
/**
* Handle collapse/expand folder tree pane
*/
function handleCollapseTree() {
const pane = app.dom.folderTreePane;
const btn = app.dom.collapseTreeBtn;
pane.classList.toggle('collapsed');
if (pane.classList.contains('collapsed')) {
// Clear any inline width from resize handle
pane.style.width = '';
btn.textContent = '▶';
btn.title = 'Expand folder tree';
} else {
btn.textContent = '◀';
btn.title = 'Collapse folder tree';
}
}
/**
* Set up folder tree resize handle
*/
function setupResizeHandle() {
const handle = document.getElementById('treeResizeHandle');
const pane = document.getElementById('folderTreePane');
if (!handle || !pane) return;
let isResizing = false;
let startX = 0;
let startWidth = 0;
handle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startWidth = pane.offsetWidth;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const delta = e.clientX - startX;
const newWidth = startWidth + delta;
// Respect min width only
if (newWidth >= 150) {
pane.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
}
});
}
/**
* Set up drag-and-drop on the welcome screen
*/
function setupWelcomeDragDrop() {
const screen = app.dom.welcomeScreen;
if (!screen) return;
['dragenter', 'dragover'].forEach(evt => {
screen.addEventListener(evt, (e) => {
e.preventDefault();
screen.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(evt => {
screen.addEventListener(evt, (e) => {
e.preventDefault();
screen.classList.remove('drag-over');
});
});
screen.addEventListener('drop', async (e) => {
const item = e.dataTransfer.items && e.dataTransfer.items[0];
if (!item) return;
const handle = await item.getAsFileSystemHandle();
if (!handle || handle.kind !== 'directory') {
alert('Please drop a folder, not a file.');
return;
}
await openDirectory(handle);
});
}
/**
* Handle directory selection via button click
*/
async function handleSelectDirectory() {
try {
const dirHandle = await window.showDirectoryPicker();
await openDirectory(dirHandle);
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Error selecting directory:', err);
alert('Error selecting directory: ' + err.message);
}
}
}
/**
* Open a directory handle and initialize the application
*/
// Show the main UI and initialize the per-tool modules ONCE. Shared by the
// legacy rename open and the workspace open/new flows (the latter scan or
// load a snapshot themselves).
var shellInited = false;
function enterAppShell() {
hideWelcomeScreen();
showMainUI();
if (!shellInited) {
shellInited = true;
app.modules.spreadsheet.init(); // Subscribe to store
app.modules.selection.init();
app.modules.preview.init(); // After selection so it can listen for rowfocused
app.modules.resize.init();
app.modules.filter.init();
app.modules.sort.init();
app.modules.tree.setupKeyboardShortcuts();
if (app.modules.targetTree) app.modules.targetTree.init();
}
if (app.dom.refreshHeaderBtn) app.dom.refreshHeaderBtn.classList.remove('hidden');
}
async function openDirectory(dirHandle) {
app.rootHandle = dirHandle;
enterAppShell();
// Default to Classify & Copy (the primary workflow). The user can switch
// to "Rename in place" via the toggle for the spreadsheet.
setMode('classify');
// Now scan directory (this will trigger store updates and renders)
await app.modules.scanner.scanDirectory(dirHandle);
}
/**
* Handle Refresh button - rescan current directory
*/
async function handleRefresh() {
if (!app.rootHandle) {
alert('No directory selected');
return;
}
try {
// A snapshot-loaded workspace handle needs its read permission
// re-granted before we can enumerate it again.
if (app.modules.persist && app.modules.persist.verifyPermission) {
const ok = await app.modules.persist.verifyPermission(app.rootHandle, false);
if (!ok) {
if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error');
return;
}
}
// Clear current data
app.folderTree = [];
app.selectedFolders.clear();
app.lastSelectedFolderPath = null;
// Reset store
app.modules.store.reset();
// Rescan directory (modules already initialized, just rescan)
await app.modules.scanner.scanDirectory(app.rootHandle);
// For a workspace, persist the refreshed snapshot (additive: the
// path-keyed map re-attaches; new files appear unassigned).
if (app.modules.workspace && app.modules.workspace.onRescanned) {
app.modules.workspace.onRescanned();
}
} catch (err) {
console.error('Error refreshing directory:', err);
alert('Error refreshing directory: ' + err.message);
}
}
/**
* Handle Save All button
*/
async function handleSaveAll() {
if (!confirm('Save all modified files?')) return;
try {
app.dom.saveAllBtn.disabled = true;
await app.modules.spreadsheet.saveAllFiles();
} catch (err) {
console.error('Error saving files:', err);
alert('Error saving files: ' + err.message);
} finally {
app.dom.saveAllBtn.disabled = false;
}
}
/**
* Handle Cancel All button
*/
function handleCancelAll() {
if (!confirm('Cancel all changes?')) return;
app.modules.spreadsheet.cancelAllChanges();
}
/**
* Handle Export Hashes button
*/
function handleExportHashes() {
app.modules.excel.exportHashes();
}
/**
* Handle SHA256 checkbox toggle
*/
function handleSha256Toggle() {
app.calculateSha256 = app.dom.sha256Checkbox.checked;
// Show/hide SHA256 column
if (app.calculateSha256) {
app.dom.sha256Column.classList.remove('hidden');
} else {
app.dom.sha256Column.classList.add('hidden');
}
// Re-render table
app.modules.spreadsheet.render();
}
/**
* Handle Hide Compliant checkbox toggle
*/
function handleHideCompliantToggle() {
app.hideCompliant = app.dom.hideCompliantCheckbox.checked;
app.modules.store.setHideCompliant(app.hideCompliant);
}
/**
* Handle keyboard shortcuts
*/
function handleKeyDown(e) {
// Ctrl+S - Save All
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
if (!app.dom.saveAllBtn.disabled) {
handleSaveAll();
}
}
// Escape - Cancel editing
if (e.key === 'Escape') {
app.modules.spreadsheet.cancelEditing();
}
}
/**
* Show welcome screen (empty-state overlay)
*/
function showWelcomeScreen() {
if (app.dom.welcomeScreen) {
app.dom.welcomeScreen.classList.remove('hidden');
}
}
/**
* Hide welcome screen (empty-state overlay)
*/
function hideWelcomeScreen() {
if (app.dom.welcomeScreen) {
app.dom.welcomeScreen.classList.add('hidden');
}
}
/**
* Show main UI (no-op: main UI is always rendered)
*/
function showMainUI() {
// Main app is always visible; only the empty-state overlay is toggled
}
/**
* Update stats display
*/
function updateStats() {
const files = app.modules.store.getDisplayFiles();
const totalFiles = files.length;
const modifiedFiles = files.filter(f => f.isDirty).length;
const errorFiles = files.filter(f => f.error).length;
app.dom.totalFiles.textContent = `${totalFiles} file${totalFiles !== 1 ? 's' : ''}`;
app.dom.modifiedFiles.textContent = `${modifiedFiles} modified`;
if (errorFiles > 0) {
app.dom.errorFiles.textContent = `${errorFiles} error${errorFiles !== 1 ? 's' : ''}`;
app.dom.errorFiles.classList.remove('hidden');
} else {
app.dom.errorFiles.classList.add('hidden');
}
// Enable/disable bulk action buttons
app.dom.saveAllBtn.disabled = modifiedFiles === 0;
app.dom.cancelAllBtn.disabled = modifiedFiles === 0;
// Enable/disable export hashes button
app.dom.exportHashesBtn.disabled = totalFiles === 0 || !app.calculateSha256;
}
// Export functions for use by other modules
app.modules.app = {
updateStats,
setMode,
enterAppShell
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

707
classifier/js/classify.js Normal file
View file

@ -0,0 +1,707 @@
/**
* ZDDC Classifier "Classify & Copy" state model.
*
* The non-destructive workflow: the source directory is read-only; the user
* maps each source file onto two orthogonal target trees, and a later copy
* step writes renamed copies into a separate output directory.
*
* - Tracking tab ( filename), POSITIONAL:
* tracking number = the file's ancestor folder names joined with '-'
* revision (+status) = its immediate parent folder, named "REV (STATUS)"
* title = derived from the original filename
* TRACKING_REV (STATUS) - TITLE.ext
* - Transmittal tab ( output path):
* <party>/{issued,received}/<YYYY-MM-DD_TN (STATUS) - TITLE>/
*
* This module is the single source of truth: placements live in `assignments`
* keyed by source-relative path (so they survive a re-pick); the trees define
* structure only. All target values are DERIVED, never stored.
*/
(function () {
'use strict';
// ── unique ids ───────────────────────────────────────────────────────────
var _idSeq = 0;
function uid() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
}
return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36);
}
// Per-workspace tracking-number PATTERN config. Drives the By-tracking
// table columns + (later) revision-modifier menus. Editable by the user.
var DEFAULT_FIELDS = [
{ name: 'ORIG', optional: false },
{ name: 'PROJ', optional: false },
{ name: 'DISC', optional: false },
{ name: 'TYPE', optional: false },
{ name: 'SEQ', optional: false },
{ name: 'SUFFIX', optional: true },
];
var DEFAULT_STATUSES = (window.zddc && window.zddc.STATUSES) ? window.zddc.STATUSES.slice() : ['---'];
var DEFAULT_MODIFIERS = ['B', 'C', 'N', 'Q'];
function defaultConfig() {
return {
trackingFields: DEFAULT_FIELDS.map(function (f) { return { name: f.name, optional: !!f.optional }; }),
statuses: DEFAULT_STATUSES.slice(),
modifiers: DEFAULT_MODIFIERS.slice(),
};
}
// ── state ────────────────────────────────────────────────────────────────
var state = {
enabled: false, // classify mode on/off
assignments: {}, // srcKey -> { trackingNodeId, transmittalNodeId, excluded, titleOverride }
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
outputName: null, // remembered output directory display name
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ]
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
var nodeIndex = {};
// Transient (not serialized): srcKeys flagged by the copy audit as a
// same-name/different-content conflict. Cleared whenever a placement changes.
var hashConflicts = {};
function setHashConflicts(map) { hashConflicts = map || {}; notify(); }
function hasHashConflict(key) { return !!hashConflicts[key]; }
function clearHashConflicts() { hashConflicts = {}; }
// ── pub/sub ──────────────────────────────────────────────────────────────
var listeners = [];
function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
var notifyScheduled = false;
function notify() {
// Coalesce bursts (a group-drop touches many keys) into one render.
// Listeners include the target/source re-renders AND the workspace
// autosave (workspace.js subscribes) — persistence is not this
// module's concern.
if (notifyScheduled) return;
notifyScheduled = true;
Promise.resolve().then(function () {
notifyScheduled = false;
for (var i = 0; i < listeners.length; i++) {
try { listeners[i](); } catch (e) { console.error('classify listener', e); }
}
});
}
// ── source keys + title derivation ───────────────────────────────────────
function stripRoot(p) {
var i = (p || '').indexOf('/');
return i < 0 ? '' : p.slice(i + 1);
}
// Stable key for a file: its path relative to the picked root (root segment
// dropped), so re-picking the same directory re-attaches the same map.
function srcKeyForFile(file) {
var rel = stripRoot(file.folderPath || '');
var fn = zddc.joinExtension(file.originalFilename, file.extension);
return rel ? rel + '/' + fn : fn;
}
// Default title: if the original name already parses as ZDDC, reuse its
// title; otherwise the cleaned stem (originalFilename is the stem already).
function defaultTitle(file) {
var full = zddc.joinExtension(file.originalFilename, file.extension);
var parsed = zddc.parseFilename(full);
if (parsed && parsed.valid && parsed.title) return parsed.title;
return (file.originalFilename || '').trim();
}
// Parse a leaf folder label "A (IFR)" → { revision, status }. No parens →
// the whole label is the revision and status is blank.
var LEAF_RE = /^(.*?)\s*\(([^)]+)\)\s*$/;
function parseLeafLabel(name) {
var m = (name || '').match(LEAF_RE);
if (m) return { revision: m[1].trim(), status: m[2].trim() };
return { revision: (name || '').trim(), status: '' };
}
// ── assignments ──────────────────────────────────────────────────────────
function assignmentFor(key) {
var a = state.assignments[key];
if (!a) {
a = { trackingNodeId: null, transmittalNodeId: null, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true };
state.assignments[key] = a;
}
return a;
}
// Read-only: returns the existing entry or null (no side effects).
function getAssignment(key) { return state.assignments[key] || null; }
function cleanAssignment(key) {
var a = state.assignments[key];
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.mdlNodeId && !a.excluded && !a.titleOverride) {
delete state.assignments[key];
}
}
// Place keys onto a node along one axis ('tracking' | 'transmittal').
// nodeId null clears that axis.
function place(keys, nodeId, axis) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId';
keys.forEach(function (k) {
var a = assignmentFor(k);
a[field] = nodeId || null;
// Tracking and MDL are alternative NAME sources — placing on one
// clears the other so the file has a single name origin.
if (axis === 'mdl' && nodeId) a.trackingNodeId = null;
else if (axis === 'tracking' && nodeId) a.mdlNodeId = null;
a.excluded = false; // placing un-excludes
cleanAssignment(k);
});
clearHashConflicts(); // a placement changed → stale conflict flags
notify();
}
function setExcluded(keys, excluded) {
keys.forEach(function (k) {
var a = assignmentFor(k);
a.excluded = !!excluded;
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; a.mdlNodeId = null; }
cleanAssignment(k);
});
clearHashConflicts();
notify();
}
// Forget any assignment for these source keys (e.g. when a .zip flips
// between single-file and folder mode and the old keys cease to exist).
function dropAssignments(keys) {
var changed = false;
(keys || []).forEach(function (k) {
if (state.assignments[k]) { delete state.assignments[k]; changed = true; }
});
if (changed) notify();
}
function setTitleOverride(key, title) {
var a = assignmentFor(key);
a.titleOverride = title && title.trim() ? title.trim() : null;
cleanAssignment(key);
notify();
}
// ── node index ───────────────────────────────────────────────────────────
function rebuildIndex() {
nodeIndex = {};
(function walkTracking(nodes, parent) {
(nodes || []).forEach(function (n) {
nodeIndex[n.id] = { node: n, kind: 'tracking', parent: parent };
walkTracking(n.children, n);
});
})(state.trackingTree, null);
(state.transmittalTree || []).forEach(function (party) {
nodeIndex[party.id] = { node: party, kind: 'party', parent: null };
(party.children || []).forEach(function (slot) {
nodeIndex[slot.id] = { node: slot, kind: 'slot', parent: party };
(slot.children || []).forEach(function (bin) {
nodeIndex[bin.id] = { node: bin, kind: 'transmittal', parent: slot };
});
});
});
(state.mdlList || []).forEach(function (row) {
nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null };
});
}
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
function infoFor(id) { return nodeIndex[id] || null; }
// Ancestor name chain for a tracking node (root → node inclusive).
function trackingChain(info) {
var names = [];
var cur = info;
while (cur && cur.kind === 'tracking') {
names.unshift(cur.node.name);
cur = cur.parent ? infoFor(cur.parent.id) : null;
}
return names;
}
// ── tracking tree ops ────────────────────────────────────────────────────
function addTrackingNode(parentId, name) {
var node = { id: uid(), name: (name || 'new').trim() || 'new', children: [] };
if (parentId) {
var info = infoFor(parentId);
if (!info || info.kind !== 'tracking') return null;
info.node.children.push(node);
} else {
state.trackingTree.push(node);
}
rebuildIndex();
notify();
return node.id;
}
// ── transmittal tree ops ─────────────────────────────────────────────────
function addParty(name) {
var party = { id: uid(), kind: 'party', name: (name || 'Party').trim() || 'Party', children: [] };
state.transmittalTree.push(party);
rebuildIndex();
notify();
return party.id;
}
function ensureSlot(party, slot) {
var existing = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
if (existing) return existing;
var node = { id: uid(), kind: 'slot', slot: slot, name: slot, children: [] };
party.children.push(node);
return node;
}
// Create a transmittal bin. meta = { date, type:'TRN'|'SUB', seq, status?, title? }.
// The folder name follows the folder grammar; party node name doubles as the
// transmittal-number prefix (so its tracking is "<party>-<type>-<seq>").
function addTransmittalBin(partyId, slot, meta) {
var info = infoFor(partyId);
if (!info || info.kind !== 'party') return null;
var slotNode = ensureSlot(info.node, slot);
var bin = { id: uid(), kind: 'transmittal', name: transmittalFolderName(info.node.name, meta), meta: meta };
slotNode.children.push(bin);
rebuildIndex();
notify();
return bin.id;
}
function transmittalFolderName(partyName, meta) {
var tn = [partyName, meta.type, meta.seq].filter(Boolean).join('-');
var status = meta.status && zddc.isValidStatus(meta.status) ? meta.status : '---';
var title = (meta.title && meta.title.trim()) || (meta.type === 'SUB' ? 'Submittal' : 'Transmittal');
return zddc.formatFolder({ date: meta.date, trackingNumber: tn, status: status, title: title });
}
// ── shared node ops ──────────────────────────────────────────────────────
function renameNode(id, name) {
var info = infoFor(id);
if (!info) return;
if (info.kind === 'slot') return; // slots are fixed
info.node.name = (name || '').trim() || info.node.name;
if (info.kind === 'party') {
// Party rename re-derives child transmittal folder names (prefix).
(info.node.children || []).forEach(function (slot) {
(slot.children || []).forEach(function (bin) {
bin.name = transmittalFolderName(info.node.name, bin.meta);
});
});
}
rebuildIndex();
notify();
}
// Delete a node (and descendants). Any placement referencing a removed node
// is cleared so no file points at a ghost.
function deleteNode(id) {
var info = infoFor(id);
if (!info) return;
var removed = {};
(function collect(n) {
removed[n.id] = true;
(n.children || []).forEach(collect);
})(info.node);
if (info.kind === 'tracking') {
removeFrom(info.parent ? info.parent.children : state.trackingTree, id);
} else if (info.kind === 'party') {
removeFrom(state.transmittalTree, id);
} else if (info.kind === 'transmittal') {
removeFrom(info.parent.children, id); // info.parent is the slot node
}
// Clear dangling placements.
Object.keys(state.assignments).forEach(function (k) {
var a = state.assignments[k];
if (a.trackingNodeId && removed[a.trackingNodeId]) a.trackingNodeId = null;
if (a.transmittalNodeId && removed[a.transmittalNodeId]) a.transmittalNodeId = null;
cleanAssignment(k);
});
rebuildIndex();
notify();
}
function removeFrom(arr, id) {
for (var i = 0; i < arr.length; i++) { if (arr[i].id === id) { arr.splice(i, 1); return; } }
}
// ── derive target ────────────────────────────────────────────────────────
// Compute the full target for a file from its placements. Pure; returns
// { tracking, revision, status, title, extension, filename, outPath,
// party, slot, transmittalFolder, complete, excluded, errors:[] }.
function deriveTarget(file) {
var key = srcKeyForFile(file);
var a = state.assignments[key] || {};
var out = {
key: key,
tracking: '', revision: '', status: '',
title: (a.titleOverride && a.titleOverride.trim()) || defaultTitle(file),
extension: file.extension || '',
filename: '', outPath: '',
party: '', slot: '', transmittalFolder: '',
trackingLeaf: false, excluded: !!a.excluded, errors: [],
};
if (out.excluded) return out;
// Axis 1 — NAME. An MDL deliverable (alternative to the tracking tree)
// supplies the tracking number + title; its revision comes from the
// classifier-local revision cell. Otherwise the tracking tree.
if (a.mdlNodeId) {
var mi = infoFor(a.mdlNodeId);
if (mi && mi.kind === 'mdl') {
var row = mi.node;
out.tracking = row.trackingNumber || '';
var ml = parseLeafLabel(row.revisionCell || '');
out.revision = ml.revision; out.status = ml.status;
out.trackingLeaf = true;
if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title;
if (!out.tracking) out.errors.push('deliverable has no tracking number');
if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")');
else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"');
} else {
out.errors.push('deliverable no longer loaded');
}
} else if (a.trackingNodeId) {
var ti = infoFor(a.trackingNodeId);
if (ti && ti.kind === 'tracking') {
var chain = trackingChain(ti); // [root … node]
out.tracking = chain.slice(0, -1).join('-'); // ancestors only
var leaf = parseLeafLabel(ti.node.name);
out.revision = leaf.revision;
out.status = leaf.status;
out.trackingLeaf = (ti.node.children || []).length === 0;
if (!out.tracking) out.errors.push('tracking number is empty — the file needs at least one ancestor folder');
if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
if (!out.status) out.errors.push('parent folder has no "(STATUS)" — name it like "A (IFR)"');
if (!out.trackingLeaf) out.errors.push('not in a leaf folder yet');
}
} else {
out.errors.push('no tracking number assigned');
}
// Axis 2 — transmittal → output path.
if (a.transmittalNodeId) {
var xi = infoFor(a.transmittalNodeId);
if (xi && xi.kind === 'transmittal') {
// bin → slot → party (nodeIndex stores parent as a NODE)
var slotInfo = xi.parent ? infoFor(xi.parent.id) : null;
out.slot = slotInfo ? slotInfo.node.slot : '';
out.party = slotInfo && slotInfo.parent ? slotInfo.parent.name : '';
out.transmittalFolder = xi.node.name;
if (out.party && out.slot && out.transmittalFolder) {
out.outPath = out.party + '/' + out.slot + '/' + out.transmittalFolder;
}
}
} else {
out.errors.push('not placed in a transmittal');
}
out.filename = zddc.formatFilename({
trackingNumber: out.tracking, revision: out.revision,
status: out.status, title: out.title, extension: out.extension,
});
if (!out.filename && out.errors.length === 0) out.errors.push('incomplete name');
out.complete = !!(out.filename && out.outPath && out.errors.length === 0);
return out;
}
// Files currently placed in a node (reverse lookup over all source files).
function filesInNode(nodeId, axis, allFiles) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
return (allFiles || []).filter(function (f) {
var a = state.assignments[srcKeyForFile(f)];
return a && a[field] === nodeId;
});
}
// Per-file classification state for the left-tree markers.
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
function fileState(file) {
var a = state.assignments[srcKeyForFile(file)];
if (!a) return 'none';
if (a.excluded) return 'excluded';
var t = !!a.trackingNodeId, x = !!a.transmittalNodeId;
if (t && x) {
var d = deriveTarget(file);
return d.complete ? 'done' : 'partial';
}
if (t) return 'tracking';
if (x) return 'transmittal';
return 'none';
}
function stats(allFiles) {
var s = { total: 0, excluded: 0, none: 0, partial: 0, done: 0 };
(allFiles || []).forEach(function (f) {
s.total++;
var st = fileState(f);
if (st === 'excluded') s.excluded++;
else if (st === 'done') s.done++;
else if (st === 'none') s.none++;
else s.partial++; // tracking | transmittal | partial
});
return s;
}
// ── serialize / load ─────────────────────────────────────────────────────
function serialize() {
return {
assignments: state.assignments,
trackingTree: state.trackingTree,
transmittalTree: state.transmittalTree,
outputName: state.outputName,
config: state.config,
mdlList: state.mdlList,
};
}
function load(obj) {
if (!obj) return;
state.assignments = obj.assignments || {};
state.trackingTree = obj.trackingTree || [];
state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
state.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : [];
rebuildIndex();
notify();
}
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
// per-project setting, not part of the data being cleared.
function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
rebuildIndex();
notify();
}
// ── pattern config ───────────────────────────────────────────────────────
function normalizeConfig(c) {
var d = defaultConfig();
if (!c || typeof c !== 'object') return d;
var fields = Array.isArray(c.trackingFields) && c.trackingFields.length
? c.trackingFields.map(function (f) { return { name: String(f && f.name || '').trim() || '?', optional: !!(f && f.optional) }; })
: d.trackingFields;
return {
trackingFields: fields,
statuses: Array.isArray(c.statuses) && c.statuses.length ? c.statuses.slice() : d.statuses,
modifiers: Array.isArray(c.modifiers) && c.modifiers.length ? c.modifiers.slice() : d.modifiers,
};
}
function getConfig() { return state.config; }
function getTrackingFields() { return state.config.trackingFields; }
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
// ── MDL deliverables (the "By MDL" drop-target axis) ─────────────────────
function setMdlList(rows) {
state.mdlList = (rows || []).map(function (r) {
return {
id: r.id || uid(), party: r.party || '',
trackingNumber: r.trackingNumber || '', title: r.title || '',
revisionCell: r.revisionCell || '',
};
});
// Drop placements pointing at deliverables no longer loaded.
var valid = Object.create(null);
state.mdlList.forEach(function (r) { valid[r.id] = true; });
Object.keys(state.assignments).forEach(function (k) {
var a = state.assignments[k];
if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); }
});
rebuildIndex();
notify();
}
function getMdlList() { return state.mdlList; }
function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; }
function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
function setRevisionCells(rowIds, value) {
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
var changed = false;
state.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } });
if (changed) notify();
}
function setTitleFromDeliverable(key, fromDeliverable) {
var a = assignmentFor(key);
a.titleFromDeliverable = !!fromDeliverable;
cleanAssignment(key);
notify();
}
// ── add-folder pattern expansion ─────────────────────────────────────────
// Brace expansion for the add-folder box. Supports (non-nested) groups:
// {a,b,c} → alternation: a | b | c
// {0001-0002} → numeric range, zero-padded to the operands' width
// {0001-0002,0005} → mix ranges and literals in one group
// Multiple groups expand as a cartesian product, e.g.
// "X-{PM,EL}-{0001-0002,0005}_A (IFR)" → 6 names.
// A pattern with no braces returns itself (one name). Unbalanced braces are
// treated literally so the user never silently loses input.
function expandGroup(body) {
var out = [];
String(body).split(',').forEach(function (piece) {
var m = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(piece);
if (m) {
var a = m[1], b = m[2];
var start = parseInt(a, 10), end = parseInt(b, 10);
// Pad when either operand carries a leading zero (e.g. 0001).
var width = (a.length > 1 && a[0] === '0') || (b.length > 1 && b[0] === '0')
? Math.max(a.length, b.length) : 0;
var step = start <= end ? 1 : -1;
for (var v = start; step > 0 ? v <= end : v >= end; v += step) {
out.push(width ? String(v).padStart(width, '0') : String(v));
}
} else {
out.push(piece);
}
});
return out;
}
function expandFolderPattern(pattern) {
var s = String(pattern == null ? '' : pattern);
var parts = []; // each: {lit} or {opts:[...]}
var i = 0;
while (i < s.length) {
var open = s.indexOf('{', i);
if (open === -1) { parts.push({ lit: s.slice(i) }); break; }
var close = s.indexOf('}', open);
if (close === -1) { parts.push({ lit: s.slice(i) }); break; } // unbalanced → literal
if (open > i) parts.push({ lit: s.slice(i, open) });
parts.push({ opts: expandGroup(s.slice(open + 1, close)) });
i = close + 1;
}
var results = [''];
parts.forEach(function (p) {
var opts = p.lit != null ? [p.lit] : p.opts;
var next = [];
results.forEach(function (prefix) {
opts.forEach(function (o) { next.push(prefix + o); });
});
results = next;
});
// Trim + drop empties so a stray comma can't create a blank folder.
return results.map(function (r) { return r.trim(); }).filter(Boolean);
}
// Parse one (already brace-expanded) folder name into the nested tracking
// levels it represents: split on "-" into tracking-number segments, then
// split the FINAL segment once on "_" to separate the last tracking segment
// from the "REV (STATUS)" leaf. So "CPO-0001_0 (IFU)" → ["CPO","0001","0 (IFU)"]
// and "BMB-187023-PM-MOM-0001_A (IFR)" → ["BMB","187023","PM","MOM","0001","A (IFR)"].
// A name with no "-"/"_" is a single level (e.g. adding a leaf "A (IFR)").
function parseFolderLevels(name) {
var s = String(name == null ? '' : name).trim();
if (!s) return [];
var u = s.indexOf('_'); // the "_" separates the tracking number from the leaf
if (u < 0) {
// No "_" → a pure tracking-number path: nest by "-".
return s.split('-').map(function (x) { return x.trim(); }).filter(Boolean);
}
// Tracking number (before "_") nests by "-"; everything AFTER the "_" is
// ONE leaf, kept whole — the revision may itself contain hyphens, e.g. a
// date revision "2025-11-17 (IFI)".
var segs = s.slice(0, u).split('-').map(function (x) { return x.trim(); }).filter(Boolean);
var leaf = s.slice(u + 1).trim();
if (leaf) segs.push(leaf);
return segs;
}
// Children array for a tracking node (or the roots for null), or null.
function trackingChildren(parentId) {
if (!parentId) return state.trackingTree;
var info = infoFor(parentId);
return (info && info.kind === 'tracking') ? info.node.children : null;
}
// Ensure a nested chain of tracking folders exists under parentId, reusing
// an existing child when one already has that name (so sibling leaves share
// ancestors). Returns the leaf node id.
function addTrackingPath(parentId, segments) {
var cur = parentId || null;
(segments || []).forEach(function (seg) {
var name = (seg || '').trim();
if (!name) return;
var kids = trackingChildren(cur) || [];
var existing = kids.filter(function (n) { return n.name === name; })[0];
cur = existing ? existing.id : addTrackingNode(cur, name);
});
return cur;
}
// A tracking node is a "complete" drop target when it's a leaf whose name
// carries a valid "(STATUS)" — i.e. a file dropped there yields a full name
// with no more levels needed. Used to decide whether a drop should prompt.
function trackingNodeComplete(nodeId) {
var info = infoFor(nodeId);
if (!info || info.kind !== 'tracking') return false;
if ((info.node.children || []).length) return false;
var leaf = parseLeafLabel(info.node.name);
return !!(leaf.status && zddc.isValidStatus(leaf.status));
}
// Human-readable "root / … / node" path for a tracking node (prompt context).
function trackingPathLabel(nodeId) {
var info = infoFor(nodeId);
if (!info || info.kind !== 'tracking') return '';
return trackingChain(info).join(' / ');
}
// ── filename-based export/import helpers ─────────────────────────────────
// A flat, AI-friendly transmittal record for a placed file (export side).
function transmittalRecord(binId) {
var info = infoFor(binId);
if (!info || info.kind !== 'transmittal') return null;
var slot = info.parent ? infoFor(info.parent.id) : null;
var party = slot && slot.parent ? infoFor(slot.parent.id) : null;
var m = info.node.meta || {};
return {
party: party ? party.node.name : '',
slot: slot ? slot.node.slot : '',
date: m.date || '', type: m.type || 'TRN', seq: m.seq || '',
status: m.status || '', title: m.title || '',
};
}
// Find-or-create a party by name (import side — reuse so shared transmittals
// don't duplicate the party).
function findOrAddParty(name) {
var existing = (state.transmittalTree || []).filter(function (p) { return p.name === name; })[0];
return existing ? existing.id : addParty(name);
}
// Find-or-create a transmittal bin under party/slot matching meta (import).
function findOrAddTransmittalBin(partyId, slot, meta) {
var pinfo = infoFor(partyId);
if (!pinfo || pinfo.kind !== 'party') return null;
var wantName = transmittalFolderName(pinfo.node.name, meta);
var slotNode = (pinfo.node.children || []).filter(function (s) { return s.slot === slot; })[0];
if (slotNode) {
var existing = (slotNode.children || []).filter(function (b) { return b.name === wantName; })[0];
if (existing) return existing.id;
}
return addTransmittalBin(partyId, slot, meta);
}
// ── mode ─────────────────────────────────────────────────────────────────
function setEnabled(on) { state.enabled = !!on; notify(); }
function isEnabled() { return state.enabled; }
window.app.modules.classify = {
// mode
setEnabled: setEnabled, isEnabled: isEnabled,
// pub/sub
on: on,
// keys/title
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
// assignments
assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
setHashConflicts: setHashConflicts, hasHashConflict: hasHashConflict,
setTitleOverride: setTitleOverride,
// trees
addTrackingNode: addTrackingNode, addParty: addParty,
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
expandFolderPattern: expandFolderPattern,
parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow,
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
setTitleFromDeliverable: setTitleFromDeliverable,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse
deriveTarget: deriveTarget, filesInNode: filesInNode,
fileState: fileState, stats: stats,
// persistence
serialize: serialize, load: load, reset: reset,
getOutputName: function () { return state.outputName; },
setOutputName: function (n) { state.outputName = n || null; notify(); },
};
})();

430
classifier/js/copy.js Normal file
View file

@ -0,0 +1,430 @@
/**
* ZDDC Classifier copy-out (Classify & Copy mode).
*
* Copies the fully-classified source files into a SEPARATE output directory
* under their canonical ZDDC names and folder layout
* <party>/{received,issued}/<DATE_TN (STATUS) - TITLE>/<TRACKING_REV (STATUS) - TITLE.ext>
* The source is never modified every operation is a read (getFile) on the
* source and a write into the chosen output handle.
*
* Duplicate detection:
* - two sources the same output path = mapping conflict (skipped + reported)
* - target already exists, identical bytes (sha256) = skipped
* - target exists, different bytes = left untouched + reported (no clobber)
*
* Built on the generic FS-Access shape (getDirectoryHandle/getFileHandle/
* createWritable), so it works against a real handle today and a server-backed
* output handle later without changing this logic.
*/
(function () {
'use strict';
var outputHandle = null; // remembered for the session
function C() { return window.app.modules.classify; }
function collectFiles() {
var out = [];
(function walk(nodes) {
(nodes || []).forEach(function (n) {
(n.files || []).forEach(function (f) { out.push(f); });
walk(n.children);
});
})(window.app.folderTree || []);
return out;
}
// Files that are ready to copy: complete target, not excluded.
function plan() {
var c = C(), items = [];
collectFiles().forEach(function (f) {
var d = c.deriveTarget(f);
if (d.excluded || !d.complete) return;
items.push({ file: f, d: d, outRel: d.outPath + '/' + d.filename });
});
return items;
}
// Group by output path; >1 source for a path = a mapping conflict.
function conflictsIn(items) {
var by = {}, conflicts = [];
items.forEach(function (p) { (by[p.outRel] = by[p.outRel] || []).push(p); });
Object.keys(by).forEach(function (k) { if (by[k].length > 1) conflicts.push(k); });
return { by: by, conflicts: conflicts };
}
function toast(msg, level) {
if (window.zddc && window.zddc.toast) window.zddc.toast(msg, level);
}
function setStatus(text) {
var el = document.getElementById('scanStatus');
if (!el) return;
el.textContent = text;
el.classList.toggle('scanning', !!text);
}
async function chooseOutput() {
if (!window.showDirectoryPicker) {
toast('Copying to an output directory needs the File System Access API (use Chromium, or run via zddc-server).', 'error');
return null;
}
try {
var h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'zddc-classifier-output' });
outputHandle = h;
C().setOutputName(h.name);
return h;
} catch (e) {
if (e.name !== 'AbortError') toast('Could not open the output directory — ' + (e.message || e), 'error');
return null;
}
}
async function ensureDir(root, relPath) {
var parts = relPath.split('/').filter(Boolean);
var cur = root;
for (var i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i], { create: true });
}
return cur;
}
// Resolve a target subdirectory WITHOUT creating it (null if any segment is
// missing). Lets us check a file's existence cheaply on resume before paying
// to create the folder chain.
async function resolveDir(root, relPath, create) {
var parts = relPath.split('/').filter(Boolean);
var cur = root;
for (var i = 0; i < parts.length; i++) {
try { cur = await cur.getDirectoryHandle(parts[i], create ? { create: true } : undefined); }
catch (e) { if (!create) return null; throw e; }
}
return cur;
}
// Resolve a source file's live handle. Fresh-scan files already carry one;
// snapshot-loaded files resolve lazily from the workspace root by path.
async function srcHandle(fileObj) {
if (fileObj.handle) return fileObj.handle;
if (!window.app.rootHandle) throw new Error('source directory not connected');
return window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, fileObj);
}
// Read a source file's bytes (a File or Blob). A zip member is extracted
// from its archive (lazily reloaded from the root); a plain file is read
// through its resolved handle. The source is never written either way.
async function readSource(fileObj) {
if (fileObj.isVirtual) {
return window.app.modules.scanner.extractZipMember(window.app.rootHandle, fileObj);
}
return (await srcHandle(fileObj)).getFile();
}
// Copy one file. Returns 'copied' | 'skipped' (already present → resumable).
// The existence check is a cheap stat/HEAD; a present target is left as-is so
// re-running after an interruption skips the work already done — no source
// read, no hashing. (Canonical ZDDC names ⇒ same name = same document, and
// the server archive is WORM, so we never overwrite.)
// SHA-256 of a source file's bytes, cached on the file object (reused by the
// duplicate-conflict audit AND the post-copy verify).
async function sourceSha(fileObj) {
if (fileObj.sha256) return fileObj.sha256;
var blob = await readSource(fileObj);
var h = await window.zddc.crypto.sha256File(blob);
fileObj.sha256 = h;
return h;
}
async function writeTarget(out, p) {
var dir = await ensureDir(out, p.d.outPath);
var srcFile = await readSource(p.file); // READ source (never write it)
var fh = await dir.getFileHandle(p.d.filename, { create: true });
var w = await fh.createWritable();
await w.write(srcFile);
await w.close();
}
async function copyOne(out, p) {
// Cheap existence probe: resolve the dir WITHOUT creating it (the HTTP
// handle doesn't verify here, but getFileHandle below does a HEAD).
var probe = await resolveDir(out, p.d.outPath, false);
if (probe) {
try { await probe.getFileHandle(p.d.filename); return 'skipped'; }
catch (e) { /* NotFound → write it below */ }
}
await writeTarget(out, p);
return 'copied';
}
// Read the written target back and compare its SHA-256 to the source.
async function verifyOne(out, p) {
var dir = await resolveDir(out, p.d.outPath, false);
if (!dir) return false;
var fh; try { fh = await dir.getFileHandle(p.d.filename); } catch (e) { return false; }
var th = await window.zddc.crypto.sha256File(await fh.getFile());
return th === (await sourceSha(p.file));
}
async function removeTarget(out, p) {
var dir = await resolveDir(out, p.d.outPath, false);
if (dir && dir.removeEntry) { try { await dir.removeEntry(p.d.filename); } catch (e) { /* best effort */ } }
}
// Snapshot-loaded files have no live handle — re-grant read on the source
// (one click) before we read any bytes (hashing or copying). Returns false
// if the source can't be read.
async function ensureSourceReadable(items) {
if (!items.some(function (p) { return !p.file.handle; })) return true;
if (!window.app.rootHandle) {
toast('The source directory isnt connected. Re-open the workspace to reconnect it.', 'error');
return false;
}
var ok = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
if (!ok) { toast('Permission to read the source directory was denied.', 'error'); return false; }
return true;
}
// Group fully-classified files by their canonical output name. Files with the
// SAME tracking number + revision MUST have the same content: identical bytes
// collapse to a single copy; differing bytes are a CONFLICT the user must fix.
async function resolvePlan(items) {
var by = {};
items.forEach(function (p) { (by[p.outRel] = by[p.outRel] || []).push(p); });
var todo = [], conflicts = [], conflictKeys = {}, dupeCount = 0, keys = Object.keys(by);
for (var i = 0; i < keys.length; i++) {
var group = by[keys[i]];
if (group.length === 1) { todo.push(group[0]); continue; }
var hashes = [], bad = false;
for (var j = 0; j < group.length; j++) {
try { hashes.push(await sourceSha(group[j].file)); } catch (e) { bad = true; hashes.push('ERR' + j); }
}
var distinct = {}; hashes.forEach(function (h) { distinct[h] = true; });
if (!bad && Object.keys(distinct).length === 1) {
todo.push(group[0]); dupeCount += group.length - 1; // identical → one copy
} else {
conflicts.push(keys[i]);
group.forEach(function (g) { conflictKeys[g.d.key] = true; });
}
}
return { todo: todo, conflicts: conflicts, conflictKeys: conflictKeys, dupeCount: dupeCount };
}
// Pre-flight shared by Copy and the standalone "Check" button: hash colliding
// names, flag conflicts in the UI, return the deduped todo (or null to abort).
async function preflight(verb) {
var items = plan();
if (!items.length) {
toast('Nothing ' + verb + ' yet — no files are fully classified (need a tracking leaf AND a transmittal).', 'warning');
return null;
}
if (!(await ensureSourceReadable(items))) return null;
setStatus('Checking for same-name/different-content conflicts…');
var r = await resolvePlan(items);
setStatus('');
C().setHashConflicts(r.conflictKeys);
if (r.conflicts.length) {
toast(r.conflicts.length + ' same-name/different-content conflict(s) flagged (≠ in red): same tracking+revision, different bytes. Fix these before copying.', 'error');
}
if (r.dupeCount) toast(r.dupeCount + ' exact duplicate(s) collapse to one copy.', 'info');
return r;
}
// Standalone audit (the "Check" button) — flag conflicts without copying.
async function audit() {
var r = await preflight('to check');
if (r && !r.conflicts.length) {
toast('No conflicts — ' + r.todo.length + ' file' + (r.todo.length === 1 ? '' : 's') + ' ready to copy.', 'success');
}
return r;
}
async function run() {
if (!C().isEnabled()) return;
var r = await preflight('to copy');
if (!r) return;
var todo = r.todo;
if (!todo.length) { if (r.conflicts.length) toast('Resolve the flagged conflicts, then copy.', 'warning'); return; }
// Where to file the canonical copies: the server archive (HTTP) or a local
// folder. Both read the source, never write it, both resumable + verified.
var dest = await chooseDestination(todo.length);
if (!dest) return;
return dest === 'server' ? copyToServer(todo) : copyToLocal(todo);
}
function summary(s, where) {
var msg = 'Copy to ' + where + ' — ' + s.copied + ' copied & verified, ' + s.skipped + ' already there'
+ (s.verifyFailed ? (', ' + s.verifyFailed + ' FAILED verification (bad copy removed — re-run)') : '')
+ (s.errors ? (', ' + s.errors + ' errored (retry to resume)') : '') + '.';
toast(msg, (s.errors || s.verifyFailed) ? 'warning' : 'success');
}
async function copyToLocal(todo) {
var out = outputHandle || await chooseOutput();
if (!out) return;
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\n'
+ 'Written under <party>/<received|issued>/<transmittal>/ — pick your archive/ folder to file them directly. '
+ 'Re-running resumes (already-copied files are skipped). The source is not modified.')) return;
var s = await copyTo(out, todo);
summary(s, '"' + out.name + '"');
return s;
}
// Copy straight into a project's archive on the server over HTTP (PUT per
// file, mkdir as needed). Uses the zddc-source HTTP handle, so the SAME copy
// engine writes <party>/<received|issued>/<transmittal>/<name> under
// <project>/archive/. The user picks any project they can access.
async function copyToServer(todo) {
var src = window.zddc && window.zddc.source;
if (!src || location.protocol === 'file:') {
toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error');
return;
}
var projects = await fetchAccessProjects();
if (projects == null) { toast('Could not load your projects from the server.', 'error'); return; }
if (!projects.length) { toast('No projects you can access on this server.', 'warning'); return; }
var proj = await chooseProject(projects);
if (!proj) return;
var archive;
try {
var rel = proj.url || ('/' + proj.name + '/');
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
archive = new URL(rel + 'archive/', location.origin).href;
} catch (e) { toast('Bad project URL — ' + (e.message || e), 'error'); return; }
var out = new src.HttpDirectoryHandle(archive, 'archive');
var s = await copyTo(out, todo);
summary(s, (proj.title || proj.name) + ' / archive');
return s;
}
// The caller's accessible projects (read view from /.profile/access). Write
// permission is enforced server-side on PUT, so a 403 surfaces per file.
async function fetchAccessProjects() {
try {
var resp = await fetch('/.profile/access', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', cache: 'no-cache' });
if (!resp.ok) return null;
if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null;
var data = await resp.json();
return Array.isArray(data.projects) ? data.projects : [];
} catch (e) { return null; }
}
function chooseProject(projects) {
return new Promise(function (resolve) {
var done = false;
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
function onKey(e) { if (e.key === 'Escape') finish(null); }
var back = document.createElement('div'); back.className = 'copy-choice__backdrop';
var box = document.createElement('div'); box.className = 'copy-choice';
var h = document.createElement('h3'); h.textContent = 'Copy to a project archive';
var p = document.createElement('p');
p.innerHTML = 'Files go to <code>&lt;project&gt;/archive/&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/</code>. Pick a project you can access.';
var sel = document.createElement('select'); sel.className = 'copy-choice__select';
projects.forEach(function (pr, i) {
var o = document.createElement('option'); o.value = String(i);
o.textContent = pr.name + (pr.title ? ' — ' + pr.title : '');
sel.appendChild(o);
});
var row = document.createElement('div'); row.className = 'copy-choice__btns';
var go = document.createElement('button'); go.className = 'btn btn-primary'; go.textContent = 'Copy here';
go.addEventListener('click', function () { finish(projects[Number(sel.value)] || null); });
var cancel = document.createElement('button'); cancel.className = 'btn btn-secondary'; cancel.textContent = 'Cancel';
cancel.addEventListener('click', function () { finish(null); });
row.appendChild(go); row.appendChild(cancel);
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});
}
// Tiny modal: choose server archive vs local folder. Resolves 'server' |
// 'local' | null. The server option is offered only over http(s).
function chooseDestination(n) {
return new Promise(function (resolve) {
var done = false;
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
function onKey(e) { if (e.key === 'Escape') finish(null); }
var onServer = location.protocol === 'http:' || location.protocol === 'https:';
var back = document.createElement('div'); back.className = 'copy-choice__backdrop';
var box = document.createElement('div'); box.className = 'copy-choice';
var h = document.createElement('h3');
h.textContent = 'Copy ' + n + ' classified file' + (n === 1 ? '' : 's');
var p = document.createElement('p');
p.innerHTML = 'Filed under <code>&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/&lt;name&gt;</code>. '
+ 'Re-running resumes — files already present at the destination are skipped.';
var row = document.createElement('div'); row.className = 'copy-choice__btns';
function btn(label, cls, val, disabled) {
var b = document.createElement('button'); b.className = 'btn ' + cls; b.textContent = label;
if (disabled) { b.disabled = true; b.title = 'Open the classifier over a zddc-server to enable this'; }
else b.addEventListener('click', function () { finish(val); });
return b;
}
row.appendChild(btn('☁ Copy to server archive', 'btn-primary', 'server', !onServer));
row.appendChild(btn('📁 Copy to a local folder…', onServer ? 'btn-secondary' : 'btn-primary', 'local'));
row.appendChild(btn('Cancel', 'btn-secondary', null));
box.appendChild(h); box.appendChild(p); box.appendChild(row);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});
}
// Run the copy loop over a ready list against an output handle. No picker,
// no confirm — that's run()'s job; this is the engine (and the test seam).
// Resumable: copyOne skips targets that already exist, so a re-run after an
// interruption only does the remaining work.
async function copyTo(out, todo) {
var s = { copied: 0, skipped: 0, errors: 0, verifyFailed: 0 };
var copied = [];
for (var i = 0; i < todo.length; i++) {
setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename);
try {
var r = await copyOne(out, todo[i]);
s[r]++;
if (r === 'copied') copied.push(todo[i]);
} catch (e) {
s.errors++;
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Failed to copy ' + todo[i].outRel + ' — ' + (e.message || e), 'error');
}
}
}
// Verification pass over JUST the files copied this run: read each target
// back, compare SHA-256 to the source. One re-copy attempt on mismatch;
// if it still fails, remove the bad target so a re-run re-copies it — so
// resume converges on a fully-correct archive.
for (var k = 0; k < copied.length; k++) {
setStatus('Verifying… ' + (k + 1) + '/' + copied.length + ' — ' + copied[k].d.filename);
try {
if (await verifyOne(out, copied[k])) continue;
await writeTarget(out, copied[k]);
if (await verifyOne(out, copied[k])) continue;
s.verifyFailed++;
await removeTarget(out, copied[k]);
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Verification failed for ' + copied[k].outRel + ' — removed the bad copy; re-run to retry.', 'error');
}
} catch (e) {
s.verifyFailed++;
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Verify error for ' + copied[k].outRel + ' — ' + (e.message || e), 'error');
}
}
}
setStatus('');
return s;
}
function readyCount() { return plan().length; }
window.app.modules.copy = {
run: run,
audit: audit,
readyCount: readyCount,
chooseOutput: chooseOutput,
// shared with the MDL flow
fetchAccessProjects: fetchAccessProjects,
chooseProject: chooseProject,
// test/advanced seams
plan: plan,
conflictsIn: conflictsIn,
resolvePlan: resolvePlan,
copyTo: copyTo,
};
})();

28
classifier/js/dnd.js Normal file
View file

@ -0,0 +1,28 @@
/**
* ZDDC Classifier drag payload bus for Classify & Copy.
*
* HTML5 dataTransfer can't be read during `dragover` (only on `drop`), and we
* need the dragged set to drive drop-target highlighting. So the source keys
* live in a module variable for the lifetime of a drag; dataTransfer carries a
* marker so the browser shows a copy cursor and external drops are ignored.
*/
(function () {
'use strict';
var keys = [];
function setDrag(srcKeys, e) {
keys = (srcKeys || []).slice();
if (e && e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy';
try { e.dataTransfer.setData('application/x-zddc-keys', keys.join('\n')); } catch (_) { /* ok */ }
}
}
function getDrag() { return keys; }
function active() { return keys.length > 0; }
function clearDrag() { keys = []; }
window.app.modules.dnd = {
setDrag: setDrag, getDrag: getDrag, active: active, clearDrag: clearDrag,
};
})();

127
classifier/js/excel.js Normal file
View file

@ -0,0 +1,127 @@
/**
* Excel Integration Module
* Toast notifications and hash export
*/
(function() {
'use strict';
/**
* Thin wrapper over the shared toast helper. Keeps the
* window.app.modules.excel.showToast call sites in classifier
* unchanged while delegating to the canonical implementation in
* shared/toast.js (window.zddc.toast).
*/
function showToast(message, type = 'info') {
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(message, type);
} else {
// shared/toast.js missing from the build — log so the
// problem is visible without crashing the caller.
console.warn('[classifier] window.zddc.toast unavailable;', type, message);
}
}
/**
* Export SHA256 hashes in sha256sum format
*/
async function exportHashes() {
const files = window.app.modules.store.getDisplayFiles();
if (files.length === 0) {
alert('No files to export');
return;
}
// Check if SHA256 is enabled
if (!window.app.calculateSha256) {
alert('Please enable SHA256 checkbox first and wait for hashes to calculate');
return;
}
try {
// Build sha256sum format: hash *filepath
const lines = [];
// Get root path
const rootPath = await getFullPath(window.app.rootHandle);
for (const file of files) {
if (!file.sha256 || file.sha256 === 'calculating...' || file.sha256 === 'error') {
continue; // Skip files without calculated hash
}
// Get full path from root
const folderPath = await getFullPath(file.folderHandle);
const fullPath = `${folderPath}/${zddc.joinExtension(file.originalFilename, file.extension)}`;
// Format: hash *filepath (asterisk indicates binary mode)
lines.push(`${file.sha256} *${fullPath}`);
}
if (lines.length === 0) {
alert('No SHA256 hashes available. Enable SHA256 and wait for calculation to complete.');
return;
}
// Create output
const output = lines.join('\n');
// Generate filename with timestamp
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, -5); // YYYY-MM-DDTHH-MM-SS
const filename = `sha256sums_${timestamp}.txt`;
// Download as file
const blob = new Blob([output], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Show success message
showToast(`✓ Downloaded ${lines.length} hash(es) to ${filename}`, 'success');
} catch (err) {
console.error('Error exporting hashes:', err);
alert('Error exporting hashes: ' + err.message);
}
}
/**
* Get full path from directory handle (all the way to root)
*/
async function getFullPath(dirHandle) {
const parts = [];
let current = dirHandle;
// Walk up to root - collect ALL parent folders
while (current) {
parts.unshift(current.name);
try {
// Try to get parent
if (typeof current.getParent === 'function') {
const parent = await current.getParent();
if (parent && parent !== current) {
current = parent;
continue;
}
}
break;
} catch {
break;
}
}
return parts.join('/');
}
// Export module
window.app.modules.excel = {
showToast,
exportHashes
};
})();

64
classifier/js/filter.js Normal file
View file

@ -0,0 +1,64 @@
/**
* Filter Module
* Column filter UI: initialises static inputs in thead, wires events.
*/
(function() {
'use strict';
/**
* Initialize filtering wire delegated events on thead.
* Filter inputs already exist in the static template; no dynamic injection needed.
*/
function init() {
const thead = window.app.dom.spreadsheet
? window.app.dom.spreadsheet.querySelector('thead')
: document.querySelector('#spreadsheet thead');
if (!thead) return;
thead.addEventListener('input', (e) => {
if (e.target.matches('.column-filter[data-filter-field]')) {
e.stopPropagation();
const field = e.target.getAttribute('data-filter-field');
const raw = e.target.value.trim();
const ast = window.zddc.filter.parse(raw);
window.app.modules.store.setFilter(field, raw, ast);
}
});
thead.addEventListener('keydown', (e) => {
if (!e.target.matches('.column-filter[data-filter-field]')) return;
if (e.key === 'Escape') {
e.target.value = '';
const field = e.target.getAttribute('data-filter-field');
window.app.modules.store.setFilter(field, '', null);
e.preventDefault();
} else if (e.key === 'Enter') {
e.preventDefault();
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
const idx = inputs.indexOf(e.target);
if (idx !== -1) inputs[(idx + 1) % inputs.length].focus();
}
});
thead.addEventListener('click', (e) => {
if (e.target.matches('.column-filter')) {
e.stopPropagation();
}
});
}
/**
* Clear all filters reset inputs and store.
*/
function clearFilters() {
document.querySelectorAll('.column-filter').forEach(input => {
input.value = '';
});
window.app.modules.store.setAllFilters({});
}
window.app.modules.filter = {
init,
clearFilters
};
})();

View file

@ -0,0 +1,209 @@
/**
* ZDDC Classifier instantiate MDL deliverables from existing archive files.
*
* Catch-up flow: the archive already holds issued documents, but the Master
* Deliverables List is empty. This reads a project's archive subtree as a flat
* file list, lets the user build a selection set (autofilter + ctrl-shift via
* the shared seltable), dedupes the selected files to one deliverable per
* tracking number, and PUTs a new deliverable .yaml into the originator's
* `archive/<originator>/mdl/` on the server. Server-only (needs http + auth).
*
* A deliverable .yaml's filename IS its tracking number; the server pins
* `originator` from the folder and composes the filename, so the body carries
* only project/discipline/type/sequence/suffix + title.
*/
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
// ── pure core (test seams) ───────────────────────────────────────────────
// A tracking number → deliverable {tracking, originator, body{...}} or null
// if it doesn't fit the MDL schema (needs orig-proj-disc-type-seq, + suffix).
function deliverableFromFile(f) {
var segs = String(f.tracking || '').split('-');
if (segs.length < 5) return null;
var body = { project: segs[1], discipline: segs[2], type: segs[3], sequence: segs[4], title: f.title || '' };
if (segs.length >= 6) body.suffix = segs.slice(5).join('-');
return { tracking: f.tracking, originator: segs[0], body: body };
}
// Dedupe a list of archive files to one deliverable per tracking number.
function dedupe(files) {
var seen = Object.create(null), out = [];
(files || []).forEach(function (f) {
if (seen[f.tracking]) return;
var d = deliverableFromFile(f);
if (d) { seen[f.tracking] = true; out.push(d); }
});
return out;
}
// Recursively walk an archive directory handle → flat list of ZDDC-named
// files (skips dot/underscore folders; non-ZDDC names like the mdl yamls
// naturally fall out because parseFilename rejects them).
async function walkArchive(rootHandle) {
var out = [];
async function walk(dirH, parts) {
for await (var entry of dirH.values()) {
var nm = String(entry.name || '').replace(/\/$/, '');
if (entry.kind === 'directory') {
var c = nm.charAt(0);
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
var childH = await dirH.getDirectoryHandle(nm);
await walk(childH, parts.concat(nm));
} else {
var p = window.zddc.parseFilename(nm);
if (p && p.valid) {
out.push({
id: parts.concat(nm).join('/'),
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
name: nm, tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
});
}
}
}
}
await walk(rootHandle, []);
return out;
}
// Write one deliverable into <archiveRoot>/<originator>/mdl/<tracking>.yaml.
// Returns 'created' | 'skipped' (already present). Throws on server error.
async function instantiateOne(archiveRoot, d) {
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
dir = await dir.getDirectoryHandle('mdl', { create: true });
var fname = d.tracking + '.yaml';
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
var yaml = window.jsyaml.dump(d.body);
var fh = await dir.getFileHandle(fname, { create: true });
var w = await fh.createWritable();
await w.write(new Blob([yaml], { type: 'application/yaml' }));
await w.close();
return 'created';
}
async function instantiateAll(archiveRoot, deliverables, onProgress) {
var s = { created: 0, skipped: 0, errors: 0 };
for (var i = 0; i < deliverables.length; i++) {
if (onProgress) onProgress(i + 1, deliverables.length, deliverables[i].tracking);
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
}
return s;
}
// ── UI ───────────────────────────────────────────────────────────────────
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
async function open() {
var copy = window.app.modules.copy;
var src = window.zddc && window.zddc.source;
if (!src || location.protocol === 'file:') {
T('Populating the MDL from the archive needs the classifier served by a zddc-server (open it over http).', 'error');
return;
}
var projects = await copy.fetchAccessProjects();
if (projects == null) { T('Could not load your projects from the server.', 'error'); return; }
if (!projects.length) { T('No projects you can access on this server.', 'warning'); return; }
var proj = await copy.chooseProject(projects);
if (!proj) return;
buildOverlay(proj);
await scan(proj);
}
function buildOverlay(proj) {
close();
overlay = el('div', 'mdl-overlay');
var box = el('div', 'mdl-overlay__box');
var head = el('div', 'mdl-overlay__head');
head.appendChild(el('h2', null, 'Populate MDL from archive — ' + (proj.title || proj.name)));
var x = el('button', 'mdl-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
head.appendChild(x);
box.appendChild(head);
statusEl = el('div', 'mdl-overlay__status', 'Scanning archive…');
box.appendChild(statusEl);
var host = el('div', 'mdl-overlay__table');
box.appendChild(host);
var foot = el('div', 'mdl-overlay__foot');
var create = el('button', 'btn btn-primary', 'Create deliverables');
create.addEventListener('click', function () { runCreate(create); });
foot.appendChild(create);
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
foot.appendChild(cancel);
box.appendChild(foot);
overlay.appendChild(box);
document.body.appendChild(overlay);
table = window.app.modules.seltable.create({
container: host,
filterPlaceholder: 'Filter by party, transmittal, tracking number, title…',
rows: function () { return files; },
rowId: function (r) { return r.id; },
columns: [
{ key: 'party', title: 'Party' },
{ key: 'slot', title: 'Slot' },
{ key: 'transmittal', title: 'Transmittal' },
{ key: 'tracking', title: 'Tracking number' },
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
{ key: 'title', title: 'Title' },
],
onSelectionChange: function (ids) { create.textContent = ids.length ? ('Create deliverables (' + dedupe(selectedFiles(ids)).length + ')') : 'Create deliverables'; },
});
table.render();
}
function selectedFiles(ids) {
var set = {}; ids.forEach(function (i) { set[i] = true; });
return files.filter(function (f) { return set[f.id]; });
}
async function scan(proj) {
var src = window.zddc.source;
var rel = (proj.url || ('/' + proj.name + '/'));
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
try {
archiveRoot = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
setStatus('Scanning archive…');
files = await walkArchive(archiveRoot);
table.renderBody();
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
} catch (e) {
setStatus('Archive scan failed — ' + (e.message || e));
T('Archive scan failed — ' + (e.message || e), 'error');
}
}
async function runCreate(btn) {
if (!table) return;
var sel = table.getSelection();
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
var deliverables = dedupe(selectedFiles(sel));
if (!deliverables.length) { T('None of the selected files have a tracking number that fits the deliverable schema.', 'warning'); return; }
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\n'
+ 'One .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
btn.disabled = true;
var s = await instantiateAll(archiveRoot, deliverables, function (i, n, tn) { setStatus('Creating ' + i + '/' + n + ' — ' + tn); });
btn.disabled = false;
setStatus(s.created + ' created, ' + s.skipped + ' already there'
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '. ' + files.length + ' files scanned.');
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there'
+ (s.errors ? (', ' + s.errors + ' failed') : '') + '.', s.errors ? 'warning' : 'success');
}
window.app.modules.mdlInstantiate = {
open: open,
// test seams
deliverableFromFile: deliverableFromFile,
dedupe: dedupe,
walkArchive: walkArchive,
instantiateOne: instantiateOne,
instantiateAll: instantiateAll,
};
})();

128
classifier/js/persist.js Normal file
View file

@ -0,0 +1,128 @@
/**
* ZDDC Classifier workspace persistence (IndexedDB).
*
* A "workspace" is one classification project: the picked source directory
* HANDLE, a SNAPSHOT of its completed scan (folder/file structure names and
* paths only, no contents), and the Classify & Copy map (assignments + target
* trees). Scan once, resume instantly across sessions without re-walking the
* (often cloud-backed, high-latency) source.
*
* Two object stores so the welcome list stays cheap:
* - 'index' (tiny): { id, name, rootName, createdAt, updatedAt, summary }
* - 'data' (large): { id, rootHandle, tree, classify }
*
* A FileSystemDirectoryHandle is structured-cloneable, so IndexedDB can hold
* it; on reuse we re-request permission (one click). It's only needed at COPY
* time opening a workspace runs entirely from the snapshot.
*/
(function () {
'use strict';
var DB_NAME = 'zddc-classifier';
var DB_VERSION = 2;
var IDX = 'index';
var DATA = 'data';
var available = typeof indexedDB !== 'undefined';
function openDB() {
return new Promise(function (resolve, reject) {
if (!available) { reject(new Error('IndexedDB unavailable')); return; }
var req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = function () {
var db = req.result;
// 'kv' (v1, single implicit map) is intentionally left behind.
if (!db.objectStoreNames.contains(IDX)) db.createObjectStore(IDX, { keyPath: 'id' });
if (!db.objectStoreNames.contains(DATA)) db.createObjectStore(DATA, { keyPath: 'id' });
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
function reqP(req) {
return new Promise(function (resolve, reject) {
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error); };
});
}
// ── public API ─────────────────────────────────────────────────────────
// Light metadata for every workspace (for the welcome list). Sorted newest
// first. Never loads the big snapshot.
function listWorkspaces() {
return openDB().then(function (db) {
return reqP(db.transaction(IDX, 'readonly').objectStore(IDX).getAll());
}).then(function (rows) {
(rows || []).sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); });
return rows || [];
}).catch(function (e) { console.warn('persist.list', e); return []; });
}
// Full data record for one workspace: { id, rootHandle, tree, classify }.
function getWorkspace(id) {
return openDB().then(function (db) {
return reqP(db.transaction(DATA, 'readonly').objectStore(DATA).get(id));
}).catch(function (e) { console.warn('persist.get', e); return null; });
}
// Save (create or update). meta = {id,name,rootName,createdAt,updatedAt,summary};
// data = {id, rootHandle, tree, classify}. tree may be omitted on a classify-
// only autosave (the snapshot rarely changes) — then we preserve the stored one.
function putWorkspace(meta, data) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction([IDX, DATA], 'readwrite');
t.oncomplete = function () { resolve(); };
t.onerror = function () { reject(t.error); };
t.objectStore(IDX).put(meta);
var ds = t.objectStore(DATA);
if (data && typeof data.tree !== 'undefined') {
ds.put(data);
} else if (data) {
// Merge classify/rootHandle without clobbering the snapshot.
var g = ds.get(meta.id);
g.onsuccess = function () {
var existing = g.result || { id: meta.id };
if (typeof data.rootHandle !== 'undefined') existing.rootHandle = data.rootHandle;
if (typeof data.classify !== 'undefined') existing.classify = data.classify;
existing.id = meta.id;
ds.put(existing);
};
}
});
}).catch(function (e) { console.warn('persist.put', e); });
}
function deleteWorkspace(id) {
return openDB().then(function (db) {
return new Promise(function (resolve, reject) {
var t = db.transaction([IDX, DATA], 'readwrite');
t.oncomplete = function () { resolve(); };
t.onerror = function () { reject(t.error); };
t.objectStore(IDX).delete(id);
t.objectStore(DATA).delete(id);
});
}).catch(function (e) { console.warn('persist.delete', e); });
}
// Re-acquire read permission on a stored handle (one click). true if usable.
function verifyPermission(handle, write) {
if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false);
var opts = { mode: write ? 'readwrite' : 'read' };
return handle.queryPermission(opts).then(function (p) {
if (p === 'granted') return true;
return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; });
}).catch(function () { return false; });
}
window.app.modules.persist = {
available: available,
listWorkspaces: listWorkspaces,
getWorkspace: getWorkspace,
putWorkspace: putWorkspace,
deleteWorkspace: deleteWorkspace,
verifyPermission: verifyPermission,
};
})();

567
classifier/js/preview.js Normal file
View file

@ -0,0 +1,567 @@
/**
* Preview Module
* Opens file preview in a separate popup window
*/
(function() {
'use strict';
let currentBlobUrl = null;
let currentFile = null;
let currentRowIndex = null;
let previewWindow = null;
// Use shared extension lists from window.zddc.preview where possible
const IMAGE_EXTENSIONS = zddc.preview.IMAGE_EXTENSIONS;
const TIFF_EXTENSIONS = zddc.preview.TIFF_EXTENSIONS;
const TEXT_EXTENSIONS = zddc.preview.TEXT_EXTENSIONS;
const PDF_EXTENSIONS = ['pdf'];
const ZIP_EXTENSIONS = ['zip'];
// Lazily load a script from CDN — delegates to shared cache.
const loadLibrary = zddc.preview.loadLibrary;
/**
* Initialize preview module
*/
function init() {
// Listen for row focused events from selection module
document.addEventListener('rowfocused', handleRowFocused);
// Set up toggle button to open/close preview window
const toggleBtn = document.getElementById('togglePreviewBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
if (previewWindow && !previewWindow.closed) {
// Close preview window
previewWindow.close();
previewWindow = null;
toggleBtn.classList.remove('preview-active');
} else if (currentFile) {
openPreviewWindow(currentFile);
toggleBtn.classList.add('preview-active');
}
});
}
}
/**
* Handle row focused event
*/
function handleRowFocused(e) {
const { rowIndex, file } = e.detail;
currentRowIndex = rowIndex;
if (file && file !== currentFile) {
currentFile = file;
// Update preview window if open
if (previewWindow && !previewWindow.closed) {
openPreviewWindow(file);
}
}
}
/**
* Open preview in a separate popup window
*/
async function openPreviewWindow(file) {
if (!file) return;
currentFile = file;
try {
const blob = await getFileBlob(file);
// Clean up previous blob URL
if (currentBlobUrl) {
URL.revokeObjectURL(currentBlobUrl);
}
currentBlobUrl = URL.createObjectURL(blob);
const fileName = zddc.joinExtension(file.originalFilename, file.extension);
// Build preview HTML with toolbar
const previewHtml = `
<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(fileName)} - Preview</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.toolbar h1 {
flex: 1;
font-size: 0.95rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
cursor: pointer;
}
.btn:hover { background: #e8e8e8; }
iframe, img {
flex: 1;
width: 100%;
border: none;
}
img {
object-fit: contain;
background: #f0f0f0;
}
pre {
flex: 1;
padding: 1rem;
overflow: auto;
background: #fafafa;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.unsupported {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
.unsupported .icon { font-size: 3rem; margin-bottom: 1rem; }
#previewContent {
flex: 1;
overflow: auto;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #666;
font-size: 1.1rem;
}
.docx-wrapper { padding: 1rem; }
.xlsx-table { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
.xlsx-table th, .xlsx-table td {
border: 1px solid #ddd;
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
}
.xlsx-table th { background: #f0f0f0; font-weight: 600; position: sticky; top: 0; }
.xlsx-table tr:nth-child(even) { background: #fafafa; }
.xlsx-table tr:hover { background: #f0f7ff; }
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid #ddd; background: #f5f5f5; }
.sheet-tab {
padding: 0.4rem 1rem;
cursor: pointer;
border: 1px solid transparent;
border-bottom: none;
font-size: 0.85rem;
background: transparent;
}
.sheet-tab:hover { background: #e8e8e8; }
.sheet-tab.active {
background: white;
border-color: #ddd;
border-bottom-color: white;
margin-bottom: -1px;
font-weight: 500;
}
</style>
</head>
<body>
<div class="toolbar">
<h1>${escapeHtml(fileName)}</h1>
<button class="btn" onclick="downloadFile()">Download</button>
</div>
${await getPreviewContent(file, currentBlobUrl)}
<script>
var blobUrl = "${currentBlobUrl}";
var fileName = "${escapeHtml(fileName).replace(/"/g, '\\"')}";
function downloadFile() {
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
<\/script>
</body>
</html>`;
// Reuse existing window if open, otherwise create new one
if (previewWindow && !previewWindow.closed) {
previewWindow.document.open();
previewWindow.document.write(previewHtml);
previewWindow.document.close();
previewWindow.focus();
} else {
// Calculate window size
const width = Math.round(screen.width * 0.6);
const height = Math.round(screen.height * 0.8);
const left = Math.round((screen.width - width) / 2);
const top = Math.round((screen.height - height) / 2);
previewWindow = window.open('', 'classifierPreview',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`);
if (!previewWindow) {
// Popup blocked - fall back to new tab
window.open(currentBlobUrl, '_blank');
return;
}
// Poll for window close — beforeunload is unreliable for popup close buttons
const closePoll = setInterval(() => {
if (previewWindow && previewWindow.closed) {
clearInterval(closePoll);
previewWindow = null;
const btn = document.getElementById('togglePreviewBtn');
if (btn) btn.classList.remove('preview-active');
}
}, 500);
previewWindow.document.write(previewHtml);
previewWindow.document.close();
previewWindow.focus();
}
// For types that need decoding, render content after window is ready
const ext = (file.extension || '').toLowerCase();
if (ext === 'docx') {
await renderDocxInWindow(file);
} else if (ext === 'xlsx' || ext === 'xls') {
await renderXlsxInWindow(file);
} else if (TIFF_EXTENSIONS.includes(ext)) {
await renderTiffInWindow(file);
} else if (ZIP_EXTENSIONS.includes(ext)) {
await renderZipInWindow(file);
}
} catch (err) {
console.error('Error opening preview:', err);
alert(`Error opening preview: ${err.message}`);
}
}
/**
* Get preview content HTML based on file type
*/
async function getPreviewContent(file, blobUrl) {
const ext = file.extension.toLowerCase();
const previewType = getPreviewType(ext);
switch (previewType) {
case 'pdf':
return `<iframe src="${blobUrl}#view=FitV"></iframe>`;
case 'html':
// Render the HTML natively (not as literal text). Sandbox
// flags allow same-origin resource loads + opening links
// in real new tabs (target=_blank / middle-click), but
// NOT allow-scripts — archived HTML cannot run JS.
return `<iframe src="${blobUrl}" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`;
case 'image':
return `<img src="${blobUrl}" alt="${escapeHtml(file.originalFilename)}" />`;
case 'text':
const text = await getFileText(file);
const maxLength = 100000;
const displayText = text.length > maxLength
? text.substring(0, maxLength) + '\n\n... (truncated)'
: text;
return `<pre>${escapeHtml(displayText)}</pre>`;
case 'docx':
case 'xlsx':
case 'tiff':
case 'zip':
return `<div id="previewContent"><div class="loading">Loading preview...</div></div>`;
default:
return `
<div class="unsupported">
<div class="icon">📄</div>
<p>Preview not available for ${ext} files</p>
<p style="margin-top: 0.5rem;">Click Download to view in external application</p>
</div>
`;
}
}
/**
* Get preview type from extension
*/
function getPreviewType(ext) {
// HTML is technically in TEXT_EXTENSIONS (used for editor
// syntax-highlighting elsewhere) but for previews we want to
// RENDER it, not show source. Check before the text branch.
if (ext === 'html' || ext === 'htm') return 'html';
if (PDF_EXTENSIONS.includes(ext)) return 'pdf';
if (TIFF_EXTENSIONS.includes(ext)) return 'tiff';
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
if (TEXT_EXTENSIONS.includes(ext)) return 'text';
if (ext === 'docx') return 'docx';
if (ext === 'xlsx' || ext === 'xls') return 'xlsx';
if (ZIP_EXTENSIONS.includes(ext)) return 'zip';
return 'none';
}
function getMimeType(ext) {
return window.app.modules.utils.getMimeType(ext);
}
/**
* Get file content as blob (handles both real and virtual files)
*/
async function getFileBlob(file) {
if (file.isVirtual) {
// Get from ZIP cache
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
// Get as arraybuffer and create blob with correct MIME type
const arrayBuffer = await zipEntry.async('arraybuffer');
const mimeType = getMimeType(file.extension);
return new Blob([arrayBuffer], { type: mimeType });
} else {
// Get from file handle
if (!file.handle) {
throw new Error('File handle not available');
}
return await file.handle.getFile();
}
}
/**
* Get file content as text (handles both real and virtual files)
*/
async function getFileText(file) {
if (file.isVirtual) {
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
return await zipEntry.async('string');
} else {
if (!file.handle) {
throw new Error('File handle not available');
}
const fileObj = await file.handle.getFile();
return await fileObj.text();
}
}
/**
* Render a DOCX file in the preview window using docx-preview library
*/
async function renderDocxInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
// jszip + docx-preview vendored by build.sh — already in scope.
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
container.innerHTML = '';
await window.docx.renderAsync(arrayBuffer, container);
} catch (err) {
console.error('Error rendering DOCX:', err);
container.innerHTML = `<div class="loading">Error rendering DOCX: ${err.message}<br>Click Download to view in Word.</div>`;
}
}
/**
* Render an XLSX/XLS file in the preview window using SheetJS
*/
async function renderXlsxInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
// XLSX bundled into the dist HTML; window.XLSX is available
// synchronously, no runtime load needed.
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
container.innerHTML = '';
// Make the content area a height-constrained flex column so the table
// scroller below fills the viewport — its horizontal scrollbar then
// sits at the window bottom instead of at the bottom of a tall sheet.
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.minHeight = '0';
container.style.overflow = 'hidden';
if (workbook.SheetNames.length > 1) {
const tabs = previewWindow.document.createElement('div');
tabs.className = 'sheet-tabs';
tabs.style.flexShrink = '0';
workbook.SheetNames.forEach((name, i) => {
const tab = previewWindow.document.createElement('button');
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
tab.textContent = name;
tab.onclick = () => {
tabs.querySelectorAll('.sheet-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
renderSheetInWindow(workbook, name, tableContainer);
};
tabs.appendChild(tab);
});
container.appendChild(tabs);
}
const tableContainer = previewWindow.document.createElement('div');
tableContainer.style.flex = '1';
tableContainer.style.minHeight = '0'; // allow it to shrink so overflow scrolls
tableContainer.style.overflow = 'auto';
container.appendChild(tableContainer);
renderSheetInWindow(workbook, workbook.SheetNames[0], tableContainer);
} catch (err) {
console.error('Error rendering XLSX:', err);
container.innerHTML = `<div class="loading">Error rendering spreadsheet: ${err.message}<br>Click Download to view in Excel.</div>`;
}
}
/**
* Render a single sheet as an HTML table in the preview window
*/
function renderSheetInWindow(workbook, sheetName, container) {
const sheet = workbook.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
container.innerHTML = html;
const table = container.querySelector('table');
if (table) table.className = 'xlsx-table';
}
/**
* Render a TIFF file in the preview window using shared zddc.preview.renderTiff
*/
async function renderTiffInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderTiff(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering TIFF:', err);
container.innerHTML = `<div class="loading">Error rendering TIFF: ${err.message}<br>Click Download to view in another application.</div>`;
}
}
/**
* Render a ZIP listing in the preview window using shared zddc.preview.renderZipListing
*/
async function renderZipInWindow(file) {
const container = previewWindow.document.getElementById('previewContent');
if (!container) return;
try {
const blob = await getFileBlob(file);
const arrayBuffer = await blob.arrayBuffer();
await zddc.preview.renderZipListing(previewWindow.document, container, arrayBuffer, {
fileName: zddc.joinExtension(file.originalFilename, file.extension)
});
} catch (err) {
console.error('Error rendering ZIP listing:', err);
container.innerHTML = `<div class="loading">Error reading ZIP: ${err.message}</div>`;
}
}
/**
* Escape HTML for safe display
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Download current file
*/
async function downloadFile() {
if (!currentFile) return;
try {
const blob = await getFileBlob(currentFile);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = zddc.joinExtension(currentFile.originalFilename, currentFile.extension);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) {
console.error('Error downloading file:', err);
alert('Error downloading file: ' + err.message);
}
}
// Export module
// Preview a file on demand (Classify & Copy mode). Snapshot-loaded files
// have no handle yet — resolve it from the workspace root (one-click read
// permission re-grant) before opening the preview window.
async function previewFile(file) {
try {
const sc = window.app.modules.scanner;
if (file.isVirtual) {
// Snapshot-restored zip member — reload its archive from the root.
if (window.app.rootHandle && !sc.getZipCache(file.zipPath)) {
if (window.app.modules.persist && window.app.modules.persist.verifyPermission) {
const ok = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
if (!ok) { if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error'); return; }
}
await sc.ensureZipLoaded(window.app.rootHandle, file.zipPath);
}
} else if (!file.handle && window.app.rootHandle) {
if (window.app.modules.persist && window.app.modules.persist.verifyPermission) {
const ok = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
if (!ok) { if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error'); return; }
}
await sc.resolveFileHandle(window.app.rootHandle, file);
}
await openPreviewWindow(file);
} catch (e) {
if (window.zddc) {
window.zddc.toast('Couldnt preview ' + (file.originalFilename || 'file') + ' — ' + (e.message || e), 'error');
}
}
}
window.app.modules.preview = {
init,
previewFile
};
})();

70
classifier/js/resize.js Normal file
View file

@ -0,0 +1,70 @@
/**
* Column Resize Module
* Handles resizable table columns
*/
(function() {
'use strict';
let resizingColumn = null;
let startX = 0;
let startWidth = 0;
/**
* Initialize column resizing
*/
function init() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
// Skip if resize handle already exists
if (th.querySelector('.column-resizer')) return;
// Add resize handle
const resizer = document.createElement('div');
resizer.className = 'column-resizer';
th.appendChild(resizer);
// Mouse down on resizer
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizingColumn = th;
startX = e.pageX;
startWidth = th.offsetWidth;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
});
}
/**
* Handle mouse move during resize
*/
function handleMouseMove(e) {
if (!resizingColumn) return;
const diff = e.pageX - startX;
const newWidth = Math.max(50, startWidth + diff);
resizingColumn.style.width = newWidth + 'px';
resizingColumn.style.minWidth = newWidth + 'px';
resizingColumn.style.maxWidth = newWidth + 'px';
}
/**
* Handle mouse up - end resize
*/
function handleMouseUp() {
resizingColumn = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
// Export module
window.app.modules.resize = {
init
};
})();

1007
classifier/js/scanner.js Normal file

File diff suppressed because it is too large Load diff

715
classifier/js/selection.js Normal file
View file

@ -0,0 +1,715 @@
/**
* Selection Module
* Handles Excel-style cell selection and copy/paste
*/
(function() {
'use strict';
let selectionStart = null;
let selectionEnd = null;
let isSelecting = false;
let initialized = false;
let autoScrollInterval = null;
let lastMouseY = 0;
let startMouseX = 0;
let startMouseY = 0;
let dragDistance = 0;
/**
* Initialize selection handlers
*/
function init() {
// Only initialize once
if (initialized) return;
initialized = true;
const table = window.app.dom.spreadsheet;
// Make table focusable so clipboard events fire
if (!table.hasAttribute('tabindex')) {
table.setAttribute('tabindex', '-1');
table.style.outline = 'none';
}
// Mouse down on cell - start selection
table.addEventListener('mousedown', handleMouseDown);
// Mouse move - extend selection
document.addEventListener('mousemove', handleMouseMove);
// Mouse up - end selection
document.addEventListener('mouseup', handleMouseUp);
// Selectstart handler - prevent only when dragging (multi-cell selection)
document.addEventListener('selectstart', (e) => {
if (isSelecting && dragDistance > 4) {
e.preventDefault();
}
});
// Copy/paste handlers
document.addEventListener('copy', handleCopy);
document.addEventListener('paste', handlePaste);
document.addEventListener('cut', handleCut);
}
/**
* Handle mouse down on cell
*/
function handleMouseDown(e) {
const cell = e.target.closest('td');
if (!cell) return;
// Ignore if clicking action buttons
if (e.target.closest('.inline-actions')) return;
// Ignore if cell is being edited (contenteditable)
if (cell.isContentEditable || cell.classList.contains('editing')) return;
// Don't start selection if double-clicking to edit
if (e.detail === 2) return;
const row = cell.closest('tr');
if (!row) return;
const rowIndex = parseInt(row.dataset.index);
const colIndex = Array.from(row.children).indexOf(cell);
// Shift+Click: extend selection from existing start to clicked cell
if (e.shiftKey && selectionStart) {
selectionEnd = { row: rowIndex, col: colIndex };
updateSelection();
updateButtonStates();
e.preventDefault();
return;
}
// Clear previous selection
clearSelection();
// Start new selection
selectionStart = { row: rowIndex, col: colIndex };
selectionEnd = { row: rowIndex, col: colIndex };
isSelecting = true;
dragDistance = 0;
startMouseX = e.clientX;
startMouseY = e.clientY;
// Highlight cell
updateSelection();
updateButtonStates();
// Focus the table so clipboard events (Ctrl+V) fire
window.app.dom.spreadsheet.focus();
// Only prevent default for shift-click (extending selection)
// Single click should allow text selection to work normally
if (e.shiftKey) {
e.preventDefault();
}
}
/**
* Handle mouse move during selection
*/
function handleMouseMove(e) {
if (!isSelecting) return;
// Store mouse position for auto-scroll
lastMouseY = e.clientY;
// Track drag distance from mousedown
const dx = e.clientX - startMouseX;
const dy = e.clientY - startMouseY;
dragDistance = Math.sqrt(dx*dx + dy*dy);
const cell = e.target.closest('td');
if (!cell) return;
const row = cell.closest('tr');
if (!row) return;
const rowIndex = parseInt(row.dataset.index);
const colIndex = Array.from(row.children).indexOf(cell);
if (rowIndex === undefined || colIndex === -1) return;
// Update selection end
selectionEnd = { row: rowIndex, col: colIndex };
// Highlight selected cells
updateSelection();
// Start auto-scroll if not already running
if (!autoScrollInterval) {
startAutoScroll();
}
}
/**
* Start continuous auto-scroll
*/
function startAutoScroll() {
const scrollThreshold = 50; // pixels from edge
const scrollSpeed = 5; // pixels per frame
const scroll = () => {
if (!isSelecting) {
autoScrollInterval = null;
return;
}
const viewport = document.querySelector('.spreadsheet-pane');
if (!viewport) {
autoScrollInterval = null;
return;
}
const rect = viewport.getBoundingClientRect();
// Determine scroll direction based on last mouse position
if (lastMouseY > rect.bottom - scrollThreshold) {
viewport.scrollTop += scrollSpeed; // Scroll down
} else if (lastMouseY < rect.top + scrollThreshold) {
viewport.scrollTop -= scrollSpeed; // Scroll up
}
// Continue scrolling
autoScrollInterval = requestAnimationFrame(scroll);
};
autoScrollInterval = requestAnimationFrame(scroll);
}
/**
* Handle mouse up - end selection
*/
function handleMouseUp(e) {
isSelecting = false;
dragDistance = 0;
// Stop auto-scrolling
if (autoScrollInterval) {
cancelAnimationFrame(autoScrollInterval);
autoScrollInterval = null;
}
updateButtonStates();
}
/**
* Update visual selection highlighting
*/
function updateSelection() {
if (!selectionStart || !selectionEnd) return;
// Clear all previous highlights
document.querySelectorAll('.selected-cell').forEach(cell => {
cell.classList.remove('selected-cell');
});
// Calculate selection bounds
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
// Highlight selected cells
const tbody = window.app.dom.spreadsheetBody;
const rows = tbody.querySelectorAll('tr');
for (let r = minRow; r <= maxRow; r++) {
const row = rows[r];
if (!row) continue;
const cells = row.children;
for (let c = minCol; c <= maxCol; c++) {
const cell = cells[c];
if (cell) {
cell.classList.add('selected-cell');
}
}
}
// Emit rowfocused event for preview pane
emitRowFocused(minRow);
}
/**
* Emit row focused event for preview pane
*/
function emitRowFocused(rowIndex) {
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (file) {
const event = new CustomEvent('rowfocused', {
detail: { rowIndex, file }
});
document.dispatchEvent(event);
}
}
/**
* Clear selection
*/
function clearSelection() {
selectionStart = null;
selectionEnd = null;
document.querySelectorAll('.selected-cell').forEach(cell => {
cell.classList.remove('selected-cell');
});
updateButtonStates();
}
/**
* Check if there is an active selection
*/
function hasSelection() {
return selectionStart !== null && selectionEnd !== null;
}
/**
* Update Copy/Paste button enabled states
*/
function updateButtonStates() {
const copyBtn = document.getElementById('copyBtn');
const pasteBtn = document.getElementById('pasteBtn');
const active = hasSelection();
if (copyBtn) copyBtn.disabled = !active;
if (pasteBtn) pasteBtn.disabled = !active;
}
/**
* Get column headers for selected columns
*/
function getColumnHeaders(minCol, maxCol) {
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
const headers = [];
for (let c = minCol; c <= maxCol; c++) {
const th = headerCells[c];
if (th) {
// Get the text content, excluding filter inputs
const text = th.childNodes[0]?.textContent?.trim() || th.textContent.trim();
headers.push(text);
} else {
headers.push('');
}
}
return headers;
}
/**
* Check if first row looks like a header row
*/
function isHeaderRow(row) {
const headerPatterns = ['#', 'Original', 'Ext', 'New', 'Tracking', 'Rev', 'Status', 'Title', 'SHA256'];
return row.some(cell => headerPatterns.includes(cell.trim()));
}
/**
* Convert 2D array of rows to an HTML table string.
* Excel prefers text/html over text/plain, so providing a
* proper <table> ensures cell boundaries are preserved.
*/
function rowsToHtml(allRows) {
const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const headerRow = allRows[0];
const dataRows = allRows.slice(1);
let html = '<table>';
html += '<tr>' + headerRow.map(c => '<th>' + esc(c) + '</th>').join('') + '</tr>';
for (const row of dataRows) {
html += '<tr>' + row.map(c => '<td>' + esc(c) + '</td>').join('') + '</tr>';
}
html += '</table>';
return html;
}
/**
* Handle copy event
*/
function handleCopy(e) {
if (!selectionStart || !selectionEnd) return;
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
// Get column headers for selected range
const headers = getColumnHeaders(minCol, maxCol);
const data = getSelectionData();
if (!data) return;
// Prepend header row
const allRows = [headers, ...data];
// Convert to TSV and HTML table
const tsv = allRows.map(row => row.join('\t')).join('\n');
e.clipboardData.setData('text/plain', tsv);
e.clipboardData.setData('text/html', rowsToHtml(allRows));
e.preventDefault();
}
/**
* Handle cut event
*/
function handleCut(e) {
if (!selectionStart || !selectionEnd) return;
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
// Get column headers for selected range
const headers = getColumnHeaders(minCol, maxCol);
const data = getSelectionData();
if (!data) return;
// Prepend header row
const allRows = [headers, ...data];
// Convert to TSV
const tsv = allRows.map(row => row.join('\t')).join('\n');
e.clipboardData.setData('text/plain', tsv);
e.clipboardData.setData('text/html', rowsToHtml(allRows));
// Clear selected cells (only editable ones)
clearSelectionData();
e.preventDefault();
}
/**
* Handle paste event
*/
function handlePaste(e) {
// Don't intercept paste in input fields
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
return;
}
if (!selectionStart) return;
const tsv = e.clipboardData.getData('text/plain');
if (!tsv) return;
e.preventDefault();
executePaste(tsv);
}
/**
* Execute paste from TSV string into the selected range.
* - If pasted cols > selected cols, right-align (user likely copied all but only pastes back editable cols).
* - If pasted data includes the first two columns (#, Original), validate they match.
* - If pasted row count != selected row count, abort with error.
*/
function executePaste(tsv) {
if (!selectionStart || !selectionEnd) return;
// Parse TSV
let rows = tsv.split('\n').map(row => row.split('\t'));
// Filter out empty trailing rows
while (rows.length > 0 && rows[rows.length - 1].every(cell => !cell.trim())) {
rows.pop();
}
if (rows.length === 0) return;
// Check if first row is a header row and skip it
if (rows.length > 1 && isHeaderRow(rows[0])) {
rows = rows.slice(1);
}
if (rows.length === 0) return;
// Selection bounds
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const selectedRowCount = maxRow - minRow + 1;
const selectedColCount = maxCol - minCol + 1;
const pastedColCount = rows[0].length;
// Row count validation: must match
if (rows.length !== selectedRowCount) {
alert(`Paste aborted: row count mismatch.\n` +
`Selected ${selectedRowCount} row(s), but clipboard has ${rows.length} row(s).`);
return;
}
// Determine column offset for right-alignment
let colOffset = 0;
if (pastedColCount > selectedColCount) {
colOffset = pastedColCount - selectedColCount;
}
// Get column names from header
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
const columnNames = Array.from(headerCells).map(th => {
const match = th.className.match(/col-(\w+)/);
return match ? match[1] : '';
});
// If pasted data includes first two columns (row-num, original), validate they match
if (colOffset === 0 && pastedColCount >= selectedColCount) {
// Check if pasted range starts at col 0 or col 1 (row-num or original)
const files = window.app.modules.store.getDisplayFiles();
const startsAtRowNum = (minCol === 0);
const startsAtOriginal = (minCol === 1);
if (startsAtRowNum || startsAtOriginal) {
for (let r = 0; r < rows.length; r++) {
const targetRowIndex = minRow + r;
const file = files[targetRowIndex];
if (!file) continue;
if (startsAtRowNum) {
// Validate col 0 = row number, col 1 = original filename
const expectedNum = String(targetRowIndex + 1);
const pastedNum = rows[r][0]?.trim();
const pastedOriginal = rows[r][1]?.trim();
if (pastedNum && pastedNum !== expectedNum) {
alert(`Paste aborted: row number mismatch at row ${targetRowIndex + 1}.\n` +
`Expected "${expectedNum}", got "${pastedNum}".\n` +
`Data may be shuffled.`);
return;
}
if (pastedOriginal && pastedOriginal !== file.originalFilename) {
alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` +
`Expected "${file.originalFilename}", got "${pastedOriginal}".\n` +
`Data may be shuffled.`);
return;
}
} else if (startsAtOriginal) {
// Validate col 0 of paste = original filename
const pastedOriginal = rows[r][0]?.trim();
if (pastedOriginal && pastedOriginal !== file.originalFilename) {
alert(`Paste aborted: filename mismatch at row ${targetRowIndex + 1}.\n` +
`Expected "${file.originalFilename}", got "${pastedOriginal}".\n` +
`Data may be shuffled.`);
return;
}
}
}
}
}
// Editable columns
const editableColumns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const files = window.app.modules.store.getDisplayFiles();
let updatedCount = 0;
for (let r = 0; r < rows.length; r++) {
const targetRowIndex = minRow + r;
if (targetRowIndex >= files.length) continue;
const file = files[targetRowIndex];
if (!file) continue;
const rowData = rows[r];
for (let c = 0; c < selectedColCount; c++) {
const pasteIdx = c + colOffset; // right-align: skip leading pasted cols
if (pasteIdx >= rowData.length) continue;
const targetColIndex = minCol + c;
if (targetColIndex >= columnNames.length) continue;
const columnName = columnNames[targetColIndex];
if (!editableColumns.includes(columnName)) continue;
const value = rowData[pasteIdx]?.trim() || '';
if (columnName === 'newFilename') {
if (value) {
file.manualFilename = value;
} else {
delete file.manualFilename;
}
} else {
file[columnName] = value;
if (file.manualFilename) {
delete file.manualFilename;
}
}
file.isDirty = true;
file.autoPopulated = false;
updatedCount++;
}
}
// Re-render and restore selection
if (updatedCount > 0) {
window.app.modules.spreadsheet.render();
// Restore selection highlight
updateSelection();
showToast(`Pasted ${updatedCount} cell(s)`, 'success');
}
}
/**
* Show a brief toast notification
*/
function showToast(message, type) {
if (window.app.modules.excel && window.app.modules.excel.showToast) {
window.app.modules.excel.showToast(message, type);
return;
}
// Fallback: simple toast
const toast = document.createElement('div');
toast.textContent = message;
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;padding:8px 16px;' +
'background:' + (type === 'success' ? '#28a745' : '#dc3545') + ';color:#fff;' +
'border-radius:4px;z-index:9999;font-size:14px;';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
/**
* Get data from selected cells
*/
function getSelectionData() {
if (!selectionStart || !selectionEnd) return null;
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const data = [];
const tbody = window.app.dom.spreadsheetBody;
const rows = tbody.querySelectorAll('tr');
for (let r = minRow; r <= maxRow; r++) {
const row = rows[r];
if (!row) continue;
const rowData = [];
const cells = row.children;
for (let c = minCol; c <= maxCol; c++) {
const cell = cells[c];
if (cell) {
rowData.push(cell.textContent.trim());
} else {
rowData.push('');
}
}
data.push(rowData);
}
return data;
}
/**
* Clear data from selected cells (only editable ones)
*/
function clearSelectionData() {
if (!selectionStart || !selectionEnd) return;
const minRow = Math.min(selectionStart.row, selectionEnd.row);
const maxRow = Math.max(selectionStart.row, selectionEnd.row);
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const tbody = window.app.dom.spreadsheetBody;
const rows = tbody.querySelectorAll('tr');
// Get column names from header
const headerCells = window.app.dom.spreadsheet.querySelectorAll('thead th');
const columnNames = Array.from(headerCells).map(th => {
const className = th.className.replace('col-', '');
return className;
});
for (let r = minRow; r <= maxRow; r++) {
const row = rows[r];
if (!row) continue;
const rowIndex = parseInt(row.dataset.index);
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) continue;
const cells = row.children;
for (let c = minCol; c <= maxCol; c++) {
const cell = cells[c];
if (!cell || !cell.classList.contains('cell-editable')) continue;
const columnName = columnNames[c];
// Clear the data
if (columnName === 'newFilename') {
delete file.manualFilename;
} else {
file[columnName] = '';
}
file.isDirty = true;
}
}
// Re-render
window.app.modules.spreadsheet.render();
}
/**
* Copy selection to clipboard via button click
*/
function doCopy() {
if (!selectionStart || !selectionEnd) return;
const minCol = Math.min(selectionStart.col, selectionEnd.col);
const maxCol = Math.max(selectionStart.col, selectionEnd.col);
const headers = getColumnHeaders(minCol, maxCol);
const data = getSelectionData();
if (!data) return;
const allRows = [headers, ...data];
const tsv = allRows.map(row => row.join('\t')).join('\n');
const html = rowsToHtml(allRows);
// Write both plain text and HTML to clipboard
const htmlBlob = new Blob([html], { type: 'text/html' });
const textBlob = new Blob([tsv], { type: 'text/plain' });
const item = new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob });
navigator.clipboard.write([item]).then(() => {
showToast(`Copied ${data.length} row(s)`, 'success');
}).catch(err => {
// Fallback to plain text if ClipboardItem not supported
navigator.clipboard.writeText(tsv).then(() => {
showToast(`Copied ${data.length} row(s)`, 'success');
}).catch(err2 => {
console.error('Copy failed:', err2);
});
});
}
/**
* Paste from clipboard via button click
*/
function doPaste() {
if (!selectionStart || !selectionEnd) return;
navigator.clipboard.readText().then(tsv => {
if (tsv) executePaste(tsv);
}).catch(err => {
console.error('Paste failed:', err);
alert('Cannot read clipboard. Use Ctrl+V instead, or grant clipboard permission.');
});
}
// Export module
window.app.modules.selection = {
init,
clearSelection,
hasSelection,
doCopy,
doPaste
};
})();

158
classifier/js/seltable.js Normal file
View file

@ -0,0 +1,158 @@
/**
* ZDDC Classifier shared selectable + autofilter table.
*
* A flat table with one global autofilter (AND of space-separated terms over
* every column) and powerful selection for building complex sets quickly:
* click replace selection + set anchor
* ctrl/cmd-click toggle one row
* shift-click range from the anchor (replaces the selection)
* ctrl-shift-click ADD the anchorrow range to the existing selection
* ctrl/cmd-Enter fire onActivate(selectedIds) a bulk action
* Esc clear
* Ranges run over the CURRENTLY FILTERED order, so "filter to a transmittal,
* then shift-select the visible block" works. Selection is keyed by a stable
* rowId so it survives filtering and re-render.
*
* Used by the MDL instantiate flow (Phase 1) and the By-MDL drop-target table
* (Phase 2).
*/
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function terms(q) { return String(q == null ? '' : q).trim().toLowerCase().split(/\s+/).filter(Boolean); }
function hit(text, ts) {
var t = String(text == null ? '' : text).toLowerCase();
for (var i = 0; i < ts.length; i++) { if (t.indexOf(ts[i]) === -1) return false; }
return true;
}
function elt(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function create(opts) {
var container = opts.container;
var columns = opts.columns || [];
var rowId = opts.rowId || function (r) { return r.id; };
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
var selected = Object.create(null); // id -> true
var anchorId = null;
var ft = []; // global filter terms
function rows() { return getRows() || []; }
function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); }
function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
function filtered() { return ft.length ? rows().filter(function (r) { return hit(rowBlob(r), ft); }) : rows().slice(); }
function getSelection() { return Object.keys(selected); }
function getFilteredRows() { return filtered(); }
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
function setFilter(q) { ft = terms(q); renderBody(); }
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
function onRowClick(e, row, fr) {
var ids = fr.map(rowId), id = rowId(row), idx = ids.indexOf(id), aIdx;
if (e.shiftKey && anchorId != null && (aIdx = ids.indexOf(anchorId)) >= 0) {
if (!(e.ctrlKey || e.metaKey)) selected = Object.create(null); // shift replaces; ctrl-shift adds
var lo = Math.min(aIdx, idx), hi = Math.max(aIdx, idx);
for (var i = lo; i <= hi; i++) selected[ids[i]] = true;
} else if (e.ctrlKey || e.metaKey) {
if (selected[id]) delete selected[id]; else selected[id] = true;
anchorId = id;
} else {
selected = Object.create(null); selected[id] = true; anchorId = id;
}
renderBody(); fireSel();
}
var bodyEl = null, countEl = null;
function render() {
container.textContent = '';
container.classList.add('seltable');
var bar = elt('div', 'seltable__bar');
var filterEl = elt('input', 'seltable__filter'); filterEl.type = 'search';
filterEl.placeholder = opts.filterPlaceholder || 'Filter…'; filterEl.spellcheck = false;
filterEl.addEventListener('input', function () { setFilter(this.value); });
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
allBtn.addEventListener('click', selectAllFiltered);
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
clrBtn.addEventListener('click', clearSel);
countEl = elt('span', 'seltable__count');
bar.appendChild(filterEl); bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
container.appendChild(bar);
var scroll = elt('div', 'seltable__scroll');
var table = elt('table', 'seltable__table');
var thead = elt('thead'), htr = elt('tr');
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
thead.appendChild(htr); table.appendChild(thead);
bodyEl = elt('tbody'); table.appendChild(bodyEl);
scroll.appendChild(table); container.appendChild(scroll);
container.tabIndex = 0;
container.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (opts.onActivate) opts.onActivate(getSelection()); }
else if (e.key === 'Escape') { clearSel(); }
});
renderBody();
}
function renderBody() {
if (!bodyEl) return;
var fr = filtered();
bodyEl.textContent = '';
fr.forEach(function (row) {
var id = rowId(row);
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
tr.dataset.id = id;
tr.addEventListener('click', function (e) {
if (e.target.closest('input,button,select,a,[data-no-select]')) return; // let controls work
onRowClick(e, row, fr);
});
if (opts.onRowDrop) {
tr.addEventListener('dragover', function (e) {
if (window.app.modules.dnd && window.app.modules.dnd.active()) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; tr.classList.add('drop-hover'); }
});
tr.addEventListener('dragleave', function () { tr.classList.remove('drop-hover'); });
tr.addEventListener('drop', function (e) {
tr.classList.remove('drop-hover');
e.preventDefault();
var keys = window.app.modules.dnd ? window.app.modules.dnd.getDrag() : [];
if (window.app.modules.dnd) window.app.modules.dnd.clearDrag();
if (keys.length) opts.onRowDrop(id, keys);
});
}
columns.forEach(function (c) {
var td = elt('td', c.cls || null);
if (c.render) c.render(row, td); else td.textContent = colVal(c, row);
tr.appendChild(td);
});
if (opts.rowExtra) { var ex = elt('td', 'seltable__extra'); opts.rowExtra(row, ex); tr.appendChild(ex); }
bodyEl.appendChild(tr);
});
if (countEl) {
var nSel = getSelection().length;
countEl.textContent = fr.length + ' shown' + (nSel ? (' · ' + nSel + ' selected') : '');
}
}
return {
render: render, renderBody: renderBody,
getSelection: getSelection, getFilteredRows: getFilteredRows,
setFilter: setFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
// test seam: simulate a row click with modifier keys.
clickRow: function (id, mods) {
var fr = filtered();
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
if (row) onRowClick(Object.assign({ shiftKey: false, ctrlKey: false, metaKey: false }, mods || {}), row, fr);
},
};
}
window.app.modules.seltable = { create: create };
})();

215
classifier/js/sort.js Normal file
View file

@ -0,0 +1,215 @@
/**
* Sort Module
* Handles multi-column sorting for the spreadsheet
*/
(function() {
'use strict';
// Sort state: array of {column, direction}
let sortState = [];
/**
* Initialize sorting
*/
function init() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach((th, index) => {
// Skip row number column
if (th.classList.contains('col-row-num')) return;
// Skip if already initialized
if (th.querySelector('.sort-indicator')) return;
// Make header clickable
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
// Add sort indicator container
const sortIndicator = document.createElement('span');
sortIndicator.className = 'sort-indicator';
// Insert after the text node (before any br or filter)
const firstChild = th.firstChild;
if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
// Insert after text node
firstChild.after(sortIndicator);
} else {
// Prepend to header
th.insertBefore(sortIndicator, firstChild);
}
// Click to sort (only add once)
const handleClick = (e) => {
// Don't sort if clicking on resizer or filter input
if (e.target.classList.contains('column-resizer')) return;
if (e.target.classList.contains('column-filter')) return;
const columnName = th.className.replace('col-', '');
handleSort(columnName, e.ctrlKey || e.metaKey);
};
th.addEventListener('click', handleClick);
});
}
/**
* Apply default sort (called after initial render)
*/
function applyDefaultSort() {
const files = window.app.modules.store.getDisplayFiles();
if (files.length > 0) {
sortState = [{ column: 'original', direction: 'asc' }];
applySorts();
updateSortIndicators();
}
}
/**
* Handle sort click
*/
function handleSort(columnName, multiSort) {
// Use store to toggle sort
window.app.modules.store.toggleSort(columnName, multiSort);
}
/**
* Apply sort to files array (pure function - doesn't mutate)
*/
function applySortToFiles(files) {
if (sortState.length === 0) {
return files;
}
return [...files].sort((a, b) => {
for (const sort of sortState) {
const result = compareValues(a, b, sort.column, sort.direction);
if (result !== 0) return result;
}
return 0;
});
}
/**
* Apply all sorts (legacy - triggers render)
*/
function applySorts() {
window.app.modules.spreadsheet.render();
}
/**
* Apply default sort
*/
function applyDefaultSort() {
const files = window.app.modules.store.getDisplayFiles();
if (files.length > 0 && sortState.length === 0) {
sortState = [{ column: 'original', direction: 'asc' }];
window.app.modules.spreadsheet.render();
}
}
/**
* Get current sort state
*/
function getSortState() {
return sortState;
}
/**
* Update sort indicators in headers
*/
function updateIndicators() {
const table = window.app.dom.spreadsheet;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
const indicator = th.querySelector('.sort-indicator');
if (!indicator) return;
const columnName = th.className.replace('col-', '');
const sortIndex = sortState.findIndex(s => s.column === columnName);
if (sortIndex >= 0) {
const sort = sortState[sortIndex];
const arrow = sort.direction === 'asc' ? '▲' : '▼';
const priority = sortState.length > 1 ? (sortIndex + 1) : '';
indicator.textContent = ` ${arrow}${priority}`;
indicator.style.display = 'inline';
} else {
indicator.textContent = '';
indicator.style.display = 'none';
}
});
}
/**
* Compare two values for sorting
*/
function compareValues(a, b, columnName, direction) {
let aVal, bVal;
// Get values based on column
switch (columnName) {
case 'original':
aVal = a.originalFilename || '';
bVal = b.originalFilename || '';
break;
case 'extension':
aVal = a.extension || '';
bVal = b.extension || '';
break;
case 'new':
case 'newFilename':
aVal = a.manualFilename || window.app.modules.spreadsheet.computeNewFilename(a, 0);
bVal = b.manualFilename || window.app.modules.spreadsheet.computeNewFilename(b, 0);
break;
case 'trackingNumber':
aVal = a.trackingNumber || '';
bVal = b.trackingNumber || '';
break;
case 'revision':
aVal = a.revision || '';
bVal = b.revision || '';
break;
case 'status':
aVal = a.status || '';
bVal = b.status || '';
break;
case 'title':
aVal = a.title || '';
bVal = b.title || '';
break;
case 'sha256':
aVal = a.sha256 || '';
bVal = b.sha256 || '';
break;
default:
return 0;
}
// Natural sort for strings (handles numbers within strings)
const comparison = aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: 'base' });
return direction === 'asc' ? comparison : -comparison;
}
/**
* Clear all sorts
*/
function clearSorts() {
sortState = [];
applySorts();
}
// Export module
window.app.modules.sort = {
init,
applyDefaultSort,
applySorts,
applySortToFiles,
updateIndicators,
getSortState,
clearSorts
};
})();

View file

@ -0,0 +1,953 @@
/**
* Spreadsheet Module
* Handles table rendering, cell editing, and file operations
*/
(function() {
'use strict';
let editingCell = null;
let editingInput = null;
/**
* Render spreadsheet from store
*/
function render() {
const tbody = window.app.dom.spreadsheetBody;
tbody.innerHTML = '';
// Get files from store (already filtered and sorted)
const files = window.app.modules.store.getDisplayFiles();
// Render rows
if (files.length === 0) {
const message = window.app.modules.store.getDisplayFiles().length === 0
? '<h3>No files to display</h3><p>Select one or more folders from the tree to view files</p>'
: '<h3>No files match filters</h3><p>Adjust or clear filters to see files</p>';
tbody.innerHTML = `
<tr>
<td colspan="10" class="spreadsheet-empty">
${message}
</td>
</tr>
`;
return;
}
files.forEach((file, index) => {
const row = createRow(file, index);
tbody.appendChild(row);
});
// Update UI
window.app.modules.app.updateStats();
updateSortIndicators();
// Calculate SHA256 if enabled
if (window.app.calculateSha256) {
calculateSha256ForAll();
}
}
/**
* Update sort indicators
*/
function updateSortIndicators() {
const sortColumns = window.app.modules.store.getSortColumns();
const headers = window.app.dom.spreadsheet.querySelectorAll('thead th');
headers.forEach(th => {
const indicator = th.querySelector('.sort-indicator');
if (!indicator) return;
const columnName = th.className.replace('col-', '');
const sortIndex = sortColumns.findIndex(s => s.column === columnName);
if (sortIndex >= 0) {
const sort = sortColumns[sortIndex];
const arrow = sort.direction === 'asc' ? '▲' : '▼';
const priority = sortColumns.length > 1 ? (sortIndex + 1) : '';
indicator.textContent = ` ${arrow}${priority}`;
indicator.style.display = 'inline';
} else {
indicator.textContent = '';
indicator.style.display = 'none';
}
});
}
/**
* Create a table row for a file
*/
function createRow(file, index) {
const row = document.createElement('tr');
row.dataset.index = index;
row.dataset.folderPath = file.folderPath; // Store folder path for highlighting
// Add state classes
if (file.isDirty) row.classList.add('modified');
if (file.error) row.classList.add('error');
// Highlight folder on hover
row.addEventListener('mouseenter', () => {
highlightFolder(file.folderPath);
});
row.addEventListener('mouseleave', () => {
clearFolderHighlight();
});
// Row number
row.appendChild(createCell('row-num', index + 1, false));
// Original filename — plain text (selectable/copyable, no link)
const originalCell = createCell('original', file.originalFilename, false);
row.appendChild(originalCell);
// Extension — hyperlink to open the file
const extCell = createCell('extension', '', false);
const extLink = document.createElement('a');
extLink.className = 'cell-link';
extLink.textContent = file.extension;
extLink.title = 'Click to open file';
extLink.style.cursor = 'pointer';
extLink.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
openFile(file);
});
extCell.appendChild(extLink);
row.appendChild(extCell);
// Parse original filename to get ZDDC components (always)
// Pass full filename (name + extension) so the regex can match the .ext suffix
const parsed = zddc.parseFilename(zddc.joinExtension(file.originalFilename, file.extension)) || {};
// Fill any empty fields from parsed filename (per-field, not all-or-nothing)
// Must happen before computeNewFilename so all fields are available
if (!file.trackingNumber) file.trackingNumber = parsed.trackingNumber || '';
if (!file.revision) file.revision = parsed.revision || '';
if (!file.status) file.status = parsed.status || '';
if (!file.title) file.title = parsed.title || '';
// New filename: show only if it would actually change the file
const computedFilename = file.manualFilename || computeNewFilename(file, index);
const originalFullName = zddc.joinExtension(file.originalFilename, file.extension);
const wouldChange = computedFilename !== originalFullName;
const newFilenameDisplay = wouldChange ? computedFilename : '';
const newFilenameCell = createEditableCell('newFilename', newFilenameDisplay, index);
// Use computedFilename (not newFilenameDisplay) for validation
const newFilename = computedFilename;
if (!file.manualFilename) {
newFilenameCell.classList.add('computed');
}
// Validate and show errors
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
newFilenameCell.classList.add('validation-error');
newFilenameCell.title = validation.errors.join('; ');
} else if (validation.warnings.length > 0) {
newFilenameCell.classList.add('validation-warning');
newFilenameCell.title = validation.warnings.join('; ');
}
// Only show action buttons if row is dirty
if (file.isDirty) {
const actions = document.createElement('span');
actions.className = 'inline-actions';
const saveBtn = document.createElement('button');
saveBtn.className = 'btn-inline btn-save';
saveBtn.textContent = '✓';
saveBtn.title = 'Save';
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveFile(index);
});
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-inline btn-cancel';
cancelBtn.textContent = '✗';
cancelBtn.title = 'Clear all fields';
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
cancelFile(index);
});
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
newFilenameCell.appendChild(actions);
}
row.appendChild(newFilenameCell);
// For each field: use file value (already populated above) for display
const displayTracking = file.trackingNumber || '';
const displayRevision = file.revision || '';
const displayStatus = file.status || '';
const displayTitle = file.title || '';
const trackingCell = createEditableCell('trackingNumber', displayTracking, index);
const revisionCell = createEditableCell('revision', displayRevision, index);
const statusCell = createEditableCell('status', displayStatus, index);
const titleCell = createEditableCell('title', displayTitle, index);
// Gray = field value matches what the original filename parses to (no change)
// Blue = field value differs from the parsed original (would produce a different filename)
if (displayTracking === (parsed.trackingNumber || '')) trackingCell.classList.add('auto-populated');
else trackingCell.classList.add('field-changed');
if (displayRevision === (parsed.revision || '')) revisionCell.classList.add('auto-populated');
else revisionCell.classList.add('field-changed');
if (displayStatus === (parsed.status || '')) statusCell.classList.add('auto-populated');
else statusCell.classList.add('field-changed');
if (displayTitle === (parsed.title || '')) titleCell.classList.add('auto-populated');
else titleCell.classList.add('field-changed');
row.appendChild(trackingCell);
row.appendChild(revisionCell);
row.appendChild(statusCell);
row.appendChild(titleCell);
// SHA256 (if enabled)
if (window.app.calculateSha256) {
const sha256Cell = createCell('sha256', file.sha256 || 'calculating...', false);
if (!file.sha256) {
sha256Cell.classList.add('sha256-calculating');
}
row.appendChild(sha256Cell);
}
return row;
}
/**
* Create a table cell
*/
function createCell(className, content, editable = false) {
const td = document.createElement('td');
td.className = `col-${className}`;
td.textContent = content;
return td;
}
/**
* Create an editable cell
*/
function createEditableCell(columnName, value, rowIndex) {
const td = document.createElement('td');
td.className = `col-${columnName} cell-editable`;
td.textContent = value;
// Double-click to edit
td.addEventListener('dblclick', (e) => {
e.stopPropagation();
startEditing(td, columnName, rowIndex);
});
return td;
}
/**
* Start editing a cell
*/
function startEditing(cell, columnName, rowIndex) {
// Cancel any existing edit
if (editingCell) {
cancelEditing();
}
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
const currentValue = file[columnName] || '';
// Clear any cell selection
if (window.app.modules.selection) {
window.app.modules.selection.clearSelection();
}
// Store references
editingCell = { cell, columnName, rowIndex, originalValue: currentValue };
// Save original content and make cell contenteditable
cell.dataset.originalContent = cell.innerHTML;
cell.contentEditable = 'true';
cell.classList.add('editing');
editingInput = cell;
// Set content (text only for editing)
cell.textContent = currentValue;
// Focus and select all
cell.focus();
// Select all text — guard against cell being detached from document
// (can happen if a re-render fires between dblclick and this point)
if (document.contains(cell)) {
const range = document.createRange();
range.selectNodeContents(cell);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// Event listeners
cell.addEventListener('blur', handleBlur, { once: true });
cell.addEventListener('keydown', handleKeyDown);
}
/**
* Handle blur event
*/
function handleBlur() {
// Small delay to allow click events to fire first
setTimeout(() => finishEditing(), 100);
}
/**
* Handle keydown in contenteditable
*/
function handleKeyDown(e) {
if (e.key === 'Enter') {
// Enter exits edit mode
e.preventDefault();
finishEditing();
} else if (e.key === 'Escape') {
// Escape undoes and exits edit mode
e.preventDefault();
cancelEditing();
} else if (e.key === 'Tab') {
// Tab/Shift+Tab moves to next/prev cell
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
const shiftKey = e.shiftKey;
finishEditingQuiet(); // Don't trigger store update
if (rowIndex !== undefined) {
if (shiftKey) {
moveToPreviousCell(rowIndex, columnName);
} else {
moveToNextCell(rowIndex, columnName);
}
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
finishEditingQuiet();
if (rowIndex !== undefined) {
moveUpRow(rowIndex, columnName);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
finishEditingQuiet();
if (rowIndex !== undefined) {
moveDownRow(rowIndex, columnName);
}
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// Allow normal cursor movement within cell
e.stopPropagation();
}
}
/**
* Finish editing and save value
*/
function finishEditing() {
if (!editingCell || !editingInput) return;
const { cell, columnName, rowIndex } = editingCell;
const newValue = editingInput.textContent.trim();
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
const oldValue = file[columnName] || '';
// Remove contenteditable
editingInput.contentEditable = 'false';
editingInput.classList.remove('editing');
editingInput.removeEventListener('keydown', handleKeyDown);
// Update file data if changed
if (newValue !== oldValue) {
// Special handling for newFilename column
if (columnName === 'newFilename') {
if (newValue) {
window.app.modules.store.updateFileField(rowIndex, 'manualFilename', newValue);
} else {
window.app.modules.store.updateFile(rowIndex, { manualFilename: null });
}
} else {
window.app.modules.store.updateFileField(rowIndex, columnName, newValue);
}
const updatedFile = window.app.modules.store.getDisplayFiles()[rowIndex];
validateFile(updatedFile);
}
editingCell = null;
editingInput = null;
}
/**
* Finish editing without triggering store update (for Tab/Arrow navigation)
*/
function finishEditingQuiet() {
if (!editingCell || !editingInput) return;
const { columnName, rowIndex } = editingCell;
const newValue = editingInput.textContent.trim();
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
// Remove contenteditable
editingInput.contentEditable = 'false';
editingInput.classList.remove('editing');
editingInput.removeEventListener('keydown', handleKeyDown);
// Update file object directly (no store notification)
if (file) {
if (columnName === 'newFilename') {
file.manualFilename = newValue || null;
} else {
file[columnName] = newValue;
}
file.isDirty = true;
}
editingCell = null;
editingInput = null;
}
/**
* Cancel editing without saving
*/
function cancelEditing() {
if (!editingCell) return;
const { rowIndex } = editingCell;
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
// Clear editing state
editingCell = null;
editingInput = null;
// Re-render the row
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
if (row) {
const newRow = createRow(file, rowIndex);
row.replaceWith(newRow);
}
}
/**
* Move to next editable cell
*/
function moveToNextCell(rowIndex, currentColumn) {
const columns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const currentIndex = columns.indexOf(currentColumn);
if (currentIndex < columns.length - 1) {
// Next column in same row
const nextColumn = columns[currentIndex + 1];
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
const nextCell = row.querySelector(`.col-${nextColumn}`);
if (nextCell) {
startEditing(nextCell, nextColumn, rowIndex);
}
} else if (rowIndex < window.app.modules.store.getDisplayFiles().length - 1) {
// First column of next row
const nextColumn = columns[0];
const nextRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex + 1}"]`);
const nextCell = nextRow.querySelector(`.col-${nextColumn}`);
if (nextCell) {
startEditing(nextCell, nextColumn, rowIndex + 1);
}
}
}
/**
* Move to previous editable cell
*/
function moveToPreviousCell(rowIndex, currentColumn) {
const columns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const currentIndex = columns.indexOf(currentColumn);
if (currentIndex > 0) {
// Previous column in same row
const prevColumn = columns[currentIndex - 1];
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
const prevCell = row.querySelector(`.col-${prevColumn}`);
if (prevCell) {
startEditing(prevCell, prevColumn, rowIndex);
}
} else if (rowIndex > 0) {
// Last column of previous row
const prevColumn = columns[columns.length - 1];
const prevRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex - 1}"]`);
const prevCell = prevRow.querySelector(`.col-${prevColumn}`);
if (prevCell) {
startEditing(prevCell, prevColumn, rowIndex - 1);
}
}
}
/**
* Move up one row, same column
*/
function moveUpRow(rowIndex, currentColumn) {
if (rowIndex > 0) {
const prevRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex - 1}"]`);
const cell = prevRow.querySelector(`.col-${currentColumn}`);
if (cell) {
startEditing(cell, currentColumn, rowIndex - 1);
}
}
}
/**
* Move down one row, same column
*/
function moveDownRow(rowIndex, currentColumn) {
if (rowIndex < window.app.modules.store.getDisplayFiles().length - 1) {
const nextRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex + 1}"]`);
const cell = nextRow.querySelector(`.col-${currentColumn}`);
if (cell) {
startEditing(cell, currentColumn, rowIndex + 1);
}
}
}
function computeNewFilename(file) {
return window.app.modules.utils.computeNewFilename(file);
}
/**
* Validate a file
*/
function validateFile(file) {
const newFilename = computeNewFilename(file, 0);
const validation = window.app.modules.validator.validateFilename(newFilename);
file.validation = validation;
file.error = !validation.isValid;
}
/**
* Open file in new tab
*/
async function openFile(file) {
try {
let blob;
if (file.isVirtual) {
// Virtual file from ZIP - get from cache
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
const arrayBuffer = await zipEntry.async('arraybuffer');
const mimeType = getMimeType(file.extension);
blob = new Blob([arrayBuffer], { type: mimeType });
} else {
blob = await file.handle.getFile();
}
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Clean up URL after a delay
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (err) {
console.error('Error opening file:', err);
alert('Cannot open file: ' + err.message);
}
}
function getMimeType(extension) {
return window.app.modules.utils.getMimeType(extension);
}
/**
* Save a single file
*/
async function saveFile(index, skipValidation = false) {
const files = window.app.modules.store.getDisplayFiles();
const file = files[index];
if (!file.isDirty) {
return;
}
// Virtual files (from ZIPs) cannot be renamed - must extract first
if (file.isVirtual) {
alert('Cannot rename files inside ZIP archives.\nExtract the ZIP first to rename files.');
return;
}
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${index}"]`);
if (!row) {
console.error(`Row not found for index ${index}`);
return;
}
row.classList.add('saving');
try {
const newFilename = computeNewFilename(file, index);
const currentFilename = zddc.joinExtension(file.originalFilename, file.extension);
// Check if already has correct name
if (currentFilename === newFilename) {
row.classList.remove('saving');
// Just clear dirty flag and fields
file.isDirty = false;
file.error = false;
delete file.manualFilename;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
const newRow = createRow(file, index);
row.replaceWith(newRow);
window.app.modules.app.updateStats();
return;
}
// Validate filename
if (!skipValidation) {
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
const errors = validation.errors.join('\n');
const confirmed = confirm(
`⚠️ Warning: Filename is not ZDDC compliant!\n\n` +
`Errors:\n${errors}\n\n` +
`Current filename: ${newFilename}\n\n` +
`Do you want to save it anyway?`
);
if (!confirmed) {
row.classList.remove('saving');
return;
}
}
}
// Request write permission for the folder
const folderPermission = await file.folderHandle.queryPermission({ mode: 'readwrite' });
if (folderPermission !== 'granted') {
const granted = await file.folderHandle.requestPermission({ mode: 'readwrite' });
if (granted !== 'granted') {
throw new Error('Write permission denied');
}
}
// Rename. HTTP-backed handles (zddc-server) get the atomic
// POST /op=move path — single round-trip, server-side
// os.Rename, no risk of half-renamed state. Local FS Access
// API handles use copy+remove because the API has no native
// rename verb.
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
try {
if (window.zddc.source.isHttpHandle(file.folderHandle)) {
const folderUrl = file.folderHandle.url();
const folderPath = new URL(folderUrl).pathname;
const srcPath = folderPath + encodeURIComponent(oldFilename);
const dstPath = folderPath + encodeURIComponent(newFilename);
await window.zddc.source.moveFile(srcPath, dstPath);
file.handle = await file.folderHandle.getFileHandle(newFilename);
} else {
// Get fresh handle for old file
const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
// Read the file content
const fileData = await oldHandle.getFile();
// Create new file with new name
const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
const writable = await newHandle.createWritable();
await writable.write(fileData);
await writable.close();
// Delete old file
await file.folderHandle.removeEntry(oldFilename);
// Update file handle
file.handle = newHandle;
}
} catch (err) {
console.error(`Failed to rename file:`, err);
throw err;
}
// Update file data directly (don't trigger store notification during batch save)
file.originalFilename = zddc.splitExtension(newFilename).name;
file.isDirty = false;
file.error = false;
file.manualFilename = null;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
file.autoPopulated = false;
// Update row UI
row.classList.remove('saving');
const newRow = createRow(file, index);
row.replaceWith(newRow);
} catch (err) {
console.error('Error saving file:', err);
file.error = true;
file.errorMessage = err.message;
row.classList.remove('saving');
row.classList.add('error');
// Re-throw so caller can handle
throw err;
}
}
/**
* Cancel/Clear all fields for a single file
*/
function cancelFile(index) {
// Clear all fields through store
window.app.modules.store.updateFile(index, {
trackingNumber: '',
revision: '',
status: '',
title: '',
manualFilename: null,
isDirty: false,
error: false,
validation: null,
autoPopulated: false
});
}
/**
* Save all modified files (only ZDDC-compliant ones)
*/
async function saveAllFiles() {
const files = window.app.modules.store.getDisplayFiles();
const modifiedFiles = files
.map((file, index) => ({ file, index }))
.filter(({ file }) => file.isDirty);
if (modifiedFiles.length === 0) {
alert('No modified files to save.');
return;
}
let successCount = 0;
let skippedCount = 0;
let errorCount = 0;
const errors = [];
const skipped = [];
for (let i = 0; i < modifiedFiles.length; i++) {
const { file, index } = modifiedFiles[i];
try {
// No inter-operation delay: saveFile() is fully awaited and
// each iteration renames a distinct file, so the saves are
// already serialized. (The old 200ms sleep papered over an
// earlier missing-await bug, since fixed.)
// Validate before saving
const newFilename = computeNewFilename(file, index);
const currentFilename = zddc.joinExtension(file.originalFilename, file.extension);
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
// Skip non-compliant files in Save All
skippedCount++;
skipped.push(`${zddc.joinExtension(file.originalFilename, file.extension)}: ${validation.errors[0]}`);
continue;
}
// Check if already has correct name
if (currentFilename === newFilename) {
// Just clear dirty flag
file.isDirty = false;
file.error = false;
delete file.manualFilename;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
successCount++;
continue;
}
// Save with validation already done - ensure properly awaited
try {
await saveFile(index, true);
successCount++;
} catch (saveErr) {
console.error(`Error saving file ${index}:`, saveErr);
errorCount++;
errors.push(`${zddc.joinExtension(file.originalFilename, file.extension)}: ${saveErr.message}`);
// No post-error delay: each file is independent, so an
// error on one doesn't require "settling" before the next.
}
} catch (err) {
console.error(`Error processing file ${index}:`, err);
errorCount++;
errors.push(`${file.originalFilename}${file.extension}: ${err.message}`);
}
}
// Trigger store notification to update UI after all saves
window.app.modules.store.notify('files');
let message = `Saved ${successCount} compliant file(s).`;
if (skippedCount > 0) {
message += `\n\n⚠️ Skipped ${skippedCount} non-compliant file(s):`;
message += `\n${skipped.slice(0, 3).join('\n')}`;
if (skipped.length > 3) {
message += `\n... and ${skipped.length - 3} more`;
}
message += `\n\nUse individual save buttons (✓) to save non-compliant files.`;
}
if (errorCount > 0) {
message += `\n\n${errorCount} error(s):`;
message += `\n${errors.slice(0, 3).join('\n')}`;
if (errors.length > 3) {
message += `\n... and ${errors.length - 3} more`;
}
}
alert(message);
}
/**
* Cancel all changes
*/
function cancelAllChanges() {
const files = window.app.modules.store.getDisplayFiles();
files.forEach((file, index) => {
if (file.isDirty) {
cancelFile(index);
}
});
}
/**
* Calculate SHA256 for all files
*/
async function calculateSha256ForAll() {
const files = window.app.modules.store.getDisplayFiles();
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.sha256) {
calculateSha256(file, i);
}
}
}
/**
* Calculate SHA256 for a single file
*/
async function calculateSha256(file, index) {
try {
let hashHex;
if (file.isVirtual) {
// Virtual file from ZIP
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
const buffer = await zipEntry.async('arraybuffer');
hashHex = await zddc.crypto.sha256Hex(buffer);
} else {
const fileObj = await file.handle.getFile();
hashHex = await zddc.crypto.sha256File(fileObj);
}
file.sha256 = hashHex;
// Update cell
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${index}"]`);
if (row) {
const sha256Cell = row.querySelector('.col-sha256');
if (sha256Cell) {
sha256Cell.textContent = hashHex.substring(0, 16) + '...';
sha256Cell.title = hashHex;
sha256Cell.classList.remove('sha256-calculating');
}
}
} catch (err) {
console.error('Error calculating SHA256:', err);
}
}
/**
* Highlight folder in tree when hovering over file
*/
function highlightFolder(folderPath) {
if (!folderPath) return;
// Find folder in tree
const folderTree = document.getElementById('folderTree');
if (!folderTree) return;
// Find the folder item by data-path attribute
const folderItem = folderTree.querySelector(`[data-path="${folderPath}"]`);
if (!folderItem) return;
// Add highlight class
folderItem.classList.add('folder-hover-highlight');
// Scroll into view if autoscroll is enabled
const autoScrollCheckbox = document.getElementById('autoScrollCheckbox');
if (autoScrollCheckbox && autoScrollCheckbox.checked) {
folderItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
/**
* Clear folder highlight
*/
function clearFolderHighlight() {
const folderTree = document.getElementById('folderTree');
if (!folderTree) return;
// Remove all highlights
const highlighted = folderTree.querySelectorAll('.folder-hover-highlight');
highlighted.forEach(el => el.classList.remove('folder-hover-highlight'));
}
/**
* Initialize spreadsheet - subscribe to store
*/
function init() {
// Subscribe to store changes (only call this after DOM is ready)
window.app.modules.store.on('files', render);
}
// Export module
window.app.modules.spreadsheet = {
init,
render,
computeNewFilename,
saveAllFiles,
cancelAllChanges,
cancelEditing
};
})();

451
classifier/js/store.js Normal file
View file

@ -0,0 +1,451 @@
/**
* Store Module
* Single source of truth for all application state
* Manages files, folders, sorting, filtering
*/
(function() {
'use strict';
// State
const state = {
// Directory structure
rootHandle: null,
folderTree: [],
selectedFolders: new Set(),
// Files
allFiles: [], // All files from selected folders
displayFiles: [], // After sorting and filtering
// Sort state
sortColumns: [], // [{column: 'original', direction: 'asc'}]
// Filter state
filters: {}, // {columnName: AST (from zddc.filter.parse)}
// UI state
hideCompliant: false,
calculateSha256: false
};
// Listeners for state changes
const listeners = {
'files': [],
'folders': [],
'sort': [],
'filter': []
};
/**
* Set sort columns
*/
function on(event, callback) {
if (listeners[event]) {
listeners[event].push(callback);
}
}
/**
* Notify listeners of state change
*/
function notify(event) {
if (listeners[event]) {
listeners[event].forEach(cb => cb());
}
}
/**
* Set root directory handle
*/
function setRootHandle(handle) {
state.rootHandle = handle;
}
/**
* Set folder tree
*/
function setFolderTree(tree) {
state.folderTree = tree;
notify('folders');
}
/**
* Select/deselect folder
*/
function toggleFolder(folderPath) {
if (state.selectedFolders.has(folderPath)) {
state.selectedFolders.delete(folderPath);
} else {
state.selectedFolders.add(folderPath);
}
loadFilesFromSelectedFolders();
}
/**
* Select multiple folders
*/
function setSelectedFolders(folderPaths) {
state.selectedFolders.clear();
folderPaths.forEach(path => state.selectedFolders.add(path));
loadFilesFromSelectedFolders();
}
/**
* Load files from selected folders
*/
function loadFilesFromSelectedFolders() {
state.allFiles = [];
if (state.selectedFolders.size === 0) {
updateDisplayFiles();
return;
}
// Collect files from selected folders
for (const folderPath of state.selectedFolders) {
const folder = findFolderByPath(folderPath);
if (folder && folder.files) {
const files = folder.files.filter(f => !f.isDirectory);
state.allFiles.push(...files);
}
}
// Apply default sort if no sort set
if (state.sortColumns.length === 0) {
state.sortColumns = [{ column: 'original', direction: 'asc' }];
}
updateDisplayFiles();
}
/**
* Find folder by path in tree
*/
function findFolderByPath(path) {
function search(folders) {
for (const folder of folders) {
if (folder.path === path) return folder;
if (folder.children) {
const found = search(folder.children);
if (found) return found;
}
}
return null;
}
return search(state.folderTree);
}
/**
* Update display files (apply sort, filter, hide compliant)
*/
function updateDisplayFiles() {
let files = [...state.allFiles];
// Apply filters
files = applyFilters(files);
// Apply hide compliant
if (state.hideCompliant) {
files = files.filter(file => {
const newFilename = computeNewFilename(file);
const validation = validateFilename(newFilename);
return !validation.isValid;
});
}
// Apply sort
files = applySort(files);
state.displayFiles = files;
notify('files');
}
/**
* Apply filters to files using zddc.filter ASTs
*/
function applyFilters(files) {
if (Object.keys(state.filters).length === 0) {
return files;
}
return files.filter(file => {
for (const [columnName, ast] of Object.entries(state.filters)) {
const value = getColumnValue(file, columnName);
if (!window.zddc.filter.matches(value, ast)) {
return false;
}
}
return true;
});
}
/**
* Apply sort to files
*/
function applySort(files) {
if (state.sortColumns.length === 0) {
return files;
}
return files.sort((a, b) => {
for (const sort of state.sortColumns) {
const result = compareValues(a, b, sort.column, sort.direction);
if (result !== 0) return result;
}
return 0;
});
}
/**
* Compare two values for sorting
*/
function compareValues(a, b, columnName, direction) {
let aVal = getColumnValue(a, columnName);
let bVal = getColumnValue(b, columnName);
const comparison = String(aVal).localeCompare(String(bVal), undefined, {
numeric: true,
sensitivity: 'base'
});
return direction === 'asc' ? comparison : -comparison;
}
/**
* Get column value from file (delegates to utils)
*/
function getColumnValue(file, columnName) {
return window.app.modules.utils.getColumnValue(file, columnName);
}
function computeNewFilename(file) {
return window.app.modules.utils.computeNewFilename(file);
}
/**
* Validate filename
*/
function validateFilename(filename) {
// Use existing validator module
if (window.app.modules.validator) {
return window.app.modules.validator.validateFilename(filename);
}
return { isValid: true, errors: [], warnings: [] };
}
/**
* Match filter text against value
*/
function matchesFilter(value, filterText) {
// Simple contains for now - can enhance later
return String(value).toLowerCase().includes(filterText.toLowerCase());
}
/**
* Set sort columns
*/
function setSortColumns(columns) {
state.sortColumns = columns;
updateDisplayFiles();
}
/**
* Toggle sort on column
*/
function toggleSort(columnName, multiSort) {
if (!multiSort) {
state.sortColumns = [];
}
const existingIndex = state.sortColumns.findIndex(s => s.column === columnName);
if (existingIndex >= 0) {
const current = state.sortColumns[existingIndex];
if (current.direction === 'asc') {
current.direction = 'desc';
} else {
state.sortColumns.splice(existingIndex, 1);
}
} else {
state.sortColumns.push({ column: columnName, direction: 'asc' });
}
updateDisplayFiles();
notify('sort');
}
/**
* Set filter for column. ast is the pre-parsed zddc.filter AST.
*/
function setFilter(columnName, filterText, ast) {
if (filterText && ast && ast.length > 0) {
state.filters[columnName] = ast;
} else {
delete state.filters[columnName];
}
updateDisplayFiles();
}
/**
* Replace all filters at once. filtersObj is {columnName: rawString}.
* Parses each value. Pass {} to clear all filters.
*/
function setAllFilters(filtersObj) {
state.filters = {};
for (const [columnName, raw] of Object.entries(filtersObj)) {
if (raw) {
const ast = window.zddc.filter.parse(raw);
if (ast && ast.length > 0) {
state.filters[columnName] = ast;
}
}
}
updateDisplayFiles();
}
/**
* Set hide compliant flag
*/
function setHideCompliant(hide) {
state.hideCompliant = hide;
updateDisplayFiles();
}
/**
* Update file data
*/
function updateFile(index, updates) {
const file = state.displayFiles[index];
if (!file) return;
// Apply updates
Object.assign(file, updates);
// Mark as dirty unless explicitly set to false
if (updates.isDirty !== false) {
file.isDirty = true;
}
// Notify listeners (will trigger re-render)
notify('files');
}
/**
* Update file field (for editing)
*/
function updateFileField(index, fieldName, value) {
const file = state.displayFiles[index];
if (!file) return;
file[fieldName] = value;
file.autoPopulated = false; // Clear auto-populated flag
// Re-evaluate dirty: if every field still matches the parsed original,
// and there is no manual filename override, the file is clean again.
file.isDirty = _isFileDirty(file);
// Notify listeners
notify('files');
}
/**
* A file is dirty if its computed filename differs from the original,
* or if it has a manual filename override.
*/
function _isFileDirty(file) {
if (file.manualFilename) return true;
const computed = zddc.formatFilename({
trackingNumber: file.trackingNumber || '',
revision: file.revision || '',
status: file.status || '',
title: file.title || '',
extension: file.extension || '',
});
const original = zddc.joinExtension(file.originalFilename, file.extension);
// If formatFilename returns '' (missing fields) fall back to original — not dirty
return computed !== '' && computed !== original;
}
/**
* Get display files (what should be shown in table)
*/
function getDisplayFiles() {
return state.displayFiles;
}
/**
* Get all files (unfiltered)
*/
function getAllFiles() {
return state.allFiles;
}
/**
* Get sort columns
*/
function getSortColumns() {
return state.sortColumns;
}
/**
* Get selected folder count
*/
function getSelectedFolderCount() {
return state.selectedFolders.size;
}
/**
* Get state (read-only)
*/
function getState() {
return {
rootHandle: state.rootHandle,
folderTree: state.folderTree,
selectedFolders: Array.from(state.selectedFolders),
allFiles: state.allFiles,
displayFiles: state.displayFiles,
sortColumns: state.sortColumns,
filters: state.filters,
hideCompliant: state.hideCompliant
};
}
/**
* Reset all state
*/
function reset() {
state.rootHandle = null;
state.folderTree = [];
state.selectedFolders.clear();
state.allFiles = [];
state.displayFiles = [];
state.sortColumns = [];
state.filters = {};
state.hideCompliant = false;
notify('files');
notify('folders');
}
// Export
window.app.modules.store = {
on,
notify,
setRootHandle,
setFolderTree,
toggleFolder,
setSelectedFolders,
toggleSort,
setFilter,
setAllFilters,
setHideCompliant,
updateFile,
updateFileField,
getDisplayFiles,
getAllFiles,
getSortColumns,
getSelectedFolderCount,
getState,
reset
};
})();

View file

@ -0,0 +1,838 @@
/**
* ZDDC Classifier target-tree pane (Classify & Copy mode).
*
* Renders the two orthogonal target trees the user maps files onto:
* - "By tracking number": folders that join with "-" into the tracking
* number; the leaf folder ("A (IFR)") is the revision+status.
* - "By transmittal": <party>/{received,issued}/<transmittal folder>.
*
* Structure here, placements in classify.js. Drag-and-drop assignment is wired
* in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and
* shows the derived filename for each placed file.
*/
(function () {
'use strict';
var SLOTS = ['received', 'issued'];
var els = {};
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
var openForm = null; // { partyId, slot } when a bin form is open
var initialized = false;
var currentTab = 'tracking'; // 'tracking' | 'transmittal' | 'mdl' — the active axis
var mdlTable = null; // the seltable controller for the By-MDL panel
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
function init() {
if (initialized) return;
initialized = true;
els = {
trackingTab: document.getElementById('trackingTab'),
transmittalTab: document.getElementById('transmittalTab'),
mdlTab: document.getElementById('mdlTab'),
trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'),
mdlPanel: document.getElementById('mdlPanel'),
trackingTree: document.getElementById('trackingTree'),
transmittalTree: document.getElementById('transmittalTree'),
mdlTree: document.getElementById('mdlTree'),
loadMdlBtn: document.getElementById('loadMdlBtn'),
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
addPartyBtn: document.getElementById('addPartyBtn'),
stats: document.getElementById('classifyStats'),
};
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
if (els.mdlTab) els.mdlTab.addEventListener('click', function () { showTab('mdl'); });
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
els.addTrackingRootBtn.addEventListener('click', function () {
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(null, name);
});
els.addPartyBtn.addEventListener('click', function () {
var name = prompt('Party name (also the transmittal-number prefix):', '');
if (name && name.trim()) C().addParty(name.trim());
});
els.trackingTree.addEventListener('click', onTrackingClick);
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.trackingTree.addEventListener('change', onFileNameChange);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupDropZone(els.trackingTree, 'tracking');
setupDropZone(els.transmittalTree, 'transmittal');
C().on(render);
if (window.app.modules.store && window.app.modules.store.on) {
window.app.modules.store.on('files', render);
}
render();
}
function C() { return window.app.modules.classify; }
// Every scanned source file (classify mode reads the left tree, not the
// selection-scoped grid). Lazy folders contribute their files once scanned.
function allFiles() {
var out = [];
(function walk(nodes) {
(nodes || []).forEach(function (n) {
(n.files || []).forEach(function (f) { out.push(f); });
walk(n.children);
});
})(window.app.folderTree || []);
return out;
}
// One pass: group files by the node they're placed in, per axis.
function buildPlaced(files) {
var c = C(), byT = {}, byX = {}, byM = {};
files.forEach(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
if (!a) return;
if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
if (a.mdlNodeId) (byM[a.mdlNodeId] = byM[a.mdlNodeId] || []).push(f);
});
return { tracking: byT, transmittal: byX, mdl: byM };
}
function showTab(which) {
currentTab = (which === 'transmittal' || which === 'mdl') ? which : 'tracking';
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
if (els.mdlTab) els.mdlTab.classList.toggle('active', currentTab === 'mdl');
els.trackingPanel.hidden = currentTab !== 'tracking';
els.transmittalPanel.hidden = currentTab !== 'transmittal';
if (els.mdlPanel) els.mdlPanel.hidden = currentTab !== 'mdl';
// The source-tree Show filters are per-axis, so the visible set changes
// with the active tab — re-render the left tree.
if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render();
}
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : currentTab === 'mdl' ? 'mdl' : 'tracking'; }
// Expand a brace pattern into folder names and create them (confirming a
// multi-create first). parentId null = root folders. See expandFolderPattern.
function addFoldersFromPattern(parentId, raw) {
if (!raw || !raw.trim()) return;
var names = C().expandFolderPattern(raw);
if (!names.length) return;
if (names.length > 1) {
var shown = names.slice(0, 8).join('\n');
if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more';
if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return;
}
// Each expanded name is parsed into nested tracking levels (split on
// "-", final "_" splits the leaf rev), reusing shared ancestors.
names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); });
}
// ── render ───────────────────────────────────────────────────────────────
function render() {
if (!initialized || !C().isEnabled()) return;
var files = allFiles();
var placed = buildPlaced(files);
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderMdlInto(placed.mdl);
renderStats(files);
}
function renderStats(files) {
var s = C().stats(files);
if (els.stats) {
els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · '
+ s.none + ' unassigned · ' + s.excluded + ' excluded';
}
var copyBtn = document.getElementById('copyOutputBtn');
if (copyBtn) {
copyBtn.disabled = s.done === 0;
copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…';
}
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function nodeActions(extra) {
var wrap = el('span', 'tnode__actions');
(extra || []).forEach(function (a) {
var b = el('button', 'tnode__act', a.label);
b.dataset.act = a.act;
b.title = a.title || '';
wrap.appendChild(b);
});
return wrap;
}
// Placed files inside a transmittal bin. Each row is draggable (drag onto
// another bin to MOVE it) and carries an ✕ to remove it from the transmittal.
function fileList(files) {
var box = el('div', 'tnode__files');
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.dataset.key = d.key;
row.draggable = true;
row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
orig.title = 'Drag to another transmittal to move · click to preview';
row.appendChild(orig);
row.appendChild(el('span', 'tfile__arrow', '→'));
// Editable derived filename — edit it to re-file the item.
var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
row.appendChild(name);
var rm = el('button', 'tnode__act tfile__remove', '✕');
rm.dataset.act = 'untransmit';
rm.title = 'Remove from this transmittal';
row.appendChild(rm);
box.appendChild(row);
});
return box;
}
// ── name filter (the autofilter box above the target trees) ────────────
var rfTerms = [];
function setNameFilter(q) {
rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
render();
}
function rfActive() { return rfTerms.length > 0; }
function rfHit(text) {
if (!rfTerms.length) return true;
var t = String(text || '').toLowerCase();
for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; }
return true;
}
// A placed-file row matches on its original name or its derived ZDDC name.
function fileRowMatches(f) {
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
}
// ── By-tracking: merged-cell table ──────────────────────────────────────
// The positional hierarchy reads left-to-right as columns (one per configured
// field), ancestor cells span their descendants' rows, and the revision (the
// leaf) gets its own aligned column. Each placed file is a row.
// A node is a revision leaf when its name ends in a "(STATUS)" we recognise —
// tracking field codes never carry a parenthesised status, so this cleanly
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
function revStatusOf(name) {
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
}
function isRevisionLeaf(node) {
return !(node.children || []).length && revStatusOf(node.name) != null;
}
// Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }.
function buildTrackingRows(nodes, placedMap) {
var rows = [];
function emit(path, rev, files) {
var fs = (files && files.length) ? files : [null];
fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); });
}
function walk(node, ancestors) {
var placed = placedMap[node.id] || [];
if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; }
var myPath = ancestors.concat(node); // node is a tracking field segment
if (placed.length) emit(myPath, null, placed); // files dropped on a partial number
var kids = node.children || [];
if (kids.length) kids.forEach(function (c) { walk(c, myPath); });
else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target
}
nodes.forEach(function (n) { walk(n, []); });
return rows;
}
function rowMatches(row) {
if (!rfActive()) return true;
if (row.file && fileRowMatches(row.file)) return true;
if (row.rev && rfHit(row.rev.name)) return true;
for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; }
return false;
}
function fieldCellContent(node) {
var inner = el('div', 'tcell__inner');
inner.appendChild(el('span', 'tcell__name', node.name));
inner.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child segment / revision' },
{ act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
function revCellContent(node, placedMap) {
var inner = el('div', 'tcell__inner trev__inner');
// The revision name doubles as a preview link for its placed file (the
// common case is one file per revision). No count bubble.
var files = placedMap[node.id] || [];
if (files.length) {
var link = el('a', 'tcell__name tcell__preview', node.name);
link.href = '#';
link.dataset.previewKey = C().srcKeyForFile(files[0]);
link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : '');
inner.appendChild(link);
} else {
inner.appendChild(el('span', 'tcell__name', node.name));
}
inner.appendChild(nodeActions([
{ act: 'rename', label: '✎', title: 'Rename revision' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
// A placed-file cell: editable ZDDC name + validation badge; the original
// filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the
// delegated preview + name-edit handlers apply.
function fileCellContent(f) {
var d = C().deriveTarget(f);
var conflict = C().hasHashConflict(d.key); // same name, different bytes
var bad = d.errors.length || conflict;
var row = el('div', 'tfile' + (bad ? ' tfile--err' : ''));
row.dataset.key = d.key;
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '')
+ (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
row.appendChild(name);
row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'),
conflict ? '≠' : (d.errors.length ? '⚠' : '✓')));
return row;
}
function renderTrackingInto(container, nodes, placedMap) {
container.textContent = '';
if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
return;
}
var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches);
if (!rows.length) {
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.'));
return;
}
var fields = C().getTrackingFields();
var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0);
var nCols = Math.max(fields.length, maxPath);
function cellId(row, col) {
if (col < nCols) { var n = row.path[col]; return n ? n.id : null; }
return row.rev ? row.rev.id : null; // col === nCols → revision
}
// Rowspan run starting at row i for column col (0 = covered from above).
function spanAt(col, i) {
var id = cellId(rows[i], col);
if (id == null) return 1;
if (i > 0 && cellId(rows[i - 1], col) === id) return 0;
var span = 1;
for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; }
return span;
}
var table = el('table', 'ttable');
var thead = el('thead'), htr = el('tr');
for (var c = 0; c < nCols; c++) {
htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·'));
}
htr.appendChild(el('th', 'ttable__rh', 'REVISION'));
htr.appendChild(el('th', 'ttable__fileh', 'Files'));
thead.appendChild(htr); table.appendChild(thead);
var tbody = el('tbody');
rows.forEach(function (row, i) {
var tr = el('tr');
for (var col = 0; col < nCols; col++) {
var span = spanAt(col, i);
if (span === 0) continue; // merged from the row above
var node = row.path[col] || null;
var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty'));
if (span > 1) td.rowSpan = span;
if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); }
tr.appendChild(td);
}
var rspan = spanAt(nCols, i);
if (rspan !== 0) {
var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty'));
if (rspan > 1) rtd.rowSpan = rspan;
if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); }
tr.appendChild(rtd);
}
var ftd = el('td', 'ttable__file');
if (row.file) ftd.appendChild(fileCellContent(row.file));
else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here'));
tr.appendChild(ftd);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
}
// Transmittal tree
function renderTransmittalInto(container, parties, placedMap) {
container.textContent = '';
if (!parties.length) {
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
return;
}
parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); });
if (rfActive() && !container.children.length) {
container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.'));
}
}
function partyNode(party, placedMap) {
var partyMatch = rfHit(party.name);
var slotEls = [], anyBin = false;
SLOTS.forEach(function (slot) {
var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
var sw = el('div', 'tslot');
sw.dataset.party = party.id;
sw.dataset.slot = slot;
var sr = el('div', 'tslot__row');
sr.appendChild(el('span', 'tslot__name', slot));
var addBtn = el('button', 'tnode__act', '+ Transmittal');
addBtn.dataset.act = 'addbin';
sr.appendChild(addBtn);
sw.appendChild(sr);
if (openForm && openForm.partyId === party.id && openForm.slot === slot) {
sw.appendChild(binForm(party.id, slot));
}
(slotNode ? slotNode.children : []).forEach(function (bin) {
var be = binNode(bin, placedMap, partyMatch);
if (be) { sw.appendChild(be); anyBin = true; }
});
slotEls.push(sw);
});
if (rfActive() && !partyMatch && !anyBin) return null;
var wrap = el('div', 'tnode tnode--party');
wrap.dataset.id = party.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__icon', '🏢'));
row.appendChild(el('span', 'tnode__name', party.name));
row.appendChild(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' },
]));
wrap.appendChild(row);
slotEls.forEach(function (sw) { wrap.appendChild(sw); });
return wrap;
}
function binNode(bin, placedMap, ancMatched) {
var matched = ancMatched || rfHit(bin.name || '');
var placed = placedMap[bin.id] || [];
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
if (rfActive() && !matched && !shownFiles.length) return null;
var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([
{ act: 'rename-bin', label: '✎', title: 'Rename transmittal' },
{ act: 'del', label: '🗑', title: 'Delete transmittal' },
]));
wrap.appendChild(row);
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap;
}
var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD'];
function binForm(partyId, slot) {
var form = el('div', 'binform');
form.dataset.party = partyId;
form.dataset.slot = slot;
var date = el('input', 'binform__date'); date.type = 'date';
try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ }
var type = document.createElement('select'); type.className = 'binform__type';
['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); });
var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)';
var status = document.createElement('select'); status.className = 'binform__status';
STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); });
var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)';
var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd';
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel';
[date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); });
return form;
}
// ── By MDL (deliverables as drop targets via the shared seltable) ───────
function renderMdlInto(placedMdl) {
mdlPlaced = placedMdl || {};
if (!C().getMdlList().length) {
mdlTable = null;
els.mdlTree.textContent = '';
els.mdlTree.appendChild(el('div', 'target-empty', 'No MDL loaded — “Load MDL…” to bring a projects deliverables in as drop targets.'));
return;
}
ensureMdlTable();
mdlTable.renderBody();
}
function ensureMdlTable() {
if (mdlTable) return mdlTable;
var c = C();
mdlTable = window.app.modules.seltable.create({
container: els.mdlTree,
filterPlaceholder: 'Filter deliverables by tracking number, title, party…',
extraTitle: 'Files',
rows: function () { return c.getMdlList(); },
rowId: function (r) { return r.id; },
columns: [
{ key: 'tracking', title: 'Tracking number', get: function (r) { return r.trackingNumber; } },
{ key: 'title', title: 'Title', get: function (r) { return r.title; } },
{ key: 'party', title: 'Party', get: function (r) { return r.party; } },
{
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
render: function (r, td) {
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'mdl-rev__input'; inp.value = r.revisionCell || '';
inp.placeholder = 'A (IFR)'; inp.setAttribute('data-no-select', '');
inp.addEventListener('change', function () { c.setRevisionCell(r.id, inp.value.trim()); });
td.appendChild(inp);
},
},
],
onRowDrop: function (rowId, keys) { c.place(keys, rowId, 'mdl'); },
onActivate: function (ids) {
if (!ids.length) return;
var v = prompt('Set the revision on ' + ids.length + ' selected deliverable(s) (e.g. "A (IFR)"):', '');
if (v != null) c.setRevisionCells(ids, v.trim());
},
rowExtra: function (r, td) { renderMdlPlaced(r, td); },
});
mdlTable.render();
return mdlTable;
}
function renderMdlPlaced(row, td) {
var c = C(), files = mdlPlaced[row.id] || [];
files.forEach(function (f) {
var d = c.deriveTarget(f);
var a = c.getAssignment(d.key) || {};
var line = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
line.dataset.key = d.key; line.draggable = true;
line.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
var nm = el('span', 'mdlfile__name', d.filename || '(set a revision)');
nm.title = 'from ' + f.originalFilename + (f.extension ? '.' + f.extension : '');
line.appendChild(nm);
var tgl = el('button', 'tnode__act', a.titleFromDeliverable === false ? 'Title: file' : 'Title: MDL');
tgl.title = 'Use the deliverables title or the files own';
tgl.addEventListener('click', function () { c.setTitleFromDeliverable(d.key, a.titleFromDeliverable === false); });
line.appendChild(tgl);
var rm = el('button', 'tnode__act tfile__remove', '✕');
rm.title = 'Remove from this deliverable';
rm.addEventListener('click', function () { c.place([d.key], null, 'mdl'); });
line.appendChild(rm);
td.appendChild(line);
});
}
// Load deliverables into the MDL table — a local folder of .yaml or a server
// project (one or more parties). Each yaml's filename stem is the tracking
// number; the revision cell starts blank (classifier-local).
function yamlToRow(party, stem, obj) {
return {
id: party + '|' + stem,
party: party,
trackingNumber: stem,
title: (obj && obj.title) || '',
revisionCell: '',
};
}
async function loadMdl() {
if (window.zddc && window.zddc.source && location.protocol !== 'file:') {
return loadMdlServer();
}
return loadMdlLocal();
}
async function loadMdlLocal() {
if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local MDL needs the File System Access API (Chromium).', 'error'); return; }
var dir;
try { dir = await window.showDirectoryPicker({ mode: 'read' }); }
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return; }
var rows = [];
try {
for await (var entry of dir.values()) {
if (entry.kind !== 'file' || !/\.yaml$/i.test(entry.name) || entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
var txt = await (await entry.getFile()).text();
var obj = null; try { obj = window.jsyaml.load(txt); } catch (_) { /* skip bad yaml */ }
rows.push(yamlToRow(dir.name || 'local', entry.name.replace(/\.yaml$/i, ''), obj));
}
} catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; }
finishLoad(rows);
}
async function loadMdlServer() {
var copy = window.app.modules.copy, src = window.zddc.source;
var projects = await copy.fetchAccessProjects();
if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return; }
if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return; }
var proj = await copy.chooseProject(projects);
if (!proj) return;
var rel = (proj.url || ('/' + proj.name + '/')); if (rel.charAt(rel.length - 1) !== '/') rel += '/';
var archive = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
var rows = [];
try {
for await (var party of archive.values()) {
if (party.kind !== 'directory') continue;
var pn = String(party.name).replace(/\/$/, '');
if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue;
var mdlDir;
try { mdlDir = await party.getDirectoryHandle('mdl'); } catch (e) { continue; } // no mdl for this party
for await (var entry of mdlDir.values()) {
var nm = String(entry.name).replace(/\/$/, '');
if (entry.kind !== 'file' || !/\.yaml$/i.test(nm) || nm === 'table.yaml' || nm === 'form.yaml') continue;
var obj = null;
try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ }
rows.push(yamlToRow(pn, nm.replace(/\.yaml$/i, ''), obj));
}
}
} catch (e) { window.zddc.toast('Reading the project MDL failed — ' + (e.message || e), 'error'); return; }
finishLoad(rows);
}
function finishLoad(rows) {
C().setMdlList(rows);
showTab('mdl');
window.zddc.toast(rows.length ? ('Loaded ' + rows.length + ' deliverable' + (rows.length === 1 ? '' : 's') + ' — set revisions and drag files on.') : 'No deliverables found.', rows.length ? 'success' : 'warning');
}
// ── events ─────────────────────────────────────────────────────────────
function closestNodeId(target) {
var n = target.closest('[data-id]');
return n ? n.dataset.id : null;
}
function fileByKey(key) {
var files = allFiles();
for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; }
return null;
}
// Click a placed-file row (anywhere but its editable name) → preview it.
function previewFromTarget(e) {
// Preview link on a revision cell (its placed file).
var pl = e.target.closest('[data-preview-key]');
if (pl) {
e.preventDefault();
var pf = fileByKey(pl.dataset.previewKey);
if (pf && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(pf);
}
return true;
}
if (e.target.closest('[data-act]')) return false; // action button — not a preview
if (e.target.closest('.tfile__name')) return false;
var tf = e.target.closest('.tfile');
if (!tf || !tf.dataset.key) return false;
var f = fileByKey(tf.dataset.key);
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(f);
}
return true;
}
// Edited a placed-file's ZDDC filename → re-derive its tracking placement
// (creating the folder path if needed) + its title override.
function onFileNameChange(e) {
var input = e.target.closest('.tfile__name');
if (input) commitFilenameEdit(input);
}
function commitFilenameEdit(input) {
var tf = input.closest('.tfile');
if (!tf || !tf.dataset.key) return;
var parsed = window.zddc.parseFilename((input.value || '').trim());
if (!parsed || !parsed.valid) {
window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning');
render(); // restore the derived value
return;
}
var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')';
var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem));
C().place([tf.dataset.key], leaf, 'tracking');
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
// place/setTitleOverride fire classify.notify → re-render.
}
// Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle).
function setSubtreeCollapsed(nodeId, collapse) {
var node = C().getNode(nodeId);
if (!node) return;
(function walk(n) {
if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; }
(n.children || []).forEach(walk);
})(node);
}
function onTrackingClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
var id = closestNodeId(btn);
if (act === 'toggle') {
var collapse = !collapsed[id];
if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse);
else if (collapse) collapsed[id] = true; else delete collapsed[id];
render();
return;
}
if (act === 'add') {
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
+ 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(id, name);
} else if (act === 'rename') {
var node = C().getNode(id);
var nn = prompt('Rename folder:', node ? node.name : '');
if (nn && nn.trim()) C().renameNode(id, nn.trim());
} else if (act === 'del') {
if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id);
}
}
function onTransmittalClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
if (act === 'addbin') {
var slotEl = btn.closest('.tslot');
openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot };
render();
return;
}
if (act === 'untransmit') {
var tf = btn.closest('.tfile');
if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal');
return;
}
if (act === 'rename-bin') {
var bid = closestNodeId(btn);
var bn = C().getNode(bid);
var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : '');
if (nn && nn.trim()) C().renameNode(bid, nn.trim());
return;
}
if (act === 'bincancel') { openForm = null; render(); return; }
if (act === 'binadd') {
var form = btn.closest('.binform');
var meta = {
date: form.querySelector('.binform__date').value,
type: form.querySelector('.binform__type').value,
seq: form.querySelector('.binform__seq').value.trim(),
status: form.querySelector('.binform__status').value,
title: form.querySelector('.binform__title').value.trim(),
};
if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; }
C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta);
openForm = null; // render() fires from classify.notify()
return;
}
var id = closestNodeId(btn);
if (act === 'rename-party') {
var node = C().getNode(id);
var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : '');
if (nn && nn.trim()) C().renameNode(id, nn.trim());
} else if (act === 'del-party') {
if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id);
} else if (act === 'del') {
if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id);
}
}
// ── drop targets ───────────────────────────────────────────────────────
// Resolve the drop target under an event:
// tracking → any folder node (.tnode)
// transmittal → a transmittal bin only (.tnode--bin)
function dropTarget(target, axis) {
if (axis === 'transmittal') {
var bin = target.closest('.tnode--bin');
if (!bin || !bin.dataset.id) return null;
return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin };
}
var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]');
if (!cell) return null;
return { id: cell.dataset.id, row: cell };
}
function clearHover(container) {
var hot = container.querySelectorAll('.drop-hover');
for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover');
}
function setupDropZone(container, axis) {
container.addEventListener('dragover', function (e) {
if (!window.app.modules.dnd.active()) return;
var t = dropTarget(e.target, axis);
clearHover(container);
if (!t) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
t.row.classList.add('drop-hover');
});
container.addEventListener('dragleave', function (e) {
if (e.target === container) clearHover(container);
});
container.addEventListener('drop', function (e) {
var t = dropTarget(e.target, axis);
clearHover(container);
if (!t) return;
e.preventDefault();
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (!keys.length) return;
if (axis === 'tracking') placeTrackingDrop(keys, t.id);
else C().place(keys, t.id, axis);
});
}
// Tracking drop: if the target is already a complete leaf, assign directly;
// otherwise prompt for the remaining levels (parsed + nested under it) so a
// file can be dropped on an existing partial tracking number and completed.
function placeTrackingDrop(keys, nodeId) {
if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; }
var label = C().trackingPathLabel(nodeId);
var input = prompt('Dropping under "' + label + '".\n'
+ 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', '');
if (input === null) return; // cancelled
var levels = C().parseFolderLevels(input.trim());
var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId;
C().place(keys, target, 'tracking');
}
// Reveal a source key's placement in the target pane (source → target).
function reveal(key) {
var a = C().getAssignment(key);
if (!a) return;
if (a.mdlNodeId) {
showTab('mdl');
if (mdlTable) { mdlTable.setFilter(''); mdlTable.renderBody(); }
} else if (a.trackingNodeId) {
showTab('tracking'); collapsed = {}; render();
flashNode(els.trackingTree, a.trackingNodeId);
} else if (a.transmittalNodeId) {
showTab('transmittal'); render();
flashNode(els.transmittalTree, a.transmittalNodeId);
}
}
function flashNode(container, id) {
var node = container.querySelector('[data-id="' + id + '"]');
if (!node) return;
node.scrollIntoView({ block: 'center' });
var row = node.querySelector('.tnode__row') || node;
row.classList.add('reveal-flash');
setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500);
}
window.app.modules.targetTree = {
init: init,
render: render,
showTab: showTab,
activeAxis: activeAxis,
setNameFilter: setNameFilter,
reveal: reveal,
};
})();

923
classifier/js/tree.js Normal file
View file

@ -0,0 +1,923 @@
/**
* Folder Tree Module
* Handles folder tree rendering and multi-select
*/
(function() {
'use strict';
// ── Classify & Copy helpers ────────────────────────────────────────────
function classifyOn() {
var c = window.app.modules.classify;
return c && c.isEnabled();
}
// All file objects in a folder's (already-scanned) subtree — group-drag.
function subtreeFiles(folder, out) {
out = out || [];
(folder.files || []).forEach(function (f) { out.push(f); });
(folder.children || []).forEach(function (c) { subtreeFiles(c, out); });
return out;
}
function keysFor(files) {
var c = window.app.modules.classify;
return files.map(function (f) { return c.srcKeyForFile(f); });
}
// A small status dot reflecting a file's classification state.
var STATE_TITLE = {
none: 'unassigned', tracking: 'has tracking number, needs a transmittal',
transmittal: 'in a transmittal, needs a tracking number',
partial: 'placed, but the name is incomplete', done: 'fully classified',
excluded: 'excluded — will not be copied',
};
function stateDot(state) {
var dot = document.createElement('span');
dot.className = 'cl-dot cl-dot--' + state;
dot.title = STATE_TITLE[state] || '';
return dot;
}
// ── Classify-mode source-tree filters ──────────────────────────────────
// The goal in either target tab is to assign-or-exclude every file. Each
// file falls in one bucket FOR THE ACTIVE AXIS — unassigned / assigned /
// excluded — and three "Show …" toggles control which buckets are visible
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder
// whose whole scanned subtree is filtered away is itself hidden.
var showFilters = { unassigned: true, partial: true, assigned: true, excluded: true };
var showEmpty = true; // show folders that contain no files
function setShowFilters(f) {
showFilters = {
unassigned: f.unassigned !== false,
partial: f.partial !== false,
assigned: f.assigned !== false,
excluded: f.excluded !== false,
};
showEmpty = f.empty !== false;
render();
}
function allFiltersOn() { return showFilters.unassigned && showFilters.partial && showFilters.assigned && showFilters.excluded; }
function activeAxis() {
var tt = window.app.modules.targetTree;
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
}
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : ax === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; }
// Bucket a file relative to the active axis (tracking | transmittal | mdl):
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
// axis only — the to-do for this tab) | 'unassigned' (no axis).
function fileCategory(file) {
var c = window.app.modules.classify;
var a = c.getAssignment(c.srcKeyForFile(file));
if (a && a.excluded) return 'excluded';
var ax = activeAxis();
if (a && a[axisField(ax)]) return 'assigned';
var others = ['tracking', 'transmittal', 'mdl'].filter(function (x) { return x !== ax; });
var any = a && others.some(function (x) { return a[axisField(x)]; });
return any ? 'partial' : 'unassigned';
}
function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; }
// ── name filter (the autofilter box above the tree) ────────────────────
// Live substring search over each file's full path+name (and folder names),
// ANDing space-separated terms. Matches reveal their whole folder hierarchy.
var nameFilter = '', filterTerms = [];
function setNameFilter(q) {
nameFilter = (q || '').trim();
filterTerms = nameFilter.toLowerCase().split(/\s+/).filter(Boolean);
render();
}
function filterActive() { return filterTerms.length > 0; }
function nameHit(text) {
if (!filterTerms.length) return true;
var t = String(text || '').toLowerCase();
for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; }
return true;
}
// Anything narrowing the tree (a name search, a show-filter off, or hiding empties).
function anyFilter() { return filterActive() || (classifyOn() && (!allFiltersOn() || !showEmpty)); }
// One pass → the set of folder paths + file keys to render. A file shows when
// it passes the show-filters AND (no name search, OR an ancestor folder
// matched, OR its own path/name matches). A folder shows when it (or an
// ancestor) matches, or anything inside it shows — so the path to a hit is
// always revealed.
var visible = null; // { folders, files } while filtering, else null
function computeVisible() {
var c = window.app.modules.classify;
var folders = Object.create(null), files = Object.create(null), open = Object.create(null);
var nf = filterActive();
function walk(folder, ancMatched) {
var selfMatch = nf && nameHit(folder.path || folder.name);
var matched = ancMatched || selfMatch;
var show = false, hasFile = false, descMatch = false;
(folder.children || []).forEach(function (ch) {
var r = walk(ch, matched);
if (r.show) show = true;
if (r.hasFile) hasFile = true;
if (r.subtreeMatch) descMatch = true; // a child leads to a match
});
(folder.files || []).forEach(function (f) {
hasFile = true;
if (!classifyAllows(f)) return;
var fileMatch = nf && nameHit(c.srcKeyForFile(f));
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; }
if (fileMatch) descMatch = true; // a match sits directly in this folder
});
if (matched) show = true;
// "Show Empty" off → hide folders whose whole subtree holds no files.
if (!hasFile && !showEmpty && !matched) show = false;
if (show) folders[folder.path] = true;
// Auto-open ONLY the connector folders on the path down to a match —
// never the matched node itself. Terminal matches and everything
// off-path keep their real collapse state; the root's expand-all
// covers the rest. (Search reveals where hits are; it doesn't reshape
// the tree.)
if (nf && descMatch) open[folder.path] = true;
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch };
}
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
return { folders: folders, files: files, open: open };
}
// True only for folders the search needs opened to expose a hit beneath them.
function autoOpen(folder) { return !!(visible && visible.open && visible.open[folder.path]); }
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
function fileShown(file) {
if (!classifyAllows(file)) return false;
return !visible || !!visible.files[window.app.modules.classify.srcKeyForFile(file)];
}
// All scanned files (for the per-bucket counts on the filter checkboxes).
function allClassifyFiles() {
var out = [];
(window.app.folderTree || []).forEach(function (f) { subtreeFiles(f, out); });
return out;
}
function updateFilterCounts() {
if (!classifyOn()) return;
var n = { unassigned: 0, partial: 0, assigned: 0, excluded: 0 };
allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; });
['unassigned', 'partial', 'assigned', 'excluded'].forEach(function (k) {
var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count');
if (el) el.textContent = '(' + n[k] + ')';
});
}
/**
* Render the folder tree
*/
function render() {
const container = window.app.dom.folderTree;
// Preserve scroll across re-render — toggling a Show filter shouldn't
// jump the view back to the top.
const prevScroll = container.scrollTop;
wireClassifyInteractions();
container.innerHTML = '';
updateFilterCounts();
visible = anyFilter() ? computeVisible() : null;
if (window.app.folderTree.length === 0) {
container.innerHTML = '<div class="tree-empty">No folders found</div>';
return;
}
window.app.folderTree.forEach(folder => {
if (!folderShown(folder)) return;
const element = createFolderElement(folder);
container.appendChild(element);
});
if (!container.children.length) {
container.innerHTML = '<div class="tree-empty">'
+ (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '</div>';
}
updateSelectedCount();
container.scrollTop = prevScroll;
}
/**
* Populate a folder row's count element with "direct+total" counts, e.g.
* "(2+10 folders, 15+300 files)" direct (immediate children) shows as
* soon as the folder's own directory is read; the total (whole subtree)
* grows and flashes grey until the subtree is fully scanned, then goes
* solid. The "+total" part is omitted once scanning is done and there's
* nothing deeper (direct == total).
*/
function populateCount(el, folder) {
el.textContent = '';
el.classList.remove('done');
const st = folder.scanState;
if (st === 'pending') return;
if (st === 'zip-pending') { el.textContent = '(zip — open to scan)'; return; }
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
const done = st === 'done';
// When fully scanned both numbers are blue; .done turns the labels blue too.
if (done) el.classList.add('done');
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('('));
if (dDir > 0 || tDir > 0) {
appendPair(frag, dDir, tDir, done);
appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, ');
}
appendPair(frag, dFile, tFile, done);
appendLabel(frag, tFile === 1 ? ' file)' : ' files)');
el.appendChild(frag);
}
// The "folders"/"files" word labels — blue only once the row is .done.
function appendLabel(frag, text) {
const s = document.createElement('span');
s.className = 'ct-label';
s.textContent = text;
frag.appendChild(s);
}
// Append "<direct>" (always a completed/blue number) and, when there's a
// subtree (or scanning is ongoing), "+<total>" with the total in a span
// that greys + pulses until final, then turns blue.
function appendPair(frag, direct, total, done) {
const d = document.createElement('span');
d.className = 'ct-direct';
d.textContent = String(direct);
frag.appendChild(d);
if (!done || total > direct) {
frag.appendChild(document.createTextNode('+'));
const t = document.createElement('span');
t.className = 'ct-total' + (done ? '' : ' pending');
t.textContent = String(total);
frag.appendChild(t);
}
}
/**
* Create a folder element
*/
function createFolderElement(folder, level = 0) {
const div = document.createElement('div');
const item = document.createElement('div');
item.className = 'folder-item';
// Grey the row until its subtree is fully scanned (scanState 'done');
// 'scanning' rows also get a subtle pulse via CSS.
if (folder.scanState && folder.scanState !== 'done') {
item.classList.add('scanning');
}
item.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`;
// Check if selected
if (window.app.selectedFolders.has(folder.path)) {
item.classList.add('selected');
}
// Classify mode: the folder row is a drag source for a group-drag of
// every file in its subtree.
if (classifyOn()) {
item.draggable = true;
item.addEventListener('dragstart', function (e) {
e.stopPropagation();
var files = subtreeFiles(folder);
if (!files.length) { e.preventDefault(); return; }
window.app.modules.dnd.setDrag(keysFor(files), e);
});
}
// Toggle button: shown when the folder has children OR hasn't been
// scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span');
toggle.className = 'folder-toggle';
const mightHaveChildren = (folder.children && folder.children.length > 0)
|| folder.scanState === 'pending'
|| folder.scanState === 'zip-pending'
// Classify mode: a folder with files (even none of subfolders) is
// expandable so its files can be revealed and dragged.
|| (classifyOn() && folder.files && folder.files.length > 0);
if (mightHaveChildren) {
toggle.textContent = (folder.expanded || autoOpen(folder)) ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const recursive = e.ctrlKey || e.metaKey;
toggleFolder(folder, recursive);
});
} else {
toggle.textContent = ' ';
}
item.appendChild(toggle);
// Folder icon (different for ZIP files)
const icon = document.createElement('span');
icon.className = 'folder-icon';
if (folder.isZipRoot) {
icon.innerHTML = '&#128230;'; // 📦
} else if (folder.isVirtualDir) {
icon.innerHTML = '&#128194;'; // 📂
} else {
icon.innerHTML = '&#128193;'; // 📁
}
item.appendChild(icon);
// Classify mode: an aggregate state dot for the folder's subtree, and a
// struck-through name when the WHOLE subtree is excluded (mirrors files).
if (classifyOn()) {
const agg = aggregateState(subtreeFiles(folder));
if (agg) item.appendChild(stateDot(agg));
if (agg === 'excluded') item.classList.add('excluded');
}
// Folder name
const name = document.createElement('span');
name.className = 'folder-name';
name.textContent = folder.name;
item.appendChild(name);
// Subfolder / file counts (immediate). Greyed via the row's .scanning
// class until the subtree is fully scanned.
const count = document.createElement('span');
count.className = 'folder-count';
populateCount(count, folder);
item.appendChild(count);
// Extract button for ZIP roots
if (folder.isZipRoot) {
const extractBtn = document.createElement('button');
extractBtn.className = 'btn btn-sm zip-extract-btn';
extractBtn.textContent = '📤 Extract';
extractBtn.title = 'Extract ZIP contents to folder';
extractBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await handleExtractZip(folder);
});
item.appendChild(extractBtn);
}
// Click handler for selection
item.addEventListener('click', (e) => {
handleFolderClick(folder, e);
});
div.appendChild(item);
// Children — when expanded, or opened on the path to a search hit below.
// The Show toggles never force-expand; search opens only connector folders.
if ((folder.expanded || autoOpen(folder)) && folder.children && folder.children.length > 0) {
const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children';
folder.children.forEach(child => {
if (!folderShown(child)) return;
const childElement = createFolderElement(child, level + 1);
childrenDiv.appendChild(childElement);
});
div.appendChild(childrenDiv);
}
// Classify mode: list this folder's own files (draggable leaves) when
// expanded (or opened to reveal a search hit), so they can be dropped.
if (classifyOn() && (folder.expanded || autoOpen(folder)) && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) {
if (!fileShown(file)) return;
filesDiv.appendChild(createFileElement(file, level + 1));
});
div.appendChild(filesDiv);
}
return div;
}
/**
* Create a draggable source-file row (classify mode only).
*/
function createFileElement(file, level) {
const c = window.app.modules.classify;
const item = document.createElement('div');
item.className = 'file-item';
item.style.paddingLeft = `${level * 1.5}rem`;
item.draggable = true;
item.title = 'Click to preview · drag onto a tracking folder or transmittal to assign';
const key = c.srcKeyForFile(file);
item.dataset.key = key;
const st = c.fileState(file);
if (st === 'excluded') item.classList.add('excluded');
item.appendChild(stateDot(st));
const icon = document.createElement('span');
icon.className = 'file-icon';
icon.innerHTML = '&#128196;'; // 📄
item.appendChild(icon);
const name = document.createElement('span');
name.className = 'file-name';
name.textContent = zddc.joinExtension(file.originalFilename, file.extension);
item.appendChild(name);
item.addEventListener('dragstart', function (e) {
e.stopPropagation();
window.app.modules.dnd.setDrag([key], e);
});
return item;
}
/**
* Handle folder click with multi-select support
*/
function handleFolderClick(folder, event) {
if (event.ctrlKey || event.metaKey) {
// Ctrl+Click: Toggle selection
if (window.app.selectedFolders.has(folder.path)) {
window.app.selectedFolders.delete(folder.path);
} else {
window.app.selectedFolders.add(folder.path);
}
} else if (event.shiftKey) {
// Shift+Click: Range selection
const visibleFolders = getVisibleFolders();
const currentIndex = visibleFolders.findIndex(f => f.path === folder.path);
if (currentIndex >= 0 && window.app.lastSelectedFolderPath) {
const lastIndex = visibleFolders.findIndex(f => f.path === window.app.lastSelectedFolderPath);
if (lastIndex >= 0) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
// Select range
for (let i = start; i <= end; i++) {
window.app.selectedFolders.add(visibleFolders[i].path);
}
}
} else {
window.app.selectedFolders.add(folder.path);
}
} else {
// Normal click: Single selection
window.app.selectedFolders.clear();
window.app.selectedFolders.add(folder.path);
}
// Remember last selected for shift-click
window.app.lastSelectedFolderPath = folder.path;
// Re-render tree
render();
// Load files from selected folders
loadFilesFromSelectedFolders();
}
/**
* Handle ZIP extraction
*/
async function handleExtractZip(folder) {
if (!folder.isZipRoot || !folder.zipPath) return;
try {
const confirmed = confirm(`Extract "${folder.name}" to a new folder?\n\nThis will create a folder named "${folder.name.replace(/\.zip$/i, '')}" with the ZIP contents.`);
if (!confirmed) return;
// Show extracting state
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
if (btn) {
btn.textContent = '⏳ Extracting...';
btn.disabled = true;
}
await window.app.modules.scanner.extractZip(folder.zipPath);
// Auto-refresh preserving tree state
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
} catch (err) {
console.error('Error extracting ZIP:', err);
alert('Error extracting ZIP: ' + err.message);
// Reset button
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
if (btn) {
btn.textContent = '📤 Extract';
btn.disabled = false;
}
}
}
/**
* Toggle folder expansion
*/
function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded;
if (recursive && folder.children) {
// Recursively expand/collapse all children
const newState = folder.expanded;
function setAllExpanded(f) {
f.expanded = newState;
if (f.children) {
f.children.forEach(setAllExpanded);
}
}
folder.children.forEach(setAllExpanded);
}
render();
// Opening a not-yet-complete folder jumps its subtree to the front of
// the scan so its contents are complete on open (re-renders as it
// fills in). Background scanning continues for everything else.
if (folder.expanded && folder.scanState !== 'done'
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
}
}
/**
* Load files from all selected folders
*/
async function loadFilesFromSelectedFolders() {
// Use store to manage files
window.app.modules.store.setSelectedFolders(Array.from(window.app.selectedFolders));
}
/**
* Find folder by path in tree
*/
function findFolderByPath(path) {
function search(folders) {
for (const folder of folders) {
if (folder.path === path) {
return folder;
}
if (folder.children) {
const found = search(folder.children);
if (found) return found;
}
}
return null;
}
return search(window.app.folderTree);
}
/**
* Update selected folders count
*/
function updateSelectedCount() {
const el = window.app.dom.selectedFoldersCount;
if (!el) return; // count no longer shown in the folder-tree header
const count = window.app.selectedFolders.size;
el.textContent = `${count} folder${count !== 1 ? 's' : ''} selected`;
}
/**
* Build folder tree from scanned data
*/
function buildTree(rootHandle, foldersMap) {
const tree = [];
// Convert flat map to tree structure
function buildNode(handle, path) {
// For virtual folders, look up by path string; for real folders, use handle
let files;
if (handle.isVirtualDir || handle.isZipRoot) {
files = foldersMap.get(handle.virtualPath || handle.zipPath) || [];
} else {
files = foldersMap.get(handle) || [];
}
// Add folderPath to each file for folder highlighting (filter out null files)
files.filter(file => file !== null).forEach(file => {
if (!file.isDirectory) {
file.folderPath = path;
}
});
// Filter out null files for the node
const validFiles = files.filter(f => f !== null);
const node = {
name: handle.name,
path: path,
handle: handle,
files: validFiles,
fileCount: validFiles.length,
children: [],
expanded: false
};
// Mark ZIP-related nodes
if (handle.isZipRoot) {
node.isZipRoot = true;
node.zipPath = handle.zipPath;
}
if (handle.isVirtualDir) {
node.isVirtualDir = true;
node.zipPath = handle.zipPath;
}
return node;
}
// Recursively build tree
function addChildren(node) {
// Get subdirectories (filter out null files first)
// For virtual folders, look up by path string
let files;
if (node.handle.isVirtualDir || node.handle.isZipRoot) {
files = foldersMap.get(node.handle.virtualPath || node.handle.zipPath) || [];
} else {
files = foldersMap.get(node.handle) || [];
}
const validFiles = files.filter(f => f !== null);
const subdirs = validFiles.filter(f => f.isDirectory);
subdirs.forEach(subdir => {
const childPath = node.path + '/' + subdir.handle.name;
const childNode = buildNode(subdir.handle, childPath);
addChildren(childNode);
node.children.push(childNode);
});
// Update file count to exclude directories and null files
node.files = validFiles.filter(f => !f.isDirectory);
node.fileCount = node.files.length;
}
// Build root
const root = buildNode(rootHandle, rootHandle.name);
addChildren(root);
// Expand root by default
root.expanded = true;
tree.push(root);
return tree;
}
/**
* Get all currently visible folders (expanded tree)
*/
function getVisibleFolders() {
const visible = [];
function traverse(folders) {
for (const folder of folders) {
visible.push(folder);
if (folder.expanded && folder.children) {
traverse(folder.children);
}
}
}
traverse(window.app.folderTree);
return visible;
}
/**
* Select all visible folders
*/
function selectAllVisible() {
const visible = getVisibleFolders();
window.app.selectedFolders.clear();
visible.forEach(f => window.app.selectedFolders.add(f.path));
render();
loadFilesFromSelectedFolders();
}
/**
* Expand all folders in tree
*/
function expandAll() {
function setAllExpanded(folder) {
folder.expanded = true;
if (folder.children) {
folder.children.forEach(setAllExpanded);
}
}
window.app.folderTree.forEach(setAllExpanded);
render();
}
/**
* Select all folders in tree
*/
function selectAll() {
function collectAllPaths(folders, paths = []) {
folders.forEach(folder => {
paths.push(folder.path);
if (folder.children) {
collectAllPaths(folder.children, paths);
}
});
return paths;
}
const allPaths = collectAllPaths(window.app.folderTree);
allPaths.forEach(path => window.app.selectedFolders.add(path));
render();
loadFilesFromSelectedFolders();
}
/**
* Set up keyboard shortcuts for folder tree
*/
function setupKeyboardShortcuts() {
const container = window.app.dom.folderTree;
container.addEventListener('keydown', (e) => {
// Ctrl+A: Select all visible
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
selectAllVisible();
}
});
// Make container focusable
container.tabIndex = 0;
}
// ── Classify interactions (exclude menu, cross-tree reveal) ─────────────
var classifyWired = false;
function wireClassifyInteractions() {
if (classifyWired) return;
classifyWired = true;
var ft = window.app.dom.folderTree;
if (!ft) { classifyWired = false; return; }
ft.addEventListener('contextmenu', onContextMenu);
// Single-click a source file → preview it (the "look at it, then assign"
// half of the workflow). Drag still assigns; right-click excludes.
ft.addEventListener('click', function (e) {
if (!classifyOn()) return;
var fe = e.target.closest('.file-item');
if (!fe || !fe.dataset.key) return;
var file = findFileByKey(fe.dataset.key);
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(file);
}
});
}
// Aggregate classification state across a folder's loaded subtree files.
function aggregateState(files) {
if (!files.length) return null;
var c = window.app.modules.classify;
var ex = 0, done = 0, placed = 0;
files.forEach(function (f) {
var s = c.fileState(f);
if (s === 'excluded') ex++;
else if (s === 'done') done++;
else if (s !== 'none') placed++;
});
if (ex === files.length) return 'excluded';
var active = files.length - ex;
if (active > 0 && done === active) return 'done';
if (done > 0 || placed > 0) return 'partial';
return 'none';
}
function findFolderByPath(path) {
var hit = null;
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (hit) return;
if (n.path === path) { hit = n; return; }
walk(n.children);
});
})(window.app.folderTree);
return hit;
}
function findFileByKey(key) {
var c = window.app.modules.classify, hit = null;
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (hit) return;
(n.files || []).forEach(function (f) { if (!hit && c.srcKeyForFile(f) === key) hit = f; });
walk(n.children);
});
})(window.app.folderTree);
return hit;
}
function expandToPath(folderPath) {
(function walk(nodes) {
(nodes || []).forEach(function (n) {
if (n.path === folderPath || folderPath.indexOf(n.path + '/') === 0) {
n.expanded = true;
walk(n.children);
}
});
})(window.app.folderTree);
}
// Reveal a source file (target → source). Expands its folder chain, renders,
// scrolls + flashes the row.
function revealFile(key) {
var file = findFileByKey(key);
if (!file) return;
expandToPath(file.folderPath);
render();
var rows = window.app.dom.folderTree.querySelectorAll('.file-item');
var row = Array.prototype.filter.call(rows, function (r) { return r.dataset.key === key; })[0];
if (row) {
row.scrollIntoView({ block: 'center' });
row.classList.add('match-highlight');
setTimeout(function () { row.classList.remove('match-highlight'); }, 1500);
}
}
// ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
function persistTreeChange() {
var ws = window.app.modules.workspace;
if (ws && ws.onRescanned) ws.onRescanned();
}
async function expandZip(file) {
if (!file.handle && !window.app.rootHandle) {
if (window.zddc) window.zddc.toast('Connect the source directory first to expand this archive.', 'warning');
return;
}
try {
var node = await window.app.modules.scanner.expandZipAsFolder(file);
if (node) { render(); persistTreeChange(); }
} catch (e) {
if (window.zddc) window.zddc.toast('Couldnt expand the archive — ' + (e.message || e), 'error');
}
}
function collapseZip(zipNode) {
if (!zipNode) return;
window.app.modules.scanner.collapseZipToFile(zipNode);
render();
persistTreeChange();
}
// ── context menu (exclude / include / clear / zip mode) ─────────────────
var menuEl = null;
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
function showMenu(x, y, items) {
hideMenu();
menuEl = document.createElement('div');
menuEl.className = 'cl-menu';
items.forEach(function (it) {
var b = document.createElement('button');
b.className = 'cl-menu__item';
b.textContent = it.label;
b.addEventListener('click', function () { hideMenu(); it.fn(); });
menuEl.appendChild(b);
});
menuEl.style.left = x + 'px';
menuEl.style.top = y + 'px';
document.body.appendChild(menuEl);
setTimeout(function () {
document.addEventListener('click', hideMenu, { once: true });
document.addEventListener('scroll', hideMenu, { once: true, capture: true });
}, 0);
}
function onContextMenu(e) {
if (!classifyOn()) return;
var c = window.app.modules.classify;
var fileEl = e.target.closest('.file-item');
var folderEl = e.target.closest('.folder-item');
if (!fileEl && !folderEl) return;
e.preventDefault();
var items = [];
if (fileEl) {
var key = fileEl.dataset.key;
var a = c.getAssignment(key);
var excluded = !!(a && a.excluded);
items.push({ label: excluded ? 'Include in copy' : 'Exclude from copy', fn: function () { c.setExcluded([key], !excluded); } });
if (a && (a.trackingNodeId || a.transmittalNodeId)) {
if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } });
if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } });
}
var file = findFileByKey(key);
if (file && file.isVirtual) {
items.push({ label: 'Collapse archive to single file', fn: function () { collapseZip(findFolderByPath(file.zipPath)); } });
} else if (file && file.extension === 'zip') {
items.push({ label: 'Expand as folder', fn: function () { expandZip(file); } });
}
} else {
var folder = findFolderByPath(folderEl.dataset.path);
if (folder && folder.isZipRoot) {
items.push({ label: 'Collapse to single file', fn: function () { collapseZip(folder); } });
}
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
if (keys.length) {
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
items.push({
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
fn: function () { c.setExcluded(keys, !allExcl); },
});
}
if (!items.length) return;
}
showMenu(e.clientX, e.clientY, items);
}
// Export module
window.app.modules.tree = {
render,
buildTree,
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll,
revealFile,
setShowFilters,
setNameFilter
};
})();

78
classifier/js/utils.js Normal file
View file

@ -0,0 +1,78 @@
/**
* Classifier utilities thin convenience layer over window.zddc.
*/
(function() {
'use strict';
/**
* Compute new filename from file fields.
* ZDDC format: trackingNumber_revision (status) - title.ext
* Falls back to original filename if any required ZDDC field is missing.
*/
function computeNewFilename(file) {
if (file.manualFilename) {
return file.manualFilename;
}
const formatted = zddc.formatFilename({
trackingNumber: file.trackingNumber || '',
revision: file.revision || '',
status: file.status || '',
title: file.title || '',
extension: file.extension || '',
});
return formatted || zddc.joinExtension(file.originalFilename, file.extension);
}
/**
* Get column value from file object.
*/
function getColumnValue(file, columnName) {
switch (columnName) {
case 'original': return file.originalFilename || '';
case 'extension': return file.extension || '';
case 'new':
case 'newFilename': return file.manualFilename || computeNewFilename(file);
case 'trackingNumber': return file.trackingNumber || '';
case 'revision': return file.revision || '';
case 'status': return file.status || '';
case 'title': return file.title || '';
case 'sha256': return file.sha256 || '';
default: return '';
}
}
/**
* Get MIME type from file extension (no leading dot).
*/
const MIME_TYPES = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
bmp: 'image/bmp',
ico: 'image/x-icon',
txt: 'text/plain',
md: 'text/markdown',
json: 'application/json',
xml: 'application/xml',
csv: 'text/csv',
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
};
function getMimeType(extension) {
return MIME_TYPES[(extension || '').toLowerCase()] || 'application/octet-stream';
}
window.app.modules.utils = {
computeNewFilename,
getColumnValue,
getMimeType,
};
})();

View file

@ -0,0 +1,48 @@
/**
* ZDDC Validation Module
* Validates file names against ZDDC conventions using the shared zddc library.
*/
(function() {
'use strict';
/**
* Validate a filename and return a detailed result.
* Delegates ZDDC pattern checking to the shared zddc.parseFilename() library.
*/
function validateFilename(filename) {
const errors = [];
const warnings = [];
if (!filename) {
errors.push('Filename is empty.');
return { isValid: false, warnings, errors };
}
const parsed = zddc.parseFilename(filename);
if (!parsed || !parsed.valid) {
errors.push('Filename does not match ZDDC format: trackingNumber_revision (status) - title.ext');
} else if (!zddc.isValidStatus(parsed.status)) {
errors.push('Invalid status code "' + parsed.status + '". Valid codes: ' + zddc.STATUSES.join(', '));
}
if (filename.length > 255) {
warnings.push('Filename is very long (>255 characters)');
}
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(filename)) {
errors.push('Filename contains invalid characters: < > : " | ? *');
}
return {
isValid: errors.length === 0,
warnings,
errors
};
}
window.app.modules.validator = {
validateFilename
};
})();

384
classifier/js/workspace.js Normal file
View file

@ -0,0 +1,384 @@
/**
* ZDDC Classifier workspace manager (Classify & Copy).
*
* A workspace = one classification project: a source directory handle, a
* snapshot of its completed scan, and the Classify & Copy map. The welcome
* screen lists them; opening one resumes instantly from the snapshot (no
* re-scan), and the map autosaves as you work. Only Copy needs the live
* filesystem (a one-click permission re-grant).
*/
(function () {
'use strict';
var els = {};
var initialized = false;
var activeId = null;
var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary}
var activeStoredHandle = null; // the workspace's persisted source dir handle
function P() { return window.app.modules.persist; }
function C() { return window.app.modules.classify; }
function now() { return Date.now(); }
function uid() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
}
return 'w' + now().toString(36) + Math.floor(Math.random() * 1e9).toString(36);
}
function init() {
if (initialized) return;
initialized = true;
els = {
welcome: document.getElementById('welcomeScreen'),
list: document.getElementById('workspaceList'),
newBtn: document.getElementById('newWorkspaceBtn'),
wsBtn: document.getElementById('workspacesBtn'),
connectBtn: document.getElementById('connectDirBtn'),
importBtn: document.getElementById('importWorkspaceBtn'),
importInput: document.getElementById('importWorkspaceInput'),
};
if (!P() || !P().available) {
// No IndexedDB → hide the workspace UI; legacy rename path still works.
var wrap = document.getElementById('workspacesSection');
if (wrap) wrap.style.display = 'none';
return;
}
if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace);
if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome);
if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); });
if (els.list) els.list.addEventListener('click', onListClick);
if (els.importBtn) els.importBtn.addEventListener('click', function () { els.importInput.click(); });
if (els.importInput) els.importInput.addEventListener('change', function () {
if (this.files && this.files[0]) importWorkspace(this.files[0]);
this.value = '';
});
// Autosave the active workspace whenever the map changes.
C().on(scheduleAutosave);
renderList();
}
// ── welcome list ────────────────────────────────────────────────────────
function showWelcome() {
if (els.welcome) els.welcome.classList.remove('hidden');
renderList();
}
function hideWelcome() {
if (els.welcome) els.welcome.classList.add('hidden');
}
function relTime(ts) {
if (!ts) return '';
var s = Math.max(0, Math.round((now() - ts) / 1000));
if (s < 60) return 'just now';
var m = Math.round(s / 60); if (m < 60) return m + 'm ago';
var h = Math.round(m / 60); if (h < 24) return h + 'h ago';
var d = Math.round(h / 24); return d + 'd ago';
}
function renderList() {
if (!els.list) return;
P().listWorkspaces().then(function (rows) {
els.list.textContent = '';
if (!rows.length) {
var empty = document.createElement('div');
empty.className = 'ws-empty';
empty.textContent = 'No workspaces yet. “+ New workspace” scans a folder once and saves it here.';
els.list.appendChild(empty);
return;
}
rows.forEach(function (r) { els.list.appendChild(rowEl(r)); });
});
}
function rowEl(r) {
var s = r.summary || { files: 0, done: 0, excluded: 0 };
var row = document.createElement('div');
row.className = 'ws-row';
row.dataset.id = r.id;
var main = document.createElement('div');
main.className = 'ws-row__main';
var nm = document.createElement('div'); nm.className = 'ws-row__name'; nm.textContent = r.name;
var meta = document.createElement('div'); meta.className = 'ws-row__meta';
meta.textContent = (r.rootName || '') + ' · ' + s.done + '/' + s.files + ' classified'
+ (s.excluded ? (' · ' + s.excluded + ' excluded') : '') + ' · updated ' + relTime(r.updatedAt);
main.appendChild(nm); main.appendChild(meta);
var actions = document.createElement('div');
actions.className = 'ws-row__actions';
var titles = {
export: 'Export this whole workspace (scanned snapshot + classifications) to transfer to another browser or back up',
delete: 'Delete this workspace — your source files are untouched',
};
[['open', 'Open'], ['rename', 'Rename'], ['export', 'Export'], ['delete', 'Delete']].forEach(function (a) {
var b = document.createElement('button');
b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary');
b.dataset.act = a[0]; b.textContent = a[1];
if (titles[a[0]]) b.title = titles[a[0]];
actions.appendChild(b);
});
row.appendChild(main); row.appendChild(actions);
return row;
}
function onListClick(e) {
var btn = e.target.closest('[data-act]');
if (!btn) return;
var row = btn.closest('.ws-row');
var id = row && row.dataset.id;
if (!id) return;
if (btn.dataset.act === 'open') openWorkspace(id);
else if (btn.dataset.act === 'rename') renameWorkspace(id);
else if (btn.dataset.act === 'export') exportWorkspace(id);
else if (btn.dataset.act === 'delete') deleteWorkspace(id);
}
// ── transfer (export / import a whole workspace as JSON) ─────────────────
// The scan is the expensive part (minutes on cloud mounts), so a workspace
// is portable: its snapshot + classify map travel as one JSON. The source
// directory HANDLE can't cross browsers, so an imported workspace has none —
// "Connect directory" re-attaches the folder once, without re-scanning.
async function exportWorkspace(id) {
var rows = await P().listWorkspaces();
var meta = rows.filter(function (r) { return r.id === id; })[0];
var rec = await P().getWorkspace(id);
if (!meta || !rec) { window.zddc.toast('Could not load that workspace to export.', 'error'); return; }
var payload = {
zddcWorkspace: 1,
exportedAt: new Date().toISOString(),
meta: { name: meta.name, rootName: meta.rootName, createdAt: meta.createdAt, updatedAt: meta.updatedAt, summary: meta.summary },
tree: rec.tree || [], // the scanned snapshot (no re-scan on the other side)
classify: rec.classify || {}, // assignments + target trees
};
var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = String(meta.name || 'workspace').replace(/[^\w.-]+/g, '_') + '.zddc-workspace.json';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
window.zddc.toast('Exported “' + meta.name + '”. Import it in another browser to skip the re-scan.', 'success');
}
function importWorkspace(file) {
return new Promise(function (resolve) {
var reader = new FileReader();
reader.onload = function () {
var obj;
try { obj = JSON.parse(reader.result); }
catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); resolve(null); return; }
if (!obj || !obj.zddcWorkspace) {
window.zddc.toast(obj && obj.zddcClassifierFiles
? 'Thats a classification dataset, not a workspace — open a workspace and use Import in the toolbar.'
: 'Import failed — not a ZDDC workspace export.', 'error');
resolve(null); return;
}
var m = obj.meta || {};
var id = uid();
var meta = {
id: id,
name: m.name || 'Imported workspace',
rootName: m.rootName || '',
createdAt: m.createdAt || now(),
updatedAt: now(),
summary: m.summary || { files: 0, done: 0, excluded: 0 },
};
// No rootHandle — it can't be serialized across browsers; the
// user re-attaches via "Connect directory" after opening.
P().putWorkspace(meta, { id: id, rootHandle: null, tree: obj.tree || [], classify: obj.classify || {} })
.then(function () {
renderList();
window.zddc.toast('Imported “' + meta.name + '”. Open it, then “Connect directory” to re-attach the source folder for preview/copy.', 'success', { durationMs: 8000 });
resolve(id);
});
};
reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); resolve(null); };
reader.readAsText(file);
});
}
// ── summary ───────────────────────────────────────────────────────────
function allFiles() {
var out = [];
(function walk(ns) { (ns || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(window.app.folderTree || []);
return out;
}
function summary() {
var s = C().stats(allFiles());
return { files: s.total, done: s.done, excluded: s.excluded };
}
// ── create / open / rename / delete ─────────────────────────────────────
async function newWorkspace() {
if (!window.showDirectoryPicker) {
window.zddc.toast('Workspaces need the File System Access API (use Chromium).', 'error');
return;
}
var dir;
try { dir = await window.showDirectoryPicker(); }
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; }
var name = prompt('Name this workspace:', dir.name);
if (name === null) name = dir.name;
name = name.trim() || dir.name;
window.app.rootHandle = dir;
activeStoredHandle = dir;
window.app.modules.app.enterAppShell();
window.app.modules.app.setMode('classify');
hideWelcome();
activeId = uid();
activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } };
// Create the record UP FRONT so an interrupted scan survives and resumes.
await saveSnapshotFull();
updateConnectUI();
// Periodically persist the partial snapshot during the (slow) scan, so an
// interruption resumes from where it left off instead of starting over.
var iv = setInterval(saveSnapshotFull, 5000);
try { await window.app.modules.scanner.scanDirectory(dir); }
finally { clearInterval(iv); saveSnapshotFull(); }
}
async function openWorkspace(id) {
var rec = await P().getWorkspace(id);
var rows = await P().listWorkspaces();
var meta = rows.filter(function (r) { return r.id === id; })[0];
if (!rec || !meta) { window.zddc.toast('Could not load that workspace.', 'error'); return; }
activeId = id;
activeMeta = meta;
activeStoredHandle = rec.rootHandle || null;
window.app.rootHandle = null; // not connected until reconnect
window.app.modules.app.enterAppShell();
window.app.modules.scanner.loadSnapshot(rec.tree || []);
C().load(rec.classify || {});
window.app.modules.app.setMode('classify');
hideWelcome();
// Offer to reconnect the source directory (needed to preview, copy, or
// finish an interrupted scan). Silent if permission is already granted.
await tryReconnect(true);
updateConnectUI();
}
// Persist the full workspace (meta + snapshot + map + source handle).
function saveSnapshotFull() {
if (!activeId || !activeMeta) return Promise.resolve();
activeMeta.updatedAt = now();
activeMeta.summary = summary();
return P().putWorkspace(activeMeta, {
id: activeId,
rootHandle: window.app.rootHandle || activeStoredHandle || null,
tree: window.app.modules.scanner.snapshotTree(),
classify: C().serialize(),
});
}
// Connect (or reconnect) the source directory. silentOnly=true never shows a
// permission prompt or picker — it only adopts an already-granted handle and
// otherwise nudges the user to click "Connect directory".
async function tryReconnect(silentOnly) {
var h = activeStoredHandle;
if (h && typeof h.queryPermission === 'function') {
var p = 'denied';
try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ }
if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); }
if (!silentOnly) {
var p2 = 'denied';
try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ }
if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); }
}
}
if (silentOnly) {
if (!window.app.rootHandle && activeId) {
window.zddc.toast('This workspaces source directory isnt connected — click “Connect directory” to preview, copy, or finish scanning.', 'info', { durationMs: 8000 });
}
return false;
}
// Explicit: no usable stored handle (or permission denied) → let the user pick.
if (!window.showDirectoryPicker) { window.zddc.toast('Connecting a directory needs the File System Access API.', 'error'); return false; }
try {
var picked = await window.showDirectoryPicker();
window.app.rootHandle = picked;
activeStoredHandle = picked;
return afterConnect();
} catch (e) {
if (e.name !== 'AbortError') window.zddc.toast('Could not connect directory — ' + (e.message || e), 'error');
return false;
}
}
async function afterConnect() {
updateConnectUI();
// Resume any still-pending folders now that we have the handle.
var did = await window.app.modules.scanner.resumeScan(window.app.rootHandle);
saveSnapshotFull(); // persist refreshed snapshot + the (re-granted) handle
return true;
}
function updateConnectUI() {
if (!els.connectBtn) return;
var show = !!activeId && !window.app.rootHandle;
els.connectBtn.hidden = !show;
}
function renameWorkspace(id) {
P().listWorkspaces().then(function (rows) {
var meta = rows.filter(function (r) { return r.id === id; })[0];
if (!meta) return;
var name = prompt('Rename workspace:', meta.name);
if (!name || !name.trim()) return;
meta.name = name.trim(); meta.updatedAt = now();
if (activeMeta && activeMeta.id === id) activeMeta.name = meta.name;
P().putWorkspace(meta, null).then(renderList);
});
}
function deleteWorkspace(id) {
if (!confirm('Delete this workspace? The map and snapshot are removed — your source files are untouched.')) return;
if (activeId === id) { activeId = null; activeMeta = null; }
P().deleteWorkspace(id).then(renderList);
}
// ── autosave (debounced) ────────────────────────────────────────────────
var saveTimer = null;
function scheduleAutosave() {
if (!activeId || !activeMeta) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
saveTimer = null;
activeMeta.updatedAt = now();
activeMeta.summary = summary();
// classify-only put: tree omitted → the stored snapshot is preserved.
P().putWorkspace(activeMeta, { id: activeId, classify: C().serialize() });
}, 500);
}
// Called after a "Refresh from disk" rescan — re-persist the snapshot for
// the active workspace (the path-keyed map carries over automatically).
function onRescanned() {
if (!activeId || !activeMeta) return;
activeMeta.updatedAt = now();
activeMeta.summary = summary();
P().putWorkspace(activeMeta, {
id: activeId,
tree: window.app.modules.scanner.snapshotTree(),
classify: C().serialize(),
});
}
window.app.modules.workspace = {
init: init,
showWelcome: showWelcome,
newWorkspace: newWorkspace,
openWorkspace: openWorkspace,
exportWorkspace: exportWorkspace,
importWorkspace: importWorkspace,
onRescanned: onRescanned,
renderList: renderList,
activeId: function () { return activeId; },
activeName: function () { return activeMeta ? activeMeta.name : null; },
};
})();

356
classifier/template.html Normal file
View file

@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Classifier</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<div id="app">
<!-- Main Application -->
<div id="mainApp" class="main-app">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
<div class="mode-switch" id="modeSwitch" role="group" aria-label="Workflow mode">
<button id="modeClassifyBtn" class="mode-btn active" title="Map files onto tracking numbers and transmittals, then copy renamed copies to an output directory — the source is never modified">Classify &amp; copy</button>
<button id="modeRenameBtn" class="mode-btn" title="Edit a spreadsheet and rename the files in place (edits the source)">Rename in place</button>
</div>
<button id="mdlInstantiateBtn" class="btn btn-secondary btn-sm" title="Populate a project's Master Deliverables List from its existing archive files (server)">⊞ MDL from archive</button>
<button id="workspacesBtn" class="btn btn-secondary btn-sm" title="Workspaces — open or create a classification project">≡ Workspaces</button>
<button id="connectDirBtn" class="btn btn-primary btn-sm" title="Connect this workspace's source directory to preview, copy, or finish scanning" hidden>⮷ Connect directory</button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
</div>
</header>
<!-- Main Content -->
<div class="main-content">
<!-- Folder Tree -->
<aside class="folder-tree-pane" id="folderTreePane">
<div class="pane-header">
<div class="pane-header-title">
<button class="btn btn-sm collapse-tree-btn" id="collapseTreeBtn" title="Collapse folder tree"></button>
<h3>Folder Tree</h3>
</div>
<div class="pane-header-controls">
<label class="checkbox-label" title="Auto-scroll folder tree when hovering files">
<input type="checkbox" id="autoScrollCheckbox" checked>
Auto-scroll
</label>
<label class="checkbox-label" id="hideCompliantLabel">
<input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant
</label>
</div>
</div>
<div id="classifyFilters" class="classify-filters tree-toolbar" hidden>
<span class="tree-toolbar__label">Show</span>
<label class="checkbox-label" title="Not assigned on either axis">
<input type="checkbox" id="showUnassignedCheckbox" checked>
Unassigned <span class="filter-count" id="showUnassignedCount"></span>
</label>
<label class="checkbox-label" title="Assigned in the OTHER tab but not this one — the to-do for this tab">
<input type="checkbox" id="showPartialCheckbox" checked>
Partial <span class="filter-count" id="showPartialCount"></span>
</label>
<label class="checkbox-label" title="Already assigned in the active tab">
<input type="checkbox" id="showAssignedCheckbox" checked>
Assigned <span class="filter-count" id="showAssignedCount"></span>
</label>
<label class="checkbox-label" title="Excluded files">
<input type="checkbox" id="showExcludedCheckbox" checked>
Excluded <span class="filter-count" id="showExcludedCount"></span>
</label>
<label class="checkbox-label" title="Folders that contain no files">
<input type="checkbox" id="showEmptyCheckbox" checked>
Empty
</label>
</div>
<input type="search" id="treeFilterInput" class="tree-filter" spellcheck="false"
placeholder="Filter files… (e.g. master deliverables list)" aria-label="Filter files">
<div id="folderTree" class="folder-tree">
<!-- Dynamically populated -->
</div>
<div class="resize-handle" id="treeResizeHandle"></div>
</aside>
<!-- Spreadsheet Table (Rename in place) -->
<main class="spreadsheet-pane" id="spreadsheetPane" hidden>
<div class="pane-header">
<div class="pane-header-left">
<h3>Files</h3>
<div class="file-stats">
<span id="totalFiles">0 files</span>
<span id="modifiedFiles">0 modified</span>
<span id="errorFiles" class="hidden">0 errors</span>
</div>
</div>
<div class="pane-header-right">
<button id="saveAllBtn" class="btn btn-success btn-sm" disabled>Save All</button>
<button id="cancelAllBtn" class="btn btn-secondary btn-sm" disabled>Cancel All</button>
<span class="header-divider">|</span>
<label class="checkbox-label">
<input type="checkbox" id="sha256Checkbox">
SHA256
</label>
<button id="exportHashesBtn" class="btn btn-secondary btn-sm" disabled title="Export SHA256 hashes in sha256sum format">💾 Export Hashes</button>
<span class="header-divider">|</span>
<button id="togglePreviewBtn" class="btn btn-secondary btn-sm" title="Toggle file preview panel">👁 Preview</button>
</div>
</div>
<div class="spreadsheet-container">
<table id="spreadsheet" class="spreadsheet">
<thead>
<tr>
<th class="col-row-num">#</th>
<th class="col-original">Original Filename
<input type="text" class="column-filter" data-filter-field="original" placeholder="filter…" spellcheck="false" aria-label="Filter by original filename">
</th>
<th class="col-extension">Ext
<input type="text" class="column-filter" data-filter-field="extension" placeholder="filter…" spellcheck="false" aria-label="Filter by extension">
</th>
<th class="col-new">New Filename
<input type="text" class="column-filter" data-filter-field="newFilename" placeholder="filter…" spellcheck="false" aria-label="Filter by new filename">
</th>
<th class="col-trackingNumber">Tracking
<input type="text" class="column-filter" data-filter-field="trackingNumber" placeholder="filter…" spellcheck="false" aria-label="Filter by tracking number">
</th>
<th class="col-revision">Rev
<input type="text" class="column-filter" data-filter-field="revision" placeholder="filter…" spellcheck="false" aria-label="Filter by revision">
</th>
<th class="col-status">Status
<input type="text" class="column-filter" data-filter-field="status" placeholder="filter…" spellcheck="false" aria-label="Filter by status">
</th>
<th class="col-title">Title
<input type="text" class="column-filter" data-filter-field="title" placeholder="filter…" spellcheck="false" aria-label="Filter by title">
</th>
<th class="col-sha256 hidden" id="sha256Column">SHA256
<input type="text" class="column-filter" data-filter-field="sha256" placeholder="filter…" spellcheck="false" aria-label="Filter by SHA256">
</th>
</tr>
</thead>
<tbody id="spreadsheetBody">
<!-- Dynamically populated -->
</tbody>
</table>
</div>
</main>
<!-- Target Trees (Classify & Copy mode) — default view -->
<main class="target-pane" id="targetPane">
<div class="pane-header">
<div class="target-tabs" role="tablist">
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
<button class="target-tab" id="mdlTab" role="tab">By MDL</button>
</div>
<div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span>
<span class="header-divider">|</span>
<button id="exportDatasetBtn" class="btn btn-secondary btn-sm" title="Download the classifications as a filename-per-file JSON to edit (e.g. with an AI), then re-import here. NOT a workspace — no scanned tree.">Export for editing</button>
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button>
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to the server archive or a local folder (source untouched, resumable, verified)">Copy…</button>
</div>
</div>
<div class="target-body">
<section id="trackingPanel" class="target-panel">
<div class="target-panel__toolbar">
<button id="addTrackingRootBtn" class="btn btn-sm btn-secondary">+ Root folder</button>
<span class="target-hint">Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)”.</span>
</div>
<input type="search" id="trackingFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree">
<div id="trackingTree" class="target-tree"></div>
</section>
<section id="transmittalPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="addPartyBtn" class="btn btn-sm btn-secondary">+ Party</button>
<span class="target-hint">&lt;party&gt;/{received,issued}/&lt;transmittal&gt;. Drag files (or a whole folder) into a transmittal.</span>
</div>
<input type="search" id="transmittalFilterInput" class="tree-filter target-filter" spellcheck="false"
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
<div id="transmittalTree" class="target-tree"></div>
</section>
<section id="mdlPanel" class="target-panel" hidden>
<div class="target-panel__toolbar">
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load MDL…</button>
<span class="target-hint">Deliverables become drop targets — set a revision, then drag files on. Ctrl-shift select rows + ctrl-Enter to set a revision on many at once.</span>
</div>
<div id="mdlTree" class="target-tree"></div>
</section>
</div>
</main>
</div>
<!-- Page footer — scan status lives here -->
<footer class="app-footer">
<span id="scanStatus" class="scan-status" aria-live="polite"></span>
</footer>
<!-- Empty State — shown until a directory is selected -->
<div id="welcomeScreen" class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered welcome">
<h1 class="welcome__title">ZDDC Classifier</h1>
<p class="welcome__lede">Turn a messy folder of project files into clean, correctly-named ZDDC documents — organized by tracking number and transmittal — <strong>without ever changing your originals</strong>.</p>
<!-- Workspaces (Classify & Copy) -->
<section id="workspacesSection" class="workspaces">
<div class="ws-head">
<h2>Your workspaces</h2>
<div class="ws-head__actions">
<button id="importWorkspaceBtn" class="btn btn-secondary" title="Import a whole workspace (.zddc-workspace.json) exported from another browser — restores the scanned snapshot so you don't re-scan">⭱ Import workspace</button>
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
</div>
<input type="file" id="importWorkspaceInput" accept="application/json,.json" hidden>
</div>
<div id="workspaceList" class="ws-list"><!-- rendered --></div>
</section>
<!-- Two-method tutorial -->
<div class="welcome__methods">
<section class="method method--primary">
<h3 class="method__title">① Classify &amp; copy <span class="method__tag">recommended · non-destructive</span></h3>
<p class="method__what">Build a tidy copy of a project in a separate output folder. Your source files are only ever <em>read</em>, never renamed or moved.</p>
<ol class="method__steps">
<li><strong>New workspace</strong> → pick a folder. It scans <em>once</em> and saves to this browser, so you can close the tab and pick up later.</li>
<li><strong>Preview</strong> a file (single-click it in the left tree) to see what it actually is.</li>
<li><strong>Drag</strong> it onto the right pane — onto a <em>tracking-number</em> folder (the folder path becomes the number, the leaf is the revision, e.g. <code>A (IFR)</code>), and onto a <em>transmittal</em> (party + date + TRN/SUB + sequence).</li>
<li><strong>Copy</strong> when ready → choose an output directory; renamed copies are written as <code>&lt;party&gt;/&lt;transmittal&gt;/&lt;name&gt;</code>, with duplicates detected.</li>
</ol>
</section>
<section class="method">
<h3 class="method__title">② Rename in place <span class="method__tag method__tag--warn">edits your files</span></h3>
<p class="method__what">A quick spreadsheet for a folder you own: fill in tracking number, revision, status and title, and rename the files on disk.</p>
<ol class="method__steps">
<li>Click <strong>Use Local Directory</strong> (top bar) to open a folder.</li>
<li>Switch the toggle to <strong>Rename in place</strong>.</li>
<li>Edit cells (or paste columns from Excel); names already in ZDDC format are parsed automatically and validated as you type.</li>
<li><strong>Save All</strong> renames the files where they sit.</li>
</ol>
</section>
</div>
<!-- Browser Compatibility Warning -->
<div id="browserWarning" class="browser-warning hidden">
<h3>⚠️ Browser Not Supported</h3>
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
</div>
<p class="welcome__note">Everything runs in your browser — no files are uploaded. Tip: if your folder lives on OneDrive/SharePoint, set it to <em>“Always keep on this device”</em> first for a much faster scan.</p>
</div>
</div>
</div>
<!-- Help Panel -->
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
<div class="help-panel__header">
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Classifier</h2>
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">&times;</button>
</div>
<div class="help-panel__body">
<h3>What is the Classifier?</h3>
<p>The Classifier is a spreadsheet-based tool for renaming files to ZDDC naming conventions. It reads a folder of files and presents them in an editable grid where you can set tracking number, revision, status, and title — then saves the renamed files back to disk.</p>
<h3>Getting Started</h3>
<ol>
<li>Click <strong>Use Local Directory</strong> to open a folder containing files to rename.</li>
<li>The folder tree on the left shows all sub-folders. Click a folder to load its files.</li>
<li>Edit cells in the spreadsheet to set the new filename components.</li>
<li>Click <strong>Save All</strong> (or save individual rows) to rename the files on disk.</li>
</ol>
<h3>Folder Tree</h3>
<dl>
<dt>Multi-select</dt>
<dd>Hold <kbd>Ctrl</kbd> and click to select multiple folders. Hold <kbd>Shift</kbd> to select a range. Files from all selected folders are shown together.</dd>
<dt>Hide Compliant</dt>
<dd>Hides folders where all files already have valid ZDDC names, letting you focus on work remaining.</dd>
<dt>Auto-scroll</dt>
<dd>When enabled, the folder tree scrolls to highlight the folder containing the row you are editing.</dd>
</dl>
<h3>Spreadsheet Editing</h3>
<dl>
<dt>Direct cell editing</dt>
<dd>Click any cell in the New Filename, Tracking, Rev, Status, or Title columns to edit it. Press <kbd>Enter</kbd> to confirm, <kbd>Escape</kbd> to cancel.</dd>
<dt>RC References</dt>
<dd>Type a formula like <code>=R[-1]C</code> to copy the value from the cell one row above in the same column — similar to Excel relative references.</dd>
<dt>Regex capture groups</dt>
<dd>Type a formula like <code>=RE(RC[-3], "(\w+)-(\d+)", "$1")</code> to extract a pattern from another cell using a regular expression.</dd>
<dt>Validation</dt>
<dd>Cells are validated automatically. Invalid values are highlighted in red. The New Filename column shows the composed result.</dd>
<dt>Column Filters</dt>
<dd>Each column header has a filter input. Supported syntax:</dd>
</dl>
<dl>
<dt><code>term</code></dt>
<dd>Contains "term" (case-insensitive)</dd>
<dt><code>!term</code></dt>
<dd>Does not contain "term"</dd>
<dt><code>^term</code></dt>
<dd>Starts with "term"</dd>
<dt><code>term$</code></dt>
<dd>Ends with "term"</dd>
<dt><code>a b</code></dt>
<dd>Matches both (AND)</dd>
<dt><code>a | b</code></dt>
<dd>Matches either (OR)</dd>
<dt><code>^IFA | ^IFB</code></dt>
<dd>Starts with IFA or IFB</dd>
<dt><code>!^~</code></dt>
<dd>Does not start with ~ (excludes drafts)</dd>
<dt><code>el.*spc</code></dt>
<dd>Regex: contains "el" followed by "spc" (use <code>.</code> for any char, <code>.*</code> for any sequence)</dd>
<dt><code>[ei]fa</code></dt>
<dd>Regex character class: matches "efa" or "ifa"</dd>
</dl>
<h3>Saving Files</h3>
<dl>
<dt>Save All</dt>
<dd>Renames all modified files in one operation. Confirms before proceeding.</dd>
<dt>Cancel All</dt>
<dd>Reverts all unsaved edits back to the original filenames.</dd>
<dt>SHA256</dt>
<dd>Enable to compute a cryptographic hash of each file. Use <strong>Export Hashes</strong> to save a <code>sha256sum</code>-compatible file.</dd>
</dl>
<h3>ZDDC Filename Format</h3>
<p>The required format is:</p>
<p><code>TRACKINGNUMBER_REVISION (STATUS) - Title.ext</code></p>
<p>Example: <code>123456-EL-SPC-2623_A (IFR) - Electrical Specification.pdf</code></p>
<p>Valid statuses: IFA, IFB, IFC, IFD, IFI, IFP, IFR, IFU, REC, RSA, RSB, RSC, RSD, RSI, ---</p>
</div>
</aside>
</div>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

94
deploy Executable file
View file

@ -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: <this-script-dir>/dist/release-output
# ZDDC_LIVE_DIR default: /srv/zddc
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
CONTENT_SRC="${ZDDC_CONTENT_DIR:-$HOME/src/zddc-website}"
RELEASES_SRC="${ZDDC_DEPLOY_RELEASES_DIR:-$SCRIPT_DIR/dist/release-output}"
LIVE="${ZDDC_LIVE_DIR:-/srv/zddc}"
case "${1:-all}" in
-h|--help|help)
sed -n '4,21p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
--content|content)
WHAT=content
;;
--releases|releases)
WHAT=releases
;;
all|"")
WHAT=all
;;
*)
echo "deploy: unknown subcommand '$1'. Try './deploy help'." >&2
exit 1
;;
esac
if [ ! -d "$LIVE" ]; then
echo "deploy: $LIVE does not exist. Create it and chown to your user first:" >&2
echo " sudo mkdir -p $LIVE && sudo chown -R \$USER:\$USER $LIVE" >&2
exit 1
fi
if [ "$WHAT" = content ] || [ "$WHAT" = all ]; then
if [ ! -d "$CONTENT_SRC" ]; then
echo "deploy: content source $CONTENT_SRC does not exist" >&2
exit 1
fi
echo "=== Syncing content: $CONTENT_SRC/ → $LIVE/ ==="
# --exclude=/releases/ keeps the live site's releases dir untouched
# by content syncs. --exclude=.git so the .git dir doesn't end up
# under /usr/share/caddy. --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/"

438
dev-server Executable file
View file

@ -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)

89
form/build.sh Executable file
View file

@ -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\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
} else {
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
}
print
next
}
/\{\{FAVICON\}\}/ {
gsub(/\{\{FAVICON\}\}/, favicon_uri)
print
next
}
/<script src="https?:\/\// { next }
/<link rel="stylesheet" href="https?:\/\// { next }
{ print }
' "$src_html" > "$output_html"
echo "Wrote $output_html"
if [ "$is_release" = "1" ]; then
promote_release "form"
fi

198
form/css/form.css Normal file
View file

@ -0,0 +1,198 @@
/* form/ ZDDC generic form renderer.
Form-specific layout only; theme tokens (--primary, --bg, --text,
--border, --bg-secondary, --text-muted, --font-mono, --radius) come
from shared/base.css. Button styles (.btn, .btn-primary,
.btn-secondary, .btn-sm) likewise inherit from shared. */
.form-main {
max-width: 800px;
margin: 1.5rem auto;
padding: 0 1rem 4rem;
}
.form-status {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.form-status.is-error {
background: var(--bg-secondary);
border-color: var(--danger);
color: var(--danger);
}
.form-status.is-success {
background: var(--bg-secondary);
border-color: var(--success);
color: var(--success);
}
.form-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__label {
font-weight: 600;
font-size: 0.95rem;
}
.form-field__label .required-mark {
color: var(--danger);
margin-left: 0.15rem;
}
.form-field__description {
font-size: 0.85rem;
color: var(--text-muted);
}
.form-field__error {
font-size: 0.85rem;
color: var(--danger);
margin-top: 0.15rem;
}
.form-field__help {
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
}
.form-field__input,
.form-field__textarea,
.form-field__select {
padding: 0.5rem 0.65rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
width: 100%;
box-sizing: border-box;
}
.form-field__textarea {
min-height: 5em;
resize: vertical;
}
.form-field__input:focus,
.form-field__textarea:focus,
.form-field__select:focus {
outline: 2px solid var(--primary);
outline-offset: -1px;
}
.form-field--invalid .form-field__input,
.form-field--invalid .form-field__textarea,
.form-field--invalid .form-field__select {
border-color: var(--danger);
}
.form-field__radio-group,
.form-field__checkbox-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field__radio-group label,
.form-field__checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 400;
cursor: pointer;
}
.form-fieldset {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-fieldset__legend {
font-weight: 600;
padding: 0 0.4rem;
}
.form-array {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem;
background: var(--bg-secondary);
}
.form-array__row-body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-array__row-actions {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-array__add {
align-self: flex-start;
}
.form-actions {
margin-top: 1.5rem;
display: flex;
gap: 0.5rem;
}
/* Standalone welcome shown when form.html is opened directly (no
server-injected #form-context). */
.form-welcome {
max-width: 36rem;
margin: 2rem auto;
padding: 1.5rem 1.75rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.form-welcome h2 {
margin-bottom: 0.5rem;
font-size: 1.25rem;
}
.form-welcome h3 {
margin: 1rem 0 0.35rem;
font-size: 0.95rem;
}
.form-welcome p { margin-bottom: 0.75rem; line-height: 1.5; }
.form-welcome ol { margin: 0 0 0.75rem 1.25rem; }
.form-welcome li { margin-bottom: 0.35rem; }
.form-welcome code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg-secondary);
padding: 0.05em 0.3em;
border-radius: 3px;
}

11
form/js/app.js Normal file
View file

@ -0,0 +1,11 @@
(function (global) {
'use strict';
if (global.formApp) {
return;
}
global.formApp = {
context: null,
rootWidget: null,
modules: {}
};
})(window);

127
form/js/array.js Normal file
View file

@ -0,0 +1,127 @@
(function (app) {
'use strict';
const u = app.modules.util;
function makeArray(schema, ui, path, value, options) {
const wrap = u.h('div', { className: 'form-field form-array' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName || '';
if (label) {
const lbl = u.h('label', { className: 'form-field__label' });
lbl.appendChild(document.createTextNode(label));
if (options.required) {
lbl.appendChild(u.h('span', { className: 'required-mark' }, '*'));
}
wrap.appendChild(lbl);
}
if (schema.description) {
wrap.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
wrap.appendChild(errEl);
const rowsEl = u.h('div', { className: 'form-array__rows' });
wrap.appendChild(rowsEl);
const itemSchema = schema.items || { type: 'string' };
const itemUi = (ui && ui.items) || {};
const uiOpts = (ui && ui['ui:options']) || {};
const addable = uiOpts.addable !== false;
const removable = uiOpts.removable !== false;
const rows = [];
function repath() {
for (let i = 0; i < rows.length; i++) {
rows[i].widget.path = u.ptrPush(path, String(i));
}
}
function addRow(rowValue) {
const idx = rows.length;
const rowPath = u.ptrPush(path, String(idx));
const childWidget = app.modules.render.create(itemSchema, itemUi, rowPath, rowValue, {
fieldName: '',
required: false
});
const rowEl = u.h('div', { className: 'form-array__row' });
const body = u.h('div', { className: 'form-array__row-body' });
body.appendChild(childWidget.el);
rowEl.appendChild(body);
if (removable) {
const actions = u.h('div', { className: 'form-array__row-actions' });
const removeBtn = u.h('button', {
type: 'button',
className: 'btn btn-sm btn-secondary',
title: 'Remove this row',
onClick: function () { removeRow(rowEl); }
}, '×');
actions.appendChild(removeBtn);
rowEl.appendChild(actions);
}
rows.push({ widget: childWidget, rowEl: rowEl });
rowsEl.appendChild(rowEl);
}
function removeRow(targetEl) {
for (let i = 0; i < rows.length; i++) {
if (rows[i].rowEl === targetEl) {
rows.splice(i, 1);
targetEl.remove();
repath();
return;
}
}
}
const initial = Array.isArray(value) ? value : [];
for (let i = 0; i < initial.length; i++) {
addRow(initial[i]);
}
if (addable) {
const addBtn = u.h('button', {
type: 'button',
className: 'btn btn-sm btn-secondary form-array__add',
onClick: function () { addRow(undefined); }
}, '+ Add');
wrap.appendChild(addBtn);
}
return {
el: wrap,
path: path,
type: 'array',
read: function () {
const out = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].widget.read();
if (v !== undefined) {
out.push(v);
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
for (let i = 0; i < rows.length; i++) {
rows[i].widget.clearErrors();
}
},
child: function (idxStr) {
const i = parseInt(idxStr, 10);
return (rows[i] && rows[i].widget) || null;
}
};
}
app.modules.array = { makeArray: makeArray };
})(window.formApp);

18
form/js/context.js Normal file
View file

@ -0,0 +1,18 @@
(function (app) {
'use strict';
function load() {
const el = document.getElementById('form-context');
if (!el) {
return {};
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[form] failed to parse #form-context', err);
return {};
}
}
app.modules.context = { load };
})(window.formApp);

41
form/js/errors.js Normal file
View file

@ -0,0 +1,41 @@
(function (app) {
'use strict';
const u = app.modules.util;
function findByPath(root, path) {
if (!path || path === '') {
return root;
}
const segs = u.ptrParse(path);
let cur = root;
for (let i = 0; i < segs.length; i++) {
if (!cur || typeof cur.child !== 'function') {
return null;
}
cur = cur.child(segs[i]);
}
return cur || null;
}
function apply(errors) {
if (!errors || !errors.length || !app.rootWidget) {
return;
}
for (let i = 0; i < errors.length; i++) {
const err = errors[i];
const widget = findByPath(app.rootWidget, err.path || '');
if (widget && typeof widget.setError === 'function') {
widget.setError(err.message || 'Invalid value');
}
}
}
function clear() {
if (app.rootWidget) {
app.rootWidget.clearErrors();
}
}
app.modules.errors = { apply: apply, clear: clear };
})(window.formApp);

113
form/js/main.js Normal file
View file

@ -0,0 +1,113 @@
(function (app) {
'use strict';
// Friendly empty-state shown when the form is opened standalone
// (file:// or otherwise without a server-injected #form-context
// payload). The form renderer is always driven by the host —
// zddc-server's form handler injects schema+ui+data; the tool has
// no client-side picker because there's nothing it could pick from
// outside that contract.
function renderStandaloneWelcome(root) {
if (!root) return;
root.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'form-welcome';
wrap.innerHTML = [
'<h2>ZDDC Form Renderer</h2>',
'<p>This tool renders a form spec injected by <code>zddc-server</code>',
' at <code>&lt;name&gt;.form.html</code> URLs. There is no schema',
' to render here — most likely you opened the standalone HTML directly.</p>',
'<h3>To use it</h3>',
'<ol>',
'<li>Run <code>zddc-server</code> against an archive that contains a',
' <code>&lt;name&gt;.form.yaml</code> spec.</li>',
'<li>Visit <code>&lt;path&gt;/&lt;name&gt;.form.html</code> in the browser.</li>',
'</ol>',
'<p>See <a href="https://zddc.varasys.io/reference.html" target="_blank" rel="noopener">',
'zddc.varasys.io/reference.html</a> for the full ZDDC reference.</p>'
].join('');
root.appendChild(wrap);
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) submitBtn.hidden = true;
}
function boot() {
// When this bundle is hosted by the unified tables.html, the
// mode dispatcher decides which app paints. Skip when mode is
// not "form" — table-mode requests are handled by tablesApp.
// (Standalone form/dist/form.html has no zddcMode global; treat
// undefined as form-mode for back-compat.)
if (window.zddcMode && window.zddcMode !== 'form') {
return;
}
app.context = app.modules.context.load();
if (app.context.title) {
// Standalone form.html has #form-title in its header; unified
// tables.html bundle has #table-title (shared across modes).
// Whichever exists, write to it.
const t = document.getElementById('form-title') ||
document.getElementById('table-title');
if (t) {
t.textContent = app.context.title;
}
document.title = app.context.title + ' — ZDDC';
}
const root = document.getElementById('form-root');
if (root && app.context.schema) {
app.rootWidget = app.modules.render.mount(
root,
app.context.schema,
app.context.ui || {},
app.context.data
);
} else if (root) {
// No schema — server-injected context is empty. Most common
// when the standalone form.html is opened from file:// without
// a host. Show a friendly explanation instead of a blank page.
renderStandaloneWelcome(root);
return;
}
if (app.context.errors && app.context.errors.length) {
app.modules.errors.apply(app.context.errors);
app.modules.post.showStatus('Please correct the errors below.', 'error');
}
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.addEventListener('click', app.modules.post.submit);
// Pre-flight gate: hide Submit when the cascade denies
// create at the submission directory. Server still
// enforces on POST — this just avoids dangling an
// affordance that would 403. Submission directory is the
// parent of submitUrl; fall back to the page URL when
// submitUrl is absent (file:// / no-context mode).
if (window.zddc && window.zddc.cap && app.context && app.context.submitUrl) {
const subUrl = app.context.submitUrl;
const dir = subUrl.replace(/\/[^\/]*$/, '/') || subUrl;
window.zddc.cap.at(dir).then(function (view) {
if (!view) return;
const verbs = view.path_verbs || '';
if (verbs.indexOf('c') === -1) {
submitBtn.hidden = true;
const status = document.getElementById('form-status');
if (status) {
status.textContent = "You don't have permission to submit here.";
status.hidden = false;
status.classList.add('is-error');
}
}
});
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})(window.formApp);

132
form/js/object.js Normal file
View file

@ -0,0 +1,132 @@
(function (app) {
'use strict';
const u = app.modules.util;
function makeObject(schema, ui, path, value, options) {
const fs = u.h('fieldset', { className: 'form-fieldset' });
const label = (ui && ui['ui:title']) || schema.title || options.fieldName;
if (label) {
fs.appendChild(u.h('legend', { className: 'form-fieldset__legend' }, label));
}
if (schema.description) {
fs.appendChild(u.h('div', { className: 'form-field__description' }, schema.description));
}
const errEl = u.h('div', { className: 'form-field__error', hidden: true });
fs.appendChild(errEl);
const props = schema.properties || {};
const requiredSet = {};
(schema.required || []).forEach(function (n) { requiredSet[n] = true; });
// Resolve render order: ui:order first (with '*' as "everything else"),
// then fall back to declaration order.
const declared = Object.keys(props);
const uiOrder = (ui && ui['ui:order']) || null;
const ordered = [];
const seen = {};
if (uiOrder && Array.isArray(uiOrder)) {
for (let i = 0; i < uiOrder.length; i++) {
const name = uiOrder[i];
if (name === '*') {
for (let j = 0; j < declared.length; j++) {
const dn = declared[j];
if (!seen[dn] && uiOrder.indexOf(dn) < 0) {
ordered.push(dn);
seen[dn] = true;
}
}
} else if (props[name] && !seen[name]) {
ordered.push(name);
seen[name] = true;
}
}
// Append anything declared but not mentioned in ui:order (and no '*' was used).
for (let j = 0; j < declared.length; j++) {
if (!seen[declared[j]]) {
ordered.push(declared[j]);
seen[declared[j]] = true;
}
}
} else {
for (let j = 0; j < declared.length; j++) {
ordered.push(declared[j]);
}
}
const children = {};
const dataObj = (value && typeof value === 'object' && !Array.isArray(value)) ? value : {};
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const childSchema = props[name];
const childUi = (ui && ui[name]) || {};
const childPath = u.ptrPush(path, name);
const childValue = dataObj[name];
const childWidget = app.modules.render.create(childSchema, childUi, childPath, childValue, {
fieldName: u.humanize(name),
required: !!requiredSet[name]
});
children[name] = childWidget;
fs.appendChild(childWidget.el);
}
// Cross-field mirror: a field with `ui:mirrorFrom: <sibling>`
// shows the live value of that sibling. Used by the project-
// rollup forms so the read-only `originator` reflects the
// selected Package (party) — the party folder is the
// originator's source of truth. Display-only: the server is
// still authoritative via the cascade's folder_fields.
for (let i = 0; i < ordered.length; i++) {
const name = ordered[i];
const mirrorFrom = ui && ui[name] && ui[name]['ui:mirrorFrom'];
if (!mirrorFrom || !children[name] || !children[mirrorFrom]) {
continue;
}
const targetInput = children[name].el.querySelector('input, select, textarea');
const sourceInput = children[mirrorFrom].el.querySelector('input, select, textarea');
if (!targetInput || !sourceInput) {
continue;
}
const sync = function () { targetInput.value = sourceInput.value; };
sourceInput.addEventListener('input', sync);
sourceInput.addEventListener('change', sync);
sync(); // initialize from any pre-filled party value
}
return {
el: fs,
path: path,
type: 'object',
read: function () {
const out = {};
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = children[k].read();
if (v !== undefined) {
out[k] = v;
}
}
return out;
},
setError: function (msg) {
errEl.textContent = msg;
errEl.hidden = false;
},
clearErrors: function () {
errEl.textContent = '';
errEl.hidden = true;
const keys = Object.keys(children);
for (let i = 0; i < keys.length; i++) {
children[keys[i]].clearErrors();
}
},
child: function (name) {
return children[name] || null;
}
};
}
app.modules.object = { makeObject: makeObject };
})(window.formApp);

82
form/js/post.js Normal file
View file

@ -0,0 +1,82 @@
(function (app) {
'use strict';
function showStatus(msg, kind) {
const el = document.getElementById('form-status');
if (!el) {
return;
}
el.textContent = msg || '';
el.hidden = !msg;
el.classList.remove('is-error', 'is-success');
if (kind === 'error') {
el.classList.add('is-error');
} else if (kind === 'success') {
el.classList.add('is-success');
}
}
async function submit() {
if (!app.context || !app.context.submitUrl) {
showStatus('No submit URL configured.', 'error');
return;
}
const data = app.modules.serialize.read();
app.modules.errors.clear();
showStatus('', '');
const submitBtn = document.getElementById('submit-btn');
if (submitBtn) {
submitBtn.disabled = true;
}
try {
const res = await fetch(app.context.submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (res.status === 200) {
showStatus('Saved.', 'success');
} else if (res.status === 201) {
const loc = res.headers.get('Location');
showStatus('Submitted.', 'success');
if (loc) {
// Capability URL for the new submission. Append .html to land
// on the form-rendered view of the just-saved data.
setTimeout(function () {
window.location.href = loc + '.html';
}, 400);
}
} else if (res.status === 422) {
let body = {};
try { body = await res.json(); } catch (e) { /* ignore */ }
app.modules.errors.apply(body.errors || []);
showStatus('Please correct the errors below.', 'error');
} else if (res.status === 403) {
showStatus('You are not allowed to submit here.', 'error');
if (window.zddc && window.zddc.cap) {
window.zddc.cap.handleForbidden(res, {
context: 'Submit',
path: app.context.submitUrl
});
}
} else if (res.status === 409) {
showStatus('A submission with this filename already exists.', 'error');
} else {
let detail = '';
try { detail = await res.text(); } catch (e) { /* ignore */ }
showStatus('Submission failed (' + res.status + ')' + (detail ? ': ' + detail : ''), 'error');
}
} catch (err) {
showStatus('Network error: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
}
}
}
app.modules.post = { submit: submit, showStatus: showStatus };
})(window.formApp);

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