Add metadata display and skipped state v0.2
This commit is contained in:
1 parent
8738b8c814
commit
e2f5c91138
8 files changed
+322
-40
No files matched your search
+124
-20
@@ -6,8 +6,8 @@ const state = {
|
||||
thumbUrls: new Map(),
|
||||
zoom: 1,
|
||||
rotation: 0,
|
||||
savedValues: new Map(),
|
||||
processing: false,
|
||||
selectionToken: 0,
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -110,6 +110,7 @@ function saveFolderState() {
|
||||
photoState[photo.name] = {
|
||||
status: photo.status,
|
||||
values: photo.values || null,
|
||||
lastModified: photo.lastModified,
|
||||
};
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify({ photos: photoState }));
|
||||
@@ -117,7 +118,7 @@ function saveFolderState() {
|
||||
|
||||
async function openFolder() {
|
||||
if (!isSupported()) {
|
||||
showToast('Use current Chrome or Edge over HTTPS. Folder access is unavailable here.', true);
|
||||
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,19 +143,23 @@ async function scanFolder() {
|
||||
if (handle.kind !== 'file' || !/\.(jpe?g)$/i.test(name)) continue;
|
||||
const file = await handle.getFile();
|
||||
const previous = stored.photos?.[name] || {};
|
||||
const unchanged = !previous.lastModified || previous.lastModified === file.lastModified;
|
||||
photos.push({
|
||||
name,
|
||||
handle,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
status: previous.status || 'pending',
|
||||
values: previous.values || null,
|
||||
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 = photos.length ? 0 : -1;
|
||||
state.currentIndex = -1;
|
||||
elements.folderName.textContent = state.directoryHandle.name;
|
||||
elements.folderAccess.textContent = 'Read/write access granted';
|
||||
elements.filter.disabled = !photos.length;
|
||||
@@ -190,6 +195,13 @@ async function getThumbUrl(photo) {
|
||||
return url;
|
||||
}
|
||||
|
||||
function statusSymbol(status) {
|
||||
if (status === 'saved') return '✓';
|
||||
if (status === 'skipped') return '→';
|
||||
if (status === 'failed') return '!';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderFileList() {
|
||||
const filter = elements.filter.value.trim().toLowerCase();
|
||||
const matching = state.photos
|
||||
@@ -207,11 +219,10 @@ function renderFileList() {
|
||||
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>`;
|
||||
<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) => {
|
||||
@@ -225,10 +236,69 @@ function renderFileList() {
|
||||
}
|
||||
}
|
||||
|
||||
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'}`);
|
||||
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();
|
||||
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
|
||||
@@ -239,15 +309,25 @@ async function selectPhoto(index, force = false) {
|
||||
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,
|
||||
);
|
||||
updatePhotoStatus(photo);
|
||||
updateNavigation();
|
||||
renderFileList();
|
||||
const active = elements.fileList.querySelector('.file-row.active');
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
if (!photo.metadataLoaded) {
|
||||
if (!photo.values) setSaveStatus('loading', 'Reading EXIF 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 updateNavigation() {
|
||||
@@ -282,6 +362,11 @@ function formValues() {
|
||||
};
|
||||
}
|
||||
|
||||
function formHasUserInput() {
|
||||
const values = formValues();
|
||||
return Boolean(values.year || values.description.trim() || values.keywords.trim());
|
||||
}
|
||||
|
||||
function captureCurrentValues() {
|
||||
if (state.currentIndex < 0 || !state.photos[state.currentIndex]) return;
|
||||
state.photos[state.currentIndex].values = formValues();
|
||||
@@ -367,8 +452,11 @@ async function savePhoto(moveNext) {
|
||||
photo.lastModified = updatedFile.lastModified;
|
||||
photo.status = 'saved';
|
||||
photo.values = values;
|
||||
photo.fileValues = { ...values };
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
saveFolderState();
|
||||
setSaveStatus('saved', 'Saved in place', `${photo.name} was overwritten successfully.`);
|
||||
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(values));
|
||||
showToast(`Saved ${photo.name}`);
|
||||
updateProgress();
|
||||
|
||||
@@ -394,11 +482,27 @@ async function savePhoto(moveNext) {
|
||||
}
|
||||
}
|
||||
|
||||
function skipCurrentPhoto() {
|
||||
if (state.processing || state.currentIndex < 0) return;
|
||||
const photo = state.photos[state.currentIndex];
|
||||
captureCurrentValues();
|
||||
photo.status = 'skipped';
|
||||
saveFolderState();
|
||||
updateProgress();
|
||||
renderFileList();
|
||||
showToast(`Skipped ${photo.name}`);
|
||||
if (state.currentIndex < state.photos.length - 1) goRelative(1);
|
||||
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 processed = saved + skipped;
|
||||
const total = state.photos.length;
|
||||
elements.progressCount.textContent = `${saved} / ${total}`;
|
||||
elements.progressBar.style.width = total ? `${(saved / total) * 100}%` : '0%';
|
||||
elements.progressCount.textContent = `${processed} / ${total}`;
|
||||
elements.progressBar.style.width = total ? `${(processed / total) * 100}%` : '0%';
|
||||
elements.progressCount.title = `${saved} saved, ${skipped} skipped`;
|
||||
}
|
||||
|
||||
function goRelative(delta) {
|
||||
@@ -409,7 +513,7 @@ function goRelative(delta) {
|
||||
function copyPreviousValues() {
|
||||
if (state.currentIndex <= 0) return;
|
||||
captureCurrentValues();
|
||||
const previous = state.photos[state.currentIndex - 1].values;
|
||||
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 });
|
||||
captureCurrentValues();
|
||||
@@ -425,7 +529,7 @@ for (const button of elements.openButtons) button.addEventListener('click', open
|
||||
elements.rescan.addEventListener('click', scanFolder);
|
||||
elements.filter.addEventListener('input', renderFileList);
|
||||
elements.previous.addEventListener('click', () => goRelative(-1));
|
||||
elements.skip.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);
|
||||
@@ -464,7 +568,7 @@ window.addEventListener('keydown', (event) => {
|
||||
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() === 's') skipCurrentPhoto();
|
||||
else if (!typing && event.key.toLowerCase() === 'c') copyPreviousValues();
|
||||
});
|
||||
|
||||
@@ -479,8 +583,8 @@ async function initialise() {
|
||||
} 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';
|
||||
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(() => {});
|
||||
|
||||
Reference in new issue
Block a user