Files
mExifEditor/app/static/app.js
T

1234 lines
48 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'),
rotationStatus: $('#rotation-status'),
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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[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,
pendingRotation: 0,
});
}
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') {
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}`;
state.zoom = 1;
state.rotation = normalizeRotation(photo.pendingRotation || 0);
applyView();
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 rotationToSave = supportsPixelRotation(photo) ? normalizeRotation(state.rotation) : 0;
payload.append('rotation', String(rotationToSave));
const saveStarted = performance.now();
setSaveStatus('saving', 'Uploading and applying changes…', `${photo.name} · large 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 rotationMode = response.headers.get('x-pixel-rotation') || 'none';
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.pendingRotation = 0;
state.rotation = 0;
photo.lastSaveTiming = timing;
saveFolderState();
setSaveStatus(
'saved',
'Saved in place',
`${formatMetadataSummary(savedValues)} · ${formatSaveTiming(timing)}`,
);
const rotationNote = rotationMode === 'jpeg-reencoded'
? ' · rotation applied; JPEG re-encoded at high quality'
: rotationMode !== 'none' ? ' · pixel rotation applied' : '';
showToast(`Saved ${photo.name}: ${formatSaveTiming(timing)}${rotationNote}`);
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 || '&copy; 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)`;
updateRotationStatus();
}
function normalizeRotation(rotation) {
return ((Number(rotation) % 360) + 360) % 360;
}
function supportsPixelRotation(photo) {
return Boolean(photo && ['JPG', 'PNG', 'TIFF'].includes(photo.format));
}
function updateRotationStatus() {
const rotation = normalizeRotation(state.rotation);
const photo = state.photos[state.currentIndex];
elements.rotationStatus.classList.toggle('hidden', rotation === 0 || !photo);
if (!rotation || !photo) return;
const supported = supportsPixelRotation(photo);
elements.rotationStatus.classList.toggle('preview-only', !supported);
elements.rotationStatus.textContent = supported
? `Rotation ${rotation}° will be applied to the image when saved`
: `Preview rotation ${rotation}° · ${photo.format} files are not altered`;
}
function setPendingRotation(rotation) {
state.rotation = normalizeRotation(rotation);
const photo = state.photos[state.currentIndex];
if (photo) photo.pendingRotation = state.rotation;
applyView();
}
function fitView() {
state.zoom = 1;
applyView();
}
function resetView() {
state.zoom = 1;
setPendingRotation(0);
}
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', fitView);
elements.rotateLeft.addEventListener('click', () => setPendingRotation(state.rotation - 90));
elements.rotateRight.addEventListener('click', () => setPendingRotation(state.rotation + 90));
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();