/* === app.js — Copyparty Content Tag (main application logic) === */ (() => { 'use strict'; /* ------------------------------------------------------------------ */ /* DOM References */ /* ------------------------------------------------------------------ */ const loginForm = document.getElementById('login-form'); const btnCheck = document.getElementById('btn-check'); const btnEnter = document.getElementById('btn-enter'); const loginStatus = document.getElementById('login-status'); const cwdInput = document.getElementById('cwd-input'); const passwordInput = document.getElementById('password-input'); const viewLogin = document.getElementById('view-login'); const viewTagging = document.getElementById('view-tagging'); const viewGallery = document.getElementById('view-gallery'); const appHeader = document.getElementById('app-header'); const cwdDisplay = document.getElementById('cwd-display'); const btnChangeDir = document.getElementById('btn-change-dir'); const btnOpenGallery = document.getElementById('btn-open-gallery'); const taggingGrid = document.getElementById('tagging-grid'); const viewerViewport = document.getElementById('viewer-viewport'); const btnPrev = document.getElementById('btn-prev'); const btnNext = document.getElementById('btn-next'); const albumSelect = document.getElementById('album-select'); const albumPosition = document.getElementById('album-position'); const tagInput = document.getElementById('tag-input'); const tagSuggestions= document.getElementById('tag-suggestions'); const metaSimHash = document.getElementById('meta-similarity-hash'); const metaColors = document.getElementById('meta-base-colors'); const btnDeleteFile = document.getElementById('btn-delete-file'); /* ------------------------------------------------------------------ */ /* State */ /* ------------------------------------------------------------------ */ /** @type {Map} In-memory TSV DB keyed by file_id */ const dbMap = new Map(); /** Array of row objects without user tags — only for grid display */ let untagged = []; /** Current selected row's file_id (null = nothing selected) */ let currentFileId = null; /** Cached permission result from last Check press */ let cachedPerms = null; /* ------------------------------------------------------------------ */ /* 1. View Switching */ /* ------------------------------------------------------------------ */ /** * Show exactly one view and optionally the shared header. * @param {'login'|'tagging'|'gallery'} which - view id suffix * @param {boolean} [showHeader] - whether #app-header is visible */ function switchView(which, showHeader = false) { const views = [viewLogin, viewTagging, viewGallery]; views.forEach(v => { if (v) v.style.display = 'none'; }); const target = document.getElementById(`view-${which}`); if (target) target.style.display = ''; if (showHeader && appHeader) { appHeader.style.display = ''; cwdDisplay.textContent = CP_CONFIG.cwd || ''; } else if (appHeader) { appHeader.style.display = 'none'; } } /* ------------------------------------------------------------------ */ /* 2. Login View */ /* ------------------------------------------------------------------ */ /** Pre-fill CWD from localStorage on load */ function restoreLoginFields() { if (CP_CONFIG.cwd) cwdInput.value = CP_CONFIG.cwd; if (CP_CONFIG.password) passwordInput.value = CP_CONFIG.password; } /** * Check button — call CP_CP.checkPerms on the CWD path. */ async function handleCheck() { const cwd = cwdInput.value.trim(); if (!cwd) { setStatus('Please enter a working directory'); return; } try { const perms = await CP_CP.checkPerms(cwd); cachedPerms = perms; if (perms.readable) { setStatus('OK — readable' + (perms.writable ? ', writable' : ', read-only'), 'ok'); } else { setStatus('Not readable', 'error'); } } catch (err) { setStatus(`Error: ${err.message}`, 'error'); } } /** * Enter button — store credentials, switch to tagging view. * Password is optional; only CWD is required. */ async function handleEnter() { const cwd = cwdInput.value.trim(); const pw = passwordInput.value; if (!cwd) { setStatus('Please enter a working directory'); return; } /* If we haven't checked permissions yet, do it now using current input. Temporarily set password so checkPerms uses the right PW header. */ if (!cachedPerms) { const prevPw = CP_CONFIG.password || ''; if (pw) CP_CONFIG.setPassword(pw); else CP_CONFIG.setPassword(''); // ensure no stale password used try { cachedPerms = await CP_CP.checkPerms(cwd); } catch (err) { setStatus(`Error: ${err.message}`, 'error'); return; } finally { /* Restore previous password so login() sets the intended value */ if (prevPw) CP_CONFIG.setPassword(prevPw); else CP_CONFIG.setPassword(''); } } /* Notify if directory is read-only */ if (cachedPerms.readable && !cachedPerms.writable) { setStatus('Warning: read-only directory. Write operations will fail.', ''); } else if (!cachedPerms.readable) { setStatus('Cannot enter — directory not readable', 'error'); return; } CP_AUTH.login(cwd, pw); switchView('tagging', true); initTagging(); } /** Set the login status message text and colour class */ function setStatus(text, type = '') { loginStatus.textContent = text; loginStatus.className = 'status-message' + (type ? ` status-${type}` : ''); } /* ------------------------------------------------------------------ */ /* 3. Tagging View */ /* ------------------------------------------------------------------ */ /** Build an absolute URL to a managed content file */ function urlFor(fileId, ext) { // Normalize: strip slashes from each segment, then join with exactly one '/' const cwd = (CP_CONFIG.cwd || '').replace(/^\/+|\/+$/g, ''); const file = `${fileId}.${ext}`; const path = [cwd, 'content', 'managed', file].filter(Boolean).join('/'); return `${location.origin}/${path}`; } /** Fetch image as blob URL — avoids Chrome canvas-gesture blocking */ async function fetchImageBlob(fileId, ext) { const url = urlFor(fileId, ext); const hdrs = {}; if (CP_CONFIG.password) hdrs['PW'] = CP_CONFIG.password; const res = await fetch(url, { headers: hdrs }); if (!res.ok) throw new Error(`fetch blob ${res.status}`); const blob = await res.blob(); return URL.createObjectURL(blob); } /** * Initialise the tagging view: ensure dirs, load DB, scan unmanaged, * populate grid with untagged images. */ async function initTagging() { const cwd = CP_CONFIG.cwd; try { // Ensure required directories exist (idempotent) await Scan.ensureDirs(cwd); // Load or create the TSV database in memory dbMap.clear(); const loaded = await TsvDb.load(cwd); loaded.forEach((row, key) => dbMap.set(key, row)); // Import any new unmanaged files (populates dbMap) const result = await Scan.importUnmanaged(cwd, dbMap); console.log(`[App] Scan result — imported: ${result.imported}, skipped: ${result.skipped}`); // Load album name↔uuid mapping (seeds from existing DB if new) await AlbumDb.load(cwd, dbMap); // Re-populate the grid after any imports populateGrid(); populateAlbumDatalist(); } catch (err) { console.error('[App] initTagging failed:', err); setStatus(`Init failed: ${err.message}`, 'error'); } } /** Populate #tagging-grid with images that have no user tags */ /** If keepViewer is true, the viewer and editor are preserved. */ function populateGrid(keepViewer) { taggingGrid.innerHTML = ''; untagged = []; for (const [, row] of dbMap) { if (!row.user_tags || !row.user_tags.trim()) { untagged.push(row); } } if (!untagged.length) { const note = document.createElement('p'); note.textContent = 'All items tagged.'; taggingGrid.appendChild(note); if (!keepViewer) clearEditor(); return; } untagged.forEach((row, idx) => { const img = document.createElement('img'); const ext = row.original_name.split('.').pop() || 'jpg'; img.src = urlFor(row.file_id, ext); img.alt = row.original_name; img.title = row.original_name; img.dataset.index = String(idx); img.dataset.fileid = row.file_id; img.classList.add('grid-item'); img.addEventListener('click', () => selectImage(idx)); taggingGrid.appendChild(img); }); if (!keepViewer) { currentFileId = null; clearEditor(); } } /** * Select an image by grid index: load into viewer, process if needed. * Tracking is by file_id so that save/delete survive grid rebuilds. * @param {number} idx */ async function selectImage(idx) { // Cancel any pending debounced save from the previous image clearTimeout(_saveTimer); _saveTimer = null; // Highlight active grid item taggingGrid.querySelectorAll('.grid-item').forEach(el => el.classList.remove('active')); const activeImg = taggingGrid.querySelector(`[data-index="${idx}"]`); if (activeImg) activeImg.classList.add('active'); const row = untagged[idx]; if (!row) return; // Track by file_id, not array index — survives grid rebuilds currentFileId = row.file_id; // Load image into the viewer const ext = row.original_name.split('.').pop() || 'jpg'; const img = new Image(); img.src = urlFor(row.file_id, ext); viewerViewport.innerHTML = ''; viewerViewport.appendChild(img); // Fill editor with current DB values (may be empty strings) tagInput.value = row.user_tags || ''; metaSimHash.textContent = row.similarity_hash || '(not processed)'; albumSelect.value = AlbumDb.getName(row.album_uuid) || ''; albumPosition.value = row.album_position !== '' ? row.album_position : ''; // Snapshot for dirty-checking — save only if user changes something editorSnapshot.tags = tagInput.value; editorSnapshot.albumUuid = albumSelect.value; editorSnapshot.albumPos = albumPosition.value || ''; if (row.base_colors) renderColorSwatches(row.base_colors); else metaColors.innerHTML = ''; btnDeleteFile.disabled = false; // Process image if hash or colours are missing. // Use a blob-fetched copy so Chrome doesn't block canvas readback. if (!row.similarity_hash || !row.base_colors) { let blobUrl = null; try { blobUrl = await fetchImageBlob(row.file_id, ext); const procImg = new Image(); procImg.src = blobUrl; await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('processImage: blob load timeout')), 15000); procImg.addEventListener('load', () => { clearTimeout(timeout); resolve(); }, { once: true }); procImg.addEventListener('error', () => { clearTimeout(timeout); reject(new Error('processImage: blob load error')); }, { once: true }); }); const result = await CP_IMAGE_PROCESSING.processImage(procImg, 4); // Update row in dbMap (same object ref) row.similarity_hash = result.similarity_hash; row.base_colors = result.base_colors; dbMap.set(row.file_id, row); metaSimHash.textContent = result.similarity_hash; renderColorSwatches(result.base_colors); // Persist to server await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); // Refresh snapshot after save so unchanged form doesn't re-trigger writes editorSnapshot.tags = tagInput.value; editorSnapshot.albumUuid = albumSelect.value; editorSnapshot.albumPos = albumPosition.value || ''; // Remove from untagged grid only if user has assigned tags if (row.user_tags && row.user_tags.trim()) { removeProcessedGridItem(); } } catch (err) { console.error('[App] processImage failed:', err); } finally { if (blobUrl && blobUrl.startsWith('blob:')) { URL.revokeObjectURL(blobUrl); } } } } /** Remove the just-processed item from the grid, keeping the viewer. */ function removeProcessedGridItem() { // Find the currently selected row by file_id (may already be processed) const activeRow = dbMap.get(currentFileId); // Remove the grid DOM element for this item (find by data-fileid if present) let el = taggingGrid.querySelector(`[data-fileid="${currentFileId}"]`); if (!el) { // Fallback: find by index from untagged array const idx = untagged.findIndex(r => r.file_id === currentFileId); if (idx >= 0) el = taggingGrid.querySelector(`[data-index="${idx}"]`); } if (el) el.remove(); // Rebuild the untagged array without the tagged item const fresh = []; for (const [, row] of dbMap) { if (!row.user_tags || !row.user_tags.trim()) { fresh.push(row); } } untagged = fresh; // Update data-index and data-fileid on remaining items taggingGrid.querySelectorAll('.grid-item').forEach((el, i) => { el.dataset.index = String(i); // Rebind click handler since index changed const clone = el.cloneNode(true); clone.addEventListener('click', () => selectImage(i)); el.replaceWith(clone); }); // Update currentFileId to point to the newly processed row in dbMap if (activeRow) { currentFileId = activeRow.file_id; } // If all items tagged, show note but keep viewer if (!fresh.length) { const note = document.createElement('p'); note.textContent = 'All items tagged.'; taggingGrid.appendChild(note); } } /** Render colour swatch elements from a base_colors string */ function renderColorSwatches(baseColorsStr) { metaColors.innerHTML = ''; if (!baseColorsStr) return; const parts = baseColorsStr.split(','); for (const part of parts) { const hex = part.split('_')[0]; if (/^#[0-9a-f]{6}$/i.test(hex)) { const swatch = document.createElement('span'); swatch.className = 'swatch'; swatch.style.backgroundColor = hex; swatch.title = part; metaColors.appendChild(swatch); } } } /** Find index of the currently selected item in a given array */ function _findIdx(arr) { if (!currentFileId) return -1; return arr.findIndex(r => r.file_id === currentFileId); } /** Navigate to the previous untagged image */ function prevImage() { const idx = _findIdx(untagged); if (idx <= 0) return; selectImage(idx - 1); } /** Navigate to the next untagged image */ function nextImage() { const idx = _findIdx(untagged); if (idx >= untagged.length - 1) return; selectImage(idx + 1); } /** Clear the editor panel */ function clearEditor() { viewerViewport.innerHTML = ''; tagInput.value = ''; metaSimHash.textContent = ''; albumSelect.value = ''; albumPosition.value = ''; metaColors.innerHTML = ''; btnDeleteFile.disabled = true; } /** Snapshot of editor values at time of last load (for dirty check) */ let editorSnapshot = { tags: '', albumUuid: '', albumPos: '' }; /** Resolve album display value (name or UUID) to a UUID */ function resolveAlbumValue(value, row) { const trimmed = (value || '').trim(); if (!trimmed) return ''; /* Already a UUID? Use as-is */ if (/^[0-9a-f]{8}-[0-9a-f]{4}/i.test(trimmed)) return trimmed; /* Lookup name → uuid; fall back to existing row value */ const uuid = AlbumDb.getUuid(trimmed); return uuid || (row && row.album_uuid) || ''; } /** Save the current row's edited tags and album to DB */ /** Looks up row by file_id in dbMap — survives grid rebuilds. */ async function saveCurrent() { if (!currentFileId) return; const row = dbMap.get(currentFileId); if (!row) return; const currentTags = tagInput.value; const currentAlbumRaw = albumSelect.value.trim(); const currentPos = albumPosition.value || ''; // Skip write if nothing changed since this image was loaded if (currentTags === editorSnapshot.tags && currentAlbumRaw === editorSnapshot.albumUuid && currentPos === editorSnapshot.albumPos) { return; } /* Resolve album display value (name) to UUID for storage */ const resolvedAlbum = resolveAlbumValue(currentAlbumRaw, row); TsvDb.updateUserTags(dbMap, row.file_id, currentTags); const updated = dbMap.get(row.file_id); if (updated) { updated.album_uuid = resolvedAlbum; updated.album_position = currentPos; } await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); // Bump snapshot so we don't re-save unchanged values on next call editorSnapshot.tags = currentTags; editorSnapshot.albumUuid = currentAlbumRaw; editorSnapshot.albumPos = currentPos; } /** Resolve album name → uuid and save */ /** Album select shows names (user-friendly) but DB stores UUIDs. */ async function resolveAlbumAndSave() { if (!currentFileId) return; const row = dbMap.get(currentFileId); if (!row) return; const albumValue = albumSelect.value.trim(); /* Resolve to uuid */ let resolvedUuid = ''; if (albumValue) { /* If value is already a UUID, use as-is */ if (/^[0-9a-f]{8}-[0-9a-f]{4}/i.test(albumValue)) { resolvedUuid = albumValue; } else { /* Treat as album name — lookup or create */ resolvedUuid = AlbumDb.getUuid(albumValue); if (!resolvedUuid) { resolvedUuid = AlbumDb.register(albumValue); await AlbumDb.save(CP_CONFIG.cwd); } } } /* Update row */ const updated = dbMap.get(currentFileId); if (updated) updated.album_uuid = resolvedUuid; /* Persist */ await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); /* Snapshot: store the display name (what user sees). */ /* saveCurrent will compare names — dirty-check remains correct. */ editorSnapshot.albumUuid = albumValue; editorSnapshot.tags = tagInput.value; editorSnapshot.albumPos = albumPosition.value || ''; /* Refresh datalist in case new album was registered */ populateAlbumDatalist(); } /** Delete the current file from managed directory and DB */ async function deleteCurrent() { if (!currentFileId) return; const row = dbMap.get(currentFileId); if (!row) return; if (!confirm(`Delete "${row.original_name}"?`)) return; try { const ext = row.original_name.split('.').pop() || 'jpg'; await CP_CP.deleteFile(`${CP_CONFIG.cwd}/content/managed/${row.file_id}.${ext}`); TsvDb.deleteRow(dbMap, row.file_id); await TsvDb.saveFull(CP_CONFIG.cwd, dbMap); currentFileId = null; populateGrid(); clearEditor(); } catch (err) { console.error('[App] deleteFile failed:', err); } } /* ------------------------------------------------------------------ */ /* 4. Album Datalist & Tag Autocomplete */ /* ------------------------------------------------------------------ */ /** Populate #album-datalist with album names (value → uuid) */ function populateAlbumDatalist() { const datalist = document.getElementById('album-datalist'); if (!datalist) return; datalist.innerHTML = ''; /* Show all registered album names; selecting one gives the UUID */ for (const name of AlbumDb.getAllNames()) { const opt = document.createElement('option'); opt.value = name; datalist.appendChild(opt); } } /** Show filtered tag suggestions based on all tags in the DB */ function showTagSuggestions() { const inputVal = tagInput.value.toLowerCase().trim(); tagSuggestions.innerHTML = ''; // Collect every tag from the entire database const allTags = new Set(); for (const [, row] of dbMap) { if (row.user_tags) { row.user_tags.split(',').forEach(t => { const trimmed = t.trim(); if (trimmed) allTags.add(trimmed); }); } } // Filter: starts with input, not already used in current value const used = new Set(currentTags()); const cursorVal = inputVal.split(',').pop().trim(); for (const tag of allTags) { if (cursorVal && tag.startsWith(cursorVal) && !used.has(tag)) { const li = document.createElement('li'); li.textContent = tag; li.style.cursor = 'pointer'; li.addEventListener('click', () => selectTagSuggestion(tag)); tagSuggestions.appendChild(li); } } } /** Split the current tag input into individual tag strings */ function currentTags() { return tagInput.value.split(',').map(t => t.trim()).filter(Boolean); } /** Append a selected suggestion to the tag input */ function selectTagSuggestion(tag) { const tags = currentTags(); // Always replace the partial last token with the full suggestion. // Works whether user typed "tag1, partial" or just "partial". if (tags.length > 0) tags.pop(); tags.push(tag); tagInput.value = tags.join(', ') + ', '; tagSuggestions.innerHTML = ''; } /* ------------------------------------------------------------------ */ /* 5. Gallery — dynamic loading */ /* ------------------------------------------------------------------ */ /** Flag tracking whether the gallery module script was loaded */ let galleryLoaded = false; /** Dynamically load .part5_gallery.js via fetch + eval */ async function loadGalleryModule() { if (galleryLoaded) return true; try { const resp = await fetch('.part5_gallery.js'); if (!resp.ok) throw new Error(`Gallery module: HTTP ${resp.status}`); const code = await resp.text(); const fn = new Function(code); fn(); // executes the script body — should expose CP_GALLERY galleryLoaded = true; return typeof CP_GALLERY !== 'undefined'; } catch (err) { console.warn('[App] Gallery module not available:', err.message); return false; } } /** Handle "Open Gallery" button click */ async function openGallery() { const hasModule = await loadGalleryModule(); switchView('gallery', true); if (hasModule && typeof CP_GALLERY.init === 'function') { try { CP_GALLERY.init(CP_CONFIG.cwd, dbMap); } catch (err) { console.error('[App] Gallery init failed:', err); } } else { console.warn('[App] CP_GALLERY module not loaded — gallery UI inactive'); } } /** Handle "Back to Tagging" button click */ function backToTagging() { switchView('tagging', true); populateGrid(); } /* ------------------------------------------------------------------ */ /* 6. Event Binding */ /* ------------------------------------------------------------------ */ // Login form btnCheck.addEventListener('click', handleCheck); btnEnter.addEventListener('click', handleEnter); // Header btnChangeDir.addEventListener('click', () => { cachedPerms = null; CP_AUTH.logout(); switchView('login'); restoreLoginFields(); }); btnOpenGallery.addEventListener('click', openGallery); // Tagging navigation btnPrev.addEventListener('click', prevImage); btnNext.addEventListener('click', nextImage); // Back to tagging (gallery view) const btnBackTagging = document.getElementById('btn-back-tagging'); if (btnBackTagging) btnBackTagging.addEventListener('click', backToTagging); // Editor: save on input change (debounced to avoid thrashing the TSV write) let _saveTimer = null; tagInput.addEventListener('input', () => { showTagSuggestions(); clearTimeout(_saveTimer); _saveTimer = setTimeout(() => saveCurrent(), 800); }); albumSelect.addEventListener('change', () => { resolveAlbumAndSave(); }); albumPosition.addEventListener('change', saveCurrent); // Tag autocomplete keyboard support tagInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { tagSuggestions.innerHTML = ''; e.preventDefault(); } }); // Keyboard shortcuts for image navigation document.addEventListener('keydown', (e) => { if (viewTagging.style.display !== 'none' && currentFileId) { if (e.key === 'ArrowLeft') prevImage(); if (e.key === 'ArrowRight') nextImage(); } }); // Delete button btnDeleteFile.addEventListener('click', deleteCurrent); /* ------------------------------------------------------------------ */ /* 7. Boot */ /* ------------------------------------------------------------------ */ restoreLoginFields(); })();