Files
delphi-database/static/js/admin_import.js
2025-09-22 22:07:50 -04:00

763 lines
33 KiB
JavaScript

/**
* Admin Import JavaScript
* Handles CSV file import functionality
*/
class ImportManager {
constructor() {
this.supportedTables = [];
this.batchFileCount = 0;
this.currentImportId = null;
this.pollInterval = null;
this.bulkFiles = [];
this.init();
}
async init() {
await this.loadSupportedTables();
await this.loadImportStatus();
this.setupEventListeners();
}
async loadSupportedTables() {
try {
console.log('Loading supported tables...');
const response = await window.http.wrappedFetch('/api/admin/import/tables');
if (response.ok) {
const data = await response.json();
this.supportedTables = data.tables || [];
console.log('Supported tables loaded:', this.supportedTables);
} else {
console.error('Failed to load supported tables, status:', response.status);
}
} catch (error) {
console.error('Failed to load supported tables:', error);
window.alerts.error('Failed to load supported tables');
}
}
setupEventListeners() {
// Single import form
document.getElementById('importForm').addEventListener('submit', (e) => {
e.preventDefault();
this.handleSingleImport();
});
// Validate button
document.getElementById('validateBtn').addEventListener('click', () => {
this.validateHeaders();
});
// Table selection change
document.getElementById('tableSelect').addEventListener('change', (e) => {
this.onTableChange(e.target.value);
});
// Bulk file upload
document.getElementById('bulkFiles').addEventListener('change', (e) => {
this.handleBulkFileSelection(e);
});
document.getElementById('autoMapBtn').addEventListener('click', () => {
this.autoMapTables();
});
document.getElementById('clearAllBtn').addEventListener('click', () => {
this.clearAllFiles();
});
document.getElementById('bulkUploadBtn').addEventListener('click', () => {
this.handleBulkUpload();
});
// Status refresh button
document.getElementById('refreshStatusBtn').addEventListener('click', () => {
this.loadImportStatus();
});
}
async onTableChange(tableName) {
const schemaInfo = document.getElementById('schemaInfo');
const schemaDetails = document.getElementById('schemaDetails');
if (!tableName) {
schemaInfo.classList.add('hidden');
return;
}
try {
console.log('Loading schema for table:', tableName);
const response = await window.http.wrappedFetch(`/api/admin/import/tables/${tableName}/schema`);
if (response.ok) {
const data = await response.json();
const schema = data.schema;
console.log('Schema loaded for', tableName, ':', schema);
let html = '<div class="grid grid-cols-1 md:grid-cols-2 gap-4">';
html += '<div><h4 class="font-semibold mb-2">Required Fields:</h4>';
html += '<ul class="list-disc list-inside space-y-1">';
schema.required_fields.forEach(field => {
html += `<li><code class="bg-blue-100 px-1 rounded">${field}</code></li>`;
});
html += '</ul></div>';
html += '<div><h4 class="font-semibold mb-2">All Available Fields:</h4>';
html += '<div class="max-h-32 overflow-y-auto">';
html += '<div class="grid grid-cols-2 gap-1 text-xs">';
Object.keys(schema.field_mapping).forEach(field => {
html += `<code class="bg-gray-100 px-1 rounded">${field}</code>`;
});
html += '</div></div></div></div>';
schemaDetails.innerHTML = html;
schemaInfo.classList.remove('hidden');
}
} catch (error) {
console.error('Failed to load schema:', error);
}
}
async validateHeaders() {
const tableSelect = document.getElementById('tableSelect');
const fileInput = document.getElementById('csvFile');
console.log('Starting header validation...');
if (!tableSelect.value) {
console.warn('No table selected for validation');
window.alerts.error('Please select a table type');
return;
}
if (!fileInput.files[0]) {
console.warn('No file selected for validation');
window.alerts.error('Please select a CSV file');
return;
}
console.log('Validating headers for table:', tableSelect.value, 'file:', fileInput.files[0].name);
const formData = new FormData();
formData.append('table_name', tableSelect.value);
formData.append('file', fileInput.files[0]);
try {
const response = await window.http.wrappedFetch('/api/admin/import/validate', {
method: 'POST',
body: formData
});
console.log('Validation response status:', response.status);
if (response.ok) {
const result = await response.json();
console.log('Validation result:', result);
if (result.success) {
window.alerts.success('CSV headers validated successfully!');
} else {
const errors = result.validation_result.errors.join('\\n');
console.error('Validation errors:', result.validation_result.errors);
window.alerts.error(`Validation failed:\\n${errors}`);
}
} else {
const error = await response.json();
console.error('Validation failed with error:', error);
window.alerts.error(`Validation failed: ${error.detail}`);
}
} catch (error) {
console.error('Validation error:', error);
window.alerts.error('Failed to validate CSV headers');
}
}
async handleSingleImport() {
const tableSelect = document.getElementById('tableSelect');
const fileInput = document.getElementById('csvFile');
console.log('Starting single import...');
if (!tableSelect.value) {
console.warn('No table selected for import');
window.alerts.error('Please select a table type');
return;
}
if (!fileInput.files[0]) {
console.warn('No file selected for import');
window.alerts.error('Please select a CSV file');
return;
}
console.log('Importing to table:', tableSelect.value, 'file:', fileInput.files[0].name);
const formData = new FormData();
formData.append('table_name', tableSelect.value);
formData.append('file', fileInput.files[0]);
// Show progress
this.showProgress();
try {
const response = await window.http.wrappedFetch('/api/admin/import/csv', {
method: 'POST',
body: formData
});
console.log('Import response status:', response.status);
if (response.ok) {
const result = await response.json();
console.log('Import started successfully:', result);
this.currentImportId = result.import_id;
this.updateProgress(`Import started for ${result.table_name} (ID: ${result.import_id})`, 'info');
this.startPolling();
} else {
const error = await response.json();
console.error('Import failed:', error);
this.updateProgress(`Import failed: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Import error:', error);
this.updateProgress('Import failed: Network error', 'error');
}
}
showProgress() {
document.getElementById('importProgress').classList.remove('hidden');
document.getElementById('importResults').classList.add('hidden');
}
updateProgress(message, type = 'info') {
const progressDetails = document.getElementById('progressDetails');
const timestamp = new Date().toLocaleTimeString();
let colorClass = 'text-blue-600';
if (type === 'error') colorClass = 'text-red-600';
if (type === 'success') colorClass = 'text-green-600';
if (type === 'warning') colorClass = 'text-yellow-600';
progressDetails.innerHTML += `
<div class="flex items-center space-x-2 mb-2">
<span class="text-gray-500 text-sm">${timestamp}</span>
<span class="${colorClass}">${message}</span>
</div>
`;
// Scroll to bottom
progressDetails.scrollTop = progressDetails.scrollHeight;
}
startPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
this.pollInterval = setInterval(async () => {
await this.checkImportStatus();
}, 2000); // Poll every 2 seconds
}
startBatchPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
this.pollInterval = setInterval(async () => {
await this.checkBatchStatus();
}, 2000); // Poll every 2 seconds
}
async checkImportStatus() {
if (!this.currentImportId) return;
try {
const response = await window.http.wrappedFetch(`/api/admin/import/status/${this.currentImportId}`);
if (response.ok) {
const status = await response.json();
if (status.status === 'COMPLETED') {
clearInterval(this.pollInterval);
this.updateProgress('Import completed successfully!', 'success');
this.showResults(status.result);
// Refresh status after successful import
setTimeout(() => {
this.loadImportStatus();
}, 1000);
} else if (status.status === 'FAILED') {
clearInterval(this.pollInterval);
this.updateProgress(`Import failed: ${status.error || 'Unknown error'}`, 'error');
if (status.result) {
this.showResults(status.result);
}
} else {
this.updateProgress(`Import status: ${status.status}`, 'info');
}
}
} catch (error) {
console.error('Status check error:', error);
}
}
async checkBatchStatus() {
if (!this.currentImportIds || !Array.isArray(this.currentImportIds)) return;
let allCompleted = true;
let anyFailed = false;
for (const importId of this.currentImportIds) {
try {
const response = await window.http.wrappedFetch(`/api/admin/import/status/${importId}`);
if (response.ok) {
const status = await response.json();
if (status.status === 'PROCESSING') {
allCompleted = false;
} else if (status.status === 'FAILED') {
anyFailed = true;
this.updateProgress(`${status.table_name} import failed: ${status.error || 'Unknown error'}`, 'error');
} else if (status.status === 'COMPLETED') {
this.updateProgress(`${status.table_name} import completed`, 'success');
}
}
} catch (error) {
console.error('Batch status check error:', error);
}
}
if (allCompleted) {
clearInterval(this.pollInterval);
const message = anyFailed ? 'Batch import completed with some failures' : 'Batch import completed successfully!';
const type = anyFailed ? 'warning' : 'success';
this.updateProgress(message, type);
// Refresh the import status after batch completion
setTimeout(() => {
this.loadImportStatus();
}, 1000);
}
}
showResults(result) {
const resultsContent = document.getElementById('resultsContent');
const resultsDiv = document.getElementById('importResults');
let html = '<div class="space-y-4">';
// Summary
html += `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="bg-blue-50 p-3 rounded">
<div class="text-2xl font-bold text-blue-600">${result.total_rows}</div>
<div class="text-sm text-blue-800">Total Rows</div>
</div>
<div class="bg-green-50 p-3 rounded">
<div class="text-2xl font-bold text-green-600">${result.imported_rows}</div>
<div class="text-sm text-green-800">Imported</div>
</div>
<div class="bg-yellow-50 p-3 rounded">
<div class="text-2xl font-bold text-yellow-600">${result.skipped_rows}</div>
<div class="text-sm text-yellow-800">Skipped</div>
</div>
<div class="bg-red-50 p-3 rounded">
<div class="text-2xl font-bold text-red-600">${result.error_rows}</div>
<div class="text-sm text-red-800">Errors</div>
</div>
</div>
`;
// Errors
if (result.errors && result.errors.length > 0) {
html += '<div class="bg-red-50 border border-red-200 rounded p-4">';
html += '<h4 class="font-semibold text-red-800 mb-2">Errors:</h4>';
html += '<div class="text-sm text-red-700 space-y-1 max-h-40 overflow-y-auto">';
result.errors.forEach(error => {
html += `<div>${this.escapeHtml(error)}</div>`;
});
html += '</div></div>';
}
// Warnings
if (result.warnings && result.warnings.length > 0) {
html += '<div class="bg-yellow-50 border border-yellow-200 rounded p-4">';
html += '<h4 class="font-semibold text-yellow-800 mb-2">Warnings:</h4>';
html += '<div class="text-sm text-yellow-700 space-y-1 max-h-40 overflow-y-auto">';
result.warnings.forEach(warning => {
html += `<div>${this.escapeHtml(warning)}</div>`;
});
html += '</div></div>';
}
html += '</div>';
resultsContent.innerHTML = html;
resultsDiv.classList.remove('hidden');
}
handleBulkFileSelection(event) {
const files = Array.from(event.target.files);
console.log('Selected files:', files);
this.bulkFiles = files.map(file => {
const suggestion = this.suggestTableType(file.name);
return {
file: file,
name: file.name,
size: file.size,
suggested_table: suggestion || 'skip' // Default to skip for unknown files
};
});
this.renderBulkFiles();
this.updateBulkControls();
}
suggestTableType(filename) {
const name = filename.toUpperCase().replace('.CSV', '');
const mapping = {
// Core supported tables
'ROLODEX': 'rolodex',
'ROLEX_V': 'rolodex', // ROLEX_V variant
'PHONE': 'phone',
'FILES': 'files',
'FILES_R': 'files',
'FILES_V': 'files',
'LEDGER': 'ledger',
'QDROS': 'qdros',
// Lookup and reference tables
'GRUPLKUP': 'gruplkup',
'PLANINFO': 'planinfo',
'RVARLKUP': 'rvarlkup',
'FVARLKUP': 'fvarlkup',
'TRNSLKUP': 'trnslkup',
'EMPLOYEE': 'employee',
'FILETYPE': 'filetype',
'TRNSTYPE': 'trnstype',
'TRNSACTN': 'trnsactn',
'FILENOTS': 'filenots',
'SETUP': 'setup',
'PENSIONS': 'pensions',
'PAYMENTS': 'payments',
'DEPOSITS': 'deposits',
// Form tables
'NUMBERAL': 'numberal',
'INX_LKUP': 'inx_lkup',
'FORM_LST': 'form_lst',
'FORM_INX': 'form_inx',
'LIFETABL': 'lifetabl',
// Pension tables
'MARRIAGE': 'marriage',
'DEATH': 'death',
'SEPARATE': 'separate',
'SCHEDULE': 'schedule'
};
return mapping[name] || '';
}
renderBulkFiles() {
const filesDisplay = document.getElementById('bulkFilesDisplay');
const filesList = document.getElementById('bulkFilesList');
const fileCount = document.getElementById('bulkFileCount');
fileCount.textContent = this.bulkFiles.length;
if (this.bulkFiles.length === 0) {
filesDisplay.classList.add('hidden');
return;
}
filesDisplay.classList.remove('hidden');
let html = '';
this.bulkFiles.forEach((fileObj, index) => {
const sizeKB = Math.round(fileObj.size / 1024);
html += `
<div class="flex items-center justify-between p-3 bg-white dark:bg-neutral-700 rounded-lg border border-neutral-200 dark:border-neutral-600">
<div class="flex items-center space-x-3">
<i class="fa-solid fa-file-csv text-primary-600"></i>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">
${fileObj.name}
</div>
<p class="text-sm text-neutral-500 dark:text-neutral-400">${sizeKB} KB</p>
</div>
</div>
<div class="flex items-center space-x-2">
<select onchange="importManager.updateBulkFileTableMapping(${index}, this.value)"
class="px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md text-sm bg-white dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100">
<option value="">-- Select Table --</option>
<optgroup label="Core Tables">
<option value="rolodex" ${fileObj.suggested_table === 'rolodex' ? 'selected' : ''}>ROLODEX (Contacts)</option>
<option value="phone" ${fileObj.suggested_table === 'phone' ? 'selected' : ''}>PHONE (Phone Numbers)</option>
<option value="files" ${fileObj.suggested_table === 'files' ? 'selected' : ''}>FILES (Case Files)</option>
<option value="ledger" ${fileObj.suggested_table === 'ledger' ? 'selected' : ''}>LEDGER (Financial)</option>
<option value="qdros" ${fileObj.suggested_table === 'qdros' ? 'selected' : ''}>QDROS (Documents)</option>
</optgroup>
<optgroup label="Lookup Tables">
<option value="gruplkup" ${fileObj.suggested_table === 'gruplkup' ? 'selected' : ''}>GRUPLKUP</option>
<option value="employee" ${fileObj.suggested_table === 'employee' ? 'selected' : ''}>EMPLOYEE</option>
<option value="filetype" ${fileObj.suggested_table === 'filetype' ? 'selected' : ''}>FILETYPE</option>
<option value="trnstype" ${fileObj.suggested_table === 'trnstype' ? 'selected' : ''}>TRNSTYPE</option>
<option value="trnslkup" ${fileObj.suggested_table === 'trnslkup' ? 'selected' : ''}>TRNSLKUP</option>
<option value="rvarlkup" ${fileObj.suggested_table === 'rvarlkup' ? 'selected' : ''}>RVARLKUP</option>
<option value="fvarlkup" ${fileObj.suggested_table === 'fvarlkup' ? 'selected' : ''}>FVARLKUP</option>
</optgroup>
<optgroup label="Financial Tables">
<option value="payments" ${fileObj.suggested_table === 'payments' ? 'selected' : ''}>PAYMENTS</option>
<option value="deposits" ${fileObj.suggested_table === 'deposits' ? 'selected' : ''}>DEPOSITS</option>
<option value="trnsactn" ${fileObj.suggested_table === 'trnsactn' ? 'selected' : ''}>TRNSACTN</option>
</optgroup>
<optgroup label="Forms & Documents">
<option value="numberal" ${fileObj.suggested_table === 'numberal' ? 'selected' : ''}>NUMBERAL</option>
<option value="inx_lkup" ${fileObj.suggested_table === 'inx_lkup' ? 'selected' : ''}>INX_LKUP</option>
<option value="form_lst" ${fileObj.suggested_table === 'form_lst' ? 'selected' : ''}>FORM_LST</option>
<option value="form_inx" ${fileObj.suggested_table === 'form_inx' ? 'selected' : ''}>FORM_INX</option>
<option value="lifetabl" ${fileObj.suggested_table === 'lifetabl' ? 'selected' : ''}>LIFETABL</option>
</optgroup>
<optgroup label="Pension Tables">
<option value="pensions" ${fileObj.suggested_table === 'pensions' ? 'selected' : ''}>PENSIONS</option>
<option value="marriage" ${fileObj.suggested_table === 'marriage' ? 'selected' : ''}>MARRIAGE</option>
<option value="death" ${fileObj.suggested_table === 'death' ? 'selected' : ''}>DEATH</option>
<option value="separate" ${fileObj.suggested_table === 'separate' ? 'selected' : ''}>SEPARATE</option>
<option value="schedule" ${fileObj.suggested_table === 'schedule' ? 'selected' : ''}>SCHEDULE</option>
</optgroup>
<optgroup label="Configuration">
<option value="setup" ${fileObj.suggested_table === 'setup' ? 'selected' : ''}>SETUP</option>
<option value="planinfo" ${fileObj.suggested_table === 'planinfo' ? 'selected' : ''}>PLANINFO</option>
<option value="filenots" ${fileObj.suggested_table === 'filenots' ? 'selected' : ''}>FILENOTS</option>
</optgroup>
<optgroup label="Other">
<option value="skip" ${fileObj.suggested_table === 'skip' ? 'selected' : ''}>⚠️ SKIP (Don't Import)</option>
</optgroup>
</select>
<button onclick="importManager.removeBulkFile(${index})"
class="px-2 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 text-sm">
<i class="fa-solid fa-times"></i>
</button>
</div>
</div>
`;
});
filesList.innerHTML = html;
}
updateBulkFileTableMapping(index, tableType) {
if (this.bulkFiles[index]) {
this.bulkFiles[index].suggested_table = tableType;
}
this.updateBulkControls();
}
removeBulkFile(index) {
this.bulkFiles.splice(index, 1);
this.renderBulkFiles();
this.updateBulkControls();
}
autoMapTables() {
this.bulkFiles.forEach(fileObj => {
if (!fileObj.suggested_table || fileObj.suggested_table === '') {
const suggestion = this.suggestTableType(fileObj.name);
// If no mapping found, default to 'skip' for unknown files
fileObj.suggested_table = suggestion || 'skip';
}
});
this.renderBulkFiles();
this.updateBulkControls();
}
clearAllFiles() {
this.bulkFiles = [];
document.getElementById('bulkFiles').value = '';
this.renderBulkFiles();
this.updateBulkControls();
}
updateBulkControls() {
const autoMapBtn = document.getElementById('autoMapBtn');
const clearAllBtn = document.getElementById('clearAllBtn');
const uploadBtn = document.getElementById('bulkUploadBtn');
const hasFiles = this.bulkFiles.length > 0;
const allMapped = this.bulkFiles.every(f => f.suggested_table && f.suggested_table !== '');
autoMapBtn.disabled = !hasFiles;
clearAllBtn.disabled = !hasFiles;
uploadBtn.disabled = !hasFiles || !allMapped;
if (hasFiles) {
const mappedCount = this.bulkFiles.filter(f => f.suggested_table && f.suggested_table !== '').length;
const importCount = this.bulkFiles.filter(f => f.suggested_table && f.suggested_table !== 'skip').length;
uploadBtn.innerHTML = `
<i class="fa-solid fa-cloud-upload"></i>
<span>Upload & Import ${importCount} Files (${mappedCount} mapped)</span>
`;
} else {
uploadBtn.innerHTML = `
<i class="fa-solid fa-cloud-upload"></i>
<span>Upload & Import All</span>
`;
}
}
async handleBulkUpload() {
if (this.bulkFiles.length === 0) {
window.alerts.error('Please select files to upload');
return;
}
// Validate all files have table mappings
const unmappedFiles = this.bulkFiles.filter(f => !f.suggested_table || f.suggested_table === '');
if (unmappedFiles.length > 0) {
window.alerts.error(`Please select table types for: ${unmappedFiles.map(f => f.name).join(', ')}`);
return;
}
// Filter out files marked as 'skip'
const filesToImport = this.bulkFiles.filter(f => f.suggested_table !== 'skip');
if (filesToImport.length === 0) {
window.alerts.error('No files selected for import (all marked as skip)');
return;
}
console.log('Starting bulk upload:', filesToImport);
// Show progress
this.showProgress();
try {
// Use the existing batch endpoint that handles FormData
const formData = new FormData();
// Add only files that are not marked as 'skip'
filesToImport.forEach(fileObj => {
formData.append('files', fileObj.file);
});
// Add corresponding table names
filesToImport.forEach(fileObj => {
formData.append('table_names', fileObj.suggested_table);
});
const response = await window.http.wrappedFetch('/api/admin/import/batch', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
this.currentImportIds = result.import_ids;
this.updateProgress(`Bulk upload started for ${result.total_files} files`, 'info');
this.startBatchPolling();
} else {
const error = await response.json();
this.updateProgress(`Bulk upload failed: ${error.detail}`, 'error');
}
} catch (error) {
console.error('Bulk upload error:', error);
this.updateProgress('Bulk upload failed: Network error', 'error');
}
}
async loadImportStatus() {
try {
console.log('Loading import status...');
const response = await window.http.wrappedFetch('/api/admin/import/status');
if (response.ok) {
const data = await response.json();
this.displayImportStatus(data);
console.log('Import status loaded:', data);
} else {
console.error('Failed to load import status, status:', response.status);
window.alerts.error('Failed to load import status');
}
} catch (error) {
console.error('Failed to load import status:', error);
window.alerts.error('Failed to load import status');
}
}
displayImportStatus(data) {
const summary = data.summary;
const categories = data.categories;
// Update summary stats
document.getElementById('totalTables').textContent = summary.total_tables;
document.getElementById('importedTables').textContent = summary.imported_tables;
document.getElementById('emptyTables').textContent = summary.empty_tables;
document.getElementById('missingTables').textContent = summary.missing_tables;
document.getElementById('totalRows').textContent = summary.total_rows.toLocaleString();
document.getElementById('completionPercentage').textContent = `${summary.completion_percentage}%`;
// Update progress bar
const progressBar = document.getElementById('progressBar');
progressBar.style.width = `${summary.completion_percentage}%`;
// Display categories
const categoryContainer = document.getElementById('statusByCategory');
categoryContainer.innerHTML = '';
Object.keys(categories).forEach(categoryName => {
const tables = categories[categoryName];
const categoryDiv = document.createElement('div');
categoryDiv.className = 'bg-neutral-50 dark:bg-neutral-900 rounded-lg p-4';
const importedCount = tables.filter(t => t.imported).length;
const totalCount = tables.length;
categoryDiv.innerHTML = `
<h3 class="font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center justify-between">
<span>${categoryName} Tables</span>
<span class="text-sm font-normal text-neutral-600 dark:text-neutral-400">${importedCount}/${totalCount} imported</span>
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
${tables.map(table => `
<div class="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-600">
<div class="flex items-center space-x-3">
<div class="flex-shrink-0">
${table.imported ?
'<i class="fa-solid fa-check-circle text-green-600"></i>' :
table.exists ?
'<i class="fa-solid fa-exclamation-circle text-yellow-600"></i>' :
'<i class="fa-solid fa-times-circle text-red-600"></i>'
}
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">${table.display_name}</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400">
${table.imported ?
`${table.row_count.toLocaleString()} rows` :
table.exists ?
'Empty table' :
'Not created'
}
</div>
<div class="text-xs text-neutral-400 dark:text-neutral-500">
Expected: ${table.expected_files.join(', ')}
</div>
</div>
</div>
</div>
`).join('')}
</div>
`;
categoryContainer.appendChild(categoryDiv);
});
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.importManager = new ImportManager();
});