ZDDC/transmittal/js/publish.js
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

260 lines
10 KiB
JavaScript

(function (app) {
'use strict';
const dom = app.dom;
const util = app.util;
const json = app.json;
const security = app.modules.security;
const validation = app.modules.validation;
const filesModule = app.modules.files;
const dataModule = app.modules.data;
const mode = app.modules.mode;
async function syncUiToJson(options) {
const sign = !!(options && options.sign);
const computeDigest = !!(options && options.computeDigest);
// First, sync UI to JSON to ensure JSON is up-to-date
filesModule.syncUiToJson();
// Now use JSON as single source of truth
const data = json.parse();
const previousEnvelope = (data && data.envelope) || {};
const payload = (data && data.payload) || {};
const previousPresentation = (data && data.presentation) || {};
const payloadStr = util.canonicalStringify(payload);
const previousPayloadStr = util.canonicalStringify(payload);
const payloadChanged = false; // Always false since we just synced
let digest = previousEnvelope.digest || '';
let digestedAt = previousEnvelope.digestedAt || '';
let signatures = Array.isArray(previousEnvelope.signatures) ? previousEnvelope.signatures.slice() : [];
let computedDigest = '';
if (sign || computeDigest || payloadChanged || !digest) {
try {
computedDigest = await util.hashString(payloadStr);
} catch (err) {
computedDigest = '';
}
}
if (sign || computeDigest) {
if (!computedDigest) {
throw new Error('Unable to compute digest.');
}
digest = computedDigest;
digestedAt = util.fetchTrustedTime();
}
if (sign) {
const jwk = options && options.signingKey;
if (!jwk || jwk.kty !== 'EC') {
throw new Error('A valid EC private key (JWK) is required to sign this transmittal.');
}
const envelopeForSigning = {
version: 1,
digestAlgorithm: app.constants.digestAlgorithm,
digest: digest,
digestedAt: digestedAt,
signatureAlgorithm: app.constants.signatureAlgorithm
};
const sigResult = await util.signEnvelope(envelopeForSigning, jwk);
const publicJwk = security.derivePublicFromPrivate(jwk);
signatures.push({
signature: sigResult.signature,
signedAt: sigResult.signedAt,
publicKeyJwk: publicJwk
});
}
const envelope = {
version: 1,
digestAlgorithm: app.constants.digestAlgorithm,
digest: digest,
digestedAt: digestedAt,
signatureAlgorithm: app.constants.signatureAlgorithm,
signatures: signatures
};
const presentation = previousPresentation;
json.setData({ envelope: envelope, payload: payload, presentation: presentation });
if (computeDigest || sign) {
try {
await security.verifySignatureIfPresent();
} catch (_) {
// ignore
}
app.state.apply();
}
app.state.dirty = false;
}
async function buildHtmlString() {
// Hide editor before cloning so its markup is not in the output
var mdEditor = app.modules.markdownEditor;
var editorWasActive = mdEditor && mdEditor.isActive();
if (editorWasActive) { mdEditor.destroy(); }
// ── Preferred path: fetch the page's own source and patch the JSON ──
// This produces an exact copy of the original file with only the data
// changed, avoiding DOM-snapshot drift. Falls back to the DOM-snapshot
// approach when the page cannot fetch itself (e.g. opaque origins).
try {
var jsonData = app.json.parse();
var html = await util.fetchAndPatchHtml(jsonData);
if (editorWasActive && mdEditor && typeof mdEditor.showRendered === 'function') {
mdEditor.showRendered();
}
return html;
} catch (fetchErr) {
console.warn('[transmittal] fetch-based save unavailable, falling back to DOM snapshot:', fetchErr.message);
}
// ── Fallback path: DOM snapshot ─────────────────────────────────────
// Populate static content before cloning for progressive enhancement
if (app.modules.hydrate && app.modules.hydrate.populateStatic) {
await app.modules.hydrate.populateStatic();
}
// Remove any existing success notifications
var notifications = document.querySelectorAll('[data-publish-notification]');
notifications.forEach(function (notif) { notif.remove(); });
// Hide workflow edit-only steps before cloning (viewers shouldn't see them)
var workflowEditSteps = dom.qsa('.workflow-step[data-edit-only], .workflow-tools');
var wasVisible = [];
workflowEditSteps.forEach(function (el) {
wasVisible.push(!el.hidden);
el.hidden = true;
});
// Re-hide bottom menu and restore no-js notice before cloning
var bottomMenu = dom.qs('#bottom-menu');
var bottomMenuWasVisible = bottomMenu && !bottomMenu.hidden;
if (bottomMenu) { bottomMenu.hidden = true; }
var noJsNotice = dom.qs('#no-js-notice');
var addedNotice = false;
if (!noJsNotice && bottomMenu && bottomMenu.parentNode) {
noJsNotice = document.createElement('span');
noJsNotice.id = 'no-js-notice';
noJsNotice.className = 'text-gray-400 text-xs italic';
noJsNotice.textContent = 'JavaScript not available';
bottomMenu.parentNode.insertBefore(noJsNotice, bottomMenu.nextSibling);
addedNotice = true;
}
// Close any open dialogs before cloning (they get reopened after)
var openDialogs = Array.from(document.querySelectorAll('dialog[open]'));
openDialogs.forEach(function (dlg) { dlg.close(); });
var snapshotHtml = util.cloneDocumentHtml();
// Restore bottom menu visibility
if (bottomMenu && bottomMenuWasVisible) { bottomMenu.hidden = false; }
if (noJsNotice) { dom.show(noJsNotice, false); }
// Reopen dialogs that were open
openDialogs.forEach(function (dlg) { dlg.showModal(); });
// Restore workflow elements
workflowEditSteps.forEach(function (el, i) {
if (wasVisible[i]) { el.hidden = false; }
});
// Re-hydrate to restore dynamic state after cloning
if (app.modules.hydrate && app.modules.hydrate.hydrate) {
app.modules.hydrate.hydrate();
}
// Show rendered preview again (editor loads on next click)
if (mdEditor) { mdEditor.showRendered(); }
return snapshotHtml;
}
function requireDirectoryHandle() {
if (!app.data.selectedDirHandle) {
throw new Error('Select a directory before saving.');
}
return app.data.selectedDirHandle;
}
async function writeHtmlToDirectory(filename, html) {
requireDirectoryHandle();
await filesModule.writeFileToSelectedDir(filename, html, 'text/html');
}
function loadSigningKey(providedKey) {
if (providedKey) { return providedKey; }
throw new Error('A signing key is required. Select or generate one first.');
}
async function executePublish(config) {
const modeSelection = (config && config.mode) || 'digest';
const wantDownload = !!(config && config.download);
const wantSaveToFolder = !!(config && config.saveToFolder);
const validationResult = validation.validateBeforePublish();
if (!validationResult.ok) {
const error = new Error(validationResult.message || 'Validation failed before publishing.');
error.focusEl = validationResult.focusEl || null;
throw error;
}
if (wantSaveToFolder) {
requireDirectoryHandle();
}
const shouldSign = modeSelection === 'signed';
const shouldComputeDigest = modeSelection === 'signed' || modeSelection === 'digest';
const isDraft = modeSelection === 'draft';
let signingKey = null;
if (shouldSign) {
signingKey = await loadSigningKey(config && config.signingKey);
}
await syncUiToJson({
sign: shouldSign,
computeDigest: shouldComputeDigest,
signingKey: signingKey
});
mode.refresh();
const html = await buildHtmlString();
const data = json.parse();
const payload = (data && data.payload) || {};
const filename = dataModule.buildFileName(payload, { extension: 'html', draft: isDraft });
if (wantSaveToFolder) {
await writeHtmlToDirectory(filename, html);
// If publishing (not draft), delete the draft file from the directory
if (!isDraft) {
var draftName = dataModule.buildFileName(payload, { extension: 'html', draft: true });
if (draftName !== filename) {
try {
await app.data.selectedDirHandle.removeEntry(draftName);
} catch (_) {
// Draft file may not exist — that's fine
}
}
}
}
if (wantDownload) {
util.downloadBlob(filename, new Blob([html], { type: 'text/html' }), 'text/html');
}
mode.refresh();
dataModule.setStatus('Published HTML as ' + filename, 'success');
return {
filename: filename,
mode: modeSelection
};
}
app.modules.publish = {
syncUiToJson,
buildHtmlString,
executePublish
};
})(window.transmittalApp);