solventum-image-metadata/templates/index.html
SamoilenkoVadym 0b136bac6c Fix session expiration issues and improve error handling
Fixed two critical session-related issues:

1. Session expiration during file processing
   - Added proper error message when session expires mid-process
   - Prevents silent failure and missing download buttons
   - Shows clear "Session expired" message to user

2. Session lifetime and cookie configuration
   - Increased session lifetime from 24 hours to 7 days (configurable)
   - Made sessions permanent (session.permanent = True) in all login flows
   - Improved cookie security settings with environment variable control
   - Added SESSION_COOKIE_SECURE and SESSION_LIFETIME_DAYS env vars
   - Fixed cookie configuration for HTTPS reverse proxy

Changes:
- web_app.py: Enhanced session configuration and made sessions permanent
- templates/index.html: Better error handling for session expiration

This fixes:
- "Unexpected token '<'" errors appearing intermittently
- Missing download buttons after metadata update
- Sessions expiring too quickly requiring frequent re-login

Environment variables (optional):
- SESSION_COOKIE_SECURE=true (default for HTTPS)
- SESSION_LIFETIME_DAYS=7 (default 7 days)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-02-09 11:46:51 +00:00

2555 lines
94 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>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ========== CSS VARIABLES ========== */
:root {
/* Main colors */
--primary-gold: #FFC407;
--primary-gold-dark: #e6b007;
--primary-gold-light: #ffcf33;
/* Dark colors */
--dark-primary: #2c2c2c;
--dark-secondary: #1a1a1a;
/* Light colors */
--white: #ffffff;
--light-bg: #fafafa;
--light-bg-gradient: #f8fafc;
/* Text colors */
--text-primary: #1f2937;
--text-secondary: #374151;
--text-muted: #6b7280;
/* Status colors */
--success-green: #4ade80;
--error-red: #ef4444;
/* Opacity */
--overlay-light: rgba(255, 255, 255, 0.95);
--overlay-dark: rgba(0, 0, 0, 0.5);
--border-light: rgba(255, 255, 255, 0.2);
--border-subtle: rgba(0, 0, 0, 0.05);
/* Shadows */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-md: 0 10px 25px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.1);
/* Radius */
--radius-sm: 4px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-xl: 20px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 20px;
--spacing-2xl: 25px;
/* Fonts */
--font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-family);
background: linear-gradient(135deg, var(--dark-primary) 0%, var(--dark-secondary) 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: var(--overlay-light);
backdrop-filter: blur(20px);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
overflow: hidden;
border: 1px solid var(--border-light);
}
.header {
background: linear-gradient(135deg, var(--primary-gold) 0%, var(--primary-gold-dark) 100%);
color: var(--dark-secondary);
padding: 30px;
text-align: center;
position: relative;
}
.header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%);
animation: shimmer 3s infinite;
pointer-events: none;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
font-weight: 600;
position: relative;
z-index: 1;
}
.header p {
opacity: 0.9;
font-size: 14px;
position: relative;
z-index: 1;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.content {
padding: 40px;
background: linear-gradient(180deg, var(--light-bg) 0%, var(--light-bg-gradient) 100%);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.upload-section {
background: var(--white);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 30px;
box-shadow: var(--shadow-sm);
}
.upload-area {
border: 3px dashed var(--primary-gold);
border-radius: var(--radius-md);
padding: 60px 20px;
text-align: center;
cursor: pointer;
transition: all var(--transition-normal);
background: var(--light-bg);
margin-bottom: 20px;
}
.upload-area:hover {
background: #fffbf0;
border-color: var(--primary-gold-dark);
transform: translateY(-2px);
}
.upload-area.dragover {
background: #fff9e6;
transform: scale(1.02);
border-color: var(--primary-gold-dark);
}
#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: var(--radius-sm);
font-size: 14px;
font-family: var(--font-family);
transition: border-color var(--transition-fast);
}
#outputDir:focus {
outline: none;
border-color: var(--primary-gold);
}
.output-dir-hint {
font-size: 12px;
color: #6c757d;
margin-top: 5px;
}
.btn {
background: linear-gradient(135deg, var(--primary-gold), var(--primary-gold-dark));
color: var(--dark-secondary);
border: none;
padding: 12px 30px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 16px;
font-weight: 600;
font-family: var(--font-family);
transition: all var(--transition-fast);
margin: 5px;
}
.btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 196, 7, 0.4);
}
.btn:active:not(:disabled) {
transform: translateY(0);
}
.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, var(--primary-gold), var(--primary-gold-dark));
transition: width var(--transition-normal);
display: flex;
align-items: center;
justify-content: center;
color: var(--dark-secondary);
font-weight: 600;
font-size: 14px;
}
.file-list {
margin-top: 30px;
display: none;
}
.batch-toolbar {
background: var(--white);
border-radius: var(--radius-md);
padding: 15px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
box-shadow: var(--shadow-sm);
}
.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: var(--white);
border-radius: var(--radius-md);
padding: 20px;
margin-bottom: 20px;
border-left: 4px solid var(--primary-gold);
box-shadow: var(--shadow-sm);
transition: all var(--transition-fast);
}
.file-item:hover {
box-shadow: var(--shadow-md);
transform: translateX(2px);
}
.file-item.selected {
background: #fffbf0;
border-left-color: var(--success-green);
}
.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: linear-gradient(135deg, var(--primary-gold), var(--primary-gold-dark));
color: var(--dark-secondary);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.metadata-comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.metadata-box {
background: var(--light-bg);
border-radius: var(--radius-sm);
padding: 15px;
border: 1px solid var(--border-subtle);
}
.metadata-box h4 {
color: var(--primary-gold-dark);
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
}
.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 var(--primary-gold);
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 var(--primary-gold);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: var(--font-family);
cursor: pointer;
background: var(--white);
transition: border-color var(--transition-fast);
}
.source-select:focus {
outline: none;
border-color: var(--primary-gold-dark);
}
.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: var(--primary-gold);
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.1);
}
.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: var(--success-green);
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: var(--primary-gold);
background: #fffbf0;
}
.template-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.template-select {
flex: 1;
min-width: 200px;
padding: 8px;
border: 2px solid var(--primary-gold);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: var(--font-family);
cursor: pointer;
transition: border-color var(--transition-fast);
}
.template-select:focus {
outline: none;
border-color: var(--primary-gold-dark);
}
.btn-template {
background: linear-gradient(135deg, var(--primary-gold), var(--primary-gold-dark));
color: var(--dark-secondary);
border: none;
padding: 8px 16px;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 13px;
font-weight: 600;
font-family: var(--font-family);
transition: all var(--transition-fast);
}
.btn-template:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 196, 7, 0.3);
}
.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: var(--primary-gold-dark);
}
/* 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: var(--primary-gold-dark);
margin: 0;
font-weight: 600;
}
.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: var(--radius-sm);
font-size: 13px;
font-family: var(--font-family);
transition: border-color var(--transition-fast);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-gold);
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.1);
}
.form-group textarea {
min-height: 60px;
resize: vertical;
}
.form-group small {
font-size: 11px;
color: #6c757d;
margin-top: 3px;
display: block;
}
.variable-hint {
background: #fffbf0;
padding: 8px;
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--primary-gold-dark);
margin-top: 5px;
border: 1px solid rgba(255, 196, 7, 0.2);
}
@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="import" selected>📂 Import from File (CSV/Excel/JSON)</option>
<option value="manual">✏️ Manual Entry</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: block;">
<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 (.xlsx, .xls), or JSON file with metadata. You'll configure column mapping after upload.
</p>
<input type="file" id="importFileInput" accept=".csv,.xlsx,.xls,.json" style="display: none;">
<button class="btn-import" onclick="document.getElementById('importFileInput').click()">
📤 Choose File to Import
</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>
{% if not docker_mode %}
<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 or paste folder path here" style="flex: 1;" />
</div>
<div class="output-dir-hint">
💡 <strong>How to copy folder path:</strong><br>
<span style="display: inline-block; margin-top: 5px;">
<strong>Mac:</strong> Right-click folder in Finder → hold Option key → click "Copy ... as Pathname"<br>
<strong>Windows:</strong> Shift + Right-click folder → "Copy as path" (remove quotes after pasting)
</span>
</div>
{% else %}
<div class="output-dir-hint" style="background: #e3f2fd; border-left: 4px solid #2196f3; padding: 12px; margin: 10px 0;">
💡 <strong>Docker Mode:</strong> Files will be updated and available for download from your browser after processing.
</div>
{% endif %}
</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.1 | Multiple metadata sources | Import • AI • Manual • Templates
</div>
</div>
<!-- Import Mapping Modal -->
<div id="importMappingModal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h3>Configure Import Mapping</h3>
<span class="close-modal" onclick="closeImportMappingModal()">&times;</span>
</div>
<div id="importMappingContent">
<!-- Will be populated dynamically -->
</div>
</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>
// API base URL for reverse proxy configuration
const API_BASE = '/solventum-image-metadata';
let currentFiles = [];
let sessionId = null;
// Helper function to handle fetch with authentication check
async function fetchWithAuth(url, options = {}) {
try {
// Add X-Requested-With header to identify AJAX requests
// Use Headers object to properly handle both JSON and FormData requests
if (!options.headers) {
options.headers = {};
}
// Convert to Headers object if it's a plain object
if (!(options.headers instanceof Headers)) {
const headers = new Headers();
for (const [key, value] of Object.entries(options.headers)) {
headers.append(key, value);
}
headers.append('X-Requested-With', 'XMLHttpRequest');
options.headers = headers;
} else {
options.headers.append('X-Requested-With', 'XMLHttpRequest');
}
const response = await fetch(url, options);
// Check for authentication error
if (response.status === 401) {
const data = await response.json().catch(() => ({}));
if (data.redirect) {
// Session expired, redirect to login
window.location.href = data.redirect;
return null;
}
}
return response;
} catch (error) {
throw error;
}
}
let importSessionId = null;
let selectedFiles = new Set();
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const spinner = document.getElementById('spinner');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const fileList = document.getElementById('fileList');
const actions = document.getElementById('actions');
const errorAlert = document.getElementById('errorAlert');
const successAlert = document.getElementById('successAlert');
const infoAlert = document.getElementById('infoAlert');
// Click to upload
uploadArea.addEventListener('click', () => fileInput.click());
// File selection
fileInput.addEventListener('change', handleFileSelect);
// Drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFiles(files);
}
});
// Import file input
const importFileInput = document.getElementById('importFileInput');
importFileInput.addEventListener('change', handleImportFile);
function handleSourceChange() {
const source = document.getElementById('metadataSource').value;
const importSection = document.getElementById('importSection');
// Show/hide import section
if (source === 'import') {
importSection.style.display = 'block';
} else {
importSection.style.display = 'none';
}
}
let currentImportData = null;
async function handleImportFile(e) {
const file = e.target.files[0];
if (!file) return;
hideAlerts();
showInfo(`Uploading import file: ${file.name}...`);
const formData = new FormData();
formData.append('import_file', file);
try {
const response = await fetchWithAuth(`${API_BASE}/import-metadata`, {
method: 'POST',
body: formData
});
if (!response) return; // Redirected to login
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
// Store import data and show mapping modal
currentImportData = data;
showImportMappingModal(data);
} catch (error) {
showError(`Import upload failed: ${error.message}`);
}
}
function showImportMappingModal(data) {
const modal = document.getElementById('importMappingModal');
const content = document.getElementById('importMappingContent');
// Build sheet selector for Excel files
let sheetsHTML = '';
if (data.sheets && data.sheets.length > 0) {
sheetsHTML = '<div class="form-group">';
sheetsHTML += '<label for="importSheetSelect">Select Sheet *</label>';
sheetsHTML += '<select id="importSheetSelect" class="form-control" onchange="updateImportSheetPreview()">';
data.sheets.forEach(sheet => {
sheetsHTML += `<option value="${sheet}">${sheet}</option>`;
});
sheetsHTML += '</select></div>';
}
content.innerHTML = `
<div class="form-group">
<label>File: <strong>${data.filename}</strong></label>
</div>
${sheetsHTML}
<div class="form-group">
<label>Column Mappings *</label>
<div style="background: #f8f9ff; padding: 15px; border-radius: 8px; margin-top: 10px;">
<div class="form-group">
<label for="importFilenameColumn">Filename Column *</label>
<select id="importFilenameColumn" class="form-control">
<option value="">-- Select Column --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
<small style="color: #6b7280;">Column containing filenames (with or without extension)</small>
</div>
<div class="form-group">
<label for="importTitleColumn">Title Column</label>
<select id="importTitleColumn" class="form-control">
<option value="">-- Select Column (Optional) --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="importSubjectColumn">Subject/Description Column</label>
<select id="importSubjectColumn" class="form-control">
<option value="">-- Select Column (Optional) --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label for="importKeywordsColumn">Keywords Column</label>
<select id="importKeywordsColumn" class="form-control">
<option value="">-- Select Column (Optional) --</option>
${data.columns.map(col => `<option value="${col}">${col}</option>`).join('')}
</select>
</div>
</div>
</div>
<div class="form-group">
<label>Preview (First 3 rows)</label>
<div id="importPreviewTable" style="overflow-x: auto; margin-top: 10px;">
${buildImportPreviewTable(data)}
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button onclick="confirmImportMapping()" class="btn btn-primary" style="flex: 1;">
✅ Confirm Mapping
</button>
<button onclick="closeImportMappingModal()" class="btn btn-secondary" style="flex: 1;">
Cancel
</button>
</div>
`;
// Auto-select likely columns
autoSelectImportColumns(data.columns);
modal.style.display = 'flex';
}
async function updateImportSheetPreview() {
const sheetName = document.getElementById('importSheetSelect').value;
if (!currentImportData || !sheetName) return;
try {
showInfo('Loading sheet preview...');
const response = await fetchWithAuth(`${API_BASE}/preview-excel-sheet`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
excel_session_id: currentImportData.import_session_id,
sheet_name: sheetName
})
});
if (!response) return; // Redirected to login
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
// Update column dropdowns
const columns = data.columns;
['importFilenameColumn', 'importTitleColumn', 'importSubjectColumn', 'importKeywordsColumn'].forEach(id => {
const select = document.getElementById(id);
const currentValue = select.value;
select.innerHTML = id === 'importFilenameColumn'
? '<option value="">-- Select Column --</option>'
: '<option value="">-- Select Column (Optional) --</option>';
columns.forEach(col => {
select.innerHTML += `<option value="${col}">${col}</option>`;
});
// Try to restore selection
if (columns.includes(currentValue)) {
select.value = currentValue;
}
});
// Update preview table
document.getElementById('importPreviewTable').innerHTML = buildImportPreviewTable(data);
// Auto-select columns again
autoSelectImportColumns(columns);
hideAlerts();
} catch (error) {
showError(`Failed to load sheet preview: ${error.message}`);
}
}
function autoSelectImportColumns(columns) {
// Try to auto-detect filename column
const filenameCandidates = ['filename', 'file name', 'file', 'name', 'path'];
for (const col of columns) {
if (filenameCandidates.some(c => col.toLowerCase().includes(c))) {
document.getElementById('importFilenameColumn').value = col;
break;
}
}
// Try to auto-detect title column
const titleCandidates = ['title', 'heading', 'name'];
for (const col of columns) {
if (titleCandidates.some(c => col.toLowerCase() === c)) {
document.getElementById('importTitleColumn').value = col;
break;
}
}
// Try to auto-detect subject column
const subjectCandidates = ['description', 'desc', 'subject', 'summary'];
for (const col of columns) {
if (subjectCandidates.some(c => col.toLowerCase().includes(c))) {
document.getElementById('importSubjectColumn').value = col;
break;
}
}
// Try to auto-detect keywords column
const keywordsCandidates = ['keywords', 'keyword', 'tags', 'tag'];
for (const col of columns) {
if (keywordsCandidates.some(c => col.toLowerCase().includes(c))) {
document.getElementById('importKeywordsColumn').value = col;
break;
}
}
}
function buildImportPreviewTable(data) {
if (!data || !data.sample_data || data.sample_data.length === 0) {
return '<p style="color: #6b7280;">No preview data available</p>';
}
let html = '<table style="width: 100%; border-collapse: collapse; font-size: 13px;">';
// Header
html += '<thead><tr style="background: var(--primary-gold); color: var(--dark-secondary);">';
data.columns.forEach(col => {
html += `<th style="padding: 8px; border: 1px solid #dee2e6; font-weight: 600;">${col}</th>`;
});
html += '</tr></thead>';
// Rows
html += '<tbody>';
data.sample_data.slice(0, 3).forEach((row, idx) => {
html += `<tr style="background: ${idx % 2 === 0 ? '#fff' : '#f8f9ff'};">`;
data.columns.forEach(col => {
const value = row[col] || '';
html += `<td style="padding: 8px; border: 1px solid #dee2e6;">${value}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
async function confirmImportMapping() {
const filenameColumn = document.getElementById('importFilenameColumn').value;
const titleColumn = document.getElementById('importTitleColumn').value;
const subjectColumn = document.getElementById('importSubjectColumn').value;
const keywordsColumn = document.getElementById('importKeywordsColumn').value;
if (!filenameColumn) {
showError('Please select a filename column');
return;
}
try {
showInfo('Configuring import mapping...');
const response = await fetchWithAuth(`${API_BASE}/configure-import-mapping`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
import_session_id: currentImportData.import_session_id,
column_mapping: {
filename: filenameColumn,
title: titleColumn,
subject: subjectColumn,
keywords: keywordsColumn
}
})
});
if (!response) return; // Redirected to login
const data = await response.json();
if (data.error) {
showError(data.error);
return;
}
// Store import session ID
importSessionId = data.import_session_id;
// Display stats
const importStats = document.getElementById('importStats');
const stats = data.stats;
importStats.innerHTML = `
${data.message}<br>
<small>
Title: ${stats.with_title}/${stats.total_records}
Subject: ${stats.with_subject}/${stats.total_records}
Keywords: ${stats.with_keywords}/${stats.total_records}
</small>
`;
importStats.style.display = 'block';
// Mark import section as active
document.getElementById('importSection').classList.add('active');
closeImportMappingModal();
showSuccess(`${data.message}`);
} catch (error) {
showError(`Import configuration failed: ${error.message}`);
}
}
function closeImportMappingModal() {
const modal = document.getElementById('importMappingModal');
modal.style.display = 'none';
currentImportData = null;
}
function handleFileSelect(e) {
const files = e.target.files;
if (files.length > 0) {
handleFiles(files);
}
}
async function handleFiles(files) {
hideAlerts();
showSpinner();
showProgress(0);
fileList.style.display = 'none';
actions.style.display = 'none';
currentFiles = [];
const metadataSource = document.getElementById('metadataSource').value;
// Validate metadata sources
if (metadataSource === 'import' && !importSessionId) {
showError('Please import a metadata file first using the "Choose Import File" button');
hideSpinner();
return;
}
// Show specific message for AI processing
if (metadataSource === 'ai') {
showInfo(`🤖 Generating AI metadata for ${files.length} file(s)... This may take 10-30 seconds per file.`);
// Start animated progress for AI
startProgressAnimation();
} else {
showInfo(`Processing ${files.length} file(s) with ${metadataSource} source...`);
}
const formData = new FormData();
formData.append('metadata_source', metadataSource);
if (importSessionId) {
formData.append('import_session_id', importSessionId);
}
for (let file of files) {
formData.append('files', file);
}
try {
const response = await fetchWithAuth(`${API_BASE}/upload`, {
method: 'POST',
body: formData
});
hideSpinner();
hideProgress();
stopProgressAnimation();
if (!response) return; // Redirected to login
const data = await response.json();
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();
stopProgressAnimation();
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>
</div>
`;
fileList.appendChild(fileItem);
// Initialize character counters
initCharCounters(index);
// Select by default
selectedFiles.add(index);
fileItem.classList.add('selected');
});
updateSelectionCount();
}
function toggleFileSelection(index) {
const checkbox = document.getElementById(`checkbox-${index}`);
const fileItem = document.getElementById(`file-${index}`);
if (checkbox.checked) {
selectedFiles.add(index);
fileItem.classList.add('selected');
} else {
selectedFiles.delete(index);
fileItem.classList.remove('selected');
}
updateSelectionCount();
}
function selectAllFiles() {
selectedFiles.clear();
currentFiles.forEach((file, index) => {
if (!file.error) {
selectedFiles.add(index);
const checkbox = document.getElementById(`checkbox-${index}`);
const fileItem = document.getElementById(`file-${index}`);
if (checkbox) checkbox.checked = true;
if (fileItem) fileItem.classList.add('selected');
}
});
updateSelectionCount();
}
function deselectAllFiles() {
selectedFiles.clear();
currentFiles.forEach((file, index) => {
if (!file.error) {
const checkbox = document.getElementById(`checkbox-${index}`);
const fileItem = document.getElementById(`file-${index}`);
if (checkbox) checkbox.checked = false;
if (fileItem) fileItem.classList.remove('selected');
}
});
updateSelectionCount();
}
function updateSelectionCount() {
const countElement = document.getElementById('selectionCount');
if (countElement) {
countElement.textContent = `${selectedFiles.size} selected`;
}
// Update download button text if it exists
const downloadBtn = document.getElementById('download-selected-btn');
if (downloadBtn) {
downloadBtn.innerHTML = `📦 Download Selected Files (${selectedFiles.size}) as ZIP`;
}
}
function displayMetadata(metadata) {
if (!metadata || Object.keys(metadata).length === 0) {
return '<p style="color: #6c757d; font-size: 13px;">(empty)</p>';
}
let html = '';
for (const [key, value] of Object.entries(metadata)) {
html += `
<div class="metadata-item">
<span class="metadata-label">${key}:</span>
<span class="metadata-value">${value || '(empty)'}</span>
</div>
`;
}
return html;
}
function displayEditableMetadata(metadata, index) {
// Filter out internal fields (starting with _)
const title = metadata?.title || '';
const subject = metadata?.subject || '';
const keywords = metadata?.keywords || '';
const author = metadata?.author || metadata?.creator || '';
const copyright = metadata?.copyright || '';
const comments = metadata?.comments || '';
return `
<div class="metadata-field">
<label for="title-${index}">Title:</label>
<input type="text"
id="title-${index}"
class="editable-field"
value="${escapeHtml(title)}"
maxlength="200"
data-field="title"
data-index="${index}"
placeholder="e.g., Product Brochure 2026" />
<span class="char-count" id="title-count-${index}">0/200</span>
</div>
<div class="metadata-field">
<label for="subject-${index}">Description/Subject:</label>
<textarea id="subject-${index}"
class="editable-field"
maxlength="300"
data-field="subject"
data-index="${index}"
placeholder="e.g., Marketing brochure for product line">${escapeHtml(subject)}</textarea>
<span class="char-count" id="subject-count-${index}">0/300</span>
</div>
<div class="metadata-field">
<label for="keywords-${index}">Keywords:</label>
<input type="text"
id="keywords-${index}"
class="editable-field"
value="${escapeHtml(keywords)}"
maxlength="500"
data-field="keywords"
data-index="${index}"
placeholder="e.g., product, marketing, 2026" />
<span class="char-count" id="keywords-count-${index}">0/500</span>
</div>
<div class="metadata-field">
<label for="author-${index}">Author/Creator:</label>
<input type="text"
id="author-${index}"
class="editable-field"
value="${escapeHtml(author)}"
maxlength="100"
data-field="author"
data-index="${index}"
placeholder="e.g., John Smith" />
<span class="char-count" id="author-count-${index}">0/100</span>
</div>
<div class="metadata-field">
<label for="copyright-${index}">Copyright:</label>
<input type="text"
id="copyright-${index}"
class="editable-field"
value="${escapeHtml(copyright)}"
maxlength="150"
data-field="copyright"
data-index="${index}"
placeholder="e.g., Copyright 2026 Company Name" />
<span class="char-count" id="copyright-count-${index}">0/150</span>
</div>
<div class="metadata-field">
<label for="comments-${index}">Comments/Notes:</label>
<textarea id="comments-${index}"
class="editable-field"
maxlength="500"
data-field="comments"
data-index="${index}"
placeholder="e.g., Internal notes about this file">${escapeHtml(comments)}</textarea>
<span class="char-count" id="comments-count-${index}">0/500</span>
</div>
<div id="custom-fields-${index}" style="margin-top: 10px;">
<!-- Custom fields will be added here -->
</div>
<button class="btn-small" onclick="addCustomField(${index})" type="button" style="margin-top: 10px; background: #17a2b8;">
Add Custom Field
</button>
`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function initCharCounters(index) {
const fields = ['title', 'subject', 'keywords', 'author', 'copyright', 'comments'];
const limits = {
title: 200,
subject: 300,
keywords: 500,
author: 100,
copyright: 150,
comments: 500
};
fields.forEach(field => {
const input = document.getElementById(`${field}-${index}`);
const counter = document.getElementById(`${field}-count-${index}`);
if (input && counter) {
// Initial count
updateCharCount(input, counter, limits[field]);
// Listen for changes
input.addEventListener('input', () => {
updateCharCount(input, counter, limits[field]);
});
}
});
}
function updateCharCount(input, counter, limit) {
const length = input.value.length;
counter.textContent = `${length}/${limit}`;
// Remove all classes first
counter.classList.remove('warning', 'danger');
input.classList.remove('invalid');
// Add warning/danger classes
if (length >= limit) {
counter.classList.add('danger');
input.classList.add('invalid');
} else if (length >= limit * 0.9) {
counter.classList.add('warning');
}
}
async function saveMetadata(index) {
const file = currentFiles[index];
if (!file || file.error) return;
const saveBtn = document.getElementById(`saveBtn-${index}`);
saveBtn.disabled = true;
saveBtn.textContent = '💾 Saving...';
// Get edited metadata
const title = document.getElementById(`title-${index}`).value.trim();
const subject = document.getElementById(`subject-${index}`).value.trim();
const keywords = document.getElementById(`keywords-${index}`).value.trim();
const author = document.getElementById(`author-${index}`).value.trim();
const copyright = document.getElementById(`copyright-${index}`).value.trim();
const comments = document.getElementById(`comments-${index}`).value.trim();
// Get custom fields
const customFields = getCustomFields(index);
try {
const response = await fetchWithAuth(`${API_BASE}/update-manual`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
file_index: index,
title: title,
subject: subject,
keywords: keywords,
author: author,
copyright: copyright,
comments: comments,
custom_fields: customFields
})
});
if (!response) return; // Redirected to login
const data = await response.json();
if (data.error) {
showError(`Failed to update ${file.filename}: ${data.error}`);
saveBtn.textContent = '💾 Save Changes';
saveBtn.disabled = false;
return;
}
// Update the file in currentFiles
currentFiles[index].suggested_metadata = {
title, subject, keywords, author, copyright, comments
};
// Show success indicator
const fileItem = document.getElementById(`file-${index}`);
if (fileItem) {
fileItem.style.borderLeftColor = '#28a745';
// Remove old success message if exists
const oldSuccess = fileItem.querySelector('.save-success');
if (oldSuccess) oldSuccess.remove();
// Add success message
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success save-success';
successDiv.style.display = 'block';
successDiv.style.marginTop = '10px';
successDiv.textContent = `✅ Metadata saved successfully!`;
fileItem.appendChild(successDiv);
// Remove success message after 3 seconds
setTimeout(() => {
successDiv.remove();
fileItem.style.borderLeftColor = '#667eea';
}, 3000);
}
saveBtn.textContent = '✅ Saved!';
setTimeout(() => {
saveBtn.textContent = '💾 Save Changes';
saveBtn.disabled = false;
}, 2000);
} catch (error) {
showError(`Error saving metadata: ${error.message}`);
saveBtn.textContent = '💾 Save Changes';
saveBtn.disabled = false;
}
}
async function updateAllFiles() {
if (selectedFiles.size === 0) {
showError('Please select at least one file to update');
return;
}
const outputDirEl = document.getElementById('outputDir');
const outputDir = outputDirEl ? outputDirEl.value.trim() : '';
const updateBtn = document.getElementById('updateAllBtn');
updateBtn.disabled = true;
hideAlerts();
showProgress(0);
showInfo(`Updating ${selectedFiles.size} selected file(s)...`);
let successCount = 0;
let errorCount = 0;
const selectedIndices = Array.from(selectedFiles);
for (let idx = 0; idx < selectedIndices.length; idx++) {
const i = selectedIndices[idx];
const file = currentFiles[i];
if (file.error) {
errorCount++;
continue;
}
const progress = ((idx + 1) / selectedIndices.length) * 100;
showProgress(progress);
try {
const response = await fetchWithAuth(`${API_BASE}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filepath: file.filepath,
session_id: sessionId,
file_index: i,
output_dir: outputDir
})
});
if (!response) {
// Session expired, show error and stop processing
hideProgress();
updateBtn.disabled = false;
showError('Session expired. Please refresh the page and try again.');
return;
}
const data = await response.json();
if (data.error) {
errorCount++;
const fileItem = document.getElementById(`file-${i}`);
if (fileItem) {
fileItem.style.borderLeftColor = '#dc3545';
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-error';
errorDiv.style.display = 'block';
errorDiv.textContent = `Error: ${data.error}`;
fileItem.appendChild(errorDiv);
}
} else {
successCount++;
const fileItem = document.getElementById(`file-${i}`);
if (fileItem) {
fileItem.style.borderLeftColor = '#28a745';
// Create success message with download button
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success';
successDiv.style.display = 'flex';
successDiv.style.alignItems = 'center';
successDiv.style.justifyContent = 'space-between';
successDiv.style.gap = '15px';
const messageSpan = document.createElement('span');
messageSpan.textContent = data.verified ?
`✅ Updated and verified!` :
`✅ Updated!`;
const downloadBtn = document.createElement('a');
downloadBtn.href = `${API_BASE}/download/${file.filename}`;
downloadBtn.className = 'btn';
downloadBtn.style.padding = '8px 16px';
downloadBtn.style.fontSize = '14px';
downloadBtn.style.backgroundColor = '#28a745';
downloadBtn.style.color = 'white';
downloadBtn.style.textDecoration = 'none';
downloadBtn.style.borderRadius = '4px';
downloadBtn.style.display = 'inline-block';
downloadBtn.download = file.filename;
downloadBtn.textContent = '⬇️ Download';
successDiv.appendChild(messageSpan);
successDiv.appendChild(downloadBtn);
fileItem.appendChild(successDiv);
}
}
} catch (error) {
errorCount++;
console.error('Error updating file:', error);
}
}
hideProgress();
updateBtn.disabled = false;
// Show final message and Download All button
if (successCount > 0 && errorCount === 0) {
showSuccess(`✅ All ${successCount} file(s) updated successfully!`);
showDownloadAllButton();
} else if (successCount > 0 && errorCount > 0) {
showInfo(`⚠️ Updated ${successCount} file(s), ${errorCount} failed.`);
showDownloadAllButton();
} else {
showError(`❌ Failed to update files. Check individual file errors above.`);
}
}
function showDownloadAllButton() {
// Remove existing Download All button if present
const existingBtn = document.getElementById('download-all-btn');
if (existingBtn) {
existingBtn.remove();
}
// Create Download All button container
const btnContainer = document.createElement('div');
btnContainer.id = 'download-all-btn';
btnContainer.style.marginTop = '30px';
btnContainer.style.marginBottom = '20px';
btnContainer.style.textAlign = 'center';
btnContainer.style.padding = '20px';
btnContainer.style.backgroundColor = '#f8f9fa';
btnContainer.style.borderRadius = '8px';
btnContainer.style.border = '2px solid #007bff';
const downloadAllBtn = document.createElement('button');
downloadAllBtn.id = 'download-selected-btn';
downloadAllBtn.className = 'btn';
downloadAllBtn.style.padding = '15px 30px';
downloadAllBtn.style.fontSize = '18px';
downloadAllBtn.style.fontWeight = 'bold';
downloadAllBtn.style.backgroundColor = '#007bff';
downloadAllBtn.style.color = 'white';
downloadAllBtn.style.border = 'none';
downloadAllBtn.style.borderRadius = '8px';
downloadAllBtn.style.cursor = 'pointer';
downloadAllBtn.style.boxShadow = '0 2px 4px rgba(0,123,255,0.3)';
downloadAllBtn.innerHTML = `📦 Download Selected Files (${selectedFiles.size}) as ZIP`;
downloadAllBtn.onmouseover = function() { this.style.backgroundColor = '#0056b3'; };
downloadAllBtn.onmouseout = function() { this.style.backgroundColor = '#007bff'; };
downloadAllBtn.onclick = downloadSelectedFiles;
btnContainer.appendChild(downloadAllBtn);
// Insert after the file list
const fileList = document.getElementById('fileList');
if (fileList && fileList.parentNode) {
fileList.parentNode.insertBefore(btnContainer, fileList.nextSibling);
}
}
async function downloadSelectedFiles() {
if (!sessionId) {
showError('No active session');
return;
}
if (selectedFiles.size === 0) {
showError('Please select at least one file to download');
return;
}
const selectedIndices = Array.from(selectedFiles);
try {
showInfo('📦 Preparing ZIP archive for download...');
// Send POST request with selected file indices
const response = await fetchWithAuth(`${API_BASE}/download-selected`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
session_id: sessionId,
file_indices: selectedIndices
})
});
if (!response) return; // Redirected to login
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create ZIP archive');
}
// Get the blob from response
const blob = await response.blob();
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `oliver_metadata_files_${new Date().getTime()}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showSuccess(`✅ Downloaded ${selectedFiles.size} file(s) as ZIP archive`);
} catch (error) {
showError(`Error: ${error.message}`);
}
}
async function exportResults() {
if (currentFiles.length === 0) {
showError('No files to export');
return;
}
// Create CSV content
let csvContent = 'Filename,Title,Subject,Keywords,Status\n';
currentFiles.forEach((file, index) => {
if (file.error) {
csvContent += `"${file.filename}","","","","Error: ${file.error}"\n`;
} else {
const title = file.suggested_metadata?.title || '';
const subject = file.suggested_metadata?.subject || '';
const keywords = file.suggested_metadata?.keywords || '';
const status = selectedFiles.has(index) ? 'Selected' : 'Not selected';
// Escape quotes in CSV
const escapeCsv = (str) => `"${String(str).replace(/"/g, '""')}"`;
csvContent += `${escapeCsv(file.filename)},${escapeCsv(title)},${escapeCsv(subject)},${escapeCsv(keywords)},${escapeCsv(status)}\n`;
}
});
// Create download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.setAttribute('href', url);
link.setAttribute('download', `oliver_metadata_export_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showSuccess('✅ Results exported to CSV');
}
function resetForm() {
fileInput.value = '';
fileList.style.display = 'none';
actions.style.display = 'none';
hideAlerts();
hideProgress();
currentFiles = [];
sessionId = null;
selectedFiles.clear();
}
function showSpinner() {
spinner.style.display = 'block';
}
function hideSpinner() {
spinner.style.display = 'none';
}
function showProgress(percent) {
progressBar.style.display = 'block';
progressFill.style.width = percent + '%';
progressFill.textContent = Math.round(percent) + '%';
}
function hideProgress() {
progressBar.style.display = 'none';
}
// Animated progress for long-running operations like AI generation
let progressAnimationInterval = null;
let animatedProgress = 0;
function startProgressAnimation() {
animatedProgress = 0;
showProgress(0);
progressAnimationInterval = setInterval(() => {
// Slow down as we approach 90%
if (animatedProgress < 30) {
animatedProgress += 2;
} else if (animatedProgress < 60) {
animatedProgress += 1;
} else if (animatedProgress < 90) {
animatedProgress += 0.5;
}
showProgress(animatedProgress);
}, 500);
}
function stopProgressAnimation() {
if (progressAnimationInterval) {
clearInterval(progressAnimationInterval);
progressAnimationInterval = null;
}
animatedProgress = 0;
}
function showError(message) {
errorAlert.textContent = message;
errorAlert.style.display = 'block';
}
function showSuccess(message) {
successAlert.textContent = message;
successAlert.style.display = 'block';
}
function showInfo(message) {
infoAlert.textContent = message;
infoAlert.style.display = 'block';
}
function hideAlerts() {
errorAlert.style.display = 'none';
successAlert.style.display = 'none';
infoAlert.style.display = 'none';
}
// Custom Fields Management
let customFieldCounters = {}; // Track number of custom fields per file
function addCustomField(index) {
if (!customFieldCounters[index]) {
customFieldCounters[index] = 0;
}
const fieldId = customFieldCounters[index]++;
const container = document.getElementById(`custom-fields-${index}`);
const customFieldDiv = document.createElement('div');
customFieldDiv.className = 'metadata-field';
customFieldDiv.id = `custom-field-container-${index}-${fieldId}`;
customFieldDiv.style.border = '1px dashed #17a2b8';
customFieldDiv.style.padding = '10px';
customFieldDiv.style.borderRadius = '5px';
customFieldDiv.style.marginTop = '10px';
customFieldDiv.style.background = '#f0f9ff';
customFieldDiv.innerHTML = `
<div style="display: flex; gap: 10px; align-items: flex-start;">
<div style="flex: 1;">
<label style="display: block; font-weight: 600; color: #495057; font-size: 12px; margin-bottom: 5px;">
Field Name:
</label>
<input type="text"
id="custom-field-name-${index}-${fieldId}"
class="editable-field"
placeholder="e.g., Department, Project, Location"
style="margin-bottom: 10px;" />
<label style="display: block; font-weight: 600; color: #495057; font-size: 12px; margin-bottom: 5px;">
Field Value:
</label>
<input type="text"
id="custom-field-value-${index}-${fieldId}"
class="editable-field"
placeholder="Enter value"
maxlength="200" />
<span class="char-count" id="custom-field-count-${index}-${fieldId}">0/200</span>
</div>
<button onclick="removeCustomField(${index}, ${fieldId})"
style="background: #dc3545; color: white; border: none; padding: 5px 10px; border-radius: 5px; cursor: pointer; margin-top: 25px;"
type="button">
</button>
</div>
`;
container.appendChild(customFieldDiv);
// Initialize character counter for value field
const valueInput = document.getElementById(`custom-field-value-${index}-${fieldId}`);
const counter = document.getElementById(`custom-field-count-${index}-${fieldId}`);
if (valueInput && counter) {
valueInput.addEventListener('input', () => {
updateCharCount(valueInput, counter, 200);
});
updateCharCount(valueInput, counter, 200);
}
}
function removeCustomField(index, fieldId) {
const container = document.getElementById(`custom-field-container-${index}-${fieldId}`);
if (container) {
container.remove();
}
}
function getCustomFields(index) {
const customFields = {};
const container = document.getElementById(`custom-fields-${index}`);
if (container) {
const fieldContainers = container.querySelectorAll('[id^="custom-field-container-"]');
fieldContainers.forEach(fieldContainer => {
const match = fieldContainer.id.match(/custom-field-container-(\d+)-(\d+)/);
if (match && match[1] === String(index)) {
const fieldId = match[2];
const nameInput = document.getElementById(`custom-field-name-${index}-${fieldId}`);
const valueInput = document.getElementById(`custom-field-value-${index}-${fieldId}`);
if (nameInput && valueInput && nameInput.value.trim()) {
customFields[nameInput.value.trim()] = valueInput.value.trim();
}
}
});
}
return customFields;
}
// Template Management Functions
async function loadTemplateList() {
try {
const response = await fetchWithAuth(`${API_BASE}/templates/list`);
if (!response) return; // Redirected to login
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 fetchWithAuth(`${API_BASE}/templates/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, title, subject, keywords })
});
if (!response) return; // Redirected to login
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 fetchWithAuth(`${API_BASE}/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: {}
})
});
if (!response) return; // Redirected to login
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 fetchWithAuth(`${API_BASE}/templates/list`);
if (!response) return; // Redirected to login
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 fetchWithAuth(`${API_BASE}/templates/delete/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
if (!response) return; // Redirected to login
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 fetchWithAuth(`${API_BASE}/templates/load/${encodeURIComponent(templateName)}`);
if (!response) return; // Redirected to login
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>