Created comprehensive metadata_importer.py module:
- CSV import with multiple encoding support (UTF-8, Latin1, ISO-8859-1, CP1252)
- Excel import (.xlsx, .xls) with sheet selection
- JSON import (object and array formats)
- Intelligent column detection for filename, title, subject, keywords
- Fuzzy column matching (case-insensitive, multiple aliases)
- Metadata normalization to standard format
- Import validation with statistics
- File lookup by filename stem (case-insensitive)
Web interface enhancements:
- /import-metadata endpoint for file uploads
- Import section UI (appears when Import source selected)
- Real-time import statistics display (records, title/subject/keywords counts)
- Import session management with unique session IDs
- Visual feedback (active state, success/error messages)
- Validation: requires import file before processing with import source
Import workflow:
1. User selects "Import from File" metadata source
2. Import section appears with file chooser
3. User uploads CSV/Excel/JSON with metadata
4. System validates and shows statistics
5. User uploads files to process
6. System matches files to imported metadata by filename
Supported import formats:
- CSV: filename, title, subject/description, keywords columns
- Excel: Any sheet with filename and metadata columns
- JSON: {filename: {metadata}} or [{filename, metadata}] formats
Technical features:
- Pandas DataFrame parsing for CSV/Excel
- Flexible column name detection (10+ aliases per field)
- NaN/null value handling
- List/array keyword support
- Unicode filename support
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1064 lines
36 KiB
HTML
1064 lines
36 KiB
HTML
<!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;
|
||
}
|
||
|
||
.file-item {
|
||
background: #f8f9fa;
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
border-left: 4px solid #667eea;
|
||
}
|
||
|
||
.file-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
@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="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>
|
||
|
||
<div class="actions" id="actions" style="display: none;">
|
||
<button class="btn" id="updateAllBtn" onclick="updateAllFiles()">
|
||
Update All 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
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentFiles = [];
|
||
let sessionId = null;
|
||
let importSessionId = null;
|
||
|
||
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) {
|
||
fileList.innerHTML = '';
|
||
fileList.style.display = 'block';
|
||
|
||
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-name">📄 ${file.filename}</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);
|
||
});
|
||
}
|
||
|
||
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 (currentFiles.length === 0) return;
|
||
|
||
const outputDir = document.getElementById('outputDir').value.trim();
|
||
const updateBtn = document.getElementById('updateAllBtn');
|
||
updateBtn.disabled = true;
|
||
hideAlerts();
|
||
showProgress(0);
|
||
|
||
showInfo(`Updating ${currentFiles.length} file(s)...`);
|
||
|
||
let successCount = 0;
|
||
let errorCount = 0;
|
||
|
||
for (let i = 0; i < currentFiles.length; i++) {
|
||
const file = currentFiles[i];
|
||
|
||
if (file.error) {
|
||
errorCount++;
|
||
continue;
|
||
}
|
||
|
||
const progress = ((i + 1) / currentFiles.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.`);
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
fileInput.value = '';
|
||
fileList.style.display = 'none';
|
||
actions.style.display = 'none';
|
||
hideAlerts();
|
||
hideProgress();
|
||
currentFiles = [];
|
||
sessionId = null;
|
||
}
|
||
|
||
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';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|