Compare commits
6 commits
adcf5dedd6
...
320c5d09ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 320c5d09ab | |||
| e7f6334daa | |||
| 7fbe7867fd | |||
| b5aab81d31 | |||
| b34edcecac | |||
| f5cf79dc1c |
78 changed files with 3885 additions and 13975 deletions
36
AGENTS.md
36
AGENTS.md
|
|
@ -27,7 +27,7 @@
|
|||
./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|mdedit|landing|form|tables|browse
|
||||
sh tool/build.sh # archive|transmittal|classifier|landing|form|tables|browse
|
||||
|
||||
# Single-tool release (rare; prefer ./build alpha|beta|release so versions
|
||||
# don't drift between tools). Same flag form as before.
|
||||
|
|
@ -38,7 +38,7 @@ sh tool/build.sh --release [<version>|alpha|beta]
|
|||
npm test
|
||||
|
||||
# Test single tool
|
||||
npx playwright test tool # archive | transmittal | classifier | mdedit | form-safety | tables
|
||||
npx playwright test tool # archive | transmittal | classifier | browse | form-safety | tables
|
||||
|
||||
# Dev server (cache-busting HTTP, on port 8000)
|
||||
./dev-server start
|
||||
|
|
@ -60,7 +60,7 @@ because the bundle is complete, dangling-link errors mean a real bug.
|
|||
|
||||
## Architecture
|
||||
|
||||
Eight independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `mdedit`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below).
|
||||
Seven independent single-file HTML tools (`archive`, `transmittal`, `classifier`, `landing`, `form`, `tables`, `browse`). Each compiles to one self-contained `.html` in `dist/` with all CSS and JS inlined — most name their output `dist/tool.html`; `landing` writes `dist/index.html` (served at `/` by `zddc-server`). Tools share a small set of canonical helpers in `shared/` (filename parsing, ZDDC filter UI, theme, help) — see "Shared modules" below. `form` is the schema-driven renderer used by zddc-server's form-data system; `tables` is its read/aggregate counterpart, rendering a directory of YAML files as a sortable table whose rows click through to the form editor — discovered presence-based via `<name>.table.yaml` next to a sibling `<name>/` rows-dir (see "Form-data system" and "Tables system" below). `browse` is the file-tree navigator and also hosts the in-place markdown editor (`browse/js/preview-markdown.js`); the dedicated `mdedit/` tool has been retired.
|
||||
|
||||
```
|
||||
tool/
|
||||
|
|
@ -202,7 +202,7 @@ Format: `trackingNumber_revision (status) - title.extension`
|
|||
|
||||
- Feature-branch workflow; squash-merge feature branches to `main`
|
||||
- Conventional commits: `feat(archive): ...`, `fix(transmittal): ...`
|
||||
- Release tags: `<tool>-v<X.Y.Z>` per tool, all nine sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `mdedit-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
|
||||
- Release tags: `<tool>-v<X.Y.Z>` per tool, all eight sharing the same X.Y.Z on a coordinated cut (e.g. `archive-v0.0.8`, `transmittal-v0.0.8`, `classifier-v0.0.8`, `landing-v0.0.8`, `form-v0.0.8`, `tables-v0.0.8`, `browse-v0.0.8`, `zddc-server-v0.0.8`)
|
||||
- `dist/` is gitignored. Build artifacts (per-tool `dist/<tool>.html` and `dist/release-output/`) are NOT committed to this repo. Reproduce them from a tag with `./build release X.Y.Z`
|
||||
- Hand-edited website content lives in a separate Codeberg repo (`codeberg.org/VARASYS/ZDDC-website`, cloned at `~/src/zddc-website/`). Source-code commits go to `main` here; content commits go to that repo
|
||||
- Release artifacts live on the deploy host (`/srv/zddc/`), not in any git history. Use `./deploy` to publish
|
||||
|
|
@ -215,7 +215,7 @@ Format: `trackingNumber_revision (status) - title.extension`
|
|||
|
||||
| Artifact | Type | Layout |
|
||||
|---|---|---|
|
||||
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, mdedit, landing, form, tables, browse |
|
||||
| `<tool>_v<X.Y.Z>.html` | real, immutable | per-version HTML for each of archive, transmittal, classifier, landing, form, tables, browse |
|
||||
| `<tool>_v<X.Y>.html`, `<tool>_v<X>.html` | symlinks | partial-version pins |
|
||||
| `<tool>_<channel>.html` | symlink (or real bytes during active channel dev) | mutable channel mirror per tool, channel ∈ {stable, beta, alpha} |
|
||||
| `zddc-server_v<X.Y.Z>_<platform>` | real binary | per-version cross-compiled binary, platform ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe} |
|
||||
|
|
@ -284,7 +284,7 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent
|
|||
No install script. Two paths:
|
||||
|
||||
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
|
||||
|
||||
To override at any level, either:
|
||||
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
|
||||
|
|
@ -314,11 +314,27 @@ Use `git worktree` to run multiple agents on separate branches simultaneously wi
|
|||
- No runtime CDN loads. Every vendor library (jszip, docx-preview, xlsx, UTIF, Toast UI) is bundled at build time via `concat_files`. The dist HTML is fully self-contained — "ship the record player with the record."
|
||||
- Published payload stored in `<script id="transmittal-data" type="application/json">`
|
||||
|
||||
## mdedit-specific
|
||||
## Markdown editor (inside browse)
|
||||
|
||||
- `css/tailwind-utils.css` is a pre-generated static subset (~80 classes). Add new Tailwind classes here; do not re-run Tailwind.
|
||||
- Toast UI Editor v3.2.2 is bundled in `vendor/`; `template.html` loads it from CDN for dev convenience
|
||||
- `</` escaping is essential: `sed 's#</#<\\/#g'` runs on both app JS and vendor JS at build time
|
||||
The markdown editor lives at `browse/js/preview-markdown.js` and is mounted as the preview plugin for `.md`/`.markdown` files by `browse/js/preview.js`. The standalone `mdedit/` tool has been retired — `browse` is the editor.
|
||||
|
||||
- Toast UI Editor v3.2.2 is vendored at `shared/vendor/toastui-editor-all.min.js` and concatenated into `browse/dist/browse.html` at build time. No runtime CDN.
|
||||
- YAML front matter (`---\n…\n---`) is split off on load and edited in a dedicated `<textarea>` in the sidebar; on save it's recombined onto the body. Always present (no "empty pane") so authoring new FM is a single click.
|
||||
- In server mode (HTTP-backed file handles), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` and triggering a browser download. The buttons auto-save the dirty buffer first so the converted bytes reflect what's on screen.
|
||||
|
||||
## Server-side document conversion (`zddc/internal/convert`)
|
||||
|
||||
zddc-server can convert `.md` → DOCX/HTML/PDF on demand at `GET /<path>/foo.md?convert=docx|html|pdf`. Implementation:
|
||||
|
||||
- **Two upstream images, pulled on first use.** No custom image build. Operator just needs `podman` or `docker` installed; the runner passes `--pull=missing` so the first request pulls each image and subsequent requests use the local cache.
|
||||
- `docker.io/pandoc/latex:latest` — pandoc's official image, entrypoint `pandoc`. Used for MD → DOCX and MD → HTML. Override via `--convert-pandoc-image=` / `ZDDC_CONVERT_PANDOC_IMAGE` (e.g. switch to `docker.io/pandoc/core:latest` for a ~90% size reduction).
|
||||
- `docker.io/zenika/alpine-chrome:latest` — Zenika's Alpine + Chromium image, entrypoint `chromium-browser`. Used for HTML → PDF (the PDF flow is two-stage: pandoc image emits HTML using viewer-template.html, chromium image prints it). Override via `--convert-chromium-image=` / `ZDDC_CONVERT_CHROMIUM_IMAGE`.
|
||||
- Engine is podman preferred, docker fallback (`--convert-engine=` / `ZDDC_CONVERT_ENGINE` to override). No host pandoc or chromium needed.
|
||||
- Each conversion runs in a throw-away container with `--rm --pull=missing --network=none --read-only --tmpfs=/tmp:size=128m,exec --memory --cpus --pids-limit --cap-drop=ALL --security-opt=no-new-privileges --env=HOME=/tmp`. Resource caps via `--convert-mem-mib` (default 512), `--convert-cpus` (default "2"), `--convert-pids` (default 100), `--convert-timeout` (default 30s). `--user` is intentionally not set so each image uses its default (root for pandoc/latex, uid 1000 for alpine-chrome) — the other flags already provide strong isolation and overriding the user would break alpine-chrome's user-data-dir layout.
|
||||
- I/O via bind mount + stdin/stdout. Pandoc reads markdown from stdin, writes to stdout. The viewer template is bind-mounted read-only at `/tpl`. Chromium reads HTML from a read-write bind mount at `/pdf` and writes the PDF to the same mount; the host reads it back.
|
||||
- Output cached at `<dir>/.converted/<base>.<ext>` (hidden by the `.` prefix). mtime synced to source so the fast path is a stat-and-serve with no exec. PUT/DELETE/MOVE on the source `.md` purges the sidecars.
|
||||
- Per-project template variables (client/project/contractor/project_number) come from `.zddc` `convert:` cascade keys. Title/tracking_number/revision/status are derived from the filename via `zddc.ParseFilename`.
|
||||
- If neither podman nor docker is present, the endpoint serves 503 with a Retry-After. The rest of the server keeps working.
|
||||
|
||||
## Form-data system (`form/` + zddc-server form handler)
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ Website files (what `zddc.varasys.io` serves) live on a **separate Codeberg repo
|
|||
releases/ ← rsync'd from ~/src/zddc/dist/release-output/
|
||||
```
|
||||
|
||||
`<tool>` ∈ {archive, transmittal, classifier, mdedit, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
|
||||
`<tool>` ∈ {archive, transmittal, classifier, landing, form, tables, browse}. `<platform>` ∈ {linux-amd64, darwin-amd64, darwin-arm64, windows-amd64.exe}.
|
||||
|
||||
Every URL under `/releases/` resolves directly via the symlink chain — no `manifest.json`, no Caddy regex-rewrite, no JavaScript indirection, no third-party mirror. Caddy serves these as plain static files. The Docker-tag pattern: `:1.2.3` is pinned, `:1.2` floats, `:1` floats further, `:stable` floats furthest, and `:beta` / `:alpha` are mutable channel mirrors that overwrite in place.
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ Two orthogonal axes: how the bytes get there (this section), and what runtime mo
|
|||
|---------------|-------------------------------------------------------------------------|
|
||||
| `archive` | every directory (multi-project, project, archive, vendor) |
|
||||
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
|
||||
| `mdedit` | any `Working` directory and its subtree |
|
||||
| `browse` | every directory (hosts the markdown editor as a preview plugin) |
|
||||
| `transmittal` | any `Staging` directory and its subtree |
|
||||
| `landing` | only at the deployment root |
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ The `X-ZDDC-Source` response header always reports what was served: `fetch:URL`,
|
|||
|
||||
### Runtime mode detection
|
||||
|
||||
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `mdedit` work the same regardless of where they live.
|
||||
Independent of how the tool got installed. `archive` auto-detects from the URL and folder shape (`?projects=` set → multi-project; scan root has an `archive/` child → project-root; otherwise → in-archive). The other tools don't care — `transmittal`, `classifier`, `browse` work the same regardless of where they live.
|
||||
|
||||
### Build Script Requirements
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ sed 's#</#<\\/#g' "$input_js" > "$safe_js"
|
|||
|
||||
Then use `</script>` (not `<\/script>`) to close the `<script>` block, since the content no longer contains any `</` sequences that the parser could misread.
|
||||
|
||||
This is already enforced for mdedit's vendor bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
|
||||
This is already enforced for browse's Toast UI bundling. It is the contributor's responsibility to ensure new tools follow this pattern.
|
||||
|
||||
### Vendor Dependencies
|
||||
|
||||
|
|
@ -208,24 +208,25 @@ Some tools bundle third-party libraries. These live in `tool/vendor/` and are co
|
|||
|
||||
| Tool | Library | File | Notes |
|
||||
|------|---------|------|-------|
|
||||
| mdedit | Toast UI Editor v3.2.2 | `vendor/toastui-editor-all.min.js` | Markdown editor with live preview |
|
||||
| mdedit | Toast UI Editor CSS | `vendor/toastui-editor.min.css` | Editor stylesheet |
|
||||
| browse | Toast UI Editor v3.2.2 | `shared/vendor/toastui-editor-all.min.js` | Markdown editor (loaded by `browse/js/preview-markdown.js`) |
|
||||
| browse | Toast UI Editor CSS | `shared/vendor/toastui-editor.min.css` | Editor stylesheet |
|
||||
| shared | jszip | `shared/vendor/jszip.min.js` | ZIP read for previews + classifier hash-export |
|
||||
| shared | docx-preview | `shared/vendor/docx-preview.min.js` | DOCX preview |
|
||||
| shared | xlsx (SheetJS) | `shared/vendor/xlsx.full.min.js` | XLSX/XLS preview |
|
||||
| shared | UTIF | `shared/vendor/utif.min.js` | TIFF preview |
|
||||
|
||||
**No runtime CDN loads.** Every external dependency is vendored into
|
||||
`shared/vendor/` (or, for mdedit's editor, `mdedit/vendor/`) and
|
||||
concatenated into each tool's bundle at build time. Tools that need a
|
||||
given library include the vendor path in their `build.sh`'s
|
||||
`concat_files` JS list. The "ship the record player with the record"
|
||||
philosophy: a downloaded `.html` file works offline against any file
|
||||
the user can open, with no network dependency at runtime.
|
||||
`shared/vendor/` and concatenated into each tool's bundle at build
|
||||
time. Tools that need a given library include the vendor path in
|
||||
their `build.sh`'s `concat_files` JS list. The "ship the record
|
||||
player with the record" philosophy: a downloaded `.html` file works
|
||||
offline against any file the user can open, with no network
|
||||
dependency at runtime.
|
||||
|
||||
Trade-off accepted: bundle sizes are larger. archive, classifier,
|
||||
transmittal land around 1.5 MB after gzip; mdedit lands around 2 MB
|
||||
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF.
|
||||
transmittal land around 1.5 MB after gzip; browse lands around 2 MB
|
||||
because it carries Toast UI + jszip + docx-preview + xlsx + UTIF
|
||||
for the in-place markdown editor and the preview pane.
|
||||
Justified by the offline-first guarantee: any tool downloaded from
|
||||
`/releases/` works without network, against air-gapped archives,
|
||||
forever. See ARCHITECTURE.md § "Why Single-File HTML Applications"
|
||||
|
|
@ -244,7 +245,7 @@ dependency inlined. No CDN URLs survive into the dist.
|
|||
| Development | CDN (live, from `template.html`) | Open `template.html` directly in Chromium |
|
||||
| Production | Bundled / Static CSS | Run `bash tool/build.sh`, open `dist/tool.html` |
|
||||
|
||||
For mdedit specifically: `template.html` loads Toast UI from CDN and uses Tailwind Play CDN. The build replaces Toast UI with the bundled vendor file and replaces the Tailwind CDN script with the static `css/tailwind-utils.css` subset.
|
||||
For browse specifically: `template.html` loads Toast UI from CDN for dev convenience. The build replaces it with the bundled vendor file (`shared/vendor/toastui-editor-all.min.js`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -290,7 +291,7 @@ main.js ← Initialization (depends on all modules)
|
|||
|
||||
### State Management
|
||||
|
||||
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, mdedit, browse, form, tables), and it doesn't hide control flow.
|
||||
Three patterns coexist. **For new tools, prefer the first one** — direct mutation on `window.app` with explicit re-render. It's debuggable, it's the most common pattern in this codebase (archive, browse, form, tables), and it doesn't hide control flow.
|
||||
|
||||
**1. Direct mutation on `window.app` + explicit re-render** *(recommended for new tools)*
|
||||
|
||||
|
|
@ -301,7 +302,7 @@ window.app.files.push(newFile);
|
|||
window.app.modules.table.render();
|
||||
```
|
||||
|
||||
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, mdedit, browse, form, tables, landing.
|
||||
State is read directly. Mutations trigger explicit `render()` calls — no auto-tracking, no surprise updates. Used by archive, browse, form, tables, landing.
|
||||
|
||||
**2. Pub-sub store on top of #1** (classifier)
|
||||
|
||||
|
|
@ -392,24 +393,17 @@ Files at the root level are ignored. The grouping folder list and transmittal fo
|
|||
|
||||
---
|
||||
|
||||
### Markdown Editor (mdedit)
|
||||
### Markdown Editor (browse preview plugin)
|
||||
|
||||
**Pattern:** Global functions (`window.updateToc`), editor instances managed per file-path in a `Map`, File System Access API for direct file read/write.
|
||||
**Lives at:** `browse/js/preview-markdown.js`, registered on `window.app.modules.markdown` and invoked by `browse/js/preview.js` for `.md`/`.markdown` files. The standalone `mdedit/` tool was retired in favour of this plugin.
|
||||
|
||||
**Dependencies:** Toast UI Editor v3.2.2 (bundled), Tailwind utility subset (static CSS).
|
||||
**Pattern:** Editor instances per-file (constructed by `render(node, container, ctx)`, disposed by `dispose()`). CSS Grid layout for the shell — sidebar (FM textarea on top, outline below) on the left, content (info header + Toast UI editor) on the right.
|
||||
|
||||
**Toast UI availability check:**
|
||||
**Front matter:** Parsed off the file on load by `parseFrontMatter()` (a small `---\n…\n---` parser); the FM body goes into a sidebar `<textarea>`, the markdown body into the Toast UI editor. On save, `assembleContent()` recombines them with the envelope on top. The textarea is always present so authoring brand-new FM is a single click; dirty tracking covers both halves via a SHA-256 hash of the assembled bytes.
|
||||
|
||||
```javascript
|
||||
if (typeof toastui === 'undefined') {
|
||||
// Graceful degradation — show error message
|
||||
}
|
||||
const editor = new toastui.Editor({ el: container, ... });
|
||||
```
|
||||
**Dependencies:** Toast UI Editor v3.2.2 (vendored at `shared/vendor/toastui-editor-all.min.js`, concatenated into `browse/dist/browse.html` at build time). No runtime CDN, no Tailwind.
|
||||
|
||||
**Key DOM IDs:** `#app`, `#select-directory`, `#welcome-screen`, `#file-tree`, `#content-container`.
|
||||
|
||||
**File tree:** Populated after `showDirectoryPicker()` resolves. File items are rendered as DOM children of `#file-tree`. Clicking a file opens it in the editor panel.
|
||||
**Server-mode features:** When the file handle is an `HttpFileHandle` (so `node.url` is set and `state.source === 'server'`), three Download buttons appear in the file header — DOCX/HTML/PDF — fetching `?convert=<fmt>` via `window.zddc.source.downloadConverted()`. Clicks auto-save first if the buffer is dirty so converted bytes reflect what's on screen. See `zddc/internal/convert` for the server-side engine.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -504,8 +498,8 @@ none of them is load-bearing alone.
|
|||
|---|---|---|
|
||||
| Authentication | Establish caller identity (email) | Two paths: `Authorization: Bearer <token>` validated against `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` (CLI / scripted callers); or `X-Auth-Request-Email` injected by an upstream auth proxy (browser users). Token system is built-in and self-issuing — no external IDP required |
|
||||
| Policy decider | Yield an allow/deny verdict for (identity, path, chain) | Pluggable via `ZDDC_OPA_URL`: in-process Go evaluator (default) or external OPA-compatible HTTP/socket endpoint. `zddc/internal/policy/` |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, walked deepest-first first-match-wins under `--cascade-mode=delegated` or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/acl.go`, `cascade.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
|
||||
| Special folders | Codify the bilateral exchange-record archetype | `Incoming`/`Working`/`Staging` get auto-ownership on mkdir (creator gets `rwcda` via an auto-written `.zddc`); `Issued`/`Received` enforce a server-side WORM split (ancestor grants masked to `r`; only an explicit `.zddc` at-or-below the WORM folder can grant `c` for a write-once drop-box). Admins exempt. `zddc/internal/zddc/special.go` |
|
||||
| ACL cascade | The default decider's rule set | Per-directory `.zddc` YAML with verb-set permissions (`r`/`w`/`c`/`d`/`a`) and roles, plus a baked-in `defaults.zddc.yaml` bottom layer (`zddc-server show-defaults`) that uses a recursive `paths:` tree to describe subfolder rules even before those folders exist. Walked deepest-first first-match-wins under `--cascade-mode=delegated`, or with absolute ancestor denies under `--cascade-mode=strict` (`zddc/internal/zddc/cascade.go`, `walker.go`, `acl.go`). External OPA can replace this rule set with arbitrary Rego while keeping the same `.zddc` files as input data |
|
||||
| Canonical-folder behaviour | Codify the bilateral exchange-record archetype | All driven by `.zddc` keys (baked into `defaults.zddc.yaml`): `auto_own:` / `auto_own_fenced:` — mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`; fenced adds `acl.inherit:false`); `worm: [principal…]` — write-once-read-many (`w`/`d`/`a` stripped for everyone non-admin, `c` survives only for the listed principals; admins exempt); `virtual:` — never materialise on disk; `drop_target:` — browse shows a drag-drop upload overlay. The defaults put `auto_own` on `working`/`staging`/`archive-party`/`incoming` and `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical convention is unchanged — but an operator can reshape it (rename `received`/`issued`, mark any path WORM, …) without a code change. `zddc/internal/zddc/lookups.go`, `worm.go`, `roles.go`; `defaults.zddc.yaml` |
|
||||
| Tool-rooted view | Make the caller's accessible subtree feel like their entire world (UX containment) | Archive auto-served at every directory; the URL it's served at *is* its root. No breadcrumb leads above |
|
||||
| URL canonicalization | Resolve URL paths to on-disk casing before any layer below sees them | `zddc/internal/fs/resolve.go ResolveCanonical` — case-insensitive lookup with lowercase-wins tiebreak when sibling case variants exist on disk. File and folder names preserve case on disk; the canonicalization is purely URL→FS-name mapping. Virtual prefixes (`.archive`, `.profile`, `.tokens`) flow through verbatim |
|
||||
| Reserved hidden prefixes | Hide operator side-state (caches, dev-shell home dirs) from listings and direct fetch | `.`-prefixed → 404 + listing-filtered; `_`-prefixed → listing-filtered only |
|
||||
|
|
@ -709,7 +703,7 @@ The schema keys that drive built-in behavior:
|
|||
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
|
||||
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
|
||||
|
||||
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
|
||||
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor plugin), `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
|
||||
|
||||
**Zip-backed directories.** A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON listing of the zip's members (or the browse SPA for an HTML request) and `GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member — so a client navigating a zipped transmittal folder never downloads the whole archive. `GET …/Foo.zip` (no trailing slash) is unchanged: the raw `.zip` download. Read-only: `PUT`/`DELETE`/`POST` to a path inside a `.zip` is rejected (405). ACL is the chain of the directory *containing* the zip — a zip carries no `.zddc` of its own, the same model as the `.archive` virtual surface. Implemented by `internal/zipfs` + `handler.ServeZip`, routed via `splitZipPath` in the dispatcher (before the file-API branch). Offline tools (archive's scanner, browse's tree) get the same capability client-side via `shared/zip-source.js` — a `ZipDirectoryHandle`/`ZipFileHandle` pair over JSZip that mimics the File-System-Access surface. The archive tool treats a `.zip` whose name minus `.zip` parses as a transmittal-folder name as that transmittal folder; the browse tool expands *any* `.zip`.
|
||||
|
||||
|
|
@ -734,7 +728,7 @@ zddc-server exposes write methods on the same URL space as GET. Each method maps
|
|||
|
||||
Writes use `WriteAtomic` (temp file → fsync → rename) for partial-write safety. Move uses `os.Rename` for same-FS atomicity. Body size capped by `--max-write-bytes` (default 256 MiB). Reserved hidden segments (`.`-prefixed, `_app`, `_template`) are 404'd uniformly with the read path. Every write logs a structured `file_write` event (op, path, email, status, bytes) into the same audit stream as access logs.
|
||||
|
||||
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, mdedit, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
|
||||
Browser clients reach the API through `shared/zddc-source.js` — an FS Access API polyfill (`HttpDirectoryHandle`, `HttpFileHandle`) that lets tools written against `showDirectoryPicker()` work unchanged when served by zddc-server. classifier, transmittal, and browse auto-detect HTTP mode at startup, build a polyfill handle for `location.pathname`'s directory, and skip the file picker entirely. A 403 on the initial listing surfaces a "no permission to list this directory" message instead of the welcome screen.
|
||||
|
||||
#### `zddc-source.js` known gaps
|
||||
|
||||
|
|
@ -770,19 +764,15 @@ out-of-the-box behavior with no per-deployment configuration.
|
|||
|
||||
## CSS Architecture
|
||||
|
||||
All tools use vanilla CSS. No frameworks at build time (mdedit's Tailwind utilities are pre-generated static CSS).
|
||||
All tools use vanilla CSS. No frameworks at build time.
|
||||
|
||||
**Common conventions:**
|
||||
|
||||
- CSS variables for theme colors and spacing in `base.css`
|
||||
- Component-scoped class names (no global utilities except where Tailwind provides them)
|
||||
- Component-scoped class names
|
||||
- `.hidden` class uses `display: none !important` for JavaScript show/hide
|
||||
- Print styles in a separate `print.css`
|
||||
|
||||
**mdedit Tailwind subset:**
|
||||
|
||||
`css/tailwind-utils.css` contains only the ~80 Tailwind v3 utility classes actually used in `template.html`. If a new utility class is needed in the template, add it here. Classes follow Tailwind v3 naming and values exactly.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
|
@ -805,7 +795,7 @@ Each tool has a spec file in `tests/`:
|
|||
tests/
|
||||
archive.spec.js ← 2 tests: load + directory scan
|
||||
classifier.spec.js ← 2 tests: load + store injection
|
||||
mdedit.spec.js ← 2 tests: load + file tree render
|
||||
browse.spec.js ← load + file tree render + markdown editor mount
|
||||
transmittal.spec.js ← 2 tests: paste round-trip + filesystem round-trip
|
||||
fixtures/
|
||||
mock-fs-api.js ← Reusable File System Access API mock
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ If something in this CLAUDE.md conflicts with those, those win — and please up
|
|||
|
||||
This is a **monorepo of independent tools**, not one application:
|
||||
|
||||
- `archive/`, `transmittal/`, `classifier/`, `mdedit/`, `landing/`, `form/`, `tables/`, `browse/` — eight self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
|
||||
- `archive/`, `transmittal/`, `classifier/`, `landing/`, `form/`, `tables/`, `browse/` — seven self-contained HTML tools, each compiled to a single inlined HTML file in its own `dist/`. Most output `dist/tool.html`; **`landing/` outputs `dist/index.html`** (it's the project picker served at the root of `zddc-server`). `form/` is the schema-driven renderer for the form-data system (any `<name>.form.yaml` file in the tree becomes an editable form at `<path>/<name>.form.html`); `tables/` is its read/aggregate counterpart, rendering a directory of YAML rows as a sortable table; `browse/` is the file-tree navigator and **also hosts the in-place markdown editor** (`browse/js/preview-markdown.js` — Toast UI Editor + YAML front-matter pane + on-demand server-side MD→DOCX/HTML/PDF download buttons). A dedicated `mdedit/` tool used to live alongside these but has been retired. See AGENTS.md "Form-data system" / "Tables system" and ARCHITECTURE.md "Form Renderer".
|
||||
- `zddc/` — Go HTTP server (separate sub-project; Go 1.24+). Two deployment shapes from the same binary: (1) **master** — owns a file tree under `ZDDC_ROOT`, applies `.zddc` ACL cascades, serves files / app HTML / archive listings. Two auth paths on master: `Authorization: Bearer <token>` validated against self-issued tokens at `<ZDDC_ROOT>/.zddc.d/tokens/<sha256-hex>` for CLI/scripted callers, or `X-Auth-Request-Email` injected by an upstream proxy for browser sessions. Self-service token UI at `/.tokens` + JSON API at `/.api/tokens`. (2) **client** — when `--upstream <url>` is set, the binary becomes a downstream proxy/cache/mirror (`zddc/internal/cache/`); master-side machinery is bypassed and `--root` becomes the cache directory. Three sub-modes via `--mode proxy|cache|mirror` (mirror is phase 3). Cache layout is a normal ZDDC root, so the cache dir can be served as a plain master if you unset `--upstream`. Marker file `.zddc-upstream` records provenance. `--no-auth` skips ACL enforcement entirely on this instance (distinct from `--insecure` which only relaxes the no-root-`.zddc` startup check); `--skip-tls-verify` is a separate flag for self-signed upstream certs. Cross-compiled binaries are produced by `./build` and live in `dist/release-output/` (gitignored); `./deploy` rsyncs them to `/srv/zddc/releases/` on the deploy host (Caddy serves them at `https://zddc.varasys.io/releases/`). The `helm/` charts in this repo build from source at deploy time.
|
||||
- `shared/` — `base.css` plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `theme.js`, `help.js`) included by every tool's build, and `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers).
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all eight HTML tools baked in via `//go:embed` (compile-time default). Tools auto-served at folder-name-driven paths: `archive` everywhere, `classifier` in `Incoming`/`Working`/`Staging` subtrees, `mdedit` in `Working` subtrees, `transmittal` in `Staging` subtrees, `landing` only at root. Override via `.zddc apps:` cascade entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`. Drop a real `.html` file at any path to override.
|
||||
- `shared/` — CSS (`base.css`, `fonts.css` + base64-inlined woff2 under `fonts/`, `nav.css`, `logo.css`, `toast.css`) plus shared JS modules (`zddc.js`, `hash.js`, `zddc-filter.js`, `zddc-source.js`, `zip-source.js`, `theme.js`, `toast.js`, `nav.js`, `logo.js`, `help.js`, `preview-lib.js`) and vendored libs (`vendor/`: jszip, xlsx, utif, docx-preview, toastui-editor) — each tool's `build.sh` concatenates the subset it needs. Also `build-lib.sh` (POSIX sh helpers sourced by every tool's `build.sh` AND by the top-level `build` for lockstep release helpers). See AGENTS.md "Shared modules" for the full inventory.
|
||||
- **Two-repo + deploy-host model.** Source code lives here (`codeberg.org/VARASYS/ZDDC`). Hand-edited website content lives in a separate repo (`codeberg.org/VARASYS/ZDDC-website`, typically cloned at `~/src/zddc-website/` — just `index.html`, `reference.html`, `css/`, `js/`, `img/`; no releases, no LFS). The live site at `zddc.varasys.io` is served from `/srv/zddc/` on the deploy host: Caddy bind-mounts that path, and it's populated by `./deploy` from this repo's `dist/release-output/` plus `~/src/zddc-website/`. **Releases are NOT in any git history** — they're reproducible from this repo's `<tool>-vX.Y.Z` tags by checking out the tag and running `./build release X.Y.Z`. Per-version files (`<tool>_v<X.Y.Z>.html`) are immutable; partial-version pins (`<tool>_v<X.Y>.html`, `<tool>_v<X>.html`) and channel mirrors (`<tool>_{stable,beta,alpha}.html`) are symlinks; zddc-server has analogous `zddc-server_v<X.Y.Z>_<platform>` per-version binaries plus channel/partial-version symlinks plus `zddc-server_<X>.html` stub pages that fan out the four-platform download in one cell. **Install model:** local use is a download from `/releases/`. Server use is `zddc-server`, which has the current-stable build of all seven HTML tools baked in via `//go:embed` (compile-time default). **Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded folder names** — a baked-in `defaults.zddc.yaml` (dump it: `zddc-server show-defaults`) declares, via a recursive `paths:` tree, per-folder `default_tool` (served at `<dir>` — `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (hosts the markdown editor), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root), `dir_tool` (served at `<dir>/`; defaults to `browse`), `available_tools`, plus the canonical-folder behaviour keys (`auto_own`, `worm:`, `virtual`, `drop_target`); operators override at any level. 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* via a `.zddc apps:` entry (channel/version/URL/path) — fetched once, cached at `<ZDDC_ROOT>/_app/`; or drop a real `.html` at any path. 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,12 @@ The name "Zero Day Document Control" comes from the convention itself — adopt
|
|||
| **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. |
|
||||
| **Markdown Editor** | Browser-based markdown editor with YAML front matter, TOC, and direct local file access. |
|
||||
| **Form Renderer** | Schema-driven `*.form.yaml` editor — every form spec auto-mounts an editable form at `<name>.form.html`. |
|
||||
| **Tables** | Sortable, filterable, in-place-editable grid view over a directory of YAML rows; click a row → edit in the form renderer. Auto-mounts on any directory containing a `table.yaml`. |
|
||||
| **Browse** | File-tree navigator with previews; the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
|
||||
| **Browse** | File-tree navigator with previews and an in-place markdown editor (YAML front matter, outline, server-side DOCX/HTML/PDF download); the everywhere-available companion to the Archive Browser when you want plain folder navigation rather than tracking-number aggregation. |
|
||||
| **Landing** | The project picker served at the deployment root of a `zddc-server`. |
|
||||
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Tools auto-appear at folder-name-driven paths (archive everywhere; classifier in `Incoming`/`Working`/`Staging`; mdedit in `Working`; transmittal in `Staging`). Override per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path). URL overrides are fetched once and cached in `<ZDDC_ROOT>/_app/`; drop a real `.html` file at any path to override entirely.
|
||||
Each tool is published in three channels (stable, beta, alpha) as static files served from <https://zddc.varasys.io/releases/>. **Local use:** download a `.html` file from `releases/` and open it in a browser. **Server use:** run `zddc-server` — the current-stable build of every tool is baked into the binary at compile time, so a fresh deployment Just Works with zero config. Which tool a directory URL serves is driven by the `.zddc` cascade: a baked-in `defaults.zddc.yaml` (dump it with `zddc-server show-defaults`) declares, per folder, `default_tool` (the no-slash form — archive under `archive/`, transmittal under `staging/`, browse under `working/`+`reviewing/` (browse hosts the in-place markdown editor), classifier under `incoming/`, tables at `archive/<party>/mdl`, landing at root) and `dir_tool` (the trailing-slash form; defaults to `browse`); operators override at any level. A `.zip` file is also a navigable directory (`GET …/Foo.zip/`), and `GET /dir/?zip=1` streams an ACL-filtered zip of a subtree. Override the *tool source* per-directory by writing an `apps:` entry in any `.zddc` file (channel/version/URL/path) — fetched once and cached in `<ZDDC_ROOT>/_app/` — or drop a real `.html` file at any path.
|
||||
|
||||
## File-naming convention
|
||||
|
||||
|
|
|
|||
|
|
@ -538,6 +538,16 @@ html, body {
|
|||
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. */
|
||||
|
|
@ -623,34 +633,41 @@ html, body {
|
|||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Front matter list ──────────────────────────────────────────────────── */
|
||||
.md-fm__empty {
|
||||
/* ── Front matter editor ────────────────────────────────────────────────── */
|
||||
.md-fm__body {
|
||||
/* Body cell owns the textarea; sized by the sidebar's grid row. */
|
||||
padding: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-fm__textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
resize: none;
|
||||
outline: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
.md-fm__textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.md-fm__list {
|
||||
margin: 0;
|
||||
padding: 0.3rem 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(4.5rem, max-content) 1fr;
|
||||
gap: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
.md-fm__textarea:focus {
|
||||
background: var(--surface-2, rgba(0, 0, 0, 0.025));
|
||||
}
|
||||
.md-fm__list dt {
|
||||
font-weight: 600;
|
||||
.md-fm__textarea[readonly] {
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md-fm__list dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort control ────────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -2,22 +2,31 @@
|
|||
//
|
||||
// Layout (CSS Grid):
|
||||
// ┌─────────────────────────────────────────────────────────────────┐
|
||||
// │ toolbar: Save | ● modified | status | source │
|
||||
// ├────────────────────────────────────────┬────────────────────────┤
|
||||
// │ │ Outline │
|
||||
// │ │ • Heading 1 │
|
||||
// │ Toast UI Editor │ • Subheading │
|
||||
// │ (md / wysiwyg / preview) │ • Heading 2 │
|
||||
// │ ├────────────────────────┤
|
||||
// │ │ Front matter │
|
||||
// │ │ title: Foo │
|
||||
// │ │ revision: A │
|
||||
// └────────────────────────────────────────┴────────────────────────┘
|
||||
// │ 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 <textarea> in the sidebar
|
||||
// (always present — typing into the placeholder grows the envelope on
|
||||
// save). On load the `---\n…\n---\n` envelope is stripped from the
|
||||
// bytes fed to Toast UI; on save the textarea content is re-stitched
|
||||
// on top of the editor body. Keeps YAML out of the rich editor where
|
||||
// users can't reliably edit it.
|
||||
//
|
||||
// Save (Ctrl+S) writes back via PUT (server mode) or
|
||||
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
|
||||
// read-only — Save stays disabled. Toast UI is vendored
|
||||
|
|
@ -93,25 +102,37 @@
|
|||
return { data: data, body: body };
|
||||
}
|
||||
|
||||
function renderFrontMatter(fmEl, content) {
|
||||
if (!fmEl) return;
|
||||
var parsed = parseFrontMatter(content);
|
||||
var keys = Object.keys(parsed.data);
|
||||
if (keys.length === 0) {
|
||||
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<dl class="md-fm__list">';
|
||||
// Inverse of parseFrontMatter — turn a {key: value | array} object back
|
||||
// into newline-separated YAML lines suitable for the textarea. Arrays
|
||||
// are quoted to match what the parser will round-trip through. Returns
|
||||
// "" when there are no keys (so the textarea shows its placeholder).
|
||||
function stringifyFrontMatter(data) {
|
||||
if (!data) return '';
|
||||
var keys = Object.keys(data);
|
||||
if (keys.length === 0) return '';
|
||||
var out = [];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var k = keys[i];
|
||||
var v = parsed.data[k];
|
||||
var displayV = Array.isArray(v)
|
||||
? v.map(escapeHtml).join(', ')
|
||||
: escapeHtml(String(v));
|
||||
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
|
||||
var v = data[k];
|
||||
if (Array.isArray(v)) {
|
||||
out.push(k + ': [' + v.map(function (x) {
|
||||
return '"' + String(x).replace(/"/g, '\\"') + '"';
|
||||
}).join(', ') + ']');
|
||||
} else {
|
||||
out.push(k + ': ' + String(v));
|
||||
}
|
||||
}
|
||||
html += '</dl>';
|
||||
fmEl.innerHTML = html;
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Stitch the textarea's YAML lines and the editor's body back together
|
||||
// into the on-disk envelope. Empty textarea → return body unchanged
|
||||
// (no envelope written). Trailing whitespace in the textarea is
|
||||
// tolerated.
|
||||
function assembleContent(fmText, body) {
|
||||
var fm = (fmText || '').replace(/\s+$/, '');
|
||||
if (!fm) return body || '';
|
||||
return '---\n' + fm + '\n---\n' + (body || '');
|
||||
}
|
||||
|
||||
// ── TOC (table of contents) ────────────────────────────────────────────
|
||||
|
|
@ -337,6 +358,13 @@
|
|||
fmHeader.textContent = 'YAML front matter';
|
||||
var fmBody = document.createElement('div');
|
||||
fmBody.className = 'md-side__body md-fm__body';
|
||||
var fmTextarea = document.createElement('textarea');
|
||||
fmTextarea.className = 'md-fm__textarea';
|
||||
fmTextarea.spellcheck = false;
|
||||
fmTextarea.autocapitalize = 'off';
|
||||
fmTextarea.autocomplete = 'off';
|
||||
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
|
||||
fmBody.appendChild(fmTextarea);
|
||||
fmSection.appendChild(fmHeader);
|
||||
fmSection.appendChild(fmBody);
|
||||
sidebar.appendChild(fmSection);
|
||||
|
|
@ -408,10 +436,35 @@
|
|||
sourceEl.textContent = 'server';
|
||||
}
|
||||
|
||||
// Download-as-{docx,html,pdf} buttons. Server-mode + .md only:
|
||||
// the server endpoint runs pandoc/chromium in a container and
|
||||
// returns the converted bytes. Click handlers wire up below
|
||||
// (after save() is defined) because they auto-save first when
|
||||
// the buffer is dirty.
|
||||
var serverModeMd = window.app && window.app.state &&
|
||||
window.app.state.source === 'server' &&
|
||||
node.url && /\.md$/i.test(node.name);
|
||||
var convertBtns = [];
|
||||
if (serverModeMd && window.zddc && window.zddc.source &&
|
||||
typeof window.zddc.source.downloadConverted === 'function') {
|
||||
['docx', 'html', 'pdf'].forEach(function (fmt) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-secondary md-shell__download';
|
||||
btn.type = 'button';
|
||||
btn.textContent = fmt.toUpperCase();
|
||||
btn.title = 'Download as ' + fmt.toUpperCase();
|
||||
btn.dataset.fmt = fmt;
|
||||
convertBtns.push(btn);
|
||||
});
|
||||
}
|
||||
|
||||
infohdr.appendChild(titleEl);
|
||||
infohdr.appendChild(dirtyEl);
|
||||
infohdr.appendChild(statusEl);
|
||||
infohdr.appendChild(sourceEl);
|
||||
for (var ci = 0; ci < convertBtns.length; ci++) {
|
||||
infohdr.appendChild(convertBtns[ci]);
|
||||
}
|
||||
infohdr.appendChild(saveBtn);
|
||||
content.appendChild(infohdr);
|
||||
|
||||
|
|
@ -420,15 +473,21 @@
|
|||
editorHost.className = 'md-shell__editor';
|
||||
content.appendChild(editorHost);
|
||||
|
||||
// Construct the editor. height: 100% works because editorHost
|
||||
// is a grid cell with a definite size.
|
||||
var initialHash = await hashContent(text);
|
||||
// Split the loaded bytes into FM (textarea) + body (editor). The
|
||||
// hash that gates dirty-state is taken over the reassembled
|
||||
// bytes so that round-tripping a clean file shows "not dirty"
|
||||
// even if we tweak whitespace in the YAML lines.
|
||||
var initialParsed = parseFrontMatter(text);
|
||||
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
||||
var bodyText = initialParsed.body;
|
||||
|
||||
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
|
||||
var editor = new window.toastui.Editor({
|
||||
el: editorHost,
|
||||
height: '100%',
|
||||
initialEditType: 'markdown',
|
||||
previewStyle: 'vertical',
|
||||
initialValue: text,
|
||||
initialValue: bodyText,
|
||||
usageStatistics: false,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
|
|
@ -446,17 +505,17 @@
|
|||
node: node,
|
||||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
fmEl: fmBody
|
||||
fmEl: fmTextarea
|
||||
};
|
||||
|
||||
var writable = canSave(node);
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
fmTextarea.readOnly = true;
|
||||
}
|
||||
|
||||
renderToc(tocBody, text, editor);
|
||||
renderFrontMatter(fmBody, text);
|
||||
renderToc(tocBody, bodyText, editor);
|
||||
|
||||
// ── Sidebar/content resizer ─────────────────────────────────────────
|
||||
// Sidebar is on the LEFT now. Dragging right grows the
|
||||
|
|
@ -552,18 +611,24 @@
|
|||
}
|
||||
|
||||
var onChange = debounce(async function () {
|
||||
var current = editor.getMarkdown();
|
||||
var h = await hashContent(current);
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
renderToc(tocBody, current, editor);
|
||||
renderFrontMatter(fmBody, current);
|
||||
renderToc(tocBody, body, editor);
|
||||
}, 250);
|
||||
editor.on('change', onChange);
|
||||
|
||||
var onFmChange = debounce(async function () {
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
}, 250);
|
||||
fmTextarea.addEventListener('input', onFmChange);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!currentInstance.dirty || !writable) return;
|
||||
var content = editor.getMarkdown();
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
|
|
@ -587,6 +652,42 @@
|
|||
save();
|
||||
}
|
||||
});
|
||||
|
||||
// Download-as-* click handlers. Auto-save when the buffer is
|
||||
// dirty so the converted file reflects what's on screen. If
|
||||
// the save fails the existing toast/status surfaces it; we
|
||||
// bail without firing the conversion.
|
||||
convertBtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
var fmt = btn.dataset.fmt;
|
||||
if (currentInstance.dirty) {
|
||||
if (!writable) {
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast(
|
||||
'This source is read-only — save a copy elsewhere first.',
|
||||
'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try { await save(); } finally { btn.disabled = false; }
|
||||
if (currentInstance.dirty) return; // save failed
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
statusEl.textContent = 'Converting to ' + fmt.toUpperCase() + '…';
|
||||
await window.zddc.source.downloadConverted(node.url, node.name, fmt);
|
||||
statusEl.textContent = 'Downloaded ' + fmt.toUpperCase();
|
||||
} catch (e) {
|
||||
statusEl.textContent = (e && e.message) || String(e);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast((e && e.message) || String(e), 'error');
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.app.modules.markdown = {
|
||||
|
|
|
|||
30
build
30
build
|
|
@ -154,7 +154,6 @@ export BUILD_LABELS_DIR
|
|||
sh "$SCRIPT_DIR/transmittal/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/archive/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
|
||||
sh "$SCRIPT_DIR/tables/build.sh" $TOOL_RELEASE_ARGS
|
||||
|
|
@ -162,27 +161,27 @@ sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS
|
|||
|
||||
echo ""
|
||||
echo "=== Assembling zddc/dist/web/ ==="
|
||||
# Six tool HTMLs ship inside the server bundle. landing and archive call
|
||||
# Tool HTMLs ship inside the server bundle. landing and archive call
|
||||
# server APIs (GET / for the project list, directory listings for archive) and
|
||||
# are useless without zddc-server. transmittal, classifier, and mdedit are
|
||||
# pure client-side tools but are still bundled — the server uses these copies
|
||||
# are useless without zddc-server. transmittal and classifier are pure
|
||||
# client-side tools but are still bundled — the server uses these copies
|
||||
# as the embedded fallback (//go:embed in internal/apps/embedded/) when both
|
||||
# the cache is empty AND the upstream is unreachable. form is the schema-
|
||||
# driven form renderer used by the form-data system; it's embedded into the
|
||||
# handler package directly (not the apps cascade) since it isn't subject to
|
||||
# per-folder version overrides.
|
||||
# per-folder version overrides. browse hosts the in-place markdown editor
|
||||
# (no separate mdedit tool — retired in favor of browse's preview plugin).
|
||||
mkdir -p "$SCRIPT_DIR/zddc/dist/web"
|
||||
cp "$SCRIPT_DIR/landing/dist/index.html" "$SCRIPT_DIR/zddc/dist/web/index.html"
|
||||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$SCRIPT_DIR/zddc/dist/web/archive.html"
|
||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/transmittal.html"
|
||||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
|
||||
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
|
||||
cp "$SCRIPT_DIR/tables/dist/tables.html" "$SCRIPT_DIR/zddc/dist/web/tables.html"
|
||||
cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html"
|
||||
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,tables,browse}.html"
|
||||
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,form,tables,browse}.html"
|
||||
|
||||
# Mirror the five cascade-served HTMLs into the apps embed source dir so the
|
||||
# Mirror the cascade-served HTMLs into the apps embed source dir so the
|
||||
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens
|
||||
# on a beta or stable cut — that's the project invariant: alpha labels are
|
||||
# never baked into the binary, beta labels go to the dev image (which builds
|
||||
|
|
@ -196,7 +195,6 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
cp "$SCRIPT_DIR/archive/dist/archive.html" "$EMBED_DIR/archive.html"
|
||||
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html"
|
||||
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html"
|
||||
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
|
||||
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
|
||||
echo "Populated $EMBED_DIR/ for //go:embed"
|
||||
fi
|
||||
|
|
@ -221,7 +219,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
VERSIONS_FILE="$EMBED_DIR/versions.txt"
|
||||
{
|
||||
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
|
||||
for _tool in archive transmittal classifier mdedit landing form tables browse; do
|
||||
for _tool in archive transmittal classifier landing form tables browse; do
|
||||
_label_file="$BUILD_LABELS_DIR/${_tool}.label"
|
||||
if [ -f "$_label_file" ]; then
|
||||
_label=$(cat "$_label_file")
|
||||
|
|
@ -436,7 +434,7 @@ build_releases_index() {
|
|||
_all_versions=$(
|
||||
find "$RELEASES_DIR" -maxdepth 1 -type f \( \
|
||||
-name 'archive_v*.html' -o -name 'transmittal_v*.html' \
|
||||
-o -name 'classifier_v*.html' -o -name 'mdedit_v*.html' \
|
||||
-o -name 'classifier_v*.html' -o -name 'browse_v*.html' \
|
||||
-o -name 'landing_v*.html' \
|
||||
-o -name 'zddc-server_v*_linux-amd64' \
|
||||
\) 2>/dev/null \
|
||||
|
|
@ -618,7 +616,7 @@ PATH_B_OPEN
|
|||
for _entry in "archive|Archive Browser|Browse and download from a ZDDC archive." \
|
||||
"transmittal|Transmittal Creator|Build, sign, and verify transmittal packages." \
|
||||
"classifier|Classifier|Rename loose files to ZDDC convention." \
|
||||
"mdedit|Markdown Editor|Edit project markdown files in place." \
|
||||
"browse|File Browser|Browse the project tree; includes the markdown editor." \
|
||||
"landing|Landing|Project picker for multi-project servers."; do
|
||||
_t="${_entry%%|*}"
|
||||
_rest="${_entry#*|}"
|
||||
|
|
@ -685,8 +683,8 @@ PIN_MID
|
|||
<select class="composer-select" data-app="classifier" style="min-width: 140px;"></select>
|
||||
</label>
|
||||
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
|
||||
<span style="flex: 1;"><code class="inline">mdedit</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Markdown Editor</span></span>
|
||||
<select class="composer-select" data-app="mdedit" style="min-width: 140px;"></select>
|
||||
<span style="flex: 1;"><code class="inline">browse</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— File Browser (with markdown editor)</span></span>
|
||||
<select class="composer-select" data-app="browse" style="min-width: 140px;"></select>
|
||||
</label>
|
||||
<label class="composer-row" style="display: flex; align-items: center; justify-content: space-between; gap: var(--spacing-sm);">
|
||||
<span style="flex: 1;"><code class="inline">landing</code> <span style="color: var(--color-text-muted); font-size: 0.85rem;">— Landing</span></span>
|
||||
|
|
@ -1012,7 +1010,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
|
|||
# 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 mdedit landing form tables browse zddc-server; do
|
||||
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")
|
||||
|
|
@ -1046,7 +1044,7 @@ else
|
|||
echo "Version: v$RELEASE_VERSION"
|
||||
echo ""
|
||||
echo "Tags created locally on main (push when ready):"
|
||||
for _t in archive transmittal classifier mdedit landing form tables browse zddc-server; do
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
#
|
||||
# Usage:
|
||||
# ./freshen-channel <tool> <channel>
|
||||
# tool archive | transmittal | classifier | mdedit | landing
|
||||
# tool archive | transmittal | classifier | browse | landing | form | tables
|
||||
# channel alpha | beta
|
||||
#
|
||||
# Why this exists:
|
||||
|
|
@ -41,10 +41,10 @@ TOOL="${1:-}"
|
|||
CHANNEL="${2:-}"
|
||||
|
||||
case "$TOOL" in
|
||||
archive | transmittal | classifier | mdedit | landing) ;;
|
||||
archive | transmittal | classifier | browse | landing | form | tables) ;;
|
||||
*)
|
||||
echo "usage: $0 <tool> <channel>" >&2
|
||||
echo " tool: archive | transmittal | classifier | mdedit | landing" >&2
|
||||
echo " tool: archive | transmittal | classifier | browse | landing | form | tables" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
|
|||
|
|
@ -650,9 +650,9 @@
|
|||
|
||||
// Render the project-workspace view: title, four stage links, MDL
|
||||
// section. Stage hrefs use the no-trailing-slash form so the server
|
||||
// routes them to each canonical default tool (mdedit for working/,
|
||||
// transmittal for staging/, etc.). Browse-all and the archive deep
|
||||
// link use the slash form to land on the directory listing.
|
||||
// routes them to each canonical default tool (browse for working/+
|
||||
// reviewing/, transmittal for staging/, etc.). Browse-all and the
|
||||
// archive deep link use the slash form to land on the directory listing.
|
||||
async function renderProjectMode() {
|
||||
var project = projectFromPath();
|
||||
if (!project) return;
|
||||
|
|
|
|||
131
mdedit/README.md
131
mdedit/README.md
|
|
@ -1,131 +0,0 @@
|
|||
# ZDDC Markdown Editor
|
||||
|
||||
[← Back to ZDDC](../README.md)
|
||||
|
||||
A lightweight, browser-based markdown editor with YAML front matter support.
|
||||
|
||||
**[🔗 Open Markdown Editor](dist/mdedit.html)** - Click to use online, or right-click → "Save Link As" to keep your own copy.
|
||||
|
||||
## Reliability
|
||||
|
||||
This tool follows the "record player with the record" philosophy - the application and your data travel together. The single HTML file contains everything needed to edit markdown files locally in your browser.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Open the editor in your browser
|
||||
2. Click **Add Local Directory** to choose a folder with markdown files
|
||||
3. Navigate the file tree on the left
|
||||
4. Click any `.md` file to edit it
|
||||
5. Click **Save File** or **Save All** to save changes
|
||||
|
||||
## Features
|
||||
|
||||
### 📂 File Navigation
|
||||
- Browse directories using the File System Access API
|
||||
- Collapsible folder tree with file type icons
|
||||
- Files sorted alphabetically with directories grouped
|
||||
|
||||
### ✏️ Markdown Editing
|
||||
- Toast UI Editor with live preview
|
||||
- Split view (markdown + preview)
|
||||
- Full toolbar for formatting
|
||||
|
||||
### 📋 YAML Front Matter
|
||||
- Separate front matter section at top of editor
|
||||
- Auto-parsed and preserved on save
|
||||
- Collapsible for more editing space
|
||||
|
||||
### 📑 Table of Contents
|
||||
- Auto-generated from headings
|
||||
- Adjustable depth (H1 only through H6)
|
||||
- Click to jump to heading in preview
|
||||
|
||||
### 💾 File Operations
|
||||
- Save individual files or Save All
|
||||
- Reload from disk (discards unsaved changes)
|
||||
- External change detection with reload prompt
|
||||
- Unsaved change warnings before leaving
|
||||
|
||||
### 🖼️ File Previews
|
||||
- Image preview for common formats
|
||||
- HTML preview in sandboxed iframe
|
||||
- Plain text editing for non-markdown files
|
||||
|
||||
## Build
|
||||
|
||||
The editor is built from modular source files using a bash script:
|
||||
|
||||
```bash
|
||||
cd mdedit
|
||||
./build.sh
|
||||
```
|
||||
|
||||
This concatenates CSS and JS files into `dist/mdedit.html`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mdedit/
|
||||
├── css/
|
||||
│ ├── base.css # Core styles and layout
|
||||
│ ├── editor.css # Toast UI Editor overrides
|
||||
│ ├── toc.css # Table of Contents styles
|
||||
│ └── markdown.css # Markdown rendering styles
|
||||
├── js/
|
||||
│ ├── app.js # Global state
|
||||
│ ├── utils.js # Utility functions
|
||||
│ ├── front-matter.js # YAML parsing
|
||||
│ ├── file-system.js # File operations
|
||||
│ ├── file-tree.js # Tree rendering
|
||||
│ ├── editor.js # Toast UI setup
|
||||
│ ├── toc.js # TOC generation
|
||||
│ ├── resizer.js # Pane resizing
|
||||
│ ├── events.js # Event listeners
|
||||
│ └── main.js # Initialization
|
||||
├── vendor/
|
||||
│ ├── toastui-editor-all.min.js # Toast UI Editor JS (bundled)
|
||||
│ └── toastui-editor.min.css # Toast UI Editor CSS (bundled)
|
||||
├── template.html # HTML structure (uses CDN for local dev convenience)
|
||||
├── build.sh # Build script (inlines vendor files, strips CDN refs)
|
||||
└── dist/
|
||||
└── mdedit.html # Built self-contained file
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
- **No server required** - runs entirely in browser
|
||||
- **File System Access API** - direct local file access
|
||||
- **Toast UI Editor v3.2.2** - bundled from `vendor/` into the built output (no CDN required)
|
||||
- **Tailwind CSS** - replaced at build time by `css/tailwind-utils.css`, a hand-written static subset containing only the ~80 utility classes actually used in `template.html` (no runtime overhead, no console warnings)
|
||||
- **Fully self-contained** - `dist/mdedit.html` (~850 KB) works offline with no external dependencies
|
||||
|
||||
> **Development note**: `template.html` loads Toast UI and Tailwind from CDN for a faster local development
|
||||
> experience (open `template.html` directly in a browser). The `build.sh` script replaces the Tailwind CDN
|
||||
> `<script>` tag with nothing (utilities come from `css/tailwind-utils.css` instead) and replaces the Toast UI
|
||||
> CDN tags with the locally bundled `vendor/` files when producing `dist/mdedit.html`.
|
||||
|
||||
### Modules
|
||||
|
||||
CSS and JS modules live under `css/` and `js/`. The canonical load order is in `build.sh`. See the root `ARCHITECTURE.md` for the build/module pattern and `AGENTS.md` for shared helpers.
|
||||
|
||||
mdedit-specific notes:
|
||||
- `css/tailwind-utils.css` is a hand-curated static subset of Tailwind v3 — there is no Tailwind build step. Add a class here when adding it to `template.html`.
|
||||
- Toast UI Editor v3.2.2 ships pre-bundled in `vendor/`. `template.html` loads it from CDN for dev convenience; `build.sh` swaps the CDN tag for the bundled file.
|
||||
- File operations (create, rename, delete) live in `js/file-ops.js`.
|
||||
|
||||
### Build Process
|
||||
|
||||
The build script (`build.sh`):
|
||||
1. Concatenates all local CSS and JS files in dependency order
|
||||
2. **Replaces** the CDN `<script>`/`<link>` tags for Tailwind and Toast UI with the locally bundled files from `vendor/`
|
||||
3. Injects everything into `template.html` to produce `dist/mdedit.html`
|
||||
|
||||
The final HTML file (~850 KB) is fully self-contained and works offline.
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
- All local CSS/JS files are inlined into the output HTML
|
||||
- Vendor dependencies (Toast UI, Tailwind) are bundled from `vendor/` — no runtime CDN access
|
||||
- `template.html` loads dependencies from CDN for convenient local development, but `build.sh` replaces these
|
||||
- No npm dependencies required at runtime
|
||||
- File System Access API requires Chromium-based browsers
|
||||
146
mdedit/build.sh
146
mdedit/build.sh
|
|
@ -1,146 +0,0 @@
|
|||
#!/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/mdedit.html"
|
||||
|
||||
# Vendor files (bundled dependencies — no CDN required at runtime)
|
||||
# Note: Tailwind is NOT a vendor file — it's replaced by css/tailwind-utils.css,
|
||||
# a hand-written subset of only the utility classes used in template.html.
|
||||
toastui_js="$root_dir/../shared/vendor/toastui-editor-all.min.js"
|
||||
toastui_css="$root_dir/../shared/vendor/toastui-editor.min.css"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
ensure_exists "$src_html"
|
||||
ensure_exists "$toastui_js"
|
||||
ensure_exists "$toastui_css"
|
||||
|
||||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
toastui_js_safe=$(mktemp)
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$toastui_js_safe"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# CSS files to concatenate in order
|
||||
concat_files \
|
||||
"css/tailwind-utils.css" \
|
||||
"../shared/fonts.css" \
|
||||
"../shared/base.css" \
|
||||
"../shared/toast.css" \
|
||||
"../shared/nav.css" \
|
||||
"../shared/logo.css" \
|
||||
"css/base.css" \
|
||||
"css/editor.css" \
|
||||
"css/toc.css" \
|
||||
"css/markdown.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JavaScript files to concatenate in order
|
||||
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/zddc-source.js" \
|
||||
"../shared/theme.js" \
|
||||
"../shared/toast.js" \
|
||||
"../shared/nav.js" \
|
||||
"../shared/logo.js" \
|
||||
"../shared/preview-lib.js" \
|
||||
"js/app.js" \
|
||||
"js/utils.js" \
|
||||
"js/front-matter.js" \
|
||||
"js/file-ops.js" \
|
||||
"js/file-system.js" \
|
||||
"js/file-tree.js" \
|
||||
"js/editor.js" \
|
||||
"js/toc.js" \
|
||||
"js/resizer.js" \
|
||||
"js/events.js" \
|
||||
"js/main.js" \
|
||||
"../shared/help.js" \
|
||||
> "$js_raw"
|
||||
|
||||
# Escape '</' in app JS and the Toast UI vendor JS so neither can prematurely
|
||||
# close the inline <script> blocks they get embedded in.
|
||||
escape_js_close_tags "$js_raw" "$js_temp"
|
||||
escape_js_close_tags "$toastui_js" "$toastui_js_safe"
|
||||
|
||||
compute_build_label "mdedit" "${1:-}" "${2:-}"
|
||||
|
||||
# Process template:
|
||||
# - Strip the Tailwind CDN <script> tag (css/tailwind-utils.css replaces it)
|
||||
# - Replace CDN <link> for Toast UI CSS with inline bundled CSS
|
||||
# - Replace CDN <script src="...toastui..."> with inline bundled Toast UI JS
|
||||
# - Inject custom CSS/JS at {{CSS_PLACEHOLDER}} and {{JS_PLACEHOLDER}}
|
||||
# - Substitute {{BUILD_LABEL}}
|
||||
awk \
|
||||
-v css_file="$css_temp" \
|
||||
-v js_file="$js_temp" \
|
||||
-v toastui_js="$toastui_js_safe" \
|
||||
-v toastui_css="$toastui_css" \
|
||||
-v build_label="$build_label" \
|
||||
-v is_red="$is_red" \
|
||||
-v favicon_uri="$favicon_data_uri" \
|
||||
'
|
||||
/\{\{CSS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < css_file) > 0) print line
|
||||
close(css_file)
|
||||
next
|
||||
}
|
||||
/\{\{JS_PLACEHOLDER\}\}/ {
|
||||
while ((getline line < js_file) > 0) print line
|
||||
close(js_file)
|
||||
next
|
||||
}
|
||||
/\{\{BUILD_LABEL\}\}/ {
|
||||
if (is_red == "1") {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, "<span style=\"color:red;font-weight:bold\">" build_label "</span>")
|
||||
} else {
|
||||
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
|
||||
}
|
||||
print
|
||||
next
|
||||
}
|
||||
/\{\{FAVICON\}\}/ {
|
||||
gsub(/\{\{FAVICON\}\}/, favicon_uri)
|
||||
print
|
||||
next
|
||||
}
|
||||
/<script src="https:\/\/cdn\.tailwindcss\.com"/ {
|
||||
# Stripped: Tailwind utility classes are in css/tailwind-utils.css instead
|
||||
next
|
||||
}
|
||||
/<link rel="stylesheet" href="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor\.min\.css"/ {
|
||||
# Inline the bundled Toast UI CSS
|
||||
print "<style>"
|
||||
while ((getline line < toastui_css) > 0) print line
|
||||
close(toastui_css)
|
||||
print "</style>"
|
||||
next
|
||||
}
|
||||
/<script src="https:\/\/uicdn\.toast\.com\/editor\/[^"]*\/toastui-editor/ {
|
||||
# Inline the bundled Toast UI JS (already passed through escape_js_close_tags
|
||||
# so its content cannot contain a literal </script> sequence). We close with
|
||||
# the real </script> because only that exact string terminates a script
|
||||
# block per the HTML5 spec.
|
||||
print "<script>"
|
||||
while ((getline line < toastui_js) > 0) print line
|
||||
close(toastui_js)
|
||||
print "</script>"
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$src_html" > "$output_html"
|
||||
|
||||
echo "Wrote $output_html ($(wc -c < "$output_html") bytes)"
|
||||
|
||||
if [ "$is_release" = "1" ]; then
|
||||
promote_release "mdedit"
|
||||
fi
|
||||
|
|
@ -1,405 +0,0 @@
|
|||
/* mdedit component styles — reset and tokens from shared/base.css */
|
||||
|
||||
/* Pane resizer */
|
||||
.pane-resizer:hover {
|
||||
background-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
/* File tree */
|
||||
.file-tree {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.directory-item,
|
||||
.file-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dir-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.2s ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dir-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.directory-item.collapsed .dir-icon {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
/* Two-line filename styles */
|
||||
.filename-main {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.filename-secondary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Active file highlighting */
|
||||
.active-file {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--text-light) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.active-file * {
|
||||
color: var(--text-light) !important;
|
||||
}
|
||||
|
||||
/* ── File Tree Action Buttons ──────────────────────────────────────────────── */
|
||||
.tree-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.directory-item:hover .tree-actions,
|
||||
.file-item:hover .tree-actions,
|
||||
.active-file .tree-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Always-visible action buttons (e.g. scratchpad download) */
|
||||
.tree-actions--always { opacity: 1; }
|
||||
|
||||
.tree-btn:disabled,
|
||||
.tree-btn.is-disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.tree-btn:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-btn--danger:hover {
|
||||
background-color: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tree-btn--danger:hover {
|
||||
background-color: rgba(127, 29, 29, 0.5);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .tree-btn--danger:hover {
|
||||
background-color: rgba(127, 29, 29, 0.5);
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-btn svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Directory toggle indicator */
|
||||
.directory-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.directory-item.collapsed .directory-contents {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* File view container */
|
||||
.file-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* File header */
|
||||
.file-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.file-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* File content area */
|
||||
.file-content-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content container */
|
||||
#content-container {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Image preview */
|
||||
.image-preview-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* HTML preview iframe */
|
||||
.html-preview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.html-preview-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Dirty indicator */
|
||||
.dirty-indicator {
|
||||
margin-left: 0.25rem;
|
||||
color: var(--warning);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.is-dirty {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Tailwind class overrides: use CSS tokens instead of hardcoded colours ── */
|
||||
/* bg-white / bg-gray-100 are used on the pane backgrounds in template.html. */
|
||||
/* Override them here so they follow the design-token system (light + dark). */
|
||||
.bg-white { background-color: var(--bg) !important; }
|
||||
.bg-gray-100 { background-color: var(--bg-secondary) !important; }
|
||||
|
||||
/* ── Section headers (YAML front matter, TOC, etc.) ───────────────────────── */
|
||||
/* Shared style for all collapsible/section headers inside the side pane —
|
||||
keeps font, padding, weight identical to the file-tree pane header. */
|
||||
.pane-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pane-section-header .toggle-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
width: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Front matter section ──────────────────────────────────────────────────── */
|
||||
.front-matter-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.front-matter-header:hover {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
.front-matter-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* When collapsed, hide content; height shrinks to header */
|
||||
.front-matter-nav.collapsed {
|
||||
height: auto !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.front-matter-nav.collapsed .front-matter-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Front matter textarea fills the content area */
|
||||
.front-matter-textarea {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
border: none;
|
||||
resize: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.front-matter-textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ── Horizontal pane resizer (height split) ─────────────────────────────── */
|
||||
.pane-resizer.horizontal {
|
||||
height: 4px;
|
||||
width: 100%;
|
||||
cursor: row-resize;
|
||||
background-color: var(--border);
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.pane-resizer.horizontal:hover,
|
||||
.pane-resizer.horizontal.active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
/* ── Hidden utility (for disabled buttons) ─────────────────────────────────── */
|
||||
.hide { display: none; }
|
||||
|
||||
/* ── File tree row layout ───────────────────────────────────────────────────── */
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-row__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* The text wrapper inside a tree-row label. For ZDDC-conforming files and
|
||||
folders, this wraps two stacked <div>s (filename-main + filename-secondary)
|
||||
so the row reads top-to-bottom as title + metadata — same shape the archive
|
||||
tool uses for its transmittal-folder list. For non-ZDDC entries it just
|
||||
contains a single line. flex column makes the two-line case work; min-width:0
|
||||
lets each line truncate independently. */
|
||||
.tree-row__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* ── New-file modal ─────────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-overlay.hidden { display: none; }
|
||||
.modal-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
min-width: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.18);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-input:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* File-nav pane: initial width + minimum size. Runtime resizer (resizer.js)
|
||||
overrides via inline style.width when the user drags; the min-width here
|
||||
is a defensive backstop. */
|
||||
#file-nav {
|
||||
width: 450px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
/* Toast UI Editor styles */
|
||||
#markdown-editor {
|
||||
display: block !important;
|
||||
height: 100% !important;
|
||||
min-height: 500px !important;
|
||||
width: 100% !important;
|
||||
position: relative !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.editor-instance {
|
||||
height: 100% !important;
|
||||
min-height: 500px !important;
|
||||
}
|
||||
|
||||
.toastui-editor-defaultUI {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.toastui-editor-defaultUI-toolbar,
|
||||
.toastui-editor-main,
|
||||
.toastui-editor-main .ProseMirror,
|
||||
.toastui-editor-main .toastui-editor-md-preview {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* ── Toast UI Editor — dark-theme overrides ───────────────────────────────
|
||||
Toast UI ships with light-mode chrome and edit surfaces by default. In
|
||||
mdedit's dark mode the editor's text (#222) falls onto the transparent
|
||||
md-container, which inherits var(--bg) dark = #1e1e1e → effectively
|
||||
black-on-black. Override the load-bearing surfaces with mdedit's tokens
|
||||
so the editor harmonises with the rest of the chrome.
|
||||
The selectors target both manual override (data-theme="dark") and the
|
||||
OS-pref auto fallback (prefers-color-scheme + no data-theme="light"). */
|
||||
|
||||
/* Manual dark override */
|
||||
[data-theme="dark"] .toastui-editor-defaultUI,
|
||||
[data-theme="dark"] .toastui-editor-md-container,
|
||||
[data-theme="dark"] .toastui-editor-md-preview,
|
||||
[data-theme="dark"] .toastui-editor-ww-container,
|
||||
[data-theme="dark"] .toastui-editor-mode-switch,
|
||||
[data-theme="dark"] .toastui-editor-main,
|
||||
[data-theme="dark"] .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-icons {
|
||||
/* Toast UI's icons are sprite-baked dark; invert flips them to light. */
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
[data-theme="dark"] .toastui-editor-popup,
|
||||
[data-theme="dark"] .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* OS-pref auto fallback (matches every selector above) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-preview,
|
||||
:root:not([data-theme="light"]) .toastui-editor-ww-container,
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch,
|
||||
:root:not([data-theme="light"]) .toastui-editor-main,
|
||||
:root:not([data-theme="light"]) .ProseMirror {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-defaultUI-toolbar {
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-md-splitter {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-icons {
|
||||
filter: invert(0.85) hue-rotate(180deg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-toolbar-divider {
|
||||
background-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch {
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-mode-switch .tab-item.active {
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
:root:not([data-theme="light"]) .toastui-editor-popup,
|
||||
:root:not([data-theme="light"]) .toastui-editor-context-menu {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
/* Markdown content rendering styles */
|
||||
.markdown-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.toastui-editor-contents h1 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h2,
|
||||
.toastui-editor-contents h2 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h3,
|
||||
.toastui-editor-contents h3 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h4,
|
||||
.toastui-editor-contents h4 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h5,
|
||||
.toastui-editor-contents h5 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 0.875em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-content h6,
|
||||
.toastui-editor-contents h6 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-muted);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Reset margin-top for first-child headings */
|
||||
.markdown-content h1:first-child,
|
||||
.markdown-content h2:first-child,
|
||||
.markdown-content h3:first-child,
|
||||
.markdown-content h4:first-child,
|
||||
.markdown-content h5:first-child,
|
||||
.markdown-content h6:first-child,
|
||||
.toastui-editor-contents h1:first-child,
|
||||
.toastui-editor-contents h2:first-child,
|
||||
.toastui-editor-contents h3:first-child,
|
||||
.toastui-editor-contents h4:first-child,
|
||||
.toastui-editor-contents h5:first-child,
|
||||
.toastui-editor-contents h6:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Reduce spacing between consecutive headings */
|
||||
.markdown-content h1 + h2,
|
||||
.toastui-editor-contents h1 + h2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content h2 + h3,
|
||||
.toastui-editor-contents h2 + h3 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-content h3 + h4,
|
||||
.toastui-editor-contents h3 + h4 {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-content h4 + h5,
|
||||
.toastui-editor-contents h4 + h5 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content h5 + h6,
|
||||
.toastui-editor-contents h5 + h6 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-content p,
|
||||
.toastui-editor-contents p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol,
|
||||
.toastui-editor-contents ul,
|
||||
.toastui-editor-contents ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.toastui-editor-contents ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-content ol,
|
||||
.toastui-editor-contents ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-content li,
|
||||
.toastui-editor-contents li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content code,
|
||||
.toastui-editor-contents code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-content pre,
|
||||
.toastui-editor-contents pre {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content pre code,
|
||||
.toastui-editor-contents pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote,
|
||||
.toastui-editor-contents blockquote {
|
||||
border-left: 4px solid var(--border);
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markdown-content a,
|
||||
.toastui-editor-contents a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content a:hover,
|
||||
.toastui-editor-contents a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content table,
|
||||
.toastui-editor-contents table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td,
|
||||
.toastui-editor-contents th,
|
||||
.toastui-editor-contents td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.toastui-editor-contents th {
|
||||
background-color: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content tr:nth-child(even),
|
||||
.toastui-editor-contents tr:nth-child(even) {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
/*
|
||||
* Tailwind utility subset for mdedit
|
||||
*
|
||||
* This file replaces the Tailwind Play CDN. It contains only the utility
|
||||
* classes actually used in template.html, hand-written to match Tailwind v3
|
||||
* output exactly. If new Tailwind classes are needed in template.html, add
|
||||
* them here and remove the class from this comment.
|
||||
*
|
||||
* Generated from: grep -o 'class="[^"]*"' template.html | tr ' ' '\n' | sort -u
|
||||
* Tailwind version parity: v3.x (default spacing scale, gray palette, etc.)
|
||||
*/
|
||||
|
||||
/* ── Reset ── */
|
||||
*, ::before, ::after { box-sizing: border-box; }
|
||||
|
||||
/* ── Display ── */
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
/* .hidden lives in shared/base.css (uses !important) */
|
||||
|
||||
/* ── Flex direction ── */
|
||||
.flex-col { flex-direction: column; }
|
||||
.flex-row { flex-direction: row; }
|
||||
|
||||
/* ── Flex grow ── */
|
||||
.flex-1 { flex: 1 1 0%; }
|
||||
|
||||
/* ── Alignment ── */
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
|
||||
/* ── Gap ── */
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
|
||||
/* ── Overflow ── */
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
|
||||
/* ── Sizing ── */
|
||||
.h-screen { height: 100vh; }
|
||||
.h-full { height: 100%; }
|
||||
.h-12 { height: 3rem; }
|
||||
.h-6 { height: 1.5rem; }
|
||||
.h-3\.5 { height: 0.875rem; }
|
||||
.h-24 { height: 6rem; }
|
||||
|
||||
/* ── Resize ── */
|
||||
.resize-none { resize: none; }
|
||||
|
||||
/* ── Border ── */
|
||||
.border-0 { border-width: 0; }
|
||||
|
||||
/* ── Outline ── */
|
||||
.focus\:outline-none:focus { outline: none; }
|
||||
.w-full { width: 100%; }
|
||||
.w-1 { width: 0.25rem; }
|
||||
.w-3\.5 { width: 0.875rem; }
|
||||
|
||||
/* ── Positioning ── */
|
||||
.relative { position: relative; }
|
||||
.z-10 { z-index: 10; }
|
||||
|
||||
/* ── Spacing ── */
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
|
||||
.pl-2 { padding-left: 0.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
|
||||
/* ── Typography ── */
|
||||
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
|
||||
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
|
||||
.text-xs { font-size: 0.75rem; line-height: 1rem; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.text-center { text-align: center; }
|
||||
.leading-none { line-height: 1; }
|
||||
.select-none { user-select: none; }
|
||||
|
||||
/* ── Colors — text ── */
|
||||
.text-white { color: #ffffff; }
|
||||
.text-gray-800 { color: #1f2937; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-500 { color: #6b7280; }
|
||||
.text-amber-600 { color: #d97706; }
|
||||
|
||||
/* ── Colors — background ── */
|
||||
.bg-white { background-color: #ffffff; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
.bg-gray-200 { background-color: #e5e7eb; }
|
||||
.bg-transparent { background-color: transparent; }
|
||||
.bg-blue-500 { background-color: #3b82f6; }
|
||||
|
||||
/* ── Borders ── */
|
||||
.border { border-width: 1px; border-style: solid; }
|
||||
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
|
||||
.border-t { border-top-width: 1px; border-top-style: solid; }
|
||||
.border-gray-200 { border-color: #e5e7eb; }
|
||||
.border-gray-300 { border-color: #d1d5db; }
|
||||
.rounded { border-radius: 0.25rem; }
|
||||
|
||||
/* ── Opacity ── */
|
||||
.opacity-70 { opacity: 0.7; }
|
||||
.opacity-80 { opacity: 0.8; }
|
||||
|
||||
/* ── SVG ── */
|
||||
.fill-current { fill: currentColor; }
|
||||
|
||||
/* ── Cursor ── */
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.cursor-col-resize { cursor: col-resize; }
|
||||
|
||||
/* ── Transitions ── */
|
||||
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
|
||||
.transition-colors { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
|
||||
.transition-opacity { transition-property: opacity; transition-timing-function: cubic-bezier(0.4,0,0.2,1); transition-duration: 150ms; }
|
||||
|
||||
/* ── Pseudo-class: hover ── */
|
||||
.hover\:bg-blue-500:hover { background-color: #3b82f6; }
|
||||
.hover\:bg-blue-600:hover { background-color: #2563eb; }
|
||||
.hover\:bg-gray-200:hover { background-color: #e5e7eb; }
|
||||
.hover\:opacity-80:hover { opacity: 0.8; }
|
||||
|
||||
/* ── Pseudo-class: disabled ── */
|
||||
.disabled\:bg-gray-400:disabled { background-color: #9ca3af; }
|
||||
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
|
||||
|
||||
/* ── Dark mode (prefers-color-scheme or manual [data-theme="dark"]) ── */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .dark\:bg-gray-700 { background-color: #374151; }
|
||||
:root:not([data-theme="light"]) .dark\:bg-gray-800 { background-color: #1f2937; }
|
||||
:root:not([data-theme="light"]) .dark\:bg-gray-900 { background-color: #111827; }
|
||||
:root:not([data-theme="light"]) .dark\:border-gray-600 { border-color: #4b5563; }
|
||||
:root:not([data-theme="light"]) .dark\:border-gray-700 { border-color: #374151; }
|
||||
:root:not([data-theme="light"]) .dark\:text-gray-200 { color: #e5e7eb; }
|
||||
:root:not([data-theme="light"]) .dark\:text-gray-400 { color: #9ca3af; }
|
||||
:root:not([data-theme="light"]) .dark\:hover\:bg-gray-700:hover { background-color: #374151; }
|
||||
:root:not([data-theme="light"]) .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; }
|
||||
}
|
||||
|
||||
/* Manual dark override */
|
||||
[data-theme="dark"] .dark\:bg-gray-700 { background-color: #374151; }
|
||||
[data-theme="dark"] .dark\:bg-gray-800 { background-color: #1f2937; }
|
||||
[data-theme="dark"] .dark\:bg-gray-900 { background-color: #111827; }
|
||||
[data-theme="dark"] .dark\:border-gray-600 { border-color: #4b5563; }
|
||||
[data-theme="dark"] .dark\:border-gray-700 { border-color: #374151; }
|
||||
[data-theme="dark"] .dark\:text-gray-200 { color: #e5e7eb; }
|
||||
[data-theme="dark"] .dark\:text-gray-400 { color: #9ca3af; }
|
||||
[data-theme="dark"] .dark\:hover\:bg-gray-700:hover { background-color: #374151; }
|
||||
[data-theme="dark"] .dark\:hover\:bg-gray-800:hover { background-color: #1f2937; }
|
||||
|
||||
/* Manual light override — ensure bg-white/bg-gray-100 are NOT overridden by above */
|
||||
[data-theme="light"] .dark\:bg-gray-700,
|
||||
[data-theme="light"] .dark\:bg-gray-800,
|
||||
[data-theme="light"] .dark\:bg-gray-900 { background-color: revert; }
|
||||
|
||||
/* ── Directional spacing (used in JS-generated elements) ── */
|
||||
.ml-1 { margin-left: 0.25rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.pl-0 { padding-left: 0; }
|
||||
.pl-4 { padding-left: 1rem; }
|
||||
|
||||
/* ── Additional missing utilities ── */
|
||||
.whitespace-nowrap { white-space: nowrap; }
|
||||
.text-ellipsis { text-overflow: ellipsis; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.border-r { border-right-width: 1px; border-right-style: solid; }
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.text-amber-500 { color: #f59e0b; }
|
||||
.text-blue-600 { color: #2563eb; }
|
||||
.hover\:bg-gray-100:hover { background-color: #f3f4f6; }
|
||||
.hover\:text-blue-800:hover { color: #1e40af; }
|
||||
.hover\:underline:hover { text-decoration: underline; }
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
/* Table of Contents styles */
|
||||
.toc-pane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toc-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.toc-container,
|
||||
.toc-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Header layout — font/padding/weight come from .pane-section-header. */
|
||||
.toc-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toc-depth-selector {
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* TOC heading level styles */
|
||||
.toc-level-1 > a {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-level-2 > a {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-level-3 > a {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-level-4 > a {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-level-5 > a,
|
||||
.toc-level-6 > a {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Nested list spacing */
|
||||
.toc-list ul {
|
||||
list-style: none;
|
||||
padding-left: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toc-list li {
|
||||
margin-bottom: 1px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toc-list li a {
|
||||
display: block;
|
||||
padding: 2px 6px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.toc-list li a:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Active TOC item highlighting */
|
||||
.toc-list li.toc-active {
|
||||
background-color: var(--primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Use high-specificity selectors to override per-level color rules */
|
||||
.toc-list li.toc-active > a,
|
||||
.toc-list li.toc-active > a:hover,
|
||||
.toc-list li.toc-level-1.toc-active > a,
|
||||
.toc-list li.toc-level-2.toc-active > a,
|
||||
.toc-list li.toc-level-3.toc-active > a,
|
||||
.toc-list li.toc-level-4.toc-active > a,
|
||||
.toc-list li.toc-level-5.toc-active > a,
|
||||
.toc-list li.toc-level-6.toc-active > a {
|
||||
color: var(--text-light);
|
||||
border-bottom-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-1 {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
padding-left: 0px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-1 a {
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--primary);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Tree-style connecting lines for TOC hierarchy */
|
||||
.toc-list li.toc-level-2 {
|
||||
font-weight: 650;
|
||||
font-size: 0.9rem;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-2::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-2 a {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3 {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4 {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
padding-left: 48px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 38px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5 {
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
padding-left: 64px;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 54px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6 {
|
||||
font-weight: 600;
|
||||
font-size: 0.65rem;
|
||||
padding-left: 80px;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 70px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6 a {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Vertical connecting lines */
|
||||
.toc-list li:not(.toc-level-1)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 50%;
|
||||
bottom: -2px;
|
||||
border-left: 1px solid var(--border);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-3::after {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-4::after {
|
||||
left: 38px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-5::after {
|
||||
left: 54px;
|
||||
}
|
||||
|
||||
.toc-list li.toc-level-6::after {
|
||||
left: 70px;
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/**
|
||||
* Global application state and constants
|
||||
*/
|
||||
|
||||
// Set to true to enable verbose console logging for development.
|
||||
const DEBUG = false;
|
||||
|
||||
// Check if File System Access API is available
|
||||
const hasFileSystemAccess = 'showDirectoryPicker' in window;
|
||||
|
||||
// Directory and file handles
|
||||
let directoryHandle = null;
|
||||
let fileTree = {};
|
||||
let currentFileHandle = null;
|
||||
|
||||
// True when the page is served over HTTP(S) and the file tree is sourced
|
||||
// from the server's JSON directory listing instead of the local FS API.
|
||||
let serverSourceMode = false;
|
||||
|
||||
// Map to store editor instances for each file
|
||||
// Key: file path, Value: { editor, container, tocContainer, etc. }
|
||||
const editorInstances = new Map();
|
||||
|
||||
// Current TOC max depth (1-6)
|
||||
let tocMaxDepth = 3;
|
||||
|
||||
// Scratchpad ID constant
|
||||
const SCRATCHPAD_ID = '__scratchpad__';
|
||||
|
||||
// Default scratchpad markdown — shown the first time mdedit loads.
|
||||
// Acts as both a welcome message and a starter pad for quick notes.
|
||||
const SCRATCHPAD_WELCOME = [
|
||||
'# Welcome to ZDDC Markdown',
|
||||
'',
|
||||
'All editing happens locally on your computer — nothing is uploaded.',
|
||||
'',
|
||||
'Use this **Scratchpad** for quick notes. Download it any time with the ⬇',
|
||||
'button on the Scratchpad row in the file list.',
|
||||
'',
|
||||
'Click **Add Local Directory** above to open a folder of Markdown files,',
|
||||
'or just start typing here.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
|
@ -1,419 +0,0 @@
|
|||
/**
|
||||
* Toast UI Editor initialization and management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize or update the Toast UI Editor for a file
|
||||
* @param {string} content - Content to display
|
||||
* @param {boolean} isMarkdown - Whether content is markdown
|
||||
* @param {string} filePath - Path of the file
|
||||
* @param {string} fileName - Name of the file
|
||||
* @param {FileSystemFileHandle} fileHandle - File handle for saving
|
||||
* @param {number} lastModified - Timestamp of last modification
|
||||
*/
|
||||
function initializeEditor(content, isMarkdown = true, filePath = '', fileName = '', fileHandle = null, lastModified = null) {
|
||||
// Parse front matter
|
||||
let frontMatterData = {};
|
||||
let markdownBody = content;
|
||||
|
||||
if (isMarkdown && content) {
|
||||
try {
|
||||
const parsed = parseFrontMatter(content);
|
||||
frontMatterData = parsed.data;
|
||||
markdownBody = parsed.content;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse front matter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all file view containers
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
// Check if file already has an instance
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return existingInstance.editor;
|
||||
}
|
||||
|
||||
// Create file view container
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
// Create file header
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
// Button container for alignment
|
||||
const buttonContainer = document.createElement('div');
|
||||
buttonContainer.className = 'flex gap-2';
|
||||
|
||||
// Determine if this is a scratchpad (no file handle)
|
||||
const isScratchpad = !fileHandle;
|
||||
const isReadOnlyHandle = !!(fileHandle && fileHandle._readOnly);
|
||||
|
||||
// Save button (or Save As for scratchpads / read-only server files)
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'btn btn-primary btn-sm';
|
||||
saveButton.textContent = (isScratchpad || isReadOnlyHandle) ? 'Save As...' : 'Save File';
|
||||
saveButton.disabled = !isScratchpad; // Scratchpads can always save; read-only enables on edit
|
||||
buttonContainer.appendChild(saveButton);
|
||||
|
||||
// Reload button (only for files, not scratchpads) — icon to match file-tree refresh
|
||||
let reloadButton = null;
|
||||
if (!isScratchpad) {
|
||||
reloadButton = document.createElement('button');
|
||||
reloadButton.className = 'btn btn-secondary btn-sm';
|
||||
reloadButton.textContent = '↻';
|
||||
reloadButton.title = 'Reload from disk (discards unsaved changes)';
|
||||
reloadButton.setAttribute('aria-label', 'Reload from disk');
|
||||
buttonContainer.appendChild(reloadButton);
|
||||
}
|
||||
|
||||
fileHeader.appendChild(buttonContainer);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
// Content area
|
||||
const contentArea = document.createElement('div');
|
||||
contentArea.className = 'flex flex-col flex-1 overflow-hidden';
|
||||
|
||||
// Editor area with TOC
|
||||
const editorArea = document.createElement('div');
|
||||
editorArea.className = 'flex flex-row flex-1 overflow-hidden';
|
||||
|
||||
// TOC pane (markdown only)
|
||||
let tocContainer = null;
|
||||
let frontMatterTextarea = null;
|
||||
if (isMarkdown) {
|
||||
const tocPane = document.createElement('div');
|
||||
tocPane.className = 'toc-pane bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700';
|
||||
tocPane.style.width = '325px';
|
||||
tocPane.style.minWidth = '150px';
|
||||
|
||||
// Front matter section (collapsible, height-resizable)
|
||||
const frontMatterNav = document.createElement('div');
|
||||
frontMatterNav.className = 'front-matter-nav';
|
||||
frontMatterNav.style.height = '180px';
|
||||
|
||||
const frontMatterHeader = document.createElement('div');
|
||||
frontMatterHeader.className = 'front-matter-header pane-section-header cursor-pointer';
|
||||
|
||||
const toggleIcon = document.createElement('span');
|
||||
toggleIcon.textContent = '▼';
|
||||
toggleIcon.className = 'toggle-icon';
|
||||
frontMatterHeader.appendChild(toggleIcon);
|
||||
|
||||
const headerText = document.createElement('span');
|
||||
headerText.textContent = 'YAML Front Matter';
|
||||
frontMatterHeader.appendChild(headerText);
|
||||
|
||||
frontMatterNav.appendChild(frontMatterHeader);
|
||||
|
||||
const frontMatterContent = document.createElement('div');
|
||||
frontMatterContent.className = 'front-matter-content';
|
||||
|
||||
frontMatterTextarea = document.createElement('textarea');
|
||||
frontMatterTextarea.className = 'front-matter-textarea';
|
||||
frontMatterTextarea.placeholder = 'title: Document Title\ndate: 2024-01-01\ntags: [example]';
|
||||
|
||||
if (frontMatterData && Object.keys(frontMatterData).length > 0) {
|
||||
try {
|
||||
let yamlText = '';
|
||||
for (const [key, value] of Object.entries(frontMatterData)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlText += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
|
||||
} else {
|
||||
yamlText += `${key}: ${value}\n`;
|
||||
}
|
||||
}
|
||||
frontMatterTextarea.value = yamlText.trim();
|
||||
} catch (error) {
|
||||
console.warn('Failed to stringify front matter:', error);
|
||||
frontMatterTextarea.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
frontMatterContent.appendChild(frontMatterTextarea);
|
||||
frontMatterNav.appendChild(frontMatterContent);
|
||||
tocPane.appendChild(frontMatterNav);
|
||||
|
||||
// Horizontal resizer between front-matter and TOC
|
||||
const fmTocResizer = document.createElement('div');
|
||||
fmTocResizer.className = 'pane-resizer horizontal';
|
||||
tocPane.appendChild(fmTocResizer);
|
||||
|
||||
// TOC section
|
||||
const tocSection = document.createElement('div');
|
||||
tocSection.className = 'toc-section';
|
||||
|
||||
const tocHeader = document.createElement('div');
|
||||
tocHeader.className = 'toc-header pane-section-header';
|
||||
|
||||
const tocTitle = document.createElement('span');
|
||||
tocTitle.textContent = 'Table of Contents';
|
||||
tocHeader.appendChild(tocTitle);
|
||||
|
||||
const tocDepthSelector = document.createElement('select');
|
||||
tocDepthSelector.className = 'toc-depth-selector';
|
||||
tocDepthSelector.innerHTML = `
|
||||
<option value="6">All Levels</option>
|
||||
<option value="1">H1 Only</option>
|
||||
<option value="2">H1-H2</option>
|
||||
<option value="3" selected>H1-H3</option>
|
||||
<option value="4">H1-H4</option>
|
||||
<option value="5">H1-H5</option>
|
||||
`;
|
||||
tocHeader.appendChild(tocDepthSelector);
|
||||
|
||||
tocSection.appendChild(tocHeader);
|
||||
|
||||
tocContainer = document.createElement('div');
|
||||
tocContainer.className = 'toc-container toc-content';
|
||||
tocSection.appendChild(tocContainer);
|
||||
|
||||
tocPane.appendChild(tocSection);
|
||||
|
||||
// Toggle: collapsed only shows the header. Hide content + horizontal resizer.
|
||||
let fmIsCollapsed = false;
|
||||
frontMatterHeader.addEventListener('click', () => {
|
||||
fmIsCollapsed = !fmIsCollapsed;
|
||||
frontMatterNav.classList.toggle('collapsed', fmIsCollapsed);
|
||||
toggleIcon.textContent = fmIsCollapsed ? '▶' : '▼';
|
||||
fmTocResizer.style.display = fmIsCollapsed ? 'none' : '';
|
||||
if (fmIsCollapsed) {
|
||||
frontMatterNav.style.height = '';
|
||||
} else {
|
||||
frontMatterNav.style.height = '180px';
|
||||
}
|
||||
});
|
||||
|
||||
editorArea.appendChild(tocPane);
|
||||
|
||||
// Vertical resizer between toc-pane and editor (placed inside editorArea)
|
||||
const tocResizer = document.createElement('div');
|
||||
tocResizer.className = 'pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500';
|
||||
tocResizer.setAttribute('data-resizer-for', 'toc-pane');
|
||||
editorArea.appendChild(tocResizer);
|
||||
|
||||
makeResizable(tocResizer, tocPane);
|
||||
|
||||
// Make the front-matter / TOC split height-adjustable
|
||||
makeHeightResizable(fmTocResizer, frontMatterNav, tocPane);
|
||||
|
||||
tocDepthSelector.addEventListener('change', function () {
|
||||
const depth = parseInt(this.value);
|
||||
if (editorInstance) {
|
||||
const currentContent = editorInstance.getMarkdown();
|
||||
updateToc(currentContent, tocContainer, editorInstance, depth);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Editor container
|
||||
const editorContainer = document.createElement('div');
|
||||
editorContainer.className = 'editor-instance flex-1 overflow-hidden';
|
||||
editorArea.appendChild(editorContainer);
|
||||
|
||||
contentArea.appendChild(editorArea);
|
||||
fileViewContainer.appendChild(contentArea);
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
// Check Toast UI availability
|
||||
if (typeof toastui === 'undefined') {
|
||||
alert('Error: Toast UI library not loaded!');
|
||||
editorContainer.innerHTML = '<div style="padding: 20px; background: #ffeeee; color: red;">Error: Toast UI library not loaded!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let editorInstance;
|
||||
|
||||
try {
|
||||
// Initialize Toast UI Editor
|
||||
const editor = new toastui.Editor({
|
||||
el: editorContainer,
|
||||
height: '100%',
|
||||
initialEditType: 'markdown',
|
||||
previewStyle: 'vertical',
|
||||
initialValue: markdownBody,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task', 'indent', 'outdent'],
|
||||
['table', 'image', 'link'],
|
||||
['code', 'codeblock']
|
||||
]
|
||||
});
|
||||
|
||||
editorInstance = editor;
|
||||
|
||||
if (!isMarkdown) {
|
||||
editorInstance.changeMode('wysiwyg');
|
||||
}
|
||||
|
||||
// Generate initial TOC
|
||||
if (isMarkdown && tocContainer) {
|
||||
try {
|
||||
updateToc(markdownBody, tocContainer, editorInstance, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error generating TOC:', error);
|
||||
}
|
||||
|
||||
const debouncedUpdateToc = debounce(() => {
|
||||
const currentContent = editorInstance.getMarkdown();
|
||||
updateToc(currentContent, tocContainer, editorInstance, tocMaxDepth);
|
||||
}, 300);
|
||||
|
||||
editorInstance.on('change', () => {
|
||||
debouncedUpdateToc();
|
||||
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData && !instanceData.isDirty) {
|
||||
instanceData.isDirty = true;
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
|
||||
if (filePath === SCRATCHPAD_ID) updateScratchpadDownloadState();
|
||||
});
|
||||
|
||||
// Scroll listener for TOC highlighting
|
||||
const mdPreview = editorInstance.getEditorElements().mdPreview;
|
||||
if (mdPreview) {
|
||||
let activeTimeout = null;
|
||||
let lastHeader = null;
|
||||
|
||||
const updateActiveHeader = () => {
|
||||
// Re-query live headings (TOC may have been regenerated)
|
||||
const liveHeaders = mdPreview.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
const previewRect = mdPreview.getBoundingClientRect();
|
||||
// Use a threshold slightly below the top so a header touching
|
||||
// the top edge counts as "active"
|
||||
const threshold = previewRect.top + 4;
|
||||
let activeHeader = null;
|
||||
for (const header of liveHeaders) {
|
||||
if (header.getBoundingClientRect().top <= threshold) {
|
||||
activeHeader = header.textContent.trim();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (activeHeader !== lastHeader) {
|
||||
lastHeader = activeHeader;
|
||||
setActiveTocItem(tocContainer, activeHeader);
|
||||
}
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(activeTimeout);
|
||||
activeTimeout = requestAnimationFrame(updateActiveHeader);
|
||||
};
|
||||
|
||||
mdPreview.addEventListener('scroll', onScroll);
|
||||
}
|
||||
} else {
|
||||
editorInstance.on('change', () => {
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData && !instanceData.isDirty) {
|
||||
instanceData.isDirty = true;
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Front matter change listener
|
||||
if (frontMatterTextarea) {
|
||||
frontMatterTextarea.addEventListener('input', () => {
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData && !instanceData.isDirty) {
|
||||
instanceData.isDirty = true;
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
updateUnsavedCount();
|
||||
}
|
||||
saveButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Button event listeners
|
||||
saveButton.addEventListener('click', async () => {
|
||||
if (isScratchpad) {
|
||||
// For scratchpads, use Save As
|
||||
const content = editorInstance.getMarkdown();
|
||||
const savedHandle = await saveFileAs(content, 'untitled.md');
|
||||
if (savedHandle && hasFileSystemAccess) {
|
||||
// Check if saved to current directory - add to file tree
|
||||
if (directoryHandle) {
|
||||
try {
|
||||
// Try to get the file from the directory to verify it's there
|
||||
const checkHandle = await directoryHandle.getFileHandle(savedHandle.name);
|
||||
// File is in current directory, add to tree
|
||||
fileTree.entries[savedHandle.name] = {
|
||||
name: savedHandle.name,
|
||||
type: 'file',
|
||||
handle: checkHandle
|
||||
};
|
||||
renderFileTree();
|
||||
|
||||
} catch (e) {
|
||||
// File not in current directory, that's fine
|
||||
}
|
||||
}
|
||||
// Clear scratchpad content after successful save
|
||||
editorInstance.setMarkdown('');
|
||||
saveButton.disabled = true;
|
||||
const instanceData = editorInstances.get(filePath);
|
||||
if (instanceData) {
|
||||
instanceData.isDirty = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveFile(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (reloadButton) {
|
||||
reloadButton.addEventListener('click', async () => {
|
||||
await reloadFileFromDisk(filePath);
|
||||
});
|
||||
}
|
||||
|
||||
// Store instance data
|
||||
const instanceData = {
|
||||
editor: editor,
|
||||
fileViewContainer: fileViewContainer,
|
||||
tocContainer: tocContainer,
|
||||
saveButton: saveButton,
|
||||
reloadButton: reloadButton,
|
||||
frontMatterTextarea: frontMatterTextarea,
|
||||
frontMatterData: frontMatterData,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false
|
||||
};
|
||||
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
return editorInstance;
|
||||
} catch (error) {
|
||||
console.error('Error initializing editor:', error);
|
||||
alert(`Error initializing Toast UI Editor: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
/**
|
||||
* Event listeners setup
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set up all event listeners for the application
|
||||
*/
|
||||
function setupEventListeners() {
|
||||
// Add Local Directory button (was id="select-directory" / "refresh-directory")
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.addEventListener('click', openDirectory);
|
||||
}
|
||||
|
||||
// Refresh button (now in header, was in file-nav pane)
|
||||
const refreshDirectoryBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshDirectoryBtn) {
|
||||
refreshDirectoryBtn.addEventListener('click', refreshDirectory);
|
||||
}
|
||||
|
||||
// New file (root) button
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
if (newFileRootBtn) {
|
||||
newFileRootBtn.addEventListener('click', () => {
|
||||
if (directoryHandle) {
|
||||
createNewFile('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save All button
|
||||
const saveAllBtn = document.getElementById('save-all');
|
||||
if (saveAllBtn) {
|
||||
saveAllBtn.addEventListener('click', saveAllFiles);
|
||||
}
|
||||
|
||||
// Warn when leaving with unsaved changes
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
let hasUnsavedChanges = false;
|
||||
|
||||
editorInstances.forEach((instanceData) => {
|
||||
if (instanceData.isDirty) {
|
||||
hasUnsavedChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
return 'You have unsaved changes. If you leave now, your changes will be lost.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up TOC depth selector
|
||||
*/
|
||||
function setupTocDepthSelector() {
|
||||
const depthSelector = document.getElementById('toc-depth-selector');
|
||||
if (!depthSelector) return;
|
||||
|
||||
depthSelector.value = tocMaxDepth.toString();
|
||||
|
||||
depthSelector.addEventListener('change', function () {
|
||||
tocMaxDepth = parseInt(this.value, 10);
|
||||
|
||||
if (currentFileHandle && currentFileHandle.name.match(/\.(md|markdown)$/i)) {
|
||||
const filePath = currentFileHandle.name;
|
||||
const instance = editorInstances.get(filePath);
|
||||
|
||||
if (instance && instance.editor && instance.tocContainer) {
|
||||
const content = instance.editor.getMarkdown();
|
||||
|
||||
try {
|
||||
updateToc(content, instance.tocContainer, instance.editor, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error updating TOC depth:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,400 +0,0 @@
|
|||
/**
|
||||
* File management operations (create, rename, delete)
|
||||
* Plain functions, no module wrapper
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve a node in fileTree by filePath
|
||||
* @param {string} filePath - Path like 'subdir/file.md' or ''
|
||||
* @returns {Object|null} The node object or null if not found
|
||||
*/
|
||||
function resolveNode(filePath) {
|
||||
if (!filePath) return fileTree;
|
||||
const parts = filePath.split('/');
|
||||
let node = fileTree;
|
||||
for (const part of parts) {
|
||||
if (!node.entries || !node.entries[part]) return null;
|
||||
node = node.entries[part];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the parent directory handle for a given file path
|
||||
* @param {string} filePath - Full path like 'subdir/file.md'
|
||||
* @returns {FileSystemDirectoryHandle|null} Parent directory handle or null
|
||||
*/
|
||||
function resolveParentDirHandle(filePath) {
|
||||
const parts = filePath.split('/');
|
||||
if (parts.length === 1) return directoryHandle;
|
||||
let node = fileTree;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
node = node.entries[parts[i]];
|
||||
if (!node) return null;
|
||||
}
|
||||
return node.handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new file
|
||||
* @param {string} parentDirPath - '' for root, or 'subdir', 'a/b/c'
|
||||
*/
|
||||
async function createNewFile(parentDirPath) {
|
||||
// Resolve parent directory handle first (no user activation needed for reads)
|
||||
let parentHandle;
|
||||
if (parentDirPath === '') {
|
||||
parentHandle = directoryHandle;
|
||||
} else {
|
||||
const node = resolveNode(parentDirPath);
|
||||
if (!node || !node.handle) {
|
||||
alert('Could not locate parent directory.');
|
||||
return;
|
||||
}
|
||||
parentHandle = node.handle;
|
||||
}
|
||||
|
||||
// Show in-page modal and wait for user to confirm or cancel.
|
||||
// Returns the filename string, or null if cancelled.
|
||||
const name = await new Promise((resolve) => {
|
||||
const modal = document.getElementById('new-file-modal');
|
||||
const input = document.getElementById('new-file-input');
|
||||
const confirmBtn = document.getElementById('new-file-confirm');
|
||||
const cancelBtn = document.getElementById('new-file-cancel');
|
||||
|
||||
input.value = 'untitled.md';
|
||||
modal.classList.remove('hidden');
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
function cleanup() {
|
||||
modal.classList.add('hidden');
|
||||
confirmBtn.removeEventListener('click', onConfirm);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
input.removeEventListener('keydown', onKey);
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
const val = input.value.trim();
|
||||
cleanup();
|
||||
resolve(val || null);
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
|
||||
function onKey(e) {
|
||||
if (e.key === 'Enter') onConfirm();
|
||||
if (e.key === 'Escape') onCancel();
|
||||
}
|
||||
|
||||
confirmBtn.addEventListener('click', onConfirm);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
input.addEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
if (!name) {
|
||||
if (DEBUG) console.log('New file creation cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (name.includes('/') || name.includes('\\')) {
|
||||
alert('Invalid filename: cannot contain / or \\.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
try {
|
||||
await parentHandle.getFileHandle(name);
|
||||
const overwrite = window.confirm('A file named "' + name + '" already exists. Overwrite it?');
|
||||
if (!overwrite) return;
|
||||
} catch (e) {
|
||||
if (e.name !== 'NotFoundError') throw e;
|
||||
}
|
||||
|
||||
// Create the file — this must happen after the modal's button click
|
||||
// which is the user activation token.
|
||||
try {
|
||||
const newHandle = await parentHandle.getFileHandle(name, { create: true });
|
||||
|
||||
const writable = await newHandle.createWritable();
|
||||
await writable.write('');
|
||||
await writable.close();
|
||||
|
||||
if (DEBUG) console.log(`Created new file: ${parentDirPath ? parentDirPath + '/' : ''}${name}`);
|
||||
|
||||
await refreshDirectory();
|
||||
|
||||
const newFilePath = parentDirPath ? parentDirPath + '/' + name : name;
|
||||
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
|
||||
if (element) {
|
||||
handleFileClick(newHandle, newFilePath, element);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating new file:', error);
|
||||
alert('Error creating file: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or directory
|
||||
* @param {string} filePath - Full path like 'subdir/file.md'
|
||||
* @param {boolean} isDirectory - true if renaming a directory (not supported on Chrome)
|
||||
*/
|
||||
async function renameEntry(filePath, isDirectory) {
|
||||
const currentName = filePath.split('/').pop();
|
||||
const newName = window.prompt('Rename to:', currentName);
|
||||
|
||||
if (newName === null || newName === currentName) {
|
||||
if (DEBUG) console.log('Rename cancelled or unchanged');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (newName.includes('/') || newName.includes('\\') || newName.trim() === '') {
|
||||
alert('Invalid filename: cannot contain / or \\ and must not be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve parent directory handle
|
||||
const parentHandle = resolveParentDirHandle(filePath);
|
||||
if (!parentHandle) {
|
||||
alert('Could not locate parent directory.');
|
||||
return;
|
||||
}
|
||||
|
||||
// For files: rename via File System Access API
|
||||
if (!isDirectory) {
|
||||
try {
|
||||
// Check if new name already exists (file or directory)
|
||||
try {
|
||||
const existing = await parentHandle.getFileHandle(newName);
|
||||
// A file with that name exists
|
||||
const overwrite = window.confirm('A file named "' + newName + '" already exists. Overwrite?');
|
||||
if (!overwrite) return;
|
||||
} catch (fileErr) {
|
||||
if (fileErr.name === 'TypeMismatchError') {
|
||||
// A directory with that name exists
|
||||
window.alert('A folder named "' + newName + '" already exists. Choose a different name.');
|
||||
return;
|
||||
}
|
||||
if (fileErr.name !== 'NotFoundError') throw fileErr;
|
||||
// NotFoundError = safe to create
|
||||
}
|
||||
|
||||
const oldHandle = resolveNode(filePath);
|
||||
if (!oldHandle || !oldHandle.handle) {
|
||||
alert('Could not find file to rename.');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await oldHandle.handle.getFile();
|
||||
const content = await file.text();
|
||||
|
||||
const newHandle = await parentHandle.getFileHandle(newName, { create: true });
|
||||
const writable = await newHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
const newFile = await newHandle.getFile();
|
||||
|
||||
await parentHandle.removeEntry(currentName);
|
||||
|
||||
// Update editor instances
|
||||
if (editorInstances.has(filePath)) {
|
||||
const instance = editorInstances.get(filePath);
|
||||
const newFilePath = filePath.substring(0, filePath.length - currentName.length) + newName;
|
||||
|
||||
// Remove old instance
|
||||
const data = editorInstances.get(filePath);
|
||||
if (data.fileViewContainer) {
|
||||
data.fileViewContainer.classList.add('hidden');
|
||||
}
|
||||
editorInstances.delete(filePath);
|
||||
|
||||
// Re-add with new path
|
||||
editorInstances.set(newFilePath, { ...data, fileHandle: newHandle, lastModified: newFile.lastModified });
|
||||
|
||||
// Update active state
|
||||
if (instance.fileViewContainer) {
|
||||
instance.fileViewContainer.classList.remove('hidden');
|
||||
instance.fileViewContainer.dataset.path = newFilePath;
|
||||
}
|
||||
|
||||
// Update fileTree entries
|
||||
const parts = filePath.split('/');
|
||||
const fileName = parts.pop();
|
||||
const dirPath = parts.join('/');
|
||||
let targetEntries = fileTree.entries;
|
||||
if (dirPath) {
|
||||
const dirParts = dirPath.split('/');
|
||||
let current = fileTree;
|
||||
for (const part of dirParts) {
|
||||
current = current.entries[part];
|
||||
}
|
||||
targetEntries = current.entries;
|
||||
}
|
||||
if (targetEntries && targetEntries[currentName]) {
|
||||
delete targetEntries[currentName];
|
||||
targetEntries[newName] = {
|
||||
name: newName,
|
||||
type: 'file',
|
||||
handle: newHandle
|
||||
};
|
||||
}
|
||||
|
||||
renderFileTree();
|
||||
restoreActiveFile(newFilePath);
|
||||
} else {
|
||||
renderFileTree();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
alert('Error renaming file: ' + error.message);
|
||||
}
|
||||
} else {
|
||||
// For directories: not supported by browser API
|
||||
alert('Directory rename is not supported by the browser File System API. Please rename the folder in your OS file manager and refresh.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or directory
|
||||
* @param {string} filePath - Full path like 'subdir/file.md' or 'subdir'
|
||||
* @param {boolean} isDirectory - true if deleting a directory
|
||||
*/
|
||||
async function deleteEntry(filePath, isDirectory) {
|
||||
const name = filePath.split('/').pop();
|
||||
|
||||
const message = isDirectory
|
||||
? 'Delete folder "' + name + '" and all its contents?'
|
||||
: 'Delete "' + name + '"?';
|
||||
|
||||
const ok = window.confirm(message);
|
||||
if (!ok) {
|
||||
if (DEBUG) console.log('Delete cancelled by user');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve parent directory handle
|
||||
const parentHandle = resolveParentDirHandle(filePath);
|
||||
if (!parentHandle) {
|
||||
alert('Could not locate parent directory.');
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = false;
|
||||
try {
|
||||
await parentHandle.removeEntry(name, { recursive: isDirectory });
|
||||
deleted = true;
|
||||
} catch (error) {
|
||||
if (error.name === 'NotFoundError') {
|
||||
// Already gone — treat as success for cleanup purposes
|
||||
deleted = true;
|
||||
} else {
|
||||
console.error('Error deleting entry:', error);
|
||||
alert('Error deleting entry: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted) {
|
||||
// Close editor if open
|
||||
if (!isDirectory && editorInstances.has(filePath)) {
|
||||
closeEditorInstance(filePath);
|
||||
} else if (isDirectory) {
|
||||
// Close any editors under this directory
|
||||
const dirsToClose = [];
|
||||
editorInstances.forEach(function(instance, key) {
|
||||
if (key === filePath || key.startsWith(filePath + '/')) {
|
||||
dirsToClose.push(key);
|
||||
}
|
||||
});
|
||||
dirsToClose.forEach(function(key) {
|
||||
closeEditorInstance(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from fileTree entries
|
||||
const parts = filePath.split('/');
|
||||
const entryName = parts.pop();
|
||||
const dirPath = parts.join('/');
|
||||
let targetEntries = fileTree.entries;
|
||||
if (dirPath) {
|
||||
const dirParts = dirPath.split('/');
|
||||
let current = fileTree;
|
||||
for (const part of dirParts) {
|
||||
current = current.entries[part];
|
||||
}
|
||||
targetEntries = current.entries;
|
||||
}
|
||||
if (targetEntries && targetEntries[entryName]) {
|
||||
delete targetEntries[entryName];
|
||||
}
|
||||
|
||||
renderFileTree();
|
||||
updateStatusCountsFromTree();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an editor instance and show welcome screen if no files open
|
||||
* @param {string} filePath - Path of file to close
|
||||
*/
|
||||
function closeEditorInstance(filePath) {
|
||||
const instance = editorInstances.get(filePath);
|
||||
if (!instance) return;
|
||||
|
||||
if (instance.fileViewContainer) {
|
||||
instance.fileViewContainer.classList.add('hidden');
|
||||
}
|
||||
editorInstances.delete(filePath);
|
||||
|
||||
// Check if any visible file-view-container children remain
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (contentContainer) {
|
||||
const visibleChildren = Array.from(contentContainer.querySelectorAll('.file-view-container'))
|
||||
.filter(function(el) { return !el.classList.contains('hidden'); });
|
||||
if (visibleChildren.length === 0) {
|
||||
document.getElementById('welcome-screen').classList.remove('hidden');
|
||||
contentContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore active file state after rename
|
||||
* @param {string} newFilePath - New path of the file
|
||||
*/
|
||||
function restoreActiveFile(newFilePath) {
|
||||
const element = document.querySelector('.file-item[data-path="' + CSS.escape(newFilePath) + '"]');
|
||||
if (element) {
|
||||
element.classList.add('active-file');
|
||||
element.style.backgroundColor = '';
|
||||
element.style.color = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status counts from fileTree
|
||||
*/
|
||||
function updateStatusCountsFromTree() {
|
||||
let folderCount = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
function countEntries(entries) {
|
||||
if (!entries) return;
|
||||
for (const [name, item] of Object.entries(entries)) {
|
||||
if (item.type === 'directory') {
|
||||
folderCount++;
|
||||
countEntries(item.entries);
|
||||
} else if (item.type === 'file') {
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
countEntries(fileTree.entries);
|
||||
updateStatusCounts(folderCount, fileCount);
|
||||
}
|
||||
|
|
@ -1,808 +0,0 @@
|
|||
/**
|
||||
* File system operations using File System Access API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Open the scratchpad editor
|
||||
*/
|
||||
function openScratchpad() {
|
||||
// Check if scratchpad already exists
|
||||
if (editorInstances.has(SCRATCHPAD_ID)) {
|
||||
// Just show it
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
// Hide all other editors, show scratchpad
|
||||
editorInstances.forEach((data, path) => {
|
||||
if (data.fileViewContainer) {
|
||||
data.fileViewContainer.style.display = path === SCRATCHPAD_ID ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide welcome screen, show content container
|
||||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
// Initialize editor with the welcome text seeded as the starting content.
|
||||
initializeEditor(SCRATCHPAD_WELCOME, true, SCRATCHPAD_ID, 'Scratchpad', null, null);
|
||||
|
||||
// Mark as scratchpad
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
if (instance) {
|
||||
instance.isScratchpad = true;
|
||||
}
|
||||
|
||||
// Reflect non-empty starting content on the scratchpad row's download button.
|
||||
updateScratchpadDownloadState();
|
||||
|
||||
if (DEBUG) console.log('Opened scratchpad');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable the scratchpad-row download button based on whether the
|
||||
* scratchpad currently holds any content. Idempotent — safe to call from
|
||||
* editor change listeners.
|
||||
*/
|
||||
function updateScratchpadDownloadState() {
|
||||
const btn = document.getElementById('scratchpad-download-btn');
|
||||
if (!btn) return;
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
let hasContent = false;
|
||||
if (instance && instance.editor) {
|
||||
try {
|
||||
hasContent = (instance.editor.getMarkdown() || '').trim().length > 0;
|
||||
} catch (_) { /* editor may not be ready yet */ }
|
||||
}
|
||||
btn.disabled = !hasContent;
|
||||
btn.classList.toggle('is-disabled', !hasContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of the current scratchpad markdown.
|
||||
* No-op if the scratchpad has no content.
|
||||
*/
|
||||
function downloadScratchpad() {
|
||||
const instance = editorInstances.get(SCRATCHPAD_ID);
|
||||
if (!instance || !instance.editor) return;
|
||||
let content = '';
|
||||
try { content = instance.editor.getMarkdown() || ''; } catch (_) { return; }
|
||||
|
||||
// Pull front matter from the textarea if any
|
||||
if (instance.frontMatterTextarea) {
|
||||
const fmText = instance.frontMatterTextarea.value.trim();
|
||||
if (fmText) content = `---\n${fmText}\n---\n${content}`;
|
||||
}
|
||||
|
||||
if (!content.trim()) return;
|
||||
|
||||
// Suggest a filename derived from the first H1 if present
|
||||
let suggested = 'scratchpad.md';
|
||||
const h1 = content.match(/^#\s+(.+)$/m);
|
||||
if (h1) {
|
||||
const slug = h1[1].trim().toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.substring(0, 60);
|
||||
if (slug) suggested = `${slug}.md`;
|
||||
}
|
||||
|
||||
saveFileAs(content, suggested);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save file using Save As dialog (for scratchpads or new saves)
|
||||
* @param {string} content - Content to save
|
||||
* @param {string} suggestedName - Suggested filename
|
||||
* @returns {Promise<FileSystemFileHandle|null>} File handle if saved, null otherwise
|
||||
*/
|
||||
async function saveFileAs(content, suggestedName = 'untitled.md') {
|
||||
if (hasFileSystemAccess) {
|
||||
try {
|
||||
const fileHandle = await window.showSaveFilePicker({
|
||||
suggestedName: suggestedName,
|
||||
types: [{
|
||||
description: 'Markdown files',
|
||||
accept: { 'text/markdown': ['.md', '.markdown'] }
|
||||
}]
|
||||
});
|
||||
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(content);
|
||||
await writable.close();
|
||||
|
||||
if (DEBUG) console.log(`File saved as: ${fileHandle.name}`);
|
||||
return fileHandle;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (DEBUG) console.log('Save cancelled by user');
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Fallback: download as blob
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = suggestedName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
if (DEBUG) console.log(`File downloaded as: ${suggestedName}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open directory picker and handle selection
|
||||
*/
|
||||
async function openDirectory() {
|
||||
try {
|
||||
if (!('showDirectoryPicker' in window)) {
|
||||
throw new Error('The File System API is not supported in this browser.');
|
||||
}
|
||||
|
||||
directoryHandle = await window.showDirectoryPicker();
|
||||
if (DEBUG) console.log('Directory selected:', directoryHandle.name);
|
||||
|
||||
// Local picker wins over any active server-source mode.
|
||||
serverSourceMode = false;
|
||||
|
||||
updateDirectoryStatus(directoryHandle.name);
|
||||
await readDirectory(directoryHandle);
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
if (DEBUG) console.log('User cancelled the directory selection');
|
||||
} else {
|
||||
console.error('Error selecting directory:', error);
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update UI to show selected directory
|
||||
* @param {string} directoryName - Name of the selected directory
|
||||
*/
|
||||
function updateDirectoryStatus(directoryName) {
|
||||
// Standardized header pattern (across all ZDDC tools): the button
|
||||
// keeps the label "Add Local Directory"; de-emphasize it once a
|
||||
// directory is loaded (the user can still click to pick another)
|
||||
// by applying the shared btn--subtle variant. The directory name
|
||||
// is shown in the file-nav pane, not on the button.
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.classList.remove('btn-primary');
|
||||
selectDirectoryBtn.classList.add('btn--subtle');
|
||||
selectDirectoryBtn.title = `Loaded: ${directoryName} — click to switch`;
|
||||
}
|
||||
|
||||
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show new file button when directory is selected
|
||||
const newFileRootBtn = document.getElementById('new-file-root');
|
||||
if (newFileRootBtn) {
|
||||
newFileRootBtn.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read directory contents and build tree structure
|
||||
* @param {FileSystemDirectoryHandle} dirHandle - Directory handle
|
||||
* @param {Object} parentNode - Parent node in tree (for recursion)
|
||||
* @returns {Object} Statistics about the directory
|
||||
*/
|
||||
async function readDirectory(dirHandle, parentNode = null) {
|
||||
if (parentNode === null) {
|
||||
fileTree = {
|
||||
name: dirHandle.name,
|
||||
type: 'directory',
|
||||
handle: dirHandle,
|
||||
entries: {}
|
||||
};
|
||||
|
||||
const fileTreeElement = document.getElementById('file-tree');
|
||||
if (fileTreeElement) {
|
||||
fileTreeElement.innerHTML = '';
|
||||
}
|
||||
|
||||
parentNode = fileTree;
|
||||
}
|
||||
|
||||
try {
|
||||
let stats = { folderCount: 0, fileCount: 0 };
|
||||
|
||||
for await (const entry of dirHandle.values()) {
|
||||
if (entry.kind === 'file' && !entry.name.startsWith('_')) {
|
||||
parentNode.entries[entry.name] = {
|
||||
name: entry.name,
|
||||
type: 'file',
|
||||
handle: entry
|
||||
};
|
||||
stats.fileCount++;
|
||||
} else if (entry.kind === 'directory' && !entry.name.startsWith('_')) {
|
||||
const dirNode = {
|
||||
name: entry.name,
|
||||
type: 'directory',
|
||||
handle: entry,
|
||||
entries: {}
|
||||
};
|
||||
|
||||
parentNode.entries[entry.name] = dirNode;
|
||||
|
||||
const subStats = await readDirectory(entry, dirNode);
|
||||
stats.folderCount += subStats.folderCount + 1;
|
||||
stats.fileCount += subStats.fileCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (parentNode === fileTree) {
|
||||
renderFileTree();
|
||||
updateStatusCounts(stats.folderCount, stats.fileCount);
|
||||
}
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Error reading directory:', error);
|
||||
return { folderCount: 0, fileCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a file by its path
|
||||
* @param {string} filePath - Path of the file to save
|
||||
* @returns {Promise<boolean>} Whether save was successful
|
||||
*/
|
||||
async function saveFile(filePath) {
|
||||
if (!filePath && currentFileHandle) {
|
||||
filePath = currentFileHandle.name;
|
||||
} else if (!filePath) {
|
||||
alert('No file is currently open');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorInstance = editorInstances.get(filePath);
|
||||
if (!editorInstance) {
|
||||
throw new Error('No editor instance found for this file');
|
||||
}
|
||||
|
||||
if (!editorInstance.isDirty) {
|
||||
if (DEBUG) console.log(`File ${filePath} is not dirty, skipping save`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) {
|
||||
throw new Error('No file handle available for this file');
|
||||
}
|
||||
|
||||
// Check for external modifications
|
||||
const file = await fileHandle.getFile();
|
||||
const currentLastModified = file.lastModified;
|
||||
const storedLastModified = editorInstance.lastModified;
|
||||
|
||||
if (storedLastModified && currentLastModified !== storedLastModified) {
|
||||
const confirmSave = confirm(
|
||||
'Warning: This file has been modified outside of the application since you opened it. ' +
|
||||
'Saving will overwrite those changes. Do you want to continue?'
|
||||
);
|
||||
|
||||
if (!confirmSave) {
|
||||
if (DEBUG) console.log('Save aborted by user due to external file modifications');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get markdown content from editor
|
||||
const markdownContent = editorInstance.editor.getMarkdown();
|
||||
|
||||
// Get front matter from textarea
|
||||
let frontMatterData = {};
|
||||
if (editorInstance.frontMatterTextarea) {
|
||||
const frontMatterText = editorInstance.frontMatterTextarea.value.trim();
|
||||
if (frontMatterText) {
|
||||
try {
|
||||
const yamlContent = `---\n${frontMatterText}\n---\n`;
|
||||
const parsed = parseFrontMatter(yamlContent);
|
||||
frontMatterData = parsed.data;
|
||||
} catch (error) {
|
||||
console.error('Error parsing front matter:', error);
|
||||
throw new Error(`Invalid YAML front matter: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply before save hooks
|
||||
frontMatterData = await applyBeforeSaveHooks(frontMatterData, markdownContent, fileHandle);
|
||||
|
||||
// Combine front matter with markdown
|
||||
const finalContent = frontMatterData && Object.keys(frontMatterData).length > 0
|
||||
? stringifyFrontMatter(markdownContent, frontMatterData)
|
||||
: markdownContent;
|
||||
|
||||
// Server-mode files are read-only: fall back to a Save-As download.
|
||||
if (typeof fileHandle.createWritable !== 'function') {
|
||||
const downloadName = (fileHandle.name || filePath.split('/').pop() || 'untitled.md');
|
||||
await saveFileAs(finalContent, downloadName);
|
||||
editorInstance.isDirty = false;
|
||||
updateFileDirtyStatus(filePath, false);
|
||||
updateUnsavedCount();
|
||||
if (editorInstance.saveButton) editorInstance.saveButton.disabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
const writable = await fileHandle.createWritable();
|
||||
await writable.write(finalContent);
|
||||
await writable.close();
|
||||
|
||||
// Update state
|
||||
const updatedFile = await fileHandle.getFile();
|
||||
editorInstance.lastModified = updatedFile.lastModified;
|
||||
editorInstance.isDirty = false;
|
||||
updateFileDirtyStatus(filePath, false);
|
||||
updateUnsavedCount();
|
||||
|
||||
if (editorInstance.saveButton) {
|
||||
editorInstance.saveButton.disabled = true;
|
||||
}
|
||||
|
||||
if (DEBUG) console.log(`File ${filePath} saved successfully!`);
|
||||
|
||||
await applyAfterSaveHooks(frontMatterData, markdownContent, fileHandle);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error saving file ${filePath}:`, error);
|
||||
alert(`Error saving file: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all files with unsaved changes
|
||||
* @returns {Promise<{saved: number, failed: number}>}
|
||||
*/
|
||||
async function saveAllFiles() {
|
||||
let saved = 0;
|
||||
let failed = 0;
|
||||
|
||||
const dirtyFiles = [];
|
||||
editorInstances.forEach((instance, filePath) => {
|
||||
if (instance.isDirty) {
|
||||
dirtyFiles.push(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (dirtyFiles.length === 0) {
|
||||
if (DEBUG) console.log('No files with unsaved changes');
|
||||
return { saved, failed };
|
||||
}
|
||||
|
||||
for (const filePath of dirtyFiles) {
|
||||
try {
|
||||
const success = await saveFile(filePath);
|
||||
if (success) {
|
||||
saved++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error saving file ${filePath}:`, error);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
if (DEBUG) console.log(`All ${saved} files saved successfully`);
|
||||
} else {
|
||||
if (DEBUG) console.log(`Saved ${saved} files, ${failed} files failed to save`);
|
||||
}
|
||||
|
||||
return { saved, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload file from disk
|
||||
* @param {string} filePath - Path of file to reload
|
||||
* @returns {Promise<boolean>} Whether reload was successful
|
||||
*/
|
||||
async function reloadFileFromDisk(filePath) {
|
||||
try {
|
||||
const editorInstance = editorInstances.get(filePath);
|
||||
if (!editorInstance) {
|
||||
throw new Error('No editor instance found for this file');
|
||||
}
|
||||
|
||||
if (editorInstance.isDirty) {
|
||||
const confirmReload = confirm(
|
||||
'This file has unsaved changes. Reloading will discard all changes. ' +
|
||||
'Do you want to continue?'
|
||||
);
|
||||
|
||||
if (!confirmReload) {
|
||||
if (DEBUG) console.log('Reload cancelled by user');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) {
|
||||
throw new Error('No file handle available for this file');
|
||||
}
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const fileContent = await file.text();
|
||||
|
||||
editorInstance.lastModified = file.lastModified;
|
||||
|
||||
if (filePath.endsWith('.md') || filePath.endsWith('.markdown')) {
|
||||
const parsed = parseFrontMatter(fileContent);
|
||||
|
||||
if (editorInstance.frontMatterTextarea) {
|
||||
const frontMatterYaml = stringifyFrontMatterToTextarea(parsed.data);
|
||||
editorInstance.frontMatterTextarea.value = frontMatterYaml;
|
||||
}
|
||||
|
||||
let currentScrollTop = 0;
|
||||
try {
|
||||
currentScrollTop = editorInstance.editor.getScrollTop();
|
||||
} catch (error) {
|
||||
if (DEBUG) console.debug('Could not get scroll position:', error);
|
||||
}
|
||||
|
||||
editorInstance.editor.setMarkdown(parsed.content);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
editorInstance.editor.setScrollTop(currentScrollTop);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.debug('Could not restore scroll position:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
if (editorInstance.tocContainer) {
|
||||
try {
|
||||
updateToc(parsed.content, editorInstance.tocContainer, editorInstance.editor, tocMaxDepth);
|
||||
} catch (error) {
|
||||
console.error('Error updating TOC during reload:', error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
editorInstance.editor.setMarkdown(fileContent);
|
||||
}
|
||||
|
||||
editorInstance.isDirty = false;
|
||||
updateFileDirtyStatus(filePath, false);
|
||||
updateUnsavedCount();
|
||||
|
||||
if (editorInstance.saveButton) {
|
||||
editorInstance.saveButton.disabled = true;
|
||||
}
|
||||
|
||||
if (DEBUG) console.log(`File ${filePath} reloaded successfully from disk!`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading file ${filePath}:`, error);
|
||||
alert(`Error reloading file: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before save hook - apply modifications before saving
|
||||
*/
|
||||
async function applyBeforeSaveHooks(frontMatter, markdownContent, fileHandle) {
|
||||
frontMatter.lastModified = new Date().toISOString();
|
||||
|
||||
if (!frontMatter.title) {
|
||||
const firstHeading = markdownContent.match(/^#\s+(.+)$/m);
|
||||
if (firstHeading) {
|
||||
frontMatter.title = firstHeading[1];
|
||||
}
|
||||
}
|
||||
|
||||
const customTags = (markdownContent.match(/<(deliverable|meeting|report|trkno)>/g) || []).length;
|
||||
if (customTags > 0) {
|
||||
frontMatter.customTagCount = customTags;
|
||||
}
|
||||
|
||||
return frontMatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* After save hook - perform actions after saving
|
||||
*/
|
||||
async function applyAfterSaveHooks(frontMatter, markdownContent, fileHandle) {
|
||||
const tags = ['deliverable', 'meeting', 'report', 'trkno'];
|
||||
const preservedTags = tags.filter(tag => markdownContent.includes(`<${tag}>`));
|
||||
if (preservedTags.length > 0) {
|
||||
if (DEBUG) console.log(`Preserved custom tags: ${preservedTags.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh directory from disk without losing unsaved work
|
||||
*/
|
||||
async function refreshDirectory() {
|
||||
if (serverSourceMode) {
|
||||
await loadServerDirectory();
|
||||
return;
|
||||
}
|
||||
if (!directoryHandle) {
|
||||
if (DEBUG) console.log('No directory selected, cannot refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get active file path from DOM before refresh
|
||||
const activeFileEl = document.querySelector('.file-item.active-file');
|
||||
const activeFilePath = activeFileEl ? activeFileEl.dataset.path : null;
|
||||
|
||||
// Get dirty files from editorInstances
|
||||
const dirtyFiles = new Set();
|
||||
editorInstances.forEach((instance, filePath) => {
|
||||
if (instance.isDirty) {
|
||||
dirtyFiles.add(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-read directory (calls renderFileTree at the end)
|
||||
await readDirectory(directoryHandle);
|
||||
|
||||
// Restore active file state
|
||||
if (activeFilePath) {
|
||||
const activeElement = document.querySelector(`.file-item[data-path="${activeFilePath}"]`);
|
||||
if (activeElement) {
|
||||
activeElement.classList.add('active-file');
|
||||
}
|
||||
}
|
||||
|
||||
// Restore dirty indicators
|
||||
dirtyFiles.forEach(filePath => {
|
||||
updateFileDirtyStatus(filePath, true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a clear "no permission to list this directory" message in
|
||||
* the file tree pane when the server returns 403 on the initial
|
||||
* listing. Distinct from "host doesn't serve JSON" so the user
|
||||
* understands why the tree is empty.
|
||||
*/
|
||||
function showServerForbiddenMessage() {
|
||||
const treeEl = document.getElementById('file-tree');
|
||||
if (!treeEl) return;
|
||||
treeEl.innerHTML =
|
||||
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
|
||||
'<strong>No permission to list this directory.</strong>' +
|
||||
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
|
||||
'Contact the document controller if you believe this is wrong.</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CRUD-capable file handle backed by a URL — uses the shared
|
||||
* HTTP polyfill from window.zddc.source. The polyfill's getFile() does
|
||||
* a GET, and createWritable() PUTs bytes back (file API on zddc-server).
|
||||
*
|
||||
* Adds `_serverUrl` for legacy code paths that still expect that field.
|
||||
* Marks `_readOnly: false` so editor.js leaves save buttons enabled.
|
||||
*/
|
||||
function createServerFileHandle(name, url) {
|
||||
const handle = new window.zddc.source.HttpFileHandle(url, name);
|
||||
handle._serverUrl = url;
|
||||
handle._readOnly = false;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CRUD-capable directory handle backed by a server URL — uses
|
||||
* the shared HTTP polyfill. Supports values()/entries(), getFileHandle,
|
||||
* getDirectoryHandle({create}), and removeEntry() against the server
|
||||
* file API. _serverUrl/_readOnly are kept for legacy probes.
|
||||
*/
|
||||
function createServerDirectoryHandle(name, url) {
|
||||
const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
|
||||
handle._serverUrl = url;
|
||||
handle._readOnly = false;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively fetch the JSON directory listing for `dirUrl` and populate
|
||||
* `parentNode.entries` with synthetic handles. Returns folder/file counts.
|
||||
* Uses the same Caddy/zddc-server JSON shape archive's source.js consumes.
|
||||
*/
|
||||
async function readServerDirectory(dirUrl, parentNode, depth) {
|
||||
if (depth > 10) return { folderCount: 0, fileCount: 0 };
|
||||
|
||||
let items;
|
||||
try {
|
||||
const resp = await fetch(dirUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
items = await resp.json();
|
||||
if (!Array.isArray(items)) throw new Error('Expected JSON array');
|
||||
} catch (err) {
|
||||
if (DEBUG) console.warn(`Server listing failed for ${dirUrl}:`, err);
|
||||
return { folderCount: 0, fileCount: 0 };
|
||||
}
|
||||
|
||||
const stats = { folderCount: 0, fileCount: 0 };
|
||||
const subdirPromises = [];
|
||||
|
||||
for (const item of items) {
|
||||
const rawName = item.name.endsWith('/') ? item.name.slice(0, -1) : item.name;
|
||||
if (rawName.startsWith('.') || rawName.startsWith('_')) continue;
|
||||
|
||||
const base = dirUrl.endsWith('/') ? dirUrl : dirUrl + '/';
|
||||
const childUrl = base + encodeURIComponent(rawName) + (item.is_dir ? '/' : '');
|
||||
|
||||
if (item.is_dir) {
|
||||
const dirNode = {
|
||||
name: rawName,
|
||||
type: 'directory',
|
||||
handle: createServerDirectoryHandle(rawName, childUrl),
|
||||
entries: {},
|
||||
};
|
||||
parentNode.entries[rawName] = dirNode;
|
||||
stats.folderCount++;
|
||||
subdirPromises.push(
|
||||
readServerDirectory(childUrl, dirNode, depth + 1).then((s) => {
|
||||
stats.folderCount += s.folderCount;
|
||||
stats.fileCount += s.fileCount;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
parentNode.entries[rawName] = {
|
||||
name: rawName,
|
||||
type: 'file',
|
||||
handle: createServerFileHandle(rawName, childUrl),
|
||||
};
|
||||
stats.fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(subdirPromises);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect HTTP context, fetch the directory the page lives under, and render
|
||||
* the resulting subtree in the file pane. Idempotent — safe to re-call.
|
||||
*/
|
||||
async function loadServerDirectory() {
|
||||
if (!(location.protocol === 'http:' || location.protocol === 'https:')) return;
|
||||
|
||||
// Compute the directory URL the file tree should be rooted at.
|
||||
//
|
||||
// <project>/working/ → root = <project>/working/
|
||||
// <project>/working/x/y/ → root = <project>/working/x/y/
|
||||
// <project>/working → root = <project>/working/ (no-slash
|
||||
// canonical-folder URL — the dispatcher
|
||||
// routes mdedit here directly without
|
||||
// a redirect, so we infer "directory"
|
||||
// from the absence of a `.` in the
|
||||
// last segment rather than stripping
|
||||
// back to the parent.)
|
||||
// <project>/x/y/mdedit.html → root = <project>/x/y/ (the leaf
|
||||
// segment IS a file; strip to parent.)
|
||||
//
|
||||
// The rule: if the last path segment contains a "." it's a file,
|
||||
// strip it; otherwise treat the whole path as the directory.
|
||||
let href = window.location.href.split('?')[0].split('#')[0];
|
||||
let baseUrl;
|
||||
if (href.endsWith('/')) {
|
||||
baseUrl = href;
|
||||
} else {
|
||||
const lastSlash = href.lastIndexOf('/');
|
||||
const lastSeg = lastSlash >= 0 ? href.substring(lastSlash + 1) : href;
|
||||
if (lastSeg.indexOf('.') !== -1) {
|
||||
// Looks like a file (has an extension) — strip to parent.
|
||||
baseUrl = lastSlash >= 0 ? href.substring(0, lastSlash + 1) : href + '/';
|
||||
} else {
|
||||
// Looks like a directory — append the trailing slash so all
|
||||
// subsequent listing URLs are computed correctly.
|
||||
baseUrl = href + '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Only enter server-source mode if the host actually serves JSON directory
|
||||
// listings (zddc-server / Caddy). On a plain static host the probe fails
|
||||
// and we must leave "Add Local Directory" visible so the user can still
|
||||
// load local files.
|
||||
//
|
||||
// 403 means the host is a zddc-server but the user lacks `r` on this
|
||||
// directory (a "no list" permission posture). Show a clear message so
|
||||
// the user understands why the tree is empty.
|
||||
try {
|
||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||
if (resp.status === 403) {
|
||||
showServerForbiddenMessage();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
const items = await resp.json();
|
||||
if (!Array.isArray(items)) return;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
serverSourceMode = true;
|
||||
|
||||
const rootName = (() => {
|
||||
const path = baseUrl.replace(/\/$/, '');
|
||||
const seg = path.substring(path.lastIndexOf('/') + 1);
|
||||
return seg || baseUrl;
|
||||
})();
|
||||
|
||||
fileTree = {
|
||||
name: rootName,
|
||||
type: 'directory',
|
||||
handle: createServerDirectoryHandle(rootName, baseUrl),
|
||||
entries: {},
|
||||
};
|
||||
|
||||
// Surface refresh. The server now exposes a CRUD file API, so write
|
||||
// controls (new file, save, delete) stay enabled — the polyfill
|
||||
// routes their writes through PUT/DELETE/POST. "Add Local Directory"
|
||||
// is de-emphasized so the user can still load a local folder if they
|
||||
// want, but server-mode is now the default working mode.
|
||||
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||
const addDirBtn = document.getElementById('addDirectoryBtn');
|
||||
if (addDirBtn) {
|
||||
addDirBtn.classList.remove('btn-primary');
|
||||
addDirBtn.classList.add('btn--subtle');
|
||||
}
|
||||
|
||||
const stats = await readServerDirectory(baseUrl, fileTree, 0);
|
||||
renderFileTree();
|
||||
updateStatusCounts(stats.folderCount, stats.fileCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start monitoring files for external changes
|
||||
*/
|
||||
function startFileChangeMonitoring() {
|
||||
setInterval(async () => {
|
||||
for (const [filePath, editorInstance] of editorInstances) {
|
||||
try {
|
||||
const fileHandle = editorInstance.fileHandle;
|
||||
if (!fileHandle) continue;
|
||||
if (fileHandle._readOnly) continue;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const currentLastModified = file.lastModified;
|
||||
const storedLastModified = editorInstance.lastModified;
|
||||
|
||||
if (storedLastModified && currentLastModified !== storedLastModified) {
|
||||
if (DEBUG) console.log(`File ${filePath} changed externally`);
|
||||
|
||||
const action = confirm(
|
||||
`File "${filePath}" has been modified by another application.\n\n` +
|
||||
'Click OK to reload from disk (discards unsaved changes)\n' +
|
||||
'Click Cancel to keep current version'
|
||||
);
|
||||
|
||||
if (action) {
|
||||
await reloadFileFromDisk(filePath);
|
||||
} else {
|
||||
editorInstance.lastModified = currentLastModified;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.debug(`Error checking file ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
|
@ -1,868 +0,0 @@
|
|||
/**
|
||||
* File tree rendering and navigation
|
||||
*/
|
||||
|
||||
// Cache for lazily loaded CDN libraries
|
||||
const loadedLibraries = new Map();
|
||||
|
||||
/**
|
||||
* Lazily load a script from CDN. Returns a promise that resolves when loaded.
|
||||
* Caches the promise so subsequent calls return immediately.
|
||||
*/
|
||||
function loadLibrary(url) {
|
||||
if (loadedLibraries.has(url)) return loadedLibraries.get(url);
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.onload = resolve;
|
||||
script.onerror = () => reject(new Error(`Failed to load library: ${url}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
loadedLibraries.set(url, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the file tree in the UI
|
||||
*/
|
||||
/**
|
||||
* Create action buttons for file/directory items
|
||||
* @param {string} filePath - Full path of the file/dir
|
||||
* @param {string} type - 'file' or 'directory'
|
||||
*/
|
||||
function createActionButtons(filePath, type) {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'tree-actions';
|
||||
|
||||
// Server mode now supports full CRUD via the file API — drop the
|
||||
// legacy short-circuit that hid the rename/delete/new-file actions.
|
||||
|
||||
if (type === 'directory') {
|
||||
// Directory: + (new file) + ✕ (delete)
|
||||
const newFileBtn = document.createElement('button');
|
||||
newFileBtn.className = 'tree-btn';
|
||||
newFileBtn.setAttribute('title', 'New file');
|
||||
newFileBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
newFileBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
createNewFile(filePath);
|
||||
};
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'tree-btn tree-btn--danger';
|
||||
deleteBtn.setAttribute('title', 'Delete');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteEntry(filePath, true);
|
||||
};
|
||||
|
||||
actionsDiv.appendChild(newFileBtn);
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
} else {
|
||||
// File: ✎ (rename) + ✕ (delete)
|
||||
const renameBtn = document.createElement('button');
|
||||
renameBtn.className = 'tree-btn';
|
||||
renameBtn.setAttribute('title', 'Rename');
|
||||
renameBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
||||
renameBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
renameEntry(filePath, false);
|
||||
};
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'tree-btn tree-btn--danger';
|
||||
deleteBtn.setAttribute('title', 'Delete');
|
||||
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
deleteBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
deleteEntry(filePath, false);
|
||||
};
|
||||
|
||||
actionsDiv.appendChild(renameBtn);
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
return actionsDiv;
|
||||
}
|
||||
|
||||
function renderFileTree() {
|
||||
const fileTreeElement = document.getElementById('file-tree');
|
||||
if (!fileTreeElement) return;
|
||||
|
||||
fileTreeElement.innerHTML = '';
|
||||
|
||||
// Always show scratchpad at top
|
||||
const scratchpadElement = document.createElement('div');
|
||||
scratchpadElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 border-b border-gray-200 dark:border-gray-700 mb-2';
|
||||
scratchpadElement.dataset.type = 'file';
|
||||
scratchpadElement.dataset.path = SCRATCHPAD_ID;
|
||||
scratchpadElement.dataset.name = 'Scratchpad';
|
||||
|
||||
const scratchLabel = document.createElement('span');
|
||||
scratchLabel.className = 'tree-row__label';
|
||||
scratchLabel.innerHTML = '<span class="tree-row__name"><div class="filename-main">📝 Scratchpad</div><div class="filename-secondary">Quick notes — no directory needed</div></span>';
|
||||
scratchpadElement.appendChild(scratchLabel);
|
||||
|
||||
const scratchActions = document.createElement('div');
|
||||
scratchActions.className = 'tree-actions tree-actions--always';
|
||||
|
||||
const scratchDownloadBtn = document.createElement('button');
|
||||
scratchDownloadBtn.id = 'scratchpad-download-btn';
|
||||
scratchDownloadBtn.className = 'tree-btn';
|
||||
scratchDownloadBtn.title = 'Download scratchpad as a Markdown file';
|
||||
scratchDownloadBtn.setAttribute('aria-label', 'Download scratchpad');
|
||||
scratchDownloadBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M5 21h14"/></svg>';
|
||||
scratchDownloadBtn.disabled = true;
|
||||
scratchDownloadBtn.classList.add('is-disabled');
|
||||
scratchDownloadBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (scratchDownloadBtn.disabled) return;
|
||||
downloadScratchpad();
|
||||
};
|
||||
scratchActions.appendChild(scratchDownloadBtn);
|
||||
scratchpadElement.appendChild(scratchActions);
|
||||
|
||||
scratchpadElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
openScratchpad();
|
||||
document.querySelectorAll('.file-item').forEach(el => el.classList.remove('active-file'));
|
||||
scratchpadElement.classList.add('active-file');
|
||||
updateScratchpadDownloadState();
|
||||
});
|
||||
|
||||
fileTreeElement.appendChild(scratchpadElement);
|
||||
// Sync button state with current scratchpad content (re-renders preserve it)
|
||||
updateScratchpadDownloadState();
|
||||
|
||||
function createFileTreeHTML(directory, parentElement, path = '') {
|
||||
if (!directory || !directory.entries) return;
|
||||
|
||||
// Sort entries: files first, then directories, alphabetically
|
||||
const sortedEntries = Object.entries(directory.entries).sort((a, b) => {
|
||||
const [nameA, itemA] = a;
|
||||
const [nameB, itemB] = b;
|
||||
|
||||
if (itemA.type !== itemB.type) {
|
||||
return itemA.type === 'file' ? -1 : 1;
|
||||
}
|
||||
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
for (const [name, item] of sortedEntries) {
|
||||
if (item.type === 'directory') {
|
||||
const dirElement = document.createElement('div');
|
||||
dirElement.className = 'directory-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800 collapsed';
|
||||
dirElement.dataset.type = 'directory';
|
||||
const currentPath = path ? `${path}/${name}` : name;
|
||||
dirElement.dataset.path = currentPath;
|
||||
|
||||
const dirIcon = document.createElement('span');
|
||||
dirIcon.className = 'dir-icon mr-1';
|
||||
dirIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
||||
|
||||
const dirName = document.createElement('span');
|
||||
dirName.className = 'tree-row__name';
|
||||
const parsedFolder = zddc.parseFolder(name);
|
||||
if (parsedFolder && parsedFolder.valid) {
|
||||
const meta = `${parsedFolder.trackingNumber} (${parsedFolder.status}) — ${parsedFolder.date}`;
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(parsedFolder.title)}</div><div class="filename-secondary">${escapeHtml(meta)}</div>`;
|
||||
} else {
|
||||
// Non-ZDDC folder: still wrap in filename-main so
|
||||
// typography matches the two-line entries (same font
|
||||
// size + weight; just no secondary line).
|
||||
dirName.innerHTML = `<div class="filename-main">📁 ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const dirLabel = document.createElement('span');
|
||||
dirLabel.className = 'tree-row__label';
|
||||
dirLabel.appendChild(dirIcon);
|
||||
dirLabel.appendChild(dirName);
|
||||
|
||||
const dirActions = createActionButtons(currentPath, 'directory');
|
||||
|
||||
dirElement.appendChild(dirLabel);
|
||||
dirElement.appendChild(dirActions);
|
||||
parentElement.appendChild(dirElement);
|
||||
|
||||
const contentsElement = document.createElement('div');
|
||||
contentsElement.className = 'directory-contents ml-4';
|
||||
contentsElement.style.display = 'none';
|
||||
parentElement.appendChild(contentsElement);
|
||||
|
||||
dirElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
dirElement.classList.toggle('collapsed');
|
||||
|
||||
const contents = dirElement.nextElementSibling;
|
||||
if (contents && contents.classList.contains('directory-contents')) {
|
||||
contents.style.display = dirElement.classList.contains('collapsed') ? 'none' : 'block';
|
||||
}
|
||||
});
|
||||
|
||||
createFileTreeHTML(item, contentsElement, currentPath);
|
||||
} else if (item.type === 'file') {
|
||||
const fileElement = document.createElement('div');
|
||||
fileElement.className = 'file-item tree-row px-2 py-1 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-gray-800';
|
||||
fileElement.dataset.type = 'file';
|
||||
const filePath = path ? `${path}/${name}` : name;
|
||||
fileElement.dataset.path = filePath;
|
||||
fileElement.dataset.name = name;
|
||||
|
||||
const fileIcon = getFileTypeIcon(name);
|
||||
|
||||
// Build the inner two-line text inside a tree-row__name
|
||||
// wrapper (column-flex). ZDDC-conforming filenames split
|
||||
// into title + meta; "Title - filename.ext" pattern uses
|
||||
// the dash as the same split. Plain names get a single
|
||||
// line via filename-main only — same wrapper, just no
|
||||
// secondary div, so the layout stays consistent.
|
||||
let fileNameInner;
|
||||
const parsed = zddc.parseFilename(name);
|
||||
if (parsed && parsed.valid) {
|
||||
const titleDisplay = escapeHtml(parsed.title);
|
||||
const metaDisplay = escapeHtml(`${parsed.trackingNumber}_${parsed.revision} (${parsed.status})`);
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${titleDisplay}</div><div class="filename-secondary">${metaDisplay}</div>`;
|
||||
} else if (name.includes(' - ')) {
|
||||
const dashIdx = name.lastIndexOf(' - ');
|
||||
const secondary = escapeHtml(name.substring(0, dashIdx));
|
||||
const primary = escapeHtml(name.substring(dashIdx + 3).replace(/\.[^.]+$/, ''));
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${primary}</div><div class="filename-secondary">${secondary}</div>`;
|
||||
} else {
|
||||
fileNameInner = `<div class="filename-main">${fileIcon} ${escapeHtml(name)}</div>`;
|
||||
}
|
||||
|
||||
const fileLabel = document.createElement('span');
|
||||
fileLabel.className = 'tree-row__label';
|
||||
fileLabel.innerHTML = `<span class="tree-row__name">${fileNameInner}</span>`;
|
||||
|
||||
const fileActions = createActionButtons(filePath, 'file');
|
||||
|
||||
fileElement.innerHTML = '';
|
||||
fileElement.appendChild(fileLabel);
|
||||
fileElement.appendChild(fileActions);
|
||||
|
||||
fileElement.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
handleFileClick(item.handle, filePath, fileElement);
|
||||
});
|
||||
|
||||
parentElement.appendChild(fileElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createFileTreeHTML(fileTree, fileTreeElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on a file in the file tree
|
||||
* @param {FileSystemFileHandle} fileHandle - The file handle
|
||||
* @param {string} filePath - Path of the file
|
||||
* @param {HTMLElement} fileElement - The clicked element
|
||||
*/
|
||||
async function handleFileClick(fileHandle, filePath, fileElement) {
|
||||
try {
|
||||
currentFileHandle = fileHandle;
|
||||
|
||||
// Remove active class from all file items
|
||||
const allFileItems = document.querySelectorAll('.file-item');
|
||||
allFileItems.forEach(item => {
|
||||
item.classList.remove('active-file');
|
||||
item.style.backgroundColor = '';
|
||||
item.style.color = '';
|
||||
});
|
||||
|
||||
// Add active class to clicked file
|
||||
fileElement.classList.add('active-file');
|
||||
fileElement.style.backgroundColor = '#3b82f6';
|
||||
fileElement.style.color = 'white';
|
||||
|
||||
await displayFileContent(fileHandle, filePath);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling file click:', error);
|
||||
alert(`Error opening file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display file content in main area
|
||||
* @param {FileSystemFileHandle} fileHandle - File handle
|
||||
* @param {string} filePath - Path of the file
|
||||
*/
|
||||
async function displayFileContent(fileHandle, filePath) {
|
||||
try {
|
||||
currentFileHandle = fileHandle;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
const fileName = file.name;
|
||||
const lastModified = file.lastModified;
|
||||
|
||||
document.getElementById('welcome-screen').classList.add('hidden');
|
||||
document.getElementById('content-container').classList.remove('hidden');
|
||||
|
||||
const lower = fileName.toLowerCase();
|
||||
const lastDot = lower.lastIndexOf('.');
|
||||
const ext = lastDot >= 0 ? lower.substring(lastDot + 1) : '';
|
||||
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
|
||||
const isImage = imageExtensions.some(e => lower.endsWith(e));
|
||||
const isTiff = window.zddc && window.zddc.preview && window.zddc.preview.isTiff(ext);
|
||||
const isZip = lower.endsWith('.zip');
|
||||
const isHtml = lower.endsWith('.html') || lower.endsWith('.htm');
|
||||
const isDocx = lower.endsWith('.docx');
|
||||
const isXlsx = lower.endsWith('.xlsx') || lower.endsWith('.xls');
|
||||
const isPdf = lower.endsWith('.pdf');
|
||||
|
||||
if (isImage) {
|
||||
displayImagePreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isTiff) {
|
||||
displayTiffPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isZip) {
|
||||
displayZipPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isHtml) {
|
||||
displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isDocx) {
|
||||
displayDocxPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isXlsx) {
|
||||
displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else if (isPdf) {
|
||||
displayPdfPreview(file, filePath, fileName, fileHandle, lastModified);
|
||||
} else {
|
||||
const content = await file.text();
|
||||
|
||||
if (fileName.toLowerCase().endsWith('.md')) {
|
||||
initializeEditor(content, true, filePath, fileName, fileHandle, lastModified);
|
||||
} else {
|
||||
initializeEditor(content, false, filePath, fileName, fileHandle, lastModified);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error displaying file content:', error);
|
||||
alert(`Error opening file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display image preview
|
||||
*/
|
||||
async function displayImagePreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const imageContainer = document.createElement('div');
|
||||
imageContainer.className = 'image-preview-container flex-1 overflow-auto p-4';
|
||||
|
||||
const imageElement = document.createElement('img');
|
||||
imageElement.className = 'image-preview';
|
||||
imageElement.alt = fileName;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
imageElement.src = objectUrl;
|
||||
|
||||
imageContainer.appendChild(imageElement);
|
||||
fileViewContainer.appendChild(imageContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false,
|
||||
objectUrl: objectUrl
|
||||
};
|
||||
|
||||
editorInstances.set(filePath, instanceData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display TIFF preview using shared zddc.preview.renderTiff (UTIF.js + canvas).
|
||||
*/
|
||||
async function displayTiffPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) return;
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existing = editorInstances.get(filePath);
|
||||
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const tiffContainer = document.createElement('div');
|
||||
tiffContainer.className = 'flex-1 min-h-0';
|
||||
tiffContainer.style.display = 'flex';
|
||||
tiffContainer.style.flexDirection = 'column';
|
||||
fileViewContainer.appendChild(tiffContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await window.zddc.preview.renderTiff(document, tiffContainer, arrayBuffer, { fileName: fileName });
|
||||
} catch (err) {
|
||||
console.error('Error rendering TIFF:', err);
|
||||
tiffContainer.textContent = 'Error rendering TIFF: ' + (err.message || err);
|
||||
}
|
||||
|
||||
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display ZIP listing using shared zddc.preview.renderZipListing.
|
||||
*/
|
||||
async function displayZipPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) return;
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(c => { c.style.display = 'none'; });
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existing = editorInstances.get(filePath);
|
||||
if (existing.fileViewContainer) existing.fileViewContainer.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const zipContainer = document.createElement('div');
|
||||
zipContainer.className = 'flex-1 min-h-0';
|
||||
zipContainer.style.display = 'flex';
|
||||
zipContainer.style.flexDirection = 'column';
|
||||
fileViewContainer.appendChild(zipContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
await window.zddc.preview.renderZipListing(document, zipContainer, arrayBuffer, { fileName: fileName });
|
||||
} catch (err) {
|
||||
console.error('Error rendering ZIP listing:', err);
|
||||
zipContainer.textContent = 'Error reading ZIP: ' + (err.message || err);
|
||||
}
|
||||
|
||||
editorInstances.set(filePath, { fileViewContainer, fileHandle, lastModified, isDirty: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display HTML preview in sandboxed iframe
|
||||
*/
|
||||
async function displayHtmlPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const htmlContent = await file.text();
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const htmlContainer = document.createElement('div');
|
||||
htmlContainer.className = 'html-preview-container flex-1 overflow-hidden';
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'html-preview-iframe w-full h-full border-0';
|
||||
|
||||
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
|
||||
iframe.setAttribute('loading', 'lazy');
|
||||
|
||||
iframe.srcdoc = htmlContent;
|
||||
|
||||
htmlContainer.appendChild(iframe);
|
||||
fileViewContainer.appendChild(htmlContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false,
|
||||
iframe: iframe
|
||||
};
|
||||
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
try {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
if (iframeDoc) {
|
||||
iframeDoc.addEventListener('click', function (e) {
|
||||
const link = e.target.closest('a');
|
||||
if (link && link.getAttribute('href')) {
|
||||
const href = link.getAttribute('href');
|
||||
if (href.startsWith('#')) {
|
||||
e.preventDefault();
|
||||
const targetId = href.substring(1);
|
||||
const targetElement = iframeDoc.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (DEBUG) console.log('Cannot access iframe content for navigation handling:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display DOCX preview in main content area
|
||||
*/
|
||||
async function displayDocxPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const docxContainer = document.createElement('div');
|
||||
docxContainer.className = 'flex-1 overflow-auto p-4';
|
||||
docxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
|
||||
fileViewContainer.appendChild(docxContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false
|
||||
};
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
try {
|
||||
// jszip + docx-preview bundled into the dist HTML; window.JSZip
|
||||
// and window.docx are available synchronously.
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
docxContainer.innerHTML = '';
|
||||
await window.docx.renderAsync(arrayBuffer, docxContainer);
|
||||
} catch (err) {
|
||||
console.error('Error rendering DOCX:', err);
|
||||
docxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering DOCX: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display XLSX/XLS preview in main content area
|
||||
*/
|
||||
async function displayXlsxPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) {
|
||||
alert('Error: content-container element not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName || 'No file selected';
|
||||
fileHeader.appendChild(fileTitle);
|
||||
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const xlsxContainer = document.createElement('div');
|
||||
xlsxContainer.className = 'flex-1 overflow-auto';
|
||||
xlsxContainer.innerHTML = '<div style="text-align:center;padding:2rem;color:#666;">Loading preview...</div>';
|
||||
fileViewContainer.appendChild(xlsxContainer);
|
||||
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
const instanceData = {
|
||||
fileViewContainer: fileViewContainer,
|
||||
fileHandle: fileHandle,
|
||||
lastModified: lastModified,
|
||||
isDirty: false
|
||||
};
|
||||
editorInstances.set(filePath, instanceData);
|
||||
|
||||
try {
|
||||
// XLSX bundled into the dist HTML; window.XLSX is available
|
||||
// synchronously, no runtime load needed.
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
|
||||
xlsxContainer.innerHTML = '';
|
||||
|
||||
if (workbook.SheetNames.length > 1) {
|
||||
const tabs = document.createElement('div');
|
||||
tabs.style.cssText = 'display:flex;gap:0;border-bottom:1px solid #ddd;background:#f5f5f5;';
|
||||
const tableArea = document.createElement('div');
|
||||
tableArea.className = 'flex-1 overflow-auto';
|
||||
|
||||
workbook.SheetNames.forEach((name, i) => {
|
||||
const tab = document.createElement('button');
|
||||
tab.textContent = name;
|
||||
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid transparent;border-bottom:none;font-size:0.85rem;background:transparent;';
|
||||
if (i === 0) tab.style.cssText += 'background:white;border-color:#ddd;border-bottom-color:white;margin-bottom:-1px;font-weight:500;';
|
||||
tab.onclick = () => {
|
||||
tabs.querySelectorAll('button').forEach(t => { t.style.background = 'transparent'; t.style.borderColor = 'transparent'; t.style.fontWeight = 'normal'; });
|
||||
tab.style.cssText = 'padding:0.4rem 1rem;cursor:pointer;border:1px solid #ddd;border-bottom-color:white;font-size:0.85rem;background:white;margin-bottom:-1px;font-weight:500;';
|
||||
renderXlsxSheet(workbook, name, tableArea);
|
||||
};
|
||||
tabs.appendChild(tab);
|
||||
});
|
||||
|
||||
xlsxContainer.appendChild(tabs);
|
||||
xlsxContainer.appendChild(tableArea);
|
||||
renderXlsxSheet(workbook, workbook.SheetNames[0], tableArea);
|
||||
} else {
|
||||
renderXlsxSheet(workbook, workbook.SheetNames[0], xlsxContainer);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error rendering XLSX:', err);
|
||||
xlsxContainer.innerHTML = `<div style="text-align:center;padding:2rem;color:#c00;">Error rendering spreadsheet: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single XLSX sheet as an HTML table
|
||||
*/
|
||||
function renderXlsxSheet(workbook, sheetName, container) {
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const html = XLSX.utils.sheet_to_html(sheet, { editable: false });
|
||||
container.innerHTML = html;
|
||||
const table = container.querySelector('table');
|
||||
if (table) {
|
||||
table.style.cssText = 'border-collapse:collapse;width:100%;font-size:0.85rem;';
|
||||
table.querySelectorAll('th,td').forEach(cell => {
|
||||
cell.style.cssText = 'border:1px solid #ddd;padding:0.35rem 0.5rem;text-align:left;white-space:nowrap;';
|
||||
});
|
||||
table.querySelectorAll('th').forEach(th => {
|
||||
th.style.background = '#f0f0f0';
|
||||
th.style.fontWeight = '600';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display PDF preview using browser's built-in PDF viewer
|
||||
*/
|
||||
async function displayPdfPreview(file, filePath, fileName, fileHandle, lastModified) {
|
||||
const contentContainer = document.getElementById('content-container');
|
||||
if (!contentContainer) return;
|
||||
|
||||
document.querySelectorAll('.file-view-container').forEach(container => {
|
||||
container.style.display = 'none';
|
||||
});
|
||||
|
||||
if (editorInstances.has(filePath)) {
|
||||
const existingInstance = editorInstances.get(filePath);
|
||||
if (existingInstance.fileViewContainer) {
|
||||
existingInstance.fileViewContainer.style.display = 'flex';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fileViewContainer = document.createElement('div');
|
||||
fileViewContainer.className = 'file-view-container flex flex-col h-full';
|
||||
|
||||
const fileHeader = document.createElement('div');
|
||||
fileHeader.className = 'file-header flex justify-between items-center px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700';
|
||||
|
||||
const fileTitle = document.createElement('span');
|
||||
fileTitle.textContent = fileName;
|
||||
fileHeader.appendChild(fileTitle);
|
||||
fileViewContainer.appendChild(fileHeader);
|
||||
|
||||
const pdfContainer = document.createElement('div');
|
||||
pdfContainer.className = 'flex-1 overflow-hidden';
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'w-full h-full border-0';
|
||||
iframe.src = objectUrl;
|
||||
iframe.setAttribute('title', fileName);
|
||||
|
||||
pdfContainer.appendChild(iframe);
|
||||
fileViewContainer.appendChild(pdfContainer);
|
||||
contentContainer.appendChild(fileViewContainer);
|
||||
|
||||
editorInstances.set(filePath, {
|
||||
fileViewContainer,
|
||||
fileHandle,
|
||||
lastModified,
|
||||
isDirty: false,
|
||||
objectUrl
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update status bar counts
|
||||
*/
|
||||
function updateStatusCounts(folderCount, fileCount) {
|
||||
const folderCountElement = document.getElementById('folder-count');
|
||||
const fileCountElement = document.getElementById('file-count');
|
||||
|
||||
if (folderCountElement) {
|
||||
folderCountElement.textContent = `${folderCount} folder${folderCount !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
if (fileCountElement) {
|
||||
fileCountElement.textContent = `${fileCount} file${fileCount !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
updateUnsavedCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update unsaved count in status bar
|
||||
*/
|
||||
function updateUnsavedCount() {
|
||||
const unsavedCountElement = document.getElementById('unsaved-count');
|
||||
if (!unsavedCountElement) return;
|
||||
|
||||
let dirtyCount = 0;
|
||||
editorInstances.forEach(instance => {
|
||||
if (instance.isDirty) {
|
||||
dirtyCount++;
|
||||
}
|
||||
});
|
||||
|
||||
unsavedCountElement.textContent = `${dirtyCount} unsaved`;
|
||||
|
||||
if (dirtyCount > 0) {
|
||||
unsavedCountElement.classList.add('text-amber-500', 'font-medium');
|
||||
} else {
|
||||
unsavedCountElement.classList.remove('text-amber-500', 'font-medium');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file dirty status indicator in tree
|
||||
*/
|
||||
function updateFileDirtyStatus(filePath, isDirty) {
|
||||
const fileElement = document.querySelector(`.file-item[data-path="${filePath}"]`);
|
||||
if (!fileElement) return;
|
||||
|
||||
if (isDirty) {
|
||||
if (!fileElement.querySelector('.dirty-indicator')) {
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = 'dirty-indicator ml-1 text-amber-500 font-bold';
|
||||
indicator.textContent = '●';
|
||||
fileElement.appendChild(indicator);
|
||||
}
|
||||
fileElement.classList.add('is-dirty');
|
||||
} else {
|
||||
const indicator = fileElement.querySelector('.dirty-indicator');
|
||||
if (indicator) {
|
||||
fileElement.removeChild(indicator);
|
||||
}
|
||||
fileElement.classList.remove('is-dirty');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
/**
|
||||
* YAML front matter parsing and stringification
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse YAML front matter from markdown content
|
||||
* @param {string} content - Full markdown content with potential front matter
|
||||
* @returns {{data: Object, content: string}} Parsed front matter data and remaining content
|
||||
*/
|
||||
function parseFrontMatter(content) {
|
||||
if (!content || !content.startsWith('---\n')) {
|
||||
return {
|
||||
data: {},
|
||||
content: content || ''
|
||||
};
|
||||
}
|
||||
|
||||
const endMatch = content.indexOf('\n---\n', 4);
|
||||
if (endMatch === -1) {
|
||||
return {
|
||||
data: {},
|
||||
content: content
|
||||
};
|
||||
}
|
||||
|
||||
const frontMatterText = content.substring(4, endMatch);
|
||||
const markdownBody = content.substring(endMatch + 5);
|
||||
|
||||
// Parse YAML front matter (basic key: value parsing)
|
||||
const frontMatterData = {};
|
||||
const lines = frontMatterText.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
|
||||
|
||||
const colonIndex = trimmedLine.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||||
let value = trimmedLine.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove quotes
|
||||
value = value.replace(/^["']|["']$/g, '');
|
||||
|
||||
// Handle arrays (basic support for [item1, item2])
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(item => item.trim().replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
|
||||
frontMatterData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: frontMatterData,
|
||||
content: markdownBody
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify front matter data and combine with markdown content
|
||||
* @param {string} content - Markdown content
|
||||
* @param {Object} data - Front matter data object
|
||||
* @returns {string} Combined YAML front matter and markdown
|
||||
*/
|
||||
function stringifyFrontMatter(content, data) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
let yamlString = '---\n';
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
|
||||
} else {
|
||||
yamlString += `${key}: "${value}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
yamlString += '---\n';
|
||||
|
||||
return yamlString + content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert front matter data to YAML string for textarea display (without delimiters)
|
||||
* @param {Object} data - Front matter data
|
||||
* @returns {string} YAML string for textarea
|
||||
*/
|
||||
function stringifyFrontMatterToTextarea(data) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let yamlString = '';
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (Array.isArray(value)) {
|
||||
yamlString += `${key}: [${value.map(v => `"${v}"`).join(', ')}]\n`;
|
||||
} else {
|
||||
yamlString += `${key}: "${value}"\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return yamlString.trim();
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* Application initialization
|
||||
*/
|
||||
|
||||
// Initialize when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Check File System API availability and update UI
|
||||
initializeApiAvailability();
|
||||
|
||||
setupEventListeners();
|
||||
initializeFileNavResizer();
|
||||
setupTocDepthSelector();
|
||||
startFileChangeMonitoring();
|
||||
|
||||
// Show scratchpad in file tree on startup
|
||||
renderFileTree();
|
||||
|
||||
// Always start with scratchpad selected and loaded
|
||||
openScratchpad();
|
||||
const scratchpadEl = document.querySelector(`.file-item[data-path="${SCRATCHPAD_ID}"]`);
|
||||
if (scratchpadEl) scratchpadEl.classList.add('active-file');
|
||||
|
||||
// In server (HTTP) mode, fetch and render the current directory subtree.
|
||||
if (location.protocol === 'http:' || location.protocol === 'https:') {
|
||||
loadServerDirectory().catch((err) => {
|
||||
if (DEBUG) console.warn('Server directory load failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize UI based on File System API availability
|
||||
*/
|
||||
function initializeApiAvailability() {
|
||||
const selectDirectoryBtn = document.getElementById('addDirectoryBtn');
|
||||
const welcomeHint = document.getElementById('welcome-hint');
|
||||
const welcomeFirefox = document.getElementById('welcome-firefox');
|
||||
|
||||
if (!hasFileSystemAccess) {
|
||||
// Disable file system buttons in Firefox and other unsupported browsers
|
||||
if (selectDirectoryBtn) {
|
||||
selectDirectoryBtn.disabled = true;
|
||||
selectDirectoryBtn.title = 'File System API not supported in this browser';
|
||||
}
|
||||
// Show Firefox warning, hide normal hint
|
||||
if (welcomeHint) welcomeHint.classList.add('hidden');
|
||||
if (welcomeFirefox) welcomeFirefox.classList.remove('hidden');
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* Pane resizing functionality
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make an element resizable by dragging its resizer
|
||||
* @param {HTMLElement} resizer - The resizer element
|
||||
* @param {HTMLElement} pane - The pane to resize
|
||||
*/
|
||||
function makeResizable(resizer, pane) {
|
||||
const initialWidth = pane.offsetWidth;
|
||||
|
||||
let x = 0;
|
||||
let paneWidth = initialWidth;
|
||||
|
||||
const mouseDownHandler = function (e) {
|
||||
x = e.clientX;
|
||||
paneWidth = pane.offsetWidth;
|
||||
|
||||
document.addEventListener('mousemove', mouseMoveHandler);
|
||||
document.addEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
resizer.classList.add('active');
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const mouseMoveHandler = function (e) {
|
||||
const dx = e.clientX - x;
|
||||
const newWidth = Math.max(150, paneWidth + dx);
|
||||
|
||||
pane.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const mouseUpHandler = function () {
|
||||
document.removeEventListener('mousemove', mouseMoveHandler);
|
||||
document.removeEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
resizer.classList.remove('active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a horizontal split height-adjustable: the resizer drags the height
|
||||
* of `topPane` while it remains a sibling of the bottom section inside `container`.
|
||||
*
|
||||
* @param {HTMLElement} resizer - The horizontal resizer between the panes
|
||||
* @param {HTMLElement} topPane - The pane whose height is set
|
||||
* @param {HTMLElement} container - The flex column containing both panes
|
||||
*/
|
||||
function makeHeightResizable(resizer, topPane, container) {
|
||||
let y = 0;
|
||||
let topHeight = 0;
|
||||
let containerHeight = 0;
|
||||
|
||||
const mouseDownHandler = (e) => {
|
||||
y = e.clientY;
|
||||
topHeight = topPane.offsetHeight;
|
||||
containerHeight = container.offsetHeight;
|
||||
document.addEventListener('mousemove', mouseMoveHandler);
|
||||
document.addEventListener('mouseup', mouseUpHandler);
|
||||
resizer.classList.add('active');
|
||||
document.body.style.cursor = 'row-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
};
|
||||
|
||||
const mouseMoveHandler = (e) => {
|
||||
const dy = e.clientY - y;
|
||||
// Reserve at least 80px for the bottom pane (TOC); cap top at containerHeight - 80.
|
||||
const minTop = 60;
|
||||
const maxTop = Math.max(minTop, containerHeight - 100);
|
||||
const newHeight = Math.max(minTop, Math.min(maxTop, topHeight + dy));
|
||||
topPane.style.height = `${newHeight}px`;
|
||||
};
|
||||
|
||||
const mouseUpHandler = () => {
|
||||
document.removeEventListener('mousemove', mouseMoveHandler);
|
||||
document.removeEventListener('mouseup', mouseUpHandler);
|
||||
resizer.classList.remove('active');
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the file navigation pane resizer
|
||||
*/
|
||||
function initializeFileNavResizer() {
|
||||
const fileNavResizer = document.querySelector('.pane-resizer[data-resizer-for="file-nav"]');
|
||||
|
||||
if (fileNavResizer && !fileNavResizer.hasAttribute('data-resizer-initialized')) {
|
||||
fileNavResizer.setAttribute('data-resizer-initialized', 'true');
|
||||
|
||||
let x = 0;
|
||||
let navWidth = 0;
|
||||
|
||||
const mouseDownHandler = function (e) {
|
||||
x = e.clientX;
|
||||
|
||||
const navPane = document.getElementById('file-nav');
|
||||
navWidth = navPane.getBoundingClientRect().width;
|
||||
|
||||
document.addEventListener('mousemove', mouseMoveHandler);
|
||||
document.addEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
fileNavResizer.classList.add('bg-blue-500');
|
||||
};
|
||||
|
||||
const mouseMoveHandler = function (e) {
|
||||
const dx = e.clientX - x;
|
||||
|
||||
const navPane = document.getElementById('file-nav');
|
||||
|
||||
const newWidth = navWidth + dx;
|
||||
|
||||
if (newWidth >= 200) {
|
||||
navPane.style.width = `${newWidth}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const mouseUpHandler = function () {
|
||||
document.removeEventListener('mousemove', mouseMoveHandler);
|
||||
document.removeEventListener('mouseup', mouseUpHandler);
|
||||
|
||||
fileNavResizer.classList.remove('bg-blue-500');
|
||||
};
|
||||
|
||||
fileNavResizer.addEventListener('mousedown', mouseDownHandler);
|
||||
}
|
||||
}
|
||||
|
||||
254
mdedit/js/toc.js
254
mdedit/js/toc.js
|
|
@ -1,254 +0,0 @@
|
|||
/**
|
||||
* Table of Contents generation and scroll functionality
|
||||
*/
|
||||
|
||||
/**
|
||||
* Scroll to header service - uses line numbers for reliable targeting
|
||||
*/
|
||||
const ScrollToHeaderService = {
|
||||
/**
|
||||
* Scroll to a specific header in the editor by line number
|
||||
* @param {Object} editorInstance - Toast UI Editor instance
|
||||
* @param {string} headerText - Text content of the header (for highlighting)
|
||||
* @param {number} lineIndex - 0-based line index of the header in markdown
|
||||
*/
|
||||
scrollToHeader(editorInstance, headerText, lineIndex) {
|
||||
if (!editorInstance) {
|
||||
console.warn('Editor instance not available for scrolling');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorElements = editorInstance.getEditorElements();
|
||||
const isWysiwygMode = editorInstance.isWysiwygMode();
|
||||
|
||||
if (isWysiwygMode) {
|
||||
// In WYSIWYG mode, find header by text (no line numbers available)
|
||||
const wysiwygEditor = editorElements.wwEditor;
|
||||
if (wysiwygEditor) {
|
||||
const headers = wysiwygEditor.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
for (const header of headers) {
|
||||
if (header.textContent.trim() === headerText.trim()) {
|
||||
// Scroll the editor container directly with 10px offset
|
||||
const headerPosition = header.getBoundingClientRect().top - wysiwygEditor.getBoundingClientRect().top;
|
||||
const offset = 10; // Account for fixed headers or padding
|
||||
wysiwygEditor.scrollTop = headerPosition - offset;
|
||||
this._highlightHeader(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// In markdown mode, use line number to position cursor, then scroll preview
|
||||
const lineNumber = lineIndex + 1; // Convert to 1-based
|
||||
|
||||
// Move cursor to the heading line in the editor
|
||||
try {
|
||||
editorInstance.setSelection([lineNumber, 1], [lineNumber, 1]);
|
||||
} catch (e) {
|
||||
if (DEBUG) console.debug('Could not set selection:', e);
|
||||
}
|
||||
|
||||
// Scroll preview to matching header
|
||||
const previewElement = editorElements.mdPreview;
|
||||
if (previewElement) {
|
||||
const headers = previewElement.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
|
||||
for (const header of headers) {
|
||||
if (header.textContent.trim() === headerText.trim()) {
|
||||
// Scroll the preview container directly with 10px offset
|
||||
const headerPosition = header.getBoundingClientRect().top - previewElement.getBoundingClientRect().top;
|
||||
const offset = 10; // Account for fixed headers or padding
|
||||
previewElement.scrollTop = headerPosition - offset;
|
||||
this._highlightHeader(header);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scrolling to header:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Highlight header briefly for visual feedback
|
||||
* @param {HTMLElement} headerElement - Header to highlight
|
||||
*/
|
||||
_highlightHeader(headerElement) {
|
||||
if (!headerElement) return;
|
||||
|
||||
headerElement.style.transition = 'background-color 0.3s ease';
|
||||
headerElement.style.backgroundColor = '#fef3c7';
|
||||
|
||||
setTimeout(() => {
|
||||
headerElement.style.backgroundColor = '';
|
||||
setTimeout(() => {
|
||||
headerElement.style.transition = '';
|
||||
}, 300);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate and update the TOC from markdown content
|
||||
* @param {string} content - Markdown content
|
||||
* @param {HTMLElement} tocContainer - Container for the TOC
|
||||
* @param {Object} editorInstance - Toast UI Editor instance
|
||||
* @param {number} maxDepth - Maximum heading level (1-6)
|
||||
*/
|
||||
function updateToc(content, tocContainer, editorInstance, maxDepth = 6) {
|
||||
if (content === undefined || content === null || !tocContainer) {
|
||||
console.warn('Missing required params for updateToc');
|
||||
return;
|
||||
}
|
||||
|
||||
tocContainer.innerHTML = '';
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list pl-0 text-sm';
|
||||
|
||||
if (!content.trim()) {
|
||||
const emptyMessage = document.createElement('p');
|
||||
emptyMessage.className = 'text-gray-500 p-4';
|
||||
emptyMessage.textContent = 'This file is empty.';
|
||||
tocContainer.appendChild(emptyMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const headings = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
let text = match[2].trim();
|
||||
|
||||
// Clean markdown formatting
|
||||
text = text
|
||||
.replace(/\\(.)/g, '$1')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.trim();
|
||||
|
||||
const id = text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
|
||||
|
||||
headings.push({
|
||||
level,
|
||||
text,
|
||||
id,
|
||||
lineIndex: index
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentList = tocList;
|
||||
let currentLevel = 0;
|
||||
let listsStack = [tocList];
|
||||
|
||||
const filteredHeadings = headings.filter(heading => heading.level <= maxDepth);
|
||||
|
||||
if (filteredHeadings.length === 0) {
|
||||
const noHeadings = document.createElement('p');
|
||||
noHeadings.className = 'text-gray-500 p-4';
|
||||
noHeadings.textContent = maxDepth === 6 ? 'No headings found in this document.' :
|
||||
'No headings at or below level H' + maxDepth + ' found.';
|
||||
tocContainer.appendChild(noHeadings);
|
||||
return;
|
||||
}
|
||||
|
||||
filteredHeadings.forEach(heading => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `toc-item toc-level-${heading.level} py-1`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.innerHTML = heading.text;
|
||||
a.href = '#';
|
||||
a.className = 'text-blue-600 hover:text-blue-800 hover:underline cursor-pointer';
|
||||
a.dataset.headerText = heading.text;
|
||||
a.dataset.lineIndex = heading.lineIndex;
|
||||
|
||||
a.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (editorInstance && ScrollToHeaderService) {
|
||||
try {
|
||||
ScrollToHeaderService.scrollToHeader(
|
||||
editorInstance,
|
||||
heading.text,
|
||||
parseInt(heading.lineIndex)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error in ScrollToHeaderService.scrollToHeader:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
li.appendChild(a);
|
||||
|
||||
if (heading.level > currentLevel) {
|
||||
const nestedUl = document.createElement('ul');
|
||||
nestedUl.className = 'pl-4 mt-1';
|
||||
listsStack[listsStack.length - 1].appendChild(nestedUl);
|
||||
listsStack.push(nestedUl);
|
||||
currentList = nestedUl;
|
||||
currentLevel = heading.level;
|
||||
} else if (heading.level < currentLevel) {
|
||||
while (heading.level < currentLevel && listsStack.length > 1) {
|
||||
listsStack.pop();
|
||||
currentLevel--;
|
||||
}
|
||||
currentList = listsStack[listsStack.length - 1];
|
||||
}
|
||||
|
||||
currentList.appendChild(li);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
clearActiveTocItem(tocContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear active TOC item from all items within the container
|
||||
* @param {HTMLElement} tocContainer - Container element holding the TOC
|
||||
*/
|
||||
function clearActiveTocItem(tocContainer) {
|
||||
if (!tocContainer) return;
|
||||
|
||||
const activeItems = tocContainer.querySelectorAll('.toc-active');
|
||||
activeItems.forEach(item => {
|
||||
item.classList.remove('toc-active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set active TOC item by finding the link matching the header text
|
||||
* @param {HTMLElement} tocContainer - Container element holding the TOC
|
||||
* @param {string} headerText - Text of the header to match and activate
|
||||
*/
|
||||
function setActiveTocItem(tocContainer, headerText) {
|
||||
if (!tocContainer || !headerText) return;
|
||||
|
||||
// First clear any existing active items
|
||||
clearActiveTocItem(tocContainer);
|
||||
|
||||
// Find the link matching the header text
|
||||
const links = tocContainer.querySelectorAll('a[data-header-text]');
|
||||
for (const link of links) {
|
||||
if (link.dataset.headerText === headerText) {
|
||||
// Add toc-active class to the parent li element
|
||||
const li = link.parentElement;
|
||||
if (li) {
|
||||
li.classList.add('toc-active');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reachable at top-level scope to other concatenated mdedit JS files via the
|
||||
// build's flat-IIFE-less module pattern; no window.* exports needed.
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
/**
|
||||
* Utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* HTML-escape a string for safe insertion into innerHTML.
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text == null ? '' : String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function calls
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function () {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type icon based on file extension
|
||||
* @param {string} fileName - Name of the file
|
||||
* @returns {string} Emoji icon for the file type
|
||||
*/
|
||||
function getFileTypeIcon(fileName) {
|
||||
const extension = zddc.splitExtension(fileName).extension;
|
||||
|
||||
const iconMap = {
|
||||
// Documents
|
||||
'md': '📝',
|
||||
'markdown': '📝',
|
||||
'txt': '📄',
|
||||
'rtf': '📄',
|
||||
'doc': '📘',
|
||||
'docx': '📘',
|
||||
'odt': '📘',
|
||||
|
||||
// Web files
|
||||
'html': '🌐',
|
||||
'htm': '🌐',
|
||||
'css': '🎨',
|
||||
'js': '⚡',
|
||||
'json': '📋',
|
||||
'xml': '📊',
|
||||
'yaml': '⚙️',
|
||||
'yml': '⚙️',
|
||||
|
||||
// PDFs and presentations
|
||||
'pdf': '📕',
|
||||
'ppt': '📊',
|
||||
'pptx': '📊',
|
||||
'odp': '📊',
|
||||
|
||||
// Spreadsheets
|
||||
'xls': '📗',
|
||||
'xlsx': '📗',
|
||||
'csv': '📊',
|
||||
'ods': '📗',
|
||||
|
||||
// Images
|
||||
'png': '🖼️',
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'gif': '🖼️',
|
||||
'svg': '🖼️',
|
||||
'webp': '🖼️',
|
||||
'bmp': '🖼️',
|
||||
|
||||
// Archives
|
||||
'zip': '📦',
|
||||
'rar': '📦',
|
||||
'tar': '📦',
|
||||
'gz': '📦',
|
||||
'7z': '📦',
|
||||
|
||||
// Code files
|
||||
'py': '🐍',
|
||||
'java': '☕',
|
||||
'cpp': '⚙️',
|
||||
'c': '⚙️',
|
||||
'h': '⚙️',
|
||||
'php': '🔧',
|
||||
'rb': '💎',
|
||||
'go': '🔵',
|
||||
'rs': '🦀',
|
||||
'swift': '🧡',
|
||||
'kt': '💜',
|
||||
|
||||
// Configuration
|
||||
'ini': '⚙️',
|
||||
'conf': '⚙️',
|
||||
'cfg': '⚙️',
|
||||
'env': '⚙️',
|
||||
|
||||
// Other
|
||||
'log': '📃',
|
||||
'sql': '🗄️',
|
||||
'db': '🗄️',
|
||||
'sqlite': '🗄️',
|
||||
};
|
||||
|
||||
return iconMap[extension] || '📄';
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ZDDC Markdown</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Toast UI Editor v3.2.2 -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css">
|
||||
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
|
||||
<style>
|
||||
{{CSS_PLACEHOLDER}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="flex flex-col h-screen w-full overflow-hidden">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
|
||||
<g fill="#fff">
|
||||
<rect x="14" y="18" width="36" height="7"/>
|
||||
<polygon points="43,25 50,25 21,43 14,43"/>
|
||||
<rect x="14" y="43" width="36" height="7"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Markdown</span>
|
||||
<span class="build-timestamp">{{BUILD_LABEL}}</span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh">⟳</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)">◐</button>
|
||||
<button id="help-btn" class="btn btn-secondary" title="Help">?</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-hidden relative">
|
||||
<div class="resizable-pane horizontal flex flex-row relative w-full h-full overflow-hidden" id="root-pane" data-pane-type="root">
|
||||
<div class="pane nav-pane relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="file-nav" data-pane-type="file-nav">
|
||||
<div class="pane-header flex flex-col px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 font-medium border-b border-gray-200 dark:border-gray-700 select-none">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>Files</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button id="new-file-root" class="btn btn-secondary btn-sm hidden" title="New file in root directory">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-content flex-1 overflow-auto p-4">
|
||||
<div id="file-tree" class="file-tree py-2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pane-resizer bg-gray-200 dark:bg-gray-700 transition-colors relative z-10 w-1 cursor-col-resize hover:bg-blue-500" data-resizer-for="file-nav"></div>
|
||||
|
||||
<div class="pane content-pane flex-1 relative flex flex-col bg-white dark:bg-gray-900 overflow-hidden" id="main-content">
|
||||
<div id="welcome-screen" class="welcome-screen hidden flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400 text-center p-6">
|
||||
<div id="welcome-banner" style="background:var(--bg-secondary);padding:0.75rem 1rem;border-left:3px solid var(--warning);text-align:left;font-size:0.9rem;color:var(--text-muted);margin-bottom:1rem;border-radius:var(--radius);max-width:36rem">
|
||||
<strong>The Browse app now opens markdown files in this same editor.</strong>
|
||||
Browse provides a unified file tree + per-file-type preview where
|
||||
<code>.md</code> files render in this Toast UI editor. The
|
||||
standalone Markdown Editor remains available for offline single-file
|
||||
editing and air-gapped environments.
|
||||
</div>
|
||||
<p id="welcome-hint" class="text-sm">Click <strong>Scratchpad</strong> in the file list to start editing,<br>or <strong>Add Local Directory</strong> to work with files.</p>
|
||||
<p id="welcome-firefox" class="text-sm text-amber-600 hidden mt-2">Your browser doesn't support the File System API.<br>Use <strong>Scratchpad</strong> to edit markdown and download as a file.</p>
|
||||
</div>
|
||||
|
||||
<div id="content-container" class="content-container flex flex-col h-full hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="status-bar flex justify-between items-center px-4 h-6 text-xs bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="status-left flex items-center gap-6 py-1">
|
||||
<button id="save-all" class="btn inline-flex items-center gap-2 px-3 py-1 text-sm bg-transparent border border-gray-300 dark:border-gray-600 rounded text-gray-800 dark:text-gray-200 cursor-pointer transition-all hover:bg-gray-200 dark:hover:bg-gray-700 h-6 leading-none" title="Save All">
|
||||
<svg class="btn-icon w-3.5 h-3.5 fill-current opacity-80" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.5 1H4.5a1.5 1.5 0 0 0-1.5 1.5v11a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-11a1.5 1.5 0 0 0-1.5-1.5zm-7 1h7a.5.5 0 0 1 .5.5V9H4V2.5a.5.5 0 0 1 .5-.5zM4 10h8v3.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5V10z"></path>
|
||||
<path d="M6.5 0a.5.5 0 0 1 .5.5V2h2V.5a.5.5 0 0 1 1 0V2h1.5a.5.5 0 0 1 0 1H10v2.5a.5.5 0 0 1-1 0V3H7v2.5a.5.5 0 0 1-1 0V3H4.5a.5.5 0 0 1 0-1H6V.5a.5.5 0 0 1 .5-.5z"></path>
|
||||
</svg>
|
||||
Save All
|
||||
</button>
|
||||
<span id="folder-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 folders</span>
|
||||
<span id="file-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 files</span>
|
||||
<span id="unsaved-count" class="status-message text-sm text-gray-800 dark:text-gray-200 opacity-80">0 unsaved</span>
|
||||
</div>
|
||||
<div class="status-right flex items-center gap-4">
|
||||
<a href="https://codeberg.org/VARASYS/ZDDC" target="_blank" rel="noopener noreferrer" class="source-link" title="View source code">
|
||||
<svg class="source-icon fill-current transition-opacity hover:opacity-80" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||
<path d="M5.5 11.5L1 8l4.5-3.5L4.4 3 0 8l4.4 5 1.1-1.5zm5 0L15 8l-4.5-3.5L11.6 3 16 8l-4.4 5-1.1-1.5z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Help Panel -->
|
||||
<aside id="help-panel" class="help-panel" hidden aria-labelledby="help-panel-title">
|
||||
<div class="help-panel__header">
|
||||
<h2 id="help-panel-title" class="help-panel__title">Help — ZDDC Markdown</h2>
|
||||
<button type="button" class="help-panel__close" id="help-panel-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="help-panel__body">
|
||||
<h3>What is ZDDC Markdown?</h3>
|
||||
<p>ZDDC Markdown is a browser-based Markdown editor that reads and writes files directly on your local file system. Everything runs locally — no data is sent to any server.</p>
|
||||
|
||||
<h3>Getting Started</h3>
|
||||
<ol>
|
||||
<li>Click <strong>Add Local Directory</strong> to open a folder. The file tree on the left will populate with all files in that folder.</li>
|
||||
<li>Click any Markdown file (<code>.md</code>) in the tree to open it in the editor.</li>
|
||||
<li>Use the <strong>Scratchpad</strong> entry (always visible at the top of the tree) for temporary notes without saving to disk.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Editor Modes</h3>
|
||||
<dl>
|
||||
<dt>WYSIWYG</dt>
|
||||
<dd>A rich-text view where formatting is rendered live. Good for composing content.</dd>
|
||||
<dt>Markdown</dt>
|
||||
<dd>A plain-text view showing raw Markdown syntax. Good for precise control.</dd>
|
||||
</dl>
|
||||
<p>Switch between modes using the toolbar buttons at the top-right of the editor.</p>
|
||||
|
||||
<h3>Saving Files</h3>
|
||||
<dl>
|
||||
<dt>Auto-save indicator</dt>
|
||||
<dd>A bullet (•) next to the filename in the tree indicates unsaved changes.</dd>
|
||||
<dt>Save (Ctrl+S)</dt>
|
||||
<dd>Saves the currently active file.</dd>
|
||||
<dt>Save All</dt>
|
||||
<dd>Saves all files that have unsaved changes in one operation.</dd>
|
||||
</dl>
|
||||
|
||||
<h3>Table of Contents</h3>
|
||||
<p>When a Markdown file is open, a table of contents is generated from its headings and shown on the right side. Use the depth selector to control how many heading levels appear.</p>
|
||||
|
||||
<h3>Browser Compatibility</h3>
|
||||
<p>File system access requires a Chromium-based browser (Chrome, Edge, Brave). In Firefox and other browsers, the <strong>Scratchpad</strong> is available for editing, and files can be saved via download.</p>
|
||||
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
<dl>
|
||||
<dt><kbd>Ctrl+S</kbd></dt>
|
||||
<dd>Save the current file.</dd>
|
||||
<dt><kbd>Escape</kbd></dt>
|
||||
<dd>Close this help panel.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- New-file modal -->
|
||||
<div id="new-file-modal" class="modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="new-file-modal-title">
|
||||
<div class="modal-box">
|
||||
<h3 id="new-file-modal-title" class="modal-title">New file name</h3>
|
||||
<input id="new-file-input" type="text" class="modal-input" value="untitled.md" autocomplete="off" spellcheck="false">
|
||||
<div class="modal-actions">
|
||||
<button id="new-file-cancel" class="btn btn-secondary">Cancel</button>
|
||||
<button id="new-file-confirm" class="btn btn-primary">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{{JS_PLACEHOLDER}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2,6 +2,44 @@
|
|||
|
||||
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`)
|
||||
|
||||
zddc-server can offer the same conversions on demand: a `.md` file in any
|
||||
served directory becomes downloadable as `.docx`, `.html`, and `.pdf` via the
|
||||
`?convert=` query parameter, surfaced as Download buttons in the browse app's
|
||||
markdown editor.
|
||||
|
||||
The server shells out to two upstream container images, pulling each on
|
||||
first use via `--pull=missing`. No custom image build is required —
|
||||
operators just install `podman` (preferred) or `docker`, and the first
|
||||
conversion request pulls the image:
|
||||
|
||||
- `docker.io/pandoc/latex:latest` — MD → DOCX and MD → HTML
|
||||
(override: `--convert-pandoc-image=` or `ZDDC_CONVERT_PANDOC_IMAGE`;
|
||||
switch to `docker.io/pandoc/core:latest` for a ~90% size reduction
|
||||
if you don't need pandoc's native LaTeX-PDF path)
|
||||
- `docker.io/zenika/alpine-chrome:latest` — HTML → PDF
|
||||
(override: `--convert-chromium-image=` or `ZDDC_CONVERT_CHROMIUM_IMAGE`)
|
||||
|
||||
The PDF flow is two-stage: pandoc renders the markdown through
|
||||
`viewer-template.html` to standalone HTML, then headless Chromium
|
||||
prints that HTML to PDF. This preserves the existing print-media CSS
|
||||
authored for the viewer template rather than going through pandoc's
|
||||
LaTeX template.
|
||||
|
||||
If neither podman nor docker is on PATH the endpoint serves 503 with
|
||||
a clear "no container runtime" message. Engine choice is overridable
|
||||
via `--convert-engine=` or `ZDDC_CONVERT_ENGINE`.
|
||||
|
||||
Resource limits are per-container and configurable: `--convert-mem-mib`
|
||||
(default 512), `--convert-cpus` (default "2"), `--convert-pids`
|
||||
(default 100), `--convert-timeout` (default 30s).
|
||||
|
||||
Each conversion runs in a throw-away container with
|
||||
`--rm --network=none --read-only --tmpfs=/tmp --cap-drop=ALL
|
||||
--security-opt=no-new-privileges` plus a bind-mounted scratch dir
|
||||
for I/O (read-only for the template; read-write for the PDF output).
|
||||
|
||||
## Features
|
||||
|
||||
### Document Conversion (`convert`)
|
||||
|
|
|
|||
|
|
@ -47,10 +47,6 @@ export default defineConfig({
|
|||
name: 'classifier',
|
||||
testMatch: 'classifier.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'mdedit',
|
||||
testMatch: 'mdedit.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'browse',
|
||||
testMatch: 'browse.spec.js',
|
||||
|
|
|
|||
|
|
@ -301,7 +301,7 @@ _emit_build_label_sidecar() {
|
|||
# Tools that participate in the lockstep release. Source of truth — used
|
||||
# by helpers that enumerate "all release artifacts" (matrix render,
|
||||
# coordinated next-stable, channel-link verifier).
|
||||
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form tables browse zddc-server"
|
||||
ZDDC_RELEASE_TOOLS="archive transmittal classifier landing form tables browse zddc-server"
|
||||
|
||||
# Compute the next-stable target for a single tool — patch-bump of its own
|
||||
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
|
||||
|
|
@ -742,7 +742,7 @@ verify_channel_links() {
|
|||
_missing=0
|
||||
_verified=0
|
||||
|
||||
for _t in archive transmittal classifier mdedit landing form tables browse; do
|
||||
for _t in archive transmittal classifier landing form tables browse; do
|
||||
for _ch in stable beta alpha; do
|
||||
_f="$_rdir/${_t}_${_ch}.html"
|
||||
if [ -e "$_f" ]; then
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*
|
||||
* Renderers operate on any document (parent window or popup window), so the
|
||||
* same code works for tools whose preview opens in a popup (classifier,
|
||||
* archive, transmittal) and tools that render inline (mdedit).
|
||||
* archive, transmittal) and tools that render inline (browse).
|
||||
*
|
||||
* Public API on window.zddc.preview:
|
||||
* loadLibrary(url) → Promise<void>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// shared/zddc-source.js — source abstraction for tools that handle
|
||||
// directory trees (classifier, mdedit, transmittal, browse, archive).
|
||||
// directory trees (classifier, transmittal, browse, archive).
|
||||
//
|
||||
// Two backends:
|
||||
//
|
||||
|
|
@ -370,12 +370,44 @@
|
|||
return !!(handle && handle.isHttp === true);
|
||||
}
|
||||
|
||||
// downloadConverted fetches a server-side MD→{docx,html,pdf}
|
||||
// conversion and triggers a browser download with a clean filename.
|
||||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
|
||||
else if (resp.status === 504) msg = 'Conversion timed out.';
|
||||
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
|
||||
// Append server-supplied body text if it adds detail.
|
||||
try {
|
||||
var detail = await resp.text();
|
||||
if (detail && detail.length < 400) msg += ' ' + detail.trim();
|
||||
} catch (_) { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
var blob = await resp.blob();
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
}
|
||||
|
||||
window.zddc.source = {
|
||||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||||
HttpFileHandle: HttpFileHandle,
|
||||
detectServerRoot: detectServerRoot,
|
||||
moveFile: moveFile,
|
||||
isHttpHandle: isHttpHandle,
|
||||
downloadConverted: downloadConverted,
|
||||
// Lower-level helpers exposed for tools that want to call the
|
||||
// server directly without going through the polyfill.
|
||||
httpListing: httpListing,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { test, expect } from '@playwright/test';
|
|||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const tools = ['archive', 'transmittal', 'classifier', 'mdedit'];
|
||||
const tools = ['archive', 'transmittal', 'classifier', 'browse'];
|
||||
|
||||
for (const tool of tools) {
|
||||
const distPath = path.resolve(`${tool}/dist/${tool}.html`);
|
||||
|
|
@ -31,7 +31,8 @@ for (const tool of tools) {
|
|||
});
|
||||
|
||||
test(`dist file: .build-timestamp element is visible in browser`, async ({ page }) => {
|
||||
const waitUntil = tool === 'mdedit' ? 'load' : 'domcontentloaded';
|
||||
// browse may load Toast UI lazily; wait for full load.
|
||||
const waitUntil = tool === 'browse' ? 'load' : 'domcontentloaded';
|
||||
await page.goto(`file://${distPath}`, { waitUntil });
|
||||
const el = page.locator('.build-timestamp');
|
||||
await expect(el).toBeVisible({ timeout: 10000 });
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { MOCK_FS_INIT_SCRIPT } from './fixtures/mock-fs-api.js';
|
||||
import * as path from 'path';
|
||||
|
||||
const HTML_PATH = path.resolve('mdedit/dist/mdedit.html');
|
||||
|
||||
test.describe('Markdown Editor', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(MOCK_FS_INIT_SCRIPT);
|
||||
});
|
||||
|
||||
test('loads without errors', async ({ page }) => {
|
||||
// Use 'load' rather than 'networkidle' — the bundled Toast UI/Tailwind
|
||||
// scripts run inline so there is no external network activity to wait for.
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#app', { timeout: 15000 });
|
||||
|
||||
// Scratchpad opens by default with welcome content seeded into the editor.
|
||||
await expect(page.locator(`.file-item[data-path="__scratchpad__"]`)).toBeVisible();
|
||||
await expect(page.locator('#content-container')).toBeVisible();
|
||||
|
||||
// Add Local Directory button is present and enabled
|
||||
const addDirBtn = page.locator('#addDirectoryBtn');
|
||||
await expect(addDirBtn).toBeVisible();
|
||||
await expect(addDirBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders a file tree from a mock directory', async ({ page }) => {
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
// Set up mock directory before triggering the picker
|
||||
await page.evaluate(() => {
|
||||
window.__setMockDirectory('notes', [
|
||||
{ name: 'readme.md', content: '# Hello\n\nWelcome.', size: 30 },
|
||||
{ name: 'notes.md', content: '# Notes\n\nSome notes.', size: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
await page.locator('#addDirectoryBtn').click();
|
||||
|
||||
// File tree should populate with the two files
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#file-tree')?.children.length > 0,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
const items = await page.locator('#file-tree *').count();
|
||||
expect(items).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('DEBUG flag is defined and console.log calls are gated', async ({ page }) => {
|
||||
const logs = [];
|
||||
page.on('console', msg => msg.type() === 'log' && logs.push(msg.text()));
|
||||
|
||||
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('#addDirectoryBtn', { timeout: 15000 });
|
||||
|
||||
const probe = await page.evaluate(() => ({
|
||||
debugDefined: typeof DEBUG !== 'undefined',
|
||||
debugValue: typeof DEBUG !== 'undefined' ? DEBUG : null,
|
||||
}));
|
||||
|
||||
expect(probe.debugDefined).toBe(true);
|
||||
expect(probe.debugValue).toBe(false);
|
||||
|
||||
// With DEBUG=false, no console.log should fire from app code on load.
|
||||
// (Browser/Toast-UI may still log; we only check none of the gated lines fired.)
|
||||
const ourLogs = logs.filter(l =>
|
||||
l.startsWith('Opened scratchpad') ||
|
||||
l.startsWith('Directory selected') ||
|
||||
l.startsWith('File ') ||
|
||||
l.startsWith('Created new file')
|
||||
);
|
||||
expect(ourLogs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -57,8 +57,8 @@ test.describe('shared/nav.js stage strip', () => {
|
|||
await expect(active).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('renders for <project>/working/foo/mdedit.html with working active', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/projA/working/casey/mdedit.html`, { waitUntil: 'load' });
|
||||
test('renders for <project>/working/foo/browse.html with working active', async ({ page }) => {
|
||||
await page.goto(`${baseUrl}/projA/working/casey/browse.html`, { waitUntil: 'load' });
|
||||
const active = page.locator('.zddc-stage-strip .zddc-stage--active');
|
||||
await expect(active).toHaveText('Working');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ roles:
|
|||
members: [dc@mycompany.com, alice@mycompany.com]
|
||||
```
|
||||
|
||||
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. A role redefined closer to the leaf shadows the ancestor's definition. Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work).
|
||||
Members are email patterns using the same glob syntax as legacy `acl.allow`. Underscore-prefixed names are conventional (`_doc_controller`, `_company`) but not magic. **Role membership UNIONS across the cascade** — a `.zddc` that defines `vendor_acme` again with one extra member *adds* that member to the inherited role; use `reset: true` on the role at a level to break the union (ancestor definitions above the reset are then excluded). Permission-map keys without `@` are treated as role references first; if no role of that name exists in the visible cascade, they fall back to legacy email-pattern matching (so `*@example.com` and bare `*` continue to work). The baked-in `defaults.zddc.yaml` ships two empty standard roles — `document_controller` and `project_team` — referenced by the default ACLs; a deployment populates their members.
|
||||
|
||||
### Step 1: starter `.zddc`
|
||||
|
||||
|
|
@ -478,8 +478,11 @@ Behaviour:
|
|||
- **Admins:** the root `admins:` list is unaffected. Root admins still
|
||||
bypass all ACL evaluation, fence or no fence — that's the deliberate
|
||||
escape hatch for misfiled documents.
|
||||
- **WORM:** the `archive/<party>/issued|received/` mask is path-based,
|
||||
not cascade-based. `inherit:` does not change WORM behaviour.
|
||||
- **WORM:** a `worm:` zone (declared by a `worm: [principal…]` key on a
|
||||
`.zddc` — the baked-in `defaults.zddc.yaml` puts it on
|
||||
`archive/<party>/{received,issued}`) is independent of the `inherit:`
|
||||
fence; `inherit: false` does not change WORM behaviour. See
|
||||
"Canonical-folder behaviour via `.zddc` keys" below.
|
||||
|
||||
**Strict cascade mode IGNORES `inherit: false`.** NIST AC-6 requires
|
||||
ancestor explicit-denies to be absolute, and the inherit directive
|
||||
|
|
@ -499,21 +502,49 @@ Implementation: parser (`zddc/internal/zddc/file.go`),
|
|||
`PolicyChain.VisibleStart` (`zddc/internal/zddc/cascade.go`), and the
|
||||
fence-aware role walk (`zddc/internal/zddc/roles.go`).
|
||||
|
||||
#### Special folders
|
||||
#### Canonical-folder behaviour via `.zddc` keys
|
||||
|
||||
Five folder names trigger built-in behaviors regardless of cascade mode (canonical list in `zddc/internal/zddc/special.go`):
|
||||
**There are no hardcoded folder names.** The canonical project structure
|
||||
(`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,
|
||||
incoming,received,issued}/`) and its built-in behaviours are described by a
|
||||
baked-in baseline `.zddc` — `zddc/internal/zddc/defaults.zddc.yaml`, the
|
||||
bottom layer of every cascade, dumpable with `zddc-server show-defaults` — that
|
||||
uses a recursive `paths:` tree to declare subfolder rules even before those
|
||||
folders exist on disk. Operators override at the on-disk root (or any deeper
|
||||
level) by mirroring the structure and changing what they need; setting
|
||||
file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer
|
||||
entirely (the structural convention included, not just the default ACLs).
|
||||
|
||||
- **`Incoming`, `Working`, `Staging`** — *auto-ownership*. When the file API processes `POST /<parent>/<new>/ X-ZDDC-Op: mkdir` and the parent is one of these three, the server writes a `.zddc` into the new folder containing `created_by: <email>` and `permissions: { <email>: rwcda }`. The grant uses the same direct email-pattern form an operator would write by hand; the creator can edit the `.zddc` later to add collaborators. `created_by` is an audit field — the cascade evaluator does not consult it.
|
||||
- **`Issued`, `Received`** — *write-once / immutable archive*. When a request path crosses an `Issued` or `Received` segment, the server applies a **WORM split**: cascade grants inherited from ancestors above the WORM folder are masked to `r` only; grants at-or-below the WORM folder retain `r,c`. Anyone with `w`/`d`/`a` from inheritance loses those verbs once they enter the archive. To grant write-once (`cr`) to the doc controller, the operator places an explicit `.zddc` at the `Issued` or `Received` folder:
|
||||
The keys that drive built-in behaviour:
|
||||
|
||||
```yaml
|
||||
# /<vendor>/Issued/.zddc
|
||||
acl:
|
||||
permissions:
|
||||
_doc_controller: cr
|
||||
```
|
||||
| Key | Effect |
|
||||
|---|---|
|
||||
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app": `archive` under `archive/`, `transmittal` under `staging/`, `browse` under `working/`+`reviewing/` (`browse` hosts the markdown editor plugin), `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at root. Cascades leaf→root. |
|
||||
| `dir_tool` | tool served at `<dir>/` (trailing slash) — the directory view; floors at `browse`. Cascades leaf→root. (JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless.) |
|
||||
| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`created_by: <email>` + `permissions: { <email>: rwcda }` — the same direct form an operator would write; the creator can edit it later to add collaborators; `created_by` is an audit field, not consulted by the evaluator). `auto_own_fenced` additionally sets `acl.inherit: false` (private to creator). Defaults: `auto_own` on `working`/`staging`/`archive/<party>`/`incoming`; fenced on the per-user `working/<email>/` homes. |
|
||||
| `worm` | `worm: [principal…]` marks a **write-once-read-many** zone: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal cascade ACL granted; admins (root / subtree) bypass entirely — the escape hatch for misfiled documents. Defaults: `worm: [document_controller]` on `archive/<party>/{received,issued}` — so filing into the archive is write-once for the doc controller and immutable for everyone else (same effect as the old hardcoded "WORM split", but the operator can rename `received`/`issued`, mark any path WORM, or add more controllers, without a code change). |
|
||||
| `available_tools` | tools the server may auto-serve at this path (cascade-unioned leaf→root). |
|
||||
| `virtual` | the directory is never materialised on disk (e.g. `reviewing/`, `archive/<party>/mdl`). |
|
||||
| `drop_target` | the browse tool shows a drag-drop upload overlay here (surfaced via the `X-ZDDC-Drop-Target` response header). |
|
||||
| `roles` | `{ name → { members: [...], reset: bool } }` — see "Roles" above (union across the cascade; `reset: true` breaks it). |
|
||||
| `admins` | subtree-admin principals (email globs or role names) — get unconditional `rwcda` over the subtree and bypass the cascade + WORM. |
|
||||
| `paths` | recursive map `<child-name> → <.zddc overlay>` — the engine of the whole convention; the walker threads each ancestor's `paths:` contributions down to the right level. |
|
||||
|
||||
The mask preserves the `c` from this same-level grant, so the doc controller can file new documents — but they still cannot overwrite, delete, or change the ACL. **Only admins (root or subtree) can mutate filed documents.** The mask is server-enforced and not configurable in v1; operators who want a non-WORM directory must avoid the names `Issued` and `Received`.
|
||||
A `.zip` file is also a navigable directory: `GET …/Foo.zip/` returns a JSON
|
||||
listing of its members (or the browse SPA for an HTML request), and
|
||||
`GET …/Foo.zip/sub/doc.pdf` extracts and streams that one member (Range / ETag
|
||||
supported); `GET …/Foo.zip` (no trailing slash) is unchanged — the raw `.zip`
|
||||
download; write methods to a path inside a `.zip` are rejected (405). And
|
||||
`GET /dir/?zip=1` streams an `application/zip` of every readable file under
|
||||
`/dir/`, recursively, ACL-filtered (`Content-Disposition: attachment;
|
||||
filename="<dir>.zip"`).
|
||||
|
||||
The baked-in `defaults.zddc.yaml` is the authoritative, heavily-commented
|
||||
reference for all of the above — `zddc-server show-defaults` prints it.
|
||||
Implementation: `zddc/internal/zddc/walker.go` (`mergeOverlay`, the `paths:`
|
||||
walk), `lookups.go` (`DefaultToolAt`/`DirToolAt`/`AutoOwnAt`/…), `worm.go`,
|
||||
`roles.go`; the file API's mkdir hook (`zddc/internal/handler/fileapi.go`) and
|
||||
`zddc/internal/zddc/ensure.go` seed auto-own `.zddc`s via `AutoOwnAt`.
|
||||
|
||||
### Glob patterns
|
||||
|
||||
|
|
@ -1500,10 +1531,11 @@ fsnotify watcher's debounce window (~2 s) — no service restart needed.
|
|||
|
||||
## Apps: virtual tool HTMLs
|
||||
|
||||
`zddc-server` virtually serves the five tool HTMLs (archive, transmittal,
|
||||
classifier, mdedit, landing) at the appropriate paths. The current-stable
|
||||
build of each tool is **baked into the binary at compile time** via
|
||||
`//go:embed`; that's the default. No fetch happens out of the box.
|
||||
`zddc-server` virtually serves the tool HTMLs (archive, transmittal,
|
||||
classifier, landing, browse, form, tables) at the appropriate paths.
|
||||
The current-stable build of each tool is **baked into the binary at
|
||||
compile time** via `//go:embed`; that's the default. No fetch happens
|
||||
out of the box.
|
||||
|
||||
### Where each tool is served
|
||||
|
||||
|
|
@ -1511,7 +1543,7 @@ build of each tool is **baked into the binary at compile time** via
|
|||
|---------------|-------------------------------------------------------------------------|
|
||||
| `archive` | every directory (multi-project, project, archive, vendor) |
|
||||
| `classifier` | any `Incoming`, `Working`, or `Staging` directory and its subtree |
|
||||
| `mdedit` | any `Working` directory and its subtree |
|
||||
| `browse` | every directory (hosts the in-place markdown editor) |
|
||||
| `transmittal` | any `Staging` directory and its subtree |
|
||||
| `landing` | only at the deployment root (the project picker) |
|
||||
|
||||
|
|
@ -1550,7 +1582,7 @@ to the embedded copy and emits a one-time WARN log per source. The
|
|||
apps:
|
||||
classifier: alpha # track alpha for this project
|
||||
archive: https://my-mirror.internal/zddc/archive_v0.0.4.html # custom mirror, pinned
|
||||
mdedit: ./our-mdedit.html # local fork
|
||||
browse: ./our-browse.html # local fork
|
||||
```
|
||||
|
||||
### Env vars
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import (
|
|||
"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/cache"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
|
||||
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
|
||||
|
|
@ -86,6 +87,19 @@ func main() {
|
|||
"addr", cfg.Addr,
|
||||
"embedded_apps", embeddedVersionsForLog(embedded))
|
||||
|
||||
// Probe the container runtime for the MD→{docx,html,pdf} endpoint.
|
||||
// Non-fatal: if the host has no podman/docker, conversion requests
|
||||
// return 503 and everything else keeps working. The probe installs
|
||||
// the package-level Runner when an engine is found; the configured
|
||||
// image refs are pulled lazily on first conversion via
|
||||
// `--pull=missing` so there's no manual setup beyond installing
|
||||
// podman or docker.
|
||||
convert.SetImages(cfg.ConvertPandocImage, cfg.ConvertChromiumImage)
|
||||
probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
convert.Probe(probeCtx, cfg.ConvertEngine)
|
||||
probeCancel()
|
||||
convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertCPUs, cfg.ConvertPIDs, cfg.ConvertTimeout)
|
||||
|
||||
// Client mode short-circuit: when cfg.Upstream is set, this binary
|
||||
// runs as a downstream proxy/cache/mirror rather than a master.
|
||||
// The master-side machinery below (archive index, watcher, apps
|
||||
|
|
@ -472,7 +486,7 @@ func setupAccessAuditLog(path string) *slog.Logger {
|
|||
// through unchanged when the client doesn't advertise gzip), appends
|
||||
// Vary: Accept-Encoding automatically, and passes through 304s untouched.
|
||||
// Yields ~75% size reduction on the larger embedded HTML responses
|
||||
// (mdedit: 920 KB → ~250 KB on the wire).
|
||||
// (browse: ~2 MB → a few hundred KB on the wire).
|
||||
//
|
||||
// Extracted so tests can construct an equivalent wrapper without going
|
||||
// through the full main() server boot.
|
||||
|
|
@ -981,9 +995,9 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// File doesn't exist at this path. If the URL matches one of
|
||||
// the canonical app HTML names AND the request directory is
|
||||
// one where that app is available (working/staging/incoming
|
||||
// for classifier, working for mdedit, staging for
|
||||
// transmittal, anywhere for archive, root only for landing),
|
||||
// resolve via the apps subsystem.
|
||||
// for classifier, staging for transmittal, anywhere for
|
||||
// archive + browse, root only for landing), resolve via the
|
||||
// apps subsystem.
|
||||
if appsSrv != nil {
|
||||
if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
|
||||
requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
|
||||
|
|
@ -1002,13 +1016,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// a virtual view. The shape rule mirrors the other canonical
|
||||
// folders (slash → browse, no-slash → default tool):
|
||||
// - JSON request, any depth → aggregator listing (handler.ServeReviewing)
|
||||
// - HTML, no slash → mdedit (default tool, via DefaultAppAt)
|
||||
// - HTML, no slash → browse (default tool, via DefaultAppAt;
|
||||
// browse hosts the markdown editor plugin)
|
||||
// - HTML, with slash → browse.html (via ServeDirectory).
|
||||
// browse fetches JSON which routes back
|
||||
// through here to ServeReviewing.
|
||||
// Depth-3 no-slash (reviewing/<tracking>) 302s to the slash form.
|
||||
// Depth-2 no-slash (reviewing) falls through to the canonical-
|
||||
// folder block below where DefaultAppAt routes to mdedit.
|
||||
// folder block below where DefaultAppAt routes to browse.
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if proj, tracking, sidePath, ok := handler.IsReviewingPath(urlPath); ok {
|
||||
if !strings.HasSuffix(urlPath, "/") {
|
||||
|
|
@ -1098,9 +1113,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
// directory view (handler.ServeDirectory → DirTool, which
|
||||
// resolves to browse by default; JSON requests always get the
|
||||
// raw listing regardless). No trailing slash → the directory's
|
||||
// default_tool ("specialized app") — mdedit under working/,
|
||||
// transmittal under staging/, archive under archive/, tables
|
||||
// under archive/<party>/mdl/ — if one is declared; otherwise
|
||||
// default_tool ("specialized app") — browse under working/+
|
||||
// reviewing/ (hosts the markdown editor), transmittal under
|
||||
// staging/, archive under archive/, tables under
|
||||
// archive/<party>/mdl/ — if one is declared; otherwise
|
||||
// (after the project-root landing case below) a 302 to the
|
||||
// slash form.
|
||||
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
|
||||
|
|
@ -1138,6 +1154,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// MD→{docx,html,pdf} on-demand conversion. The endpoint reuses the
|
||||
// source file's read policy (already gated above), so no separate
|
||||
// ACL verb. Only .md sources are convertible; everything else falls
|
||||
// through to the regular file serve.
|
||||
if fmt := r.URL.Query().Get("convert"); fmt != "" &&
|
||||
strings.HasSuffix(strings.ToLower(absPath), ".md") {
|
||||
handler.ServeConverted(cfg, w, r, absPath, fmt, chain)
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeFile(w, r, absPath)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,14 +138,14 @@ func TestDispatchAppsResolution(t *testing.T) {
|
|||
"archive": upstream.URL + "/archive_stable.html",
|
||||
"transmittal": upstream.URL + "/transmittal_stable.html",
|
||||
"classifier": upstream.URL + "/classifier_stable.html",
|
||||
"mdedit": upstream.URL + "/mdedit_stable.html",
|
||||
"landing": upstream.URL + "/landing_stable.html",
|
||||
"browse": upstream.URL + "/browse_stable.html",
|
||||
},
|
||||
}
|
||||
if err := zddc.WriteFile(root, zf); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
// Create folder convention dirs so classifier/mdedit/transmittal
|
||||
// Create folder convention dirs so classifier/browse/transmittal
|
||||
// availability rules pass for the test paths used below.
|
||||
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
||||
|
||||
|
|
@ -380,10 +380,10 @@ func TestDispatchArchiveRedirect(t *testing.T) {
|
|||
func TestDispatchSlashRouting(t *testing.T) {
|
||||
// Convention: <dir>/ → browse (directory view, via DirTool which
|
||||
// defaults to browse); <dir> → the directory's default_tool ("the
|
||||
// specialized app": mdedit under working/, transmittal under
|
||||
// staging/, archive under archive/, tables under archive/<party>/mdl).
|
||||
// Without a default_tool, no-slash falls through to the trailing-
|
||||
// slash redirect (302).
|
||||
// specialized app": browse under working/+reviewing/, transmittal
|
||||
// under staging/, archive under archive/, tables under
|
||||
// archive/<party>/mdl). Without a default_tool, no-slash falls
|
||||
// through to the trailing-slash redirect (302).
|
||||
//
|
||||
// The only trailing-slash redirect is for a directory that is the
|
||||
// rows-dir of a table declared via a REAL on-disk parent .zddc
|
||||
|
|
@ -429,7 +429,7 @@ func TestDispatchSlashRouting(t *testing.T) {
|
|||
wantNoRedirect bool
|
||||
wantLoc string // checked when wantStatus is a redirect
|
||||
}{
|
||||
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true, ""},
|
||||
{"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
|
||||
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
|
||||
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
|
||||
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
|
||||
|
|
@ -525,21 +525,21 @@ func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
|
|||
}
|
||||
|
||||
// No-trailing-slash form on a canonical folder → default app
|
||||
// (mdedit for working/, transmittal for staging/, archive for
|
||||
// archive/). Mirror of the existing "no-slash → default app"
|
||||
// behavior at the IsDir branch, extended to cover the case where
|
||||
// the folder doesn't exist on disk yet.
|
||||
// (browse for working/+reviewing/, transmittal for staging/,
|
||||
// archive for archive/). Mirror of the existing "no-slash →
|
||||
// default app" behavior at the IsDir branch, extended to cover
|
||||
// the case where the folder doesn't exist on disk yet.
|
||||
noSlashDefaultApp := []struct {
|
||||
stage string
|
||||
expect string // substring that should appear in the response body
|
||||
}{
|
||||
{"working", "ZDDC Markdown"},
|
||||
{"working", "ZDDC Browse"},
|
||||
{"staging", "ZDDC Transmittal"},
|
||||
{"archive", "ZDDC Archive"},
|
||||
// reviewing/ also routes to mdedit; the polyfill follows the
|
||||
// virtual aggregator's listing into canonical archive/+staging
|
||||
// paths from there.
|
||||
{"reviewing", "ZDDC Markdown"},
|
||||
// reviewing/ also routes to browse (markdown editor lives
|
||||
// inside it now); the polyfill follows the virtual aggregator's
|
||||
// listing into canonical archive/+staging paths from there.
|
||||
{"reviewing", "ZDDC Browse"},
|
||||
}
|
||||
for _, tc := range noSlashDefaultApp {
|
||||
t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Package apps serves the five ZDDC tool HTML files (archive, transmittal,
|
||||
// classifier, mdedit, landing) on virtual paths in the file tree. Each tool
|
||||
// is "available" only at directories whose name matches a folder convention
|
||||
// (Incoming/Working/Staging) — see availability.go.
|
||||
// Package apps serves the ZDDC tool HTML files (archive, transmittal,
|
||||
// classifier, landing, browse, form, tables) on virtual paths in the
|
||||
// file tree. Each tool is "available" only at directories whose name
|
||||
// matches a folder convention (Incoming/Working/Staging) — see
|
||||
// availability.go. The markdown editor lives as a plugin inside browse.
|
||||
//
|
||||
// Resolution priority for an enabled <dir>/<app>.html request:
|
||||
//
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ func AppAvailableAt(root, requestDir, app string) bool {
|
|||
// - <project>/archive/ → "archive"
|
||||
// - <project>/archive/<party>/... → "archive"
|
||||
// - <project>/staging/... → "transmittal"
|
||||
// - <project>/working/... → "mdedit"
|
||||
// - <project>/reviewing/... → "mdedit" (operates on the
|
||||
// - <project>/working/... → "browse" (hosts the
|
||||
// markdown editor plugin)
|
||||
// - <project>/reviewing/... → "browse" (operates on the
|
||||
// virtual aggregator listing)
|
||||
// - any other directory → "" (no default)
|
||||
//
|
||||
|
|
|
|||
|
|
@ -35,11 +35,12 @@ func TestAppAvailableAt(t *testing.T) {
|
|||
{root + "/Project-A/archive/ACME/mdl", "classifier", false},
|
||||
{root + "/Project-A/some-other-folder", "classifier", false},
|
||||
|
||||
// mdedit: working/ only (review responses live in working/<rs-name>/)
|
||||
{root + "/Project-A/working", "mdedit", true},
|
||||
{root + "/Project-A/working/sub", "mdedit", true},
|
||||
{root + "/Project-A/staging", "mdedit", false},
|
||||
{root + "/Project-A/archive/ACME/incoming", "mdedit", false},
|
||||
// browse: universal — every directory has browse available
|
||||
// (it's in the embedded-defaults baseline available_tools).
|
||||
{root + "/Project-A/working", "browse", true},
|
||||
{root + "/Project-A/working/sub", "browse", true},
|
||||
{root + "/Project-A/staging", "browse", true},
|
||||
{root + "/Project-A/archive/ACME/incoming", "browse", true},
|
||||
|
||||
// transmittal: staging/ only
|
||||
{root + "/Project-A/staging", "transmittal", true},
|
||||
|
|
@ -48,8 +49,6 @@ func TestAppAvailableAt(t *testing.T) {
|
|||
{root + "/Project-A/archive/ACME/issued", "transmittal", false},
|
||||
|
||||
// case-fold: any case of canonical names matches
|
||||
{root + "/Project-A/Working", "mdedit", true},
|
||||
{root + "/Project-A/WORKING", "mdedit", true},
|
||||
{root + "/Project-A/Staging", "transmittal", true},
|
||||
{root + "/Project-A/STAGING", "transmittal", true},
|
||||
{root + "/Project-A/archive/ACME/Incoming", "classifier", true},
|
||||
|
|
@ -81,9 +80,9 @@ func TestDefaultAppAt(t *testing.T) {
|
|||
// no-slash falls through to the redirect.
|
||||
{root + "/Project-A", ""},
|
||||
// Canonical project-root folders.
|
||||
{root + "/Project-A/working", "mdedit"},
|
||||
{root + "/Project-A/working/alice@example.com", "mdedit"},
|
||||
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "mdedit"},
|
||||
{root + "/Project-A/working", "browse"},
|
||||
{root + "/Project-A/working/alice@example.com", "browse"},
|
||||
{root + "/Project-A/working/2026-06-15_x (DFT) - y", "browse"},
|
||||
{root + "/Project-A/staging", "transmittal"},
|
||||
{root + "/Project-A/staging/2026-06-15_x (DFT) - y", "transmittal"},
|
||||
// archive: at the archive root, party folders default to archive.
|
||||
|
|
@ -98,15 +97,16 @@ func TestDefaultAppAt(t *testing.T) {
|
|||
// mdl wins over the broader archive rule.
|
||||
{root + "/Project-A/archive/Acme/mdl", "tables"},
|
||||
{root + "/Project-A/archive/Acme/mdl/anything-deeper", "tables"},
|
||||
// reviewing/ is virtual but mdedit is wired as the default
|
||||
// tool; the polyfill follows the listing's canonical URLs
|
||||
// into archive/ and staging/ for the actual files.
|
||||
{root + "/Project-A/reviewing", "mdedit"},
|
||||
{root + "/Project-A/reviewing/123-EM-SUB-0001", "mdedit"},
|
||||
// reviewing/ is virtual; browse hosts the markdown editor that
|
||||
// renders responses (the polyfill follows the listing's
|
||||
// canonical URLs into archive/ and staging/ for the actual
|
||||
// files).
|
||||
{root + "/Project-A/reviewing", "browse"},
|
||||
{root + "/Project-A/reviewing/123-EM-SUB-0001", "browse"},
|
||||
// Random non-canonical folder names → no default.
|
||||
{root + "/Project-A/scratch", ""},
|
||||
// Case-fold on canonical names.
|
||||
{root + "/Project-A/Working", "mdedit"},
|
||||
{root + "/Project-A/Working", "browse"},
|
||||
{root + "/Project-A/STAGING", "transmittal"},
|
||||
{root + "/Project-A/Archive/Acme/MDL", "tables"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// Embedded fallback: the five tool HTMLs from the time the binary was
|
||||
// built. Used as a last-resort served-bytes when (cache miss) AND
|
||||
// (upstream unreachable) AND (no operator override) — see handler.go.
|
||||
// Embedded fallback: tool HTMLs from the time the binary was built.
|
||||
// Used as a last-resort served-bytes when (cache miss) AND (upstream
|
||||
// unreachable) AND (no operator override) — see handler.go.
|
||||
//
|
||||
// The files are populated by the top-level build.sh, which copies the
|
||||
// freshly-built dist/<tool>.html into ./embedded/ before `go build` runs.
|
||||
|
|
@ -26,9 +26,6 @@ var embeddedTransmittal []byte
|
|||
//go:embed embedded/classifier.html
|
||||
var embeddedClassifier []byte
|
||||
|
||||
//go:embed embedded/mdedit.html
|
||||
var embeddedMdedit []byte
|
||||
|
||||
//go:embed embedded/index.html
|
||||
var embeddedLanding []byte
|
||||
|
||||
|
|
@ -47,8 +44,6 @@ func EmbeddedBytes(app string) []byte {
|
|||
b = embeddedTransmittal
|
||||
case "classifier":
|
||||
b = embeddedClassifier
|
||||
case "mdedit":
|
||||
b = embeddedMdedit
|
||||
case "landing":
|
||||
b = embeddedLanding
|
||||
case "browse":
|
||||
|
|
|
|||
|
|
@ -2470,7 +2470,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
@ -5214,7 +5214,7 @@ X.B(E,Y);return E}return J}())
|
|||
*
|
||||
* Renderers operate on any document (parent window or popup window), so the
|
||||
* same code works for tools whose preview opens in a popup (classifier,
|
||||
* archive, transmittal) and tools that render inline (mdedit).
|
||||
* archive, transmittal) and tools that render inline (browse).
|
||||
*
|
||||
* Public API on window.zddc.preview:
|
||||
* loadLibrary(url) → Promise<void>
|
||||
|
|
|
|||
|
|
@ -1477,6 +1477,16 @@ html, body {
|
|||
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. */
|
||||
|
|
@ -1562,34 +1572,41 @@ html, body {
|
|||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Front matter list ──────────────────────────────────────────────────── */
|
||||
.md-fm__empty {
|
||||
/* ── Front matter editor ────────────────────────────────────────────────── */
|
||||
.md-fm__body {
|
||||
/* Body cell owns the textarea; sized by the sidebar's grid row. */
|
||||
padding: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.md-fm__textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
resize: none;
|
||||
outline: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
.md-fm__textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.md-fm__list {
|
||||
margin: 0;
|
||||
padding: 0.3rem 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(4.5rem, max-content) 1fr;
|
||||
gap: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
.md-fm__textarea:focus {
|
||||
background: var(--surface-2, rgba(0, 0, 0, 0.025));
|
||||
}
|
||||
.md-fm__list dt {
|
||||
font-weight: 600;
|
||||
.md-fm__textarea[readonly] {
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.md-fm__list dd {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
overflow-wrap: anywhere;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Sort control ────────────────────────────────────────────────────────── */
|
||||
|
|
@ -1640,7 +1657,7 @@ html, body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Browse</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||
|
|
@ -4293,7 +4310,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
*
|
||||
* Renderers operate on any document (parent window or popup window), so the
|
||||
* same code works for tools whose preview opens in a popup (classifier,
|
||||
* archive, transmittal) and tools that render inline (mdedit).
|
||||
* archive, transmittal) and tools that render inline (browse).
|
||||
*
|
||||
* Public API on window.zddc.preview:
|
||||
* loadLibrary(url) → Promise<void>
|
||||
|
|
@ -5874,22 +5891,31 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
//
|
||||
// Layout (CSS Grid):
|
||||
// ┌─────────────────────────────────────────────────────────────────┐
|
||||
// │ toolbar: Save | ● modified | status | source │
|
||||
// ├────────────────────────────────────────┬────────────────────────┤
|
||||
// │ │ Outline │
|
||||
// │ │ • Heading 1 │
|
||||
// │ Toast UI Editor │ • Subheading │
|
||||
// │ (md / wysiwyg / preview) │ • Heading 2 │
|
||||
// │ ├────────────────────────┤
|
||||
// │ │ Front matter │
|
||||
// │ │ title: Foo │
|
||||
// │ │ revision: A │
|
||||
// └────────────────────────────────────────┴────────────────────────┘
|
||||
// │ 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 <textarea> in the sidebar
|
||||
// (always present — typing into the placeholder grows the envelope on
|
||||
// save). On load the `---\n…\n---\n` envelope is stripped from the
|
||||
// bytes fed to Toast UI; on save the textarea content is re-stitched
|
||||
// on top of the editor body. Keeps YAML out of the rich editor where
|
||||
// users can't reliably edit it.
|
||||
//
|
||||
// Save (Ctrl+S) writes back via PUT (server mode) or
|
||||
// FileSystemWritableFileStream (FS-API). Zip-virtual files are
|
||||
// read-only — Save stays disabled. Toast UI is vendored
|
||||
|
|
@ -5965,25 +5991,37 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
return { data: data, body: body };
|
||||
}
|
||||
|
||||
function renderFrontMatter(fmEl, content) {
|
||||
if (!fmEl) return;
|
||||
var parsed = parseFrontMatter(content);
|
||||
var keys = Object.keys(parsed.data);
|
||||
if (keys.length === 0) {
|
||||
fmEl.innerHTML = '<p class="md-fm__empty">No front matter.</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<dl class="md-fm__list">';
|
||||
// Inverse of parseFrontMatter — turn a {key: value | array} object back
|
||||
// into newline-separated YAML lines suitable for the textarea. Arrays
|
||||
// are quoted to match what the parser will round-trip through. Returns
|
||||
// "" when there are no keys (so the textarea shows its placeholder).
|
||||
function stringifyFrontMatter(data) {
|
||||
if (!data) return '';
|
||||
var keys = Object.keys(data);
|
||||
if (keys.length === 0) return '';
|
||||
var out = [];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var k = keys[i];
|
||||
var v = parsed.data[k];
|
||||
var displayV = Array.isArray(v)
|
||||
? v.map(escapeHtml).join(', ')
|
||||
: escapeHtml(String(v));
|
||||
html += '<dt>' + escapeHtml(k) + '</dt><dd>' + displayV + '</dd>';
|
||||
var v = data[k];
|
||||
if (Array.isArray(v)) {
|
||||
out.push(k + ': [' + v.map(function (x) {
|
||||
return '"' + String(x).replace(/"/g, '\\"') + '"';
|
||||
}).join(', ') + ']');
|
||||
} else {
|
||||
out.push(k + ': ' + String(v));
|
||||
}
|
||||
}
|
||||
html += '</dl>';
|
||||
fmEl.innerHTML = html;
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
// Stitch the textarea's YAML lines and the editor's body back together
|
||||
// into the on-disk envelope. Empty textarea → return body unchanged
|
||||
// (no envelope written). Trailing whitespace in the textarea is
|
||||
// tolerated.
|
||||
function assembleContent(fmText, body) {
|
||||
var fm = (fmText || '').replace(/\s+$/, '');
|
||||
if (!fm) return body || '';
|
||||
return '---\n' + fm + '\n---\n' + (body || '');
|
||||
}
|
||||
|
||||
// ── TOC (table of contents) ────────────────────────────────────────────
|
||||
|
|
@ -6209,6 +6247,13 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
fmHeader.textContent = 'YAML front matter';
|
||||
var fmBody = document.createElement('div');
|
||||
fmBody.className = 'md-side__body md-fm__body';
|
||||
var fmTextarea = document.createElement('textarea');
|
||||
fmTextarea.className = 'md-fm__textarea';
|
||||
fmTextarea.spellcheck = false;
|
||||
fmTextarea.autocapitalize = 'off';
|
||||
fmTextarea.autocomplete = 'off';
|
||||
fmTextarea.placeholder = 'title: Document Title\ndate: 2026-05-13\ntags: [example]';
|
||||
fmBody.appendChild(fmTextarea);
|
||||
fmSection.appendChild(fmHeader);
|
||||
fmSection.appendChild(fmBody);
|
||||
sidebar.appendChild(fmSection);
|
||||
|
|
@ -6280,10 +6325,35 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
sourceEl.textContent = 'server';
|
||||
}
|
||||
|
||||
// Download-as-{docx,html,pdf} buttons. Server-mode + .md only:
|
||||
// the server endpoint runs pandoc/chromium in a container and
|
||||
// returns the converted bytes. Click handlers wire up below
|
||||
// (after save() is defined) because they auto-save first when
|
||||
// the buffer is dirty.
|
||||
var serverModeMd = window.app && window.app.state &&
|
||||
window.app.state.source === 'server' &&
|
||||
node.url && /\.md$/i.test(node.name);
|
||||
var convertBtns = [];
|
||||
if (serverModeMd && window.zddc && window.zddc.source &&
|
||||
typeof window.zddc.source.downloadConverted === 'function') {
|
||||
['docx', 'html', 'pdf'].forEach(function (fmt) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'btn btn-sm btn-secondary md-shell__download';
|
||||
btn.type = 'button';
|
||||
btn.textContent = fmt.toUpperCase();
|
||||
btn.title = 'Download as ' + fmt.toUpperCase();
|
||||
btn.dataset.fmt = fmt;
|
||||
convertBtns.push(btn);
|
||||
});
|
||||
}
|
||||
|
||||
infohdr.appendChild(titleEl);
|
||||
infohdr.appendChild(dirtyEl);
|
||||
infohdr.appendChild(statusEl);
|
||||
infohdr.appendChild(sourceEl);
|
||||
for (var ci = 0; ci < convertBtns.length; ci++) {
|
||||
infohdr.appendChild(convertBtns[ci]);
|
||||
}
|
||||
infohdr.appendChild(saveBtn);
|
||||
content.appendChild(infohdr);
|
||||
|
||||
|
|
@ -6292,15 +6362,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
editorHost.className = 'md-shell__editor';
|
||||
content.appendChild(editorHost);
|
||||
|
||||
// Construct the editor. height: 100% works because editorHost
|
||||
// is a grid cell with a definite size.
|
||||
var initialHash = await hashContent(text);
|
||||
// Split the loaded bytes into FM (textarea) + body (editor). The
|
||||
// hash that gates dirty-state is taken over the reassembled
|
||||
// bytes so that round-tripping a clean file shows "not dirty"
|
||||
// even if we tweak whitespace in the YAML lines.
|
||||
var initialParsed = parseFrontMatter(text);
|
||||
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
||||
var bodyText = initialParsed.body;
|
||||
|
||||
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
|
||||
var editor = new window.toastui.Editor({
|
||||
el: editorHost,
|
||||
height: '100%',
|
||||
initialEditType: 'markdown',
|
||||
previewStyle: 'vertical',
|
||||
initialValue: text,
|
||||
initialValue: bodyText,
|
||||
usageStatistics: false,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
|
|
@ -6318,17 +6394,17 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
node: node,
|
||||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
fmEl: fmBody
|
||||
fmEl: fmTextarea
|
||||
};
|
||||
|
||||
var writable = canSave(node);
|
||||
if (!writable) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
fmTextarea.readOnly = true;
|
||||
}
|
||||
|
||||
renderToc(tocBody, text, editor);
|
||||
renderFrontMatter(fmBody, text);
|
||||
renderToc(tocBody, bodyText, editor);
|
||||
|
||||
// ── Sidebar/content resizer ─────────────────────────────────────────
|
||||
// Sidebar is on the LEFT now. Dragging right grows the
|
||||
|
|
@ -6424,18 +6500,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
}
|
||||
|
||||
var onChange = debounce(async function () {
|
||||
var current = editor.getMarkdown();
|
||||
var h = await hashContent(current);
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
renderToc(tocBody, current, editor);
|
||||
renderFrontMatter(fmBody, current);
|
||||
renderToc(tocBody, body, editor);
|
||||
}, 250);
|
||||
editor.on('change', onChange);
|
||||
|
||||
var onFmChange = debounce(async function () {
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
markDirty(h !== currentInstance.hash);
|
||||
}, 250);
|
||||
fmTextarea.addEventListener('input', onFmChange);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
async function save() {
|
||||
if (!currentInstance.dirty || !writable) return;
|
||||
var content = editor.getMarkdown();
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
await saveContent(node, content);
|
||||
|
|
@ -6459,6 +6541,42 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
|||
save();
|
||||
}
|
||||
});
|
||||
|
||||
// Download-as-* click handlers. Auto-save when the buffer is
|
||||
// dirty so the converted file reflects what's on screen. If
|
||||
// the save fails the existing toast/status surfaces it; we
|
||||
// bail without firing the conversion.
|
||||
convertBtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', async function () {
|
||||
var fmt = btn.dataset.fmt;
|
||||
if (currentInstance.dirty) {
|
||||
if (!writable) {
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast(
|
||||
'This source is read-only — save a copy elsewhere first.',
|
||||
'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try { await save(); } finally { btn.disabled = false; }
|
||||
if (currentInstance.dirty) return; // save failed
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
statusEl.textContent = 'Converting to ' + fmt.toUpperCase() + '…';
|
||||
await window.zddc.source.downloadConverted(node.url, node.name, fmt);
|
||||
statusEl.textContent = 'Downloaded ' + fmt.toUpperCase();
|
||||
} catch (e) {
|
||||
statusEl.textContent = (e && e.message) || String(e);
|
||||
if (window.zddc && window.zddc.toast) {
|
||||
window.zddc.toast((e && e.message) || String(e), 'error');
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.app.modules.markdown = {
|
||||
|
|
|
|||
|
|
@ -1681,7 +1681,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
@ -3584,7 +3584,7 @@ X.B(E,Y);return E}return J}())
|
|||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
// shared/zddc-source.js — source abstraction for tools that handle
|
||||
// directory trees (classifier, mdedit, transmittal, browse, archive).
|
||||
// directory trees (classifier, transmittal, browse, archive).
|
||||
//
|
||||
// Two backends:
|
||||
//
|
||||
|
|
@ -3955,12 +3955,44 @@ X.B(E,Y);return E}return J}())
|
|||
return !!(handle && handle.isHttp === true);
|
||||
}
|
||||
|
||||
// downloadConverted fetches a server-side MD→{docx,html,pdf}
|
||||
// conversion and triggers a browser download with a clean filename.
|
||||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
|
||||
else if (resp.status === 504) msg = 'Conversion timed out.';
|
||||
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
|
||||
// Append server-supplied body text if it adds detail.
|
||||
try {
|
||||
var detail = await resp.text();
|
||||
if (detail && detail.length < 400) msg += ' ' + detail.trim();
|
||||
} catch (_) { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
var blob = await resp.blob();
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
}
|
||||
|
||||
window.zddc.source = {
|
||||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||||
HttpFileHandle: HttpFileHandle,
|
||||
detectServerRoot: detectServerRoot,
|
||||
moveFile: moveFile,
|
||||
isHttpHandle: isHttpHandle,
|
||||
downloadConverted: downloadConverted,
|
||||
// Lower-level helpers exposed for tools that want to call the
|
||||
// server directly without going through the polyfill.
|
||||
httpListing: httpListing,
|
||||
|
|
@ -4428,7 +4460,7 @@ X.B(E,Y);return E}return J}())
|
|||
*
|
||||
* Renderers operate on any document (parent window or popup window), so the
|
||||
* same code works for tools whose preview opens in a popup (classifier,
|
||||
* archive, transmittal) and tools that render inline (mdedit).
|
||||
* archive, transmittal) and tools that render inline (browse).
|
||||
*
|
||||
* Public API on window.zddc.preview:
|
||||
* loadLibrary(url) → Promise<void>
|
||||
|
|
|
|||
|
|
@ -1424,7 +1424,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -3284,9 +3284,9 @@ body {
|
|||
|
||||
// Render the project-workspace view: title, four stage links, MDL
|
||||
// section. Stage hrefs use the no-trailing-slash form so the server
|
||||
// routes them to each canonical default tool (mdedit for working/,
|
||||
// transmittal for staging/, etc.). Browse-all and the archive deep
|
||||
// link use the slash form to land on the directory listing.
|
||||
// routes them to each canonical default tool (browse for working/+
|
||||
// reviewing/, transmittal for staging/, etc.). Browse-all and the
|
||||
// archive deep link use the slash form to land on the directory listing.
|
||||
async function renderProjectMode() {
|
||||
var project = projectFromPath();
|
||||
if (!project) return;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2523,7 +2523,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
@ -4640,7 +4640,7 @@ X.B(E,Y);return E}return J}())
|
|||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
|
||||
// shared/zddc-source.js — source abstraction for tools that handle
|
||||
// directory trees (classifier, mdedit, transmittal, browse, archive).
|
||||
// directory trees (classifier, transmittal, browse, archive).
|
||||
//
|
||||
// Two backends:
|
||||
//
|
||||
|
|
@ -5011,12 +5011,44 @@ X.B(E,Y);return E}return J}())
|
|||
return !!(handle && handle.isHttp === true);
|
||||
}
|
||||
|
||||
// downloadConverted fetches a server-side MD→{docx,html,pdf}
|
||||
// conversion and triggers a browser download with a clean filename.
|
||||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
|
||||
else if (resp.status === 504) msg = 'Conversion timed out.';
|
||||
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
|
||||
// Append server-supplied body text if it adds detail.
|
||||
try {
|
||||
var detail = await resp.text();
|
||||
if (detail && detail.length < 400) msg += ' ' + detail.trim();
|
||||
} catch (_) { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
var blob = await resp.blob();
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
}
|
||||
|
||||
window.zddc.source = {
|
||||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||||
HttpFileHandle: HttpFileHandle,
|
||||
detectServerRoot: detectServerRoot,
|
||||
moveFile: moveFile,
|
||||
isHttpHandle: isHttpHandle,
|
||||
downloadConverted: downloadConverted,
|
||||
// Lower-level helpers exposed for tools that want to call the
|
||||
// server directly without going through the polyfill.
|
||||
httpListing: httpListing,
|
||||
|
|
@ -5484,7 +5516,7 @@ X.B(E,Y);return E}return J}())
|
|||
*
|
||||
* Renderers operate on any document (parent window or popup window), so the
|
||||
* same code works for tools whose preview opens in a popup (classifier,
|
||||
* archive, transmittal) and tools that render inline (mdedit).
|
||||
* archive, transmittal) and tools that render inline (browse).
|
||||
*
|
||||
* Public API on window.zddc.preview:
|
||||
* loadLibrary(url) → Promise<void>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
transmittal=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
classifier=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
mdedit=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
landing=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
form=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
tables=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
browse=v0.0.17-beta · 2026-05-12 · candle-mast-pearl
|
||||
archive=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
transmittal=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
classifier=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
landing=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
form=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
tables=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
browse=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||
|
|
|
|||
|
|
@ -60,8 +60,6 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
|
|||
return "transmittal", dir
|
||||
case "classifier.html":
|
||||
return "classifier", dir
|
||||
case "mdedit.html":
|
||||
return "mdedit", dir
|
||||
case "browse.html":
|
||||
return "browse", dir
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ func TestMatchAppHTML(t *testing.T) {
|
|||
{"/index.html", "landing", ""},
|
||||
{"/archive.html", "archive", ""},
|
||||
{"/Project-X/archive.html", "archive", "Project-X"},
|
||||
{"/Project-X/Working/mdedit.html", "mdedit", "Project-X/Working"},
|
||||
{"/Project-X/Working/browse.html", "browse", "Project-X/Working"},
|
||||
{"/foo.html", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,20 @@ type Config struct {
|
|||
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
|
||||
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
|
||||
ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable.
|
||||
|
||||
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
|
||||
// The server shells out to upstream pandoc + chromium container
|
||||
// images via podman or docker, pulling each on first use via
|
||||
// `--pull=missing`. No custom image build is required — only that
|
||||
// podman or docker is on PATH and the configured image refs are
|
||||
// reachable. If no runtime is found the endpoint serves 503.
|
||||
ConvertPandocImage string // --convert-pandoc-image / ZDDC_CONVERT_PANDOC_IMAGE — image for MD→DOCX/HTML. Default docker.io/pandoc/latex:latest.
|
||||
ConvertChromiumImage string // --convert-chromium-image / ZDDC_CONVERT_CHROMIUM_IMAGE — image for HTML→PDF. Default docker.io/zenika/alpine-chrome:latest.
|
||||
ConvertEngine string // --convert-engine / ZDDC_CONVERT_ENGINE — override engine binary (default: probe for podman, then docker).
|
||||
ConvertMemMiB int // --convert-mem-mib / ZDDC_CONVERT_MEM_MIB — per-container memory cap in MiB. Default 512.
|
||||
ConvertCPUs string // --convert-cpus / ZDDC_CONVERT_CPUS — per-container CPU limit. Default "2".
|
||||
ConvertPIDs int // --convert-pids / ZDDC_CONVERT_PIDS — per-container PID limit. Default 100.
|
||||
ConvertTimeout time.Duration // --convert-timeout / ZDDC_CONVERT_TIMEOUT — per-conversion wall clock. Default 30s.
|
||||
}
|
||||
|
||||
// ErrHelpRequested is returned by Load when --help is passed; the caller
|
||||
|
|
@ -127,6 +141,20 @@ func Load(args []string) (Config, error) {
|
|||
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
|
||||
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
|
||||
"Periodic full re-walk of the archive index. Required on SMB/CIFS-backed roots where inotify misses cross-client writes. Default 60s; set 0 to disable.")
|
||||
convertPandocImageFlag := fs.String("convert-pandoc-image", getEnv("ZDDC_CONVERT_PANDOC_IMAGE", "docker.io/pandoc/latex:latest"),
|
||||
"Pandoc container image for MD→DOCX and MD→HTML. Pulled on first use via --pull=missing.")
|
||||
convertChromiumImageFlag := fs.String("convert-chromium-image", getEnv("ZDDC_CONVERT_CHROMIUM_IMAGE", "docker.io/zenika/alpine-chrome:latest"),
|
||||
"Headless Chromium container image for HTML→PDF. Pulled on first use via --pull=missing.")
|
||||
convertEngineFlag := fs.String("convert-engine", os.Getenv("ZDDC_CONVERT_ENGINE"),
|
||||
"Container engine override (default: probe for podman, then docker).")
|
||||
convertMemMiBFlag := fs.Int("convert-mem-mib", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_MEM_MIB"), 512),
|
||||
"Per-conversion container memory limit in MiB. Default 512.")
|
||||
convertCPUsFlag := fs.String("convert-cpus", getEnv("ZDDC_CONVERT_CPUS", "2"),
|
||||
"Per-conversion container CPU limit (passed to --cpus). Default 2.")
|
||||
convertPIDsFlag := fs.Int("convert-pids", parseIntOrDefault(os.Getenv("ZDDC_CONVERT_PIDS"), 100),
|
||||
"Per-conversion container PID limit. Default 100.")
|
||||
convertTimeoutFlag := fs.Duration("convert-timeout", parseDurationOrDefault(os.Getenv("ZDDC_CONVERT_TIMEOUT"), 30*time.Second),
|
||||
"Per-conversion wall-clock timeout. Default 30s.")
|
||||
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
|
||||
"Tee structured access logs to this file (JSON, size-rotated). "+
|
||||
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
|
||||
|
|
@ -199,6 +227,13 @@ func Load(args []string) (Config, error) {
|
|||
MaxWriteBytes: *maxWriteBytesFlag,
|
||||
CascadeMode: *cascadeModeFlag,
|
||||
ArchiveRescanInterval: *archiveRescanIntervalFlag,
|
||||
ConvertPandocImage: *convertPandocImageFlag,
|
||||
ConvertChromiumImage: *convertChromiumImageFlag,
|
||||
ConvertEngine: *convertEngineFlag,
|
||||
ConvertMemMiB: *convertMemMiBFlag,
|
||||
ConvertCPUs: *convertCPUsFlag,
|
||||
ConvertPIDs: *convertPIDsFlag,
|
||||
ConvertTimeout: *convertTimeoutFlag,
|
||||
}
|
||||
|
||||
// Default Root to the current working directory.
|
||||
|
|
@ -494,3 +529,14 @@ func parseInt64OrDefault(s string, def int64) int64 {
|
|||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func parseIntOrDefault(s string, def int) int {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscan(s, &n); err == nil {
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
|
|
|||
253
zddc/internal/convert/convert.go
Normal file
253
zddc/internal/convert/convert.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
// Package convert turns a markdown source byte-buffer into DOCX, HTML,
|
||||
// or PDF via two stock upstream container images: pandoc (default
|
||||
// `docker.io/pandoc/latex:latest`) handles MD↔DOCX and MD→HTML, and
|
||||
// a headless-chromium image (default `docker.io/zenika/alpine-chrome:latest`)
|
||||
// handles HTML→PDF. No custom image build is required — the operator
|
||||
// just needs `podman` or `docker` on PATH and the runner pulls each
|
||||
// image on first use via `--pull=missing`.
|
||||
//
|
||||
// Public surface:
|
||||
//
|
||||
// ToDocx(ctx, source, meta) → []byte (DOCX bytes)
|
||||
// ToHTML(ctx, source, meta) → []byte (standalone HTML)
|
||||
// ToPDF (ctx, source, meta) → []byte (PDF, via HTML + chromium)
|
||||
//
|
||||
// Probe(ctx, override) → Capabilities (call once at startup)
|
||||
// Available() → (Capabilities, bool)
|
||||
// SetImages(pandoc, chromium) — install image refs from config
|
||||
//
|
||||
// All three converters are safe for concurrent use; each call gets a
|
||||
// fresh container. The pandoc image's entrypoint is `pandoc`, so the
|
||||
// argv we pass after the image flows straight into pandoc. The
|
||||
// alpine-chrome image's entrypoint is `chromium-browser`, so the argv
|
||||
// flows into chromium-browser. No `sh -c` wrappers, no shell quoting.
|
||||
//
|
||||
// Metadata maps to the placeholders consumed by viewer-template.html.
|
||||
// title/tracking_number/revision/status/is_draft typically come from
|
||||
// the source filename (zddc.ParseFilename); client/project/contractor/
|
||||
// project_number from the .zddc cascade `convert:` block.
|
||||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metadata is the variable bag passed to pandoc as `--variable k=v`
|
||||
// pairs. Fields with zero values are omitted. The viewer-template.html
|
||||
// uses `$if(field)$ … $endif$` blocks so absent fields render cleanly.
|
||||
type Metadata struct {
|
||||
Title string
|
||||
TrackingNumber string
|
||||
Revision string
|
||||
Status string
|
||||
Client string
|
||||
Project string
|
||||
Contractor string
|
||||
ProjectNumber string
|
||||
GenerationTime time.Time
|
||||
IsDraft bool
|
||||
NoTOC bool
|
||||
}
|
||||
|
||||
// Default images. Operator overrides via --convert-pandoc-image /
|
||||
// --convert-chromium-image (see cmd/zddc-server). pandoc/latex carries
|
||||
// TeX Live for native PDF too, so it's a superset of pandoc/core;
|
||||
// operators wanting a slimmer footprint can switch to pandoc/core.
|
||||
const (
|
||||
DefaultPandocImage = "docker.io/pandoc/latex:latest"
|
||||
DefaultChromiumImage = "docker.io/zenika/alpine-chrome:latest"
|
||||
)
|
||||
|
||||
var (
|
||||
pandocImage atomic.Pointer[string]
|
||||
chromiumImage atomic.Pointer[string]
|
||||
)
|
||||
|
||||
// SetImages installs the image refs used for subsequent ToDocx/ToHTML/
|
||||
// ToPDF calls. Empty values keep the previous setting (or the
|
||||
// DefaultPandocImage / DefaultChromiumImage constants on first call).
|
||||
// Called from cmd/zddc-server/main.go after flag parsing.
|
||||
func SetImages(pandoc, chromium string) {
|
||||
if pandoc != "" {
|
||||
s := pandoc
|
||||
pandocImage.Store(&s)
|
||||
}
|
||||
if chromium != "" {
|
||||
s := chromium
|
||||
chromiumImage.Store(&s)
|
||||
}
|
||||
}
|
||||
|
||||
func currentPandocImage() string {
|
||||
if p := pandocImage.Load(); p != nil && *p != "" {
|
||||
return *p
|
||||
}
|
||||
return DefaultPandocImage
|
||||
}
|
||||
|
||||
func currentChromiumImage() string {
|
||||
if p := chromiumImage.Load(); p != nil && *p != "" {
|
||||
return *p
|
||||
}
|
||||
return DefaultChromiumImage
|
||||
}
|
||||
|
||||
// ToDocx renders source markdown to DOCX bytes. One container run via
|
||||
// the pandoc image. Caller passes the full file content (envelope +
|
||||
// body); pandoc handles `markdown+yaml_metadata_block` natively.
|
||||
func ToDocx(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
cmd := []string{
|
||||
"--from=markdown+yaml_metadata_block",
|
||||
"--to=docx",
|
||||
"--output=-",
|
||||
}
|
||||
cmd = append(cmd, metadataArgs(m)...)
|
||||
cmd = append(cmd, "-")
|
||||
return r.Run(ctx, currentPandocImage(), source, nil, cmd)
|
||||
}
|
||||
|
||||
// ToHTML renders source markdown to standalone HTML using
|
||||
// viewer-template.html. Embeds CSS + images via --embed-resources.
|
||||
// Template + custom.css are bind-mounted into the container at /tpl
|
||||
// from a per-call scratch dir.
|
||||
func ToHTML(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
scratch, err := writeAssetsToScratch()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scratch: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(scratch)
|
||||
|
||||
cmd := []string{
|
||||
"--from=markdown+yaml_metadata_block",
|
||||
"--to=html5",
|
||||
"--standalone",
|
||||
"--embed-resources",
|
||||
"--section-divs",
|
||||
"--id-prefix=",
|
||||
"--html-q-tags",
|
||||
"--template=/tpl/viewer-template.html",
|
||||
}
|
||||
if !m.NoTOC {
|
||||
cmd = append(cmd, "--toc", "--toc-depth=6")
|
||||
}
|
||||
cmd = append(cmd, metadataArgs(m)...)
|
||||
cmd = append(cmd, "--output=-", "-")
|
||||
|
||||
mounts := []string{scratch + ":/tpl:ro"}
|
||||
return r.Run(ctx, currentPandocImage(), source, mounts, cmd)
|
||||
}
|
||||
|
||||
// ToPDF renders source markdown to PDF in two stages: pandoc produces
|
||||
// HTML using viewer-template.html (stage 1, pandoc image), then headless
|
||||
// Chromium prints that HTML to PDF (stage 2, chromium image). The
|
||||
// two-stage choice preserves the print-media CSS already authored in
|
||||
// viewer-template.html — pandoc's native --pdf-engine path uses LaTeX
|
||||
// which would bypass it entirely.
|
||||
//
|
||||
// Chromium runs from the alpine-chrome image whose entrypoint is
|
||||
// `chromium-browser`; our cmd is the flag list passed straight to that
|
||||
// binary. The host scratch dir is bind-mounted read-write at /pdf so
|
||||
// chromium can write out.pdf and we read it back afterward.
|
||||
func ToPDF(ctx context.Context, source []byte, m Metadata) ([]byte, error) {
|
||||
html, err := ToHTML(ctx, source, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := currentRunner()
|
||||
if r == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
|
||||
scratch, err := os.MkdirTemp("", "zddc-pdf-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scratch: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(scratch)
|
||||
htmlPath := filepath.Join(scratch, "in.html")
|
||||
pdfPath := filepath.Join(scratch, "out.pdf")
|
||||
if err := os.WriteFile(htmlPath, html, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write html: %w", err)
|
||||
}
|
||||
if err := chmodTree(scratch, 0o755, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mounts := []string{scratch + ":/pdf:rw"}
|
||||
// alpine-chrome's entrypoint is `chromium-browser`. --no-sandbox is
|
||||
// required because the container drops CAP_SYS_ADMIN; the threat
|
||||
// model is "malicious markdown drives chromium RCE", contained by
|
||||
// --network=none + --cap-drop=ALL + --read-only + tmpfs.
|
||||
cmd := []string{
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
"--user-data-dir=/tmp/chrome",
|
||||
"--no-pdf-header-footer",
|
||||
"--virtual-time-budget=10000",
|
||||
"--print-to-pdf=/pdf/out.pdf",
|
||||
"file:///pdf/in.html",
|
||||
}
|
||||
if _, err := r.Run(ctx, currentChromiumImage(), nil, mounts, cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(pdfPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pdf: %w", err)
|
||||
}
|
||||
if len(out) < 4 || string(out[:4]) != "%PDF" {
|
||||
return nil, &ConvertError{
|
||||
Tool: "chromium",
|
||||
ExitCode: 0,
|
||||
Stderr: "chromium did not produce a valid PDF",
|
||||
Cause: fmt.Errorf("invalid PDF magic in output (got %d bytes)", len(out)),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// metadataArgs renders Metadata into pandoc -V flags. Order is stable
|
||||
// so test fixtures don't churn. Empty values are omitted (the template
|
||||
// uses $if(...)$ blocks).
|
||||
func metadataArgs(m Metadata) []string {
|
||||
var out []string
|
||||
add := func(k, v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
out = append(out, "-V", k+"="+v)
|
||||
}
|
||||
add("title", m.Title)
|
||||
add("tracking_number", m.TrackingNumber)
|
||||
add("revision", m.Revision)
|
||||
add("status", m.Status)
|
||||
add("client", m.Client)
|
||||
add("project", m.Project)
|
||||
add("contractor", m.Contractor)
|
||||
add("project_number", m.ProjectNumber)
|
||||
if !m.GenerationTime.IsZero() {
|
||||
add("generation_time", m.GenerationTime.Format("January 02, 2006 at 3:04:05 PM MST"))
|
||||
}
|
||||
if m.IsDraft {
|
||||
add("is_draft", "true")
|
||||
}
|
||||
if m.NoTOC {
|
||||
add("no-toc", "true")
|
||||
}
|
||||
return out
|
||||
}
|
||||
286
zddc/internal/convert/convert_test.go
Normal file
286
zddc/internal/convert/convert_test.go
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeRunner records the args it was invoked with and replays canned
|
||||
// responses. Lets us assert the command lines + image refs without
|
||||
// needing podman.
|
||||
type fakeRunner struct {
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
images []string
|
||||
stdin [][]byte
|
||||
mounts [][]string
|
||||
resp []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRunner) Run(_ context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, append([]string(nil), cmd...))
|
||||
f.images = append(f.images, image)
|
||||
f.stdin = append(f.stdin, append([]byte(nil), stdin...))
|
||||
f.mounts = append(f.mounts, append([]string(nil), mounts...))
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func (f *fakeRunner) lastCall() (string, []string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if len(f.calls) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return f.images[len(f.images)-1], f.calls[len(f.calls)-1]
|
||||
}
|
||||
|
||||
func TestToDocx_UsesPandocImage(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("FAKE-DOCX")}
|
||||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetImages("docker.io/pandoc/latex:latest", "")
|
||||
|
||||
out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{
|
||||
Title: "Hello",
|
||||
Client: "Acme",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ToDocx: %v", err)
|
||||
}
|
||||
if string(out) != "FAKE-DOCX" {
|
||||
t.Errorf("unexpected output: %q", out)
|
||||
}
|
||||
image, call := f.lastCall()
|
||||
if image != "docker.io/pandoc/latex:latest" {
|
||||
t.Errorf("expected pandoc image, got %q", image)
|
||||
}
|
||||
if !contains(call, "--to=docx") {
|
||||
t.Errorf("missing --to=docx: %v", call)
|
||||
}
|
||||
if !contains(call, "title=Hello") {
|
||||
t.Errorf("missing title metadata: %v", call)
|
||||
}
|
||||
if !contains(call, "client=Acme") {
|
||||
t.Errorf("missing client metadata: %v", call)
|
||||
}
|
||||
// Last arg must be "-" so pandoc reads from stdin.
|
||||
if call[len(call)-1] != "-" {
|
||||
t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML_UsesTemplateAndMountsScratch(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("<html>fake</html>")}
|
||||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetImages("docker.io/pandoc/latex:latest", "")
|
||||
|
||||
_, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("ToHTML: %v", err)
|
||||
}
|
||||
image, call := f.lastCall()
|
||||
if image != "docker.io/pandoc/latex:latest" {
|
||||
t.Errorf("expected pandoc image, got %q", image)
|
||||
}
|
||||
if !contains(call, "--template=/tpl/viewer-template.html") {
|
||||
t.Errorf("template flag missing: %v", call)
|
||||
}
|
||||
if !contains(call, "--toc") {
|
||||
t.Errorf("TOC flag missing (default NoTOC=false): %v", call)
|
||||
}
|
||||
if len(f.mounts) == 0 || len(f.mounts[0]) == 0 {
|
||||
t.Fatalf("expected at least one bind mount for /tpl")
|
||||
}
|
||||
mount := f.mounts[0][0]
|
||||
if !strings.Contains(mount, ":/tpl:") {
|
||||
t.Errorf("mount missing /tpl: %q", mount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToHTML_NoTOCSuppressesTOC(t *testing.T) {
|
||||
f := &fakeRunner{resp: []byte("<html/>")}
|
||||
InstallRunner(f)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
|
||||
_, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true})
|
||||
_, call := f.lastCall()
|
||||
if contains(call, "--toc") {
|
||||
t.Errorf("TOC should be suppressed when NoTOC=true: %v", call)
|
||||
}
|
||||
if !contains(call, "no-toc=true") {
|
||||
t.Errorf("no-toc metadata variable missing: %v", call)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingRunner records every call and returns canned responses
|
||||
// in sequence. Lets ToPDF tests assert the two-stage pipeline
|
||||
// (pandoc image then chromium image).
|
||||
type recordingRunner struct {
|
||||
mu sync.Mutex
|
||||
calls []recordedCall
|
||||
resp [][]byte
|
||||
err []error
|
||||
cursor int
|
||||
}
|
||||
|
||||
type recordedCall struct {
|
||||
image string
|
||||
cmd []string
|
||||
mounts []string
|
||||
}
|
||||
|
||||
func (r *recordingRunner) Run(_ context.Context, image string, _ []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, recordedCall{
|
||||
image: image,
|
||||
cmd: append([]string(nil), cmd...),
|
||||
mounts: append([]string(nil), mounts...),
|
||||
})
|
||||
if r.cursor >= len(r.resp) {
|
||||
return nil, nil
|
||||
}
|
||||
out := r.resp[r.cursor]
|
||||
var e error
|
||||
if r.cursor < len(r.err) {
|
||||
e = r.err[r.cursor]
|
||||
}
|
||||
r.cursor++
|
||||
return out, e
|
||||
}
|
||||
|
||||
func TestToPDF_TwoStagePipeline(t *testing.T) {
|
||||
// Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from
|
||||
// the bind mount and writes /pdf/out.pdf. The fake runner can't
|
||||
// actually write the PDF, so we expect ToPDF to fail at the
|
||||
// read-back step — but we can still assert the two-stage call
|
||||
// shape and the right image per stage.
|
||||
r := &recordingRunner{
|
||||
resp: [][]byte{
|
||||
[]byte("<html><body>fake</body></html>"), // stage 1 stdout
|
||||
nil, // stage 2 stdout (chromium writes PDF to bind mount)
|
||||
},
|
||||
}
|
||||
InstallRunner(r)
|
||||
t.Cleanup(func() { InstallRunner(nil) })
|
||||
SetImages("docker.io/pandoc/latex:latest", "docker.io/zenika/alpine-chrome:latest")
|
||||
|
||||
_, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{})
|
||||
// PDF read-back will fail (fake runner didn't write the file) —
|
||||
// that's expected for this test which only inspects the call
|
||||
// shape.
|
||||
if err == nil {
|
||||
t.Fatalf("expected error from PDF read-back; got nil")
|
||||
}
|
||||
if len(r.calls) != 2 {
|
||||
t.Fatalf("expected 2 container calls (pandoc + chromium); got %d", len(r.calls))
|
||||
}
|
||||
if r.calls[0].image != "docker.io/pandoc/latex:latest" {
|
||||
t.Errorf("stage 1 image: got %q want pandoc/latex", r.calls[0].image)
|
||||
}
|
||||
if r.calls[1].image != "docker.io/zenika/alpine-chrome:latest" {
|
||||
t.Errorf("stage 2 image: got %q want alpine-chrome", r.calls[1].image)
|
||||
}
|
||||
// Stage 2 must include the --print-to-pdf flag pointing at /pdf.
|
||||
if !contains(r.calls[1].cmd, "--print-to-pdf=/pdf/out.pdf") {
|
||||
t.Errorf("chromium call missing --print-to-pdf flag: %v", r.calls[1].cmd)
|
||||
}
|
||||
if !contains(r.calls[1].cmd, "--no-sandbox") {
|
||||
t.Errorf("chromium call missing --no-sandbox: %v", r.calls[1].cmd)
|
||||
}
|
||||
// Stage 2's bind mount must be writable (chromium writes the PDF).
|
||||
if len(r.calls[1].mounts) == 0 || !strings.Contains(r.calls[1].mounts[0], ":rw") {
|
||||
t.Errorf("chromium mount must be :rw, got %v", r.calls[1].mounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrUnavailable_WhenNoRunner(t *testing.T) {
|
||||
InstallRunner(nil)
|
||||
_, err := ToDocx(context.Background(), []byte("x"), Metadata{})
|
||||
if !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("expected ErrUnavailable, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) {
|
||||
args := metadataArgs(Metadata{
|
||||
Title: "T",
|
||||
Project: "P",
|
||||
GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC),
|
||||
})
|
||||
want := []string{
|
||||
"-V", "title=T",
|
||||
"-V", "project=P",
|
||||
}
|
||||
for i, w := range want {
|
||||
if i >= len(args) || args[i] != w {
|
||||
t.Fatalf("args[%d]: got %v want prefix %v", i, args, want)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(args, "|")
|
||||
if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") {
|
||||
t.Errorf("generation_time missing or malformed: %v", args)
|
||||
}
|
||||
if strings.Contains(joined, "client=") {
|
||||
t.Errorf("empty client should not be passed: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageTag(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"docker.io/pandoc/latex:latest": "pandoc/latex",
|
||||
"docker.io/zenika/alpine-chrome:latest": "zenika/alpine-chrome",
|
||||
"pandoc/core": "pandoc/core",
|
||||
"quay.io/example/foo:v1": "example/foo",
|
||||
"alpine": "alpine",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := imageTag(in); got != want {
|
||||
t.Errorf("imageTag(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleflight_Collapses(t *testing.T) {
|
||||
var g singleflightGroup
|
||||
const N = 50
|
||||
var wg sync.WaitGroup
|
||||
var hits int32
|
||||
var mu sync.Mutex
|
||||
|
||||
wg.Add(N)
|
||||
for i := 0; i < N; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = g.Do("k", func() (any, error) {
|
||||
mu.Lock()
|
||||
hits++
|
||||
mu.Unlock()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return "v", nil
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if hits != 1 {
|
||||
t.Errorf("singleflight collapse: got %d invocations, want 1", hits)
|
||||
}
|
||||
}
|
||||
|
||||
// contains reports whether haystack has needle as any of its elements.
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
163
zddc/internal/convert/custom.css
Normal file
163
zddc/internal/convert/custom.css
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* Legal-style heading numbering for ZDDC documents
|
||||
* Adds hierarchical numbering like 1, 1.1, 1.1.1, etc.
|
||||
*/
|
||||
|
||||
/* Reset counters at document level */
|
||||
.document-content {
|
||||
counter-reset: h1-counter;
|
||||
}
|
||||
|
||||
/* H1 counters */
|
||||
h1 {
|
||||
counter-reset: h2-counter h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h1-counter;
|
||||
}
|
||||
|
||||
h1::before {
|
||||
content: counter(h1-counter) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H2 counters */
|
||||
h2 {
|
||||
counter-reset: h3-counter h4-counter h5-counter h6-counter;
|
||||
counter-increment: h2-counter;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H3 counters */
|
||||
h3 {
|
||||
counter-reset: h4-counter h5-counter h6-counter;
|
||||
counter-increment: h3-counter;
|
||||
}
|
||||
|
||||
h3::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H4 counters */
|
||||
h4 {
|
||||
counter-reset: h5-counter h6-counter;
|
||||
counter-increment: h4-counter;
|
||||
}
|
||||
|
||||
h4::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H5 counters */
|
||||
h5 {
|
||||
counter-reset: h6-counter;
|
||||
counter-increment: h5-counter;
|
||||
}
|
||||
|
||||
h5::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* H6 counters */
|
||||
h6 {
|
||||
counter-increment: h6-counter;
|
||||
}
|
||||
|
||||
h6::before {
|
||||
content: counter(h1-counter) "." counter(h2-counter) "." counter(h3-counter) "." counter(h4-counter) "." counter(h5-counter) "." counter(h6-counter) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* TOC numbering to match document headings */
|
||||
.toc {
|
||||
counter-reset: toc-h1;
|
||||
}
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toc > ul > li {
|
||||
counter-increment: toc-h1;
|
||||
counter-reset: toc-h2 toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > a::before {
|
||||
content: counter(toc-h1) ". ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li {
|
||||
counter-increment: toc-h2;
|
||||
counter-reset: toc-h3 toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li {
|
||||
counter-increment: toc-h3;
|
||||
counter-reset: toc-h4 toc-h5 toc-h6;
|
||||
}
|
||||
|
||||
.toc > ul > li > ul > li > ul > li > a::before {
|
||||
content: counter(toc-h1) "." counter(toc-h2) "." counter(toc-h3) " ";
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
/* Optional: Add some spacing after the numbers */
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
/* Print-specific adjustments */
|
||||
@media print {
|
||||
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
|
||||
color: #000 !important; /* Ensure numbers print in black */
|
||||
}
|
||||
}
|
||||
|
||||
/* Optional: Style adjustments for better visual hierarchy */
|
||||
h1 {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
padding-bottom: 0.3em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Reduce margin for first heading */
|
||||
h1:first-of-type {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.2em;
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
|
||||
h4, h5, h6 {
|
||||
margin-top: 1em;
|
||||
}
|
||||
19
zddc/internal/convert/embed.go
Normal file
19
zddc/internal/convert/embed.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package convert
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Pandoc HTML template and its companion stylesheet, copied verbatim from
|
||||
// /pandoc/viewer-template.html and /pandoc/custom.css. The runner writes
|
||||
// these to a host scratch dir on each conversion and bind-mounts them
|
||||
// read-only into the container so pandoc can `--template` against them.
|
||||
//
|
||||
// Refresh: when /pandoc/viewer-template.html changes, copy the new bytes
|
||||
// here. There's no symlink because go:embed paths must resolve under the
|
||||
// containing module — and we want the binary to ship the bytes verbatim,
|
||||
// not depend on the source tree at runtime.
|
||||
|
||||
//go:embed viewer-template.html
|
||||
var viewerTemplate []byte
|
||||
|
||||
//go:embed custom.css
|
||||
var customCSS []byte
|
||||
152
zddc/internal/convert/health.go
Normal file
152
zddc/internal/convert/health.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package convert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Capabilities is the snapshot of "can we convert right now?". The
|
||||
// only hard requirement is a container runtime on PATH — image presence
|
||||
// is left to `--pull=missing` at conversion time, so a missing image
|
||||
// surfaces as a normal ConvertError (not a probe failure).
|
||||
type Capabilities struct {
|
||||
Engine string // "podman" | "docker" | ""
|
||||
EngineVer string // first line of "<engine> --version"
|
||||
PandocImage string // resolved pandoc image ref
|
||||
ChromiumImage string // resolved chromium image ref
|
||||
ProbedAt time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
// Ready reports whether conversions can be attempted. The first
|
||||
// conversion may still fail if the configured image isn't reachable
|
||||
// from the host's registry (the runner will surface a clear error
|
||||
// from podman/docker stderr).
|
||||
func (c Capabilities) Ready() bool {
|
||||
return c.Engine != "" && c.Err == nil
|
||||
}
|
||||
|
||||
// Reason returns a short human-friendly explanation when Ready() is
|
||||
// false. Used as the body of a 503.
|
||||
func (c Capabilities) Reason() string {
|
||||
if c.Engine == "" {
|
||||
return "no container runtime (podman or docker) found on PATH"
|
||||
}
|
||||
if c.Err != nil {
|
||||
return c.Err.Error()
|
||||
}
|
||||
return "unavailable"
|
||||
}
|
||||
|
||||
var (
|
||||
caps atomic.Pointer[Capabilities]
|
||||
probeCool sync.Mutex
|
||||
)
|
||||
|
||||
// Available returns the current Capabilities snapshot and whether
|
||||
// conversions can proceed.
|
||||
func Available() (Capabilities, bool) {
|
||||
p := caps.Load()
|
||||
if p == nil {
|
||||
return Capabilities{}, false
|
||||
}
|
||||
return *p, p.Ready()
|
||||
}
|
||||
|
||||
// Probe locates the container engine and installs a containerRunner
|
||||
// as the package default. Call once at server startup. Returns the
|
||||
// captured Capabilities for logging.
|
||||
//
|
||||
// Engine order: engineOverride (if non-empty) → podman → docker. First
|
||||
// hit wins. Image presence is NOT probed: the runner uses
|
||||
// `--pull=missing` so the first conversion request will pull whichever
|
||||
// image it needs.
|
||||
//
|
||||
// Any failure here is non-fatal: the server still starts, conversion
|
||||
// endpoints just return 503. This matches the user's locked-in
|
||||
// requirement that no-container-runtime ⇒ "can't do conversions".
|
||||
func Probe(ctx context.Context, engineOverride string) Capabilities {
|
||||
probeCool.Lock()
|
||||
defer probeCool.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
c := Capabilities{
|
||||
PandocImage: currentPandocImage(),
|
||||
ChromiumImage: currentChromiumImage(),
|
||||
ProbedAt: now,
|
||||
}
|
||||
|
||||
engine := resolveEngine(engineOverride)
|
||||
if engine == "" {
|
||||
c.Err = fmt.Errorf("no container runtime found (tried: %s)", strings.Join(enginesTried(engineOverride), ", "))
|
||||
caps.Store(&c)
|
||||
slog.Warn("convert: probe failed", "reason", c.Err.Error())
|
||||
return c
|
||||
}
|
||||
c.Engine = engine
|
||||
|
||||
if v, err := probeVersion(ctx, engine); err == nil {
|
||||
c.EngineVer = v
|
||||
}
|
||||
|
||||
InstallRunner(newContainerRunner(engine))
|
||||
caps.Store(&c)
|
||||
slog.Info("convert: ready",
|
||||
"engine", engine,
|
||||
"engine_version", c.EngineVer,
|
||||
"pandoc_image", c.PandocImage,
|
||||
"chromium_image", c.ChromiumImage)
|
||||
return c
|
||||
}
|
||||
|
||||
// Reprobe re-runs Probe with the existing configuration. Used by the
|
||||
// handler when a request hits a not-Ready state — gives the operator
|
||||
// a way to recover (e.g. installed podman after the server started)
|
||||
// without a server restart. Cooldown of 60 s between probes to keep
|
||||
// error-path requests cheap.
|
||||
func Reprobe(ctx context.Context, engineOverride string) Capabilities {
|
||||
if p := caps.Load(); p != nil {
|
||||
if time.Since(p.ProbedAt) < 60*time.Second {
|
||||
return *p
|
||||
}
|
||||
}
|
||||
return Probe(ctx, engineOverride)
|
||||
}
|
||||
|
||||
func resolveEngine(override string) string {
|
||||
if override != "" {
|
||||
if p, err := exec.LookPath(override); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, name := range []string{"podman", "docker"} {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func enginesTried(override string) []string {
|
||||
if override != "" {
|
||||
return []string{override}
|
||||
}
|
||||
return []string{"podman", "docker"}
|
||||
}
|
||||
|
||||
func probeVersion(ctx context.Context, engine string) (string, error) {
|
||||
c := exec.CommandContext(ctx, engine, "--version")
|
||||
out, err := c.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
line := strings.SplitN(strings.TrimSpace(string(out)), "\n", 2)[0]
|
||||
return line, nil
|
||||
}
|
||||
386
zddc/internal/convert/runner.go
Normal file
386
zddc/internal/convert/runner.go
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
package convert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner executes a conversion sub-process and returns its stdout.
|
||||
// The host-side implementation (containerRunner) wraps `podman run`
|
||||
// or `docker run`; tests use a fake.
|
||||
//
|
||||
// image is the OCI image to invoke (e.g. "docker.io/pandoc/latex:latest"
|
||||
// or "docker.io/zenika/alpine-chrome:latest"). stdin is piped to the
|
||||
// container's stdin. cmd is the argv passed *to the image's entrypoint*
|
||||
// — for pandoc/latex the entrypoint is `pandoc`, for alpine-chrome it
|
||||
// is `chromium-browser`. mounts is a list of "<hostPath>:<containerPath>"
|
||||
// specs handed to --volume (":ro" is added if no mode segment is
|
||||
// present).
|
||||
//
|
||||
// All exec calls in this package go through Runner.Run. This is the
|
||||
// first os/exec site in the codebase; the hardening here is the
|
||||
// pattern for future shell-outs.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error)
|
||||
}
|
||||
|
||||
// ErrUnavailable means no container runtime is present on the host.
|
||||
// Handlers translate to HTTP 503.
|
||||
var ErrUnavailable = errors.New("conversion unavailable")
|
||||
|
||||
// ConvertError carries the failure surface from a non-zero exit.
|
||||
// Stderr is captured (truncated to 4 KiB by the runner) so callers can
|
||||
// surface pandoc/chromium's own complaint.
|
||||
type ConvertError struct {
|
||||
Tool string // image name fragment, used only for logging
|
||||
ExitCode int
|
||||
Stderr string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ConvertError) Error() string {
|
||||
if e == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
if e.Stderr != "" {
|
||||
return fmt.Sprintf("%s exit %d: %s", e.Tool, e.ExitCode, strings.TrimSpace(e.Stderr))
|
||||
}
|
||||
return fmt.Sprintf("%s exit %d: %v", e.Tool, e.ExitCode, e.Cause)
|
||||
}
|
||||
|
||||
func (e *ConvertError) Unwrap() error { return e.Cause }
|
||||
|
||||
// containerRunner runs each conversion inside a fresh container.
|
||||
// The engine ("podman" preferred, "docker" fallback) is resolved once
|
||||
// at startup by Probe. Resource limits are configurable via
|
||||
// SetLimits (called from main.go after flag parsing). Images are passed
|
||||
// per call so the same runner handles both pandoc and chromium
|
||||
// invocations.
|
||||
//
|
||||
// The runner relies on `--pull=missing` so the operator never has to
|
||||
// pre-pull images: the first request that needs an image pulls it,
|
||||
// subsequent requests use the local cache. Both podman and docker
|
||||
// honour this flag identically.
|
||||
type containerRunner struct {
|
||||
mu sync.RWMutex
|
||||
engine string
|
||||
memMiB int
|
||||
cpus string
|
||||
pids int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
// shared default runner, populated by InstallRunner (called from
|
||||
// the health probe at startup once the engine is known).
|
||||
defaultRunnerMu sync.RWMutex
|
||||
defaultRunner Runner
|
||||
)
|
||||
|
||||
// InstallRunner sets the package-level Runner used by ToDocx/ToHTML/ToPDF.
|
||||
// Tests inject a fake; production code lets the health probe install a
|
||||
// containerRunner. Safe to call from multiple goroutines.
|
||||
func InstallRunner(r Runner) {
|
||||
defaultRunnerMu.Lock()
|
||||
defaultRunner = r
|
||||
defaultRunnerMu.Unlock()
|
||||
}
|
||||
|
||||
// ConfigureLimits applies resource limits to the package-level Runner,
|
||||
// if it's a containerRunner. No-op when no runner is installed yet
|
||||
// (the probe failed) or when the installed runner doesn't accept
|
||||
// limits (e.g. a test fake). Zero values keep the previous setting.
|
||||
//
|
||||
// Called from cmd/zddc-server/main.go after Probe so the limits from
|
||||
// the operator's flags take effect before any conversion request lands.
|
||||
func ConfigureLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
|
||||
defaultRunnerMu.RLock()
|
||||
r := defaultRunner
|
||||
defaultRunnerMu.RUnlock()
|
||||
if cr, ok := r.(*containerRunner); ok {
|
||||
cr.SetLimits(memMiB, cpus, pids, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func currentRunner() Runner {
|
||||
defaultRunnerMu.RLock()
|
||||
r := defaultRunner
|
||||
defaultRunnerMu.RUnlock()
|
||||
return r
|
||||
}
|
||||
|
||||
// SetLimits updates the resource ceilings used for subsequent Run
|
||||
// invocations. Zero values keep the previous setting (or the defaults
|
||||
// set at construction). Safe to call from multiple goroutines.
|
||||
func (cr *containerRunner) SetLimits(memMiB int, cpus string, pids int, timeout time.Duration) {
|
||||
cr.mu.Lock()
|
||||
defer cr.mu.Unlock()
|
||||
if memMiB > 0 {
|
||||
cr.memMiB = memMiB
|
||||
}
|
||||
if cpus != "" {
|
||||
cr.cpus = cpus
|
||||
}
|
||||
if pids > 0 {
|
||||
cr.pids = pids
|
||||
}
|
||||
if timeout > 0 {
|
||||
cr.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func newContainerRunner(engine string) *containerRunner {
|
||||
return &containerRunner{
|
||||
engine: engine,
|
||||
memMiB: 512,
|
||||
cpus: "2",
|
||||
pids: 100,
|
||||
timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes one container invocation. cmd is the argv passed to the
|
||||
// image's entrypoint (pandoc for pandoc/latex, chromium-browser for
|
||||
// alpine-chrome). mounts is a list of "<hostPath>:<containerPath>"
|
||||
// strings; ":ro" is appended when no mode segment is present. stdin is
|
||||
// piped to the container, stdout is returned as bytes (capped at
|
||||
// 128 MiB).
|
||||
//
|
||||
// Hardening:
|
||||
// - --pull=missing: image is fetched on first use, cached after.
|
||||
// Operator only needs podman/docker installed; no manual pull.
|
||||
// - --rm: container is removed on exit, even if killed.
|
||||
// - --network=none: no network inside the container. Prevents data
|
||||
// exfiltration through embedded URLs in source documents.
|
||||
// - --read-only + tmpfs on /tmp and /run: image fs is immutable;
|
||||
// pandoc/chromium scratch goes to tmpfs only.
|
||||
// - --memory / --cpus / --pids-limit: kernel-enforced caps.
|
||||
// - --cap-drop=ALL + --security-opt=no-new-privileges: standard
|
||||
// container-escape hardening.
|
||||
// - context-cancel kill + WaitDelay: a wedged podman gets force-
|
||||
// killed; pipes drop after 2s so we don't leak goroutines.
|
||||
// - cmd.Env minimal: only PATH + HOME are passed through to the
|
||||
// engine binary; the container itself sees only what the image
|
||||
// bakes in plus what --env adds (HOME=/tmp).
|
||||
//
|
||||
// Note: --user is intentionally NOT set so each image uses its
|
||||
// default user (pandoc/latex runs as root, alpine-chrome runs as
|
||||
// uid 1000). With --read-only + tmpfs + --cap-drop=ALL +
|
||||
// --network=none + --no-new-privileges the additional defense from
|
||||
// forcing nobody is small and would break alpine-chrome's own
|
||||
// user-data-dir layout.
|
||||
func (cr *containerRunner) Run(ctx context.Context, image string, stdin []byte, mounts []string, cmd []string) ([]byte, error) {
|
||||
cr.mu.RLock()
|
||||
engine := cr.engine
|
||||
memMiB := cr.memMiB
|
||||
cpus := cr.cpus
|
||||
pids := cr.pids
|
||||
timeout := cr.timeout
|
||||
cr.mu.RUnlock()
|
||||
|
||||
if engine == "" {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if image == "" {
|
||||
return nil, fmt.Errorf("convert.Run: image is empty")
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"run",
|
||||
"--rm",
|
||||
"--pull=missing",
|
||||
"-i",
|
||||
"--network=none",
|
||||
"--read-only",
|
||||
"--tmpfs=/tmp:size=128m,exec",
|
||||
"--tmpfs=/run:size=4m",
|
||||
fmt.Sprintf("--memory=%dm", memMiB),
|
||||
fmt.Sprintf("--cpus=%s", cpus),
|
||||
fmt.Sprintf("--pids-limit=%d", pids),
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges",
|
||||
"--env=HOME=/tmp",
|
||||
"--workdir=/tmp",
|
||||
}
|
||||
for _, m := range mounts {
|
||||
if !strings.Contains(m, ":ro") && !strings.Contains(m, ":rw") {
|
||||
m += ":ro"
|
||||
}
|
||||
args = append(args, "--volume="+m)
|
||||
}
|
||||
args = append(args, image)
|
||||
args = append(args, cmd...)
|
||||
|
||||
c := exec.CommandContext(runCtx, engine, args...)
|
||||
c.Cancel = func() error {
|
||||
if c.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return c.Process.Kill()
|
||||
}
|
||||
c.WaitDelay = 2 * time.Second
|
||||
c.SysProcAttr = sysProcAttr()
|
||||
c.Env = []string{
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.TempDir(),
|
||||
}
|
||||
c.Stdin = bytes.NewReader(stdin)
|
||||
|
||||
var stdoutBuf bytes.Buffer
|
||||
c.Stdout = &limitWriter{w: &stdoutBuf, max: 128 << 20}
|
||||
stderr := newRingWriter(4 << 10)
|
||||
c.Stderr = stderr
|
||||
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
exitCode := -1
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = ee.ExitCode()
|
||||
}
|
||||
toolName := imageTag(image)
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
return nil, &ConvertError{
|
||||
Tool: toolName,
|
||||
ExitCode: exitCode,
|
||||
Stderr: stderr.String(),
|
||||
Cause: fmt.Errorf("timeout after %s: %w", timeout, runCtx.Err()),
|
||||
}
|
||||
}
|
||||
return nil, &ConvertError{
|
||||
Tool: toolName,
|
||||
ExitCode: exitCode,
|
||||
Stderr: stderr.String(),
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
return stdoutBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
// imageTag extracts a short name for an image reference, used as the
|
||||
// "Tool" label on ConvertError. "docker.io/pandoc/latex:latest" →
|
||||
// "pandoc/latex".
|
||||
func imageTag(image string) string {
|
||||
s := image
|
||||
// Strip registry prefix.
|
||||
if i := strings.Index(s, "/"); i >= 0 {
|
||||
if strings.Contains(s[:i], ".") || strings.Contains(s[:i], ":") {
|
||||
s = s[i+1:]
|
||||
}
|
||||
}
|
||||
// Strip tag suffix.
|
||||
if i := strings.LastIndex(s, ":"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// limitWriter caps the underlying buffer at max bytes. Writes past the
|
||||
// cap return io.ErrShortWrite, which surfaces as a Run() error — the
|
||||
// caller then maps to 422 (output too large) at the handler edge.
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
max int64
|
||||
n int64
|
||||
}
|
||||
|
||||
func (l *limitWriter) Write(p []byte) (int, error) {
|
||||
if l.n >= l.max {
|
||||
return 0, fmt.Errorf("output exceeded %d bytes", l.max)
|
||||
}
|
||||
rem := l.max - l.n
|
||||
if int64(len(p)) > rem {
|
||||
n, _ := l.w.Write(p[:rem])
|
||||
l.n += int64(n)
|
||||
return n, fmt.Errorf("output exceeded %d bytes", l.max)
|
||||
}
|
||||
n, err := l.w.Write(p)
|
||||
l.n += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ringWriter keeps only the tail of what's written — useful for stderr
|
||||
// capture where the most-recent bytes are the ones with the actual
|
||||
// error message and earlier output is usually progress noise.
|
||||
type ringWriter struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
max int
|
||||
}
|
||||
|
||||
func newRingWriter(max int) *ringWriter {
|
||||
return &ringWriter{max: max}
|
||||
}
|
||||
|
||||
func (r *ringWriter) Write(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(p) >= r.max {
|
||||
r.buf = append(r.buf[:0], p[len(p)-r.max:]...)
|
||||
return len(p), nil
|
||||
}
|
||||
r.buf = append(r.buf, p...)
|
||||
if len(r.buf) > r.max {
|
||||
r.buf = r.buf[len(r.buf)-r.max:]
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (r *ringWriter) String() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return string(r.buf)
|
||||
}
|
||||
|
||||
// writeAssetsToScratch materialises the embedded viewer-template.html
|
||||
// and custom.css into a fresh scratch dir under TMPDIR and returns the
|
||||
// host path. Caller is responsible for os.RemoveAll(dir) when done.
|
||||
// Used by ToHTML which needs the template visible inside the container.
|
||||
//
|
||||
// Files are written world-readable so the container's default user
|
||||
// (root for pandoc/latex, uid 1000 for alpine-chrome) can read them
|
||||
// through the read-only bind mount regardless of the host's umask.
|
||||
func writeAssetsToScratch() (string, error) {
|
||||
dir, err := os.MkdirTemp("", "zddc-convert-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scratch dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "viewer-template.html"), viewerTemplate, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", fmt.Errorf("write template: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "custom.css"), customCSS, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", fmt.Errorf("write css: %w", err)
|
||||
}
|
||||
if err := chmodTree(dir, 0o755, 0o644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func chmodTree(root string, dirMode, fileMode os.FileMode) error {
|
||||
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return os.Chmod(p, dirMode)
|
||||
}
|
||||
return os.Chmod(p, fileMode)
|
||||
})
|
||||
}
|
||||
43
zddc/internal/convert/singleflight.go
Normal file
43
zddc/internal/convert/singleflight.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package convert
|
||||
|
||||
import "sync"
|
||||
|
||||
// singleflightGroup deduplicates concurrent calls keyed by string. If N
|
||||
// goroutines call Do(key, fn) before the first one returns, fn runs once
|
||||
// and all callers receive the same (val, err).
|
||||
//
|
||||
// Copy of internal/apps/singleflight.go so this package has no internal
|
||||
// cross-imports. If a third caller appears, lift to internal/sf/.
|
||||
type singleflightGroup struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*sfCall
|
||||
}
|
||||
|
||||
type sfCall struct {
|
||||
done chan struct{}
|
||||
val any
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
|
||||
g.mu.Lock()
|
||||
if g.m == nil {
|
||||
g.m = make(map[string]*sfCall)
|
||||
}
|
||||
if c, ok := g.m[key]; ok {
|
||||
g.mu.Unlock()
|
||||
<-c.done
|
||||
return c.val, c.err
|
||||
}
|
||||
c := &sfCall{done: make(chan struct{})}
|
||||
g.m[key] = c
|
||||
g.mu.Unlock()
|
||||
|
||||
c.val, c.err = fn()
|
||||
close(c.done)
|
||||
|
||||
g.mu.Lock()
|
||||
delete(g.m, key)
|
||||
g.mu.Unlock()
|
||||
return c.val, c.err
|
||||
}
|
||||
20
zddc/internal/convert/sysprocattr_linux.go
Normal file
20
zddc/internal/convert/sysprocattr_linux.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build linux
|
||||
|
||||
package convert
|
||||
|
||||
import "syscall"
|
||||
|
||||
// sysProcAttr returns the platform-specific SysProcAttr for the
|
||||
// container-engine child.
|
||||
//
|
||||
// - Setpgid: put the child in its own process group so a kill
|
||||
// targeted at -pid kills grandchildren too (podman/docker spawn
|
||||
// helper processes for chromium).
|
||||
// - Pdeathsig: SIGKILL the child if the zddc-server parent exits.
|
||||
// This is a Linux-only feature (other platforms get only Setpgid).
|
||||
func sysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
Pdeathsig: syscall.SIGKILL,
|
||||
}
|
||||
}
|
||||
17
zddc/internal/convert/sysprocattr_other.go
Normal file
17
zddc/internal/convert/sysprocattr_other.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//go:build darwin || freebsd || netbsd || openbsd
|
||||
|
||||
package convert
|
||||
|
||||
import "syscall"
|
||||
|
||||
// sysProcAttr returns the platform-specific SysProcAttr for the
|
||||
// container-engine child. BSD-family targets get Setpgid only (no
|
||||
// Pdeathsig); a zddc-server crash mid-conversion may leave the
|
||||
// detached engine process running on macOS/BSD. In practice
|
||||
// production deployments are Linux containers where the full
|
||||
// hardening applies.
|
||||
func sysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
}
|
||||
}
|
||||
14
zddc/internal/convert/sysprocattr_windows.go
Normal file
14
zddc/internal/convert/sysprocattr_windows.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//go:build windows
|
||||
|
||||
package convert
|
||||
|
||||
import "syscall"
|
||||
|
||||
// sysProcAttr returns the platform-specific SysProcAttr for the
|
||||
// container-engine child. Windows: no Setpgid / Pdeathsig analogue;
|
||||
// process-group semantics differ. We rely on context cancel +
|
||||
// cmd.Process.Kill() + WaitDelay for cleanup. In practice production
|
||||
// deployments are Linux containers where the full hardening applies.
|
||||
func sysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
1226
zddc/internal/convert/viewer-template.html
Normal file
1226
zddc/internal/convert/viewer-template.html
Normal file
File diff suppressed because it is too large
Load diff
297
zddc/internal/handler/converthandler.go
Normal file
297
zddc/internal/handler/converthandler.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
|
||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||
)
|
||||
|
||||
// On-demand MD→{docx,html,pdf} conversion endpoint.
|
||||
//
|
||||
// GET /<path>/foo.md?convert=docx|html|pdf
|
||||
//
|
||||
// The source file's read policy (already enforced by the dispatcher
|
||||
// before this handler runs) gates the response. The converted bytes
|
||||
// are cached at <dir>/.converted/<base>.<ext>, with mtime synced to the
|
||||
// source — so a fast-path GET that finds a fresh cache hit serves the
|
||||
// disk file via http.ServeContent without invoking pandoc at all.
|
||||
//
|
||||
// When the cache is stale (or absent) the handler:
|
||||
// 1. Reads source bytes.
|
||||
// 2. Walks the .zddc cascade to assemble the convert.Metadata.
|
||||
// 3. Calls convert.ToDocx / ToHTML / ToPDF (containerised pandoc).
|
||||
// 4. Atomically writes the result to .converted/ and syncs mtime.
|
||||
// 5. Serves it.
|
||||
//
|
||||
// Concurrent requests for the same URL share a single conversion via
|
||||
// the singleflightGroup keyed by the cached-file absolute path.
|
||||
|
||||
var convertSF singleflightGroup
|
||||
|
||||
// convertTimeout bounds the slow-path conversion + write + serve. The
|
||||
// runner itself enforces a finer-grained timeout on the container.
|
||||
const convertTimeout = 90 * time.Second
|
||||
|
||||
// ServeConverted is the entry point. format is the requested target
|
||||
// extension; chain is the already-resolved ACL chain (re-used here
|
||||
// only to extract the convert: cascade metadata).
|
||||
func ServeConverted(cfg config.Config, w http.ResponseWriter, r *http.Request, srcAbs, format string, chain zddc.PolicyChain) {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
switch format {
|
||||
case "docx", "html", "pdf":
|
||||
default:
|
||||
http.Error(w, "Bad Request — convert must be docx, html, or pdf", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
caps, ok := convert.Available()
|
||||
if !ok {
|
||||
// One re-probe attempt — gives the operator a way to recover
|
||||
// after building the image without restarting the server.
|
||||
caps = convert.Reprobe(r.Context(), os.Getenv("ZDDC_CONVERT_ENGINE"))
|
||||
if !caps.Ready() {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "Service Unavailable — "+caps.Reason(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
srcInfo, err := os.Stat(srcAbs)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
if srcInfo.IsDir() {
|
||||
http.Error(w, "Bad Request — convert applies to files", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
|
||||
dir := filepath.Dir(srcAbs)
|
||||
cacheDir := filepath.Join(dir, ".converted")
|
||||
cacheAbs := filepath.Join(cacheDir, base+"."+format)
|
||||
|
||||
// Fast path: cached file present and mtime-equal to source.
|
||||
if cacheInfo, err := os.Stat(cacheAbs); err == nil && cacheInfo.Mode().IsRegular() {
|
||||
if cacheInfo.ModTime().Equal(srcInfo.ModTime()) {
|
||||
serveCached(w, r, cacheAbs, format, base)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: convert, cache, serve. Singleflight collapses
|
||||
// concurrent requests for the same target.
|
||||
_, err = convertSF.Do(cacheAbs, func() (any, error) {
|
||||
return nil, buildAndStore(r.Context(), srcAbs, srcInfo, cacheDir, cacheAbs, format, base, chain)
|
||||
})
|
||||
if err != nil {
|
||||
mapConvertError(w, err, format)
|
||||
return
|
||||
}
|
||||
|
||||
serveCached(w, r, cacheAbs, format, base)
|
||||
}
|
||||
|
||||
// buildAndStore reads the source, runs the conversion, atomically
|
||||
// writes the result, and syncs the cached mtime to the source mtime.
|
||||
// Returns the cached file's absolute path on success.
|
||||
func buildAndStore(ctx context.Context, srcAbs string, srcInfo os.FileInfo, cacheDir, cacheAbs, format, base string, chain zddc.PolicyChain) error {
|
||||
source, err := os.ReadFile(srcAbs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
|
||||
meta := buildMetadata(srcAbs, chain)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out []byte
|
||||
switch format {
|
||||
case "docx":
|
||||
out, err = convert.ToDocx(ctx, source, meta)
|
||||
case "html":
|
||||
out, err = convert.ToHTML(ctx, source, meta)
|
||||
case "pdf":
|
||||
out, err = convert.ToPDF(ctx, source, meta)
|
||||
default:
|
||||
return fmt.Errorf("unsupported format %q", format)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir cache: %w", err)
|
||||
}
|
||||
if err := zddc.WriteAtomic(cacheAbs, out); err != nil {
|
||||
return fmt.Errorf("write cache: %w", err)
|
||||
}
|
||||
// Sync mtime to source so the fast-path predicate works on the
|
||||
// next request. Both atime and mtime get the source's mtime —
|
||||
// http.ServeContent honors mtime for Last-Modified / ETag.
|
||||
srcMT := srcInfo.ModTime()
|
||||
if err := os.Chtimes(cacheAbs, srcMT, srcMT); err != nil {
|
||||
slog.Warn("convert: chtimes failed (continuing)", "path", cacheAbs, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMetadata assembles the Metadata used by pandoc -V flags. The
|
||||
// filename-derived fields (title, tracking_number, revision, status,
|
||||
// is_draft) come from zddc.ParseFilename; the project-wide fields
|
||||
// (client/project/contractor/project_number) come from the cascade.
|
||||
//
|
||||
// chain.Levels is walked from leaf (last index, most specific) toward
|
||||
// root, then Embedded as the final fallback. The first non-empty value
|
||||
// per field wins.
|
||||
func buildMetadata(srcAbs string, chain zddc.PolicyChain) convert.Metadata {
|
||||
meta := convert.Metadata{
|
||||
GenerationTime: time.Now(),
|
||||
}
|
||||
|
||||
name := filepath.Base(srcAbs)
|
||||
parsed := zddc.ParseFilename(name)
|
||||
if parsed.Valid {
|
||||
meta.Title = parsed.Title
|
||||
meta.TrackingNumber = parsed.TrackingNumber
|
||||
meta.Revision = parsed.Revision
|
||||
meta.Status = parsed.Status
|
||||
meta.IsDraft = strings.Contains(parsed.Revision, "~")
|
||||
} else {
|
||||
// Strip extension as a last-resort title.
|
||||
stem := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
meta.Title = stem
|
||||
}
|
||||
|
||||
apply := func(zf zddc.ZddcFile) {
|
||||
if zf.Convert == nil {
|
||||
return
|
||||
}
|
||||
if meta.Client == "" {
|
||||
meta.Client = zf.Convert.Client
|
||||
}
|
||||
if meta.Project == "" {
|
||||
meta.Project = zf.Convert.Project
|
||||
}
|
||||
if meta.Contractor == "" {
|
||||
meta.Contractor = zf.Convert.Contractor
|
||||
}
|
||||
if meta.ProjectNumber == "" {
|
||||
meta.ProjectNumber = zf.Convert.ProjectNumber
|
||||
}
|
||||
}
|
||||
// Leaf → root.
|
||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
||||
apply(chain.Levels[i])
|
||||
}
|
||||
apply(chain.Embedded)
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// serveCached writes the cached file with the correct headers. ETag is
|
||||
// derived from the source's mtime so a refresh changes it cleanly.
|
||||
func serveCached(w http.ResponseWriter, r *http.Request, cacheAbs, format, base string) {
|
||||
f, err := os.Open(cacheAbs)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentTypeFor(format))
|
||||
w.Header().Set("Content-Disposition", contentDispositionFor(format, base))
|
||||
w.Header().Set("X-ZDDC-Source", "convert:"+format)
|
||||
// http.ServeContent handles If-Modified-Since / Range / etc. and
|
||||
// emits Last-Modified from info.ModTime(). The clients we ship
|
||||
// don't issue conditional GETs for conversions today, but other
|
||||
// callers might.
|
||||
http.ServeContent(w, r, filepath.Base(cacheAbs), info.ModTime(), f)
|
||||
}
|
||||
|
||||
func contentTypeFor(format string) string {
|
||||
switch format {
|
||||
case "docx":
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case "html":
|
||||
return "text/html; charset=utf-8"
|
||||
case "pdf":
|
||||
return "application/pdf"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// contentDispositionFor returns the disposition header. HTML is served
|
||||
// inline (so the browser renders the rich viewer template directly);
|
||||
// DOCX and PDF are inline too but the browse client adds the anchor's
|
||||
// `download` attribute, which forces save-as. Filename is the source
|
||||
// stem + the target extension so the user gets `foo.docx`, not
|
||||
// `foo.md.docx`.
|
||||
func contentDispositionFor(format, base string) string {
|
||||
return fmt.Sprintf(`inline; filename="%s.%s"`, base, format)
|
||||
}
|
||||
|
||||
// purgeConverted removes the cached .converted/<base>.{docx,html,pdf}
|
||||
// sidecars for an .md source. Called from the file API after a
|
||||
// successful PUT/DELETE/MOVE so the next GET ?convert= regenerates.
|
||||
// Best-effort: errors (including "directory doesn't exist") are
|
||||
// swallowed. Non-.md sources are a no-op so this is safe to call
|
||||
// unconditionally after any write.
|
||||
func purgeConverted(srcAbs string) {
|
||||
if !strings.HasSuffix(strings.ToLower(srcAbs), ".md") {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(srcAbs)
|
||||
base := strings.TrimSuffix(filepath.Base(srcAbs), filepath.Ext(srcAbs))
|
||||
for _, ext := range []string{".docx", ".html", ".pdf"} {
|
||||
_ = os.Remove(filepath.Join(dir, ".converted", base+ext))
|
||||
}
|
||||
}
|
||||
|
||||
func mapConvertError(w http.ResponseWriter, err error, format string) {
|
||||
if errors.Is(err, convert.ErrUnavailable) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "Service Unavailable — conversion runtime not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var ce *convert.ConvertError
|
||||
if errors.As(err, &ce) {
|
||||
// Timeout → 504. Non-zero exit with stderr → 422 with the
|
||||
// stderr excerpt so the client can show a real message.
|
||||
if ce.Cause != nil && errors.Is(ce.Cause, context.DeadlineExceeded) {
|
||||
http.Error(w, "Gateway Timeout — conversion timed out", http.StatusGatewayTimeout)
|
||||
return
|
||||
}
|
||||
msg := strings.TrimSpace(ce.Stderr)
|
||||
if msg == "" {
|
||||
msg = ce.Error()
|
||||
}
|
||||
if len(msg) > 1024 {
|
||||
msg = msg[:1024]
|
||||
}
|
||||
http.Error(w, "Unprocessable Entity — "+msg, http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
slog.Warn("convert: unexpected error", "format", format, "err", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
|
|
@ -379,6 +379,9 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
// Invalidate ETag cache (static.go memoizes by mtime; rename produces
|
||||
// a fresh mtime so a stale entry is harmless, but clearing is cheap).
|
||||
etagCacheM.Delete(abs)
|
||||
// Invalidate any cached MD→{docx,html,pdf} conversions sitting in
|
||||
// the sibling .converted/ dir for this source.
|
||||
purgeConverted(abs)
|
||||
|
||||
etag := fileETag(body)
|
||||
w.Header().Set("ETag", `"`+etag+`"`)
|
||||
|
|
@ -433,6 +436,7 @@ func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
etagCacheM.Delete(abs)
|
||||
purgeConverted(abs)
|
||||
|
||||
w.Header().Set("X-ZDDC-Source", "fileapi:delete")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
|
@ -553,6 +557,8 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
etagCacheM.Delete(srcAbs)
|
||||
etagCacheM.Delete(dstAbs)
|
||||
purgeConverted(srcAbs)
|
||||
purgeConverted(dstAbs)
|
||||
|
||||
// Compute new ETag from the moved bytes for the response — clients
|
||||
// that want to keep tracking should pin to this ETag.
|
||||
|
|
|
|||
|
|
@ -233,8 +233,8 @@ func computePending(ctx context.Context, decider policy.Decider,
|
|||
|
||||
// ServeReviewing emits the aggregator JSON listing for any depth under
|
||||
// <project>/reviewing/. The HTML branch is handled separately by the
|
||||
// apps subsystem (mdedit served at the URL); only requests that accept
|
||||
// JSON reach here.
|
||||
// apps subsystem (browse served at the URL — its markdown editor plugin
|
||||
// renders responses); only requests that accept JSON reach here.
|
||||
//
|
||||
// Depths:
|
||||
//
|
||||
|
|
|
|||
44
zddc/internal/handler/singleflight.go
Normal file
44
zddc/internal/handler/singleflight.go
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
package handler
|
||||
|
||||
import "sync"
|
||||
|
||||
// singleflightGroup deduplicates concurrent calls keyed by string. If N
|
||||
// goroutines call Do(key, fn) before the first one returns, fn runs once
|
||||
// and all callers receive the same (val, err).
|
||||
//
|
||||
// Copy of internal/apps/singleflight.go (same pattern, no extra
|
||||
// dependency). The convert package has its own copy too; if a third
|
||||
// caller appears, lift to internal/sf/.
|
||||
type singleflightGroup struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*sfCall
|
||||
}
|
||||
|
||||
type sfCall struct {
|
||||
done chan struct{}
|
||||
val any
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *singleflightGroup) Do(key string, fn func() (any, error)) (any, error) {
|
||||
g.mu.Lock()
|
||||
if g.m == nil {
|
||||
g.m = make(map[string]*sfCall)
|
||||
}
|
||||
if c, ok := g.m[key]; ok {
|
||||
g.mu.Unlock()
|
||||
<-c.done
|
||||
return c.val, c.err
|
||||
}
|
||||
c := &sfCall{done: make(chan struct{})}
|
||||
g.m[key] = c
|
||||
g.mu.Unlock()
|
||||
|
||||
c.val, c.err = fn()
|
||||
close(c.done)
|
||||
|
||||
g.mu.Lock()
|
||||
delete(g.m, key)
|
||||
g.mu.Unlock()
|
||||
return c.val, c.err
|
||||
}
|
||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-12 · candle-mast-pearl</span></span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -1845,7 +1845,7 @@ body.help-open .app-header {
|
|||
}(typeof window !== 'undefined' ? window : this));
|
||||
|
||||
// shared/zddc-source.js — source abstraction for tools that handle
|
||||
// directory trees (classifier, mdedit, transmittal, browse, archive).
|
||||
// directory trees (classifier, transmittal, browse, archive).
|
||||
//
|
||||
// Two backends:
|
||||
//
|
||||
|
|
@ -2216,12 +2216,44 @@ body.help-open .app-header {
|
|||
return !!(handle && handle.isHttp === true);
|
||||
}
|
||||
|
||||
// downloadConverted fetches a server-side MD→{docx,html,pdf}
|
||||
// conversion and triggers a browser download with a clean filename.
|
||||
// srcUrl points at the .md source on the server. fmt is one of
|
||||
// "docx" | "html" | "pdf". The server response status maps to a
|
||||
// friendly error message for the caller to surface (toast / status).
|
||||
async function downloadConverted(srcUrl, fileName, fmt) {
|
||||
var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt),
|
||||
{ credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
var msg;
|
||||
if (resp.status === 503) msg = 'Conversion service unavailable on this server.';
|
||||
else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.';
|
||||
else if (resp.status === 504) msg = 'Conversion timed out.';
|
||||
else msg = 'Conversion failed (HTTP ' + resp.status + ').';
|
||||
// Append server-supplied body text if it adds detail.
|
||||
try {
|
||||
var detail = await resp.text();
|
||||
if (detail && detail.length < 400) msg += ' ' + detail.trim();
|
||||
} catch (_) { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
var blob = await resp.blob();
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = fileName.replace(/\.md$/i, '') + '.' + fmt;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
||||
}
|
||||
|
||||
window.zddc.source = {
|
||||
HttpDirectoryHandle: HttpDirectoryHandle,
|
||||
HttpFileHandle: HttpFileHandle,
|
||||
detectServerRoot: detectServerRoot,
|
||||
moveFile: moveFile,
|
||||
isHttpHandle: isHttpHandle,
|
||||
downloadConverted: downloadConverted,
|
||||
// Lower-level helpers exposed for tools that want to call the
|
||||
// server directly without going through the polyfill.
|
||||
httpListing: httpListing,
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ func TestServeZddcEditorRendersAppsSection(t *testing.T) {
|
|||
`data-apps-key="default"`,
|
||||
`data-apps-key="archive"`,
|
||||
`data-apps-key="classifier"`,
|
||||
`data-apps-key="mdedit"`,
|
||||
`data-apps-key="browse"`,
|
||||
`data-apps-key="transmittal"`,
|
||||
`data-apps-key="landing"`,
|
||||
`value=":beta"`,
|
||||
|
|
|
|||
|
|
@ -53,11 +53,12 @@ roles:
|
|||
members: []
|
||||
|
||||
# Universal tool baseline. archive (record browser), browse (file
|
||||
# tree), and landing (project picker) work everywhere. Each canonical
|
||||
# folder below adds its own context-specific tools (mdedit in
|
||||
# working/, transmittal in staging/, etc.). The cascade unions
|
||||
# available_tools across all levels — leaf restrictions don't drop
|
||||
# ancestor entries — so this baseline propagates to every descendant.
|
||||
# tree, hosts the in-place markdown editor), and landing (project
|
||||
# picker) work everywhere. Each canonical folder below adds its own
|
||||
# context-specific tools (transmittal in staging/, etc.). The cascade
|
||||
# unions available_tools across all levels — leaf restrictions don't
|
||||
# drop ancestor entries — so this baseline propagates to every
|
||||
# descendant.
|
||||
available_tools: [archive, browse, landing]
|
||||
|
||||
# ── The slash / no-slash routing convention ────────────────────────────────
|
||||
|
|
@ -71,9 +72,9 @@ available_tools: [archive, browse, landing]
|
|||
# default; you rarely set it.
|
||||
# <dir> (no slash) → `default_tool` — the "specialized
|
||||
# app" for this folder (e.g. archive,
|
||||
# transmittal, mdedit, tables). If a
|
||||
# folder declares no default_tool, the
|
||||
# no-slash form just 302s to the slash
|
||||
# transmittal, tables). If a folder
|
||||
# declares no default_tool, the no-
|
||||
# slash form just 302s to the slash
|
||||
# form, so you land on `dir_tool`.
|
||||
#
|
||||
# JSON listing requests are unaffected by either key — they always get
|
||||
|
|
@ -81,7 +82,7 @@ available_tools: [archive, browse, landing]
|
|||
# can enumerate entries no matter what dir_tool/default_tool are.
|
||||
#
|
||||
# Both keys cascade leaf→root: a parent's default_tool applies to
|
||||
# descendants unless a deeper level overrides it (mdedit set on
|
||||
# descendants unless a deeper level overrides it (browse set on
|
||||
# working/ reaches working/alice/notes/ for free). The keys below set
|
||||
# default_tool on the canonical folders; dir_tool is left unset
|
||||
# everywhere, so the slash form is always `browse`.
|
||||
|
|
@ -201,8 +202,8 @@ paths:
|
|||
default_tool: archive
|
||||
worm: [document_controller]
|
||||
working:
|
||||
default_tool: mdedit
|
||||
available_tools: [mdedit, classifier]
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
# working/ auto-owns the first creator + the per-user homes
|
||||
# below.
|
||||
auto_own: true
|
||||
|
|
@ -215,8 +216,8 @@ paths:
|
|||
admins: [document_controller]
|
||||
paths:
|
||||
"*": # per-user home dir
|
||||
default_tool: mdedit
|
||||
available_tools: [mdedit, classifier]
|
||||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
auto_own: true
|
||||
# Per-user home is private by default: the generated
|
||||
# auto-own .zddc carries inherit:false so ancestor ACL
|
||||
|
|
@ -233,8 +234,8 @@ paths:
|
|||
# rationale as working/.
|
||||
admins: [document_controller]
|
||||
reviewing:
|
||||
default_tool: mdedit
|
||||
available_tools: [mdedit]
|
||||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
# reviewing/ is purely virtual — the aggregator handler
|
||||
# synthesises listings from received/ ↔ staging/ ↔ issued/.
|
||||
virtual: true
|
||||
|
|
|
|||
|
|
@ -92,6 +92,22 @@ type Role struct {
|
|||
Reset bool `yaml:"reset,omitempty" json:"reset,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertMetadata supplies per-project template variables for the
|
||||
// server-side MD→{docx,html,pdf} conversion endpoint. The handler
|
||||
// resolves the effective set by walking the .zddc cascade leaf→root
|
||||
// with per-key latest-wins (an empty deeper value does NOT clear an
|
||||
// ancestor value — operators write the explicit string they want).
|
||||
//
|
||||
// Variables are passed to pandoc as -V key=value flags and consumed by
|
||||
// pandoc/viewer-template.html's $if(client)$ / $if(project)$ /
|
||||
// $if(contractor)$ / $if(project_number)$ blocks.
|
||||
type ConvertMetadata struct {
|
||||
Client string `yaml:"client,omitempty" json:"client,omitempty"`
|
||||
Project string `yaml:"project,omitempty" json:"project,omitempty"`
|
||||
Contractor string `yaml:"contractor,omitempty" json:"contractor,omitempty"`
|
||||
ProjectNumber string `yaml:"project_number,omitempty" json:"project_number,omitempty"`
|
||||
}
|
||||
|
||||
// ZddcFile represents the parsed contents of a .zddc configuration file.
|
||||
//
|
||||
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
|
||||
|
|
@ -157,6 +173,17 @@ type ZddcFile struct {
|
|||
// directory whose entries they want renamed.
|
||||
Display map[string]string `yaml:"display,omitempty" json:"display,omitempty"`
|
||||
|
||||
// Convert supplies template variables for the server-side
|
||||
// MD→{docx,html,pdf} conversion endpoint (see internal/convert).
|
||||
// Cascades leaf→root with per-key latest-wins. Pointer-to-struct
|
||||
// so unset is distinguishable from "explicitly empty" — relevant
|
||||
// because the cascade merger needs to know whether a deeper .zddc
|
||||
// is contributing a value or just inheriting.
|
||||
//
|
||||
// Filename-derived variables (title, tracking_number, revision,
|
||||
// status) come from zddc.ParseFilename and are NOT in this struct.
|
||||
Convert *ConvertMetadata `yaml:"convert,omitempty" json:"convert,omitempty"`
|
||||
|
||||
// Roles are named principal groups available at this level and below.
|
||||
// See Role for member syntax.
|
||||
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||
|
|
@ -185,9 +212,9 @@ type ZddcFile struct {
|
|||
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
|
||||
|
||||
// DefaultTool is the tool name served at this directory's
|
||||
// no-slash URL form (e.g. /Project/working without trailing slash
|
||||
// → mdedit). Empty means "no default" — the no-slash form 302s to
|
||||
// the slash form, which serves DirTool (browse by default).
|
||||
// no-slash URL form (e.g. /Project/staging without trailing slash
|
||||
// → transmittal). Empty means "no default" — the no-slash form
|
||||
// 302s to the slash form, which serves DirTool (browse by default).
|
||||
// Cascades through Paths: an ancestor's Paths entry can set
|
||||
// DefaultTool for a virtual descendant without anyone creating
|
||||
// that dir. This is the "specialized app" half of the slash/no-
|
||||
|
|
@ -271,8 +298,10 @@ type ZddcFile struct {
|
|||
// Empty list at every level means "no tools available" (effectively
|
||||
// blocks all auto-serving); the embedded defaults seed the
|
||||
// universal baseline of archive/browse/landing at root. Operators
|
||||
// can add tools at deeper levels (working/ adds mdedit + classifier,
|
||||
// staging/ adds transmittal + classifier, etc.).
|
||||
// can add tools at deeper levels (working/ adds classifier,
|
||||
// staging/ adds transmittal + classifier, etc.). browse hosts the
|
||||
// markdown editor as a plugin so no extra tool is needed under
|
||||
// working/ or reviewing/.
|
||||
//
|
||||
// This does NOT gate explicit static files: an on-disk
|
||||
// <dir>/transmittal.html is always served. It gates only the
|
||||
|
|
|
|||
|
|
@ -46,3 +46,50 @@ func IsTrnOrSubTracking(tracking string) bool {
|
|||
upper := strings.ToUpper(tracking)
|
||||
return strings.Contains(upper, "-TRN-") || strings.Contains(upper, "-SUB-")
|
||||
}
|
||||
|
||||
// documentFilenameRE matches the canonical ZDDC document-filename shape:
|
||||
//
|
||||
// <tracking>_<revision> (<status>) - <title>.<ext>
|
||||
//
|
||||
// where <tracking> has no spaces or underscores in the tracking part,
|
||||
// <revision> is anything without a space, <status> is anything inside
|
||||
// parentheses, and <title> is anything after the dash up to the
|
||||
// last "." before the extension.
|
||||
//
|
||||
// Mirror of the JS parser in shared/zddc.js — kept here for the
|
||||
// conversion handler which needs to feed title/tracking/revision/
|
||||
// status to pandoc as template variables.
|
||||
var documentFilenameRE = regexp.MustCompile(
|
||||
`^([^_]+)_(\S+)\s*\(([^)]+)\)\s*-\s*(.+)\.([^.]+)$`,
|
||||
)
|
||||
|
||||
// ParsedFilename is the result of ParseFilename: tracking number,
|
||||
// revision, status, title (everything before the extension), and the
|
||||
// lowercased extension. Valid is true iff the filename matched the
|
||||
// canonical pattern.
|
||||
type ParsedFilename struct {
|
||||
TrackingNumber string
|
||||
Revision string
|
||||
Status string
|
||||
Title string
|
||||
Extension string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// ParseFilename splits a document filename into its ZDDC components.
|
||||
// Returns Valid=false if the filename doesn't match the canonical shape;
|
||||
// callers can fall back to stem-based metadata in that case.
|
||||
func ParseFilename(name string) ParsedFilename {
|
||||
m := documentFilenameRE.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return ParsedFilename{}
|
||||
}
|
||||
return ParsedFilename{
|
||||
TrackingNumber: m[1],
|
||||
Revision: m[2],
|
||||
Status: m[3],
|
||||
Title: m[4],
|
||||
Extension: strings.ToLower(m[5]),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
// Lookup walks chain.Levels from leaf toward root, returning the
|
||||
// first non-empty value. This implements the "parent applies to
|
||||
// descendants unless overridden" cascade rule: a working/ folder's
|
||||
// default_tool=mdedit propagates to working/alice/notes/ even when
|
||||
// no .zddc declares mdedit at the deeper levels.
|
||||
// default_tool=browse propagates to working/alice/notes/ even when
|
||||
// no .zddc declares browse at the deeper levels.
|
||||
//
|
||||
// Used by the URL dispatcher to route no-slash directory URLs.
|
||||
// Replaces apps.DefaultAppAt once consumers are migrated (Phase 3b).
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
|||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
|
||||
{filepath.Join(root, "Project-X", "working"), "mdedit"},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"},
|
||||
{filepath.Join(root, "Project-X", "working"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "browse"},
|
||||
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), "mdedit"},
|
||||
{filepath.Join(root, "Project-X", "reviewing"), "browse"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := DefaultToolAt(root, tc.path)
|
||||
|
|
@ -177,7 +177,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
// Operator declares that Special/working uses classifier
|
||||
// instead of the embedded-default mdedit.
|
||||
// instead of the embedded-default browse.
|
||||
writeZddc(t, filepath.Join(root, "Special", "working"),
|
||||
"default_tool: classifier\n")
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
|||
t.Errorf("operator override should set default_tool=classifier, got %q", got)
|
||||
}
|
||||
// Default still applies at other projects.
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" {
|
||||
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "browse" {
|
||||
t.Errorf("default convention should hold at unchanged paths, got %q", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -193,14 +193,14 @@ func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
|
|||
// TestDefaultToolAt_PropagatesToDescendants — once an ancestor sets
|
||||
// default_tool, descendants inherit it unless they override. So a
|
||||
// path under working/ that isn't explicitly declared in paths: still
|
||||
// gets mdedit as its default tool.
|
||||
// gets browse as its default tool.
|
||||
func TestDefaultToolAt_PropagatesToDescendants(t *testing.T) {
|
||||
resetCache()
|
||||
root := t.TempDir()
|
||||
// Deep path under working/ — not explicitly mentioned in paths:.
|
||||
deep := filepath.Join(root, "Project-X", "working", "alice@example.com", "notes", "sub", "deep")
|
||||
if got := DefaultToolAt(root, deep); got != "mdedit" {
|
||||
t.Errorf("DefaultToolAt(%q) = %q, want mdedit (cascade propagation)",
|
||||
if got := DefaultToolAt(root, deep); got != "browse" {
|
||||
t.Errorf("DefaultToolAt(%q) = %q, want browse (cascade propagation)",
|
||||
deep[len(root):], got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import (
|
|||
// via the apps fetch+cache subsystem. Order is stable for reproducible
|
||||
// admin-UI rendering.
|
||||
//
|
||||
// All eight HTML tools belong here — including browse, form, and tables.
|
||||
// All seven HTML tools belong here — including browse, form, and tables.
|
||||
// Omitting any of them means the apps cascade (.zddc apps:) silently
|
||||
// short-circuits to embedded for that name, defeating live-dev
|
||||
// path-source overrides.
|
||||
var AppNames = []string{"archive", "transmittal", "classifier", "mdedit", "landing", "browse", "form", "tables"}
|
||||
//
|
||||
// Markdown editing used to be a dedicated tool ("mdedit"); it now
|
||||
// lives as a plugin inside browse (browse/js/preview-markdown.js).
|
||||
var AppNames = []string{"archive", "transmittal", "classifier", "landing", "browse", "form", "tables"}
|
||||
|
||||
// AppsDefaultKey is the special apps-map key that provides the baseline
|
||||
// URL prefix and channel for any app not overridden per-name. Cascades
|
||||
|
|
@ -237,7 +240,7 @@ func ValidateFile(zf ZddcFile) []FieldError {
|
|||
if !IsValidAppsKey(app) {
|
||||
errs = append(errs, FieldError{
|
||||
Field: fmt.Sprintf("apps.%s", app),
|
||||
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, mdedit, landing, browse, form, tables)", app),
|
||||
Message: fmt.Sprintf("unknown app %q (known: default, archive, transmittal, classifier, landing, browse, form, tables)", app),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func TestIsValidAppsKey(t *testing.T) {
|
|||
{"archive", true},
|
||||
{"transmittal", true},
|
||||
{"classifier", true},
|
||||
{"mdedit", true},
|
||||
{"browse", true},
|
||||
{"landing", true},
|
||||
{"unknown", false},
|
||||
{"", false},
|
||||
|
|
@ -161,7 +161,7 @@ func TestValidateFile_Apps(t *testing.T) {
|
|||
"classifier": "v0.0.4", // ok
|
||||
"default": "https://zddc.varasys.io/releases:stable", // ok (default key + URL+channel)
|
||||
"transmittal": ":beta", // ok (channel-only)
|
||||
"mdedit": "https://my-mirror.example/releases", // ok (URL-prefix only)
|
||||
"browse": "https://my-mirror.example/releases", // ok (URL-prefix only)
|
||||
"unknown": "stable", // unknown app
|
||||
"landing": "what is this", // bad spec
|
||||
},
|
||||
|
|
|
|||
|
|
@ -105,6 +105,29 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
|||
out.Tables = mergeStringMap(out.Tables, top.Tables)
|
||||
out.Display = mergeStringMap(out.Display, top.Display)
|
||||
|
||||
// Convert: per-key latest-wins. Pointer-to-struct so we can tell
|
||||
// "absent" from "explicitly empty" — the latter is rare but valid
|
||||
// (an operator who wants to suppress a deployment-default value).
|
||||
// Empty top values do NOT clear the ancestor value; operators must
|
||||
// set an explicit non-empty string to override.
|
||||
if top.Convert != nil {
|
||||
if out.Convert == nil {
|
||||
out.Convert = &ConvertMetadata{}
|
||||
}
|
||||
if top.Convert.Client != "" {
|
||||
out.Convert.Client = top.Convert.Client
|
||||
}
|
||||
if top.Convert.Project != "" {
|
||||
out.Convert.Project = top.Convert.Project
|
||||
}
|
||||
if top.Convert.Contractor != "" {
|
||||
out.Convert.Contractor = top.Convert.Contractor
|
||||
}
|
||||
if top.Convert.ProjectNumber != "" {
|
||||
out.Convert.ProjectNumber = top.Convert.ProjectNumber
|
||||
}
|
||||
}
|
||||
|
||||
// Roles: per-name merge (top wins on name clash). This combines
|
||||
// the on-disk .zddc at a level with any virtual contributions
|
||||
// from ancestor paths: at the same level. Cross-LEVEL role
|
||||
|
|
|
|||
Loading…
Reference in a new issue