Phase 2.1 & 2.2: Manual metadata editing and multiple sources

Implemented manual metadata editing UI:
- Added editable input fields for title (200 chars), subject (300 chars), keywords (500 chars)
- Character counters with warning/danger indicators at 90%/100%
- Real-time validation with visual feedback
- Save and Reset buttons for each file
- Individual file metadata updates via /update-manual endpoint

Implemented multiple metadata sources:
- Added metadata source selector dropdown (Excel, Manual, AI, Import)
- Modified /upload endpoint to handle different metadata sources
- Excel lookup: existing functionality (fastest)
- Manual entry: empty fields for user input
- AI generation: placeholder for Phase 2.3
- Import: placeholder for Phase 2.4

Technical improvements:
- Session-based metadata storage for persistence
- Graceful success/error feedback with visual indicators
- Sanitized metadata input with length limits
- Backup creation before updates

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
SamoilenkoVadym 2026-01-25 15:34:05 +00:00
parent ae19179752
commit fa2b4da2f7
2 changed files with 459 additions and 17 deletions

View file

@ -227,10 +227,148 @@
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;
}
@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>
@ -243,6 +381,17 @@
<div class="content">
<div class="upload-section">
<div class="metadata-source-selector">
<label for="metadataSource">Metadata Source:</label>
<select id="metadataSource" class="source-select">
<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="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<h3>Drop files here or click to browse</h3>
@ -341,9 +490,11 @@
actions.style.display = 'none';
currentFiles = [];
showInfo(`Processing ${files.length} file(s)...`);
const metadataSource = document.getElementById('metadataSource').value;
showInfo(`Processing ${files.length} file(s) with ${metadataSource} source...`);
const formData = new FormData();
formData.append('metadata_source', metadataSource);
for (let file of files) {
formData.append('files', file);
}
@ -412,13 +563,25 @@
</div>
<div class="metadata-box">
<h4>✨ AI-Generated Metadata</h4>
${displayMetadata(file.suggested_metadata)}
<h4>✏️ Edit Metadata</h4>
${displayEditableMetadata(file.suggested_metadata, index)}
</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);
});
}
@ -439,6 +602,186 @@
return html;
}
function displayEditableMetadata(metadata, index) {
const title = metadata?.title || '';
const subject = metadata?.subject || '';
const keywords = metadata?.keywords || '';
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;

View file

@ -96,12 +96,15 @@ def upload_file():
if not files or files[0].filename == '':
return jsonify({'error': 'No files selected'}), 400
# Get metadata source choice (excel, manual, ai, import)
metadata_source = request.form.get('metadata_source', 'excel')
results = []
session_id = str(len(sessions) + 1)
sessions[session_id] = {'files': []}
sessions[session_id] = {'files': [], 'metadata_source': metadata_source}
# Get metadata lookup
lookup = get_metadata_lookup()
# Get metadata lookup (only if using Excel source)
lookup = get_metadata_lookup() if metadata_source == 'excel' else None
for file in files:
try:
@ -132,21 +135,52 @@ def upload_file():
# Read current metadata from file
old_metadata = extractor.read_metadata(filepath)
# Lookup metadata from Excel by filename
excel_data = lookup.lookup_by_filename(filename)
# Generate metadata based on chosen source
excel_found = False
new_metadata = {'title': '', 'subject': '', 'keywords': ''}
if excel_data:
# Use Excel data for metadata
if metadata_source == 'excel' and lookup:
# Lookup metadata from Excel by filename
excel_data = lookup.lookup_by_filename(filename)
if excel_data:
new_metadata = {
'title': excel_data.get('title', ''),
'subject': excel_data.get('description', ''),
'keywords': ''
}
excel_found = True
else:
# No Excel data found - use filename as fallback
new_metadata = {
'title': Path(filename).stem,
'subject': f'No metadata found in Excel for {filename}',
'keywords': ''
}
elif metadata_source == 'manual':
# Return empty metadata for user to fill manually
new_metadata = {
'title': excel_data.get('title', ''),
'subject': excel_data.get('description', ''), # External Description/Alt Text
'keywords': '' # Not used from Excel
'title': Path(filename).stem, # Suggest filename
'subject': '',
'keywords': ''
}
else:
# No Excel data found - use filename as fallback
elif metadata_source == 'ai':
# AI generation - will be implemented in Phase 2.3
# For now, return placeholder
new_metadata = {
'title': Path(filename).stem,
'subject': f'No metadata found in Excel for {filename}',
'subject': 'AI generation not yet implemented',
'keywords': ''
}
elif metadata_source == 'import':
# Import from file - will be implemented in Phase 2.4
# For now, return placeholder
new_metadata = {
'title': Path(filename).stem,
'subject': 'Import feature not yet implemented',
'keywords': ''
}
@ -157,7 +191,8 @@ def upload_file():
'current_metadata': old_metadata,
'suggested_metadata': new_metadata,
'filepath': filepath,
'excel_found': excel_data is not None
'metadata_source': metadata_source,
'excel_found': excel_found
}
results.append(file_info)
@ -240,6 +275,70 @@ def update_metadata():
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/update-manual', methods=['POST'])
def update_manual_metadata():
"""Update file with manually entered metadata."""
data = request.json
session_id = data.get('session_id')
file_index = data.get('file_index')
# Validate and sanitize metadata
custom_metadata = {
'title': data.get('title', '').strip()[:200],
'subject': data.get('subject', '').strip()[:300],
'keywords': data.get('keywords', '').strip()[:500]
}
# Validate session
if not session_id or session_id not in sessions:
return jsonify({'error': 'Invalid or expired session'}), 400
# Validate file index
if file_index is None or file_index >= len(sessions[session_id]['files']):
return jsonify({'error': 'Invalid file index'}), 400
try:
# Get file info from session
file_info = sessions[session_id]['files'][file_index]
filepath = file_info.get('filepath')
if not filepath or not os.path.exists(filepath):
return jsonify({'error': 'File not found'}), 404
# Detect file type
file_type = FileDetector.detect_file_type(filepath)
if file_type == FileType.UNSUPPORTED:
return jsonify({'error': 'Unsupported file type'}), 400
# Get updater for this file type
updater = updaters.get(file_type)
if not updater:
return jsonify({'error': 'No updater available for this file type'}), 400
# Update metadata
success = updater.update_metadata(filepath, custom_metadata, backup=True)
if not success:
return jsonify({'error': 'Failed to update metadata'}), 500
# Update session with new metadata
sessions[session_id]['files'][file_index]['suggested_metadata'] = custom_metadata
# Verify update
verified = updater.verify_metadata(filepath, custom_metadata)
return jsonify({
'status': 'success',
'message': 'Metadata updated successfully',
'verified': verified,
'metadata': custom_metadata
})
except Exception as e:
return jsonify({'error': f'Error updating metadata: {str(e)}'}), 500
@app.route('/download/<filename>')
def download_file(filename):
"""Download processed file."""