video-master-adapt/templates/standalone.html
nickviljoen 891c36bbfb Add standalone desktop application with web interface
Major Features:
- 🖥️ Standalone desktop app (VideoMatcher.app) - double-click to run
- 🎨 Black & gold branded UI (Montserrat font, #FFC407 accent)
- 📁 Local file browser for master/adaptation folders
-  Fast mode processing (10-20x faster, disables AKAZE/AI Vision)
- 🤖 Smart AI Vision fallback (auto-retry when no matches found)
- 📊 Real-time progress bars (fingerprinting & matching)
- 💾 Local processing (no cloud, no authentication)
- 📤 CSV export with master filenames

Web Application (Enterprise):
- 🌐 Flask web app with Azure AD authentication
- 📦 Box.com integration for cloud storage
- 🐳 Docker support for deployment
- 🔐 JWT validation with httpOnly cookies
- 🎯 REST API endpoints

Enhancements:
- Fixed master filename lookup (was showing "Unknown")
- Automatic fingerprint recovery (detects missing files)
- Improved CSV format (master file next to adaptation)
- Port conflict handling (auto-finds available port)
- Environment variable fixes for standalone mode

Documentation:
- Updated README with standalone app section
- Added 10+ guide documents (UI improvements, fingerprint recovery, etc.)
- Build instructions with PyInstaller
- Comprehensive troubleshooting guide

Technical:
- PyInstaller build configuration (video_matcher.spec)
- Launcher with environment setup (launcher.py)
- Mock authentication for standalone mode
- Video matcher service layer
- Metadata parser and AKAZE video matching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 09:49:04 +02:00

865 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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>Video Matcher - Standalone</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Montserrat Font -->
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap" rel="stylesheet">
<style>
body {
background: #000000;
min-height: 100vh;
padding: 20px;
font-family: 'Montserrat', sans-serif;
}
.main-container {
max-width: 1200px;
margin: 0 auto;
}
.card {
border: none;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
margin-bottom: 20px;
}
.card-header {
background: #FFC407;
color: #000000;
border-radius: 15px 15px 0 0 !important;
font-weight: 600;
}
.folder-path {
background: #f8f9fa;
padding: 10px 15px;
border-radius: 8px;
font-family: monospace;
font-size: 13px;
margin: 10px 0;
word-break: break-all;
}
.folder-item {
padding: 12px;
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
background: #f8f9fa;
}
.folder-item:hover {
background: #e9ecef;
border-color: #FFC407;
}
.folder-item.selected {
background: #fff9e6;
border-color: #FFC407;
}
.video-file {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #dee2e6;
}
.video-file:last-child {
border-bottom: none;
}
.badge-size {
background-color: #6c757d;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
color: white;
}
.step-indicator {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
}
.step {
flex: 1;
text-align: center;
position: relative;
padding: 10px;
}
.step::before {
content: '';
position: absolute;
top: 25px;
left: 50%;
right: -50%;
height: 2px;
background: #dee2e6;
z-index: 0;
}
.step:last-child::before {
display: none;
}
.step-number {
width: 50px;
height: 50px;
border-radius: 50%;
background: #dee2e6;
color: #6c757d;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 20px;
position: relative;
z-index: 1;
}
.step.active .step-number {
background: #FFC407;
color: #000000;
}
.step.completed .step-number {
background: #28a745;
color: white;
}
.step-label {
margin-top: 10px;
font-size: 14px;
color: #6c757d;
}
.step.active .step-label {
color: #FFC407;
font-weight: 600;
}
.progress-section {
margin: 20px 0;
}
.result-item {
padding: 15px;
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 15px;
}
.result-item.matched {
background: #d4edda;
border-color: #28a745;
}
.result-item.unmatched {
background: #f8d7da;
border-color: #dc3545;
}
.btn-primary {
background: #FFC407;
border: none;
color: #000000;
font-weight: 600;
}
.btn-primary:hover {
background: #e6b006;
color: #000000;
}
</style>
</head>
<body>
<div class="main-container">
<div class="card">
<div class="card-header text-center py-4">
<h2 class="mb-0">🎬 Video Master-Adaptation Matcher</h2>
<small>Standalone Application</small>
</div>
<div class="card-body">
<!-- Step Indicator -->
<div class="step-indicator">
<div class="step active" id="step1">
<div class="step-number">1</div>
<div class="step-label">Select Masters</div>
</div>
<div class="step" id="step2">
<div class="step-number">2</div>
<div class="step-label">Select Adaptations</div>
</div>
<div class="step" id="step3">
<div class="step-number">3</div>
<div class="step-label">Process & View Results</div>
</div>
</div>
<!-- Step 1: Select Master Folder -->
<div id="masterSelection" class="step-content">
<h4 class="mb-3">Step 1: Select Master Videos Folder</h4>
<p class="text-muted">Choose the folder containing your master video files</p>
<div class="d-flex gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="browseHome()">🏠 Home</button>
<button class="btn btn-outline-secondary btn-sm" onclick="browseDesktop()">🖥️ Desktop</button>
<button class="btn btn-outline-secondary btn-sm" onclick="browseDocuments()">📁 Documents</button>
<button class="btn btn-outline-secondary btn-sm" onclick="browseUp()" id="btnUp" disabled>⬆️ Parent Folder</button>
</div>
<div id="currentMasterPath" class="folder-path">No folder selected</div>
<div id="masterBrowser" class="mt-3" style="max-height: 400px; overflow-y: auto;">
<!-- Folder browser will be populated here -->
</div>
<!-- Fingerprinting Progress -->
<div id="fingerprintProgress" style="display: none;" class="mt-4">
<div class="progress" style="height: 30px;">
<div id="fingerprintProgressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
role="progressbar" style="width: 0%">
<span id="fingerprintProgressText">0 / 0</span>
</div>
</div>
<div id="fingerprintCurrentVideo" class="mt-2 text-muted" style="font-size: 14px;">
Processing...
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary" id="btnSelectMasterFolder" onclick="selectMasterFolder()" disabled>
Use This Folder →
</button>
</div>
</div>
<!-- Step 2: Select Adaptation Folders -->
<div id="adaptationSelection" class="step-content" style="display: none;">
<h4 class="mb-3">Step 2: Select Adaptation Videos Folder(s)</h4>
<p class="text-muted">Choose one or more folders containing adaptation videos</p>
<div class="d-flex gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptHome()">🏠 Home</button>
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptDesktop()">🖥️ Desktop</button>
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptDocuments()">📁 Documents</button>
<button class="btn btn-outline-secondary btn-sm" onclick="browseAdaptUp()" id="btnAdaptUp" disabled>⬆️ Parent Folder</button>
</div>
<div id="currentAdaptPath" class="folder-path">No folder selected</div>
<div id="adaptationBrowser" class="mt-3" style="max-height: 400px; overflow-y: auto;">
<!-- Folder browser will be populated here -->
</div>
<div class="mt-3">
<button class="btn btn-outline-primary" onclick="addAdaptationFolder()" id="btnAddFolder" disabled>
Add This Folder
</button>
</div>
<div class="mt-4" id="selectedAdaptations" style="display: none;">
<h5>Selected Folders:</h5>
<div id="adaptationList"></div>
</div>
<div class="mt-3">
<button class="btn btn-secondary" onclick="backToMasters()">← Back</button>
<button class="btn btn-primary" id="btnProceed" onclick="proceedToMatching()" disabled>
Start Matching →
</button>
</div>
</div>
<!-- Step 3: Processing & Results -->
<div id="processingResults" class="step-content" style="display: none;">
<h4 class="mb-3">Step 3: Processing Videos</h4>
<div id="processingStatus" class="progress-section">
<div class="text-center">
<div class="spinner-border text-primary" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Processing...</span>
</div>
<p class="mt-3">Analyzing videos and finding matches...</p>
<!-- Progress Bar -->
<div class="mt-4" style="max-width: 500px; margin: 0 auto;">
<div class="progress" style="height: 30px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 0%">
<span id="progressText">0 / 0</span>
</div>
</div>
<div id="currentVideoText" class="mt-2 text-muted" style="font-size: 14px;">
Starting...
</div>
</div>
</div>
</div>
<div id="resultsSection" style="display: none;">
<h4 class="mb-3">Matching Results</h4>
<div class="alert alert-info">
<strong>Summary:</strong>
<span id="resultsSummary"></span>
</div>
<div id="resultsList" class="mt-4">
<!-- Results will be populated here -->
</div>
<div class="mt-4">
<button class="btn btn-primary" onclick="exportResults()">📊 Export Results</button>
<button class="btn btn-outline-secondary" onclick="startOver()">🔄 Start Over</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Application State
let currentMasterPath = null;
let selectedMasterFolder = null;
let currentAdaptPath = null;
let selectedAdaptationFolders = [];
let roots = {};
let matchResults = null;
// Initialize
document.addEventListener('DOMContentLoaded', function() {
browseHome();
});
// Master Folder Selection
async function browseMasterPath(path) {
try {
const response = await fetch('/local/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
const data = await response.json();
if (!response.ok) {
alert('Error: ' + (data.error || 'Failed to browse folder'));
return;
}
currentMasterPath = data.current_path;
roots = data.roots || {};
// Update UI
document.getElementById('currentMasterPath').textContent = currentMasterPath || 'Select a starting location';
document.getElementById('btnUp').disabled = !data.parent_path;
document.getElementById('btnSelectMasterFolder').disabled = !currentMasterPath || data.video_count === 0;
// Render folders
const browser = document.getElementById('masterBrowser');
browser.innerHTML = '';
// Show roots if at root level
if (!path && data.roots) {
for (const [key, value] of Object.entries(data.roots)) {
if (value) {
const item = createFolderItem(key, value, () => browseMasterPath(value));
browser.appendChild(item);
}
}
}
// Show folders
data.folders.forEach(folder => {
const item = createFolderItem('📁 ' + folder.name, folder.path, () => browseMasterPath(folder.path));
browser.appendChild(item);
});
// Show video count
if (data.video_count > 0) {
const info = document.createElement('div');
info.className = 'alert alert-success mt-3';
info.textContent = `✓ Found ${data.video_count} video file(s) in this folder`;
browser.appendChild(info);
}
} catch (error) {
alert('Error browsing folder: ' + error.message);
}
}
function browseHome() { browseMasterPath(null); }
function browseDesktop() { browseMasterPath(roots.desktop || null); }
function browseDocuments() { browseMasterPath(roots.documents || null); }
function browseUp() {
if (currentMasterPath) {
const parent = currentMasterPath.split('/').slice(0, -1).join('/') || '/';
browseMasterPath(parent);
}
}
async function selectMasterFolder() {
if (!currentMasterPath) return;
// Show loading state
const btnSelect = document.getElementById('btnSelectMasterFolder');
const originalText = btnSelect.innerHTML;
btnSelect.disabled = true;
btnSelect.innerHTML = '⏳ Scanning folder...';
try {
// Scan the folder
const response = await fetch('/local/scan-masters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: currentMasterPath })
});
const data = await response.json();
if (!response.ok) {
alert('Error: ' + (data.error || 'Failed to scan folder'));
btnSelect.disabled = false;
btnSelect.innerHTML = originalText;
return;
}
if (data.new_videos === 0 && data.already_added === 0) {
alert('No video files found in this folder');
btnSelect.disabled = false;
btnSelect.innerHTML = originalText;
return;
}
selectedMasterFolder = {
path: currentMasterPath,
...data
};
// Process masters if needed (new videos or missing fingerprints)
if (data.new_videos > 0) {
// Check if any need re-fingerprinting
const needsRefp = data.scanned.filter(v => v.reason && v.reason.includes('Missing')).length;
const isNew = data.scanned.filter(v => v.reason === 'New video').length;
let statusMsg = `⏳ Processing ${data.new_videos} video(s)...`;
btnSelect.innerHTML = statusMsg;
btnSelect.disabled = true;
// Start progress polling
fingerprintProgressInterval = setInterval(pollFingerprintProgress, 1000);
const videoPaths = data.scanned.map(v => v.path);
const addResponse = await fetch('/local/add-masters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_paths: videoPaths })
});
// Stop polling
if (fingerprintProgressInterval) {
clearInterval(fingerprintProgressInterval);
fingerprintProgressInterval = null;
}
const addData = await addResponse.json();
if (!addResponse.ok) {
alert('Error adding masters: ' + (addData.error || 'Failed'));
btnSelect.disabled = false;
btnSelect.innerHTML = originalText;
return;
}
// Build detailed message
let message = '';
const newCount = addData.results.filter(r => r.status === 'new').length;
const refpCount = addData.results.filter(r => r.status === 're-fingerprinted').length;
if (newCount > 0 && refpCount > 0) {
message = `✓ Added ${newCount} new master(s) and re-fingerprinted ${refpCount} existing master(s)`;
} else if (refpCount > 0) {
message = `✓ Re-fingerprinted ${refpCount} master(s) with missing fingerprints`;
} else {
message = `✓ Successfully added ${newCount} new master video(s)`;
}
if (data.already_added > 0) {
message += `\n\n${data.already_added} master(s) already had valid fingerprints`;
}
if (addData.failed > 0) {
message += `\n\n${addData.failed} video(s) failed to process. Check terminal for details.`;
}
alert(message);
} else {
alert(`✓ All ${data.already_added} master video(s) already have valid fingerprints`);
}
// Move to step 2
goToStep2();
} catch (error) {
alert('Error: ' + error.message);
btnSelect.disabled = false;
btnSelect.innerHTML = originalText;
}
}
// Adaptation Folder Selection
async function browseAdaptPath(path) {
try {
const response = await fetch('/local/browse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path })
});
const data = await response.json();
if (!response.ok) {
alert('Error: ' + (data.error || 'Failed to browse folder'));
return;
}
currentAdaptPath = data.current_path;
// Update UI
document.getElementById('currentAdaptPath').textContent = currentAdaptPath || 'Select a starting location';
document.getElementById('btnAdaptUp').disabled = !data.parent_path;
document.getElementById('btnAddFolder').disabled = !currentAdaptPath || data.video_count === 0;
// Render folders
const browser = document.getElementById('adaptationBrowser');
browser.innerHTML = '';
// Show roots if at root level
if (!path && data.roots) {
for (const [key, value] of Object.entries(data.roots)) {
if (value) {
const item = createFolderItem(key, value, () => browseAdaptPath(value));
browser.appendChild(item);
}
}
}
// Show folders
data.folders.forEach(folder => {
const item = createFolderItem('📁 ' + folder.name, folder.path, () => browseAdaptPath(folder.path));
browser.appendChild(item);
});
// Show video count
if (data.video_count > 0) {
const info = document.createElement('div');
info.className = 'alert alert-success mt-3';
info.textContent = `✓ Found ${data.video_count} video file(s) in this folder`;
browser.appendChild(info);
}
} catch (error) {
alert('Error browsing folder: ' + error.message);
}
}
function browseAdaptHome() { browseAdaptPath(null); }
function browseAdaptDesktop() { browseAdaptPath(roots.desktop || null); }
function browseAdaptDocuments() { browseAdaptPath(roots.documents || null); }
function browseAdaptUp() {
if (currentAdaptPath) {
const parent = currentAdaptPath.split('/').slice(0, -1).join('/') || '/';
browseAdaptPath(parent);
}
}
function addAdaptationFolder() {
if (!currentAdaptPath) return;
// Check if already added
if (selectedAdaptationFolders.includes(currentAdaptPath)) {
alert('This folder is already added');
return;
}
selectedAdaptationFolders.push(currentAdaptPath);
updateAdaptationList();
}
function updateAdaptationList() {
const list = document.getElementById('adaptationList');
const section = document.getElementById('selectedAdaptations');
if (selectedAdaptationFolders.length === 0) {
section.style.display = 'none';
document.getElementById('btnProceed').disabled = true;
return;
}
section.style.display = 'block';
document.getElementById('btnProceed').disabled = false;
list.innerHTML = selectedAdaptationFolders.map((folder, index) => `
<div class="alert alert-info d-flex justify-content-between align-items-center">
<span>📁 ${folder}</span>
<button class="btn btn-sm btn-danger" onclick="removeAdaptationFolder(${index})">Remove</button>
</div>
`).join('');
}
function removeAdaptationFolder(index) {
selectedAdaptationFolders.splice(index, 1);
updateAdaptationList();
}
let progressInterval = null;
let fingerprintProgressInterval = null;
async function pollProgress() {
try {
const response = await fetch('/local/match-progress');
const progress = await response.json();
if (progress.active) {
// Update progress bar
const percent = progress.total > 0 ? (progress.current / progress.total * 100) : 0;
document.getElementById('progressBar').style.width = percent + '%';
document.getElementById('progressText').textContent = `${progress.current} / ${progress.total}`;
document.getElementById('currentVideoText').textContent =
progress.current_video ? `Processing: ${progress.current_video}` : 'Starting...';
} else {
// Processing complete, stop polling
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
}
} catch (error) {
console.error('Error polling progress:', error);
}
}
async function pollFingerprintProgress() {
try {
const response = await fetch('/local/add-masters-progress');
const progress = await response.json();
if (progress.active) {
// Show progress bar
document.getElementById('fingerprintProgress').style.display = 'block';
// Update progress bar
const percent = progress.total > 0 ? (progress.current / progress.total * 100) : 0;
document.getElementById('fingerprintProgressBar').style.width = percent + '%';
document.getElementById('fingerprintProgressText').textContent = `${progress.current} / ${progress.total}`;
document.getElementById('fingerprintCurrentVideo').textContent =
progress.current_video ? `Fingerprinting: ${progress.current_video}` : 'Starting...';
} else {
// Processing complete, stop polling
if (fingerprintProgressInterval) {
clearInterval(fingerprintProgressInterval);
fingerprintProgressInterval = null;
}
// Hide progress bar after a moment
setTimeout(() => {
document.getElementById('fingerprintProgress').style.display = 'none';
}, 1000);
}
} catch (error) {
console.error('Error polling fingerprint progress:', error);
}
}
async function proceedToMatching() {
if (selectedAdaptationFolders.length === 0) {
alert('Please select at least one adaptation folder');
return;
}
goToStep3();
try {
// Scan adaptation folders
document.getElementById('currentVideoText').textContent = 'Scanning adaptation folders...';
const scanResponse = await fetch('/local/scan-adaptations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_paths: selectedAdaptationFolders })
});
const scanData = await scanResponse.json();
if (!scanResponse.ok) {
throw new Error(scanData.error || 'Failed to scan adaptations');
}
if (scanData.total_videos === 0) {
alert('No video files found in selected folders');
return;
}
// Start progress polling
progressInterval = setInterval(pollProgress, 1000);
// Start matching
const videoPaths = scanData.videos.map(v => v.path);
const matchResponse = await fetch('/local/match', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adaptation_paths: videoPaths })
});
const matchData = await matchResponse.json();
// Stop polling
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
if (!matchResponse.ok) {
throw new Error(matchData.error || 'Matching failed');
}
matchResults = matchData;
displayResults(matchData);
} catch (error) {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
alert('Error during processing: ' + error.message);
document.getElementById('processingStatus').innerHTML =
`<div class="alert alert-danger">Error: ${error.message}</div>`;
}
}
function displayResults(data) {
document.getElementById('processingStatus').style.display = 'none';
document.getElementById('resultsSection').style.display = 'block';
// Build summary with AI fallback info
let summaryText = `${data.matched} matched, ${data.unmatched} unmatched out of ${data.total} total videos`;
if (data.ai_fallback_used > 0) {
summaryText += `<br><span class="badge bg-info mt-2">🤖 ${data.ai_fallback_used} matched using AI Vision fallback (cross-aspect ratio)</span>`;
}
document.getElementById('resultsSummary').innerHTML = summaryText;
const list = document.getElementById('resultsList');
list.innerHTML = data.results.map(result => {
const matched = result.matched;
const itemClass = matched ? 'matched' : 'unmatched';
const icon = matched ? '✅' : '❌';
const matchMethod = result.match_method || 'fast';
const isAiFallback = matchMethod === 'ai_vision_fallback';
return `
<div class="result-item ${itemClass}">
<h6>
${icon} ${result.adaptation_filename || 'Unknown'}
${isAiFallback ? '<span class="badge bg-info ms-2" style="font-size: 11px;">🤖 AI Vision</span>' : ''}
</h6>
${matched ? `
<p class="mb-1"><strong>Matched Master:</strong> ${result.master_filename || result.master_id || 'Unknown'}</p>
<p class="mb-1"><strong>Confidence:</strong> ${result.confidence ? (result.confidence * 100).toFixed(1) : '0.0'}%</p>
<p class="mb-1"><strong>Audio Score:</strong> ${result.audio_score ? (result.audio_score * 100).toFixed(1) : '0.0'}%</p>
${isAiFallback ? '<p class="mb-0 text-info"><small><em>Matched using AI Vision (likely cross-aspect ratio)</em></small></p>' : ''}
` : `
<p class="mb-0 text-muted">No matching master found</p>
`}
</div>
`;
}).join('');
}
function exportResults() {
if (!matchResults) return;
const csv = generateCSV(matchResults);
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `match_results_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
}
function generateCSV(data) {
const headers = ['Adaptation File', 'Master File', 'Matched', 'Confidence', 'Audio Score', 'Match Method'];
const rows = data.results.map(r => [
r.adaptation_filename || r.adaptation_path,
r.matched ? (r.master_filename || 'Unknown') : '',
r.matched ? 'Yes' : 'No',
r.confidence ? (r.confidence * 100).toFixed(1) + '%' : '0%',
r.audio_score ? (r.audio_score * 100).toFixed(1) + '%' : '0%',
r.match_method === 'ai_vision_fallback' ? 'AI Vision' :
r.match_method === 'fast' ? 'Fast' : 'No Match'
]);
return [headers, ...rows].map(row =>
row.map(cell => `"${cell}"`).join(',')
).join('\n');
}
function startOver() {
location.reload();
}
// Helper Functions
function createFolderItem(label, path, onClick) {
const div = document.createElement('div');
div.className = 'folder-item';
div.textContent = label;
div.onclick = onClick;
return div;
}
function goToStep2() {
document.getElementById('masterSelection').style.display = 'none';
document.getElementById('adaptationSelection').style.display = 'block';
document.getElementById('step1').classList.remove('active');
document.getElementById('step1').classList.add('completed');
document.getElementById('step2').classList.add('active');
browseAdaptHome();
}
function goToStep3() {
document.getElementById('adaptationSelection').style.display = 'none';
document.getElementById('processingResults').style.display = 'block';
document.getElementById('step2').classList.remove('active');
document.getElementById('step2').classList.add('completed');
document.getElementById('step3').classList.add('active');
}
function backToMasters() {
document.getElementById('adaptationSelection').style.display = 'none';
document.getElementById('masterSelection').style.display = 'block';
document.getElementById('step2').classList.remove('active');
document.getElementById('step1').classList.remove('completed');
document.getElementById('step1').classList.add('active');
}
</script>
</body>
</html>