Compare commits
No commits in common. "59ffd861f93c39192647440ec2a2b24e352e49e9" and "7d9317190038cb4bf71521bc20d3bd8c3ffdf63a" have entirely different histories.
59ffd861f9
...
7d93171900
37 changed files with 698 additions and 4112 deletions
|
|
@ -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 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).
|
||||
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).
|
||||
|
||||
Pick a role per persona:
|
||||
|
||||
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list).
|
||||
- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Auto-owns (`rwcda`) the `working/<party>/` folder they create; it is **unfenced**, so `working/` stays a shared team space (every `project_team` member keeps cascade `cr` there). A per-directory `auto_own_fenced` opt-in (not set in the default tree) would make it private.
|
||||
- `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`).
|
||||
- `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`, 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_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 |
|
||||
| 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 |
|
||||
| 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 the operator's own ancestor-deny-absolute Rego (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 `access_federal.rego` for ancestor-deny-absolute / 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 their own Rego (zddc-server ships only the fail-closed read-ACL skeleton at `--print-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 the bundled `access_federal.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.
|
||||
|
|
|
|||
|
|
@ -65,11 +65,8 @@ npm test # all Playwright specs (build first!)
|
|||
npx playwright test <tool> # one spec
|
||||
./dev-server start # stop # cache-busting HTTP on :8000
|
||||
|
||||
# zddc/ Go server (sub-project). Go is NOT on the host — run go test/build
|
||||
# through the localhost/zddc-go:1.24 container (canonical wrapper, with the
|
||||
# GOPROXY/GOPRIVATE env it needs, in AGENTS.md § Test). The bare command
|
||||
# below fails on the host.
|
||||
(cd zddc && go test ./...) # unit tests (Go 1.24+) — via the podman wrapper, not host
|
||||
# zddc/ Go server (sub-project)
|
||||
(cd zddc && go test ./...) # unit tests (Go 1.24+)
|
||||
```
|
||||
|
||||
No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design.
|
||||
|
|
|
|||
|
|
@ -51,15 +51,9 @@ 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" \
|
||||
|
|
|
|||
|
|
@ -175,46 +175,34 @@
|
|||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Counts read "direct+total". Completed numbers are blue — var(--primary),
|
||||
which is theme-aware (medium blue in light, lighter blue in dark). The
|
||||
direct number is always completed (known the moment the folder is read).
|
||||
The "+total" subtree count stays muted grey + pulses while still scanning,
|
||||
then turns blue once final. Once the row is fully scanned (both numbers
|
||||
blue) the folders/files labels turn blue too (.folder-count.done). */
|
||||
.folder-count .ct-direct,
|
||||
/* 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. */
|
||||
.folder-count .ct-total {
|
||||
color: var(--primary);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
.folder-count .ct-total.pending {
|
||||
color: var(--text-muted, #9aa0a6);
|
||||
font-style: italic;
|
||||
animation: scan-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.folder-count.done .ct-label {
|
||||
color: var(--primary);
|
||||
}
|
||||
@keyframes scan-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Page footer — hosts the live scan status. */
|
||||
.app-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.75rem;
|
||||
border-top: 1px solid var(--border, #e2e2e2);
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
/* Live scan status line under the tree-pane header. */
|
||||
.scan-status {
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8a8a8a);
|
||||
min-height: 1.4em;
|
||||
}
|
||||
.scan-status {
|
||||
border-bottom: 1px solid var(--border, #e2e2e2);
|
||||
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 {
|
||||
|
|
@ -265,242 +253,6 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -33,9 +33,6 @@
|
|||
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
|
||||
|
|
@ -142,10 +139,7 @@
|
|||
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'),
|
||||
|
|
@ -164,41 +158,10 @@
|
|||
errorFiles: document.getElementById('errorFiles'),
|
||||
|
||||
// Preview
|
||||
togglePreviewBtn: document.getElementById('togglePreviewBtn'),
|
||||
|
||||
// Mode switch + Classify & Copy panes
|
||||
modeRenameBtn: document.getElementById('modeRenameBtn'),
|
||||
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
|
||||
spreadsheetPane: document.getElementById('spreadsheetPane'),
|
||||
targetPane: document.getElementById('targetPane'),
|
||||
copyOutputBtn: document.getElementById('copyOutputBtn')
|
||||
togglePreviewBtn: document.getElementById('togglePreviewBtn')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -222,38 +185,15 @@
|
|||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -370,35 +310,27 @@
|
|||
/**
|
||||
* Open a directory handle and initialize the application
|
||||
*/
|
||||
// Show the main UI and initialize the per-tool modules ONCE. Shared by the
|
||||
// legacy rename open and the workspace open/new flows (the latter scan or
|
||||
// load a snapshot themselves).
|
||||
var shellInited = false;
|
||||
function enterAppShell() {
|
||||
hideWelcomeScreen();
|
||||
showMainUI();
|
||||
if (!shellInited) {
|
||||
shellInited = true;
|
||||
app.modules.spreadsheet.init(); // Subscribe to store
|
||||
app.modules.selection.init();
|
||||
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
||||
app.modules.resize.init();
|
||||
app.modules.filter.init();
|
||||
app.modules.sort.init();
|
||||
app.modules.tree.setupKeyboardShortcuts();
|
||||
if (app.modules.targetTree) app.modules.targetTree.init();
|
||||
}
|
||||
if (app.dom.refreshHeaderBtn) app.dom.refreshHeaderBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function openDirectory(dirHandle) {
|
||||
app.rootHandle = dirHandle;
|
||||
enterAppShell();
|
||||
// Default to Classify & Copy (the primary workflow). The user can switch
|
||||
// to "Rename in place" via the toggle for the spreadsheet.
|
||||
setMode('classify');
|
||||
|
||||
// Hide welcome screen and show main UI
|
||||
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();
|
||||
|
||||
// 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'); }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -411,33 +343,17 @@
|
|||
}
|
||||
|
||||
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);
|
||||
|
|
@ -573,9 +489,7 @@
|
|||
|
||||
// Export functions for use by other modules
|
||||
app.modules.app = {
|
||||
updateStats,
|
||||
setMode,
|
||||
enterAppShell
|
||||
updateStats
|
||||
};
|
||||
|
||||
// Initialize when DOM is ready
|
||||
|
|
|
|||
|
|
@ -1,519 +0,0 @@
|
|||
/**
|
||||
* 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(); },
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
/**
|
||||
* 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 isn’t 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -521,28 +521,7 @@
|
|||
}
|
||||
|
||||
// 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('Couldn’t preview ' + (file.originalFilename || 'file') + ' — ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.app.modules.preview = {
|
||||
init,
|
||||
previewFile
|
||||
init
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -16,15 +16,6 @@
|
|||
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;
|
||||
|
|
@ -41,27 +32,18 @@
|
|||
updateScanStatus();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Render the running scan status into the tree-pane header.
|
||||
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 ' + elapsedStr();
|
||||
+ scanStats.files + ' files in ' + secs + 's';
|
||||
el.classList.remove('scanning');
|
||||
} else {
|
||||
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
|
||||
+ scanStats.files + ' files · ' + elapsedStr()
|
||||
+ scanStats.files + ' files'
|
||||
+ (scanStats.current ? ' — ' + scanStats.current : '');
|
||||
el.classList.add('scanning');
|
||||
}
|
||||
|
|
@ -109,56 +91,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// One-shot toast for scan errors (permission denied, 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);
|
||||
// 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 = 'Couldn’t scan ' + path + ' — ' + describeFsError(err)
|
||||
+ '\n\n(' + raw + ')';
|
||||
const msg = 'Couldn’t scan ' + path + ': ' + (err && err.message ? err.message : err);
|
||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||
window.zddc.toast(msg, 'error');
|
||||
}
|
||||
|
|
@ -199,70 +139,42 @@
|
|||
}
|
||||
flushRender();
|
||||
|
||||
// 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);
|
||||
// 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;
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
// 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]);
|
||||
}
|
||||
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();
|
||||
});
|
||||
}
|
||||
const runners = [];
|
||||
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
|
||||
await Promise.all(runners);
|
||||
}
|
||||
|
||||
// Force a folder's subtree to scan NOW (jumped ahead of the background
|
||||
|
|
@ -270,7 +182,16 @@
|
|||
// shows complete contents. Idempotent + shares the live scan generation.
|
||||
async function ensureScanned(node) {
|
||||
if (!node || !node.handle || node.scanState === 'done') return;
|
||||
await drainQueue([node], scanGen, SCAN_CONCURRENCY);
|
||||
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;
|
||||
}
|
||||
flushRender();
|
||||
}
|
||||
|
||||
|
|
@ -308,8 +229,6 @@
|
|||
// 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;
|
||||
|
|
@ -319,19 +238,18 @@
|
|||
for await (const entry of node.handle.values()) {
|
||||
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
|
||||
if (entry.kind === 'file') {
|
||||
const fo = createFileObject(entry, node.handle);
|
||||
const fo = await createFileObject(entry, node.handle);
|
||||
if (!fo) continue;
|
||||
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);
|
||||
zipNode._zipFileObj = fo;
|
||||
zipNode.scanState = 'zip-pending';
|
||||
try { await scanZipIntoNode(zipNode, fo); }
|
||||
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
|
||||
childDirs.push(zipNode);
|
||||
if (scanStats) scanStats.folders++;
|
||||
}
|
||||
|
|
@ -349,15 +267,18 @@
|
|||
node.fileCount = files.length;
|
||||
node.children = childDirs;
|
||||
node.subdirCount = childDirs.length;
|
||||
// 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;
|
||||
// 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; }
|
||||
}
|
||||
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
|
||||
// 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;
|
||||
// Zip children are scanned inline ('done'); real dirs are still pending.
|
||||
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
|
||||
if (node.pending === 0) {
|
||||
markDone(node);
|
||||
} else {
|
||||
|
|
@ -366,30 +287,6 @@
|
|||
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.
|
||||
|
|
@ -721,159 +618,37 @@
|
|||
/**
|
||||
* Create file object with metadata
|
||||
*/
|
||||
// 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.
|
||||
};
|
||||
}
|
||||
async function createFileObject(fileHandle, folderHandle) {
|
||||
try {
|
||||
const file = await fileHandle.getFile();
|
||||
const split = zddc.splitExtension(file.name);
|
||||
|
||||
// ── 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,
|
||||
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;
|
||||
}
|
||||
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
|
||||
|
|
@ -881,12 +656,7 @@
|
|||
scanDirectory,
|
||||
ensureScanned,
|
||||
getZipCache,
|
||||
extractZip,
|
||||
snapshotTree,
|
||||
loadSnapshot,
|
||||
resolveFileHandle,
|
||||
resolveDirHandle,
|
||||
resumeScan
|
||||
extractZip
|
||||
};
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,426 +0,0 @@
|
|||
/**
|
||||
* 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,
|
||||
};
|
||||
})();
|
||||
|
|
@ -5,73 +5,11 @@
|
|||
(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) {
|
||||
|
|
@ -80,7 +18,6 @@
|
|||
}
|
||||
|
||||
window.app.folderTree.forEach(folder => {
|
||||
if (folderHidden(folder)) return;
|
||||
const element = createFolderElement(folder);
|
||||
container.appendChild(element);
|
||||
});
|
||||
|
|
@ -98,15 +35,11 @@
|
|||
*/
|
||||
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;
|
||||
|
||||
|
|
@ -114,29 +47,17 @@
|
|||
frag.appendChild(document.createTextNode('('));
|
||||
if (dDir > 0 || tDir > 0) {
|
||||
appendPair(frag, dDir, tDir, done);
|
||||
appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, ');
|
||||
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
|
||||
}
|
||||
appendPair(frag, dFile, tFile, done);
|
||||
appendLabel(frag, tFile === 1 ? ' file)' : ' files)');
|
||||
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
|
||||
el.appendChild(frag);
|
||||
}
|
||||
|
||||
// The "folders"/"files" word labels — blue only once the row is .done.
|
||||
function appendLabel(frag, text) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'ct-label';
|
||||
s.textContent = text;
|
||||
frag.appendChild(s);
|
||||
}
|
||||
|
||||
// Append "<direct>" (always a completed/blue number) and, when there's a
|
||||
// subtree (or scanning is ongoing), "+<total>" with the total in a span
|
||||
// that greys + pulses until final, then turns blue.
|
||||
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
|
||||
// "+<total>" with the total in a span that greys + pulses until final.
|
||||
function appendPair(frag, direct, total, done) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'ct-direct';
|
||||
d.textContent = String(direct);
|
||||
frag.appendChild(d);
|
||||
frag.appendChild(document.createTextNode(String(direct)));
|
||||
if (!done || total > direct) {
|
||||
frag.appendChild(document.createTextNode('+'));
|
||||
const t = document.createElement('span');
|
||||
|
|
@ -167,28 +88,12 @@
|
|||
item.classList.add('selected');
|
||||
}
|
||||
|
||||
// Classify mode: the folder row is a drag source for a group-drag of
|
||||
// every file in its subtree.
|
||||
if (classifyOn()) {
|
||||
item.draggable = true;
|
||||
item.addEventListener('dragstart', function (e) {
|
||||
e.stopPropagation();
|
||||
var files = subtreeFiles(folder);
|
||||
if (!files.length) { e.preventDefault(); return; }
|
||||
window.app.modules.dnd.setDrag(keysFor(files), e);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle button: shown when the folder has children OR hasn't been
|
||||
// scanned yet (it might have children — expanding triggers its scan).
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'folder-toggle';
|
||||
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
||||
|| folder.scanState === 'pending'
|
||||
|| folder.scanState === 'zip-pending'
|
||||
// Classify mode: a folder with files (even none of subfolders) is
|
||||
// expandable so its files can be revealed and dragged.
|
||||
|| (classifyOn() && folder.files && folder.files.length > 0);
|
||||
|| folder.scanState === 'pending';
|
||||
if (mightHaveChildren) {
|
||||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||||
toggle.addEventListener('click', (e) => {
|
||||
|
|
@ -213,12 +118,6 @@
|
|||
}
|
||||
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';
|
||||
|
|
@ -273,62 +172,15 @@
|
|||
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 = '📄'; // 📄
|
||||
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
|
||||
*/
|
||||
|
|
@ -715,146 +567,6 @@
|
|||
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,
|
||||
|
|
@ -862,8 +574,6 @@
|
|||
loadFilesFromSelectedFolders,
|
||||
setupKeyboardShortcuts,
|
||||
expandAll,
|
||||
selectAll,
|
||||
revealFile,
|
||||
setHideAssigned
|
||||
selectAll
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,304 +0,0 @@
|
|||
/**
|
||||
* 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 workspace’s source directory isn’t 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; },
|
||||
};
|
||||
})();
|
||||
|
|
@ -30,12 +30,6 @@
|
|||
</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 & 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>
|
||||
|
|
@ -57,26 +51,22 @@
|
|||
<input type="checkbox" id="autoScrollCheckbox" checked>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<label class="checkbox-label" id="hideCompliantLabel">
|
||||
<label class="checkbox-label">
|
||||
<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 (Rename in place) -->
|
||||
<main class="spreadsheet-pane" id="spreadsheetPane" hidden>
|
||||
<!-- Spreadsheet Table -->
|
||||
<main class="spreadsheet-pane">
|
||||
<div class="pane-header">
|
||||
<div class="pane-header-left">
|
||||
<h3>Files</h3>
|
||||
|
|
@ -137,82 +127,20 @@
|
|||
</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"><party>/{received,issued}/<transmittal>. 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 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 & 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><party>/<transmittal>/<name></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>
|
||||
<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>
|
||||
|
||||
<!-- Browser Compatibility Warning -->
|
||||
<div id="browserWarning" class="browser-warning hidden">
|
||||
|
|
@ -220,7 +148,16 @@
|
|||
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
|
||||
</div>
|
||||
|
||||
<p class="welcome__note">Everything runs in your browser — no files are uploaded. Tip: if your folder lives on OneDrive/SharePoint, set it to <em>“Always keep on this device”</em> first for a much faster scan.</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,10 +51,6 @@ export default defineConfig({
|
|||
name: 'classifier',
|
||||
testMatch: 'classifier.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'classify',
|
||||
testMatch: 'classify.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'browse',
|
||||
testMatch: 'browse.spec.js',
|
||||
|
|
|
|||
|
|
@ -3,75 +3,23 @@
|
|||
with tool-local .toast classes; the old classifier rules can stay
|
||||
alongside until this file is concatenated above them in the build. */
|
||||
|
||||
/* 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: 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);
|
||||
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;
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-width: 420px;
|
||||
z-index: 9000;
|
||||
max-width: 400px;
|
||||
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); }
|
||||
|
|
|
|||
120
shared/toast.js
120
shared/toast.js
|
|
@ -1,120 +1,72 @@
|
|||
// shared/toast.js — non-blocking notification helper available to every
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Usage:
|
||||
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
|
||||
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
|
||||
// window.zddc.toast('Saved.', 'success');
|
||||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
||||
// 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';
|
||||
var c = container();
|
||||
|
||||
// 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 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);
|
||||
|
||||
// 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);
|
||||
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);
|
||||
|
||||
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);
|
||||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
||||
el.addEventListener('click', function () {
|
||||
clearTimeout(timer);
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
});
|
||||
|
||||
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() 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.
|
||||
// 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).
|
||||
if (typeof window.alert === 'function' && !window.alertNative) {
|
||||
window.alertNative = window.alert.bind(window);
|
||||
window.alert = function (msg) {
|
||||
|
|
|
|||
|
|
@ -1,619 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
});
|
||||
111
zddc/README.md
111
zddc/README.md
|
|
@ -433,7 +433,8 @@ 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 their own Rego; see "External OPA" below.
|
||||
OPA with the bundled `access_federal.rego` (or their own Rego); see
|
||||
"External OPA" below.
|
||||
|
||||
#### The `inherit:` directive
|
||||
|
||||
|
|
@ -470,10 +471,10 @@ Behaviour:
|
|||
fence; `inherit: false` does not change WORM behaviour. See
|
||||
"Canonical-folder behaviour via `.zddc` keys" below.
|
||||
|
||||
**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-
|
||||
**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-
|
||||
style "reset" semantics in a federal-track deployment should not use
|
||||
the directive — instead, restructure the tree so the permissive
|
||||
ancestor rule never appears.
|
||||
|
|
@ -926,14 +927,13 @@ 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)~~ — *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.
|
||||
- ~~**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.
|
||||
- **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,47 +1266,56 @@ cache lookup would be.
|
|||
|
||||
### Reference Rego policy
|
||||
|
||||
The `--print-rego` flag emits the bundled reference Rego **skeleton**:
|
||||
The `--print-rego` flag emits the bundled reference Rego policies. Two
|
||||
variants ship:
|
||||
|
||||
```sh
|
||||
zddc-server --print-rego # read-ACL skeleton (fail-closed)
|
||||
zddc-server --print-rego # standard cascade (commercial)
|
||||
zddc-server --print-rego=standard # same
|
||||
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
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 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.
|
||||
|
||||
- **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.
|
||||
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.
|
||||
- **Role-based access** — read additional input fields like
|
||||
`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.).
|
||||
`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.
|
||||
|
||||
### Reference deployment shapes
|
||||
|
||||
|
|
@ -1317,7 +1326,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 own Rego policy from a configured source
|
||||
loads the deployment's bundled Rego policy from a configured source
|
||||
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
|
||||
independently of zddc-server.
|
||||
|
||||
|
|
@ -1341,12 +1350,10 @@ 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 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."
|
||||
- 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.
|
||||
|
||||
## Admin Debug Page
|
||||
|
||||
|
|
|
|||
|
|
@ -36,18 +36,21 @@ import (
|
|||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
// --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: 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 → read-ACL skeleton (fail-closed)
|
||||
// --print-rego → standard cascade (commercial default)
|
||||
// --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
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -377,9 +377,10 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
if existed {
|
||||
action = policy.ActionWrite
|
||||
}
|
||||
// Config files (.zddc / .zddc.zip) always require `a` (admin/config-edit)
|
||||
// regardless of create/overwrite — see configWriteAction.
|
||||
action = configWriteAction(abs, action)
|
||||
// .zddc writes always require `a` (admin) regardless of create/overwrite.
|
||||
if filepath.Base(abs) == ".zddc" {
|
||||
action = policy.ActionAdmin
|
||||
}
|
||||
|
||||
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
||||
return
|
||||
|
|
@ -544,9 +545,10 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
// Config files (.zddc / .zddc.zip) require `a` (admin/config-edit) to
|
||||
// delete — see configWriteAction.
|
||||
action := configWriteAction(abs, policy.ActionDelete)
|
||||
action := policy.ActionDelete
|
||||
if filepath.Base(abs) == ".zddc" {
|
||||
action = policy.ActionAdmin
|
||||
}
|
||||
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
||||
return
|
||||
}
|
||||
|
|
@ -674,18 +676,10 @@ 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.
|
||||
//
|
||||
// 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)) {
|
||||
if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
|
||||
return
|
||||
}
|
||||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, configWriteAction(dstAbs, policy.ActionCreate)) {
|
||||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
||||
return
|
||||
}
|
||||
// If-Match concurrency applies to the source bytes — only meaningful for
|
||||
|
|
@ -821,12 +815,10 @@ 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 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.
|
||||
// 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.
|
||||
if email != "" {
|
||||
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
||||
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
||||
|
|
|
|||
|
|
@ -949,86 +949,3 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -814,75 +814,23 @@ 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. */
|
||||
|
||||
/* 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: 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);
|
||||
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;
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
max-width: 420px;
|
||||
z-index: 9000;
|
||||
max-width: 400px;
|
||||
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); }
|
||||
|
|
@ -1722,7 +1670,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-dev · 2026-06-10 14:42:21 · 8f839fc</span></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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -2798,122 +2746,74 @@ body.is-elevated::after {
|
|||
}());
|
||||
|
||||
// shared/toast.js — non-blocking notification helper available to every
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Usage:
|
||||
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
|
||||
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
|
||||
// window.zddc.toast('Saved.', 'success');
|
||||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
||||
// 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';
|
||||
var c = container();
|
||||
|
||||
// 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 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);
|
||||
|
||||
// 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);
|
||||
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);
|
||||
|
||||
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);
|
||||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
||||
el.addEventListener('click', function () {
|
||||
clearTimeout(timer);
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
});
|
||||
|
||||
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() 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.
|
||||
// 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).
|
||||
if (typeof window.alert === 'function' && !window.alertNative) {
|
||||
window.alertNative = window.alert.bind(window);
|
||||
window.alert = function (msg) {
|
||||
|
|
|
|||
191
zddc/internal/policy/federal_parity_test.go
Normal file
191
zddc/internal/policy/federal_parity_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -58,21 +58,18 @@ import (
|
|||
// External Rego policies can:
|
||||
// - read input.user.email (string)
|
||||
// - read input.path (string)
|
||||
// - read input.action ("read"|"write"|"create"|"delete"|"admin");
|
||||
// empty/absent ≡ "read"
|
||||
// - read input.action ("read" | "write"); 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/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.
|
||||
// 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.
|
||||
type AllowInput struct {
|
||||
User struct {
|
||||
Email string `json:"email"`
|
||||
|
|
@ -243,21 +240,11 @@ 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.
|
||||
//
|
||||
// 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).
|
||||
// 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.
|
||||
if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) {
|
||||
return true, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,26 +2,22 @@ package policy
|
|||
|
||||
import _ "embed"
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
//
|
||||
// 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:
|
||||
// Operators running an external OPA can use this as the starting point
|
||||
// for their own policy bundle:
|
||||
//
|
||||
// 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).
|
||||
// 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`.
|
||||
// - 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.
|
||||
|
|
@ -30,3 +26,21 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,26 +1,16 @@
|
|||
# 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.
|
||||
# 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).
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
|
||||
# {
|
||||
# "user": {"email": "alice@example.com", "is_active_admin": false},
|
||||
# "action": "read", # "" / absent == read; else write|create|delete|admin
|
||||
# "user": {"email": "alice@example.com"},
|
||||
# "path": "/Project-A/sub/",
|
||||
# "policy_chain": {
|
||||
# "levels": [
|
||||
|
|
@ -49,40 +39,14 @@ import future.keywords.in
|
|||
|
||||
default allow := false
|
||||
|
||||
# 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 when no .zddc files anywhere in the chain AND no rule matches.
|
||||
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
|
||||
}
|
||||
|
||||
# Read allowed when the deepest matching level grants.
|
||||
# Allow 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])
|
||||
|
|
|
|||
99
zddc/internal/policy/rego/access_federal.rego
Normal file
99
zddc/internal/policy/rego/access_federal.rego
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 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)
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -72,47 +72,3 @@ 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -200,11 +200,10 @@ 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 — 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.
|
||||
// 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.
|
||||
_ = parentSegs // depth-tracking no longer needed
|
||||
_ = i
|
||||
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
||||
|
|
@ -223,11 +222,12 @@ 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 (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.
|
||||
// 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.
|
||||
if principalEmail != "" {
|
||||
for _, c := range freshlyCreated {
|
||||
if !c.autoOwn {
|
||||
|
|
|
|||
|
|
@ -25,13 +25,12 @@ import (
|
|||
// (e.g. a vendor folder where only the vendor and the doc controller
|
||||
// should have access regardless of broader project-level grants).
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Inherit is per-level and not itself cascading: an ancestor's
|
||||
// `inherit: false` does not transitively block descendants from
|
||||
|
|
@ -229,15 +228,12 @@ 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 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 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 *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
||||
|
||||
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
|
||||
|
|
|
|||
|
|
@ -32,21 +32,20 @@ func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
|
|||
}
|
||||
|
||||
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
||||
// 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.
|
||||
// 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."
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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). Callers using the fenced variant typically pass nil roles.
|
||||
// inherit:false). Typically callers using the fenced variant pass nil
|
||||
// roles — per-user homes don't need peer authority.
|
||||
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
|
||||
return writeAutoOwn(dir, principalEmail, true, roles)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue