oliver-metadata-tool/static/js/app.js
SamoilenkoVadym ebc2322d61 Background AI with polling for bulk uploads (up to 100 files)
- Upload returns immediately for AI source, processes in background
- New GET /session/{id}/files endpoint for polling AI progress
- Frontend polls every 3s, updates UI as files complete
- Shows progress: "X of Y files done..."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 22:09:06 +00:00

1529 lines
63 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

let currentFiles = [];
let sessionId = null;
let importSessionId = null;
let selectedFiles = new Set();
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const spinner = document.getElementById('spinner');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const fileList = document.getElementById('fileList');
const actions = document.getElementById('actions');
const errorAlert = document.getElementById('errorAlert');
const successAlert = document.getElementById('successAlert');
const infoAlert = document.getElementById('infoAlert');
// Click to upload
uploadArea.addEventListener('click', () => fileInput.click());
// File selection
fileInput.addEventListener('change', handleFileSelect);
// Drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFiles(files);
}
});
// Import file input
const importFileInput = document.getElementById('importFileInput');
importFileInput.addEventListener('change', handleImportFile);
function handleSourceChange() {
const source = document.getElementById('metadataSource').value;
const importSection = document.getElementById('importSection');
// Show/hide import section
if (source === 'import') {
importSection.style.display = 'block';
} else {
importSection.style.display = 'none';
}
}
let currentImportData = null;
async function handleImportFile(e) {
const file = e.target.files[0];
if (!file) return;
hideAlerts();
showInfo(`Uploading import file: ${file.name}...`);
const formData = new FormData();
formData.append('import_file', file);
try {
const response = await fetch(BASE_PATH + '/import-metadata', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
// Store import data and show mapping modal
currentImportData = data;
showImportMappingModal(data);
} catch (error) {
showError(`Import upload failed: ${error.message}`);
}
}
function showImportMappingModal(data) {
const modal = document.getElementById('importMappingModal');
const content = document.getElementById('importMappingContent');
// Build sheet selector for Excel files
let sheetsHTML = '';
if (data.sheets && data.sheets.length > 0) {
sheetsHTML = '<div class="form-group">';
sheetsHTML += '<label for="importSheetSelect">Select Sheet *</label>';
sheetsHTML += '<select id="importSheetSelect" class="form-control" onchange="updateImportSheetPreview()">';
data.sheets.forEach(sheet => {
sheetsHTML += `<option value="${sheet}">${sheet}</option>`;
});
sheetsHTML += '</select></div>';
}
content.innerHTML = `
<div class="form-group">
<label>File: <strong>${data.filename}</strong></label>
</div>
${sheetsHTML}
<div class="form-group">
<label>Column Mappings *</label>
<div style="background: #f8f9ff; padding: 15px; border-radius: 8px; margin-top: 10px;">
<div class="form-group">
<label for="importFilenameColumn">Filename Column *</label>
<select id="importFilenameColumn" class="form-control">
<option value="">-- Select Column --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
<small style="color: #6b7280;">Column containing filenames (with or without extension)</small>
</div>
<div class="form-group">
<label for="importTitleColumn">Title Column</label>
<select id="importTitleColumn" class="form-control">
<option value="">-- Select Column (Optional) --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="importSubjectColumn">Subject/Description Column</label>
<select id="importSubjectColumn" class="form-control">
<option value="">-- Select Column (Optional) --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="importKeywordsColumn">Keywords Column</label>
<select id="importKeywordsColumn" class="form-control">
<option value="">-- Select Column (Optional) --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
</div>
</div>
</div>
<div class="form-group">
<label>Preview (First 3 rows)</label>
<div id="importPreviewTable" style="overflow-x: auto; margin-top: 10px;">
${buildImportPreviewTable(data)}
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button onclick="confirmImportMapping()" class="btn btn-primary" style="flex: 1;">
✅ Confirm Mapping
</button>
<button onclick="closeImportMappingModal()" class="btn btn-secondary" style="flex: 1;">
Cancel
</button>
</div>
`;
// Auto-select likely columns
autoSelectImportColumns(data.columns);
modal.style.display = 'flex';
}
async function updateImportSheetPreview() {
const sheetName = document.getElementById('importSheetSelect').value;
if (!currentImportData || !sheetName) return;
try {
showInfo('Loading sheet preview...');
const response = await fetch(BASE_PATH + '/preview-excel-sheet', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
excel_session_id: currentImportData.import_session_id,
sheet_name: sheetName
})
});
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
// Update column dropdowns
const columns = data.columns;
['importFilenameColumn', 'importTitleColumn', 'importSubjectColumn', 'importKeywordsColumn'].forEach(id => {
const select = document.getElementById(id);
const currentValue = select.value;
select.innerHTML = id === 'importFilenameColumn'
? '<option value="">-- Select Column --</option>'
: '<option value="">-- Select Column (Optional) --</option>';
columns.forEach(col => {
select.innerHTML += `<option value="${col}">${col}</option>`;
});
// Try to restore selection
if (columns.includes(currentValue)) {
select.value = currentValue;
}
});
// Update preview table
document.getElementById('importPreviewTable').innerHTML = buildImportPreviewTable(data);
// Auto-select columns again
autoSelectImportColumns(columns);
hideAlerts();
} catch (error) {
showError(`Failed to load sheet preview: ${error.message}`);
}
}
function autoSelectImportColumns(columns) {
// Try to auto-detect filename column
const filenameCandidates = ['filename', 'file name', 'file', 'name', 'path'];
for (const col of columns) {
if (filenameCandidates.some(c => col.toLowerCase().includes(c))) {
document.getElementById('importFilenameColumn').value = col;
break;
}
}
// Try to auto-detect title column
const titleCandidates = ['title', 'heading', 'name'];
for (const col of columns) {
if (titleCandidates.some(c => col.toLowerCase() === c)) {
document.getElementById('importTitleColumn').value = col;
break;
}
}
// Try to auto-detect subject column
const subjectCandidates = ['description', 'desc', 'subject', 'summary'];
for (const col of columns) {
if (subjectCandidates.some(c => col.toLowerCase().includes(c))) {
document.getElementById('importSubjectColumn').value = col;
break;
}
}
// Try to auto-detect keywords column
const keywordsCandidates = ['keywords', 'keyword', 'tags', 'tag'];
for (const col of columns) {
if (keywordsCandidates.some(c => col.toLowerCase().includes(c))) {
document.getElementById('importKeywordsColumn').value = col;
break;
}
}
}
function buildImportPreviewTable(data) {
if (!data || !data.sample_data || data.sample_data.length === 0) {
return '<p style="color: #6b7280;">No preview data available</p>';
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 13px;">';
// Header
html += '<thead><tr style="background: var(--primary-gold); color: var(--dark-secondary);">';
data.columns.forEach(col => {
html += `<th style="padding: 8px; border: 1px solid #dee2e6; font-weight: 600;">${col}</th>`;
});
html += '</tr></thead>';
// Rows
html += '<tbody>';
data.sample_data.slice(0, 3).forEach((row, idx) => {
html += `<tr style="background: ${idx % 2 === 0 ? '#fff' : '#f8f9ff'};">`;
data.columns.forEach(col => {
const value = row[col] || '';
html += `<td style="padding: 8px; border: 1px solid #dee2e6;">${value}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
async function confirmImportMapping() {
const filenameColumn = document.getElementById('importFilenameColumn').value;
const titleColumn = document.getElementById('importTitleColumn').value;
const subjectColumn = document.getElementById('importSubjectColumn').value;
const keywordsColumn = document.getElementById('importKeywordsColumn').value;
if (!filenameColumn) {
showError('Please select a filename column');
return;
}
try {
showInfo('Configuring import mapping...');
const response = await fetch(BASE_PATH + '/configure-import-mapping', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
import_session_id: currentImportData.import_session_id,
column_mapping: {
filename: filenameColumn,
title: titleColumn,
subject: subjectColumn,
keywords: keywordsColumn
}
})
});
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
// Store import session ID
importSessionId = data.import_session_id;
// Display stats
const importStats = document.getElementById('importStats');
const stats = data.stats;
importStats.innerHTML = `
${data.message}<br>
<small>
Title: ${stats.with_title}/${stats.total_records}
Subject: ${stats.with_subject}/${stats.total_records}
Keywords: ${stats.with_keywords}/${stats.total_records}
</small>
`;
importStats.style.display = 'block';
// Mark import section as active
document.getElementById('importSection').classList.add('active');
closeImportMappingModal();
showSuccess(`${data.message}`);
} catch (error) {
showError(`Import configuration failed: ${error.message}`);
}
}
function closeImportMappingModal() {
const modal = document.getElementById('importMappingModal');
modal.style.display = 'none';
currentImportData = null;
}
function handleFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
handleFiles(files);
}
}
async function handleFiles(files) {
hideAlerts();
showSpinner();
showProgress(0);
fileList.style.display = 'none';
actions.style.display = 'none';
currentFiles = [];
const metadataSource = document.getElementById('metadataSource').value;
// Validate metadata sources
if (metadataSource === 'import' && !importSessionId) {
showError('Please import a metadata file first using the "Choose Import File" button');
hideSpinner();
return;
}
// Show specific message for AI processing
if (metadataSource === 'ai') {
showInfo(`🤖 Generating AI metadata for ${files.length} file(s)... This may take 10-30 seconds per file.`);
// Start animated progress for AI
startProgressAnimation();
} else {
showInfo(`Processing ${files.length} file(s) with ${metadataSource} source...`);
}
const formData = new FormData();
formData.append('metadata_source', metadataSource);
if (importSessionId) {
formData.append('import_session_id', importSessionId);
}
for (let file of files) {
formData.append('files', file);
}
try {
const response = await fetch(BASE_PATH + '/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
hideSpinner();
hideProgress();
stopProgressAnimation();
if (data.error) {
showError(data.error);
return;
}
sessionId = data.session_id;
currentFiles = data.files;
displayFiles(data.files);
showSuccess(`Successfully analyzed ${data.files.length} file(s)!`);
actions.style.display = 'block';
// If AI is processing in background, start polling for results
if (data.ai_processing) {
showInfo('Generating AI metadata for ' + data.files.length + ' file(s)... This may take 10-30 seconds per file.');
pollAiResults(data.session_id);
}
} catch (error) {
hideSpinner();
hideProgress();
stopProgressAnimation();
showError('Error processing files: ' + error.message);
}
}
async function pollAiResults(sid) {
const poll = async () => {
try {
const resp = await fetch(BASE_PATH + '/session/' + sid + '/files');
const data = await resp.json();
if (!data.success) return;
// Update files and UI with latest data
currentFiles = data.files;
displayFiles(data.files);
const status = data.ai_status;
if (status.done) {
if (status.error > 0) {
showInfo(`AI completed: ${status.complete} succeeded, ${status.error} failed.`);
} else {
showSuccess(`AI metadata generated for ${status.complete} file(s)!`);
}
return; // Stop polling
}
// Show progress
showInfo(`AI processing: ${status.complete} of ${status.complete + status.pending} files done...`);
// Poll again in 3 seconds
setTimeout(poll, 3000);
} catch (err) {
console.error('Polling error:', err);
setTimeout(poll, 5000); // Retry after 5s on error
}
};
// Start first poll after 3 seconds
setTimeout(poll, 3000);
}
function displayFiles(files) {
const batchToolbar = `
<div class="batch-toolbar" id="batchToolbar">
<div class="batch-toolbar-left">
<button class="btn-toolbar" onclick="selectAllFiles()">✓ Select All</button>
<button class="btn-toolbar" onclick="deselectAllFiles()">✗ Deselect All</button>
<span class="selection-count" id="selectionCount">0 selected</span>
</div>
<div class="batch-toolbar-right">
<button class="btn-toolbar btn-export" onclick="exportResults()">📊 Export Results</button>
</div>
</div>
`;
fileList.innerHTML = batchToolbar;
fileList.style.display = 'block';
// Reset selected files
selectedFiles.clear();
files.forEach((file, index) => {
if (file.error) {
const errorItem = document.createElement('div');
errorItem.className = 'file-item';
errorItem.style.borderLeftColor = '#dc3545';
errorItem.innerHTML = `
<div class="file-header">
<div class="file-name">❌ ${file.filename}</div>
</div>
<div class="alert alert-error" style="display: block;">${file.error}</div>
`;
fileList.appendChild(errorItem);
return;
}
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.id = `file-${index}`;
// Build AI info section if available
let aiInfoHtml = '';
if (file.suggested_metadata._tokens_used) {
aiInfoHtml = `<div style="font-size: 11px; color: #6c757d; margin-top: 5px;">
✓ AI generated (${file.suggested_metadata._tokens_used} tokens used)
</div>`;
}
if (file.suggested_metadata._ai_error) {
aiInfoHtml = `<div class="alert alert-error" style="display: block; margin-top: 5px; font-size: 12px;">
⚠️ AI Error: ${file.suggested_metadata._ai_error}
</div>`;
}
fileItem.innerHTML = `
<div class="file-header">
<div class="file-header-left">
<input type="checkbox"
class="file-checkbox"
id="checkbox-${index}"
onchange="toggleFileSelection(${index})"
checked />
<div class="file-name">📄 ${file.filename}</div>
</div>
<div class="file-type">${file.file_type}</div>
</div>
<div class="metadata-comparison">
<div class="metadata-box">
<h4>📋 Current Metadata</h4>
${displayMetadata(file.current_metadata)}
</div>
<div class="metadata-box">
<h4>✏️ Edit Metadata</h4>
${displayEditableMetadata(file.suggested_metadata, index)}
${aiInfoHtml}
</div>
</div>
<div class="file-actions">
<button class="btn-save" onclick="saveMetadata(${index})" id="saveBtn-${index}">
💾 Save Changes
</button>
</div>
`;
fileList.appendChild(fileItem);
// Initialize character counters
initCharCounters(index);
// Select by default
selectedFiles.add(index);
fileItem.classList.add('selected');
});
updateSelectionCount();
}
function toggleFileSelection(index) {
const checkbox = document.getElementById(`checkbox-${index}`);
const fileItem = document.getElementById(`file-${index}`);
if (checkbox.checked) {
selectedFiles.add(index);
fileItem.classList.add('selected');
} else {
selectedFiles.delete(index);
fileItem.classList.remove('selected');
}
updateSelectionCount();
}
function selectAllFiles() {
selectedFiles.clear();
currentFiles.forEach((file, index) => {
if (!file.error) {
selectedFiles.add(index);
const checkbox = document.getElementById(`checkbox-${index}`);
const fileItem = document.getElementById(`file-${index}`);
if (checkbox) checkbox.checked = true;
if (fileItem) fileItem.classList.add('selected');
}
});
updateSelectionCount();
}
function deselectAllFiles() {
selectedFiles.clear();
currentFiles.forEach((file, index) => {
if (!file.error) {
const checkbox = document.getElementById(`checkbox-${index}`);
const fileItem = document.getElementById(`file-${index}`);
if (checkbox) checkbox.checked = false;
if (fileItem) fileItem.classList.remove('selected');
}
});
updateSelectionCount();
}
function updateSelectionCount() {
const countElement = document.getElementById('selectionCount');
if (countElement) {
countElement.textContent = `${selectedFiles.size} selected`;
}
// Update download button text if it exists
const downloadBtn = document.getElementById('download-selected-btn');
if (downloadBtn) {
downloadBtn.innerHTML = `📦 Download Selected Files (${selectedFiles.size}) as ZIP`;
}
}
function displayMetadata(metadata) {
if (!metadata || Object.keys(metadata).length === 0) {
return '<p style="color: #6c757d; font-size: 13px;">(empty)</p>';
}
let html = '';
for (const [key, value] of Object.entries(metadata)) {
html += `
<div class="metadata-item">
<span class="metadata-label">${key}:</span>
<span class="metadata-value">${value || '(empty)'}</span>
</div>
`;
}
return html;
}
function displayEditableMetadata(metadata, index) {
// Filter out internal fields (starting with _)
const title = metadata?.title || '';
const subject = metadata?.subject || '';
const keywords = metadata?.keywords || '';
const author = metadata?.author || metadata?.creator || '';
const copyright = metadata?.copyright || '';
const comments = metadata?.comments || '';
return `
<div class="metadata-field">
<label for="title-${index}">Title:</label>
<input type="text"
id="title-${index}"
class="editable-field"
value="${escapeHtml(title)}"
maxlength="200"
data-field="title"
data-index="${index}"
placeholder="e.g., Product Brochure 2026" />
<span class="char-count" id="title-count-${index}">0/200</span>
</div>
<div class="metadata-field">
<label for="subject-${index}">Description/Subject:</label>
<textarea id="subject-${index}"
class="editable-field"
maxlength="300"
data-field="subject"
data-index="${index}"
placeholder="e.g., Marketing brochure for product line">${escapeHtml(subject)}</textarea>
<span class="char-count" id="subject-count-${index}">0/300</span>
</div>
<div class="metadata-field">
<label for="keywords-${index}">Keywords:</label>
<input type="text"
id="keywords-${index}"
class="editable-field"
value="${escapeHtml(keywords)}"
maxlength="500"
data-field="keywords"
data-index="${index}"
placeholder="e.g., product, marketing, 2026" />
<span class="char-count" id="keywords-count-${index}">0/500</span>
</div>
<div class="metadata-field">
<label for="author-${index}">Author/Creator:</label>
<input type="text"
id="author-${index}"
class="editable-field"
value="${escapeHtml(author)}"
maxlength="100"
data-field="author"
data-index="${index}"
placeholder="e.g., John Smith" />
<span class="char-count" id="author-count-${index}">0/100</span>
</div>
<div class="metadata-field">
<label for="copyright-${index}">Copyright:</label>
<input type="text"
id="copyright-${index}"
class="editable-field"
value="${escapeHtml(copyright)}"
maxlength="150"
data-field="copyright"
data-index="${index}"
placeholder="e.g., Copyright 2026 Company Name" />
<span class="char-count" id="copyright-count-${index}">0/150</span>
</div>
<div class="metadata-field">
<label for="comments-${index}">Comments/Notes:</label>
<textarea id="comments-${index}"
class="editable-field"
maxlength="500"
data-field="comments"
data-index="${index}"
placeholder="e.g., Internal notes about this file">${escapeHtml(comments)}</textarea>
<span class="char-count" id="comments-count-${index}">0/500</span>
</div>
<div id="custom-fields-${index}" style="margin-top: 10px;">
<!-- Custom fields will be added here -->
</div>
<button class="btn-small" onclick="addCustomField(${index})" type="button" style="margin-top: 10px; background: #17a2b8;">
Add Custom Field
</button>
`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function initCharCounters(index) {
const fields = ['title', 'subject', 'keywords', 'author', 'copyright', 'comments'];
const limits = {
title: 200,
subject: 300,
keywords: 500,
author: 100,
copyright: 150,
comments: 500
};
fields.forEach(field => {
const input = document.getElementById(`${field}-${index}`);
const counter = document.getElementById(`${field}-count-${index}`);
if (input && counter) {
// Initial count
updateCharCount(input, counter, limits[field]);
// Listen for changes
input.addEventListener('input', () => {
updateCharCount(input, counter, limits[field]);
});
}
});
}
function updateCharCount(input, counter, limit) {
const length = input.value.length;
counter.textContent = `${length}/${limit}`;
// Remove all classes first
counter.classList.remove('warning', 'danger');
input.classList.remove('invalid');
// Add warning/danger classes
if (length >= limit) {
counter.classList.add('danger');
input.classList.add('invalid');
} else if (length >= limit * 0.9) {
counter.classList.add('warning');
}
}
async function saveMetadata(index) {
const file = currentFiles[index];
if (!file || file.error) return;
const saveBtn = document.getElementById(`saveBtn-${index}`);
saveBtn.disabled = true;
saveBtn.textContent = '💾 Saving...';
// Get edited metadata
const title = document.getElementById(`title-${index}`).value.trim();
const subject = document.getElementById(`subject-${index}`).value.trim();
const keywords = document.getElementById(`keywords-${index}`).value.trim();
const author = document.getElementById(`author-${index}`).value.trim();
const copyright = document.getElementById(`copyright-${index}`).value.trim();
const comments = document.getElementById(`comments-${index}`).value.trim();
// Get custom fields
const customFields = getCustomFields(index);
try {
const response = await fetch(BASE_PATH + '/update-manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
file_index: index,
title: title,
subject: subject,
keywords: keywords,
author: author,
copyright: copyright,
comments: comments,
custom_fields: customFields
})
});
const data = await response.json();
if (data.error) {
showError(`Failed to update ${file.filename}: ${data.error}`);
saveBtn.textContent = '💾 Save Changes';
saveBtn.disabled = false;
return;
}
// Update the file in currentFiles
currentFiles[index].suggested_metadata = {
title, subject, keywords, author, copyright, comments
};
// Show success indicator
const fileItem = document.getElementById(`file-${index}`);
if (fileItem) {
fileItem.style.borderLeftColor = '#28a745';
// Remove old success message if exists
const oldSuccess = fileItem.querySelector('.save-success');
if (oldSuccess) oldSuccess.remove();
// Add success message
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success save-success';
successDiv.style.display = 'block';
successDiv.style.marginTop = '10px';
successDiv.textContent = `✅ Metadata saved successfully!`;
fileItem.appendChild(successDiv);
// Remove success message after 3 seconds
setTimeout(() => {
successDiv.remove();
fileItem.style.borderLeftColor = '#667eea';
}, 3000);
}
saveBtn.textContent = '✅ Saved!';
setTimeout(() => {
saveBtn.textContent = '💾 Save Changes';
saveBtn.disabled = false;
}, 2000);
} catch (error) {
showError(`Error saving metadata: ${error.message}`);
saveBtn.textContent = '💾 Save Changes';
saveBtn.disabled = false;
}
}
async function updateAllFiles() {
if (selectedFiles.size === 0) {
showError('Please select at least one file to update');
return;
}
const outputDirEl = document.getElementById('outputDir');
const outputDir = outputDirEl ? outputDirEl.value.trim() : '';
const updateBtn = document.getElementById('updateAllBtn');
updateBtn.disabled = true;
hideAlerts();
showProgress(0);
showInfo(`Updating ${selectedFiles.size} selected file(s)...`);
let successCount = 0;
let errorCount = 0;
const selectedIndices = Array.from(selectedFiles);
for (let idx = 0; idx < selectedIndices.length; idx++) {
const i = selectedIndices[idx];
const file = currentFiles[i];
if (file.error) {
errorCount++;
continue;
}
const progress = ((idx + 1) / selectedIndices.length) * 100;
showProgress(progress);
try {
const response = await fetch(BASE_PATH + '/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filepath: file.filepath,
session_id: sessionId,
file_index: i,
output_dir: outputDir
})
});
const data = await response.json();
if (data.error) {
errorCount++;
const fileItem = document.getElementById(`file-${i}`);
if (fileItem) {
fileItem.style.borderLeftColor = '#dc3545';
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-error';
errorDiv.style.display = 'block';
errorDiv.textContent = `Error: ${data.error}`;
fileItem.appendChild(errorDiv);
}
} else {
successCount++;
const fileItem = document.getElementById(`file-${i}`);
if (fileItem) {
fileItem.style.borderLeftColor = '#28a745';
// Create success message with download button
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success';
successDiv.style.display = 'flex';
successDiv.style.alignItems = 'center';
successDiv.style.justifyContent = 'space-between';
successDiv.style.gap = '15px';
const messageSpan = document.createElement('span');
messageSpan.textContent = data.verified ?
`✅ Updated and verified!` :
`✅ Updated!`;
const downloadBtn = document.createElement('a');
downloadBtn.href = `${BASE_PATH}/download/${file.filename}`;
downloadBtn.className = 'btn';
downloadBtn.style.padding = '8px 16px';
downloadBtn.style.fontSize = '14px';
downloadBtn.style.backgroundColor = '#28a745';
downloadBtn.style.color = 'white';
downloadBtn.style.textDecoration = 'none';
downloadBtn.style.borderRadius = '4px';
downloadBtn.style.display = 'inline-block';
downloadBtn.download = file.filename;
downloadBtn.textContent = '⬇️ Download';
successDiv.appendChild(messageSpan);
successDiv.appendChild(downloadBtn);
fileItem.appendChild(successDiv);
}
}
} catch (error) {
errorCount++;
console.error('Error updating file:', error);
}
}
hideProgress();
updateBtn.disabled = false;
// Show final message and Download All button
if (successCount > 0 && errorCount === 0) {
showSuccess(`✅ All ${successCount} file(s) updated successfully!`);
showDownloadAllButton();
} else if (successCount > 0 && errorCount > 0) {
showInfo(`⚠️ Updated ${successCount} file(s), ${errorCount} failed.`);
showDownloadAllButton();
} else {
showError(`❌ Failed to update files. Check individual file errors above.`);
}
}
function showDownloadAllButton() {
// Remove existing Download All button if present
const existingBtn = document.getElementById('download-all-btn');
if (existingBtn) {
existingBtn.remove();
}
// Create Download All button container
const btnContainer = document.createElement('div');
btnContainer.id = 'download-all-btn';
btnContainer.style.marginTop = '30px';
btnContainer.style.marginBottom = '20px';
btnContainer.style.textAlign = 'center';
btnContainer.style.padding = '20px';
btnContainer.style.backgroundColor = '#f8f9fa';
btnContainer.style.borderRadius = '8px';
btnContainer.style.border = '2px solid #007bff';
const downloadAllBtn = document.createElement('button');
downloadAllBtn.id = 'download-selected-btn';
downloadAllBtn.className = 'btn';
downloadAllBtn.style.padding = '15px 30px';
downloadAllBtn.style.fontSize = '18px';
downloadAllBtn.style.fontWeight = 'bold';
downloadAllBtn.style.backgroundColor = '#007bff';
downloadAllBtn.style.color = 'white';
downloadAllBtn.style.border = 'none';
downloadAllBtn.style.borderRadius = '8px';
downloadAllBtn.style.cursor = 'pointer';
downloadAllBtn.style.boxShadow = '0 2px 4px rgba(0,123,255,0.3)';
downloadAllBtn.innerHTML = `📦 Download Selected Files (${selectedFiles.size}) as ZIP`;
downloadAllBtn.onmouseover = function() { this.style.backgroundColor = '#0056b3'; };
downloadAllBtn.onmouseout = function() { this.style.backgroundColor = '#007bff'; };
downloadAllBtn.onclick = downloadSelectedFiles;
btnContainer.appendChild(downloadAllBtn);
// Insert after the file list
const fileList = document.getElementById('fileList');
if (fileList && fileList.parentNode) {
fileList.parentNode.insertBefore(btnContainer, fileList.nextSibling);
}
}
async function downloadSelectedFiles() {
if (!sessionId) {
showError('No active session');
return;
}
if (selectedFiles.size === 0) {
showError('Please select at least one file to download');
return;
}
const selectedIndices = Array.from(selectedFiles);
try {
showInfo('📦 Preparing ZIP archive for download...');
// Send POST request with selected file indices
const response = await fetch(BASE_PATH + '/download-selected', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
session_id: sessionId,
file_indices: selectedIndices
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create ZIP archive');
}
// Get the blob from response
const blob = await response.blob();
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `oliver_metadata_files_${new Date().getTime()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showSuccess(`✅ Downloaded ${selectedFiles.size} file(s) as ZIP archive`);
} catch (error) {
showError(`Error: ${error.message}`);
}
}
async function exportResults() {
if (currentFiles.length === 0) {
showError('No files to export');
return;
}
// Create CSV content
let csvContent = 'Filename,Title,Subject,Keywords,Status\n';
currentFiles.forEach((file, index) => {
if (file.error) {
csvContent += `"${file.filename}","","","","Error: ${file.error}"\n`;
} else {
const title = file.suggested_metadata?.title || '';
const subject = file.suggested_metadata?.subject || '';
const keywords = file.suggested_metadata?.keywords || '';
const status = selectedFiles.has(index) ? 'Selected' : 'Not selected';
// Escape quotes in CSV
const escapeCsv = (str) => `"${String(str).replace(/"/g, '""')}"`;
csvContent += `${escapeCsv(file.filename)},${escapeCsv(title)},${escapeCsv(subject)},${escapeCsv(keywords)},${escapeCsv(status)}\n`;
}
});
// Create download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.setAttribute('href', url);
link.setAttribute('download', `oliver_metadata_export_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showSuccess('✅ Results exported to CSV');
}
function resetForm() {
fileInput.value = '';
fileList.style.display = 'none';
actions.style.display = 'none';
hideAlerts();
hideProgress();
currentFiles = [];
sessionId = null;
selectedFiles.clear();
}
function showSpinner() {
spinner.style.display = 'block';
}
function hideSpinner() {
spinner.style.display = 'none';
}
function showProgress(percent) {
progressBar.style.display = 'block';
progressFill.style.width = percent + '%';
progressFill.textContent = Math.round(percent) + '%';
}
function hideProgress() {
progressBar.style.display = 'none';
}
// Animated progress for long-running operations like AI generation
let progressAnimationInterval = null;
let animatedProgress = 0;
function startProgressAnimation() {
animatedProgress = 0;
showProgress(0);
progressAnimationInterval = setInterval(() => {
// Slow down as we approach 90%
if (animatedProgress < 30) {
animatedProgress += 2;
} else if (animatedProgress < 60) {
animatedProgress += 1;
} else if (animatedProgress < 90) {
animatedProgress += 0.5;
}
showProgress(animatedProgress);
}, 500);
}
function stopProgressAnimation() {
if (progressAnimationInterval) {
clearInterval(progressAnimationInterval);
progressAnimationInterval = null;
}
animatedProgress = 0;
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.style.display = 'block';
}
function showSuccess(message) {
successAlert.textContent = message;
successAlert.style.display = 'block';
}
function showInfo(message) {
infoAlert.textContent = message;
infoAlert.style.display = 'block';
}
function hideAlerts() {
errorAlert.style.display = 'none';
successAlert.style.display = 'none';
infoAlert.style.display = 'none';
}
// Custom Fields Management
let customFieldCounters = {}; // Track number of custom fields per file
function addCustomField(index) {
if (!customFieldCounters[index]) {
customFieldCounters[index] = 0;
}
const fieldId = customFieldCounters[index]++;
const container = document.getElementById(`custom-fields-${index}`);
const customFieldDiv = document.createElement('div');
customFieldDiv.className = 'metadata-field';
customFieldDiv.id = `custom-field-container-${index}-${fieldId}`;
customFieldDiv.style.border = '1px dashed #17a2b8';
customFieldDiv.style.padding = '10px';
customFieldDiv.style.borderRadius = '5px';
customFieldDiv.style.marginTop = '10px';
customFieldDiv.style.background = '#f0f9ff';
customFieldDiv.innerHTML = `
<div style="display: flex; gap: 10px; align-items: flex-start;">
<div style="flex: 1;">
<label style="display: block; font-weight: 600; color: #495057; font-size: 12px; margin-bottom: 5px;">
Field Name:
</label>
<input type="text"
id="custom-field-name-${index}-${fieldId}"
class="editable-field"
placeholder="e.g., Department, Project, Location"
style="margin-bottom: 10px;" />
<label style="display: block; font-weight: 600; color: #495057; font-size: 12px; margin-bottom: 5px;">
Field Value:
</label>
<input type="text"
id="custom-field-value-${index}-${fieldId}"
class="editable-field"
placeholder="Enter value"
maxlength="200" />
<span class="char-count" id="custom-field-count-${index}-${fieldId}">0/200</span>
</div>
<button onclick="removeCustomField(${index}, ${fieldId})"
style="background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 5px; cursor: pointer; margin-top: 25px;"
type="button">
</button>
</div>
`;
container.appendChild(customFieldDiv);
// Initialize character counter for value field
const valueInput = document.getElementById(`custom-field-value-${index}-${fieldId}`);
const counter = document.getElementById(`custom-field-count-${index}-${fieldId}`);
if (valueInput && counter) {
valueInput.addEventListener('input', () => {
updateCharCount(valueInput, counter, 200);
});
updateCharCount(valueInput, counter, 200);
}
}
function removeCustomField(index, fieldId) {
const container = document.getElementById(`custom-field-container-${index}-${fieldId}`);
if (container) {
container.remove();
}
}
function getCustomFields(index) {
const customFields = {};
const container = document.getElementById(`custom-fields-${index}`);
if (container) {
const fieldContainers = container.querySelectorAll('[id^="custom-field-container-"]');
fieldContainers.forEach(fieldContainer => {
const match = fieldContainer.id.match(/custom-field-container-(\d+)-(\d+)/);
if (match && match[1] === String(index)) {
const fieldId = match[2];
const nameInput = document.getElementById(`custom-field-name-${index}-${fieldId}`);
const valueInput = document.getElementById(`custom-field-value-${index}-${fieldId}`);
if (nameInput && valueInput && nameInput.value.trim()) {
customFields[nameInput.value.trim()] = valueInput.value.trim();
}
}
});
}
return customFields;
}
// Template Management Functions
async function loadTemplateList() {
try {
const response = await fetch(BASE_PATH + '/templates/list');
const data = await response.json();
if (data.success) {
const templateSelect = document.getElementById('templateSelect');
templateSelect.innerHTML = '<option value="">Select a template...</option>';
data.templates.forEach(template => {
const option = document.createElement('option');
option.value = template.name;
option.textContent = template.name;
if (template.description) {
option.title = template.description;
}
templateSelect.appendChild(option);
});
// Enable/disable apply button
document.getElementById('applyTemplateBtn').disabled = !data.templates.length;
}
} catch (error) {
console.error('Failed to load templates:', error);
}
}
// Load templates on page load
loadTemplateList();
function showCreateTemplateModal() {
document.getElementById('createTemplateModal').style.display = 'block';
}
function closeCreateTemplateModal() {
document.getElementById('createTemplateModal').style.display = 'none';
// Clear form
document.getElementById('templateName').value = '';
document.getElementById('templateDescription').value = '';
document.getElementById('templateTitle').value = '';
document.getElementById('templateSubject').value = '';
document.getElementById('templateKeywords').value = '';
}
async function saveNewTemplate() {
const name = document.getElementById('templateName').value.trim();
const description = document.getElementById('templateDescription').value.trim();
const title = document.getElementById('templateTitle').value.trim();
const subject = document.getElementById('templateSubject').value.trim();
const keywords = document.getElementById('templateKeywords').value.trim();
if (!name || !title || !subject || !keywords) {
showError('Please fill in all required fields (Name, Title, Subject, Keywords)');
return;
}
hideAlerts();
try {
const response = await fetch(BASE_PATH + '/templates/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, title, subject, keywords })
});
const data = await response.json();
if (data.success) {
showSuccess(`Template "${name}" created successfully!`);
closeCreateTemplateModal();
loadTemplateList();
} else {
showError(data.error || 'Failed to save template');
}
} catch (error) {
showError(`Failed to save template: ${error.message}`);
}
}
async function applyTemplate() {
const templateName = document.getElementById('templateSelect').value;
if (!templateName) {
showError('Please select a template');
return;
}
if (selectedFiles.size === 0) {
showError('Please select at least one file to apply template');
return;
}
if (!sessionId) {
showError('No active session. Please upload files first.');
return;
}
hideAlerts();
showInfo(`Applying template "${templateName}" to ${selectedFiles.size} file(s)...`);
try {
const response = await fetch(BASE_PATH + '/templates/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_name: templateName,
file_indices: Array.from(selectedFiles),
session_id: sessionId,
custom_vars: {}
})
});
const data = await response.json();
if (data.success) {
// Update UI with new metadata
data.results.forEach(result => {
const index = result.file_index;
document.getElementById(`title-${index}`).value = result.metadata.title;
document.getElementById(`subject-${index}`).value = result.metadata.subject;
document.getElementById(`keywords-${index}`).value = result.metadata.keywords;
// Update character counters
initCharCounters(index);
// Update currentFiles
currentFiles[index].suggested_metadata = result.metadata;
});
showSuccess(`✅ Template applied to ${data.results.length} file(s)`);
} else {
showError(data.error || 'Failed to apply template');
}
} catch (error) {
showError(`Failed to apply template: ${error.message}`);
}
}
async function manageTemplates() {
try {
const response = await fetch(BASE_PATH + '/templates/list');
const data = await response.json();
if (!data.success) {
showError('Failed to load templates');
return;
}
if (data.templates.length === 0) {
showInfo('No templates available. Create a new template to get started!');
return;
}
let message = 'Available Templates:\n\n';
data.templates.forEach((template, index) => {
message += `${index + 1}. ${template.name}\n`;
if (template.description) {
message += ` ${template.description}\n`;
}
message += ` Created: ${new Date(template.created_at).toLocaleDateString()}\n`;
message += ` Variables: ${template.variables_used.join(', ') || 'None'}\n\n`;
});
const templateName = prompt(message + '\nEnter template name to delete (or Cancel):');
if (templateName) {
const confirmDelete = confirm(`Are you sure you want to delete template "${templateName}"?`);
if (confirmDelete) {
await deleteTemplate(templateName);
}
}
} catch (error) {
showError(`Failed to manage templates: ${error.message}`);
}
}
async function deleteTemplate(name) {
try {
const response = await fetch(`${BASE_PATH}/templates/delete/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showSuccess(`Template "${name}" deleted successfully`);
loadTemplateList();
} else {
showError(data.error || 'Failed to delete template');
}
} catch (error) {
showError(`Failed to delete template: ${error.message}`);
}
}
// Preview template when selected
document.getElementById('templateSelect').addEventListener('change', async function() {
const templateName = this.value;
const previewDiv = document.getElementById('templatePreview');
if (!templateName) {
previewDiv.style.display = 'none';
return;
}
try {
const response = await fetch(`${BASE_PATH}/templates/load/${encodeURIComponent(templateName)}`);
const data = await response.json();
if (data.success) {
const template = data.template;
previewDiv.innerHTML = `
<div class="template-preview-item">
<span class="template-preview-label">Title:</span> ${template.title}
</div>
<div class="template-preview-item">
<span class="template-preview-label">Subject:</span> ${template.subject}
</div>
<div class="template-preview-item">
<span class="template-preview-label">Keywords:</span> ${template.keywords}
</div>
`;
previewDiv.style.display = 'block';
} else {
previewDiv.style.display = 'none';
}
} catch (error) {
console.error('Failed to preview template:', error);
previewDiv.style.display = 'none';
}
});