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>
131 lines
5.3 KiB
JavaScript
131 lines
5.3 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 ({
|
|
'&': '&', '<': '<', '>': '>',
|
|
'"': '"', "'": '''
|
|
})[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
|
|
};
|
|
})();
|