From bbbf5326e7ec26a7cc4e18704481b56f57c74d27 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 3 Jun 2026 15:07:00 -0500 Subject: [PATCH] refactor(browse): consolidate duplicated helpers into util.js; fix YAML save divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/build.sh | 1 + browse/js/accept-transmittal.js | 44 ++--------- browse/js/create-transmittal.js | 14 +--- browse/js/events.js | 6 +- browse/js/history.js | 13 +--- browse/js/hovercard.js | 18 +---- browse/js/plan-review.js | 43 ++--------- browse/js/preview-markdown.js | 52 ++----------- browse/js/preview-yaml.js | 48 +++--------- browse/js/preview.js | 14 +--- browse/js/stage.js | 18 +---- browse/js/tree.js | 13 +--- browse/js/util.js | 131 ++++++++++++++++++++++++++++++++ 13 files changed, 173 insertions(+), 242 deletions(-) create mode 100644 browse/js/util.js diff --git a/browse/build.sh b/browse/build.sh index 3f69eba..1e7b226 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -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" \ diff --git a/browse/js/accept-transmittal.js b/browse/js/accept-transmittal.js index 727d531..101b74b 100644 --- a/browse/js/accept-transmittal.js +++ b/browse/js/accept-transmittal.js @@ -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 ({ - '&': '&', '<': '<', '>': '>', - '"': '"', "'": ''' - })[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) { diff --git a/browse/js/create-transmittal.js b/browse/js/create-transmittal.js index 9f1824a..5bea4ff 100644 --- a/browse/js/create-transmittal.js +++ b/browse/js/create-transmittal.js @@ -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 ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[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) { diff --git a/browse/js/events.js b/browse/js/events.js index fad750b..68335de 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -663,11 +663,7 @@ return parentDir; } - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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.-]*$). diff --git a/browse/js/history.js b/browse/js/history.js index e07b592..d17bf72 100644 --- a/browse/js/history.js +++ b/browse/js/history.js @@ -16,11 +16,7 @@ (function () { 'use strict'; - function escapeHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + 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 diff --git a/browse/js/hovercard.js b/browse/js/hovercard.js index 54d975c..be0f87a 100644 --- a/browse/js/hovercard.js +++ b/browse/js/hovercard.js @@ -58,22 +58,10 @@ currentRow = null; } - // ── Formatting (kept local so this module is self-contained) ── + // ── Formatting ── - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } - - 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 ''; diff --git a/browse/js/plan-review.js b/browse/js/plan-review.js index b284a2c..1e6f2d9 100644 --- a/browse/js/plan-review.js +++ b/browse/js/plan-review.js @@ -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 ({ - '&': '&', '<': '<', '>': '>', - '"': '"', "'": ''' - })[c]; - }); - } + var escapeHtml = util.escapeHtml; // Detect whether a tree node is an archive//received// // folder. The path is path-shaped, not content-based — tracking-number diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index c844e3b..bf9c6af 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); - } + 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/" 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; diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index 8fe539a..21c2153 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -22,10 +22,8 @@ if (!window.app || !window.app.modules) return; - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + 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 ──────────────────────────────────────────────────────── // diff --git a/browse/js/preview.js b/browse/js/preview.js index aed5985..9125a3e 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -19,10 +19,8 @@ console.error('[browse] zddc.preview not loaded — preview disabled.'); } - function escapeHtml(s) { - return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - } + 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 diff --git a/browse/js/stage.js b/browse/js/stage.js index 7f5d4b9..2c23c81 100644 --- a/browse/js/stage.js +++ b/browse/js/stage.js @@ -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 ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[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, { diff --git a/browse/js/tree.js b/browse/js/tree.js index 0e30fbc..0fe9d41 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); - } + 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), diff --git a/browse/js/util.js b/browse/js/util.js new file mode 100644 index 0000000..a4249e3 --- /dev/null +++ b/browse/js/util.js @@ -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 ({ + '&': '&', '<': '<', '>': '>', + '"': '"', "'": ''' + })[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/" 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 + }; +})();