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>
2555 lines
94 KiB
HTML
2555 lines
94 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Oliver Metadata Tool</title>
|
||
<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()">×</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()">×</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>
|