Merge feat/md-history: markdown edit-history + creator-owned working folders
Server-side edit-history versioning for working-folder markdown (content- addressed .history store + browse version-history viewer with diff/restore), and the creator-owned working-folder model with document-controller-gated files at the working/ root.
This commit is contained in:
commit
db96333718
34 changed files with 2348 additions and 41 deletions
|
|
@ -684,7 +684,9 @@ There are **no hardcoded folder names** — the canonical project structure is d
|
||||||
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
|
**Project shape (after the May 2026 reshape).** `archive/` is the only physical project-root directory. Everything party-scoped lives uniformly under `archive/<party>/{ssr.yaml, mdl/, rsk/, received/, issued/, incoming/, working/<email>/, staging/<batch>/, reviewing/<tracking>/}`. Six sibling top-level URLs are **virtual aggregators**, never on disk:
|
||||||
|
|
||||||
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
|
- **Row rollups** (tables tool, `default_tool: tables`) — `<project>/ssr`, `<project>/mdl`, `<project>/rsk`. Synthesise one row per party (SSR) or per row file across parties (MDL/RSK), with the source party injected as a synthesised `$party` column. The `$` sigil marks the column system-managed: the tables tool renders it read-only and strips it before submitting a write. Form-mode "+ Add row" on a rollup view prompts for `party` (the routing key, stored in the form schema as a real input field; stripped on write because the folder name *is* the identity).
|
||||||
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. No writes through the virtual URL space; sharing/bookmarks land on the canonical path after the redirect.
|
- **Folder-nav aggregators** (browse tool, `default_tool: browse`) — `<project>/working`, `<project>/staging`, `<project>/reviewing`. List the parties whose `archive/<party>/<slot>/` has non-empty content (the in-flight filter — empty or .zddc-only slots are suppressed). Per-party URLs `<project>/<slot>/<party>[/<rest>]` 302-redirect to the canonical `<project>/archive/<party>/<slot>[/<rest>]`. (A party name fails `ValidPartyName` only if it contains a character outside `[A-Za-z0-9.-]` — the resolver then declines to redirect and the path is treated physically; see `working/` below.) Sharing/bookmarks land on the canonical path after the redirect.
|
||||||
|
|
||||||
|
`working/` is the one folder-nav aggregator that **also materialises on disk**: it doubles as a shared project-level drafting space holding **creator-owned working folders** at `<project>/working/<folder>/`. The slot dir is instantiated lazily by `EnsureCanonicalAncestors` the first time real content is created beneath it (it stays a plain dir — never auto-owned), and each `<folder>/` a user creates gets an *unfenced* auto-own `.zddc` (`history: true` inherits in, so markdown drafts there are versioned). Authorisation splits dir-vs-file at the root: project members may create folders (`project_team: rc` in the defaults), but a **bare file directly at the `working/` root is reserved for the `document_controller`** — regular users work inside a folder; the DC creates files at the root or promotes one up with a MOVE. Enforced in `serveFilePut`/`serveFileMove` via `isProjectWorkingRootFile` + `zddc.IsRoleMemberAt`, independent of the ACL verb (since mkdir and file-PUT both authorise as `ActionCreate`). The earlier per-user `working/<email>/` "personal workspace" idea was dropped as more complexity than it earned. `staging`/`reviewing` remain non-materialising — `EnsureCanonicalAncestors` still rejects physical writes under them.
|
||||||
|
|
||||||
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.
|
Mkdir at the project root is restricted: only `archive` and `_`/`.`-prefixed system names are accepted (`handler/fileapi.go: rejectProjectRootMkdir`). Any other name — including the six virtual aggregator names, which would shadow the virtual surface — returns 409 Conflict. This is the only structural mkdir guard; deeper paths are governed by `auto_own:` + `worm:` + ACL.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ 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.
|
||||||
|
|
@ -47,6 +48,7 @@ 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" \
|
||||||
|
|
@ -71,6 +73,7 @@ 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" \
|
||||||
|
|
|
||||||
131
browse/css/history.css
Normal file
131
browse/css/history.css
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* 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,6 +1061,24 @@
|
||||||
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 ──
|
||||||
|
|
|
||||||
396
browse/js/history.js
Normal file
396
browse/js/history.js
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
// 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,6 +60,12 @@
|
||||||
// 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,6 +75,10 @@ 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
Normal file
108
shared/diff.js
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/*
|
||||||
|
* 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,8 +2,9 @@
|
||||||
<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 so Playwright tests can call window.zddc.* -->
|
<!-- Loads shared/zddc.js + shared/hash.js + shared/diff.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>
|
||||||
|
|
|
||||||
71
tests/diff.spec.js
Normal file
71
tests/diff.spec.js
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* 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,11 +1323,23 @@ 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.)
|
||||||
|
|
||||||
// Record-history list: GET <record>.yaml?history=1 returns the
|
// Edit-history: ACL already passed (parent-dir chain).
|
||||||
// list of prior revisions stored under <dir>/.history/<base>/.
|
// - Records (.yaml rows): GET <record>.yaml?history=1 lists prior
|
||||||
// ACL already passed (parent-dir chain). Non-record paths fall
|
// revisions stored under <dir>/.history/<base>/ (audit in-body).
|
||||||
// through to the normal file serve.
|
// - Text (markdown) under a history: true subtree:
|
||||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" {
|
// ?history=1 lists versions; ?history=<sha> returns that version's
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -298,9 +298,11 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
return req.WithContext(handler.WithEmail(req.Context(), email))
|
return req.WithContext(handler.WithEmail(req.Context(), email))
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT a new file via dispatch.
|
// PUT a new file via dispatch. Files live in a sub-folder under
|
||||||
|
// working/ (creator-owned); bare files at the working/ root are
|
||||||
|
// document-controller-only (see TestFileAPI_WorkingRootFileDocControllerOnly).
|
||||||
body := []byte("note body")
|
body := []byte("note body")
|
||||||
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
|
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/drafts/note.md", strings.NewReader(string(body))), "alice@example.com")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
|
|
@ -308,7 +310,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET it back.
|
// GET it back.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/drafts/note.md", nil), "alice@example.com")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
||||||
|
|
@ -316,9 +318,9 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOVE it.
|
// MOVE it.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/drafts/note.md", nil), "alice@example.com")
|
||||||
req.Header.Set("X-ZDDC-Op", "move")
|
req.Header.Set("X-ZDDC-Op", "move")
|
||||||
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
|
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/drafts/renamed.md")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|
@ -326,7 +328,7 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE it.
|
// DELETE it.
|
||||||
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
|
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/drafts/renamed.md", nil), "alice@example.com")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
||||||
if rec.Code != http.StatusNoContent {
|
if rec.Code != http.StatusNoContent {
|
||||||
|
|
@ -1063,4 +1065,3 @@ func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">v0.0.24</span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -2329,6 +2329,138 @@ 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>
|
||||||
|
|
@ -2344,7 +2476,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">v0.0.24</span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -4227,6 +4359,115 @@ 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,
|
||||||
|
|
@ -6790,6 +7031,12 @@ 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
|
||||||
};
|
};
|
||||||
|
|
@ -11375,6 +11622,403 @@ 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.
|
||||||
//
|
//
|
||||||
|
|
@ -12585,6 +13229,24 @@ 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">v0.0.24</span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -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">v0.0.24</span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></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">v0.0.24</span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.24
|
archive=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
transmittal=v0.0.24
|
transmittal=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
classifier=v0.0.24
|
classifier=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
landing=v0.0.24
|
landing=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
form=v0.0.24
|
form=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
tables=v0.0.24
|
tables=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
browse=v0.0.24
|
browse=v0.0.25-beta · 2026-05-29 19:37:02 · c489a78
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@ 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()
|
||||||
|
|
@ -189,6 +193,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -392,6 +392,17 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Files placed DIRECTLY at the project-level virtual working/ root are
|
||||||
|
// reserved for the document controller. Regular users create folders
|
||||||
|
// under working/ and work inside them; the DC creates files at the
|
||||||
|
// root or promotes them up from a folder (see serveFileMove). Files in
|
||||||
|
// sub-folders and the working/.zddc config are unaffected.
|
||||||
|
if filepath.Base(abs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, abs) &&
|
||||||
|
!zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(abs), "document_controller", EmailFromContext(r)) {
|
||||||
|
auditFile(r, "put", cleanURL, http.StatusForbidden, 0, nil)
|
||||||
|
http.Error(w, "Forbidden — files cannot be created directly in working/; create a folder and work inside it. Only the document controller may place files at the working/ root.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !checkIfMatch(w, r, abs) {
|
if !checkIfMatch(w, r, abs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -432,6 +443,16 @@ 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)
|
||||||
|
|
@ -645,6 +666,13 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Promoting a file to the project-level working/ root is reserved for
|
||||||
|
// the document controller, same as a direct create there (serveFilePut).
|
||||||
|
if filepath.Base(dstAbs) != ".zddc" && isProjectWorkingRootFile(cfg.Root, dstAbs) &&
|
||||||
|
!zddc.IsRoleMemberAt(cfg.Root, filepath.Dir(dstAbs), "document_controller", EmailFromContext(r)) {
|
||||||
|
http.Error(w, "Forbidden — only the document controller may move files to the working/ root.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !checkIfMatch(w, r, srcAbs) {
|
if !checkIfMatch(w, r, srcAbs) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -800,6 +828,26 @@ func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
// Returns (true, reason) when the request should be 409'd. Returns
|
// Returns (true, reason) when the request should be 409'd. Returns
|
||||||
// (false, "") when the target is at any other depth or carries an
|
// (false, "") when the target is at any other depth or carries an
|
||||||
// allowed name.
|
// allowed name.
|
||||||
|
// isProjectWorkingRootFile reports whether abs targets a file sitting
|
||||||
|
// directly in the project-level virtual working/ aggregator —
|
||||||
|
// <project>/working/<file> — as opposed to a file inside a sub-folder
|
||||||
|
// (<project>/working/<folder>/<file>, depth 4+) or anywhere else.
|
||||||
|
// Used to gate file creation/promotion at the working/ root to the
|
||||||
|
// document controller; everything deeper is ordinary creator-owned
|
||||||
|
// working space.
|
||||||
|
func isProjectWorkingRootFile(fsRoot, abs string) bool {
|
||||||
|
rel, err := filepath.Rel(fsRoot, abs)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if strings.HasPrefix(rel, "../") || rel == "." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(rel, "/")
|
||||||
|
return len(parts) == 3 && strings.EqualFold(parts[1], "working")
|
||||||
|
}
|
||||||
|
|
||||||
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
|
||||||
rel, err := filepath.Rel(fsRoot, abs)
|
rel, err := filepath.Rel(fsRoot, abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -720,3 +720,104 @@ func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
||||||
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
||||||
// working/<email>/, the project-level pairing no longer maps cleanly.
|
// working/<email>/, the project-level pairing no longer maps cleanly.
|
||||||
// Tests for the removed behaviour have been deleted.)
|
// Tests for the removed behaviour have been deleted.)
|
||||||
|
|
||||||
|
// workingRootSetup builds a root that grants the team rwcd and names a
|
||||||
|
// document controller (the standard role ships empty; a deployment
|
||||||
|
// populates it). Returns the same do() helper shape as fileAPITestSetup.
|
||||||
|
func workingRootSetup(t *testing.T) (root string, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) {
|
||||||
|
t.Helper()
|
||||||
|
root = t.TempDir()
|
||||||
|
rootZddc := "acl:\n permissions:\n \"*@example.com\": rwcd\n" +
|
||||||
|
"roles:\n document_controller:\n members: [dc@example.com]\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(rootZddc), 0o644); err != nil {
|
||||||
|
t.Fatalf("write root .zddc: %v", err)
|
||||||
|
}
|
||||||
|
zddc.InvalidateCache(root)
|
||||||
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 1 << 20}
|
||||||
|
do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
var req *http.Request
|
||||||
|
if body != nil {
|
||||||
|
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, target, nil)
|
||||||
|
}
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
||||||
|
ctx = context.WithValue(ctx, ElevatedKey, true)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ServeFileAPI(cfg, rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
return root, do
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files directly at the project-level working/ root are document-
|
||||||
|
// controller-only; folders (and files inside them) are open to the team
|
||||||
|
// and become creator-owned.
|
||||||
|
func TestFileAPI_WorkingRootFileDocControllerOnly(t *testing.T) {
|
||||||
|
root, do := workingRootSetup(t)
|
||||||
|
|
||||||
|
// Non-DC user: a bare file at the working/ root is forbidden.
|
||||||
|
if rec := do(http.MethodPut, "/Proj/working/memo.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("non-DC file at working/ root: want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "memo.md")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("forbidden file must not be written")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same user CAN create a folder and work inside it; the folder
|
||||||
|
// becomes creator-owned (unfenced auto-own .zddc).
|
||||||
|
if rec := do(http.MethodPut, "/Proj/working/drafts/notes.md", "alice@example.com", []byte("x"), nil); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("user file inside working folder: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
z, err := os.ReadFile(filepath.Join(root, "Proj", "working", "drafts", ".zddc"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creator-owned folder auto-own .zddc missing: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(z), "alice@example.com: rwcda") {
|
||||||
|
t.Errorf("creator folder should grant alice rwcda; got: %s", z)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document controller CAN place a file at the working/ root.
|
||||||
|
if rec := do(http.MethodPut, "/Proj/working/memo.md", "dc@example.com", []byte("x"), nil); rec.Code != http.StatusCreated {
|
||||||
|
t.Errorf("DC file at working/ root: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promoting a file from a working folder up to the working/ root is
|
||||||
|
// document-controller-only, mirroring direct creation.
|
||||||
|
func TestFileAPI_WorkingRootMoveDocControllerOnly(t *testing.T) {
|
||||||
|
root, do := workingRootSetup(t)
|
||||||
|
|
||||||
|
// alice owns a folder with a file.
|
||||||
|
if rec := do(http.MethodPut, "/Proj/working/drafts/a.md", "alice@example.com", []byte("body"), nil); rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("seed file: want 201, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// alice cannot promote it to the working/ root.
|
||||||
|
rec := do(http.MethodPost, "/Proj/working/drafts/a.md", "alice@example.com", nil, map[string]string{
|
||||||
|
"X-ZDDC-Op": "move",
|
||||||
|
"X-ZDDC-Destination": "/Proj/working/a.md",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("non-DC move to working/ root: want 403, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("forbidden move must not create the destination")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The document controller can.
|
||||||
|
rec = do(http.MethodPost, "/Proj/working/drafts/a.md", "dc@example.com", nil, map[string]string{
|
||||||
|
"X-ZDDC-Op": "move",
|
||||||
|
"X-ZDDC-Destination": "/Proj/working/a.md",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("DC move to working/ root: want 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", "a.md")); err != nil {
|
||||||
|
t.Errorf("DC move should land the file at the root: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -802,3 +802,243 @@ 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)
|
||||||
|
}
|
||||||
|
|
|
||||||
221
zddc/internal/handler/mdhistory_test.go
Normal file
221
zddc/internal/handler/mdhistory_test.go
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
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">v0.0.24</span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.25-beta · 2026-05-29 19:37:02 · c489a78</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,12 @@ 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,6 +47,25 @@ 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
|
||||||
|
|
@ -370,6 +389,7 @@ 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 != "")
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,41 @@ paths:
|
||||||
working:
|
working:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse]
|
available_tools: [browse]
|
||||||
|
# Project-level working is BOTH an outstanding-only aggregator of
|
||||||
|
# the per-party archive/<party>/working/ slots AND the home for
|
||||||
|
# shared, creator-owned working folders at <project>/working/
|
||||||
|
# <folder>/. (The earlier per-user <email>/ "personal workspace"
|
||||||
|
# concept was dropped — too much complexity for too little gain.
|
||||||
|
# A working folder is just a named drafting space its creator
|
||||||
|
# owns; nothing keys off the email.)
|
||||||
virtual: true
|
virtual: true
|
||||||
|
# Edit-history versioning for markdown drafts (inherits to the
|
||||||
|
# folders below). archive/<party>/working/ carries its own
|
||||||
|
# history: true separately.
|
||||||
|
history: true
|
||||||
|
# Project members may CREATE here (so they can make their own
|
||||||
|
# working folders) but the file handler additionally restricts
|
||||||
|
# bare files at this root to the document controller — users put
|
||||||
|
# files inside a folder they created; the DC promotes/creates
|
||||||
|
# files at the root. The DC carries rwc so it can do both (and,
|
||||||
|
# unfenced, that authority cascades into every folder below).
|
||||||
|
acl:
|
||||||
|
permissions:
|
||||||
|
project_team: rc
|
||||||
|
document_controller: rwc
|
||||||
|
paths:
|
||||||
|
"*":
|
||||||
|
# A creator-owned working folder: <project>/working/<folder>/.
|
||||||
|
# auto_own (NOT auto_own_fenced) makes the creator the owner
|
||||||
|
# (rwcda + a .zddc they can edit) while leaving the folder
|
||||||
|
# readable to the rest of the project_team via the unfenced
|
||||||
|
# cascade above. Other members inherit rc here (read + add
|
||||||
|
# new files) but lack w/d, so they cannot modify or delete
|
||||||
|
# the owner's files; the owner narrows this in their own
|
||||||
|
# .zddc if they want it private.
|
||||||
|
default_tool: browse
|
||||||
|
available_tools: [browse]
|
||||||
|
auto_own: true
|
||||||
staging:
|
staging:
|
||||||
default_tool: browse
|
default_tool: browse
|
||||||
available_tools: [browse]
|
available_tools: [browse]
|
||||||
|
|
@ -475,6 +509,13 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -84,13 +84,19 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
|
||||||
//
|
//
|
||||||
// Canonical positions, relative to fsRoot:
|
// Canonical positions, relative to fsRoot:
|
||||||
//
|
//
|
||||||
// - <project>/archive (the only physical project-root canonical;
|
// - <project>/archive (a physical project-root canonical)
|
||||||
// working/staging/reviewing/ssr/mdl/rsk at project root are virtual
|
//
|
||||||
// aggregators with no on-disk presence — writes targeting them
|
// - <project>/working (virtual at the slot level, but materialised
|
||||||
// must be rejected by the caller's project-root mkdir guard.)
|
// on disk to host creator-owned working folders + document-
|
||||||
|
// controller files; created as a plain dir, never auto-owned)
|
||||||
|
//
|
||||||
|
// staging/reviewing/ssr/mdl/rsk at project root are virtual
|
||||||
|
// aggregators with no on-disk presence — writes targeting them are
|
||||||
|
// rejected here and by the caller's project-root mkdir guard.
|
||||||
|
//
|
||||||
// - <project>/archive/<party>/<canonical-party> where
|
// - <project>/archive/<party>/<canonical-party> where
|
||||||
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
// <canonical-party> ∈ {mdl, rsk, incoming, received, issued,
|
||||||
// working, staging, reviewing}
|
// working, staging, reviewing}
|
||||||
//
|
//
|
||||||
// fsRoot and target must be absolute filesystem paths under the same
|
// fsRoot and target must be absolute filesystem paths under the same
|
||||||
// volume; target may not yet exist on disk.
|
// volume; target may not yet exist on disk.
|
||||||
|
|
@ -110,13 +116,24 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
|
||||||
return target, nil
|
return target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject writes targeting top-level virtual aggregators —
|
// Project-root virtual aggregators. ssr/mdl/rsk are pure tables
|
||||||
// <project>/{ssr,mdl,rsk,working,staging,reviewing}/... — these
|
// rollups and staging/reviewing are synthesised lifecycle windows —
|
||||||
// resolve through ResolveVirtualView, not as physical paths. A
|
// none materialise on disk; row PUTs / lifecycle writes are rewritten
|
||||||
// caller writing under them bypassed the virtual resolver.
|
// to canonical archive/<party>/ paths by ResolveVirtualView before
|
||||||
|
// reaching here, so a physical write under one of these means the
|
||||||
|
// resolver was bypassed.
|
||||||
|
//
|
||||||
|
// working/ is the exception: it is virtual at the slot level (the
|
||||||
|
// listing is synthesised from archive/*/working/), but it is also
|
||||||
|
// the home for creator-owned working folders at <project>/working/
|
||||||
|
// <folder>/. The slot dir is instantiated on disk the moment real
|
||||||
|
// content is created beneath it. Fall through to materialise it +
|
||||||
|
// the target's ancestors; WHO may write here (users create folders;
|
||||||
|
// only the document controller places files at the root) is gated in
|
||||||
|
// the file handler, not here.
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
switch strings.ToLower(parts[1]) {
|
switch strings.ToLower(parts[1]) {
|
||||||
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
|
case "ssr", "mdl", "rsk", "staging", "reviewing":
|
||||||
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
|
return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,12 +197,12 @@ func TestEnsureCanonicalAncestors_NoPrincipalSkipsAutoOwn(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project-root virtual aggregator names are rejected — a write
|
// Project-root virtual aggregator names (except working/) are rejected —
|
||||||
// targeting <project>/working/<...> bypasses the virtual resolver
|
// a write targeting <project>/{staging,reviewing,ssr,mdl,rsk}/<...>
|
||||||
// and must not materialise on disk.
|
// bypasses the virtual resolver and must not materialise on disk.
|
||||||
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
for _, slot := range []string{"staging", "reviewing", "ssr", "mdl", "rsk"} {
|
||||||
target := filepath.Join(root, "Proj", slot, "x.md")
|
target := filepath.Join(root, "Proj", slot, "x.md")
|
||||||
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
_, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -214,6 +214,52 @@ func TestEnsureCanonicalAncestors_RejectsProjectRootVirtual(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// working/ is the exception: it hosts creator-owned working folders, so
|
||||||
|
// real content beneath it instantiates the (otherwise virtual) slot dir
|
||||||
|
// on disk. The slot dir itself stays plain (never auto-owned); the
|
||||||
|
// creator-owned folder under it gets the unfenced auto-own .zddc.
|
||||||
|
func TestEnsureCanonicalAncestors_WorkingMaterialisesCreatorFolder(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "Proj", "working", "drafts", "notes.md")
|
||||||
|
if _, err := EnsureCanonicalAncestors(root, target, "alice@x.com", 0o755); err != nil {
|
||||||
|
t.Fatalf("ensure: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
|
||||||
|
t.Errorf("working/ not created: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err)
|
||||||
|
}
|
||||||
|
folderZddc := filepath.Join(root, "Proj", "working", "drafts", ".zddc")
|
||||||
|
data, err := os.ReadFile(folderZddc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creator folder auto-own .zddc missing: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "alice@x.com: rwcda") {
|
||||||
|
t.Errorf("creator folder auto-own missing creator grant: %s", data)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "inherit: false") {
|
||||||
|
t.Errorf("creator working folder must be UNFENCED (readable by the team); got: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare file directly at the project-level working/ root still
|
||||||
|
// materialises the slot dir — the file handler gates WHO may write it,
|
||||||
|
// not EnsureCanonicalAncestors.
|
||||||
|
func TestEnsureCanonicalAncestors_WorkingRootFileMaterialisesSlot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
target := filepath.Join(root, "Proj", "working", "memo.md")
|
||||||
|
if _, err := EnsureCanonicalAncestors(root, target, "dc@x.com", 0o755); err != nil {
|
||||||
|
t.Fatalf("ensure: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working")); err != nil {
|
||||||
|
t.Errorf("working/ not created: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "Proj", "working", ".zddc")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("working/ slot dir must stay plain (no auto-own .zddc); got err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
|
func TestEnsureCanonicalAncestors_RejectsTraversal(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
other := t.TempDir()
|
other := t.TempDir()
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,24 @@ 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
|
||||||
|
|
|
||||||
58
zddc/internal/zddc/history_policy_test.go
Normal file
58
zddc/internal/zddc/history_policy_test.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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,6 +147,35 @@ 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRoleMemberAt reports whether email is a member of roleName as the
|
||||||
|
// role is visible (after fences / resets, unioned with the embedded
|
||||||
|
// defaults) at dirPath's cascade leaf. Returns false for an empty
|
||||||
|
// email or on cascade error. Used by the file handler to gate the few
|
||||||
|
// operations reserved for a standard role — e.g. only the
|
||||||
|
// document_controller may place files directly at the working/ root.
|
||||||
|
func IsRoleMemberAt(fsRoot, dirPath, roleName, email string) bool {
|
||||||
|
if email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
chain, err := EffectivePolicy(fsRoot, dirPath)
|
||||||
|
if err != nil || len(chain.Levels) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return MatchesPrincipal(roleName, email, chain, len(chain.Levels)-1)
|
||||||
|
}
|
||||||
|
|
||||||
// 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,6 +48,43 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHistoryAt_Defaults — the embedded convention enables edit-history
|
||||||
|
// versioning on BOTH (a) the project-level personal workspace
|
||||||
|
// <project>/working/ + its per-user <email>/ homes, and (b) the per-party
|
||||||
|
// archive/<party>/working/ + its homes. History is subtree-inheriting and
|
||||||
|
// ignores the homes' inherit:false fences. Sibling slots (staging,
|
||||||
|
// reviewing, mdl, incoming, received) do NOT get history.
|
||||||
|
func TestHistoryAt_Defaults(t *testing.T) {
|
||||||
|
resetCache()
|
||||||
|
root := t.TempDir()
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Project-level personal workspace.
|
||||||
|
{filepath.Join(root, "Project-X", "working"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
|
||||||
|
{filepath.Join(root, "Project-X", "working", "alice@example.com", "notes"), true},
|
||||||
|
// Per-party working.
|
||||||
|
{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},
|
||||||
|
// Sibling slots get no history.
|
||||||
|
{filepath.Join(root, "Project-X", "staging"), false},
|
||||||
|
{filepath.Join(root, "Project-X", "reviewing"), false},
|
||||||
|
{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,6 +88,9 @@ 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