Compare commits

..

25 commits

Author SHA1 Message Date
59ffd861f9 chore(server): regenerate embedded tables.html (picks up the shared toast stack)
zddc/internal/handler/tables.html is //go:embed'd and regenerated by ./build,
but it was never refreshed when shared/toast moved to the stacked .zddc-toasts
model (cb1456e) — so the committed embed was stale. ./build brings it current;
no source change here, just the regenerated artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:27:54 -05:00
430ea8371e docs(claude): flag that Go runs via the podman wrapper, not the host
The "Most-used commands" block showed `(cd zddc && go test ./...)` with no
note that Go isn't installed on the host — that bare command fails. Point at
the localhost/zddc-go:1.24 container (canonical invocation + required
GOPROXY/GOPRIVATE env in AGENTS.md § Test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:23:38 -05:00
055f4cf4e0 fix(classifier): parse add-folder names into nested levels; controls back to right/hover
Follow-up to the Classify & Copy add-folder work:

- Add-folder now parses each (brace-expanded) name into the nested tracking
  levels it represents — split on "-", then the FINAL "_" splits the leaf
  revision. "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU); a braced pattern nests
  every expansion and shares common ancestors. New classify.parseFolderLevels
  + addTrackingPath (ensure-path with name reuse).
- Node add/edit/delete controls moved back to the RIGHT of the level name and
  revealed on hover (was left + always-visible).

Tests: parseFolderLevels cases + a nested-chain/shared-ancestor test; updated
the "+ Root folder" test for the new nesting (classify.spec.js -> 31 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:58:42 -05:00
8f839fc0c9 feat(classifier): Hide Assigned filter, left-aligned node controls, brace-expand add
Classify & Copy polish — in either target tab the goal is to assign or exclude
every left-pane file until nothing remains:

- Hide Assigned checkbox (classify mode, in the folder-tree pane header):
  collapses the source tree to only what's left on the ACTIVE axis — hides
  files already assigned in the current tab (or excluded) and any folder whose
  scanned subtree is thereby empty. Re-renders on tab switch; target-tree
  exposes activeAxis().
- Node add/edit/delete controls moved to the LEFT of the level name and made
  always-visible (was right-aligned + hover-only), so building/pruning the
  tracking and transmittal trees is one click.
- Brace expansion in the add-folder box: "BMB-187023-{PM,EL,EM}-MOM-
  {0001-0002,0005}_A (IFR)" creates all 9 folders — {a,b} alternation +
  {N-M} zero-padded numeric ranges, cartesian product across groups; a
  multi-create is confirmed first. New classify.expandFolderPattern().

Tests: expandFolderPattern unit cases + a Hide-Assigned DOM test
(classify.spec.js → 29 passed; classifier.spec.js → 4 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:31:14 -05:00
6d132572d3 chore(server): drop the federal reference Rego (bring-your-own-policy)
Decision: external OPA is a bring-your-own-policy escape hatch, not a
supported turnkey mode — so stop shipping access_federal.rego. A verb-blind
read-ACL policy under NIST AC-6 branding is a liability to hand a federal
evaluator, and (like access.rego before the fail-close) it over-granted writes
and ignored WORM. The HTTPDecider + Decider interface stay: operators who want
an AC-6 ancestor-deny-absolute posture write their own Rego.

- Delete rego/access_federal.rego, FederalRego, --print-rego=federal, and
  federal_parity_test.go; trim the federal cases from rego_failclosed_test.go.
- Reframe every doc reference (rego.go, main.go, file.go, ARCHITECTURE.md,
  README.md) to "operators write their own Rego"; rewrite the README
  "Reference Rego policy" section to describe the single fail-closed read-ACL
  skeleton accurately (it also still carried the now-removed "mirrors exactly"
  parity claim).

Out of scope (flagged): the broader federal-readiness narrative
(FedRAMP/FIPS/IdP) and the separate website page federal.html still discuss
federal posture — the OPA bring-your-own-Rego path stays valid, but a
deliberate review with the federal go-to-market in mind is warranted.

go vet + full go test ./... green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:45:21 -05:00
84c1b58b66 docs: fix stale "fenced/private home" claims — default homes are shared
The auto_own_fenced mechanism (private per-creator home via inherit:false) still
exists, but the current default tree sets it NOWHERE — the working/staging/
incoming/reviewing <party> homes are auto_own but UNFENCED, so ancestor grants
(project_team: cr at working/) cascade in and they are shared team folders. Code
comments (file.go AutoOwnFenced, special.go WriteAutoOwnZddcFenced, ensure.go,
fileapi.go) and AGENTS.md (role model + the auto_own_fenced key) still described
per-user homes as fenced/private-by-default — a pre-reshape artifact.

Correct them: fencing is an opt-in not used by the default tree; the party homes
are unfenced/shared. No behavior change (grep finds no auto_own_fenced in
internal/zddc/defaults). From the deferred-findings triage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:57:13 -05:00
88ef2dd921 docs(server): correct overstated WORM/config-edit comment; pin two-step demotion
The decider comment claimed standing config-edit "only ever grants VerbA, so it
can never write/delete/create WORM records." True for a single decision, but it
overstated the guarantee: a config-editor who administers a WORM zone can edit
that zone's .zddc (inherit:false drops the embedded worm:), after which ordinary
writes are no longer clamped. That two-step demotion is intended — owning a
subtree's policy includes its worm: marker, and the edit is access-logged — so
WORM is tamper-EVIDENT to its policy owner, not tamper-PROOF.

Rewrite the comment to say so (and note where to gate worm: relaxation behind
elevation if a deployment needs tamper-proof markers), and add
TestStandingConfigEdit_WormDemotionIsTwoStep pinning the boundary (direct WORM
write denied unelevated), the lever (config-edit allowed), and the consequence
(post-demotion write allowed). Surfaced by the deferred-findings triage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:57:13 -05:00
d14516a74d fix(server): fail-close the reference Rego; stop claiming internal-decider parity
The bundled reference Rego (`zddc-server --print-rego`) modeled the read-ACL
cascade only, but its header claimed to "mirror the internal decider exactly,
validated on every CI run." It is verb-blind, role-blind, WORM-blind, and
admin-blind: an external-OPA deployment (ZDDC_OPA_URL=http(s)/unix) loading it
granted writes/deletes to read-only principals and ignored WORM zones. The
parity tests never exercised a write action, a role principal, a WORM level, or
is_active_admin — so the divergence shipped silently behind a false "mirrors
exactly" claim.

Make both shipped policies fail-closed instead of falsely-complete:
- access.rego / access_federal.rego: gate every cascade grant on a read action
  (empty/absent == read); non-read actions fall through to default-deny.
  access.rego honors the single is_active_admin bypass (the one write-capable
  principal); access_federal.rego deliberately has none (strict AC-6).
- Rewrite the access.rego / access_federal.rego / rego.go headers: these are
  read-ACL SKELETONS, NOT a tested mirror of the internal decider; operators
  must add write/WORM/role/admin semantics before granting writes.
- policy.go: fix the stale AllowInput doc claiming the internal decider "treats
  read and write identically — any allow grants full CRUD" (it honors the
  action verb, with the WORM clamp and admin/elevation bypass applied).

Tests:
- rego_failclosed_test.go: pins the contract — reads allowed, every write verb
  denied, active-admin writes allowed (commercial) / denied (federal).
- embedded_neutral_test.go: pins that EmbeddedDefaults() carries no top-level
  worm: and no role members — the invariant that makes policy.SerializableChain
  dropping PolicyChain.Embedded behavior-neutral (a latent wire-contract gap).

Existing read-cascade parity + federal-divergence tests stay green; full Go
suite + vet pass. The default in-process InternalDecider is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:30:09 -05:00
42f520e087 fix(server): MOVE must require config-edit authority for .zddc/.zddc.zip
serveFileMove authorized config files with content verbs — the destination
as ActionCreate, a .zddc source as ActionWrite — so a caller holding only
create/write authority could plant or relocate an attacker-controlled
.zddc / .zddc.zip cascade (admins:/acl:) that PUT and DELETE both gate
behind ActionAdmin (VerbA / IsConfigEditor). The MOVE destination rides in
the X-ZDDC-Destination header, which no dispatch gate inspects, so the bar
must be enforced at the handler on the resolved target path.

Centralize the escalation in configWriteAction() (.zddc / .zddc.zip →
ActionAdmin, case-insensitive) and apply it to BOTH sides of serveFileMove;
replace the inlined `.zddc` checks in serveFilePut/serveFileDelete with the
same helper (also escalating whole-file .zddc.zip writes at the handler
layer, where previously only the dispatch visibility gate covered them).

Found via an authz-subsystem audit; the existing suite did not pin this
path. Adds TestFileAPI_MoveOntoConfigRequiresConfigEdit (non-editor MOVE
onto/away-from config → 403; config-editor → 200). Full Go suite + vet green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:06:28 -05:00
01b01f8f7a feat(classifier): welcome rewrite + resumable scan + reconnect on restore
- Welcome: drop the 'absorbed into Browse' notice; bigger, inviting intro with
  a two-method tutorial (Classify & copy — recommended/non-destructive; Rename
  in place — edits files) and a OneDrive 'keep on device' tip.
- Resumable scan: the snapshot now records per-folder scan state, the workspace
  record is created up front, and the partial snapshot is persisted every 5s
  during the (slow) scan. scanner.resumeScan() resolves handles for only the
  still-pending folders and drains them — so an interrupted scan picks up where
  it left off instead of starting over.
- Reconnect on restore: opening a workspace no longer assumes the source is
  connected; a header 'Connect directory' button (and a prompt) re-grants the
  persisted handle in one click or lets you re-pick it. Until connected you can
  still edit the data model; connecting also resumes any pending scan.
- Tests: resume-scan via mock root handle (31 classify/classifier green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:38:08 -05:00
975c804cc7 feat(classifier): click-to-preview in Classify & Copy mode
Identifying a file is half the workflow — you preview it to see what it is,
then assign its tracking number by drag. Preview was only wired into the old
Rename grid; in Classify & Copy a source file now previews on single-click
(drag still assigns, right-click excludes). preview.previewFile() resolves a
snapshot file's handle from the workspace root (one-click read re-grant) before
opening, so it works for resumed workspaces too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:08:57 -05:00
afcba81e61 ux(classifier): default to Classify & copy; relabel the mode toggle
The top-level toggle is a tool choice, not the two classification axes (those
are the By-tracking / By-transmittal tabs inside Classify & Copy). Default to
the Classify & copy workflow and relabel the toggle 'Classify & copy' /
'Rename in place' so its purpose is clear; the in-place spreadsheet stays one
click away. 'Use Local Directory' now opens in Classify mode too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:26 -05:00
eb07e7622d fix(classifier): file-only folders are expandable in Classify mode
A folder with files but no subfolders got no expand toggle, so in Classify &
Copy mode its files (the drag source) could never be revealed — and leaf
folders full of files are exactly where the work is. Make a folder expandable
when it has files in classify mode; expanding lists the draggable file rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:26 -05:00
1d09abdc8b feat(classifier): workspaces — scan-once, resume from snapshot (phase 6)
The classifier re-scanned the source on every session; on cloud-backed mounts
(OneDrive/Samba) that's minutes of per-op latency. Workspaces fix it: scan a
folder ONCE, snapshot the completed tree, and resume instantly — all
classification runs on the data model; the filesystem is only touched at copy.

- persist.js v2: multi-workspace IndexedDB (tiny 'index' store for the welcome
  list + 'data' store holding the source handle, tree snapshot, and map). DB v2.
- scanner.js: snapshotTree()/loadSnapshot() (compact, handle-less, marked done,
  totals recomputed) + lazy resolveFileHandle/resolveDirHandle from the root.
- workspace.js: welcome manager (new/open/rename/delete), debounced autosave of
  the active workspace, 'Refresh from disk' (re-scan → re-snapshot, path-keyed
  map carries over). New workspace = the one slow full scan; reopen = instant.
- copy.js: resolves snapshot files' handles from the workspace root with a
  one-click read permission re-grant; missing-on-disk files surface as errors.
- app.js: enterAppShell() shared by rename/workspace flows; exposes setMode;
  classify.js decoupled from persistence.
- template/css: welcome workspace list + header 'Workspaces' button.
- tests: snapshot round-trip, persist CRUD + classify-only-preserves-tree,
  copy-from-snapshot via mock root handle (28 classify/classifier tests green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:07:40 -05:00
05fc3b69dd perf(classifier): raise scan concurrency 16 -> 32
The scan is I/O-bound on cloud-sync / network mounts (OneDrive, Samba) where
each directory read is a high-latency round-trip. More in-flight reads hide
that latency on the many-folders case. (A single large folder is still
enumerated one entry at a time by the File System Access API and can't be
parallelized.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:08:54 -05:00
420f735e89 feat(classifier): copy-out with duplicate detection + map restore (phase 5)
The Copy button (enabled once >=1 file is fully classified) copies the mapped
files into a user-chosen output directory under their canonical names/layout
<party>/{received,issued}/<transmittal>/<filename> — reading the source, never
writing it.

- copy.js: plan() (complete, non-excluded files) → conflict scan (two sources
  → same output path are reported + skipped) → copyTo() engine on the generic
  FS-Access shape (ensureDir + getFileHandle + createWritable). Per-file dedup:
  identical target (sha256) is skipped; existing-but-different is left
  untouched and reported; live footer progress; completion toast.
- app.js: restores the saved map on launch (keyed by source-relative path, so
  it re-attaches when the same directory is re-opened) and persists the source
  handle on open; Copy button wired.
- target-tree.js: enables/labels the Copy button from the done count.
- 2 copy-engine tests with mock FS handles (copy/skip/differ + conflict);
  24 classify+classifier tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:37:44 -05:00
eb1e3ec948 feat(classifier): left-tree markers, exclude, cross-tree find (phase 4)
- Each source file row shows a classification state dot (unassigned →
  has-tracking/transmittal → done), and each folder shows an aggregate dot
  over its subtree.
- Right-click a file or folder to Exclude/Include from the copy (folder applies
  to its whole subtree) or clear an axis; excluded files are struck through and
  never copied.
- Cross-tree find is bidirectional: click a placed file in the target pane to
  reveal+flash it in the source tree (expanding its folders); click a source
  file to switch the target pane to its placed axis and flash the node.
- Target pane now reverse-looks-up over ALL scanned files (the left tree), not
  the selection-scoped grid, with placements grouped in one pass per render.
- classify.getAssignment() read-only accessor; 5 new tests (18 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:32:42 -05:00
47cf58b0e9 feat(classifier): drag-and-drop assignment (phase 3)
In Classify & Copy mode the left tree now lists each folder's files as
draggable rows (with a classification state dot), and folder rows are
draggable for a group-drag of the whole subtree. Target-tree nodes are drop
zones: a tracking folder (any node) or a transmittal bin; dropping assigns the
dragged source key(s) along that axis via classify.place().

- dnd.js: drag-payload bus (keys held in a module var since dataTransfer can't
  be read during dragover; carries a marker for the copy cursor).
- tree.js: createFileElement + group-drag dragstart; classify-mode file rows.
- target-tree.js: setupDropZone with dragover highlight + drop assignment
  (tracking = any node, transmittal = bins only).
- app.js: source tree re-renders on classify state change.
- 2 DnD drop-handler tests (14 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:23:38 -05:00
a8403d1f73 feat(classifier): mode toggle + dual-pane target trees (phase 2)
Header gets a Rename / Classify & Copy switch. In Classify & Copy mode the
spreadsheet pane is replaced by a tabbed target pane (By tracking number /
By transmittal), while the source tree stays on the left.

- target-tree.js: renders both trees from classify state; tracking-folder
  create/rename/delete (leaf folders styled as the revision); party CRUD +
  per-slot inline transmittal-bin form (date + TRN/SUB + seq + optional
  status/title); shows the derived filename + a validation badge for each
  placed file; live header stats (done / in progress / unassigned / excluded).
- app.js setMode(): swaps panes, toggles classify mode, re-renders both trees.
- 3 UI smoke tests added to classify.spec.js (12 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:19:35 -05:00
a8f116734d feat(classifier): Classify & Copy state model + persistence (phase 1)
Foundation for the non-destructive map+copy workflow: source stays read-only,
files are mapped onto two orthogonal target trees, a later step copies renamed
copies to a separate output dir.

- classify.js: the single source of truth. assignments map keyed by
  source-relative path (survives re-pick); tracking tree (positional: ancestors
  joined '-' = tracking number, immediate parent 'REV (STATUS)' leaf = rev+status,
  title from original name) and transmittal tree (<party>/{received,issued}/<bin>).
  deriveTarget() computes filename + output path + validation purely; pub/sub +
  debounced autosave; node CRUD with dangling-placement cleanup.
- persist.js: IndexedDB store of the serialized map + the source
  FileSystemDirectoryHandle, with queryPermission/requestPermission re-grant on
  reload and a re-pick fallback.
- tests/classify.spec.js: 9 in-page unit tests for the derive/assignment logic
  (no FS Access needed) — tracking join, leaf REV (STATUS) parse incl. invalid
  status, title derivation/override, transmittal path composition, exclude,
  cascade delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:11:04 -05:00
389b2e94ac ux(classifier): blue completed counts; blue labels when row fully scanned
The black-completed vs grey-flashing distinction was too subtle. Completed
numbers (the direct count, always; the +total once final) now render in
var(--primary) — theme-aware blue in both light and dark. While a subtree
is still scanning its +total stays muted grey + pulses, so blue = done,
grey = in progress. Once both numbers are blue the row's folders/files
labels turn blue too (.folder-count.done .ct-label).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:11:51 -05:00
e0ba77a75b perf+ux(classifier): continuous 16-way scan pool; accurate FS error text
Scan concurrency: the scan is I/O-bound — each directory read is a network
round-trip to the share, so the lever is parallel in-flight reads, not CPU
threads. Replace the per-level BFS barrier (which idled workers waiting on
the slowest dir in each level) with a continuous shared-queue pool that
keeps up to SCAN_CONCURRENCY (16, up from 6) reads in flight at once,
pulling newly discovered child dirs as they land. Still roughly
breadth-first (FIFO), so top levels surface first. ensureScanned reuses it.

Error messages: translate File System Access DOMExceptions into accurate,
actionable text keyed on err.name (not the cryptic raw message, which reads
like a permission problem when it isn't). e.g. InvalidStateError now reads
'the folder changed on disk since it was first read … rescan' instead of
'an operation that depends on state cached in an interface object …'. The
raw name+message is appended in parens for copy-paste troubleshooting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:09:14 -05:00
caff489206 perf(classifier): scan is a pure listing — no getFile() per file; lazy zips
The scan was slow because it OPENED every file (getFile() for size/lastModified
— which the grid doesn't even display) and read every ZIP inline. On a network
share that's a round-trip per file. Now:

- createFileObject builds rows from the directory entry name alone, no
  getFile(); size/lastModified load on demand (preview/SHA/rename already call
  getFile() themselves). The scan is now a pure directory listing.
- ZIPs are lazy: a .zip is an expandable node read only when opened
  (scanZipNode), not during the walk.
- Footer shows live elapsed time (ticks every second), and a success toast
  fires at completion with totals: "Scan complete — N folders, M files in Ts."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:55:29 -05:00
cb1456e55f feat(shared): sticky, dismissible, selectable toasts
Toasts now collect in a bottom-right stack. Error/warning toasts are STICKY —
they stay until dismissed so the user can read, select, and copy them while
troubleshooting (e.g. classifier scan errors); info/success still auto-dismiss
(opts.durationMs:0 forces sticky for any level). Each toast has a × to dismiss
it, a "Clear all" appears when 2+ are stacked, and the message text is
selectable/copyable. alert() maps to a sticky error toast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:38:09 -05:00
28bfcc6e8c feat(classifier): move scan status to a page footer
The live "Scanning… N folders · M files — <path>" status now lives in a
persistent page footer bar instead of under the folder-tree header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:38:09 -05:00
37 changed files with 4114 additions and 700 deletions

View file

@ -526,12 +526,12 @@ 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 their auto-own-fenced `<email>/` home. 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).
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) or reach inside fenced working homes.
- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Owns their `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`).
- `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.)
@ -544,7 +544,7 @@ Pick a role per persona:
- `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` (private-by-default home). No effect without `auto_own: true`.
- `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.

View file

@ -544,7 +544,7 @@ none of them is load-bearing alone.
|---|---|---|
| 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 bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
| 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 |
@ -700,7 +700,7 @@ whether to deploy the system should know which column they're in.
| 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 `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) |
| 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 |
@ -723,7 +723,7 @@ Five permission verbs gate every read and write:
`.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 the bundled `access_federal.rego`.
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.

View file

@ -65,8 +65,11 @@ 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)
(cd zddc && go test ./...) # unit tests (Go 1.24+)
# 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.

View file

@ -51,9 +51,15 @@ concat_files \
"js/utils.js" \
"../shared/zddc-filter.js" \
"js/store.js" \
"js/persist.js" \
"js/classify.js" \
"js/workspace.js" \
"js/dnd.js" \
"js/validator.js" \
"js/scanner.js" \
"js/tree.js" \
"js/target-tree.js" \
"js/copy.js" \
"js/spreadsheet.js" \
"js/selection.js" \
"js/preview.js" \

View file

@ -175,34 +175,46 @@
background-color: var(--bg-hover);
}
/* Counts read "direct+total". The direct number stays solid (immediate info);
the "+total" subtree count is muted and pulses while its subtree is still
being scanned, then goes solid once final. */
/* 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(--text-secondary, #6b7280);
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; }
}
/* Live scan status line under the tree-pane header. */
.scan-status {
padding: 0.25rem 0.6rem;
/* 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);
border-bottom: 1px solid var(--border, #e2e2e2);
min-height: 1.4em;
}
.scan-status {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 1.1em;
}
.scan-status:empty { display: none; }
.scan-status.scanning { color: var(--primary, #2868c8); }
.folder-item.selected {
@ -253,6 +265,242 @@
margin-left: 1.5rem;
}
/* ── Welcome screen (intro + tutorial) ─────────────────────────────────── */
.empty-state--overlay { overflow-y: 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; }
.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; }
.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); }
/* placed-file row in the target pane is clickable (reveal in source) */
.tfile { cursor: pointer; }
.tfile:hover .tfile__name { text-decoration: underline; }
/* 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;

View file

@ -33,6 +33,9 @@
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
@ -139,7 +142,10 @@
exportHashesBtn: document.getElementById('exportHashesBtn'),
sha256Checkbox: document.getElementById('sha256Checkbox'),
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'),
hideAssignedLabel: document.getElementById('hideAssignedLabel'),
// Folder tree
folderTree: document.getElementById('folderTree'),
folderTreePane: document.getElementById('folderTreePane'),
@ -158,10 +164,41 @@
errorFiles: document.getElementById('errorFiles'),
// Preview
togglePreviewBtn: document.getElementById('togglePreviewBtn')
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')
};
}
/**
* 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.hideAssignedLabel) app.dom.hideAssignedLabel.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();
}
/**
* Set up event listeners
*/
@ -185,15 +222,38 @@
// Hide compliant toggle
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
// Hide assigned toggle (classify mode — filters the source tree)
if (app.dom.hideAssignedCheckbox) {
app.dom.hideAssignedCheckbox.addEventListener('change', function () {
if (app.modules.tree && app.modules.tree.setHideAssigned) {
app.modules.tree.setHideAssigned(this.checked);
}
});
}
// 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(); });
// 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();
});
}
}
/**
@ -310,27 +370,35 @@
/**
* Open a directory handle and initialize the application
*/
async function openDirectory(dirHandle) {
app.rootHandle = dirHandle;
// Hide welcome screen and show main UI
// 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();
// Initialize modules BEFORE scanning (so they're ready for store updates)
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 (!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);
// Show refresh button now that a directory is loaded
if (app.dom.refreshHeaderBtn) { app.dom.refreshHeaderBtn.classList.remove('hidden'); }
}
/**
@ -343,17 +411,33 @@
}
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);
@ -489,7 +573,9 @@
// Export functions for use by other modules
app.modules.app = {
updateStats
updateStats,
setMode,
enterAppShell
};
// Initialize when DOM is ready

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

@ -0,0 +1,519 @@
/**
* 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);
}
// ── 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
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
var nodeIndex = {};
// ── 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, excluded: false, titleOverride: null };
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.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' : 'trackingNodeId';
keys.forEach(function (k) {
var a = assignmentFor(k);
a[field] = nodeId || null;
a.excluded = false; // placing un-excludes
cleanAssignment(k);
});
notify();
}
function setExcluded(keys, excluded) {
keys.forEach(function (k) {
var a = assignmentFor(k);
a.excluded = !!excluded;
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
cleanAssignment(k);
});
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 };
});
});
});
}
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 — tracking.
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,
};
}
function load(obj) {
if (!obj) return;
state.assignments = obj.assignments || {};
state.trackingTree = obj.trackingTree || [];
state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null;
rebuildIndex();
notify();
}
function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
rebuildIndex();
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 segs = s.split('-');
var last = segs.pop();
var u = last.indexOf('_');
if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); }
else { segs.push(last); }
return segs.map(function (x) { return x.trim(); }).filter(Boolean);
}
// 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;
}
// ── 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,
setTitleOverride: setTitleOverride,
// trees
addTrackingNode: addTrackingNode, addParty: addParty,
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
expandFolderPattern: expandFolderPattern,
parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
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(); },
};
})();

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

@ -0,0 +1,199 @@
/**
* 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;
}
async function sameContent(existingHandle, srcFileObj) {
var ef = await existingHandle.getFile();
var sf = await (await srcHandle(srcFileObj)).getFile();
if (ef.size !== sf.size) return false;
var a = await window.zddc.crypto.sha256File(ef);
var b = await window.zddc.crypto.sha256File(sf);
return a === b;
}
// 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);
}
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone).
async function copyOne(out, p) {
var dir = await ensureDir(out, p.d.outPath);
var existing = null;
try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ }
if (existing) {
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
}
var srcFile = await (await srcHandle(p.file)).getFile(); // 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();
return 'copied';
}
async function run() {
if (!C().isEnabled()) return;
var items = plan();
if (!items.length) {
toast('Nothing to copy yet — no files are fully classified (need both a tracking leaf and a transmittal).', 'warning');
return;
}
var cf = conflictsIn(items);
var blocked = {};
cf.conflicts.forEach(function (path) { blocked[path] = true; });
var todo = items.filter(function (p) { return !blocked[p.outRel]; });
if (cf.conflicts.length) {
toast(cf.conflicts.length + ' output-name collision(s) — two source files map to the same name. Skipped:\n'
+ cf.conflicts.join('\n'), 'error');
}
if (!todo.length) return;
// Snapshot-loaded files have no live handle — re-grant read on the
// workspace source directory (one click) before copying.
if (todo.some(function (p) { return !p.file.handle; })) {
if (!window.app.rootHandle) {
toast('The source directory isnt connected. Re-open the workspace to reconnect it.', 'error');
return;
}
var srcOk = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; }
}
var out = outputHandle || await chooseOutput();
if (!out) return;
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return;
var s = await copyTo(out, todo);
var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped'
+ (s.differ ? (', ' + s.differ + ' already exist with different content (left untouched)') : '')
+ (s.errors ? (', ' + s.errors + ' errors') : '') + '.';
toast(msg, (s.errors || s.differ) ? 'warning' : 'success');
if (s.differing.length) toast('Existing-but-different (not overwritten):\n' + s.differing.join('\n'), 'warning');
return s;
}
// 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).
async function copyTo(out, todo) {
var s = { copied: 0, skipped: 0, differ: 0, errors: 0, differing: [] };
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 === 'differ') s.differing.push(todo[i].outRel);
} catch (e) {
s.errors++;
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Failed to copy ' + todo[i].outRel + ' — ' + (e.message || e), 'error');
}
}
}
setStatus('');
return s;
}
function readyCount() { return plan().length; }
window.app.modules.copy = {
run: run,
readyCount: readyCount,
chooseOutput: chooseOutput,
// test/advanced seams
plan: plan,
conflictsIn: conflictsIn,
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,
};
})();

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

View file

@ -521,7 +521,28 @@
}
// 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 {
if (!file.handle && !file.isVirtual && 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 window.app.modules.scanner.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
init,
previewFile
};
})();

View file

@ -16,6 +16,15 @@
let scanGen = 0; // bumped per scan; stale workers bail
let scanStats = null; // { folders, files, current, done, startedAt }
let renderTimer = null; // throttle for progressive re-render
// How many directories to read in flight at once. The scan is I/O-bound —
// each readdir is a round-trip to the backing store (cloud-sync / network
// mounts like OneDrive or Samba have high per-op latency), so the lever is
// parallel in-flight reads, not CPU threads. This only helps the
// many-folders case; a single fat folder is enumerated one entry at a time
// by the File System Access API and can't be parallelized. Raise it if the
// store tolerates more concurrency; too high risks cloud-provider
// throttling.
var SCAN_CONCURRENCY = 32;
function scheduleRender() {
if (renderTimer) return;
@ -32,18 +41,27 @@
updateScanStatus();
}
// Render the running scan status into the tree-pane header.
// elapsed since the scan started, e.g. "3.2s" or "1m 04s".
function elapsedStr() {
if (!scanStats) return '0s';
const ms = Date.now() - scanStats.startedAt;
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
const m = Math.floor(ms / 60000);
const s = Math.round((ms % 60000) / 1000);
return m + 'm ' + (s < 10 ? '0' : '') + s + 's';
}
// Render the running scan status (with live elapsed time) into the footer.
function updateScanStatus() {
const el = document.getElementById('scanStatus');
if (!el || !scanStats) return;
if (scanStats.done) {
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files in ' + secs + 's';
+ scanStats.files + ' files in ' + elapsedStr();
el.classList.remove('scanning');
} else {
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files'
+ scanStats.files + ' files · ' + elapsedStr()
+ (scanStats.current ? ' — ' + scanStats.current : '');
el.classList.add('scanning');
}
@ -91,14 +109,56 @@
}
}
// One-shot toast for scan errors (permission denied, network hiccups on a
// share). De-duped per path so a flaky folder doesn't spam.
// Translate a File System Access API error into accurate, actionable text.
// The browser's raw DOMException messages are cryptic and often read like a
// permission problem when they aren't — we key off err.name (reliable)
// rather than the message. Returns a plain-language explanation; the raw
// name + message are still appended by the caller for troubleshooting.
function describeFsError(err) {
var name = err && err.name ? err.name : '';
switch (name) {
case 'NotAllowedError':
return 'Permission to read this folder was denied or revoked. '
+ 'Re-pick the root folder to re-grant access.';
case 'InvalidStateError':
// The handle was read once, then the directory changed underneath
// it (common on a network/SMB share that's being written to, or
// after a disconnect/reconnect). NOT a permissions problem.
return 'The folder changed on disk since it was first read '
+ '(common on a busy or reconnecting network share). '
+ 'Rescan to pick up the current contents.';
case 'NotFoundError':
return 'The folder no longer exists — it may have been moved, '
+ 'renamed, or deleted since the scan started.';
case 'NotReadableError':
return 'The folder could not be read — the share may have '
+ 'disconnected, or the OS denied access.';
case 'SecurityError':
return 'The browser blocked access to this folder for security '
+ 'reasons.';
case 'TypeMismatchError':
return 'Expected a folder here but found a file (or vice-versa).';
case 'AbortError':
return 'Reading this folder was aborted.';
default:
return 'Could not read this folder.';
}
}
// One-shot toast for scan errors (permission denied, stale handles, network
// hiccups on a share). De-duped per path so a flaky folder doesn't spam.
const scanErrorsSeen = new Set();
function reportScanError(path, err) {
console.error('Scan error:', path, err);
if (scanErrorsSeen.has(path)) return;
scanErrorsSeen.add(path);
const msg = 'Couldnt scan ' + path + ': ' + (err && err.message ? err.message : err);
// Plain-language explanation, then the raw error in parentheses so the
// user can copy it (toasts are selectable) for deeper troubleshooting.
var raw = err && err.name
? err.name + (err.message ? ': ' + err.message : '')
: (err && err.message ? err.message : String(err));
var msg = 'Couldnt scan ' + path + ' — ' + describeFsError(err)
+ '\n\n(' + raw + ')';
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'error');
}
@ -139,42 +199,70 @@
}
flushRender();
// Breadth-first by level behind a bounded worker pool: level 1, then
// level 2, … each rendered as it lands (top levels appear first).
// Deeper levels keep filling in; workers await between directories so
// the UI stays responsive on a slow/large network drive.
let level = [root];
while (level.length && myGen === scanGen) {
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
const next = [];
for (const n of level) {
for (const c of n.children) {
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
if (c.scanState === 'pending') next.push(c);
}
}
level = next;
// Tick the footer's elapsed time once a second even if no new folder
// landed (so a slow directory doesn't make the timer look frozen).
const ticker = setInterval(function () {
if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; }
updateScanStatus();
}, 1000);
// Continuous breadth-first walk: up to SCAN_CONCURRENCY directory reads
// in flight at once, pulling newly-discovered child dirs as they land
// (no per-level barrier, so the pool stays saturated). Top levels still
// appear first (FIFO). The cap is the lever — see SCAN_CONCURRENCY.
await drainQueue([root], myGen, SCAN_CONCURRENCY);
if (preserveState && savedExpanded.size) {
restoreExpandedPaths(window.app.folderTree, savedExpanded);
}
clearInterval(ticker);
if (myGen !== scanGen) return; // superseded by a newer scan
scanStats.done = true;
scanStats.current = '';
flushRender();
// Completion toast with the totals + elapsed time.
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(
'Scan complete — ' + scanStats.folders + ' folders, '
+ scanStats.files + ' files in ' + elapsedStr() + '.',
'success');
}
}
// Run fn over items with at most `limit` concurrent calls; resolves when
// all have settled. Termination is clean (no transient-empty-queue race).
async function runWithConcurrency(items, limit, fn) {
let i = 0;
async function runner() {
while (i < items.length) {
const idx = i++;
await fn(items[idx]);
// Continuous worker pool over a shared queue: keep up to `conc` directory
// reads in flight at once, pulling newly-discovered child dirs as they land
// — no per-level barrier, so workers never idle waiting on the slowest dir
// in a level. Roughly breadth-first (FIFO; a node's children are enqueued
// after it), so top levels still surface first. Resolves when the queue is
// drained and no read is in flight (clean termination, no empty-queue race).
function drainQueue(seed, myGen, conc) {
const queue = seed.slice();
let active = 0;
return new Promise(function (resolve) {
function finishIfIdle() {
if (queue.length === 0 && active === 0) resolve();
}
}
const runners = [];
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
await Promise.all(runners);
function pump() {
while (myGen === scanGen && active < conc && queue.length) {
const node = queue.shift();
active++;
Promise.resolve(scanNodeChildren(node, myGen)).then(function () {
active--;
if (myGen === scanGen) {
const kids = node.children;
for (let i = 0; i < kids.length; i++) {
if (kids[i].scanState === 'pending') queue.push(kids[i]);
}
}
pump();
finishIfIdle();
}, function () { active--; pump(); finishIfIdle(); });
}
finishIfIdle();
}
pump();
});
}
// Force a folder's subtree to scan NOW (jumped ahead of the background
@ -182,16 +270,7 @@
// shows complete contents. Idempotent + shares the live scan generation.
async function ensureScanned(node) {
if (!node || !node.handle || node.scanState === 'done') return;
const myGen = scanGen;
let level = [node];
while (level.length && myGen === scanGen) {
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
const next = [];
for (const n of level) {
for (const c of n.children) if (c.scanState === 'pending') next.push(c);
}
level = next;
}
await drainQueue([node], scanGen, SCAN_CONCURRENCY);
flushRender();
}
@ -229,6 +308,8 @@
// only a 'pending' node is scanned, so concurrent callers (background +
// open-prioritised) don't double-scan.
async function scanNodeChildren(node, myGen) {
// A .zip is a lazy node — read its contents only when opened.
if (node.scanState === 'zip-pending') { await scanZipNode(node); return; }
if (node.scanState !== 'pending') return;
node.scanState = 'scanning';
if (scanStats) scanStats.current = node.path;
@ -238,18 +319,19 @@
for await (const entry of node.handle.values()) {
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
if (entry.kind === 'file') {
const fo = await createFileObject(entry, node.handle);
if (!fo) continue;
const fo = createFileObject(entry, node.handle);
fo.folderPath = node.path;
files.push(fo);
if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
// Don't read the archive during the listing — make an
// expandable, lazy zip node scanned on open (scanZipNode).
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
const zipPath = node.path + '/' + zipName;
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
const zipNode = makeNode(zh, zipPath, node);
try { await scanZipIntoNode(zipNode, fo); }
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
zipNode._zipFileObj = fo;
zipNode.scanState = 'zip-pending';
childDirs.push(zipNode);
if (scanStats) scanStats.folders++;
}
@ -267,18 +349,15 @@
node.fileCount = files.length;
node.children = childDirs;
node.subdirCount = childDirs.length;
// Roll this folder's own files/dirs (plus the full contents of any
// inline-zip children) into the running subtree totals of this node
// and every ancestor. Regular child dirs add their own share when they
// get scanned — that's how the total fills in progressively.
let addF = files.length;
let addD = childDirs.length;
for (const c of childDirs) {
if (c.scanState === 'done') { addF += c.runFiles; addD += c.runDirs; }
}
// Roll this folder's own files/dirs into the running subtree totals of
// this node + every ancestor. Real child dirs add their share when they
// get scanned; lazy zip nodes add theirs when opened (scanZipNode).
const addF = files.length;
const addD = childDirs.length;
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
// Zip children are scanned inline ('done'); real dirs are still pending.
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
// Only real unscanned dirs hold the parent open; zip-pending children
// are lazy, so they don't.
node.pending = childDirs.filter(function (c) { return c.scanState === 'pending'; }).length;
if (node.pending === 0) {
markDone(node);
} else {
@ -287,6 +366,30 @@
scheduleRender();
}
// Read a lazy zip node's contents on demand (when opened), building its
// child nodes and folding its internal totals into ancestors.
async function scanZipNode(node) {
if (node.scanState !== 'zip-pending' || !node._zipFileObj) return;
node.scanState = 'scanning';
scheduleRender();
try {
await scanZipIntoNode(node, node._zipFileObj); // builds children, runFiles/runDirs, sets 'done'
} catch (e) {
reportScanError(node.path, e);
node.scanState = 'done';
node.runFiles = 0;
node.runDirs = 0;
}
node._zipFileObj = null;
// The zip counted as 1 dir in its parent already; now fold in its
// internal files/dirs to every ancestor's running totals.
for (let a = node.parent; a; a = a.parent) {
a.runFiles += node.runFiles;
a.runDirs += node.runDirs;
}
scheduleRender();
}
// Build a zip-root node's children from its archive contents (in memory),
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
// node shape so the rest of the app treats zip folders like real ones.
@ -618,37 +721,159 @@
/**
* Create file object with metadata
*/
async function createFileObject(fileHandle, folderHandle) {
try {
const file = await fileHandle.getFile();
const split = zddc.splitExtension(file.name);
// Build a file row from JUST the directory entry — no getFile(). Listing a
// network share is already slow; the old code opened EVERY file to read
// size/lastModified (which the grid doesn't even display), turning a
// listing into one network round-trip per file. size/lastModified are now
// loaded on demand by preview / SHA / rename, which call getFile()
// themselves. The scan is now a pure directory listing.
function createFileObject(fileHandle, folderHandle) {
const split = zddc.splitExtension(fileHandle.name);
return {
handle: fileHandle,
folderHandle: folderHandle,
originalFilename: split.name,
extension: split.extension,
size: null,
lastModified: null,
// Editable fields
trackingNumber: '',
revision: '',
status: '',
title: '',
// State
isDirty: false,
error: false,
errorMessage: '',
validation: null,
sha256: null
// folderPath added by the caller.
};
}
return {
handle: fileHandle,
folderHandle: folderHandle,
originalFilename: split.name,
extension: split.extension,
size: file.size,
lastModified: file.lastModified,
// Editable fields
trackingNumber: '',
revision: '',
status: '',
title: '',
// State
isDirty: false,
error: false,
errorMessage: '',
validation: null,
sha256: null
// folderPath will be added later in buildTree
};
} catch (err) {
console.error('Error reading file:', fileHandle.name, err);
return null;
// ── Workspace snapshot (scan once, resume without re-walking the FS) ────
// Serialize the completed scan to compact JSON (short keys: large trees).
// Zip-root nodes are NOT preserved as expandable folders — the .zip stays a
// plain file in its parent (classifying inside archives is out of scope for
// a persisted workspace).
function snapshotTree() {
function serFile(f) { return { o: f.originalFilename, e: f.extension, p: f.folderPath }; }
function serNode(n) {
var o = { n: n.name, p: n.path };
if (n.files && n.files.length) o.f = n.files.map(serFile);
var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; });
if (realKids.length) o.c = realKids.map(serNode);
// Record scan progress so an interrupted scan can resume: 'children'
// = direct entries fully read (kids may still be pending); anything
// unfinished (pending/scanning/zip) → 'pending' to re-read. 'done'
// is the default and omitted.
var st = n.scanState;
if (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending';
return o;
}
return (window.app.folderTree || []).map(serNode);
}
// Rebuild window.app.folderTree from a snapshot — handle-less nodes, marked
// 'done', subtree totals recomputed. Handles are resolved lazily from the
// workspace root handle at copy/preview time.
function loadSnapshot(snap) {
function deFile(sf) {
return {
handle: null, folderHandle: null,
originalFilename: sf.o, extension: sf.e,
size: null, lastModified: null,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
folderPath: sf.p,
};
}
function deNode(sn, parent) {
var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent);
node.handle = null;
node.scanState = sn.s || 'done'; // 'pending'/'children' resume on reconnect
node.expanded = false;
node.files = (sn.f || []).map(deFile);
node.children = (sn.c || []).map(function (c) { return deNode(c, node); });
node.fileCount = node.files.length;
node.subdirCount = node.children.length;
return node;
}
var roots = (snap || []).map(function (sn) { return deNode(sn, null); });
if (roots[0]) roots[0].expanded = true;
(function totals(nodes) {
nodes.forEach(function (n) {
totals(n.children);
var rf = n.files.length, rd = n.children.length;
n.children.forEach(function (c) { rf += c.runFiles; rd += c.runDirs; });
n.runFiles = rf; n.runDirs = rd;
});
})(roots);
window.app.folderTree = roots;
if (window.app.modules.store && window.app.modules.store.setFolderTree) {
window.app.modules.store.setFolderTree(roots);
}
return roots;
}
// ── Lazy handle resolution (snapshot files carry paths, not handles) ────
function relFromRoot(p) { var i = (p || '').indexOf('/'); return i < 0 ? '' : p.slice(i + 1); }
async function resolveDirHandle(rootHandle, relPath) {
var cur = rootHandle;
var parts = (relPath || '').split('/').filter(Boolean);
for (var i = 0; i < parts.length; i++) { cur = await cur.getDirectoryHandle(parts[i]); }
return cur;
}
// Resolve (and cache) a file object's handle from the workspace root.
async function resolveFileHandle(rootHandle, fileObj) {
if (fileObj.handle) return fileObj.handle;
var dir = await resolveDirHandle(rootHandle, relFromRoot(fileObj.folderPath));
var name = zddc.joinExtension(fileObj.originalFilename, fileObj.extension);
var h = await dir.getFileHandle(name);
fileObj.handle = h;
fileObj.folderHandle = dir;
return h;
}
// Resume an interrupted scan: walk the loaded tree for 'pending' folders,
// resolve their handles from the (reconnected) root, and drain only those —
// already-scanned folders are left alone. Returns true if work was done.
async function resumeScan(rootHandle) {
if (!rootHandle) return false;
var pend = [];
(function walk(ns) {
(ns || []).forEach(function (n) {
if (n.scanState === 'pending') pend.push(n);
else walk(n.children);
});
})(window.app.folderTree || []);
if (!pend.length) return false;
var myGen = ++scanGen;
zipCache.clear();
scanStats = { folders: 0, files: 0, current: '', done: false, startedAt: Date.now() };
var ticker = setInterval(function () {
if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; }
updateScanStatus();
}, 1000);
for (var i = 0; i < pend.length; i++) {
try { pend[i].handle = await resolveDirHandle(rootHandle, relFromRoot(pend[i].path)); }
catch (e) { pend[i].scanState = 'done'; reportScanError(pend[i].path, e); }
}
await drainQueue(pend.filter(function (n) { return n.handle; }), myGen, SCAN_CONCURRENCY);
clearInterval(ticker);
if (myGen !== scanGen) return true;
scanStats.done = true;
scanStats.current = '';
flushRender();
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast('Resumed scan complete — ' + scanStats.folders + ' folders, '
+ scanStats.files + ' files added in ' + elapsedStr() + '.', 'success');
}
return true;
}
// Export module
@ -656,7 +881,12 @@
scanDirectory,
ensureScanned,
getZipCache,
extractZip
extractZip,
snapshotTree,
loadSnapshot,
resolveFileHandle,
resolveDirHandle,
resumeScan
};
})();

View file

@ -0,0 +1,426 @@
/**
* 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' — the active axis
function init() {
if (initialized) return;
initialized = true;
els = {
trackingTab: document.getElementById('trackingTab'),
transmittalTab: document.getElementById('transmittalTab'),
trackingPanel: document.getElementById('trackingPanel'),
transmittalPanel: document.getElementById('transmittalPanel'),
trackingTree: document.getElementById('trackingTree'),
transmittalTree: document.getElementById('transmittalTree'),
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'); });
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);
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 = {};
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);
});
return { tracking: byT, transmittal: byX };
}
function showTab(which) {
var t = which === 'transmittal';
currentTab = t ? 'transmittal' : 'tracking';
els.trackingTab.classList.toggle('active', !t);
els.transmittalTab.classList.toggle('active', t);
els.trackingPanel.hidden = t;
els.transmittalPanel.hidden = !t;
// The "Hide Assigned" filter on the source tree is 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' : '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);
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;
}
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.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree';
row.dataset.key = d.key; // for cross-tree reveal
row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
row.appendChild(el('span', 'tfile__arrow', '→'));
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
box.appendChild(row);
});
return box;
}
// Tracking tree (recursive)
function renderTrackingInto(container, nodes, placedMap) {
container.textContent = '';
if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
return;
}
nodes.forEach(function (n) { container.appendChild(trackingNode(n, placedMap)); });
}
function trackingNode(n, placedMap) {
var isLeaf = (n.children || []).length === 0;
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
wrap.dataset.id = n.id;
var row = el('div', 'tnode__row');
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾'));
if (!isLeaf) toggle.dataset.act = 'toggle';
row.appendChild(toggle);
row.appendChild(el('span', 'tnode__name', n.name));
var placed = placedMap[n.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child folder' },
{ act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed));
if (!isLeaf && !collapsed[n.id]) {
var kids = el('div', 'tnode__children');
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); });
wrap.appendChild(kids);
}
return wrap;
}
// 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) { container.appendChild(partyNode(p, placedMap)); });
}
function partyNode(party, placedMap) {
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);
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) {
sw.appendChild(binNode(bin, placedMap));
});
wrap.appendChild(sw);
});
return wrap;
}
function binNode(bin, placedMap) {
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)'));
var placed = placedMap[bin.id] || [];
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
wrap.appendChild(row);
if (placed.length) wrap.appendChild(fileList(placed));
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;
}
// ── events ─────────────────────────────────────────────────────────────
function closestNodeId(target) {
var n = target.closest('.tnode');
return n ? n.dataset.id : null;
}
function revealInSource(e) {
var tf = e.target.closest('.tfile');
if (tf && tf.dataset.key && window.app.modules.tree.revealFile) {
window.app.modules.tree.revealFile(tf.dataset.key);
return true;
}
return false;
}
function onTrackingClick(e) {
if (revealInSource(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
var id = closestNodeId(btn);
if (act === 'toggle') { collapsed[id] = !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 (revealInSource(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 === '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) {
var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode';
var node = target.closest(sel);
if (!node || !node.dataset.id) return null;
return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node };
}
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) C().place(keys, t.id, axis);
});
}
// 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.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('.tnode[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,
reveal: reveal,
};
})();

View file

@ -5,11 +5,73 @@
(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;
}
// ── "Hide Assigned" filter (classify mode) ─────────────────────────────
// The goal in either target tab is to assign-or-exclude every file, so this
// collapses the left tree down to only what's left to deal with on the
// ACTIVE axis: hide files already assigned in the current tab (or excluded),
// and any folder whose whole (scanned) subtree is thereby empty.
var hideAssigned = false;
function setHideAssigned(on) { hideAssigned = !!on; render(); }
function activeAxis() {
var tt = window.app.modules.targetTree;
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
}
function fileDealtWith(file) {
var c = window.app.modules.classify;
var a = c.getAssignment(c.srcKeyForFile(file));
if (!a) return false;
if (a.excluded) return true;
return activeAxis() === 'transmittal' ? !!a.transmittalNodeId : !!a.trackingNodeId;
}
function subtreeRemaining(folder) {
var n = 0;
subtreeFiles(folder).forEach(function (f) { if (!fileDealtWith(f)) n++; });
return n;
}
// Hide a folder only when it's fully scanned (so we never hide one that may
// still reveal files) and nothing in its subtree remains to be dealt with.
function folderHidden(folder) {
if (!classifyOn() || !hideAssigned) return false;
if (folder.scanState && folder.scanState !== 'done') return false;
return subtreeRemaining(folder) === 0;
}
/**
* Render the folder tree
*/
function render() {
const container = window.app.dom.folderTree;
wireClassifyInteractions();
container.innerHTML = '';
if (window.app.folderTree.length === 0) {
@ -18,6 +80,7 @@
}
window.app.folderTree.forEach(folder => {
if (folderHidden(folder)) return;
const element = createFolderElement(folder);
container.appendChild(element);
});
@ -35,11 +98,15 @@
*/
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;
@ -47,17 +114,29 @@
frag.appendChild(document.createTextNode('('));
if (dDir > 0 || tDir > 0) {
appendPair(frag, dDir, tDir, done);
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, ');
}
appendPair(frag, dFile, tFile, done);
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
appendLabel(frag, tFile === 1 ? ' file)' : ' files)');
el.appendChild(frag);
}
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
// "+<total>" with the total in a span that greys + pulses until final.
// 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) {
frag.appendChild(document.createTextNode(String(direct)));
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');
@ -88,12 +167,28 @@
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 === '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 ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
@ -118,6 +213,12 @@
}
item.appendChild(icon);
// Classify mode: an aggregate state dot for the folder's subtree.
if (classifyOn()) {
const agg = aggregateState(subtreeFiles(folder));
if (agg) item.appendChild(stateDot(agg));
}
// Folder name
const name = document.createElement('span');
name.className = 'folder-name';
@ -172,15 +273,62 @@
const childrenDiv = document.createElement('div');
childrenDiv.className = 'folder-children';
folder.children.forEach(child => {
if (folderHidden(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, so they can be dropped onto the target trees.
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
const filesDiv = document.createElement('div');
filesDiv.className = 'folder-children folder-files';
folder.files.forEach(function (file) {
if (hideAssigned && fileDealtWith(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
*/
@ -567,6 +715,146 @@
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);
}
}
// ── context menu (exclude / include / clear) ───────────────────────────
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'); } });
}
} else {
var folder = findFolderByPath(folderEl.dataset.path);
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
if (!keys.length) return;
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); },
});
}
showMenu(e.clientX, e.clientY, items);
}
// Export module
window.app.modules.tree = {
render,
@ -574,6 +862,8 @@
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll
selectAll,
revealFile,
setHideAssigned
};
})();

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

@ -0,0 +1,304 @@
/**
* 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'),
};
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);
// 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';
[['open', 'Open'], ['rename', 'Rename'], ['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];
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 === 'delete') deleteWorkspace(id);
}
// ── 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,
onRescanned: onRescanned,
renderList: renderList,
activeId: function () { return activeId; },
};
})();

View file

@ -30,6 +30,12 @@
</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="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>
@ -51,22 +57,26 @@
<input type="checkbox" id="autoScrollCheckbox" checked>
Auto-scroll
</label>
<label class="checkbox-label">
<label class="checkbox-label" id="hideCompliantLabel">
<input type="checkbox" id="hideCompliantCheckbox">
Hide Compliant
</label>
<label class="checkbox-label" id="hideAssignedLabel" hidden
title="Hide files already assigned in the active tab (or excluded), and folders left empty">
<input type="checkbox" id="hideAssignedCheckbox">
Hide Assigned
</label>
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div>
</div>
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
<div id="folderTree" class="folder-tree">
<!-- Dynamically populated -->
</div>
<div class="resize-handle" id="treeResizeHandle"></div>
</aside>
<!-- Spreadsheet Table -->
<main class="spreadsheet-pane">
<!-- Spreadsheet Table (Rename in place) -->
<main class="spreadsheet-pane" id="spreadsheetPane" hidden>
<div class="pane-header">
<div class="pane-header-left">
<h3>Files</h3>
@ -127,20 +137,82 @@
</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>
</div>
<div class="pane-header-right">
<span id="classifyStats" class="file-stats"></span>
<span class="header-divider">|</span>
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">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>
<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>
<div id="transmittalTree" 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">
<h2>ZDDC Classifier</h2>
<p style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;">
<strong>This standalone tool is being absorbed into the Browse app.</strong>
Browse's <em>Grid</em> view-mode now provides the same spreadsheet
workflow alongside file navigation. This standalone build remains
available for offline use and air-gapped environments.
</p>
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>
<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>
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
</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">
@ -148,16 +220,7 @@
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
</div>
<ul class="welcome-list">
<li>Files already named to ZDDC format are parsed automatically</li>
<li>Edit cells directly, or copy columns to and from Excel</li>
<li>Real-time validation highlights non-compliant names</li>
<li>Rename one file or all modified files at once</li>
</ul>
<p>Click <strong>Use Local Directory</strong> to begin.</p>
<p class="note">This application works entirely in your browser. No data is transmitted to any server.</p>
<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>

View file

@ -51,6 +51,10 @@ export default defineConfig({
name: 'classifier',
testMatch: 'classifier.spec.js',
},
{
name: 'classify',
testMatch: 'classify.spec.js',
},
{
name: 'browse',
testMatch: 'browse.spec.js',

View file

@ -3,23 +3,75 @@
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
/* Toast STACK bottom-right, newest at the bottom. The container is
click-through (pointer-events:none) so the gaps don't block the page; each
toast + button re-enables pointer events. */
.zddc-toasts {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
pointer-events: none;
}
/* "Clear all" — shown above the stack when 2+ toasts are present. */
.zddc-toasts__clear {
pointer-events: auto;
align-self: flex-end;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
cursor: pointer;
}
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
.zddc-toast {
position: relative;
pointer-events: auto;
background: var(--bg);
color: var(--text);
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
max-width: 420px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
/* Message text — selectable + copyable; long/multi-line errors wrap. */
.zddc-toast__msg {
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-word;
}
/* Per-toast dismiss. */
.zddc-toast__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
border: none;
background: transparent;
color: var(--text-muted, #888);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0 0.15rem;
}
.zddc-toast__close:hover { color: var(--text); }
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }

View file

@ -1,72 +1,120 @@
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
// tool via window.zddc.toast(msg, level, opts).
//
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
// they stay until the user dismisses them (per-toast × or a "Clear all"
// button) so the message can be read, selected, and copied while
// troubleshooting. info/success toasts auto-dismiss. The message text is
// always selectable.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
// Levels that persist until the user dismisses them (troubleshooting).
var STICKY = { error: true, warning: true };
function container() {
var c = document.getElementById('zddc-toasts');
if (c) return c;
c = document.createElement('div');
c.id = 'zddc-toasts';
c.className = 'zddc-toasts';
document.body.appendChild(c);
return c;
}
// Show/hide a "Clear all" control when 2+ toasts are stacked.
function refreshClearAll(c) {
var bar = c.querySelector('.zddc-toasts__clear');
var count = c.querySelectorAll('.zddc-toast').length;
if (count >= 2) {
if (!bar) {
bar = document.createElement('button');
bar.type = 'button';
bar.className = 'zddc-toasts__clear';
bar.textContent = 'Clear all';
bar.addEventListener('click', function () {
var all = c.querySelectorAll('.zddc-toast');
for (var i = 0; i < all.length; i++) dismiss(all[i]);
});
c.insertBefore(bar, c.firstChild);
}
} else if (bar) {
bar.remove();
}
}
function dismiss(el) {
if (el._dismissed) return;
el._dismissed = true;
if (el._timer) clearTimeout(el._timer);
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
refreshClearAll(container());
}, FADE_MS);
}
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var c = container();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Selectable, copyable message text (its own element so clicking to
// select doesn't dismiss the toast — only the × does).
var msg = document.createElement('span');
msg.className = 'zddc-toast__msg';
msg.textContent = message == null ? '' : String(message);
el.appendChild(msg);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
var close = document.createElement('button');
close.type = 'button';
close.className = 'zddc-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', function () { dismiss(el); });
el.appendChild(close);
c.appendChild(el);
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
// auto-dismiss after the (overridable) duration.
var sticky = opts.durationMs === 0 ||
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
if (!sticky) {
var dur = typeof opts.durationMs === 'number'
? opts.durationMs : DEFAULT_DURATION_MS;
el._timer = setTimeout(function () { dismiss(el); }, dur);
}
refreshClearAll(c);
return el;
}
window.zddc.toast = toast;
// Route window.alert() calls into the toast helper. Every tool has
// accumulated some `alert(...)` sites for error reporting; rather
// than touch each one, intercept globally so they're non-blocking
// and ARIA-announced consistently. Native alert is preserved on
// window.alertNative for the rare case where a truly modal block
// is needed (e.g. before navigating away with unsaved changes).
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
// consistent). Native alert preserved on window.alertNative. alert() maps
// to an error toast (sticky) since that's its usual purpose.
if (typeof window.alert === 'function' && !window.alertNative) {
window.alertNative = window.alert.bind(window);
window.alert = function (msg) {

619
tests/classify.spec.js Normal file
View file

@ -0,0 +1,619 @@
/**
* Tests for the classifier "Classify & Copy" state model
* (classifier/js/classify.js) the pure derive/assignment logic.
*
* Runs against the compiled classifier/dist/classifier.html, driving
* window.app.modules.classify via page.evaluate. No File System Access API is
* needed: synthetic file objects ({folderPath, originalFilename, extension})
* carry everything deriveTarget consults. Drag-and-drop and the actual copy
* stay manual (Playwright can't drive the directory picker).
*
* Build first: sh classifier/build.sh
*/
import { test, expect } from '@playwright/test';
import * as path from 'path';
const PAGE = 'file://' + path.resolve('classifier/dist/classifier.html');
test.beforeEach(async ({ page }) => {
await page.goto(PAGE, { waitUntil: 'load' });
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
expect(ok).toBe(true);
await page.evaluate(() => {
window.app.modules.classify.reset();
// No directory is opened in these tests; dismiss the welcome overlay so
// it doesn't intercept clicks on the in-pane controls.
const w = document.getElementById('welcomeScreen');
if (w) w.classList.add('hidden');
});
});
// Build a tracking chain of folders and place one file in the deepest;
// return the derived target.
async function deriveInTracking(page, segments, file) {
return page.evaluate(({ segments, file }) => {
const c = window.app.modules.classify;
let parent = null;
for (const name of segments) parent = c.addTrackingNode(parent, name);
const key = c.srcKeyForFile(file);
c.place([key], parent, 'tracking');
return c.deriveTarget(file);
}, { segments, file });
}
const FILE = { folderPath: 'Root/Sub', originalFilename: 'Foundation Plan', extension: 'pdf' };
test('tracking: ancestors join with "-", parent leaf = REV (STATUS), title from name', async ({ page }) => {
const d = await deriveInTracking(page, ['ACME-PROJ', 'MECH', '0001', 'A (IFR)'], FILE);
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
expect(d.revision).toBe('A');
expect(d.status).toBe('IFR');
expect(d.title).toBe('Foundation Plan');
expect(d.filename).toBe('ACME-PROJ-MECH-0001_A (IFR) - Foundation Plan.pdf');
expect(d.trackingLeaf).toBe(true);
});
test('tracking: a single full-number folder also works', async ({ page }) => {
const d = await deriveInTracking(page, ['ACME-PROJ-MECH-0001', 'B (IFC)'], FILE);
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
expect(d.revision).toBe('B');
expect(d.status).toBe('IFC');
expect(d.filename).toBe('ACME-PROJ-MECH-0001_B (IFC) - Foundation Plan.pdf');
});
test('tracking: parked in an intermediate (non-leaf) folder is flagged incomplete', async ({ page }) => {
const d = await page.evaluate((file) => {
const c = window.app.modules.classify;
const proj = c.addTrackingNode(null, 'ACME-PROJ');
const mech = c.addTrackingNode(proj, 'MECH');
c.addTrackingNode(mech, '0001'); // child exists → MECH is not a leaf
const key = c.srcKeyForFile(file);
c.place([key], mech, 'tracking'); // file parked at MECH
return c.deriveTarget(file);
}, FILE);
expect(d.trackingLeaf).toBe(false);
expect(d.errors.join(' ')).toContain('leaf');
expect(d.complete).toBe(false);
});
test('tracking: unknown status code is reported', async ({ page }) => {
const d = await deriveInTracking(page, ['ACME', 'Z (BOGUS)'], FILE);
expect(d.status).toBe('BOGUS');
expect(d.errors.join(' ')).toContain('unknown status');
expect(d.complete).toBe(false);
});
test('tracking: leaf with no "(STATUS)" parens is flagged', async ({ page }) => {
const d = await deriveInTracking(page, ['ACME', '0001'], FILE);
expect(d.status).toBe('');
expect(d.filename).toBe(''); // formatFilename needs a status
expect(d.errors.join(' ')).toContain('STATUS');
});
test('title: original ZDDC name reuses its title; titleOverride wins', async ({ page }) => {
const zddcFile = { folderPath: 'Root', originalFilename: 'X-1_A (IFR) - Real Title', extension: 'pdf' };
const def = await page.evaluate((f) => window.app.modules.classify.defaultTitle(f), zddcFile);
expect(def).toBe('Real Title');
const overridden = await page.evaluate((f) => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile(f);
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
c.place([key], leaf, 'tracking');
c.setTitleOverride(key, 'Custom Title');
return c.deriveTarget(f);
}, FILE);
expect(overridden.title).toBe('Custom Title');
expect(overridden.filename).toContain(' - Custom Title.pdf');
});
test('transmittal: party/slot/bin → output path; full target composes', async ({ page }) => {
const d = await page.evaluate((file) => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile(file);
// axis 1
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
c.place([key], leaf, 'tracking');
// axis 2
const party = c.addParty('ClientCorp');
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([key], bin, 'transmittal');
return { d: c.deriveTarget(file), binName: c.getNode(bin).name };
}, FILE);
expect(d.binName).toBe('2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
expect(d.d.party).toBe('ClientCorp');
expect(d.d.slot).toBe('received');
expect(d.d.outPath).toBe('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
expect(d.d.complete).toBe(true);
});
test('exclude clears placements and reports excluded state', async ({ page }) => {
const r = await page.evaluate((file) => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile(file);
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
c.place([key], leaf, 'tracking');
c.setExcluded([key], true);
return { state: c.fileState(file), d: c.deriveTarget(file) };
}, FILE);
expect(r.state).toBe('excluded');
expect(r.d.excluded).toBe(true);
});
// ── Phase 2: mode toggle + target-tree rendering (UI) ──────────────────────
test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => {
await page.click('#modeClassifyBtn');
expect(await page.locator('#targetPane').isHidden()).toBe(false);
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true);
await page.click('#modeRenameBtn');
expect(await page.locator('#targetPane').isHidden()).toBe(true);
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
});
test('target tree renders structure and tabs switch', async ({ page }) => {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
const c = window.app.modules.classify;
const acme = c.addTrackingNode(null, 'ACME-PROJ');
c.addTrackingNode(acme, 'A (IFR)');
const party = c.addParty('ClientCorp');
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
});
// Tracking panel visible by default with the nodes rendered.
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible();
// Switch to transmittal tab.
await page.click('#transmittalTab');
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
});
test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => {
await page.click('#modeClassifyBtn');
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
await page.click('#addTrackingRootBtn');
// "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels).
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible();
});
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
await page.click('#modeClassifyBtn');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
window.app.modules.targetTree.render();
const row = document.querySelector('#trackingTree .tnode--leaf .tnode__row');
const key = 'Sub/foundation.pdf';
window.app.modules.dnd.setDrag([key]);
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
});
expect(r.assigned).toBe(r.leaf);
});
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
await page.click('#modeClassifyBtn');
await page.click('#transmittalTab');
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
const party = c.addParty('ClientCorp');
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
window.app.modules.targetTree.render();
const key = 'Sub/foundation.pdf';
// Drop on the bin → assigned.
const binRow = document.querySelector('#transmittalTree .tnode--bin .tnode__row');
window.app.modules.dnd.setDrag([key]);
binRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
const afterBin = c.assignmentFor(key).transmittalNodeId;
// Reset, then drop on the party row → ignored (only bins are targets).
c.place([key], null, 'transmittal');
const partyRow = document.querySelector('#transmittalTree .tnode--party > .tnode__row');
window.app.modules.dnd.setDrag([key]);
partyRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
const afterParty = c.assignmentFor(key).transmittalNodeId;
return { afterBin, bin, afterParty };
});
expect(r.afterBin).toBe(r.bin);
expect(r.afterParty).toBe(null);
});
// ── Phase 4: left-tree markers, exclude, cross-tree find ───────────────────
// Inject a synthetic scanned tree (no FS Access needed) and render it.
async function withSourceTree(page) {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }],
children: [], fileCount: 1, subdirCount: 0, runFiles: 1, runDirs: 0,
}];
window.app.modules.tree.render();
});
}
test('source file rows render with a state dot in classify mode', async ({ page }) => {
await withSourceTree(page);
await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'Foundation Plan.pdf' })).toBeVisible();
await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible();
});
test('classify: single-click a source file triggers preview', async ({ page }) => {
await page.click('#modeClassifyBtn');
const previewed = await page.evaluate(() => {
let got = null;
window.app.modules.preview.previewFile = (f) => { got = f.originalFilename; }; // capture, skip popup
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }], children: [],
}];
window.app.modules.tree.render();
document.querySelector('#folderTree .file-item').click();
return got;
});
expect(previewed).toBe('Foundation Plan');
});
test('classify: a folder with files but no subfolders is expandable (drag source)', async ({ page }) => {
await page.click('#modeClassifyBtn');
await page.evaluate(() => {
window.app.folderTree = [{
name: 'Leaf', path: 'Leaf', expanded: false, scanState: 'done',
files: [{ originalFilename: 'x', extension: 'pdf', folderPath: 'Leaf' }], children: [],
}];
window.app.modules.tree.render();
});
const toggle = page.locator('#folderTree .folder-item .folder-toggle').first();
await expect(toggle).toHaveText('▶'); // file-only folder still gets an expand arrow
await toggle.click();
await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'x.pdf' })).toBeVisible();
});
test('placing a file turns its dot (and the folder aggregate) done', async ({ page }) => {
await withSourceTree(page);
await page.evaluate(() => {
const c = window.app.modules.classify;
const realKey = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([realKey], leaf, 'tracking');
c.place([realKey], bin, 'transmittal');
window.app.modules.tree.render();
});
await expect(page.locator('#folderTree .file-item .cl-dot--done')).toBeVisible();
await expect(page.locator('#folderTree .folder-item .cl-dot--done')).toBeVisible();
});
test('context-menu exclude marks the file excluded', async ({ page }) => {
await withSourceTree(page);
await page.locator('#folderTree .file-item').click({ button: 'right' });
await expect(page.locator('.cl-menu')).toBeVisible();
await page.locator('.cl-menu__item', { hasText: 'Exclude from copy' }).click();
await expect(page.locator('#folderTree .file-item.excluded')).toBeVisible();
const excluded = await page.evaluate(() => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
return c.getAssignment(key).excluded;
});
expect(excluded).toBe(true);
});
test('cross-tree reveal: source→target switches to the placed axis', async ({ page }) => {
await withSourceTree(page);
const ok = await page.evaluate(() => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'issued', { date: '2026-03-14', type: 'SUB', seq: '0001' });
c.place([key], bin, 'transmittal');
window.app.modules.targetTree.reveal(key); // should switch to transmittal tab
return !document.getElementById('transmittalPanel').hidden;
});
expect(ok).toBe(true);
});
// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
test('copy: writes the file, skips an identical re-copy, flags a differing target', async ({ page }) => {
await page.click('#modeClassifyBtn');
const res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy;
const store = {};
const fileHandleFor = (full) => ({
getFile: async () => new File([store[full] != null ? store[full] : ''], full.split('/').pop()),
createWritable: async () => ({ write: async (d) => { store[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
});
const mockDir = (prefix) => ({
name: prefix || 'out',
getDirectoryHandle: async (name) => mockDir((prefix ? prefix + '/' : '') + name),
getFileHandle: async (name, opts) => {
const full = (prefix ? prefix + '/' : '') + name;
if (!opts || !opts.create) { if (!(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
return fileHandleFor(full);
},
});
const srcFile = (name, content) => {
const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.');
return { originalFilename: stem, extension: ext, folderPath: 'Root', handle: { getFile: async () => new File([content], name) } };
};
const f = srcFile('foundation.pdf', 'AAA');
window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [f], children: [], runFiles: 1 }];
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
const key = c.srcKeyForFile(f);
c.place([key], leaf, 'tracking'); c.place([key], bin, 'transmittal');
const out = mockDir('');
const first = await copy.copyTo(out, copy.plan());
const second = await copy.copyTo(out, copy.plan()); // identical → skipped
const tkey = Object.keys(store)[0];
store[tkey] = 'DIFFERENT'; // tamper target
const third = await copy.copyTo(out, copy.plan()); // differs → left alone
return { firstCopied: first.copied, secondSkipped: second.skipped, thirdDiffer: third.differ, keys: Object.keys(store) };
});
expect(res.firstCopied).toBe(1);
expect(res.secondSkipped).toBe(1);
expect(res.thirdDiffer).toBe(1);
expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true);
});
test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => {
await page.click('#modeClassifyBtn');
const conflicts = await page.evaluate(() => {
const c = window.app.modules.classify, copy = window.app.modules.copy;
const srcFile = (name, folder) => {
const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.');
return { originalFilename: stem, extension: ext, folderPath: folder, handle: { getFile: async () => new File(['x'], name) } };
};
const f1 = srcFile('plan.pdf', 'Root/a');
const f2 = srcFile('plan.pdf', 'Root/b'); // same name, different folder → same derived output
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [],
children: [
{ name: 'a', path: 'Root/a', files: [f1], children: [] },
{ name: 'b', path: 'Root/b', files: [f2], children: [] },
],
}];
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([c.srcKeyForFile(f1)], leaf, 'tracking'); c.place([c.srcKeyForFile(f1)], bin, 'transmittal');
c.place([c.srcKeyForFile(f2)], leaf, 'tracking'); c.place([c.srcKeyForFile(f2)], bin, 'transmittal');
return copy.conflictsIn(copy.plan()).conflicts.length;
});
expect(conflicts).toBe(1);
});
// ── Workspaces: snapshot, persistence, copy-from-snapshot ──────────────────
test('snapshot: serialize + rebuild preserves structure, marks done, drops handles', async ({ page }) => {
const r = await page.evaluate(() => {
const sc = window.app.modules.scanner;
window.app.folderTree = [{
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Root' }],
children: [{
name: 'sub', path: 'Root/sub', scanState: 'done',
files: [{ originalFilename: 'b', extension: 'txt', folderPath: 'Root/sub' }], children: [],
}],
}];
const json = JSON.stringify(sc.snapshotTree());
window.app.folderTree = [];
sc.loadSnapshot(JSON.parse(json));
const root = window.app.folderTree[0];
return {
rootName: root.name, done: root.scanState === 'done', runFiles: root.runFiles,
subFile: root.children[0].files[0].originalFilename,
subPath: root.children[0].files[0].folderPath,
handleNull: root.children[0].files[0].handle === null,
};
});
expect(r.rootName).toBe('Root');
expect(r.done).toBe(true);
expect(r.runFiles).toBe(2);
expect(r.subFile).toBe('b');
expect(r.subPath).toBe('Root/sub');
expect(r.handleNull).toBe(true);
});
test('scan: resume scans only the pending folders from a snapshot', async ({ page }) => {
const r = await page.evaluate(async () => {
const sc = window.app.modules.scanner;
// Snapshot: Root (done) with a child 'sub' left pending.
sc.loadSnapshot([{ n: 'Root', p: 'Root', c: [{ n: 'sub', p: 'Root/sub', s: 'pending' }] }]);
// Mock root handle: Root/sub contains one file.
const subDir = { kind: 'directory', name: 'sub', values: async function* () { yield { kind: 'file', name: 'x.pdf' }; } };
const root = {
kind: 'directory', name: 'Root',
getDirectoryHandle: async (n) => { if (n === 'sub') return subDir; const e = new Error('NF'); e.name = 'NotFoundError'; throw e; },
};
window.app.rootHandle = root;
const did = await sc.resumeScan(root);
const sub = window.app.folderTree[0].children[0];
return { did, subState: sub.scanState, subFiles: sub.files.length, name: sub.files[0] && sub.files[0].originalFilename };
});
expect(r.did).toBe(true);
expect(r.subState).toBe('done');
expect(r.subFiles).toBe(1);
expect(r.name).toBe('x');
});
test('persist: workspace put / list / get / delete round-trip', async ({ page }) => {
const r = await page.evaluate(async () => {
const P = window.app.modules.persist;
const id = 'test-ws-1';
await P.putWorkspace(
{ id, name: 'WS', rootName: 'Root', createdAt: 1, updatedAt: 2, summary: { files: 3, done: 1, excluded: 0 } },
{ id, rootHandle: null, tree: [{ n: 'Root', p: 'Root' }], classify: { assignments: {}, trackingTree: [], transmittalTree: [] } });
const meta = (await P.listWorkspaces()).filter((w) => w.id === id)[0];
const data = await P.getWorkspace(id);
await P.deleteWorkspace(id);
const goneAfter = (await P.listWorkspaces()).filter((w) => w.id === id).length;
return { name: meta && meta.name, files: meta && meta.summary.files, treeLen: data && data.tree.length, goneAfter };
});
expect(r.name).toBe('WS');
expect(r.files).toBe(3);
expect(r.treeLen).toBe(1);
expect(r.goneAfter).toBe(0);
});
test('persist: classify-only autosave preserves the stored snapshot', async ({ page }) => {
const r = await page.evaluate(async () => {
const P = window.app.modules.persist;
const id = 'test-ws-2';
await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 1, summary: { files: 1, done: 0, excluded: 0 } },
{ id, rootHandle: null, tree: [{ n: 'R', p: 'R' }], classify: {} });
await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 2, summary: { files: 1, done: 1, excluded: 0 } },
{ id, classify: { assignments: { x: {} } } }); // no tree → must preserve
const data = await P.getWorkspace(id);
await P.deleteWorkspace(id);
return { treePreserved: !!(data && data.tree && data.tree.length === 1), hasClassify: !!(data && data.classify.assignments) };
});
expect(r.treePreserved).toBe(true);
expect(r.hasClassify).toBe(true);
});
test('copy: snapshot files (no handle) resolve from the workspace root', async ({ page }) => {
await page.click('#modeClassifyBtn');
const res = await page.evaluate(async () => {
const c = window.app.modules.classify, copy = window.app.modules.copy;
const srcStore = { 'Sub/foundation.pdf': 'AAA' };
const mkSrcDir = (prefix) => ({
name: prefix || 'Root',
getDirectoryHandle: async (n) => mkSrcDir((prefix ? prefix + '/' : '') + n),
getFileHandle: async (n) => {
const full = (prefix ? prefix + '/' : '') + n;
if (!(full in srcStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
return { getFile: async () => new File([srcStore[full]], n) };
},
queryPermission: async () => 'granted', requestPermission: async () => 'granted',
});
window.app.rootHandle = mkSrcDir('');
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root/Sub', handle: null };
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [{ name: 'Sub', path: 'Root/Sub', files: [f], children: [] }] }];
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');
const outStore = {};
const mkOut = (prefix) => ({
name: prefix || 'out',
getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n),
getFileHandle: async (n, opts) => {
const full = (prefix ? prefix + '/' : '') + n;
if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
return {
getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n),
createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
};
},
});
const s = await copy.copyTo(mkOut(''), copy.plan());
return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('foundation.pdf')) };
});
expect(res.copied).toBe(1);
expect(res.wrote).toBe(true);
expect(res.content).toBe('AAA');
});
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
const after = await page.evaluate((file) => {
const c = window.app.modules.classify;
const key = c.srcKeyForFile(file);
const acme = c.addTrackingNode(null, 'ACME');
const leaf = c.addTrackingNode(acme, 'A (IFR)');
c.place([key], leaf, 'tracking');
c.deleteNode(acme); // removes leaf too
return c.fileState(file);
}, FILE);
expect(after).toBe('none');
});
test('expandFolderPattern: alternation, zero-padded ranges, cartesian product', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
return {
plain: c.expandFolderPattern('Plain'),
alt: c.expandFolderPattern('A-{PM,EL,EM}'),
range: c.expandFolderPattern('X-{0001-0002,0005}'),
full: c.expandFolderPattern('BMB-187023-{PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)'),
unbalanced: c.expandFolderPattern('Lit-{oops'),
};
});
expect(r.plain).toEqual(['Plain']);
expect(r.alt).toEqual(['A-PM', 'A-EL', 'A-EM']);
expect(r.range).toEqual(['X-0001', 'X-0002', 'X-0005']);
expect(r.full.length).toBe(9);
expect(r.full[0]).toBe('BMB-187023-PM-MOM-0001_A (IFR)');
expect(r.full).toContain('BMB-187023-EM-MOM-0005_A (IFR)');
expect(r.unbalanced).toEqual(['Lit-{oops']); // unbalanced brace kept literal
});
test('Hide Assigned: hides files dealt-with on the active axis and folders left empty', async ({ page }) => {
await page.click('#modeClassifyBtn');
const before = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
const fA = { originalFilename: 'a1', extension: 'pdf', folderPath: 'A' };
window.app.folderTree = [
{ name: 'A', path: 'A', expanded: true, scanState: 'done', files: [fA], children: [] },
{ name: 'B', path: 'B', expanded: true, scanState: 'done',
files: [{ originalFilename: 'b1', extension: 'pdf', folderPath: 'B' }], children: [] },
];
const t = c.addTrackingNode(null, 'TN'); // assign A's file on the tracking axis
c.place([c.srcKeyForFile(fA)], t, 'tracking');
window.app.modules.tree.render();
return document.querySelectorAll('#folderTree .file-item').length;
});
expect(before).toBe(2); // nothing hidden yet
const after = await page.evaluate(() => {
window.app.modules.tree.setHideAssigned(true);
return {
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map(e => e.textContent),
folderA: !!document.querySelector('#folderTree .folder-item[data-path="A"]'),
folderB: !!document.querySelector('#folderTree .folder-item[data-path="B"]'),
};
});
expect(after.folderA).toBe(false); // A's only file is assigned on the active (tracking) axis → folder hidden
expect(after.folderB).toBe(true); // B still needs a tracking number → stays
expect(after.files).toEqual(['b1.pdf']);
});
test('parseFolderLevels: split by - then a final _ into nested levels', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
return {
three: c.parseFolderLevels('CPO-0001_0 (IFU)'),
full: c.parseFolderLevels('BMB-187023-PM-MOM-0001_A (IFR)'),
leafOnly: c.parseFolderLevels('A (IFR)'),
noRev: c.parseFolderLevels('CPO-0001'),
};
});
expect(r.three).toEqual(['CPO', '0001', '0 (IFU)']);
expect(r.full).toEqual(['BMB', '187023', 'PM', 'MOM', '0001', 'A (IFR)']);
expect(r.leafOnly).toEqual(['A (IFR)']);
expect(r.noRev).toEqual(['CPO', '0001']);
});
test('add-folder builds a nested chain sharing common ancestors', async ({ page }) => {
const r = await page.evaluate(() => {
const c = window.app.modules.classify;
c.reset();
// Brace-expand then nest: two leaves under shared CPO/000x ancestors.
c.expandFolderPattern('CPO-{0001,0002}_0 (IFU)').forEach((nm) =>
c.addTrackingPath(null, c.parseFolderLevels(nm)));
return JSON.parse(JSON.stringify(c.getTrackingTree(), (k, v) => (k === 'id' ? undefined : v)));
});
expect(r.length).toBe(1); // one shared CPO root
expect(r[0].name).toBe('CPO');
expect(r[0].children.map((n) => n.name)).toEqual(['0001', '0002']);
expect(r[0].children[0].children[0].name).toBe('0 (IFU)'); // leaf rev under each number
});

View file

@ -433,8 +433,7 @@ fence is computed by `PolicyChain.VisibleStart`.
The leaf-overrides-ancestor behaviour above is the in-process decider's only
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
OPA with the bundled `access_federal.rego` (or their own Rego); see
"External OPA" below.
OPA with their own Rego; see "External OPA" below.
#### The `inherit:` directive
@ -471,10 +470,10 @@ Behaviour:
fence; `inherit: false` does not change WORM behaviour. See
"Canonical-folder behaviour via `.zddc` keys" below.
**Federal posture and `inherit: false`.** The bundled federal Rego at
`--print-rego=federal` makes ancestor explicit-denies absolute and
therefore ignores `inherit: false` (allowing a leaf to widen access an
ancestor refused would defeat NIST AC-6). Operators who need fence-
**Federal posture and `inherit: false`.** An external OPA policy with
ancestor-deny-absolute (NIST AC-6) semantics makes ancestor explicit-denies
absolute and therefore ignores `inherit: false` (allowing a leaf to widen
access an ancestor refused would defeat NIST AC-6). Operators who need fence-
style "reset" semantics in a federal-track deployment should not use
the directive — instead, restructure the tree so the permissive
ancestor rule never appears.
@ -927,13 +926,14 @@ have to redo the gap analysis from scratch.
Identity-source-driven role assignment plumbs through unchanged
(the upstream proxy still asserts the email; role membership is
evaluated server-side against the cascade).
- ~~**Least-privilege bounding** (NIST AC-6)~~*closed.* Operators
deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego
(`zddc-server --print-rego=federal`) or their own variant. Under
that policy any ancestor explicit-deny is absolute and cannot be
overridden by a leaf grant. The in-process Go evaluator implements
only the commercial "leaf grants override ancestor denies" rule;
federal posture is exclusively the OPA path.
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *available via the OPA
path.* Operators deploy OPA (`ZDDC_OPA_URL`) pointed at their own
ancestor-deny-absolute Rego, under which any ancestor explicit-deny is
absolute and cannot be overridden by a leaf grant. The in-process Go
evaluator implements only the commercial "leaf grants override ancestor
denies" rule, and the bundled `--print-rego` skeleton models read-ACL
only (fail-closed for writes) — an AC-6 federal policy is the operator's
own Rego, not a shipped artifact.
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
authoritative sources (PIV cert subject, IdP-managed identity). Required:
documented integration with at least one IdP supporting federal identity
@ -1266,56 +1266,47 @@ cache lookup would be.
### Reference Rego policy
The `--print-rego` flag emits the bundled reference Rego policies. Two
variants ship:
The `--print-rego` flag emits the bundled reference Rego **skeleton**:
```sh
zddc-server --print-rego # standard cascade (commercial)
zddc-server --print-rego # read-ACL skeleton (fail-closed)
zddc-server --print-rego=standard # same
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
```
The standard variant mirrors internal-mode semantics exactly — leaf-
level allows can override an ancestor's deny (the cascade's intentional
delegation property). The federal variant is the strict-least-privilege
posture: any deny anywhere in the chain is absolute, no leaf-level
override possible. Federal customers running their own OPA can drop
the federal Rego in unchanged, or use either as a starting point for
further customization.
This skeleton models the **read-ACL cascade only** — glob patterns,
deny-first-within-a-level, default-deny once any `.zddc` exists, and the
leaf-allow-overrides-ancestor-deny delegation property. It is **NOT** a
semantic mirror of the internal Go decider: it does not implement per-verb
authorization (write/create/delete/admin), WORM zones, `roles:` resolution,
`inherit:false` fences, or standing config-edit. Because those are
unmodelled it is **fail-closed** — every non-read action is denied, and an
elevated admin (`input.user.is_active_admin`) is the only write-capable
principal. **Treat it as a starting point, not a turnkey policy:** an
operator relying on external OPA for write authorization must add the
missing semantics (and, for a NIST AC-6 ancestor-deny-absolute posture,
write that rule) before granting writes.
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
imports the OPA Go module **as a test-only dependency**, evaluates both
bundled Regos against fixture sets and asserts:
A build-time guard (`zddc/internal/policy/parity_test.go`,
`rego_failclosed_test.go`) imports the OPA Go module **as a test-only
dependency** and asserts the skeleton matches the internal Go evaluator on
the read-cascade dimension (`TestRegoParity_AllInternalCases`) and denies
every write verb (`TestReferenceRego_FailClosedOnWrites`). This is a
read-cascade + fail-closed guard, **not** a full-parity proof. The
test-only import means the production binary stays OPA-free (~13 MB) — the
OPA library is in `go.mod` but not in `go build`'s output.
- The standard Rego matches the internal Go evaluator on every documented
cascade scenario (`TestRegoParity_AllInternalCases`).
- The federal Rego agrees with the standard policy on every case where
no ancestor-deny intersects a leaf-allow, AND **disagrees** on the
cases where the AC-6 rule differs (`TestFederalRego_DivergencesFromStandard`).
This way both policies are guaranteed to behave as documented.
The production decider is pure Go (no library bloat, no extra process); the
wire format is OPA-canonical, so an operator can point an external OPA at it
and extend the skeleton. Typical extensions an operator writes on top:
The test-only import means the production binary stays OPA-free (still
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output.
This gives you both ends of the spectrum: a single OPA-aware codebase
where the production decider is pure Go (no library bloat, no extra
process), the wire format is OPA-canonical (just point an external OPA
at it and decisions delegate seamlessly), and the bundled reference
Rego is a parity-tested artifact you can ship alongside or extend.
Typical federal customizations on top of the bundled Rego:
- **Parent-deny-is-absolute** — flip the leaf-allow-overrides-parent-deny
rule for NIST AC-6 least-privilege posture.
- **Per-verb + WORM + roles + config-edit** — the semantics the skeleton
omits; required before the policy can authorize writes at all.
- **Parent-deny-is-absolute** — make any ancestor deny absolute for a NIST
AC-6 least-privilege posture.
- **Role-based access** — read additional input fields like
`input.user.roles` populated by the upstream proxy from SAML/OIDC
claims, and decide based on those instead of (or alongside) email.
- **Time-of-day or IP-range constraints** — Rego can read
`input.context.now` and request metadata for context-aware
decisions.
- **SIEM-shipped decision logs** — OPA's logging plugins emit every
decision in a structured format ready for Splunk Government, Elastic
Federal, etc.
`input.user.roles` populated by the upstream proxy from SAML/OIDC claims.
- **Time-of-day or IP-range constraints**, and **SIEM-shipped decision
logs** via OPA's logging plugins (Splunk Government, Elastic Federal, etc.).
### Reference deployment shapes
@ -1326,7 +1317,7 @@ No sidecar, no extra port, no extra binary.
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
nomad task, or systemd service on the same host), bind it to
`127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA
loads the deployment's bundled Rego policy from a configured source
loads the deployment's own Rego policy from a configured source
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
independently of zddc-server.
@ -1350,10 +1341,12 @@ gaps that warrant code, in addition to the federal-readiness items above:
CM-3 federal control above).
- Per-decision caching for external OPA mode (small TTL on (email, path)
to amortize the .archive listing's per-entry round-trip).
- A reference Rego bundle shipped alongside the binary that exactly
reproduces internal mode, plus a "federal-mode" variant that flips
the parent-deny-is-absolute toggle. Useful as a starting point for
customers who want to extend rather than write from scratch.
- A full-parity reference Rego (modelling per-verb / WORM / roles /
config-edit, not just the read-ACL skeleton shipped today) plus a
generative differential test against the internal decider — only worth
building if external OPA becomes a *supported deployment mode* rather than
a bring-your-own-policy escape hatch. See the skeleton's caveats under
"Reference Rego policy."
## Admin Debug Page

View file

@ -36,21 +36,18 @@ import (
var version = "dev"
func main() {
// --print-rego: dump a bundled reference Rego policy and exit.
// Cheap escape hatch for operators standing up an external OPA who want
// a parity-tested baseline as a starting point for customization.
// --print-rego: dump the bundled reference Rego skeleton and exit.
// A starting point for operators standing up an external OPA: it models
// the read-ACL cascade only and is fail-closed for writes (NOT a mirror
// of the internal decider), so it must be extended before granting writes.
//
// --print-rego → standard cascade (commercial default)
// --print-rego → read-ACL skeleton (fail-closed)
// --print-rego=standard → same
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
for _, a := range os.Args[1:] {
switch a {
case "--print-rego", "--print-rego=standard":
fmt.Print(policy.ReferenceRego)
return
case "--print-rego=federal":
fmt.Print(policy.FederalRego)
return
case "show-defaults", "--show-defaults":
// Emit the embedded baseline as a .zddc.zip (per-depth policy
// tree, "*" wildcard members) to stdout. Redirect into a bundle

View file

@ -0,0 +1,37 @@
package handler
import (
"path/filepath"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
)
// configWriteAction returns the action a write to absPath must be authorized
// as. The .zddc cascade file and the .zddc.zip bundle are policy, not content:
// mutating either is a VerbA operation requiring standing config-edit authority
// (IsConfigEditor — a subtree admin or `a`-verb holder, no elevation), which
// the decider enforces when the action is tagged ActionAdmin. For any other
// path the supplied default action is returned unchanged.
//
// This is the single predicate behind the per-verb escalation that previously
// lived inlined in serveFilePut/serveFileDelete (.zddc only) and was MISSING
// from serveFileMove — letting a MOVE plant or relocate a policy file with mere
// create/write authority. PUT/DELETE on a URL-visible .zddc.zip are also
// existence-gated to config-editors at dispatch (the bundle visibility gate in
// cmd/zddc-server), but a MOVE destination rides in the X-ZDDC-Destination
// header and never reaches that gate — so the authority bar must be enforced
// here, on the resolved target path, for every write verb.
//
// Matching is case-insensitive to align with HasReservedSidecar: ZDDC_ROOT may
// sit on a case-insensitive filesystem (SMB/CIFS/Azure Files) where `.ZDDC` /
// `.ZDDC.ZIP` resolve to the same files, and a case-varied target must not slip
// past the gate.
func configWriteAction(absPath, def string) string {
base := filepath.Base(absPath)
if strings.EqualFold(base, ".zddc") || strings.EqualFold(base, apps.BundleName) {
return policy.ActionAdmin
}
return def
}

View file

@ -377,10 +377,9 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
if existed {
action = policy.ActionWrite
}
// .zddc writes always require `a` (admin) regardless of create/overwrite.
if filepath.Base(abs) == ".zddc" {
action = policy.ActionAdmin
}
// Config files (.zddc / .zddc.zip) always require `a` (admin/config-edit)
// regardless of create/overwrite — see configWriteAction.
action = configWriteAction(abs, action)
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
return
@ -545,10 +544,9 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
return
}
action := policy.ActionDelete
if filepath.Base(abs) == ".zddc" {
action = policy.ActionAdmin
}
// Config files (.zddc / .zddc.zip) require `a` (admin/config-edit) to
// delete — see configWriteAction.
action := configWriteAction(abs, policy.ActionDelete)
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
return
}
@ -676,10 +674,18 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// ACL: source side requires `w` (rename mutates the source); dest
// side requires `c` (creates a new path). Cross-folder moves run
// both gates against potentially different chains.
if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
//
// Config files (.zddc / .zddc.zip) are policy, not content: relocating
// one mutates policy at BOTH ends (removing it from the source dir,
// installing it at the dest), so each side escalates to ActionAdmin —
// the same VerbA/config-edit bar PUT and DELETE enforce. Without this a
// caller holding only `w`/`c` could plant an attacker-controlled cascade
// (admins:/acl:) via the header-borne destination, which no dispatch
// gate inspects. See configWriteAction.
if !authorizeAction(cfg, w, r, srcAbs, srcURL, configWriteAction(srcAbs, policy.ActionWrite)) {
return
}
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
if !authorizeAction(cfg, w, r, dstAbs, dstURL, configWriteAction(dstAbs, policy.ActionCreate)) {
return
}
// If-Match concurrency applies to the source bytes — only meaningful for
@ -815,10 +821,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// - abs's parent is declared auto_own — every child mkdir under
// an auto-own folder (working/, staging/, archive/<party>/,
// archive/<party>/incoming/, …) gets the creator's grant.
// The fence (inherit:false) follows abs's own cascade level:
// per-user homes under working/ declare auto_own_fenced, so the
// generated .zddc is private; other auto-own positions are
// unfenced so ancestor grants still cascade through.
// The fence (inherit:false) follows abs's own cascade level via
// AutoOwnFencedAt. It is an opt-in the default tree does not set —
// the working/staging/incoming/reviewing party homes are auto-owned
// but UNFENCED, so ancestor grants (e.g. project_team cr) cascade
// through and they behave as shared team folders. An operator can
// set auto_own_fenced on a position to make it private.
if email != "" {
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)

View file

@ -949,3 +949,86 @@ func TestFileAPI_PreservesCase(t *testing.T) {
t.Errorf("PUT case NOT preserved (%v)", names)
}
}
// TestFileAPI_MoveOntoConfigRequiresConfigEdit pins the authorization parity
// between MOVE and PUT/DELETE for config files. alice@example.com holds rwcd
// (create+write) via the default *@example.com grant and is elevated — but is
// named in no admins: and holds no `a` verb, so she is NOT a config-editor.
// Moving a file ONTO a .zddc/.zddc.zip, or moving a .zddc AWAY, mutates policy
// and must require config-edit (VerbA), not mere create/write. Pre-fix, MOVE
// authorized the destination as ActionCreate and the source as ActionWrite, so
// each of these succeeded — letting a non-admin plant an attacker-controlled
// cascade. See configWriteAction / serveFileMove.
func TestFileAPI_MoveOntoConfigRequiresConfigEdit(t *testing.T) {
t.Run("move onto .zddc is forbidden", func(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Incoming"}, map[string]string{
"Incoming/payload.txt": "x",
})
rec := do(http.MethodPost, "/Incoming/payload.txt", "alice@example.com", nil, map[string]string{
headerOp: opMove,
headerDestination: "/Incoming/.zddc",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc")); err == nil {
t.Fatal("a .zddc was planted despite the 403")
}
})
t.Run("move onto .zddc.zip is forbidden", func(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Incoming"}, map[string]string{
"Incoming/payload.txt": "x",
})
rec := do(http.MethodPost, "/Incoming/payload.txt", "alice@example.com", nil, map[string]string{
headerOp: opMove,
headerDestination: "/Incoming/.zddc.zip",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc.zip")); err == nil {
t.Fatal("a .zddc.zip bundle was planted despite the 403")
}
})
t.Run("moving a .zddc away is forbidden", func(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Incoming", "Other"}, map[string]string{
"Incoming/.zddc": "acl:\n permissions:\n \"*@example.com\": rwcd\n",
})
rec := do(http.MethodPost, "/Incoming/.zddc", "alice@example.com", nil, map[string]string{
headerOp: opMove,
headerDestination: "/Other/.zddc",
})
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc")); err != nil {
t.Fatalf("source .zddc was removed despite the 403: %v", err)
}
})
t.Run("config-editor may move config (positive control)", func(t *testing.T) {
_, do, root := fileAPITestSetup(t, []string{"Incoming"}, map[string]string{
"Incoming/payload.txt": "x",
})
// Grant admin@example.com the `a` verb (standing config-edit) on top
// of rwcd. The same MOVE that alice is denied must still succeed for a
// config-editor — proving the fix gates on authority, not on the verb.
if err := os.WriteFile(filepath.Join(root, ".zddc"),
[]byte("acl:\n permissions:\n \"admin@example.com\": rwcda\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
t.Fatalf("rewrite root .zddc: %v", err)
}
zddc.InvalidateCache(root)
rec := do(http.MethodPost, "/Incoming/payload.txt", "admin@example.com", nil, map[string]string{
headerOp: opMove,
headerDestination: "/Incoming/.zddc",
})
if rec.Code != http.StatusOK {
t.Fatalf("config-editor move: want 200, got %d: %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc")); err != nil {
t.Fatalf("authorized editor's move did not land the file: %v", err)
}
})
}

View file

@ -814,23 +814,75 @@ body.help-open .app-header {
with tool-local .toast classes; the old classifier rules can stay
alongside until this file is concatenated above them in the build. */
.zddc-toast {
/* Toast STACK — bottom-right, newest at the bottom. The container is
click-through (pointer-events:none) so the gaps don't block the page; each
toast + button re-enables pointer events. */
.zddc-toasts {
position: fixed;
bottom: 2rem;
right: 2rem;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
max-height: calc(100vh - 3rem);
overflow-y: auto;
pointer-events: none;
}
/* "Clear all" — shown above the stack when 2+ toasts are present. */
.zddc-toasts__clear {
pointer-events: auto;
align-self: flex-end;
background: var(--bg);
color: var(--text);
padding: 0.875rem 1.25rem;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.2rem 0.6rem;
font-size: 0.78rem;
cursor: pointer;
}
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
.zddc-toast {
position: relative;
pointer-events: auto;
background: var(--bg);
color: var(--text);
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9000;
max-width: 400px;
max-width: 420px;
font-size: 0.875rem;
cursor: pointer;
animation: zddc-toast-in 0.3s ease-out;
}
/* Message text — selectable + copyable; long/multi-line errors wrap. */
.zddc-toast__msg {
user-select: text;
-webkit-user-select: text;
cursor: text;
white-space: pre-wrap;
word-break: break-word;
}
/* Per-toast dismiss. */
.zddc-toast__close {
position: absolute;
top: 0.2rem;
right: 0.35rem;
border: none;
background: transparent;
color: var(--text-muted, #888);
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0 0.15rem;
}
.zddc-toast__close:hover { color: var(--text); }
.zddc-toast--success { border-left: 4px solid var(--success); }
.zddc-toast--error { border-left: 4px solid var(--danger); }
.zddc-toast--info { border-left: 4px solid var(--info); }
@ -1670,7 +1722,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-10 14:42:21 · 8f839fc</span></span>
</div>
</div>
<div class="header-right">
@ -2746,74 +2798,122 @@ body.is-elevated::after {
}());
// shared/toast.js — non-blocking notification helper available to every
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
// local showToast (classifier/js/excel.js); promoted here so tools that
// today use alert() or silent console.error can switch to a uniform
// non-blocking surface.
// tool via window.zddc.toast(msg, level, opts).
//
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
// they stay until the user dismisses them (per-toast × or a "Clear all"
// button) so the message can be read, selected, and copied while
// troubleshooting. info/success toasts auto-dismiss. The message text is
// always selectable.
//
// Usage:
// window.zddc.toast('Saved.', 'success');
// window.zddc.toast('Could not load: ' + err.message, 'error');
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
//
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
// Each tool may also expose app.notify(msg, level) as a thin wrapper —
// see ARCHITECTURE.md for the convention.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
// Don't overwrite if a tool defined its own first.
if (typeof window.zddc.toast === 'function') return;
var DEFAULT_DURATION_MS = 5000;
var FADE_MS = 300;
// Levels that persist until the user dismisses them (troubleshooting).
var STICKY = { error: true, warning: true };
function container() {
var c = document.getElementById('zddc-toasts');
if (c) return c;
c = document.createElement('div');
c.id = 'zddc-toasts';
c.className = 'zddc-toasts';
document.body.appendChild(c);
return c;
}
// Show/hide a "Clear all" control when 2+ toasts are stacked.
function refreshClearAll(c) {
var bar = c.querySelector('.zddc-toasts__clear');
var count = c.querySelectorAll('.zddc-toast').length;
if (count >= 2) {
if (!bar) {
bar = document.createElement('button');
bar.type = 'button';
bar.className = 'zddc-toasts__clear';
bar.textContent = 'Clear all';
bar.addEventListener('click', function () {
var all = c.querySelectorAll('.zddc-toast');
for (var i = 0; i < all.length; i++) dismiss(all[i]);
});
c.insertBefore(bar, c.firstChild);
}
} else if (bar) {
bar.remove();
}
}
function dismiss(el) {
if (el._dismissed) return;
el._dismissed = true;
if (el._timer) clearTimeout(el._timer);
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
refreshClearAll(container());
}, FADE_MS);
}
function toast(message, level, opts) {
opts = opts || {};
var lvl = (level === 'success' || level === 'error' ||
level === 'warning') ? level : 'info';
// Single-toast policy: dismiss any existing toast immediately
// so the new one is always the most recent. Matches the
// classifier's prior behavior and avoids stack-of-toasts UX.
var existing = document.querySelector('.zddc-toast');
if (existing) existing.remove();
var c = container();
var el = document.createElement('div');
el.className = 'zddc-toast zddc-toast--' + lvl;
// ARIA: errors get assertive (interrupts SR queue), others polite.
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
el.textContent = message == null ? '' : String(message);
document.body.appendChild(el);
var dur = typeof opts.durationMs === 'number' ?
opts.durationMs : DEFAULT_DURATION_MS;
var timer = setTimeout(function () {
el.classList.add('zddc-toast--fade');
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, FADE_MS);
}, dur);
// Selectable, copyable message text (its own element so clicking to
// select doesn't dismiss the toast — only the × does).
var msg = document.createElement('span');
msg.className = 'zddc-toast__msg';
msg.textContent = message == null ? '' : String(message);
el.appendChild(msg);
// Click-to-dismiss. Useful for sticky errors the user wants gone.
el.addEventListener('click', function () {
clearTimeout(timer);
if (el.parentNode) el.parentNode.removeChild(el);
});
var close = document.createElement('button');
close.type = 'button';
close.className = 'zddc-toast__close';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '×';
close.addEventListener('click', function () { dismiss(el); });
el.appendChild(close);
c.appendChild(el);
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
// auto-dismiss after the (overridable) duration.
var sticky = opts.durationMs === 0 ||
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
if (!sticky) {
var dur = typeof opts.durationMs === 'number'
? opts.durationMs : DEFAULT_DURATION_MS;
el._timer = setTimeout(function () { dismiss(el); }, dur);
}
refreshClearAll(c);
return el;
}
window.zddc.toast = toast;
// Route window.alert() calls into the toast helper. Every tool has
// accumulated some `alert(...)` sites for error reporting; rather
// than touch each one, intercept globally so they're non-blocking
// and ARIA-announced consistently. Native alert is preserved on
// window.alertNative for the rare case where a truly modal block
// is needed (e.g. before navigating away with unsaved changes).
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
// consistent). Native alert preserved on window.alertNative. alert() maps
// to an error toast (sticky) since that's its usual purpose.
if (typeof window.alert === 'function' && !window.alertNative) {
window.alertNative = window.alert.bind(window);
window.alert = function (msg) {

View file

@ -1,191 +0,0 @@
package policy
import (
"context"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestFederalRego_DivergencesFromStandard validates the federal-mode
// variant by asserting both that:
//
// (a) most cascade scenarios produce the same verdict as standard
// (the federal rule reduces to standard whenever no parent deny
// intersects a leaf allow), AND
//
// (b) the specific scenarios where the rules differ (a leaf-level
// allow overlaying an ancestor's deny) produce DIFFERENT verdicts:
// standard says allow (leaf wins); federal says deny (ancestor
// deny is absolute — NIST AC-6 default).
//
// Like the standard parity test, this imports the OPA library as a
// test-only dependency. The federal Rego is a deployable artifact
// (operators dump it via --print-rego=federal); the parity guard
// here proves the artifact behaves as documented.
func TestFederalRego_DivergencesFromStandard(t *testing.T) {
ctx := context.Background()
standard, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile standard rego: %v", err)
}
federal, err := rego.New(
rego.Query("data.zddc.access_federal.allow"),
rego.Module("access_federal.rego", FederalRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile federal rego: %v", err)
}
allow := func(p ...string) zddc.ZddcFile {
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = "rwcd"
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
deny := func(p ...string) zddc.ZddcFile {
m := make(map[string]string, len(p))
for _, x := range p {
m[x] = ""
}
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
}
empty := zddc.ZddcFile{}
cases := []struct {
name string
chain zddc.PolicyChain
email string
wantStandard bool
wantFederal bool
divergesByDesign bool // true if standard and federal must disagree here
}{
// ── Cases where the two policies must AGREE ────────────────
{
"empty chain, no files",
zddc.PolicyChain{HasAnyFile: false},
"alice@example.com",
true, true, false,
},
{
"files exist, no rule matches → both deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
"alice@example.com",
false, false, false,
},
{
"leaf allow with no ancestor deny → both allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true, true, false,
},
{
"only deny anywhere → both deny",
zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com")}, HasAnyFile: true},
"alice@example.com",
false, false, false,
},
{
"glob allow, no deny → both allow",
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true},
"alice@example.com",
true, true, false,
},
// ── The signature divergence: leaf allow overlaying ancestor deny ──
{
"leaf allows what parent denied → standard allows, federal denies (AC-6)",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true, // standard: leaf wins
false, // federal: parent deny is absolute
true,
},
{
"deep leaf re-allows after middle deny → standard allows, federal denies",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
allow("*@example.com"),
deny("alice@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
false,
true,
},
{
"glob deny at root, specific allow at leaf → both differ",
zddc.PolicyChain{Levels: []zddc.ZddcFile{
deny("*@example.com"),
allow("alice@example.com"),
}, HasAnyFile: true},
"alice@example.com",
true,
false,
true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
input := AllowInput{Path: "/test", PolicyChain: chainToSerializable(tc.chain)}
input.User.Email = tc.email
regoInput, err := canonicalInput(input)
if err != nil {
t.Fatalf("encode input: %v", err)
}
std, err := standard.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("standard eval: %v", err)
}
fed, err := federal.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("federal eval: %v", err)
}
if len(std) == 0 || len(fed) == 0 {
t.Fatal("rego returned empty result set")
}
stdAllow := std[0].Expressions[0].Value.(bool)
fedAllow := fed[0].Expressions[0].Value.(bool)
if stdAllow != tc.wantStandard {
t.Errorf("standard rego: got %v, want %v", stdAllow, tc.wantStandard)
}
if fedAllow != tc.wantFederal {
t.Errorf("federal rego: got %v, want %v", fedAllow, tc.wantFederal)
}
// Cross-check the divergence flag itself: if we said the cases
// must disagree, they must; if we said they agree, they must.
diverges := stdAllow != fedAllow
if diverges != tc.divergesByDesign {
t.Errorf("divergence = %v, want %v (standard=%v, federal=%v)",
diverges, tc.divergesByDesign, stdAllow, fedAllow)
}
})
}
}
// TestFederalRego_RegoCompiles is a sanity check that the embedded
// federal Rego file parses without error in OPA, separate from the
// behavior tests. Catches accidental syntax breakage in
// access_federal.rego before running the (slower) parity matrix.
func TestFederalRego_RegoCompiles(t *testing.T) {
_, err := rego.New(
rego.Query("data.zddc.access_federal.allow"),
rego.Module("access_federal.rego", FederalRego),
).PrepareForEval(context.Background())
if err != nil {
t.Fatalf("federal rego does not compile: %v", err)
}
}

View file

@ -58,18 +58,21 @@ import (
// External Rego policies can:
// - read input.user.email (string)
// - read input.path (string)
// - read input.action ("read" | "write"); empty/absent ≡ "read"
// - read input.action ("read"|"write"|"create"|"delete"|"admin");
// empty/absent ≡ "read"
// - walk input.policy_chain.levels[].acl.{allow,deny} for
// custom cascade semantics, or read the pre-resolved
// input.policy_chain.has_any_file when implementing the
// same default-deny rule we use internally.
//
// Action distinguishes read (GET/HEAD on listings, files, app HTML)
// from write (PUT, DELETE, POST/move on the file API). The internal
// decider treats both identically — any allow grants full CRUD,
// matching the model in place before the file API existed (anyone
// with read access also had OS-level write via the mounted share).
// External Rego policies can split the two by inspecting input.action.
// Action distinguishes read (GET/HEAD on listings, files, app HTML) from
// write/create/delete/admin (PUT, DELETE, POST/move on the file API). The
// internal decider HONORS it: actionVerb maps each action to the verb it
// requires, and AllowActionFromChainP checks that specific verb against the
// cascade's effective grant, with the WORM clamp and the admin/elevation
// bypass applied. The bundled reference Rego, by contrast, models the
// read-ACL cascade only and is fail-closed for non-read actions — see
// rego/access.rego.
type AllowInput struct {
User struct {
Email string `json:"email"`
@ -240,11 +243,21 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
// is a STANDING permission: a subtree admin (admins: cascade) or a
// holder of the `a` verb may edit the config of subtrees they
// administer WITHOUT elevating. This sits ABOVE the WORM clamp because
// config is not WORM-protected data — and it only ever grants VerbA,
// so it can never write/delete/create WORM *records* (those need
// W/C/D, which stay clamped and behind the elevated bypass above).
// Elevation is thus purely additive: it adds the WORM/destructive
// overrides, never gating config-edit you already have authority for.
// config is not WORM-protected data.
//
// Scope: this grants VerbA only, so no SINGLE decision here authorizes a
// WORM *record* write/delete/create (those need W/C/D, which stay clamped
// below, behind the elevated bypass above). It does NOT, however, make a
// WORM zone tamper-proof against its own policy owner: a config-editor who
// administers a WORM directory may edit that directory's .zddc — including
// relaxing or (via inherit:false) dropping its `worm:` marker — after
// which ordinary writes to that subtree are no longer clamped. That
// two-step demotion is intended (owning a subtree's policy includes its
// worm: declaration) and is access-logged/transparent: WORM is therefore
// tamper-EVIDENT to its config owner, not tamper-PROOF. A deployment that
// needs the marker immutable except under elevation should gate worm:
// relaxation behind IsActiveAdmin. The edit-then-write composition is
// pinned in standing_config_test.go (TestStandingConfigEdit_WormDemotionIsTwoStep).
if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) {
return true, nil
}

View file

@ -2,22 +2,26 @@ package policy
import _ "embed"
// ReferenceRego is the canonical Rego policy bundled with zddc-server.
// It mirrors the InternalDecider's semantics exactly — every release CI
// run validates parity via parity_test.go (which imports the OPA library
// as a test-only dependency, so the production binary stays OPA-free).
// ReferenceRego is a read-ACL Rego SKELETON bundled with zddc-server for
// external-OPA deployments. It models the read cascade ONLY and is NOT a
// semantic mirror of the InternalDecider: it does not implement per-verb
// authorization (write/create/delete/admin), WORM zones, roles, fences, or
// config-edit, so it is FAIL-CLOSED — every non-read action is denied except
// for an elevated admin (input.user.is_active_admin). The InternalDecider
// remains the production source of truth. parity_test.go (OPA as a test-only
// dependency, so the production binary stays OPA-free) checks the modelled
// read-cascade dimension only — it does NOT prove full parity.
//
// Operators running an external OPA can use this as the starting point
// for their own policy bundle:
// Operators running an external OPA can use this as a STARTING POINT — they
// must add the unmodelled write/WORM/role/admin semantics before relying on
// it for write authorization:
//
// zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
//
// Customizations typical for federal deployments:
//
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
// are absolute (NIST AC-6 least-privilege posture). For this specific
// case zddc-server ships a parity-tested federal-mode variant; see
// FederalRego and `--print-rego=federal`.
// are absolute (NIST AC-6 least-privilege posture).
// - Add role-based access via additional input fields (input.user.roles
// populated by the upstream proxy from SAML/OIDC claims).
// - Add time-of-day or IP-range constraints.
@ -26,21 +30,3 @@ import _ "embed"
//
//go:embed rego/access.rego
var ReferenceRego string
// FederalRego is the strict-least-privilege variant of ReferenceRego
// where parent denies are absolute (NIST AC-6). Drop-in for federal
// customers who need the AC-6 posture without writing Rego from
// scratch:
//
// zddc-server --print-rego=federal > /etc/opa/policies/zddc-access.rego
//
// The internal Go evaluator does NOT implement these semantics — it
// stays on the commercial cascade. Federal-mode is reachable only by
// running OPA with this policy and pointing ZDDC_OPA_URL at it. See
// zddc/internal/policy/rego/access_federal.rego for the policy itself
// and federal_parity_test.go for the divergence-test fixtures (cases
// where federal-mode and commercial-mode disagree, asserting each gives
// the expected verdict).
//
//go:embed rego/access_federal.rego
var FederalRego string

View file

@ -1,16 +1,26 @@
# Reference Rego policy that mirrors zddc-server's built-in `internal`
# decider exactly. Federal customers running their own OPA can use this
# as a starting point (and then tighten — e.g. flip the leaf-allow-overrides-
# parent-deny rule for NIST AC-6 compliance).
# Reference Rego SKELETON for an external-OPA deployment. It models the
# read-ACL cascade ONLY. It is NOT semantically equivalent to zddc-server's
# built-in `internal` decider and MUST NOT be deployed as-is for a system
# that relies on write authorization.
#
# The internal evaluator (in zddc/internal/zddc/acl.go) is the source of
# truth for production. This file is validated against that evaluator on
# every CI run via the parity test in zddc/internal/policy/parity_test.go.
# Both implementations must produce the same decision for every fixture.
# Models: the deepest-matching-level read-ACL cascade — glob patterns,
# deny-first-within-a-level, default-deny once any .zddc exists.
#
# Does NOT model (the internal decider in zddc/internal/zddc + internal/policy
# does): per-verb authorization (write/create/delete/admin), WORM zones,
# roles: membership resolution, inherit:false fences, and standing config-edit
# (the `a` verb). Because those are unmodelled this policy is FAIL-CLOSED:
# every non-read action is denied, and an elevated admin
# (input.user.is_active_admin) is the only write-capable principal. A real
# deployment must add the missing semantics before granting writes — see the
# parity tests under zddc/internal/policy for the dimensions to cover. The
# internal Go decider remains the production source of truth; this file is a
# starting point, not a tested mirror of it.
#
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
# {
# "user": {"email": "alice@example.com"},
# "user": {"email": "alice@example.com", "is_active_admin": false},
# "action": "read", # "" / absent == read; else write|create|delete|admin
# "path": "/Project-A/sub/",
# "policy_chain": {
# "levels": [
@ -39,14 +49,40 @@ import future.keywords.in
default allow := false
# Allow when no .zddc files anywhere in the chain AND no rule matches.
# Elevated admins bypass — mirrors the internal decider's single admin
# short-circuit. The caller computes is_active_admin (admin authority on this
# chain AND elevated/opted-in); trusting it here is the same trust the
# internal decider applies. This is the ONLY path that authorizes a non-read
# action under this read-ACL skeleton.
allow if {
input.user.is_active_admin
}
# This policy models read-ACL only, so every cascade grant below is gated on a
# read action; any write/create/delete/admin falls through to the default-deny
# above (fail-closed). Empty/absent action == read.
is_read_action if {
not input.action
}
is_read_action if {
input.action == ""
}
is_read_action if {
input.action == "read"
}
# Read allowed when no .zddc files anywhere in the chain AND no rule matches.
allow if {
is_read_action
not input.policy_chain.has_any_file
count(matched_levels) == 0
}
# Allow when the deepest matching level grants.
# Read allowed when the deepest matching level grants.
allow if {
is_read_action
count(matched_levels) > 0
deepest := max(matched_levels)
level_grants(input.policy_chain.levels[deepest])

View file

@ -1,99 +0,0 @@
# Federal-mode reference policy: parent-deny-is-absolute (NIST AC-6).
#
# This is a strict-least-privilege variant of access.rego. The two policies
# differ in exactly one rule, but the semantic difference is meaningful for
# federal evaluators:
#
# access.rego (commercial, default):
# "Bottom-up walk; first explicit match wins; deny-first within a level.
# A leaf-level allow CAN override an ancestor's deny."
# Test: cascade_test.go "leaf allows user that parent denies → leaf wins".
#
# access_federal.rego (federal):
# "Any deny anywhere along the chain is absolute. An allow only matters
# if no ancestor (or sibling level) has denied the same email. Leaf-
# level allows do NOT override ancestor denies."
# Required by NIST AC-6 (Least Privilege) default expectations: a
# central admin's deny at the root must be unbypassable by a tenant
# who controls a subtree's .zddc.
#
# Why ship two policies? The internal Go evaluator (in zddc/internal/zddc/
# acl.go) implements only the commercial cascade — it's the rule the
# default deployment exercises. Federal customers running their own OPA
# with this file get the strict variant without any zddc-server code
# change. They can also write a hybrid policy (e.g. "deny is absolute
# only for emails matching some pattern; cascade rules for everyone
# else") since once they're hosting their own OPA, the constraint is
# whatever they write.
#
# Input shape: identical to access.rego — see that file's docstring.
# acl.permissions maps principal patterns to verb strings; an empty
# verb string is an explicit deny.
package zddc.access_federal
import future.keywords.if
import future.keywords.in
default allow := false
# Allow when no .zddc files exist anywhere AND no rule matches.
# Same default-allow case as commercial; preserves the empty-tree
# behaviour. (zddc-server's --insecure check at startup makes this
# unreachable in any non-deliberately-public deployment.)
allow if {
not input.policy_chain.has_any_file
not any_deny_match
not any_allow_match
}
# Allow when files exist, no level (any depth) denies, and at least
# one level allows. The "any level" check is what makes parent denies
# absolute — there is no "deepest match wins" rule here.
allow if {
input.policy_chain.has_any_file
not any_deny_match
any_allow_match
}
# Any explicit-deny permission entry at ANY level matches the email.
any_deny_match if {
some level in input.policy_chain.levels
some pattern, verbs in level.acl.permissions
verbs == ""
email_matches(pattern, input.user.email)
}
# Any grant permission entry (non-empty verbs) at ANY level matches.
any_allow_match if {
some level in input.policy_chain.levels
some pattern, verbs in level.acl.permissions
verbs != ""
email_matches(pattern, input.user.email)
}
# email_matches: identical to access.rego — see that file for the
# rationale on the four cases. Duplicated rather than imported so this
# file is self-contained for operators who copy it as a starting point.
email_matches(pattern, email) if {
pattern == email
}
email_matches(pattern, email) if {
pattern == "*"
email != ""
}
email_matches(pattern, email) if {
contains(pattern, "*")
contains(pattern, "@")
glob.match(pattern, ["@"], email)
}
email_matches(pattern, email) if {
contains(pattern, "*")
not contains(pattern, "@")
pattern != "*"
glob.match(pattern, [], email)
}

View file

@ -0,0 +1,83 @@
package policy
import (
"context"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/open-policy-agent/opa/rego"
)
// TestReferenceRego_FailClosedOnWrites pins the security contract of the
// bundled reference Rego skeleton: it models READ-ACL only, so any non-read
// action must be DENIED even when the read-ACL would grant — and the only
// write-capable principal is an elevated admin. This is the behavior that,
// untested, previously let a verb-blind policy ship claiming to "mirror the
// internal decider exactly." See rego/access.rego.
func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
ctx := context.Background()
stdQ, err := rego.New(
rego.Query("data.zddc.access.allow"),
rego.Module("access.rego", ReferenceRego),
).PrepareForEval(ctx)
if err != nil {
t.Fatalf("compile access.rego: %v", err)
}
// A chain that GRANTS full rwcd to alice — so any denial below is the
// action gate, not a missing ACL.
grant := zddc.PolicyChain{
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"*@example.com": "rwcd"}}}},
HasAnyFile: true,
}
evalAllow := func(q rego.PreparedEvalQuery, action string, admin bool) bool {
in := AllowInput{Path: "/p/", Action: action, PolicyChain: chainToSerializable(grant)}
in.User.Email = "alice@example.com"
in.User.IsActiveAdmin = admin
regoInput, err := canonicalInput(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
rs, err := q.Eval(ctx, rego.EvalInput(regoInput))
if err != nil {
t.Fatalf("eval: %v", err)
}
if len(rs) == 0 {
t.Fatalf("no result")
}
v, ok := rs[0].Expressions[0].Value.(bool)
if !ok {
t.Fatalf("result not bool: %v", rs[0].Expressions[0].Value)
}
return v
}
cases := []struct {
name string
q rego.PreparedEvalQuery
action string
admin bool
wantAllow bool
}{
// Commercial: reads granted, every write verb denied (fail-closed).
{"access read allowed", stdQ, ActionRead, false, true},
{"access empty-action(read) allowed", stdQ, "", false, true},
{"access write denied", stdQ, ActionWrite, false, false},
{"access create denied", stdQ, ActionCreate, false, false},
{"access delete denied", stdQ, ActionDelete, false, false},
{"access admin-action denied", stdQ, ActionAdmin, false, false},
// An elevated admin is the one write-capable principal.
{"access write allowed for active admin", stdQ, ActionWrite, true, true},
{"access delete allowed for active admin", stdQ, ActionDelete, true, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := evalAllow(tc.q, tc.action, tc.admin); got != tc.wantAllow {
t.Errorf("allow=%v, want %v (action=%q active_admin=%v)", got, tc.wantAllow, tc.action, tc.admin)
}
})
}
}

View file

@ -72,3 +72,47 @@ func TestStandingConfigEdit(t *testing.T) {
}
}
}
// TestStandingConfigEdit_WormDemotionIsTwoStep documents the (intended)
// composition that a single-action view of the decider hides: a config-editor
// who administers a WORM zone cannot write a WORM record directly, but CAN
// demote the zone by editing its own .zddc, after which an ordinary write is
// no longer clamped. WORM is thus tamper-evident to its policy owner, not
// tamper-proof. Pinned so the behavior is an explicit, tested decision — if a
// deployment ever needs WORM markers immutable except under elevation, this is
// the test that must change alongside gating worm: relaxation behind
// IsActiveAdmin in policy.InternalDecider.Allow.
func TestStandingConfigEdit_WormDemotionIsTwoStep(t *testing.T) {
d := &InternalDecider{}
dec := func(chain zddc.PolicyChain, p zddc.Principal, action string) bool {
ok, _ := AllowActionFromChainP(context.Background(), d, chain, p, "/proj/probe", action)
return ok
}
alice := zddc.Principal{Email: "alice@x", Elevated: false} // config-editor, NOT elevated
// Before — alice administers a WORM zone (admins: + a non-nil worm list).
worm := zddc.PolicyChain{
Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}, Worm: []string{}}},
HasAnyFile: true,
}
// The boundary holds: a direct WORM record write is denied unelevated...
if dec(worm, alice, ActionWrite) {
t.Error("unelevated config-editor must NOT directly write a WORM record")
}
// ...but she CAN edit the zone's policy (VerbA) — the lever for demotion.
if !dec(worm, alice, ActionAdmin) {
t.Error("config-editor should be able to edit the WORM zone's .zddc unelevated")
}
// After — alice has rewritten that .zddc: inherit:false dropped the
// embedded worm: and her acl now grants rwcd (the post-edit cascade the
// file API persists). The subtree is no longer WORM, so her write lands —
// still unelevated. This is step two of the intended demotion.
demoted := zddc.PolicyChain{
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "rwcd"}}}},
HasAnyFile: true,
}
if !dec(demoted, alice, ActionWrite) {
t.Error("after the config-editor demotes the zone, the ordinary write should be allowed")
}
}

View file

@ -0,0 +1,45 @@
package zddc
import "testing"
// TestEmbeddedDefaults_LosslessUnderSerialization pins the invariant that
// makes it safe for policy.SerializableChain to drop PolicyChain.Embedded on
// the wire (and for policy.InternalDecider.Allow to reconstruct a chain from
// AllowInput without it).
//
// The decision path consults chain.Embedded at two sites:
// - worm.go (WormZoneGrant): folds in a top-level chain.Embedded.Worm.
// - roles.go (lookupRoleMembers / pathRoles): reads chain.Embedded.Roles.
//
// Today both are no-ops only because the baked-in baseline (EmbeddedDefaults,
// the root of every cascade — internal/zddc/defaults/.zddc) declares no
// top-level `worm:` and no role members (WORM is declared via the `paths:`
// tree, which lands in chain.Levels, not chain.Embedded). If a future default
// adds either, those contributions would be SILENTLY ignored by an external
// OPA (it never receives Embedded) and by the InternalDecider over a
// serialized chain — an authz divergence with no error. This test fails loudly
// at that moment so the change is paired with serializing PolicyChain.Embedded
// (one field on SerializableChain) before it ships.
func TestEmbeddedDefaults_LosslessUnderSerialization(t *testing.T) {
e, err := EmbeddedDefaults()
if err != nil {
t.Fatalf("EmbeddedDefaults: %v", err)
}
if e.Worm != nil {
t.Errorf("embedded baseline declares a top-level worm: %v — it is read at "+
"decision time (WormZoneGrant) but dropped by policy.SerializableChain, "+
"so external OPA and the reconstructed InternalDecider chain would "+
"silently ignore it. Declare WORM via paths: (→ chain.Levels) or "+
"serialize PolicyChain.Embedded.", e.Worm)
}
for name, role := range e.Roles {
if len(role.Members) > 0 {
t.Errorf("embedded baseline role %q has members %v — read at decision "+
"time (chain.Embedded.Roles in roles.go) but dropped by "+
"policy.SerializableChain. Keep embedded roles member-less or "+
"serialize PolicyChain.Embedded.", name, role.Members)
}
}
}

View file

@ -200,10 +200,11 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// Determine if this newly-created ancestor is an auto-own
// position and whether it should be fenced (inherit: false).
// Resolved via the .zddc cascade — internal/zddc/defaults/
// carries the canonical "working/staging auto-own + per-user
// homes fenced + incoming auto-own" convention, and any
// on-disk .zddc can override per-directory.
// Resolved via the .zddc cascade — the embedded defaults
// (internal/zddc/defaults/) declare auto_own at the working/
// staging/ incoming/ reviewing/ <party> homes but do NOT fence
// them (they are shared team folders); an on-disk .zddc can opt
// a directory into fencing per-directory with auto_own_fenced.
_ = parentSegs // depth-tracking no longer needed
_ = i
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
@ -222,12 +223,11 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// Seed auto-own .zddc on the canonical positions that were freshly
// created. Skip if no principal email is available (anonymous or
// system writes). The fenced variant is used at per-user home
// folders under working/ — private by default; owner can later
// edit the .zddc to add collaborators. Role grants (from the
// cascade's auto_own_roles list) are written alongside the
// creator email so role-level peer authority survives without
// needing a subtree-admin grant.
// system writes). The fenced variant (inherit:false, private to the
// creator) is an opt-in the default tree does not use — see
// AutoOwnFencedAt. Role grants (from the cascade's auto_own_roles
// list) are written alongside the creator email so role-level peer
// authority survives without needing a subtree-admin grant.
if principalEmail != "" {
for _, c := range freshlyCreated {
if !c.autoOwn {

View file

@ -25,12 +25,13 @@ import (
// (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants).
//
// Federal deployments running the bundled `access_federal.rego` get
// parent-deny-is-absolute / NIST AC-6 semantics; the directive's
// fence-style "reset" should be avoided there because it would let a
// leaf widen access an ancestor refused. The cascade tracer at
// /.profile/effective-policy reports `chain.visible_start` so an
// operator can verify which level a fence is actually cutting off.
// A deployment running an external OPA with ancestor-deny-absolute
// (NIST AC-6) semantics should avoid the directive's fence-style "reset",
// since under that posture it would let a leaf widen access an ancestor
// refused. (zddc-server ships only the read-ACL skeleton at --print-rego;
// an AC-6 policy is the operator's own Rego.) The cascade tracer at
// /.profile/effective-policy reports `chain.visible_start` so an operator
// can verify which level a fence is actually cutting off.
//
// Inherit is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from
@ -228,12 +229,15 @@ type ZddcFile struct {
// created. Empty (nil) inherits via cascade.
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc
// is written with `inherit: false` so the new directory is
// private to its creator (ancestor ACL grants don't apply). Used
// for per-user home folders under working/<email>/. Default
// (nil/false) writes a non-fenced auto-own .zddc — ancestor
// admin grants still apply.
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc is
// written with `inherit: false` so the new directory is private to its
// creator (ancestor ACL grants don't cascade in). It is an OPT-IN an
// operator can set on any auto_own position; the current embedded default
// tree does NOT set it anywhere — the working/ staging/ incoming/
// reviewing/ <party> homes are auto-owned but UNFENCED, so ancestor grants
// (e.g. `project_team: cr` at working/) still cascade in, making them
// shared team folders rather than private per-user sandboxes. Default
// (nil/false) writes a non-fenced auto-own .zddc.
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
// AutoOwnRoles augments AutoOwn with role-level grants: when set,

View file

@ -32,20 +32,21 @@ func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
}
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
// sets `acl.inherit: false` — fencing ancestor cascade grants. Used at
// per-user home folders under working/ where the convention is "private
// by default; owner edits the file to add collaborators."
// sets `acl.inherit: false` — fencing ancestor cascade grants so the new
// directory is private to its creator. This is the OPT-IN private-home form:
// the current embedded default tree does NOT use it (the working/staging/
// incoming/reviewing party homes are unfenced and shared — see AutoOwnFenced
// in file.go). An operator opts in by setting auto_own_fenced on a position.
//
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
// authenticated users) would let any user read every other user's
// working subfolder via cascade — defeating the per-user sandbox.
// With the fence, an ancestor grant (e.g. `project_team: cr` at working/)
// does NOT cascade in, so only the creator (and any roles passed below) can
// reach the directory.
//
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
// alongside the creator, and like the creator grant they're INSIDE
// the fence (only resolvable if the role is defined at this level or
// in chain.Embedded, since ancestor role definitions are hidden by
// inherit:false). Typically callers using the fenced variant pass nil
// roles — per-user homes don't need peer authority.
// inherit:false). Callers using the fenced variant typically pass nil roles.
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
return writeAutoOwn(dir, principalEmail, true, roles)
}