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

@@ -10,6 +10,8 @@ from typing import List, Optional
import mysql.connector import mysql.connector
from mysql.connector import Error from mysql.connector import Error
import os import os
import csv
import io
from contextlib import contextmanager from contextlib import contextmanager
# Configuration # Configuration
@@ -93,6 +95,18 @@ class Subscription(BaseModel):
member_email: EmailStr member_email: EmailStr
active: bool = True active: bool = True
class BulkImportRequest(BaseModel):
csv_data: str
list_ids: List[int]
class BulkImportResult(BaseModel):
total_rows: int
processed_rows: int
created_members: int
updated_members: int
subscriptions_added: int
errors: List[str]
# Routes # Routes
@app.get("/") @app.get("/")
async def root(): async def root():
@@ -365,6 +379,118 @@ async def unsubscribe_member(list_email: EmailStr, member_email: EmailStr, token
cursor.close() cursor.close()
return {"message": "Unsubscribed successfully"} return {"message": "Unsubscribed successfully"}
@app.post("/bulk-import", response_model=BulkImportResult)
async def bulk_import_members(bulk_request: BulkImportRequest, token: str = Depends(verify_token)):
"""Bulk import members from CSV data and subscribe them to specified lists"""
result = BulkImportResult(
total_rows=0,
processed_rows=0,
created_members=0,
updated_members=0,
subscriptions_added=0,
errors=[]
)
with get_db() as conn:
cursor = conn.cursor(dictionary=True)
try:
# Verify all list_ids exist
if bulk_request.list_ids:
placeholders = ','.join(['%s'] * len(bulk_request.list_ids))
cursor.execute(f"SELECT list_id FROM lists WHERE list_id IN ({placeholders})", bulk_request.list_ids)
existing_lists = [row['list_id'] for row in cursor.fetchall()]
invalid_lists = set(bulk_request.list_ids) - set(existing_lists)
if invalid_lists:
raise HTTPException(status_code=400, detail=f"Invalid list IDs: {list(invalid_lists)}")
# Parse CSV data
csv_reader = csv.DictReader(io.StringIO(bulk_request.csv_data))
# Validate CSV headers - we need at least Name and Email
if not csv_reader.fieldnames or 'Name' not in csv_reader.fieldnames or 'Email' not in csv_reader.fieldnames:
raise HTTPException(status_code=400, detail="CSV must contain 'Name' and 'Email' columns")
for row_num, row in enumerate(csv_reader, start=1):
result.total_rows += 1
try:
name = row.get('Name', '').strip()
email = row.get('Email', '').strip()
# Skip rows without email (name is optional)
if not email:
result.errors.append(f"Row {row_num}: Missing email address")
continue
# Basic email validation
if '@' not in email or '.' not in email.split('@')[1]:
result.errors.append(f"Row {row_num}: Invalid email format: {email}")
continue
# Use email as name if no name provided
if not name:
name = email.split('@')[0] # Use the part before @ as a default name
# Check if member exists
cursor.execute("SELECT member_id FROM members WHERE email = %s", (email,))
existing_member = cursor.fetchone()
if existing_member:
# Update existing member's name if we have a better name (not auto-generated from email)
should_update_name = (
row.get('Name', '').strip() and # We have a name in the CSV
name != email.split('@')[0] # It's not the auto-generated name
)
if should_update_name:
cursor.execute("UPDATE members SET name = %s WHERE member_id = %s", (name, existing_member['member_id']))
if cursor.rowcount > 0:
result.updated_members += 1
member_id = existing_member['member_id']
else:
# Create new member
cursor.execute(
"INSERT INTO members (name, email, active) VALUES (%s, %s, %s)",
(name, email, True)
)
member_id = cursor.lastrowid
result.created_members += 1
# Subscribe to selected lists
for list_id in bulk_request.list_ids:
try:
cursor.execute(
"INSERT INTO list_members (list_id, member_id, active) VALUES (%s, %s, %s)",
(list_id, member_id, True)
)
result.subscriptions_added += 1
except Error as e:
if "Duplicate entry" in str(e):
# Member already subscribed to this list - not an error
pass
else:
result.errors.append(f"Row {row_num}: Failed to subscribe to list {list_id}: {str(e)}")
result.processed_rows += 1
except Exception as e:
result.errors.append(f"Row {row_num}: {str(e)}")
continue
# Commit all changes
conn.commit()
cursor.close()
return result
except HTTPException:
raise
except Exception as e:
cursor.close()
raise HTTPException(status_code=500, detail=f"Bulk import failed: {str(e)}")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -96,10 +96,16 @@
<span>Click the "Lists" button next to any member to manage their subscriptions</span> <span>Click the "Lists" button next to any member to manage their subscriptions</span>
</div> </div>
</div> </div>
<button class="btn btn-primary" id="addMemberBtn"> <div class="button-group">
<i class="fas fa-plus"></i> <button class="btn btn-primary" id="addMemberBtn">
Add Member <i class="fas fa-plus"></i>
</button> Add Member
</button>
<button class="btn btn-secondary" id="showBulkImportBtn">
<i class="fas fa-upload"></i>
Bulk Import CSV
</button>
</div>
</div> </div>
<div class="data-table"> <div class="data-table">
@@ -273,6 +279,189 @@
</div> </div>
</div> </div>
<!-- Bulk Import Modal -->
<div class="modal modal-large" id="bulkImportModal">
<div class="modal-content">
<div class="modal-header">
<h3>Bulk Import Members from CSV</h3>
<button class="modal-close" id="bulkImportModalClose">
<i class="fas fa-times"></i>
</button>
</div>
<div class="modal-body">
<!-- Step 1: File Upload -->
<div class="import-step" id="importStep1">
<h4><i class="fas fa-upload"></i> Step 1: Upload CSV File</h4>
<p class="text-muted">Upload a CSV file containing member information. The file must have 'Name' and 'Email' columns.</p>
<div class="file-upload-area" id="fileUploadArea">
<div class="file-upload-content">
<i class="fas fa-cloud-upload-alt"></i>
<p>Click to select a CSV file or drag and drop</p>
<input type="file" id="csvFileInput" accept=".csv" style="display: none;">
</div>
</div>
<div class="file-info" id="fileInfo" style="display: none;">
<div class="file-details">
<i class="fas fa-file-csv"></i>
<span id="fileName">file.csv</span>
<span id="fileSize" class="text-muted">(0 KB)</span>
</div>
<button type="button" class="btn btn-sm btn-secondary" id="removeFileBtn">
<i class="fas fa-times"></i> Remove
</button>
</div>
<div class="csv-format-help">
<h5><i class="fas fa-info-circle"></i> CSV Format</h5>
<p>Your CSV file should look like this:</p>
<div class="code-example">
<code>Name,Email,Delivery,Member since<br>
Ahmed Ajzan,ahmedajzan@doctors.org.uk,,2025-04-06T18:44:26.819454<br>
Alan Bailey,abgower@icloud.com,,2025-04-06T18:44:26.824446</code>
</div>
<p class="text-muted text-sm">Only the 'Name' and 'Email' columns are required. Additional columns will be ignored.</p>
</div>
</div>
<!-- Step 2: Preview -->
<div class="import-step" id="importStep2" style="display: none;">
<h4><i class="fas fa-eye"></i> Step 2: Preview Data</h4>
<p class="text-muted">Review the data that will be imported:</p>
<div class="preview-stats" id="previewStats">
<div class="stat-item">
<span class="stat-value" id="totalRowsCount">0</span>
<span class="stat-label">Total Rows</span>
</div>
<div class="stat-item">
<span class="stat-value" id="validRowsCount">0</span>
<span class="stat-label">Valid Rows</span>
</div>
<div class="stat-item">
<span class="stat-value" id="errorRowsCount">0</span>
<span class="stat-label">Errors</span>
</div>
</div>
<div class="preview-table-container" id="previewTableContainer">
<table class="table table-sm" id="previewTable">
<thead>
<tr>
<th>Row</th>
<th>Name</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody id="previewTableBody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
<div class="error-list" id="errorList" style="display: none;">
<div class="error-header">
<h5><i class="fas fa-exclamation-triangle"></i> Errors Found</h5>
<button type="button" class="btn btn-sm btn-secondary" id="toggleErrorsBtn">
<span id="errorToggleText">Show All</span>
<i class="fas fa-chevron-down" id="errorToggleIcon"></i>
</button>
</div>
<div class="error-summary" id="errorSummary">
<!-- Summary will be shown here -->
</div>
<div class="error-details" id="errorDetails" style="display: none;">
<div class="error-table-container">
<table class="table table-sm" id="errorTable">
<thead>
<tr>
<th>Row</th>
<th>Issue</th>
<th>Name</th>
<th>Email</th>
<th>Raw Data</th>
</tr>
</thead>
<tbody id="errorTableBody">
<!-- Dynamic content -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Step 3: List Selection -->
<div class="import-step" id="importStep3" style="display: none;">
<h4><i class="fas fa-list"></i> Step 3: Select Mailing Lists</h4>
<p class="text-muted">Choose which mailing lists these members should be subscribed to:</p>
<div class="list-selection" id="listSelection">
<!-- Dynamic content -->
</div>
<div class="selection-info">
<p class="text-muted text-sm">
<i class="fas fa-info-circle"></i>
If a member already exists, they will be added to the selected lists (existing subscriptions will be preserved).
</p>
</div>
</div>
<!-- Step 4: Import Results -->
<div class="import-step" id="importStep4" style="display: none;">
<h4><i class="fas fa-check-circle"></i> Step 4: Import Complete</h4>
<div class="import-results" id="importResults">
<div class="result-stats">
<div class="stat-item">
<span class="stat-value" id="processedCount">0</span>
<span class="stat-label">Processed</span>
</div>
<div class="stat-item">
<span class="stat-value" id="createdCount">0</span>
<span class="stat-label">Created</span>
</div>
<div class="stat-item">
<span class="stat-value" id="updatedCount">0</span>
<span class="stat-label">Updated</span>
</div>
<div class="stat-item">
<span class="stat-value" id="subscriptionsCount">0</span>
<span class="stat-label">Subscriptions</span>
</div>
</div>
<div class="result-errors" id="resultErrors" style="display: none;">
<h5><i class="fas fa-exclamation-triangle"></i> Import Errors</h5>
<ul id="resultErrorList">
<!-- Dynamic content -->
</ul>
</div>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="bulkImportBackBtn" style="display: none;">
<i class="fas fa-arrow-left"></i> Back
</button>
<button type="button" class="btn btn-secondary" id="bulkImportCancelBtn">Cancel</button>
<button type="button" class="btn btn-primary" id="bulkImportNextBtn" disabled>
Next <i class="fas fa-arrow-right"></i>
</button>
<button type="button" class="btn btn-primary" id="bulkImportBtn" style="display: none;">
<i class="fas fa-upload"></i> Import Members
</button>
<button type="button" class="btn btn-success" id="bulkImportDoneBtn" style="display: none;">
<i class="fas fa-check"></i> Done
</button>
</div>
</div>
</div>
<!-- Confirmation Modal --> <!-- Confirmation Modal -->
<div class="modal" id="confirmModal"> <div class="modal" id="confirmModal">
<div class="modal-content"> <div class="modal-content">

View File

@@ -405,16 +405,23 @@ body {
/* Action buttons in tables */ /* Action buttons in tables */
.action-buttons { .action-buttons {
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-1);
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center;
min-width: 200px; min-width: 200px;
} }
.action-buttons .btn {
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-sm);
white-space: nowrap;
}
/* Ensure actions column is wide enough */ /* Ensure actions column is wide enough */
.table th:last-child, .table th:last-child,
.table td:last-child { .table td:last-child {
min-width: 220px; min-width: 200px;
width: 220px; width: 200px;
} }
/* Subscriptions Grid */ /* Subscriptions Grid */
@@ -548,6 +555,11 @@ body {
padding: var(--space-4) var(--space-6) var(--space-6); padding: var(--space-4) var(--space-6) var(--space-6);
} }
/* Forms inside modals need proper padding */
.modal form {
padding: var(--space-4) var(--space-6) 0;
}
/* Forms */ /* Forms */
.form-group { .form-group {
margin-bottom: var(--space-5); margin-bottom: var(--space-5);
@@ -684,14 +696,19 @@ body {
} }
.action-buttons { .action-buttons {
flex-direction: column; gap: var(--space-1);
min-width: auto; min-width: auto;
} }
.action-buttons .btn {
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-xs);
}
.table th:last-child, .table th:last-child,
.table td:last-child { .table td:last-child {
min-width: auto; min-width: 180px;
width: auto; width: 180px;
} }
} }
@@ -854,6 +871,339 @@ body {
.mt-4 { margin-top: var(--space-4); } .mt-4 { margin-top: var(--space-4); }
.hidden { display: none; } .hidden { display: none; }
/* Bulk Import Styles */
.modal-large .modal-content {
max-width: 800px;
width: 90vw;
}
.import-step {
margin-bottom: var(--space-6);
}
.import-step h4 {
color: var(--gray-800);
font-size: var(--font-size-lg);
font-weight: 600;
margin-bottom: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-2);
}
.file-upload-area {
border: 2px dashed var(--gray-300);
border-radius: var(--border-radius);
padding: var(--space-8);
text-align: center;
transition: var(--transition);
cursor: pointer;
background: var(--gray-50);
}
.file-upload-area:hover {
border-color: var(--primary-color);
background: rgba(37, 99, 235, 0.05);
}
.file-upload-area.dragover {
border-color: var(--primary-color);
background: rgba(37, 99, 235, 0.05);
}
.file-upload-content i {
font-size: var(--font-size-3xl);
color: var(--gray-400);
margin-bottom: var(--space-3);
}
.file-upload-content p {
color: var(--gray-600);
font-weight: 500;
}
.file-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4);
background: var(--gray-50);
border-radius: var(--border-radius);
margin-top: var(--space-4);
}
.file-details {
display: flex;
align-items: center;
gap: var(--space-3);
}
.file-details i {
color: var(--success-color);
font-size: var(--font-size-lg);
}
.csv-format-help {
margin-top: var(--space-6);
padding: var(--space-4);
background: var(--gray-50);
border-radius: var(--border-radius);
}
.csv-format-help h5 {
color: var(--gray-800);
margin-bottom: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.code-example {
background: var(--gray-800);
color: var(--gray-100);
padding: var(--space-4);
border-radius: var(--border-radius);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: var(--font-size-sm);
margin: var(--space-3) 0;
overflow-x: auto;
}
.preview-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-item {
text-align: center;
padding: var(--space-4);
background: var(--gray-50);
border-radius: var(--border-radius);
}
.stat-value {
display: block;
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--primary-color);
margin-bottom: var(--space-1);
}
.stat-label {
color: var(--gray-600);
font-size: var(--font-size-sm);
font-weight: 500;
}
.preview-table-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--gray-200);
border-radius: var(--border-radius);
margin-bottom: var(--space-4);
}
.preview-table-container .table {
margin-bottom: 0;
}
.preview-table-container .table th {
position: sticky;
top: 0;
background: var(--gray-50);
z-index: 1;
}
.error-list {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: var(--border-radius);
padding: var(--space-4);
}
.error-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.error-list h5 {
color: var(--danger-color);
margin-bottom: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
.error-list ul {
color: var(--danger-color);
margin-left: var(--space-5);
}
.error-summary {
color: var(--danger-color);
font-size: var(--font-size-sm);
margin-bottom: var(--space-3);
}
.error-table-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: var(--border-radius);
margin-top: var(--space-3);
}
.error-table-container .table {
margin-bottom: 0;
font-size: var(--font-size-sm);
}
.error-table-container .table th {
position: sticky;
top: 0;
background: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
border-bottom: 2px solid rgba(239, 68, 68, 0.2);
z-index: 1;
}
.error-table-container .table td {
border-color: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
}
.raw-data-cell {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: var(--font-size-xs);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.raw-data-cell:hover {
overflow: visible;
white-space: normal;
word-break: break-all;
}
.list-selection {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.list-checkbox {
display: flex;
align-items: center;
padding: var(--space-3);
background: var(--gray-50);
border-radius: var(--border-radius);
cursor: pointer;
transition: var(--transition);
}
.list-checkbox:hover {
background: var(--gray-100);
}
.list-checkbox input[type="checkbox"] {
margin-right: var(--space-3);
transform: scale(1.2);
}
.list-checkbox-label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.list-checkbox-name {
font-weight: 500;
color: var(--gray-800);
}
.list-checkbox-email {
font-size: var(--font-size-sm);
color: var(--gray-600);
}
.selection-info {
padding: var(--space-4);
background: rgba(59, 130, 246, 0.1);
border-radius: var(--border-radius);
}
.import-results {
text-align: center;
}
.result-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.result-stats .stat-item {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.result-stats .stat-value {
color: var(--success-color);
}
.result-errors {
text-align: left;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: var(--border-radius);
padding: var(--space-4);
}
.result-errors h5 {
color: var(--danger-color);
margin-bottom: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
}
.result-errors ul {
color: var(--danger-color);
margin-left: var(--space-5);
}
.button-group {
display: flex;
gap: var(--space-3);
}
/* Row status indicators for preview table */
.row-status {
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2);
border-radius: 12px;
font-weight: 500;
text-transform: uppercase;
}
.row-status.valid {
background: rgba(16, 185, 129, 0.1);
color: var(--success-color);
}
.row-status.error {
background: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
}
/* Animations */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }

View File

@@ -184,6 +184,19 @@ class APIClient {
method: 'DELETE' 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(); this.handleLogin();
} }
}); });
// Bulk import button
document.getElementById('showBulkImportBtn').addEventListener('click', () => {
uiManager.showBulkImportModal();
});
} }
/** /**

View File

@@ -50,6 +50,9 @@ class UIManager {
this.showListModal(); this.showListModal();
}); });
// Initialize bulk import listeners
this.initializeBulkImportListeners();
document.getElementById('addMemberBtn').addEventListener('click', () => { document.getElementById('addMemberBtn').addEventListener('click', () => {
this.showMemberModal(); this.showMemberModal();
}); });
@@ -634,6 +637,546 @@ class UIManager {
return link; 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 * Escape HTML to prevent XSS
*/ */