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

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

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

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

' + + '

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

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

Create Transmittal folder

' + + '

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

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

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

' + + '

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

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

Loading…

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

Loading…

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

Loading…

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

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

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

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

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

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

+ + + +
+

Verify your downloads

+

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

+
+
+

Public key

+

Download pubkey.pem

+

SHA-256 fingerprint of the DER-encoded SubjectPublicKeyInfo:

+
7766dc8cf963f32156ddcc96825c52ba0333ffe4c243ad54f9eaf26195b065ab
+

Verify the fingerprint after downloading:

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

Verify a download

+

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

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

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

+
+
+

Configure zddc-server to auto-verify

+

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

+
+
+

Env var (k8s, systemd, Docker)

+

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

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

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

+
+
+

Inline in root .zddc

+

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

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

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

+
+
+

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

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