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

Binary file not shown.
+151 -3
View File
@@ -3,11 +3,12 @@ from __future__ import annotations
import json
import logging
import os
import re
import subprocess
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, Response
@@ -15,6 +16,7 @@ from fastapi.staticfiles import StaticFiles
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
APP_VERSION = "0.2.0"
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -33,7 +35,7 @@ app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
return {"status": "ok", "version": APP_VERSION}
@app.get("/api/config")
@@ -41,6 +43,7 @@ def config() -> dict[str, str | int]:
return {
"appName": APP_NAME,
"appUrl": APP_URL,
"version": APP_VERSION,
"maxUploadMb": MAX_UPLOAD_MB,
}
@@ -113,6 +116,150 @@ async def _save_upload(upload: UploadFile, destination: Path) -> None:
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
def _first_value(metadata: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = metadata.get(key)
if value not in (None, "", []):
return value
return None
def _as_text(value: Any) -> str:
if isinstance(value, list):
return str(value[0]).strip() if value else ""
if isinstance(value, dict):
for candidate in ("x-default", "en", "en-US"):
if candidate in value:
return str(value[candidate]).strip()
return str(next(iter(value.values()), "")).strip()
return str(value or "").strip()
def _as_keywords(*values: Any) -> list[str]:
output: list[str] = []
seen: set[str] = set()
for value in values:
items = value if isinstance(value, list) else [value]
for item in items:
text = _as_text(item)
if not text or text.casefold() in seen:
continue
seen.add(text.casefold())
output.append(text)
return output
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, str]]:
date_text = _as_text(
_first_value(
metadata,
"DateTimeOriginal",
"CreateDate",
"DateCreated",
"ModifyDate",
)
)
instructions = _as_text(_first_value(metadata, "Instructions"))
description = _as_text(
_first_value(
metadata,
"ImageDescription",
"Description",
"Caption-Abstract",
)
)
keywords = _as_keywords(metadata.get("Subject"), metadata.get("Keywords"))
precision = "exact"
time_mode = "known"
marker = re.search(
r"Photo Date Editor precision:\s*(Exact date|Month and year|Year only|Approximate year)"
r"(?:;\s*time:\s*(Known|Unknown))?",
instructions,
flags=re.IGNORECASE,
)
if marker:
precision = {
"exact date": "exact",
"month and year": "month",
"year only": "year",
"approximate year": "approximate",
}[marker.group(1).casefold()]
if marker.group(2):
time_mode = "known" if marker.group(2).casefold() == "known" else "unknown"
parsed_date: datetime | None = None
for pattern in ("%Y:%m:%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y:%m:%d", "%Y-%m-%d"):
try:
parsed_date = datetime.strptime(date_text, pattern)
break
except ValueError:
continue
if marker and not marker.group(2) and parsed_date and parsed_date.strftime("%H:%M:%S") == "12:00:00":
# Backward compatibility with v0.1, which used noon for unknown time but did not store a marker.
time_mode = "unknown"
values = {
"precision": precision,
"year": str(parsed_date.year) if parsed_date else "",
"month": str(parsed_date.month) if parsed_date else "1",
"day": str(parsed_date.day) if parsed_date else "1",
"timeMode": time_mode,
"time": parsed_date.strftime("%H:%M:%S") if parsed_date else "12:00:00",
"description": description,
"keywords": ", ".join(keywords),
}
has_metadata = bool(parsed_date or description or keywords or marker)
edited_by_app = bool(marker)
return has_metadata, edited_by_app, values
@app.post("/api/metadata")
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, str]]:
filename = file.filename or "photo.jpg"
suffix = Path(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg"}:
raise HTTPException(status_code=415, detail="This version supports JPG and JPEG files only.")
with tempfile.TemporaryDirectory(prefix="photo-date-editor-read-") as temp_dir:
temp_path = Path(temp_dir) / f"source{suffix}"
await _save_upload(file, temp_path)
completed = subprocess.run(
[
"exiftool",
"-json",
"-s",
"-DateTimeOriginal",
"-CreateDate",
"-ModifyDate",
"-DateCreated",
"-Instructions",
"-ImageDescription",
"-Description",
"-Caption-Abstract",
"-Subject",
"-Keywords",
str(temp_path),
],
capture_output=True,
text=True,
timeout=45,
check=False,
)
if completed.returncode != 0:
logger.error("ExifTool metadata read failed: %s", completed.stderr.strip())
raise HTTPException(status_code=500, detail="ExifTool could not read this image's metadata.")
try:
output = json.loads(completed.stdout)
metadata = output[0] if isinstance(output, list) and output else {}
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail="ExifTool returned invalid metadata output.") from exc
has_metadata, edited_by_app, values = _metadata_values(metadata)
return {"hasMetadata": has_metadata, "editedByApp": edited_by_app, "values": values}
@app.post("/api/process")
async def process_photo(
file: Annotated[UploadFile, File(...)],
@@ -136,6 +283,7 @@ async def process_photo(
day=day,
time_value=time_value,
)
time_label = "Known" if time_value else "Unknown"
try:
raw_keywords = json.loads(keywords_json)
@@ -160,7 +308,7 @@ async def process_photo(
f"-XMP-xmp:CreateDate={exif_datetime}",
f"-XMP-xmp:ModifyDate={exif_datetime}",
f"-XMP-photoshop:DateCreated={xmp_date}",
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}",
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}; time: {time_label}",
"-EXIF:ImageDescription=",
"-XMP-dc:Description=",
"-IPTC:Caption-Abstract=",
+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; }