Add metadata display and skipped state v0.2

This commit is contained in:
dmo committed 2026-07-23 01:12:10 +02:00
1 parent 8738b8c814
commit e2f5c91138
8 files changed
+322 -40

No files matched your search

+124 -20
View File
@@ -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(() => {});
+5 -4
View File
@@ -31,7 +31,7 @@
<div class="folder-row">
<div>
<strong id="folder-name">No folder selected</strong>
<span id="folder-access">Choose a local Chromebook folder</span>
<span id="folder-access">Choose a local folder</span>
</div>
<button id="open-folder" class="button compact">Change</button>
</div>
@@ -45,6 +45,7 @@
<div class="progress-track"><div id="progress-bar" class="progress-bar"></div></div>
<div class="status-legend">
<span><i class="dot saved"></i>Saved</span>
<span><i class="dot skipped"></i>Skipped</span>
<span><i class="dot pending"></i>Pending</span>
<span><i class="dot failed"></i>Failed</span>
</div>
@@ -96,7 +97,7 @@
<div id="viewer-position" class="position">0 / 0</div>
<div class="navigation-actions">
<button id="skip-button" class="button ghost">Skip</button>
<button id="save-button" class="button">Save</button>
<button id="save-button" class="button">Save current</button>
<button id="save-next-button" class="button primary">Save &amp; Next →</button>
</div>
</div>
@@ -192,9 +193,9 @@
<div class="dialog-heading"><h2>Keyboard shortcuts</h2><button id="close-shortcuts" class="icon-button">×</button></div>
<dl class="shortcut-list">
<div><dt>Enter / Ctrl+Enter</dt><dd>Save and open next photo</dd></div>
<div><dt>Ctrl+S</dt><dd>Save without moving</dd></div>
<div><dt>Ctrl+S</dt><dd>Save current photo and stay on it</dd></div>
<div><dt>← / →</dt><dd>Previous or next photo</dd></div>
<div><dt>S</dt><dd>Skip to next photo</dd></div>
<div><dt>S</dt><dd>Mark skipped and open next photo</dd></div>
<div><dt>C</dt><dd>Copy previous photo's values</dd></div>
</dl>
</dialog>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v1';
const CACHE_NAME = 'photo-date-editor-v2';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
+3 -2
View File
@@ -45,7 +45,7 @@ button { color: inherit; }
.progress-bar { width: 0; height: 100%; background: var(--primary); transition: width .2s ease; }
.status-legend { display: flex; gap: 12px; margin-top: 11px; color: var(--muted); font-size: 11px; }
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
.dot.saved { background: var(--success); }.dot.pending { background: #748294; }.dot.failed { background: var(--danger); }
.dot.saved { background: var(--success); }.dot.skipped { background: #c49a5a; }.dot.pending { background: #748294; }.dot.failed { background: var(--danger); }
.file-section { flex: 1; display: flex; min-height: 0; flex-direction: column; }
.file-list { flex: 1; min-height: 120px; overflow: auto; margin-top: 10px; padding-right: 3px; }
.empty-list { color: var(--muted); font-size: 13px; line-height: 1.55; padding: 15px 5px; }
@@ -58,6 +58,7 @@ button { color: inherit; }
.file-meta strong { font-size: 13px; }.file-meta span { margin-top: 4px; color: var(--muted); font-size: 11px; }
.file-state { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; font-size: 11px; border: 1px solid #536071; color: #8390a0; }
.file-state.saved { color: #092b1b; background: var(--success); border-color: var(--success); font-weight: 800; }
.file-state.skipped { color: #2e2111; background: #c49a5a; border-color: #c49a5a; font-weight: 800; }
.file-state.failed { color: white; background: var(--danger); border-color: var(--danger); }
.sidebar-footer, .right-footer { padding: 16px 18px; border-top: 1px solid var(--line-soft); margin-top: auto; }
@@ -88,7 +89,7 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
.textarea { resize: vertical; min-height: 125px; line-height: 1.55; }
.field-help, .helper-row { color: var(--muted); font-size: 11px; line-height: 1.5; }.helper-row { display: flex; justify-content: space-between; margin-top: -9px; }
.info-card { border: 1px solid #4c4434; background: #211d16; padding: 12px; border-radius: 8px; color: #d7c8a9; font-size: 12px; line-height: 1.5; }.info-card strong { color: #ead4a6; }.info-card p { margin: 4px 0 0; }
.save-status { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }.save-status .status-icon { color: #748294; }.save-status strong, .save-status span { display: block; }.save-status strong { font-size: 13px; }.save-status span { color: var(--muted); font-size: 11px; margin-top: 3px; }.save-status.saving .status-icon { color: var(--warning); }.save-status.saved .status-icon { color: var(--success); }.save-status.failed .status-icon { color: var(--danger); }
.save-status { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }.save-status .status-icon { color: #748294; }.save-status strong, .save-status span { display: block; }.save-status strong { font-size: 13px; }.save-status span { color: var(--muted); font-size: 11px; margin-top: 3px; }.save-status.loading .status-icon, .save-status.saving .status-icon { color: var(--warning); }.save-status.saved .status-icon { color: var(--success); }.save-status.skipped .status-icon { color: #c49a5a; }.save-status.failed .status-icon { color: var(--danger); }
.button, .icon-button { border: 1px solid #354150; background: #1a232d; border-radius: 7px; cursor: pointer; transition: .15s ease; }
.button { padding: 10px 14px; }.button:hover, .icon-button:hover { border-color: #566578; background: #222d39; }.button:disabled, .icon-button:disabled { opacity: .45; cursor: not-allowed; }.button.primary { border-color: #4f87ff; background: #376fdf; }.button.primary:hover { background: #477feb; }.button.ghost { background: transparent; }.button.compact { padding: 8px 11px; }.button.large { padding: 12px 18px; }.button.wide { width: 100%; }.icon-button { width: 38px; height: 38px; display: grid; place-items: center; font-size: 18px; }