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:
parent
41d4e59899
commit
bbbf5326e7
13 changed files with 173 additions and 242 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.-]*$).
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').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
|
||||
|
|
|
|||
|
|
@ -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, '>').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 '';
|
||||
|
|
|
|||
|
|
@ -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/<party>/received/<tracking>/
|
||||
// folder. The path is path-shaped, not content-based — tracking-number
|
||||
|
|
|
|||
|
|
@ -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, '>').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/<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;
|
||||
|
|
|
|||
|
|
@ -22,10 +22,8 @@
|
|||
|
||||
if (!window.app || !window.app.modules) return;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').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 ────────────────────────────────────────────────────────
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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, '>').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
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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, '>').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),
|
||||
|
|
|
|||
131
browse/js/util.js
Normal file
131
browse/js/util.js
Normal 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 ({
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
})[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
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue