ZDDC/archive/js/export.js
ZDDC 9481122570 perf(tools): vendor jszip + docx-preview for archive/transmittal/classifier
Same pattern as the browse fix. archive, transmittal, classifier
previously CDN-loaded jszip + docx-preview on first preview of a
.zip / .docx file via shared/preview-lib.js's loadLibrary helper.
That meant each first-preview blocked on a CDN round-trip + parse,
and broke entirely under restrictive networks or CSPs.

Vendor both libs under shared/vendor/ and concat them at the top of
each tool's build, ahead of init.js. window.JSZip + window.docx are
now defined immediately on page load. Drop the redundant loadLibrary
calls (and classifier's stray <script src="cdn..."> tag in the
template, plus archive's bespoke loadJSZip helper in export.js).

xlsx (SheetJS) intentionally stays CDN-loaded — at ~900 KB it's too
large to inline, and only fires on .xlsx preview which is a rarer
path.

Bundle size impact (uncompressed):
  archive:     304 KB → 476 KB  (+172 KB)
  transmittal: 449 KB → 621 KB  (+172 KB)
  classifier:  252 KB → 424 KB  (+172 KB)

With the gzip middleware (~75% reduction on HTML) and ETag-cached
revalidation now in place, the wire-size delta is ~40 KB per tool
on the first load and 0 on every subsequent load until redeploy.
2026-05-04 07:49:17 -05:00

262 lines
9.3 KiB
JavaScript

(function() {
'use strict';
// Export functionality
// Escape a single value for RFC-4180 CSV
function csvCell(value) {
const str = String(value == null ? '' : value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
// Convert an array of row arrays to a CSV string
function rowsToCSV(rows) {
return rows.map(row => row.map(csvCell).join(',')).join('\n');
}
// Export selected files to CSV
function exportCSV() {
if (window.app.selectedFiles.size === 0) {
alert('No files selected for export.');
return;
}
const headers = ['Tracking Number', 'Title', 'Revision', 'Status', 'Extension', 'Size', 'Size (bytes)', 'Path', 'Modified'];
const rows = [headers];
// Add data rows for selected files only
window.app.files.forEach(file => {
if (!window.app.selectedFiles.has(file.id)) return;
rows.push([
file.trackingNumber || '',
file.title || '',
file.revision || '',
file.status || '',
file.extension || '',
formatFileSize(file.size),
file.size != null ? file.size : '',
file.path,
file.modified ? new Date(file.modified).toLocaleString() : '—'
]);
});
downloadFile(rowsToCSV(rows), 'archive-export.csv', 'text/csv');
}
// Download selected files as ZIP
async function downloadSelected() {
if (window.app.selectedFiles.size === 0) {
alert('No files selected for download.');
return;
}
// JSZip is vendored (concat'd by build.sh), so window.JSZip is
// already defined. Defensive check in case a future refactor
// reorders things.
if (typeof JSZip === 'undefined') {
alert('JSZip library not bundled — rebuild archive with shared/vendor/jszip.min.js');
return;
}
const zip = new JSZip();
const selectedFiles = [];
// Get selected file objects
window.app.files.forEach(file => {
if (window.app.selectedFiles.has(file.id)) {
selectedFiles.push(file);
}
});
// Show progress
showProgress('Preparing ZIP file...', 0, selectedFiles.length);
try {
// Add files to ZIP
for (let i = 0; i < selectedFiles.length; i++) {
const file = selectedFiles[i];
showProgress(`Adding ${file.name}...`, i + 1, selectedFiles.length);
try {
let arrayBuffer;
if (file.handle) {
// Local mode: read via File System Access API
const fileData = await file.handle.getFile();
arrayBuffer = await fileData.arrayBuffer();
} else if (file.url) {
// HTTP mode: fetch from server
const resp = await fetch(file.url);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
arrayBuffer = await resp.arrayBuffer();
} else {
throw new Error('No file handle or URL available');
}
// Create folder structure in ZIP
const relativePath = file.path.substring(file.path.indexOf('/') + 1); // Remove root directory
zip.file(relativePath, arrayBuffer);
} catch (err) {
console.error(`Error adding file ${file.name}:`, err);
}
}
showProgress('Generating ZIP...', selectedFiles.length, selectedFiles.length);
// Generate ZIP
const blob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
// Download
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
downloadBlob(blob, `archive-${timestamp}.zip`);
hideProgress();
} catch (err) {
hideProgress();
console.error('Error creating ZIP:', err);
alert('Error creating ZIP file: ' + err.message);
}
}
// Show progress indicator
function showProgress(message, current, total) {
let progressDiv = document.getElementById('progressIndicator');
if (!progressDiv) {
progressDiv = document.createElement('div');
progressDiv.id = 'progressIndicator';
progressDiv.className = 'progress-indicator';
document.body.appendChild(progressDiv);
}
const percentage = Math.round((current / total) * 100);
progressDiv.innerHTML =
'<div class="progress-indicator__message">' + window.app.modules.app.escapeHtml(message) + '</div>' +
'<div class="progress-indicator__track">' +
'<div class="progress-indicator__fill" style="width:' + percentage + '%"></div>' +
'</div>' +
'<div class="progress-indicator__label">' + current + ' / ' + total + '</div>';
}
// Hide progress indicator
function hideProgress() {
const progressDiv = document.getElementById('progressIndicator');
if (progressDiv) {
progressDiv.remove();
}
}
// Download file utility
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
downloadBlob(blob, filename);
}
// Download blob utility
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Export to HTML report
function exportHTMLReport() {
// Group files by tracking number
const grouped = window.app.modules.parser.groupFilesByTrackingNumber(window.app.filteredFiles);
const sorted = window.app.modules.parser.sortGroupedFiles(grouped);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Archive Report - ${new Date().toLocaleDateString()}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
table { border-collapse: collapse; width: 100%; margin-top: 20px; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; font-weight: bold; }
tr:nth-child(even) { background-color: #f9f9f9; }
.revision { font-family: monospace; }
.status { color: #666; font-size: 0.9em; }
@media print {
body { margin: 0; }
h1 { font-size: 18pt; }
table { font-size: 10pt; }
}
</style>
</head>
<body>
<h1>Archive Report</h1>
<p>Generated: ${new Date().toLocaleString()}</p>
<p>Total Files: ${window.app.filteredFiles.length}</p>
<table>
<thead>
<tr>
<th>Tracking Number</th>
<th>Title</th>
<th>Revisions</th>
</tr>
</thead>
<tbody>
${sorted.map(group => `
<tr>
<td>${window.app.modules.app.escapeHtml(group.trackingNumber)}</td>
<td>${window.app.modules.app.escapeHtml(group.title)}</td>
<td>
${group.sortedRevisions.map(rev => `
<div>
<span class="revision">${window.app.modules.app.escapeHtml(rev.revision)}</span>
<span class="status">(${window.app.modules.app.escapeHtml(rev.status)})</span>
${rev.files.map(f => f.extension.toUpperCase()).join(', ')}
</div>
`).join('')}
</td>
</tr>
`).join('')}
</tbody>
</table>
</body>
</html>`;
downloadFile(html, 'archive-report.html', 'text/html');
}
window.app.modules.export = {
csvCell,
rowsToCSV,
exportCSV,
downloadSelected,
showProgress,
hideProgress,
downloadFile,
downloadBlob,
formatFileSize,
exportHTMLReport
};
})();