feat: initial commit — OST to PST/MBOX converter with Docker support
Some checks failed
Build & Push Multi-Arch Image / build-and-push (push) Has been cancelled

This commit is contained in:
Simon Cloutier
2026-05-20 13:38:46 -04:00
commit cfee611fdc
17 changed files with 3311 additions and 0 deletions

280
public/app.js Normal file
View File

@@ -0,0 +1,280 @@
document.addEventListener('DOMContentLoaded', () => {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const fileDetails = document.getElementById('file-details');
const fileNameEl = document.getElementById('file-name');
const fileSizeEl = document.getElementById('file-size');
const btnCancelFile = document.getElementById('btn-cancel-file');
const btnConvert = document.getElementById('btn-convert');
const progressContainer = document.getElementById('progress-container');
const progressStatus = document.getElementById('progress-status');
const progressPercentage = document.getElementById('progress-percentage');
const progressBarFill = document.getElementById('progress-bar-fill');
const terminalContainer = document.getElementById('terminal-container');
const terminalLogs = document.getElementById('terminal-logs');
const successContainer = document.getElementById('success-container');
const outFileNameEl = document.getElementById('out-file-name');
const btnDownload = document.getElementById('btn-download');
const btnReset = document.getElementById('btn-reset');
let selectedFile = null;
let eventSource = null;
// Helper: format bytes to human readable format
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Handle Drag & Drop events
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
}, false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
handleFileSelection(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (fileInput.files.length > 0) {
handleFileSelection(fileInput.files[0]);
}
});
function handleFileSelection(file) {
if (!file.name.toLowerCase().endsWith('.ost')) {
alert('Please select an Outlook OST file (.ost).');
return;
}
selectedFile = file;
fileNameEl.textContent = file.name;
fileSizeEl.textContent = formatBytes(file.size);
dropZone.classList.add('hidden');
fileDetails.classList.remove('hidden');
}
btnCancelFile.addEventListener('click', () => {
resetUI();
});
// Reset UI to initial state
function resetUI() {
selectedFile = null;
fileInput.value = '';
if (eventSource) {
eventSource.close();
eventSource = null;
}
dropZone.classList.remove('hidden');
fileDetails.classList.add('hidden');
progressContainer.classList.add('hidden');
successContainer.classList.add('hidden');
document.getElementById('summary-card').classList.add('hidden');
progressBarFill.style.width = '0%';
progressPercentage.textContent = '0%';
terminalLogs.innerHTML = '<div class="log-line system">Waiting for process start...</div>';
}
// Show/hide the combine-VCF sub-option based on selected format
const vcfCombineWrap = document.getElementById('vcf-combine-wrap');
document.querySelectorAll('input[name="output-format"]').forEach(radio => {
radio.addEventListener('change', () => {
if (radio.value === 'mbox' && radio.checked) {
vcfCombineWrap.classList.remove('hidden');
} else {
vcfCombineWrap.classList.add('hidden');
document.getElementById('combine-vcf').checked = false;
}
});
});
// Convert button click handler
btnConvert.addEventListener('click', () => {
if (!selectedFile) return;
// Update UI states
fileDetails.classList.add('hidden');
progressContainer.classList.remove('hidden');
progressStatus.textContent = 'Uploading OST file to local server...';
// Start file upload
uploadFile(selectedFile);
});
function uploadFile(file) {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('ostFile', file);
const selectedFormat = document.querySelector('input[name="output-format"]:checked').value;
formData.append('format', selectedFormat);
const combineVcf = document.getElementById('combine-vcf').checked;
formData.append('combineVcf', combineVcf ? 'true' : 'false');
// Upload progress listener
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
progressBarFill.style.width = percentComplete + '%';
progressPercentage.textContent = percentComplete + '%';
}
});
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
startStream(response.taskId);
} else {
showError('Upload failed: ' + (xhr.statusText || 'Server error'));
}
};
xhr.onerror = () => {
showError('Network error occurred during upload.');
};
xhr.open('POST', '/api/upload', true);
xhr.send(formData);
}
function startStream(taskId) {
progressStatus.textContent = 'Converting OST to PST (processing folders)...';
progressBarFill.style.width = '100%';
progressPercentage.textContent = 'Running';
// Terminal is always visible in the right column — no toggle needed
eventSource = new EventSource(`/api/stream/${taskId}`);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'starting') {
appendLog(data.message, 'system');
} else if (data.status === 'running') {
const log = data.log;
// Filter out Java compiler noise (deprecation warnings, caret markers, raw code lines)
if (log.trim() === '^' ||
/^\s+\w.*new MboxrdStorageWriter/.test(log) ||
/\.java:\d+: warning:/.test(log) ||
/\d+ warning/.test(log)) {
return; // Silently drop compiler noise
}
// Style warnings/errors/successes
let type = '';
if (log.toLowerCase().includes('warning') || log.toLowerCase().includes('skipping')) type = 'warning';
else if (log.toLowerCase().includes('error')) type = 'error';
else if (log.toLowerCase().includes('success') || log.toLowerCase().includes('completed')) type = 'system';
appendLog(log, type);
} else if (data.status === 'completed') {
eventSource.close();
showSuccess(data.fileName, data.downloadUrl, data.summary);
} else if (data.status === 'failed') {
eventSource.close();
showError(data.error);
}
};
eventSource.onerror = (err) => {
console.error('SSE Error:', err);
eventSource.close();
showError('Lost connection to conversion engine.');
};
}
function appendLog(message, type = '') {
const logLine = document.createElement('div');
logLine.className = 'log-line';
if (type) logLine.classList.add(type);
logLine.textContent = message;
terminalLogs.appendChild(logLine);
// Auto-scroll to bottom of logs
terminalLogs.scrollTop = terminalLogs.scrollHeight;
}
function showSuccess(fileName, downloadUrl, summary) {
progressContainer.classList.add('hidden');
// Keep terminal logs visible so the user can inspect completion states/warnings
successContainer.classList.remove('hidden');
outFileNameEl.textContent = fileName;
btnDownload.href = downloadUrl;
btnDownload.setAttribute('download', fileName);
const isMbox = fileName.endsWith('.zip');
btnDownload.textContent = isMbox ? 'Download MBOX ZIP Archive' : 'Download PST File';
const summaryCard = document.getElementById('summary-card');
const summaryFoldersCount = document.getElementById('summary-folders-count');
const summaryMessagesCount = document.getElementById('summary-messages-count');
const summaryDuration = document.getElementById('summary-duration');
const summaryTableBody = document.getElementById('summary-table-body');
if (summary) {
summaryCard.classList.remove('hidden');
summaryFoldersCount.textContent = summary.folders ? summary.folders.length : 0;
summaryMessagesCount.textContent = summary.totalMessages || 0;
summaryDuration.textContent = (summary.duration || 0).toFixed(1) + 's';
summaryTableBody.innerHTML = '';
if (summary.folders && summary.folders.length > 0) {
summary.folders.forEach(f => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${escapeHtml(f.folder)}</td>
<td style="text-align: right; font-weight: 600;">${f.messages}</td>
`;
summaryTableBody.appendChild(row);
});
} else {
summaryTableBody.innerHTML = `<tr><td colspan="2" style="text-align: center; color: var(--text-secondary);">No folders containing emails were converted.</td></tr>`;
}
} else {
summaryCard.classList.add('hidden');
}
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function showError(errorMessage) {
progressStatus.textContent = 'Error';
progressPercentage.textContent = 'Failed';
progressBarFill.style.backgroundColor = 'var(--error-color)';
appendLog(`[ERROR] ${errorMessage}`, 'error');
}
btnReset.addEventListener('click', () => {
resetUI();
});
});