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

355 lines
14 KiB
JavaScript

(function (app) {
'use strict';
const dom = app.dom;
const json = app.json;
const dataModule = app.modules.data = {};
const STATUS_DISPLAY_MS = 6000;
let statusTimer = null;
function qs(selector) {
return dom.qs(selector);
}
function setStatus(message, type) {
const element = qs('#data-status');
if (!element) {
return;
}
element.textContent = message || '';
element.dataset.statusType = type || '';
element.classList.remove('text-red-600', 'text-green-600');
if (type === 'success') {
element.classList.add('text-green-600');
} else if (type === 'error') {
element.classList.add('text-red-600');
}
if (statusTimer) {
clearTimeout(statusTimer);
}
if (message) {
statusTimer = setTimeout(function () {
element.textContent = '';
element.classList.remove('text-red-600', 'text-green-600');
}, STATUS_DISPLAY_MS);
}
}
const INVALID_FILENAME_CHARS = /[\\/:*?"<>|]+/g;
function sanitizeFilenameSegment(value, fallback) {
const trimmed = (value || '').toString().trim();
if (!trimmed) {
return fallback || '';
}
return trimmed
.replace(INVALID_FILENAME_CHARS, '-')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeTitle(value) {
const sanitized = sanitizeFilenameSegment(value, 'Transmittal');
return sanitized || 'Transmittal';
}
function sanitizeTracking(value) {
const sanitized = sanitizeFilenameSegment(value, 'TRANS');
return sanitized.replace(/[_\s]+/g, '-');
}
function sanitizeStatus(value, fallback) {
const sanitized = sanitizeFilenameSegment(value, fallback || '');
const cleaned = sanitized.replace(/[()]/g, '').trim();
if (cleaned) {
return cleaned;
}
return (fallback || 'Unspecified');
}
function sanitizeDate(value) {
const trimmed = (value || '').toString().trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
return trimmed;
}
if (!trimmed) {
return '0000-00-00';
}
return trimmed.replace(/\s+/g, '-');
}
// Folder name: DATE_TRACKING (STATUS) - Title (date first for chronological sort)
dataModule.buildFolderName = function buildFolderName(payload) {
const p = payload || {};
const date = sanitizeDate(p.date || '');
const tracking = sanitizeTracking(p.trackingNumber || '');
const status = sanitizeStatus(p.status || p.purpose || '', 'Unspecified');
const title = sanitizeTitle(p.subject || p.title || '');
const statusPart = status ? ' (' + status + ')' : '';
return date + '_' + tracking + statusPart + ' - ' + title;
};
// File name: TRACKING_[~]DATE (STATUS) - Subject.ext
// draft: true → ~DATE (tilde prefix indicates draft)
dataModule.buildFileName = function buildFileName(payload, options) {
const p = payload || {};
const opts = options || {};
const ext = opts.extension ? opts.extension.replace(/^\.+/, '') : 'json';
const isDraft = !!opts.draft;
const tracking = sanitizeTracking(p.trackingNumber || '');
const date = sanitizeDate(p.date || '');
const status = sanitizeStatus(p.status || p.purpose || '', 'Unspecified');
const title = sanitizeTitle(p.subject || p.title || '');
const statusPart = status ? ' (' + status + ')' : '';
const datePrefix = isDraft ? '~' : '';
return tracking + '_' + datePrefix + date + statusPart + ' - ' + title + '.' + ext;
};
async function saveFileWithPicker(filename, contents, mime) {
if (typeof window.showSaveFilePicker !== 'function') {
app.util.downloadBlob(filename, contents, mime);
return filename;
}
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [
{
description: mime || 'File',
accept: { [mime || 'application/octet-stream']: ['.' + zddc.splitExtension(filename).extension] }
}
]
});
const writable = await handle.createWritable();
await writable.write(new Blob([contents], { type: mime || 'application/octet-stream' }));
await writable.close();
return handle.name || filename;
}
async function serializeUiToJson() {
if (!app.modules.publish || typeof app.modules.publish.syncUiToJson !== 'function') {
throw new Error('Publish module not ready');
}
await app.modules.publish.syncUiToJson({ sign: false, computeDigest: false });
app.state.dirty = false;
}
// ── Extract JSON from an HTML transmittal string ────
function extractJsonFromHtml(htmlText) {
var m = htmlText.match(new RegExp('<script\\s+id\\s*=\\s*["\']transmittal-data["\'][^>]*>([\\s\\S]*?)</' + 'script>', 'i'));
if (!m || !m[1]) { return null; }
try {
var data = JSON.parse(m[1]);
if (data && typeof data === 'object' && data.payload) { return data; }
} catch (_) { /* not valid JSON */ }
return null;
}
function pickHtmlFile() {
if (typeof window.showOpenFilePicker === 'function') {
return window.showOpenFilePicker({
multiple: false,
types: [{ description: 'HTML Files', accept: { 'text/html': ['.html', '.htm'] } }]
}).then(function (handles) {
var handle = handles[0];
return handle.getFile().then(function (file) {
return { file: file, name: handle.name || file.name || 'import.html' };
});
});
}
return new Promise(function (resolve, reject) {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'text/html,.html,.htm';
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (!file) { reject(new Error('No file selected')); return; }
resolve({ file: file, name: file.name || 'import.html' });
});
input.click();
});
}
async function handleImportHtml() {
try {
var picked = await pickHtmlFile();
var text = await picked.file.text();
var data = extractJsonFromHtml(text);
if (!data) { throw new Error('No valid transmittal data found in this HTML file'); }
await applyLoadedData(data, picked.name || 'import.html');
} catch (err) {
if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) { return; }
console.error('[transmittal] import-html failed', err);
setStatus('Failed to import HTML: ' + (err && err.message ? err.message : err), 'error');
}
}
async function applyLoadedData(data, sourceName) {
json.setData(data);
app.modules.files.loadFromJson();
app.state.mode = 'edit';
app.state.dirty = false;
if (app.modules.visibility && app.modules.visibility.applyFieldVisibility) {
app.modules.visibility.applyFieldVisibility();
}
await app.modules.security.verifySignatureIfPresent();
if (app.modules.security.renderSignaturesList) {
await app.modules.security.renderSignaturesList();
}
app.modules.mode.refresh();
app.state.apply();
if (app.modules.liveDigest && app.modules.liveDigest.schedule) {
app.modules.liveDigest.schedule();
}
setStatus('Loaded: ' + sourceName, 'success');
}
app.onDirty(function () {
setStatus('Unsaved changes', 'info');
});
async function handleLoadFromClipboard() {
try {
var text = await navigator.clipboard.readText();
if (!text || !text.trim()) {
setStatus('Clipboard is empty', 'error');
return;
}
var data = JSON.parse(text.trim());
if (!data || typeof data !== 'object' || !data.payload) {
throw new Error('Invalid ZDDC JSON data');
}
await applyLoadedData(data, 'clipboard');
} catch (err) {
console.error('[transmittal] load-from-clipboard failed', err);
if (err && err.name === 'NotAllowedError') {
setStatus('Clipboard access denied — please paste manually', 'error');
} else {
setStatus('Failed to load from clipboard: ' + (err && err.message ? err.message : err), 'error');
}
}
}
async function handleCopyJson() {
try {
await serializeUiToJson();
var data = json.parse();
var text = JSON.stringify(data, null, 2);
await navigator.clipboard.writeText(text);
setStatus('JSON copied to clipboard', 'success');
} catch (err) {
console.error('[transmittal] copy-json failed', err);
if (err && err.name === 'NotAllowedError') {
setStatus('Clipboard access denied', 'error');
} else {
setStatus('Failed to copy JSON: ' + (err && err.message ? err.message : err), 'error');
}
}
}
async function handleSaveHtmlDraft() {
try {
var pub = app.modules.publish;
if (!pub || typeof pub.syncUiToJson !== 'function' || typeof pub.buildHtmlString !== 'function') {
throw new Error('Publish module not ready');
}
await pub.syncUiToJson({ sign: false, computeDigest: false });
var html = await pub.buildHtmlString();
var data = json.parse();
var payload = (data && data.payload) || {};
var filename = dataModule.buildFileName(payload, { extension: 'html', draft: true });
await saveFileWithPicker(filename, html, 'text/html');
setStatus('Saved draft as ' + filename, 'success');
} catch (err) {
console.error('[transmittal] save-html-draft failed', err);
setStatus('Failed to save draft: ' + (err && err.message ? err.message : err), 'error');
}
}
async function handleCopyTable() {
try {
var TAB = '\t';
var NL = '\r\n';
var esc = app.util.escapeHtml;
var textStyle = ' style="mso-number-format:\'\\@\'"';
var headers = ['#', 'TRACKING NUMBER', 'TITLE', 'REVISION', 'STATUS', 'EXT', 'SIZE', 'SHA256'];
var headerTsv = headers.join(TAB);
var headerHtml = '<tr>' + headers.map(function (h) { return '<th' + textStyle + '>' + esc(h) + '</th>'; }).join('') + '</tr>';
var tsvLines = [headerTsv];
var htmlRows = [headerHtml];
function addRow(cells) {
tsvLines.push(cells.join(TAB));
htmlRows.push('<tr>' + cells.map(function (c) { return '<td' + textStyle + '>' + esc(c) + '</td>'; }).join('') + '</tr>');
}
// Row 0: self-entry
var filesModule = app.modules.files;
var self = filesModule && filesModule.buildSelfEntry ? filesModule.buildSelfEntry() : null;
if (self) {
addRow(['0', self.trackingNumber || '', self.title || '', self.revision || '', self.status || '', 'html', '\u2014', 'see above']);
}
// Regular files
var files = Array.isArray(app.data.files) ? app.data.files : [];
for (var i = 0; i < files.length; i++) {
var f = files[i];
var size = (f.fileSize != null ? f.fileSize : f.size);
addRow([
String(i + 1),
f.trackingNumber || '',
f.title || '',
f.revision || '',
f.status || '',
(f.extension || '').toLowerCase(),
size ? String(size) : '',
f.sha256 || ''
]);
}
var plainText = tsvLines.join(NL);
var html = '<table>' + htmlRows.join('') + '</table>';
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([plainText], { type: 'text/plain' }),
'text/html': new Blob([html], { type: 'text/html' })
})
]);
setStatus('Table copied (' + files.length + ' files)', 'success');
} catch (err) {
console.error('[transmittal] copy-table failed', err);
setStatus('Failed to copy table: ' + (err && err.message ? err.message : err), 'error');
}
}
async function handleRevise() {
if (!confirm('This will create a new draft from the published transmittal. The digest and signatures will be removed. Continue?')) {
return;
}
var data = json.parse();
if (!data) { return; }
// Strip digest and signatures; preserve existing date for user to update
data.envelope = { version: 1, digestAlgorithm: app.constants.digestAlgorithm, digest: '', digestedAt: '', signatureAlgorithm: app.constants.signatureAlgorithm, signatures: [] };
json.setData(data);
app.modules.files.loadFromJson();
app.state.mode = 'edit';
app.state.published = false;
app.state.dirty = true;
app.modules.mode.refresh();
setStatus('Created new draft from published transmittal', 'success');
}
dataModule.extractJsonFromHtml = extractJsonFromHtml;
dataModule.setStatus = setStatus;
dataModule.serializeUiToJson = serializeUiToJson;
dataModule.handleLoadFromClipboard = handleLoadFromClipboard;
dataModule.handleCopyJson = handleCopyJson;
dataModule.handleCopyTable = handleCopyTable;
dataModule.handleSaveHtmlDraft = handleSaveHtmlDraft;
dataModule.handleRevise = handleRevise;
dataModule.handleImportHtml = handleImportHtml;
dataModule.applyLoadedData = applyLoadedData;
dataModule.buildFileName = dataModule.buildFileName || buildFileName;
})(window.transmittalApp);