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:
parent
ae19179752
commit
fa2b4da2f7
2 changed files with 459 additions and 17 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
127
web_app.py
127
web_app.py
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue