Compare commits
25 commits
7d93171900
...
59ffd861f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ffd861f9 | |||
| 430ea8371e | |||
| 055f4cf4e0 | |||
| 8f839fc0c9 | |||
| 6d132572d3 | |||
| 84c1b58b66 | |||
| 88ef2dd921 | |||
| d14516a74d | |||
| 42f520e087 | |||
| 01b01f8f7a | |||
| 975c804cc7 | |||
| afcba81e61 | |||
| eb07e7622d | |||
| 1d09abdc8b | |||
| 05fc3b69dd | |||
| 420f735e89 | |||
| eb1e3ec948 | |||
| 47cf58b0e9 | |||
| a8403d1f73 | |||
| a8f116734d | |||
| 389b2e94ac | |||
| e0ba77a75b | |||
| caff489206 | |||
| cb1456e55f | |||
| 28bfcc6e8c |
37 changed files with 4114 additions and 700 deletions
|
|
@ -526,12 +526,12 @@ The in-flight lifecycle slots form a one-way ratchet:
|
||||||
|
|
||||||
`working/` → `staging/` → `issued/` (WORM)
|
`working/` → `staging/` → `issued/` (WORM)
|
||||||
|
|
||||||
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside their auto-own-fenced `<email>/` home. At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/` → `received/` (WORM).
|
Each handoff drops `project_team`'s modify rights for the slot they pushed from. At `working/` they have `cr` plus `rwcda` inside the `<party>/` folder they create (auto-owned but **unfenced** — `working/` is a shared team space, so peers keep their `cr` there). At `staging/` they have `cr` only (drop files, no modify after). DC takes over with `rwcd` at staging and files to `issued/` via the WORM `cr` grant. Same shape on the inbound side via `incoming/` → `received/` (WORM).
|
||||||
|
|
||||||
Pick a role per persona:
|
Pick a role per persona:
|
||||||
|
|
||||||
- `document_controller` — per-party records custodian; files into WORM `received/issued`, manages the `working/staging/reviewing` lifecycle, QCs the counterparty's drops in `incoming/`. **NOT a subtree-admin** anywhere — authority comes purely from cascade grants (the role-level `rwcda` written by `auto_own_roles` at each party, plus explicit `rwcd` at `incoming/` and `staging/`). They cannot bypass WORM (only worm-create via the list) or reach inside fenced working homes.
|
- `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. Owns their `archive/<party>/working/<email>/` home via auto-own with a fenced `.zddc` (`inherit: false`).
|
- `project_team` — day-to-day contributor. Reads across the project; ratchets through the in-flight slots. Auto-owns (`rwcda`) the `working/<party>/` folder they create; it is **unfenced**, so `working/` stays a shared team space (every `project_team` member keeps cascade `cr` there). A per-directory `auto_own_fenced` opt-in (not set in the default tree) would make it private.
|
||||||
- `observer` — pure read-only across the project. No auto-own home (the role itself has no `c` anywhere). Intended for auditors, regulators, and external read-only viewers who must not contribute content.
|
- `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.)
|
**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`.
|
- `roles: { <name>: { members: [...], reset: <bool>? } }` — members union across the cascade unless `reset: true`.
|
||||||
- `admins: [<email>, ...]` — root only; sudo-style elevation per request.
|
- `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: <bool>` — when true, ensure.go writes a `.zddc` granting the creator's email `rwcda` on first mkdir.
|
||||||
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc` (private-by-default home). No effect without `auto_own: true`.
|
- `auto_own_fenced: <bool>` — adds `inherit: false` to the auto-own `.zddc`, making the directory private to its creator (ancestor grants don't cascade in). **Opt-in — not set anywhere in the default tree**, so the default working/staging/incoming/reviewing party homes are unfenced/shared. No effect without `auto_own: true`.
|
||||||
- `auto_own_roles: [<role>, ...]` — additional role names that get `rwcda` in the auto-own `.zddc`, alongside the creator's email. Lets the schema express role-level peer authority without `admins:` (which would be subtree-admin and bypass WORM/fences via elevation).
|
- `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.
|
- `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 |
|
| 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/` |
|
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego (the bundled `access_federal.rego` is the parent-deny-is-absolute / NIST AC-6 variant) while keeping the same `.zddc` files as input data |
|
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in default tree bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego the operator writes (e.g. ancestor-deny-absolute for NIST AC-6) while keeping the same `.zddc` files as input data; zddc-server ships only a fail-closed read-ACL skeleton (`--print-rego`) as a starting point |
|
||||||
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into the embedded default tree): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; the embedded default tree |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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`) |
|
| Access model | Per-verb (`r`/`w`/`c`/`d`/`a`) with first-class roles and an admin escape hatch — closes NIST AC-3(7) | (closed by default; external Rego still available for org-specific policy via `ZDDC_OPA_URL`) |
|
||||||
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with `access_federal.rego` for ancestor-deny-absolute / NIST AC-6 | (closed; federal posture is the OPA path) |
|
| Subtree authority | In-process decider: leaf grants override ancestor denies (delegation primitive). Federal posture: deploy OPA with the operator's own ancestor-deny-absolute Rego (NIST AC-6) | (closed; federal posture is the OPA path) |
|
||||||
| Audit log integrity | Local lumberjack rotation, filesystem-trusted | Tamper-evident (signed chain or external append-only sink), 1y online + 3y archive |
|
| 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 |
|
| 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 |
|
| 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.
|
`.zddc` files express grants under `acl.permissions: { principal → verb-set }`. A principal containing `@` is an email pattern matched by `MatchesPattern` (existing glob); a bare name is a role looked up against `roles:` definitions, walking the cascade for the closest definition. Empty verb set is an explicit deny.
|
||||||
|
|
||||||
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with the bundled `access_federal.rego`.
|
Cascade evaluation walks leaf→root for the first level whose entries match the user; the union of matching verb sets at that level wins. A leaf allow overrides an ancestor explicit-deny — that's the load-bearing delegation primitive that lets a subtree owner grant access without root-admin involvement. Operators who need the opposite rule (ancestor-deny-absolute, NIST AC-6) deploy OPA with their own Rego (zddc-server ships only the fail-closed read-ACL skeleton at `--print-rego`).
|
||||||
|
|
||||||
The `admins:` field (root or any subtree `.zddc`) confers admin authority over that level and below, but it splits into two powers — see the elevation section below:
|
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.
|
- **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,8 +65,11 @@ npm test # all Playwright specs (build first!)
|
||||||
npx playwright test <tool> # one spec
|
npx playwright test <tool> # one spec
|
||||||
./dev-server start # stop # cache-busting HTTP on :8000
|
./dev-server start # stop # cache-busting HTTP on :8000
|
||||||
|
|
||||||
# zddc/ Go server (sub-project)
|
# zddc/ Go server (sub-project). Go is NOT on the host — run go test/build
|
||||||
(cd zddc && go test ./...) # unit tests (Go 1.24+)
|
# through the localhost/zddc-go:1.24 container (canonical wrapper, with the
|
||||||
|
# GOPROXY/GOPRIVATE env it needs, in AGENTS.md § Test). The bare command
|
||||||
|
# below fails on the host.
|
||||||
|
(cd zddc && go test ./...) # unit tests (Go 1.24+) — via the podman wrapper, not host
|
||||||
```
|
```
|
||||||
|
|
||||||
No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design.
|
No lint/typecheck/format commands exist for the HTML tools — vanilla JS + POSIX sh by design.
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,15 @@ concat_files \
|
||||||
"js/utils.js" \
|
"js/utils.js" \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
"js/store.js" \
|
"js/store.js" \
|
||||||
|
"js/persist.js" \
|
||||||
|
"js/classify.js" \
|
||||||
|
"js/workspace.js" \
|
||||||
|
"js/dnd.js" \
|
||||||
"js/validator.js" \
|
"js/validator.js" \
|
||||||
"js/scanner.js" \
|
"js/scanner.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
|
"js/target-tree.js" \
|
||||||
|
"js/copy.js" \
|
||||||
"js/spreadsheet.js" \
|
"js/spreadsheet.js" \
|
||||||
"js/selection.js" \
|
"js/selection.js" \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
|
|
|
||||||
|
|
@ -175,34 +175,46 @@
|
||||||
background-color: var(--bg-hover);
|
background-color: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Counts read "direct+total". The direct number stays solid (immediate info);
|
/* Counts read "direct+total". Completed numbers are blue — var(--primary),
|
||||||
the "+total" subtree count is muted and pulses while its subtree is still
|
which is theme-aware (medium blue in light, lighter blue in dark). The
|
||||||
being scanned, then goes solid once final. */
|
direct number is always completed (known the moment the folder is read).
|
||||||
|
The "+total" subtree count stays muted grey + pulses while still scanning,
|
||||||
|
then turns blue once final. Once the row is fully scanned (both numbers
|
||||||
|
blue) the folders/files labels turn blue too (.folder-count.done). */
|
||||||
|
.folder-count .ct-direct,
|
||||||
.folder-count .ct-total {
|
.folder-count .ct-total {
|
||||||
color: var(--text-secondary, #6b7280);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
.folder-count .ct-total.pending {
|
.folder-count .ct-total.pending {
|
||||||
color: var(--text-muted, #9aa0a6);
|
color: var(--text-muted, #9aa0a6);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
animation: scan-pulse 1.2s ease-in-out infinite;
|
animation: scan-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
.folder-count.done .ct-label {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
@keyframes scan-pulse {
|
@keyframes scan-pulse {
|
||||||
0%, 100% { opacity: 0.55; }
|
0%, 100% { opacity: 0.55; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Live scan status line under the tree-pane header. */
|
/* Page footer — hosts the live scan status. */
|
||||||
.scan-status {
|
.app-footer {
|
||||||
padding: 0.25rem 0.6rem;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.75rem;
|
||||||
|
border-top: 1px solid var(--border, #e2e2e2);
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted, #8a8a8a);
|
color: var(--text-muted, #8a8a8a);
|
||||||
border-bottom: 1px solid var(--border, #e2e2e2);
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
.scan-status {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
min-height: 1.1em;
|
|
||||||
}
|
}
|
||||||
.scan-status:empty { display: none; }
|
|
||||||
.scan-status.scanning { color: var(--primary, #2868c8); }
|
.scan-status.scanning { color: var(--primary, #2868c8); }
|
||||||
|
|
||||||
.folder-item.selected {
|
.folder-item.selected {
|
||||||
|
|
@ -253,6 +265,242 @@
|
||||||
margin-left: 1.5rem;
|
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 */
|
||||||
.spreadsheet-pane {
|
.spreadsheet-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@
|
||||||
cacheDOMElements();
|
cacheDOMElements();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
|
// Workspace manager (renders the welcome list, owns new/open/autosave).
|
||||||
|
if (app.modules.workspace) app.modules.workspace.init();
|
||||||
|
|
||||||
// Browser-compatibility branch:
|
// Browser-compatibility branch:
|
||||||
// HTTP mode (served by zddc-server) — works everywhere; the
|
// HTTP mode (served by zddc-server) — works everywhere; the
|
||||||
// HTTP polyfill stands in for the FS Access API. Auto-load
|
// HTTP polyfill stands in for the FS Access API. Auto-load
|
||||||
|
|
@ -139,6 +142,9 @@
|
||||||
exportHashesBtn: document.getElementById('exportHashesBtn'),
|
exportHashesBtn: document.getElementById('exportHashesBtn'),
|
||||||
sha256Checkbox: document.getElementById('sha256Checkbox'),
|
sha256Checkbox: document.getElementById('sha256Checkbox'),
|
||||||
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
|
hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'),
|
||||||
|
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
|
||||||
|
hideAssignedCheckbox: document.getElementById('hideAssignedCheckbox'),
|
||||||
|
hideAssignedLabel: document.getElementById('hideAssignedLabel'),
|
||||||
|
|
||||||
// Folder tree
|
// Folder tree
|
||||||
folderTree: document.getElementById('folderTree'),
|
folderTree: document.getElementById('folderTree'),
|
||||||
|
|
@ -158,10 +164,41 @@
|
||||||
errorFiles: document.getElementById('errorFiles'),
|
errorFiles: document.getElementById('errorFiles'),
|
||||||
|
|
||||||
// Preview
|
// Preview
|
||||||
togglePreviewBtn: document.getElementById('togglePreviewBtn')
|
togglePreviewBtn: document.getElementById('togglePreviewBtn'),
|
||||||
|
|
||||||
|
// Mode switch + Classify & Copy panes
|
||||||
|
modeRenameBtn: document.getElementById('modeRenameBtn'),
|
||||||
|
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
|
||||||
|
spreadsheetPane: document.getElementById('spreadsheetPane'),
|
||||||
|
targetPane: document.getElementById('targetPane'),
|
||||||
|
copyOutputBtn: document.getElementById('copyOutputBtn')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch between "Rename" (in-place grid) and "Classify & Copy" (map files
|
||||||
|
* onto target trees, copy renamed copies out). The source tree (left) stays
|
||||||
|
* in both modes; only the right pane swaps.
|
||||||
|
*/
|
||||||
|
function setMode(mode) {
|
||||||
|
const classify = mode === 'classify';
|
||||||
|
app.dom.modeRenameBtn.classList.toggle('active', !classify);
|
||||||
|
app.dom.modeClassifyBtn.classList.toggle('active', classify);
|
||||||
|
if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify;
|
||||||
|
if (app.dom.targetPane) app.dom.targetPane.hidden = !classify;
|
||||||
|
// Mode-specific source-tree filters: "Hide Compliant" is for the rename
|
||||||
|
// grid; "Hide Assigned" is for the classify workflow.
|
||||||
|
if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify;
|
||||||
|
if (app.dom.hideAssignedLabel) app.dom.hideAssignedLabel.hidden = !classify;
|
||||||
|
app.modules.classify.setEnabled(classify);
|
||||||
|
if (classify && app.modules.targetTree) {
|
||||||
|
app.modules.targetTree.init();
|
||||||
|
app.modules.targetTree.render();
|
||||||
|
}
|
||||||
|
// Re-render the source tree so its per-file markers appear/disappear.
|
||||||
|
if (app.modules.tree) app.modules.tree.render();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up event listeners
|
* Set up event listeners
|
||||||
*/
|
*/
|
||||||
|
|
@ -186,14 +223,37 @@
|
||||||
// Hide compliant toggle
|
// Hide compliant toggle
|
||||||
app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle);
|
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
|
// Collapse tree button
|
||||||
app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree);
|
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
|
// Keyboard shortcuts
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
// Resize handle
|
// Resize handle
|
||||||
setupResizeHandle();
|
setupResizeHandle();
|
||||||
|
|
||||||
|
// Re-render the source tree when classify state changes (so file dots
|
||||||
|
// and placements stay in sync after a drop). Cheap no-op outside
|
||||||
|
// classify mode.
|
||||||
|
if (app.modules.classify) {
|
||||||
|
app.modules.classify.on(function () {
|
||||||
|
if (app.modules.classify.isEnabled() && app.modules.tree) app.modules.tree.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -310,27 +370,35 @@
|
||||||
/**
|
/**
|
||||||
* Open a directory handle and initialize the application
|
* Open a directory handle and initialize the application
|
||||||
*/
|
*/
|
||||||
async function openDirectory(dirHandle) {
|
// Show the main UI and initialize the per-tool modules ONCE. Shared by the
|
||||||
app.rootHandle = dirHandle;
|
// legacy rename open and the workspace open/new flows (the latter scan or
|
||||||
|
// load a snapshot themselves).
|
||||||
// Hide welcome screen and show main UI
|
var shellInited = false;
|
||||||
|
function enterAppShell() {
|
||||||
hideWelcomeScreen();
|
hideWelcomeScreen();
|
||||||
showMainUI();
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize modules BEFORE scanning (so they're ready for store updates)
|
async function openDirectory(dirHandle) {
|
||||||
app.modules.spreadsheet.init(); // Subscribe to store
|
app.rootHandle = dirHandle;
|
||||||
app.modules.selection.init();
|
enterAppShell();
|
||||||
app.modules.preview.init(); // After selection so it can listen for rowfocused
|
// Default to Classify & Copy (the primary workflow). The user can switch
|
||||||
app.modules.resize.init();
|
// to "Rename in place" via the toggle for the spreadsheet.
|
||||||
app.modules.filter.init();
|
setMode('classify');
|
||||||
app.modules.sort.init();
|
|
||||||
app.modules.tree.setupKeyboardShortcuts();
|
|
||||||
|
|
||||||
// Now scan directory (this will trigger store updates and renders)
|
// Now scan directory (this will trigger store updates and renders)
|
||||||
await app.modules.scanner.scanDirectory(dirHandle);
|
await app.modules.scanner.scanDirectory(dirHandle);
|
||||||
|
|
||||||
// Show refresh button now that a directory is loaded
|
|
||||||
if (app.dom.refreshHeaderBtn) { app.dom.refreshHeaderBtn.classList.remove('hidden'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -343,6 +411,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Clear current data
|
||||||
app.folderTree = [];
|
app.folderTree = [];
|
||||||
app.selectedFolders.clear();
|
app.selectedFolders.clear();
|
||||||
|
|
@ -354,6 +432,12 @@
|
||||||
// Rescan directory (modules already initialized, just rescan)
|
// Rescan directory (modules already initialized, just rescan)
|
||||||
await app.modules.scanner.scanDirectory(app.rootHandle);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error refreshing directory:', err);
|
console.error('Error refreshing directory:', err);
|
||||||
alert('Error refreshing directory: ' + err.message);
|
alert('Error refreshing directory: ' + err.message);
|
||||||
|
|
@ -489,7 +573,9 @@
|
||||||
|
|
||||||
// Export functions for use by other modules
|
// Export functions for use by other modules
|
||||||
app.modules.app = {
|
app.modules.app = {
|
||||||
updateStats
|
updateStats,
|
||||||
|
setMode,
|
||||||
|
enterAppShell
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
|
|
|
||||||
519
classifier/js/classify.js
Normal file
519
classifier/js/classify.js
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — "Classify & Copy" state model.
|
||||||
|
*
|
||||||
|
* The non-destructive workflow: the source directory is read-only; the user
|
||||||
|
* maps each source file onto two orthogonal target trees, and a later copy
|
||||||
|
* step writes renamed copies into a separate output directory.
|
||||||
|
*
|
||||||
|
* - Tracking tab (→ filename), POSITIONAL:
|
||||||
|
* tracking number = the file's ancestor folder names joined with '-'
|
||||||
|
* revision (+status) = its immediate parent folder, named "REV (STATUS)"
|
||||||
|
* title = derived from the original filename
|
||||||
|
* → TRACKING_REV (STATUS) - TITLE.ext
|
||||||
|
* - Transmittal tab (→ output path):
|
||||||
|
* <party>/{issued,received}/<YYYY-MM-DD_TN (STATUS) - TITLE>/
|
||||||
|
*
|
||||||
|
* This module is the single source of truth: placements live in `assignments`
|
||||||
|
* keyed by source-relative path (so they survive a re-pick); the trees define
|
||||||
|
* structure only. All target values are DERIVED, never stored.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── unique ids ───────────────────────────────────────────────────────────
|
||||||
|
var _idSeq = 0;
|
||||||
|
function uid() {
|
||||||
|
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||||
|
try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
|
||||||
|
}
|
||||||
|
return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── state ────────────────────────────────────────────────────────────────
|
||||||
|
var state = {
|
||||||
|
enabled: false, // classify mode on/off
|
||||||
|
assignments: {}, // srcKey -> { trackingNodeId, transmittalNodeId, excluded, titleOverride }
|
||||||
|
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
|
||||||
|
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
|
||||||
|
outputName: null, // remembered output directory display name
|
||||||
|
};
|
||||||
|
|
||||||
|
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
||||||
|
var nodeIndex = {};
|
||||||
|
|
||||||
|
// ── pub/sub ──────────────────────────────────────────────────────────────
|
||||||
|
var listeners = [];
|
||||||
|
function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
|
||||||
|
var notifyScheduled = false;
|
||||||
|
function notify() {
|
||||||
|
// Coalesce bursts (a group-drop touches many keys) into one render.
|
||||||
|
// Listeners include the target/source re-renders AND the workspace
|
||||||
|
// autosave (workspace.js subscribes) — persistence is not this
|
||||||
|
// module's concern.
|
||||||
|
if (notifyScheduled) return;
|
||||||
|
notifyScheduled = true;
|
||||||
|
Promise.resolve().then(function () {
|
||||||
|
notifyScheduled = false;
|
||||||
|
for (var i = 0; i < listeners.length; i++) {
|
||||||
|
try { listeners[i](); } catch (e) { console.error('classify listener', e); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── source keys + title derivation ───────────────────────────────────────
|
||||||
|
function stripRoot(p) {
|
||||||
|
var i = (p || '').indexOf('/');
|
||||||
|
return i < 0 ? '' : p.slice(i + 1);
|
||||||
|
}
|
||||||
|
// Stable key for a file: its path relative to the picked root (root segment
|
||||||
|
// dropped), so re-picking the same directory re-attaches the same map.
|
||||||
|
function srcKeyForFile(file) {
|
||||||
|
var rel = stripRoot(file.folderPath || '');
|
||||||
|
var fn = zddc.joinExtension(file.originalFilename, file.extension);
|
||||||
|
return rel ? rel + '/' + fn : fn;
|
||||||
|
}
|
||||||
|
// Default title: if the original name already parses as ZDDC, reuse its
|
||||||
|
// title; otherwise the cleaned stem (originalFilename is the stem already).
|
||||||
|
function defaultTitle(file) {
|
||||||
|
var full = zddc.joinExtension(file.originalFilename, file.extension);
|
||||||
|
var parsed = zddc.parseFilename(full);
|
||||||
|
if (parsed && parsed.valid && parsed.title) return parsed.title;
|
||||||
|
return (file.originalFilename || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a leaf folder label "A (IFR)" → { revision, status }. No parens →
|
||||||
|
// the whole label is the revision and status is blank.
|
||||||
|
var LEAF_RE = /^(.*?)\s*\(([^)]+)\)\s*$/;
|
||||||
|
function parseLeafLabel(name) {
|
||||||
|
var m = (name || '').match(LEAF_RE);
|
||||||
|
if (m) return { revision: m[1].trim(), status: m[2].trim() };
|
||||||
|
return { revision: (name || '').trim(), status: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── assignments ──────────────────────────────────────────────────────────
|
||||||
|
function assignmentFor(key) {
|
||||||
|
var a = state.assignments[key];
|
||||||
|
if (!a) {
|
||||||
|
a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
|
||||||
|
state.assignments[key] = a;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
// Read-only: returns the existing entry or null (no side effects).
|
||||||
|
function getAssignment(key) { return state.assignments[key] || null; }
|
||||||
|
function cleanAssignment(key) {
|
||||||
|
var a = state.assignments[key];
|
||||||
|
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
|
||||||
|
delete state.assignments[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place keys onto a node along one axis ('tracking' | 'transmittal').
|
||||||
|
// nodeId null clears that axis.
|
||||||
|
function place(keys, nodeId, axis) {
|
||||||
|
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
var a = assignmentFor(k);
|
||||||
|
a[field] = nodeId || null;
|
||||||
|
a.excluded = false; // placing un-excludes
|
||||||
|
cleanAssignment(k);
|
||||||
|
});
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
function setExcluded(keys, excluded) {
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
var a = assignmentFor(k);
|
||||||
|
a.excluded = !!excluded;
|
||||||
|
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
|
||||||
|
cleanAssignment(k);
|
||||||
|
});
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
function setTitleOverride(key, title) {
|
||||||
|
var a = assignmentFor(key);
|
||||||
|
a.titleOverride = title && title.trim() ? title.trim() : null;
|
||||||
|
cleanAssignment(key);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── node index ───────────────────────────────────────────────────────────
|
||||||
|
function rebuildIndex() {
|
||||||
|
nodeIndex = {};
|
||||||
|
(function walkTracking(nodes, parent) {
|
||||||
|
(nodes || []).forEach(function (n) {
|
||||||
|
nodeIndex[n.id] = { node: n, kind: 'tracking', parent: parent };
|
||||||
|
walkTracking(n.children, n);
|
||||||
|
});
|
||||||
|
})(state.trackingTree, null);
|
||||||
|
(state.transmittalTree || []).forEach(function (party) {
|
||||||
|
nodeIndex[party.id] = { node: party, kind: 'party', parent: null };
|
||||||
|
(party.children || []).forEach(function (slot) {
|
||||||
|
nodeIndex[slot.id] = { node: slot, kind: 'slot', parent: party };
|
||||||
|
(slot.children || []).forEach(function (bin) {
|
||||||
|
nodeIndex[bin.id] = { node: bin, kind: 'transmittal', parent: slot };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
|
||||||
|
function infoFor(id) { return nodeIndex[id] || null; }
|
||||||
|
|
||||||
|
// Ancestor name chain for a tracking node (root → node inclusive).
|
||||||
|
function trackingChain(info) {
|
||||||
|
var names = [];
|
||||||
|
var cur = info;
|
||||||
|
while (cur && cur.kind === 'tracking') {
|
||||||
|
names.unshift(cur.node.name);
|
||||||
|
cur = cur.parent ? infoFor(cur.parent.id) : null;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── tracking tree ops ────────────────────────────────────────────────────
|
||||||
|
function addTrackingNode(parentId, name) {
|
||||||
|
var node = { id: uid(), name: (name || 'new').trim() || 'new', children: [] };
|
||||||
|
if (parentId) {
|
||||||
|
var info = infoFor(parentId);
|
||||||
|
if (!info || info.kind !== 'tracking') return null;
|
||||||
|
info.node.children.push(node);
|
||||||
|
} else {
|
||||||
|
state.trackingTree.push(node);
|
||||||
|
}
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
return node.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transmittal tree ops ─────────────────────────────────────────────────
|
||||||
|
function addParty(name) {
|
||||||
|
var party = { id: uid(), kind: 'party', name: (name || 'Party').trim() || 'Party', children: [] };
|
||||||
|
state.transmittalTree.push(party);
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
return party.id;
|
||||||
|
}
|
||||||
|
function ensureSlot(party, slot) {
|
||||||
|
var existing = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
|
||||||
|
if (existing) return existing;
|
||||||
|
var node = { id: uid(), kind: 'slot', slot: slot, name: slot, children: [] };
|
||||||
|
party.children.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
// Create a transmittal bin. meta = { date, type:'TRN'|'SUB', seq, status?, title? }.
|
||||||
|
// The folder name follows the folder grammar; party node name doubles as the
|
||||||
|
// transmittal-number prefix (so its tracking is "<party>-<type>-<seq>").
|
||||||
|
function addTransmittalBin(partyId, slot, meta) {
|
||||||
|
var info = infoFor(partyId);
|
||||||
|
if (!info || info.kind !== 'party') return null;
|
||||||
|
var slotNode = ensureSlot(info.node, slot);
|
||||||
|
var bin = { id: uid(), kind: 'transmittal', name: transmittalFolderName(info.node.name, meta), meta: meta };
|
||||||
|
slotNode.children.push(bin);
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
return bin.id;
|
||||||
|
}
|
||||||
|
function transmittalFolderName(partyName, meta) {
|
||||||
|
var tn = [partyName, meta.type, meta.seq].filter(Boolean).join('-');
|
||||||
|
var status = meta.status && zddc.isValidStatus(meta.status) ? meta.status : '---';
|
||||||
|
var title = (meta.title && meta.title.trim()) || (meta.type === 'SUB' ? 'Submittal' : 'Transmittal');
|
||||||
|
return zddc.formatFolder({ date: meta.date, trackingNumber: tn, status: status, title: title });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared node ops ──────────────────────────────────────────────────────
|
||||||
|
function renameNode(id, name) {
|
||||||
|
var info = infoFor(id);
|
||||||
|
if (!info) return;
|
||||||
|
if (info.kind === 'slot') return; // slots are fixed
|
||||||
|
info.node.name = (name || '').trim() || info.node.name;
|
||||||
|
if (info.kind === 'party') {
|
||||||
|
// Party rename re-derives child transmittal folder names (prefix).
|
||||||
|
(info.node.children || []).forEach(function (slot) {
|
||||||
|
(slot.children || []).forEach(function (bin) {
|
||||||
|
bin.name = transmittalFolderName(info.node.name, bin.meta);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
// Delete a node (and descendants). Any placement referencing a removed node
|
||||||
|
// is cleared so no file points at a ghost.
|
||||||
|
function deleteNode(id) {
|
||||||
|
var info = infoFor(id);
|
||||||
|
if (!info) return;
|
||||||
|
var removed = {};
|
||||||
|
(function collect(n) {
|
||||||
|
removed[n.id] = true;
|
||||||
|
(n.children || []).forEach(collect);
|
||||||
|
})(info.node);
|
||||||
|
|
||||||
|
if (info.kind === 'tracking') {
|
||||||
|
removeFrom(info.parent ? info.parent.children : state.trackingTree, id);
|
||||||
|
} else if (info.kind === 'party') {
|
||||||
|
removeFrom(state.transmittalTree, id);
|
||||||
|
} else if (info.kind === 'transmittal') {
|
||||||
|
removeFrom(info.parent.children, id); // info.parent is the slot node
|
||||||
|
}
|
||||||
|
// Clear dangling placements.
|
||||||
|
Object.keys(state.assignments).forEach(function (k) {
|
||||||
|
var a = state.assignments[k];
|
||||||
|
if (a.trackingNodeId && removed[a.trackingNodeId]) a.trackingNodeId = null;
|
||||||
|
if (a.transmittalNodeId && removed[a.transmittalNodeId]) a.transmittalNodeId = null;
|
||||||
|
cleanAssignment(k);
|
||||||
|
});
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
function removeFrom(arr, id) {
|
||||||
|
for (var i = 0; i < arr.length; i++) { if (arr[i].id === id) { arr.splice(i, 1); return; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── derive target ────────────────────────────────────────────────────────
|
||||||
|
// Compute the full target for a file from its placements. Pure; returns
|
||||||
|
// { tracking, revision, status, title, extension, filename, outPath,
|
||||||
|
// party, slot, transmittalFolder, complete, excluded, errors:[] }.
|
||||||
|
function deriveTarget(file) {
|
||||||
|
var key = srcKeyForFile(file);
|
||||||
|
var a = state.assignments[key] || {};
|
||||||
|
var out = {
|
||||||
|
key: key,
|
||||||
|
tracking: '', revision: '', status: '',
|
||||||
|
title: (a.titleOverride && a.titleOverride.trim()) || defaultTitle(file),
|
||||||
|
extension: file.extension || '',
|
||||||
|
filename: '', outPath: '',
|
||||||
|
party: '', slot: '', transmittalFolder: '',
|
||||||
|
trackingLeaf: false, excluded: !!a.excluded, errors: [],
|
||||||
|
};
|
||||||
|
if (out.excluded) return out;
|
||||||
|
|
||||||
|
// Axis 1 — tracking.
|
||||||
|
if (a.trackingNodeId) {
|
||||||
|
var ti = infoFor(a.trackingNodeId);
|
||||||
|
if (ti && ti.kind === 'tracking') {
|
||||||
|
var chain = trackingChain(ti); // [root … node]
|
||||||
|
out.tracking = chain.slice(0, -1).join('-'); // ancestors only
|
||||||
|
var leaf = parseLeafLabel(ti.node.name);
|
||||||
|
out.revision = leaf.revision;
|
||||||
|
out.status = leaf.status;
|
||||||
|
out.trackingLeaf = (ti.node.children || []).length === 0;
|
||||||
|
if (!out.tracking) out.errors.push('tracking number is empty — the file needs at least one ancestor folder');
|
||||||
|
if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
|
||||||
|
if (!out.status) out.errors.push('parent folder has no "(STATUS)" — name it like "A (IFR)"');
|
||||||
|
if (!out.trackingLeaf) out.errors.push('not in a leaf folder yet');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.errors.push('no tracking number assigned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis 2 — transmittal → output path.
|
||||||
|
if (a.transmittalNodeId) {
|
||||||
|
var xi = infoFor(a.transmittalNodeId);
|
||||||
|
if (xi && xi.kind === 'transmittal') {
|
||||||
|
// bin → slot → party (nodeIndex stores parent as a NODE)
|
||||||
|
var slotInfo = xi.parent ? infoFor(xi.parent.id) : null;
|
||||||
|
out.slot = slotInfo ? slotInfo.node.slot : '';
|
||||||
|
out.party = slotInfo && slotInfo.parent ? slotInfo.parent.name : '';
|
||||||
|
out.transmittalFolder = xi.node.name;
|
||||||
|
if (out.party && out.slot && out.transmittalFolder) {
|
||||||
|
out.outPath = out.party + '/' + out.slot + '/' + out.transmittalFolder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.errors.push('not placed in a transmittal');
|
||||||
|
}
|
||||||
|
|
||||||
|
out.filename = zddc.formatFilename({
|
||||||
|
trackingNumber: out.tracking, revision: out.revision,
|
||||||
|
status: out.status, title: out.title, extension: out.extension,
|
||||||
|
});
|
||||||
|
if (!out.filename && out.errors.length === 0) out.errors.push('incomplete name');
|
||||||
|
out.complete = !!(out.filename && out.outPath && out.errors.length === 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files currently placed in a node (reverse lookup over all source files).
|
||||||
|
function filesInNode(nodeId, axis, allFiles) {
|
||||||
|
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
||||||
|
return (allFiles || []).filter(function (f) {
|
||||||
|
var a = state.assignments[srcKeyForFile(f)];
|
||||||
|
return a && a[field] === nodeId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-file classification state for the left-tree markers.
|
||||||
|
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
|
||||||
|
function fileState(file) {
|
||||||
|
var a = state.assignments[srcKeyForFile(file)];
|
||||||
|
if (!a) return 'none';
|
||||||
|
if (a.excluded) return 'excluded';
|
||||||
|
var t = !!a.trackingNodeId, x = !!a.transmittalNodeId;
|
||||||
|
if (t && x) {
|
||||||
|
var d = deriveTarget(file);
|
||||||
|
return d.complete ? 'done' : 'partial';
|
||||||
|
}
|
||||||
|
if (t) return 'tracking';
|
||||||
|
if (x) return 'transmittal';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stats(allFiles) {
|
||||||
|
var s = { total: 0, excluded: 0, none: 0, partial: 0, done: 0 };
|
||||||
|
(allFiles || []).forEach(function (f) {
|
||||||
|
s.total++;
|
||||||
|
var st = fileState(f);
|
||||||
|
if (st === 'excluded') s.excluded++;
|
||||||
|
else if (st === 'done') s.done++;
|
||||||
|
else if (st === 'none') s.none++;
|
||||||
|
else s.partial++; // tracking | transmittal | partial
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── serialize / load ─────────────────────────────────────────────────────
|
||||||
|
function serialize() {
|
||||||
|
return {
|
||||||
|
assignments: state.assignments,
|
||||||
|
trackingTree: state.trackingTree,
|
||||||
|
transmittalTree: state.transmittalTree,
|
||||||
|
outputName: state.outputName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function load(obj) {
|
||||||
|
if (!obj) return;
|
||||||
|
state.assignments = obj.assignments || {};
|
||||||
|
state.trackingTree = obj.trackingTree || [];
|
||||||
|
state.transmittalTree = obj.transmittalTree || [];
|
||||||
|
state.outputName = obj.outputName || null;
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
function reset() {
|
||||||
|
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
||||||
|
state.outputName = null;
|
||||||
|
rebuildIndex();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── add-folder pattern expansion ─────────────────────────────────────────
|
||||||
|
// Brace expansion for the add-folder box. Supports (non-nested) groups:
|
||||||
|
// {a,b,c} → alternation: a | b | c
|
||||||
|
// {0001-0002} → numeric range, zero-padded to the operands' width
|
||||||
|
// {0001-0002,0005} → mix ranges and literals in one group
|
||||||
|
// Multiple groups expand as a cartesian product, e.g.
|
||||||
|
// "X-{PM,EL}-{0001-0002,0005}_A (IFR)" → 6 names.
|
||||||
|
// A pattern with no braces returns itself (one name). Unbalanced braces are
|
||||||
|
// treated literally so the user never silently loses input.
|
||||||
|
function expandGroup(body) {
|
||||||
|
var out = [];
|
||||||
|
String(body).split(',').forEach(function (piece) {
|
||||||
|
var m = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(piece);
|
||||||
|
if (m) {
|
||||||
|
var a = m[1], b = m[2];
|
||||||
|
var start = parseInt(a, 10), end = parseInt(b, 10);
|
||||||
|
// Pad when either operand carries a leading zero (e.g. 0001).
|
||||||
|
var width = (a.length > 1 && a[0] === '0') || (b.length > 1 && b[0] === '0')
|
||||||
|
? Math.max(a.length, b.length) : 0;
|
||||||
|
var step = start <= end ? 1 : -1;
|
||||||
|
for (var v = start; step > 0 ? v <= end : v >= end; v += step) {
|
||||||
|
out.push(width ? String(v).padStart(width, '0') : String(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(piece);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function expandFolderPattern(pattern) {
|
||||||
|
var s = String(pattern == null ? '' : pattern);
|
||||||
|
var parts = []; // each: {lit} or {opts:[...]}
|
||||||
|
var i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
var open = s.indexOf('{', i);
|
||||||
|
if (open === -1) { parts.push({ lit: s.slice(i) }); break; }
|
||||||
|
var close = s.indexOf('}', open);
|
||||||
|
if (close === -1) { parts.push({ lit: s.slice(i) }); break; } // unbalanced → literal
|
||||||
|
if (open > i) parts.push({ lit: s.slice(i, open) });
|
||||||
|
parts.push({ opts: expandGroup(s.slice(open + 1, close)) });
|
||||||
|
i = close + 1;
|
||||||
|
}
|
||||||
|
var results = [''];
|
||||||
|
parts.forEach(function (p) {
|
||||||
|
var opts = p.lit != null ? [p.lit] : p.opts;
|
||||||
|
var next = [];
|
||||||
|
results.forEach(function (prefix) {
|
||||||
|
opts.forEach(function (o) { next.push(prefix + o); });
|
||||||
|
});
|
||||||
|
results = next;
|
||||||
|
});
|
||||||
|
// Trim + drop empties so a stray comma can't create a blank folder.
|
||||||
|
return results.map(function (r) { return r.trim(); }).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse one (already brace-expanded) folder name into the nested tracking
|
||||||
|
// levels it represents: split on "-" into tracking-number segments, then
|
||||||
|
// split the FINAL segment once on "_" to separate the last tracking segment
|
||||||
|
// from the "REV (STATUS)" leaf. So "CPO-0001_0 (IFU)" → ["CPO","0001","0 (IFU)"]
|
||||||
|
// and "BMB-187023-PM-MOM-0001_A (IFR)" → ["BMB","187023","PM","MOM","0001","A (IFR)"].
|
||||||
|
// A name with no "-"/"_" is a single level (e.g. adding a leaf "A (IFR)").
|
||||||
|
function parseFolderLevels(name) {
|
||||||
|
var s = String(name == null ? '' : name).trim();
|
||||||
|
if (!s) return [];
|
||||||
|
var segs = s.split('-');
|
||||||
|
var last = segs.pop();
|
||||||
|
var u = last.indexOf('_');
|
||||||
|
if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); }
|
||||||
|
else { segs.push(last); }
|
||||||
|
return segs.map(function (x) { return x.trim(); }).filter(Boolean);
|
||||||
|
}
|
||||||
|
// Children array for a tracking node (or the roots for null), or null.
|
||||||
|
function trackingChildren(parentId) {
|
||||||
|
if (!parentId) return state.trackingTree;
|
||||||
|
var info = infoFor(parentId);
|
||||||
|
return (info && info.kind === 'tracking') ? info.node.children : null;
|
||||||
|
}
|
||||||
|
// Ensure a nested chain of tracking folders exists under parentId, reusing
|
||||||
|
// an existing child when one already has that name (so sibling leaves share
|
||||||
|
// ancestors). Returns the leaf node id.
|
||||||
|
function addTrackingPath(parentId, segments) {
|
||||||
|
var cur = parentId || null;
|
||||||
|
(segments || []).forEach(function (seg) {
|
||||||
|
var name = (seg || '').trim();
|
||||||
|
if (!name) return;
|
||||||
|
var kids = trackingChildren(cur) || [];
|
||||||
|
var existing = kids.filter(function (n) { return n.name === name; })[0];
|
||||||
|
cur = existing ? existing.id : addTrackingNode(cur, name);
|
||||||
|
});
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mode ─────────────────────────────────────────────────────────────────
|
||||||
|
function setEnabled(on) { state.enabled = !!on; notify(); }
|
||||||
|
function isEnabled() { return state.enabled; }
|
||||||
|
|
||||||
|
window.app.modules.classify = {
|
||||||
|
// mode
|
||||||
|
setEnabled: setEnabled, isEnabled: isEnabled,
|
||||||
|
// pub/sub
|
||||||
|
on: on,
|
||||||
|
// keys/title
|
||||||
|
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
|
||||||
|
// assignments
|
||||||
|
assignmentFor: assignmentFor, getAssignment: getAssignment,
|
||||||
|
place: place, setExcluded: setExcluded,
|
||||||
|
setTitleOverride: setTitleOverride,
|
||||||
|
// trees
|
||||||
|
addTrackingNode: addTrackingNode, addParty: addParty,
|
||||||
|
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
|
||||||
|
expandFolderPattern: expandFolderPattern,
|
||||||
|
parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
|
||||||
|
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
||||||
|
getTransmittalTree: function () { return state.transmittalTree; },
|
||||||
|
// derive + reverse
|
||||||
|
deriveTarget: deriveTarget, filesInNode: filesInNode,
|
||||||
|
fileState: fileState, stats: stats,
|
||||||
|
// persistence
|
||||||
|
serialize: serialize, load: load, reset: reset,
|
||||||
|
getOutputName: function () { return state.outputName; },
|
||||||
|
setOutputName: function (n) { state.outputName = n || null; notify(); },
|
||||||
|
};
|
||||||
|
})();
|
||||||
199
classifier/js/copy.js
Normal file
199
classifier/js/copy.js
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — copy-out (Classify & Copy mode).
|
||||||
|
*
|
||||||
|
* Copies the fully-classified source files into a SEPARATE output directory
|
||||||
|
* under their canonical ZDDC names and folder layout
|
||||||
|
* <party>/{received,issued}/<DATE_TN (STATUS) - TITLE>/<TRACKING_REV (STATUS) - TITLE.ext>
|
||||||
|
* The source is never modified — every operation is a read (getFile) on the
|
||||||
|
* source and a write into the chosen output handle.
|
||||||
|
*
|
||||||
|
* Duplicate detection:
|
||||||
|
* - two sources → the same output path = mapping conflict (skipped + reported)
|
||||||
|
* - target already exists, identical bytes (sha256) = skipped
|
||||||
|
* - target exists, different bytes = left untouched + reported (no clobber)
|
||||||
|
*
|
||||||
|
* Built on the generic FS-Access shape (getDirectoryHandle/getFileHandle/
|
||||||
|
* createWritable), so it works against a real handle today and a server-backed
|
||||||
|
* output handle later without changing this logic.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var outputHandle = null; // remembered for the session
|
||||||
|
|
||||||
|
function C() { return window.app.modules.classify; }
|
||||||
|
|
||||||
|
function collectFiles() {
|
||||||
|
var out = [];
|
||||||
|
(function walk(nodes) {
|
||||||
|
(nodes || []).forEach(function (n) {
|
||||||
|
(n.files || []).forEach(function (f) { out.push(f); });
|
||||||
|
walk(n.children);
|
||||||
|
});
|
||||||
|
})(window.app.folderTree || []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files that are ready to copy: complete target, not excluded.
|
||||||
|
function plan() {
|
||||||
|
var c = C(), items = [];
|
||||||
|
collectFiles().forEach(function (f) {
|
||||||
|
var d = c.deriveTarget(f);
|
||||||
|
if (d.excluded || !d.complete) return;
|
||||||
|
items.push({ file: f, d: d, outRel: d.outPath + '/' + d.filename });
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by output path; >1 source for a path = a mapping conflict.
|
||||||
|
function conflictsIn(items) {
|
||||||
|
var by = {}, conflicts = [];
|
||||||
|
items.forEach(function (p) { (by[p.outRel] = by[p.outRel] || []).push(p); });
|
||||||
|
Object.keys(by).forEach(function (k) { if (by[k].length > 1) conflicts.push(k); });
|
||||||
|
return { by: by, conflicts: conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, level) {
|
||||||
|
if (window.zddc && window.zddc.toast) window.zddc.toast(msg, level);
|
||||||
|
}
|
||||||
|
function setStatus(text) {
|
||||||
|
var el = document.getElementById('scanStatus');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.toggle('scanning', !!text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseOutput() {
|
||||||
|
if (!window.showDirectoryPicker) {
|
||||||
|
toast('Copying to an output directory needs the File System Access API (use Chromium, or run via zddc-server).', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'zddc-classifier-output' });
|
||||||
|
outputHandle = h;
|
||||||
|
C().setOutputName(h.name);
|
||||||
|
return h;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') toast('Could not open the output directory — ' + (e.message || e), 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(root, relPath) {
|
||||||
|
var parts = relPath.split('/').filter(Boolean);
|
||||||
|
var cur = root;
|
||||||
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
cur = await cur.getDirectoryHandle(parts[i], { create: true });
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sameContent(existingHandle, srcFileObj) {
|
||||||
|
var ef = await existingHandle.getFile();
|
||||||
|
var sf = await (await srcHandle(srcFileObj)).getFile();
|
||||||
|
if (ef.size !== sf.size) return false;
|
||||||
|
var a = await window.zddc.crypto.sha256File(ef);
|
||||||
|
var b = await window.zddc.crypto.sha256File(sf);
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a source file's live handle. Fresh-scan files already carry one;
|
||||||
|
// snapshot-loaded files resolve lazily from the workspace root by path.
|
||||||
|
async function srcHandle(fileObj) {
|
||||||
|
if (fileObj.handle) return fileObj.handle;
|
||||||
|
if (!window.app.rootHandle) throw new Error('source directory not connected');
|
||||||
|
return window.app.modules.scanner.resolveFileHandle(window.app.rootHandle, fileObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone).
|
||||||
|
async function copyOne(out, p) {
|
||||||
|
var dir = await ensureDir(out, p.d.outPath);
|
||||||
|
var existing = null;
|
||||||
|
try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ }
|
||||||
|
if (existing) {
|
||||||
|
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
|
||||||
|
}
|
||||||
|
var srcFile = await (await srcHandle(p.file)).getFile(); // READ source (never write it)
|
||||||
|
var fh = await dir.getFileHandle(p.d.filename, { create: true });
|
||||||
|
var w = await fh.createWritable();
|
||||||
|
await w.write(srcFile);
|
||||||
|
await w.close();
|
||||||
|
return 'copied';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!C().isEnabled()) return;
|
||||||
|
var items = plan();
|
||||||
|
if (!items.length) {
|
||||||
|
toast('Nothing to copy yet — no files are fully classified (need both a tracking leaf and a transmittal).', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var cf = conflictsIn(items);
|
||||||
|
var blocked = {};
|
||||||
|
cf.conflicts.forEach(function (path) { blocked[path] = true; });
|
||||||
|
var todo = items.filter(function (p) { return !blocked[p.outRel]; });
|
||||||
|
|
||||||
|
if (cf.conflicts.length) {
|
||||||
|
toast(cf.conflicts.length + ' output-name collision(s) — two source files map to the same name. Skipped:\n'
|
||||||
|
+ cf.conflicts.join('\n'), 'error');
|
||||||
|
}
|
||||||
|
if (!todo.length) return;
|
||||||
|
|
||||||
|
// Snapshot-loaded files have no live handle — re-grant read on the
|
||||||
|
// workspace source directory (one click) before copying.
|
||||||
|
if (todo.some(function (p) { return !p.file.handle; })) {
|
||||||
|
if (!window.app.rootHandle) {
|
||||||
|
toast('The source directory 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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
28
classifier/js/dnd.js
Normal file
28
classifier/js/dnd.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — drag payload bus for Classify & Copy.
|
||||||
|
*
|
||||||
|
* HTML5 dataTransfer can't be read during `dragover` (only on `drop`), and we
|
||||||
|
* need the dragged set to drive drop-target highlighting. So the source keys
|
||||||
|
* live in a module variable for the lifetime of a drag; dataTransfer carries a
|
||||||
|
* marker so the browser shows a copy cursor and external drops are ignored.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var keys = [];
|
||||||
|
|
||||||
|
function setDrag(srcKeys, e) {
|
||||||
|
keys = (srcKeys || []).slice();
|
||||||
|
if (e && e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
try { e.dataTransfer.setData('application/x-zddc-keys', keys.join('\n')); } catch (_) { /* ok */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getDrag() { return keys; }
|
||||||
|
function active() { return keys.length > 0; }
|
||||||
|
function clearDrag() { keys = []; }
|
||||||
|
|
||||||
|
window.app.modules.dnd = {
|
||||||
|
setDrag: setDrag, getDrag: getDrag, active: active, clearDrag: clearDrag,
|
||||||
|
};
|
||||||
|
})();
|
||||||
128
classifier/js/persist.js
Normal file
128
classifier/js/persist.js
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — workspace persistence (IndexedDB).
|
||||||
|
*
|
||||||
|
* A "workspace" is one classification project: the picked source directory
|
||||||
|
* HANDLE, a SNAPSHOT of its completed scan (folder/file structure — names and
|
||||||
|
* paths only, no contents), and the Classify & Copy map (assignments + target
|
||||||
|
* trees). Scan once, resume instantly across sessions without re-walking the
|
||||||
|
* (often cloud-backed, high-latency) source.
|
||||||
|
*
|
||||||
|
* Two object stores so the welcome list stays cheap:
|
||||||
|
* - 'index' (tiny): { id, name, rootName, createdAt, updatedAt, summary }
|
||||||
|
* - 'data' (large): { id, rootHandle, tree, classify }
|
||||||
|
*
|
||||||
|
* A FileSystemDirectoryHandle is structured-cloneable, so IndexedDB can hold
|
||||||
|
* it; on reuse we re-request permission (one click). It's only needed at COPY
|
||||||
|
* time — opening a workspace runs entirely from the snapshot.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var DB_NAME = 'zddc-classifier';
|
||||||
|
var DB_VERSION = 2;
|
||||||
|
var IDX = 'index';
|
||||||
|
var DATA = 'data';
|
||||||
|
|
||||||
|
var available = typeof indexedDB !== 'undefined';
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
if (!available) { reject(new Error('IndexedDB unavailable')); return; }
|
||||||
|
var req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
req.onupgradeneeded = function () {
|
||||||
|
var db = req.result;
|
||||||
|
// 'kv' (v1, single implicit map) is intentionally left behind.
|
||||||
|
if (!db.objectStoreNames.contains(IDX)) db.createObjectStore(IDX, { keyPath: 'id' });
|
||||||
|
if (!db.objectStoreNames.contains(DATA)) db.createObjectStore(DATA, { keyPath: 'id' });
|
||||||
|
};
|
||||||
|
req.onsuccess = function () { resolve(req.result); };
|
||||||
|
req.onerror = function () { reject(req.error); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqP(req) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
req.onsuccess = function () { resolve(req.result); };
|
||||||
|
req.onerror = function () { reject(req.error); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Light metadata for every workspace (for the welcome list). Sorted newest
|
||||||
|
// first. Never loads the big snapshot.
|
||||||
|
function listWorkspaces() {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return reqP(db.transaction(IDX, 'readonly').objectStore(IDX).getAll());
|
||||||
|
}).then(function (rows) {
|
||||||
|
(rows || []).sort(function (a, b) { return (b.updatedAt || 0) - (a.updatedAt || 0); });
|
||||||
|
return rows || [];
|
||||||
|
}).catch(function (e) { console.warn('persist.list', e); return []; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full data record for one workspace: { id, rootHandle, tree, classify }.
|
||||||
|
function getWorkspace(id) {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return reqP(db.transaction(DATA, 'readonly').objectStore(DATA).get(id));
|
||||||
|
}).catch(function (e) { console.warn('persist.get', e); return null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save (create or update). meta = {id,name,rootName,createdAt,updatedAt,summary};
|
||||||
|
// data = {id, rootHandle, tree, classify}. tree may be omitted on a classify-
|
||||||
|
// only autosave (the snapshot rarely changes) — then we preserve the stored one.
|
||||||
|
function putWorkspace(meta, data) {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var t = db.transaction([IDX, DATA], 'readwrite');
|
||||||
|
t.oncomplete = function () { resolve(); };
|
||||||
|
t.onerror = function () { reject(t.error); };
|
||||||
|
t.objectStore(IDX).put(meta);
|
||||||
|
var ds = t.objectStore(DATA);
|
||||||
|
if (data && typeof data.tree !== 'undefined') {
|
||||||
|
ds.put(data);
|
||||||
|
} else if (data) {
|
||||||
|
// Merge classify/rootHandle without clobbering the snapshot.
|
||||||
|
var g = ds.get(meta.id);
|
||||||
|
g.onsuccess = function () {
|
||||||
|
var existing = g.result || { id: meta.id };
|
||||||
|
if (typeof data.rootHandle !== 'undefined') existing.rootHandle = data.rootHandle;
|
||||||
|
if (typeof data.classify !== 'undefined') existing.classify = data.classify;
|
||||||
|
existing.id = meta.id;
|
||||||
|
ds.put(existing);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(function (e) { console.warn('persist.put', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteWorkspace(id) {
|
||||||
|
return openDB().then(function (db) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var t = db.transaction([IDX, DATA], 'readwrite');
|
||||||
|
t.oncomplete = function () { resolve(); };
|
||||||
|
t.onerror = function () { reject(t.error); };
|
||||||
|
t.objectStore(IDX).delete(id);
|
||||||
|
t.objectStore(DATA).delete(id);
|
||||||
|
});
|
||||||
|
}).catch(function (e) { console.warn('persist.delete', e); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-acquire read permission on a stored handle (one click). true if usable.
|
||||||
|
function verifyPermission(handle, write) {
|
||||||
|
if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false);
|
||||||
|
var opts = { mode: write ? 'readwrite' : 'read' };
|
||||||
|
return handle.queryPermission(opts).then(function (p) {
|
||||||
|
if (p === 'granted') return true;
|
||||||
|
return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; });
|
||||||
|
}).catch(function () { return false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.persist = {
|
||||||
|
available: available,
|
||||||
|
listWorkspaces: listWorkspaces,
|
||||||
|
getWorkspace: getWorkspace,
|
||||||
|
putWorkspace: putWorkspace,
|
||||||
|
deleteWorkspace: deleteWorkspace,
|
||||||
|
verifyPermission: verifyPermission,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -521,7 +521,28 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export module
|
// 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 = {
|
window.app.modules.preview = {
|
||||||
init
|
init,
|
||||||
|
previewFile
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@
|
||||||
let scanGen = 0; // bumped per scan; stale workers bail
|
let scanGen = 0; // bumped per scan; stale workers bail
|
||||||
let scanStats = null; // { folders, files, current, done, startedAt }
|
let scanStats = null; // { folders, files, current, done, startedAt }
|
||||||
let renderTimer = null; // throttle for progressive re-render
|
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() {
|
function scheduleRender() {
|
||||||
if (renderTimer) return;
|
if (renderTimer) return;
|
||||||
|
|
@ -32,18 +41,27 @@
|
||||||
updateScanStatus();
|
updateScanStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the running scan status into the tree-pane header.
|
// elapsed since the scan started, e.g. "3.2s" or "1m 04s".
|
||||||
|
function elapsedStr() {
|
||||||
|
if (!scanStats) return '0s';
|
||||||
|
const ms = Date.now() - scanStats.startedAt;
|
||||||
|
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||||||
|
const m = Math.floor(ms / 60000);
|
||||||
|
const s = Math.round((ms % 60000) / 1000);
|
||||||
|
return m + 'm ' + (s < 10 ? '0' : '') + s + 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the running scan status (with live elapsed time) into the footer.
|
||||||
function updateScanStatus() {
|
function updateScanStatus() {
|
||||||
const el = document.getElementById('scanStatus');
|
const el = document.getElementById('scanStatus');
|
||||||
if (!el || !scanStats) return;
|
if (!el || !scanStats) return;
|
||||||
if (scanStats.done) {
|
if (scanStats.done) {
|
||||||
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
|
|
||||||
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
|
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
|
||||||
+ scanStats.files + ' files in ' + secs + 's';
|
+ scanStats.files + ' files in ' + elapsedStr();
|
||||||
el.classList.remove('scanning');
|
el.classList.remove('scanning');
|
||||||
} else {
|
} else {
|
||||||
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
|
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
|
||||||
+ scanStats.files + ' files'
|
+ scanStats.files + ' files · ' + elapsedStr()
|
||||||
+ (scanStats.current ? ' — ' + scanStats.current : '');
|
+ (scanStats.current ? ' — ' + scanStats.current : '');
|
||||||
el.classList.add('scanning');
|
el.classList.add('scanning');
|
||||||
}
|
}
|
||||||
|
|
@ -91,14 +109,56 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-shot toast for scan errors (permission denied, network hiccups on a
|
// Translate a File System Access API error into accurate, actionable text.
|
||||||
// share). De-duped per path so a flaky folder doesn't spam.
|
// The browser's raw DOMException messages are cryptic and often read like a
|
||||||
|
// permission problem when they aren't — we key off err.name (reliable)
|
||||||
|
// rather than the message. Returns a plain-language explanation; the raw
|
||||||
|
// name + message are still appended by the caller for troubleshooting.
|
||||||
|
function describeFsError(err) {
|
||||||
|
var name = err && err.name ? err.name : '';
|
||||||
|
switch (name) {
|
||||||
|
case 'NotAllowedError':
|
||||||
|
return 'Permission to read this folder was denied or revoked. '
|
||||||
|
+ 'Re-pick the root folder to re-grant access.';
|
||||||
|
case 'InvalidStateError':
|
||||||
|
// The handle was read once, then the directory changed underneath
|
||||||
|
// it (common on a network/SMB share that's being written to, or
|
||||||
|
// after a disconnect/reconnect). NOT a permissions problem.
|
||||||
|
return 'The folder changed on disk since it was first read '
|
||||||
|
+ '(common on a busy or reconnecting network share). '
|
||||||
|
+ 'Rescan to pick up the current contents.';
|
||||||
|
case 'NotFoundError':
|
||||||
|
return 'The folder no longer exists — it may have been moved, '
|
||||||
|
+ 'renamed, or deleted since the scan started.';
|
||||||
|
case 'NotReadableError':
|
||||||
|
return 'The folder could not be read — the share may have '
|
||||||
|
+ 'disconnected, or the OS denied access.';
|
||||||
|
case 'SecurityError':
|
||||||
|
return 'The browser blocked access to this folder for security '
|
||||||
|
+ 'reasons.';
|
||||||
|
case 'TypeMismatchError':
|
||||||
|
return 'Expected a folder here but found a file (or vice-versa).';
|
||||||
|
case 'AbortError':
|
||||||
|
return 'Reading this folder was aborted.';
|
||||||
|
default:
|
||||||
|
return 'Could not read this folder.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot toast for scan errors (permission denied, stale handles, network
|
||||||
|
// hiccups on a share). De-duped per path so a flaky folder doesn't spam.
|
||||||
const scanErrorsSeen = new Set();
|
const scanErrorsSeen = new Set();
|
||||||
function reportScanError(path, err) {
|
function reportScanError(path, err) {
|
||||||
console.error('Scan error:', path, err);
|
console.error('Scan error:', path, err);
|
||||||
if (scanErrorsSeen.has(path)) return;
|
if (scanErrorsSeen.has(path)) return;
|
||||||
scanErrorsSeen.add(path);
|
scanErrorsSeen.add(path);
|
||||||
const msg = 'Couldn’t scan ' + path + ': ' + (err && err.message ? err.message : err);
|
// Plain-language explanation, then the raw error in parentheses so the
|
||||||
|
// user can copy it (toasts are selectable) for deeper troubleshooting.
|
||||||
|
var raw = err && err.name
|
||||||
|
? err.name + (err.message ? ': ' + err.message : '')
|
||||||
|
: (err && err.message ? err.message : String(err));
|
||||||
|
var msg = 'Couldn’t scan ' + path + ' — ' + describeFsError(err)
|
||||||
|
+ '\n\n(' + raw + ')';
|
||||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||||
window.zddc.toast(msg, 'error');
|
window.zddc.toast(msg, 'error');
|
||||||
}
|
}
|
||||||
|
|
@ -139,42 +199,70 @@
|
||||||
}
|
}
|
||||||
flushRender();
|
flushRender();
|
||||||
|
|
||||||
// Breadth-first by level behind a bounded worker pool: level 1, then
|
// Tick the footer's elapsed time once a second even if no new folder
|
||||||
// level 2, … each rendered as it lands (top levels appear first).
|
// landed (so a slow directory doesn't make the timer look frozen).
|
||||||
// Deeper levels keep filling in; workers await between directories so
|
const ticker = setInterval(function () {
|
||||||
// the UI stays responsive on a slow/large network drive.
|
if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; }
|
||||||
let level = [root];
|
updateScanStatus();
|
||||||
while (level.length && myGen === scanGen) {
|
}, 1000);
|
||||||
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
|
|
||||||
const next = [];
|
// Continuous breadth-first walk: up to SCAN_CONCURRENCY directory reads
|
||||||
for (const n of level) {
|
// in flight at once, pulling newly-discovered child dirs as they land
|
||||||
for (const c of n.children) {
|
// (no per-level barrier, so the pool stays saturated). Top levels still
|
||||||
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
|
// appear first (FIFO). The cap is the lever — see SCAN_CONCURRENCY.
|
||||||
if (c.scanState === 'pending') next.push(c);
|
await drainQueue([root], myGen, SCAN_CONCURRENCY);
|
||||||
}
|
if (preserveState && savedExpanded.size) {
|
||||||
}
|
restoreExpandedPaths(window.app.folderTree, savedExpanded);
|
||||||
level = next;
|
|
||||||
}
|
}
|
||||||
|
clearInterval(ticker);
|
||||||
if (myGen !== scanGen) return; // superseded by a newer scan
|
if (myGen !== scanGen) return; // superseded by a newer scan
|
||||||
|
|
||||||
scanStats.done = true;
|
scanStats.done = true;
|
||||||
scanStats.current = '';
|
scanStats.current = '';
|
||||||
flushRender();
|
flushRender();
|
||||||
|
|
||||||
|
// Completion toast with the totals + elapsed time.
|
||||||
|
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||||
|
window.zddc.toast(
|
||||||
|
'Scan complete — ' + scanStats.folders + ' folders, '
|
||||||
|
+ scanStats.files + ' files in ' + elapsedStr() + '.',
|
||||||
|
'success');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run fn over items with at most `limit` concurrent calls; resolves when
|
// Continuous worker pool over a shared queue: keep up to `conc` directory
|
||||||
// all have settled. Termination is clean (no transient-empty-queue race).
|
// reads in flight at once, pulling newly-discovered child dirs as they land
|
||||||
async function runWithConcurrency(items, limit, fn) {
|
// — no per-level barrier, so workers never idle waiting on the slowest dir
|
||||||
let i = 0;
|
// in a level. Roughly breadth-first (FIFO; a node's children are enqueued
|
||||||
async function runner() {
|
// after it), so top levels still surface first. Resolves when the queue is
|
||||||
while (i < items.length) {
|
// drained and no read is in flight (clean termination, no empty-queue race).
|
||||||
const idx = i++;
|
function drainQueue(seed, myGen, conc) {
|
||||||
await fn(items[idx]);
|
const queue = seed.slice();
|
||||||
|
let active = 0;
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
function finishIfIdle() {
|
||||||
|
if (queue.length === 0 && active === 0) resolve();
|
||||||
}
|
}
|
||||||
}
|
function pump() {
|
||||||
const runners = [];
|
while (myGen === scanGen && active < conc && queue.length) {
|
||||||
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
|
const node = queue.shift();
|
||||||
await Promise.all(runners);
|
active++;
|
||||||
|
Promise.resolve(scanNodeChildren(node, myGen)).then(function () {
|
||||||
|
active--;
|
||||||
|
if (myGen === scanGen) {
|
||||||
|
const kids = node.children;
|
||||||
|
for (let i = 0; i < kids.length; i++) {
|
||||||
|
if (kids[i].scanState === 'pending') queue.push(kids[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pump();
|
||||||
|
finishIfIdle();
|
||||||
|
}, function () { active--; pump(); finishIfIdle(); });
|
||||||
|
}
|
||||||
|
finishIfIdle();
|
||||||
|
}
|
||||||
|
pump();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force a folder's subtree to scan NOW (jumped ahead of the background
|
// Force a folder's subtree to scan NOW (jumped ahead of the background
|
||||||
|
|
@ -182,16 +270,7 @@
|
||||||
// shows complete contents. Idempotent + shares the live scan generation.
|
// shows complete contents. Idempotent + shares the live scan generation.
|
||||||
async function ensureScanned(node) {
|
async function ensureScanned(node) {
|
||||||
if (!node || !node.handle || node.scanState === 'done') return;
|
if (!node || !node.handle || node.scanState === 'done') return;
|
||||||
const myGen = scanGen;
|
await drainQueue([node], scanGen, SCAN_CONCURRENCY);
|
||||||
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();
|
flushRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,6 +308,8 @@
|
||||||
// only a 'pending' node is scanned, so concurrent callers (background +
|
// only a 'pending' node is scanned, so concurrent callers (background +
|
||||||
// open-prioritised) don't double-scan.
|
// open-prioritised) don't double-scan.
|
||||||
async function scanNodeChildren(node, myGen) {
|
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;
|
if (node.scanState !== 'pending') return;
|
||||||
node.scanState = 'scanning';
|
node.scanState = 'scanning';
|
||||||
if (scanStats) scanStats.current = node.path;
|
if (scanStats) scanStats.current = node.path;
|
||||||
|
|
@ -238,18 +319,19 @@
|
||||||
for await (const entry of node.handle.values()) {
|
for await (const entry of node.handle.values()) {
|
||||||
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
|
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
|
||||||
if (entry.kind === 'file') {
|
if (entry.kind === 'file') {
|
||||||
const fo = await createFileObject(entry, node.handle);
|
const fo = createFileObject(entry, node.handle);
|
||||||
if (!fo) continue;
|
|
||||||
fo.folderPath = node.path;
|
fo.folderPath = node.path;
|
||||||
files.push(fo);
|
files.push(fo);
|
||||||
if (scanStats) scanStats.files++;
|
if (scanStats) scanStats.files++;
|
||||||
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
|
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 zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
|
||||||
const zipPath = node.path + '/' + zipName;
|
const zipPath = node.path + '/' + zipName;
|
||||||
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
|
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
|
||||||
const zipNode = makeNode(zh, zipPath, node);
|
const zipNode = makeNode(zh, zipPath, node);
|
||||||
try { await scanZipIntoNode(zipNode, fo); }
|
zipNode._zipFileObj = fo;
|
||||||
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
|
zipNode.scanState = 'zip-pending';
|
||||||
childDirs.push(zipNode);
|
childDirs.push(zipNode);
|
||||||
if (scanStats) scanStats.folders++;
|
if (scanStats) scanStats.folders++;
|
||||||
}
|
}
|
||||||
|
|
@ -267,18 +349,15 @@
|
||||||
node.fileCount = files.length;
|
node.fileCount = files.length;
|
||||||
node.children = childDirs;
|
node.children = childDirs;
|
||||||
node.subdirCount = childDirs.length;
|
node.subdirCount = childDirs.length;
|
||||||
// Roll this folder's own files/dirs (plus the full contents of any
|
// Roll this folder's own files/dirs into the running subtree totals of
|
||||||
// inline-zip children) into the running subtree totals of this node
|
// this node + every ancestor. Real child dirs add their share when they
|
||||||
// and every ancestor. Regular child dirs add their own share when they
|
// get scanned; lazy zip nodes add theirs when opened (scanZipNode).
|
||||||
// get scanned — that's how the total fills in progressively.
|
const addF = files.length;
|
||||||
let addF = files.length;
|
const addD = childDirs.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; }
|
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
|
||||||
// Zip children are scanned inline ('done'); real dirs are still pending.
|
// Only real unscanned dirs hold the parent open; zip-pending children
|
||||||
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
|
// are lazy, so they don't.
|
||||||
|
node.pending = childDirs.filter(function (c) { return c.scanState === 'pending'; }).length;
|
||||||
if (node.pending === 0) {
|
if (node.pending === 0) {
|
||||||
markDone(node);
|
markDone(node);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -287,6 +366,30 @@
|
||||||
scheduleRender();
|
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),
|
// Build a zip-root node's children from its archive contents (in memory),
|
||||||
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
|
// 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.
|
// node shape so the rest of the app treats zip folders like real ones.
|
||||||
|
|
@ -618,37 +721,159 @@
|
||||||
/**
|
/**
|
||||||
* Create file object with metadata
|
* Create file object with metadata
|
||||||
*/
|
*/
|
||||||
async function createFileObject(fileHandle, folderHandle) {
|
// Build a file row from JUST the directory entry — no getFile(). Listing a
|
||||||
try {
|
// network share is already slow; the old code opened EVERY file to read
|
||||||
const file = await fileHandle.getFile();
|
// size/lastModified (which the grid doesn't even display), turning a
|
||||||
const split = zddc.splitExtension(file.name);
|
// listing into one network round-trip per file. size/lastModified are now
|
||||||
|
// loaded on demand by preview / SHA / rename, which call getFile()
|
||||||
|
// themselves. The scan is now a pure directory listing.
|
||||||
|
function createFileObject(fileHandle, folderHandle) {
|
||||||
|
const split = zddc.splitExtension(fileHandle.name);
|
||||||
|
return {
|
||||||
|
handle: fileHandle,
|
||||||
|
folderHandle: folderHandle,
|
||||||
|
originalFilename: split.name,
|
||||||
|
extension: split.extension,
|
||||||
|
size: null,
|
||||||
|
lastModified: null,
|
||||||
|
// Editable fields
|
||||||
|
trackingNumber: '',
|
||||||
|
revision: '',
|
||||||
|
status: '',
|
||||||
|
title: '',
|
||||||
|
// State
|
||||||
|
isDirty: false,
|
||||||
|
error: false,
|
||||||
|
errorMessage: '',
|
||||||
|
validation: null,
|
||||||
|
sha256: null
|
||||||
|
// folderPath added by the caller.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// ── Workspace snapshot (scan once, resume without re-walking the FS) ────
|
||||||
handle: fileHandle,
|
|
||||||
folderHandle: folderHandle,
|
|
||||||
originalFilename: split.name,
|
|
||||||
extension: split.extension,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
|
|
||||||
// Editable fields
|
// Serialize the completed scan to compact JSON (short keys: large trees).
|
||||||
trackingNumber: '',
|
// Zip-root nodes are NOT preserved as expandable folders — the .zip stays a
|
||||||
revision: '',
|
// plain file in its parent (classifying inside archives is out of scope for
|
||||||
status: '',
|
// a persisted workspace).
|
||||||
title: '',
|
function snapshotTree() {
|
||||||
|
function serFile(f) { return { o: f.originalFilename, e: f.extension, p: f.folderPath }; }
|
||||||
// State
|
function serNode(n) {
|
||||||
isDirty: false,
|
var o = { n: n.name, p: n.path };
|
||||||
error: false,
|
if (n.files && n.files.length) o.f = n.files.map(serFile);
|
||||||
errorMessage: '',
|
var realKids = (n.children || []).filter(function (c) { return !c.isZipRoot; });
|
||||||
validation: null,
|
if (realKids.length) o.c = realKids.map(serNode);
|
||||||
sha256: null
|
// Record scan progress so an interrupted scan can resume: 'children'
|
||||||
// folderPath will be added later in buildTree
|
// = direct entries fully read (kids may still be pending); anything
|
||||||
};
|
// unfinished (pending/scanning/zip) → 'pending' to re-read. 'done'
|
||||||
} catch (err) {
|
// is the default and omitted.
|
||||||
console.error('Error reading file:', fileHandle.name, err);
|
var st = n.scanState;
|
||||||
return null;
|
if (st && st !== 'done') o.s = (st === 'children') ? 'children' : 'pending';
|
||||||
|
return o;
|
||||||
}
|
}
|
||||||
|
return (window.app.folderTree || []).map(serNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild window.app.folderTree from a snapshot — handle-less nodes, marked
|
||||||
|
// 'done', subtree totals recomputed. Handles are resolved lazily from the
|
||||||
|
// workspace root handle at copy/preview time.
|
||||||
|
function loadSnapshot(snap) {
|
||||||
|
function deFile(sf) {
|
||||||
|
return {
|
||||||
|
handle: null, folderHandle: null,
|
||||||
|
originalFilename: sf.o, extension: sf.e,
|
||||||
|
size: null, lastModified: null,
|
||||||
|
trackingNumber: '', revision: '', status: '', title: '',
|
||||||
|
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null,
|
||||||
|
folderPath: sf.p,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function deNode(sn, parent) {
|
||||||
|
var node = makeNode({ name: sn.n, kind: 'directory' }, sn.p, parent);
|
||||||
|
node.handle = null;
|
||||||
|
node.scanState = sn.s || 'done'; // 'pending'/'children' resume on reconnect
|
||||||
|
node.expanded = false;
|
||||||
|
node.files = (sn.f || []).map(deFile);
|
||||||
|
node.children = (sn.c || []).map(function (c) { return deNode(c, node); });
|
||||||
|
node.fileCount = node.files.length;
|
||||||
|
node.subdirCount = node.children.length;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
var roots = (snap || []).map(function (sn) { return deNode(sn, null); });
|
||||||
|
if (roots[0]) roots[0].expanded = true;
|
||||||
|
(function totals(nodes) {
|
||||||
|
nodes.forEach(function (n) {
|
||||||
|
totals(n.children);
|
||||||
|
var rf = n.files.length, rd = n.children.length;
|
||||||
|
n.children.forEach(function (c) { rf += c.runFiles; rd += c.runDirs; });
|
||||||
|
n.runFiles = rf; n.runDirs = rd;
|
||||||
|
});
|
||||||
|
})(roots);
|
||||||
|
window.app.folderTree = roots;
|
||||||
|
if (window.app.modules.store && window.app.modules.store.setFolderTree) {
|
||||||
|
window.app.modules.store.setFolderTree(roots);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lazy handle resolution (snapshot files carry paths, not handles) ────
|
||||||
|
function relFromRoot(p) { var i = (p || '').indexOf('/'); return i < 0 ? '' : p.slice(i + 1); }
|
||||||
|
async function resolveDirHandle(rootHandle, relPath) {
|
||||||
|
var cur = rootHandle;
|
||||||
|
var parts = (relPath || '').split('/').filter(Boolean);
|
||||||
|
for (var i = 0; i < parts.length; i++) { cur = await cur.getDirectoryHandle(parts[i]); }
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
// Resolve (and cache) a file object's handle from the workspace root.
|
||||||
|
async function resolveFileHandle(rootHandle, fileObj) {
|
||||||
|
if (fileObj.handle) return fileObj.handle;
|
||||||
|
var dir = await resolveDirHandle(rootHandle, relFromRoot(fileObj.folderPath));
|
||||||
|
var name = zddc.joinExtension(fileObj.originalFilename, fileObj.extension);
|
||||||
|
var h = await dir.getFileHandle(name);
|
||||||
|
fileObj.handle = h;
|
||||||
|
fileObj.folderHandle = dir;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume an interrupted scan: walk the loaded tree for 'pending' folders,
|
||||||
|
// resolve their handles from the (reconnected) root, and drain only those —
|
||||||
|
// already-scanned folders are left alone. Returns true if work was done.
|
||||||
|
async function resumeScan(rootHandle) {
|
||||||
|
if (!rootHandle) return false;
|
||||||
|
var pend = [];
|
||||||
|
(function walk(ns) {
|
||||||
|
(ns || []).forEach(function (n) {
|
||||||
|
if (n.scanState === 'pending') pend.push(n);
|
||||||
|
else walk(n.children);
|
||||||
|
});
|
||||||
|
})(window.app.folderTree || []);
|
||||||
|
if (!pend.length) return false;
|
||||||
|
|
||||||
|
var myGen = ++scanGen;
|
||||||
|
zipCache.clear();
|
||||||
|
scanStats = { folders: 0, files: 0, current: '', done: false, startedAt: Date.now() };
|
||||||
|
var ticker = setInterval(function () {
|
||||||
|
if (myGen !== scanGen || (scanStats && scanStats.done)) { clearInterval(ticker); return; }
|
||||||
|
updateScanStatus();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
for (var i = 0; i < pend.length; i++) {
|
||||||
|
try { pend[i].handle = await resolveDirHandle(rootHandle, relFromRoot(pend[i].path)); }
|
||||||
|
catch (e) { pend[i].scanState = 'done'; reportScanError(pend[i].path, e); }
|
||||||
|
}
|
||||||
|
await drainQueue(pend.filter(function (n) { return n.handle; }), myGen, SCAN_CONCURRENCY);
|
||||||
|
|
||||||
|
clearInterval(ticker);
|
||||||
|
if (myGen !== scanGen) return true;
|
||||||
|
scanStats.done = true;
|
||||||
|
scanStats.current = '';
|
||||||
|
flushRender();
|
||||||
|
if (window.zddc && typeof window.zddc.toast === 'function') {
|
||||||
|
window.zddc.toast('Resumed scan complete — ' + scanStats.folders + ' folders, '
|
||||||
|
+ scanStats.files + ' files added in ' + elapsedStr() + '.', 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export module
|
// Export module
|
||||||
|
|
@ -656,7 +881,12 @@
|
||||||
scanDirectory,
|
scanDirectory,
|
||||||
ensureScanned,
|
ensureScanned,
|
||||||
getZipCache,
|
getZipCache,
|
||||||
extractZip
|
extractZip,
|
||||||
|
snapshotTree,
|
||||||
|
loadSnapshot,
|
||||||
|
resolveFileHandle,
|
||||||
|
resolveDirHandle,
|
||||||
|
resumeScan
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
426
classifier/js/target-tree.js
Normal file
426
classifier/js/target-tree.js
Normal file
|
|
@ -0,0 +1,426 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — target-tree pane (Classify & Copy mode).
|
||||||
|
*
|
||||||
|
* Renders the two orthogonal target trees the user maps files onto:
|
||||||
|
* - "By tracking number": folders that join with "-" into the tracking
|
||||||
|
* number; the leaf folder ("A (IFR)") is the revision+status.
|
||||||
|
* - "By transmittal": <party>/{received,issued}/<transmittal folder>.
|
||||||
|
*
|
||||||
|
* Structure here, placements in classify.js. Drag-and-drop assignment is wired
|
||||||
|
* in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and
|
||||||
|
* shows the derived filename for each placed file.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var SLOTS = ['received', 'issued'];
|
||||||
|
|
||||||
|
var els = {};
|
||||||
|
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
||||||
|
var openForm = null; // { partyId, slot } when a bin form is open
|
||||||
|
var initialized = false;
|
||||||
|
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active axis
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
els = {
|
||||||
|
trackingTab: document.getElementById('trackingTab'),
|
||||||
|
transmittalTab: document.getElementById('transmittalTab'),
|
||||||
|
trackingPanel: document.getElementById('trackingPanel'),
|
||||||
|
transmittalPanel: document.getElementById('transmittalPanel'),
|
||||||
|
trackingTree: document.getElementById('trackingTree'),
|
||||||
|
transmittalTree: document.getElementById('transmittalTree'),
|
||||||
|
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||||||
|
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||||
|
stats: document.getElementById('classifyStats'),
|
||||||
|
};
|
||||||
|
|
||||||
|
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
||||||
|
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||||||
|
els.addTrackingRootBtn.addEventListener('click', function () {
|
||||||
|
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||||||
|
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
|
||||||
|
addFoldersFromPattern(null, name);
|
||||||
|
});
|
||||||
|
els.addPartyBtn.addEventListener('click', function () {
|
||||||
|
var name = prompt('Party name (also the transmittal-number prefix):', '');
|
||||||
|
if (name && name.trim()) C().addParty(name.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
els.trackingTree.addEventListener('click', onTrackingClick);
|
||||||
|
els.transmittalTree.addEventListener('click', onTransmittalClick);
|
||||||
|
|
||||||
|
setupDropZone(els.trackingTree, 'tracking');
|
||||||
|
setupDropZone(els.transmittalTree, 'transmittal');
|
||||||
|
|
||||||
|
C().on(render);
|
||||||
|
if (window.app.modules.store && window.app.modules.store.on) {
|
||||||
|
window.app.modules.store.on('files', render);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function C() { return window.app.modules.classify; }
|
||||||
|
// Every scanned source file (classify mode reads the left tree, not the
|
||||||
|
// selection-scoped grid). Lazy folders contribute their files once scanned.
|
||||||
|
function allFiles() {
|
||||||
|
var out = [];
|
||||||
|
(function walk(nodes) {
|
||||||
|
(nodes || []).forEach(function (n) {
|
||||||
|
(n.files || []).forEach(function (f) { out.push(f); });
|
||||||
|
walk(n.children);
|
||||||
|
});
|
||||||
|
})(window.app.folderTree || []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// One pass: group files by the node they're placed in, per axis.
|
||||||
|
function buildPlaced(files) {
|
||||||
|
var c = C(), byT = {}, byX = {};
|
||||||
|
files.forEach(function (f) {
|
||||||
|
var a = c.getAssignment(c.srcKeyForFile(f));
|
||||||
|
if (!a) return;
|
||||||
|
if (a.trackingNodeId) (byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
|
||||||
|
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
|
||||||
|
});
|
||||||
|
return { tracking: byT, transmittal: byX };
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTab(which) {
|
||||||
|
var t = which === 'transmittal';
|
||||||
|
currentTab = t ? 'transmittal' : 'tracking';
|
||||||
|
els.trackingTab.classList.toggle('active', !t);
|
||||||
|
els.transmittalTab.classList.toggle('active', t);
|
||||||
|
els.trackingPanel.hidden = t;
|
||||||
|
els.transmittalPanel.hidden = !t;
|
||||||
|
// The "Hide Assigned" filter on the source tree is per-axis, so the
|
||||||
|
// visible set changes with the active tab — re-render the left tree.
|
||||||
|
if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render();
|
||||||
|
}
|
||||||
|
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
|
||||||
|
|
||||||
|
// Expand a brace pattern into folder names and create them (confirming a
|
||||||
|
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
||||||
|
function addFoldersFromPattern(parentId, raw) {
|
||||||
|
if (!raw || !raw.trim()) return;
|
||||||
|
var names = C().expandFolderPattern(raw);
|
||||||
|
if (!names.length) return;
|
||||||
|
if (names.length > 1) {
|
||||||
|
var shown = names.slice(0, 8).join('\n');
|
||||||
|
if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more';
|
||||||
|
if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return;
|
||||||
|
}
|
||||||
|
// Each expanded name is parsed into nested tracking levels (split on
|
||||||
|
// "-", final "_" splits the leaf rev), reusing shared ancestors.
|
||||||
|
names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── render ───────────────────────────────────────────────────────────────
|
||||||
|
function render() {
|
||||||
|
if (!initialized || !C().isEnabled()) return;
|
||||||
|
var files = allFiles();
|
||||||
|
var placed = buildPlaced(files);
|
||||||
|
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking);
|
||||||
|
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
|
||||||
|
renderStats(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats(files) {
|
||||||
|
var s = C().stats(files);
|
||||||
|
if (els.stats) {
|
||||||
|
els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · '
|
||||||
|
+ s.none + ' unassigned · ' + s.excluded + ' excluded';
|
||||||
|
}
|
||||||
|
var copyBtn = document.getElementById('copyOutputBtn');
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.disabled = s.done === 0;
|
||||||
|
copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeActions(extra) {
|
||||||
|
var wrap = el('span', 'tnode__actions');
|
||||||
|
(extra || []).forEach(function (a) {
|
||||||
|
var b = el('button', 'tnode__act', a.label);
|
||||||
|
b.dataset.act = a.act;
|
||||||
|
b.title = a.title || '';
|
||||||
|
wrap.appendChild(b);
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileList(files) {
|
||||||
|
var box = el('div', 'tnode__files');
|
||||||
|
files.forEach(function (f) {
|
||||||
|
var d = C().deriveTarget(f);
|
||||||
|
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
||||||
|
row.title = d.errors.length ? d.errors.join('; ') : 'Click to find this file in the source tree';
|
||||||
|
row.dataset.key = d.key; // for cross-tree reveal
|
||||||
|
row.appendChild(el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : '')));
|
||||||
|
row.appendChild(el('span', 'tfile__arrow', '→'));
|
||||||
|
row.appendChild(el('span', 'tfile__name', d.filename || '(incomplete)'));
|
||||||
|
box.appendChild(row);
|
||||||
|
});
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracking tree (recursive)
|
||||||
|
function renderTrackingInto(container, nodes, placedMap) {
|
||||||
|
container.textContent = '';
|
||||||
|
if (!nodes.length) {
|
||||||
|
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodes.forEach(function (n) { container.appendChild(trackingNode(n, placedMap)); });
|
||||||
|
}
|
||||||
|
function trackingNode(n, placedMap) {
|
||||||
|
var isLeaf = (n.children || []).length === 0;
|
||||||
|
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
|
||||||
|
wrap.dataset.id = n.id;
|
||||||
|
var row = el('div', 'tnode__row');
|
||||||
|
|
||||||
|
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (collapsed[n.id] ? '▸' : '▾'));
|
||||||
|
if (!isLeaf) toggle.dataset.act = 'toggle';
|
||||||
|
row.appendChild(toggle);
|
||||||
|
row.appendChild(el('span', 'tnode__name', n.name));
|
||||||
|
|
||||||
|
var placed = placedMap[n.id] || [];
|
||||||
|
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
||||||
|
|
||||||
|
row.appendChild(nodeActions([
|
||||||
|
{ act: 'add', label: '+', title: 'Add child folder' },
|
||||||
|
{ act: 'rename', label: '✎', title: 'Rename' },
|
||||||
|
{ act: 'del', label: '🗑', title: 'Delete' },
|
||||||
|
]));
|
||||||
|
wrap.appendChild(row);
|
||||||
|
|
||||||
|
if (placed.length) wrap.appendChild(fileList(placed));
|
||||||
|
if (!isLeaf && !collapsed[n.id]) {
|
||||||
|
var kids = el('div', 'tnode__children');
|
||||||
|
(n.children || []).forEach(function (c) { kids.appendChild(trackingNode(c, placedMap)); });
|
||||||
|
wrap.appendChild(kids);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transmittal tree
|
||||||
|
function renderTransmittalInto(container, parties, placedMap) {
|
||||||
|
container.textContent = '';
|
||||||
|
if (!parties.length) {
|
||||||
|
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parties.forEach(function (p) { container.appendChild(partyNode(p, placedMap)); });
|
||||||
|
}
|
||||||
|
function partyNode(party, placedMap) {
|
||||||
|
var wrap = el('div', 'tnode tnode--party');
|
||||||
|
wrap.dataset.id = party.id;
|
||||||
|
var row = el('div', 'tnode__row');
|
||||||
|
row.appendChild(el('span', 'tnode__icon', '🏢'));
|
||||||
|
row.appendChild(el('span', 'tnode__name', party.name));
|
||||||
|
row.appendChild(nodeActions([
|
||||||
|
{ act: 'rename-party', label: '✎', title: 'Rename party' },
|
||||||
|
{ act: 'del-party', label: '🗑', title: 'Delete party' },
|
||||||
|
]));
|
||||||
|
wrap.appendChild(row);
|
||||||
|
|
||||||
|
SLOTS.forEach(function (slot) {
|
||||||
|
var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
|
||||||
|
var sw = el('div', 'tslot');
|
||||||
|
sw.dataset.party = party.id;
|
||||||
|
sw.dataset.slot = slot;
|
||||||
|
var sr = el('div', 'tslot__row');
|
||||||
|
sr.appendChild(el('span', 'tslot__name', slot));
|
||||||
|
var addBtn = el('button', 'tnode__act', '+ Transmittal');
|
||||||
|
addBtn.dataset.act = 'addbin';
|
||||||
|
sr.appendChild(addBtn);
|
||||||
|
sw.appendChild(sr);
|
||||||
|
|
||||||
|
if (openForm && openForm.partyId === party.id && openForm.slot === slot) {
|
||||||
|
sw.appendChild(binForm(party.id, slot));
|
||||||
|
}
|
||||||
|
(slotNode ? slotNode.children : []).forEach(function (bin) {
|
||||||
|
sw.appendChild(binNode(bin, placedMap));
|
||||||
|
});
|
||||||
|
wrap.appendChild(sw);
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
function binNode(bin, placedMap) {
|
||||||
|
var wrap = el('div', 'tnode tnode--bin');
|
||||||
|
wrap.dataset.id = bin.id;
|
||||||
|
var row = el('div', 'tnode__row');
|
||||||
|
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
|
||||||
|
var placed = placedMap[bin.id] || [];
|
||||||
|
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
||||||
|
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
|
||||||
|
wrap.appendChild(row);
|
||||||
|
if (placed.length) wrap.appendChild(fileList(placed));
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD'];
|
||||||
|
function binForm(partyId, slot) {
|
||||||
|
var form = el('div', 'binform');
|
||||||
|
form.dataset.party = partyId;
|
||||||
|
form.dataset.slot = slot;
|
||||||
|
var date = el('input', 'binform__date'); date.type = 'date';
|
||||||
|
try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ }
|
||||||
|
var type = document.createElement('select'); type.className = 'binform__type';
|
||||||
|
['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); });
|
||||||
|
var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)';
|
||||||
|
var status = document.createElement('select'); status.className = 'binform__status';
|
||||||
|
STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); });
|
||||||
|
var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)';
|
||||||
|
var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd';
|
||||||
|
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel';
|
||||||
|
[date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); });
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── events ─────────────────────────────────────────────────────────────
|
||||||
|
function closestNodeId(target) {
|
||||||
|
var n = target.closest('.tnode');
|
||||||
|
return n ? n.dataset.id : null;
|
||||||
|
}
|
||||||
|
function revealInSource(e) {
|
||||||
|
var tf = e.target.closest('.tfile');
|
||||||
|
if (tf && tf.dataset.key && window.app.modules.tree.revealFile) {
|
||||||
|
window.app.modules.tree.revealFile(tf.dataset.key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function onTrackingClick(e) {
|
||||||
|
if (revealInSource(e)) return;
|
||||||
|
var btn = e.target.closest('[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
var act = btn.dataset.act;
|
||||||
|
var id = closestNodeId(btn);
|
||||||
|
if (act === 'toggle') { collapsed[id] = !collapsed[id]; render(); return; }
|
||||||
|
if (act === 'add') {
|
||||||
|
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
|
||||||
|
+ 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
|
||||||
|
addFoldersFromPattern(id, name);
|
||||||
|
} else if (act === 'rename') {
|
||||||
|
var node = C().getNode(id);
|
||||||
|
var nn = prompt('Rename folder:', node ? node.name : '');
|
||||||
|
if (nn && nn.trim()) C().renameNode(id, nn.trim());
|
||||||
|
} else if (act === 'del') {
|
||||||
|
if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onTransmittalClick(e) {
|
||||||
|
if (revealInSource(e)) return;
|
||||||
|
var btn = e.target.closest('[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
var act = btn.dataset.act;
|
||||||
|
|
||||||
|
if (act === 'addbin') {
|
||||||
|
var slotEl = btn.closest('.tslot');
|
||||||
|
openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot };
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (act === 'bincancel') { openForm = null; render(); return; }
|
||||||
|
if (act === 'binadd') {
|
||||||
|
var form = btn.closest('.binform');
|
||||||
|
var meta = {
|
||||||
|
date: form.querySelector('.binform__date').value,
|
||||||
|
type: form.querySelector('.binform__type').value,
|
||||||
|
seq: form.querySelector('.binform__seq').value.trim(),
|
||||||
|
status: form.querySelector('.binform__status').value,
|
||||||
|
title: form.querySelector('.binform__title').value.trim(),
|
||||||
|
};
|
||||||
|
if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; }
|
||||||
|
C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta);
|
||||||
|
openForm = null; // render() fires from classify.notify()
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = closestNodeId(btn);
|
||||||
|
if (act === 'rename-party') {
|
||||||
|
var node = C().getNode(id);
|
||||||
|
var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : '');
|
||||||
|
if (nn && nn.trim()) C().renameNode(id, nn.trim());
|
||||||
|
} else if (act === 'del-party') {
|
||||||
|
if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id);
|
||||||
|
} else if (act === 'del') {
|
||||||
|
if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── drop targets ───────────────────────────────────────────────────────
|
||||||
|
// Resolve the drop target under an event:
|
||||||
|
// tracking → any folder node (.tnode)
|
||||||
|
// transmittal → a transmittal bin only (.tnode--bin)
|
||||||
|
function dropTarget(target, axis) {
|
||||||
|
var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode';
|
||||||
|
var node = target.closest(sel);
|
||||||
|
if (!node || !node.dataset.id) return null;
|
||||||
|
return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node };
|
||||||
|
}
|
||||||
|
function clearHover(container) {
|
||||||
|
var hot = container.querySelectorAll('.drop-hover');
|
||||||
|
for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover');
|
||||||
|
}
|
||||||
|
function setupDropZone(container, axis) {
|
||||||
|
container.addEventListener('dragover', function (e) {
|
||||||
|
if (!window.app.modules.dnd.active()) return;
|
||||||
|
var t = dropTarget(e.target, axis);
|
||||||
|
clearHover(container);
|
||||||
|
if (!t) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
t.row.classList.add('drop-hover');
|
||||||
|
});
|
||||||
|
container.addEventListener('dragleave', function (e) {
|
||||||
|
if (e.target === container) clearHover(container);
|
||||||
|
});
|
||||||
|
container.addEventListener('drop', function (e) {
|
||||||
|
var t = dropTarget(e.target, axis);
|
||||||
|
clearHover(container);
|
||||||
|
if (!t) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var keys = window.app.modules.dnd.getDrag();
|
||||||
|
window.app.modules.dnd.clearDrag();
|
||||||
|
if (keys.length) C().place(keys, t.id, axis);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reveal a source key's placement in the target pane (source → target).
|
||||||
|
function reveal(key) {
|
||||||
|
var a = C().getAssignment(key);
|
||||||
|
if (!a) return;
|
||||||
|
if (a.trackingNodeId) {
|
||||||
|
showTab('tracking'); collapsed = {}; render();
|
||||||
|
flashNode(els.trackingTree, a.trackingNodeId);
|
||||||
|
} else if (a.transmittalNodeId) {
|
||||||
|
showTab('transmittal'); render();
|
||||||
|
flashNode(els.transmittalTree, a.transmittalNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function flashNode(container, id) {
|
||||||
|
var node = container.querySelector('.tnode[data-id="' + id + '"]');
|
||||||
|
if (!node) return;
|
||||||
|
node.scrollIntoView({ block: 'center' });
|
||||||
|
var row = node.querySelector('.tnode__row') || node;
|
||||||
|
row.classList.add('reveal-flash');
|
||||||
|
setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.targetTree = {
|
||||||
|
init: init,
|
||||||
|
render: render,
|
||||||
|
showTab: showTab,
|
||||||
|
activeAxis: activeAxis,
|
||||||
|
reveal: reveal,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -5,11 +5,73 @@
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'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
|
* Render the folder tree
|
||||||
*/
|
*/
|
||||||
function render() {
|
function render() {
|
||||||
const container = window.app.dom.folderTree;
|
const container = window.app.dom.folderTree;
|
||||||
|
wireClassifyInteractions();
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
if (window.app.folderTree.length === 0) {
|
if (window.app.folderTree.length === 0) {
|
||||||
|
|
@ -18,6 +80,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.folderTree.forEach(folder => {
|
window.app.folderTree.forEach(folder => {
|
||||||
|
if (folderHidden(folder)) return;
|
||||||
const element = createFolderElement(folder);
|
const element = createFolderElement(folder);
|
||||||
container.appendChild(element);
|
container.appendChild(element);
|
||||||
});
|
});
|
||||||
|
|
@ -35,11 +98,15 @@
|
||||||
*/
|
*/
|
||||||
function populateCount(el, folder) {
|
function populateCount(el, folder) {
|
||||||
el.textContent = '';
|
el.textContent = '';
|
||||||
|
el.classList.remove('done');
|
||||||
const st = folder.scanState;
|
const st = folder.scanState;
|
||||||
if (st === 'pending') return;
|
if (st === 'pending') return;
|
||||||
|
if (st === 'zip-pending') { el.textContent = '(zip — open to scan)'; return; }
|
||||||
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
|
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
|
||||||
|
|
||||||
const done = st === 'done';
|
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 dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
|
||||||
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
|
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
|
||||||
|
|
||||||
|
|
@ -47,17 +114,29 @@
|
||||||
frag.appendChild(document.createTextNode('('));
|
frag.appendChild(document.createTextNode('('));
|
||||||
if (dDir > 0 || tDir > 0) {
|
if (dDir > 0 || tDir > 0) {
|
||||||
appendPair(frag, dDir, tDir, done);
|
appendPair(frag, dDir, tDir, done);
|
||||||
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
|
appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, ');
|
||||||
}
|
}
|
||||||
appendPair(frag, dFile, tFile, done);
|
appendPair(frag, dFile, tFile, done);
|
||||||
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
|
appendLabel(frag, tFile === 1 ? ' file)' : ' files)');
|
||||||
el.appendChild(frag);
|
el.appendChild(frag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
|
// The "folders"/"files" word labels — blue only once the row is .done.
|
||||||
// "+<total>" with the total in a span that greys + pulses until final.
|
function appendLabel(frag, text) {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'ct-label';
|
||||||
|
s.textContent = text;
|
||||||
|
frag.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append "<direct>" (always a completed/blue number) and, when there's a
|
||||||
|
// subtree (or scanning is ongoing), "+<total>" with the total in a span
|
||||||
|
// that greys + pulses until final, then turns blue.
|
||||||
function appendPair(frag, direct, total, done) {
|
function appendPair(frag, direct, total, done) {
|
||||||
frag.appendChild(document.createTextNode(String(direct)));
|
const d = document.createElement('span');
|
||||||
|
d.className = 'ct-direct';
|
||||||
|
d.textContent = String(direct);
|
||||||
|
frag.appendChild(d);
|
||||||
if (!done || total > direct) {
|
if (!done || total > direct) {
|
||||||
frag.appendChild(document.createTextNode('+'));
|
frag.appendChild(document.createTextNode('+'));
|
||||||
const t = document.createElement('span');
|
const t = document.createElement('span');
|
||||||
|
|
@ -88,12 +167,28 @@
|
||||||
item.classList.add('selected');
|
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
|
// Toggle button: shown when the folder has children OR hasn't been
|
||||||
// scanned yet (it might have children — expanding triggers its scan).
|
// scanned yet (it might have children — expanding triggers its scan).
|
||||||
const toggle = document.createElement('span');
|
const toggle = document.createElement('span');
|
||||||
toggle.className = 'folder-toggle';
|
toggle.className = 'folder-toggle';
|
||||||
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
||||||
|| folder.scanState === 'pending';
|
|| folder.scanState === 'pending'
|
||||||
|
|| folder.scanState === 'zip-pending'
|
||||||
|
// Classify mode: a folder with files (even none of subfolders) is
|
||||||
|
// expandable so its files can be revealed and dragged.
|
||||||
|
|| (classifyOn() && folder.files && folder.files.length > 0);
|
||||||
if (mightHaveChildren) {
|
if (mightHaveChildren) {
|
||||||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||||||
toggle.addEventListener('click', (e) => {
|
toggle.addEventListener('click', (e) => {
|
||||||
|
|
@ -118,6 +213,12 @@
|
||||||
}
|
}
|
||||||
item.appendChild(icon);
|
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
|
// Folder name
|
||||||
const name = document.createElement('span');
|
const name = document.createElement('span');
|
||||||
name.className = 'folder-name';
|
name.className = 'folder-name';
|
||||||
|
|
@ -172,15 +273,62 @@
|
||||||
const childrenDiv = document.createElement('div');
|
const childrenDiv = document.createElement('div');
|
||||||
childrenDiv.className = 'folder-children';
|
childrenDiv.className = 'folder-children';
|
||||||
folder.children.forEach(child => {
|
folder.children.forEach(child => {
|
||||||
|
if (folderHidden(child)) return;
|
||||||
const childElement = createFolderElement(child, level + 1);
|
const childElement = createFolderElement(child, level + 1);
|
||||||
childrenDiv.appendChild(childElement);
|
childrenDiv.appendChild(childElement);
|
||||||
});
|
});
|
||||||
div.appendChild(childrenDiv);
|
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;
|
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
|
* Handle folder click with multi-select support
|
||||||
*/
|
*/
|
||||||
|
|
@ -567,6 +715,146 @@
|
||||||
container.tabIndex = 0;
|
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
|
// Export module
|
||||||
window.app.modules.tree = {
|
window.app.modules.tree = {
|
||||||
render,
|
render,
|
||||||
|
|
@ -574,6 +862,8 @@
|
||||||
loadFilesFromSelectedFolders,
|
loadFilesFromSelectedFolders,
|
||||||
setupKeyboardShortcuts,
|
setupKeyboardShortcuts,
|
||||||
expandAll,
|
expandAll,
|
||||||
selectAll
|
selectAll,
|
||||||
|
revealFile,
|
||||||
|
setHideAssigned
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
304
classifier/js/workspace.js
Normal file
304
classifier/js/workspace.js
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
/**
|
||||||
|
* ZDDC Classifier — workspace manager (Classify & Copy).
|
||||||
|
*
|
||||||
|
* A workspace = one classification project: a source directory handle, a
|
||||||
|
* snapshot of its completed scan, and the Classify & Copy map. The welcome
|
||||||
|
* screen lists them; opening one resumes instantly from the snapshot (no
|
||||||
|
* re-scan), and the map autosaves as you work. Only Copy needs the live
|
||||||
|
* filesystem (a one-click permission re-grant).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var els = {};
|
||||||
|
var initialized = false;
|
||||||
|
var activeId = null;
|
||||||
|
var activeMeta = null; // {id,name,rootName,createdAt,updatedAt,summary}
|
||||||
|
var activeStoredHandle = null; // the workspace's persisted source dir handle
|
||||||
|
|
||||||
|
function P() { return window.app.modules.persist; }
|
||||||
|
function C() { return window.app.modules.classify; }
|
||||||
|
function now() { return Date.now(); }
|
||||||
|
function uid() {
|
||||||
|
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
||||||
|
try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
|
||||||
|
}
|
||||||
|
return 'w' + now().toString(36) + Math.floor(Math.random() * 1e9).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
els = {
|
||||||
|
welcome: document.getElementById('welcomeScreen'),
|
||||||
|
list: document.getElementById('workspaceList'),
|
||||||
|
newBtn: document.getElementById('newWorkspaceBtn'),
|
||||||
|
wsBtn: document.getElementById('workspacesBtn'),
|
||||||
|
connectBtn: document.getElementById('connectDirBtn'),
|
||||||
|
};
|
||||||
|
if (!P() || !P().available) {
|
||||||
|
// No IndexedDB → hide the workspace UI; legacy rename path still works.
|
||||||
|
var wrap = document.getElementById('workspacesSection');
|
||||||
|
if (wrap) wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (els.newBtn) els.newBtn.addEventListener('click', newWorkspace);
|
||||||
|
if (els.wsBtn) els.wsBtn.addEventListener('click', showWelcome);
|
||||||
|
if (els.connectBtn) els.connectBtn.addEventListener('click', function () { tryReconnect(false); });
|
||||||
|
if (els.list) els.list.addEventListener('click', onListClick);
|
||||||
|
|
||||||
|
// Autosave the active workspace whenever the map changes.
|
||||||
|
C().on(scheduleAutosave);
|
||||||
|
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── welcome list ────────────────────────────────────────────────────────
|
||||||
|
function showWelcome() {
|
||||||
|
if (els.welcome) els.welcome.classList.remove('hidden');
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
function hideWelcome() {
|
||||||
|
if (els.welcome) els.welcome.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function relTime(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
var s = Math.max(0, Math.round((now() - ts) / 1000));
|
||||||
|
if (s < 60) return 'just now';
|
||||||
|
var m = Math.round(s / 60); if (m < 60) return m + 'm ago';
|
||||||
|
var h = Math.round(m / 60); if (h < 24) return h + 'h ago';
|
||||||
|
var d = Math.round(h / 24); return d + 'd ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
if (!els.list) return;
|
||||||
|
P().listWorkspaces().then(function (rows) {
|
||||||
|
els.list.textContent = '';
|
||||||
|
if (!rows.length) {
|
||||||
|
var empty = document.createElement('div');
|
||||||
|
empty.className = 'ws-empty';
|
||||||
|
empty.textContent = 'No workspaces yet. “+ New workspace” scans a folder once and saves it here.';
|
||||||
|
els.list.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.forEach(function (r) { els.list.appendChild(rowEl(r)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function rowEl(r) {
|
||||||
|
var s = r.summary || { files: 0, done: 0, excluded: 0 };
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'ws-row';
|
||||||
|
row.dataset.id = r.id;
|
||||||
|
|
||||||
|
var main = document.createElement('div');
|
||||||
|
main.className = 'ws-row__main';
|
||||||
|
var nm = document.createElement('div'); nm.className = 'ws-row__name'; nm.textContent = r.name;
|
||||||
|
var meta = document.createElement('div'); meta.className = 'ws-row__meta';
|
||||||
|
meta.textContent = (r.rootName || '') + ' · ' + s.done + '/' + s.files + ' classified'
|
||||||
|
+ (s.excluded ? (' · ' + s.excluded + ' excluded') : '') + ' · updated ' + relTime(r.updatedAt);
|
||||||
|
main.appendChild(nm); main.appendChild(meta);
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'ws-row__actions';
|
||||||
|
[['open', 'Open'], ['rename', 'Rename'], ['delete', 'Delete']].forEach(function (a) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.className = 'btn btn-sm ' + (a[0] === 'open' ? 'btn-primary' : 'btn-secondary');
|
||||||
|
b.dataset.act = a[0]; b.textContent = a[1];
|
||||||
|
actions.appendChild(b);
|
||||||
|
});
|
||||||
|
row.appendChild(main); row.appendChild(actions);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function onListClick(e) {
|
||||||
|
var btn = e.target.closest('[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
var row = btn.closest('.ws-row');
|
||||||
|
var id = row && row.dataset.id;
|
||||||
|
if (!id) return;
|
||||||
|
if (btn.dataset.act === 'open') openWorkspace(id);
|
||||||
|
else if (btn.dataset.act === 'rename') renameWorkspace(id);
|
||||||
|
else if (btn.dataset.act === 'delete') deleteWorkspace(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── summary ───────────────────────────────────────────────────────────
|
||||||
|
function allFiles() {
|
||||||
|
var out = [];
|
||||||
|
(function walk(ns) { (ns || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(window.app.folderTree || []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function summary() {
|
||||||
|
var s = C().stats(allFiles());
|
||||||
|
return { files: s.total, done: s.done, excluded: s.excluded };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── create / open / rename / delete ─────────────────────────────────────
|
||||||
|
async function newWorkspace() {
|
||||||
|
if (!window.showDirectoryPicker) {
|
||||||
|
window.zddc.toast('Workspaces need the File System Access API (use Chromium).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dir;
|
||||||
|
try { dir = await window.showDirectoryPicker(); }
|
||||||
|
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open folder — ' + (e.message || e), 'error'); return; }
|
||||||
|
|
||||||
|
var name = prompt('Name this workspace:', dir.name);
|
||||||
|
if (name === null) name = dir.name;
|
||||||
|
name = name.trim() || dir.name;
|
||||||
|
|
||||||
|
window.app.rootHandle = dir;
|
||||||
|
activeStoredHandle = dir;
|
||||||
|
window.app.modules.app.enterAppShell();
|
||||||
|
window.app.modules.app.setMode('classify');
|
||||||
|
hideWelcome();
|
||||||
|
|
||||||
|
activeId = uid();
|
||||||
|
activeMeta = { id: activeId, name: name, rootName: dir.name, createdAt: now(), updatedAt: now(), summary: { files: 0, done: 0, excluded: 0 } };
|
||||||
|
// Create the record UP FRONT so an interrupted scan survives and resumes.
|
||||||
|
await saveSnapshotFull();
|
||||||
|
updateConnectUI();
|
||||||
|
|
||||||
|
// Periodically persist the partial snapshot during the (slow) scan, so an
|
||||||
|
// interruption resumes from where it left off instead of starting over.
|
||||||
|
var iv = setInterval(saveSnapshotFull, 5000);
|
||||||
|
try { await window.app.modules.scanner.scanDirectory(dir); }
|
||||||
|
finally { clearInterval(iv); saveSnapshotFull(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspace(id) {
|
||||||
|
var rec = await P().getWorkspace(id);
|
||||||
|
var rows = await P().listWorkspaces();
|
||||||
|
var meta = rows.filter(function (r) { return r.id === id; })[0];
|
||||||
|
if (!rec || !meta) { window.zddc.toast('Could not load that workspace.', 'error'); return; }
|
||||||
|
|
||||||
|
activeId = id;
|
||||||
|
activeMeta = meta;
|
||||||
|
activeStoredHandle = rec.rootHandle || null;
|
||||||
|
window.app.rootHandle = null; // not connected until reconnect
|
||||||
|
window.app.modules.app.enterAppShell();
|
||||||
|
window.app.modules.scanner.loadSnapshot(rec.tree || []);
|
||||||
|
C().load(rec.classify || {});
|
||||||
|
window.app.modules.app.setMode('classify');
|
||||||
|
hideWelcome();
|
||||||
|
|
||||||
|
// Offer to reconnect the source directory (needed to preview, copy, or
|
||||||
|
// finish an interrupted scan). Silent if permission is already granted.
|
||||||
|
await tryReconnect(true);
|
||||||
|
updateConnectUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the full workspace (meta + snapshot + map + source handle).
|
||||||
|
function saveSnapshotFull() {
|
||||||
|
if (!activeId || !activeMeta) return Promise.resolve();
|
||||||
|
activeMeta.updatedAt = now();
|
||||||
|
activeMeta.summary = summary();
|
||||||
|
return P().putWorkspace(activeMeta, {
|
||||||
|
id: activeId,
|
||||||
|
rootHandle: window.app.rootHandle || activeStoredHandle || null,
|
||||||
|
tree: window.app.modules.scanner.snapshotTree(),
|
||||||
|
classify: C().serialize(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect (or reconnect) the source directory. silentOnly=true never shows a
|
||||||
|
// permission prompt or picker — it only adopts an already-granted handle and
|
||||||
|
// otherwise nudges the user to click "Connect directory".
|
||||||
|
async function tryReconnect(silentOnly) {
|
||||||
|
var h = activeStoredHandle;
|
||||||
|
if (h && typeof h.queryPermission === 'function') {
|
||||||
|
var p = 'denied';
|
||||||
|
try { p = await h.queryPermission({ mode: 'read' }); } catch (_) { /* ignore */ }
|
||||||
|
if (p === 'granted') { window.app.rootHandle = h; return afterConnect(); }
|
||||||
|
if (!silentOnly) {
|
||||||
|
var p2 = 'denied';
|
||||||
|
try { p2 = await h.requestPermission({ mode: 'read' }); } catch (_) { /* ignore */ }
|
||||||
|
if (p2 === 'granted') { window.app.rootHandle = h; return afterConnect(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (silentOnly) {
|
||||||
|
if (!window.app.rootHandle && activeId) {
|
||||||
|
window.zddc.toast('This 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,6 +30,12 @@
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<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>
|
<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>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||||
|
|
@ -51,22 +57,26 @@
|
||||||
<input type="checkbox" id="autoScrollCheckbox" checked>
|
<input type="checkbox" id="autoScrollCheckbox" checked>
|
||||||
Auto-scroll
|
Auto-scroll
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label" id="hideCompliantLabel">
|
||||||
<input type="checkbox" id="hideCompliantCheckbox">
|
<input type="checkbox" id="hideCompliantCheckbox">
|
||||||
Hide Compliant
|
Hide Compliant
|
||||||
</label>
|
</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>
|
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
|
|
||||||
<div id="folderTree" class="folder-tree">
|
<div id="folderTree" class="folder-tree">
|
||||||
<!-- Dynamically populated -->
|
<!-- Dynamically populated -->
|
||||||
</div>
|
</div>
|
||||||
<div class="resize-handle" id="treeResizeHandle"></div>
|
<div class="resize-handle" id="treeResizeHandle"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Spreadsheet Table -->
|
<!-- Spreadsheet Table (Rename in place) -->
|
||||||
<main class="spreadsheet-pane">
|
<main class="spreadsheet-pane" id="spreadsheetPane" hidden>
|
||||||
<div class="pane-header">
|
<div class="pane-header">
|
||||||
<div class="pane-header-left">
|
<div class="pane-header-left">
|
||||||
<h3>Files</h3>
|
<h3>Files</h3>
|
||||||
|
|
@ -127,20 +137,82 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
</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 -->
|
<!-- Empty State — shown until a directory is selected -->
|
||||||
<div id="welcomeScreen" class="empty-state empty-state--overlay">
|
<div id="welcomeScreen" class="empty-state empty-state--overlay">
|
||||||
<div class="empty-state__inner empty-state__inner--centered">
|
<div class="empty-state__inner empty-state__inner--centered welcome">
|
||||||
<h2>ZDDC Classifier</h2>
|
<h1 class="welcome__title">ZDDC Classifier</h1>
|
||||||
<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;">
|
<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>
|
||||||
<strong>This standalone tool is being absorbed into the Browse app.</strong>
|
|
||||||
Browse's <em>Grid</em> view-mode now provides the same spreadsheet
|
<!-- Workspaces (Classify & Copy) -->
|
||||||
workflow alongside file navigation. This standalone build remains
|
<section id="workspacesSection" class="workspaces">
|
||||||
available for offline use and air-gapped environments.
|
<div class="ws-head">
|
||||||
</p>
|
<h2>Your workspaces</h2>
|
||||||
<p>Rename a folder of files to ZDDC format using a spreadsheet interface.</p>
|
<button id="newWorkspaceBtn" class="btn btn-primary">+ New workspace</button>
|
||||||
<p>Open a directory, fill in tracking number, revision, status, and title for each file, then save — the files are renamed on disk.</p>
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- Browser Compatibility Warning -->
|
<!-- Browser Compatibility Warning -->
|
||||||
<div id="browserWarning" class="browser-warning hidden">
|
<div id="browserWarning" class="browser-warning hidden">
|
||||||
|
|
@ -148,16 +220,7 @@
|
||||||
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
|
<p>This application requires the File System Access API, available only in Chromium-based browsers (Chrome, Edge, Brave, Opera).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="welcome-list">
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@ export default defineConfig({
|
||||||
name: 'classifier',
|
name: 'classifier',
|
||||||
testMatch: 'classifier.spec.js',
|
testMatch: 'classifier.spec.js',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'classify',
|
||||||
|
testMatch: 'classify.spec.js',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'browse',
|
name: 'browse',
|
||||||
testMatch: 'browse.spec.js',
|
testMatch: 'browse.spec.js',
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,75 @@
|
||||||
with tool-local .toast classes; the old classifier rules can stay
|
with tool-local .toast classes; the old classifier rules can stay
|
||||||
alongside until this file is concatenated above them in the build. */
|
alongside until this file is concatenated above them in the build. */
|
||||||
|
|
||||||
.zddc-toast {
|
/* Toast STACK — bottom-right, newest at the bottom. The container is
|
||||||
|
click-through (pointer-events:none) so the gaps don't block the page; each
|
||||||
|
toast + button re-enables pointer events. */
|
||||||
|
.zddc-toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2rem;
|
bottom: 1.5rem;
|
||||||
right: 2rem;
|
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);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.875rem 1.25rem;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
|
||||||
|
|
||||||
|
.zddc-toast {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 9000;
|
max-width: 420px;
|
||||||
max-width: 400px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
|
||||||
animation: zddc-toast-in 0.3s ease-out;
|
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--success { border-left: 4px solid var(--success); }
|
||||||
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
||||||
.zddc-toast--info { border-left: 4px solid var(--info); }
|
.zddc-toast--info { border-left: 4px solid var(--info); }
|
||||||
|
|
|
||||||
120
shared/toast.js
120
shared/toast.js
|
|
@ -1,72 +1,120 @@
|
||||||
// shared/toast.js — non-blocking notification helper available to every
|
// shared/toast.js — non-blocking notification helper available to every
|
||||||
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
|
// tool via window.zddc.toast(msg, level, opts).
|
||||||
// local showToast (classifier/js/excel.js); promoted here so tools that
|
//
|
||||||
// today use alert() or silent console.error can switch to a uniform
|
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
|
||||||
// non-blocking surface.
|
// they stay until the user dismisses them (per-toast × or a "Clear all"
|
||||||
|
// button) so the message can be read, selected, and copied while
|
||||||
|
// troubleshooting. info/success toasts auto-dismiss. The message text is
|
||||||
|
// always selectable.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// window.zddc.toast('Saved.', 'success');
|
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
|
||||||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
|
||||||
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
||||||
|
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
|
||||||
//
|
//
|
||||||
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
// 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
if (!window.zddc) window.zddc = {};
|
||||||
// Don't overwrite if a tool defined its own first.
|
|
||||||
if (typeof window.zddc.toast === 'function') return;
|
if (typeof window.zddc.toast === 'function') return;
|
||||||
|
|
||||||
var DEFAULT_DURATION_MS = 5000;
|
var DEFAULT_DURATION_MS = 5000;
|
||||||
var FADE_MS = 300;
|
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) {
|
function toast(message, level, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var lvl = (level === 'success' || level === 'error' ||
|
var lvl = (level === 'success' || level === 'error' ||
|
||||||
level === 'warning') ? level : 'info';
|
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');
|
var el = document.createElement('div');
|
||||||
el.className = 'zddc-toast zddc-toast--' + lvl;
|
el.className = 'zddc-toast zddc-toast--' + lvl;
|
||||||
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
||||||
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
||||||
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
||||||
el.textContent = message == null ? '' : String(message);
|
|
||||||
document.body.appendChild(el);
|
|
||||||
|
|
||||||
var dur = typeof opts.durationMs === 'number' ?
|
// Selectable, copyable message text (its own element so clicking to
|
||||||
opts.durationMs : DEFAULT_DURATION_MS;
|
// select doesn't dismiss the toast — only the × does).
|
||||||
var timer = setTimeout(function () {
|
var msg = document.createElement('span');
|
||||||
el.classList.add('zddc-toast--fade');
|
msg.className = 'zddc-toast__msg';
|
||||||
setTimeout(function () {
|
msg.textContent = message == null ? '' : String(message);
|
||||||
if (el.parentNode) el.parentNode.removeChild(el);
|
el.appendChild(msg);
|
||||||
}, FADE_MS);
|
|
||||||
}, dur);
|
|
||||||
|
|
||||||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
var close = document.createElement('button');
|
||||||
el.addEventListener('click', function () {
|
close.type = 'button';
|
||||||
clearTimeout(timer);
|
close.className = 'zddc-toast__close';
|
||||||
if (el.parentNode) el.parentNode.removeChild(el);
|
close.setAttribute('aria-label', 'Dismiss');
|
||||||
});
|
close.textContent = '×';
|
||||||
|
close.addEventListener('click', function () { dismiss(el); });
|
||||||
|
el.appendChild(close);
|
||||||
|
|
||||||
|
c.appendChild(el);
|
||||||
|
|
||||||
|
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
|
||||||
|
// auto-dismiss after the (overridable) duration.
|
||||||
|
var sticky = opts.durationMs === 0 ||
|
||||||
|
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
|
||||||
|
if (!sticky) {
|
||||||
|
var dur = typeof opts.durationMs === 'number'
|
||||||
|
? opts.durationMs : DEFAULT_DURATION_MS;
|
||||||
|
el._timer = setTimeout(function () { dismiss(el); }, dur);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshClearAll(c);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.toast = toast;
|
window.zddc.toast = toast;
|
||||||
|
|
||||||
// Route window.alert() calls into the toast helper. Every tool has
|
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
|
||||||
// accumulated some `alert(...)` sites for error reporting; rather
|
// consistent). Native alert preserved on window.alertNative. alert() maps
|
||||||
// than touch each one, intercept globally so they're non-blocking
|
// to an error toast (sticky) since that's its usual purpose.
|
||||||
// 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) {
|
if (typeof window.alert === 'function' && !window.alertNative) {
|
||||||
window.alertNative = window.alert.bind(window);
|
window.alertNative = window.alert.bind(window);
|
||||||
window.alert = function (msg) {
|
window.alert = function (msg) {
|
||||||
|
|
|
||||||
619
tests/classify.spec.js
Normal file
619
tests/classify.spec.js
Normal file
|
|
@ -0,0 +1,619 @@
|
||||||
|
/**
|
||||||
|
* Tests for the classifier "Classify & Copy" state model
|
||||||
|
* (classifier/js/classify.js) — the pure derive/assignment logic.
|
||||||
|
*
|
||||||
|
* Runs against the compiled classifier/dist/classifier.html, driving
|
||||||
|
* window.app.modules.classify via page.evaluate. No File System Access API is
|
||||||
|
* needed: synthetic file objects ({folderPath, originalFilename, extension})
|
||||||
|
* carry everything deriveTarget consults. Drag-and-drop and the actual copy
|
||||||
|
* stay manual (Playwright can't drive the directory picker).
|
||||||
|
*
|
||||||
|
* Build first: sh classifier/build.sh
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const PAGE = 'file://' + path.resolve('classifier/dist/classifier.html');
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto(PAGE, { waitUntil: 'load' });
|
||||||
|
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.app.modules.classify.reset();
|
||||||
|
// No directory is opened in these tests; dismiss the welcome overlay so
|
||||||
|
// it doesn't intercept clicks on the in-pane controls.
|
||||||
|
const w = document.getElementById('welcomeScreen');
|
||||||
|
if (w) w.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a tracking chain of folders and place one file in the deepest;
|
||||||
|
// return the derived target.
|
||||||
|
async function deriveInTracking(page, segments, file) {
|
||||||
|
return page.evaluate(({ segments, file }) => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
let parent = null;
|
||||||
|
for (const name of segments) parent = c.addTrackingNode(parent, name);
|
||||||
|
const key = c.srcKeyForFile(file);
|
||||||
|
c.place([key], parent, 'tracking');
|
||||||
|
return c.deriveTarget(file);
|
||||||
|
}, { segments, file });
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE = { folderPath: 'Root/Sub', originalFilename: 'Foundation Plan', extension: 'pdf' };
|
||||||
|
|
||||||
|
test('tracking: ancestors join with "-", parent leaf = REV (STATUS), title from name', async ({ page }) => {
|
||||||
|
const d = await deriveInTracking(page, ['ACME-PROJ', 'MECH', '0001', 'A (IFR)'], FILE);
|
||||||
|
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
|
||||||
|
expect(d.revision).toBe('A');
|
||||||
|
expect(d.status).toBe('IFR');
|
||||||
|
expect(d.title).toBe('Foundation Plan');
|
||||||
|
expect(d.filename).toBe('ACME-PROJ-MECH-0001_A (IFR) - Foundation Plan.pdf');
|
||||||
|
expect(d.trackingLeaf).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracking: a single full-number folder also works', async ({ page }) => {
|
||||||
|
const d = await deriveInTracking(page, ['ACME-PROJ-MECH-0001', 'B (IFC)'], FILE);
|
||||||
|
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
|
||||||
|
expect(d.revision).toBe('B');
|
||||||
|
expect(d.status).toBe('IFC');
|
||||||
|
expect(d.filename).toBe('ACME-PROJ-MECH-0001_B (IFC) - Foundation Plan.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracking: parked in an intermediate (non-leaf) folder is flagged incomplete', async ({ page }) => {
|
||||||
|
const d = await page.evaluate((file) => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const proj = c.addTrackingNode(null, 'ACME-PROJ');
|
||||||
|
const mech = c.addTrackingNode(proj, 'MECH');
|
||||||
|
c.addTrackingNode(mech, '0001'); // child exists → MECH is not a leaf
|
||||||
|
const key = c.srcKeyForFile(file);
|
||||||
|
c.place([key], mech, 'tracking'); // file parked at MECH
|
||||||
|
return c.deriveTarget(file);
|
||||||
|
}, FILE);
|
||||||
|
expect(d.trackingLeaf).toBe(false);
|
||||||
|
expect(d.errors.join(' ')).toContain('leaf');
|
||||||
|
expect(d.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracking: unknown status code is reported', async ({ page }) => {
|
||||||
|
const d = await deriveInTracking(page, ['ACME', 'Z (BOGUS)'], FILE);
|
||||||
|
expect(d.status).toBe('BOGUS');
|
||||||
|
expect(d.errors.join(' ')).toContain('unknown status');
|
||||||
|
expect(d.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tracking: leaf with no "(STATUS)" parens is flagged', async ({ page }) => {
|
||||||
|
const d = await deriveInTracking(page, ['ACME', '0001'], FILE);
|
||||||
|
expect(d.status).toBe('');
|
||||||
|
expect(d.filename).toBe(''); // formatFilename needs a status
|
||||||
|
expect(d.errors.join(' ')).toContain('STATUS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('title: original ZDDC name reuses its title; titleOverride wins', async ({ page }) => {
|
||||||
|
const zddcFile = { folderPath: 'Root', originalFilename: 'X-1_A (IFR) - Real Title', extension: 'pdf' };
|
||||||
|
const def = await page.evaluate((f) => window.app.modules.classify.defaultTitle(f), zddcFile);
|
||||||
|
expect(def).toBe('Real Title');
|
||||||
|
|
||||||
|
const overridden = await page.evaluate((f) => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const key = c.srcKeyForFile(f);
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
|
||||||
|
c.place([key], leaf, 'tracking');
|
||||||
|
c.setTitleOverride(key, 'Custom Title');
|
||||||
|
return c.deriveTarget(f);
|
||||||
|
}, FILE);
|
||||||
|
expect(overridden.title).toBe('Custom Title');
|
||||||
|
expect(overridden.filename).toContain(' - Custom Title.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transmittal: party/slot/bin → output path; full target composes', async ({ page }) => {
|
||||||
|
const d = await page.evaluate((file) => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const key = c.srcKeyForFile(file);
|
||||||
|
// axis 1
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
c.place([key], leaf, 'tracking');
|
||||||
|
// axis 2
|
||||||
|
const party = c.addParty('ClientCorp');
|
||||||
|
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([key], bin, 'transmittal');
|
||||||
|
return { d: c.deriveTarget(file), binName: c.getNode(bin).name };
|
||||||
|
}, FILE);
|
||||||
|
expect(d.binName).toBe('2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
|
||||||
|
expect(d.d.party).toBe('ClientCorp');
|
||||||
|
expect(d.d.slot).toBe('received');
|
||||||
|
expect(d.d.outPath).toBe('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
|
||||||
|
expect(d.d.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclude clears placements and reports excluded state', async ({ page }) => {
|
||||||
|
const r = await page.evaluate((file) => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const key = c.srcKeyForFile(file);
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
|
||||||
|
c.place([key], leaf, 'tracking');
|
||||||
|
c.setExcluded([key], true);
|
||||||
|
return { state: c.fileState(file), d: c.deriveTarget(file) };
|
||||||
|
}, FILE);
|
||||||
|
expect(r.state).toBe('excluded');
|
||||||
|
expect(r.d.excluded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 2: mode toggle + target-tree rendering (UI) ──────────────────────
|
||||||
|
|
||||||
|
test('mode switch swaps the spreadsheet pane for the target pane', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
expect(await page.locator('#targetPane').isHidden()).toBe(false);
|
||||||
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(true);
|
||||||
|
await page.click('#modeRenameBtn');
|
||||||
|
expect(await page.locator('#targetPane').isHidden()).toBe(true);
|
||||||
|
expect(await page.locator('#spreadsheetPane').isHidden()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('target tree renders structure and tabs switch', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const acme = c.addTrackingNode(null, 'ACME-PROJ');
|
||||||
|
c.addTrackingNode(acme, 'A (IFR)');
|
||||||
|
const party = c.addParty('ClientCorp');
|
||||||
|
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
});
|
||||||
|
// Tracking panel visible by default with the nodes rendered.
|
||||||
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
||||||
|
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible();
|
||||||
|
// Switch to transmittal tab.
|
||||||
|
await page.click('#transmittalTab');
|
||||||
|
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
||||||
|
await expect(page.locator('#transmittalTree .tnode--bin .tnode__name', { hasText: 'ClientCorp-TRN-0007' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"+ Root folder" button (prompt) parses a name into nested levels', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
|
||||||
|
await page.click('#addTrackingRootBtn');
|
||||||
|
// "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels).
|
||||||
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible();
|
||||||
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible();
|
||||||
|
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
||||||
|
|
||||||
|
test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
window.app.modules.targetTree.render();
|
||||||
|
const row = document.querySelector('#trackingTree .tnode--leaf .tnode__row');
|
||||||
|
const key = 'Sub/foundation.pdf';
|
||||||
|
window.app.modules.dnd.setDrag([key]);
|
||||||
|
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||||
|
return { assigned: c.assignmentFor(key).trackingNodeId, leaf };
|
||||||
|
});
|
||||||
|
expect(r.assigned).toBe(r.leaf);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dropping onto a transmittal bin assigns; dropping on a party row does not', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
await page.click('#transmittalTab');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const party = c.addParty('ClientCorp');
|
||||||
|
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
window.app.modules.targetTree.render();
|
||||||
|
const key = 'Sub/foundation.pdf';
|
||||||
|
|
||||||
|
// Drop on the bin → assigned.
|
||||||
|
const binRow = document.querySelector('#transmittalTree .tnode--bin .tnode__row');
|
||||||
|
window.app.modules.dnd.setDrag([key]);
|
||||||
|
binRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||||
|
const afterBin = c.assignmentFor(key).transmittalNodeId;
|
||||||
|
|
||||||
|
// Reset, then drop on the party row → ignored (only bins are targets).
|
||||||
|
c.place([key], null, 'transmittal');
|
||||||
|
const partyRow = document.querySelector('#transmittalTree .tnode--party > .tnode__row');
|
||||||
|
window.app.modules.dnd.setDrag([key]);
|
||||||
|
partyRow.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||||
|
const afterParty = c.assignmentFor(key).transmittalNodeId;
|
||||||
|
|
||||||
|
return { afterBin, bin, afterParty };
|
||||||
|
});
|
||||||
|
expect(r.afterBin).toBe(r.bin);
|
||||||
|
expect(r.afterParty).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 4: left-tree markers, exclude, cross-tree find ───────────────────
|
||||||
|
|
||||||
|
// Inject a synthetic scanned tree (no FS Access needed) and render it.
|
||||||
|
async function withSourceTree(page) {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }],
|
||||||
|
children: [], fileCount: 1, subdirCount: 0, runFiles: 1, runDirs: 0,
|
||||||
|
}];
|
||||||
|
window.app.modules.tree.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('source file rows render with a state dot in classify mode', async ({ page }) => {
|
||||||
|
await withSourceTree(page);
|
||||||
|
await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'Foundation Plan.pdf' })).toBeVisible();
|
||||||
|
await expect(page.locator('#folderTree .file-item .cl-dot--none')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classify: single-click a source file triggers preview', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const previewed = await page.evaluate(() => {
|
||||||
|
let got = null;
|
||||||
|
window.app.modules.preview.previewFile = (f) => { got = f.originalFilename; }; // capture, skip popup
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' }], children: [],
|
||||||
|
}];
|
||||||
|
window.app.modules.tree.render();
|
||||||
|
document.querySelector('#folderTree .file-item').click();
|
||||||
|
return got;
|
||||||
|
});
|
||||||
|
expect(previewed).toBe('Foundation Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classify: a folder with files but no subfolders is expandable (drag source)', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Leaf', path: 'Leaf', expanded: false, scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'x', extension: 'pdf', folderPath: 'Leaf' }], children: [],
|
||||||
|
}];
|
||||||
|
window.app.modules.tree.render();
|
||||||
|
});
|
||||||
|
const toggle = page.locator('#folderTree .folder-item .folder-toggle').first();
|
||||||
|
await expect(toggle).toHaveText('▶'); // file-only folder still gets an expand arrow
|
||||||
|
await toggle.click();
|
||||||
|
await expect(page.locator('#folderTree .file-item .file-name', { hasText: 'x.pdf' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placing a file turns its dot (and the folder aggregate) done', async ({ page }) => {
|
||||||
|
await withSourceTree(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const realKey = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([realKey], leaf, 'tracking');
|
||||||
|
c.place([realKey], bin, 'transmittal');
|
||||||
|
window.app.modules.tree.render();
|
||||||
|
});
|
||||||
|
await expect(page.locator('#folderTree .file-item .cl-dot--done')).toBeVisible();
|
||||||
|
await expect(page.locator('#folderTree .folder-item .cl-dot--done')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('context-menu exclude marks the file excluded', async ({ page }) => {
|
||||||
|
await withSourceTree(page);
|
||||||
|
await page.locator('#folderTree .file-item').click({ button: 'right' });
|
||||||
|
await expect(page.locator('.cl-menu')).toBeVisible();
|
||||||
|
await page.locator('.cl-menu__item', { hasText: 'Exclude from copy' }).click();
|
||||||
|
await expect(page.locator('#folderTree .file-item.excluded')).toBeVisible();
|
||||||
|
const excluded = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
|
||||||
|
return c.getAssignment(key).excluded;
|
||||||
|
});
|
||||||
|
expect(excluded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-tree reveal: source→target switches to the placed axis', async ({ page }) => {
|
||||||
|
await withSourceTree(page);
|
||||||
|
const ok = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const key = c.srcKeyForFile({ originalFilename: 'Foundation Plan', extension: 'pdf', folderPath: 'Root' });
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'issued', { date: '2026-03-14', type: 'SUB', seq: '0001' });
|
||||||
|
c.place([key], bin, 'transmittal');
|
||||||
|
window.app.modules.targetTree.reveal(key); // should switch to transmittal tab
|
||||||
|
return !document.getElementById('transmittalPanel').hidden;
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
|
||||||
|
|
||||||
|
test('copy: writes the file, skips an identical re-copy, flags a differing target', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
const store = {};
|
||||||
|
const fileHandleFor = (full) => ({
|
||||||
|
getFile: async () => new File([store[full] != null ? store[full] : ''], full.split('/').pop()),
|
||||||
|
createWritable: async () => ({ write: async (d) => { store[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
|
||||||
|
});
|
||||||
|
const mockDir = (prefix) => ({
|
||||||
|
name: prefix || 'out',
|
||||||
|
getDirectoryHandle: async (name) => mockDir((prefix ? prefix + '/' : '') + name),
|
||||||
|
getFileHandle: async (name, opts) => {
|
||||||
|
const full = (prefix ? prefix + '/' : '') + name;
|
||||||
|
if (!opts || !opts.create) { if (!(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
|
||||||
|
return fileHandleFor(full);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const srcFile = (name, content) => {
|
||||||
|
const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.');
|
||||||
|
return { originalFilename: stem, extension: ext, folderPath: 'Root', handle: { getFile: async () => new File([content], name) } };
|
||||||
|
};
|
||||||
|
const f = srcFile('foundation.pdf', 'AAA');
|
||||||
|
window.app.folderTree = [{ name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [f], children: [], runFiles: 1 }];
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
const key = c.srcKeyForFile(f);
|
||||||
|
c.place([key], leaf, 'tracking'); c.place([key], bin, 'transmittal');
|
||||||
|
|
||||||
|
const out = mockDir('');
|
||||||
|
const first = await copy.copyTo(out, copy.plan());
|
||||||
|
const second = await copy.copyTo(out, copy.plan()); // identical → skipped
|
||||||
|
const tkey = Object.keys(store)[0];
|
||||||
|
store[tkey] = 'DIFFERENT'; // tamper target
|
||||||
|
const third = await copy.copyTo(out, copy.plan()); // differs → left alone
|
||||||
|
return { firstCopied: first.copied, secondSkipped: second.skipped, thirdDiffer: third.differ, keys: Object.keys(store) };
|
||||||
|
});
|
||||||
|
expect(res.firstCopied).toBe(1);
|
||||||
|
expect(res.secondSkipped).toBe(1);
|
||||||
|
expect(res.thirdDiffer).toBe(1);
|
||||||
|
expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy: two sources mapping to the same output path are a conflict', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const conflicts = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
const srcFile = (name, folder) => {
|
||||||
|
const p = name.split('.'); const ext = p.length > 1 ? p.pop() : ''; const stem = p.join('.');
|
||||||
|
return { originalFilename: stem, extension: ext, folderPath: folder, handle: { getFile: async () => new File(['x'], name) } };
|
||||||
|
};
|
||||||
|
const f1 = srcFile('plan.pdf', 'Root/a');
|
||||||
|
const f2 = srcFile('plan.pdf', 'Root/b'); // same name, different folder → same derived output
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done', files: [],
|
||||||
|
children: [
|
||||||
|
{ name: 'a', path: 'Root/a', files: [f1], children: [] },
|
||||||
|
{ name: 'b', path: 'Root/b', files: [f2], children: [] },
|
||||||
|
],
|
||||||
|
}];
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([c.srcKeyForFile(f1)], leaf, 'tracking'); c.place([c.srcKeyForFile(f1)], bin, 'transmittal');
|
||||||
|
c.place([c.srcKeyForFile(f2)], leaf, 'tracking'); c.place([c.srcKeyForFile(f2)], bin, 'transmittal');
|
||||||
|
return copy.conflictsIn(copy.plan()).conflicts.length;
|
||||||
|
});
|
||||||
|
expect(conflicts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Workspaces: snapshot, persistence, copy-from-snapshot ──────────────────
|
||||||
|
|
||||||
|
test('snapshot: serialize + rebuild preserves structure, marks done, drops handles', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const sc = window.app.modules.scanner;
|
||||||
|
window.app.folderTree = [{
|
||||||
|
name: 'Root', path: 'Root', expanded: true, scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'a', extension: 'pdf', folderPath: 'Root' }],
|
||||||
|
children: [{
|
||||||
|
name: 'sub', path: 'Root/sub', scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'b', extension: 'txt', folderPath: 'Root/sub' }], children: [],
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
const json = JSON.stringify(sc.snapshotTree());
|
||||||
|
window.app.folderTree = [];
|
||||||
|
sc.loadSnapshot(JSON.parse(json));
|
||||||
|
const root = window.app.folderTree[0];
|
||||||
|
return {
|
||||||
|
rootName: root.name, done: root.scanState === 'done', runFiles: root.runFiles,
|
||||||
|
subFile: root.children[0].files[0].originalFilename,
|
||||||
|
subPath: root.children[0].files[0].folderPath,
|
||||||
|
handleNull: root.children[0].files[0].handle === null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.rootName).toBe('Root');
|
||||||
|
expect(r.done).toBe(true);
|
||||||
|
expect(r.runFiles).toBe(2);
|
||||||
|
expect(r.subFile).toBe('b');
|
||||||
|
expect(r.subPath).toBe('Root/sub');
|
||||||
|
expect(r.handleNull).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scan: resume scans only the pending folders from a snapshot', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const sc = window.app.modules.scanner;
|
||||||
|
// Snapshot: Root (done) with a child 'sub' left pending.
|
||||||
|
sc.loadSnapshot([{ n: 'Root', p: 'Root', c: [{ n: 'sub', p: 'Root/sub', s: 'pending' }] }]);
|
||||||
|
// Mock root handle: Root/sub contains one file.
|
||||||
|
const subDir = { kind: 'directory', name: 'sub', values: async function* () { yield { kind: 'file', name: 'x.pdf' }; } };
|
||||||
|
const root = {
|
||||||
|
kind: 'directory', name: 'Root',
|
||||||
|
getDirectoryHandle: async (n) => { if (n === 'sub') return subDir; const e = new Error('NF'); e.name = 'NotFoundError'; throw e; },
|
||||||
|
};
|
||||||
|
window.app.rootHandle = root;
|
||||||
|
const did = await sc.resumeScan(root);
|
||||||
|
const sub = window.app.folderTree[0].children[0];
|
||||||
|
return { did, subState: sub.scanState, subFiles: sub.files.length, name: sub.files[0] && sub.files[0].originalFilename };
|
||||||
|
});
|
||||||
|
expect(r.did).toBe(true);
|
||||||
|
expect(r.subState).toBe('done');
|
||||||
|
expect(r.subFiles).toBe(1);
|
||||||
|
expect(r.name).toBe('x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persist: workspace put / list / get / delete round-trip', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const P = window.app.modules.persist;
|
||||||
|
const id = 'test-ws-1';
|
||||||
|
await P.putWorkspace(
|
||||||
|
{ id, name: 'WS', rootName: 'Root', createdAt: 1, updatedAt: 2, summary: { files: 3, done: 1, excluded: 0 } },
|
||||||
|
{ id, rootHandle: null, tree: [{ n: 'Root', p: 'Root' }], classify: { assignments: {}, trackingTree: [], transmittalTree: [] } });
|
||||||
|
const meta = (await P.listWorkspaces()).filter((w) => w.id === id)[0];
|
||||||
|
const data = await P.getWorkspace(id);
|
||||||
|
await P.deleteWorkspace(id);
|
||||||
|
const goneAfter = (await P.listWorkspaces()).filter((w) => w.id === id).length;
|
||||||
|
return { name: meta && meta.name, files: meta && meta.summary.files, treeLen: data && data.tree.length, goneAfter };
|
||||||
|
});
|
||||||
|
expect(r.name).toBe('WS');
|
||||||
|
expect(r.files).toBe(3);
|
||||||
|
expect(r.treeLen).toBe(1);
|
||||||
|
expect(r.goneAfter).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persist: classify-only autosave preserves the stored snapshot', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const P = window.app.modules.persist;
|
||||||
|
const id = 'test-ws-2';
|
||||||
|
await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 1, summary: { files: 1, done: 0, excluded: 0 } },
|
||||||
|
{ id, rootHandle: null, tree: [{ n: 'R', p: 'R' }], classify: {} });
|
||||||
|
await P.putWorkspace({ id, name: 'A', rootName: 'R', createdAt: 1, updatedAt: 2, summary: { files: 1, done: 1, excluded: 0 } },
|
||||||
|
{ id, classify: { assignments: { x: {} } } }); // no tree → must preserve
|
||||||
|
const data = await P.getWorkspace(id);
|
||||||
|
await P.deleteWorkspace(id);
|
||||||
|
return { treePreserved: !!(data && data.tree && data.tree.length === 1), hasClassify: !!(data && data.classify.assignments) };
|
||||||
|
});
|
||||||
|
expect(r.treePreserved).toBe(true);
|
||||||
|
expect(r.hasClassify).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy: snapshot files (no handle) resolve from the workspace root', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
const srcStore = { 'Sub/foundation.pdf': 'AAA' };
|
||||||
|
const mkSrcDir = (prefix) => ({
|
||||||
|
name: prefix || 'Root',
|
||||||
|
getDirectoryHandle: async (n) => mkSrcDir((prefix ? prefix + '/' : '') + n),
|
||||||
|
getFileHandle: async (n) => {
|
||||||
|
const full = (prefix ? prefix + '/' : '') + n;
|
||||||
|
if (!(full in srcStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
|
||||||
|
return { getFile: async () => new File([srcStore[full]], n) };
|
||||||
|
},
|
||||||
|
queryPermission: async () => 'granted', requestPermission: async () => 'granted',
|
||||||
|
});
|
||||||
|
window.app.rootHandle = mkSrcDir('');
|
||||||
|
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root/Sub', handle: null };
|
||||||
|
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [], children: [{ name: 'Sub', path: 'Root/Sub', files: [f], children: [] }] }];
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');
|
||||||
|
|
||||||
|
const outStore = {};
|
||||||
|
const mkOut = (prefix) => ({
|
||||||
|
name: prefix || 'out',
|
||||||
|
getDirectoryHandle: async (n) => mkOut((prefix ? prefix + '/' : '') + n),
|
||||||
|
getFileHandle: async (n, opts) => {
|
||||||
|
const full = (prefix ? prefix + '/' : '') + n;
|
||||||
|
if (!opts || !opts.create) { if (!(full in outStore)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; } }
|
||||||
|
return {
|
||||||
|
getFile: async () => new File([outStore[full] != null ? outStore[full] : ''], n),
|
||||||
|
createWritable: async () => ({ write: async (d) => { outStore[full] = (d && d.text) ? await d.text() : d; }, close: async () => { } }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const s = await copy.copyTo(mkOut(''), copy.plan());
|
||||||
|
return { copied: s.copied, content: Object.values(outStore)[0], wrote: Object.keys(outStore).some((k) => k.endsWith('foundation.pdf')) };
|
||||||
|
});
|
||||||
|
expect(res.copied).toBe(1);
|
||||||
|
expect(res.wrote).toBe(true);
|
||||||
|
expect(res.content).toBe('AAA');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
||||||
|
const after = await page.evaluate((file) => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
const key = c.srcKeyForFile(file);
|
||||||
|
const acme = c.addTrackingNode(null, 'ACME');
|
||||||
|
const leaf = c.addTrackingNode(acme, 'A (IFR)');
|
||||||
|
c.place([key], leaf, 'tracking');
|
||||||
|
c.deleteNode(acme); // removes leaf too
|
||||||
|
return c.fileState(file);
|
||||||
|
}, FILE);
|
||||||
|
expect(after).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expandFolderPattern: alternation, zero-padded ranges, cartesian product', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
return {
|
||||||
|
plain: c.expandFolderPattern('Plain'),
|
||||||
|
alt: c.expandFolderPattern('A-{PM,EL,EM}'),
|
||||||
|
range: c.expandFolderPattern('X-{0001-0002,0005}'),
|
||||||
|
full: c.expandFolderPattern('BMB-187023-{PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)'),
|
||||||
|
unbalanced: c.expandFolderPattern('Lit-{oops'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.plain).toEqual(['Plain']);
|
||||||
|
expect(r.alt).toEqual(['A-PM', 'A-EL', 'A-EM']);
|
||||||
|
expect(r.range).toEqual(['X-0001', 'X-0002', 'X-0005']);
|
||||||
|
expect(r.full.length).toBe(9);
|
||||||
|
expect(r.full[0]).toBe('BMB-187023-PM-MOM-0001_A (IFR)');
|
||||||
|
expect(r.full).toContain('BMB-187023-EM-MOM-0005_A (IFR)');
|
||||||
|
expect(r.unbalanced).toEqual(['Lit-{oops']); // unbalanced brace kept literal
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hide Assigned: hides files dealt-with on the active axis and folders left empty', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const before = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
c.reset();
|
||||||
|
const fA = { originalFilename: 'a1', extension: 'pdf', folderPath: 'A' };
|
||||||
|
window.app.folderTree = [
|
||||||
|
{ name: 'A', path: 'A', expanded: true, scanState: 'done', files: [fA], children: [] },
|
||||||
|
{ name: 'B', path: 'B', expanded: true, scanState: 'done',
|
||||||
|
files: [{ originalFilename: 'b1', extension: 'pdf', folderPath: 'B' }], children: [] },
|
||||||
|
];
|
||||||
|
const t = c.addTrackingNode(null, 'TN'); // assign A's file on the tracking axis
|
||||||
|
c.place([c.srcKeyForFile(fA)], t, 'tracking');
|
||||||
|
window.app.modules.tree.render();
|
||||||
|
return document.querySelectorAll('#folderTree .file-item').length;
|
||||||
|
});
|
||||||
|
expect(before).toBe(2); // nothing hidden yet
|
||||||
|
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
window.app.modules.tree.setHideAssigned(true);
|
||||||
|
return {
|
||||||
|
files: Array.from(document.querySelectorAll('#folderTree .file-item .file-name')).map(e => e.textContent),
|
||||||
|
folderA: !!document.querySelector('#folderTree .folder-item[data-path="A"]'),
|
||||||
|
folderB: !!document.querySelector('#folderTree .folder-item[data-path="B"]'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(after.folderA).toBe(false); // A's only file is assigned on the active (tracking) axis → folder hidden
|
||||||
|
expect(after.folderB).toBe(true); // B still needs a tracking number → stays
|
||||||
|
expect(after.files).toEqual(['b1.pdf']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseFolderLevels: split by - then a final _ into nested levels', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
return {
|
||||||
|
three: c.parseFolderLevels('CPO-0001_0 (IFU)'),
|
||||||
|
full: c.parseFolderLevels('BMB-187023-PM-MOM-0001_A (IFR)'),
|
||||||
|
leafOnly: c.parseFolderLevels('A (IFR)'),
|
||||||
|
noRev: c.parseFolderLevels('CPO-0001'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.three).toEqual(['CPO', '0001', '0 (IFU)']);
|
||||||
|
expect(r.full).toEqual(['BMB', '187023', 'PM', 'MOM', '0001', 'A (IFR)']);
|
||||||
|
expect(r.leafOnly).toEqual(['A (IFR)']);
|
||||||
|
expect(r.noRev).toEqual(['CPO', '0001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add-folder builds a nested chain sharing common ancestors', async ({ page }) => {
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify;
|
||||||
|
c.reset();
|
||||||
|
// Brace-expand then nest: two leaves under shared CPO/000x ancestors.
|
||||||
|
c.expandFolderPattern('CPO-{0001,0002}_0 (IFU)').forEach((nm) =>
|
||||||
|
c.addTrackingPath(null, c.parseFolderLevels(nm)));
|
||||||
|
return JSON.parse(JSON.stringify(c.getTrackingTree(), (k, v) => (k === 'id' ? undefined : v)));
|
||||||
|
});
|
||||||
|
expect(r.length).toBe(1); // one shared CPO root
|
||||||
|
expect(r[0].name).toBe('CPO');
|
||||||
|
expect(r[0].children.map((n) => n.name)).toEqual(['0001', '0002']);
|
||||||
|
expect(r[0].children[0].children[0].name).toBe('0 (IFU)'); // leaf rev under each number
|
||||||
|
});
|
||||||
111
zddc/README.md
111
zddc/README.md
|
|
@ -433,8 +433,7 @@ fence is computed by `PolicyChain.VisibleStart`.
|
||||||
|
|
||||||
The leaf-overrides-ancestor behaviour above is the in-process decider's only
|
The leaf-overrides-ancestor behaviour above is the in-process decider's only
|
||||||
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
|
rule. Federal deployments needing absolute parent denies (NIST AC-6) deploy
|
||||||
OPA with the bundled `access_federal.rego` (or their own Rego); see
|
OPA with their own Rego; see "External OPA" below.
|
||||||
"External OPA" below.
|
|
||||||
|
|
||||||
#### The `inherit:` directive
|
#### The `inherit:` directive
|
||||||
|
|
||||||
|
|
@ -471,10 +470,10 @@ Behaviour:
|
||||||
fence; `inherit: false` does not change WORM behaviour. See
|
fence; `inherit: false` does not change WORM behaviour. See
|
||||||
"Canonical-folder behaviour via `.zddc` keys" below.
|
"Canonical-folder behaviour via `.zddc` keys" below.
|
||||||
|
|
||||||
**Federal posture and `inherit: false`.** The bundled federal Rego at
|
**Federal posture and `inherit: false`.** An external OPA policy with
|
||||||
`--print-rego=federal` makes ancestor explicit-denies absolute and
|
ancestor-deny-absolute (NIST AC-6) semantics makes ancestor explicit-denies
|
||||||
therefore ignores `inherit: false` (allowing a leaf to widen access an
|
absolute and therefore ignores `inherit: false` (allowing a leaf to widen
|
||||||
ancestor refused would defeat NIST AC-6). Operators who need fence-
|
access an ancestor refused would defeat NIST AC-6). Operators who need fence-
|
||||||
style "reset" semantics in a federal-track deployment should not use
|
style "reset" semantics in a federal-track deployment should not use
|
||||||
the directive — instead, restructure the tree so the permissive
|
the directive — instead, restructure the tree so the permissive
|
||||||
ancestor rule never appears.
|
ancestor rule never appears.
|
||||||
|
|
@ -927,13 +926,14 @@ have to redo the gap analysis from scratch.
|
||||||
Identity-source-driven role assignment plumbs through unchanged
|
Identity-source-driven role assignment plumbs through unchanged
|
||||||
(the upstream proxy still asserts the email; role membership is
|
(the upstream proxy still asserts the email; role membership is
|
||||||
evaluated server-side against the cascade).
|
evaluated server-side against the cascade).
|
||||||
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *closed.* Operators
|
- ~~**Least-privilege bounding** (NIST AC-6)~~ — *available via the OPA
|
||||||
deploy OPA (`ZDDC_OPA_URL`) pointed at the bundled federal Rego
|
path.* Operators deploy OPA (`ZDDC_OPA_URL`) pointed at their own
|
||||||
(`zddc-server --print-rego=federal`) or their own variant. Under
|
ancestor-deny-absolute Rego, under which any ancestor explicit-deny is
|
||||||
that policy any ancestor explicit-deny is absolute and cannot be
|
absolute and cannot be overridden by a leaf grant. The in-process Go
|
||||||
overridden by a leaf grant. The in-process Go evaluator implements
|
evaluator implements only the commercial "leaf grants override ancestor
|
||||||
only the commercial "leaf grants override ancestor denies" rule;
|
denies" rule, and the bundled `--print-rego` skeleton models read-ACL
|
||||||
federal posture is exclusively the OPA path.
|
only (fail-closed for writes) — an AC-6 federal policy is the operator's
|
||||||
|
own Rego, not a shipped artifact.
|
||||||
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
- **Account lifecycle** (NIST AC-2) — emails as identifiers must tie to
|
||||||
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
authoritative sources (PIV cert subject, IdP-managed identity). Required:
|
||||||
documented integration with at least one IdP supporting federal identity
|
documented integration with at least one IdP supporting federal identity
|
||||||
|
|
@ -1266,56 +1266,47 @@ cache lookup would be.
|
||||||
|
|
||||||
### Reference Rego policy
|
### Reference Rego policy
|
||||||
|
|
||||||
The `--print-rego` flag emits the bundled reference Rego policies. Two
|
The `--print-rego` flag emits the bundled reference Rego **skeleton**:
|
||||||
variants ship:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
zddc-server --print-rego # standard cascade (commercial)
|
zddc-server --print-rego # read-ACL skeleton (fail-closed)
|
||||||
zddc-server --print-rego=standard # same
|
zddc-server --print-rego=standard # same
|
||||||
zddc-server --print-rego=federal # parent-deny-is-absolute (NIST AC-6)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The standard variant mirrors internal-mode semantics exactly — leaf-
|
This skeleton models the **read-ACL cascade only** — glob patterns,
|
||||||
level allows can override an ancestor's deny (the cascade's intentional
|
deny-first-within-a-level, default-deny once any `.zddc` exists, and the
|
||||||
delegation property). The federal variant is the strict-least-privilege
|
leaf-allow-overrides-ancestor-deny delegation property. It is **NOT** a
|
||||||
posture: any deny anywhere in the chain is absolute, no leaf-level
|
semantic mirror of the internal Go decider: it does not implement per-verb
|
||||||
override possible. Federal customers running their own OPA can drop
|
authorization (write/create/delete/admin), WORM zones, `roles:` resolution,
|
||||||
the federal Rego in unchanged, or use either as a starting point for
|
`inherit:false` fences, or standing config-edit. Because those are
|
||||||
further customization.
|
unmodelled it is **fail-closed** — every non-read action is denied, and an
|
||||||
|
elevated admin (`input.user.is_active_admin`) is the only write-capable
|
||||||
|
principal. **Treat it as a starting point, not a turnkey policy:** an
|
||||||
|
operator relying on external OPA for write authorization must add the
|
||||||
|
missing semantics (and, for a NIST AC-6 ancestor-deny-absolute posture,
|
||||||
|
write that rule) before granting writes.
|
||||||
|
|
||||||
Parity is enforced at build time. `zddc/internal/policy/parity_test.go`
|
A build-time guard (`zddc/internal/policy/parity_test.go`,
|
||||||
imports the OPA Go module **as a test-only dependency**, evaluates both
|
`rego_failclosed_test.go`) imports the OPA Go module **as a test-only
|
||||||
bundled Regos against fixture sets and asserts:
|
dependency** and asserts the skeleton matches the internal Go evaluator on
|
||||||
|
the read-cascade dimension (`TestRegoParity_AllInternalCases`) and denies
|
||||||
|
every write verb (`TestReferenceRego_FailClosedOnWrites`). This is a
|
||||||
|
read-cascade + fail-closed guard, **not** a full-parity proof. The
|
||||||
|
test-only import means the production binary stays OPA-free (~13 MB) — the
|
||||||
|
OPA library is in `go.mod` but not in `go build`'s output.
|
||||||
|
|
||||||
- The standard Rego matches the internal Go evaluator on every documented
|
The production decider is pure Go (no library bloat, no extra process); the
|
||||||
cascade scenario (`TestRegoParity_AllInternalCases`).
|
wire format is OPA-canonical, so an operator can point an external OPA at it
|
||||||
- The federal Rego agrees with the standard policy on every case where
|
and extend the skeleton. Typical extensions an operator writes on top:
|
||||||
no ancestor-deny intersects a leaf-allow, AND **disagrees** on the
|
|
||||||
cases where the AC-6 rule differs (`TestFederalRego_DivergencesFromStandard`).
|
|
||||||
This way both policies are guaranteed to behave as documented.
|
|
||||||
|
|
||||||
The test-only import means the production binary stays OPA-free (still
|
- **Per-verb + WORM + roles + config-edit** — the semantics the skeleton
|
||||||
13 MB) — the OPA library is in `go.mod` but not in `go build`'s output.
|
omits; required before the policy can authorize writes at all.
|
||||||
|
- **Parent-deny-is-absolute** — make any ancestor deny absolute for a NIST
|
||||||
This gives you both ends of the spectrum: a single OPA-aware codebase
|
AC-6 least-privilege posture.
|
||||||
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
|
- **Role-based access** — read additional input fields like
|
||||||
`input.user.roles` populated by the upstream proxy from SAML/OIDC
|
`input.user.roles` populated by the upstream proxy from SAML/OIDC claims.
|
||||||
claims, and decide based on those instead of (or alongside) email.
|
- **Time-of-day or IP-range constraints**, and **SIEM-shipped decision
|
||||||
- **Time-of-day or IP-range constraints** — Rego can read
|
logs** via OPA's logging plugins (Splunk Government, Elastic Federal, etc.).
|
||||||
`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
|
### Reference deployment shapes
|
||||||
|
|
||||||
|
|
@ -1326,7 +1317,7 @@ No sidecar, no extra port, no extra binary.
|
||||||
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
|
**Federal sidecar**: deploy OPA alongside zddc-server (k8s sidecar,
|
||||||
nomad task, or systemd service on the same host), bind it to
|
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
|
`127.0.0.1:8181` (or a Unix socket), point `ZDDC_OPA_URL` at it. OPA
|
||||||
loads the deployment's bundled Rego policy from a configured source
|
loads the deployment's own Rego policy from a configured source
|
||||||
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
|
(filesystem, signed bundle from S3, OPAL, etc.) and is patched
|
||||||
independently of zddc-server.
|
independently of zddc-server.
|
||||||
|
|
||||||
|
|
@ -1350,10 +1341,12 @@ gaps that warrant code, in addition to the federal-readiness items above:
|
||||||
CM-3 federal control above).
|
CM-3 federal control above).
|
||||||
- Per-decision caching for external OPA mode (small TTL on (email, path)
|
- Per-decision caching for external OPA mode (small TTL on (email, path)
|
||||||
to amortize the .archive listing's per-entry round-trip).
|
to amortize the .archive listing's per-entry round-trip).
|
||||||
- A reference Rego bundle shipped alongside the binary that exactly
|
- A full-parity reference Rego (modelling per-verb / WORM / roles /
|
||||||
reproduces internal mode, plus a "federal-mode" variant that flips
|
config-edit, not just the read-ACL skeleton shipped today) plus a
|
||||||
the parent-deny-is-absolute toggle. Useful as a starting point for
|
generative differential test against the internal decider — only worth
|
||||||
customers who want to extend rather than write from scratch.
|
building if external OPA becomes a *supported deployment mode* rather than
|
||||||
|
a bring-your-own-policy escape hatch. See the skeleton's caveats under
|
||||||
|
"Reference Rego policy."
|
||||||
|
|
||||||
## Admin Debug Page
|
## Admin Debug Page
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,21 +36,18 @@ import (
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// --print-rego: dump a bundled reference Rego policy and exit.
|
// --print-rego: dump the bundled reference Rego skeleton and exit.
|
||||||
// Cheap escape hatch for operators standing up an external OPA who want
|
// A starting point for operators standing up an external OPA: it models
|
||||||
// a parity-tested baseline as a starting point for customization.
|
// the read-ACL cascade only and is fail-closed for writes (NOT a mirror
|
||||||
|
// of the internal decider), so it must be extended before granting writes.
|
||||||
//
|
//
|
||||||
// --print-rego → standard cascade (commercial default)
|
// --print-rego → read-ACL skeleton (fail-closed)
|
||||||
// --print-rego=standard → same
|
// --print-rego=standard → same
|
||||||
// --print-rego=federal → parent-deny-is-absolute (NIST AC-6)
|
|
||||||
for _, a := range os.Args[1:] {
|
for _, a := range os.Args[1:] {
|
||||||
switch a {
|
switch a {
|
||||||
case "--print-rego", "--print-rego=standard":
|
case "--print-rego", "--print-rego=standard":
|
||||||
fmt.Print(policy.ReferenceRego)
|
fmt.Print(policy.ReferenceRego)
|
||||||
return
|
return
|
||||||
case "--print-rego=federal":
|
|
||||||
fmt.Print(policy.FederalRego)
|
|
||||||
return
|
|
||||||
case "show-defaults", "--show-defaults":
|
case "show-defaults", "--show-defaults":
|
||||||
// Emit the embedded baseline as a .zddc.zip (per-depth policy
|
// Emit the embedded baseline as a .zddc.zip (per-depth policy
|
||||||
// tree, "*" wildcard members) to stdout. Redirect into a bundle
|
// tree, "*" wildcard members) to stdout. Redirect into a bundle
|
||||||
|
|
|
||||||
37
zddc/internal/handler/configpath.go
Normal file
37
zddc/internal/handler/configpath.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configWriteAction returns the action a write to absPath must be authorized
|
||||||
|
// as. The .zddc cascade file and the .zddc.zip bundle are policy, not content:
|
||||||
|
// mutating either is a VerbA operation requiring standing config-edit authority
|
||||||
|
// (IsConfigEditor — a subtree admin or `a`-verb holder, no elevation), which
|
||||||
|
// the decider enforces when the action is tagged ActionAdmin. For any other
|
||||||
|
// path the supplied default action is returned unchanged.
|
||||||
|
//
|
||||||
|
// This is the single predicate behind the per-verb escalation that previously
|
||||||
|
// lived inlined in serveFilePut/serveFileDelete (.zddc only) and was MISSING
|
||||||
|
// from serveFileMove — letting a MOVE plant or relocate a policy file with mere
|
||||||
|
// create/write authority. PUT/DELETE on a URL-visible .zddc.zip are also
|
||||||
|
// existence-gated to config-editors at dispatch (the bundle visibility gate in
|
||||||
|
// cmd/zddc-server), but a MOVE destination rides in the X-ZDDC-Destination
|
||||||
|
// header and never reaches that gate — so the authority bar must be enforced
|
||||||
|
// here, on the resolved target path, for every write verb.
|
||||||
|
//
|
||||||
|
// Matching is case-insensitive to align with HasReservedSidecar: ZDDC_ROOT may
|
||||||
|
// sit on a case-insensitive filesystem (SMB/CIFS/Azure Files) where `.ZDDC` /
|
||||||
|
// `.ZDDC.ZIP` resolve to the same files, and a case-varied target must not slip
|
||||||
|
// past the gate.
|
||||||
|
func configWriteAction(absPath, def string) string {
|
||||||
|
base := filepath.Base(absPath)
|
||||||
|
if strings.EqualFold(base, ".zddc") || strings.EqualFold(base, apps.BundleName) {
|
||||||
|
return policy.ActionAdmin
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
@ -377,10 +377,9 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
if existed {
|
if existed {
|
||||||
action = policy.ActionWrite
|
action = policy.ActionWrite
|
||||||
}
|
}
|
||||||
// .zddc writes always require `a` (admin) regardless of create/overwrite.
|
// Config files (.zddc / .zddc.zip) always require `a` (admin/config-edit)
|
||||||
if filepath.Base(abs) == ".zddc" {
|
// regardless of create/overwrite — see configWriteAction.
|
||||||
action = policy.ActionAdmin
|
action = configWriteAction(abs, action)
|
||||||
}
|
|
||||||
|
|
||||||
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
||||||
return
|
return
|
||||||
|
|
@ -545,10 +544,9 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
action := policy.ActionDelete
|
// Config files (.zddc / .zddc.zip) require `a` (admin/config-edit) to
|
||||||
if filepath.Base(abs) == ".zddc" {
|
// delete — see configWriteAction.
|
||||||
action = policy.ActionAdmin
|
action := configWriteAction(abs, policy.ActionDelete)
|
||||||
}
|
|
||||||
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -676,10 +674,18 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
// ACL: source side requires `w` (rename mutates the source); dest
|
// ACL: source side requires `w` (rename mutates the source); dest
|
||||||
// side requires `c` (creates a new path). Cross-folder moves run
|
// side requires `c` (creates a new path). Cross-folder moves run
|
||||||
// both gates against potentially different chains.
|
// both gates against potentially different chains.
|
||||||
if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
|
//
|
||||||
|
// Config files (.zddc / .zddc.zip) are policy, not content: relocating
|
||||||
|
// one mutates policy at BOTH ends (removing it from the source dir,
|
||||||
|
// installing it at the dest), so each side escalates to ActionAdmin —
|
||||||
|
// the same VerbA/config-edit bar PUT and DELETE enforce. Without this a
|
||||||
|
// caller holding only `w`/`c` could plant an attacker-controlled cascade
|
||||||
|
// (admins:/acl:) via the header-borne destination, which no dispatch
|
||||||
|
// gate inspects. See configWriteAction.
|
||||||
|
if !authorizeAction(cfg, w, r, srcAbs, srcURL, configWriteAction(srcAbs, policy.ActionWrite)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
if !authorizeAction(cfg, w, r, dstAbs, dstURL, configWriteAction(dstAbs, policy.ActionCreate)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If-Match concurrency applies to the source bytes — only meaningful for
|
// If-Match concurrency applies to the source bytes — only meaningful for
|
||||||
|
|
@ -815,10 +821,12 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
// - abs's parent is declared auto_own — every child mkdir under
|
// - abs's parent is declared auto_own — every child mkdir under
|
||||||
// an auto-own folder (working/, staging/, archive/<party>/,
|
// an auto-own folder (working/, staging/, archive/<party>/,
|
||||||
// archive/<party>/incoming/, …) gets the creator's grant.
|
// archive/<party>/incoming/, …) gets the creator's grant.
|
||||||
// The fence (inherit:false) follows abs's own cascade level:
|
// The fence (inherit:false) follows abs's own cascade level via
|
||||||
// per-user homes under working/ declare auto_own_fenced, so the
|
// AutoOwnFencedAt. It is an opt-in the default tree does not set —
|
||||||
// generated .zddc is private; other auto-own positions are
|
// the working/staging/incoming/reviewing party homes are auto-owned
|
||||||
// unfenced so ancestor grants still cascade through.
|
// but UNFENCED, so ancestor grants (e.g. project_team cr) cascade
|
||||||
|
// through and they behave as shared team folders. An operator can
|
||||||
|
// set auto_own_fenced on a position to make it private.
|
||||||
if email != "" {
|
if email != "" {
|
||||||
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
if zddc.AutoOwnAt(cfg.Root, abs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(abs)) {
|
||||||
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
roles := zddc.AutoOwnRolesAt(cfg.Root, abs)
|
||||||
|
|
|
||||||
|
|
@ -949,3 +949,86 @@ func TestFileAPI_PreservesCase(t *testing.T) {
|
||||||
t.Errorf("PUT case NOT preserved (%v)", names)
|
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,23 +814,75 @@ body.help-open .app-header {
|
||||||
with tool-local .toast classes; the old classifier rules can stay
|
with tool-local .toast classes; the old classifier rules can stay
|
||||||
alongside until this file is concatenated above them in the build. */
|
alongside until this file is concatenated above them in the build. */
|
||||||
|
|
||||||
.zddc-toast {
|
/* Toast STACK — bottom-right, newest at the bottom. The container is
|
||||||
|
click-through (pointer-events:none) so the gaps don't block the page; each
|
||||||
|
toast + button re-enables pointer events. */
|
||||||
|
.zddc-toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 2rem;
|
bottom: 1.5rem;
|
||||||
right: 2rem;
|
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);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.875rem 1.25rem;
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.zddc-toasts__clear:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); }
|
||||||
|
|
||||||
|
.zddc-toast {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.7rem 1.7rem 0.7rem 1rem; /* room for the × at top-right */
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
z-index: 9000;
|
max-width: 420px;
|
||||||
max-width: 400px;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
cursor: pointer;
|
|
||||||
animation: zddc-toast-in 0.3s ease-out;
|
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--success { border-left: 4px solid var(--success); }
|
||||||
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
.zddc-toast--error { border-left: 4px solid var(--danger); }
|
||||||
.zddc-toast--info { border-left: 4px solid var(--info); }
|
.zddc-toast--info { border-left: 4px solid var(--info); }
|
||||||
|
|
@ -1670,7 +1722,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-dev · 2026-06-10 14:42:21 · 8f839fc</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -2746,74 +2798,122 @@ body.is-elevated::after {
|
||||||
}());
|
}());
|
||||||
|
|
||||||
// shared/toast.js — non-blocking notification helper available to every
|
// shared/toast.js — non-blocking notification helper available to every
|
||||||
// tool via window.zddc.toast(msg, level, opts). Originated as classifier's
|
// tool via window.zddc.toast(msg, level, opts).
|
||||||
// local showToast (classifier/js/excel.js); promoted here so tools that
|
//
|
||||||
// today use alert() or silent console.error can switch to a uniform
|
// Toasts collect in a bottom-right STACK. Error/warning toasts are STICKY —
|
||||||
// non-blocking surface.
|
// they stay until the user dismisses them (per-toast × or a "Clear all"
|
||||||
|
// button) so the message can be read, selected, and copied while
|
||||||
|
// troubleshooting. info/success toasts auto-dismiss. The message text is
|
||||||
|
// always selectable.
|
||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
// window.zddc.toast('Saved.', 'success');
|
// window.zddc.toast('Saved.', 'success'); // auto-dismiss
|
||||||
// window.zddc.toast('Could not load: ' + err.message, 'error');
|
// window.zddc.toast('Could not load: ' + e.message, 'error'); // sticky
|
||||||
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
// window.zddc.toast('Note', 'info', { durationMs: 3000 });
|
||||||
|
// window.zddc.toast('Heads up', 'info', { durationMs: 0 }); // force sticky
|
||||||
//
|
//
|
||||||
// Levels: 'info' (default) | 'success' | 'warning' | 'error'.
|
// 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
if (!window.zddc) window.zddc = {};
|
if (!window.zddc) window.zddc = {};
|
||||||
// Don't overwrite if a tool defined its own first.
|
|
||||||
if (typeof window.zddc.toast === 'function') return;
|
if (typeof window.zddc.toast === 'function') return;
|
||||||
|
|
||||||
var DEFAULT_DURATION_MS = 5000;
|
var DEFAULT_DURATION_MS = 5000;
|
||||||
var FADE_MS = 300;
|
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) {
|
function toast(message, level, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
var lvl = (level === 'success' || level === 'error' ||
|
var lvl = (level === 'success' || level === 'error' ||
|
||||||
level === 'warning') ? level : 'info';
|
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');
|
var el = document.createElement('div');
|
||||||
el.className = 'zddc-toast zddc-toast--' + lvl;
|
el.className = 'zddc-toast zddc-toast--' + lvl;
|
||||||
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
// ARIA: errors get assertive (interrupts SR queue), others polite.
|
||||||
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
el.setAttribute('role', lvl === 'error' ? 'alert' : 'status');
|
||||||
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
el.setAttribute('aria-live', lvl === 'error' ? 'assertive' : 'polite');
|
||||||
el.textContent = message == null ? '' : String(message);
|
|
||||||
document.body.appendChild(el);
|
|
||||||
|
|
||||||
var dur = typeof opts.durationMs === 'number' ?
|
// Selectable, copyable message text (its own element so clicking to
|
||||||
opts.durationMs : DEFAULT_DURATION_MS;
|
// select doesn't dismiss the toast — only the × does).
|
||||||
var timer = setTimeout(function () {
|
var msg = document.createElement('span');
|
||||||
el.classList.add('zddc-toast--fade');
|
msg.className = 'zddc-toast__msg';
|
||||||
setTimeout(function () {
|
msg.textContent = message == null ? '' : String(message);
|
||||||
if (el.parentNode) el.parentNode.removeChild(el);
|
el.appendChild(msg);
|
||||||
}, FADE_MS);
|
|
||||||
}, dur);
|
|
||||||
|
|
||||||
// Click-to-dismiss. Useful for sticky errors the user wants gone.
|
var close = document.createElement('button');
|
||||||
el.addEventListener('click', function () {
|
close.type = 'button';
|
||||||
clearTimeout(timer);
|
close.className = 'zddc-toast__close';
|
||||||
if (el.parentNode) el.parentNode.removeChild(el);
|
close.setAttribute('aria-label', 'Dismiss');
|
||||||
});
|
close.textContent = '×';
|
||||||
|
close.addEventListener('click', function () { dismiss(el); });
|
||||||
|
el.appendChild(close);
|
||||||
|
|
||||||
|
c.appendChild(el);
|
||||||
|
|
||||||
|
// Sticky (error/warning, or opts.durationMs === 0) persists; otherwise
|
||||||
|
// auto-dismiss after the (overridable) duration.
|
||||||
|
var sticky = opts.durationMs === 0 ||
|
||||||
|
(typeof opts.durationMs !== 'number' && STICKY[lvl]);
|
||||||
|
if (!sticky) {
|
||||||
|
var dur = typeof opts.durationMs === 'number'
|
||||||
|
? opts.durationMs : DEFAULT_DURATION_MS;
|
||||||
|
el._timer = setTimeout(function () { dismiss(el); }, dur);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshClearAll(c);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.zddc.toast = toast;
|
window.zddc.toast = toast;
|
||||||
|
|
||||||
// Route window.alert() calls into the toast helper. Every tool has
|
// Route window.alert() into the toast helper (non-blocking, ARIA-announced,
|
||||||
// accumulated some `alert(...)` sites for error reporting; rather
|
// consistent). Native alert preserved on window.alertNative. alert() maps
|
||||||
// than touch each one, intercept globally so they're non-blocking
|
// to an error toast (sticky) since that's its usual purpose.
|
||||||
// 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) {
|
if (typeof window.alert === 'function' && !window.alertNative) {
|
||||||
window.alertNative = window.alert.bind(window);
|
window.alertNative = window.alert.bind(window);
|
||||||
window.alert = function (msg) {
|
window.alert = function (msg) {
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
package policy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
||||||
|
|
||||||
"github.com/open-policy-agent/opa/rego"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestFederalRego_DivergencesFromStandard validates the federal-mode
|
|
||||||
// variant by asserting both that:
|
|
||||||
//
|
|
||||||
// (a) most cascade scenarios produce the same verdict as standard
|
|
||||||
// (the federal rule reduces to standard whenever no parent deny
|
|
||||||
// intersects a leaf allow), AND
|
|
||||||
//
|
|
||||||
// (b) the specific scenarios where the rules differ (a leaf-level
|
|
||||||
// allow overlaying an ancestor's deny) produce DIFFERENT verdicts:
|
|
||||||
// standard says allow (leaf wins); federal says deny (ancestor
|
|
||||||
// deny is absolute — NIST AC-6 default).
|
|
||||||
//
|
|
||||||
// Like the standard parity test, this imports the OPA library as a
|
|
||||||
// test-only dependency. The federal Rego is a deployable artifact
|
|
||||||
// (operators dump it via --print-rego=federal); the parity guard
|
|
||||||
// here proves the artifact behaves as documented.
|
|
||||||
func TestFederalRego_DivergencesFromStandard(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
standard, err := rego.New(
|
|
||||||
rego.Query("data.zddc.access.allow"),
|
|
||||||
rego.Module("access.rego", ReferenceRego),
|
|
||||||
).PrepareForEval(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compile standard rego: %v", err)
|
|
||||||
}
|
|
||||||
federal, err := rego.New(
|
|
||||||
rego.Query("data.zddc.access_federal.allow"),
|
|
||||||
rego.Module("access_federal.rego", FederalRego),
|
|
||||||
).PrepareForEval(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("compile federal rego: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
allow := func(p ...string) zddc.ZddcFile {
|
|
||||||
m := make(map[string]string, len(p))
|
|
||||||
for _, x := range p {
|
|
||||||
m[x] = "rwcd"
|
|
||||||
}
|
|
||||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
|
|
||||||
}
|
|
||||||
deny := func(p ...string) zddc.ZddcFile {
|
|
||||||
m := make(map[string]string, len(p))
|
|
||||||
for _, x := range p {
|
|
||||||
m[x] = ""
|
|
||||||
}
|
|
||||||
return zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: m}}
|
|
||||||
}
|
|
||||||
empty := zddc.ZddcFile{}
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
chain zddc.PolicyChain
|
|
||||||
email string
|
|
||||||
wantStandard bool
|
|
||||||
wantFederal bool
|
|
||||||
divergesByDesign bool // true if standard and federal must disagree here
|
|
||||||
}{
|
|
||||||
// ── Cases where the two policies must AGREE ────────────────
|
|
||||||
{
|
|
||||||
"empty chain, no files",
|
|
||||||
zddc.PolicyChain{HasAnyFile: false},
|
|
||||||
"alice@example.com",
|
|
||||||
true, true, false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files exist, no rule matches → both deny",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@trusted.com")}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
false, false, false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"leaf allow with no ancestor deny → both allow",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{empty, allow("*@example.com")}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
true, true, false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"only deny anywhere → both deny",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{deny("alice@example.com")}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
false, false, false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob allow, no deny → both allow",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{allow("*@example.com")}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
true, true, false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── The signature divergence: leaf allow overlaying ancestor deny ──
|
|
||||||
{
|
|
||||||
"leaf allows what parent denied → standard allows, federal denies (AC-6)",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
||||||
deny("alice@example.com"),
|
|
||||||
allow("alice@example.com"),
|
|
||||||
}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
true, // standard: leaf wins
|
|
||||||
false, // federal: parent deny is absolute
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deep leaf re-allows after middle deny → standard allows, federal denies",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
||||||
allow("*@example.com"),
|
|
||||||
deny("alice@example.com"),
|
|
||||||
allow("alice@example.com"),
|
|
||||||
}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"glob deny at root, specific allow at leaf → both differ",
|
|
||||||
zddc.PolicyChain{Levels: []zddc.ZddcFile{
|
|
||||||
deny("*@example.com"),
|
|
||||||
allow("alice@example.com"),
|
|
||||||
}, HasAnyFile: true},
|
|
||||||
"alice@example.com",
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
input := AllowInput{Path: "/test", PolicyChain: chainToSerializable(tc.chain)}
|
|
||||||
input.User.Email = tc.email
|
|
||||||
regoInput, err := canonicalInput(input)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encode input: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
std, err := standard.Eval(ctx, rego.EvalInput(regoInput))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("standard eval: %v", err)
|
|
||||||
}
|
|
||||||
fed, err := federal.Eval(ctx, rego.EvalInput(regoInput))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("federal eval: %v", err)
|
|
||||||
}
|
|
||||||
if len(std) == 0 || len(fed) == 0 {
|
|
||||||
t.Fatal("rego returned empty result set")
|
|
||||||
}
|
|
||||||
stdAllow := std[0].Expressions[0].Value.(bool)
|
|
||||||
fedAllow := fed[0].Expressions[0].Value.(bool)
|
|
||||||
|
|
||||||
if stdAllow != tc.wantStandard {
|
|
||||||
t.Errorf("standard rego: got %v, want %v", stdAllow, tc.wantStandard)
|
|
||||||
}
|
|
||||||
if fedAllow != tc.wantFederal {
|
|
||||||
t.Errorf("federal rego: got %v, want %v", fedAllow, tc.wantFederal)
|
|
||||||
}
|
|
||||||
// Cross-check the divergence flag itself: if we said the cases
|
|
||||||
// must disagree, they must; if we said they agree, they must.
|
|
||||||
diverges := stdAllow != fedAllow
|
|
||||||
if diverges != tc.divergesByDesign {
|
|
||||||
t.Errorf("divergence = %v, want %v (standard=%v, federal=%v)",
|
|
||||||
diverges, tc.divergesByDesign, stdAllow, fedAllow)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFederalRego_RegoCompiles is a sanity check that the embedded
|
|
||||||
// federal Rego file parses without error in OPA, separate from the
|
|
||||||
// behavior tests. Catches accidental syntax breakage in
|
|
||||||
// access_federal.rego before running the (slower) parity matrix.
|
|
||||||
func TestFederalRego_RegoCompiles(t *testing.T) {
|
|
||||||
_, err := rego.New(
|
|
||||||
rego.Query("data.zddc.access_federal.allow"),
|
|
||||||
rego.Module("access_federal.rego", FederalRego),
|
|
||||||
).PrepareForEval(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("federal rego does not compile: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -58,18 +58,21 @@ import (
|
||||||
// External Rego policies can:
|
// External Rego policies can:
|
||||||
// - read input.user.email (string)
|
// - read input.user.email (string)
|
||||||
// - read input.path (string)
|
// - read input.path (string)
|
||||||
// - read input.action ("read" | "write"); empty/absent ≡ "read"
|
// - read input.action ("read"|"write"|"create"|"delete"|"admin");
|
||||||
|
// empty/absent ≡ "read"
|
||||||
// - walk input.policy_chain.levels[].acl.{allow,deny} for
|
// - walk input.policy_chain.levels[].acl.{allow,deny} for
|
||||||
// custom cascade semantics, or read the pre-resolved
|
// custom cascade semantics, or read the pre-resolved
|
||||||
// input.policy_chain.has_any_file when implementing the
|
// input.policy_chain.has_any_file when implementing the
|
||||||
// same default-deny rule we use internally.
|
// same default-deny rule we use internally.
|
||||||
//
|
//
|
||||||
// Action distinguishes read (GET/HEAD on listings, files, app HTML)
|
// Action distinguishes read (GET/HEAD on listings, files, app HTML) from
|
||||||
// from write (PUT, DELETE, POST/move on the file API). The internal
|
// write/create/delete/admin (PUT, DELETE, POST/move on the file API). The
|
||||||
// decider treats both identically — any allow grants full CRUD,
|
// internal decider HONORS it: actionVerb maps each action to the verb it
|
||||||
// matching the model in place before the file API existed (anyone
|
// requires, and AllowActionFromChainP checks that specific verb against the
|
||||||
// with read access also had OS-level write via the mounted share).
|
// cascade's effective grant, with the WORM clamp and the admin/elevation
|
||||||
// External Rego policies can split the two by inspecting input.action.
|
// bypass applied. The bundled reference Rego, by contrast, models the
|
||||||
|
// read-ACL cascade only and is fail-closed for non-read actions — see
|
||||||
|
// rego/access.rego.
|
||||||
type AllowInput struct {
|
type AllowInput struct {
|
||||||
User struct {
|
User struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|
@ -240,11 +243,21 @@ func (d *InternalDecider) Allow(_ context.Context, input AllowInput) (bool, erro
|
||||||
// is a STANDING permission: a subtree admin (admins: cascade) or a
|
// is a STANDING permission: a subtree admin (admins: cascade) or a
|
||||||
// holder of the `a` verb may edit the config of subtrees they
|
// holder of the `a` verb may edit the config of subtrees they
|
||||||
// administer WITHOUT elevating. This sits ABOVE the WORM clamp because
|
// administer WITHOUT elevating. This sits ABOVE the WORM clamp because
|
||||||
// config is not WORM-protected data — and it only ever grants VerbA,
|
// config is not WORM-protected data.
|
||||||
// so it can never write/delete/create WORM *records* (those need
|
//
|
||||||
// W/C/D, which stay clamped and behind the elevated bypass above).
|
// Scope: this grants VerbA only, so no SINGLE decision here authorizes a
|
||||||
// Elevation is thus purely additive: it adds the WORM/destructive
|
// WORM *record* write/delete/create (those need W/C/D, which stay clamped
|
||||||
// overrides, never gating config-edit you already have authority for.
|
// below, behind the elevated bypass above). It does NOT, however, make a
|
||||||
|
// WORM zone tamper-proof against its own policy owner: a config-editor who
|
||||||
|
// administers a WORM directory may edit that directory's .zddc — including
|
||||||
|
// relaxing or (via inherit:false) dropping its `worm:` marker — after
|
||||||
|
// which ordinary writes to that subtree are no longer clamped. That
|
||||||
|
// two-step demotion is intended (owning a subtree's policy includes its
|
||||||
|
// worm: declaration) and is access-logged/transparent: WORM is therefore
|
||||||
|
// tamper-EVIDENT to its config owner, not tamper-PROOF. A deployment that
|
||||||
|
// needs the marker immutable except under elevation should gate worm:
|
||||||
|
// relaxation behind IsActiveAdmin. The edit-then-write composition is
|
||||||
|
// pinned in standing_config_test.go (TestStandingConfigEdit_WormDemotionIsTwoStep).
|
||||||
if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) {
|
if verb == zddc.VerbA && zddc.IsConfigEditor(chain, email) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,26 @@ package policy
|
||||||
|
|
||||||
import _ "embed"
|
import _ "embed"
|
||||||
|
|
||||||
// ReferenceRego is the canonical Rego policy bundled with zddc-server.
|
// ReferenceRego is a read-ACL Rego SKELETON bundled with zddc-server for
|
||||||
// It mirrors the InternalDecider's semantics exactly — every release CI
|
// external-OPA deployments. It models the read cascade ONLY and is NOT a
|
||||||
// run validates parity via parity_test.go (which imports the OPA library
|
// semantic mirror of the InternalDecider: it does not implement per-verb
|
||||||
// as a test-only dependency, so the production binary stays OPA-free).
|
// authorization (write/create/delete/admin), WORM zones, roles, fences, or
|
||||||
|
// config-edit, so it is FAIL-CLOSED — every non-read action is denied except
|
||||||
|
// for an elevated admin (input.user.is_active_admin). The InternalDecider
|
||||||
|
// remains the production source of truth. parity_test.go (OPA as a test-only
|
||||||
|
// dependency, so the production binary stays OPA-free) checks the modelled
|
||||||
|
// read-cascade dimension only — it does NOT prove full parity.
|
||||||
//
|
//
|
||||||
// Operators running an external OPA can use this as the starting point
|
// Operators running an external OPA can use this as a STARTING POINT — they
|
||||||
// for their own policy bundle:
|
// must add the unmodelled write/WORM/role/admin semantics before relying on
|
||||||
|
// it for write authorization:
|
||||||
//
|
//
|
||||||
// zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
// zddc-server --print-rego > /etc/opa/policies/zddc-access.rego
|
||||||
//
|
//
|
||||||
// Customizations typical for federal deployments:
|
// Customizations typical for federal deployments:
|
||||||
//
|
//
|
||||||
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
// - Flip the leaf-allow-overrides-parent-deny semantics so parent denies
|
||||||
// are absolute (NIST AC-6 least-privilege posture). For this specific
|
// are absolute (NIST AC-6 least-privilege posture).
|
||||||
// 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
|
// - Add role-based access via additional input fields (input.user.roles
|
||||||
// populated by the upstream proxy from SAML/OIDC claims).
|
// populated by the upstream proxy from SAML/OIDC claims).
|
||||||
// - Add time-of-day or IP-range constraints.
|
// - Add time-of-day or IP-range constraints.
|
||||||
|
|
@ -26,21 +30,3 @@ import _ "embed"
|
||||||
//
|
//
|
||||||
//go:embed rego/access.rego
|
//go:embed rego/access.rego
|
||||||
var ReferenceRego string
|
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,16 +1,26 @@
|
||||||
# Reference Rego policy that mirrors zddc-server's built-in `internal`
|
# Reference Rego SKELETON for an external-OPA deployment. It models the
|
||||||
# decider exactly. Federal customers running their own OPA can use this
|
# read-ACL cascade ONLY. It is NOT semantically equivalent to zddc-server's
|
||||||
# as a starting point (and then tighten — e.g. flip the leaf-allow-overrides-
|
# built-in `internal` decider and MUST NOT be deployed as-is for a system
|
||||||
# parent-deny rule for NIST AC-6 compliance).
|
# that relies on write authorization.
|
||||||
#
|
#
|
||||||
# The internal evaluator (in zddc/internal/zddc/acl.go) is the source of
|
# Models: the deepest-matching-level read-ACL cascade — glob patterns,
|
||||||
# truth for production. This file is validated against that evaluator on
|
# deny-first-within-a-level, default-deny once any .zddc exists.
|
||||||
# every CI run via the parity test in zddc/internal/policy/parity_test.go.
|
#
|
||||||
# Both implementations must produce the same decision for every fixture.
|
# Does NOT model (the internal decider in zddc/internal/zddc + internal/policy
|
||||||
|
# does): per-verb authorization (write/create/delete/admin), WORM zones,
|
||||||
|
# roles: membership resolution, inherit:false fences, and standing config-edit
|
||||||
|
# (the `a` verb). Because those are unmodelled this policy is FAIL-CLOSED:
|
||||||
|
# every non-read action is denied, and an elevated admin
|
||||||
|
# (input.user.is_active_admin) is the only write-capable principal. A real
|
||||||
|
# deployment must add the missing semantics before granting writes — see the
|
||||||
|
# parity tests under zddc/internal/policy for the dimensions to cover. The
|
||||||
|
# internal Go decider remains the production source of truth; this file is a
|
||||||
|
# starting point, not a tested mirror of it.
|
||||||
#
|
#
|
||||||
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
|
# Input shape (matches zddc/internal/policy.AllowInput JSON encoding):
|
||||||
# {
|
# {
|
||||||
# "user": {"email": "alice@example.com"},
|
# "user": {"email": "alice@example.com", "is_active_admin": false},
|
||||||
|
# "action": "read", # "" / absent == read; else write|create|delete|admin
|
||||||
# "path": "/Project-A/sub/",
|
# "path": "/Project-A/sub/",
|
||||||
# "policy_chain": {
|
# "policy_chain": {
|
||||||
# "levels": [
|
# "levels": [
|
||||||
|
|
@ -39,14 +49,40 @@ import future.keywords.in
|
||||||
|
|
||||||
default allow := false
|
default allow := false
|
||||||
|
|
||||||
# Allow when no .zddc files anywhere in the chain AND no rule matches.
|
# Elevated admins bypass — mirrors the internal decider's single admin
|
||||||
|
# short-circuit. The caller computes is_active_admin (admin authority on this
|
||||||
|
# chain AND elevated/opted-in); trusting it here is the same trust the
|
||||||
|
# internal decider applies. This is the ONLY path that authorizes a non-read
|
||||||
|
# action under this read-ACL skeleton.
|
||||||
allow if {
|
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
|
not input.policy_chain.has_any_file
|
||||||
count(matched_levels) == 0
|
count(matched_levels) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# Allow when the deepest matching level grants.
|
# Read allowed when the deepest matching level grants.
|
||||||
allow if {
|
allow if {
|
||||||
|
is_read_action
|
||||||
count(matched_levels) > 0
|
count(matched_levels) > 0
|
||||||
deepest := max(matched_levels)
|
deepest := max(matched_levels)
|
||||||
level_grants(input.policy_chain.levels[deepest])
|
level_grants(input.policy_chain.levels[deepest])
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
# Federal-mode reference policy: parent-deny-is-absolute (NIST AC-6).
|
|
||||||
#
|
|
||||||
# This is a strict-least-privilege variant of access.rego. The two policies
|
|
||||||
# differ in exactly one rule, but the semantic difference is meaningful for
|
|
||||||
# federal evaluators:
|
|
||||||
#
|
|
||||||
# access.rego (commercial, default):
|
|
||||||
# "Bottom-up walk; first explicit match wins; deny-first within a level.
|
|
||||||
# A leaf-level allow CAN override an ancestor's deny."
|
|
||||||
# Test: cascade_test.go "leaf allows user that parent denies → leaf wins".
|
|
||||||
#
|
|
||||||
# access_federal.rego (federal):
|
|
||||||
# "Any deny anywhere along the chain is absolute. An allow only matters
|
|
||||||
# if no ancestor (or sibling level) has denied the same email. Leaf-
|
|
||||||
# level allows do NOT override ancestor denies."
|
|
||||||
# Required by NIST AC-6 (Least Privilege) default expectations: a
|
|
||||||
# central admin's deny at the root must be unbypassable by a tenant
|
|
||||||
# who controls a subtree's .zddc.
|
|
||||||
#
|
|
||||||
# Why ship two policies? The internal Go evaluator (in zddc/internal/zddc/
|
|
||||||
# acl.go) implements only the commercial cascade — it's the rule the
|
|
||||||
# default deployment exercises. Federal customers running their own OPA
|
|
||||||
# with this file get the strict variant without any zddc-server code
|
|
||||||
# change. They can also write a hybrid policy (e.g. "deny is absolute
|
|
||||||
# only for emails matching some pattern; cascade rules for everyone
|
|
||||||
# else") since once they're hosting their own OPA, the constraint is
|
|
||||||
# whatever they write.
|
|
||||||
#
|
|
||||||
# Input shape: identical to access.rego — see that file's docstring.
|
|
||||||
# acl.permissions maps principal patterns to verb strings; an empty
|
|
||||||
# verb string is an explicit deny.
|
|
||||||
|
|
||||||
package zddc.access_federal
|
|
||||||
|
|
||||||
import future.keywords.if
|
|
||||||
import future.keywords.in
|
|
||||||
|
|
||||||
default allow := false
|
|
||||||
|
|
||||||
# Allow when no .zddc files exist anywhere AND no rule matches.
|
|
||||||
# Same default-allow case as commercial; preserves the empty-tree
|
|
||||||
# behaviour. (zddc-server's --insecure check at startup makes this
|
|
||||||
# unreachable in any non-deliberately-public deployment.)
|
|
||||||
allow if {
|
|
||||||
not input.policy_chain.has_any_file
|
|
||||||
not any_deny_match
|
|
||||||
not any_allow_match
|
|
||||||
}
|
|
||||||
|
|
||||||
# Allow when files exist, no level (any depth) denies, and at least
|
|
||||||
# one level allows. The "any level" check is what makes parent denies
|
|
||||||
# absolute — there is no "deepest match wins" rule here.
|
|
||||||
allow if {
|
|
||||||
input.policy_chain.has_any_file
|
|
||||||
not any_deny_match
|
|
||||||
any_allow_match
|
|
||||||
}
|
|
||||||
|
|
||||||
# Any explicit-deny permission entry at ANY level matches the email.
|
|
||||||
any_deny_match if {
|
|
||||||
some level in input.policy_chain.levels
|
|
||||||
some pattern, verbs in level.acl.permissions
|
|
||||||
verbs == ""
|
|
||||||
email_matches(pattern, input.user.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Any grant permission entry (non-empty verbs) at ANY level matches.
|
|
||||||
any_allow_match if {
|
|
||||||
some level in input.policy_chain.levels
|
|
||||||
some pattern, verbs in level.acl.permissions
|
|
||||||
verbs != ""
|
|
||||||
email_matches(pattern, input.user.email)
|
|
||||||
}
|
|
||||||
|
|
||||||
# email_matches: identical to access.rego — see that file for the
|
|
||||||
# rationale on the four cases. Duplicated rather than imported so this
|
|
||||||
# file is self-contained for operators who copy it as a starting point.
|
|
||||||
|
|
||||||
email_matches(pattern, email) if {
|
|
||||||
pattern == email
|
|
||||||
}
|
|
||||||
|
|
||||||
email_matches(pattern, email) if {
|
|
||||||
pattern == "*"
|
|
||||||
email != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
email_matches(pattern, email) if {
|
|
||||||
contains(pattern, "*")
|
|
||||||
contains(pattern, "@")
|
|
||||||
glob.match(pattern, ["@"], email)
|
|
||||||
}
|
|
||||||
|
|
||||||
email_matches(pattern, email) if {
|
|
||||||
contains(pattern, "*")
|
|
||||||
not contains(pattern, "@")
|
|
||||||
pattern != "*"
|
|
||||||
glob.match(pattern, [], email)
|
|
||||||
}
|
|
||||||
83
zddc/internal/policy/rego_failclosed_test.go
Normal file
83
zddc/internal/policy/rego_failclosed_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
|
|
||||||
|
"github.com/open-policy-agent/opa/rego"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReferenceRego_FailClosedOnWrites pins the security contract of the
|
||||||
|
// bundled reference Rego skeleton: it models READ-ACL only, so any non-read
|
||||||
|
// action must be DENIED even when the read-ACL would grant — and the only
|
||||||
|
// write-capable principal is an elevated admin. This is the behavior that,
|
||||||
|
// untested, previously let a verb-blind policy ship claiming to "mirror the
|
||||||
|
// internal decider exactly." See rego/access.rego.
|
||||||
|
func TestReferenceRego_FailClosedOnWrites(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
stdQ, err := rego.New(
|
||||||
|
rego.Query("data.zddc.access.allow"),
|
||||||
|
rego.Module("access.rego", ReferenceRego),
|
||||||
|
).PrepareForEval(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile access.rego: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chain that GRANTS full rwcd to alice — so any denial below is the
|
||||||
|
// action gate, not a missing ACL.
|
||||||
|
grant := zddc.PolicyChain{
|
||||||
|
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"*@example.com": "rwcd"}}}},
|
||||||
|
HasAnyFile: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
evalAllow := func(q rego.PreparedEvalQuery, action string, admin bool) bool {
|
||||||
|
in := AllowInput{Path: "/p/", Action: action, PolicyChain: chainToSerializable(grant)}
|
||||||
|
in.User.Email = "alice@example.com"
|
||||||
|
in.User.IsActiveAdmin = admin
|
||||||
|
regoInput, err := canonicalInput(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
rs, err := q.Eval(ctx, rego.EvalInput(regoInput))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("eval: %v", err)
|
||||||
|
}
|
||||||
|
if len(rs) == 0 {
|
||||||
|
t.Fatalf("no result")
|
||||||
|
}
|
||||||
|
v, ok := rs[0].Expressions[0].Value.(bool)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("result not bool: %v", rs[0].Expressions[0].Value)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
q rego.PreparedEvalQuery
|
||||||
|
action string
|
||||||
|
admin bool
|
||||||
|
wantAllow bool
|
||||||
|
}{
|
||||||
|
// Commercial: reads granted, every write verb denied (fail-closed).
|
||||||
|
{"access read allowed", stdQ, ActionRead, false, true},
|
||||||
|
{"access empty-action(read) allowed", stdQ, "", false, true},
|
||||||
|
{"access write denied", stdQ, ActionWrite, false, false},
|
||||||
|
{"access create denied", stdQ, ActionCreate, false, false},
|
||||||
|
{"access delete denied", stdQ, ActionDelete, false, false},
|
||||||
|
{"access admin-action denied", stdQ, ActionAdmin, false, false},
|
||||||
|
// An elevated admin is the one write-capable principal.
|
||||||
|
{"access write allowed for active admin", stdQ, ActionWrite, true, true},
|
||||||
|
{"access delete allowed for active admin", stdQ, ActionDelete, true, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := evalAllow(tc.q, tc.action, tc.admin); got != tc.wantAllow {
|
||||||
|
t.Errorf("allow=%v, want %v (action=%q active_admin=%v)", got, tc.wantAllow, tc.action, tc.admin)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,3 +72,47 @@ func TestStandingConfigEdit(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStandingConfigEdit_WormDemotionIsTwoStep documents the (intended)
|
||||||
|
// composition that a single-action view of the decider hides: a config-editor
|
||||||
|
// who administers a WORM zone cannot write a WORM record directly, but CAN
|
||||||
|
// demote the zone by editing its own .zddc, after which an ordinary write is
|
||||||
|
// no longer clamped. WORM is thus tamper-evident to its policy owner, not
|
||||||
|
// tamper-proof. Pinned so the behavior is an explicit, tested decision — if a
|
||||||
|
// deployment ever needs WORM markers immutable except under elevation, this is
|
||||||
|
// the test that must change alongside gating worm: relaxation behind
|
||||||
|
// IsActiveAdmin in policy.InternalDecider.Allow.
|
||||||
|
func TestStandingConfigEdit_WormDemotionIsTwoStep(t *testing.T) {
|
||||||
|
d := &InternalDecider{}
|
||||||
|
dec := func(chain zddc.PolicyChain, p zddc.Principal, action string) bool {
|
||||||
|
ok, _ := AllowActionFromChainP(context.Background(), d, chain, p, "/proj/probe", action)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
alice := zddc.Principal{Email: "alice@x", Elevated: false} // config-editor, NOT elevated
|
||||||
|
|
||||||
|
// Before — alice administers a WORM zone (admins: + a non-nil worm list).
|
||||||
|
worm := zddc.PolicyChain{
|
||||||
|
Levels: []zddc.ZddcFile{{Admins: []string{"alice@x"}, Worm: []string{}}},
|
||||||
|
HasAnyFile: true,
|
||||||
|
}
|
||||||
|
// The boundary holds: a direct WORM record write is denied unelevated...
|
||||||
|
if dec(worm, alice, ActionWrite) {
|
||||||
|
t.Error("unelevated config-editor must NOT directly write a WORM record")
|
||||||
|
}
|
||||||
|
// ...but she CAN edit the zone's policy (VerbA) — the lever for demotion.
|
||||||
|
if !dec(worm, alice, ActionAdmin) {
|
||||||
|
t.Error("config-editor should be able to edit the WORM zone's .zddc unelevated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After — alice has rewritten that .zddc: inherit:false dropped the
|
||||||
|
// embedded worm: and her acl now grants rwcd (the post-edit cascade the
|
||||||
|
// file API persists). The subtree is no longer WORM, so her write lands —
|
||||||
|
// still unelevated. This is step two of the intended demotion.
|
||||||
|
demoted := zddc.PolicyChain{
|
||||||
|
Levels: []zddc.ZddcFile{{ACL: zddc.ACLRules{Permissions: map[string]string{"alice@x": "rwcd"}}}},
|
||||||
|
HasAnyFile: true,
|
||||||
|
}
|
||||||
|
if !dec(demoted, alice, ActionWrite) {
|
||||||
|
t.Error("after the config-editor demotes the zone, the ordinary write should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
45
zddc/internal/zddc/embedded_neutral_test.go
Normal file
45
zddc/internal/zddc/embedded_neutral_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package zddc
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestEmbeddedDefaults_LosslessUnderSerialization pins the invariant that
|
||||||
|
// makes it safe for policy.SerializableChain to drop PolicyChain.Embedded on
|
||||||
|
// the wire (and for policy.InternalDecider.Allow to reconstruct a chain from
|
||||||
|
// AllowInput without it).
|
||||||
|
//
|
||||||
|
// The decision path consults chain.Embedded at two sites:
|
||||||
|
// - worm.go (WormZoneGrant): folds in a top-level chain.Embedded.Worm.
|
||||||
|
// - roles.go (lookupRoleMembers / pathRoles): reads chain.Embedded.Roles.
|
||||||
|
//
|
||||||
|
// Today both are no-ops only because the baked-in baseline (EmbeddedDefaults,
|
||||||
|
// the root of every cascade — internal/zddc/defaults/.zddc) declares no
|
||||||
|
// top-level `worm:` and no role members (WORM is declared via the `paths:`
|
||||||
|
// tree, which lands in chain.Levels, not chain.Embedded). If a future default
|
||||||
|
// adds either, those contributions would be SILENTLY ignored by an external
|
||||||
|
// OPA (it never receives Embedded) and by the InternalDecider over a
|
||||||
|
// serialized chain — an authz divergence with no error. This test fails loudly
|
||||||
|
// at that moment so the change is paired with serializing PolicyChain.Embedded
|
||||||
|
// (one field on SerializableChain) before it ships.
|
||||||
|
func TestEmbeddedDefaults_LosslessUnderSerialization(t *testing.T) {
|
||||||
|
e, err := EmbeddedDefaults()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EmbeddedDefaults: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Worm != nil {
|
||||||
|
t.Errorf("embedded baseline declares a top-level worm: %v — it is read at "+
|
||||||
|
"decision time (WormZoneGrant) but dropped by policy.SerializableChain, "+
|
||||||
|
"so external OPA and the reconstructed InternalDecider chain would "+
|
||||||
|
"silently ignore it. Declare WORM via paths: (→ chain.Levels) or "+
|
||||||
|
"serialize PolicyChain.Embedded.", e.Worm)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, role := range e.Roles {
|
||||||
|
if len(role.Members) > 0 {
|
||||||
|
t.Errorf("embedded baseline role %q has members %v — read at decision "+
|
||||||
|
"time (chain.Embedded.Roles in roles.go) but dropped by "+
|
||||||
|
"policy.SerializableChain. Keep embedded roles member-less or "+
|
||||||
|
"serialize PolicyChain.Embedded.", name, role.Members)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -200,10 +200,11 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
|
|
||||||
// Determine if this newly-created ancestor is an auto-own
|
// Determine if this newly-created ancestor is an auto-own
|
||||||
// position and whether it should be fenced (inherit: false).
|
// position and whether it should be fenced (inherit: false).
|
||||||
// Resolved via the .zddc cascade — internal/zddc/defaults/
|
// Resolved via the .zddc cascade — the embedded defaults
|
||||||
// carries the canonical "working/staging auto-own + per-user
|
// (internal/zddc/defaults/) declare auto_own at the working/
|
||||||
// homes fenced + incoming auto-own" convention, and any
|
// staging/ incoming/ reviewing/ <party> homes but do NOT fence
|
||||||
// on-disk .zddc can override per-directory.
|
// them (they are shared team folders); an on-disk .zddc can opt
|
||||||
|
// a directory into fencing per-directory with auto_own_fenced.
|
||||||
_ = parentSegs // depth-tracking no longer needed
|
_ = parentSegs // depth-tracking no longer needed
|
||||||
_ = i
|
_ = i
|
||||||
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
|
||||||
|
|
@ -222,12 +223,11 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
|
|
||||||
// Seed auto-own .zddc on the canonical positions that were freshly
|
// Seed auto-own .zddc on the canonical positions that were freshly
|
||||||
// created. Skip if no principal email is available (anonymous or
|
// created. Skip if no principal email is available (anonymous or
|
||||||
// system writes). The fenced variant is used at per-user home
|
// system writes). The fenced variant (inherit:false, private to the
|
||||||
// folders under working/ — private by default; owner can later
|
// creator) is an opt-in the default tree does not use — see
|
||||||
// edit the .zddc to add collaborators. Role grants (from the
|
// AutoOwnFencedAt. Role grants (from the cascade's auto_own_roles
|
||||||
// cascade's auto_own_roles list) are written alongside the
|
// list) are written alongside the creator email so role-level peer
|
||||||
// creator email so role-level peer authority survives without
|
// authority survives without needing a subtree-admin grant.
|
||||||
// needing a subtree-admin grant.
|
|
||||||
if principalEmail != "" {
|
if principalEmail != "" {
|
||||||
for _, c := range freshlyCreated {
|
for _, c := range freshlyCreated {
|
||||||
if !c.autoOwn {
|
if !c.autoOwn {
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,13 @@ import (
|
||||||
// (e.g. a vendor folder where only the vendor and the doc controller
|
// (e.g. a vendor folder where only the vendor and the doc controller
|
||||||
// should have access regardless of broader project-level grants).
|
// should have access regardless of broader project-level grants).
|
||||||
//
|
//
|
||||||
// Federal deployments running the bundled `access_federal.rego` get
|
// A deployment running an external OPA with ancestor-deny-absolute
|
||||||
// parent-deny-is-absolute / NIST AC-6 semantics; the directive's
|
// (NIST AC-6) semantics should avoid the directive's fence-style "reset",
|
||||||
// fence-style "reset" should be avoided there because it would let a
|
// since under that posture it would let a leaf widen access an ancestor
|
||||||
// leaf widen access an ancestor refused. The cascade tracer at
|
// refused. (zddc-server ships only the read-ACL skeleton at --print-rego;
|
||||||
// /.profile/effective-policy reports `chain.visible_start` so an
|
// an AC-6 policy is the operator's own Rego.) The cascade tracer at
|
||||||
// operator can verify which level a fence is actually cutting off.
|
// /.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 is per-level and not itself cascading: an ancestor's
|
||||||
// `inherit: false` does not transitively block descendants from
|
// `inherit: false` does not transitively block descendants from
|
||||||
|
|
@ -228,12 +229,15 @@ type ZddcFile struct {
|
||||||
// created. Empty (nil) inherits via cascade.
|
// created. Empty (nil) inherits via cascade.
|
||||||
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
|
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
|
||||||
|
|
||||||
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc
|
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc is
|
||||||
// is written with `inherit: false` so the new directory is
|
// written with `inherit: false` so the new directory is private to its
|
||||||
// private to its creator (ancestor ACL grants don't apply). Used
|
// creator (ancestor ACL grants don't cascade in). It is an OPT-IN an
|
||||||
// for per-user home folders under working/<email>/. Default
|
// operator can set on any auto_own position; the current embedded default
|
||||||
// (nil/false) writes a non-fenced auto-own .zddc — ancestor
|
// tree does NOT set it anywhere — the working/ staging/ incoming/
|
||||||
// admin grants still apply.
|
// reviewing/ <party> homes are auto-owned but UNFENCED, so ancestor grants
|
||||||
|
// (e.g. `project_team: cr` at working/) still cascade in, making them
|
||||||
|
// shared team folders rather than private per-user sandboxes. Default
|
||||||
|
// (nil/false) writes a non-fenced auto-own .zddc.
|
||||||
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
|
||||||
|
|
||||||
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
|
// AutoOwnRoles augments AutoOwn with role-level grants: when set,
|
||||||
|
|
|
||||||
|
|
@ -32,20 +32,21 @@ func WriteAutoOwnZddc(dir, principalEmail string, roles []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
|
||||||
// sets `acl.inherit: false` — fencing ancestor cascade grants. Used at
|
// sets `acl.inherit: false` — fencing ancestor cascade grants so the new
|
||||||
// per-user home folders under working/ where the convention is "private
|
// directory is private to its creator. This is the OPT-IN private-home form:
|
||||||
// by default; owner edits the file to add collaborators."
|
// the current embedded default tree does NOT use it (the working/staging/
|
||||||
|
// incoming/reviewing party homes are unfenced and shared — see AutoOwnFenced
|
||||||
|
// in file.go). An operator opts in by setting auto_own_fenced on a position.
|
||||||
//
|
//
|
||||||
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
|
// With the fence, an ancestor grant (e.g. `project_team: cr` at working/)
|
||||||
// authenticated users) would let any user read every other user's
|
// does NOT cascade in, so only the creator (and any roles passed below) can
|
||||||
// working subfolder via cascade — defeating the per-user sandbox.
|
// reach the directory.
|
||||||
//
|
//
|
||||||
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
|
// roles is the same as for WriteAutoOwnZddc — listed roles get rwcda
|
||||||
// alongside the creator, and like the creator grant they're INSIDE
|
// alongside the creator, and like the creator grant they're INSIDE
|
||||||
// the fence (only resolvable if the role is defined at this level or
|
// the fence (only resolvable if the role is defined at this level or
|
||||||
// in chain.Embedded, since ancestor role definitions are hidden by
|
// in chain.Embedded, since ancestor role definitions are hidden by
|
||||||
// inherit:false). Typically callers using the fenced variant pass nil
|
// inherit:false). Callers using the fenced variant typically pass nil roles.
|
||||||
// roles — per-user homes don't need peer authority.
|
|
||||||
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
|
func WriteAutoOwnZddcFenced(dir, principalEmail string, roles []string) error {
|
||||||
return writeAutoOwn(dir, principalEmail, true, roles)
|
return writeAutoOwn(dir, principalEmail, true, roles)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue