(function (app) { 'use strict'; const util = app.util = app.util || {}; util.hasCrypto = function hasCrypto() { return !!(window.crypto && window.crypto.subtle && typeof window.crypto.subtle.digest === 'function'); }; util.canonicalStringify = function canonicalStringify(input) { if (input === null) { return 'null'; } const type = typeof input; if (type === 'number' || type === 'boolean' || type === 'string') { return JSON.stringify(input); } if (Array.isArray(input)) { return '[' + input.map(util.canonicalStringify).join(',') + ']'; } const keys = Object.keys(input).sort(); return '{' + keys.map(function (key) { return JSON.stringify(key) + ':' + util.canonicalStringify(input[key]); }).join(',') + '}'; }; util.hashString = function hashString(str) { return zddc.crypto.sha256String(str); }; util.arrayBufferToHex = function arrayBufferToHex(buffer) { return zddc.crypto.bytesToHex(buffer); }; util.base64ToArrayBuffer = function base64ToArrayBuffer(base64Value) { if (!base64Value) { return new ArrayBuffer(0); } const binaryString = atob(base64Value); const length = binaryString.length; const bytes = new Uint8Array(length); for (let index = 0; index < length; index += 1) { bytes[index] = binaryString.charCodeAt(index); } return bytes.buffer; }; util.arrayBufferToBase64 = function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let output = ''; for (let index = 0; index < bytes.length; index += 1) { output += String.fromCharCode(bytes[index]); } return btoa(output); }; // Revision comparison delegates to shared zddc library util.compareRevisionPriority = function compareRevisionPriority(aRevision, bRevision) { return zddc.compareRevisions(aRevision, bRevision); }; util.compareFilesByTrackingRevision = function compareFilesByTrackingRevision(a, b) { const trackingA = (a && a.trackingNumber ? String(a.trackingNumber) : '').toLowerCase(); const trackingB = (b && b.trackingNumber ? String(b.trackingNumber) : '').toLowerCase(); if (trackingA < trackingB) { return -1; } if (trackingA > trackingB) { return 1; } const revisionCompare = util.compareRevisionPriority(a && a.revision, b && b.revision); if (revisionCompare !== 0) { return revisionCompare; } const extA = (a && a.extension ? String(a.extension) : '').toLowerCase(); const extB = (b && b.extension ? String(b.extension) : '').toLowerCase(); if (extA < extB) { return -1; } if (extA > extB) { return 1; } return 0; }; util.canonicalizePublicJwk = function canonicalizePublicJwk(pk) { if (!pk) { return { kty: 'EC', crv: 'P-256', x: '', y: '' }; } return { kty: pk.kty || 'EC', crv: pk.crv || 'P-256', x: pk.x || '', y: pk.y || '' }; }; util.publicKeyFingerprint = async function publicKeyFingerprint(pk) { try { if (!pk) { return ''; } if (!util.hasCrypto()) { return null; } const canonical = util.canonicalizePublicJwk(pk); const canonicalStr = util.canonicalStringify(canonical); const hash = await util.hashString(canonicalStr); return (hash || '').slice(0, 12); } catch (err) { console.error('[transmittal] publicKeyFingerprint error', err); return ''; } }; util.hashFile = function hashFile(file, onProgress) { return zddc.crypto.sha256File(file, onProgress); }; util.escapeHtml = function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>'); }; util.escapeHtmlAttribute = function escapeHtmlAttribute(value) { return String(value || '') .replace(/&/g, '&') .replace(/"/g, '"'); }; util.downloadBlob = function downloadBlob(filename, contents, mime) { var blob = (contents instanceof Blob) ? contents : new Blob([contents], { type: mime || 'application/octet-stream' }); var anchor = document.createElement('a'); anchor.href = URL.createObjectURL(blob); anchor.download = filename; document.body.appendChild(anchor); anchor.click(); setTimeout(function () { URL.revokeObjectURL(anchor.href); anchor.remove(); }, 0); }; util.createEmptyData = function createEmptyData(date) { return { envelope: { version: 1, digestAlgorithm: app.constants.digestAlgorithm, digest: '', digestedAt: '', signatureAlgorithm: app.constants.signatureAlgorithm, signatures: [] }, payload: { version: 1, type: 'Transmittal', title: '', client: '', project: '', projectNumber: '', date: date || '', trackingNumber: '', from: '', to: '', purpose: '', responseDue: '', subject: '', remarks: '', files: [] }, presentation: { leftLogo: '', rightLogo: '', theme: 'default', customCss: '' } }; }; util.fetchTrustedTime = function fetchTrustedTime() { return new Date().toISOString(); }; util.formatISOWithTZ = function formatISOWithTZ(isoStr) { if (!isoStr) { return 'Unknown'; } var d = new Date(isoStr); if (isNaN(d.getTime())) { return isoStr; } try { return d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); } catch (_) { return d.toLocaleString(); } }; util.signEnvelope = async function signEnvelope(envelope, jwk) { if (!jwk || jwk.kty !== 'EC') { throw new Error('A valid EC private key (JWK) is required to sign.'); } var envelopeToSign = { version: envelope.version || 1, digestAlgorithm: envelope.digestAlgorithm || app.constants.digestAlgorithm, digest: envelope.digest, digestedAt: envelope.digestedAt, signatureAlgorithm: envelope.signatureAlgorithm || app.constants.signatureAlgorithm }; var envelopeStr = util.canonicalStringify(envelopeToSign); var key = await window.crypto.subtle.importKey( 'jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'] ); var raw = await window.crypto.subtle.sign( { name: 'ECDSA', hash: { name: 'SHA-256' } }, key, new TextEncoder().encode(envelopeStr) ); return { signature: util.arrayBufferToBase64(raw), signedAt: util.fetchTrustedTime() }; }; util.formatShortHash = function formatShortHash(hex) { const normalized = (hex || '').trim(); if (!normalized) { return ''; } if (normalized.length <= 20) { return normalized; } return normalized.slice(0, 12) + '\u2026' + normalized.slice(-8); }; util.formatShortFileHash = function formatShortFileHash(hex) { const normalized = (hex || '').trim(); if (!normalized) { return ''; } if (normalized.length <= 12) { return normalized; } return normalized.slice(0, 6) + '\u2026' + normalized.slice(-5); }; util.formatFileSize = function formatFileSize(bytes) { const num = Number(bytes) || 0; if (num === 0) { return '0 B'; } if (num < 1024) { return num + ' B'; } if (num < 1024 * 1024) { return (num / 1024).toFixed(1) + ' KB'; } if (num < 1024 * 1024 * 1024) { return (num / (1024 * 1024)).toFixed(1) + ' MB'; } return (num / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }; util.fileToImage = function fileToImage(file) { return new Promise(function (resolve, reject) { const url = URL.createObjectURL(file); const img = new Image(); img.onload = function () { URL.revokeObjectURL(url); resolve(img); }; img.onerror = function (err) { URL.revokeObjectURL(url); reject(err); }; img.src = url; }); }; util.imageFileToPngDataUrl = async function imageFileToPngDataUrl(file, maxWidth, maxHeight) { const img = await util.fileToImage(file); const scale = Math.min(1, maxWidth / img.width, maxHeight / img.height); const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); return canvas.toDataURL('image/png'); }; // ── ZDDC Signing Key utilities ───────────────────────── var KEY_FORMAT = 'zddc-signing-key-v1'; var KDF_ITERATIONS = 100000; function deriveWrappingKey(password, salt) { var enc = new TextEncoder(); return window.crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']) .then(function (baseKey) { return window.crypto.subtle.deriveKey( { name: 'PBKDF2', salt: salt, iterations: KDF_ITERATIONS, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); }); } util.encryptPrivateKey = async function encryptPrivateKey(jwk, password, publicFingerprint) { var plaintext = new TextEncoder().encode(JSON.stringify(jwk)); var salt = window.crypto.getRandomValues(new Uint8Array(16)); var iv = window.crypto.getRandomValues(new Uint8Array(12)); var wrappingKey = await deriveWrappingKey(password, salt); var ciphertext = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, wrappingKey, plaintext); return { format: KEY_FORMAT, publicFingerprint: publicFingerprint || '', encrypted: true, kdf: 'PBKDF2-SHA256', iterations: KDF_ITERATIONS, salt: util.arrayBufferToBase64(salt), iv: util.arrayBufferToBase64(iv), ciphertext: util.arrayBufferToBase64(ciphertext) }; }; util.decryptPrivateKey = async function decryptPrivateKey(keyData, password) { var salt = util.base64ToArrayBuffer(keyData.salt); var iv = util.base64ToArrayBuffer(keyData.iv); var ciphertext = util.base64ToArrayBuffer(keyData.ciphertext); var wrappingKey = await deriveWrappingKey(password, salt); var plaintext = await window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, wrappingKey, ciphertext); return JSON.parse(new TextDecoder().decode(plaintext)); }; util.wrapKeyFile = function wrapKeyFile(jwk, publicFingerprint) { return { format: KEY_FORMAT, publicFingerprint: publicFingerprint || '', encrypted: false, key: jwk }; }; util.loadKeyFile = async function loadKeyFile(text, promptForPassword) { var data = JSON.parse(text); // ZDDC key format if (data.format === KEY_FORMAT) { if (data.encrypted) { if (typeof promptForPassword !== 'function') { throw new Error('Password required but no prompt available.'); } var password = await promptForPassword(data.publicFingerprint); if (!password && password !== '') { return null; } // user cancelled return util.decryptPrivateKey(data, password); } return data.key; } throw new Error('Unrecognized key file format. Expected a .zddc-key file.'); }; util.cloneDocumentHtml = function cloneDocumentHtml() { // Temporarily remove inline scripts from the LIVE DOM so that // outerHTML produces a clean string with zero script bodies. // We never create a clone via innerHTML because the HTML parser // would corrupt the JSON block if base64 data contains a close-script tag. var scripts = document.querySelectorAll('script:not([src])'); var saved = []; for (var i = 0; i < scripts.length; i++) { var el = scripts[i]; saved.push({ el: el, parent: el.parentNode, next: el.nextSibling, body: el.textContent || '', attrs: '' }); for (var a = 0; a < el.attributes.length; a++) { var attr = el.attributes[a]; saved[i].attrs += ' ' + attr.name + '="' + attr.value.replace(/&/g, '&').replace(/"/g, '"') + '"'; } el.parentNode.removeChild(el); } var html = document.documentElement.outerHTML; // Restore every script element to the live DOM immediately for (var r = 0; r < saved.length; r++) { saved[r].parent.insertBefore(saved[r].el, saved[r].next); } // Build each script tag as a safe string and insert before var scriptStrings = ''; for (var j = 0; j < saved.length; j++) { var s = saved[j]; var safeBody = s.body; var scriptType = (s.el.getAttribute('type') || '').toLowerCase(); if (scriptType === 'application/json') { // Re-serialize from parsed data so we control the output. // \u003c is valid JSON; JSON.parse converts it back to < var jsonData = app.json.parse(); safeBody = JSON.stringify(jsonData, null, 2).replace(/' + safeBody + ''; } // Insert scripts before closing var bodyClose = html.lastIndexOf(''); if (bodyClose !== -1) { html = html.substring(0, bodyClose) + scriptStrings + '\n' + html.substring(bodyClose); } else { html += scriptStrings; } return '\n' + html; }; /** * Fetch the current page's own source HTML and replace only the * transmittal-data JSON block with the supplied data object. * * This is the preferred save mechanism for drafts because it produces * an exact copy of the source file with only the data changed, rather * than a DOM snapshot that may contain stale or mutated content. * * @param {object} jsonData - The data object to embed as JSON. * @returns {Promise} The patched HTML string. * @throws {Error} If the fetch fails or the JSON block is not found. */ util.fetchAndPatchHtml = async function fetchAndPatchHtml(jsonData) { var response = await fetch(location.href, { cache: 'no-cache' }); if (!response.ok) { throw new Error('fetch failed with status ' + response.status); } var html = await response.text(); // \u003c is valid JSON; JSON.parse converts it back to < var jsonStr = JSON.stringify(jsonData, null, 2).replace(/]*\\bid\\s*=\\s*["\']transmittal-data["\'][^>]*>)[\\s\\S]*?(<\\/' + 'script>)', 'i' ), '$1\n' + jsonStr + '\n$2' ); if (patched === html) { throw new Error('transmittal-data script block not found in fetched HTML'); } return patched; }; })(window.transmittalApp);