CSV Import

This commit is contained in:
James Pattinson
2025-10-13 13:28:12 +00:00
parent 9b6a6dab06
commit f721be7280
6 changed files with 1237 additions and 11 deletions

View File

@@ -184,6 +184,19 @@ class APIClient {
method: 'DELETE'
});
}
/**
* Bulk import members from CSV data
*/
async bulkImportMembers(csvData, listIds) {
return this.request('/bulk-import', {
method: 'POST',
body: JSON.stringify({
csv_data: csvData,
list_ids: listIds
})
});
}
}
/**

View File

@@ -45,6 +45,11 @@ class MailingListApp {
this.handleLogin();
}
});
// Bulk import button
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
uiManager.showBulkImportModal();
});
}
/**

View File

@@ -50,6 +50,9 @@ class UIManager {
this.showListModal();
});
// Initialize bulk import listeners
this.initializeBulkImportListeners();
document.getElementById('addMemberBtn').addEventListener('click', () => {
this.showMemberModal();
});
@@ -634,6 +637,546 @@ class UIManager {
return link;
}
/**
* Show bulk import modal
*/
showBulkImportModal() {
this.resetBulkImportModal();
this.showModal(document.getElementById('bulkImportModal'));
}
/**
* Reset bulk import modal state
*/
resetBulkImportModal() {
// Reset to step 1
document.querySelectorAll('.import-step').forEach(step => {
step.style.display = 'none';
});
document.getElementById('importStep1').style.display = 'block';
// Reset button states
document.getElementById('bulkImportBackBtn').style.display = 'none';
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
document.getElementById('bulkImportNextBtn').disabled = true;
document.getElementById('bulkImportBtn').style.display = 'none';
document.getElementById('bulkImportDoneBtn').style.display = 'none';
// Clear file input
document.getElementById('csvFileInput').value = '';
document.getElementById('fileInfo').style.display = 'none';
// Reset parsed data
this.csvData = null;
this.parsedRows = [];
this.currentStep = 1;
}
/**
* Initialize bulk import event listeners
*/
initializeBulkImportListeners() {
const fileUploadArea = document.getElementById('fileUploadArea');
const csvFileInput = document.getElementById('csvFileInput');
const nextBtn = document.getElementById('bulkImportNextBtn');
const backBtn = document.getElementById('bulkImportBackBtn');
const importBtn = document.getElementById('bulkImportBtn');
const doneBtn = document.getElementById('bulkImportDoneBtn');
const removeFileBtn = document.getElementById('removeFileBtn');
// File upload area click
fileUploadArea.addEventListener('click', () => {
csvFileInput.click();
});
// File input change
csvFileInput.addEventListener('change', (e) => {
this.handleFileSelection(e.target.files[0]);
});
// Remove file button
removeFileBtn.addEventListener('click', () => {
this.removeSelectedFile();
});
// Drag and drop
fileUploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
fileUploadArea.classList.add('dragover');
});
fileUploadArea.addEventListener('dragleave', () => {
fileUploadArea.classList.remove('dragover');
});
fileUploadArea.addEventListener('drop', (e) => {
e.preventDefault();
fileUploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.handleFileSelection(files[0]);
}
});
// Navigation buttons
nextBtn.addEventListener('click', () => {
this.handleBulkImportNext();
});
backBtn.addEventListener('click', () => {
this.handleBulkImportBack();
});
importBtn.addEventListener('click', () => {
this.handleBulkImportSubmit();
});
doneBtn.addEventListener('click', () => {
this.closeModal(document.getElementById('bulkImportModal'));
// Reload data to show imported members
if (window.app) {
window.app.loadData();
}
});
}
/**
* Handle file selection
*/
handleFileSelection(file) {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
this.showNotification('Please select a CSV file', 'error');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
this.csvData = e.target.result;
this.displayFileInfo(file);
this.parseCSVPreview();
document.getElementById('bulkImportNextBtn').disabled = false;
};
reader.readAsText(file);
}
/**
* Display file information
*/
displayFileInfo(file) {
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = `(${this.formatFileSize(file.size)})`;
document.getElementById('fileInfo').style.display = 'flex';
}
/**
* Remove selected file
*/
removeSelectedFile() {
document.getElementById('csvFileInput').value = '';
document.getElementById('fileInfo').style.display = 'none';
document.getElementById('bulkImportNextBtn').disabled = true;
this.csvData = null;
this.parsedRows = [];
}
/**
* Format file size in human readable format
*/
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(1)) + ' ' + sizes[i];
}
/**
* Parse CSV for preview
*/
parseCSVPreview() {
if (!this.csvData) return;
const lines = this.csvData.trim().split('\n');
if (lines.length < 2) {
this.showNotification('CSV file must contain at least a header and one data row', 'error');
return;
}
// Parse header
const headers = this.parseCSVLine(lines[0]);
const nameIndex = headers.findIndex(h => h.toLowerCase().includes('name'));
const emailIndex = headers.findIndex(h => h.toLowerCase().includes('email'));
if (nameIndex === -1 || emailIndex === -1) {
this.showNotification('CSV must contain Name and Email columns', 'error');
return;
}
this.parsedRows = [];
this.csvHeaders = headers;
let validRows = 0;
let errorRows = 0;
// Parse data rows
for (let i = 1; i < lines.length; i++) {
const rawLine = lines[i].trim();
if (!rawLine) continue; // Skip empty lines
const values = this.parseCSVLine(rawLine);
const name = (values[nameIndex] || '').trim();
const email = (values[emailIndex] || '').trim();
// Determine error type and message
let error = null;
let isValid = true;
if (!email) {
error = 'Missing email address';
isValid = false;
} else if (!this.isValidEmail(email)) {
error = 'Invalid email format';
isValid = false;
}
// Note: Missing name is OK - we'll use the email as display name if needed
const row = {
index: i,
name,
email,
valid: isValid,
error,
rawLine,
parsedValues: values,
headers: headers
};
this.parsedRows.push(row);
if (isValid) validRows++;
else errorRows++;
}
// Update preview stats
document.getElementById('totalRowsCount').textContent = this.parsedRows.length;
document.getElementById('validRowsCount').textContent = validRows;
document.getElementById('errorRowsCount').textContent = errorRows;
}
/**
* Parse a CSV line handling quoted values
*/
parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
/**
* Simple email validation
*/
isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Handle next button click
*/
handleBulkImportNext() {
if (this.currentStep === 1) {
this.showStep2Preview();
} else if (this.currentStep === 2) {
this.showStep3ListSelection();
}
}
/**
* Handle back button click
*/
handleBulkImportBack() {
if (this.currentStep === 2) {
this.showStep1Upload();
} else if (this.currentStep === 3) {
this.showStep2Preview();
}
}
/**
* Show step 1 (upload)
*/
showStep1Upload() {
document.querySelectorAll('.import-step').forEach(step => {
step.style.display = 'none';
});
document.getElementById('importStep1').style.display = 'block';
document.getElementById('bulkImportBackBtn').style.display = 'none';
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
document.getElementById('bulkImportBtn').style.display = 'none';
this.currentStep = 1;
}
/**
* Show step 2 (preview)
*/
showStep2Preview() {
document.querySelectorAll('.import-step').forEach(step => {
step.style.display = 'none';
});
document.getElementById('importStep2').style.display = 'block';
document.getElementById('bulkImportBackBtn').style.display = 'inline-block';
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
document.getElementById('bulkImportBtn').style.display = 'none';
this.renderPreviewTable();
this.currentStep = 2;
}
/**
* Show step 3 (list selection)
*/
showStep3ListSelection() {
document.querySelectorAll('.import-step').forEach(step => {
step.style.display = 'none';
});
document.getElementById('importStep3').style.display = 'block';
document.getElementById('bulkImportBackBtn').style.display = 'inline-block';
document.getElementById('bulkImportNextBtn').style.display = 'none';
document.getElementById('bulkImportBtn').style.display = 'inline-block';
this.renderListSelection();
this.currentStep = 3;
}
/**
* Render preview table
*/
renderPreviewTable() {
const tbody = document.getElementById('previewTableBody');
tbody.innerHTML = '';
// Show first 10 rows for preview
const previewRows = this.parsedRows.slice(0, 10);
previewRows.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${row.index}</td>
<td>${this.escapeHtml(row.name)}</td>
<td>${this.escapeHtml(row.email)}</td>
<td>
<span class="row-status ${row.valid ? 'valid' : 'error'}">
${row.valid ? 'Valid' : 'Error'}
</span>
</td>
`;
tbody.appendChild(tr);
});
// Show errors if any
const errors = this.parsedRows.filter(row => !row.valid);
if (errors.length > 0) {
document.getElementById('errorList').style.display = 'block';
this.renderErrorDisplay(errors);
} else {
document.getElementById('errorList').style.display = 'none';
}
}
/**
* Render error display with expandable details
*/
renderErrorDisplay(errors) {
// Update error summary
const errorSummary = document.getElementById('errorSummary');
const errorTypes = {};
errors.forEach(error => {
const type = error.error;
errorTypes[type] = (errorTypes[type] || 0) + 1;
});
const summaryText = Object.entries(errorTypes)
.map(([type, count]) => `${count} ${type.toLowerCase()}`)
.join(', ');
errorSummary.innerHTML = `
<p><strong>${errors.length}</strong> rows have issues: ${summaryText}</p>
<p class="text-muted text-sm">These rows will be skipped during import. Click "Show All" to see details.</p>
`;
// Render error table
const errorTableBody = document.getElementById('errorTableBody');
errorTableBody.innerHTML = '';
errors.forEach(row => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="font-medium">${row.index + 1}</td>
<td class="font-medium">${this.escapeHtml(row.error)}</td>
<td>${this.escapeHtml(row.name || '-')}</td>
<td>${this.escapeHtml(row.email || '-')}</td>
<td class="raw-data-cell" title="${this.escapeHtml(row.rawLine)}">${this.escapeHtml(row.rawLine)}</td>
`;
errorTableBody.appendChild(tr);
});
// Setup toggle functionality
this.setupErrorToggle();
}
/**
* Setup error toggle functionality
*/
setupErrorToggle() {
const toggleBtn = document.getElementById('toggleErrorsBtn');
const errorDetails = document.getElementById('errorDetails');
const toggleText = document.getElementById('errorToggleText');
const toggleIcon = document.getElementById('errorToggleIcon');
// Remove existing listener to prevent duplicates
const newToggleBtn = toggleBtn.cloneNode(true);
toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
newToggleBtn.addEventListener('click', () => {
const isHidden = errorDetails.style.display === 'none';
if (isHidden) {
errorDetails.style.display = 'block';
toggleText.textContent = 'Hide Details';
toggleIcon.className = 'fas fa-chevron-up';
} else {
errorDetails.style.display = 'none';
toggleText.textContent = 'Show All';
toggleIcon.className = 'fas fa-chevron-down';
}
});
}
/**
* Render list selection checkboxes
*/
renderListSelection() {
const container = document.getElementById('listSelection');
container.innerHTML = '';
if (!window.app || !window.app.lists) {
container.innerHTML = '<p class="text-muted">No mailing lists available</p>';
return;
}
window.app.lists.forEach(list => {
const div = document.createElement('div');
div.className = 'list-checkbox';
div.innerHTML = `
<input type="checkbox" id="list_${list.list_id}" value="${list.list_id}">
<div class="list-checkbox-label">
<span class="list-checkbox-name">${this.escapeHtml(list.list_name)}</span>
<span class="list-checkbox-email">${this.escapeHtml(list.list_email)}</span>
</div>
`;
container.appendChild(div);
// Make the whole div clickable
div.addEventListener('click', (e) => {
if (e.target.type !== 'checkbox') {
const checkbox = div.querySelector('input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
}
});
});
}
/**
* Handle bulk import submission
*/
async handleBulkImportSubmit() {
const selectedLists = Array.from(document.querySelectorAll('#listSelection input[type="checkbox"]:checked'))
.map(cb => parseInt(cb.value));
if (selectedLists.length === 0) {
this.showNotification('Please select at least one mailing list', 'error');
return;
}
try {
this.setLoading(true);
const result = await apiClient.bulkImportMembers(this.csvData, selectedLists);
this.showStep4Results(result);
} catch (error) {
this.handleError(error, 'Bulk import failed');
} finally {
this.setLoading(false);
}
}
/**
* Show step 4 (results)
*/
showStep4Results(result) {
document.querySelectorAll('.import-step').forEach(step => {
step.style.display = 'none';
});
document.getElementById('importStep4').style.display = 'block';
document.getElementById('bulkImportBackBtn').style.display = 'none';
document.getElementById('bulkImportNextBtn').style.display = 'none';
document.getElementById('bulkImportBtn').style.display = 'none';
document.getElementById('bulkImportDoneBtn').style.display = 'inline-block';
// Update result stats
document.getElementById('processedCount').textContent = result.processed_rows;
document.getElementById('createdCount').textContent = result.created_members;
document.getElementById('updatedCount').textContent = result.updated_members;
document.getElementById('subscriptionsCount').textContent = result.subscriptions_added;
// Show errors if any
if (result.errors && result.errors.length > 0) {
document.getElementById('resultErrors').style.display = 'block';
const errorList = document.getElementById('resultErrorList');
errorList.innerHTML = '';
result.errors.forEach(error => {
const li = document.createElement('li');
li.textContent = error;
errorList.appendChild(li);
});
} else {
document.getElementById('resultErrors').style.display = 'none';
}
this.currentStep = 4;
// Show success notification
this.showNotification(
`Successfully imported ${result.processed_rows} members with ${result.subscriptions_added} subscriptions`,
'success'
);
}
/**
* Escape HTML to prevent XSS
*/