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
Some checks failed
Build & Push Multi-Arch Image / build-and-push (push) Has been cancelled
This commit is contained in:
280
public/app.js
Normal file
280
public/app.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function showError(errorMessage) {
|
||||
progressStatus.textContent = 'Error';
|
||||
progressPercentage.textContent = 'Failed';
|
||||
progressBarFill.style.backgroundColor = 'var(--error-color)';
|
||||
appendLog(`[ERROR] ${errorMessage}`, 'error');
|
||||
}
|
||||
|
||||
btnReset.addEventListener('click', () => {
|
||||
resetUI();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user