Compare commits
13 commits
242d25d55a
...
0d052a20c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d052a20c3 | |||
| ec9c9c72bc | |||
| 49e8ea4b4f | |||
| af16b14a52 | |||
| 1bd73b1512 | |||
| 74ffefa191 | |||
| d44e1b01bf | |||
| a13ce12a75 | |||
| 2c877bd5b7 | |||
| 84f93ba56d | |||
| cfe379d4f9 | |||
| f06ab5542d | |||
| bed6231d6b |
22 changed files with 2371 additions and 1288 deletions
|
|
@ -14,9 +14,21 @@ ensure_exists "$src_html"
|
||||||
css_temp=$(mktemp)
|
css_temp=$(mktemp)
|
||||||
js_raw=$(mktemp)
|
js_raw=$(mktemp)
|
||||||
js_temp=$(mktemp)
|
js_temp=$(mktemp)
|
||||||
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
|
# Generated schema lives under dist/ (gitignored); concat_files resolves paths
|
||||||
|
# relative to $root_dir, so we pass the relative form.
|
||||||
|
schema_rel="dist/.zddc-schema.gen.js"
|
||||||
|
schema_js="$root_dir/$schema_rel"
|
||||||
|
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp" "$schema_js"; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Bake the .zddc JSON Schema into the bundle so the lint + completion + hover
|
||||||
|
# all share ONE grammar (no hand-kept key list to drift from the Go structs)
|
||||||
|
# AND work offline (file://), where /.api/zddc-schema is unreachable. This is
|
||||||
|
# the exact file the server serves at that endpoint.
|
||||||
|
schema_src="$root_dir/../zddc/internal/zddc/zddc.schema.json"
|
||||||
|
ensure_exists "$schema_src"
|
||||||
|
{ printf 'window.__ZDDC_SCHEMA__ = '; cat "$schema_src"; printf ';\n'; } > "$schema_js"
|
||||||
|
|
||||||
# CSS files: shared base first, then browse-specific. Toast UI's CSS
|
# CSS files: shared base first, then browse-specific. Toast UI's CSS
|
||||||
# is bundled because the markdown plugin uses Toast UI inside the
|
# is bundled because the markdown plugin uses Toast UI inside the
|
||||||
# preview pane (.md files render as a full editor).
|
# preview pane (.md files render as a full editor).
|
||||||
|
|
@ -27,6 +39,7 @@ concat_files \
|
||||||
"../shared/logo.css" \
|
"../shared/logo.css" \
|
||||||
"../shared/vendor/toastui-editor.min.css" \
|
"../shared/vendor/toastui-editor.min.css" \
|
||||||
"../shared/vendor/codemirror-yaml.min.css" \
|
"../shared/vendor/codemirror-yaml.min.css" \
|
||||||
|
"../shared/vendor/codemirror-show-hint.min.css" \
|
||||||
"../shared/context-menu.css" \
|
"../shared/context-menu.css" \
|
||||||
"../shared/elevation.css" \
|
"../shared/elevation.css" \
|
||||||
"../shared/profile-menu.css" \
|
"../shared/profile-menu.css" \
|
||||||
|
|
@ -34,6 +47,7 @@ concat_files \
|
||||||
"css/tree.css" \
|
"css/tree.css" \
|
||||||
"css/preview-yaml.css" \
|
"css/preview-yaml.css" \
|
||||||
"css/history.css" \
|
"css/history.css" \
|
||||||
|
"css/manage-access.css" \
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
||||||
# JS files: shared canonical helpers, then browse modules.
|
# JS files: shared canonical helpers, then browse modules.
|
||||||
|
|
@ -48,6 +62,7 @@ concat_files \
|
||||||
"../shared/vendor/utif.min.js" \
|
"../shared/vendor/utif.min.js" \
|
||||||
"../shared/vendor/js-yaml.min.js" \
|
"../shared/vendor/js-yaml.min.js" \
|
||||||
"../shared/vendor/codemirror-yaml.min.js" \
|
"../shared/vendor/codemirror-yaml.min.js" \
|
||||||
|
"../shared/vendor/codemirror-show-hint.min.js" \
|
||||||
"../shared/vendor/toastui-editor-all.min.js" \
|
"../shared/vendor/toastui-editor-all.min.js" \
|
||||||
"../shared/zddc.js" \
|
"../shared/zddc.js" \
|
||||||
"../shared/zddc-filter.js" \
|
"../shared/zddc-filter.js" \
|
||||||
|
|
@ -65,7 +80,10 @@ concat_files \
|
||||||
"../shared/icons.js" \
|
"../shared/icons.js" \
|
||||||
"../shared/zddc-source.js" \
|
"../shared/zddc-source.js" \
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
|
"$schema_rel" \
|
||||||
"js/util.js" \
|
"js/util.js" \
|
||||||
|
"js/yaml-complete.js" \
|
||||||
|
"js/manage-access.js" \
|
||||||
"js/conflict.js" \
|
"js/conflict.js" \
|
||||||
"js/menu-model.js" \
|
"js/menu-model.js" \
|
||||||
"js/loader.js" \
|
"js/loader.js" \
|
||||||
|
|
@ -73,7 +91,6 @@ concat_files \
|
||||||
"js/preview.js" \
|
"js/preview.js" \
|
||||||
"js/preview-markdown.js" \
|
"js/preview-markdown.js" \
|
||||||
"js/preview-yaml.js" \
|
"js/preview-yaml.js" \
|
||||||
"js/preview-zddc-form.js" \
|
|
||||||
"js/hovercard.js" \
|
"js/hovercard.js" \
|
||||||
"js/grid.js" \
|
"js/grid.js" \
|
||||||
"js/upload.js" \
|
"js/upload.js" \
|
||||||
|
|
|
||||||
90
browse/css/manage-access.css
Normal file
90
browse/css/manage-access.css
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/* manage-access.js — guided "who can do what here" dialog. */
|
||||||
|
.ma-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.ma-box {
|
||||||
|
background: var(--bg-elevated, var(--bg, #fff));
|
||||||
|
color: var(--text, #222);
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32);
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
width: min(34rem, 94vw);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.ma-title { margin: 0 0 0.2rem; font-size: 1.15rem; }
|
||||||
|
.ma-sub {
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted, #777);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.ma-list { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
/* who fills the row and shrinks (min-width:0); level + delete size to content
|
||||||
|
so nothing overflows the dialog regardless of email/principal length. */
|
||||||
|
.ma-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) max-content max-content;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ma-who,
|
||||||
|
.ma-level {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font: inherit;
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg, #fff);
|
||||||
|
color: var(--text, #222);
|
||||||
|
}
|
||||||
|
.ma-who { width: 100%; min-width: 0; }
|
||||||
|
.ma-level { width: 8.5rem; cursor: pointer; }
|
||||||
|
.ma-legend {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
.ma-del {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ma-del:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.06)); color: var(--danger, #c14242); }
|
||||||
|
.ma-add {
|
||||||
|
margin: 0.6rem 0 0;
|
||||||
|
border: 1px dashed var(--border, #bbb);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary, #2868c8);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.ma-add:hover { background: var(--bg-secondary, rgba(0, 0, 0, 0.04)); }
|
||||||
|
.ma-inherit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.ma-err { color: var(--danger, #c14242); font-size: 0.82rem; margin: 0.5rem 0 0; min-height: 0; }
|
||||||
|
.ma-err:empty { display: none; }
|
||||||
|
.ma-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,24 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover-doc tooltip (yaml-complete.js) — appended to document.body, so it's
|
||||||
|
styled globally. Carries a key's schema description on hover. */
|
||||||
|
.cm-doc-tip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9700;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: var(--bg-elevated, var(--bg, #fff));
|
||||||
|
color: var(--text, #222);
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/* CodeMirror has to fill the grid cell. The vendored CSS sets
|
/* CodeMirror has to fill the grid cell. The vendored CSS sets
|
||||||
`height: 300px` by default — we override to 100% so it grows with
|
`height: 300px` by default — we override to 100% so it grows with
|
||||||
the preview pane. */
|
the preview pane. */
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ body {
|
||||||
content's natural size (which clips the
|
content's natural size (which clips the
|
||||||
YAML editor's bottom when there are many
|
YAML editor's bottom when there are many
|
||||||
lines, even with the editor's own scroll) */
|
lines, even with the editor's own scroll) */
|
||||||
|
min-width: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -256,10 +257,16 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The body's children fill the available space. Plugins inject
|
/* The body's children fill the available space. Plugins inject
|
||||||
different content here — img, iframe, pre, custom markdown editor. */
|
different content here — img, iframe, pre, custom markdown editor.
|
||||||
|
min-width:0 is load-bearing: a flex item defaults to min-width:auto
|
||||||
|
(its min-content width), so the markdown editor's wide internal
|
||||||
|
min-content would push the whole pane past the viewport's right edge
|
||||||
|
instead of shrinking. With min-width:0 the editor shrinks and its own
|
||||||
|
(and the grid's minmax(0)) scrolling takes over. */
|
||||||
.preview-pane__body > * {
|
.preview-pane__body > * {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-empty {
|
.preview-empty {
|
||||||
|
|
@ -896,39 +903,55 @@ body {
|
||||||
|
|
||||||
/* ── Front matter editor ────────────────────────────────────────────────── */
|
/* ── Front matter editor ────────────────────────────────────────────────── */
|
||||||
.md-fm__body {
|
.md-fm__body {
|
||||||
/* Body cell owns the textarea; sized by the sidebar's grid row. */
|
/* Body cell owns the CodeMirror editor; sized by the sidebar's grid row. */
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.md-fm__textarea {
|
/* Recognised-keys caption under the header (tooltip carries the full list). */
|
||||||
width: 100%;
|
.md-fm__hint {
|
||||||
|
padding: 2px 0.6rem 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: help;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* CodeMirror YAML front-matter editor — fills the body cell + scrolls
|
||||||
|
internally, matching the .zddc previewer's editor styling. */
|
||||||
|
.md-fm__editor,
|
||||||
|
.md-fm__editor .CodeMirror {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
box-sizing: border-box;
|
}
|
||||||
margin: 0;
|
.md-fm__editor .CodeMirror {
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
resize: none;
|
background: transparent;
|
||||||
outline: none;
|
color: var(--text);
|
||||||
white-space: pre;
|
|
||||||
overflow: auto;
|
|
||||||
tab-size: 2;
|
|
||||||
}
|
}
|
||||||
.md-fm__textarea::placeholder {
|
.md-fm__editor .CodeMirror-gutters {
|
||||||
color: var(--text-muted);
|
background: var(--bg-secondary);
|
||||||
font-style: italic;
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.md-fm__textarea:focus {
|
/* Schema-completion dropdown (show-hint add-on) — theme it to the app
|
||||||
background: var(--surface-2, rgba(0, 0, 0, 0.025));
|
palette so it reads in dark mode; show-hint.css ships light-only. */
|
||||||
|
.CodeMirror-hints {
|
||||||
|
z-index: 9600;
|
||||||
|
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: var(--bg-elevated, var(--bg, #fff));
|
||||||
|
border: 1px solid var(--border, #ccc);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
.md-fm__textarea[readonly] {
|
.CodeMirror-hint {
|
||||||
color: var(--text-muted);
|
color: var(--text, #222);
|
||||||
cursor: not-allowed;
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
li.CodeMirror-hint-active {
|
||||||
|
background: var(--primary, #2868c8);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
|
||||||
|
|
|
||||||
243
browse/js/manage-access.js
Normal file
243
browse/js/manage-access.js
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
// manage-access.js — guided "who can do what here" dialog. A task-first
|
||||||
|
// front door for a folder's .zddc acl: the user picks people + friendly access
|
||||||
|
// levels; we read the on-disk .zddc, merge ONLY the access bits (preserving
|
||||||
|
// every other key), and PUT it. No YAML, no schema knowledge required. The raw
|
||||||
|
// editor stays as the "Advanced" escape hatch.
|
||||||
|
//
|
||||||
|
// Friendly level → verbs (r read, w overwrite, c create, d delete, a admin):
|
||||||
|
// View → r Contribute → rc
|
||||||
|
// Edit → rwc Manage → admins: membership (not a verb string)
|
||||||
|
// "Custom" preserves a hand-written verb string we don't recognise.
|
||||||
|
(function (app) {
|
||||||
|
'use strict';
|
||||||
|
if (!app || !app.modules) return;
|
||||||
|
var util = app.modules.util;
|
||||||
|
|
||||||
|
var LEVELS = [
|
||||||
|
{ id: 'view', label: 'View', hint: 'read only', verbs: 'r' },
|
||||||
|
{ id: 'contribute', label: 'Contribute', hint: 'read + add new files', verbs: 'rc' },
|
||||||
|
{ id: 'edit', label: 'Edit', hint: 'read, overwrite, add', verbs: 'rwc' },
|
||||||
|
{ id: 'manage', label: 'Manage', hint: 'full config + (elevated) bypass', verbs: null }
|
||||||
|
];
|
||||||
|
function verbsOfLevel(id) {
|
||||||
|
for (var i = 0; i < LEVELS.length; i++) if (LEVELS[i].id === id) return LEVELS[i].verbs;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function levelOfVerbs(verbs) {
|
||||||
|
verbs = String(verbs || '');
|
||||||
|
if (verbs.indexOf('a') !== -1) return 'manage';
|
||||||
|
if (verbs.indexOf('w') !== -1) return 'edit';
|
||||||
|
if (verbs.indexOf('c') !== -1) return 'contribute';
|
||||||
|
if (verbs.indexOf('r') !== -1) return 'view';
|
||||||
|
return 'custom'; // empty (explicit deny) or non-standard
|
||||||
|
}
|
||||||
|
|
||||||
|
function dirUrl(dir) {
|
||||||
|
var u = dir || '/';
|
||||||
|
if (u.charAt(0) !== '/') u = '/' + u;
|
||||||
|
if (u.charAt(u.length - 1) !== '/') u += '/';
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(dir) {
|
||||||
|
if (!app.state || app.state.source !== 'server') {
|
||||||
|
toast('Access management needs the server.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var base = dirUrl(dir);
|
||||||
|
var zddcUrl = base + '.zddc';
|
||||||
|
var data = {}, etag = null;
|
||||||
|
try {
|
||||||
|
var r = await fetch(zddcUrl, { credentials: 'same-origin' });
|
||||||
|
if (r.ok) {
|
||||||
|
etag = r.headers.get('ETag');
|
||||||
|
var txt = await r.text();
|
||||||
|
try { data = (window.jsyaml && window.jsyaml.load(txt)) || {}; } catch (_e) { data = {}; }
|
||||||
|
} else if (r.status !== 404) {
|
||||||
|
throw new Error('HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast('Could not read access rules: ' + (e.message || e), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
|
||||||
|
|
||||||
|
// Build the principal → level model from admins (Manage) + acl.permissions.
|
||||||
|
var acl = (data.acl && typeof data.acl === 'object') ? data.acl : {};
|
||||||
|
var perms = (acl.permissions && typeof acl.permissions === 'object') ? acl.permissions : {};
|
||||||
|
var admins = Array.isArray(data.admins) ? data.admins : [];
|
||||||
|
var rows = [];
|
||||||
|
var seen = {};
|
||||||
|
admins.forEach(function (p) {
|
||||||
|
if (typeof p === 'string' && !seen[p]) { seen[p] = 1; rows.push({ principal: p, level: 'manage', custom: '' }); }
|
||||||
|
});
|
||||||
|
Object.keys(perms).forEach(function (p) {
|
||||||
|
if (seen[p]) return;
|
||||||
|
seen[p] = 1;
|
||||||
|
var lvl = levelOfVerbs(perms[p]);
|
||||||
|
rows.push({ principal: p, level: lvl, custom: lvl === 'custom' ? String(perms[p] || '') : '' });
|
||||||
|
});
|
||||||
|
var inherit = acl.inherit !== false;
|
||||||
|
|
||||||
|
renderModal(base, zddcUrl, data, etag, rows, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, kind) { if (window.zddc && window.zddc.toast) window.zddc.toast(msg, kind || 'info'); }
|
||||||
|
|
||||||
|
function renderModal(base, zddcUrl, data, etag, rows, inherit) {
|
||||||
|
var overlay = el('div', 'ma-overlay');
|
||||||
|
var box = el('div', 'ma-box');
|
||||||
|
overlay.appendChild(box);
|
||||||
|
|
||||||
|
box.appendChild(el('h2', 'ma-title', 'Manage access'));
|
||||||
|
var sub = el('p', 'ma-sub', 'Who can do what in ' + base + ' — changes here only.');
|
||||||
|
box.appendChild(sub);
|
||||||
|
|
||||||
|
var list = el('div', 'ma-list');
|
||||||
|
box.appendChild(list);
|
||||||
|
|
||||||
|
function addRow(model) {
|
||||||
|
var row = el('div', 'ma-row');
|
||||||
|
var who = el('input', 'ma-who');
|
||||||
|
who.type = 'text';
|
||||||
|
who.value = model.principal || '';
|
||||||
|
who.placeholder = 'email or *@domain or role name';
|
||||||
|
who.addEventListener('input', function () { model.principal = who.value.trim(); });
|
||||||
|
|
||||||
|
var sel = el('select', 'ma-level');
|
||||||
|
LEVELS.forEach(function (lv) {
|
||||||
|
var o = el('option', null, lv.label);
|
||||||
|
o.value = lv.id;
|
||||||
|
o.title = lv.hint;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
if (model.level === 'custom') {
|
||||||
|
var o2 = el('option', null, 'Custom');
|
||||||
|
o2.value = 'custom';
|
||||||
|
o2.title = 'verbs: ' + model.custom;
|
||||||
|
sel.appendChild(o2);
|
||||||
|
}
|
||||||
|
sel.value = model.level;
|
||||||
|
sel.addEventListener('change', function () { model.level = sel.value; });
|
||||||
|
|
||||||
|
var del = el('button', 'ma-del', '✕');
|
||||||
|
del.type = 'button';
|
||||||
|
del.title = 'Remove';
|
||||||
|
del.addEventListener('click', function () { row.remove(); model._removed = true; });
|
||||||
|
|
||||||
|
row.appendChild(who);
|
||||||
|
row.appendChild(sel);
|
||||||
|
row.appendChild(del);
|
||||||
|
list.appendChild(row);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
rows.forEach(addRow);
|
||||||
|
|
||||||
|
var addBtn = el('button', 'ma-add', '+ Add person or group');
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.addEventListener('click', function () {
|
||||||
|
var m = { principal: '', level: 'view', custom: '' };
|
||||||
|
rows.push(m);
|
||||||
|
addRow(m);
|
||||||
|
});
|
||||||
|
box.appendChild(addBtn);
|
||||||
|
|
||||||
|
var legend = el('p', 'ma-legend',
|
||||||
|
'View = read · Contribute = add new files · Edit = overwrite + add · Manage = admin');
|
||||||
|
box.appendChild(legend);
|
||||||
|
|
||||||
|
// Inherit / make-private.
|
||||||
|
var inhWrap = el('label', 'ma-inherit');
|
||||||
|
var inhBox = el('input');
|
||||||
|
inhBox.type = 'checkbox';
|
||||||
|
inhBox.checked = inherit;
|
||||||
|
inhWrap.appendChild(inhBox);
|
||||||
|
inhWrap.appendChild(el('span', null, ' Inherit access from parent folders'));
|
||||||
|
box.appendChild(inhWrap);
|
||||||
|
|
||||||
|
var err = el('p', 'ma-err');
|
||||||
|
box.appendChild(err);
|
||||||
|
|
||||||
|
var actions = el('div', 'ma-actions');
|
||||||
|
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel');
|
||||||
|
cancel.type = 'button';
|
||||||
|
var save = el('button', 'btn btn-sm btn-primary', 'Save');
|
||||||
|
save.type = 'button';
|
||||||
|
actions.appendChild(cancel);
|
||||||
|
actions.appendChild(save);
|
||||||
|
box.appendChild(actions);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
}
|
||||||
|
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); close(); } }
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(); });
|
||||||
|
cancel.addEventListener('click', close);
|
||||||
|
|
||||||
|
save.addEventListener('click', function () {
|
||||||
|
err.textContent = '';
|
||||||
|
// Rebuild perms + admins from the live rows (skip removed/blank).
|
||||||
|
var perms = {}, admins = [], bad = false;
|
||||||
|
rows.forEach(function (m) {
|
||||||
|
if (m._removed) return;
|
||||||
|
var p = (m.principal || '').trim();
|
||||||
|
if (!p) return;
|
||||||
|
if (m.level === 'manage') {
|
||||||
|
if (admins.indexOf(p) === -1) admins.push(p);
|
||||||
|
} else if (m.level === 'custom') {
|
||||||
|
perms[p] = m.custom; // preserve the hand-written string
|
||||||
|
} else {
|
||||||
|
perms[p] = verbsOfLevel(m.level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge into the existing doc, preserving every unmanaged key.
|
||||||
|
var out = {};
|
||||||
|
Object.keys(data).forEach(function (k) { out[k] = data[k]; });
|
||||||
|
var acl = (out.acl && typeof out.acl === 'object') ? Object.assign({}, out.acl) : {};
|
||||||
|
if (Object.keys(perms).length) acl.permissions = perms; else delete acl.permissions;
|
||||||
|
if (!inhBox.checked) acl.inherit = false; else delete acl.inherit;
|
||||||
|
if (Object.keys(acl).length) out.acl = acl; else delete out.acl;
|
||||||
|
if (admins.length) out.admins = admins; else delete out.admins;
|
||||||
|
|
||||||
|
var content;
|
||||||
|
try { content = window.jsyaml.dump(out); }
|
||||||
|
catch (e2) { err.textContent = 'Could not serialize: ' + (e2.message || e2); return; }
|
||||||
|
|
||||||
|
save.disabled = true;
|
||||||
|
save.textContent = 'Saving…';
|
||||||
|
var node = { url: zddcUrl, name: '.zddc', ext: '' };
|
||||||
|
util.saveFile(node, content, 'application/yaml; charset=utf-8', etag ? { etag: etag } : {})
|
||||||
|
.then(function () {
|
||||||
|
toast('Access updated for ' + base, 'success');
|
||||||
|
var ev = app.modules.events;
|
||||||
|
if (ev && ev.refreshListing) { try { ev.refreshListing(); } catch (_e) { /* ignore */ } }
|
||||||
|
close();
|
||||||
|
})
|
||||||
|
.catch(function (e3) {
|
||||||
|
save.disabled = false;
|
||||||
|
save.textContent = 'Save';
|
||||||
|
if (e3 && e3.status === 412) {
|
||||||
|
err.textContent = 'These rules changed on the server since you opened this. Close and reopen to get the latest, then redo your change.';
|
||||||
|
} else {
|
||||||
|
err.textContent = 'Save failed: ' + (e3 && e3.message ? e3.message : e3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
var first = box.querySelector('.ma-who');
|
||||||
|
if (first) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.modules.manageAccess = { open: open };
|
||||||
|
})(window.app);
|
||||||
|
|
@ -352,13 +352,30 @@
|
||||||
|
|
||||||
// ── admin / sub-admin tier ──
|
// ── admin / sub-admin tier ──
|
||||||
{
|
{
|
||||||
// HIDDEN unless the user can actually edit access rules here
|
// Guided "who can do what here" dialog — the front door for access.
|
||||||
// (admin verb 'a', or subtree/site admin) — not shown greyed.
|
// HIDDEN unless the user can administer here (admin verb 'a', or
|
||||||
|
// subtree/site admin).
|
||||||
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
id: 'manage-access', group: 'admin', surfaces: ['row', 'pane'],
|
||||||
label: 'Edit access rules…',
|
label: 'Manage access…',
|
||||||
appliesTo: function (ctx) {
|
appliesTo: function (ctx) {
|
||||||
if (!isServer()) return false; // server-only tier
|
if (!isServer()) return false; // server-only tier
|
||||||
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||||
|
return typeOk && manageAccessGate(ctx).enabled
|
||||||
|
&& !!(window.app.modules.manageAccess);
|
||||||
|
},
|
||||||
|
action: function (ctx) {
|
||||||
|
var m = window.app.modules.manageAccess;
|
||||||
|
if (m) m.open(ctx.dir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The raw-YAML escape hatch — same authority gate, demoted to
|
||||||
|
// "advanced" since the guided dialog covers the common case.
|
||||||
|
id: 'edit-zddc-raw', group: 'admin', surfaces: ['row', 'pane'],
|
||||||
|
label: 'Edit raw policy (.zddc)…',
|
||||||
|
appliesTo: function (ctx) {
|
||||||
|
if (!isServer()) return false;
|
||||||
|
var typeOk = ctx.surface === 'pane' || appliesToFolderLike(ctx.node);
|
||||||
return typeOk && manageAccessGate(ctx).enabled;
|
return typeOk && manageAccessGate(ctx).enabled;
|
||||||
},
|
},
|
||||||
action: function (ctx) { openZddcEditor(ctx.dir); }
|
action: function (ctx) { openZddcEditor(ctx.dir); }
|
||||||
|
|
|
||||||
|
|
@ -82,30 +82,38 @@
|
||||||
// empty / unavailable. The promise dedupes concurrent fetches.
|
// empty / unavailable. The promise dedupes concurrent fetches.
|
||||||
var fmPlaceholder = null;
|
var fmPlaceholder = null;
|
||||||
var fmPlaceholderPromise = null;
|
var fmPlaceholderPromise = null;
|
||||||
|
// Recognised fields ([{name, hint, values}]) from the same /.api/frontmatter
|
||||||
|
// fetch — drives schema completion (keys + enum values). null = not loaded.
|
||||||
|
var fmFields = null;
|
||||||
|
|
||||||
// applyFrontMatterPlaceholder sets the textarea placeholder to the server's
|
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the
|
||||||
// recognised-field hint, in server mode only. Async + best-effort: a failed
|
// server's recognised front-matter fields, in server mode only. Async +
|
||||||
// fetch leaves the pane blank (no placeholder), never an error.
|
// best-effort: a failed fetch leaves the caption hidden, never an error.
|
||||||
function applyFrontMatterPlaceholder(textarea) {
|
// (Replaces the old textarea placeholder — CodeMirror 5 has no built-in
|
||||||
|
// placeholder without an unvendored add-on. Arbitrary keys stay free.)
|
||||||
|
function applyFrontMatterHint(el) {
|
||||||
var st = window.app && window.app.state;
|
var st = window.app && window.app.state;
|
||||||
if (!st || st.source !== 'server') return;
|
if (!st || st.source !== 'server') return;
|
||||||
if (fmPlaceholder !== null) {
|
function paint() {
|
||||||
textarea.placeholder = fmPlaceholder;
|
if (!el.isConnected) return; // user switched files before resolve
|
||||||
return;
|
if (!fmPlaceholder) { el.style.display = 'none'; return; }
|
||||||
|
el.textContent = 'ⓘ Recognised front-matter keys (hover) — any other key is allowed';
|
||||||
|
el.title = fmPlaceholder;
|
||||||
|
el.style.display = '';
|
||||||
}
|
}
|
||||||
|
if (fmPlaceholder !== null) { paint(); return; }
|
||||||
if (!fmPlaceholderPromise) {
|
if (!fmPlaceholderPromise) {
|
||||||
fmPlaceholderPromise = fetch('/.api/frontmatter', {
|
fmPlaceholderPromise = fetch('/.api/frontmatter', {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
credentials: 'same-origin'
|
credentials: 'same-origin'
|
||||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||||
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; })
|
.then(function (j) {
|
||||||
.catch(function () { fmPlaceholder = ''; });
|
fmPlaceholder = (j && j.placeholder) || '';
|
||||||
|
fmFields = (j && j.fields) || [];
|
||||||
|
})
|
||||||
|
.catch(function () { fmPlaceholder = ''; fmFields = []; });
|
||||||
}
|
}
|
||||||
fmPlaceholderPromise.then(function () {
|
fmPlaceholderPromise.then(paint);
|
||||||
// Only apply if this textarea is still in the DOM (user may have
|
|
||||||
// switched files before the fetch resolved).
|
|
||||||
if (textarea.isConnected) textarea.placeholder = fmPlaceholder;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
||||||
|
|
@ -427,22 +435,19 @@
|
||||||
fmHeader.textContent = 'YAML front matter';
|
fmHeader.textContent = 'YAML front matter';
|
||||||
var fmBody = document.createElement('div');
|
var fmBody = document.createElement('div');
|
||||||
fmBody.className = 'md-side__body md-fm__body';
|
fmBody.className = 'md-side__body md-fm__body';
|
||||||
var fmTextarea = document.createElement('textarea');
|
// CodeMirror YAML editor host — mounted with the front-matter value
|
||||||
fmTextarea.className = 'md-fm__textarea';
|
// once it's computed (sync-on-open) below. Same editor family as the
|
||||||
fmTextarea.spellcheck = false;
|
// .zddc previewer: syntax highlighting, line numbers, lint gutter.
|
||||||
fmTextarea.autocapitalize = 'off';
|
var fmEditorHost = document.createElement('div');
|
||||||
fmTextarea.autocomplete = 'off';
|
fmEditorHost.className = 'md-fm__editor';
|
||||||
// Placeholder: in server mode, hint the recognised front-matter keys
|
fmBody.appendChild(fmEditorHost);
|
||||||
// (doctype, numbering, …) as greyed text so authors can discover them.
|
// Recognised-keys hint (server mode): a greyed caption under the header
|
||||||
// It's placeholder-only — inserts nothing, vanishes on the first
|
// whose tooltip carries the full "key: # hint" template from
|
||||||
// keystroke — so arbitrary keys stay free and a file with no front
|
// /.api/frontmatter. Replaces the old textarea placeholder.
|
||||||
// matter still renders as a genuinely empty pane. The text is fetched
|
var fmHint = document.createElement('div');
|
||||||
// from the server (/.api/frontmatter), the single source of truth, so
|
fmHint.className = 'md-fm__hint';
|
||||||
// it never drifts from what the converter honours. file:// mode shows
|
fmHint.style.display = 'none';
|
||||||
// no placeholder (conversion is server-only).
|
applyFrontMatterHint(fmHint);
|
||||||
fmTextarea.placeholder = '';
|
|
||||||
applyFrontMatterPlaceholder(fmTextarea);
|
|
||||||
fmBody.appendChild(fmTextarea);
|
|
||||||
// Rename cue: shown when the author edits an identity field
|
// Rename cue: shown when the author edits an identity field
|
||||||
// (tracking_number / revision / status / title) away from the
|
// (tracking_number / revision / status / title) away from the
|
||||||
// filename. The filename owns identity, so the cue offers an explicit
|
// filename. The filename owns identity, so the cue offers an explicit
|
||||||
|
|
@ -450,12 +455,16 @@
|
||||||
// discarding the value. Populated by renderIdentityCue().
|
// discarding the value. Populated by renderIdentityCue().
|
||||||
var fmWarn = document.createElement('div');
|
var fmWarn = document.createElement('div');
|
||||||
fmWarn.className = 'md-fm__warn';
|
fmWarn.className = 'md-fm__warn';
|
||||||
fmWarn.hidden = true;
|
// Visibility is controlled via style.display (toggled in
|
||||||
|
// renderIdentityCue), NOT the `hidden` attribute: an inline
|
||||||
|
// display:flex outranks [hidden]{display:none}, which would leave an
|
||||||
|
// empty box on screen whenever the cue has nothing to say.
|
||||||
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
|
fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid '
|
||||||
+ '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:'
|
+ '#fcd34d;border-radius:4px;padding:6px 8px;margin:0 0 4px;font-size:'
|
||||||
+ '0.78rem;line-height:1.5;display:flex;flex-wrap:wrap;align-items:'
|
+ '0.78rem;line-height:1.5;flex-wrap:wrap;align-items:center;gap:6px;'
|
||||||
+ 'center;gap:6px;';
|
+ 'display:none;';
|
||||||
fmSection.appendChild(fmHeader);
|
fmSection.appendChild(fmHeader);
|
||||||
|
fmSection.appendChild(fmHint);
|
||||||
fmSection.appendChild(fmWarn);
|
fmSection.appendChild(fmWarn);
|
||||||
fmSection.appendChild(fmBody);
|
fmSection.appendChild(fmBody);
|
||||||
sidebar.appendChild(fmSection);
|
sidebar.appendChild(fmSection);
|
||||||
|
|
@ -600,21 +609,59 @@
|
||||||
// even if we tweak whitespace in the YAML lines.
|
// even if we tweak whitespace in the YAML lines.
|
||||||
var initialParsed = parseFrontMatter(text);
|
var initialParsed = parseFrontMatter(text);
|
||||||
var bodyText = initialParsed.body;
|
var bodyText = initialParsed.body;
|
||||||
// On open, mirror the filename-derived identity into the front matter
|
// On open, RECONCILE existing front-matter identity keys with the
|
||||||
// (the filename is the single source of truth; this keeps the values
|
// filename (the single source of truth) — but never ADD them. A blank
|
||||||
// baked in for the converter). No-op for non-ZDDC filenames. The dirty
|
// or new file opens blank (we don't inject a title etc.); a file whose
|
||||||
|
// author already wrote a now-stale title/revision/… gets corrected.
|
||||||
|
// The converter derives identity from the filename regardless, so
|
||||||
|
// there's nothing to "bake in" for an empty front matter. The dirty
|
||||||
// baseline stays the ON-DISK state, so a correction opens the buffer
|
// baseline stays the ON-DISK state, so a correction opens the buffer
|
||||||
// dirty and a save persists it.
|
// dirty and a save persists it.
|
||||||
var onDiskFM = stringifyFrontMatter(initialParsed.data);
|
var onDiskFM = stringifyFrontMatter(initialParsed.data);
|
||||||
var fid = filenameIdentity(node.name);
|
var fid = filenameIdentity(node.name);
|
||||||
if (fid) {
|
if (fid) {
|
||||||
for (var ik in fid) {
|
for (var ik in fid) {
|
||||||
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
|
if (Object.prototype.hasOwnProperty.call(fid, ik)
|
||||||
|
&& Object.prototype.hasOwnProperty.call(initialParsed.data, ik)) {
|
||||||
|
initialParsed.data[ik] = fid[ik];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
var syncedFM = stringifyFrontMatter(initialParsed.data);
|
||||||
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
|
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
|
||||||
var writableMode = canSave(node);
|
var writableMode = canSave(node);
|
||||||
|
|
||||||
|
// Front-matter YAML editor — CodeMirror, the same editor family as the
|
||||||
|
// .zddc previewer (syntax highlighting, line numbers, shared js-yaml
|
||||||
|
// lint gutter). Replaces the old <textarea>.
|
||||||
|
var fmCM = window.CodeMirror(fmEditorHost, {
|
||||||
|
value: syncedFM,
|
||||||
|
mode: 'yaml',
|
||||||
|
lineNumbers: true,
|
||||||
|
tabSize: 2,
|
||||||
|
indentUnit: 2,
|
||||||
|
indentWithTabs: false,
|
||||||
|
lineWrapping: true,
|
||||||
|
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
||||||
|
lint: { hasGutters: true },
|
||||||
|
autofocus: false,
|
||||||
|
readOnly: !writableMode
|
||||||
|
});
|
||||||
|
// The yaml lint helper (registered by the .zddc previewer) checks this
|
||||||
|
// to decide the schema layer; a .md node → plain js-yaml parse lint.
|
||||||
|
fmCM._zddcNode = node;
|
||||||
|
// Schema completion + hover docs via the shared helper. The front
|
||||||
|
// matter is flat; the identity keys are excluded from suggestions
|
||||||
|
// (filename-driven — see renderIdentityCue).
|
||||||
|
var yc = window.app.modules.yamlComplete;
|
||||||
|
if (yc) {
|
||||||
|
yc.attach(fmCM, yc.flatProvider(function () { return fmFields; }, {
|
||||||
|
exclude: IDENTITY_FIELDS.map(function (f) { return f.fm; })
|
||||||
|
}), { readOnly: !writableMode });
|
||||||
|
}
|
||||||
|
// CodeMirror mis-measures when mounted before its pane is laid out;
|
||||||
|
// refresh on the next frame so the gutters + scroll size correctly.
|
||||||
|
requestAnimationFrame(function () { fmCM.refresh(); });
|
||||||
// autofocus:false keeps the keyboard caret in the tree pane —
|
// autofocus:false keeps the keyboard caret in the tree pane —
|
||||||
// arrow-key nav can continue through markdown files without
|
// arrow-key nav can continue through markdown files without
|
||||||
// diverting into the editor. The user clicks into the editor
|
// diverting into the editor. The user clicks into the editor
|
||||||
|
|
@ -663,7 +710,7 @@
|
||||||
node: node,
|
node: node,
|
||||||
hash: initialHash,
|
hash: initialHash,
|
||||||
tocEl: tocBody,
|
tocEl: tocBody,
|
||||||
fmEl: fmTextarea,
|
fmEl: fmCM,
|
||||||
ac: ac,
|
ac: ac,
|
||||||
// Server version token captured at load — sent as If-Match on
|
// Server version token captured at load — sent as If-Match on
|
||||||
// save and refreshed from each successful PUT's response ETag.
|
// save and refreshed from each successful PUT's response ETag.
|
||||||
|
|
@ -675,7 +722,7 @@
|
||||||
if (!writableMode) {
|
if (!writableMode) {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
saveBtn.title = 'Save not available — read-only source.';
|
saveBtn.title = 'Save not available — read-only source.';
|
||||||
fmTextarea.readOnly = true;
|
// fmCM was created with readOnly:!writableMode — nothing more here.
|
||||||
}
|
}
|
||||||
|
|
||||||
renderToc(tocBody, bodyText, editor);
|
renderToc(tocBody, bodyText, editor);
|
||||||
|
|
@ -785,7 +832,7 @@
|
||||||
var onChange = debounce(async function () {
|
var onChange = debounce(async function () {
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
var body = editor.getMarkdown();
|
var body = editor.getMarkdown();
|
||||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
markDirty(h !== instance.hash);
|
markDirty(h !== instance.hash);
|
||||||
renderToc(tocBody, body, editor);
|
renderToc(tocBody, body, editor);
|
||||||
|
|
@ -822,8 +869,8 @@
|
||||||
function renderIdentityCue() {
|
function renderIdentityCue() {
|
||||||
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
|
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
|
||||||
var fid = filenameIdentity(node.name);
|
var fid = filenameIdentity(node.name);
|
||||||
if (!fid || !canSave(node)) { fmWarn.hidden = true; return; }
|
if (!fid || !canSave(node)) { fmWarn.style.display = 'none'; return; }
|
||||||
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
|
||||||
var edits = [];
|
var edits = [];
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
IDENTITY_FIELDS.forEach(function (f) {
|
||||||
if (!(f.fm in data)) return;
|
if (!(f.fm in data)) return;
|
||||||
|
|
@ -831,7 +878,7 @@
|
||||||
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
|
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
|
||||||
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
|
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
|
||||||
});
|
});
|
||||||
if (!edits.length) { fmWarn.hidden = true; return; }
|
if (!edits.length) { fmWarn.style.display = 'none'; return; }
|
||||||
var msg = document.createElement('span');
|
var msg = document.createElement('span');
|
||||||
msg.textContent = '✎ Identity comes from the filename. You changed '
|
msg.textContent = '✎ Identity comes from the filename. You changed '
|
||||||
+ edits.join(', ') + '. ';
|
+ edits.join(', ') + '. ';
|
||||||
|
|
@ -846,7 +893,36 @@
|
||||||
btn.addEventListener('click', function () { renameToMatch(newName); });
|
btn.addEventListener('click', function () { renameToMatch(newName); });
|
||||||
fmWarn.appendChild(btn);
|
fmWarn.appendChild(btn);
|
||||||
}
|
}
|
||||||
fmWarn.hidden = false;
|
// Cancel: discard the identity edits, restoring the filename values.
|
||||||
|
var cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.className = 'btn btn-sm md-fm__revert';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.title = 'Discard these identity edits and restore the filename values.';
|
||||||
|
cancelBtn.addEventListener('click', function () { revertIdentityEdits(); });
|
||||||
|
fmWarn.appendChild(cancelBtn);
|
||||||
|
fmWarn.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert the identity fields in the front matter to the filename-
|
||||||
|
// derived values (undo a manual identity edit), leaving the rest of the
|
||||||
|
// front matter + body untouched. Recomputes dirty + the cue after.
|
||||||
|
async function revertIdentityEdits() {
|
||||||
|
var fid = filenameIdentity(node.name);
|
||||||
|
if (!fid) return;
|
||||||
|
var data = parseFrontMatter('---\n' + fmCM.getValue() + '\n---\n').data || {};
|
||||||
|
for (var k in fid) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(fid, k)
|
||||||
|
&& Object.prototype.hasOwnProperty.call(data, k)) {
|
||||||
|
data[k] = fid[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmCM.setValue(stringifyFrontMatter(data));
|
||||||
|
var body = editor.getMarkdown();
|
||||||
|
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
||||||
|
if (currentInstance !== instance) return;
|
||||||
|
markDirty(h !== instance.hash);
|
||||||
|
renderIdentityCue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename action: persist the current buffer (so body edits aren't
|
// Rename action: persist the current buffer (so body edits aren't
|
||||||
|
|
@ -857,11 +933,26 @@
|
||||||
async function renameToMatch(newName) {
|
async function renameToMatch(newName) {
|
||||||
var up = window.app.modules.upload;
|
var up = window.app.modules.upload;
|
||||||
if (!up || !up.renameNode || !newName) return;
|
if (!up || !up.renameNode || !newName) return;
|
||||||
// 1. Save first so body/FM edits survive the rename. A failed save
|
// 1. Persist the current buffer first so body edits survive the
|
||||||
// (conflict, ACL) leaves the buffer dirty — abort the rename.
|
// rename. Force the write (no If-Match) — the user deliberately
|
||||||
if (instance.dirty) {
|
// initiated this rename, so we commit their version rather than
|
||||||
await save();
|
// interrupting with the conflict-resolution modal (which save()
|
||||||
if (currentInstance !== instance || instance.dirty) return;
|
// raises on a 412). The identity edit that triggered the rename
|
||||||
|
// is consumed by the new filename, so there's nothing to merge.
|
||||||
|
if (instance.dirty && canSave(node)) {
|
||||||
|
try {
|
||||||
|
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
|
||||||
|
statusEl.textContent = 'Saving…';
|
||||||
|
var res = await saveContent(node, content, { force: true });
|
||||||
|
await markSaved(content, res);
|
||||||
|
if (currentInstance !== instance) return;
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.textContent = '';
|
||||||
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
window.zddc.toast('Save failed: ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 2. Rename on disk.
|
// 2. Rename on disk.
|
||||||
try {
|
try {
|
||||||
|
|
@ -902,17 +993,21 @@
|
||||||
var onFmChange = debounce(async function () {
|
var onFmChange = debounce(async function () {
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
var body = editor.getMarkdown();
|
var body = editor.getMarkdown();
|
||||||
var h = await hashContent(assembleContent(fmTextarea.value, body));
|
var h = await hashContent(assembleContent(fmCM.getValue(), body));
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
markDirty(h !== instance.hash);
|
markDirty(h !== instance.hash);
|
||||||
renderIdentityCue();
|
renderIdentityCue();
|
||||||
}, 250);
|
}, 250);
|
||||||
fmTextarea.addEventListener('input', onFmChange);
|
fmCM.on('change', onFmChange);
|
||||||
renderIdentityCue(); // initial state on load (clean after sync-on-open)
|
renderIdentityCue(); // initial state on load (clean after sync-on-open)
|
||||||
|
|
||||||
// If sync-on-open corrected the front matter, open the buffer dirty so
|
// If sync-on-open corrected the front matter, open the buffer dirty so
|
||||||
// a save bakes the filename-derived identity in.
|
// a save bakes the filename-derived identity in — and say so, since the
|
||||||
if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true);
|
// change is otherwise silent (the values just match the filename now).
|
||||||
|
if (writableMode && fmCM.getValue() !== onDiskFM) {
|
||||||
|
markDirty(true);
|
||||||
|
statusEl.textContent = 'Front matter synced to filename — review and save';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Save ───────────────────────────────────────────────────────────
|
// ── Save ───────────────────────────────────────────────────────────
|
||||||
// Mark a successful write: adopt the new server ETag (so the next
|
// Mark a successful write: adopt the new server ETag (so the next
|
||||||
|
|
@ -976,7 +1071,7 @@
|
||||||
async function save() {
|
async function save() {
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
if (!instance.dirty || !canSave(node)) return;
|
if (!instance.dirty || !canSave(node)) return;
|
||||||
var content = assembleContent(fmTextarea.value, editor.getMarkdown());
|
var content = assembleContent(fmCM.getValue(), editor.getMarkdown());
|
||||||
try {
|
try {
|
||||||
statusEl.textContent = 'Saving…';
|
statusEl.textContent = 'Saving…';
|
||||||
var res = await saveContent(node, content, {
|
var res = await saveContent(node, content, {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,26 @@
|
||||||
return ext === 'yaml' || ext === 'yml';
|
return ext === 'yaml' || ext === 'yml';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CodeMirror editor is the general editor for editable TEXT files that
|
||||||
|
// aren't markdown (markdown has its own editor). Syntax highlighting is
|
||||||
|
// YAML-only — that's the one CM mode in the vendored bundle — so every
|
||||||
|
// other type opens as a plaintext editor (still line numbers, find,
|
||||||
|
// selection, save). svg/json-as-image etc. stay with their preview
|
||||||
|
// renderers; this set is deliberately the "edit the source" types.
|
||||||
|
var CODE_EXTS = {
|
||||||
|
yaml: 1, yml: 1, txt: 1, text: 1, csv: 1, tsv: 1, tab: 1,
|
||||||
|
json: 1, xml: 1, html: 1, htm: 1, css: 1, js: 1, mjs: 1,
|
||||||
|
log: 1, ini: 1, conf: 1, cfg: 1, toml: 1, env: 1,
|
||||||
|
sh: 1, bash: 1, properties: 1
|
||||||
|
};
|
||||||
|
function isCodeFile(node) {
|
||||||
|
if (!node || node.isDir || node.isZip) return false;
|
||||||
|
if (isYamlFile(node)) return true;
|
||||||
|
return !!CODE_EXTS[(node.ext || '').toLowerCase()];
|
||||||
|
}
|
||||||
|
// CodeMirror mode by extension — only yaml is vendored; others plaintext.
|
||||||
|
function codeMode(node) { return isYamlFile(node) ? 'yaml' : null; }
|
||||||
|
|
||||||
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
|
||||||
|
|
||||||
function saveContent(node, content, opts) {
|
function saveContent(node, content, opts) {
|
||||||
|
|
@ -90,49 +110,10 @@
|
||||||
// any level surface as warnings — typos like `defaul_tool` are
|
// any level surface as warnings — typos like `defaul_tool` are
|
||||||
// common and the cascade silently ignores them.
|
// common and the cascade silently ignores them.
|
||||||
|
|
||||||
var ALLOWED_TOOLS = {
|
// The valid keys, types, enums and nesting are NOT hand-listed here any
|
||||||
archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1,
|
// more — they come from the baked .zddc JSON Schema (window.__ZDDC_SCHEMA__,
|
||||||
tables: 1, form: 1
|
// the same grammar the server serves at /.api/zddc-schema and that drives
|
||||||
};
|
// completion + hover). One source, no drift. See validateZddcSchema below.
|
||||||
|
|
||||||
var TOP_KEYS = {
|
|
||||||
title: 'string',
|
|
||||||
acl: 'acl',
|
|
||||||
admins: 'string[]',
|
|
||||||
roles: 'rolemap',
|
|
||||||
available_tools: 'tools[]',
|
|
||||||
default_tool: 'tool',
|
|
||||||
dir_tool: 'tool',
|
|
||||||
auto_own: 'bool',
|
|
||||||
auto_own_fenced: 'bool',
|
|
||||||
virtual: 'bool',
|
|
||||||
drop_target: 'bool',
|
|
||||||
worm: 'string[]',
|
|
||||||
paths: 'pathmap',
|
|
||||||
display: 'stringmap',
|
|
||||||
tables: 'stringmap',
|
|
||||||
views: 'viewmap',
|
|
||||||
convert: 'convert',
|
|
||||||
created_by: 'string',
|
|
||||||
inherit: 'bool',
|
|
||||||
// Keys the Go decoder (zddc/internal/zddc/file.go) accepts that the
|
|
||||||
// lint was missing — flagged valid configs as "unknown key".
|
|
||||||
party_source: 'string',
|
|
||||||
history: 'bool',
|
|
||||||
history_globs: 'string[]',
|
|
||||||
records: 'object',
|
|
||||||
auto_own_roles: 'string[]',
|
|
||||||
received_path: 'string',
|
|
||||||
planned_response_date: 'string',
|
|
||||||
planned_review_date: 'string',
|
|
||||||
field_codes: 'object'
|
|
||||||
};
|
|
||||||
|
|
||||||
var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap',
|
|
||||||
allow: 'string[]', deny: 'string[]' };
|
|
||||||
var ROLE_KEYS = { members: 'string[]', reset: 'bool' };
|
|
||||||
var CONVERT_KEYS = { client: 'string', project: 'string',
|
|
||||||
contractor: 'string', project_number: 'string' };
|
|
||||||
|
|
||||||
function typeOf(v) {
|
function typeOf(v) {
|
||||||
if (v === null || v === undefined) return 'null';
|
if (v === null || v === undefined) return 'null';
|
||||||
|
|
@ -140,168 +121,87 @@
|
||||||
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
|
return typeof v; // 'string' | 'number' | 'boolean' | 'object'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect schema issues for a parsed .zddc document. Each issue is
|
// The .zddc JSON Schema, baked into the bundle at build time
|
||||||
// { keyPath: string[], message: string, severity: 'error' | 'warning' }.
|
// (window.__ZDDC_SCHEMA__ — the same file the server serves at
|
||||||
// keyPath is used by findLine() to locate the offending source line.
|
// /.api/zddc-schema). Single source for lint, completion and hover; works
|
||||||
|
// offline. Synchronous, so the lint helper can use it directly.
|
||||||
|
function getZddcSchema() {
|
||||||
|
return (window.__ZDDC_SCHEMA__ && window.__ZDDC_SCHEMA__.properties)
|
||||||
|
? window.__ZDDC_SCHEMA__ : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a parsed .zddc document against the JSON Schema, producing
|
||||||
|
// { keyPath, severity, message } issues (mapped to source lines by
|
||||||
|
// findLine). Covers the draft-2020-12 subset .zddc uses: type, enum,
|
||||||
|
// properties, additionalProperties (false | schema), patternProperties,
|
||||||
|
// items, pattern, and the recursive $ref:"#" (paths:).
|
||||||
function validateZddc(doc) {
|
function validateZddc(doc) {
|
||||||
|
var schema = getZddcSchema();
|
||||||
var issues = [];
|
var issues = [];
|
||||||
|
if (!schema || !schema.properties) return issues; // schema unavailable
|
||||||
if (typeOf(doc) === 'null') return issues;
|
if (typeOf(doc) === 'null') return issues;
|
||||||
if (typeOf(doc) !== 'object') {
|
if (typeOf(doc) !== 'object') {
|
||||||
issues.push({ keyPath: [], severity: 'error',
|
issues.push({ keyPath: [], severity: 'error',
|
||||||
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
|
message: 'Root must be a map (got ' + typeOf(doc) + ').' });
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
walkObject(doc, TOP_KEYS, [], issues);
|
function deref(n) { return (n && n.$ref === '#') ? schema : n; }
|
||||||
return issues;
|
function typeOk(t, want) {
|
||||||
}
|
if (Array.isArray(want)) {
|
||||||
|
for (var i = 0; i < want.length; i++) if (typeOk(t, want[i])) return true;
|
||||||
function walkObject(obj, schema, path, issues) {
|
return false;
|
||||||
for (var key in obj) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
||||||
var here = path.concat([key]);
|
|
||||||
var kind = schema[key];
|
|
||||||
if (!kind) {
|
|
||||||
issues.push({ keyPath: here, severity: 'warning',
|
|
||||||
message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' });
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
checkValue(obj[key], kind, here, issues);
|
if (want === 'integer' || want === 'number') return t === 'number';
|
||||||
|
return t === want;
|
||||||
}
|
}
|
||||||
}
|
function walk(value, sch, path) {
|
||||||
|
sch = deref(sch);
|
||||||
function checkValue(val, kind, path, issues) {
|
if (!sch) return;
|
||||||
var t = typeOf(val);
|
var t = typeOf(value);
|
||||||
switch (kind) {
|
if (t === 'null') return; // empty value mid-edit — don't flag
|
||||||
case 'string':
|
if (sch.type && !typeOk(t, sch.type)) {
|
||||||
if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues);
|
issues.push({ keyPath: path, severity: 'error',
|
||||||
|
message: 'Expected ' + (Array.isArray(sch.type) ? sch.type.join('/') : sch.type)
|
||||||
|
+ ', got ' + t + '.' });
|
||||||
return;
|
return;
|
||||||
case 'bool':
|
}
|
||||||
if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues);
|
if (sch.enum && sch.enum.map(String).indexOf(String(value)) === -1) {
|
||||||
return;
|
issues.push({ keyPath: path, severity: 'warning',
|
||||||
case 'string[]':
|
message: 'Unknown value "' + value + '". Allowed: ' + sch.enum.join(', ') + '.' });
|
||||||
if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues);
|
}
|
||||||
return;
|
if (sch.pattern && t === 'string' && !new RegExp(sch.pattern).test(value)) {
|
||||||
case 'tools[]':
|
issues.push({ keyPath: path, severity: 'error',
|
||||||
if (t !== 'array' && t !== 'null') {
|
message: 'Value "' + value + '" must match ' + sch.pattern + '.' });
|
||||||
addTypeErr(path, kind, t, issues); return;
|
}
|
||||||
}
|
if (t === 'object') {
|
||||||
if (t === 'array') {
|
var props = sch.properties || {};
|
||||||
for (var i = 0; i < val.length; i++) {
|
for (var k in value) {
|
||||||
if (typeOf(val[i]) !== 'string') {
|
if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
|
||||||
issues.push({ keyPath: path, severity: 'error',
|
var kp = path.concat([k]);
|
||||||
message: 'available_tools[' + i + '] must be a string.' });
|
if (props[k]) { walk(value[k], props[k], kp); continue; }
|
||||||
} else if (!ALLOWED_TOOLS[val[i]]) {
|
var ap = sch.additionalProperties;
|
||||||
issues.push({ keyPath: path, severity: 'warning',
|
if (ap && typeof ap === 'object') { walk(value[k], ap, kp); continue; }
|
||||||
message: 'Unknown tool "' + val[i]
|
if (sch.patternProperties) {
|
||||||
+ '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
var matched = null;
|
||||||
|
for (var p in sch.patternProperties) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(sch.patternProperties, p)
|
||||||
|
&& new RegExp(p).test(k)) { matched = sch.patternProperties[p]; break; }
|
||||||
}
|
}
|
||||||
|
if (matched) { walk(value[k], matched, kp); continue; }
|
||||||
|
}
|
||||||
|
if (ap === false) {
|
||||||
|
issues.push({ keyPath: kp, severity: 'warning',
|
||||||
|
message: 'Unknown key "' + k + '" — not in the .zddc schema; it will be ignored.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
} else if (t === 'array' && sch.items) {
|
||||||
case 'tool':
|
for (var i = 0; i < value.length; i++) {
|
||||||
if (t === 'null') return;
|
walk(value[i], sch.items, path.concat([String(i)]));
|
||||||
if (t !== 'string') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
if (!ALLOWED_TOOLS[val]) {
|
|
||||||
issues.push({ keyPath: path, severity: 'warning',
|
|
||||||
message: 'Unknown tool "' + val + '". Known: '
|
|
||||||
+ Object.keys(ALLOWED_TOOLS).join(', ') + '.' });
|
|
||||||
}
|
}
|
||||||
return;
|
}
|
||||||
case 'stringmap':
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
for (var k in val) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(val, k)) continue;
|
|
||||||
if (typeOf(val[k]) !== 'string') {
|
|
||||||
issues.push({ keyPath: path.concat([k]), severity: 'error',
|
|
||||||
message: 'Value must be a string (got '
|
|
||||||
+ typeOf(val[k]) + ').' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'pathmap':
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
for (var seg in val) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(val, seg)) continue;
|
|
||||||
if (seg.indexOf('/') !== -1) {
|
|
||||||
issues.push({ keyPath: path.concat([seg]), severity: 'error',
|
|
||||||
message: 'Path keys must be a single segment — '
|
|
||||||
+ 'nest blocks instead of using "' + seg + '".' });
|
|
||||||
}
|
|
||||||
var v = val[seg];
|
|
||||||
if (typeOf(v) === 'null') continue;
|
|
||||||
if (typeOf(v) !== 'object') {
|
|
||||||
issues.push({ keyPath: path.concat([seg]), severity: 'error',
|
|
||||||
message: 'paths.' + seg + ' must be a map of cascade rules.' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
walkObject(v, TOP_KEYS, path.concat([seg]), issues);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'viewmap':
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
for (var shape in val) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(val, shape)) continue;
|
|
||||||
if (['dir', 'dir_slash', 'file'].indexOf(shape) === -1) {
|
|
||||||
issues.push({ keyPath: path.concat([shape]), severity: 'warning',
|
|
||||||
message: 'Unknown view shape "' + shape + '" (known: dir, dir_slash, file).' });
|
|
||||||
}
|
|
||||||
var vv = val[shape];
|
|
||||||
if (typeOf(vv) !== 'object') {
|
|
||||||
issues.push({ keyPath: path.concat([shape]), severity: 'error',
|
|
||||||
message: 'views.' + shape + ' must be a map ({tool, config}).' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeOf(vv.tool) !== 'string' || !ALLOWED_TOOLS[vv.tool]) {
|
|
||||||
issues.push({ keyPath: path.concat([shape, 'tool']), severity: 'warning',
|
|
||||||
message: 'views.' + shape + '.tool should be a known tool ('
|
|
||||||
+ Object.keys(ALLOWED_TOOLS).join(', ') + ').' });
|
|
||||||
}
|
|
||||||
if (vv.config !== undefined && typeOf(vv.config) !== 'string') {
|
|
||||||
issues.push({ keyPath: path.concat([shape, 'config']), severity: 'error',
|
|
||||||
message: 'views.' + shape + '.config must be a filename string.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'rolemap':
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
for (var rn in val) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(val, rn)) continue;
|
|
||||||
var rv = val[rn];
|
|
||||||
if (typeOf(rv) === 'null') continue;
|
|
||||||
if (typeOf(rv) !== 'object') {
|
|
||||||
issues.push({ keyPath: path.concat([rn]), severity: 'error',
|
|
||||||
message: 'roles.' + rn + ' must be a map ({members, reset}).' });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
walkObject(rv, ROLE_KEYS, path.concat([rn]), issues);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'acl':
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
walkObject(val, ACL_KEYS, path, issues);
|
|
||||||
return;
|
|
||||||
case 'convert':
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
walkObject(val, CONVERT_KEYS, path, issues);
|
|
||||||
return;
|
|
||||||
case 'object':
|
|
||||||
// Free-form map (records, field_codes) — the server accepts any
|
|
||||||
// nested shape, so we only check it's a mapping, not its keys.
|
|
||||||
if (t === 'null') return;
|
|
||||||
if (t !== 'object') { addTypeErr(path, kind, t, issues); return; }
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
walk(doc, schema, []);
|
||||||
|
return issues;
|
||||||
function addTypeErr(path, expected, got, issues) {
|
|
||||||
issues.push({ keyPath: path, severity: 'error',
|
|
||||||
message: 'Expected ' + expected + ', got ' + got + '.' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locate the source line for a key path. .zddc files are
|
// Locate the source line for a key path. .zddc files are
|
||||||
|
|
@ -465,8 +365,10 @@
|
||||||
schemaTag.addEventListener('keydown', function (ev) {
|
schemaTag.addEventListener('keydown', function (ev) {
|
||||||
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
|
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); openSchema(); }
|
||||||
});
|
});
|
||||||
} else {
|
} else if (isYamlFile(node)) {
|
||||||
schemaTag.textContent = 'YAML';
|
schemaTag.textContent = 'YAML';
|
||||||
|
} else {
|
||||||
|
schemaTag.textContent = (node.ext || 'text').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
var dirtyEl = document.createElement('span');
|
var dirtyEl = document.createElement('span');
|
||||||
|
|
@ -506,16 +408,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
var writable = canSave(node);
|
var writable = canSave(node);
|
||||||
|
var mode = codeMode(node);
|
||||||
|
// Lint (js-yaml + the .zddc schema) only applies to YAML; other text
|
||||||
|
// types are plaintext, so skip the lint gutter for them.
|
||||||
|
var yamlMode = mode === 'yaml';
|
||||||
var editor = window.CodeMirror(editorHost, {
|
var editor = window.CodeMirror(editorHost, {
|
||||||
value: text,
|
value: text,
|
||||||
mode: 'yaml',
|
mode: mode,
|
||||||
lineNumbers: true,
|
lineNumbers: true,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
indentUnit: 2,
|
indentUnit: 2,
|
||||||
indentWithTabs: false,
|
indentWithTabs: false,
|
||||||
lineWrapping: false,
|
lineWrapping: false,
|
||||||
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
|
gutters: yamlMode
|
||||||
lint: { hasGutters: true },
|
? ['CodeMirror-lint-markers', 'CodeMirror-linenumbers']
|
||||||
|
: ['CodeMirror-linenumbers'],
|
||||||
|
lint: yamlMode ? { hasGutters: true } : false,
|
||||||
// autofocus:false keeps the keyboard caret in the browse
|
// autofocus:false keeps the keyboard caret in the browse
|
||||||
// tree pane so arrow-key nav can continue through yaml /
|
// tree pane so arrow-key nav can continue through yaml /
|
||||||
// .zddc files without diverting into the editor. User
|
// .zddc files without diverting into the editor. User
|
||||||
|
|
@ -533,8 +441,15 @@
|
||||||
// Stash the node on the editor so the lint helper can decide
|
// Stash the node on the editor so the lint helper can decide
|
||||||
// whether to apply the .zddc schema layer.
|
// whether to apply the .zddc schema layer.
|
||||||
editor._zddcNode = node;
|
editor._zddcNode = node;
|
||||||
// Force an initial lint pass now that _zddcNode is set.
|
// Force an initial lint pass now that _zddcNode is set (YAML only).
|
||||||
editor.performLint();
|
if (yamlMode) editor.performLint();
|
||||||
|
// Schema completion + hover docs for .zddc files (the machine grammar
|
||||||
|
// drives keys, enum/boolean values, and nested paths via $ref:"#").
|
||||||
|
// Plain .yaml gets no schema (lint + highlighting only).
|
||||||
|
var yc = window.app.modules.yamlComplete;
|
||||||
|
if (yc && isZddcFile(node.name)) {
|
||||||
|
yc.attach(editor, yc.schemaProvider(getZddcSchema), { readOnly: !writable });
|
||||||
|
}
|
||||||
currentEditor = editor;
|
currentEditor = editor;
|
||||||
currentNodeRef = node;
|
currentNodeRef = node;
|
||||||
currentDirty = false;
|
currentDirty = false;
|
||||||
|
|
@ -656,8 +571,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function handles(node) {
|
function handles(node) {
|
||||||
if (!node || node.isDir || node.isZip) return false;
|
return isCodeFile(node);
|
||||||
return isYamlFile(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.app.modules.yamledit = {
|
window.app.modules.yamledit = {
|
||||||
|
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
// preview-zddc-form.js — schema-driven FORM view for .zddc files.
|
|
||||||
//
|
|
||||||
// The user shouldn't have to understand YAML cascades to configure a project.
|
|
||||||
// This renders the .zddc as a form: the OPTION fields (the blanks an operator
|
|
||||||
// fills — title, admins, role members) are editable widgets; the STRUCTURE
|
|
||||||
// (paths, WORM, tools, behaviours — what a ZDDC project IS) is shown read-only
|
|
||||||
// for context. The split is driven by the server's .zddc JSON Schema
|
|
||||||
// (/.api/zddc-schema, x-zddc-tier: structure|option). Saving merges the edited
|
|
||||||
// option values back into the file (preserving all structure keys) and PUTs the
|
|
||||||
// YAML — which works for an on-disk .zddc and for a .zddc.zip bundle member
|
|
||||||
// (the server's ServeZipWrite). An "Edit raw YAML" escape hands off to the
|
|
||||||
// CodeMirror editor for anything the form doesn't cover (field_codes, display,
|
|
||||||
// convert, advanced acl).
|
|
||||||
//
|
|
||||||
// This is the primary .zddc editor; the raw-YAML plugin (preview-yaml.js) is
|
|
||||||
// the power-user fallback.
|
|
||||||
(function (app) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var util = app.modules.util || window.app.modules.util;
|
|
||||||
var escapeHtml = util.escapeHtml;
|
|
||||||
var saveFile = util.saveFile;
|
|
||||||
var isEditableZipMember = util.isEditableZipMember;
|
|
||||||
|
|
||||||
var current = null; // { node, dirty, etag, lastModified }
|
|
||||||
|
|
||||||
// Cached .zddc schema (property → {tier, description}).
|
|
||||||
var schemaProps = null;
|
|
||||||
function loadSchema() {
|
|
||||||
if (schemaProps) return Promise.resolve(schemaProps);
|
|
||||||
return fetch('/.api/zddc-schema', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (j) { schemaProps = (j && j.properties) || {}; return schemaProps; })
|
|
||||||
.catch(function () { schemaProps = {}; return schemaProps; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handles(node) {
|
|
||||||
return !!node && (node.name === '.zddc' || /\.zddc$/i.test(node.name || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function canSave(node) {
|
|
||||||
if (isEditableZipMember(node)) return true;
|
|
||||||
if (node.url && window.app.state.source === 'server' && window.zddc.cap) {
|
|
||||||
// A .zddc edit is an ActionAdmin write — needs the 'a' verb.
|
|
||||||
return window.zddc.cap.has(node, 'a');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirty() { return !!(current && current.dirty); }
|
|
||||||
function currentNode() { return current ? current.node : null; }
|
|
||||||
function dispose() { current = null; }
|
|
||||||
|
|
||||||
function desc(name) {
|
|
||||||
return (schemaProps && schemaProps[name] && schemaProps[name].description) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── small DOM helpers ───────────────────────────────────────────────────
|
|
||||||
function el(tag, cls, text) {
|
|
||||||
var e = document.createElement(tag);
|
|
||||||
if (cls) e.className = cls;
|
|
||||||
if (text != null) e.textContent = text;
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
// A growable list of single-string rows (used for admins + role members).
|
|
||||||
function listEditor(values, placeholder, onChange, readOnly) {
|
|
||||||
var wrap = el('div', 'zf-list');
|
|
||||||
function addRow(val) {
|
|
||||||
var row = el('div', 'zf-list__row');
|
|
||||||
row.style.cssText = 'display:flex;gap:.4rem;margin:.2rem 0;';
|
|
||||||
var input = el('input');
|
|
||||||
input.type = 'text';
|
|
||||||
input.value = val || '';
|
|
||||||
input.placeholder = placeholder || '';
|
|
||||||
input.style.cssText = 'flex:1;padding:.3rem;font-family:var(--code,monospace);';
|
|
||||||
input.disabled = !!readOnly;
|
|
||||||
input.addEventListener('input', onChange);
|
|
||||||
row.appendChild(input);
|
|
||||||
if (!readOnly) {
|
|
||||||
var del = el('button', null, '−');
|
|
||||||
del.type = 'button';
|
|
||||||
del.title = 'Remove';
|
|
||||||
del.addEventListener('click', function () { row.remove(); onChange(); });
|
|
||||||
row.appendChild(del);
|
|
||||||
}
|
|
||||||
wrap.appendChild(row);
|
|
||||||
}
|
|
||||||
(values || []).forEach(addRow);
|
|
||||||
if (!readOnly) {
|
|
||||||
var add = el('button', 'zf-add', '+ add');
|
|
||||||
add.type = 'button';
|
|
||||||
add.style.cssText = 'margin-top:.2rem;';
|
|
||||||
add.addEventListener('click', function () { addRow(''); onChange(); });
|
|
||||||
wrap.appendChild(add);
|
|
||||||
}
|
|
||||||
wrap._values = function () {
|
|
||||||
return Array.prototype.slice.call(wrap.querySelectorAll('.zf-list__row input'))
|
|
||||||
.map(function (i) { return i.value.trim(); })
|
|
||||||
.filter(function (v) { return v; });
|
|
||||||
};
|
|
||||||
return wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(node, container, ctx) {
|
|
||||||
dispose();
|
|
||||||
var text, etag = null, lastModified = null;
|
|
||||||
try {
|
|
||||||
if (ctx.getContentWithVersion) {
|
|
||||||
var loaded = await ctx.getContentWithVersion(node);
|
|
||||||
text = new TextDecoder('utf-8', { fatal: false }).decode(loaded.buf);
|
|
||||||
etag = loaded.etag;
|
|
||||||
lastModified = loaded.lastModified;
|
|
||||||
} else {
|
|
||||||
text = new TextDecoder('utf-8', { fatal: false }).decode(await ctx.getArrayBuffer(node));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
container.innerHTML = '<div class="preview-empty" style="color:var(--danger)">'
|
|
||||||
+ 'Could not read ' + escapeHtml(node.name) + ': ' + escapeHtml(e.message || String(e)) + '</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = {};
|
|
||||||
try { data = (window.jsyaml && window.jsyaml.load(text)) || {}; } catch (_) { data = {}; }
|
|
||||||
if (typeof data !== 'object' || Array.isArray(data)) data = {};
|
|
||||||
await loadSchema();
|
|
||||||
|
|
||||||
var editable = canSave(node);
|
|
||||||
current = { node: node, dirty: false, etag: etag, lastModified: lastModified };
|
|
||||||
|
|
||||||
container.innerHTML = '';
|
|
||||||
var shell = el('div', 'yaml-shell zddc-form');
|
|
||||||
shell.style.cssText = 'padding:.75rem 1rem;overflow:auto;height:100%;box-sizing:border-box;';
|
|
||||||
container.appendChild(shell);
|
|
||||||
|
|
||||||
// Header.
|
|
||||||
var hdr = el('div', 'md-shell__infohdr');
|
|
||||||
hdr.appendChild(el('span', 'md-shell__title', node.name));
|
|
||||||
var srcTag = el('span', 'md-shell__source', isEditableZipMember(node) ? 'config bundle' : (editable ? '.zddc form' : 'read-only'));
|
|
||||||
hdr.appendChild(srcTag);
|
|
||||||
var dirtyEl = el('span', 'md-shell__dirty');
|
|
||||||
hdr.appendChild(dirtyEl);
|
|
||||||
var statusEl = el('span', 'md-shell__status');
|
|
||||||
hdr.appendChild(statusEl);
|
|
||||||
var rawBtn = el('button', 'btn btn-sm btn-secondary', 'Edit raw YAML');
|
|
||||||
rawBtn.type = 'button';
|
|
||||||
rawBtn.title = 'Switch to the raw YAML editor (covers every key).';
|
|
||||||
rawBtn.addEventListener('click', function () {
|
|
||||||
var ym = window.app.modules.yamledit;
|
|
||||||
if (ym && ym.render) { dispose(); ym.render(node, container, ctx); }
|
|
||||||
});
|
|
||||||
hdr.appendChild(rawBtn);
|
|
||||||
var saveBtn = el('button', 'btn btn-sm btn-primary', 'Save');
|
|
||||||
saveBtn.type = 'button';
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
hdr.appendChild(saveBtn);
|
|
||||||
shell.appendChild(hdr);
|
|
||||||
|
|
||||||
function markDirty() {
|
|
||||||
if (!current) return;
|
|
||||||
current.dirty = true;
|
|
||||||
dirtyEl.textContent = '● modified';
|
|
||||||
if (editable) saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var help = el('p', 'help');
|
|
||||||
help.style.cssText = 'color:var(--color-text-muted,#666);font-size:.85rem;margin:.3rem 0 .5rem;';
|
|
||||||
help.textContent = editable
|
|
||||||
? 'Project options. Structural keys are read-only — use Edit raw YAML.'
|
|
||||||
: 'Read-only — you need admin authority over this path to edit it.';
|
|
||||||
shell.appendChild(help);
|
|
||||||
|
|
||||||
// ── OPTION fields ───────────────────────────────────────────────────
|
|
||||||
function section(title, hint, tight) {
|
|
||||||
var s = el('section', 'zf-section');
|
|
||||||
s.style.cssText = 'margin:0 0 1rem;';
|
|
||||||
var h = el('h3', null, title);
|
|
||||||
// `tight` drops the heading's top margin for the FIRST section so
|
|
||||||
// it doesn't stack with the intro's bottom margin (the gap above
|
|
||||||
// Title was reading as excessive). Later sections keep the gap.
|
|
||||||
h.style.cssText = 'font-size:1em;margin:' + (tight ? '0' : '.6rem') + ' 0 .2rem;';
|
|
||||||
s.appendChild(h);
|
|
||||||
if (hint) {
|
|
||||||
var p = el('p', 'help', hint);
|
|
||||||
p.style.cssText = 'color:var(--color-text-muted,#888);font-size:.8rem;margin:0 0 .3rem;';
|
|
||||||
s.appendChild(p);
|
|
||||||
}
|
|
||||||
shell.appendChild(s);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// title
|
|
||||||
var titleSec = section('Title', desc('title'), true);
|
|
||||||
var titleInput = el('input');
|
|
||||||
titleInput.type = 'text';
|
|
||||||
titleInput.value = (typeof data.title === 'string') ? data.title : '';
|
|
||||||
titleInput.disabled = !editable;
|
|
||||||
titleInput.style.cssText = 'width:100%;max-width:32rem;padding:.35rem;';
|
|
||||||
titleInput.addEventListener('input', markDirty);
|
|
||||||
titleSec.appendChild(titleInput);
|
|
||||||
|
|
||||||
// admins
|
|
||||||
var adminsSec = section('Admins', desc('admins'));
|
|
||||||
var adminsList = listEditor(Array.isArray(data.admins) ? data.admins : [], 'email or *@domain', markDirty, !editable);
|
|
||||||
adminsSec.appendChild(adminsList);
|
|
||||||
|
|
||||||
// roles (map name → {members:[]})
|
|
||||||
var rolesSec = section('Roles', desc('roles') || 'Who belongs to each project role.');
|
|
||||||
var rolesHost = el('div', 'zf-roles');
|
|
||||||
rolesSec.appendChild(rolesHost);
|
|
||||||
var roleEditors = []; // {name, membersEl, getName}
|
|
||||||
function addRole(name, members) {
|
|
||||||
var box = el('div', 'zf-role');
|
|
||||||
box.style.cssText = 'border:1px solid rgba(0,0,0,0.1);border-radius:4px;padding:.4rem .6rem;margin:.3rem 0;';
|
|
||||||
var nameRow = el('div');
|
|
||||||
nameRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.2rem;';
|
|
||||||
var nameInput = el('input');
|
|
||||||
nameInput.type = 'text';
|
|
||||||
nameInput.value = name || '';
|
|
||||||
nameInput.placeholder = 'role name (e.g. document_controller)';
|
|
||||||
nameInput.style.cssText = 'font-family:var(--code,monospace);font-weight:600;flex:1;padding:.25rem;';
|
|
||||||
nameInput.disabled = !editable;
|
|
||||||
nameInput.addEventListener('input', markDirty);
|
|
||||||
nameRow.appendChild(el('span', null, '👥'));
|
|
||||||
nameRow.appendChild(nameInput);
|
|
||||||
box.appendChild(nameRow);
|
|
||||||
var membersList = listEditor(members || [], 'member email or *@domain', markDirty, !editable);
|
|
||||||
box.appendChild(membersList);
|
|
||||||
rolesHost.appendChild(box);
|
|
||||||
roleEditors.push({ getName: function () { return nameInput.value.trim(); }, members: membersList });
|
|
||||||
}
|
|
||||||
var roles = (data.roles && typeof data.roles === 'object') ? data.roles : {};
|
|
||||||
Object.keys(roles).forEach(function (rn) {
|
|
||||||
var m = (roles[rn] && Array.isArray(roles[rn].members)) ? roles[rn].members : [];
|
|
||||||
addRole(rn, m);
|
|
||||||
});
|
|
||||||
if (editable) {
|
|
||||||
var addRoleBtn = el('button', 'zf-add', '+ add role');
|
|
||||||
addRoleBtn.type = 'button';
|
|
||||||
addRoleBtn.addEventListener('click', function () { addRole('', []); markDirty(); });
|
|
||||||
rolesSec.appendChild(addRoleBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── STRUCTURE (read-only) ───────────────────────────────────────────
|
|
||||||
var structKeys = Object.keys(data).filter(function (k) {
|
|
||||||
return schemaProps[k] && schemaProps[k].tier === 'structure';
|
|
||||||
});
|
|
||||||
// Also surface option keys this form doesn't render yet, as read-only.
|
|
||||||
var rawHandled = { title: 1, admins: 1, roles: 1 };
|
|
||||||
var otherKeys = Object.keys(data).filter(function (k) {
|
|
||||||
return !rawHandled[k] && !(schemaProps[k] && schemaProps[k].tier === 'structure');
|
|
||||||
});
|
|
||||||
if (structKeys.length || otherKeys.length) {
|
|
||||||
var det = el('details', 'zf-structure');
|
|
||||||
det.style.cssText = 'margin-top:.5rem;';
|
|
||||||
var sum = el('summary', null, 'Structure & advanced (read-only — edit via raw YAML)');
|
|
||||||
sum.style.cssText = 'cursor:pointer;color:var(--color-text-muted,#666);font-size:.85rem;';
|
|
||||||
det.appendChild(sum);
|
|
||||||
var subset = {};
|
|
||||||
structKeys.concat(otherKeys).forEach(function (k) { subset[k] = data[k]; });
|
|
||||||
var pre = el('pre');
|
|
||||||
pre.style.cssText = 'background:var(--code-bg,#f6f8fa);padding:.5rem;border-radius:4px;overflow:auto;font-size:.8rem;';
|
|
||||||
try { pre.textContent = window.jsyaml ? window.jsyaml.dump(subset) : JSON.stringify(subset, null, 2); }
|
|
||||||
catch (_) { pre.textContent = JSON.stringify(subset, null, 2); }
|
|
||||||
det.appendChild(pre);
|
|
||||||
shell.appendChild(det);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Save ────────────────────────────────────────────────────────────
|
|
||||||
function buildContent() {
|
|
||||||
var out = {};
|
|
||||||
// Preserve everything not managed by the form (structure + unrendered options).
|
|
||||||
Object.keys(data).forEach(function (k) { if (!rawHandled[k]) out[k] = data[k]; });
|
|
||||||
var t = titleInput.value.trim();
|
|
||||||
if (t) out.title = t;
|
|
||||||
var admins = adminsList._values();
|
|
||||||
if (admins.length) out.admins = admins;
|
|
||||||
var rolesOut = {};
|
|
||||||
roleEditors.forEach(function (re) {
|
|
||||||
var n = re.getName();
|
|
||||||
if (!n) return;
|
|
||||||
var mem = re.members._values();
|
|
||||||
rolesOut[n] = mem.length ? { members: mem } : { members: [] };
|
|
||||||
});
|
|
||||||
if (Object.keys(rolesOut).length) out.roles = rolesOut;
|
|
||||||
return window.jsyaml.dump(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveBtn.addEventListener('click', async function () {
|
|
||||||
if (!current || !editable) return;
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
statusEl.textContent = 'Saving…';
|
|
||||||
var content;
|
|
||||||
try { content = buildContent(); }
|
|
||||||
catch (e) { statusEl.textContent = 'Serialize failed: ' + (e.message || e); return; }
|
|
||||||
try {
|
|
||||||
var res = await saveFile(node, content, 'application/yaml; charset=utf-8',
|
|
||||||
{ etag: current.etag, lastModified: current.lastModified });
|
|
||||||
if (!current) return;
|
|
||||||
current.etag = (res && res.etag) || current.etag;
|
|
||||||
current.dirty = false;
|
|
||||||
dirtyEl.textContent = '';
|
|
||||||
statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString();
|
|
||||||
if (window.zddc && window.zddc.toast) window.zddc.toast('Saved ' + node.name, 'success');
|
|
||||||
} catch (e) {
|
|
||||||
if (e && e.status === 412 && window.app.modules.conflict) {
|
|
||||||
window.app.modules.conflict.open({
|
|
||||||
name: node.name, theirsText: '', minePut: function () { return saveFile(node, content, 'application/yaml; charset=utf-8', {}); }
|
|
||||||
});
|
|
||||||
statusEl.textContent = 'Conflict — changed on server.';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = 'Save failed: ' + (e && e.message ? e.message : e);
|
|
||||||
}
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.modules.zddcform = {
|
|
||||||
handles: handles,
|
|
||||||
render: render,
|
|
||||||
isDirty: isDirty,
|
|
||||||
currentNode: currentNode,
|
|
||||||
dispose: dispose
|
|
||||||
};
|
|
||||||
})(window.app);
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
|
|
||||||
function editorModules() {
|
function editorModules() {
|
||||||
var m = window.app.modules;
|
var m = window.app.modules;
|
||||||
return [m.markdown, m.yamledit, m.zddcform].filter(Boolean);
|
return [m.markdown, m.yamledit].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeEditors() {
|
function disposeEditors() {
|
||||||
|
|
@ -132,6 +132,9 @@
|
||||||
disposeEditors();
|
disposeEditors();
|
||||||
var container = document.getElementById('previewBody');
|
var container = document.getElementById('previewBody');
|
||||||
if (container) container.innerHTML = '';
|
if (container) container.innerHTML = '';
|
||||||
|
toggleTargetNode = null;
|
||||||
|
var tb = document.getElementById('previewViewToggle');
|
||||||
|
if (tb) tb.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn before a full page unload (reload / close / external nav) drops
|
// Warn before a full page unload (reload / close / external nav) drops
|
||||||
|
|
@ -141,6 +144,41 @@
|
||||||
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
|
if (dirtyEditor()) { e.preventDefault(); e.returnValue = ''; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Rendered ⇄ Source toggle ─────────────────────────────────────────────
|
||||||
|
// Some types we can RENDER, not just edit (.html). Those show rendered by
|
||||||
|
// default (sandboxed — no scripts, no same-origin) with a toggle to the
|
||||||
|
// CodeMirror source view. Markdown has its own rendered/source toggle, so
|
||||||
|
// it's not here. Extend RENDERABLE to add more (svg already previews as an
|
||||||
|
// image; csv could render as a table later).
|
||||||
|
var RENDERABLE = { html: 1, htm: 1 };
|
||||||
|
function isRenderable(ext) { return !!RENDERABLE[(ext || '').toLowerCase()]; }
|
||||||
|
function nodeKey(node) { return (node && (node.url || node.name)) || ''; }
|
||||||
|
// Per-node mode; 'rendered' is the default. Only the node the user last
|
||||||
|
// toggled is remembered, so switching files resets to rendered.
|
||||||
|
var viewToggle = { key: null, mode: 'rendered' };
|
||||||
|
var toggleTargetNode = null;
|
||||||
|
function effectiveMode(node) {
|
||||||
|
return (viewToggle.key && viewToggle.key === nodeKey(node)) ? viewToggle.mode : 'rendered';
|
||||||
|
}
|
||||||
|
function ensureViewToggleBtn() {
|
||||||
|
var btn = document.getElementById('previewViewToggle');
|
||||||
|
if (btn) return btn;
|
||||||
|
var popout = document.getElementById('previewPopout');
|
||||||
|
if (!popout || !popout.parentNode) return null;
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.id = 'previewViewToggle';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'btn btn-sm btn-secondary hidden';
|
||||||
|
popout.parentNode.insertBefore(btn, popout);
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!toggleTargetNode) return;
|
||||||
|
var next = effectiveMode(toggleTargetNode) === 'rendered' ? 'source' : 'rendered';
|
||||||
|
viewToggle = { key: nodeKey(toggleTargetNode), mode: next };
|
||||||
|
renderInline(toggleTargetNode, { toggle: true });
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Inline rendering ────────────────────────────────────────────────────
|
// ── Inline rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Bumped on every renderInline entry; a render that loses the race
|
// Bumped on every renderInline entry; a render that loses the race
|
||||||
|
|
@ -169,9 +207,10 @@
|
||||||
var dm = dirtyEditor();
|
var dm = dirtyEditor();
|
||||||
if (dm) {
|
if (dm) {
|
||||||
var cur = dm.currentNode ? dm.currentNode() : null;
|
var cur = dm.currentNode ? dm.currentNode() : null;
|
||||||
if (samePreviewNode(cur, node)) {
|
if (samePreviewNode(cur, node) && !opts.toggle) {
|
||||||
// Re-selecting the file we're already editing — don't reload
|
// Re-selecting the file we're already editing — don't reload
|
||||||
// and clobber the in-progress edits.
|
// and clobber the in-progress edits. (A deliberate view toggle
|
||||||
|
// falls through to the discard prompt below.)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.auto) {
|
if (opts.auto) {
|
||||||
|
|
@ -199,6 +238,32 @@
|
||||||
|
|
||||||
var ext = (node.ext || '').toLowerCase();
|
var ext = (node.ext || '').toLowerCase();
|
||||||
|
|
||||||
|
// Rendered ⇄ Source toggle button — shown only for renderable types.
|
||||||
|
var toggleBtn = ensureViewToggleBtn();
|
||||||
|
if (toggleBtn) {
|
||||||
|
if (isRenderable(ext)) {
|
||||||
|
toggleTargetNode = node;
|
||||||
|
toggleBtn.classList.remove('hidden');
|
||||||
|
toggleBtn.textContent = effectiveMode(node) === 'rendered' ? '⟨⟩ Source' : '◱ Preview';
|
||||||
|
} else {
|
||||||
|
toggleBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderable types (.html) — show rendered by default, sandboxed for
|
||||||
|
// safety (no scripts, no same-origin). The toggle flips to source.
|
||||||
|
if (isRenderable(ext) && effectiveMode(node) === 'rendered') {
|
||||||
|
try {
|
||||||
|
var rinfo = await getBlobUrl(node);
|
||||||
|
if (seq !== renderSeq) return;
|
||||||
|
container.innerHTML = '<iframe class="preview-iframe" sandbox src="'
|
||||||
|
+ escapeHtml(rinfo.url) + '"></iframe>';
|
||||||
|
} catch (e) {
|
||||||
|
renderError(container, e.message || String(e));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Markdown plugin (if loaded) takes over for .md / .markdown.
|
// Markdown plugin (if loaded) takes over for .md / .markdown.
|
||||||
if ((ext === 'md' || ext === 'markdown') &&
|
if ((ext === 'md' || ext === 'markdown') &&
|
||||||
window.app.modules.markdown &&
|
window.app.modules.markdown &&
|
||||||
|
|
@ -211,39 +276,27 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// .zddc form view: a schema-driven form (option fields editable,
|
// CodeMirror editor: the general editor for editable text files that
|
||||||
// structure read-only) is the PRIMARY editor for .zddc files. It hands
|
// aren't markdown — yaml/.zddc (schema lint + completion + hover) plus
|
||||||
// off to the raw YAML editor on demand. Other YAML files skip it.
|
// txt/csv/tsv/json/xml/html/css/js/… as a plaintext code editor.
|
||||||
var zddcForm = window.app.modules.zddcform;
|
// Guided dialogs (Manage access, …) are the front door for the common
|
||||||
if (zddcForm && zddcForm.handles(node)) {
|
// .zddc tasks; this is the full/raw edit surface.
|
||||||
try {
|
|
||||||
await zddcForm.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
|
||||||
} catch (e) {
|
|
||||||
renderError(container, '.zddc form render failed: ' + (e.message || e));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a
|
|
||||||
// CodeMirror 5 editor with js-yaml linting; .zddc files also
|
|
||||||
// get a schema-aware lint pass.
|
|
||||||
var yamlMod = window.app.modules.yamledit;
|
var yamlMod = window.app.modules.yamledit;
|
||||||
if (yamlMod && yamlMod.handles(node)) {
|
if (yamlMod && yamlMod.handles(node)) {
|
||||||
try {
|
try {
|
||||||
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer, getContentWithVersion: getContentWithVersion });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderError(container, 'YAML render failed: ' + (e.message || e));
|
renderError(container, 'Editor failed: ' + (e.message || e));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF / HTML → iframe.
|
// PDF → iframe (HTML now routes to the editor above).
|
||||||
if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
|
if (ext === 'pdf') {
|
||||||
try {
|
try {
|
||||||
var info = await getBlobUrl(node);
|
var info = await getBlobUrl(node);
|
||||||
if (seq !== renderSeq) return;
|
if (seq !== renderSeq) return;
|
||||||
var sandbox = (ext === 'pdf') ? '' : ' sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"';
|
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"></iframe>';
|
||||||
container.innerHTML = '<iframe class="preview-iframe" src="' + escapeHtml(info.url) + '"' + sandbox + '></iframe>';
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderError(container, e.message || String(e));
|
renderError(container, e.message || String(e));
|
||||||
}
|
}
|
||||||
|
|
@ -432,6 +485,25 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderInPopup(node) {
|
async function renderInPopup(node) {
|
||||||
|
// Editor-type files (markdown, yaml/.zddc, code text) can't be hosted
|
||||||
|
// in the lightweight popup window — they need the bundled editor. Pop
|
||||||
|
// them out as the FULL browse app deep-linked to the file, which loads
|
||||||
|
// the real editor in a new window. Server mode only; HTML keeps its
|
||||||
|
// rendered popup. Falls through to the lightweight popup otherwise.
|
||||||
|
var pext = (node.ext || '').toLowerCase();
|
||||||
|
var ym = window.app.modules.yamledit;
|
||||||
|
var isEditorType = pext === 'md' || pext === 'markdown'
|
||||||
|
|| (ym && ym.handles && ym.handles(node) && pext !== 'html' && pext !== 'htm');
|
||||||
|
if (isEditorType && window.app.state.source === 'server' && node.url) {
|
||||||
|
var slash = node.url.lastIndexOf('/');
|
||||||
|
var pdir = slash >= 0 ? node.url.slice(0, slash + 1) : '/';
|
||||||
|
var pbase = slash >= 0 ? node.url.slice(slash + 1) : node.url;
|
||||||
|
var pp = new URLSearchParams();
|
||||||
|
try { pp.set('file', decodeURIComponent(pbase)); } catch (_e) { pp.set('file', pbase); }
|
||||||
|
if (window.app.state.showHidden) pp.set('hidden', '1');
|
||||||
|
window.open(pdir + '?' + pp.toString(), '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
var info;
|
var info;
|
||||||
try {
|
try {
|
||||||
info = await getBlobUrl(node);
|
info = await getBlobUrl(node);
|
||||||
|
|
|
||||||
275
browse/js/yaml-complete.js
Normal file
275
browse/js/yaml-complete.js
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
// yaml-complete.js — deterministic, schema-driven completion + hover docs for
|
||||||
|
// the browse YAML editors (markdown front matter + .zddc). NO heuristics, no
|
||||||
|
// AI: every candidate and doc string comes from a PROVIDER backed by the
|
||||||
|
// converter's field list or the .zddc JSON Schema.
|
||||||
|
//
|
||||||
|
// A provider answers three questions about a position, identified by its key
|
||||||
|
// PATH (the array of parent keys):
|
||||||
|
// keysAt(path) → [{name, hint, values}] valid child keys here
|
||||||
|
// valuesFor(path, key) → [string] | null enum/boolean values
|
||||||
|
// describe(path, key) → string | null doc text (for hover)
|
||||||
|
// The CodeMirror plumbing (indent→path, sibling scan, show-hint, hover) is
|
||||||
|
// shared; only the provider differs between the flat front matter and the
|
||||||
|
// nested .zddc schema. Requires CodeMirror 5 + the show-hint add-on.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
if (!window.app) window.app = {};
|
||||||
|
if (!window.app.modules) window.app.modules = {};
|
||||||
|
|
||||||
|
function indentOf(line) { var m = line.match(/^(\s*)/); return m ? m[1].length : 0; }
|
||||||
|
function isBlankOrComment(line) { return /^\s*$/.test(line) || /^\s*#/.test(line); }
|
||||||
|
function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
|
||||||
|
|
||||||
|
// Parent key-path for a line, derived from YAML indentation: walk upward
|
||||||
|
// collecting each "key:" line at a strictly smaller indent.
|
||||||
|
function pathAt(cm, lineNo) {
|
||||||
|
var path = [];
|
||||||
|
var target = indentOf(cm.getLine(lineNo));
|
||||||
|
for (var ln = lineNo - 1; ln >= 0 && target > 0; ln--) {
|
||||||
|
var line = cm.getLine(ln);
|
||||||
|
if (isBlankOrComment(line)) continue;
|
||||||
|
var ind = indentOf(line);
|
||||||
|
if (ind < target) {
|
||||||
|
var m = line.match(/^\s*([\w.\-]+)\s*:/);
|
||||||
|
if (m) { path.unshift(m[1]); target = ind; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sibling keys already present at the same indent within this block, so we
|
||||||
|
// don't re-suggest a key the author already wrote.
|
||||||
|
function presentSiblings(cm, lineNo, indent) {
|
||||||
|
var present = {};
|
||||||
|
[-1, 1].forEach(function (dir) {
|
||||||
|
for (var ln = lineNo + dir; ln >= 0 && ln < cm.lineCount(); ln += dir) {
|
||||||
|
var line = cm.getLine(ln);
|
||||||
|
if (isBlankOrComment(line)) continue;
|
||||||
|
var ind = indentOf(line);
|
||||||
|
if (ind < indent) break; // left the block
|
||||||
|
if (ind === indent) {
|
||||||
|
var m = line.match(/^\s*([\w.\-]+)\s*:/);
|
||||||
|
if (m) present[m[1]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return present;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyItem(k, hinter) {
|
||||||
|
var item = {
|
||||||
|
text: k.name + ': ',
|
||||||
|
displayText: k.name + (k.hint ? ' — ' + truncate(k.hint, 64) : '')
|
||||||
|
};
|
||||||
|
// An enum key inserts "key: " then immediately opens its value menu.
|
||||||
|
if (k.values && k.values.length) {
|
||||||
|
item.hint = function (cmi, data, comp) {
|
||||||
|
cmi.replaceRange(comp.text, data.from, data.to);
|
||||||
|
setTimeout(function () { cmi.showHint({ hint: hinter, completeSingle: false }); }, 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHinter(provider) {
|
||||||
|
function hinter(cm) {
|
||||||
|
var CM = window.CodeMirror;
|
||||||
|
if (!CM) return null;
|
||||||
|
var cur = cm.getCursor();
|
||||||
|
var before = cm.getLine(cur.line).slice(0, cur.ch);
|
||||||
|
var colon = before.indexOf(':');
|
||||||
|
var path = pathAt(cm, cur.line);
|
||||||
|
|
||||||
|
if (colon === -1) {
|
||||||
|
// KEY context.
|
||||||
|
var m = before.match(/^(\s*)([\w.\-]*)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
var indent = m[1], typed = m[2];
|
||||||
|
var keys = provider.keysAt(path) || [];
|
||||||
|
if (!keys.length) return null;
|
||||||
|
var present = presentSiblings(cm, cur.line, indent.length);
|
||||||
|
var list = [];
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
if (present[k.name]) return;
|
||||||
|
if (typed && k.name.indexOf(typed) !== 0) return;
|
||||||
|
list.push(keyItem(k, hinter));
|
||||||
|
});
|
||||||
|
if (!list.length) return null;
|
||||||
|
return { list: list, from: CM.Pos(cur.line, indent.length), to: cur };
|
||||||
|
}
|
||||||
|
|
||||||
|
// VALUE context.
|
||||||
|
var key = before.slice(0, colon).trim();
|
||||||
|
var values = provider.valuesFor(path, key) || [];
|
||||||
|
if (!values.length) return null;
|
||||||
|
var rest = before.slice(colon + 1);
|
||||||
|
var valTyped = rest.replace(/^\s*/, '');
|
||||||
|
var valStart = colon + 1 + (rest.length - valTyped.length);
|
||||||
|
var vlist = [];
|
||||||
|
values.forEach(function (v) {
|
||||||
|
if (valTyped && v.indexOf(valTyped) !== 0) return;
|
||||||
|
vlist.push({ text: v, displayText: v });
|
||||||
|
});
|
||||||
|
if (!vlist.length) return null;
|
||||||
|
return { list: vlist, from: CM.Pos(cur.line, valStart), to: cur };
|
||||||
|
}
|
||||||
|
return hinter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight hover docs: hover a "key:" → its schema description. No
|
||||||
|
// add-on — a debounced mousemove over the editor + a fixed-position tip.
|
||||||
|
function attachHover(cm, provider) {
|
||||||
|
var tip = null, timer = null;
|
||||||
|
function hide() { if (tip && tip.parentNode) tip.parentNode.removeChild(tip); tip = null; }
|
||||||
|
function show(text, x, y) {
|
||||||
|
hide();
|
||||||
|
tip = document.createElement('div');
|
||||||
|
tip.className = 'cm-doc-tip';
|
||||||
|
tip.textContent = text;
|
||||||
|
document.body.appendChild(tip);
|
||||||
|
tip.style.left = x + 'px';
|
||||||
|
tip.style.top = (y + 16) + 'px';
|
||||||
|
}
|
||||||
|
var wrap = cm.getWrapperElement();
|
||||||
|
wrap.addEventListener('mousemove', function (e) {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
var ex = e.clientX, ey = e.clientY;
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
if (!wrap.isConnected) { hide(); return; }
|
||||||
|
try {
|
||||||
|
var pos = cm.coordsChar({ left: ex, top: ey }, 'window');
|
||||||
|
var line = cm.getLine(pos.line) || '';
|
||||||
|
var m = line.match(/^\s*([\w.\-]+)\s*:/);
|
||||||
|
if (!m) { hide(); return; }
|
||||||
|
var keyStart = line.indexOf(m[1]);
|
||||||
|
if (pos.ch < keyStart || pos.ch > keyStart + m[1].length) { hide(); return; }
|
||||||
|
var doc = provider.describe(pathAt(cm, pos.line), m[1]);
|
||||||
|
if (doc) show(doc, ex, ey); else hide();
|
||||||
|
} catch (_e) { hide(); }
|
||||||
|
}, 350);
|
||||||
|
});
|
||||||
|
wrap.addEventListener('mouseleave', function () { if (timer) clearTimeout(timer); hide(); });
|
||||||
|
cm.on('cursorActivity', hide);
|
||||||
|
cm.on('changes', hide);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire completion (Ctrl-Space + auto-trigger as you type) and hover docs
|
||||||
|
// onto a CodeMirror instance. opts.readOnly skips the typing trigger;
|
||||||
|
// opts.hover:false skips hover.
|
||||||
|
function attach(cm, provider, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var hinter = makeHinter(provider);
|
||||||
|
var keys = Object.assign({}, cm.getOption('extraKeys') || {}, {
|
||||||
|
'Ctrl-Space': function (c) { c.showHint({ hint: hinter, completeSingle: false }); }
|
||||||
|
});
|
||||||
|
cm.setOption('extraKeys', keys);
|
||||||
|
if (!opts.readOnly) {
|
||||||
|
cm.on('inputRead', function (c, change) {
|
||||||
|
if (!change.text || change.text.length !== 1) return; // skip paste/delete
|
||||||
|
if (!/[\w.\-]/.test(change.text[0])) return;
|
||||||
|
c.showHint({ hint: hinter, completeSingle: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (opts.hover !== false) attachHover(cm, provider);
|
||||||
|
return hinter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Providers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Flat: a fixed field list [{name, hint, values}] at the root, nothing
|
||||||
|
// nested (front matter). opts.exclude = names never suggested.
|
||||||
|
function flatProvider(getFields, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var exclude = {};
|
||||||
|
(opts.exclude || []).forEach(function (n) { exclude[n] = true; });
|
||||||
|
function fields() { return getFields() || []; }
|
||||||
|
function find(name) {
|
||||||
|
var fs = fields();
|
||||||
|
for (var i = 0; i < fs.length; i++) if (fs[i].name === name) return fs[i];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
keysAt: function (path) {
|
||||||
|
if (path.length) return [];
|
||||||
|
return fields().filter(function (f) { return !exclude[f.name]; })
|
||||||
|
.map(function (f) { return { name: f.name, hint: f.hint, values: f.values }; });
|
||||||
|
},
|
||||||
|
valuesFor: function (path, key) {
|
||||||
|
if (path.length) return null;
|
||||||
|
var f = find(key); return f ? f.values : null;
|
||||||
|
},
|
||||||
|
describe: function (path, key) {
|
||||||
|
if (path.length) return null;
|
||||||
|
var f = find(key); return f ? f.hint : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema: a JSON Schema (draft-2020-12 subset). Resolves nested key-paths
|
||||||
|
// through properties / additionalProperties / patternProperties and the
|
||||||
|
// recursive $ref:"#" .zddc uses for paths:. Keys = object property names;
|
||||||
|
// values = enum / boolean.
|
||||||
|
function schemaProvider(getSchema) {
|
||||||
|
function root() { return getSchema(); }
|
||||||
|
function deref(node) { return (node && node.$ref === '#') ? root() : node; }
|
||||||
|
function stepInto(node, seg) {
|
||||||
|
node = deref(node);
|
||||||
|
if (!node || node.type !== 'object') return null;
|
||||||
|
if (node.properties && node.properties[seg]) return node.properties[seg];
|
||||||
|
if (node.additionalProperties && typeof node.additionalProperties === 'object') {
|
||||||
|
return node.additionalProperties;
|
||||||
|
}
|
||||||
|
if (node.patternProperties) {
|
||||||
|
for (var p in node.patternProperties) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(node.patternProperties, p)) {
|
||||||
|
return node.patternProperties[p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function containerAt(path) {
|
||||||
|
var node = deref(root());
|
||||||
|
for (var i = 0; i < path.length; i++) {
|
||||||
|
node = stepInto(node, path[i]);
|
||||||
|
if (!node) return null;
|
||||||
|
node = deref(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
function valuesOf(node) {
|
||||||
|
node = deref(node);
|
||||||
|
if (!node) return null;
|
||||||
|
if (Array.isArray(node.enum)) return node.enum.map(String);
|
||||||
|
if (node.type === 'boolean') return ['true', 'false'];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function keyNodeAt(path, key) {
|
||||||
|
var c = containerAt(path);
|
||||||
|
if (!c || !c.properties) return null;
|
||||||
|
return c.properties[key] || null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
keysAt: function (path) {
|
||||||
|
var c = containerAt(path);
|
||||||
|
if (!c || c.type !== 'object' || !c.properties) return [];
|
||||||
|
return Object.keys(c.properties).map(function (name) {
|
||||||
|
var n = deref(c.properties[name]) || {};
|
||||||
|
return { name: name, hint: n.description, values: valuesOf(n) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
valuesFor: function (path, key) { return valuesOf(keyNodeAt(path, key)); },
|
||||||
|
describe: function (path, key) {
|
||||||
|
var n = deref(keyNodeAt(path, key));
|
||||||
|
return n ? n.description : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.modules.yamlComplete = {
|
||||||
|
attach: attach,
|
||||||
|
makeHinter: makeHinter,
|
||||||
|
flatProvider: flatProvider,
|
||||||
|
schemaProvider: schemaProvider
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -684,9 +684,12 @@
|
||||||
var p = encodeURIComponent(project);
|
var p = encodeURIComponent(project);
|
||||||
var stages = [
|
var stages = [
|
||||||
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
||||||
{ id: 'stageWorking', href: '/' + p + '/working' },
|
// working/staging/reviewing get a trailing slash so the user lands
|
||||||
{ id: 'stageStaging', href: '/' + p + '/staging' },
|
// INSIDE the folder (the dir_tool browse listing of parties),
|
||||||
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
|
// not on the browse tool scoped at the project level.
|
||||||
|
{ id: 'stageWorking', href: '/' + p + '/working/' },
|
||||||
|
{ id: 'stageStaging', href: '/' + p + '/staging/' },
|
||||||
|
{ id: 'stageReviewing', href: '/' + p + '/reviewing/' },
|
||||||
];
|
];
|
||||||
for (var i = 0; i < stages.length; i++) {
|
for (var i = 0; i < stages.length; i++) {
|
||||||
var a = document.getElementById(stages[i].id);
|
var a = document.getElementById(stages[i].id);
|
||||||
|
|
|
||||||
1
shared/vendor/codemirror-show-hint.min.css
vendored
Normal file
1
shared/vendor/codemirror-show-hint.min.css
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}
|
||||||
1
shared/vendor/codemirror-show-hint.min.js
vendored
Normal file
1
shared/vendor/codemirror-show-hint.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -2665,7 +2665,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1876,7 +1876,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1619,7 +1619,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -3864,9 +3864,12 @@ body {
|
||||||
var p = encodeURIComponent(project);
|
var p = encodeURIComponent(project);
|
||||||
var stages = [
|
var stages = [
|
||||||
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
{ id: 'stageArchive', href: '/' + p + '/archive' },
|
||||||
{ id: 'stageWorking', href: '/' + p + '/working' },
|
// working/staging/reviewing get a trailing slash so the user lands
|
||||||
{ id: 'stageStaging', href: '/' + p + '/staging' },
|
// INSIDE the folder (the dir_tool browse listing of parties),
|
||||||
{ id: 'stageReviewing', href: '/' + p + '/reviewing' },
|
// not on the browse tool scoped at the project level.
|
||||||
|
{ id: 'stageWorking', href: '/' + p + '/working/' },
|
||||||
|
{ id: 'stageStaging', href: '/' + p + '/staging/' },
|
||||||
|
{ id: 'stageReviewing', href: '/' + p + '/reviewing/' },
|
||||||
];
|
];
|
||||||
for (var i = 0; i < stages.length; i++) {
|
for (var i = 0; i < stages.length; i++) {
|
||||||
var a = document.getElementById(stages[i].id);
|
var a = document.getElementById(stages[i].id);
|
||||||
|
|
|
||||||
|
|
@ -2718,7 +2718,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
archive=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
|
||||||
transmittal=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
transmittal=v0.0.27-beta · 2026-06-08 20:19:35 · ec9c9c7
|
||||||
classifier=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
classifier=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
landing=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
landing=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
form=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
form=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
tables=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
tables=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
browse=v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199
|
browse=v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@ type Metadata struct {
|
||||||
type FrontMatterField struct {
|
type FrontMatterField struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Hint string `json:"hint"`
|
Hint string `json:"hint"`
|
||||||
|
// Values is the closed set of valid values for this key (an enum), used
|
||||||
|
// by the editor for value completion. Empty/nil = free-text.
|
||||||
|
Values []string `json:"values,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecognizedFrontMatter is the single source of truth for the front-matter keys
|
// RecognizedFrontMatter is the single source of truth for the front-matter keys
|
||||||
|
|
@ -76,18 +79,20 @@ type FrontMatterField struct {
|
||||||
// user most needs told about.
|
// user most needs told about.
|
||||||
func RecognizedFrontMatter() []FrontMatterField {
|
func RecognizedFrontMatter() []FrontMatterField {
|
||||||
return []FrontMatterField{
|
return []FrontMatterField{
|
||||||
{"doctype", "report | letter | specification"},
|
// doctype enum tracks the template set (internal/convert/templates/
|
||||||
{"numbering", "true to number headings (default false)"},
|
// *.html, sans the _-prefixed partials).
|
||||||
{"title", "mirrors the filename — rename the file to change it"},
|
{"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
|
||||||
{"tracking_number", "mirrors the filename — rename the file to change it"},
|
{"numbering", "true to number headings (default false)", []string{"true", "false"}},
|
||||||
{"revision", "mirrors the filename — rename the file to change it"},
|
{"title", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"status", "mirrors the filename — rename the file to change it"},
|
{"tracking_number", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"date", "document date (free text)"},
|
{"revision", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"custom_header", "extra line shown in the document header"},
|
{"status", "mirrors the filename — rename the file to change it", nil},
|
||||||
{"client", "overrides the .zddc convert: cascade"},
|
{"date", "document date (free text)", nil},
|
||||||
{"project", "overrides the .zddc convert: cascade"},
|
{"custom_header", "extra line shown in the document header", nil},
|
||||||
{"project_number", "overrides the .zddc convert: cascade"},
|
{"client", "overrides the .zddc convert: cascade", nil},
|
||||||
{"contractor", "overrides the .zddc convert: cascade"},
|
{"project", "overrides the .zddc convert: cascade", nil},
|
||||||
|
{"project_number", "overrides the .zddc convert: cascade", nil},
|
||||||
|
{"contractor", "overrides the .zddc convert: cascade", nil},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1648,7 +1648,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 20:19:36 · ec9c9c7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue