Compare commits

..

13 commits

Author SHA1 Message Date
0d052a20c3 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s
2026-06-08 15:19:43 -05:00
ec9c9c72bc fix(landing): trailing slash on working/staging/reviewing project links
These three are virtual party-aggregator folders — the trailing slash serves
their dir_tool (the browse folder-nav listing of parties INSIDE the folder),
while the no-slash form served the browse tool scoped at the project level.
Land the user inside the folder. archive/ keeps the no-slash form (its
default_tool is the archive tool, which is the intended landing there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:16:22 -05:00
49e8ea4b4f fix(browse): markdown editor shrinks instead of overhanging; pop out opens the real editor
- Overflow: the preview pane's child (the markdown shell) was a flex item with
  the default min-width:auto, so the editor's wide internal min-content pushed
  the whole pane past the viewport's right edge. Add min-width:0 on
  .preview-pane__body and its children so the editor shrinks (and its own +
  the grid's minmax(0) scrolling takes over) — the pane never overhangs.
- Pop out: editor-type files (markdown, yaml/.zddc, code text) were popped into
  the lightweight preview window, which can't host the bundled editor — so
  markdown showed as raw <pre>. Now they open the FULL browse app deep-linked
  to the file (<dir>?file=<name>) in a new window, loading the real editor.
  HTML keeps its rendered popup; images/pdf/office unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:56:36 -05:00
af16b14a52 feat(browse): render previewable files (.html) safely, with a Source⇄Preview toggle
Renderable types (.html/.htm) now show RENDERED by default again — but in a
sandboxed iframe (no scripts, no same-origin) so arbitrary HTML is safe — and a
header toggle flips to the CodeMirror source view (and back). Non-renderable
text files open straight in CodeMirror as before; PDF stays an iframe; markdown
keeps its own rendered/source toggle.

- RENDERABLE map + per-node view mode (rendered default; remembers only the
  last-toggled node, so switching files resets). Toggle button sits by Pop out.
- The same-node render guard now allows a deliberate toggle through (prompting
  to discard if the source editor is dirty); clearPreview hides the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:44:24 -05:00
1bd73b1512 feat(browse): CodeMirror for all editable text files; drop the .zddc form; tidy access dialog
- Editor routing: the CodeMirror editor is now the general editor for editable
  text files that aren't markdown — yaml/.zddc keep schema lint + completion +
  hover; txt/csv/tsv/json/xml/html/css/js/log/ini/conf/… open as a plaintext
  code editor (line numbers, find, save) instead of a read-only <pre>. HTML now
  edits its source (was an iframe render); PDF stays an iframe. Mode is yaml-only
  (the vendored CM mode); others plaintext, lint gutter suppressed.
- Abandon the .zddc schema FORM (preview-zddc-form.js deleted): .zddc opens in
  CodeMirror. Guided dialogs (Manage access, …) are the front door for common
  tasks; CodeMirror is the full/raw surface. One fewer half-baked middle layer.
- Manage Access dialog laid out cleanly: grid rows (who fills + shrinks, level
  sizes to content) so long emails/levels never overflow; short level labels
  with tooltips + a one-line legend; box-sizing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:18:31 -05:00
74ffefa191 feat(browse): guided "Manage access" dialog — task-first, no raw YAML
First of the intent-driven actions that replace raw-YAML editing as the front
door. Right-click a folder (or the pane) → "Manage access…" → a dialog of
people + friendly levels; the raw .zddc editor is demoted to "Edit raw policy
(.zddc)…" as the advanced escape. Both gated by the same admin authority.

- browse/js/manage-access.js: reads the folder's on-disk .zddc, presents
  principals as View / Contribute / Edit / Manage (admins: membership), plus an
  "inherit from parent" toggle (uncheck = make private). Save maps levels back
  to verbs (r / rc / rwc / admins:), merges ONLY the access bits into the doc
  (every other key preserved), and PUTs. Unrecognised verb strings show as
  "Custom" and are preserved untouched.
- Menu: "Manage access…" (guided) is now primary; "Edit raw policy (.zddc)…"
  is the escape hatch.
- Self-contained modal + CSS; refreshes the listing on save.

Sets the pattern for the remaining operator tasks (role members, display label,
project convert vars, register a party).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:03:27 -05:00
d44e1b01bf feat(browse): unify the .zddc lint onto the JSON schema (baked, single source)
The .zddc lint used a hand-kept TOP_KEYS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS list
with bespoke type tags — a parallel grammar that drifted from the Go structs
(we'd already had to patch in party_source/history/etc.). Replace it with one
schema-driven validator over the same JSON Schema that now drives completion +
hover, so lint/completion/hover/form all share ONE grammar.

- Bake zddc.schema.json into the bundle at build time (window.__ZDDC_SCHEMA__),
  the exact file the server serves at /.api/zddc-schema. Synchronous + works
  offline (file://), where that endpoint is unreachable — drops the runtime
  fetch entirely.
- validateZddc() now walks the parsed doc against the schema: type, enum,
  pattern, properties, additionalProperties (false|schema), patternProperties,
  items, and the recursive $ref:"#" (paths:). Same {keyPath,severity,message}
  shape, so findLine + the CM lint helper are unchanged.
- Delete TOP_KEYS/ALLOWED_TOOLS/ROLE_KEYS/ACL_KEYS/CONVERT_KEYS + walkObject/
  checkValue/addTypeErr. preview-yaml completion now reads getZddcSchema too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:30:17 -05:00
a13ce12a75 feat(browse): shared schema completion + hover docs; bring it to the .zddc editor
Generalise the front-matter completion into a reusable, provider-based helper
(browse/js/yaml-complete.js) and wire BOTH YAML editors through it. Still fully
deterministic — every candidate and doc string comes from a schema, no AI.

- yaml-complete.js: shared CodeMirror plumbing (indent→key-path, sibling scan,
  show-hint, debounced hover tooltip) + two providers:
    · flatProvider  — a fixed field list (front matter), with an exclude set.
    · schemaProvider — a JSON Schema walker that resolves nested key-paths
      through properties / additionalProperties / patternProperties and the
      recursive $ref:"#" .zddc uses for paths:; keys from object properties,
      values from enum / boolean, hover docs from `description`.
- .zddc editor (preview-yaml.js): fetch /.api/zddc-schema once and attach the
  schemaProvider on .zddc files — nested-key completion at every level, enum
  values (default_tool, dir_tool, views.*.tool), booleans, and hover docs.
  Plain .yaml stays lint+highlight only.
- Front-matter editor (preview-markdown.js): refactored to delegate to the
  shared helper via flatProvider (excluding the filename-driven identity keys);
  the bespoke frontMatterHints is gone — one implementation now.
- Hover-doc tooltip styling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:18:07 -05:00
2c877bd5b7 feat(browse): schema completion in the front-matter editor (keys + enum values)
Vendor CodeMirror's show-hint add-on and wire deterministic, schema-driven
completion into the markdown front-matter pane — NO heuristics, no AI; every
candidate comes from the converter's own field list.

- Vendor codemirror-show-hint.min.{js,css} (CM 5.65.x add-on); concat in
  browse/build.sh after the core CM bundle so it extends window.CodeMirror.
- Server: add a structured `values` enum to convert.FrontMatterField (doctype →
  report/letter/specification, numbering → true/false), exposed via the existing
  /.api/frontmatter JSON. Tracks the template set; keeps the server as the
  single source of truth instead of parsing the hint prose.
- Client: frontMatterHints() completes recognised KEYS at line start (excluding
  the filename-driven identity keys and keys already present) and enum VALUES
  after `key:`. Picking an enum key auto-opens its value list. Triggered on
  Ctrl-Space and automatically as you type (completeSingle:false — always a
  menu, never an auto-guess). Themed dropdown for dark mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:09:37 -05:00
84f93ba56d fix(browse): don't inject front matter on open; no conflict modal on rename
Two fixes to the markdown editor's identity handling:

- Sync-on-open now only RECONCILES front-matter identity keys the author
  already wrote (correcting a stale title/revision/…); it never ADDS them. A
  blank or new file opens blank instead of getting an auto-generated title at
  the top. The converter derives identity from the filename regardless, so an
  empty front matter needs nothing baked in. (Cancel/revert likewise only
  touches existing keys.)

- The "Rename file & reopen" flow force-writes the buffer (no If-Match) instead
  of routing through save(), which raised the conflict-resolution modal on a
  (spurious) 412. The rename is a deliberate user action and the identity edit
  that triggered it is consumed by the new filename, so we commit the buffer
  rather than interrupting with a merge dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:54:59 -05:00
cfe379d4f9 feat(browse): CodeMirror YAML editor for the markdown front-matter pane
Replace the raw <textarea> front-matter editor with a CodeMirror 5 instance —
the same editor family the .zddc previewer already uses (and already bundled in
browse, so no added weight). Gains: YAML syntax highlighting, line numbers, and
the shared js-yaml lint gutter; one consistent editing feel across the two YAML
surfaces.

- Mount fmCM (mode:yaml, lineNumbers, lint gutter, readOnly when not writable)
  into a host div; refresh on the next frame so it measures correctly.
- Route every front-matter read/write through fmCM.getValue()/setValue() and
  the change event (sync-on-open, dirty tracking, the rename cue, Cancel, save).
- The old textarea placeholder (recognised front-matter keys) becomes a greyed
  caption under the header whose tooltip carries the full key list — CodeMirror
  5 has no built-in placeholder. Arbitrary keys remain free either way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:43:54 -05:00
f06ab5542d feat(browse): Cancel button on the identity-rename cue
Adds a Cancel button alongside "Rename file & reopen" that discards the manual
identity-field edits and restores the filename-derived values (leaving the rest
of the front matter + body untouched), then recomputes dirty + the cue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:35:28 -05:00
bed6231d6b fix(browse): empty rename-cue box; signal sync-on-open changes
The rename-cue div used an inline display:flex, which outranks the `hidden`
attribute's [hidden]{display:none} — so fmWarn.hidden=true never hid it and an
empty yellow box showed whenever the cue had nothing to say. Control visibility
via style.display ('none'/'flex') instead of the hidden attribute.

Also surface a status line when sync-on-open rewrites the front matter to match
the filename, so the change isn't silent ("Front matter synced to filename —
review and save").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:24:56 -05:00
22 changed files with 2371 additions and 1288 deletions

View file

@ -14,9 +14,21 @@ ensure_exists "$src_html"
css_temp=$(mktemp) css_temp=$(mktemp)
js_raw=$(mktemp) js_raw=$(mktemp)
js_temp=$(mktemp) js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; } # Generated schema lives under dist/ (gitignored); concat_files resolves paths
# relative to $root_dir, so we pass the relative form.
schema_rel="dist/.zddc-schema.gen.js"
schema_js="$root_dir/$schema_rel"
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; }
trap cleanup EXIT trap cleanup EXIT
# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover
# all share ONE grammar (no hand-kept key list to drift from the Go structs)
# AND work offline (file://), where /.api/zddc-schema is unreachable. This is
# the exact file the server serves at that endpoint.
schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json"
ensure_exists "$schema_src"
{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js"
# CSS files: shared base first, then browse-specific. Toast UI's CSS # CSS files: shared base first, then browse-specific. Toast UI's CSS
# is bundled because the markdown plugin uses Toast UI inside the # is bundled because the markdown plugin uses Toast UI inside the
# preview pane (.md files render as a full editor). # preview pane (.md files render as a full editor).
@ -27,6 +39,7 @@ concat_files \
"../shared/logo.css" \ "../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \ "../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \ "../shared/vendor/codemirror-yaml.min.css" \
"../shared/vendor/codemirror-show-hint.min.css" \
"../shared/context-menu.css" \ "../shared/context-menu.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/profile-menu.css" \ "../shared/profile-menu.css" \
@ -34,6 +47,7 @@ concat_files \
"css/tree.css" \ "css/tree.css" \
"css/preview-yaml.css" \ "css/preview-yaml.css" \
"css/history.css" \ "css/history.css" \
"css/manage-access.css" \
> "$css_temp" > "$css_temp"
# JS files: shared canonical helpers, then browse modules. # JS files: shared canonical helpers, then browse modules.
@ -48,6 +62,7 @@ concat_files \
"../shared/vendor/utif.min.js" \ "../shared/vendor/utif.min.js" \
"../shared/vendor/js-yaml.min.js" \ "../shared/vendor/js-yaml.min.js" \
"../shared/vendor/codemirror-yaml.min.js" \ "../shared/vendor/codemirror-yaml.min.js" \
"../shared/vendor/codemirror-show-hint.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \ "../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
@ -65,7 +80,10 @@ concat_files \
"../shared/icons.js" \ "../shared/icons.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"js/init.js" \ "js/init.js" \
"$schema_rel" \
"js/util.js" \ "js/util.js" \
"js/yaml-complete.js" \
"js/manage-access.js" \
"js/conflict.js" \ "js/conflict.js" \
"js/menu-model.js" \ "js/menu-model.js" \
"js/loader.js" \ "js/loader.js" \
@ -73,7 +91,6 @@ concat_files \
"js/preview.js" \ "js/preview.js" \
"js/preview-markdown.js" \ "js/preview-markdown.js" \
"js/preview-yaml.js" \ "js/preview-yaml.js" \
"js/preview-zddc-form.js" \
"js/hovercard.js" \ "js/hovercard.js" \
"js/grid.js" \ "js/grid.js" \
"js/upload.js" \ "js/upload.js" \

View file

@ -0,0 +1,90 @@
/* manage-access.js — guided "who can do what here" dialog. */
.ma-overlay {
position: fixed;
inset: 0;
z-index: 9800;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.ma-box {
background: var(--bg-elevated, var(--bg, #fff));
color: var(--text, #222);
border: 1px solid var(--border, #ccc);
border-radius: 8px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32);
padding: 1.1rem 1.25rem;
width: min(34rem, 94vw);
max-height: 90vh;
overflow: auto;
}
.ma-title { margin: 0 0 0.2rem; font-size: 1.15rem; }
.ma-sub {
margin: 0 0 0.8rem;
font-size: 0.82rem;
color: var(--text-muted, #777);
word-break: break-all;
}
.ma-list { display: flex; flex-direction: column; gap: 0.4rem; }
/* who fills the row and shrinks (min-width:0); level + delete size to content
so nothing overflows the dialog regardless of email/principal length. */
.ma-row {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content max-content;
gap: 0.5rem;
align-items: center;
}
.ma-who,
.ma-level {
box-sizing: border-box;
padding: 0.4rem 0.5rem;
font: inherit;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
background: var(--bg, #fff);
color: var(--text, #222);
}
.ma-who { width: 100%; min-width: 0; }
.ma-level { width: 8.5rem; cursor: pointer; }
.ma-legend {
margin: 0.5rem 0 0;
font-size: 0.74rem;
color: var(--text-muted, #888);
}
.ma-del {
border: none;
background: transparent;
color: var(--text-muted, #999);
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
}
.ma-del:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.06)); color: var(--danger, #c14242); }
.ma-add {
margin: 0.6rem 0 0;
border: 1px dashed var(--border, #bbb);
background: transparent;
color: var(--primary, #2868c8);
cursor: pointer;
padding: 0.35rem 0.6rem;
border-radius: 4px;
font: inherit;
}
.ma-add:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); }
.ma-inherit {
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0.9rem 0 0;
font-size: 0.88rem;
}
.ma-err { color: var(--danger, #c14242); font-size: 0.82rem; margin: 0.5rem 0 0; min-height: 0; }
.ma-err:empty { display: none; }
.ma-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}

View file

@ -40,6 +40,24 @@
outline: none; outline: none;
} }
/* Hover-doc tooltip (yaml-complete.js) appended to document.body, so it's
styled globally. Carries a key's schema description on hover. */
.cm-doc-tip {
position: fixed;
z-index: 9700;
max-width: 360px;
padding: 6px 9px;
font-size: 0.75rem;
line-height: 1.4;
background: var(--bg-elevated, var(--bg, #fff));
color: var(--text, #222);
border: 1px solid var(--border, #ccc);
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
pointer-events: none;
white-space: normal;
}
/* CodeMirror has to fill the grid cell. The vendored CSS sets /* CodeMirror has to fill the grid cell. The vendored CSS sets
`height: 300px` by default we override to 100% so it grows with `height: 300px` by default we override to 100% so it grows with
the preview pane. */ the preview pane. */

View file

@ -249,6 +249,7 @@ body {
content's natural size (which clips the content's natural size (which clips the
YAML editor's bottom when there are many YAML editor's bottom when there are many
lines, even with the editor's own scroll) */ lines, even with the editor's own scroll) */
min-width: 0;
overflow: auto; overflow: auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -256,10 +257,16 @@ body {
} }
/* The body's children fill the available space. Plugins inject /* The body's children fill the available space. Plugins inject
different content here img, iframe, pre, custom markdown editor. */ different content here img, iframe, pre, custom markdown editor.
min-width:0 is load-bearing: a flex item defaults to min-width:auto
(its min-content width), so the markdown editor's wide internal
min-content would push the whole pane past the viewport's right edge
instead of shrinking. With min-width:0 the editor shrinks and its own
(and the grid's minmax(0)) scrolling takes over. */
.preview-pane__body > * { .preview-pane__body > * {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
min-width: 0;
} }
.preview-empty { .preview-empty {
@ -896,39 +903,55 @@ body {
/* ── Front matter editor ────────────────────────────────────────────────── */ /* ── Front matter editor ────────────────────────────────────────────────── */
.md-fm__body { .md-fm__body {
/* Body cell owns the textarea; sized by the sidebar's grid row. */ /* Body cell owns the CodeMirror editor; sized by the sidebar's grid row. */
padding: 0; padding: 0;
display: block; display: block;
overflow: hidden; overflow: hidden;
} }
.md-fm__textarea { /* Recognised-keys caption under the header (tooltip carries the full list). */
width: 100%; .md-fm__hint {
padding: 2px 0.6rem 4px;
font-size: 0.72rem;
color: var(--text-muted);
cursor: help;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* CodeMirror YAML front-matter editor fills the body cell + scrolls
internally, matching the .zddc previewer's editor styling. */
.md-fm__editor,
.md-fm__editor .CodeMirror {
height: 100%; height: 100%;
box-sizing: border-box; }
margin: 0; .md-fm__editor .CodeMirror {
padding: 0.4rem 0.6rem;
border: 0;
background: transparent;
color: var(--text);
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.45; line-height: 1.45;
resize: none; background: transparent;
outline: none; color: var(--text);
white-space: pre;
overflow: auto;
tab-size: 2;
} }
.md-fm__textarea::placeholder { .md-fm__editor .CodeMirror-gutters {
color: var(--text-muted); background: var(--bg-secondary);
font-style: italic; border-right: 1px solid var(--border);
} }
.md-fm__textarea:focus { /* Schema-completion dropdown (show-hint add-on) theme it to the app
background: var(--surface-2, rgba(0, 0, 0, 0.025)); palette so it reads in dark mode; show-hint.css ships light-only. */
.CodeMirror-hints {
z-index: 9600;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.78rem;
background: var(--bg-elevated, var(--bg, #fff));
border: 1px solid var(--border, #ccc);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
} }
.md-fm__textarea[readonly] { .CodeMirror-hint {
color: var(--text-muted); color: var(--text, #222);
cursor: not-allowed; padding: 2px 8px;
}
li.CodeMirror-hint-active {
background: var(--primary, #2868c8);
color: #fff;
} }
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced

243
browse/js/manage-access.js Normal file
View file

@ -0,0 +1,243 @@
// manage-access.js — guided "who can do what here" dialog. A task-first
// front door for a folder's .zddc acl: the user picks people + friendly access
// levels; we read the on-disk .zddc, merge ONLY the access bits (preserving
// every other key), and PUT it. No YAML, no schema knowledge required. The raw
// editor stays as the "Advanced" escape hatch.
//
// Friendly level → verbs (r read, w overwrite, c create, d delete, a admin):
// View → r Contribute → rc
// Edit → rwc Manage → admins: membership (not a verb string)
// "Custom" preserves a hand-written verb string we don't recognise.
(function (app) {
'use strict';
if (!app || !app.modules) return;
var util = app.modules.util;
var LEVELS = [
{ id: 'view', label: 'View', hint: 'read only', verbs: 'r' },
{ id: 'contribute', label: 'Contribute', hint: 'read + add new files', verbs: 'rc' },
{ id: 'edit', label: 'Edit', hint: 'read, overwrite, add', verbs: 'rwc' },
{ id: 'manage', label: 'Manage', hint: 'full config + (elevated) bypass', verbs: null }
];
function verbsOfLevel(id) {
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i].verbs;
return null;
}
function levelOfVerbs(verbs) {
verbs = String(verbs || '');
if (verbs.indexOf('a') !== -1) return 'manage';
if (verbs.indexOf('w') !== -1) return 'edit';
if (verbs.indexOf('c') !== -1) return 'contribute';
if (verbs.indexOf('r') !== -1) return 'view';
return 'custom'; // empty (explicit deny) or non-standard
}
function dirUrl(dir) {
var u = dir || '/';
if (u.charAt(0) !== '/') u = '/' + u;
if (u.charAt(u.length - 1) !== '/') u += '/';
return u;
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
async function open(dir) {
if (!app.state || app.state.source !== 'server') {
toast('Access management needs the server.', 'error');
return;
}
var base = dirUrl(dir);
var zddcUrl = base + '.zddc';
var data = {}, etag = null;
try {
var r = await fetch(zddcUrl, { credentials: 'same-origin' });
if (r.ok) {
etag = r.headers.get('ETag');
var txt = await r.text();
try { data = (window.jsyaml && window.jsyaml.load(txt)) || {}; } catch (_e) { data = {}; }
} else if (r.status !== 404) {
throw new Error('HTTP ' + r.status);
}
} catch (e) {
toast('Could not read access rules: ' + (e.message || e), 'error');
return;
}
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
// Build the principal → level model from admins (Manage) + acl.permissions.
var acl = (data.acl && typeof data.acl === 'object') ? data.acl : {};
var perms = (acl.permissions && typeof acl.permissions === 'object') ? acl.permissions : {};
var admins = Array.isArray(data.admins) ? data.admins : [];
var rows = [];
var seen = {};
admins.forEach(function (p) {
if (typeof p === 'string' && !seen[p]) { seen[p] = 1; rows.push({ principal: p, level: 'manage', custom: '' }); }
});
Object.keys(perms).forEach(function (p) {
if (seen[p]) return;
seen[p] = 1;
var lvl = levelOfVerbs(perms[p]);
rows.push({ principal: p, level: lvl, custom: lvl === 'custom' ? String(perms[p] || '') : '' });
});
var inherit = acl.inherit !== false;
renderModal(base, zddcUrl, data, etag, rows, inherit);
}
function toast(msg, kind) { if (window.zddc && window.zddc.toast) window.zddc.toast(msg, kind || 'info'); }
function renderModal(base, zddcUrl, data, etag, rows, inherit) {
var overlay = el('div', 'ma-overlay');
var box = el('div', 'ma-box');
overlay.appendChild(box);
box.appendChild(el('h2', 'ma-title', 'Manage access'));
var sub = el('p', 'ma-sub', 'Who can do what in ' + base + ' — changes here only.');
box.appendChild(sub);
var list = el('div', 'ma-list');
box.appendChild(list);
function addRow(model) {
var row = el('div', 'ma-row');
var who = el('input', 'ma-who');
who.type = 'text';
who.value = model.principal || '';
who.placeholder = 'email or *@domain or role name';
who.addEventListener('input', function () { model.principal = who.value.trim(); });
var sel = el('select', 'ma-level');
LEVELS.forEach(function (lv) {
var o = el('option', null, lv.label);
o.value = lv.id;
o.title = lv.hint;
sel.appendChild(o);
});
if (model.level === 'custom') {
var o2 = el('option', null, 'Custom');
o2.value = 'custom';
o2.title = 'verbs: ' + model.custom;
sel.appendChild(o2);
}
sel.value = model.level;
sel.addEventListener('change', function () { model.level = sel.value; });
var del = el('button', 'ma-del', '✕');
del.type = 'button';
del.title = 'Remove';
del.addEventListener('click', function () { row.remove(); model._removed = true; });
row.appendChild(who);
row.appendChild(sel);
row.appendChild(del);
list.appendChild(row);
return model;
}
rows.forEach(addRow);
var addBtn = el('button', 'ma-add', '+ Add person or group');
addBtn.type = 'button';
addBtn.addEventListener('click', function () {
var m = { principal: '', level: 'view', custom: '' };
rows.push(m);
addRow(m);
});
box.appendChild(addBtn);
var legend = el('p', 'ma-legend',
'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin');
box.appendChild(legend);
// Inherit / make-private.
var inhWrap = el('label', 'ma-inherit');
var inhBox = el('input');
inhBox.type = 'checkbox';
inhBox.checked = inherit;
inhWrap.appendChild(inhBox);
inhWrap.appendChild(el('span', null, ' Inherit access from parent folders'));
box.appendChild(inhWrap);
var err = el('p', 'ma-err');
box.appendChild(err);
var actions = el('div', 'ma-actions');
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel');
cancel.type = 'button';
var save = el('button', 'btn btn-sm btn-primary', 'Save');
save.type = 'button';
actions.appendChild(cancel);
actions.appendChild(save);
box.appendChild(actions);
function close() {
document.removeEventListener('keydown', onKey, true);
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
}
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
document.addEventListener('keydown', onKey, true);
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(); });
cancel.addEventListener('click', close);
save.addEventListener('click', function () {
err.textContent = '';
// Rebuild perms + admins from the live rows (skip removed/blank).
var perms = {}, admins = [], bad = false;
rows.forEach(function (m) {
if (m._removed) return;
var p = (m.principal || '').trim();
if (!p) return;
if (m.level === 'manage') {
if (admins.indexOf(p) === -1) admins.push(p);
} else if (m.level === 'custom') {
perms[p] = m.custom; // preserve the hand-written string
} else {
perms[p] = verbsOfLevel(m.level);
}
});
// Merge into the existing doc, preserving every unmanaged key.
var out = {};
Object.keys(data).forEach(function (k) { out[k] = data[k]; });
var acl = (out.acl && typeof out.acl === 'object') ? Object.assign({}, out.acl) : {};
if (Object.keys(perms).length) acl.permissions = perms; else delete acl.permissions;
if (!inhBox.checked) acl.inherit = false; else delete acl.inherit;
if (Object.keys(acl).length) out.acl = acl; else delete out.acl;
if (admins.length) out.admins = admins; else delete out.admins;
var content;
try { content = window.jsyaml.dump(out); }
catch (e2) { err.textContent = 'Could not serialize: ' + (e2.message || e2); return; }
save.disabled = true;
save.textContent = 'Saving…';
var node = { url: zddcUrl, name: '.zddc', ext: '' };
util.saveFile(node, content, 'application/yaml; charset=utf-8', etag ? { etag: etag } : {})
.then(function () {
toast('Access updated for ' + base, 'success');
var ev = app.modules.events;
if (ev && ev.refreshListing) { try { ev.refreshListing(); } catch (_e) { /* ignore */ } }
close();
})
.catch(function (e3) {
save.disabled = false;
save.textContent = 'Save';
if (e3 && e3.status === 412) {
err.textContent = 'These rules changed on the server since you opened this. Close and reopen to get the latest, then redo your change.';
} else {
err.textContent = 'Save failed: ' + (e3 && e3.message ? e3.message : e3);
}
});
});
document.body.appendChild(overlay);
var first = box.querySelector('.ma-who');
if (first) first.focus();
}
app.modules.manageAccess = { open: open };
})(window.app);

View file

@ -352,13 +352,30 @@
// ── admin / sub-admin tier ── // ── admin / sub-admin tier ──
{ {
// HIDDEN unless the user can actually edit access rules here // Guided "who can do what here" dialog — the front door for access.
// (admin verb 'a', or subtree/site admin) — not shown greyed. // HIDDEN unless the user can administer here (admin verb 'a', or
// subtree/site admin).
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'], id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
label: 'Edit access rules…', label: 'Manage access…',
appliesTo: function (ctx) { appliesTo: function (ctx) {
if (!isServer()) return false; // server-only tier if (!isServer()) return false; // server-only tier
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node); var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
return typeOk && manageAccessGate(ctx).enabled
&& !!(window.app.modules.manageAccess);
},
action: function (ctx) {
var m = window.app.modules.manageAccess;
if (m) m.open(ctx.dir);
}
},
{
// The raw-YAML escape hatch — same authority gate, demoted to
// "advanced" since the guided dialog covers the common case.
id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'],
label: 'Edit raw policy (.zddc)…',
appliesTo: function (ctx) {
if (!isServer()) return false;
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
return typeOk && manageAccessGate(ctx).enabled; return typeOk && manageAccessGate(ctx).enabled;
}, },
action: function (ctx) { openZddcEditor(ctx.dir); } action: function (ctx) { openZddcEditor(ctx.dir); }

View file

@ -82,30 +82,38 @@
// empty / unavailable. The promise dedupes concurrent fetches. // empty / unavailable. The promise dedupes concurrent fetches.
var fmPlaceholder = null; var fmPlaceholder = null;
var fmPlaceholderPromise = null; var fmPlaceholderPromise = null;
// Recognised fields ([{name, hint, values}]) from the same /.api/frontmatter
// fetch — drives schema completion (keys + enum values). null = not loaded.
var fmFields = null;
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's // applyFrontMatterHint populates a greyed caption (+ tooltip) with the
// recognised-field hint, in server mode only. Async + best-effort: a failed // server's recognised front-matter fields, in server mode only. Async +
// fetch leaves the pane blank (no placeholder), never an error. // best-effort: a failed fetch leaves the caption hidden, never an error.
function applyFrontMatterPlaceholder(textarea) { // (Replaces the old textarea placeholder — CodeMirror 5 has no built-in
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
function applyFrontMatterHint(el) {
var st = window.app && window.app.state; var st = window.app && window.app.state;
if (!st || st.source !== 'server') return; if (!st || st.source !== 'server') return;
if (fmPlaceholder !== null) { function paint() {
textarea.placeholder = fmPlaceholder; if (!el.isConnected) return; // user switched files before resolve
return; if (!fmPlaceholder) { el.style.display = 'none'; return; }
el.textContent = 'ⓘ Recognised front-matter keys (hover) — any other key is allowed';
el.title = fmPlaceholder;
el.style.display = '';
} }
if (fmPlaceholder !== null) { paint(); return; }
if (!fmPlaceholderPromise) { if (!fmPlaceholderPromise) {
fmPlaceholderPromise = fetch('/.api/frontmatter', { fmPlaceholderPromise = fetch('/.api/frontmatter', {
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
credentials: 'same-origin' credentials: 'same-origin'
}).then(function (r) { return r.ok ? r.json() : null; }) }).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; }) .then(function (j) {
.catch(function () { fmPlaceholder = ''; }); fmPlaceholder = (j && j.placeholder) || '';
fmFields = (j && j.fields) || [];
})
.catch(function () { fmPlaceholder = ''; fmFields = []; });
} }
fmPlaceholderPromise.then(function () { fmPlaceholderPromise.then(paint);
// Only apply if this textarea is still in the DOM (user may have
// switched files before the fetch resolved).
if (textarea.isConnected) textarea.placeholder = fmPlaceholder;
});
} }
// Lightweight YAML front-matter parser. Same envelope as mdedit's: // Lightweight YAML front-matter parser. Same envelope as mdedit's:
@ -427,22 +435,19 @@
fmHeader.textContent = 'YAML front matter'; fmHeader.textContent = 'YAML front matter';
var fmBody = document.createElement('div'); var fmBody = document.createElement('div');
fmBody.className = 'md-side__body md-fm__body'; fmBody.className = 'md-side__body md-fm__body';
var fmTextarea = document.createElement('textarea'); // CodeMirror YAML editor host — mounted with the front-matter value
fmTextarea.className = 'md-fm__textarea'; // once it's computed (sync-on-open) below. Same editor family as the
fmTextarea.spellcheck = false; // .zddc previewer: syntax highlighting, line numbers, lint gutter.
fmTextarea.autocapitalize = 'off'; var fmEditorHost = document.createElement('div');
fmTextarea.autocomplete = 'off'; fmEditorHost.className = 'md-fm__editor';
// Placeholder: in server mode, hint the recognised front-matter keys fmBody.appendChild(fmEditorHost);
// (doctype, numbering, …) as greyed text so authors can discover them. // Recognised-keys hint (server mode): a greyed caption under the header
// It's placeholder-only — inserts nothing, vanishes on the first // whose tooltip carries the full "key: # hint" template from
// keystroke — so arbitrary keys stay free and a file with no front // /.api/frontmatter. Replaces the old textarea placeholder.
// matter still renders as a genuinely empty pane. The text is fetched var fmHint = document.createElement('div');
// from the server (/.api/frontmatter), the single source of truth, so fmHint.className = 'md-fm__hint';
// it never drifts from what the converter honours. file:// mode shows fmHint.style.display = 'none';
// no placeholder (conversion is server-only). applyFrontMatterHint(fmHint);
fmTextarea.placeholder = '';
applyFrontMatterPlaceholder(fmTextarea);
fmBody.appendChild(fmTextarea);
// Rename cue: shown when the author edits an identity field // Rename cue: shown when the author edits an identity field
// (tracking_number / revision / status / title) away from the // (tracking_number / revision / status / title) away from the
// filename. The filename owns identity, so the cue offers an explicit // filename. The filename owns identity, so the cue offers an explicit
@ -450,12 +455,16 @@
// discarding the value. Populated by renderIdentityCue(). // discarding the value. Populated by renderIdentityCue().
var fmWarn = document.createElement('div'); var fmWarn = document.createElement('div');
fmWarn.className = 'md-fm__warn'; fmWarn.className = 'md-fm__warn';
fmWarn.hidden = true; // Visibility is controlled via style.display (toggled in
// renderIdentityCue), NOT the `hidden` attribute: an inline
// display:flex outranks [hidden]{display:none}, which would leave an
// empty box on screen whenever the cue has nothing to say.
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid ' fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
+ '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:' + '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:'
+ '0.78rem;line-height:1.5;display:flex;flex-wrap:wrap;align-items:' + '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;'
+ 'center;gap:6px;'; + 'display:none;';
fmSection.appendChild(fmHeader); fmSection.appendChild(fmHeader);
fmSection.appendChild(fmHint);
fmSection.appendChild(fmWarn); fmSection.appendChild(fmWarn);
fmSection.appendChild(fmBody); fmSection.appendChild(fmBody);
sidebar.appendChild(fmSection); sidebar.appendChild(fmSection);
@ -600,21 +609,59 @@
// even if we tweak whitespace in the YAML lines. // even if we tweak whitespace in the YAML lines.
var initialParsed = parseFrontMatter(text); var initialParsed = parseFrontMatter(text);
var bodyText = initialParsed.body; var bodyText = initialParsed.body;
// On open, mirror the filename-derived identity into the front matter // On open, RECONCILE existing front-matter identity keys with the
// (the filename is the single source of truth; this keeps the values // filename (the single source of truth) — but never ADD them. A blank
// baked in for the converter). No-op for non-ZDDC filenames. The dirty // or new file opens blank (we don't inject a title etc.); a file whose
// author already wrote a now-stale title/revision/… gets corrected.
// The converter derives identity from the filename regardless, so
// there's nothing to "bake in" for an empty front matter. The dirty
// baseline stays the ON-DISK state, so a correction opens the buffer // baseline stays the ON-DISK state, so a correction opens the buffer
// dirty and a save persists it. // dirty and a save persists it.
var onDiskFM = stringifyFrontMatter(initialParsed.data); var onDiskFM = stringifyFrontMatter(initialParsed.data);
var fid = filenameIdentity(node.name); var fid = filenameIdentity(node.name);
if (fid) { if (fid) {
for (var ik in fid) { for (var ik in fid) {
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik]; if (Object.prototype.hasOwnProperty.call(fid, ik)
&& Object.prototype.hasOwnProperty.call(initialParsed.data, ik)) {
initialParsed.data[ik] = fid[ik];
}
} }
} }
fmTextarea.value = stringifyFrontMatter(initialParsed.data); var syncedFM = stringifyFrontMatter(initialParsed.data);
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText)); var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
var writableMode = canSave(node); var writableMode = canSave(node);
// Front-matter YAML editor — CodeMirror, the same editor family as the
// .zddc previewer (syntax highlighting, line numbers, shared js-yaml
// lint gutter). Replaces the old <textarea>.
var fmCM = window.CodeMirror(fmEditorHost, {
value: syncedFM,
mode: 'yaml',
lineNumbers: true,
tabSize: 2,
indentUnit: 2,
indentWithTabs: false,
lineWrapping: true,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
autofocus: false,
readOnly: !writableMode
});
// The yaml lint helper (registered by the .zddc previewer) checks this
// to decide the schema layer; a .md node → plain js-yaml parse lint.
fmCM._zddcNode = node;
// Schema completion + hover docs via the shared helper. The front
// matter is flat; the identity keys are excluded from suggestions
// (filename-driven — see renderIdentityCue).
var yc = window.app.modules.yamlComplete;
if (yc) {
yc.attach(fmCM, yc.flatProvider(function () { return fmFields; }, {
exclude: IDENTITY_FIELDS.map(function (f) { return f.fm; })
}), { readOnly: !writableMode });
}
// CodeMirror mis-measures when mounted before its pane is laid out;
// refresh on the next frame so the gutters + scroll size correctly.
requestAnimationFrame(function () { fmCM.refresh(); });
// autofocus:false keeps the keyboard caret in the tree pane — // autofocus:false keeps the keyboard caret in the tree pane —
// arrow-key nav can continue through markdown files without // arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor // diverting into the editor. The user clicks into the editor
@ -663,7 +710,7 @@
node: node, node: node,
hash: initialHash, hash: initialHash,
tocEl: tocBody, tocEl: tocBody,
fmEl: fmTextarea, fmEl: fmCM,
ac: ac, ac: ac,
// Server version token captured at load — sent as If-Match on // Server version token captured at load — sent as If-Match on
// save and refreshed from each successful PUT's response ETag. // save and refreshed from each successful PUT's response ETag.
@ -675,7 +722,7 @@
if (!writableMode) { if (!writableMode) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.'; saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true; // fmCM was created with readOnly:!writableMode — nothing more here.
} }
renderToc(tocBody, bodyText, editor); renderToc(tocBody, bodyText, editor);
@ -785,7 +832,7 @@
var onChange = debounce(async function () { var onChange = debounce(async function () {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderToc(tocBody, body, editor); renderToc(tocBody, body, editor);
@ -822,8 +869,8 @@
function renderIdentityCue() { function renderIdentityCue() {
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild); while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
var fid = filenameIdentity(node.name); var fid = filenameIdentity(node.name);
if (!fid || !canSave(node)) { fmWarn.hidden = true; return; } if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; }
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {}; var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
var edits = []; var edits = [];
IDENTITY_FIELDS.forEach(function (f) { IDENTITY_FIELDS.forEach(function (f) {
if (!(f.fm in data)) return; if (!(f.fm in data)) return;
@ -831,7 +878,7 @@
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim(); var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”'); if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
}); });
if (!edits.length) { fmWarn.hidden = true; return; } if (!edits.length) { fmWarn.style.display = 'none'; return; }
var msg = document.createElement('span'); var msg = document.createElement('span');
msg.textContent = '✎ Identity comes from the filename. You changed ' msg.textContent = '✎ Identity comes from the filename. You changed '
+ edits.join(', ') + '. '; + edits.join(', ') + '. ';
@ -846,7 +893,36 @@
btn.addEventListener('click', function () { renameToMatch(newName); }); btn.addEventListener('click', function () { renameToMatch(newName); });
fmWarn.appendChild(btn); fmWarn.appendChild(btn);
} }
fmWarn.hidden = false; // Cancel: discard the identity edits, restoring the filename values.
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn btn-sm md-fm__revert';
cancelBtn.textContent = 'Cancel';
cancelBtn.title = 'Discard these identity edits and restore the filename values.';
cancelBtn.addEventListener('click', function () { revertIdentityEdits(); });
fmWarn.appendChild(cancelBtn);
fmWarn.style.display = 'flex';
}
// Revert the identity fields in the front matter to the filename-
// derived values (undo a manual identity edit), leaving the rest of the
// front matter + body untouched. Recomputes dirty + the cue after.
async function revertIdentityEdits() {
var fid = filenameIdentity(node.name);
if (!fid) return;
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
for (var k in fid) {
if (Object.prototype.hasOwnProperty.call(fid, k)
&& Object.prototype.hasOwnProperty.call(data, k)) {
data[k] = fid[k];
}
}
fmCM.setValue(stringifyFrontMatter(data));
var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return;
markDirty(h !== instance.hash);
renderIdentityCue();
} }
// Rename action: persist the current buffer (so body edits aren't // Rename action: persist the current buffer (so body edits aren't
@ -857,11 +933,26 @@
async function renameToMatch(newName) { async function renameToMatch(newName) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up || !up.renameNode || !newName) return; if (!up || !up.renameNode || !newName) return;
// 1. Save first so body/FM edits survive the rename. A failed save // 1. Persist the current buffer first so body edits survive the
// (conflict, ACL) leaves the buffer dirty — abort the rename. // rename. Force the write (no If-Match) — the user deliberately
if (instance.dirty) { // initiated this rename, so we commit their version rather than
await save(); // interrupting with the conflict-resolution modal (which save()
if (currentInstance !== instance || instance.dirty) return; // raises on a 412). The identity edit that triggered the rename
// is consumed by the new filename, so there's nothing to merge.
if (instance.dirty && canSave(node)) {
try {
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, { force: true });
await markSaved(content, res);
if (currentInstance !== instance) return;
} catch (e) {
statusEl.textContent = '';
if (window.zddc && window.zddc.toast) {
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
}
return;
}
} }
// 2. Rename on disk. // 2. Rename on disk.
try { try {
@ -902,17 +993,21 @@
var onFmChange = debounce(async function () { var onFmChange = debounce(async function () {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
var body = editor.getMarkdown(); var body = editor.getMarkdown();
var h = await hashContent(assembleContent(fmTextarea.value, body)); var h = await hashContent(assembleContent(fmCM.getValue(), body));
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
markDirty(h !== instance.hash); markDirty(h !== instance.hash);
renderIdentityCue(); renderIdentityCue();
}, 250); }, 250);
fmTextarea.addEventListener('input', onFmChange); fmCM.on('change', onFmChange);
renderIdentityCue(); // initial state on load (clean after sync-on-open) renderIdentityCue(); // initial state on load (clean after sync-on-open)
// If sync-on-open corrected the front matter, open the buffer dirty so // If sync-on-open corrected the front matter, open the buffer dirty so
// a save bakes the filename-derived identity in. // a save bakes the filename-derived identity in — and say so, since the
if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true); // change is otherwise silent (the values just match the filename now).
if (writableMode && fmCM.getValue() !== onDiskFM) {
markDirty(true);
statusEl.textContent = 'Front matter synced to filename — review and save';
}
// ── Save ─────────────────────────────────────────────────────────── // ── Save ───────────────────────────────────────────────────────────
// Mark a successful write: adopt the new server ETag (so the next // Mark a successful write: adopt the new server ETag (so the next
@ -976,7 +1071,7 @@
async function save() { async function save() {
if (currentInstance !== instance) return; if (currentInstance !== instance) return;
if (!instance.dirty || !canSave(node)) return; if (!instance.dirty || !canSave(node)) return;
var content = assembleContent(fmTextarea.value, editor.getMarkdown()); var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
try { try {
statusEl.textContent = 'Saving…'; statusEl.textContent = 'Saving…';
var res = await saveContent(node, content, { var res = await saveContent(node, content, {

View file

@ -43,6 +43,26 @@
return ext === 'yaml' || ext === 'yml'; return ext === 'yaml' || ext === 'yml';
} }
// The CodeMirror editor is the general editor for editable TEXT files that
// aren't markdown (markdown has its own editor). Syntax highlighting is
// YAML-only — that's the one CM mode in the vendored bundle — so every
// other type opens as a plaintext editor (still line numbers, find,
// selection, save). svg/json-as-image etc. stay with their preview
// renderers; this set is deliberately the "edit the source" types.
var CODE_EXTS = {
yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1,
json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1,
log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1,
sh: 1, bash: 1, properties: 1
};
function isCodeFile(node) {
if (!node || node.isDir || node.isZip) return false;
if (isYamlFile(node)) return true;
return !!CODE_EXTS[(node.ext || '').toLowerCase()];
}
// CodeMirror mode by extension — only yaml is vendored; others plaintext.
function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; }
// ── Save (mirrors preview-markdown.js) ───────────────────────────────── // ── Save (mirrors preview-markdown.js) ─────────────────────────────────
function saveContent(node, content, opts) { function saveContent(node, content, opts) {
@ -90,49 +110,10 @@
// any level surface as warnings — typos like `defaul_tool` are // any level surface as warnings — typos like `defaul_tool` are
// common and the cascade silently ignores them. // common and the cascade silently ignores them.
var ALLOWED_TOOLS = { // The valid keys, types, enums and nesting are NOT hand-listed here any
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1, // more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
tables: 1, form: 1 // the same grammar the server serves at /.api/zddc-schema and that drives
}; // completion + hover). One source, no drift. See validateZddcSchema below.
var TOP_KEYS = {
title: 'string',
acl: 'acl',
admins: 'string[]',
roles: 'rolemap',
available_tools: 'tools[]',
default_tool: 'tool',
dir_tool: 'tool',
auto_own: 'bool',
auto_own_fenced: 'bool',
virtual: 'bool',
drop_target: 'bool',
worm: 'string[]',
paths: 'pathmap',
display: 'stringmap',
tables: 'stringmap',
views: 'viewmap',
convert: 'convert',
created_by: 'string',
inherit: 'bool',
// Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the
// lint was missing — flagged valid configs as "unknown key".
party_source: 'string',
history: 'bool',
history_globs: 'string[]',
records: 'object',
auto_own_roles: 'string[]',
received_path: 'string',
planned_response_date: 'string',
planned_review_date: 'string',
field_codes: 'object'
};
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
allow: 'string[]', deny: 'string[]' };
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
var CONVERT_KEYS = { client: 'string', project: 'string',
contractor: 'string', project_number: 'string' };
function typeOf(v) { function typeOf(v) {
if (v === null || v === undefined) return 'null'; if (v === null || v === undefined) return 'null';
@ -140,168 +121,87 @@
return typeof v; // 'string' | 'number' | 'boolean' | 'object' return typeof v; // 'string' | 'number' | 'boolean' | 'object'
} }
// Collect schema issues for a parsed .zddc document. Each issue is // The .zddc JSON Schema, baked into the bundle at build time
// { keyPath: string[], message: string, severity: 'error' | 'warning' }. // (window.__ZDDC_SCHEMA__ — the same file the server serves at
// keyPath is used by findLine() to locate the offending source line. // /.api/zddc-schema). Single source for lint, completion and hover; works
// offline. Synchronous, so the lint helper can use it directly.
function getZddcSchema() {
return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties)
? window.__ZDDC_SCHEMA__ : {};
}
// Validate a parsed .zddc document against the JSON Schema, producing
// { keyPath, severity, message } issues (mapped to source lines by
// findLine). Covers the draft-2020-12 subset .zddc uses: type, enum,
// properties, additionalProperties (false | schema), patternProperties,
// items, pattern, and the recursive $ref:"#" (paths:).
function validateZddc(doc) { function validateZddc(doc) {
var schema = getZddcSchema();
var issues = []; var issues = [];
if (!schema || !schema.properties) return issues; // schema unavailable
if (typeOf(doc) === 'null') return issues; if (typeOf(doc) === 'null') return issues;
if (typeOf(doc) !== 'object') { if (typeOf(doc) !== 'object') {
issues.push({ keyPath: [], severity: 'error', issues.push({ keyPath: [], severity: 'error',
message: 'Root must be a map (got ' + typeOf(doc) + ').' }); message: 'Root must be a map (got ' + typeOf(doc) + ').' });
return issues; return issues;
} }
walkObject(doc, TOP_KEYS, [], issues); function deref(n) { return (n && n.$ref === '#') ? schema : n; }
return issues; function typeOk(t, want) {
} if (Array.isArray(want)) {
for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
function walkObject(obj, schema, path, issues) { return false;
for (var key in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
var here = path.concat([key]);
var kind = schema[key];
if (!kind) {
issues.push({ keyPath: here, severity: 'warning',
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
continue;
} }
checkValue(obj[key], kind, here, issues); if (want === 'integer' || want === 'number') return t === 'number';
return t === want;
} }
} function walk(value, sch, path) {
sch = deref(sch);
function checkValue(val, kind, path, issues) { if (!sch) return;
var t = typeOf(val); var t = typeOf(value);
switch (kind) { if (t === 'null') return; // empty value mid-edit — don't flag
case 'string': if (sch.type && !typeOk(t, sch.type)) {
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues); issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
+ ', got ' + t + '.' });
return; return;
case 'bool': }
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues); if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) {
return; issues.push({ keyPath: path, severity: 'warning',
case 'string[]': message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' });
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues); }
return; if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) {
case 'tools[]': issues.push({ keyPath: path, severity: 'error',
if (t !== 'array' && t !== 'null') { message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
addTypeErr(path, kind, t, issues); return; }
} if (t === 'object') {
if (t === 'array') { var props = sch.properties || {};
for (var i = 0; i < val.length; i++) { for (var k in value) {
if (typeOf(val[i]) !== 'string') { if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
issues.push({ keyPath: path, severity: 'error', var kp = path.concat([k]);
message: 'available_tools[' + i + '] must be a string.' }); if (props[k]) { walk(value[k], props[k], kp); continue; }
} else if (!ALLOWED_TOOLS[val[i]]) { var ap = sch.additionalProperties;
issues.push({ keyPath: path, severity: 'warning', if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
message: 'Unknown tool "' + val[i] if (sch.patternProperties) {
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' }); var matched = null;
for (var p in sch.patternProperties) {
if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p)
&& new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; }
} }
if (matched) { walk(value[k], matched, kp); continue; }
}
if (ap === false) {
issues.push({ keyPath: kp, severity: 'warning',
message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' });
} }
} }
return; } else if (t === 'array' && sch.items) {
case 'tool': for (var i = 0; i < value.length; i++) {
if (t === 'null') return; walk(value[i], sch.items, path.concat([String(i)]));
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
if (!ALLOWED_TOOLS[val]) {
issues.push({ keyPath: path, severity: 'warning',
message: 'Unknown tool "' + val + '". Known: '
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
} }
return; }
case 'stringmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var k in val) {
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
if (typeOf(val[k]) !== 'string') {
issues.push({ keyPath: path.concat([k]), severity: 'error',
message: 'Value must be a string (got '
+ typeOf(val[k]) + ').' });
}
}
return;
case 'pathmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var seg in val) {
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
if (seg.indexOf('/') !== -1) {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'Path keys must be a single segment — '
+ 'nest blocks instead of using "' + seg + '".' });
}
var v = val[seg];
if (typeOf(v) === 'null') continue;
if (typeOf(v) !== 'object') {
issues.push({ keyPath: path.concat([seg]), severity: 'error',
message: 'paths.' + seg + ' must be a map of cascade rules.' });
continue;
}
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
}
return;
case 'viewmap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var shape in val) {
if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
issues.push({ keyPath: path.concat([shape]), severity: 'warning',
message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
}
var vv = val[shape];
if (typeOf(vv) !== 'object') {
issues.push({ keyPath: path.concat([shape]), severity: 'error',
message: 'views.' + shape + ' must be a map ({tool, config}).' });
continue;
}
if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
message: 'views.' + shape + '.tool should be a known tool ('
+ Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
}
if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
message: 'views.' + shape + '.config must be a filename string.' });
}
}
return;
case 'rolemap':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
for (var rn in val) {
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
var rv = val[rn];
if (typeOf(rv) === 'null') continue;
if (typeOf(rv) !== 'object') {
issues.push({ keyPath: path.concat([rn]), severity: 'error',
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
continue;
}
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
}
return;
case 'acl':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, ACL_KEYS, path, issues);
return;
case 'convert':
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
walkObject(val, CONVERT_KEYS, path, issues);
return;
case 'object':
// Free-form map (records, field_codes) — the server accepts any
// nested shape, so we only check it's a mapping, not its keys.
if (t === 'null') return;
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
return;
} }
} walk(doc, schema, []);
return issues;
function addTypeErr(path, expected, got, issues) {
issues.push({ keyPath: path, severity: 'error',
message: 'Expected ' + expected + ', got ' + got + '.' });
} }
// Locate the source line for a key path. .zddc files are // Locate the source line for a key path. .zddc files are
@ -465,8 +365,10 @@
schemaTag.addEventListener('keydown', function (ev) { schemaTag.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); } if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
}); });
} else { } else if (isYamlFile(node)) {
schemaTag.textContent = 'YAML'; schemaTag.textContent = 'YAML';
} else {
schemaTag.textContent = (node.ext || 'text').toUpperCase();
} }
var dirtyEl = document.createElement('span'); var dirtyEl = document.createElement('span');
@ -506,16 +408,22 @@
} }
var writable = canSave(node); var writable = canSave(node);
var mode = codeMode(node);
// Lint (js-yaml + the .zddc schema) only applies to YAML; other text
// types are plaintext, so skip the lint gutter for them.
var yamlMode = mode === 'yaml';
var editor = window.CodeMirror(editorHost, { var editor = window.CodeMirror(editorHost, {
value: text, value: text,
mode: 'yaml', mode: mode,
lineNumbers: true, lineNumbers: true,
tabSize: 2, tabSize: 2,
indentUnit: 2, indentUnit: 2,
indentWithTabs: false, indentWithTabs: false,
lineWrapping: false, lineWrapping: false,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], gutters: yamlMode
lint: { hasGutters: true }, ? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
: ['CodeMirror-linenumbers'],
lint: yamlMode ? { hasGutters: true } : false,
// autofocus:false keeps the keyboard caret in the browse // autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml / // tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User // .zddc files without diverting into the editor. User
@ -533,8 +441,15 @@
// Stash the node on the editor so the lint helper can decide // Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer. // whether to apply the .zddc schema layer.
editor._zddcNode = node; editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set. // Force an initial lint pass now that _zddcNode is set (YAML only).
editor.performLint(); if (yamlMode) editor.performLint();
// Schema completion + hover docs for .zddc files (the machine grammar
// drives keys, enum/boolean values, and nested paths via $ref:"#").
// Plain .yaml gets no schema (lint + highlighting only).
var yc = window.app.modules.yamlComplete;
if (yc && isZddcFile(node.name)) {
yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable });
}
currentEditor = editor; currentEditor = editor;
currentNodeRef = node; currentNodeRef = node;
currentDirty = false; currentDirty = false;
@ -656,8 +571,7 @@
} }
function handles(node) { function handles(node) {
if (!node || node.isDir || node.isZip) return false; return isCodeFile(node);
return isYamlFile(node);
} }
window.app.modules.yamledit = { window.app.modules.yamledit = {

View file

@ -1,325 +0,0 @@
// preview-zddc-form.js — schema-driven FORM view for .zddc files.
//
// The user shouldn't have to understand YAML cascades to configure a project.
// This renders the .zddc as a form: the OPTION fields (the blanks an operator
// fills — title, admins, role members) are editable widgets; the STRUCTURE
// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
// for context. The split is driven by the server's .zddc JSON Schema
// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
// option values back into the file (preserving all structure keys) and PUTs the
// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
// CodeMirror editor for anything the form doesn't cover (field_codes, display,
// convert, advanced acl).
//
// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
// the power-user fallback.
(function (app) {
'use strict';
var util = app.modules.util || window.app.modules.util;
var escapeHtml = util.escapeHtml;
var saveFile = util.saveFile;
var isEditableZipMember = util.isEditableZipMember;
var current = null; // { node, dirty, etag, lastModified }
// Cached .zddc schema (property → {tier, description}).
var schemaProps = null;
function loadSchema() {
if (schemaProps) return Promise.resolve(schemaProps);
return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
.catch(function () { schemaProps = {}; return schemaProps; });
}
function handles(node) {
return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
}
function canSave(node) {
if (isEditableZipMember(node)) return true;
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
// A .zddc edit is an ActionAdmin write — needs the 'a' verb.
return window.zddc.cap.has(node, 'a');
}
return false;
}
function isDirty() { return !!(current && current.dirty); }
function currentNode() { return current ? current.node : null; }
function dispose() { current = null; }
function desc(name) {
return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
}
// ── small DOM helpers ───────────────────────────────────────────────────
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
// A growable list of single-string rows (used for admins + role members).
function listEditor(values, placeholder, onChange, readOnly) {
var wrap = el('div', 'zf-list');
function addRow(val) {
var row = el('div', 'zf-list__row');
row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
var input = el('input');
input.type = 'text';
input.value = val || '';
input.placeholder = placeholder || '';
input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
input.disabled = !!readOnly;
input.addEventListener('input', onChange);
row.appendChild(input);
if (!readOnly) {
var del = el('button', null, '');
del.type = 'button';
del.title = 'Remove';
del.addEventListener('click', function () { row.remove(); onChange(); });
row.appendChild(del);
}
wrap.appendChild(row);
}
(values || []).forEach(addRow);
if (!readOnly) {
var add = el('button', 'zf-add', '+ add');
add.type = 'button';
add.style.cssText = 'margin-top:.2rem;';
add.addEventListener('click', function () { addRow(''); onChange(); });
wrap.appendChild(add);
}
wrap._values = function () {
return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
.map(function (i) { return i.value.trim(); })
.filter(function (v) { return v; });
};
return wrap;
}
async function render(node, container, ctx) {
dispose();
var text, etag = null, lastModified = null;
try {
if (ctx.getContentWithVersion) {
var loaded = await ctx.getContentWithVersion(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
etag = loaded.etag;
lastModified = loaded.lastModified;
} else {
text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
}
} catch (e) {
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '</div>';
return;
}
var data = {};
try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; }
if (typeof data !== 'object' || Array.isArray(data)) data = {};
await loadSchema();
var editable = canSave(node);
current = { node: node, dirty: false, etag: etag, lastModified: lastModified };
container.innerHTML = '';
var shell = el('div', 'yaml-shell zddc-form');
shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;';
container.appendChild(shell);
// Header.
var hdr = el('div', 'md-shell__infohdr');
hdr.appendChild(el('span', 'md-shell__title', node.name));
var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only'));
hdr.appendChild(srcTag);
var dirtyEl = el('span', 'md-shell__dirty');
hdr.appendChild(dirtyEl);
var statusEl = el('span', 'md-shell__status');
hdr.appendChild(statusEl);
var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML');
rawBtn.type = 'button';
rawBtn.title = 'Switch to the raw YAML editor (covers every key).';
rawBtn.addEventListener('click', function () {
var ym = window.app.modules.yamledit;
if (ym && ym.render) { dispose(); ym.render(node, container, ctx); }
});
hdr.appendChild(rawBtn);
var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save');
saveBtn.type = 'button';
saveBtn.disabled = true;
hdr.appendChild(saveBtn);
shell.appendChild(hdr);
function markDirty() {
if (!current) return;
current.dirty = true;
dirtyEl.textContent = '● modified';
if (editable) saveBtn.disabled = false;
}
var help = el('p', 'help');
help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;';
help.textContent = editable
? 'Project options. Structural keys are read-only — use Edit raw YAML.'
: 'Read-only — you need admin authority over this path to edit it.';
shell.appendChild(help);
// ── OPTION fields ───────────────────────────────────────────────────
function section(title, hint, tight) {
var s = el('section', 'zf-section');
s.style.cssText = 'margin:0 0 1rem;';
var h = el('h3', null, title);
// `tight` drops the heading's top margin for the FIRST section so
// it doesn't stack with the intro's bottom margin (the gap above
// Title was reading as excessive). Later sections keep the gap.
h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;';
s.appendChild(h);
if (hint) {
var p = el('p', 'help', hint);
p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;';
s.appendChild(p);
}
shell.appendChild(s);
return s;
}
// title
var titleSec = section('Title', desc('title'), true);
var titleInput = el('input');
titleInput.type = 'text';
titleInput.value = (typeof data.title === 'string') ? data.title : '';
titleInput.disabled = !editable;
titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;';
titleInput.addEventListener('input', markDirty);
titleSec.appendChild(titleInput);
// admins
var adminsSec = section('Admins', desc('admins'));
var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable);
adminsSec.appendChild(adminsList);
// roles (map name → {members:[]})
var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.');
var rolesHost = el('div', 'zf-roles');
rolesSec.appendChild(rolesHost);
var roleEditors = []; // {name, membersEl, getName}
function addRole(name, members) {
var box = el('div', 'zf-role');
box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;';
var nameRow = el('div');
nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;';
var nameInput = el('input');
nameInput.type = 'text';
nameInput.value = name || '';
nameInput.placeholder = 'role name (e.g. document_controller)';
nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;';
nameInput.disabled = !editable;
nameInput.addEventListener('input', markDirty);
nameRow.appendChild(el('span', null, '👥'));
nameRow.appendChild(nameInput);
box.appendChild(nameRow);
var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable);
box.appendChild(membersList);
rolesHost.appendChild(box);
roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList });
}
var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {};
Object.keys(roles).forEach(function (rn) {
var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : [];
addRole(rn, m);
});
if (editable) {
var addRoleBtn = el('button', 'zf-add', '+ add role');
addRoleBtn.type = 'button';
addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); });
rolesSec.appendChild(addRoleBtn);
}
// ── STRUCTURE (read-only) ───────────────────────────────────────────
var structKeys = Object.keys(data).filter(function (k) {
return schemaProps[k] && schemaProps[k].tier === 'structure';
});
// Also surface option keys this form doesn't render yet, as read-only.
var rawHandled = { title: 1, admins: 1, roles: 1 };
var otherKeys = Object.keys(data).filter(function (k) {
return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure');
});
if (structKeys.length || otherKeys.length) {
var det = el('details', 'zf-structure');
det.style.cssText = 'margin-top:.5rem;';
var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)');
sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;';
det.appendChild(sum);
var subset = {};
structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; });
var pre = el('pre');
pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;';
try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); }
catch (_) { pre.textContent = JSON.stringify(subset, null, 2); }
det.appendChild(pre);
shell.appendChild(det);
}
// ── Save ────────────────────────────────────────────────────────────
function buildContent() {
var out = {};
// Preserve everything not managed by the form (structure + unrendered options).
Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; });
var t = titleInput.value.trim();
if (t) out.title = t;
var admins = adminsList._values();
if (admins.length) out.admins = admins;
var rolesOut = {};
roleEditors.forEach(function (re) {
var n = re.getName();
if (!n) return;
var mem = re.members._values();
rolesOut[n] = mem.length ? { members: mem } : { members: [] };
});
if (Object.keys(rolesOut).length) out.roles = rolesOut;
return window.jsyaml.dump(out);
}
saveBtn.addEventListener('click', async function () {
if (!current || !editable) return;
saveBtn.disabled = true;
statusEl.textContent = 'Saving…';
var content;
try { content = buildContent(); }
catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; }
try {
var res = await saveFile(node, content, 'application/yaml; charset=utf-8',
{ etag: current.etag, lastModified: current.lastModified });
if (!current) return;
current.etag = (res && res.etag) || current.etag;
current.dirty = false;
dirtyEl.textContent = '';
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success');
} catch (e) {
if (e && e.status === 412 && window.app.modules.conflict) {
window.app.modules.conflict.open({
name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); }
});
statusEl.textContent = 'Conflict — changed on server.';
} else {
statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e);
}
saveBtn.disabled = false;
}
});
}
app.modules.zddcform = {
handles: handles,
render: render,
isDirty: isDirty,
currentNode: currentNode,
dispose: dispose
};
})(window.app);

View file

@ -100,7 +100,7 @@
function editorModules() { function editorModules() {
var m = window.app.modules; var m = window.app.modules;
return [m.markdown, m.yamledit, m.zddcform].filter(Boolean); return [m.markdown, m.yamledit].filter(Boolean);
} }
function disposeEditors() { function disposeEditors() {
@ -132,6 +132,9 @@
disposeEditors(); disposeEditors();
var container = document.getElementById('previewBody'); var container = document.getElementById('previewBody');
if (container) container.innerHTML = ''; if (container) container.innerHTML = '';
toggleTargetNode = null;
var tb = document.getElementById('previewViewToggle');
if (tb) tb.classList.add('hidden');
} }
// Warn before a full page unload (reload / close / external nav) drops // Warn before a full page unload (reload / close / external nav) drops
@ -141,6 +144,41 @@
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; } if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
}); });
// ── Rendered ⇄ Source toggle ─────────────────────────────────────────────
// Some types we can RENDER, not just edit (.html). Those show rendered by
// default (sandboxed — no scripts, no same-origin) with a toggle to the
// CodeMirror source view. Markdown has its own rendered/source toggle, so
// it's not here. Extend RENDERABLE to add more (svg already previews as an
// image; csv could render as a table later).
var RENDERABLE = { html: 1, htm: 1 };
function isRenderable(ext) { return !!RENDERABLE[(ext || '').toLowerCase()]; }
function nodeKey(node) { return (node && (node.url || node.name)) || ''; }
// Per-node mode; 'rendered' is the default. Only the node the user last
// toggled is remembered, so switching files resets to rendered.
var viewToggle = { key: null, mode: 'rendered' };
var toggleTargetNode = null;
function effectiveMode(node) {
return (viewToggle.key && viewToggle.key === nodeKey(node)) ? viewToggle.mode : 'rendered';
}
function ensureViewToggleBtn() {
var btn = document.getElementById('previewViewToggle');
if (btn) return btn;
var popout = document.getElementById('previewPopout');
if (!popout || !popout.parentNode) return null;
btn = document.createElement('button');
btn.id = 'previewViewToggle';
btn.type = 'button';
btn.className = 'btn btn-sm btn-secondary hidden';
popout.parentNode.insertBefore(btn, popout);
btn.addEventListener('click', function () {
if (!toggleTargetNode) return;
var next = effectiveMode(toggleTargetNode) === 'rendered' ? 'source' : 'rendered';
viewToggle = { key: nodeKey(toggleTargetNode), mode: next };
renderInline(toggleTargetNode, { toggle: true });
});
return btn;
}
// ── Inline rendering ──────────────────────────────────────────────────── // ── Inline rendering ────────────────────────────────────────────────────
// Bumped on every renderInline entry; a render that loses the race // Bumped on every renderInline entry; a render that loses the race
@ -169,9 +207,10 @@
var dm = dirtyEditor(); var dm = dirtyEditor();
if (dm) { if (dm) {
var cur = dm.currentNode ? dm.currentNode() : null; var cur = dm.currentNode ? dm.currentNode() : null;
if (samePreviewNode(cur, node)) { if (samePreviewNode(cur, node) && !opts.toggle) {
// Re-selecting the file we're already editing — don't reload // Re-selecting the file we're already editing — don't reload
// and clobber the in-progress edits. // and clobber the in-progress edits. (A deliberate view toggle
// falls through to the discard prompt below.)
return; return;
} }
if (opts.auto) { if (opts.auto) {
@ -199,6 +238,32 @@
var ext = (node.ext || '').toLowerCase(); var ext = (node.ext || '').toLowerCase();
// Rendered ⇄ Source toggle button — shown only for renderable types.
var toggleBtn = ensureViewToggleBtn();
if (toggleBtn) {
if (isRenderable(ext)) {
toggleTargetNode = node;
toggleBtn.classList.remove('hidden');
toggleBtn.textContent = effectiveMode(node) === 'rendered' ? '⟨⟩ Source' : '◱ Preview';
} else {
toggleBtn.classList.add('hidden');
}
}
// Renderable types (.html) — show rendered by default, sandboxed for
// safety (no scripts, no same-origin). The toggle flips to source.
if (isRenderable(ext) && effectiveMode(node) === 'rendered') {
try {
var rinfo = await getBlobUrl(node);
if (seq !== renderSeq) return;
container.innerHTML = '<iframe class="preview-iframe" sandbox src="'
+ escapeHtml(rinfo.url) + '"></iframe>';
} catch (e) {
renderError(container, e.message || String(e));
}
return;
}
// Markdown plugin (if loaded) takes over for .md / .markdown. // Markdown plugin (if loaded) takes over for .md / .markdown.
if ((ext === 'md' || ext === 'markdown') && if ((ext === 'md' || ext === 'markdown') &&
window.app.modules.markdown && window.app.modules.markdown &&
@ -211,39 +276,27 @@
return; return;
} }
// .zddc form view: a schema-driven form (option fields editable, // CodeMirror editor: the general editor for editable text files that
// structure read-only) is the PRIMARY editor for .zddc files. It hands // aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
// off to the raw YAML editor on demand. Other YAML files skip it. // txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
var zddcForm = window.app.modules.zddcform; // Guided dialogs (Manage access, …) are the front door for the common
if (zddcForm && zddcForm.handles(node)) { // .zddc tasks; this is the full/raw edit surface.
try {
await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) {
renderError(container, '.zddc form render failed: ' + (e.message || e));
}
return;
}
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
// CodeMirror 5 editor with js-yaml linting; .zddc files also
// get a schema-aware lint pass.
var yamlMod = window.app.modules.yamledit; var yamlMod = window.app.modules.yamledit;
if (yamlMod && yamlMod.handles(node)) { if (yamlMod && yamlMod.handles(node)) {
try { try {
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion }); await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
} catch (e) { } catch (e) {
renderError(container, 'YAML render failed: ' + (e.message || e)); renderError(container, 'Editor failed: ' + (e.message || e));
} }
return; return;
} }
// PDF / HTML → iframe. // PDF → iframe (HTML now routes to the editor above).
if (ext === 'pdf' || ext === 'html' || ext === 'htm') { if (ext === 'pdf') {
try { try {
var info = await getBlobUrl(node); var info = await getBlobUrl(node);
if (seq !== renderSeq) return; if (seq !== renderSeq) return;
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"'; container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"></iframe>';
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
} catch (e) { } catch (e) {
renderError(container, e.message || String(e)); renderError(container, e.message || String(e));
} }
@ -432,6 +485,25 @@
} }
async function renderInPopup(node) { async function renderInPopup(node) {
// Editor-type files (markdown, yaml/.zddc, code text) can't be hosted
// in the lightweight popup window — they need the bundled editor. Pop
// them out as the FULL browse app deep-linked to the file, which loads
// the real editor in a new window. Server mode only; HTML keeps its
// rendered popup. Falls through to the lightweight popup otherwise.
var pext = (node.ext || '').toLowerCase();
var ym = window.app.modules.yamledit;
var isEditorType = pext === 'md' || pext === 'markdown'
|| (ym && ym.handles && ym.handles(node) && pext !== 'html' && pext !== 'htm');
if (isEditorType && window.app.state.source === 'server' && node.url) {
var slash = node.url.lastIndexOf('/');
var pdir = slash >= 0 ? node.url.slice(0, slash + 1) : '/';
var pbase = slash >= 0 ? node.url.slice(slash + 1) : node.url;
var pp = new URLSearchParams();
try { pp.set('file', decodeURIComponent(pbase)); } catch (_e) { pp.set('file', pbase); }
if (window.app.state.showHidden) pp.set('hidden', '1');
window.open(pdir + '?' + pp.toString(), '_blank', 'noopener');
return;
}
var info; var info;
try { try {
info = await getBlobUrl(node); info = await getBlobUrl(node);

275
browse/js/yaml-complete.js Normal file
View file

@ -0,0 +1,275 @@
// yaml-complete.js — deterministic, schema-driven completion + hover docs for
// the browse YAML editors (markdown front matter + .zddc). NO heuristics, no
// AI: every candidate and doc string comes from a PROVIDER backed by the
// converter's field list or the .zddc JSON Schema.
//
// A provider answers three questions about a position, identified by its key
// PATH (the array of parent keys):
// keysAt(path) → [{name, hint, values}] valid child keys here
// valuesFor(path, key) → [string] | null enum/boolean values
// describe(path, key) → string | null doc text (for hover)
// The CodeMirror plumbing (indent→path, sibling scan, show-hint, hover) is
// shared; only the provider differs between the flat front matter and the
// nested .zddc schema. Requires CodeMirror 5 + the show-hint add-on.
(function () {
'use strict';
if (!window.app) window.app = {};
if (!window.app.modules) window.app.modules = {};
function indentOf(line) { var m = line.match(/^(\s*)/); return m ? m[1].length : 0; }
function isBlankOrComment(line) { return /^\s*$/.test(line) || /^\s*#/.test(line); }
function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
// Parent key-path for a line, derived from YAML indentation: walk upward
// collecting each "key:" line at a strictly smaller indent.
function pathAt(cm, lineNo) {
var path = [];
var target = indentOf(cm.getLine(lineNo));
for (var ln = lineNo - 1; ln >= 0 && target > 0; ln--) {
var line = cm.getLine(ln);
if (isBlankOrComment(line)) continue;
var ind = indentOf(line);
if (ind < target) {
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (m) { path.unshift(m[1]); target = ind; }
}
}
return path;
}
// Sibling keys already present at the same indent within this block, so we
// don't re-suggest a key the author already wrote.
function presentSiblings(cm, lineNo, indent) {
var present = {};
[-1, 1].forEach(function (dir) {
for (var ln = lineNo + dir; ln >= 0 && ln < cm.lineCount(); ln += dir) {
var line = cm.getLine(ln);
if (isBlankOrComment(line)) continue;
var ind = indentOf(line);
if (ind < indent) break; // left the block
if (ind === indent) {
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (m) present[m[1]] = true;
}
}
});
return present;
}
function keyItem(k, hinter) {
var item = {
text: k.name + ': ',
displayText: k.name + (k.hint ? ' — ' + truncate(k.hint, 64) : '')
};
// An enum key inserts "key: " then immediately opens its value menu.
if (k.values && k.values.length) {
item.hint = function (cmi, data, comp) {
cmi.replaceRange(comp.text, data.from, data.to);
setTimeout(function () { cmi.showHint({ hint: hinter, completeSingle: false }); }, 0);
};
}
return item;
}
function makeHinter(provider) {
function hinter(cm) {
var CM = window.CodeMirror;
if (!CM) return null;
var cur = cm.getCursor();
var before = cm.getLine(cur.line).slice(0, cur.ch);
var colon = before.indexOf(':');
var path = pathAt(cm, cur.line);
if (colon === -1) {
// KEY context.
var m = before.match(/^(\s*)([\w.\-]*)$/);
if (!m) return null;
var indent = m[1], typed = m[2];
var keys = provider.keysAt(path) || [];
if (!keys.length) return null;
var present = presentSiblings(cm, cur.line, indent.length);
var list = [];
keys.forEach(function (k) {
if (present[k.name]) return;
if (typed && k.name.indexOf(typed) !== 0) return;
list.push(keyItem(k, hinter));
});
if (!list.length) return null;
return { list: list, from: CM.Pos(cur.line, indent.length), to: cur };
}
// VALUE context.
var key = before.slice(0, colon).trim();
var values = provider.valuesFor(path, key) || [];
if (!values.length) return null;
var rest = before.slice(colon + 1);
var valTyped = rest.replace(/^\s*/, '');
var valStart = colon + 1 + (rest.length - valTyped.length);
var vlist = [];
values.forEach(function (v) {
if (valTyped && v.indexOf(valTyped) !== 0) return;
vlist.push({ text: v, displayText: v });
});
if (!vlist.length) return null;
return { list: vlist, from: CM.Pos(cur.line, valStart), to: cur };
}
return hinter;
}
// Lightweight hover docs: hover a "key:" → its schema description. No
// add-on — a debounced mousemove over the editor + a fixed-position tip.
function attachHover(cm, provider) {
var tip = null, timer = null;
function hide() { if (tip && tip.parentNode) tip.parentNode.removeChild(tip); tip = null; }
function show(text, x, y) {
hide();
tip = document.createElement('div');
tip.className = 'cm-doc-tip';
tip.textContent = text;
document.body.appendChild(tip);
tip.style.left = x + 'px';
tip.style.top = (y + 16) + 'px';
}
var wrap = cm.getWrapperElement();
wrap.addEventListener('mousemove', function (e) {
if (timer) clearTimeout(timer);
var ex = e.clientX, ey = e.clientY;
timer = setTimeout(function () {
if (!wrap.isConnected) { hide(); return; }
try {
var pos = cm.coordsChar({ left: ex, top: ey }, 'window');
var line = cm.getLine(pos.line) || '';
var m = line.match(/^\s*([\w.\-]+)\s*:/);
if (!m) { hide(); return; }
var keyStart = line.indexOf(m[1]);
if (pos.ch < keyStart || pos.ch > keyStart + m[1].length) { hide(); return; }
var doc = provider.describe(pathAt(cm, pos.line), m[1]);
if (doc) show(doc, ex, ey); else hide();
} catch (_e) { hide(); }
}, 350);
});
wrap.addEventListener('mouseleave', function () { if (timer) clearTimeout(timer); hide(); });
cm.on('cursorActivity', hide);
cm.on('changes', hide);
}
// Wire completion (Ctrl-Space + auto-trigger as you type) and hover docs
// onto a CodeMirror instance. opts.readOnly skips the typing trigger;
// opts.hover:false skips hover.
function attach(cm, provider, opts) {
opts = opts || {};
var hinter = makeHinter(provider);
var keys = Object.assign({}, cm.getOption('extraKeys') || {}, {
'Ctrl-Space': function (c) { c.showHint({ hint: hinter, completeSingle: false }); }
});
cm.setOption('extraKeys', keys);
if (!opts.readOnly) {
cm.on('inputRead', function (c, change) {
if (!change.text || change.text.length !== 1) return; // skip paste/delete
if (!/[\w.\-]/.test(change.text[0])) return;
c.showHint({ hint: hinter, completeSingle: false });
});
}
if (opts.hover !== false) attachHover(cm, provider);
return hinter;
}
// ── Providers ───────────────────────────────────────────────────────────
// Flat: a fixed field list [{name, hint, values}] at the root, nothing
// nested (front matter). opts.exclude = names never suggested.
function flatProvider(getFields, opts) {
opts = opts || {};
var exclude = {};
(opts.exclude || []).forEach(function (n) { exclude[n] = true; });
function fields() { return getFields() || []; }
function find(name) {
var fs = fields();
for (var i = 0; i < fs.length; i++) if (fs[i].name === name) return fs[i];
return null;
}
return {
keysAt: function (path) {
if (path.length) return [];
return fields().filter(function (f) { return !exclude[f.name]; })
.map(function (f) { return { name: f.name, hint: f.hint, values: f.values }; });
},
valuesFor: function (path, key) {
if (path.length) return null;
var f = find(key); return f ? f.values : null;
},
describe: function (path, key) {
if (path.length) return null;
var f = find(key); return f ? f.hint : null;
}
};
}
// Schema: a JSON Schema (draft-2020-12 subset). Resolves nested key-paths
// through properties / additionalProperties / patternProperties and the
// recursive $ref:"#" .zddc uses for paths:. Keys = object property names;
// values = enum / boolean.
function schemaProvider(getSchema) {
function root() { return getSchema(); }
function deref(node) { return (node && node.$ref === '#') ? root() : node; }
function stepInto(node, seg) {
node = deref(node);
if (!node || node.type !== 'object') return null;
if (node.properties && node.properties[seg]) return node.properties[seg];
if (node.additionalProperties && typeof node.additionalProperties === 'object') {
return node.additionalProperties;
}
if (node.patternProperties) {
for (var p in node.patternProperties) {
if (Object.prototype.hasOwnProperty.call(node.patternProperties, p)) {
return node.patternProperties[p];
}
}
}
return null;
}
function containerAt(path) {
var node = deref(root());
for (var i = 0; i < path.length; i++) {
node = stepInto(node, path[i]);
if (!node) return null;
node = deref(node);
}
return node;
}
function valuesOf(node) {
node = deref(node);
if (!node) return null;
if (Array.isArray(node.enum)) return node.enum.map(String);
if (node.type === 'boolean') return ['true', 'false'];
return null;
}
function keyNodeAt(path, key) {
var c = containerAt(path);
if (!c || !c.properties) return null;
return c.properties[key] || null;
}
return {
keysAt: function (path) {
var c = containerAt(path);
if (!c || c.type !== 'object' || !c.properties) return [];
return Object.keys(c.properties).map(function (name) {
var n = deref(c.properties[name]) || {};
return { name: name, hint: n.description, values: valuesOf(n) };
});
},
valuesFor: function (path, key) { return valuesOf(keyNodeAt(path, key)); },
describe: function (path, key) {
var n = deref(keyNodeAt(path, key));
return n ? n.description : null;
}
};
}
window.app.modules.yamlComplete = {
attach: attach,
makeHinter: makeHinter,
flatProvider: flatProvider,
schemaProvider: schemaProvider
};
})();

View file

@ -684,9 +684,12 @@
var p = encodeURIComponent(project); var p = encodeURIComponent(project);
var stages = [ var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' }, { id: 'stageArchive', href: '/' + p + '/archive' },
{ id: 'stageWorking', href: '/' + p + '/working' }, // working/staging/reviewing get a trailing slash so the user lands
{ id: 'stageStaging', href: '/' + p + '/staging' }, // INSIDE the folder (the dir_tool browse listing of parties),
{ id: 'stageReviewing', href: '/' + p + '/reviewing' }, // not on the browse tool scoped at the project level.
{ id: 'stageWorking', href: '/' + p + '/working/' },
{ id: 'stageStaging', href: '/' + p + '/staging/' },
{ id: 'stageReviewing', href: '/' + p + '/reviewing/' },
]; ];
for (var i = 0; i < stages.length; i++) { for (var i = 0; i < stages.length; i++) {
var a = document.getElementById(stages[i].id); var a = document.getElementById(stages[i].id);

View file

@ -0,0 +1 @@
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}

File diff suppressed because one or more lines are too long

View file

@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

File diff suppressed because one or more lines are too long

View file

@ -1876,7 +1876,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1619,7 +1619,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3864,9 +3864,12 @@ body {
var p = encodeURIComponent(project); var p = encodeURIComponent(project);
var stages = [ var stages = [
{ id: 'stageArchive', href: '/' + p + '/archive' }, { id: 'stageArchive', href: '/' + p + '/archive' },
{ id: 'stageWorking', href: '/' + p + '/working' }, // working/staging/reviewing get a trailing slash so the user lands
{ id: 'stageStaging', href: '/' + p + '/staging' }, // INSIDE the folder (the dir_tool browse listing of parties),
{ id: 'stageReviewing', href: '/' + p + '/reviewing' }, // not on the browse tool scoped at the project level.
{ id: 'stageWorking', href: '/' + p + '/working/' },
{ id: 'stageStaging', href: '/' + p + '/staging/' },
{ id: 'stageReviewing', href: '/' + p + '/reviewing/' },
]; ];
for (var i = 0; i < stages.length; i++) { for (var i = 0; i < stages.length; i++) {
var a = document.getElementById(stages[i].id); var a = document.getElementById(stages[i].id);

View file

@ -2718,7 +2718,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199 archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
transmittal=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199 transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
classifier=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199 classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
landing=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199 landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
form=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199 form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
tables=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199 tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
browse=v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199 browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7

View file

@ -65,6 +65,9 @@ type Metadata struct {
type FrontMatterField struct { type FrontMatterField struct {
Name string `json:"name"` Name string `json:"name"`
Hint string `json:"hint"` Hint string `json:"hint"`
// Values is the closed set of valid values for this key (an enum), used
// by the editor for value completion. Empty/nil = free-text.
Values []string `json:"values,omitempty"`
} }
// RecognizedFrontMatter is the single source of truth for the front-matter keys // RecognizedFrontMatter is the single source of truth for the front-matter keys
@ -76,18 +79,20 @@ type FrontMatterField struct {
// user most needs told about. // user most needs told about.
func RecognizedFrontMatter() []FrontMatterField { func RecognizedFrontMatter() []FrontMatterField {
return []FrontMatterField{ return []FrontMatterField{
{"doctype", "report | letter | specification"}, // doctype enum tracks the template set (internal/convert/templates/
{"numbering", "true to number headings (default false)"}, // *.html, sans the _-prefixed partials).
{"title", "mirrors the filename — rename the file to change it"}, {"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
{"tracking_number", "mirrors the filename — rename the file to change it"}, {"numbering", "true to number headings (default false)", []string{"true", "false"}},
{"revision", "mirrors the filename — rename the file to change it"}, {"title", "mirrors the filename — rename the file to change it", nil},
{"status", "mirrors the filename — rename the file to change it"}, {"tracking_number", "mirrors the filename — rename the file to change it", nil},
{"date", "document date (free text)"}, {"revision", "mirrors the filename — rename the file to change it", nil},
{"custom_header", "extra line shown in the document header"}, {"status", "mirrors the filename — rename the file to change it", nil},
{"client", "overrides the .zddc convert: cascade"}, {"date", "document date (free text)", nil},
{"project", "overrides the .zddc convert: cascade"}, {"custom_header", "extra line shown in the document header", nil},
{"project_number", "overrides the .zddc convert: cascade"}, {"client", "overrides the .zddc convert: cascade", nil},
{"contractor", "overrides the .zddc convert: cascade"}, {"project", "overrides the .zddc convert: cascade", nil},
{"project_number", "overrides the .zddc convert: cascade", nil},
{"contractor", "overrides the .zddc convert: cascade", nil},
} }
} }

View file

@ -1648,7 +1648,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">