1081 lines
42 KiB
JavaScript
1081 lines
42 KiB
JavaScript
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 = `<span class="status-icon">●</span><div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail)}</span></div>`;
|
|
}
|
|
|
|
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';
|
|
return extension.toUpperCase();
|
|
}
|
|
|
|
function requiresServerPreview(photo) {
|
|
return ['TIFF', 'HEIF', 'DNG', 'CR2', 'CR3'].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 = '<div class="empty-list">No photos match the current filters.</div>';
|
|
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 = `
|
|
<div class="file-thumb format-placeholder" aria-hidden="true">${escapeHtml(photo.format)}</div>
|
|
<div class="file-meta">
|
|
<strong>${escapeHtml(photo.name)}</strong>
|
|
<span class="file-details"><span>${index + 1} / ${state.photos.length}</span><span>${formatBytes(photo.size)}</span><span class="format-badge">${escapeHtml(photo.format)}</span></span>
|
|
</div>
|
|
<span class="file-state ${photo.status}" title="${photo.status}">${statusSymbol(photo.status)}</span>`;
|
|
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') {
|
|
setSaveStatus('saved', 'Previously saved', formatMetadataSummary(values));
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
const data = await response.json();
|
|
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 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 previewUrl = await getPreviewUrl(photo, file);
|
|
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
|
|
elements.preview.src = previewUrl;
|
|
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 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 updatedBlob = await response.blob();
|
|
const writable = await photo.handle.createWritable({ keepExistingData: false });
|
|
try {
|
|
await writable.write(updatedBlob);
|
|
await writable.close();
|
|
} catch (error) {
|
|
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;
|
|
saveFolderState();
|
|
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(savedValues));
|
|
showToast(`Saved ${photo.name}`);
|
|
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 = '<div class="search-message">Searching…</div>';
|
|
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 = '<div class="search-message">No matching places found.</div>';
|
|
return;
|
|
}
|
|
for (const result of results) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'address-result';
|
|
button.innerHTML = `<strong>${escapeHtml(result.label)}</strong><span>${Number(result.latitude).toFixed(5)}, ${Number(result.longitude).toFixed(5)}</span>`;
|
|
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();
|