refactor(browse): consolidate duplicated helpers into util.js; fix YAML save divergence

Nine copies of escapeHtml (some escaping single-quotes + handling null,
others not), two byte-identical hashContent hashers, two saveContent
writers, two isZipMemberNode predicates, the ISO-date + YAML-quote helpers
duplicated across the workflow modals, three /.profile/access email
fetchers, and three byte-size formatters had all drifted across the browse
modules. Hoist a single browse-local window.app.modules.util (no new global;
concatenated right after init.js) and alias the call sites to it.

Reliability fix folded in: the YAML editor's saveContent skipped the
upload.ensureWritable() escalation that the markdown editor performs, so
saving a .yaml/.zddc file to a read-only-picked local folder failed where
markdown succeeded. Both now go through util.saveFile, which always
escalates — the shared writer makes the two editors impossible to drift
apart again.

Canonical escapeHtml is the strict superset (escapes & < > " ', null →
"") so it's a safe drop-in for every prior variant. fmtSize gains the GB
tier everywhere (history.js previously capped at MB). Also removes the dead
stage.js fetchSelfEmail (defined, never called).

Net −200 lines across the modules. No behavior change beyond the save fix;
all 6 browse Playwright specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-03 15:07:00 -05:00
parent 41d4e59899
commit bbbf5326e7
13 changed files with 173 additions and 242 deletions

View file

@ -63,6 +63,7 @@ concat_files \
"../shared/icons.js" \
"../shared/zddc-source.js" \
"js/init.js" \
"js/util.js" \
"js/loader.js" \
"js/tree.js" \
"js/preview.js" \

View file

@ -25,28 +25,10 @@
if (t) t(msg, level || 'info');
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var isoDateToday = util.isoDateToday;
var isoDatePlus = util.isoDatePlus;
// Is this node a direct child of an incoming/ canonical folder
// AND a well-formed transmittal folder? The first half is the
@ -96,19 +78,7 @@
return out;
}
function fetchPeopleSuggestions() {
return fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
}).then(function (r) {
if (!r.ok) return [];
return r.json().then(function (data) {
var out = [];
if (data && data.email) out.push(data.email);
return out;
});
}).catch(function () { return []; });
}
var fetchPeopleSuggestions = util.fetchAccessEmails;
function openForm(initial) {
return new Promise(function (resolve, reject) {
@ -230,9 +200,7 @@
});
}
function quote(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
var quote = util.yamlQuote;
function buildBody(values) {
var lines = ['received_date: ' + values.receivedDate];
if (values.setupPlanReview) {

View file

@ -18,17 +18,9 @@
var t = window.zddc && window.zddc.toast;
if (t) t(msg, level || 'info');
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
}
function isoDateToday() {
var d = new Date();
return d.getFullYear()
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2)
+ '-' + ('0' + d.getDate()).slice(-2);
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var isoDateToday = util.isoDateToday;
function openForm() {
return new Promise(function (resolve, reject) {

View file

@ -663,11 +663,7 @@
return parentDir;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
var escapeHtml = window.app.modules.util.escapeHtml;
// Valid party folder name — mirrors zddc.ValidPartyName server-side
// (^[A-Za-z0-9][A-Za-z0-9.-]*$).

View file

@ -16,11 +16,7 @@
(function () {
'use strict';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var escapeHtml = window.app.modules.util.escapeHtml;
function toast(msg, kind) {
if (window.zddc && typeof window.zddc.toast === 'function') {
@ -40,12 +36,7 @@
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';
}
var fmtBytes = window.app.modules.util.fmtSize;
// Can the principal write (restore) to this file? Mirrors the
// events.js Rename/Delete gating: verbs===undefined means a non-zddc

View file

@ -58,22 +58,10 @@
currentRow = null;
}
// ── Formatting (kept local so this module is self-contained) ──
// ── Formatting ──
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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';
}
var escapeHtml = window.app.modules.util.escapeHtml;
var fmtSize = window.app.modules.util.fmtSize;
function fmtDate(d) {
if (!d) return '';

View file

@ -45,44 +45,18 @@
}
}
// Compute today + N days as a YYYY-MM-DD string.
function isoDatePlus(days) {
var d = new Date();
d.setDate(d.getDate() + days);
var y = d.getFullYear();
var m = ('0' + (d.getMonth() + 1)).slice(-2);
var dd = ('0' + d.getDate()).slice(-2);
return y + '-' + m + '-' + dd;
}
var util = window.app.modules.util;
var isoDatePlus = util.isoDatePlus;
// Fetch suggestion emails from /.profile/access so the originator
// field has a datalist of likely values. Best-effort — silent on
// failure (the field still accepts free text).
async function fetchOriginatorSuggestions() {
try {
var resp = await fetch('/.profile/access', {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) return [];
var data = await resp.json();
var out = [];
// The endpoint exposes the current user + any role members
// visible to them. Pull anything that looks like an email
// for the datalist; the field is otherwise free text.
if (data && data.email) out.push(data.email);
return out;
} catch (_e) {
return [];
}
}
var fetchOriginatorSuggestions = util.fetchAccessEmails;
// Build the YAML body for the plan-review POST. Quoting is minimal
// (just enough for emails with special chars).
function buildBody(values) {
function yamlString(s) {
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
var yamlString = util.yamlQuote;
return [
'review_lead: ' + yamlString(values.reviewLead),
'approver: ' + yamlString(values.approver),
@ -188,14 +162,7 @@
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
var escapeHtml = util.escapeHtml;
// Detect whether a tree node is an archive/<party>/received/<tracking>/
// folder. The path is path-shaped, not content-based — tracking-number

View file

@ -42,27 +42,14 @@
var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var hashContent = util.hashContent;
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT;
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 dispose() {
if (currentInstance) {
// Tear down the document-level resizer drag listeners (added
@ -291,38 +278,11 @@
// ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') {
// Local folders are picked read-only; escalate to readwrite on
// first save (one FS-Access prompt, then granted for the session).
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;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
function saveContent(node, content) {
return util.saveFile(node, content, 'text/markdown; charset=utf-8');
}
// A markdown 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;
}
var isZipMemberNode = util.isZipMemberNode;
function canSave(node) {
if (isZipMemberNode(node)) return false;

View file

@ -22,10 +22,8 @@
if (!window.app || !window.app.modules) return;
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
// ── Filename routing ────────────────────────────────────────────────────
@ -47,32 +45,14 @@
// ── Save (mirrors preview-markdown.js) ─────────────────────────────────
async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') {
var writable = await node.handle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
function saveContent(node, content) {
// Via the shared saveFile so local (FS-Access) saves escalate to
// readwrite the same as the markdown editor — previously this path
// skipped ensureWritable and failed on read-only-picked folders.
return util.saveFile(node, content, 'application/x-yaml; charset=utf-8');
}
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;
}
var isZipMemberNode = util.isZipMemberNode;
function canSave(node) {
if (isZipMemberNode(node)) return false;
@ -96,17 +76,7 @@
return false;
}
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;
}
var hashContent = util.hashContent;
// ── .zddc schema ────────────────────────────────────────────────────────
//

View file

@ -19,10 +19,8 @@
console.error('[browse] zddc.preview not loaded — preview disabled.');
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
var MIME = {
'pdf': 'application/pdf',
@ -41,13 +39,7 @@
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
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';
}
var fmtSize = util.fmtSize;
async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so

View file

@ -34,11 +34,7 @@
// Guard against a second invocation while a move is mid-flight (e.g. a
// double menu click). The picker modal also blocks re-entry while open.
var busy = false;
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
}
var escapeHtml = window.app.modules.util.escapeHtml;
// ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its path matches
@ -99,18 +95,6 @@
.map(function (e) { return e.name; });
}
async function fetchSelfEmail() {
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) || '';
} catch (_e) { return ''; }
}
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
async function mkdir(absUrl) {
var resp = await fetch(absUrl, {

View file

@ -211,13 +211,7 @@
// ── Rendering ────────────────────────────────────────────────────────
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';
}
var fmtSize = window.app.modules.util.fmtSize;
function fmtDate(d) {
if (!d) return '';
@ -226,10 +220,7 @@
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var escapeHtml = window.app.modules.util.escapeHtml;
// Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id),

131
browse/js/util.js Normal file
View file

@ -0,0 +1,131 @@
// 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;
}
// Write content back to a file's source. 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.
// Throws when the source has no write target.
async function saveFile(node, content, contentType) {
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;
}
if (node.url && window.app.state.source === 'server') {
var resp = await fetch(node.url, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: content,
credentials: 'same-origin'
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return;
}
throw new Error('No write target for this file (read-only source).');
}
window.app.modules.util = {
escapeHtml: escapeHtml,
hashContent: hashContent,
isoDateToday: isoDateToday,
isoDatePlus: isoDatePlus,
yamlQuote: yamlQuote,
fetchAccessEmails: fetchAccessEmails,
fmtSize: fmtSize,
isZipMemberNode: isZipMemberNode,
saveFile: saveFile
};
})();