ZDDC/browse/js/util.js
ZDDC 8edbb81958 feat(browse): lost-update protection for editors + shared conflict dialog
Two users editing the same file online could silently clobber each other:
the editor's save did a bare PUT with no precondition, even though the master
already enforces optimistic concurrency (fileapi.go checkIfMatch → 412). Now
the editor sends a precondition and surfaces a conflict UI instead of
overwriting.

- util.js: saveFile(node, content, contentType, opts) sends `If-Match: <etag>`
  (or `If-Unmodified-Since` fallback) unless opts.force; returns {etag} from
  the PUT response (so save→edit→save adopts the new version and doesn't
  false-conflict); throws ConflictError (.status===412) on a precondition
  failure so callers branch cleanly. New saveCopy() parks a conflicting edit
  as `<stem>-conflict-<ts>.<ext>` (collision-probed) without losing either side.
- preview.js: getContentWithVersion(node) → {buf, etag, lastModified} captured
  from the content GET (the listing JSON carries no per-file etag); threaded
  into the editor ctx and exported. getArrayBuffer left untouched.
- conflict.js (new): shared, callback-driven dialog — mine-vs-theirs diff
  (reuses zddc.diff + css/history.css) + Overwrite / Reload-theirs /
  Save-a-copy / Cancel. Never calls saveFile/showFilePreview itself, so the
  deferred Phase 5 cache-outbox conflict UI can reuse it with its own callbacks.
- preview-markdown.js / preview-yaml.js: capture + forward the version token,
  adopt the returned etag on success, and on 412 open the dialog (Overwrite
  re-fetches the current etag then re-saves — re-conflicts on a third writer
  rather than blind-forcing; Reload clears dirty first so the renderInline
  guard skips its confirm). FS-Access mode sends no precondition (no
  concurrency) and never conflicts.
- build.sh: concat conflict.js after util.js.
- tests/conflict.spec.js (+ playwright project): If-Match sent, ConflictError
  on 412, new-etag returned, force omits the precondition, dialog renders the
  diff and each action resolves via its callback. Drives the fresh dist build
  over file:// with a stubbed fetch (the test binary embeds the committed
  browse.html, not dist, so a server-mode E2E would run stale code).

All browse + diff + conflict specs pass (18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:24:15 -05:00

200 lines
8.9 KiB
JavaScript

// util.js — small browse-local helpers shared across the tool's modules.
//
// Consolidates copies that had drifted across modules: escapeHtml (some
// variants escaped single-quotes and handled null, others didn't), the
// SHA-256 content hasher (byte-identical in both editors), ISO-date and
// YAML-quote helpers (duplicated across the workflow modals), the
// /.profile/access email lookup, byte-size formatting, and the editor
// save/zip-member primitives. Attaches to window.app.modules.util — no new
// global (per the two-globals rule). Concatenated right after init.js so
// it's present when every later module's IIFE runs.
(function () {
'use strict';
if (!window.app || !window.app.modules) return;
// Escape a value for HTML text/attribute insertion. Escapes all five
// significant characters (including the single quote, which some call
// sites need for single-quoted attributes) and treats null/undefined
// as an empty string. Strict superset of every previous local copy.
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// SHA-256 hex of a string, or null when WebCrypto is unavailable.
// Used to gate editor dirty-state.
async function hashContent(text) {
if (!window.crypto || !window.crypto.subtle) return null;
var enc = new TextEncoder().encode(text);
var buf = await window.crypto.subtle.digest('SHA-256', enc);
var bytes = new Uint8Array(buf);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return hex;
}
function pad2(n) { return ('0' + n).slice(-2); }
function fmtIsoDate(d) {
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate());
}
// YYYY-MM-DD for today / today + N days (local time).
function isoDateToday() { return fmtIsoDate(new Date()); }
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return fmtIsoDate(d);
}
// Double-quoted YAML scalar with backslash + quote escaping. Enough for
// the email/string fields the workflow modals emit.
function yamlQuote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
// GET /.profile/access → [email] for datalist suggestions. Best-effort:
// returns [] on any error so callers can populate a datalist blind.
async function fetchAccessEmails() {
try {
var r = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!r.ok) return [];
var d = await r.json();
return (d && d.email) ? [d.email] : [];
} catch (_e) { return []; }
}
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// A file living inside a .zip is read-only: a ZipFileHandle refuses
// createWritable (offline / nested) and zddc-server refuses writes to a
// "<…>.zip/<member>" URL (405).
function isZipMemberNode(node) {
if (node.handle && node.handle.isZipEntry) return true;
if (node.url && window.app.state.source === 'server'
&& /\.zip\//i.test(node.url)) return true;
return false;
}
// Thrown by saveFile when the server rejects a write with 412
// Precondition Failed — the file changed under us since we loaded it.
// Callers branch on `.status === 412` to open the conflict UI instead
// of treating it as a generic save failure.
function ConflictError(message) {
var e = new Error(message || 'Conflict: file changed on server');
e.name = 'ConflictError';
e.status = 412;
return e;
}
// Write content back to a file's source, returning { etag } (the new
// server ETag, or null in FS-Access mode). Local (FS-Access) folders are
// picked read-only, so the first write escalates to readwrite via
// upload.ensureWritable (one permission prompt, then granted for the
// session). contentType sets the PUT Content-Type for server files.
//
// opts (server mode only):
// etag — send as `If-Match` so the master 412s if the file
// changed since we observed this version (optimistic
// concurrency; preferred — exact).
// lastModified — fallback precondition sent as `If-Unmodified-Since`
// (raw HTTP-date string) when no etag is available.
// force — skip the precondition entirely (deliberate overwrite).
//
// Throws ConflictError (.status===412) on a precondition failure, a
// plain Error('HTTP <status>') on any other non-2xx, or "no write
// target" when the source is read-only.
async function saveFile(node, content, contentType, opts) {
opts = opts || {};
if (node.handle && typeof node.handle.createWritable === 'function') {
var up = window.app.modules.upload;
if (up && up.ensureWritable) await up.ensureWritable();
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return { etag: null };
}
if (node.url && window.app.state.source === 'server') {
var headers = { 'Content-Type': contentType };
if (!opts.force) {
if (opts.etag) headers['If-Match'] = opts.etag;
else if (opts.lastModified) headers['If-Unmodified-Since'] = opts.lastModified;
}
var resp = await fetch(node.url, {
method: 'PUT',
headers: headers,
body: content,
credentials: 'same-origin'
});
if (resp.status === 412) throw ConflictError();
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return { etag: resp.headers.get('ETag') || null };
}
throw new Error('No write target for this file (read-only source).');
}
// Write `content` to a NEW sibling of `node` named
// `<stem>-conflict-<YYYYMMDD-HHMMSS>.<ext>` (server mode only), so a
// conflicting edit can be parked without losing either version. Probes
// for a free name (numeric-suffix bump, capped) so a same-second retry
// doesn't clobber a prior copy. Returns the created filename. The PUT
// uses no precondition — it's a brand-new path.
async function saveCopy(node, content, contentType) {
if (!(node.url && window.app.state.source === 'server')) {
throw new Error('Save a copy is only available for server files.');
}
var split = window.zddc.splitExtension(node.name);
var stem = split.name || node.name;
var ext = split.extension;
var d = new Date();
var stamp = d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate())
+ '-' + pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
var base = stem + '-conflict-' + stamp;
var slash = node.url.lastIndexOf('/');
var dirUrl = slash >= 0 ? node.url.slice(0, slash + 1) : '';
var name = '', candidateUrl = '';
for (var i = 0; i < 20; i++) {
name = window.zddc.joinExtension(base + (i ? '-' + (i + 1) : ''), ext);
candidateUrl = dirUrl + encodeURIComponent(name);
var head;
try {
head = await fetch(candidateUrl, { method: 'HEAD', credentials: 'same-origin' });
} catch (_e) {
break; // network unknown — attempt the write rather than spin
}
if (head.status === 404) break; // free slot
if (head.status !== 200) break; // HEAD unsupported / odd — attempt anyway
if (i === 19) throw new Error('Could not find a free filename for the copy.');
}
await saveFile({ url: candidateUrl, name: name, ext: ext }, content, contentType, { force: true });
return name;
}
window.app.modules.util = {
escapeHtml: escapeHtml,
hashContent: hashContent,
isoDateToday: isoDateToday,
isoDatePlus: isoDatePlus,
yamlQuote: yamlQuote,
fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode,
saveFile: saveFile,
saveCopy: saveCopy,
ConflictError: ConflictError
};
})();