Compare commits
No commits in common. "e58e66a49c23e66ab816a9592e5f8164431d27c3" and "de046360e65096a84427af279c190b09752af937" have entirely different histories.
e58e66a49c
...
de046360e6
29 changed files with 19 additions and 2061 deletions
|
|
@ -32,7 +32,6 @@ concat_files \
|
||||||
"css/base.css" \
|
"css/base.css" \
|
||||||
"css/tree.css" \
|
"css/tree.css" \
|
||||||
"css/preview-yaml.css" \
|
"css/preview-yaml.css" \
|
||||||
"css/history.css" \
|
|
||||||
> "$css_temp"
|
> "$css_temp"
|
||||||
|
|
||||||
# JS files: shared canonical helpers, then browse modules.
|
# JS files: shared canonical helpers, then browse modules.
|
||||||
|
|
@ -48,7 +47,6 @@ concat_files \
|
||||||
"../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" \
|
||||||
"../shared/diff.js" \
|
|
||||||
"../shared/zip-source.js" \
|
"../shared/zip-source.js" \
|
||||||
"../shared/theme.js" \
|
"../shared/theme.js" \
|
||||||
"../shared/toast.js" \
|
"../shared/toast.js" \
|
||||||
|
|
@ -73,7 +71,6 @@ concat_files \
|
||||||
"js/plan-review.js" \
|
"js/plan-review.js" \
|
||||||
"js/accept-transmittal.js" \
|
"js/accept-transmittal.js" \
|
||||||
"js/stage.js" \
|
"js/stage.js" \
|
||||||
"js/history.js" \
|
|
||||||
"js/create-transmittal.js" \
|
"js/create-transmittal.js" \
|
||||||
"js/events.js" \
|
"js/events.js" \
|
||||||
"js/app.js" \
|
"js/app.js" \
|
||||||
|
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
/* history.css — markdown edit-history modal (browse/js/history.js). */
|
|
||||||
|
|
||||||
.md-history-box {
|
|
||||||
background: var(--bg, #fff);
|
|
||||||
color: var(--fg, #111);
|
|
||||||
padding: 1.1rem 1.35rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
min-width: 30rem;
|
|
||||||
max-width: 56rem;
|
|
||||||
width: 80vw;
|
|
||||||
max-height: 85vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-title {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0; /* allow inner scroll regions to shrink */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-hint {
|
|
||||||
margin: 0 0 0.6rem 0;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--muted, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-empty {
|
|
||||||
margin: 1rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--muted, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── version list ── */
|
|
||||||
.md-history-list {
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--border, #ddd);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-bottom: 1px solid var(--border, #eee);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-row:last-child { border-bottom: none; }
|
|
||||||
.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); }
|
|
||||||
|
|
||||||
.md-history-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-time { font-variant-numeric: tabular-nums; }
|
|
||||||
.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; }
|
|
||||||
.md-history-size { color: var(--muted, #888); font-size: 0.8rem; }
|
|
||||||
|
|
||||||
.md-history-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--accent, #3c82f6);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-actions { display: flex; gap: 0.35rem; }
|
|
||||||
|
|
||||||
/* ── single-version view ── */
|
|
||||||
.md-history-pre {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
border: 1px solid var(--border, #ddd);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--code-bg, #f7f7f8);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── diff view ── */
|
|
||||||
.md-diff {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--border, #ddd);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
||||||
.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; }
|
|
||||||
.md-diff-text { flex: 1 1 auto; }
|
|
||||||
|
|
||||||
.md-diff-add { background: rgba(46, 160, 67, 0.16); }
|
|
||||||
.md-diff-add .md-diff-gutter { color: #2ea043; }
|
|
||||||
.md-diff-del { background: rgba(248, 81, 73, 0.16); }
|
|
||||||
.md-diff-del .md-diff-gutter { color: #f85149; }
|
|
||||||
.md-diff-eq { color: var(--muted, #777); }
|
|
||||||
|
|
||||||
.md-diff-old { color: #f85149; }
|
|
||||||
.md-diff-new { color: #2ea043; }
|
|
||||||
|
|
||||||
/* ── footer ── */
|
|
||||||
.md-history-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
@ -1061,24 +1061,6 @@
|
||||||
if (s) s.invokeUnstage(c.node);
|
if (s) s.invokeUnstage(c.node);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// ── Version history (history:true subtree, real files only) ──
|
|
||||||
// Server-mode only: the audit trail (who saved when) is
|
|
||||||
// server-stamped, so there's no offline equivalent. node.history
|
|
||||||
// is set by the listing when this file sits in a history-enabled
|
|
||||||
// cascade subtree (working/).
|
|
||||||
{
|
|
||||||
label: 'History…',
|
|
||||||
icon: '🕘',
|
|
||||||
visible: function (c) {
|
|
||||||
if (!serverMode) return false;
|
|
||||||
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
|
|
||||||
return !!c.node.history;
|
|
||||||
},
|
|
||||||
action: function (c) {
|
|
||||||
var h = window.app.modules.history;
|
|
||||||
if (h) h.open(c.node);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
||||||
// ── View ──
|
// ── View ──
|
||||||
|
|
|
||||||
|
|
@ -1,396 +0,0 @@
|
||||||
// history.js — markdown edit-history viewer for the browse tool.
|
|
||||||
//
|
|
||||||
// Surfaced by events.js as a "History…" right-click item on files in a
|
|
||||||
// history:true cascade subtree (working/). Server mode only — the audit
|
|
||||||
// trail (who saved when) is stamped server-side, so there's no offline
|
|
||||||
// equivalent.
|
|
||||||
//
|
|
||||||
// Talks to the zddc-server history endpoints on the file's own URL:
|
|
||||||
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
|
|
||||||
// GET <url>?history=<sha> → that version's raw bytes
|
|
||||||
// Restore re-PUTs a chosen version's bytes to <url>, which the server
|
|
||||||
// records as a new version (forward-only; never destructive).
|
|
||||||
//
|
|
||||||
// Diffs are computed client-side via window.zddc.diff (shared/diff.js).
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toast(msg, kind) {
|
|
||||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
|
||||||
window.zddc.toast(msg, kind || 'info');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append ?history=<v> (or &history=) to a file URL.
|
|
||||||
function histURL(baseURL, v) {
|
|
||||||
var sep = baseURL.indexOf('?') === -1 ? '?' : '&';
|
|
||||||
return baseURL + sep + 'history=' + encodeURIComponent(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(ts) {
|
|
||||||
var d = new Date(ts);
|
|
||||||
if (isNaN(d.getTime())) return ts || '';
|
|
||||||
return d.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(n) {
|
|
||||||
if (n == null) return '';
|
|
||||||
if (n < 1024) return n + ' B';
|
|
||||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
||||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can the principal write (restore) to this file? Mirrors the
|
|
||||||
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
|
|
||||||
// backend (no cascade signal) → allow the attempt; otherwise check w.
|
|
||||||
function canRestore(node) {
|
|
||||||
if (!node || !node.url) return false;
|
|
||||||
if (!window.zddc || !window.zddc.cap) return true;
|
|
||||||
if (typeof node.verbs !== 'string') return true;
|
|
||||||
return window.zddc.cap.has(node, 'w');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchList(node) {
|
|
||||||
var resp = await fetch(histURL(node.url, '1'), {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
var data = await resp.json();
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchVersion(node, sha) {
|
|
||||||
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
return await resp.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modal shell ──────────────────────────────────────────────────────
|
|
||||||
// One overlay; its body is swapped between the list, a diff, and a
|
|
||||||
// single-version view. Returns { overlay, body, setTitle, close }.
|
|
||||||
function makeModal(titleText) {
|
|
||||||
var overlay = document.createElement('div');
|
|
||||||
overlay.className = 'modal-overlay md-history-overlay';
|
|
||||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
|
||||||
|
|
||||||
var box = document.createElement('div');
|
|
||||||
box.className = 'md-history-box';
|
|
||||||
|
|
||||||
var title = document.createElement('h2');
|
|
||||||
title.className = 'md-history-title';
|
|
||||||
title.textContent = titleText;
|
|
||||||
|
|
||||||
var body = document.createElement('div');
|
|
||||||
body.className = 'md-history-body';
|
|
||||||
|
|
||||||
box.appendChild(title);
|
|
||||||
box.appendChild(body);
|
|
||||||
overlay.appendChild(box);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
}
|
|
||||||
function onKey(e) { if (e.key === 'Escape') close(); }
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
overlay.addEventListener('mousedown', function (e) {
|
|
||||||
if (e.target === overlay) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
overlay: overlay,
|
|
||||||
body: body,
|
|
||||||
setTitle: function (t) { title.textContent = t; },
|
|
||||||
close: close
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function footerBar() {
|
|
||||||
var f = document.createElement('div');
|
|
||||||
f.className = 'md-history-footer';
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
function button(label, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.textContent = label;
|
|
||||||
if (opts.primary) b.className = 'btn-primary';
|
|
||||||
if (opts.disabled) b.disabled = true;
|
|
||||||
if (opts.onClick) b.addEventListener('click', opts.onClick);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List view ──────────────────────────────────────────────────────
|
|
||||||
function renderList(modal, node, entries) {
|
|
||||||
modal.setTitle('History — ' + node.name);
|
|
||||||
var body = modal.body;
|
|
||||||
body.innerHTML = '';
|
|
||||||
|
|
||||||
if (!entries.length) {
|
|
||||||
var empty = document.createElement('p');
|
|
||||||
empty.className = 'md-history-empty';
|
|
||||||
empty.textContent = 'No saved versions yet. Each save of this file is recorded here.';
|
|
||||||
body.appendChild(empty);
|
|
||||||
var f0 = footerBar();
|
|
||||||
f0.appendChild(button('Close', { onClick: modal.close }));
|
|
||||||
body.appendChild(f0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hint = document.createElement('p');
|
|
||||||
hint.className = 'md-history-hint';
|
|
||||||
hint.textContent = 'Newest first. Select two versions to diff.';
|
|
||||||
body.appendChild(hint);
|
|
||||||
|
|
||||||
var list = document.createElement('div');
|
|
||||||
list.className = 'md-history-list';
|
|
||||||
var selected = []; // shas, in click order (max 2)
|
|
||||||
|
|
||||||
var diffBtn;
|
|
||||||
function syncDiffBtn() {
|
|
||||||
if (diffBtn) diffBtn.disabled = selected.length !== 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach(function (ent) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'md-history-row' + (ent.current ? ' is-current' : '');
|
|
||||||
|
|
||||||
var cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.className = 'md-history-pick';
|
|
||||||
cb.addEventListener('change', function () {
|
|
||||||
if (cb.checked) {
|
|
||||||
selected.push(ent.sha);
|
|
||||||
// Keep at most two: drop the oldest selection.
|
|
||||||
if (selected.length > 2) {
|
|
||||||
var dropped = selected.shift();
|
|
||||||
var others = list.querySelectorAll('.md-history-pick');
|
|
||||||
others.forEach(function (o, i) {
|
|
||||||
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selected = selected.filter(function (s) { return s !== ent.sha; });
|
|
||||||
}
|
|
||||||
syncDiffBtn();
|
|
||||||
});
|
|
||||||
|
|
||||||
var meta = document.createElement('div');
|
|
||||||
meta.className = 'md-history-meta';
|
|
||||||
meta.innerHTML =
|
|
||||||
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
|
|
||||||
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
|
|
||||||
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
|
|
||||||
(ent.current ? '<span class="md-history-badge">current</span>' : '');
|
|
||||||
|
|
||||||
var actions = document.createElement('div');
|
|
||||||
actions.className = 'md-history-actions';
|
|
||||||
actions.appendChild(button('View', {
|
|
||||||
onClick: function () { renderView(modal, node, ent, entries); }
|
|
||||||
}));
|
|
||||||
if (!ent.current && canRestore(node)) {
|
|
||||||
actions.appendChild(button('Restore', {
|
|
||||||
onClick: function () { restore(modal, node, ent); }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
row.appendChild(cb);
|
|
||||||
row.appendChild(meta);
|
|
||||||
row.appendChild(actions);
|
|
||||||
list.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
body.appendChild(list);
|
|
||||||
|
|
||||||
var f = footerBar();
|
|
||||||
diffBtn = button('Diff selected', {
|
|
||||||
primary: true, disabled: true,
|
|
||||||
onClick: function () {
|
|
||||||
if (selected.length !== 2) return;
|
|
||||||
// Order oldest→newest by the entries' position (newest
|
|
||||||
// first in the list), so the diff reads old → new.
|
|
||||||
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
|
|
||||||
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
|
|
||||||
renderDiff(modal, node, picks[0], picks[1], entries);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
f.appendChild(diffBtn);
|
|
||||||
f.appendChild(button('Close', { onClick: modal.close }));
|
|
||||||
body.appendChild(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Single-version view ──────────────────────────────────────────────
|
|
||||||
async function renderView(modal, node, ent, entries) {
|
|
||||||
modal.setTitle('Version — ' + fmtTime(ent.ts));
|
|
||||||
var body = modal.body;
|
|
||||||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
|
||||||
var text;
|
|
||||||
try {
|
|
||||||
text = await fetchVersion(node, ent.sha);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = '';
|
|
||||||
var err = document.createElement('p');
|
|
||||||
err.className = 'md-history-empty';
|
|
||||||
err.textContent = 'Could not load this version: ' + (e.message || e);
|
|
||||||
body.appendChild(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
body.innerHTML = '';
|
|
||||||
var meta = document.createElement('p');
|
|
||||||
meta.className = 'md-history-hint';
|
|
||||||
meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts);
|
|
||||||
body.appendChild(meta);
|
|
||||||
|
|
||||||
var pre = document.createElement('pre');
|
|
||||||
pre.className = 'md-history-pre';
|
|
||||||
pre.textContent = text;
|
|
||||||
body.appendChild(pre);
|
|
||||||
|
|
||||||
var f = footerBar();
|
|
||||||
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
|
|
||||||
if (!ent.current && canRestore(node)) {
|
|
||||||
f.appendChild(button('Restore this version', {
|
|
||||||
primary: true, onClick: function () { restore(modal, node, ent); }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
body.appendChild(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Diff view ─────────────────────────────────────────────────────────
|
|
||||||
async function renderDiff(modal, node, oldEnt, newEnt, entries) {
|
|
||||||
modal.setTitle('Diff');
|
|
||||||
var body = modal.body;
|
|
||||||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
|
||||||
var oldText, newText;
|
|
||||||
try {
|
|
||||||
oldText = await fetchVersion(node, oldEnt.sha);
|
|
||||||
newText = await fetchVersion(node, newEnt.sha);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = '';
|
|
||||||
var err = document.createElement('p');
|
|
||||||
err.className = 'md-history-empty';
|
|
||||||
err.textContent = 'Could not load versions: ' + (e.message || e);
|
|
||||||
body.appendChild(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
body.innerHTML = '';
|
|
||||||
|
|
||||||
var hdr = document.createElement('p');
|
|
||||||
hdr.className = 'md-history-hint';
|
|
||||||
hdr.innerHTML =
|
|
||||||
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
|
|
||||||
' → ' +
|
|
||||||
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
|
|
||||||
body.appendChild(hdr);
|
|
||||||
|
|
||||||
var ops = (window.zddc && window.zddc.diff)
|
|
||||||
? window.zddc.diff.lines(oldText, newText)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var pane = document.createElement('div');
|
|
||||||
pane.className = 'md-diff';
|
|
||||||
if (!ops) {
|
|
||||||
pane.textContent = 'Diff unavailable (diff module not loaded).';
|
|
||||||
} else {
|
|
||||||
var unchanged = true;
|
|
||||||
ops.forEach(function (op) {
|
|
||||||
if (op.type !== 'eq') unchanged = false;
|
|
||||||
var line = document.createElement('div');
|
|
||||||
line.className = 'md-diff-line md-diff-' + op.type;
|
|
||||||
var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
|
|
||||||
var g = document.createElement('span');
|
|
||||||
g.className = 'md-diff-gutter';
|
|
||||||
g.textContent = gutter;
|
|
||||||
var t = document.createElement('span');
|
|
||||||
t.className = 'md-diff-text';
|
|
||||||
t.textContent = op.text;
|
|
||||||
line.appendChild(g);
|
|
||||||
line.appendChild(t);
|
|
||||||
pane.appendChild(line);
|
|
||||||
});
|
|
||||||
if (unchanged) {
|
|
||||||
var same = document.createElement('div');
|
|
||||||
same.className = 'md-diff-line md-diff-eq';
|
|
||||||
same.textContent = '(no differences)';
|
|
||||||
pane.appendChild(same);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body.appendChild(pane);
|
|
||||||
|
|
||||||
if (window.zddc && window.zddc.diff && ops) {
|
|
||||||
var s = window.zddc.diff.stats(ops);
|
|
||||||
var statline = document.createElement('p');
|
|
||||||
statline.className = 'md-history-hint';
|
|
||||||
statline.textContent = '+' + s.added + ' / −' + s.removed;
|
|
||||||
body.appendChild(statline);
|
|
||||||
}
|
|
||||||
|
|
||||||
var f = footerBar();
|
|
||||||
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
|
|
||||||
body.appendChild(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Restore ───────────────────────────────────────────────────────────
|
|
||||||
async function restore(modal, node, ent) {
|
|
||||||
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var text = await fetchVersion(node, ent.sha);
|
|
||||||
var resp = await fetch(node.url, {
|
|
||||||
method: 'PUT',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'text/markdown' },
|
|
||||||
body: text
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
toast('Restored version from ' + fmtTime(ent.ts), 'success');
|
|
||||||
// Reflect the new head: refetch the list.
|
|
||||||
var entries = await fetchList(node);
|
|
||||||
renderList(modal, node, entries);
|
|
||||||
// If the file is open in the preview pane, reload it.
|
|
||||||
var preview = window.app && window.app.modules && window.app.modules.preview;
|
|
||||||
if (preview && typeof preview.showFilePreview === 'function') {
|
|
||||||
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast('Restore failed: ' + (e.message || e), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entry point ─────────────────────────────────────────────────────
|
|
||||||
async function open(node) {
|
|
||||||
if (!node || !node.url) {
|
|
||||||
toast('History is only available in server mode.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var modal = makeModal('History — ' + node.name);
|
|
||||||
modal.body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
|
||||||
try {
|
|
||||||
var entries = await fetchList(node);
|
|
||||||
renderList(modal, node, entries);
|
|
||||||
} catch (e) {
|
|
||||||
modal.body.innerHTML = '';
|
|
||||||
var err = document.createElement('p');
|
|
||||||
err.className = 'md-history-empty';
|
|
||||||
err.textContent = 'Could not load history: ' + (e.message || e);
|
|
||||||
modal.body.appendChild(err);
|
|
||||||
var f = footerBar();
|
|
||||||
f.appendChild(button('Close', { onClick: modal.close }));
|
|
||||||
modal.body.appendChild(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.history = { open: open };
|
|
||||||
})();
|
|
||||||
|
|
@ -60,12 +60,6 @@
|
||||||
// whatever the server enforces on the
|
// whatever the server enforces on the
|
||||||
// actual PUT/DELETE still apply.
|
// actual PUT/DELETE still apply.
|
||||||
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
||||||
// Server-computed: true when this file lives in a history:true
|
|
||||||
// cascade subtree, so every save is versioned and
|
|
||||||
// GET <url>?history lists prior versions. Drives the "History…"
|
|
||||||
// context-menu affordance (server mode only — offline has no
|
|
||||||
// authenticated identity to attribute saves to).
|
|
||||||
history: !!e.history,
|
|
||||||
// FS-API specific (null in server mode):
|
// FS-API specific (null in server mode):
|
||||||
handle: null
|
handle: null
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,6 @@ export default defineConfig({
|
||||||
name: 'zddc',
|
name: 'zddc',
|
||||||
testMatch: 'zddc.spec.js',
|
testMatch: 'zddc.spec.js',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'diff',
|
|
||||||
testMatch: 'diff.spec.js',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'form-safety',
|
name: 'form-safety',
|
||||||
testMatch: 'form-safety.spec.js',
|
testMatch: 'form-safety.spec.js',
|
||||||
|
|
|
||||||
108
shared/diff.js
108
shared/diff.js
|
|
@ -1,108 +0,0 @@
|
||||||
/*
|
|
||||||
* shared/diff.js — a small, dependency-free text diff.
|
|
||||||
*
|
|
||||||
* Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which
|
|
||||||
* creates the window.zddc object). Used by the browse tool's markdown
|
|
||||||
* version-history viewer to show what changed between any two saved
|
|
||||||
* versions; kept in shared/ so other tools can reuse it.
|
|
||||||
*
|
|
||||||
* API:
|
|
||||||
* window.zddc.diff.lines(oldStr, newStr)
|
|
||||||
* → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS)
|
|
||||||
* window.zddc.diff.words(oldStr, newStr)
|
|
||||||
* → [{ type: 'eq'|'del'|'add', text }] token-level diff for one
|
|
||||||
* changed line (whitespace-preserving), for intra-line highlights
|
|
||||||
* window.zddc.diff.stats(ops) → { added, removed }
|
|
||||||
*
|
|
||||||
* The line diff trims the common prefix/suffix before running the O(n*m)
|
|
||||||
* LCS dynamic program, so a small edit in a large file stays cheap. A
|
|
||||||
* safety cap falls back to "replace whole block" when the changed middle
|
|
||||||
* is pathologically large, so the UI never freezes.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback
|
|
||||||
|
|
||||||
function splitLines(s) {
|
|
||||||
return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// LCS diff of two arrays of strings → ordered [{type, text}] ops.
|
|
||||||
function lcsDiff(a, b) {
|
|
||||||
var n = a.length, m = b.length;
|
|
||||||
if (n === 0 && m === 0) return [];
|
|
||||||
if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; });
|
|
||||||
if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; });
|
|
||||||
|
|
||||||
if (n * m > LCS_CELL_CAP) {
|
|
||||||
// Too large to diff finely without risking a UI stall: treat
|
|
||||||
// the whole block as a wholesale replacement.
|
|
||||||
var out = a.map(function (t) { return { type: 'del', text: t }; });
|
|
||||||
return out.concat(b.map(function (t) { return { type: 'add', text: t }; }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// dp[i][j] = LCS length of a[i:] and b[j:].
|
|
||||||
var dp = new Array(n + 1);
|
|
||||||
for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
|
|
||||||
for (var ii = n - 1; ii >= 0; ii--) {
|
|
||||||
for (var jj = m - 1; jj >= 0; jj--) {
|
|
||||||
if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1;
|
|
||||||
else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ops = [], i = 0, j = 0;
|
|
||||||
while (i < n && j < m) {
|
|
||||||
if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; }
|
|
||||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; }
|
|
||||||
else { ops.push({ type: 'add', text: b[j] }); j++; }
|
|
||||||
}
|
|
||||||
while (i < n) ops.push({ type: 'del', text: a[i++] });
|
|
||||||
while (j < m) ops.push({ type: 'add', text: b[j++] });
|
|
||||||
return ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffLines(oldStr, newStr) {
|
|
||||||
var a = splitLines(oldStr), b = splitLines(newStr);
|
|
||||||
var ops = [];
|
|
||||||
|
|
||||||
// Common prefix.
|
|
||||||
var start = 0;
|
|
||||||
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
||||||
// Common suffix (not overlapping the prefix).
|
|
||||||
var endA = a.length, endB = b.length;
|
|
||||||
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
||||||
|
|
||||||
for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] });
|
|
||||||
var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB));
|
|
||||||
for (var k = 0; k < mid.length; k++) ops.push(mid[k]);
|
|
||||||
for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] });
|
|
||||||
return ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whitespace-preserving tokenization: words and the runs of
|
|
||||||
// whitespace between them are separate tokens, so a re-diff lines up
|
|
||||||
// on word boundaries while keeping the original spacing renderable.
|
|
||||||
function tokenize(s) {
|
|
||||||
return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffWords(oldStr, newStr) {
|
|
||||||
return lcsDiff(tokenize(oldStr), tokenize(newStr));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stats(ops) {
|
|
||||||
var added = 0, removed = 0;
|
|
||||||
for (var i = 0; i < ops.length; i++) {
|
|
||||||
if (ops[i].type === 'add') added++;
|
|
||||||
else if (ops[i].type === 'del') removed++;
|
|
||||||
}
|
|
||||||
return { added: added, removed: removed };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.zddc) {
|
|
||||||
throw new Error('shared/diff.js: window.zddc must be loaded first');
|
|
||||||
}
|
|
||||||
window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats };
|
|
||||||
})();
|
|
||||||
|
|
@ -2,9 +2,8 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head><meta charset="utf-8"><title>ZDDC library test shim</title></head>
|
<head><meta charset="utf-8"><title>ZDDC library test shim</title></head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Loads shared/zddc.js + shared/hash.js + shared/diff.js so Playwright tests can call window.zddc.* -->
|
<!-- Loads shared/zddc.js + shared/hash.js so Playwright tests can call window.zddc.* -->
|
||||||
<script src="zddc.js"></script>
|
<script src="zddc.js"></script>
|
||||||
<script src="hash.js"></script>
|
<script src="hash.js"></script>
|
||||||
<script src="diff.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* Tests for shared/diff.js — the dependency-free text diff used by the
|
|
||||||
* browse tool's markdown version-history viewer.
|
|
||||||
*
|
|
||||||
* Runs against the same shim as zddc.spec.js (shared/zddc-test.html,
|
|
||||||
* which loads shared/diff.js and exposes window.zddc.diff).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const SHIM_PATH = 'file://' + path.resolve('shared/zddc-test.html');
|
|
||||||
|
|
||||||
async function diff(page, fn, ...args) {
|
|
||||||
return page.evaluate(
|
|
||||||
([fn, args]) => window.zddc.diff[fn](...args),
|
|
||||||
[fn, args]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto(SHIM_PATH, { waitUntil: 'load' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('diff module is attached to window.zddc', async ({ page }) => {
|
|
||||||
const present = await page.evaluate(() =>
|
|
||||||
!!(window.zddc && window.zddc.diff &&
|
|
||||||
typeof window.zddc.diff.lines === 'function' &&
|
|
||||||
typeof window.zddc.diff.words === 'function'));
|
|
||||||
expect(present).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('identical text produces only eq ops', async ({ page }) => {
|
|
||||||
const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nb\nc');
|
|
||||||
expect(ops.every(o => o.type === 'eq')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a changed middle line shows del then add', async ({ page }) => {
|
|
||||||
const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nB\nc');
|
|
||||||
const compact = ops.map(o => `${o.type}:${o.text}`).join('|');
|
|
||||||
expect(compact).toContain('eq:a');
|
|
||||||
expect(compact).toContain('del:b');
|
|
||||||
expect(compact).toContain('add:B');
|
|
||||||
expect(compact).toContain('eq:c');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stats count added and removed lines', async ({ page }) => {
|
|
||||||
const addStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb', 'a\nx\nb'));
|
|
||||||
expect(addStats).toEqual({ added: 1, removed: 0 });
|
|
||||||
|
|
||||||
const delStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb\nc', 'a\nc'));
|
|
||||||
expect(delStats).toEqual({ added: 0, removed: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pure insertion at end', async ({ page }) => {
|
|
||||||
const ops = await diff(page, 'lines', 'one\ntwo', 'one\ntwo\nthree');
|
|
||||||
const added = ops.filter(o => o.type === 'add').map(o => o.text);
|
|
||||||
expect(added).toEqual(['three']);
|
|
||||||
expect(ops.filter(o => o.type === 'del')).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('word diff aligns on word boundaries, preserving spaces', async ({ page }) => {
|
|
||||||
const ops = await diff(page, 'words', 'the quick fox', 'the slow fox');
|
|
||||||
const changed = ops.filter(o => o.type !== 'eq').map(o => `${o.type}:${o.text}`);
|
|
||||||
expect(changed).toEqual(['del:quick', 'add:slow']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('empty inputs do not throw', async ({ page }) => {
|
|
||||||
const ops = await diff(page, 'lines', '', '');
|
|
||||||
expect(Array.isArray(ops)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
@ -1323,23 +1323,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
// see RecognizeVirtualConvert). The .md source serves
|
// see RecognizeVirtualConvert). The .md source serves
|
||||||
// normally here.)
|
// normally here.)
|
||||||
|
|
||||||
// Edit-history: ACL already passed (parent-dir chain).
|
// Record-history list: GET <record>.yaml?history=1 returns the
|
||||||
// - Records (.yaml rows): GET <record>.yaml?history=1 lists prior
|
// list of prior revisions stored under <dir>/.history/<base>/.
|
||||||
// revisions stored under <dir>/.history/<base>/ (audit in-body).
|
// ACL already passed (parent-dir chain). Non-record paths fall
|
||||||
// - Text (markdown) under a history: true subtree:
|
// through to the normal file serve.
|
||||||
// ?history=1 lists versions; ?history=<sha> returns that version's
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" {
|
||||||
// bytes. Audit lives in <dir>/.history/<stem>/log.jsonl.
|
|
||||||
// Non-history paths fall through to the normal file serve.
|
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
|
|
||||||
version := r.URL.Query().Get("history")
|
|
||||||
if handler.IsTextHistoryCandidate(absPath) {
|
|
||||||
if chain.EffectiveHistory() {
|
|
||||||
handler.ServeTextHistory(w, r, absPath, version)
|
|
||||||
} else {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
handler.ServeHistoryList(w, r, absPath)
|
handler.ServeHistoryList(w, r, absPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2582,7 +2582,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.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
|
<span class="build-timestamp">v0.0.24</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>
|
||||||
|
|
|
||||||
|
|
@ -2329,138 +2329,6 @@ body {
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* history.css — markdown edit-history modal (browse/js/history.js). */
|
|
||||||
|
|
||||||
.md-history-box {
|
|
||||||
background: var(--bg, #fff);
|
|
||||||
color: var(--fg, #111);
|
|
||||||
padding: 1.1rem 1.35rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
min-width: 30rem;
|
|
||||||
max-width: 56rem;
|
|
||||||
width: 80vw;
|
|
||||||
max-height: 85vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-title {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0; /* allow inner scroll regions to shrink */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-hint {
|
|
||||||
margin: 0 0 0.6rem 0;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--muted, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-empty {
|
|
||||||
margin: 1rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--muted, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── version list ── */
|
|
||||||
.md-history-list {
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--border, #ddd);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
border-bottom: 1px solid var(--border, #eee);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-row:last-child { border-bottom: none; }
|
|
||||||
.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); }
|
|
||||||
|
|
||||||
.md-history-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-time { font-variant-numeric: tabular-nums; }
|
|
||||||
.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; }
|
|
||||||
.md-history-size { color: var(--muted, #888); font-size: 0.8rem; }
|
|
||||||
|
|
||||||
.md-history-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--accent, #3c82f6);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-history-actions { display: flex; gap: 0.35rem; }
|
|
||||||
|
|
||||||
/* ── single-version view ── */
|
|
||||||
.md-history-pre {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
border: 1px solid var(--border, #ddd);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--code-bg, #f7f7f8);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── diff view ── */
|
|
||||||
.md-diff {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--border, #ddd);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
||||||
.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; }
|
|
||||||
.md-diff-text { flex: 1 1 auto; }
|
|
||||||
|
|
||||||
.md-diff-add { background: rgba(46, 160, 67, 0.16); }
|
|
||||||
.md-diff-add .md-diff-gutter { color: #2ea043; }
|
|
||||||
.md-diff-del { background: rgba(248, 81, 73, 0.16); }
|
|
||||||
.md-diff-del .md-diff-gutter { color: #f85149; }
|
|
||||||
.md-diff-eq { color: var(--muted, #777); }
|
|
||||||
|
|
||||||
.md-diff-old { color: #f85149; }
|
|
||||||
.md-diff-new { color: #2ea043; }
|
|
||||||
|
|
||||||
/* ── footer ── */
|
|
||||||
.md-history-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -2476,7 +2344,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.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
|
<span class="build-timestamp">v0.0.24</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>
|
||||||
|
|
@ -4359,115 +4227,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
window.zddc.filter = { parse: parse, matches: matches };
|
window.zddc.filter = { parse: parse, matches: matches };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/*
|
|
||||||
* shared/diff.js — a small, dependency-free text diff.
|
|
||||||
*
|
|
||||||
* Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which
|
|
||||||
* creates the window.zddc object). Used by the browse tool's markdown
|
|
||||||
* version-history viewer to show what changed between any two saved
|
|
||||||
* versions; kept in shared/ so other tools can reuse it.
|
|
||||||
*
|
|
||||||
* API:
|
|
||||||
* window.zddc.diff.lines(oldStr, newStr)
|
|
||||||
* → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS)
|
|
||||||
* window.zddc.diff.words(oldStr, newStr)
|
|
||||||
* → [{ type: 'eq'|'del'|'add', text }] token-level diff for one
|
|
||||||
* changed line (whitespace-preserving), for intra-line highlights
|
|
||||||
* window.zddc.diff.stats(ops) → { added, removed }
|
|
||||||
*
|
|
||||||
* The line diff trims the common prefix/suffix before running the O(n*m)
|
|
||||||
* LCS dynamic program, so a small edit in a large file stays cheap. A
|
|
||||||
* safety cap falls back to "replace whole block" when the changed middle
|
|
||||||
* is pathologically large, so the UI never freezes.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback
|
|
||||||
|
|
||||||
function splitLines(s) {
|
|
||||||
return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// LCS diff of two arrays of strings → ordered [{type, text}] ops.
|
|
||||||
function lcsDiff(a, b) {
|
|
||||||
var n = a.length, m = b.length;
|
|
||||||
if (n === 0 && m === 0) return [];
|
|
||||||
if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; });
|
|
||||||
if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; });
|
|
||||||
|
|
||||||
if (n * m > LCS_CELL_CAP) {
|
|
||||||
// Too large to diff finely without risking a UI stall: treat
|
|
||||||
// the whole block as a wholesale replacement.
|
|
||||||
var out = a.map(function (t) { return { type: 'del', text: t }; });
|
|
||||||
return out.concat(b.map(function (t) { return { type: 'add', text: t }; }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// dp[i][j] = LCS length of a[i:] and b[j:].
|
|
||||||
var dp = new Array(n + 1);
|
|
||||||
for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0);
|
|
||||||
for (var ii = n - 1; ii >= 0; ii--) {
|
|
||||||
for (var jj = m - 1; jj >= 0; jj--) {
|
|
||||||
if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1;
|
|
||||||
else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ops = [], i = 0, j = 0;
|
|
||||||
while (i < n && j < m) {
|
|
||||||
if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; }
|
|
||||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; }
|
|
||||||
else { ops.push({ type: 'add', text: b[j] }); j++; }
|
|
||||||
}
|
|
||||||
while (i < n) ops.push({ type: 'del', text: a[i++] });
|
|
||||||
while (j < m) ops.push({ type: 'add', text: b[j++] });
|
|
||||||
return ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffLines(oldStr, newStr) {
|
|
||||||
var a = splitLines(oldStr), b = splitLines(newStr);
|
|
||||||
var ops = [];
|
|
||||||
|
|
||||||
// Common prefix.
|
|
||||||
var start = 0;
|
|
||||||
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
||||||
// Common suffix (not overlapping the prefix).
|
|
||||||
var endA = a.length, endB = b.length;
|
|
||||||
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
||||||
|
|
||||||
for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] });
|
|
||||||
var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB));
|
|
||||||
for (var k = 0; k < mid.length; k++) ops.push(mid[k]);
|
|
||||||
for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] });
|
|
||||||
return ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whitespace-preserving tokenization: words and the runs of
|
|
||||||
// whitespace between them are separate tokens, so a re-diff lines up
|
|
||||||
// on word boundaries while keeping the original spacing renderable.
|
|
||||||
function tokenize(s) {
|
|
||||||
return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; });
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffWords(oldStr, newStr) {
|
|
||||||
return lcsDiff(tokenize(oldStr), tokenize(newStr));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stats(ops) {
|
|
||||||
var added = 0, removed = 0;
|
|
||||||
for (var i = 0; i < ops.length; i++) {
|
|
||||||
if (ops[i].type === 'add') added++;
|
|
||||||
else if (ops[i].type === 'del') removed++;
|
|
||||||
}
|
|
||||||
return { added: added, removed: removed };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!window.zddc) {
|
|
||||||
throw new Error('shared/diff.js: window.zddc must be loaded first');
|
|
||||||
}
|
|
||||||
window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// shared/zip-source.js — present the contents of a .zip as a tree of
|
// shared/zip-source.js — present the contents of a .zip as a tree of
|
||||||
// File System Access API handles, so tools written against
|
// File System Access API handles, so tools written against
|
||||||
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner,
|
// FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner,
|
||||||
|
|
@ -7031,12 +6790,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
// whatever the server enforces on the
|
// whatever the server enforces on the
|
||||||
// actual PUT/DELETE still apply.
|
// actual PUT/DELETE still apply.
|
||||||
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
verbs: typeof e.verbs === 'string' ? e.verbs : undefined,
|
||||||
// Server-computed: true when this file lives in a history:true
|
|
||||||
// cascade subtree, so every save is versioned and
|
|
||||||
// GET <url>?history lists prior versions. Drives the "History…"
|
|
||||||
// context-menu affordance (server mode only — offline has no
|
|
||||||
// authenticated identity to attribute saves to).
|
|
||||||
history: !!e.history,
|
|
||||||
// FS-API specific (null in server mode):
|
// FS-API specific (null in server mode):
|
||||||
handle: null
|
handle: null
|
||||||
};
|
};
|
||||||
|
|
@ -11622,403 +11375,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// history.js — markdown edit-history viewer for the browse tool.
|
|
||||||
//
|
|
||||||
// Surfaced by events.js as a "History…" right-click item on files in a
|
|
||||||
// history:true cascade subtree (working/). Server mode only — the audit
|
|
||||||
// trail (who saved when) is stamped server-side, so there's no offline
|
|
||||||
// equivalent.
|
|
||||||
//
|
|
||||||
// Talks to the zddc-server history endpoints on the file's own URL:
|
|
||||||
// GET <url>?history=1 → JSON [{ts, by, sha, prev, bytes, current}]
|
|
||||||
// GET <url>?history=<sha> → that version's raw bytes
|
|
||||||
// Restore re-PUTs a chosen version's bytes to <url>, which the server
|
|
||||||
// records as a new version (forward-only; never destructive).
|
|
||||||
//
|
|
||||||
// Diffs are computed client-side via window.zddc.diff (shared/diff.js).
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return String(s == null ? '' : s)
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toast(msg, kind) {
|
|
||||||
if (window.zddc && typeof window.zddc.toast === 'function') {
|
|
||||||
window.zddc.toast(msg, kind || 'info');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append ?history=<v> (or &history=) to a file URL.
|
|
||||||
function histURL(baseURL, v) {
|
|
||||||
var sep = baseURL.indexOf('?') === -1 ? '?' : '&';
|
|
||||||
return baseURL + sep + 'history=' + encodeURIComponent(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(ts) {
|
|
||||||
var d = new Date(ts);
|
|
||||||
if (isNaN(d.getTime())) return ts || '';
|
|
||||||
return d.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtBytes(n) {
|
|
||||||
if (n == null) return '';
|
|
||||||
if (n < 1024) return n + ' B';
|
|
||||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
||||||
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can the principal write (restore) to this file? Mirrors the
|
|
||||||
// events.js Rename/Delete gating: verbs===undefined means a non-zddc
|
|
||||||
// backend (no cascade signal) → allow the attempt; otherwise check w.
|
|
||||||
function canRestore(node) {
|
|
||||||
if (!node || !node.url) return false;
|
|
||||||
if (!window.zddc || !window.zddc.cap) return true;
|
|
||||||
if (typeof node.verbs !== 'string') return true;
|
|
||||||
return window.zddc.cap.has(node, 'w');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchList(node) {
|
|
||||||
var resp = await fetch(histURL(node.url, '1'), {
|
|
||||||
headers: { 'Accept': 'application/json' },
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
var data = await resp.json();
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchVersion(node, sha) {
|
|
||||||
var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' });
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
return await resp.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modal shell ──────────────────────────────────────────────────────
|
|
||||||
// One overlay; its body is swapped between the list, a diff, and a
|
|
||||||
// single-version view. Returns { overlay, body, setTitle, close }.
|
|
||||||
function makeModal(titleText) {
|
|
||||||
var overlay = document.createElement('div');
|
|
||||||
overlay.className = 'modal-overlay md-history-overlay';
|
|
||||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
|
||||||
|
|
||||||
var box = document.createElement('div');
|
|
||||||
box.className = 'md-history-box';
|
|
||||||
|
|
||||||
var title = document.createElement('h2');
|
|
||||||
title.className = 'md-history-title';
|
|
||||||
title.textContent = titleText;
|
|
||||||
|
|
||||||
var body = document.createElement('div');
|
|
||||||
body.className = 'md-history-body';
|
|
||||||
|
|
||||||
box.appendChild(title);
|
|
||||||
box.appendChild(body);
|
|
||||||
overlay.appendChild(box);
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
|
||||||
document.removeEventListener('keydown', onKey);
|
|
||||||
}
|
|
||||||
function onKey(e) { if (e.key === 'Escape') close(); }
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
overlay.addEventListener('mousedown', function (e) {
|
|
||||||
if (e.target === overlay) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
overlay: overlay,
|
|
||||||
body: body,
|
|
||||||
setTitle: function (t) { title.textContent = t; },
|
|
||||||
close: close
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function footerBar() {
|
|
||||||
var f = document.createElement('div');
|
|
||||||
f.className = 'md-history-footer';
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
function button(label, opts) {
|
|
||||||
opts = opts || {};
|
|
||||||
var b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.textContent = label;
|
|
||||||
if (opts.primary) b.className = 'btn-primary';
|
|
||||||
if (opts.disabled) b.disabled = true;
|
|
||||||
if (opts.onClick) b.addEventListener('click', opts.onClick);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List view ──────────────────────────────────────────────────────
|
|
||||||
function renderList(modal, node, entries) {
|
|
||||||
modal.setTitle('History — ' + node.name);
|
|
||||||
var body = modal.body;
|
|
||||||
body.innerHTML = '';
|
|
||||||
|
|
||||||
if (!entries.length) {
|
|
||||||
var empty = document.createElement('p');
|
|
||||||
empty.className = 'md-history-empty';
|
|
||||||
empty.textContent = 'No saved versions yet. Each save of this file is recorded here.';
|
|
||||||
body.appendChild(empty);
|
|
||||||
var f0 = footerBar();
|
|
||||||
f0.appendChild(button('Close', { onClick: modal.close }));
|
|
||||||
body.appendChild(f0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hint = document.createElement('p');
|
|
||||||
hint.className = 'md-history-hint';
|
|
||||||
hint.textContent = 'Newest first. Select two versions to diff.';
|
|
||||||
body.appendChild(hint);
|
|
||||||
|
|
||||||
var list = document.createElement('div');
|
|
||||||
list.className = 'md-history-list';
|
|
||||||
var selected = []; // shas, in click order (max 2)
|
|
||||||
|
|
||||||
var diffBtn;
|
|
||||||
function syncDiffBtn() {
|
|
||||||
if (diffBtn) diffBtn.disabled = selected.length !== 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach(function (ent) {
|
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'md-history-row' + (ent.current ? ' is-current' : '');
|
|
||||||
|
|
||||||
var cb = document.createElement('input');
|
|
||||||
cb.type = 'checkbox';
|
|
||||||
cb.className = 'md-history-pick';
|
|
||||||
cb.addEventListener('change', function () {
|
|
||||||
if (cb.checked) {
|
|
||||||
selected.push(ent.sha);
|
|
||||||
// Keep at most two: drop the oldest selection.
|
|
||||||
if (selected.length > 2) {
|
|
||||||
var dropped = selected.shift();
|
|
||||||
var others = list.querySelectorAll('.md-history-pick');
|
|
||||||
others.forEach(function (o, i) {
|
|
||||||
if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selected = selected.filter(function (s) { return s !== ent.sha; });
|
|
||||||
}
|
|
||||||
syncDiffBtn();
|
|
||||||
});
|
|
||||||
|
|
||||||
var meta = document.createElement('div');
|
|
||||||
meta.className = 'md-history-meta';
|
|
||||||
meta.innerHTML =
|
|
||||||
'<span class="md-history-time">' + escapeHtml(fmtTime(ent.ts)) + '</span>' +
|
|
||||||
'<span class="md-history-by">' + escapeHtml(ent.by || '—') + '</span>' +
|
|
||||||
'<span class="md-history-size">' + escapeHtml(fmtBytes(ent.bytes)) + '</span>' +
|
|
||||||
(ent.current ? '<span class="md-history-badge">current</span>' : '');
|
|
||||||
|
|
||||||
var actions = document.createElement('div');
|
|
||||||
actions.className = 'md-history-actions';
|
|
||||||
actions.appendChild(button('View', {
|
|
||||||
onClick: function () { renderView(modal, node, ent, entries); }
|
|
||||||
}));
|
|
||||||
if (!ent.current && canRestore(node)) {
|
|
||||||
actions.appendChild(button('Restore', {
|
|
||||||
onClick: function () { restore(modal, node, ent); }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
row.appendChild(cb);
|
|
||||||
row.appendChild(meta);
|
|
||||||
row.appendChild(actions);
|
|
||||||
list.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
body.appendChild(list);
|
|
||||||
|
|
||||||
var f = footerBar();
|
|
||||||
diffBtn = button('Diff selected', {
|
|
||||||
primary: true, disabled: true,
|
|
||||||
onClick: function () {
|
|
||||||
if (selected.length !== 2) return;
|
|
||||||
// Order oldest→newest by the entries' position (newest
|
|
||||||
// first in the list), so the diff reads old → new.
|
|
||||||
var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; });
|
|
||||||
picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); });
|
|
||||||
renderDiff(modal, node, picks[0], picks[1], entries);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
f.appendChild(diffBtn);
|
|
||||||
f.appendChild(button('Close', { onClick: modal.close }));
|
|
||||||
body.appendChild(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Single-version view ──────────────────────────────────────────────
|
|
||||||
async function renderView(modal, node, ent, entries) {
|
|
||||||
modal.setTitle('Version — ' + fmtTime(ent.ts));
|
|
||||||
var body = modal.body;
|
|
||||||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
|
||||||
var text;
|
|
||||||
try {
|
|
||||||
text = await fetchVersion(node, ent.sha);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = '';
|
|
||||||
var err = document.createElement('p');
|
|
||||||
err.className = 'md-history-empty';
|
|
||||||
err.textContent = 'Could not load this version: ' + (e.message || e);
|
|
||||||
body.appendChild(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
body.innerHTML = '';
|
|
||||||
var meta = document.createElement('p');
|
|
||||||
meta.className = 'md-history-hint';
|
|
||||||
meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts);
|
|
||||||
body.appendChild(meta);
|
|
||||||
|
|
||||||
var pre = document.createElement('pre');
|
|
||||||
pre.className = 'md-history-pre';
|
|
||||||
pre.textContent = text;
|
|
||||||
body.appendChild(pre);
|
|
||||||
|
|
||||||
var f = footerBar();
|
|
||||||
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
|
|
||||||
if (!ent.current && canRestore(node)) {
|
|
||||||
f.appendChild(button('Restore this version', {
|
|
||||||
primary: true, onClick: function () { restore(modal, node, ent); }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
body.appendChild(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Diff view ─────────────────────────────────────────────────────────
|
|
||||||
async function renderDiff(modal, node, oldEnt, newEnt, entries) {
|
|
||||||
modal.setTitle('Diff');
|
|
||||||
var body = modal.body;
|
|
||||||
body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
|
||||||
var oldText, newText;
|
|
||||||
try {
|
|
||||||
oldText = await fetchVersion(node, oldEnt.sha);
|
|
||||||
newText = await fetchVersion(node, newEnt.sha);
|
|
||||||
} catch (e) {
|
|
||||||
body.innerHTML = '';
|
|
||||||
var err = document.createElement('p');
|
|
||||||
err.className = 'md-history-empty';
|
|
||||||
err.textContent = 'Could not load versions: ' + (e.message || e);
|
|
||||||
body.appendChild(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
body.innerHTML = '';
|
|
||||||
|
|
||||||
var hdr = document.createElement('p');
|
|
||||||
hdr.className = 'md-history-hint';
|
|
||||||
hdr.innerHTML =
|
|
||||||
'<span class="md-diff-old">' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '</span>' +
|
|
||||||
' → ' +
|
|
||||||
'<span class="md-diff-new">' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + '</span>';
|
|
||||||
body.appendChild(hdr);
|
|
||||||
|
|
||||||
var ops = (window.zddc && window.zddc.diff)
|
|
||||||
? window.zddc.diff.lines(oldText, newText)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var pane = document.createElement('div');
|
|
||||||
pane.className = 'md-diff';
|
|
||||||
if (!ops) {
|
|
||||||
pane.textContent = 'Diff unavailable (diff module not loaded).';
|
|
||||||
} else {
|
|
||||||
var unchanged = true;
|
|
||||||
ops.forEach(function (op) {
|
|
||||||
if (op.type !== 'eq') unchanged = false;
|
|
||||||
var line = document.createElement('div');
|
|
||||||
line.className = 'md-diff-line md-diff-' + op.type;
|
|
||||||
var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' ');
|
|
||||||
var g = document.createElement('span');
|
|
||||||
g.className = 'md-diff-gutter';
|
|
||||||
g.textContent = gutter;
|
|
||||||
var t = document.createElement('span');
|
|
||||||
t.className = 'md-diff-text';
|
|
||||||
t.textContent = op.text;
|
|
||||||
line.appendChild(g);
|
|
||||||
line.appendChild(t);
|
|
||||||
pane.appendChild(line);
|
|
||||||
});
|
|
||||||
if (unchanged) {
|
|
||||||
var same = document.createElement('div');
|
|
||||||
same.className = 'md-diff-line md-diff-eq';
|
|
||||||
same.textContent = '(no differences)';
|
|
||||||
pane.appendChild(same);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
body.appendChild(pane);
|
|
||||||
|
|
||||||
if (window.zddc && window.zddc.diff && ops) {
|
|
||||||
var s = window.zddc.diff.stats(ops);
|
|
||||||
var statline = document.createElement('p');
|
|
||||||
statline.className = 'md-history-hint';
|
|
||||||
statline.textContent = '+' + s.added + ' / −' + s.removed;
|
|
||||||
body.appendChild(statline);
|
|
||||||
}
|
|
||||||
|
|
||||||
var f = footerBar();
|
|
||||||
f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } }));
|
|
||||||
body.appendChild(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Restore ───────────────────────────────────────────────────────────
|
|
||||||
async function restore(modal, node, ent) {
|
|
||||||
if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
var text = await fetchVersion(node, ent.sha);
|
|
||||||
var resp = await fetch(node.url, {
|
|
||||||
method: 'PUT',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
headers: { 'Content-Type': 'text/markdown' },
|
|
||||||
body: text
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
toast('Restored version from ' + fmtTime(ent.ts), 'success');
|
|
||||||
// Reflect the new head: refetch the list.
|
|
||||||
var entries = await fetchList(node);
|
|
||||||
renderList(modal, node, entries);
|
|
||||||
// If the file is open in the preview pane, reload it.
|
|
||||||
var preview = window.app && window.app.modules && window.app.modules.preview;
|
|
||||||
if (preview && typeof preview.showFilePreview === 'function') {
|
|
||||||
try { preview.showFilePreview(node); } catch (_e) { /* best effort */ }
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast('Restore failed: ' + (e.message || e), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entry point ─────────────────────────────────────────────────────
|
|
||||||
async function open(node) {
|
|
||||||
if (!node || !node.url) {
|
|
||||||
toast('History is only available in server mode.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var modal = makeModal('History — ' + node.name);
|
|
||||||
modal.body.innerHTML = '<p class="md-history-hint">Loading…</p>';
|
|
||||||
try {
|
|
||||||
var entries = await fetchList(node);
|
|
||||||
renderList(modal, node, entries);
|
|
||||||
} catch (e) {
|
|
||||||
modal.body.innerHTML = '';
|
|
||||||
var err = document.createElement('p');
|
|
||||||
err.className = 'md-history-empty';
|
|
||||||
err.textContent = 'Could not load history: ' + (e.message || e);
|
|
||||||
modal.body.appendChild(err);
|
|
||||||
var f = footerBar();
|
|
||||||
f.appendChild(button('Close', { onClick: modal.close }));
|
|
||||||
modal.body.appendChild(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.history = { open: open };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// create-transmittal.js — folder-creation plumbing for outgoing
|
// create-transmittal.js — folder-creation plumbing for outgoing
|
||||||
// transmittals.
|
// transmittals.
|
||||||
//
|
//
|
||||||
|
|
@ -13229,24 +12585,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
if (s) s.invokeUnstage(c.node);
|
if (s) s.invokeUnstage(c.node);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// ── Version history (history:true subtree, real files only) ──
|
|
||||||
// Server-mode only: the audit trail (who saved when) is
|
|
||||||
// server-stamped, so there's no offline equivalent. node.history
|
|
||||||
// is set by the listing when this file sits in a history-enabled
|
|
||||||
// cascade subtree (working/).
|
|
||||||
{
|
|
||||||
label: 'History…',
|
|
||||||
icon: '🕘',
|
|
||||||
visible: function (c) {
|
|
||||||
if (!serverMode) return false;
|
|
||||||
if (c.node.isDir || c.node.isZip || c.node.virtual) return false;
|
|
||||||
return !!c.node.history;
|
|
||||||
},
|
|
||||||
action: function (c) {
|
|
||||||
var h = window.app.modules.history;
|
|
||||||
if (h) h.open(c.node);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
|
||||||
// ── View ──
|
// ── View ──
|
||||||
|
|
|
||||||
|
|
@ -1793,7 +1793,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.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
|
<span class="build-timestamp">v0.0.24</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>
|
||||||
|
|
|
||||||
|
|
@ -1536,7 +1536,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.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
|
<span class="build-timestamp">v0.0.24</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2635,7 +2635,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.25-beta · 2026-05-28 19:19:53 · 9972e67</span></span>
|
<span class="build-timestamp">v0.0.24</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.25-beta · 2026-05-28 19:19:54 · 9972e67
|
archive=v0.0.24
|
||||||
transmittal=v0.0.25-beta · 2026-05-28 19:19:53 · 9972e67
|
transmittal=v0.0.24
|
||||||
classifier=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
|
classifier=v0.0.24
|
||||||
landing=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
|
landing=v0.0.24
|
||||||
form=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
|
form=v0.0.24
|
||||||
tables=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
|
tables=v0.0.24
|
||||||
browse=v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
|
browse=v0.0.24
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,6 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
|
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
|
||||||
parentActiveAdmin := elevated && userEmail != "" &&
|
parentActiveAdmin := elevated && userEmail != "" &&
|
||||||
zddc.IsAdminForChain(parentChain, userEmail)
|
zddc.IsAdminForChain(parentChain, userEmail)
|
||||||
// Edit-history is a subtree behavior; resolved once for this dir and
|
|
||||||
// flagged on each eligible (markdown) file so the browse client knows
|
|
||||||
// where to offer the History/diff affordances.
|
|
||||||
historyEnabled := parentChain.EffectiveHistory()
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
@ -193,7 +189,6 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
writableBit = zddc.VerbA
|
writableBit = zddc.VerbA
|
||||||
}
|
}
|
||||||
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
|
fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin
|
||||||
fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md")
|
|
||||||
result = append(result, fi)
|
result = append(result, fi)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -432,16 +432,6 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
finalBody = res.FinalBody
|
finalBody = res.FinalBody
|
||||||
stamped = true
|
stamped = true
|
||||||
} else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
|
|
||||||
// History-enabled text (markdown) files: snapshot every save
|
|
||||||
// into <dir>/.history/<stem>/ with a server-stamped audit line,
|
|
||||||
// then write the live file. The live file at its natural path
|
|
||||||
// remains the source of truth.
|
|
||||||
if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil {
|
|
||||||
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if err := zddc.WriteAtomic(abs, body); err != nil {
|
if err := zddc.WriteAtomic(abs, body); err != nil {
|
||||||
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
|
||||||
|
|
|
||||||
|
|
@ -802,243 +802,3 @@ func atoiSafe(s string) int {
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Markdown / text edit-history ────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// History-enabled text files (a `history: true` .zddc subtree — see
|
|
||||||
// zddc.PolicyChain.EffectiveHistory) keep every saved version under
|
|
||||||
// <dir>/.history/<stem>/. Unlike records, text files can't carry audit
|
|
||||||
// fields in-body, so authorship + ordering live in a sidecar log:
|
|
||||||
//
|
|
||||||
// .history/<stem>/<contentSha>.md one immutable blob per distinct content
|
|
||||||
// .history/<stem>/log.jsonl one MdHistoryEntry per save, in order
|
|
||||||
//
|
|
||||||
// Blobs are content-addressed (so reverting to an earlier exact state
|
|
||||||
// reuses its blob); the live file's content always equals the last log
|
|
||||||
// entry's sha. Email + timestamp are stamped server-side from the
|
|
||||||
// authenticated principal — never client-supplied, mirroring the record
|
|
||||||
// path's anti-forgery stance.
|
|
||||||
|
|
||||||
const mdHistoryLogName = "log.jsonl"
|
|
||||||
|
|
||||||
// MdHistoryEntry is one saved version of a history-tracked text file.
|
|
||||||
type MdHistoryEntry struct {
|
|
||||||
Ts string `json:"ts"` // RFC3339Nano UTC of the save
|
|
||||||
By string `json:"by"` // authenticated principal email ("" if pre-history)
|
|
||||||
Sha string `json:"sha"` // content hash = version id = blob stem
|
|
||||||
Prev string `json:"prev,omitempty"` // prior version's sha
|
|
||||||
Bytes int `json:"bytes"` // size of this version
|
|
||||||
Current bool `json:"current,omitempty"` // derived by ListMdHistory; never persisted
|
|
||||||
}
|
|
||||||
|
|
||||||
// mdVersionIDRe validates a client-supplied version id so it can't
|
|
||||||
// escape the history dir. Version ids are lowercase hex content hashes.
|
|
||||||
var mdVersionIDRe = regexp.MustCompile(`^[0-9a-f]{1,64}$`)
|
|
||||||
|
|
||||||
// IsTextHistoryCandidate reports whether abs is a text file eligible for
|
|
||||||
// edit-history versioning. Scoped to markdown for now (the browse editor
|
|
||||||
// surface); widen here to add .txt etc.
|
|
||||||
func IsTextHistoryCandidate(abs string) bool {
|
|
||||||
return strings.EqualFold(filepath.Ext(abs), ".md")
|
|
||||||
}
|
|
||||||
|
|
||||||
func mdHistoryDir(abs string) string {
|
|
||||||
return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteTextWithHistory snapshots prior and new content into
|
|
||||||
// .history/<stem>/, appends an audit line, then writes the live file
|
|
||||||
// atomically. No-op saves (content identical to the current head) don't
|
|
||||||
// create a version. History is written BEFORE the live file so a crash
|
|
||||||
// can't lose a version the live write would have superseded.
|
|
||||||
func WriteTextWithHistory(abs string, body []byte, principalEmail string) error {
|
|
||||||
histDir := mdHistoryDir(abs)
|
|
||||||
logPath := filepath.Join(histDir, mdHistoryLogName)
|
|
||||||
ext := filepath.Ext(abs)
|
|
||||||
|
|
||||||
// Prior live content (nil on create).
|
|
||||||
var prior []byte
|
|
||||||
priorSha := ""
|
|
||||||
priorMtime := ""
|
|
||||||
if info, err := os.Stat(abs); err == nil && !info.IsDir() {
|
|
||||||
if data, rerr := os.ReadFile(abs); rerr == nil {
|
|
||||||
prior = data
|
|
||||||
priorSha = fileETag(prior)
|
|
||||||
priorMtime = info.ModTime().UTC().Format(time.RFC3339Nano)
|
|
||||||
}
|
|
||||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
newSha := fileETag(body)
|
|
||||||
if principalEmail == "" {
|
|
||||||
principalEmail = "anonymous"
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(histDir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("mkdir history: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeBlob := func(sha string, data []byte) error {
|
|
||||||
blob := filepath.Join(histDir, sha+ext)
|
|
||||||
if _, err := os.Stat(blob); errors.Is(err, os.ErrNotExist) {
|
|
||||||
return zddc.WriteAtomic(blob, data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
logExisted := false
|
|
||||||
if _, err := os.Stat(logPath); err == nil {
|
|
||||||
logExisted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []MdHistoryEntry
|
|
||||||
if logExisted {
|
|
||||||
existing, err := readMdLog(logPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entries = existing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lazy-seed: a file that pre-existed history enablement has prior
|
|
||||||
// bytes but no log. Capture that state as the origin version so the
|
|
||||||
// chain isn't missing its start. Authorship is unknown ("").
|
|
||||||
if prior != nil && !logExisted {
|
|
||||||
if err := writeBlob(priorSha, prior); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ts := priorMtime
|
|
||||||
if ts == "" {
|
|
||||||
ts = now
|
|
||||||
}
|
|
||||||
entries = append(entries, MdHistoryEntry{Ts: ts, By: "", Sha: priorSha, Bytes: len(prior)})
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSha := ""
|
|
||||||
if len(entries) > 0 {
|
|
||||||
lastSha = entries[len(entries)-1].Sha
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record this save unless it's a no-op (identical to the head).
|
|
||||||
changed := newSha != lastSha
|
|
||||||
if changed {
|
|
||||||
if err := writeBlob(newSha, body); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
entries = append(entries, MdHistoryEntry{Ts: now, By: principalEmail, Sha: newSha, Prev: priorSha, Bytes: len(body)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rewrite the log atomically (CIFS-safe; avoids partial-append
|
|
||||||
// corruption) only when something actually changed.
|
|
||||||
if changed || (prior != nil && !logExisted) {
|
|
||||||
if err := writeMdLog(logPath, entries); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return zddc.WriteAtomic(abs, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readMdLog(path string) ([]MdHistoryEntry, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var out []MdHistoryEntry
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var e MdHistoryEntry
|
|
||||||
if err := json.Unmarshal([]byte(line), &e); err != nil {
|
|
||||||
continue // skip a malformed line rather than fail the whole read
|
|
||||||
}
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeMdLog(path string, entries []MdHistoryEntry) error {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, e := range entries {
|
|
||||||
e.Current = false // never persist the derived flag
|
|
||||||
b, err := json.Marshal(e)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sb.Write(b)
|
|
||||||
sb.WriteByte('\n')
|
|
||||||
}
|
|
||||||
return zddc.WriteAtomic(path, []byte(sb.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListMdHistory returns the saved versions of abs, newest first, with
|
|
||||||
// Current set on the version whose content matches the live file.
|
|
||||||
func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
|
|
||||||
logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName)
|
|
||||||
entries, err := readMdLog(logPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
liveSha := ""
|
|
||||||
if data, err := os.ReadFile(abs); err == nil {
|
|
||||||
liveSha = fileETag(data)
|
|
||||||
}
|
|
||||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Ts > entries[j].Ts })
|
|
||||||
// Mark the newest entry whose content matches the live file as the
|
|
||||||
// current version. A revert reuses an earlier blob, so the same sha
|
|
||||||
// can appear twice — only the most recent save is "current".
|
|
||||||
for i := range entries {
|
|
||||||
if entries[i].Sha == liveSha {
|
|
||||||
entries[i].Current = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeTextHistory dispatches GET <file>?history=... for history-enabled
|
|
||||||
// text files: `?history=1` (or empty / `list`) returns the version list
|
|
||||||
// as JSON; `?history=<sha>` returns that version's raw bytes. ACL on the
|
|
||||||
// live file has already been checked by the caller.
|
|
||||||
func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) {
|
|
||||||
if !IsTextHistoryCandidate(abs) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if version == "" || version == "1" || version == "list" {
|
|
||||||
entries, err := ListMdHistory(abs)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("X-ZDDC-Source", "history-list")
|
|
||||||
_ = json.NewEncoder(w).Encode(entries)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !mdVersionIDRe.MatchString(version) {
|
|
||||||
http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
blob := filepath.Join(mdHistoryDir(abs), version+filepath.Ext(abs))
|
|
||||||
data, err := os.ReadFile(blob)
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
|
||||||
w.Header().Set("X-ZDDC-Source", "history-version")
|
|
||||||
w.Header().Set("X-ZDDC-History-Version", version)
|
|
||||||
if r.Method == http.MethodHead {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(data)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mustNoErr(t *testing.T, err error) {
|
|
||||||
t.Helper()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func countBlobs(t *testing.T, histDir string) int {
|
|
||||||
t.Helper()
|
|
||||||
ents, err := os.ReadDir(histDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read history dir: %v", err)
|
|
||||||
}
|
|
||||||
n := 0
|
|
||||||
for _, e := range ents {
|
|
||||||
if strings.HasSuffix(e.Name(), ".md") {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
abs := filepath.Join(dir, "notes.md")
|
|
||||||
histDir := filepath.Join(dir, ".history", "notes")
|
|
||||||
sha1 := fileETag([]byte("v1"))
|
|
||||||
sha2 := fileETag([]byte("v2"))
|
|
||||||
|
|
||||||
// ── create ──
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com"))
|
|
||||||
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
|
||||||
t.Fatalf("live = %q, want v1", b)
|
|
||||||
}
|
|
||||||
entries, err := ListMdHistory(abs)
|
|
||||||
mustNoErr(t, err)
|
|
||||||
if len(entries) != 1 {
|
|
||||||
t.Fatalf("after create: want 1 entry, got %d", len(entries))
|
|
||||||
}
|
|
||||||
if entries[0].By != "alice@x.com" {
|
|
||||||
t.Errorf("by = %q, want alice@x.com", entries[0].By)
|
|
||||||
}
|
|
||||||
if entries[0].Sha != sha1 {
|
|
||||||
t.Errorf("sha = %q, want %q", entries[0].Sha, sha1)
|
|
||||||
}
|
|
||||||
if !entries[0].Current {
|
|
||||||
t.Errorf("v1 should be current")
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil {
|
|
||||||
t.Errorf("v1 blob missing: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── update ──
|
|
||||||
time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
|
||||||
if b, _ := os.ReadFile(abs); string(b) != "v2" {
|
|
||||||
t.Fatalf("live = %q, want v2", b)
|
|
||||||
}
|
|
||||||
entries, _ = ListMdHistory(abs)
|
|
||||||
if len(entries) != 2 {
|
|
||||||
t.Fatalf("after update: want 2 entries, got %d", len(entries))
|
|
||||||
}
|
|
||||||
// newest first
|
|
||||||
if entries[0].Sha != sha2 || !entries[0].Current {
|
|
||||||
t.Errorf("head = %+v, want v2 current", entries[0])
|
|
||||||
}
|
|
||||||
if entries[0].Prev != sha1 {
|
|
||||||
t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1)
|
|
||||||
}
|
|
||||||
if entries[1].Sha != sha1 || entries[1].Current {
|
|
||||||
t.Errorf("tail = %+v, want v1 non-current", entries[1])
|
|
||||||
}
|
|
||||||
if entries[1].By != "alice@x.com" {
|
|
||||||
t.Errorf("v1 author lost: %q", entries[1].By)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── no-op save (identical content) → dedup, no new entry ──
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com"))
|
|
||||||
entries, _ = ListMdHistory(abs)
|
|
||||||
if len(entries) != 2 {
|
|
||||||
t.Fatalf("dedup failed: want 2 entries, got %d", len(entries))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── restore v1 content → new log entry, blob reused ──
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com"))
|
|
||||||
if b, _ := os.ReadFile(abs); string(b) != "v1" {
|
|
||||||
t.Fatalf("live = %q, want restored v1", b)
|
|
||||||
}
|
|
||||||
entries, _ = ListMdHistory(abs)
|
|
||||||
if len(entries) != 3 {
|
|
||||||
t.Fatalf("after restore: want 3 entries, got %d", len(entries))
|
|
||||||
}
|
|
||||||
if entries[0].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" {
|
|
||||||
t.Errorf("head = %+v, want restored v1 by carol, current", entries[0])
|
|
||||||
}
|
|
||||||
// Only the newest matching entry is current, even though the oldest
|
|
||||||
// entry has the same sha.
|
|
||||||
if entries[2].Current {
|
|
||||||
t.Errorf("oldest v1 entry should not be current: %+v", entries[2])
|
|
||||||
}
|
|
||||||
// Content-addressed: only two distinct blobs (v1, v2) despite 3 saves.
|
|
||||||
if n := countBlobs(t, histDir); n != 2 {
|
|
||||||
t.Errorf("distinct blobs = %d, want 2", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
abs := filepath.Join(dir, "doc.md")
|
|
||||||
// Simulate a file that existed before history was enabled.
|
|
||||||
mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy")))
|
|
||||||
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com"))
|
|
||||||
|
|
||||||
entries, err := ListMdHistory(abs)
|
|
||||||
mustNoErr(t, err)
|
|
||||||
if len(entries) != 2 {
|
|
||||||
t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries))
|
|
||||||
}
|
|
||||||
// newest = the edit; oldest = the seeded legacy version (author unknown)
|
|
||||||
if entries[0].By != "dave@x.com" || entries[0].Sha != fileETag([]byte("edited")) {
|
|
||||||
t.Errorf("head = %+v, want edit by dave", entries[0])
|
|
||||||
}
|
|
||||||
if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) {
|
|
||||||
t.Errorf("seed = %+v, want legacy with empty author", entries[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
abs := filepath.Join(dir, "x.md")
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), ""))
|
|
||||||
entries, _ := ListMdHistory(abs)
|
|
||||||
if len(entries) != 1 || entries[0].By != "anonymous" {
|
|
||||||
t.Fatalf("empty author should record anonymous, got %+v", entries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServeTextHistory_ListAndVersion(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
abs := filepath.Join(dir, "page.md")
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com"))
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com"))
|
|
||||||
sha1 := fileETag([]byte("one"))
|
|
||||||
|
|
||||||
// ── list ──
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeTextHistory(rec, req, abs, "1")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("list status = %d", rec.Code)
|
|
||||||
}
|
|
||||||
var got []MdHistoryEntry
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
||||||
t.Fatalf("list body not JSON: %v", err)
|
|
||||||
}
|
|
||||||
if len(got) != 2 || got[0].By != "b@x.com" {
|
|
||||||
t.Fatalf("list = %+v, want 2 newest-first", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── specific version content ──
|
|
||||||
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
ServeTextHistory(rec, req, abs, sha1)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("version status = %d", rec.Code)
|
|
||||||
}
|
|
||||||
if rec.Body.String() != "one" {
|
|
||||||
t.Errorf("version body = %q, want %q", rec.Body.String(), "one")
|
|
||||||
}
|
|
||||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
|
|
||||||
t.Errorf("content-type = %q", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
abs := filepath.Join(dir, "p.md")
|
|
||||||
mustNoErr(t, WriteTextWithHistory(abs, []byte("x"), "a@x.com"))
|
|
||||||
// Drop a secret in the parent so a successful traversal would be visible.
|
|
||||||
mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET")))
|
|
||||||
|
|
||||||
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "deadbeef.md"} {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeTextHistory(rec, req, abs, bad)
|
|
||||||
if rec.Code == http.StatusOK {
|
|
||||||
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
|
|
||||||
}
|
|
||||||
if strings.Contains(rec.Body.String(), "TOPSECRET") {
|
|
||||||
t.Fatalf("traversal leaked secret for input %q", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-markdown path → 404 (history not applicable).
|
|
||||||
yamlAbs := filepath.Join(dir, "rec.yaml")
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeTextHistory(rec, req, yamlAbs, "1")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("non-md status = %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1534,7 +1534,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.25-beta · 2026-05-28 19:19:54 · 9972e67</span></span>
|
<span class="build-timestamp">v0.0.24</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,4 @@ type FileInfo struct {
|
||||||
// as "no permissions known" and fall back to a server round-trip
|
// as "no permissions known" and fall back to a server round-trip
|
||||||
// (or just disable affordances) rather than assuming any grant.
|
// (or just disable affordances) rather than assuming any grant.
|
||||||
Verbs string `json:"verbs,omitempty"`
|
Verbs string `json:"verbs,omitempty"`
|
||||||
|
|
||||||
// History is true when this entry is a text file in a history: true
|
|
||||||
// cascade subtree — i.e. every save is versioned into the sibling
|
|
||||||
// .history/ store and GET <url>?history=1 lists prior versions. The
|
|
||||||
// browse client uses it to show "History…" / diff affordances only
|
|
||||||
// where they apply. False/absent for directories, virtual entries,
|
|
||||||
// and files outside a history-enabled subtree.
|
|
||||||
History bool `json:"history,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,25 +47,6 @@ func (chain PolicyChain) VisibleStart(toIdx int) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveHistory reports whether edit-history versioning is enabled
|
|
||||||
// for writes at this chain's directory. Unlike DropTarget (leaf-only),
|
|
||||||
// history is a subtree behavior: the closest-to-leaf explicit setting
|
|
||||||
// wins and applies to all descendants. It deliberately IGNORES
|
|
||||||
// inherit:false ACL fences — versioning is a write behavior, not a
|
|
||||||
// permission, so a fenced per-user home under a history-enabled
|
|
||||||
// working/ still records history. Falls back to the embedded defaults.
|
|
||||||
func (chain PolicyChain) EffectiveHistory() bool {
|
|
||||||
for i := len(chain.Levels) - 1; i >= 0; i-- {
|
|
||||||
if v := chain.Levels[i].History; v != nil {
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := chain.Embedded.History; v != nil {
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// policyCache caches effective policies keyed by dirPath.
|
// policyCache caches effective policies keyed by dirPath.
|
||||||
// Values are PolicyChain.
|
// Values are PolicyChain.
|
||||||
var policyCache sync.Map
|
var policyCache sync.Map
|
||||||
|
|
@ -389,7 +370,6 @@ func nonZeroZddcFields(zf ZddcFile) []string {
|
||||||
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
add("auto_own_fenced", zf.AutoOwnFenced != nil)
|
||||||
add("virtual", zf.Virtual != nil)
|
add("virtual", zf.Virtual != nil)
|
||||||
add("drop_target", zf.DropTarget != nil)
|
add("drop_target", zf.DropTarget != nil)
|
||||||
add("history", zf.History != nil)
|
|
||||||
add("worm", zf.Worm != nil)
|
add("worm", zf.Worm != nil)
|
||||||
add("available_tools", len(zf.AvailableTools) > 0)
|
add("available_tools", len(zf.AvailableTools) > 0)
|
||||||
add("received_path", zf.ReceivedPath != "")
|
add("received_path", zf.ReceivedPath != "")
|
||||||
|
|
|
||||||
|
|
@ -475,13 +475,6 @@ paths:
|
||||||
# homes below.
|
# homes below.
|
||||||
auto_own: true
|
auto_own: true
|
||||||
drop_target: true
|
drop_target: true
|
||||||
# Edit-history: every markdown save under working/ (incl.
|
|
||||||
# the fenced per-user homes — history inherits through
|
|
||||||
# fences) is versioned into a sibling .history/ store with
|
|
||||||
# a server-stamped audit line (who + when). The live file
|
|
||||||
# stays the source of truth; GET <file>?history lists prior
|
|
||||||
# versions. See ZddcFile.History / handler.WriteTextWithHistory.
|
|
||||||
history: true
|
|
||||||
paths:
|
paths:
|
||||||
"*": # per-user home dir, fenced
|
"*": # per-user home dir, fenced
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
|
|
|
||||||
|
|
@ -263,24 +263,6 @@ type ZddcFile struct {
|
||||||
// not its descendants. Defaults (nil): no drop zone.
|
// not its descendants. Defaults (nil): no drop zone.
|
||||||
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
|
DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"`
|
||||||
|
|
||||||
// History enables server-managed edit-history versioning for text
|
|
||||||
// (markdown) writes in this subtree. When true, each save of a
|
|
||||||
// history-eligible file (see handler.IsTextHistoryCandidate) snapshots
|
|
||||||
// the content into <dir>/.history/<stem>/ and appends a server-stamped
|
|
||||||
// audit line (timestamp + authenticated email) to that folder's
|
|
||||||
// log.jsonl, then writes the live file. The live file at its natural
|
|
||||||
// path stays the source of truth; the .history/ store is immutable and
|
|
||||||
// auto-hidden (dot-prefixed).
|
|
||||||
//
|
|
||||||
// Unlike DropTarget (leaf-only), History is a SUBTREE behavior: a
|
|
||||||
// `history: true` at an ancestor (e.g. archive/<party>/working/)
|
|
||||||
// applies to every descendant, and it deliberately inherits through
|
|
||||||
// inherit:false ACL fences (per-user homes under working/ still record
|
|
||||||
// history) — versioning is a write behavior, not a permission. A
|
|
||||||
// deeper level may set `history: false` to opt a subtree out. Resolved
|
|
||||||
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
|
|
||||||
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
|
|
||||||
|
|
||||||
// Worm marks this directory (and its descendants) as
|
// Worm marks this directory (and its descendants) as
|
||||||
// write-once-read-many. A non-nil Worm list — even an empty one —
|
// write-once-read-many. A non-nil Worm list — even an empty one —
|
||||||
// puts the path into a WORM zone with these effects, applied AFTER
|
// puts the path into a WORM zone with these effects, applied AFTER
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
package zddc
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func bptr(b bool) *bool { return &b }
|
|
||||||
|
|
||||||
func TestEffectiveHistory(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
chain PolicyChain
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no levels, no embedded default",
|
|
||||||
chain: PolicyChain{},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "embedded default true, no explicit level",
|
|
||||||
chain: PolicyChain{Levels: []ZddcFile{{}, {}}, Embedded: ZddcFile{History: bptr(true)}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ancestor true inherits down to leaf (nil)",
|
|
||||||
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {}, {}}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "leaf false overrides ancestor true",
|
|
||||||
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {History: bptr(false)}}},
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "leaf true overrides embedded false",
|
|
||||||
chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}}, Embedded: ZddcFile{History: bptr(false)}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// History must inherit THROUGH an inherit:false ACL fence —
|
|
||||||
// versioning is a write behavior, not a permission. The fenced
|
|
||||||
// middle level sets no History, so the ancestor's true wins.
|
|
||||||
name: "inherits through an inherit:false fence",
|
|
||||||
chain: PolicyChain{Levels: []ZddcFile{
|
|
||||||
{History: bptr(true)},
|
|
||||||
{ACL: ACLRules{Inherit: bptr(false)}},
|
|
||||||
{},
|
|
||||||
}},
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := tc.chain.EffectiveHistory(); got != tc.want {
|
|
||||||
t.Errorf("EffectiveHistory() = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -147,18 +147,6 @@ func VirtualAt(fsRoot, dirPath string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryAt reports whether edit-history versioning is enabled for
|
|
||||||
// writes in dirPath. Subtree-inheriting (see
|
|
||||||
// PolicyChain.EffectiveHistory) — a `history: true` at an ancestor
|
|
||||||
// applies here even through inherit:false fences.
|
|
||||||
func HistoryAt(fsRoot, dirPath string) bool {
|
|
||||||
chain, err := EffectivePolicy(fsRoot, dirPath)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return chain.EffectiveHistory()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDeclaredPath reports whether dirPath is mentioned in the
|
// IsDeclaredPath reports whether dirPath is mentioned in the
|
||||||
// cascade — either by an on-disk .zddc at that level OR by any
|
// cascade — either by an on-disk .zddc at that level OR by any
|
||||||
// ancestor's paths: tree (including the embedded defaults).
|
// ancestor's paths: tree (including the embedded defaults).
|
||||||
|
|
|
||||||
|
|
@ -48,34 +48,6 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHistoryAt_Defaults — the embedded convention enables edit-history
|
|
||||||
// versioning on archive/<party>/working/ and (because history is
|
|
||||||
// subtree-inheriting and ignores the auto_own_fenced homes' inherit:false)
|
|
||||||
// on the per-user homes and any depth beneath them. Sibling slots do not
|
|
||||||
// get history.
|
|
||||||
func TestHistoryAt_Defaults(t *testing.T) {
|
|
||||||
resetCache()
|
|
||||||
root := t.TempDir()
|
|
||||||
cases := []struct {
|
|
||||||
path string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working"), true},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false},
|
|
||||||
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
|
|
||||||
{filepath.Join(root, "Project-X", "archive"), false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := HistoryAt(root, tc.path); got != tc.want {
|
|
||||||
t.Errorf("HistoryAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDirToolAt — the trailing-slash form floors at "browse" for
|
// TestDirToolAt — the trailing-slash form floors at "browse" for
|
||||||
// every path (the embedded convention sets dir_tool nowhere), and an
|
// every path (the embedded convention sets dir_tool nowhere), and an
|
||||||
// on-disk .zddc can override it for a subtree.
|
// on-disk .zddc can override it for a subtree.
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,6 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
|
||||||
if top.DropTarget != nil {
|
if top.DropTarget != nil {
|
||||||
out.DropTarget = top.DropTarget
|
out.DropTarget = top.DropTarget
|
||||||
}
|
}
|
||||||
if top.History != nil {
|
|
||||||
out.History = top.History
|
|
||||||
}
|
|
||||||
// Worm: presence (non-nil, even empty) marks the WORM zone.
|
// Worm: presence (non-nil, even empty) marks the WORM zone.
|
||||||
// Concat-dedupe across levels (a deeper .zddc adds controllers);
|
// Concat-dedupe across levels (a deeper .zddc adds controllers);
|
||||||
// preserve a non-nil empty slice so `worm: []` survives the
|
// preserve a non-nil empty slice so `worm: []` survives the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue