Initial JPEG photo metadata editor v0.1

This commit is contained in:
dmo committed 2026-07-23 01:10:49 +02:00
1 parent cdc206680c
commit 8738b8c814
12 files changed
+1222

No files matched your search

+489
View File
@@ -0,0 +1,489 @@
const state = {
directoryHandle: null,
photos: [],
currentIndex: -1,
objectUrl: null,
thumbUrls: new Map(),
zoom: 1,
rotation: 0,
savedValues: new Map(),
processing: false,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
const elements = {
appName: $('#app-name'),
openButtons: [$('#open-folder'), $('#open-folder-top'), $('#open-folder-center')],
rescan: $('#rescan-folder'),
folderName: $('#folder-name'),
folderAccess: $('#folder-access'),
progressCount: $('#progress-count'),
progressBar: $('#progress-bar'),
photoCount: $('#photo-count'),
filter: $('#filter-input'),
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'),
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 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,
};
}
localStorage.setItem(key, JSON.stringify({ photos: photoState }));
}
async function openFolder() {
if (!isSupported()) {
showToast('Use current Chrome or Edge over HTTPS. Folder access is unavailable here.', true);
return;
}
try {
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' || !/\.(jpe?g)$/i.test(name)) continue;
const file = await handle.getFile();
const previous = stored.photos?.[name] || {};
photos.push({
name,
handle,
size: file.size,
lastModified: file.lastModified,
status: previous.status || 'pending',
values: previous.values || null,
});
}
photos.sort((a, b) => naturalCompare(a.name, b.name));
state.photos = photos;
state.currentIndex = photos.length ? 0 : -1;
elements.folderName.textContent = state.directoryHandle.name;
elements.folderAccess.textContent = 'Read/write access granted';
elements.filter.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 JPG or JPEG 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 getThumbUrl(photo) {
if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name);
const file = await photo.handle.getFile();
const url = URL.createObjectURL(file);
state.thumbUrls.set(photo.name, url);
return url;
}
function renderFileList() {
const filter = elements.filter.value.trim().toLowerCase();
const matching = state.photos
.map((photo, index) => ({ photo, index }))
.filter(({ photo }) => !filter || photo.name.toLowerCase().includes(filter));
elements.fileList.innerHTML = '';
if (!matching.length) {
elements.fileList.innerHTML = '<div class="empty-list">No matching photos.</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);
const symbol = photo.status === 'saved' ? '✓' : photo.status === 'failed' ? '!' : '';
row.innerHTML = `
<div class="file-thumb" aria-hidden="true"></div>
<div class="file-meta"><strong>${escapeHtml(photo.name)}</strong><span>${index + 1} / ${state.photos.length} · ${formatBytes(photo.size)}</span></div>
<span class="file-state ${photo.status}" title="${photo.status}">${symbol}</span>`;
row.addEventListener('click', () => selectPhoto(index));
elements.fileList.appendChild(row);
getThumbUrl(photo).then((url) => {
const placeholder = row.querySelector('.file-thumb');
const image = document.createElement('img');
image.className = 'file-thumb';
image.alt = '';
image.src = url;
placeholder.replaceWith(image);
}).catch(() => {});
}
}
async function selectPhoto(index, force = false) {
if (index < 0 || index >= state.photos.length || (state.processing && !force)) return;
captureCurrentValues();
state.currentIndex = index;
const photo = state.photos[index];
const file = await photo.handle.getFile();
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
state.objectUrl = URL.createObjectURL(file);
elements.preview.src = state.objectUrl;
elements.filename.textContent = photo.name;
elements.filesize.textContent = `${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
resetView();
restoreValues(photo.values);
setSaveStatus(
photo.status === 'saved' ? 'saved' : photo.status === 'failed' ? 'failed' : 'idle',
photo.status === 'saved' ? 'Previously saved' : photo.status === 'failed' ? 'Previous save failed' : 'Ready to edit',
photo.name,
);
updateNavigation();
renderFileList();
const active = elements.fileList.querySelector('.file-row.active');
active?.scrollIntoView({ block: 'nearest' });
}
function updateNavigation() {
const hasPhoto = state.currentIndex >= 0;
elements.previous.disabled = !hasPhoto || state.currentIndex === 0 || 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,
};
}
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: '',
};
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.timeField.classList.toggle('hidden', value.timeMode !== 'known');
elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`;
updatePrecisionFields();
}
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];
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 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';
photo.values = values;
saveFolderState();
setSaveStatus('saved', 'Saved in place', `${photo.name} was overwritten successfully.`);
showToast(`Saved ${photo.name}`);
updateProgress();
const oldThumb = state.thumbUrls.get(photo.name);
if (oldThumb) URL.revokeObjectURL(oldThumb);
state.thumbUrls.delete(photo.name);
if (moveNext && state.currentIndex < state.photos.length - 1) {
await selectPhoto(state.currentIndex + 1, 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 updateProgress() {
const saved = state.photos.filter((photo) => photo.status === 'saved').length;
const total = state.photos.length;
elements.progressCount.textContent = `${saved} / ${total}`;
elements.progressBar.style.width = total ? `${(saved / total) * 100}%` : '0%';
}
function goRelative(delta) {
const target = state.currentIndex + delta;
if (target >= 0 && target < state.photos.length) selectPhoto(target);
}
function copyPreviousValues() {
if (state.currentIndex <= 0) return;
captureCurrentValues();
const previous = state.photos[state.currentIndex - 1].values;
if (!previous) return showToast('The previous photo has no entered values yet.', true);
restoreValues({ ...previous });
captureCurrentValues();
showToast('Copied values from the previous photo.');
}
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);
elements.previous.addEventListener('click', () => goRelative(-1));
elements.skip.addEventListener('click', () => goRelative(1));
elements.save.addEventListener('click', () => savePhoto(false));
elements.saveNext.addEventListener('click', () => savePhoto(true));
elements.copyPrevious.addEventListener('click', copyPreviousValues);
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', () => {
$$('.tab').forEach((item) => item.classList.toggle('active', item === tab));
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab));
});
}
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.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') goRelative(1);
else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues();
});
async function initialise() {
try {
const response = await fetch('/api/config', { cache: 'no-store' });
if (response.ok) {
const config = await response.json();
elements.appName.textContent = config.appName;
document.title = config.appName;
}
} catch {}
if (!isSupported()) {
setSaveStatus('failed', 'Browser folder access unavailable', 'Use current Chrome or Edge over HTTPS.');
for (const button of elements.openButtons) button.title = 'Requires Chrome/Edge and HTTPS';
}
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/service-worker.js').catch(() => {});
}
initialise();