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/icons.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"js/init.js" \ "js/init.js" \
"js/util.js" \
"js/loader.js" \ "js/loader.js" \
"js/tree.js" \ "js/tree.js" \
"js/preview.js" \ "js/preview.js" \

View file

@ -25,28 +25,10 @@
if (t) t(msg, level || 'info'); if (t) t(msg, level || 'info');
} }
function isoDateToday() { var util = window.app.modules.util;
var d = new Date(); var escapeHtml = util.escapeHtml;
return d.getFullYear() var isoDateToday = util.isoDateToday;
+ '-' + ('0' + (d.getMonth() + 1)).slice(-2) var isoDatePlus = util.isoDatePlus;
+ '-' + ('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];
});
}
// Is this node a direct child of an incoming/ canonical folder // Is this node a direct child of an incoming/ canonical folder
// AND a well-formed transmittal folder? The first half is the // AND a well-formed transmittal folder? The first half is the
@ -96,19 +78,7 @@
return out; return out;
} }
function fetchPeopleSuggestions() { var fetchPeopleSuggestions = util.fetchAccessEmails;
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 []; });
}
function openForm(initial) { function openForm(initial) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
@ -230,9 +200,7 @@
}); });
} }
function quote(s) { var quote = util.yamlQuote;
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function buildBody(values) { function buildBody(values) {
var lines = ['received_date: ' + values.receivedDate]; var lines = ['received_date: ' + values.receivedDate];
if (values.setupPlanReview) { if (values.setupPlanReview) {

View file

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

View file

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

View file

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

View file

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

View file

@ -45,44 +45,18 @@
} }
} }
// Compute today + N days as a YYYY-MM-DD string. var util = window.app.modules.util;
function isoDatePlus(days) { var isoDatePlus = util.isoDatePlus;
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;
}
// Fetch suggestion emails from /.profile/access so the originator // Fetch suggestion emails from /.profile/access so the originator
// field has a datalist of likely values. Best-effort — silent on // field has a datalist of likely values. Best-effort — silent on
// failure (the field still accepts free text). // failure (the field still accepts free text).
async function fetchOriginatorSuggestions() { var fetchOriginatorSuggestions = util.fetchAccessEmails;
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 [];
}
}
// Build the YAML body for the plan-review POST. Quoting is minimal // Build the YAML body for the plan-review POST. Quoting is minimal
// (just enough for emails with special chars). // (just enough for emails with special chars).
function buildBody(values) { function buildBody(values) {
function yamlString(s) { var yamlString = util.yamlQuote;
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return [ return [
'review_lead: ' + yamlString(values.reviewLead), 'review_lead: ' + yamlString(values.reviewLead),
'approver: ' + yamlString(values.approver), 'approver: ' + yamlString(values.approver),
@ -188,14 +162,7 @@
}); });
} }
function escapeHtml(s) { var escapeHtml = util.escapeHtml;
return String(s).replace(/[&<>"']/g, function (c) {
return ({
'&': '&amp;', '<': '&lt;', '>': '&gt;',
'"': '&quot;', "'": '&#39;'
})[c];
});
}
// Detect whether a tree node is an archive/<party>/received/<tracking>/ // Detect whether a tree node is an archive/<party>/received/<tracking>/
// folder. The path is path-shaped, not content-based — tracking-number // folder. The path is path-shaped, not content-based — tracking-number

View file

@ -42,27 +42,14 @@
var SIDEBAR_DEFAULT_WIDTH = 280; var SIDEBAR_DEFAULT_WIDTH = 280;
var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar var FM_DEFAULT_HEIGHT = 180; // px — front-matter pane height inside sidebar
function escapeHtml(s) { var util = window.app.modules.util;
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') var escapeHtml = util.escapeHtml;
.replace(/>/g, '&gt;').replace(/"/g, '&quot;'); var hashContent = util.hashContent;
}
var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl } var currentInstance = null; // { editor, container, dirty, node, hash, tocEl, fmEl }
var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts var lastSidebarWidth = SIDEBAR_DEFAULT_WIDTH; // remember across mounts
var lastFmHeight = FM_DEFAULT_HEIGHT; 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() { function dispose() {
if (currentInstance) { if (currentInstance) {
// Tear down the document-level resizer drag listeners (added // Tear down the document-level resizer drag listeners (added
@ -291,38 +278,11 @@
// ── Save ──────────────────────────────────────────────────────────────── // ── Save ────────────────────────────────────────────────────────────────
async function saveContent(node, content) { function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') { return util.saveFile(node, content, 'text/markdown; charset=utf-8');
// 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).');
} }
// A markdown file living inside a .zip is read-only: a ZipFileHandle var isZipMemberNode = util.isZipMemberNode;
// 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;
}
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;

View file

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

View file

@ -19,10 +19,8 @@
console.error('[browse] zddc.preview not loaded — preview disabled.'); console.error('[browse] zddc.preview not loaded — preview disabled.');
} }
function escapeHtml(s) { var util = window.app.modules.util;
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;') var escapeHtml = util.escapeHtml;
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
var MIME = { var MIME = {
'pdf': 'application/pdf', 'pdf': 'application/pdf',
@ -41,13 +39,7 @@
function getMime(ext) { return MIME[ext] || 'application/octet-stream'; } function getMime(ext) { return MIME[ext] || 'application/octet-stream'; }
function fmtSize(bytes) { var fmtSize = util.fmtSize;
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';
}
async function getArrayBuffer(node) { async function getArrayBuffer(node) {
// A zip member node carries a ZipFileHandle in node.handle, so // 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 // 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. // double menu click). The picker modal also blocks re-entry while open.
var busy = false; var busy = false;
function escapeHtml(s) { var escapeHtml = window.app.modules.util.escapeHtml;
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c];
});
}
// ── Scope detection: path-shape, not cascade-content ────────────── // ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its path matches // A file is stageable if its path matches
@ -99,18 +95,6 @@
.map(function (e) { return e.name; }); .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. // POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
async function mkdir(absUrl) { async function mkdir(absUrl) {
var resp = await fetch(absUrl, { var resp = await fetch(absUrl, {

View file

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