solventum-image-metadata/templates/index.html
SamoilenkoVadym f99aa118bf Phase 3 Complete: Batch Selection, CSV Export, and Metadata Templates
This commit completes Phase 3 implementation with advanced batch processing
and metadata template system.

Changes:
- Added batch file selection with checkboxes
- Implemented select all/deselect all functionality
- Updated batch processing to handle only selected files
- Added CSV export for processing results
- Created template_manager.py with variable substitution system
- Added template endpoints (list, save, load, delete, apply, preview)
- Integrated template UI with modal dialog for creation
- Template variables: {filename}, {date}, {datetime}, {user}, {year}, {month}, {day}

Phase 3 Status: Complete
Next Phase: Phase 4 (Authentication + SSO) for v3.1 release

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 15:52:05 +00:00

1725 lines
60 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oliver Metadata Tool</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 { font-size: 28px; margin-bottom: 10px; }
.header p { opacity: 0.9; font-size: 14px; }
.content { padding: 40px; }
.upload-section {
background: #f8f9ff;
border-radius: 10px;
padding: 20px;
margin-bottom: 30px;
}
.upload-area {
border: 3px dashed #667eea;
border-radius: 10px;
padding: 60px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: white;
margin-bottom: 20px;
}
.upload-area:hover { background: #f0f2ff; border-color: #764ba2; }
.upload-area.dragover { background: #e8ebff; transform: scale(1.02); }
#fileInput { display: none; }
.upload-icon { font-size: 48px; margin-bottom: 15px; }
.output-dir-section {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
background: white;
border-radius: 8px;
}
.output-dir-section label {
font-weight: 600;
color: #495057;
min-width: 120px;
}
#outputDir {
flex: 1;
padding: 10px;
border: 2px solid #dee2e6;
border-radius: 5px;
font-size: 14px;
}
.output-dir-hint {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
.btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 30px;
border-radius: 25px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: transform 0.2s;
margin: 5px;
}
.btn:hover { transform: translateY(-2px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn-small {
padding: 8px 20px;
font-size: 14px;
}
.progress-bar {
width: 100%;
height: 30px;
background: #e9ecef;
border-radius: 15px;
overflow: hidden;
margin: 20px 0;
display: none;
}
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.file-list {
margin-top: 30px;
display: none;
}
.batch-toolbar {
background: #f8f9ff;
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.batch-toolbar-left {
display: flex;
gap: 10px;
align-items: center;
}
.batch-toolbar-right {
display: flex;
gap: 10px;
}
.btn-toolbar {
background: #6c757d;
color: white;
border: none;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: transform 0.2s;
}
.btn-toolbar:hover {
transform: translateY(-2px);
background: #5a6268;
}
.btn-export {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
}
.btn-export:hover {
background: linear-gradient(135deg, #218838 0%, #1fa589 100%);
}
.selection-count {
font-size: 13px;
color: #495057;
font-weight: 600;
}
.file-item {
background: #f8f9fa;
border-radius: 10px;
padding: 20px;
margin-bottom: 20px;
border-left: 4px solid #667eea;
}
.file-item.selected {
background: #e8f4f8;
border-left-color: #28a745;
}
.file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.file-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.file-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.file-name {
font-weight: 600;
font-size: 16px;
color: #495057;
}
.file-type {
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
}
.metadata-comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.metadata-box {
background: white;
border-radius: 8px;
padding: 15px;
}
.metadata-box h4 {
color: #667eea;
margin-bottom: 10px;
font-size: 14px;
}
.metadata-item {
display: flex;
flex-direction: column;
padding: 8px 0;
border-bottom: 1px solid #dee2e6;
}
.metadata-item:last-child { border-bottom: none; }
.metadata-label { font-weight: 600; color: #495057; font-size: 12px; margin-bottom: 4px; }
.metadata-value { color: #6c757d; font-size: 13px; }
.alert {
padding: 15px;
border-radius: 8px;
margin: 15px 0;
display: none;
}
.alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
.actions {
text-align: center;
margin-top: 20px;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.footer {
text-align: center;
padding: 20px;
color: #6c757d;
font-size: 12px;
border-top: 1px solid #dee2e6;
}
/* Metadata Source Selector */
.metadata-source-selector {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 15px;
}
.metadata-source-selector label {
font-weight: 600;
color: #495057;
min-width: 140px;
}
.source-select {
flex: 1;
padding: 10px;
border: 2px solid #667eea;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
background: white;
}
.source-info {
font-size: 12px;
color: #6c757d;
margin-left: 10px;
}
/* Editable Metadata Fields */
.editable-field {
width: 100%;
padding: 8px;
border: 2px solid #dee2e6;
border-radius: 5px;
font-size: 13px;
font-family: inherit;
transition: border-color 0.3s;
}
.editable-field:focus {
outline: none;
border-color: #667eea;
}
.editable-field.invalid {
border-color: #dc3545;
}
textarea.editable-field {
min-height: 60px;
resize: vertical;
}
.char-count {
font-size: 11px;
color: #6c757d;
margin-top: 4px;
display: block;
}
.char-count.warning {
color: #ffc107;
}
.char-count.danger {
color: #dc3545;
}
.metadata-field {
margin-bottom: 15px;
}
.metadata-field label {
display: block;
font-weight: 600;
color: #495057;
font-size: 12px;
margin-bottom: 5px;
}
/* File Action Buttons */
.file-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn-save {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: none;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: transform 0.2s;
}
.btn-save:hover {
transform: translateY(-2px);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-reset {
background: #6c757d;
color: white;
border: none;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: transform 0.2s;
}
.btn-reset:hover {
transform: translateY(-2px);
background: #5a6268;
}
/* Import Metadata Section */
.import-section {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
border: 2px dashed #dee2e6;
}
.import-section.active {
border-color: #28a745;
background: #f0fff4;
}
.btn-import {
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
color: white;
border: none;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: transform 0.2s;
}
.btn-import:hover {
transform: translateY(-2px);
}
.import-stats {
font-size: 12px;
color: #28a745;
margin-top: 10px;
padding: 8px;
background: white;
border-radius: 5px;
}
/* Template Section */
.template-section {
background: white;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
border: 2px dashed #dee2e6;
}
.template-section.active {
border-color: #667eea;
background: #f8f9ff;
}
.template-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.template-select {
flex: 1;
min-width: 200px;
padding: 8px;
border: 2px solid #667eea;
border-radius: 5px;
font-size: 13px;
cursor: pointer;
}
.btn-template {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 8px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: transform 0.2s;
}
.btn-template:hover {
transform: translateY(-2px);
}
.btn-template:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.template-preview {
margin-top: 10px;
padding: 10px;
background: white;
border-radius: 5px;
font-size: 12px;
color: #495057;
display: none;
}
.template-preview-item {
margin-bottom: 5px;
}
.template-preview-label {
font-weight: 600;
color: #667eea;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 30px;
border-radius: 15px;
width: 90%;
max-width: 600px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h3 {
color: #667eea;
margin: 0;
}
.close-modal {
font-size: 28px;
font-weight: bold;
color: #aaa;
cursor: pointer;
}
.close-modal:hover {
color: #000;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: 600;
color: #495057;
margin-bottom: 5px;
font-size: 13px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 10px;
border: 2px solid #dee2e6;
border-radius: 5px;
font-size: 13px;
font-family: inherit;
}
.form-group textarea {
min-height: 60px;
resize: vertical;
}
.form-group small {
font-size: 11px;
color: #6c757d;
margin-top: 3px;
display: block;
}
.variable-hint {
background: #f8f9ff;
padding: 8px;
border-radius: 5px;
font-size: 11px;
color: #667eea;
margin-top: 5px;
}
@media (max-width: 768px) {
.metadata-comparison {
grid-template-columns: 1fr;
}
.metadata-source-selector {
flex-direction: column;
align-items: flex-start;
}
.metadata-source-selector label {
min-width: auto;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎯 Oliver Metadata Tool</h1>
<p>Universal metadata creation and management for all file types</p>
</div>
<div class="content">
<div class="upload-section">
<div class="metadata-source-selector">
<label for="metadataSource">Metadata Source:</label>
<select id="metadataSource" class="source-select" onchange="handleSourceChange()">
<option value="excel" selected>📊 Excel Lookup (Fastest)</option>
<option value="manual">✏️ Manual Entry</option>
<option value="import">📂 Import from File</option>
<option value="ai">🤖 AI Generation (Slower)</option>
</select>
<span class="source-info"> Choose how to generate metadata</span>
</div>
<div class="import-section" id="importSection" style="display: none;">
<h4 style="margin-bottom: 10px; color: #495057;">📂 Import Metadata File</h4>
<p style="font-size: 13px; color: #6c757d; margin-bottom: 10px;">
Upload a CSV, Excel, or JSON file with metadata (columns: filename, title, subject, keywords)
</p>
<input type="file" id="importFileInput" accept=".csv,.xlsx,.xls,.json" style="display: none;">
<button class="btn-import" onclick="document.getElementById('importFileInput').click()">
📤 Choose Import File
</button>
<div id="importStats" class="import-stats" style="display: none;"></div>
</div>
<div class="template-section" id="templateSection">
<h4 style="margin-bottom: 10px; color: #495057;">📋 Metadata Templates</h4>
<p style="font-size: 13px; color: #6c757d; margin-bottom: 10px;">
Use templates with variables like {filename}, {date}, {user} for quick metadata generation
</p>
<div class="template-controls">
<select id="templateSelect" class="template-select">
<option value="">Select a template...</option>
</select>
<button class="btn-template" onclick="applyTemplate()" id="applyTemplateBtn" disabled>
✓ Apply Template
</button>
<button class="btn-template" onclick="showCreateTemplateModal()">
Create New
</button>
<button class="btn-template" onclick="manageTemplates()">
⚙️ Manage
</button>
</div>
<div id="templatePreview" class="template-preview"></div>
</div>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<h3>Drop files here or click to browse</h3>
<p style="color: #6c757d; margin-top: 10px;">Supported: PDF, JPG, PNG, DOCX, XLSX, PPTX, MP4, MOV</p>
<p style="color: #667eea; margin-top: 5px; font-weight: 600;">Multiple files supported!</p>
<input type="file" id="fileInput" accept=".pdf,.jpg,.jpeg,.png,.gif,.docx,.xlsx,.pptx,.mp4,.mov,.avi" multiple>
</div>
<div class="output-dir-section">
<label for="outputDir">Save to folder:</label>
<input type="text" id="outputDir" placeholder="Leave empty to save in original location" />
</div>
<div class="output-dir-hint">
💡 Tip: Paste the folder path where you want to save processed files (e.g., /Users/YourName/Desktop/ProcessedFiles)
</div>
</div>
<div class="progress-bar" id="progressBar">
<div class="progress-fill" id="progressFill">0%</div>
</div>
<div class="spinner" id="spinner"></div>
<div class="alert alert-error" id="errorAlert"></div>
<div class="alert alert-success" id="successAlert"></div>
<div class="alert alert-info" id="infoAlert"></div>
<div class="file-list" id="fileList">
<div class="batch-toolbar" id="batchToolbar" style="display: none;">
<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>
</div>
<div class="actions" id="actions" style="display: none;">
<button class="btn" id="updateAllBtn" onclick="updateAllFiles()">
Update Selected Files
</button>
<button class="btn" onclick="resetForm()">
Process More Files
</button>
</div>
</div>
<div class="footer">
Oliver Metadata Tool v3.0 | Multiple metadata sources | Excel • AI • Manual • Import • Templates
</div>
</div>
<!-- Create Template Modal -->
<div id="createTemplateModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Create Metadata Template</h3>
<span class="close-modal" onclick="closeCreateTemplateModal()">&times;</span>
</div>
<div class="form-group">
<label for="templateName">Template Name *</label>
<input type="text" id="templateName" placeholder="e.g., Product Brochure Template" required>
</div>
<div class="form-group">
<label for="templateDescription">Description</label>
<input type="text" id="templateDescription" placeholder="Optional description of this template">
</div>
<div class="form-group">
<label for="templateTitle">Title Template *</label>
<input type="text" id="templateTitle" placeholder="e.g., {filename} - Product Guide">
<div class="variable-hint">
Available variables: {filename}, {date}, {datetime}, {user}, {year}, {month}, {day}
</div>
</div>
<div class="form-group">
<label for="templateSubject">Subject Template *</label>
<textarea id="templateSubject" placeholder="e.g., Product information guide for {filename}"></textarea>
</div>
<div class="form-group">
<label for="templateKeywords">Keywords Template *</label>
<input type="text" id="templateKeywords" placeholder="e.g., product, guide, {year}">
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn" onclick="saveNewTemplate()">💾 Save Template</button>
<button class="btn" style="background: #6c757d;" onclick="closeCreateTemplateModal()">Cancel</button>
</div>
</div>
</div>
<script>
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');
if (source === 'import') {
importSection.style.display = 'block';
} else {
importSection.style.display = 'none';
}
}
async function handleImportFile(e) {
const file = e.target.files[0];
if (!file) return;
hideAlerts();
showInfo(`Importing metadata from ${file.name}...`);
const formData = new FormData();
formData.append('import_file', file);
try {
const response = await fetch('/import-metadata', {
method: 'POST',
body: formData
});
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');
showSuccess(`${data.message}`);
} catch (error) {
showError(`Import failed: ${error.message}`);
}
}
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 import source
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.`);
} 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('/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
hideSpinner();
hideProgress();
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';
} catch (error) {
hideSpinner();
hideProgress();
showError('Error processing files: ' + error.message);
}
}
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>
<button class="btn-reset" onclick="resetMetadata(${index})">
🔄 Reset
</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`;
}
}
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 || '';
// Don't show internal metadata fields in the form
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}" />
<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}">${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}" />
<span class="char-count" id="keywords-count-${index}">0/500</span>
</div>
`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function initCharCounters(index) {
const fields = ['title', 'subject', 'keywords'];
const limits = { title: 200, subject: 300, keywords: 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();
try {
const response = await fetch('/update-manual', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
file_index: index,
title: title,
subject: subject,
keywords: keywords
})
});
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 };
// 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;
}
}
function resetMetadata(index) {
const file = currentFiles[index];
if (!file || file.error) return;
// Reset to original suggested metadata
const original = file.suggested_metadata;
document.getElementById(`title-${index}`).value = original?.title || '';
document.getElementById(`subject-${index}`).value = original?.subject || '';
document.getElementById(`keywords-${index}`).value = original?.keywords || '';
// Update character counters
initCharCounters(index);
// Remove any success messages
const fileItem = document.getElementById(`file-${index}`);
if (fileItem) {
const successMsg = fileItem.querySelector('.save-success');
if (successMsg) successMsg.remove();
fileItem.style.borderLeftColor = '#667eea';
}
}
async function updateAllFiles() {
if (selectedFiles.size === 0) {
showError('Please select at least one file to update');
return;
}
const outputDir = document.getElementById('outputDir').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('/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filepath: file.filepath,
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';
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success';
successDiv.style.display = 'block';
successDiv.textContent = data.verified ?
`✅ Updated and verified! Saved to: ${data.output_path}` :
`✅ Updated! Saved to: ${data.output_path}`;
fileItem.appendChild(successDiv);
}
}
} catch (error) {
errorCount++;
console.error('Error updating file:', error);
}
}
hideProgress();
updateBtn.disabled = false;
if (successCount > 0 && errorCount === 0) {
showSuccess(`✅ All ${successCount} file(s) updated successfully!`);
} else if (successCount > 0 && errorCount > 0) {
showInfo(`⚠️ Updated ${successCount} file(s), ${errorCount} failed.`);
} else {
showError(`❌ Failed to update files. Check individual file errors above.`);
}
}
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';
}
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';
}
// Template Management Functions
async function loadTemplateList() {
try {
const response = await fetch('/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('/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('/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('/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(`/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(`/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';
}
});
</script>
</body>
</html>