Initial JPEG photo metadata editor v0.1
This commit is contained in:
1 parent
cdc206680c
commit
8738b8c814
12 files changed
+1222
No files matched your search
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[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();
|
||||
Reference in new issue
Block a user