Compare commits
No commits in common. "242d25d55acb2611ff531eb799023f30a675850e" and "d5ce4e123013af9af852ef0789f40fd5dcc6bea1" have entirely different histories.
242d25d55a
...
d5ce4e1230
11 changed files with 87 additions and 351 deletions
|
|
@ -172,35 +172,6 @@
|
||||||
return '---\n' + fm + '\n---\n' + (body || '');
|
return '---\n' + fm + '\n---\n' + (body || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ZDDC identity (filename is the single source of truth) ──────────────
|
|
||||||
// The four identity fields live in the filename. They're mirrored into the
|
|
||||||
// front matter so the converter (which reads FM) sees them, but the
|
|
||||||
// filename always wins: on open we sync FM ← filename, and editing one in
|
|
||||||
// the FM is treated as a cue to RENAME the file (see renderIdentityCue),
|
|
||||||
// never a silent value change. Maps the FM key ↔ the parseFilename field.
|
|
||||||
var IDENTITY_FIELDS = [
|
|
||||||
{ fm: 'title', fn: 'title', label: 'title' },
|
|
||||||
{ fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
|
|
||||||
{ fm: 'revision', fn: 'revision', label: 'revision' },
|
|
||||||
{ fm: 'status', fn: 'status', label: 'status' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// parseFilename → {title, tracking_number, revision, status} (non-empty
|
|
||||||
// fields only), or null when the name isn't a conventional ZDDC filename
|
|
||||||
// (no canonical identity — the editor stays fully usable on arbitrary
|
|
||||||
// directories, where FM is the sole source).
|
|
||||||
function filenameIdentity(filename) {
|
|
||||||
var z = window.zddc;
|
|
||||||
var fn = (z && z.parseFilename) ? z.parseFilename(filename) : null;
|
|
||||||
if (!fn || !fn.trackingNumber) return null;
|
|
||||||
var out = {};
|
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
|
||||||
var v = fn[f.fn];
|
|
||||||
if (v != null && String(v).trim() !== '') out[f.fm] = v;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TOC (table of contents) ────────────────────────────────────────────
|
// ── TOC (table of contents) ────────────────────────────────────────────
|
||||||
// ATX headings only; the body markdown drives the outline. Clicking
|
// ATX headings only; the body markdown drives the outline. Clicking
|
||||||
// a heading routes to whichever Toast UI pane is currently active
|
// a heading routes to whichever Toast UI pane is currently active
|
||||||
|
|
@ -443,18 +414,16 @@
|
||||||
fmTextarea.placeholder = '';
|
fmTextarea.placeholder = '';
|
||||||
applyFrontMatterPlaceholder(fmTextarea);
|
applyFrontMatterPlaceholder(fmTextarea);
|
||||||
fmBody.appendChild(fmTextarea);
|
fmBody.appendChild(fmTextarea);
|
||||||
// Rename cue: shown when the author edits an identity field
|
// Non-blocking warning shown when front matter disagrees with the
|
||||||
// (tracking_number / revision / status / title) away from the
|
// canonical filename on an identity field (tracking_number / revision /
|
||||||
// filename. The filename owns identity, so the cue offers an explicit
|
// status / title). The filename always wins in the rendered doc; this
|
||||||
// "Rename file & reopen" button rather than silently keeping or
|
// just tells the author their front-matter value is being ignored.
|
||||||
// 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;
|
fmWarn.hidden = true;
|
||||||
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:4px 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.4;';
|
||||||
+ 'center;gap:6px;';
|
|
||||||
fmSection.appendChild(fmHeader);
|
fmSection.appendChild(fmHeader);
|
||||||
fmSection.appendChild(fmWarn);
|
fmSection.appendChild(fmWarn);
|
||||||
fmSection.appendChild(fmBody);
|
fmSection.appendChild(fmBody);
|
||||||
|
|
@ -599,21 +568,10 @@
|
||||||
// bytes so that round-tripping a clean file shows "not dirty"
|
// bytes so that round-tripping a clean file shows "not dirty"
|
||||||
// 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;
|
|
||||||
// On open, mirror the filename-derived identity into the front matter
|
|
||||||
// (the filename is the single source of truth; this keeps the values
|
|
||||||
// baked in for the converter). No-op for non-ZDDC filenames. The dirty
|
|
||||||
// baseline stays the ON-DISK state, so a correction opens the buffer
|
|
||||||
// dirty and a save persists it.
|
|
||||||
var onDiskFM = stringifyFrontMatter(initialParsed.data);
|
|
||||||
var fid = filenameIdentity(node.name);
|
|
||||||
if (fid) {
|
|
||||||
for (var ik in fid) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
||||||
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
|
var bodyText = initialParsed.body;
|
||||||
|
|
||||||
|
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
|
||||||
var writableMode = canSave(node);
|
var writableMode = canSave(node);
|
||||||
// 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
|
||||||
|
|
@ -792,127 +750,49 @@
|
||||||
}, 250);
|
}, 250);
|
||||||
editor.on('change', onChange);
|
editor.on('change', onChange);
|
||||||
|
|
||||||
// Build the filename the current FM identity edits imply: the parsed
|
// Identity fields are sourced from the canonical ZDDC filename; setting
|
||||||
// filename parts, overlaid with any non-empty identity values the
|
// a different value in front matter is ignored at render (the filename
|
||||||
// author typed in the FM. "" when the name isn't ZDDC-conventional or
|
// wins). Surface a mismatch so the author isn't silently overridden.
|
||||||
// the result wouldn't be a valid ZDDC filename.
|
// Maps the front-matter key to the parseFilename field.
|
||||||
function renamedFilenameFromEdits(data) {
|
var IDENTITY_FIELDS = [
|
||||||
|
{ fm: 'title', fn: 'title', label: 'title' },
|
||||||
|
{ fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
|
||||||
|
{ fm: 'revision', fn: 'revision', label: 'revision' },
|
||||||
|
{ fm: 'status', fn: 'status', label: 'status' }
|
||||||
|
];
|
||||||
|
function checkFilenameMismatch() {
|
||||||
var z = window.zddc;
|
var z = window.zddc;
|
||||||
var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
|
var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
|
||||||
if (!fn || !fn.valid || !z.formatFilename) return '';
|
// Only meaningful for a conventional ZDDC filename (it always has a
|
||||||
var parts = {
|
// tracking number). Non-conventional files have no canonical
|
||||||
trackingNumber: fn.trackingNumber,
|
// identity, so front matter is free — no warning.
|
||||||
revision: fn.revision,
|
if (!fn || !fn.trackingNumber) { fmWarn.hidden = true; return; }
|
||||||
status: fn.status,
|
|
||||||
title: fn.title,
|
|
||||||
extension: fn.extension
|
|
||||||
};
|
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
|
||||||
if (!(f.fm in data)) return;
|
|
||||||
var v = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
|
||||||
if (v !== '') parts[f.fn] = v;
|
|
||||||
});
|
|
||||||
try { return z.formatFilename(parts); } catch (_e) { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The filename owns identity, so a manual edit to an identity field is
|
|
||||||
// a cue to RENAME the file, not a value to keep. When the FM identity
|
|
||||||
// differs from the filename, surface that + an explicit "Rename file &
|
|
||||||
// reopen" button. No-op for non-ZDDC names (no canonical identity).
|
|
||||||
function renderIdentityCue() {
|
|
||||||
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
|
|
||||||
var fid = filenameIdentity(node.name);
|
|
||||||
if (!fid || !canSave(node)) { fmWarn.hidden = true; return; }
|
|
||||||
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
||||||
var edits = [];
|
var clashes = [];
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
IDENTITY_FIELDS.forEach(function (f) {
|
||||||
if (!(f.fm in data)) return;
|
if (!(f.fm in data)) return;
|
||||||
var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
||||||
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
|
var want = String(fn[f.fn] == null ? '' : fn[f.fn]).trim();
|
||||||
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
|
if (got !== '' && want !== '' && got !== want) {
|
||||||
|
clashes.push(f.label + ' “' + got + '” ≠ filename “' + want + '”');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!edits.length) { fmWarn.hidden = true; return; }
|
if (!clashes.length) { fmWarn.hidden = true; fmWarn.textContent = ''; return; }
|
||||||
var msg = document.createElement('span');
|
fmWarn.textContent = '⚠ Front matter disagrees with the filename (the '
|
||||||
msg.textContent = '✎ Identity comes from the filename. You changed '
|
+ 'filename wins): ' + clashes.join('; ') + '.';
|
||||||
+ edits.join(', ') + '. ';
|
|
||||||
fmWarn.appendChild(msg);
|
|
||||||
var newName = renamedFilenameFromEdits(data);
|
|
||||||
if (newName && newName !== node.name) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'btn btn-sm md-fm__rename';
|
|
||||||
btn.textContent = 'Rename file & reopen';
|
|
||||||
btn.title = 'Save, rename to “' + newName + '”, and reopen it for editing.';
|
|
||||||
btn.addEventListener('click', function () { renameToMatch(newName); });
|
|
||||||
fmWarn.appendChild(btn);
|
|
||||||
}
|
|
||||||
fmWarn.hidden = false;
|
fmWarn.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename action: persist the current buffer (so body edits aren't
|
|
||||||
// lost), rename the file to match the edited identity, then reopen the
|
|
||||||
// new name fresh. Server mode reopens via the ?file deep-link (reuses
|
|
||||||
// the tested open-by-path walker); FS-Access mode reuses the moved
|
|
||||||
// handle in place.
|
|
||||||
async function renameToMatch(newName) {
|
|
||||||
var up = window.app.modules.upload;
|
|
||||||
if (!up || !up.renameNode || !newName) return;
|
|
||||||
// 1. Save first so body/FM edits survive the rename. A failed save
|
|
||||||
// (conflict, ACL) leaves the buffer dirty — abort the rename.
|
|
||||||
if (instance.dirty) {
|
|
||||||
await save();
|
|
||||||
if (currentInstance !== instance || instance.dirty) return;
|
|
||||||
}
|
|
||||||
// 2. Rename on disk.
|
|
||||||
try {
|
|
||||||
statusEl.textContent = 'Renaming…';
|
|
||||||
await up.renameNode(node, newName);
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.textContent = '';
|
|
||||||
if (window.zddc && window.zddc.toast) {
|
|
||||||
window.zddc.toast('Rename failed: ' + (e.message || e), 'error');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Drop the dirty guard so the reopen/navigation isn't blocked.
|
|
||||||
markDirty(false); instance.dirty = false;
|
|
||||||
// 3. Reopen the renamed file.
|
|
||||||
if (window.app.state.source === 'server') {
|
|
||||||
var tree = window.app.modules.tree;
|
|
||||||
var scope = (window.app.state.currentPath || '/').replace(/\/$/, '') + '/';
|
|
||||||
var oldPath = tree ? tree.pathFor(node) : '';
|
|
||||||
var dir = oldPath.slice(0, oldPath.lastIndexOf('/') + 1);
|
|
||||||
var newPath = dir + newName;
|
|
||||||
var rel = newPath.indexOf(scope) === 0 ? newPath.slice(scope.length) : newName;
|
|
||||||
var params = new URLSearchParams();
|
|
||||||
params.set('file', rel);
|
|
||||||
if (window.app.state.showHidden) params.set('hidden', '1');
|
|
||||||
window.location.assign(scope + '?' + params.toString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// FS-Access: the handle was renamed in place. Update the node,
|
|
||||||
// refresh the tree, reopen the editor on it.
|
|
||||||
node.name = newName;
|
|
||||||
var ev = window.app.modules.events;
|
|
||||||
if (ev && ev.refreshListing) { try { await ev.refreshListing(); } catch (_e) { /* swallow */ } }
|
|
||||||
var prev = window.app.modules.preview;
|
|
||||||
if (prev && prev.showFilePreview) prev.showFilePreview(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(fmTextarea.value, body));
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
markDirty(h !== instance.hash);
|
markDirty(h !== instance.hash);
|
||||||
renderIdentityCue();
|
checkFilenameMismatch();
|
||||||
}, 250);
|
}, 250);
|
||||||
fmTextarea.addEventListener('input', onFmChange);
|
fmTextarea.addEventListener('input', onFmChange);
|
||||||
renderIdentityCue(); // initial state on load (clean after sync-on-open)
|
checkFilenameMismatch(); // initial state on load
|
||||||
|
|
||||||
// If sync-on-open corrected the front matter, open the buffer dirty so
|
|
||||||
// a save bakes the filename-derived identity in.
|
|
||||||
if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true);
|
|
||||||
|
|
||||||
// ── 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
|
||||||
|
|
|
||||||
|
|
@ -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 11:50:20 · 0d7feb3</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>
|
||||||
|
|
|
||||||
|
|
@ -2639,7 +2639,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3</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 listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -10102,35 +10102,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
return '---\n' + fm + '\n---\n' + (body || '');
|
return '---\n' + fm + '\n---\n' + (body || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ZDDC identity (filename is the single source of truth) ──────────────
|
|
||||||
// The four identity fields live in the filename. They're mirrored into the
|
|
||||||
// front matter so the converter (which reads FM) sees them, but the
|
|
||||||
// filename always wins: on open we sync FM ← filename, and editing one in
|
|
||||||
// the FM is treated as a cue to RENAME the file (see renderIdentityCue),
|
|
||||||
// never a silent value change. Maps the FM key ↔ the parseFilename field.
|
|
||||||
var IDENTITY_FIELDS = [
|
|
||||||
{ fm: 'title', fn: 'title', label: 'title' },
|
|
||||||
{ fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
|
|
||||||
{ fm: 'revision', fn: 'revision', label: 'revision' },
|
|
||||||
{ fm: 'status', fn: 'status', label: 'status' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// parseFilename → {title, tracking_number, revision, status} (non-empty
|
|
||||||
// fields only), or null when the name isn't a conventional ZDDC filename
|
|
||||||
// (no canonical identity — the editor stays fully usable on arbitrary
|
|
||||||
// directories, where FM is the sole source).
|
|
||||||
function filenameIdentity(filename) {
|
|
||||||
var z = window.zddc;
|
|
||||||
var fn = (z && z.parseFilename) ? z.parseFilename(filename) : null;
|
|
||||||
if (!fn || !fn.trackingNumber) return null;
|
|
||||||
var out = {};
|
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
|
||||||
var v = fn[f.fn];
|
|
||||||
if (v != null && String(v).trim() !== '') out[f.fm] = v;
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TOC (table of contents) ────────────────────────────────────────────
|
// ── TOC (table of contents) ────────────────────────────────────────────
|
||||||
// ATX headings only; the body markdown drives the outline. Clicking
|
// ATX headings only; the body markdown drives the outline. Clicking
|
||||||
// a heading routes to whichever Toast UI pane is currently active
|
// a heading routes to whichever Toast UI pane is currently active
|
||||||
|
|
@ -10373,18 +10344,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
fmTextarea.placeholder = '';
|
fmTextarea.placeholder = '';
|
||||||
applyFrontMatterPlaceholder(fmTextarea);
|
applyFrontMatterPlaceholder(fmTextarea);
|
||||||
fmBody.appendChild(fmTextarea);
|
fmBody.appendChild(fmTextarea);
|
||||||
// Rename cue: shown when the author edits an identity field
|
// Non-blocking warning shown when front matter disagrees with the
|
||||||
// (tracking_number / revision / status / title) away from the
|
// canonical filename on an identity field (tracking_number / revision /
|
||||||
// filename. The filename owns identity, so the cue offers an explicit
|
// status / title). The filename always wins in the rendered doc; this
|
||||||
// "Rename file & reopen" button rather than silently keeping or
|
// just tells the author their front-matter value is being ignored.
|
||||||
// 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;
|
fmWarn.hidden = true;
|
||||||
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:4px 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.4;';
|
||||||
+ 'center;gap:6px;';
|
|
||||||
fmSection.appendChild(fmHeader);
|
fmSection.appendChild(fmHeader);
|
||||||
fmSection.appendChild(fmWarn);
|
fmSection.appendChild(fmWarn);
|
||||||
fmSection.appendChild(fmBody);
|
fmSection.appendChild(fmBody);
|
||||||
|
|
@ -10529,21 +10498,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// bytes so that round-tripping a clean file shows "not dirty"
|
// bytes so that round-tripping a clean file shows "not dirty"
|
||||||
// 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;
|
|
||||||
// On open, mirror the filename-derived identity into the front matter
|
|
||||||
// (the filename is the single source of truth; this keeps the values
|
|
||||||
// baked in for the converter). No-op for non-ZDDC filenames. The dirty
|
|
||||||
// baseline stays the ON-DISK state, so a correction opens the buffer
|
|
||||||
// dirty and a save persists it.
|
|
||||||
var onDiskFM = stringifyFrontMatter(initialParsed.data);
|
|
||||||
var fid = filenameIdentity(node.name);
|
|
||||||
if (fid) {
|
|
||||||
for (var ik in fid) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(fid, ik)) initialParsed.data[ik] = fid[ik];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
fmTextarea.value = stringifyFrontMatter(initialParsed.data);
|
||||||
var initialHash = await hashContent(assembleContent(onDiskFM, bodyText));
|
var bodyText = initialParsed.body;
|
||||||
|
|
||||||
|
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
|
||||||
var writableMode = canSave(node);
|
var writableMode = canSave(node);
|
||||||
// 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
|
||||||
|
|
@ -10722,127 +10680,49 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
}, 250);
|
}, 250);
|
||||||
editor.on('change', onChange);
|
editor.on('change', onChange);
|
||||||
|
|
||||||
// Build the filename the current FM identity edits imply: the parsed
|
// Identity fields are sourced from the canonical ZDDC filename; setting
|
||||||
// filename parts, overlaid with any non-empty identity values the
|
// a different value in front matter is ignored at render (the filename
|
||||||
// author typed in the FM. "" when the name isn't ZDDC-conventional or
|
// wins). Surface a mismatch so the author isn't silently overridden.
|
||||||
// the result wouldn't be a valid ZDDC filename.
|
// Maps the front-matter key to the parseFilename field.
|
||||||
function renamedFilenameFromEdits(data) {
|
var IDENTITY_FIELDS = [
|
||||||
|
{ fm: 'title', fn: 'title', label: 'title' },
|
||||||
|
{ fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' },
|
||||||
|
{ fm: 'revision', fn: 'revision', label: 'revision' },
|
||||||
|
{ fm: 'status', fn: 'status', label: 'status' }
|
||||||
|
];
|
||||||
|
function checkFilenameMismatch() {
|
||||||
var z = window.zddc;
|
var z = window.zddc;
|
||||||
var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
|
var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null;
|
||||||
if (!fn || !fn.valid || !z.formatFilename) return '';
|
// Only meaningful for a conventional ZDDC filename (it always has a
|
||||||
var parts = {
|
// tracking number). Non-conventional files have no canonical
|
||||||
trackingNumber: fn.trackingNumber,
|
// identity, so front matter is free — no warning.
|
||||||
revision: fn.revision,
|
if (!fn || !fn.trackingNumber) { fmWarn.hidden = true; return; }
|
||||||
status: fn.status,
|
|
||||||
title: fn.title,
|
|
||||||
extension: fn.extension
|
|
||||||
};
|
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
|
||||||
if (!(f.fm in data)) return;
|
|
||||||
var v = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
|
||||||
if (v !== '') parts[f.fn] = v;
|
|
||||||
});
|
|
||||||
try { return z.formatFilename(parts); } catch (_e) { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The filename owns identity, so a manual edit to an identity field is
|
|
||||||
// a cue to RENAME the file, not a value to keep. When the FM identity
|
|
||||||
// differs from the filename, surface that + an explicit "Rename file &
|
|
||||||
// reopen" button. No-op for non-ZDDC names (no canonical identity).
|
|
||||||
function renderIdentityCue() {
|
|
||||||
while (fmWarn.firstChild) fmWarn.removeChild(fmWarn.firstChild);
|
|
||||||
var fid = filenameIdentity(node.name);
|
|
||||||
if (!fid || !canSave(node)) { fmWarn.hidden = true; return; }
|
|
||||||
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {};
|
||||||
var edits = [];
|
var clashes = [];
|
||||||
IDENTITY_FIELDS.forEach(function (f) {
|
IDENTITY_FIELDS.forEach(function (f) {
|
||||||
if (!(f.fm in data)) return;
|
if (!(f.fm in data)) return;
|
||||||
var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
var got = String(data[f.fm] == null ? '' : data[f.fm]).trim();
|
||||||
var want = String(fid[f.fm] == null ? '' : fid[f.fm]).trim();
|
var want = String(fn[f.fn] == null ? '' : fn[f.fn]).trim();
|
||||||
if (got !== '' && got !== want) edits.push(f.label + ' → “' + got + '”');
|
if (got !== '' && want !== '' && got !== want) {
|
||||||
|
clashes.push(f.label + ' “' + got + '” ≠ filename “' + want + '”');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!edits.length) { fmWarn.hidden = true; return; }
|
if (!clashes.length) { fmWarn.hidden = true; fmWarn.textContent = ''; return; }
|
||||||
var msg = document.createElement('span');
|
fmWarn.textContent = '⚠ Front matter disagrees with the filename (the '
|
||||||
msg.textContent = '✎ Identity comes from the filename. You changed '
|
+ 'filename wins): ' + clashes.join('; ') + '.';
|
||||||
+ edits.join(', ') + '. ';
|
|
||||||
fmWarn.appendChild(msg);
|
|
||||||
var newName = renamedFilenameFromEdits(data);
|
|
||||||
if (newName && newName !== node.name) {
|
|
||||||
var btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'btn btn-sm md-fm__rename';
|
|
||||||
btn.textContent = 'Rename file & reopen';
|
|
||||||
btn.title = 'Save, rename to “' + newName + '”, and reopen it for editing.';
|
|
||||||
btn.addEventListener('click', function () { renameToMatch(newName); });
|
|
||||||
fmWarn.appendChild(btn);
|
|
||||||
}
|
|
||||||
fmWarn.hidden = false;
|
fmWarn.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename action: persist the current buffer (so body edits aren't
|
|
||||||
// lost), rename the file to match the edited identity, then reopen the
|
|
||||||
// new name fresh. Server mode reopens via the ?file deep-link (reuses
|
|
||||||
// the tested open-by-path walker); FS-Access mode reuses the moved
|
|
||||||
// handle in place.
|
|
||||||
async function renameToMatch(newName) {
|
|
||||||
var up = window.app.modules.upload;
|
|
||||||
if (!up || !up.renameNode || !newName) return;
|
|
||||||
// 1. Save first so body/FM edits survive the rename. A failed save
|
|
||||||
// (conflict, ACL) leaves the buffer dirty — abort the rename.
|
|
||||||
if (instance.dirty) {
|
|
||||||
await save();
|
|
||||||
if (currentInstance !== instance || instance.dirty) return;
|
|
||||||
}
|
|
||||||
// 2. Rename on disk.
|
|
||||||
try {
|
|
||||||
statusEl.textContent = 'Renaming…';
|
|
||||||
await up.renameNode(node, newName);
|
|
||||||
} catch (e) {
|
|
||||||
statusEl.textContent = '';
|
|
||||||
if (window.zddc && window.zddc.toast) {
|
|
||||||
window.zddc.toast('Rename failed: ' + (e.message || e), 'error');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Drop the dirty guard so the reopen/navigation isn't blocked.
|
|
||||||
markDirty(false); instance.dirty = false;
|
|
||||||
// 3. Reopen the renamed file.
|
|
||||||
if (window.app.state.source === 'server') {
|
|
||||||
var tree = window.app.modules.tree;
|
|
||||||
var scope = (window.app.state.currentPath || '/').replace(/\/$/, '') + '/';
|
|
||||||
var oldPath = tree ? tree.pathFor(node) : '';
|
|
||||||
var dir = oldPath.slice(0, oldPath.lastIndexOf('/') + 1);
|
|
||||||
var newPath = dir + newName;
|
|
||||||
var rel = newPath.indexOf(scope) === 0 ? newPath.slice(scope.length) : newName;
|
|
||||||
var params = new URLSearchParams();
|
|
||||||
params.set('file', rel);
|
|
||||||
if (window.app.state.showHidden) params.set('hidden', '1');
|
|
||||||
window.location.assign(scope + '?' + params.toString());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// FS-Access: the handle was renamed in place. Update the node,
|
|
||||||
// refresh the tree, reopen the editor on it.
|
|
||||||
node.name = newName;
|
|
||||||
var ev = window.app.modules.events;
|
|
||||||
if (ev && ev.refreshListing) { try { await ev.refreshListing(); } catch (_e) { /* swallow */ } }
|
|
||||||
var prev = window.app.modules.preview;
|
|
||||||
if (prev && prev.showFilePreview) prev.showFilePreview(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(fmTextarea.value, body));
|
||||||
if (currentInstance !== instance) return;
|
if (currentInstance !== instance) return;
|
||||||
markDirty(h !== instance.hash);
|
markDirty(h !== instance.hash);
|
||||||
renderIdentityCue();
|
checkFilenameMismatch();
|
||||||
}, 250);
|
}, 250);
|
||||||
fmTextarea.addEventListener('input', onFmChange);
|
fmTextarea.addEventListener('input', onFmChange);
|
||||||
renderIdentityCue(); // initial state on load (clean after sync-on-open)
|
checkFilenameMismatch(); // initial state on load
|
||||||
|
|
||||||
// If sync-on-open corrected the front matter, open the buffer dirty so
|
|
||||||
// a save bakes the filename-derived identity in.
|
|
||||||
if (writableMode && fmTextarea.value !== onDiskFM) markDirty(true);
|
|
||||||
|
|
||||||
// ── 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
|
||||||
|
|
|
||||||
|
|
@ -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 11:50:20 · 0d7feb3</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 11:50:20 · 0d7feb3</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -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 11:50:20 · 0d7feb3</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 11:50:20 · 0d7feb3
|
||||||
transmittal=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
transmittal=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||||
classifier=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
classifier=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||||
landing=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
landing=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||||
form=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
form=v0.0.27-beta · 2026-06-08 11:50:20 · 0d7feb3
|
||||||
tables=v0.0.27-beta · 2026-06-08 13:10:35 · 48b8199
|
tables=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
|
||||||
browse=v0.0.27-beta · 2026-06-08 13:10:36 · 48b8199
|
browse=v0.0.27-beta · 2026-06-08 11:50:21 · 0d7feb3
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,10 @@ func RecognizedFrontMatter() []FrontMatterField {
|
||||||
return []FrontMatterField{
|
return []FrontMatterField{
|
||||||
{"doctype", "report | letter | specification"},
|
{"doctype", "report | letter | specification"},
|
||||||
{"numbering", "true to number headings (default false)"},
|
{"numbering", "true to number headings (default false)"},
|
||||||
{"title", "mirrors the filename — rename the file to change it"},
|
{"title", "set by the filename (the filename wins on mismatch)"},
|
||||||
{"tracking_number", "mirrors the filename — rename the file to change it"},
|
{"tracking_number", "set by the filename (the filename wins on mismatch)"},
|
||||||
{"revision", "mirrors the filename — rename the file to change it"},
|
{"revision", "set by the filename (the filename wins on mismatch)"},
|
||||||
{"status", "mirrors the filename — rename the file to change it"},
|
{"status", "set by the filename (the filename wins on mismatch)"},
|
||||||
{"date", "document date (free text)"},
|
{"date", "document date (free text)"},
|
||||||
{"custom_header", "extra line shown in the document header"},
|
{"custom_header", "extra line shown in the document header"},
|
||||||
{"client", "overrides the .zddc convert: cascade"},
|
{"client", "overrides the .zddc convert: cascade"},
|
||||||
|
|
|
||||||
|
|
@ -922,16 +922,6 @@ func partySourceGate(fsRoot, abs string) (reject bool, msg, party string) {
|
||||||
if source == "" {
|
if source == "" {
|
||||||
return false, "", "" // peer does no party gating (e.g. ssr/)
|
return false, "", "" // peer does no party gating (e.g. ssr/)
|
||||||
}
|
}
|
||||||
// The gate only guards INTRODUCING a new party. Once the party
|
|
||||||
// directory exists on disk the party is established, so a PUT/move
|
|
||||||
// into its existing subtree (e.g. editing a file already filed under
|
|
||||||
// working/<party>/…) must not be blocked — the registration check is
|
|
||||||
// an onboarding guard, not a write gate. Without this, editing any
|
|
||||||
// pre-existing file under a party folder whose registry row is
|
|
||||||
// missing or differently-cased 409s on save.
|
|
||||||
if fi, err := os.Stat(filepath.Join(fsRoot, project, peer, p)); err == nil && fi.IsDir() {
|
|
||||||
return false, "", p
|
|
||||||
}
|
|
||||||
if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
|
if zddc.PartyRegistered(filepath.Join(fsRoot, project), source, p) {
|
||||||
return false, "", p
|
return false, "", p
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -854,20 +854,6 @@ func TestFileAPI_PartySourceGate(t *testing.T) {
|
||||||
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil {
|
||||||
t.Errorf("workspace folder not created: %v", err)
|
t.Errorf("workspace folder not created: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: a party folder that already exists on disk but whose
|
|
||||||
// registry row is missing (deleted, migrated, or differently-cased)
|
|
||||||
// must still accept writes. The gate guards INTRODUCING a party, not
|
|
||||||
// editing one that's established — without this, opening any
|
|
||||||
// pre-existing file under such a folder and saving it 409s. Create
|
|
||||||
// the folder out-of-band (no ssr/ row) and PUT into it.
|
|
||||||
if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Ghost"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rec = do(http.MethodPut, "/Proj/working/Ghost/note.md", "alice@example.com", []byte("# hi\n"), nil)
|
|
||||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
|
||||||
t.Errorf("PUT into existing unregistered party folder: want 201/200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// An in-place rename of a markdown file carries its .history/<stem>/ folder
|
// An in-place rename of a markdown file carries its .history/<stem>/ folder
|
||||||
|
|
|
||||||
|
|
@ -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 11:50:21 · 0d7feb3</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue