Spaces:
Sleeping
Sleeping
| /** Change Detection tab — pick library images and run comparison. */ | |
| const compareState = { t1: null, t2: null, pickingSlot: null, selectedNode: null, allLibraryItems: [] }; | |
| function compareFormatBytes(n) { | |
| if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'; | |
| if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'; | |
| return (n / 1024).toFixed(0) + ' KB'; | |
| } | |
| function thumbUrlFor(path) { | |
| return `/api/dda/local/thumb?path=${encodeURIComponent(path)}`; | |
| } | |
| function encodePath(path) { | |
| return encodeURIComponent(path || ''); | |
| } | |
| function decodePath(encoded) { | |
| try { | |
| return decodeURIComponent(encoded || ''); | |
| } catch (_) { | |
| return encoded || ''; | |
| } | |
| } | |
| function ensureDdaState() { | |
| window.ddaState = window.ddaState || {}; | |
| return window.ddaState; | |
| } | |
| function updateRunButton() { | |
| const btn = document.getElementById('btn-run-job'); | |
| if (btn) btn.disabled = !(compareState.t1 && compareState.t2); | |
| } | |
| function renderSlotPreview(slotKey, selection) { | |
| const wrap = document.getElementById(slotKey === 't1' ? 'slot-t1-preview' : 'slot-t2-preview'); | |
| const slot = document.getElementById(slotKey === 't1' ? 'slot-t1' : 'slot-t2'); | |
| if (!wrap || !slot) return; | |
| if (!selection) { | |
| wrap.innerHTML = '<p class="dim">No image selected</p>'; | |
| slot.classList.remove('filled'); | |
| return; | |
| } | |
| slot.classList.add('filled'); | |
| const thumb = selection.thumbUrl || thumbUrlFor(selection.path); | |
| wrap.innerHTML = ` | |
| <img class="dda-slot-preview" src="${thumb}" alt="" /> | |
| <div class="dda-slot-meta">${escapeHtml(selection.label || selection.filename)}</div>`; | |
| } | |
| function populateSelects(items) { | |
| ['select-t1', 'select-t2'].forEach((id) => { | |
| const sel = document.getElementById(id); | |
| if (!sel) return; | |
| const current = sel.value; | |
| const label = id === 'select-t1' ? '— Choose old image —' : '— Choose new image —'; | |
| sel.innerHTML = `<option value="">${label}</option>` + | |
| items.map((img) => `<option value="${encodePath(img.path)}">${escapeHtml(img.breadcrumb || img.nodePath || img.filename)}</option>`).join(''); | |
| if (current) sel.value = current; | |
| }); | |
| } | |
| function setSlot(slotKey, img) { | |
| if (!img || !img.path) return; | |
| const item = { | |
| path: img.path, | |
| label: img.breadcrumb || img.nodePath || img.filename, | |
| filename: img.filename, | |
| thumbUrl: img.thumbUrl || thumbUrlFor(img.path), | |
| }; | |
| if (slotKey === 't1') { | |
| compareState.t1 = item; | |
| const sel = document.getElementById('select-t1'); | |
| if (sel) sel.value = encodePath(item.path); | |
| renderSlotPreview('t1', item); | |
| } else { | |
| compareState.t2 = item; | |
| const sel = document.getElementById('select-t2'); | |
| if (sel) sel.value = encodePath(item.path); | |
| renderSlotPreview('t2', item); | |
| } | |
| updateRunButton(); | |
| refreshCompareLibrarySelection(); | |
| if (typeof showDdaSuccess === 'function') { | |
| showDdaSuccess(`${slotKey.toUpperCase()} set: ${item.filename}`); | |
| } | |
| } | |
| function findLibraryItem(path) { | |
| const norm = decodePath(path); | |
| const all = compareState.allLibraryItems || []; | |
| const fromAll = all.find((i) => i.path === norm); | |
| if (fromAll) return fromAll; | |
| const items = ensureDdaState().libraryItems || []; | |
| return items.find((i) => i.path === norm) || { | |
| path: norm, | |
| label: norm.split('/').pop(), | |
| filename: norm.split('/').pop(), | |
| thumbUrl: thumbUrlFor(norm), | |
| }; | |
| } | |
| function compareSelectionTitle() { | |
| return compareState.selectedNode?.path || 'All images'; | |
| } | |
| function updateCompareFolderPath() { | |
| const el = document.getElementById('compare-folder-path'); | |
| if (el) el.textContent = compareSelectionTitle(); | |
| } | |
| function renderCompareGrid(items) { | |
| const grid = document.getElementById('compare-lib-grid'); | |
| if (!grid) return; | |
| if (!items.length) { | |
| grid.innerHTML = `<p class="dim">No images in <strong>${escapeHtml(compareSelectionTitle())}</strong>. Select another folder or click Refresh to sync from disk.</p>`; | |
| return; | |
| } | |
| grid.innerHTML = items.map((img) => { | |
| const thumb = img.thumbUrl || thumbUrlFor(img.path); | |
| const enc = encodePath(img.path); | |
| const t1Sel = compareState.t1?.path === img.path ? ' selected-t1' : ''; | |
| const t2Sel = compareState.t2?.path === img.path ? ' selected-t2' : ''; | |
| const safeName = img.filename.replace(/</g, '<'); | |
| return ` | |
| <div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true"> | |
| ${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'} | |
| <div class="meta"> | |
| <span class="dim">${escapeHtml(img.breadcrumb || img.nodePath || '')}</span><br/> | |
| ${safeName}<br/> | |
| <span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span> | |
| </div> | |
| <div class="dda-compare-assign"> | |
| <button type="button" class="btn btn-secondary btn-sm" data-assign="t1" data-path="${enc}">T1</button> | |
| <button type="button" class="btn btn-secondary btn-sm" data-assign="t2" data-path="${enc}">T2</button> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| grid.querySelectorAll('.dda-compare-card').forEach((card) => { | |
| card.addEventListener('dragstart', (e) => { | |
| const path = decodePath(card.dataset.imagePath); | |
| e.dataTransfer.setData('application/x-dda-image-path', path); | |
| e.dataTransfer.setData('text/plain', path); | |
| }); | |
| }); | |
| refreshCompareLibrarySelection(); | |
| } | |
| function refreshCompareLibrarySelection() { | |
| const grid = document.getElementById('compare-lib-grid'); | |
| if (!grid) return; | |
| grid.querySelectorAll('.dda-compare-card').forEach((card) => { | |
| const path = decodePath(card.dataset.imagePath || ''); | |
| card.classList.toggle('selected-t1', !!(compareState.t1 && compareState.t1.path === path)); | |
| card.classList.toggle('selected-t2', !!(compareState.t2 && compareState.t2.path === path)); | |
| }); | |
| } | |
| async function loadCompareLibraryGrid() { | |
| const grid = document.getElementById('compare-lib-grid'); | |
| const title = document.getElementById('compare-grid-title'); | |
| if (!grid) return; | |
| grid.innerHTML = '<p class="dim">Loading library images…</p>'; | |
| if (title) title.textContent = `Pick from library — ${compareSelectionTitle()}`; | |
| updateCompareFolderPath(); | |
| try { | |
| const allItems = await ddaApi('GET', '/api/dda/local/images'); | |
| compareState.allLibraryItems = allItems; | |
| populateSelects(allItems); | |
| const params = new URLSearchParams(); | |
| if (compareState.selectedNode?.id) params.set('node_id', String(compareState.selectedNode.id)); | |
| const gridItems = compareState.selectedNode?.id | |
| ? await ddaApi('GET', '/api/dda/local/images?' + params.toString()) | |
| : allItems; | |
| ensureDdaState().libraryItems = gridItems; | |
| renderCompareGrid(gridItems); | |
| } catch (err) { | |
| grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`; | |
| if (typeof showDdaError === 'function') showDdaError(err.message); | |
| } | |
| } | |
| async function openPicker(slotKey) { | |
| compareState.pickingSlot = slotKey; | |
| const modal = document.getElementById('dda-picker-modal'); | |
| const list = document.getElementById('dda-picker-list'); | |
| const title = document.getElementById('dda-picker-title'); | |
| if (!modal || !list) return; | |
| if (title) { | |
| title.textContent = slotKey === 't1' ? 'Select Old Image (T1)' : 'Select New Image (T2)'; | |
| } | |
| list.innerHTML = '<p class="dim">Loading library…</p>'; | |
| modal.classList.remove('hidden'); | |
| let items = ensureDdaState().libraryItems || []; | |
| try { | |
| if (compareState.selectedNode?.id) { | |
| const params = new URLSearchParams(); | |
| params.set('node_id', String(compareState.selectedNode.id)); | |
| items = await ddaApi('GET', '/api/dda/local/images?' + params.toString()); | |
| } else { | |
| items = compareState.allLibraryItems.length | |
| ? compareState.allLibraryItems | |
| : await ddaApi('GET', '/api/dda/local/images'); | |
| compareState.allLibraryItems = items; | |
| } | |
| populateSelects(compareState.allLibraryItems.length | |
| ? compareState.allLibraryItems | |
| : items); | |
| } catch (err) { | |
| list.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`; | |
| return; | |
| } | |
| if (!items.length) { | |
| list.innerHTML = '<p class="dim">No images in library. Add files under library_sources/YEAR/ and refresh.</p>'; | |
| } else { | |
| list.innerHTML = items.map((img) => { | |
| const enc = encodePath(img.path); | |
| return ` | |
| <button type="button" class="dda-picker-item" data-path="${enc}"> | |
| <img src="${img.thumbUrl || thumbUrlFor(img.path)}" alt="" loading="lazy" /> | |
| <span>${escapeHtml(img.breadcrumb || img.filename)}</span> | |
| </button>`; | |
| }).join(''); | |
| list.querySelectorAll('.dda-picker-item').forEach((btn) => { | |
| btn.addEventListener('click', () => { | |
| const slot = compareState.pickingSlot; | |
| if (!slot) return; | |
| setSlot(slot, findLibraryItem(btn.dataset.path)); | |
| closePicker(); | |
| }); | |
| }); | |
| } | |
| } | |
| function closePicker() { | |
| document.getElementById('dda-picker-modal')?.classList.add('hidden'); | |
| compareState.pickingSlot = null; | |
| } | |
| function setupCompareInteractions() { | |
| const slotsWrap = document.querySelector('.dda-compare-slots'); | |
| if (slotsWrap) { | |
| slotsWrap.addEventListener('click', (e) => { | |
| const pickBtn = e.target.closest('.dda-slot-pick'); | |
| if (pickBtn) { | |
| e.preventDefault(); | |
| openPicker(pickBtn.dataset.pick); | |
| } | |
| }); | |
| } | |
| document.getElementById('select-t1')?.addEventListener('change', (e) => { | |
| const path = e.target.value; | |
| if (!path) { | |
| compareState.t1 = null; | |
| renderSlotPreview('t1', null); | |
| updateRunButton(); | |
| refreshCompareLibrarySelection(); | |
| return; | |
| } | |
| setSlot('t1', findLibraryItem(path)); | |
| }); | |
| document.getElementById('select-t2')?.addEventListener('change', (e) => { | |
| const path = e.target.value; | |
| if (!path) { | |
| compareState.t2 = null; | |
| renderSlotPreview('t2', null); | |
| updateRunButton(); | |
| refreshCompareLibrarySelection(); | |
| return; | |
| } | |
| setSlot('t2', findLibraryItem(path)); | |
| }); | |
| const grid = document.getElementById('compare-lib-grid'); | |
| if (grid) { | |
| grid.addEventListener('click', (e) => { | |
| const btn = e.target.closest('[data-assign]'); | |
| if (!btn) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setSlot(btn.dataset.assign, findLibraryItem(btn.dataset.path)); | |
| }); | |
| } | |
| ['slot-t1', 'slot-t2'].forEach((id) => { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| el.addEventListener('dragover', (e) => { e.preventDefault(); el.classList.add('drag-over'); }); | |
| el.addEventListener('dragleave', () => el.classList.remove('drag-over')); | |
| el.addEventListener('drop', (e) => { | |
| e.preventDefault(); | |
| el.classList.remove('drag-over'); | |
| const path = e.dataTransfer.getData('application/x-dda-image-path') | |
| || e.dataTransfer.getData('text/plain'); | |
| if (!path) return; | |
| setSlot(id === 'slot-t1' ? 't1' : 't2', findLibraryItem(path)); | |
| }); | |
| }); | |
| document.getElementById('btn-compare-refresh')?.addEventListener('click', async () => { | |
| const btn = document.getElementById('btn-compare-refresh'); | |
| btn.disabled = true; | |
| try { | |
| const data = await window.ddaState.rescan(); | |
| const sync = data.sync || {}; | |
| const parts = [`${data.totalImages || 0} image(s)`]; | |
| if (sync.nodesCreated) parts.push(`${sync.nodesCreated} folder(s) imported`); | |
| if (sync.imagesIndexed) parts.push(`${sync.imagesIndexed} image(s) indexed`); | |
| if (typeof showDdaSuccess === 'function') showDdaSuccess(`Library synced — ${parts.join(', ')}.`); | |
| await loadCompareLibraryGrid(); | |
| } catch (err) { | |
| if (typeof showDdaError === 'function') showDdaError(err.message); | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| }); | |
| document.getElementById('dda-picker-close')?.addEventListener('click', closePicker); | |
| document.getElementById('dda-picker-modal')?.addEventListener('click', (e) => { | |
| if (e.target.id === 'dda-picker-modal') closePicker(); | |
| }); | |
| document.getElementById('btn-run-job')?.addEventListener('click', runLibraryDetection); | |
| const notifyCb = document.getElementById('dda-detect-notify'); | |
| const notifyEmail = document.getElementById('dda-detect-notify-email'); | |
| notifyCb?.addEventListener('change', () => { | |
| if (notifyEmail) notifyEmail.classList.toggle('hidden', !notifyCb.checked); | |
| }); | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key !== 'Escape') return; | |
| const picker = document.getElementById('dda-picker-modal'); | |
| if (picker && !picker.classList.contains('hidden')) closePicker(); | |
| }); | |
| } | |
| function showDetectResult(data) { | |
| if (typeof showDdaResult === 'function') showDdaResult(data); | |
| else if (typeof showDdaError === 'function') showDdaError('Result viewer failed to load.'); | |
| } | |
| function setDetectProgress(pct, stage) { | |
| const fill = document.getElementById('detect-progress-fill'); | |
| const label = document.getElementById('detect-progress-label'); | |
| const clamped = Math.max(0, Math.min(100, Number(pct) || 0)); | |
| if (fill) fill.style.width = `${clamped}%`; | |
| const text = stage ? `${stage} — ${clamped}%` : `${clamped}%`; | |
| if (label) label.textContent = text; | |
| } | |
| function showDetectProgress() { | |
| const wrap = document.getElementById('detect-progress'); | |
| wrap?.classList.remove('hidden'); | |
| setDetectProgress(0, 'Starting detection'); | |
| } | |
| function hideDetectProgress(delayMs = 0) { | |
| const hide = () => document.getElementById('detect-progress')?.classList.add('hidden'); | |
| if (delayMs > 0) setTimeout(hide, delayMs); | |
| else hide(); | |
| } | |
| async function runDetectionWithFallback(form) { | |
| setDetectProgress(2, 'Queuing detection job'); | |
| try { | |
| const queued = await ddaApi('POST', '/api/dda/jobs', { body: form }); | |
| return await pollJobUntilDone(queued.jobId); | |
| } catch (err) { | |
| const msg = String(err.message || ''); | |
| const useSync = msg.includes('Not Found') || msg.includes('404') | |
| || msg.includes('503') || msg.includes('409') || msg.includes('busy') | |
| || msg.includes('Internal Server Error') || msg.includes('NOT NULL'); | |
| if (!useSync) throw err; | |
| return runSyncDetectionWithProgress(form); | |
| } | |
| } | |
| async function runSyncDetectionWithProgress(form) { | |
| let pct = 5; | |
| setDetectProgress(pct, 'Running detection (sync)'); | |
| const timer = setInterval(() => { | |
| pct = Math.min(92, pct + (pct < 50 ? 4 : 2)); | |
| setDetectProgress(pct, 'Running detection (sync)'); | |
| }, 1500); | |
| try { | |
| const result = await ddaApi('POST', '/api/dda/detect/from-library', { body: form }); | |
| setDetectProgress(100, 'Complete'); | |
| return { result, jobId: null }; | |
| } finally { | |
| clearInterval(timer); | |
| } | |
| } | |
| async function pollJobUntilDone(jobId) { | |
| const maxAttempts = 600; | |
| for (let i = 0; i < maxAttempts; i++) { | |
| const job = await ddaApi('GET', `/api/dda/jobs/${jobId}`); | |
| const status = job.status; | |
| const pct = job.progressPct ?? (status === 'queued' ? 0 : 10); | |
| const stage = job.progressStage || (status === 'queued' ? 'Queued' : 'Running'); | |
| setDetectProgress(pct, stage); | |
| if (status === 'completed') { | |
| setDetectProgress(100, 'Complete'); | |
| if (job.result) { | |
| if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications(); | |
| return { result: job.result, jobId }; | |
| } | |
| if (job.runId) { | |
| const data = await ddaApi('GET', `/api/history/${job.runId}`); | |
| if (typeof window.refreshDdaNotifications === 'function') window.refreshDdaNotifications(); | |
| return { result: data, jobId }; | |
| } | |
| if (job.resultError) throw new Error(job.resultError); | |
| } | |
| if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed'); | |
| await new Promise((r) => setTimeout(r, 2000)); | |
| } | |
| throw new Error('Detection timed out. Check Reports tab for job status.'); | |
| } | |
| async function runLibraryDetection() { | |
| if (!compareState.t1 || !compareState.t2) { | |
| if (typeof showDdaError === 'function') showDdaError('Select both T1 (old) and T2 (new) images first.'); | |
| return; | |
| } | |
| const btn = document.getElementById('btn-run-job'); | |
| if (typeof hideDdaError === 'function') hideDdaError(); | |
| btn.disabled = true; | |
| showDetectProgress(); | |
| const form = new FormData(); | |
| form.append('base_path', compareState.t1.path); | |
| form.append('comparison_path', compareState.t2.path); | |
| form.append('title', `${compareState.t1.filename} vs ${compareState.t2.filename}`); | |
| form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning'); | |
| form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false)); | |
| form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false)); | |
| form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45))))); | |
| const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150); | |
| if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea))); | |
| const notifyCb = document.getElementById('dda-detect-notify'); | |
| const notifyEmail = document.getElementById('dda-detect-notify-email'); | |
| if (notifyCb?.checked && notifyEmail?.value?.trim()) { | |
| form.append('notify_email', notifyEmail.value.trim()); | |
| } | |
| try { | |
| const { result: data, jobId } = await runDetectionWithFallback(form); | |
| if (jobId && typeof window.markDdaJobSeen === 'function') window.markDdaJobSeen(jobId); | |
| showDetectResult(data); | |
| if (typeof showDdaSuccess === 'function') { | |
| let msg = 'Detection complete.'; | |
| if (data.notificationSent) msg += ' Report email sent.'; | |
| showDdaSuccess(msg); | |
| } | |
| if (typeof loadReportsList === 'function') loadReportsList(); | |
| } catch (err) { | |
| if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed'); | |
| } finally { | |
| btn.disabled = !(compareState.t1 && compareState.t2); | |
| hideDetectProgress(2000); | |
| } | |
| } | |
| let compareInitialized = false; | |
| function initCompareTab() { | |
| if (!compareInitialized) { | |
| if (typeof registerCompareTreeSidebar === 'function') { | |
| registerCompareTreeSidebar({ | |
| containerId: 'compare-tree', | |
| searchId: 'compare-tree-search', | |
| allBtnId: 'btn-compare-tree-all', | |
| getSelectedNode: () => compareState.selectedNode, | |
| onNodeSelect: (node) => { | |
| compareState.selectedNode = node; | |
| loadCompareLibraryGrid(); | |
| }, | |
| onClearNode: () => { | |
| compareState.selectedNode = null; | |
| loadCompareLibraryGrid(); | |
| }, | |
| }); | |
| } | |
| setupCompareInteractions(); | |
| compareInitialized = true; | |
| } | |
| updateRunButton(); | |
| if (typeof loadTree === 'function') { | |
| loadTree().then(() => loadCompareLibraryGrid()).catch(() => loadCompareLibraryGrid()); | |
| } else { | |
| loadCompareLibraryGrid(); | |
| } | |
| } | |
| window.loadCompareLibraryGrid = loadCompareLibraryGrid; | |
| window.initCompareTab = initCompareTab; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', initCompareTab); | |
| } else { | |
| initCompareTab(); | |
| } | |