// 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; } // 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 ') 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 // `-conflict-.` (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 }; })();