Compare commits
No commits in common. "0d052a20c301de460d6750f777ccdfc76f2f3642" and "242d25d55acb2611ff531eb799023f30a675850e" have entirely different histories.
0d052a20c3
...
242d25d55a
22 changed files with 1302 additions and 2385 deletions
|
|
@ -14,21 +14,9 @@ ensure_exists "$src_html"
|
|||
css_temp=$(mktemp)
|
||||
js_raw=$(mktemp)
|
||||
js_temp=$(mktemp)
|
||||
# Generated schema lives under dist/ (gitignored); concat_files resolves paths
|
||||
# relative to $root_dir, so we pass the relative form.
|
||||
schema_rel="dist/.zddc-schema.gen.js"
|
||||
schema_js="$root_dir/$schema_rel"
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; }
|
||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover
|
||||
# all share ONE grammar (no hand-kept key list to drift from the Go structs)
|
||||
# AND work offline (file://), where /.api/zddc-schema is unreachable. This is
|
||||
# the exact file the server serves at that endpoint.
|
||||
schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json"
|
||||
ensure_exists "$schema_src"
|
||||
{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js"
|
||||
|
||||
# CSS files: shared base first, then browse-specific. Toast UI's CSS
|
||||
# is bundled because the markdown plugin uses Toast UI inside the
|
||||
# preview pane (.md files render as a full editor).
|
||||
|
|
@ -39,7 +27,6 @@ concat_files \
|
|||
"../shared/logo.css" \
|
||||
"../shared/vendor/toastui-editor.min.css" \
|
||||
"../shared/vendor/codemirror-yaml.min.css" \
|
||||
"../shared/vendor/codemirror-show-hint.min.css" \
|
||||
"../shared/context-menu.css" \
|
||||
"../shared/elevation.css" \
|
||||
"../shared/profile-menu.css" \
|
||||
|
|
@ -47,7 +34,6 @@ concat_files \
|
|||
"css/tree.css" \
|
||||
"css/preview-yaml.css" \
|
||||
"css/history.css" \
|
||||
"css/manage-access.css" \
|
||||
> "$css_temp"
|
||||
|
||||
# JS files: shared canonical helpers, then browse modules.
|
||||
|
|
@ -62,7 +48,6 @@ concat_files \
|
|||
"../shared/vendor/utif.min.js" \
|
||||
"../shared/vendor/js-yaml.min.js" \
|
||||
"../shared/vendor/codemirror-yaml.min.js" \
|
||||
"../shared/vendor/codemirror-show-hint.min.js" \
|
||||
"../shared/vendor/toastui-editor-all.min.js" \
|
||||
"../shared/zddc.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
|
|
@ -80,10 +65,7 @@ concat_files \
|
|||
"../shared/icons.js" \
|
||||
"../shared/zddc-source.js" \
|
||||
"js/init.js" \
|
||||
"$schema_rel" \
|
||||
"js/util.js" \
|
||||
"js/yaml-complete.js" \
|
||||
"js/manage-access.js" \
|
||||
"js/conflict.js" \
|
||||
"js/menu-model.js" \
|
||||
"js/loader.js" \
|
||||
|
|
@ -91,6 +73,7 @@ concat_files \
|
|||
"js/preview.js" \
|
||||
"js/preview-markdown.js" \
|
||||
"js/preview-yaml.js" \
|
||||
"js/preview-zddc-form.js" \
|
||||
"js/hovercard.js" \
|
||||
"js/grid.js" \
|
||||
"js/upload.js" \
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -40,24 +40,6 @@
|
|||
outline: none;
|
||||
}
|
||||
|
||||
/* Hover-doc tooltip (yaml-complete.js) — appended to document.body, so it's
|
||||
styled globally. Carries a key's schema description on hover. */
|
||||
.cm-doc-tip {
|
||||
position: fixed;
|
||||
z-index: 9700;
|
||||
max-width: 360px;
|
||||
padding: 6px 9px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
background: var(--bg-elevated, var(--bg, #fff));
|
||||
color: var(--text, #222);
|
||||
border: 1px solid var(--border, #ccc);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* CodeMirror has to fill the grid cell. The vendored CSS sets
|
||||
`height: 300px` by default — we override to 100% so it grows with
|
||||
the preview pane. */
|
||||
|
|
|
|||
|
|
@ -249,7 +249,6 @@ body {
|
|||
content's natural size (which clips the
|
||||
YAML editor's bottom when there are many
|
||||
lines, even with the editor's own scroll) */
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -257,16 +256,10 @@ body {
|
|||
}
|
||||
|
||||
/* The body's children fill the available space. Plugins inject
|
||||
different content here — img, iframe, pre, custom markdown editor.
|
||||
min-width:0 is load-bearing: a flex item defaults to min-width:auto
|
||||
(its min-content width), so the markdown editor's wide internal
|
||||
min-content would push the whole pane past the viewport's right edge
|
||||
instead of shrinking. With min-width:0 the editor shrinks and its own
|
||||
(and the grid's minmax(0)) scrolling takes over. */
|
||||
different content here — img, iframe, pre, custom markdown editor. */
|
||||
.preview-pane__body > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
|
|
@ -903,55 +896,39 @@ body {
|
|||
|
||||
/* ── Front matter editor ────────────────────────────────────────────────── */
|
||||
.md-fm__body {
|
||||
/* Body cell owns the CodeMirror editor; sized by the sidebar's grid row. */
|
||||
/* Body cell owns the textarea; sized by the sidebar's grid row. */
|
||||
padding: 0;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Recognised-keys caption under the header (tooltip carries the full list). */
|
||||
.md-fm__hint {
|
||||
padding: 2px 0.6rem 4px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* CodeMirror YAML front-matter editor — fills the body cell + scrolls
|
||||
internally, matching the .zddc previewer's editor styling. */
|
||||
.md-fm__editor,
|
||||
.md-fm__editor .CodeMirror {
|
||||
.md-fm__textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.md-fm__editor .CodeMirror {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
resize: none;
|
||||
outline: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
.md-fm__editor .CodeMirror-gutters {
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
.md-fm__textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
/* Schema-completion dropdown (show-hint add-on) — theme it to the app
|
||||
palette so it reads in dark mode; show-hint.css ships light-only. */
|
||||
.CodeMirror-hints {
|
||||
z-index: 9600;
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg-elevated, var(--bg, #fff));
|
||||
border: 1px solid var(--border, #ccc);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
||||
.md-fm__textarea:focus {
|
||||
background: var(--surface-2, rgba(0, 0, 0, 0.025));
|
||||
}
|
||||
.CodeMirror-hint {
|
||||
color: var(--text, #222);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
li.CodeMirror-hint-active {
|
||||
background: var(--primary, #2868c8);
|
||||
color: #fff;
|
||||
.md-fm__textarea[readonly] {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
// 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);
|
||||
|
|
@ -352,30 +352,13 @@
|
|||
|
||||
// ── admin / sub-admin tier ──
|
||||
{
|
||||
// Guided "who can do what here" dialog — the front door for access.
|
||||
// HIDDEN unless the user can administer here (admin verb 'a', or
|
||||
// subtree/site admin).
|
||||
// HIDDEN unless the user can actually edit access rules here
|
||||
// (admin verb 'a', or subtree/site admin) — not shown greyed.
|
||||
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
||||
label: 'Manage access…',
|
||||
label: 'Edit access rules…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false; // server-only tier
|
||||
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||
return typeOk && manageAccessGate(ctx).enabled
|
||||
&& !!(window.app.modules.manageAccess);
|
||||
},
|
||||
action: function (ctx) {
|
||||
var m = window.app.modules.manageAccess;
|
||||
if (m) m.open(ctx.dir);
|
||||
}
|
||||
},
|
||||
{
|
||||
// The raw-YAML escape hatch — same authority gate, demoted to
|
||||
// "advanced" since the guided dialog covers the common case.
|
||||
id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'],
|
||||
label: 'Edit raw policy (.zddc)…',
|
||||
appliesTo: function (ctx) {
|
||||
if (!isServer()) return false;
|
||||
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||
return typeOk && manageAccessGate(ctx).enabled;
|
||||
},
|
||||
action: function (ctx) { openZddcEditor(ctx.dir); }
|
||||
|
|
|
|||
|
|
@ -82,38 +82,30 @@
|
|||
// empty / unavailable. The promise dedupes concurrent fetches.
|
||||
var fmPlaceholder = 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;
|
||||
|
||||
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the
|
||||
// server's recognised front-matter fields, in server mode only. Async +
|
||||
// best-effort: a failed fetch leaves the caption hidden, never an error.
|
||||
// (Replaces the old textarea placeholder — CodeMirror 5 has no built-in
|
||||
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
|
||||
function applyFrontMatterHint(el) {
|
||||
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's
|
||||
// recognised-field hint, in server mode only. Async + best-effort: a failed
|
||||
// fetch leaves the pane blank (no placeholder), never an error.
|
||||
function applyFrontMatterPlaceholder(textarea) {
|
||||
var st = window.app && window.app.state;
|
||||
if (!st || st.source !== 'server') return;
|
||||
function paint() {
|
||||
if (!el.isConnected) return; // user switched files before resolve
|
||||
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) {
|
||||
textarea.placeholder = fmPlaceholder;
|
||||
return;
|
||||
}
|
||||
if (fmPlaceholder !== null) { paint(); return; }
|
||||
if (!fmPlaceholderPromise) {
|
||||
fmPlaceholderPromise = fetch('/.api/frontmatter', {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
fmPlaceholder = (j && j.placeholder) || '';
|
||||
fmFields = (j && j.fields) || [];
|
||||
})
|
||||
.catch(function () { fmPlaceholder = ''; fmFields = []; });
|
||||
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
|
||||
.catch(function () { fmPlaceholder = ''; });
|
||||
}
|
||||
fmPlaceholderPromise.then(paint);
|
||||
fmPlaceholderPromise.then(function () {
|
||||
// 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:
|
||||
|
|
@ -435,19 +427,22 @@
|
|||
fmHeader.textContent = 'YAML front matter';
|
||||
var fmBody = document.createElement('div');
|
||||
fmBody.className = 'md-side__body md-fm__body';
|
||||
// CodeMirror YAML editor host — mounted with the front-matter value
|
||||
// once it's computed (sync-on-open) below. Same editor family as the
|
||||
// .zddc previewer: syntax highlighting, line numbers, lint gutter.
|
||||
var fmEditorHost = document.createElement('div');
|
||||
fmEditorHost.className = 'md-fm__editor';
|
||||
fmBody.appendChild(fmEditorHost);
|
||||
// Recognised-keys hint (server mode): a greyed caption under the header
|
||||
// whose tooltip carries the full "key: # hint" template from
|
||||
// /.api/frontmatter. Replaces the old textarea placeholder.
|
||||
var fmHint = document.createElement('div');
|
||||
fmHint.className = 'md-fm__hint';
|
||||
fmHint.style.display = 'none';
|
||||
applyFrontMatterHint(fmHint);
|
||||
var fmTextarea = document.createElement('textarea');
|
||||
fmTextarea.className = 'md-fm__textarea';
|
||||
fmTextarea.spellcheck = false;
|
||||
fmTextarea.autocapitalize = 'off';
|
||||
fmTextarea.autocomplete = 'off';
|
||||
// Placeholder: in server mode, hint the recognised front-matter keys
|
||||
// (doctype, numbering, …) as greyed text so authors can discover them.
|
||||
// It's placeholder-only — inserts nothing, vanishes on the first
|
||||
// keystroke — so arbitrary keys stay free and a file with no front
|
||||
// matter still renders as a genuinely empty pane. The text is fetched
|
||||
// from the server (/.api/frontmatter), the single source of truth, so
|
||||
// it never drifts from what the converter honours. file:// mode shows
|
||||
// no placeholder (conversion is server-only).
|
||||
fmTextarea.placeholder = '';
|
||||
applyFrontMatterPlaceholder(fmTextarea);
|
||||
fmBody.appendChild(fmTextarea);
|
||||
// Rename cue: shown when the author edits an identity field
|
||||
// (tracking_number / revision / status / title) away from the
|
||||
// filename. The filename owns identity, so the cue offers an explicit
|
||||
|
|
@ -455,16 +450,12 @@
|
|||
// discarding the value. Populated by renderIdentityCue().
|
||||
var fmWarn = document.createElement('div');
|
||||
fmWarn.className = 'md-fm__warn';
|
||||
// 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.hidden = true;
|
||||
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
|
||||
+ '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:'
|
||||
+ '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;'
|
||||
+ 'display:none;';
|
||||
+ '0.78rem;line-height:1.5;display:flex;flex-wrap:wrap;align-items:'
|
||||
+ 'center;gap:6px;';
|
||||
fmSection.appendChild(fmHeader);
|
||||
fmSection.appendChild(fmHint);
|
||||
fmSection.appendChild(fmWarn);
|
||||
fmSection.appendChild(fmBody);
|
||||
sidebar.appendChild(fmSection);
|
||||
|
|
@ -609,59 +600,21 @@
|
|||
// even if we tweak whitespace in the YAML lines.
|
||||
var initialParsed = parseFrontMatter(text);
|
||||
var bodyText = initialParsed.body;
|
||||
// On open, RECONCILE existing front-matter identity keys with the
|
||||
// filename (the single source of truth) — but never ADD them. A blank
|
||||
// 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
|
||||
// On open, mirror the filename-derived identity into the front matter
|
||||
// (the filename is the single source of truth; this keeps the values
|
||||
// baked in for the converter). No-op for non-ZDDC filenames. The dirty
|
||||
// baseline stays the ON-DISK state, so a correction opens the buffer
|
||||
// dirty and a save persists it.
|
||||
var onDiskFM = stringifyFrontMatter(initialParsed.data);
|
||||
var fid = filenameIdentity(node.name);
|
||||
if (fid) {
|
||||
for (var ik in fid) {
|
||||
if (Object.prototype.hasOwnProperty.call(fid, ik)
|
||||
&& Object.prototype.hasOwnProperty.call(initialParsed.data, ik)) {
|
||||
initialParsed.data[ik] = fid[ik];
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
|
||||
}
|
||||
}
|
||||
var syncedFM = stringifyFrontMatter(initialParsed.data);
|
||||
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
||||
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
|
||||
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 —
|
||||
// arrow-key nav can continue through markdown files without
|
||||
// diverting into the editor. The user clicks into the editor
|
||||
|
|
@ -710,7 +663,7 @@
|
|||
node: node,
|
||||
hash: initialHash,
|
||||
tocEl: tocBody,
|
||||
fmEl: fmCM,
|
||||
fmEl: fmTextarea,
|
||||
ac: ac,
|
||||
// Server version token captured at load — sent as If-Match on
|
||||
// save and refreshed from each successful PUT's response ETag.
|
||||
|
|
@ -722,7 +675,7 @@
|
|||
if (!writableMode) {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.title = 'Save not available — read-only source.';
|
||||
// fmCM was created with readOnly:!writableMode — nothing more here.
|
||||
fmTextarea.readOnly = true;
|
||||
}
|
||||
|
||||
renderToc(tocBody, bodyText, editor);
|
||||
|
|
@ -832,7 +785,7 @@
|
|||
var onChange = debounce(async function () {
|
||||
if (currentInstance !== instance) return;
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
if (currentInstance !== instance) return;
|
||||
markDirty(h !== instance.hash);
|
||||
renderToc(tocBody, body, editor);
|
||||
|
|
@ -869,8 +822,8 @@
|
|||
function renderIdentityCue() {
|
||||
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
|
||||
var fid = filenameIdentity(node.name);
|
||||
if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; }
|
||||
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
|
||||
if (!fid || !canSave(node)) { fmWarn.hidden = true; return; }
|
||||
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
||||
var edits = [];
|
||||
IDENTITY_FIELDS.forEach(function (f) {
|
||||
if (!(f.fm in data)) return;
|
||||
|
|
@ -878,7 +831,7 @@
|
|||
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
|
||||
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
|
||||
});
|
||||
if (!edits.length) { fmWarn.style.display = 'none'; return; }
|
||||
if (!edits.length) { fmWarn.hidden = true; return; }
|
||||
var msg = document.createElement('span');
|
||||
msg.textContent = '✎ Identity comes from the filename. You changed '
|
||||
+ edits.join(', ') + '. ';
|
||||
|
|
@ -893,36 +846,7 @@
|
|||
btn.addEventListener('click', function () { renameToMatch(newName); });
|
||||
fmWarn.appendChild(btn);
|
||||
}
|
||||
// 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();
|
||||
fmWarn.hidden = false;
|
||||
}
|
||||
|
||||
// Rename action: persist the current buffer (so body edits aren't
|
||||
|
|
@ -933,26 +857,11 @@
|
|||
async function renameToMatch(newName) {
|
||||
var up = window.app.modules.upload;
|
||||
if (!up || !up.renameNode || !newName) return;
|
||||
// 1. Persist the current buffer first so body edits survive the
|
||||
// rename. Force the write (no If-Match) — the user deliberately
|
||||
// initiated this rename, so we commit their version rather than
|
||||
// interrupting with the conflict-resolution modal (which save()
|
||||
// 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;
|
||||
}
|
||||
// 1. Save first so body/FM edits survive the rename. A failed save
|
||||
// (conflict, ACL) leaves the buffer dirty — abort the rename.
|
||||
if (instance.dirty) {
|
||||
await save();
|
||||
if (currentInstance !== instance || instance.dirty) return;
|
||||
}
|
||||
// 2. Rename on disk.
|
||||
try {
|
||||
|
|
@ -993,21 +902,17 @@
|
|||
var onFmChange = debounce(async function () {
|
||||
if (currentInstance !== instance) return;
|
||||
var body = editor.getMarkdown();
|
||||
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
||||
if (currentInstance !== instance) return;
|
||||
markDirty(h !== instance.hash);
|
||||
renderIdentityCue();
|
||||
}, 250);
|
||||
fmCM.on('change', onFmChange);
|
||||
fmTextarea.addEventListener('input', onFmChange);
|
||||
renderIdentityCue(); // initial state on load (clean after sync-on-open)
|
||||
|
||||
// If sync-on-open corrected the front matter, open the buffer dirty so
|
||||
// a save bakes the filename-derived identity in — and say so, since the
|
||||
// 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';
|
||||
}
|
||||
// a save bakes the filename-derived identity in.
|
||||
if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true);
|
||||
|
||||
// ── Save ───────────────────────────────────────────────────────────
|
||||
// Mark a successful write: adopt the new server ETag (so the next
|
||||
|
|
@ -1071,7 +976,7 @@
|
|||
async function save() {
|
||||
if (currentInstance !== instance) return;
|
||||
if (!instance.dirty || !canSave(node)) return;
|
||||
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
|
||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
||||
try {
|
||||
statusEl.textContent = 'Saving…';
|
||||
var res = await saveContent(node, content, {
|
||||
|
|
|
|||
|
|
@ -43,26 +43,6 @@
|
|||
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) ─────────────────────────────────
|
||||
|
||||
function saveContent(node, content, opts) {
|
||||
|
|
@ -110,10 +90,49 @@
|
|||
// any level surface as warnings — typos like `defaul_tool` are
|
||||
// common and the cascade silently ignores them.
|
||||
|
||||
// The valid keys, types, enums and nesting are NOT hand-listed here any
|
||||
// more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
|
||||
// the same grammar the server serves at /.api/zddc-schema and that drives
|
||||
// completion + hover). One source, no drift. See validateZddcSchema below.
|
||||
var ALLOWED_TOOLS = {
|
||||
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
|
||||
tables: 1, form: 1
|
||||
};
|
||||
|
||||
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) {
|
||||
if (v === null || v === undefined) return 'null';
|
||||
|
|
@ -121,89 +140,170 @@
|
|||
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
|
||||
}
|
||||
|
||||
// The .zddc JSON Schema, baked into the bundle at build time
|
||||
// (window.__ZDDC_SCHEMA__ — the same file the server serves at
|
||||
// /.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:).
|
||||
// Collect schema issues for a parsed .zddc document. Each issue is
|
||||
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
|
||||
// keyPath is used by findLine() to locate the offending source line.
|
||||
function validateZddc(doc) {
|
||||
var schema = getZddcSchema();
|
||||
var issues = [];
|
||||
if (!schema || !schema.properties) return issues; // schema unavailable
|
||||
if (typeOf(doc) === 'null') return issues;
|
||||
if (typeOf(doc) !== 'object') {
|
||||
issues.push({ keyPath: [], severity: 'error',
|
||||
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
|
||||
return issues;
|
||||
}
|
||||
function deref(n) { return (n && n.$ref === '#') ? schema : n; }
|
||||
function typeOk(t, want) {
|
||||
if (Array.isArray(want)) {
|
||||
for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
|
||||
return false;
|
||||
}
|
||||
if (want === 'integer' || want === 'number') return t === 'number';
|
||||
return t === want;
|
||||
}
|
||||
function walk(value, sch, path) {
|
||||
sch = deref(sch);
|
||||
if (!sch) return;
|
||||
var t = typeOf(value);
|
||||
if (t === 'null') return; // empty value mid-edit — don't flag
|
||||
if (sch.type && !typeOk(t, sch.type)) {
|
||||
issues.push({ keyPath: path, severity: 'error',
|
||||
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
|
||||
+ ', got ' + t + '.' });
|
||||
return;
|
||||
}
|
||||
if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) {
|
||||
issues.push({ keyPath: path, severity: 'warning',
|
||||
message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' });
|
||||
}
|
||||
if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) {
|
||||
issues.push({ keyPath: path, severity: 'error',
|
||||
message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
|
||||
}
|
||||
if (t === 'object') {
|
||||
var props = sch.properties || {};
|
||||
for (var k in value) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
|
||||
var kp = path.concat([k]);
|
||||
if (props[k]) { walk(value[k], props[k], kp); continue; }
|
||||
var ap = sch.additionalProperties;
|
||||
if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
|
||||
if (sch.patternProperties) {
|
||||
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.' });
|
||||
}
|
||||
}
|
||||
} else if (t === 'array' && sch.items) {
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
walk(value[i], sch.items, path.concat([String(i)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc, schema, []);
|
||||
walkObject(doc, TOP_KEYS, [], issues);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function walkObject(obj, schema, path, issues) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function checkValue(val, kind, path, issues) {
|
||||
var t = typeOf(val);
|
||||
switch (kind) {
|
||||
case 'string':
|
||||
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
return;
|
||||
case 'bool':
|
||||
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
return;
|
||||
case 'string[]':
|
||||
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
|
||||
return;
|
||||
case 'tools[]':
|
||||
if (t !== 'array' && t !== 'null') {
|
||||
addTypeErr(path, kind, t, issues); return;
|
||||
}
|
||||
if (t === 'array') {
|
||||
for (var i = 0; i < val.length; i++) {
|
||||
if (typeOf(val[i]) !== 'string') {
|
||||
issues.push({ keyPath: path, severity: 'error',
|
||||
message: 'available_tools[' + i + '] must be a string.' });
|
||||
} else if (!ALLOWED_TOOLS[val[i]]) {
|
||||
issues.push({ keyPath: path, severity: 'warning',
|
||||
message: 'Unknown tool "' + val[i]
|
||||
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'tool':
|
||||
if (t === 'null') return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// block-style YAML in practice (no flow style, no anchors), so a
|
||||
// simple indent-aware scan works: for each segment, find a line
|
||||
|
|
@ -365,10 +465,8 @@
|
|||
schemaTag.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
|
||||
});
|
||||
} else if (isYamlFile(node)) {
|
||||
schemaTag.textContent = 'YAML';
|
||||
} else {
|
||||
schemaTag.textContent = (node.ext || 'text').toUpperCase();
|
||||
schemaTag.textContent = 'YAML';
|
||||
}
|
||||
|
||||
var dirtyEl = document.createElement('span');
|
||||
|
|
@ -408,22 +506,16 @@
|
|||
}
|
||||
|
||||
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, {
|
||||
value: text,
|
||||
mode: mode,
|
||||
mode: 'yaml',
|
||||
lineNumbers: true,
|
||||
tabSize: 2,
|
||||
indentUnit: 2,
|
||||
indentWithTabs: false,
|
||||
lineWrapping: false,
|
||||
gutters: yamlMode
|
||||
? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
|
||||
: ['CodeMirror-linenumbers'],
|
||||
lint: yamlMode ? { hasGutters: true } : false,
|
||||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||
lint: { hasGutters: true },
|
||||
// autofocus:false keeps the keyboard caret in the browse
|
||||
// tree pane so arrow-key nav can continue through yaml /
|
||||
// .zddc files without diverting into the editor. User
|
||||
|
|
@ -441,15 +533,8 @@
|
|||
// Stash the node on the editor so the lint helper can decide
|
||||
// whether to apply the .zddc schema layer.
|
||||
editor._zddcNode = node;
|
||||
// Force an initial lint pass now that _zddcNode is set (YAML only).
|
||||
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 });
|
||||
}
|
||||
// Force an initial lint pass now that _zddcNode is set.
|
||||
editor.performLint();
|
||||
currentEditor = editor;
|
||||
currentNodeRef = node;
|
||||
currentDirty = false;
|
||||
|
|
@ -571,7 +656,8 @@
|
|||
}
|
||||
|
||||
function handles(node) {
|
||||
return isCodeFile(node);
|
||||
if (!node || node.isDir || node.isZip) return false;
|
||||
return isYamlFile(node);
|
||||
}
|
||||
|
||||
window.app.modules.yamledit = {
|
||||
|
|
|
|||
325
browse/js/preview-zddc-form.js
Normal file
325
browse/js/preview-zddc-form.js
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
// 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);
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
|
||||
function editorModules() {
|
||||
var m = window.app.modules;
|
||||
return [m.markdown, m.yamledit].filter(Boolean);
|
||||
return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
|
||||
}
|
||||
|
||||
function disposeEditors() {
|
||||
|
|
@ -132,9 +132,6 @@
|
|||
disposeEditors();
|
||||
var container = document.getElementById('previewBody');
|
||||
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
|
||||
|
|
@ -144,41 +141,6 @@
|
|||
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 ────────────────────────────────────────────────────
|
||||
|
||||
// Bumped on every renderInline entry; a render that loses the race
|
||||
|
|
@ -207,10 +169,9 @@
|
|||
var dm = dirtyEditor();
|
||||
if (dm) {
|
||||
var cur = dm.currentNode ? dm.currentNode() : null;
|
||||
if (samePreviewNode(cur, node) && !opts.toggle) {
|
||||
if (samePreviewNode(cur, node)) {
|
||||
// Re-selecting the file we're already editing — don't reload
|
||||
// and clobber the in-progress edits. (A deliberate view toggle
|
||||
// falls through to the discard prompt below.)
|
||||
// and clobber the in-progress edits.
|
||||
return;
|
||||
}
|
||||
if (opts.auto) {
|
||||
|
|
@ -238,32 +199,6 @@
|
|||
|
||||
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.
|
||||
if ((ext === 'md' || ext === 'markdown') &&
|
||||
window.app.modules.markdown &&
|
||||
|
|
@ -276,27 +211,39 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// CodeMirror editor: the general editor for editable text files that
|
||||
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
|
||||
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
|
||||
// Guided dialogs (Manage access, …) are the front door for the common
|
||||
// .zddc tasks; this is the full/raw edit surface.
|
||||
// .zddc form view: a schema-driven form (option fields editable,
|
||||
// structure read-only) is the PRIMARY editor for .zddc files. It hands
|
||||
// off to the raw YAML editor on demand. Other YAML files skip it.
|
||||
var zddcForm = window.app.modules.zddcform;
|
||||
if (zddcForm && zddcForm.handles(node)) {
|
||||
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;
|
||||
if (yamlMod && yamlMod.handles(node)) {
|
||||
try {
|
||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||
} catch (e) {
|
||||
renderError(container, 'Editor failed: ' + (e.message || e));
|
||||
renderError(container, 'YAML render failed: ' + (e.message || e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PDF → iframe (HTML now routes to the editor above).
|
||||
if (ext === 'pdf') {
|
||||
// PDF / HTML → iframe.
|
||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
||||
try {
|
||||
var info = await getBlobUrl(node);
|
||||
if (seq !== renderSeq) return;
|
||||
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"></iframe>';
|
||||
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) + '"' + sandbox + '></iframe>';
|
||||
} catch (e) {
|
||||
renderError(container, e.message || String(e));
|
||||
}
|
||||
|
|
@ -485,25 +432,6 @@
|
|||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
info = await getBlobUrl(node);
|
||||
|
|
|
|||
|
|
@ -1,275 +0,0 @@
|
|||
// 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
|
||||
};
|
||||
})();
|
||||
|
|
@ -684,12 +684,9 @@
|
|||
var p = encodeURIComponent(project);
|
||||
var stages = [
|
||||
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
||||
// working/staging/reviewing get a trailing slash so the user lands
|
||||
// INSIDE the folder (the dir_tool browse listing of parties),
|
||||
// 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/' },
|
||||
{ id: 'stageWorking', href: '/' + p + '/working' },
|
||||
{ id: 'stageStaging', href: '/' + p + '/staging' },
|
||||
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
|
||||
];
|
||||
for (var i = 0; i < stages.length; i++) {
|
||||
var a = document.getElementById(stages[i].id);
|
||||
|
|
|
|||
1
shared/vendor/codemirror-show-hint.min.css
vendored
1
shared/vendor/codemirror-show-hint.min.css
vendored
|
|
@ -1 +0,0 @@
|
|||
.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}
|
||||
1
shared/vendor/codemirror-show-hint.min.js
vendored
1
shared/vendor/codemirror-show-hint.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Archive</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></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>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1876,7 +1876,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Classifier</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></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>
|
||||
</div>
|
||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||
|
|
|
|||
|
|
@ -1619,7 +1619,7 @@ body {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
@ -3864,12 +3864,9 @@ body {
|
|||
var p = encodeURIComponent(project);
|
||||
var stages = [
|
||||
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
||||
// working/staging/reviewing get a trailing slash so the user lands
|
||||
// INSIDE the folder (the dir_tool browse listing of parties),
|
||||
// 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/' },
|
||||
{ id: 'stageWorking', href: '/' + p + '/working' },
|
||||
{ id: 'stageStaging', href: '/' + p + '/staging' },
|
||||
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
|
||||
];
|
||||
for (var i = 0; i < stages.length; i++) {
|
||||
var a = document.getElementById(stages[i].id);
|
||||
|
|
|
|||
|
|
@ -2718,7 +2718,7 @@ dialog.modal--narrow {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title">ZDDC Transmittal</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></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>
|
||||
</div>
|
||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||
<!-- Publish split-button (Transmittal-specific primary action;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||
archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
|
||||
transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
|
||||
classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||
landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||
form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||
tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||
browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||
archive=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
||||
transmittal=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
||||
classifier=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
||||
landing=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
||||
form=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
||||
tables=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
||||
browse=v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199
|
||||
|
|
|
|||
|
|
@ -65,9 +65,6 @@ type Metadata struct {
|
|||
type FrontMatterField struct {
|
||||
Name string `json:"name"`
|
||||
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
|
||||
|
|
@ -79,20 +76,18 @@ type FrontMatterField struct {
|
|||
// user most needs told about.
|
||||
func RecognizedFrontMatter() []FrontMatterField {
|
||||
return []FrontMatterField{
|
||||
// doctype enum tracks the template set (internal/convert/templates/
|
||||
// *.html, sans the _-prefixed partials).
|
||||
{"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
|
||||
{"numbering", "true to number headings (default false)", []string{"true", "false"}},
|
||||
{"title", "mirrors the filename — rename the file to change it", nil},
|
||||
{"tracking_number", "mirrors the filename — rename the file to change it", nil},
|
||||
{"revision", "mirrors the filename — rename the file to change it", nil},
|
||||
{"status", "mirrors the filename — rename the file to change it", nil},
|
||||
{"date", "document date (free text)", nil},
|
||||
{"custom_header", "extra line shown in the document header", nil},
|
||||
{"client", "overrides the .zddc convert: cascade", nil},
|
||||
{"project", "overrides the .zddc convert: cascade", nil},
|
||||
{"project_number", "overrides the .zddc convert: cascade", nil},
|
||||
{"contractor", "overrides the .zddc convert: cascade", nil},
|
||||
{"doctype", "report | letter | specification"},
|
||||
{"numbering", "true to number headings (default false)"},
|
||||
{"title", "mirrors the filename — rename the file to change it"},
|
||||
{"tracking_number", "mirrors the filename — rename the file to change it"},
|
||||
{"revision", "mirrors the filename — rename the file to change it"},
|
||||
{"status", "mirrors the filename — rename the file to change it"},
|
||||
{"date", "document date (free text)"},
|
||||
{"custom_header", "extra line shown in the document header"},
|
||||
{"client", "overrides the .zddc convert: cascade"},
|
||||
{"project", "overrides the .zddc convert: cascade"},
|
||||
{"project_number", "overrides the .zddc convert: cascade"},
|
||||
{"contractor", "overrides the .zddc convert: cascade"},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1648,7 +1648,7 @@ body.is-elevated::after {
|
|||
</svg>
|
||||
<div class="header-title-group">
|
||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
|
|
|
|||
Loading…
Reference in a new issue