Add PNG and TIFF support v0.5

This commit is contained in:
dmo committed 2026-07-23 11:23:49 +02:00
1 parent 0541790dba
commit 3acf6bb7ec
10 files changed
+230 -66

No files matched your search

+61 -12
View File
@@ -113,6 +113,18 @@ function formatBytes(bytes) {
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';
return extension.toUpperCase();
}
function requiresServerPreview(photo) {
return photo.format === 'TIFF';
}
function storageKey() {
return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null;
}
@@ -164,13 +176,14 @@ async function scanFolder() {
const photos = [];
for await (const [name, handle] of state.directoryHandle.entries()) {
if (handle.kind !== 'file' || !/\.(jpe?g)$/i.test(name)) continue;
if (handle.kind !== 'file' || !/\.(jpe?g|png|tiff?)$/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,
format: formatType(name),
size: file.size,
lastModified: file.lastModified,
status: unchanged ? (previous.status || 'pending') : 'pending',
@@ -202,7 +215,7 @@ async function scanFolder() {
} else {
elements.viewerContent.classList.add('hidden');
elements.viewerEmpty.classList.remove('hidden');
showToast('No JPG or JPEG files were found in that folder.', true);
showToast('No JPG, PNG, or TIFF files were found in that folder.', true);
}
}
@@ -213,14 +226,34 @@ function clearObjectUrls() {
state.thumbUrls.clear();
}
async function getThumbUrl(photo) {
async function getPreviewUrl(photo, suppliedFile = null) {
if (state.thumbUrls.has(photo.name)) return state.thumbUrls.get(photo.name);
const file = await photo.handle.getFile();
const url = URL.createObjectURL(file);
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 '→';
@@ -243,6 +276,8 @@ function visiblePhotoEntries() {
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));
}
@@ -264,12 +299,16 @@ function renderFileList() {
row.className = `file-row${index === state.currentIndex ? ' active' : ''}`;
row.dataset.index = String(index);
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>
<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';
@@ -346,11 +385,9 @@ async function selectPhoto(index, force = false) {
const selectionToken = ++state.selectionToken;
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.preview.removeAttribute('src');
elements.filename.textContent = photo.name;
elements.filesize.textContent = `${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
elements.filesize.textContent = `${photo.format} · ${formatBytes(file.size)} · modified ${new Date(file.lastModified).toLocaleString()}`;
elements.position.textContent = `${index + 1} / ${state.photos.length}`;
resetView();
restoreValues(photo.values);
@@ -360,8 +397,20 @@ async function selectPhoto(index, force = false) {
const active = elements.fileList.querySelector('.file-row.active');
active?.scrollIntoView({ block: 'nearest' });
try {
if (requiresServerPreview(photo)) setSaveStatus('loading', 'Rendering TIFF preview…', photo.name);
const previewUrl = await getPreviewUrl(photo, file);
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
elements.preview.src = previewUrl;
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 EXIF metadata…', photo.name);
if (!photo.values) setSaveStatus('loading', 'Reading image metadata…', photo.name);
try {
await readPhotoMetadata(photo, file);
if (selectionToken !== state.selectionToken || state.currentIndex !== index) return;
+3 -2
View File
@@ -16,7 +16,7 @@
<div class="brand-mark" aria-hidden="true">◫</div>
<div>
<h1 id="app-name">Photo Date Editor</h1>
<p>Local folder · in-place JPEG metadata</p>
<p>Local folder · in-place JPG, PNG and TIFF metadata</p>
</div>
</div>
<div class="top-actions">
@@ -70,10 +70,11 @@
<select id="sort-select" class="input filter" aria-label="Sort photos" disabled>
<option value="filename">Sort: Filename</option>
<option value="status">Sort: Status</option>
<option value="format">Sort: Format</option>
</select>
</div>
<div id="file-list" class="file-list" aria-live="polite">
<div class="empty-list">Choose a folder containing JPG or JPEG photos.</div>
<div class="empty-list">Choose a folder containing JPG, PNG, or TIFF photos.</div>
</div>
</section>
+1 -1
View File
@@ -5,5 +5,5 @@
"display": "standalone",
"background_color": "#0d1218",
"theme_color": "#121820",
"description": "Edit dates, descriptions, and keywords in scanned JPEG photos."
"description": "Edit dates, descriptions, keywords, and locations in scanned JPG, PNG, and TIFF photos."
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v4-3';
const CACHE_NAME = 'photo-date-editor-v5-0';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
self.addEventListener('install', (event) => {
+5 -2
View File
@@ -58,9 +58,12 @@ button { color: inherit; }
.file-row:hover { background: rgba(255,255,255,.045); }
.file-row.active { background: var(--primary-soft); outline: 1px solid rgba(79,135,255,.4); }
.file-thumb { width: 50px; height: 44px; object-fit: cover; background: #0b1015; border-radius: 5px; }
.file-thumb.format-placeholder { display: grid; place-items: center; border: 1px solid var(--border); color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .06em; }
.file-meta { min-width: 0; }
.file-meta strong, .file-meta span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-meta strong { font-size: 13px; }.file-meta span { margin-top: 4px; color: var(--muted); font-size: 11px; }
.file-meta strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.file-details { margin-top: 4px; display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--muted); font-size: 11px; white-space: nowrap; }
.file-details > span:not(.format-badge) { overflow: hidden; text-overflow: ellipsis; }
.format-badge { flex: 0 0 auto; padding: 1px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--text); font-size: 9px; font-weight: 800; letter-spacing: .05em; }
.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; }