ZDDC/transmittal/js/publish-modal.js
2026-06-11 13:32:31 -05:00

488 lines
19 KiB
JavaScript

(function (app) {
'use strict';
if (!app || !app.dom || !app.modules) {
console.error('[publish-modal] App not properly initialized');
return;
}
var dom = app.dom;
var util = app.util;
function qs(sel) { return dom.qs(sel); }
// ── Date helpers ────────────────────────────────────
function getTodayIso() {
var d = new Date();
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function toIsoDate(str) {
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) { return str; }
var d = new Date(str);
if (isNaN(d.getTime())) { return str; }
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function syncDateWarning() {
var dateInput = qs('#publish-date-input');
var warning = qs('#publish-date-warning');
if (!dateInput || !warning) { return; }
var val = (dateInput.value || '').trim();
var today = getTodayIso();
if (!val || val === today) {
warning.textContent = '';
warning.hidden = true;
} else {
warning.textContent = '\u26A0 Date differs from today (' + today + ')';
warning.hidden = false;
}
}
// ── Feedback helpers ────────────────────────────────
function showFeedback(feedbackEl, message, type) {
if (!feedbackEl) { return; }
feedbackEl.textContent = message || '';
feedbackEl.style.color = type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '';
}
// ── Success notification ────────────────────────────
function showSuccessNotification(filename) {
var notification = document.createElement('div');
notification.className = 'publish-notification';
notification.setAttribute('data-publish-notification', 'true');
var title = document.createElement('div');
title.className = 'publish-notification__title';
title.textContent = '\u2713 Transmittal Published Successfully';
var file = document.createElement('div');
file.className = 'publish-notification__file';
file.textContent = 'Saved as: ' + filename;
var closeBtn = document.createElement('button');
closeBtn.className = 'publish-notification__close';
closeBtn.textContent = 'Close';
closeBtn.addEventListener('click', function () { notification.remove(); });
notification.appendChild(title);
notification.appendChild(file);
notification.appendChild(closeBtn);
document.body.appendChild(notification);
setTimeout(function () { if (notification.parentElement) { notification.remove(); } }, 10000);
}
// ── Password dialog ─────────────────────────────────
function promptForPassword(fingerprint) {
return new Promise(function (resolve) {
var dialog = qs('#password-dialog');
var input = qs('#password-dialog-input');
var okBtn = qs('#password-dialog-ok');
var feedback = qs('#password-dialog-feedback');
var fpEl = qs('#password-dialog-fingerprint');
if (!dialog || !input || !okBtn) { resolve(null); return; }
input.value = '';
showFeedback(feedback, '', '');
if (fpEl) {
fpEl.textContent = fingerprint ? 'Key fingerprint: ' + fingerprint : 'This key is password-protected.';
}
dialog.showModal();
input.focus();
function cleanup() {
okBtn.removeEventListener('click', onOk);
dialog.removeEventListener('close', onClose);
}
function onOk() {
var pw = input.value;
cleanup();
dialog.close();
resolve(pw);
}
function onClose() {
cleanup();
resolve(null);
}
okBtn.addEventListener('click', onOk);
dialog.addEventListener('close', onClose);
// Enter key submits
input.addEventListener('keydown', function handler(e) {
if (e.key === 'Enter') {
e.preventDefault();
input.removeEventListener('keydown', handler);
onOk();
}
});
});
}
// ── Key dialog ──────────────────────────────────────
function openKeyDialog() {
return new Promise(function (resolve) {
var dialog = qs('#key-dialog');
var selectBtn = qs('#key-select-file');
var generateBtn = qs('#key-generate');
var generatePanel = qs('#key-generate-panel');
var genConfirmBtn = qs('#key-generate-confirm');
var genCancelBtn = qs('#key-generate-cancel');
var pwInput = qs('#key-password');
var pwConfirm = qs('#key-password-confirm');
var feedback = qs('#key-dialog-feedback');
if (!dialog) { resolve(null); return; }
// Reset state
if (generatePanel) { generatePanel.hidden = true; }
if (pwInput) { pwInput.value = ''; }
if (pwConfirm) { pwConfirm.value = ''; }
showFeedback(feedback, '', '');
dialog.showModal();
var resolved = false;
function done(jwk) {
if (resolved) { return; }
resolved = true;
cleanup();
if (dialog.open) { dialog.close(); }
resolve(jwk);
}
function cleanup() {
if (selectBtn) { selectBtn.removeEventListener('click', onSelect); }
if (generateBtn) { generateBtn.removeEventListener('click', onGenerate); }
if (genConfirmBtn) { genConfirmBtn.removeEventListener('click', onGenConfirm); }
if (genCancelBtn) { genCancelBtn.removeEventListener('click', onGenCancel); }
dialog.removeEventListener('close', onDialogClose);
}
function onDialogClose() { done(null); }
async function onSelect() {
try {
var security = app.modules.security;
var jwk = await pickKeyFileWithPassword();
if (jwk) { done(jwk); }
} catch (err) {
showFeedback(feedback, 'Failed to load key: ' + (err.message || err), 'error');
}
}
function onGenerate() {
if (generatePanel) { generatePanel.hidden = false; }
if (generateBtn) { generateBtn.hidden = true; }
if (selectBtn) { selectBtn.hidden = true; }
}
function onGenCancel() {
if (generatePanel) { generatePanel.hidden = true; }
if (generateBtn) { generateBtn.hidden = false; }
if (selectBtn) { selectBtn.hidden = false; }
if (pwInput) { pwInput.value = ''; }
if (pwConfirm) { pwConfirm.value = ''; }
}
async function onGenConfirm() {
var pw = pwInput ? pwInput.value : '';
var pwc = pwConfirm ? pwConfirm.value : '';
if (pw && pw !== pwc) {
showFeedback(feedback, 'Passwords do not match.', 'error');
return;
}
try {
showFeedback(feedback, 'Generating key\u2026', '');
var security = app.modules.security;
var keys = await window.crypto.subtle.generateKey(
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']
);
var privateJwk = await window.crypto.subtle.exportKey('jwk', keys.privateKey);
var publicJwk = security.derivePublicFromPrivate(privateJwk);
var fingerprint = await util.publicKeyFingerprint(publicJwk);
var keyFileData;
if (pw) {
keyFileData = await util.encryptPrivateKey(privateJwk, pw, fingerprint);
} else {
keyFileData = util.wrapKeyFile(privateJwk, fingerprint);
}
util.downloadBlob('signing-key.zddc-key', JSON.stringify(keyFileData, null, 2), 'application/json');
showFeedback(feedback, 'Key downloaded. Fingerprint: ' + (fingerprint || '(unknown)'), 'success');
done(privateJwk);
} catch (err) {
showFeedback(feedback, 'Failed to generate key: ' + (err.message || err), 'error');
}
}
if (selectBtn) { selectBtn.addEventListener('click', onSelect); }
if (generateBtn) { generateBtn.addEventListener('click', onGenerate); }
if (genConfirmBtn) { genConfirmBtn.addEventListener('click', onGenConfirm); }
if (genCancelBtn) { genCancelBtn.addEventListener('click', onGenCancel); }
dialog.addEventListener('close', onDialogClose);
});
}
// ── Pick key file with automatic password prompt ────
function pickKeyFileWithPassword() {
return new Promise(function (resolve, reject) {
var input = document.createElement('input');
input.type = 'file';
input.accept = '.zddc-key';
input.addEventListener('change', async function () {
try {
var file = input.files && input.files[0];
if (!file) { resolve(null); return; }
var text = await file.text();
var jwk = await util.loadKeyFile(text, promptForPassword);
resolve(jwk);
} catch (err) {
reject(err);
}
});
input.click();
});
}
// ── Output checkbox helpers ───────────────────────────
function readOutputOptions() {
var saveCb = qs('#publish-save-folder');
var dlCb = qs('#publish-download-html');
return {
saveToFolder: saveCb && saveCb.checked,
download: dlCb && dlCb.checked
};
}
function syncOutputCheckboxes() {
var saveCb = qs('#publish-save-folder');
var dlCb = qs('#publish-download-html');
var hasDir = !!app.data.selectedDirHandle;
if (saveCb) { saveCb.checked = hasDir; }
if (dlCb) { dlCb.checked = !hasDir; }
}
function restoreOutputOptions(opts) {
var saveCb = qs('#publish-save-folder');
var dlCb = qs('#publish-download-html');
if (saveCb) { saveCb.checked = !!opts.saveToFolder; }
if (dlCb) { dlCb.checked = !!opts.download; }
}
function feedbackLabel(mode) {
if (mode === 'draft') { return 'Saving draft'; }
if (mode === 'signed') { return 'Signing and publishing'; }
return 'Publishing';
}
function setPublishBusy(busy) {
var btns = [qs('#publish-confirm'), qs('#publish-signed-btn'), qs('#publish-draft-btn')];
for (var i = 0; i < btns.length; i++) {
if (!btns[i]) { continue; }
btns[i].disabled = busy;
if (busy) { btns[i].classList.add('opacity-60'); }
else { btns[i].classList.remove('opacity-60'); }
}
}
// ── Core publish execution ──────────────────────────
async function executePublishFlow(mode, signingKey) {
var feedback = qs('#publish-modal-feedback');
var opts = readOutputOptions();
if (!opts.saveToFolder && !opts.download) {
throw new Error('Select at least one output option.');
}
// Prompt for directory if Save in directory is checked but none selected
if (opts.saveToFolder && !app.data.selectedDirHandle) {
try {
var dirHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
app.data.selectedDirHandle = dirHandle;
if (app.modules.files && app.modules.files.updateDirectoryIndicator) {
app.modules.files.updateDirectoryIndicator();
}
} catch (dirErr) {
throw new Error('A directory is required to save. Please select a directory.');
}
}
// Sync date from modal back to form
var dateInput = qs('#publish-date-input');
var formDate = qs('#date');
if (dateInput && formDate && dateInput.value) {
formDate.value = dateInput.value;
}
var publishModule = app.modules.publish;
if (!publishModule || typeof publishModule.executePublish !== 'function') {
throw new Error('Publish module not ready');
}
showFeedback(feedback, feedbackLabel(mode) + '\u2026', '');
var result = await publishModule.executePublish({
mode: mode,
saveToFolder: opts.saveToFolder,
download: opts.download,
signingKey: signingKey || null
});
return result;
}
// ── Publish modal ───────────────────────────────────
function initPublishModal() {
var modal = qs('#publish-modal');
if (!modal) { return; }
var feedback = qs('#publish-modal-feedback');
var confirmBtn = qs('#publish-confirm');
var signedBtn = qs('#publish-signed-btn');
var draftBtn = qs('#publish-draft-btn');
var dateInput = qs('#publish-date-input');
// Close buttons
modal.querySelectorAll('[data-modal-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (modal.open) { modal.close(); }
});
});
// Backdrop click
modal.addEventListener('click', function (e) {
if (e.target === modal) { modal.close(); }
});
// Date warning
if (dateInput) { dateInput.addEventListener('input', syncDateWarning); }
function openModal() {
showFeedback(feedback, '', '');
syncOutputCheckboxes();
// Populate date
var formDate = qs('#date');
if (formDate && dateInput) {
var val = (formDate.value || '').trim();
dateInput.value = val ? toIsoDate(val) : getTodayIso();
}
syncDateWarning();
// Warn about missing hashes before user commits
var missingHash = 0;
(app.data.files || []).forEach(function (f) {
if (!f.sha256) { missingHash++; }
});
if (missingHash > 0) {
showFeedback(feedback, missingHash + ' file(s) have no SHA-256 hash.', 'error');
}
if (!modal.open) { modal.showModal(); }
}
function closeAndNotify(result) {
if (modal.open) { modal.close(); }
var msg = result.zipFilename ? result.filename + ' + ' + result.zipFilename : result.filename;
setTimeout(function () { showSuccessNotification(msg); }, 100);
}
function handleError(err) {
showFeedback(feedback, err && err.message ? err.message : 'Publish failed.', 'error');
if (err && err.focusEl && typeof err.focusEl.focus === 'function') {
if (modal.open) { modal.close(); }
err.focusEl.focus();
if (err.focusEl.scrollIntoView) { err.focusEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
}
}
// Publish (unsigned with digest) — primary action
if (confirmBtn) {
confirmBtn.addEventListener('click', async function () {
setPublishBusy(true);
try {
var result = await executePublishFlow('digest');
setPublishBusy(false);
closeAndNotify(result);
} catch (err) {
setPublishBusy(false);
handleError(err);
}
});
}
// Publish Signed — opens key dialog first
if (signedBtn) {
signedBtn.addEventListener('click', async function () {
// Save user's checkbox selections before closing
var savedOpts = readOutputOptions();
if (modal.open) { modal.close(); }
try {
var jwk = await openKeyDialog();
if (!jwk) { openModal(); restoreOutputOptions(savedOpts); return; }
openModal();
restoreOutputOptions(savedOpts);
setPublishBusy(true);
var result = await executePublishFlow('signed', jwk);
setPublishBusy(false);
closeAndNotify(result);
} catch (err) {
setPublishBusy(false);
openModal();
restoreOutputOptions(savedOpts);
handleError(err);
}
});
}
// Save Draft
if (draftBtn) {
draftBtn.addEventListener('click', async function () {
setPublishBusy(true);
try {
var result = await executePublishFlow('draft');
setPublishBusy(false);
closeAndNotify(result);
} catch (err) {
setPublishBusy(false);
handleError(err);
}
});
}
// Listen for publish requests from the primary action button
document.addEventListener('transmittal:open-publish', function () {
openModal();
});
}
// ── Password dialog close buttons ───────────────────
function initPasswordDialog() {
var dialog = qs('#password-dialog');
if (!dialog) { return; }
dialog.querySelectorAll('[data-modal-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dialog.open) { dialog.close(); }
});
});
dialog.addEventListener('click', function (e) {
if (e.target === dialog) { dialog.close(); }
});
}
// ── Key dialog close buttons ────────────────────────
function initKeyDialog() {
var dialog = qs('#key-dialog');
if (!dialog) { return; }
dialog.querySelectorAll('[data-modal-close]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (dialog.open) { dialog.close(); }
});
});
dialog.addEventListener('click', function (e) {
if (e.target === dialog) { dialog.close(); }
});
}
app.registerInit(function () {
initPublishModal();
initPasswordDialog();
initKeyDialog();
});
})(window.transmittalApp);