const state = { directoryHandle: null, photos: [], currentIndex: -1, objectUrl: null, thumbUrls: new Map(), zoom: 1, rotation: 0, processing: false, selectionToken: 0, mapConfig: null, map: null, mapMarker: null, locationDirty: false, favoriteLocations: [], configPromise: null, enabledExtensions: new Set(), enabledFormatLabel: '', }; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => [...document.querySelectorAll(selector)]; const elements = { appName: $('#app-name'), appSubtitle: $('#app-subtitle'), openButtons: [$('#open-folder'), $('#open-folder-top'), $('#open-folder-center')], rescan: $('#rescan-folder'), folderName: $('#folder-name'), folderAccess: $('#folder-access'), progressCount: $('#progress-count'), progressSaved: $('#progress-saved'), progressSkipped: $('#progress-skipped'), progressFailed: $('#progress-failed'), photoCount: $('#photo-count'), filter: $('#filter-input'), statusFilter: $('#status-filter'), sortSelect: $('#sort-select'), fileList: $('#file-list'), viewerEmpty: $('#viewer-empty'), viewerContent: $('#viewer-content'), preview: $('#photo-preview'), filename: $('#current-filename'), filesize: $('#current-filesize'), position: $('#viewer-position'), previous: $('#previous-button'), skip: $('#skip-button'), save: $('#save-button'), saveNext: $('#save-next-button'), copyPrevious: $('#copy-previous'), zoomOut: $('#zoom-out'), zoomIn: $('#zoom-in'), fit: $('#fit-image'), rotateLeft: $('#rotate-left'), rotateRight: $('#rotate-right'), resetView: $('#reset-view'), form: $('#metadata-form'), day: $('#day-input'), month: $('#month-input'), year: $('#year-input'), timeMode: $('#time-mode'), timeField: $('#time-field'), time: $('#time-input'), description: $('#description-input'), descriptionCount: $('#description-count'), keywords: $('#keywords-input'), addressSearch: $('#address-search'), addressSearchButton: $('#address-search-button'), addressResults: $('#address-results'), locationMap: $('#location-map'), mapStatus: $('#map-status'), latitude: $('#latitude-input'), longitude: $('#longitude-input'), locationName: $('#location-name-input'), clearLocation: $('#clear-location'), favoriteSelect: $('#favorite-location-select'), useFavorite: $('#use-favorite-location'), saveFavorite: $('#save-favorite-location'), deleteFavorite: $('#delete-favorite-location'), saveStatus: $('#save-status'), shortcutsButton: $('#shortcuts-button'), shortcutsDialog: $('#shortcuts-dialog'), closeShortcuts: $('#close-shortcuts'), toast: $('#toast'), }; function isSupported() { return 'showDirectoryPicker' in window && window.isSecureContext; } function showToast(message, isError = false) { elements.toast.textContent = message; elements.toast.classList.toggle('error', isError); elements.toast.classList.add('show'); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => elements.toast.classList.remove('show'), 3000); } function setSaveStatus(kind, title, detail) { elements.saveStatus.className = `save-status ${kind}`; elements.saveStatus.innerHTML = `●
${escapeHtml(title)}${escapeHtml(detail)}
`; } function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[char]); } function naturalCompare(a, b) { return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 ** 2).toFixed(1)} MB`; } function formatType(filename) { const extension = filename.split('.').pop()?.toLowerCase() || ''; if (extension === 'jpg' || extension === 'jpeg') return 'JPG'; if (extension === 'png') return 'PNG'; if (extension === 'tif' || extension === 'tiff') return 'TIFF'; if (extension === 'heic' || extension === 'heif' || extension === 'hif') return 'HEIF'; if (extension === 'dng') return 'DNG'; if (extension === 'cr2') return 'CR2'; if (extension === 'cr3') return 'CR3'; if (extension === 'nef') return 'NEF'; if (extension === 'nrw') return 'NRW'; if (extension === 'arw') return 'ARW'; if (extension === 'arq') return 'ARQ'; return extension.toUpperCase(); } function requiresServerPreview(photo) { return ['TIFF', 'HEIF', 'DNG', 'CR2', 'CR3', 'NEF', 'NRW', 'ARW', 'ARQ'].includes(photo.format); } function isEnabledFilename(filename) { const dot = filename.lastIndexOf('.'); if (dot < 0) return false; return state.enabledExtensions.has(filename.slice(dot).toLowerCase()); } function storageKey() { return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null; } function loadFolderState() { const key = storageKey(); if (!key) return {}; try { return JSON.parse(localStorage.getItem(key) || '{}'); } catch { return {}; } } function saveFolderState() { const key = storageKey(); if (!key) return; const photoState = {}; for (const photo of state.photos) { photoState[photo.name] = { status: photo.status, values: photo.values || null, lastModified: photo.lastModified, }; } localStorage.setItem(key, JSON.stringify({ photos: photoState })); } async function openFolder() { try { await loadAppConfig(); await loadFavoriteLocations(); if (!isSupported()) { showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true); return; } const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'photo-date-editor' }); const permission = await handle.requestPermission({ mode: 'readwrite' }); if (permission !== 'granted') throw new Error('Read/write permission was not granted.'); state.directoryHandle = handle; await scanFolder(); } catch (error) { if (error.name !== 'AbortError') showToast(error.message || 'Could not open the folder.', true); } } async function scanFolder() { if (!state.directoryHandle) return; clearObjectUrls(); const stored = loadFolderState(); const photos = []; for await (const [name, handle] of state.directoryHandle.entries()) { if (handle.kind !== 'file' || !isEnabledFilename(name)) continue; const file = await handle.getFile(); const previous = stored.photos?.[name] || {}; const unchanged = !previous.lastModified || previous.lastModified === file.lastModified; photos.push({ name, handle, format: formatType(name), size: file.size, lastModified: file.lastModified, status: unchanged ? (previous.status || 'pending') : 'pending', values: unchanged ? (previous.values || null) : null, fileValues: null, metadataLoaded: false, metadataPresent: false, }); } photos.sort((a, b) => naturalCompare(a.name, b.name)); state.photos = photos; state.currentIndex = -1; elements.folderName.textContent = state.directoryHandle.name; elements.folderAccess.textContent = 'Read/write access granted'; elements.filter.disabled = !photos.length; elements.statusFilter.disabled = !photos.length; elements.sortSelect.disabled = !photos.length; elements.rescan.disabled = false; elements.photoCount.textContent = String(photos.length); renderFileList(); updateProgress(); if (photos.length) { elements.viewerEmpty.classList.add('hidden'); elements.viewerContent.classList.remove('hidden'); await selectPhoto(0); } else { elements.viewerContent.classList.add('hidden'); elements.viewerEmpty.classList.remove('hidden'); showToast(`No enabled ${state.enabledFormatLabel} files were found in that folder.`, true); } } function clearObjectUrls() { if (state.objectUrl) URL.revokeObjectURL(state.objectUrl); state.objectUrl = null; for (const url of state.thumbUrls.values()) URL.revokeObjectURL(url); state.thumbUrls.clear(); } async function getPreviewUrl(photo, suppliedFile = null) { if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name); const file = suppliedFile || await photo.handle.getFile(); let previewBlob = file; if (requiresServerPreview(photo)) { const payload = new FormData(); payload.append('file', file, photo.name); const response = await fetch('/api/preview', { method: 'POST', body: payload }); if (!response.ok) { let message = `Preview failed (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } previewBlob = await response.blob(); } const url = URL.createObjectURL(previewBlob); state.thumbUrls.set(photo.name, url); return url; } async function getThumbUrl(photo) { if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name); if (requiresServerPreview(photo)) return null; return getPreviewUrl(photo); } function statusSymbol(status) { if (status === 'saved') return '✓'; if (status === 'skipped') return '→'; if (status === 'failed') return '!'; return ''; } const statusSortOrder = { pending: 0, failed: 1, skipped: 2, saved: 3 }; function visiblePhotoEntries() { const filenameFilter = elements.filter.value.trim().toLowerCase(); const statusFilter = elements.statusFilter.value; const entries = state.photos .map((photo, index) => ({ photo, index })) .filter(({ photo }) => !filenameFilter || photo.name.toLowerCase().includes(filenameFilter)) .filter(({ photo }) => statusFilter === 'all' || photo.status === statusFilter); if (elements.sortSelect.value === 'status') { entries.sort((a, b) => { const statusDifference = statusSortOrder[a.photo.status] - statusSortOrder[b.photo.status]; return statusDifference || naturalCompare(a.photo.name, b.photo.name); }); } else if (elements.sortSelect.value === 'format') { entries.sort((a, b) => naturalCompare(a.photo.format, b.photo.format) || naturalCompare(a.photo.name, b.photo.name)); } else { entries.sort((a, b) => naturalCompare(a.photo.name, b.photo.name)); } return entries; } function renderFileList() { const matching = visiblePhotoEntries(); elements.fileList.innerHTML = ''; if (!matching.length) { elements.fileList.innerHTML = '
No photos match the current filters.
'; return; } for (const { photo, index } of matching) { const row = document.createElement('button'); row.type = 'button'; row.className = `file-row${index === state.currentIndex ? ' active' : ''}`; row.dataset.index = String(index); row.innerHTML = `
${escapeHtml(photo.name)} ${index + 1} / ${state.photos.length}${formatBytes(photo.size)}${escapeHtml(photo.format)}
${statusSymbol(photo.status)}`; row.addEventListener('click', () => selectPhoto(index)); elements.fileList.appendChild(row); getThumbUrl(photo).then((url) => { if (!url || !row.isConnected) return; const placeholder = row.querySelector('.file-thumb'); const image = document.createElement('img'); image.className = 'file-thumb'; image.alt = ''; image.src = url; placeholder.replaceWith(image); }).catch(() => {}); } } function formatMetadataSummary(values) { if (!values?.year) return 'No date metadata has been entered.'; const monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const month = monthNames[Math.max(0, Number(values.month || 1) - 1)]; let dateText; if (values.precision === 'approximate') dateText = `Approx. ${values.year}`; else if (values.precision === 'year') dateText = values.year; else if (values.precision === 'month') dateText = `${month} ${values.year}`; else dateText = `${values.day} ${month} ${values.year}`; const parts = [dateText, values.timeMode === 'known' ? values.time : 'time unknown']; if (values.description?.trim()) parts.push('description set'); const keywordCount = parseKeywords(values.keywords || '').length; if (keywordCount) parts.push(`${keywordCount} keyword${keywordCount === 1 ? '' : 's'}`); if (validCoordinates(values.latitude, values.longitude)) parts.push(values.locationName?.trim() || 'location set'); return parts.join(' · '); } function updatePhotoStatus(photo) { const values = photo.fileValues || photo.values; if (photo.status === 'saved') { const timing = photo.lastSaveTiming ? ` · ${formatSaveTiming(photo.lastSaveTiming)}` : ''; setSaveStatus('saved', 'Previously saved', `${formatMetadataSummary(values)}${timing}`); } else if (photo.status === 'skipped') { setSaveStatus('skipped', 'Previously skipped', 'No metadata was written to this file.'); } else if (photo.status === 'failed') { setSaveStatus('failed', 'Previous save failed', photo.name); } else if (photo.metadataPresent) { setSaveStatus('idle', 'Metadata loaded from file', formatMetadataSummary(values)); } else { setSaveStatus('idle', 'Ready to edit', photo.name); } } function formatSaveTiming(timing) { if (timing.streamed) { return `${timing.total.toFixed(1)}s total · ${timing.firstByte.toFixed(1)}s to first byte · ${timing.streamWrite.toFixed(1)}s stream/write`; } return `${timing.total.toFixed(1)}s total · ${timing.serverTransfer.toFixed(1)}s server/transfer · ${timing.localWrite.toFixed(1)}s local write`; } async function readPhotoMetadata(photo, file) { if (photo.metadataLoaded) return; const payload = new FormData(); payload.append('file', file, photo.name); const response = await fetch('/api/metadata', { method: 'POST', body: payload }); if (!response.ok) { let message = `Metadata read failed (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } applyMetadataResult(photo, await response.json()); } function applyMetadataResult(photo, data) { photo.metadataLoaded = true; photo.metadataPresent = Boolean(data.hasMetadata); photo.fileValues = data.hasMetadata ? data.values : null; if (!photo.values && data.hasMetadata) photo.values = { ...data.values }; if (data.editedByApp && photo.status === 'pending') { photo.status = 'saved'; saveFolderState(); updateProgress(); renderFileList(); } } async function inspectServerPreviewPhoto(photo, file) { const payload = new FormData(); payload.append('file', file, photo.name); const response = await fetch('/api/inspect', { method: 'POST', body: payload }); if (!response.ok) { let message = `Photo inspection failed (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } const buffer = await response.arrayBuffer(); if (buffer.byteLength < 5) throw new Error('The server returned an incomplete inspection response.'); const view = new DataView(buffer); const metadataLength = view.getUint32(0, false); if (metadataLength < 2 || metadataLength > buffer.byteLength - 4) { throw new Error('The server returned an invalid inspection response.'); } const metadataBytes = new Uint8Array(buffer, 4, metadataLength); const data = JSON.parse(new TextDecoder().decode(metadataBytes)); const previewBytes = new Uint8Array(buffer, 4 + metadataLength); if (!previewBytes.length) throw new Error('The server returned no preview image.'); const oldUrl = state.thumbUrls.get(photo.name); if (oldUrl) URL.revokeObjectURL(oldUrl); const previewUrl = URL.createObjectURL(new Blob([previewBytes], { type: 'image/jpeg' })); state.thumbUrls.set(photo.name, previewUrl); applyMetadataResult(photo, data); return previewUrl; } async function selectPhoto(index, force = false) { if (index < 0 || index >= state.photos.length || (state.processing && !force)) return; captureCurrentValues(); state.currentIndex = index; const selectionToken = ++state.selectionToken; const photo = state.photos[index]; const file = await photo.handle.getFile(); elements.preview.removeAttribute('src'); elements.filename.textContent = photo.name; elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`; elements.position.textContent = `${index + 1} / ${state.photos.length}`; resetView(); restoreValues(photo.values); updatePhotoStatus(photo); updateNavigation(); renderFileList(); const active = elements.fileList.querySelector('.file-row.active'); active?.scrollIntoView({ block: 'nearest' }); try { if (requiresServerPreview(photo)) { setSaveStatus('loading', `Rendering ${photo.format} preview…`, photo.name); } const serverInspectionNeeded = requiresServerPreview(photo) && (!state.thumbUrls.has(photo.name) || !photo.metadataLoaded); const previewUrl = serverInspectionNeeded ? await inspectServerPreviewPhoto(photo, file) : await getPreviewUrl(photo, file); if (selectionToken !== state.selectionToken || state.currentIndex !== index) return; elements.preview.src = previewUrl; if (serverInspectionNeeded) { if (!formHasUserInput() && photo.values) restoreValues(photo.values); updatePhotoStatus(photo); } renderFileList(); } catch (error) { if (selectionToken === state.selectionToken && state.currentIndex === index) { setSaveStatus('failed', 'Could not render preview', error.message || photo.name); } } if (!photo.metadataLoaded) { if (!photo.values) setSaveStatus('loading', 'Reading image metadata…', photo.name); try { await readPhotoMetadata(photo, file); if (selectionToken !== state.selectionToken || state.currentIndex !== index) return; if (!formHasUserInput() && photo.values) restoreValues(photo.values); updatePhotoStatus(photo); } catch (error) { if (selectionToken === state.selectionToken && state.currentIndex === index) { setSaveStatus('failed', 'Could not read metadata', error.message || photo.name); } } } } function adjacentVisibleIndex(delta) { const visible = visiblePhotoEntries(); if (!visible.length) return null; const position = visible.findIndex(({ index }) => index === state.currentIndex); if (position === -1) return delta > 0 ? visible[0].index : visible[visible.length - 1].index; const target = position + delta; return target >= 0 && target < visible.length ? visible[target].index : null; } function updateNavigation() { const hasPhoto = state.currentIndex >= 0; elements.previous.disabled = !hasPhoto || adjacentVisibleIndex(-1) === null || state.processing; elements.skip.disabled = !hasPhoto || state.processing; elements.save.disabled = !hasPhoto || state.processing; elements.saveNext.disabled = !hasPhoto || state.processing; elements.copyPrevious.disabled = !hasPhoto || state.currentIndex === 0 || state.processing; } function currentPrecision() { return $('input[name="precision"]:checked').value; } function updatePrecisionFields() { const precision = currentPrecision(); elements.month.disabled = precision === 'year' || precision === 'approximate'; elements.day.disabled = precision !== 'exact'; } function formValues() { return { precision: currentPrecision(), year: elements.year.value, month: elements.month.value, day: elements.day.value, timeMode: elements.timeMode.value, time: elements.time.value, description: elements.description.value, keywords: elements.keywords.value, latitude: elements.latitude.value, longitude: elements.longitude.value, locationName: elements.locationName.value, locationDirty: state.locationDirty, }; } function formHasUserInput() { const values = formValues(); return Boolean(values.year || values.description.trim() || values.keywords.trim() || validCoordinates(values.latitude, values.longitude)); } function captureCurrentValues() { if (state.currentIndex < 0 || !state.photos[state.currentIndex]) return; state.photos[state.currentIndex].values = formValues(); } function restoreValues(values) { const defaults = { precision: 'exact', year: '', month: '1', day: '1', timeMode: 'unknown', time: '12:00:00', description: '', keywords: '', latitude: '', longitude: '', locationName: '', locationDirty: false, }; const value = { ...defaults, ...(values || {}) }; const radio = $(`input[name="precision"][value="${CSS.escape(value.precision)}"]`); if (radio) radio.checked = true; elements.year.value = value.year; elements.month.value = value.month; elements.day.value = value.day; elements.timeMode.value = value.timeMode; elements.time.value = value.time; elements.description.value = value.description; elements.keywords.value = value.keywords; elements.latitude.value = value.latitude || ''; elements.longitude.value = value.longitude || ''; elements.locationName.value = value.locationName || ''; state.locationDirty = Boolean(value.locationDirty); elements.timeField.classList.toggle('hidden', value.timeMode !== 'known'); elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`; updatePrecisionFields(); syncMapFromFields(false); } function validCoordinates(latitude, longitude) { if (latitude === '' || longitude === '' || latitude === null || longitude === null) return false; const lat = Number(latitude); const lon = Number(longitude); return Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180; } function parseKeywords(value) { return [...new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean))]; } function validateForm() { if (!elements.year.value) throw new Error('Enter a year before saving.'); const year = Number(elements.year.value); if (!Number.isInteger(year) || year < 1800 || year > 2200) throw new Error('Year must be between 1800 and 2200.'); if (currentPrecision() === 'exact' && !elements.day.value) throw new Error('Enter a day for an exact date.'); } async function savePhoto(moveNext) { if (state.processing || state.currentIndex < 0) return; try { validateForm(); } catch (error) { showToast(error.message, true); return; } const photo = state.photos[state.currentIndex]; const nextVisibleIndex = moveNext ? adjacentVisibleIndex(1) : null; state.processing = true; updateNavigation(); setSaveStatus('saving', 'Saving metadata…', photo.name); try { let permission = await photo.handle.queryPermission({ mode: 'readwrite' }); if (permission !== 'granted') permission = await photo.handle.requestPermission({ mode: 'readwrite' }); if (permission !== 'granted') throw new Error('Write permission was not granted.'); const file = await photo.handle.getFile(); const values = formValues(); const payload = new FormData(); payload.append('file', file, photo.name); payload.append('precision', values.precision); payload.append('year', values.year); if (values.precision === 'exact' || values.precision === 'month') payload.append('month', values.month); if (values.precision === 'exact') payload.append('day', values.day); if (values.timeMode === 'known') payload.append('time_value', values.time); payload.append('description', values.description); payload.append('keywords_json', JSON.stringify(parseKeywords(values.keywords))); const hasCoordinates = validCoordinates(values.latitude, values.longitude); const gpsAction = values.locationDirty ? (hasCoordinates ? 'set' : 'clear') : 'preserve'; payload.append('gps_action', gpsAction); if (gpsAction === 'set') { payload.append('latitude', values.latitude); payload.append('longitude', values.longitude); payload.append('location_name', values.locationName); } const saveStarted = performance.now(); setSaveStatus('saving', 'Uploading and rewriting metadata…', `${photo.name} · large RAW files can take a while`); const response = await fetch('/api/process', { method: 'POST', body: payload }); if (!response.ok) { let message = `Save failed (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } const responseReceivedAt = performance.now(); const firstByteSeconds = (responseReceivedAt - saveStarted) / 1000; const writable = await photo.handle.createWritable({ keepExistingData: false }); let timing; let responseReader = null; try { if (response.body && typeof response.body.getReader === 'function') { responseReader = response.body.getReader(); const expectedBytes = Number(response.headers.get('content-length')) || 0; let receivedBytes = 0; let lastProgressUpdate = 0; while (true) { const { done, value } = await responseReader.read(); if (done) break; await writable.write(value); receivedBytes += value.byteLength; const now = performance.now(); if (now - lastProgressUpdate >= 200) { const progress = expectedBytes ? `${Math.min(100, (receivedBytes / expectedBytes) * 100).toFixed(0)}% · ${formatBytes(receivedBytes)} / ${formatBytes(expectedBytes)}` : `${formatBytes(receivedBytes)} received`; setSaveStatus('saving', 'Streaming updated file…', `${photo.name} · ${progress}`); lastProgressUpdate = now; } } await writable.close(); const completedAt = performance.now(); timing = { streamed: true, total: (completedAt - saveStarted) / 1000, firstByte: firstByteSeconds, streamWrite: (completedAt - responseReceivedAt) / 1000, }; } else { const updatedBlob = await response.blob(); const serverAndTransferSeconds = (performance.now() - saveStarted) / 1000; setSaveStatus('saving', 'Writing updated file locally…', `${photo.name} · buffered compatibility mode`); const localWriteStarted = performance.now(); await writable.write(updatedBlob); await writable.close(); const localWriteSeconds = (performance.now() - localWriteStarted) / 1000; timing = { streamed: false, total: serverAndTransferSeconds + localWriteSeconds, serverTransfer: serverAndTransferSeconds, localWrite: localWriteSeconds, }; } } catch (error) { if (responseReader) await responseReader.cancel().catch(() => {}); await writable.abort().catch(() => {}); throw error; } const updatedFile = await photo.handle.getFile(); photo.size = updatedFile.size; photo.lastModified = updatedFile.lastModified; photo.status = 'saved'; const savedValues = { ...values, locationDirty: false }; state.locationDirty = false; photo.values = savedValues; photo.fileValues = { ...savedValues }; photo.metadataLoaded = true; photo.metadataPresent = true; photo.lastSaveTiming = timing; saveFolderState(); setSaveStatus( 'saved', 'Saved in place', `${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`, ); showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}`); updateProgress(); const oldThumb = state.thumbUrls.get(photo.name); if (oldThumb) URL.revokeObjectURL(oldThumb); state.thumbUrls.delete(photo.name); if (moveNext && nextVisibleIndex !== null) { await selectPhoto(nextVisibleIndex, true); } else { await selectPhoto(state.currentIndex, true); } } catch (error) { photo.status = 'failed'; saveFolderState(); setSaveStatus('failed', 'Save failed', error.message || 'The original file was not changed.'); showToast(error.message || 'Save failed.', true); renderFileList(); updateProgress(); } finally { state.processing = false; updateNavigation(); } } function skipCurrentPhoto() { if (state.processing || state.currentIndex < 0) return; const photo = state.photos[state.currentIndex]; const nextVisibleIndex = adjacentVisibleIndex(1); captureCurrentValues(); photo.status = 'skipped'; saveFolderState(); updateProgress(); renderFileList(); showToast(`Skipped ${photo.name}`); if (nextVisibleIndex !== null) selectPhoto(nextVisibleIndex); else updatePhotoStatus(photo); } function updateProgress() { const saved = state.photos.filter((photo) => photo.status === 'saved').length; const skipped = state.photos.filter((photo) => photo.status === 'skipped').length; const failed = state.photos.filter((photo) => photo.status === 'failed').length; const pending = state.photos.filter((photo) => photo.status === 'pending').length; const processed = saved + skipped; const total = state.photos.length; const percentage = (count) => total ? `${(count / total) * 100}%` : '0%'; elements.progressCount.textContent = `${processed} / ${total}`; elements.progressSaved.style.width = percentage(saved); elements.progressSkipped.style.width = percentage(skipped); elements.progressFailed.style.width = percentage(failed); elements.progressCount.title = `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`; const track = elements.progressSaved.parentElement; track.setAttribute('aria-valuenow', total ? String(Math.round((processed / total) * 100)) : '0'); track.setAttribute('aria-valuetext', `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`); } function goRelative(delta) { const target = adjacentVisibleIndex(delta); if (target !== null) selectPhoto(target); } function copyPreviousValues() { if (state.currentIndex <= 0) return; captureCurrentValues(); const previous = state.photos[state.currentIndex - 1].values || state.photos[state.currentIndex - 1].fileValues; if (!previous) return showToast('The previous photo has no entered values yet.', true); restoreValues({ ...previous, locationDirty: true }); state.locationDirty = true; captureCurrentValues(); showToast('Copied values from the previous photo.'); } function activeTabIndex() { return $$('.tab').findIndex((tab) => tab.classList.contains('active')); } function focusFirstControl(tabName) { const panel = document.querySelector(`.tab-panel[data-panel="${tabName}"]`); const first = panel?.querySelector('input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'); first?.focus(); } function activateTab(tabName, focusMode = 'none') { const tabs = $$('.tab'); const target = tabs.find((tab) => tab.dataset.tab === tabName); if (!target) return; tabs.forEach((item) => { const active = item === target; item.classList.toggle('active', active); item.setAttribute('aria-selected', String(active)); item.tabIndex = active ? 0 : -1; }); $$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tabName)); if (tabName === 'location') { initialiseMap(); window.setTimeout(() => { state.map?.invalidateSize(); syncMapFromFields(false); if (focusMode === 'field') focusFirstControl(tabName); else if (focusMode === 'tab') target.focus(); }, 0); } else if (focusMode === 'field') { window.setTimeout(() => focusFirstControl(tabName), 0); } else if (focusMode === 'tab') { target.focus(); } } function cycleTabs(delta, focusMode = 'field') { const tabs = $$('.tab'); if (!tabs.length) return; const current = Math.max(0, activeTabIndex()); const next = (current + delta + tabs.length) % tabs.length; activateTab(tabs[next].dataset.tab, focusMode); } function markLocationDirty() { state.locationDirty = true; captureCurrentValues(); } function setMapStatus(message, isError = false) { if (!elements.mapStatus) return; elements.mapStatus.textContent = message; elements.mapStatus.classList.toggle('error', isError); } function initialiseMap() { if (state.map || !elements.locationMap) return; if (typeof window.L === 'undefined') { setMapStatus('Map library failed to load. Rebuild the container and hard-refresh the page.', true); return; } const config = state.mapConfig || {}; try { state.map = L.map(elements.locationMap, { zoomControl: true }).setView( [Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)], Number(config.defaultMapZoom ?? 5), ); const layer = L.tileLayer(config.mapTileUrl || '/api/map/tiles/{z}/{x}/{y}.png', { maxZoom: 19, attribution: config.mapAttribution || '© OpenStreetMap contributors', }).addTo(state.map); layer.on('loading', () => setMapStatus('Loading map…')); layer.on('load', () => setMapStatus('Click the map to place a pin.')); layer.on('tileerror', () => setMapStatus('Map tiles could not be loaded. Address search and manual coordinates still work.', true)); state.map.on('click', (event) => { setLocation(event.latlng.lat, event.latlng.lng, '', true, false); }); syncMapFromFields(false); } catch (error) { state.map = null; setMapStatus(error.message || 'The map could not be initialized.', true); } } function setMarker(latitude, longitude, center = false) { if (!state.map || !validCoordinates(latitude, longitude)) return; const lat = Number(latitude); const lon = Number(longitude); if (!state.mapMarker) { state.mapMarker = L.marker([lat, lon], { draggable: true }).addTo(state.map); state.mapMarker.on('dragend', () => { const point = state.mapMarker.getLatLng(); setLocation(point.lat, point.lng, '', true, false); }); } else { state.mapMarker.setLatLng([lat, lon]); } if (center) state.map.setView([lat, lon], Math.max(state.map.getZoom(), 15)); } function removeMarker() { if (state.map && state.mapMarker) state.map.removeLayer(state.mapMarker); state.mapMarker = null; } function setLocation(latitude, longitude, label = '', dirty = true, center = true) { elements.latitude.value = Number(latitude).toFixed(6); elements.longitude.value = Number(longitude).toFixed(6); elements.locationName.value = label; if (dirty) state.locationDirty = true; setMarker(latitude, longitude, center); captureCurrentValues(); } function syncMapFromFields(center = false) { if (!state.map) return; if (validCoordinates(elements.latitude.value, elements.longitude.value)) { setMarker(elements.latitude.value, elements.longitude.value, center); } else { removeMarker(); } } function clearLocation() { elements.latitude.value = ''; elements.longitude.value = ''; elements.locationName.value = ''; state.locationDirty = true; removeMarker(); captureCurrentValues(); showToast('Location cleared. Save the photo to remove GPS metadata.'); } function renderFavoriteLocations(selectedId = '') { const favorites = state.favoriteLocations; elements.favoriteSelect.innerHTML = ''; if (!favorites.length) { const option = document.createElement('option'); option.value = ''; option.textContent = 'No favorites saved'; elements.favoriteSelect.appendChild(option); elements.favoriteSelect.disabled = true; elements.useFavorite.disabled = true; elements.deleteFavorite.disabled = true; return; } const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = 'Choose a favorite…'; elements.favoriteSelect.appendChild(placeholder); for (const favorite of favorites) { const option = document.createElement('option'); option.value = favorite.id; option.textContent = `${favorite.name} — ${Number(favorite.latitude).toFixed(5)}, ${Number(favorite.longitude).toFixed(5)}`; elements.favoriteSelect.appendChild(option); } elements.favoriteSelect.disabled = false; elements.favoriteSelect.value = favorites.some((item) => item.id === selectedId) ? selectedId : ''; updateFavoriteButtons(); } function updateFavoriteButtons() { const selected = Boolean(elements.favoriteSelect.value); elements.useFavorite.disabled = !selected; elements.deleteFavorite.disabled = !selected; } async function loadFavoriteLocations(selectedId = '') { try { const response = await fetch('/api/favorites', { cache: 'no-store' }); if (!response.ok) throw new Error(`Could not load favorites (${response.status}).`); const data = await response.json(); state.favoriteLocations = Array.isArray(data.favorites) ? data.favorites : []; renderFavoriteLocations(selectedId); } catch (error) { state.favoriteLocations = []; renderFavoriteLocations(); showToast(error.message || 'Could not load favorite locations.', true); } } function useSelectedFavorite() { const favorite = state.favoriteLocations.find((item) => item.id === elements.favoriteSelect.value); if (!favorite) return; setLocation(favorite.latitude, favorite.longitude, favorite.name, true, true); showToast(`Location set to ${favorite.name}.`); } async function saveCurrentAsFavorite() { if (!validCoordinates(elements.latitude.value, elements.longitude.value)) { showToast('Set a valid location before saving it as a favorite.', true); return; } const suggested = elements.locationName.value.trim() || 'Favorite location'; const entered = window.prompt('Name this favorite location:', suggested); if (entered === null) return; const name = entered.trim(); if (!name) { showToast('Favorite name cannot be empty.', true); return; } elements.saveFavorite.disabled = true; try { const response = await fetch('/api/favorites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, latitude: Number(elements.latitude.value), longitude: Number(elements.longitude.value), }), }); if (!response.ok) { let message = `Could not save favorite (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } const data = await response.json(); await loadFavoriteLocations(data.favorite?.id || ''); showToast(`${name} added to favorite locations.`); } catch (error) { showToast(error.message || 'Could not save favorite location.', true); } finally { elements.saveFavorite.disabled = false; } } async function deleteSelectedFavorite() { const favorite = state.favoriteLocations.find((item) => item.id === elements.favoriteSelect.value); if (!favorite) return; if (!window.confirm(`Delete favorite location “${favorite.name}”?`)) return; elements.deleteFavorite.disabled = true; try { const response = await fetch(`/api/favorites/${encodeURIComponent(favorite.id)}`, { method: 'DELETE' }); if (!response.ok) { let message = `Could not delete favorite (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } await loadFavoriteLocations(); showToast(`${favorite.name} removed from favorite locations.`); } catch (error) { showToast(error.message || 'Could not delete favorite location.', true); updateFavoriteButtons(); } } async function searchAddress() { const query = elements.addressSearch.value.trim(); if (query.length < 3) { showToast('Enter at least three characters to search.', true); return; } elements.addressSearchButton.disabled = true; elements.addressResults.innerHTML = '
Searching…
'; try { const response = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`, { cache: 'no-store' }); if (!response.ok) { let message = `Address search failed (${response.status}).`; try { message = (await response.json()).detail || message; } catch {} throw new Error(message); } const data = await response.json(); const results = Array.isArray(data.results) ? data.results : []; elements.addressResults.innerHTML = ''; if (!results.length) { elements.addressResults.innerHTML = '
No matching places found.
'; return; } for (const result of results) { const button = document.createElement('button'); button.type = 'button'; button.className = 'address-result'; button.innerHTML = `${escapeHtml(result.label)}${Number(result.latitude).toFixed(5)}, ${Number(result.longitude).toFixed(5)}`; button.addEventListener('click', () => { setLocation(result.latitude, result.longitude, result.label, true, true); elements.addressResults.innerHTML = ''; }); elements.addressResults.appendChild(button); } } catch (error) { elements.addressResults.innerHTML = ''; showToast(error.message || 'Address search failed.', true); } finally { elements.addressSearchButton.disabled = false; } } function applyView() { elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`; } function resetView() { state.zoom = 1; state.rotation = 0; applyView(); } for (const button of elements.openButtons) button.addEventListener('click', openFolder); elements.rescan.addEventListener('click', scanFolder); elements.filter.addEventListener('input', () => { renderFileList(); updateNavigation(); }); elements.statusFilter.addEventListener('change', () => { renderFileList(); updateNavigation(); }); elements.sortSelect.addEventListener('change', () => { renderFileList(); updateNavigation(); }); elements.previous.addEventListener('click', () => goRelative(-1)); elements.skip.addEventListener('click', skipCurrentPhoto); elements.save.addEventListener('click', () => savePhoto(false)); elements.saveNext.addEventListener('click', () => savePhoto(true)); elements.copyPrevious.addEventListener('click', copyPreviousValues); elements.addressSearchButton.addEventListener('click', searchAddress); elements.addressSearch.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); searchAddress(); } }); elements.clearLocation.addEventListener('click', clearLocation); elements.favoriteSelect.addEventListener('change', updateFavoriteButtons); elements.useFavorite.addEventListener('click', useSelectedFavorite); elements.saveFavorite.addEventListener('click', saveCurrentAsFavorite); elements.deleteFavorite.addEventListener('click', deleteSelectedFavorite); for (const input of [elements.latitude, elements.longitude]) { input.addEventListener('input', () => { state.locationDirty = true; syncMapFromFields(false); }); input.addEventListener('change', () => syncMapFromFields(true)); } elements.locationName.addEventListener('input', markLocationDirty); elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); }); elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); }); elements.fit.addEventListener('click', resetView); elements.rotateLeft.addEventListener('click', () => { state.rotation -= 90; applyView(); }); elements.rotateRight.addEventListener('click', () => { state.rotation += 90; applyView(); }); elements.resetView.addEventListener('click', resetView); elements.timeMode.addEventListener('change', () => elements.timeField.classList.toggle('hidden', elements.timeMode.value !== 'known')); elements.description.addEventListener('input', () => { elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`; captureCurrentValues(); }); elements.form.addEventListener('input', captureCurrentValues); for (const radio of $$('input[name="precision"]')) radio.addEventListener('change', updatePrecisionFields); for (const tab of $$('.tab')) { tab.addEventListener('click', () => activateTab(tab.dataset.tab)); } activateTab('date'); elements.shortcutsButton.addEventListener('click', () => elements.shortcutsDialog.showModal()); elements.closeShortcuts.addEventListener('click', () => elements.shortcutsDialog.close()); window.addEventListener('keydown', (event) => { if (elements.shortcutsDialog.open) return; const tag = document.activeElement?.tagName; const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag); if (event.key === 'Tab' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey && document.activeElement?.closest('.right-panel')) { event.preventDefault(); event.stopPropagation(); cycleTabs(-1, 'field'); return; } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); savePhoto(false); return; } if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); savePhoto(true); return; } if (!typing && event.key === 'Enter') { event.preventDefault(); savePhoto(true); } else if (!typing && event.key === 'ArrowLeft') goRelative(-1); else if (!typing && event.key === 'ArrowRight') goRelative(1); else if (!typing && event.key.toLowerCase() === 's') skipCurrentPhoto(); else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues(); }); function loadAppConfig() { if (!state.configPromise) { state.configPromise = (async () => { const response = await fetch('/api/config', { cache: 'no-store' }); if (!response.ok) throw new Error(`Configuration request failed (${response.status}).`); const config = await response.json(); const extensions = Array.isArray(config.enabledExtensions) ? config.enabledExtensions : []; if (!extensions.length) throw new Error('The server has no enabled photo formats.'); const pageTitle = config.appTitle || config.appName; elements.appName.textContent = pageTitle; elements.appSubtitle.textContent = config.appSubtitle; document.title = pageTitle; state.mapConfig = config; state.enabledExtensions = new Set(extensions.map((value) => String(value).toLowerCase())); state.enabledFormatLabel = config.enabledFormatLabel || 'photo'; return config; })(); } return state.configPromise; } async function initialise() { try { await loadAppConfig(); } catch (error) { setSaveStatus('failed', 'Configuration unavailable', error.message || 'Could not load enabled formats.'); for (const button of elements.openButtons) button.disabled = true; } // Favorite locations are server-side shared data and must be loaded on every page start, // independently of whether the user opens a photo folder in this browser session. await loadFavoriteLocations(); if (!isSupported()) { setSaveStatus('failed', 'Browser folder access unavailable', 'Use a current Chromium browser over HTTPS.'); for (const button of elements.openButtons) button.title = 'Requires a Chromium browser and HTTPS'; } if ('serviceWorker' in navigator) navigator.serviceWorker.register('/service-worker.js').catch(() => {}); } initialise();