Add status filtering and segmented progress v0.3

This commit is contained in:
dmo
2026-07-23 01:13:15 +02:00
parent e2f5c91138
commit cbf0daf062
7 changed files with 123 additions and 28 deletions
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## 0.3.0
- Add status filtering for All, Pending, Saved, Skipped, and Failed photos.
- Add sorting by filename or status; status order prioritizes Pending, then Failed, Skipped, and Saved.
- Make previous/next and Save & Next follow the currently filtered and sorted list.
- Replace the single blue progress fill with green Saved, yellow Skipped, and red Failed segments; Pending remains the grey remainder.
- Add accessible progress text with counts for every status.
- Document the proposed PNG, TIFF, HEIC/HEIF, DNG, Canon RAW, and Nikon RAW roadmap.
- Explicitly drop DWG and BMP alongside the previously excluded formats.
- Bump the service-worker cache and application version to 0.3.0.
## 0.2.0
- Read EXIF/XMP/IPTC metadata from the selected JPEG and populate the editor fields.
+23 -7
View File
@@ -1,6 +1,6 @@
# Photo Date Editor
Version 0.2.0
Version 0.3.0
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local computer or Chromebook folder, sends one JPEG at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
@@ -14,6 +14,8 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
- Description and keywords
- In-place overwrite: no browser download and no `_original` file
- Saved/skipped/pending/failed state stored in browser local storage
- Filename search, status filtering, and filename/status sorting
- Segmented progress bar: saved is green, skipped is yellow, failed is red, and pending remains grey
- Existing metadata is read from the JPEG when a photo is selected
- Files previously edited by this app are recognized from their XMP marker, even in a different browser
- Keyboard navigation
@@ -47,7 +49,7 @@ LOG_LEVEL=INFO
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
## Upgrade from 0.1
## Upgrade from 0.1 or 0.2
Replace the project files with this version and rebuild:
@@ -118,9 +120,23 @@ The chosen precision is also written into XMP Photoshop Instructions.
## Planned format expansion
The backend already uses ExifTool, which is a good foundation for later format support. The next likely steps are:
Format support needs two separate capabilities: safe metadata writing and a preview the browser can display. ExifTool gives the backend a strong metadata foundation, while formats that browsers do not reliably preview will use a temporary server-generated preview without converting or replacing the original file.
1. HEIC/HEIF preview and metadata round-trip testing on ChromeOS.
2. Canon RAW (`CR2`, `CR3`) and Nikon RAW (`NEF`, `NRW`).
3. Sidecar XMP mode for RAW files, because modifying proprietary RAW containers directly is a different risk profile from JPEG.
4. Optional server-side folder mode for photos stored on NAS-mounted storage.
### Proposed order
1. **PNG** — direct browser preview and in-place EXIF/XMP/IPTC metadata writing.
2. **TIFF/TIF** — in-place metadata writing, with a server-generated preview where the browser cannot display the file directly.
3. **HEIC/HEIF/HIF** — in-place EXIF/XMP writing and a server-generated preview. This phase should use a recent ExifTool release and test normal, HDR, and motion-photo samples.
4. **DNG** — in-place metadata writing and preview extraction/rendering.
5. **Canon RAW**`CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
6. **Nikon RAW**`NEF` and `NRW`, using per-format tag rules and embedded/server-generated previews.
`RAW` is not treated as one universal format. Each camera family will be enabled and tested explicitly.
### Not planned
- **DWG** — CAD drawing format rather than a scanned-photo format.
- **BMP/DIB** — ExifTool can read it but cannot write the metadata this app needs.
- **SVG, PDF, EPS, WebP, AVIF, EXR, XCF** — intentionally outside the scope of this photo workflow.
RAW support will remain **in-place** by default to match the JPEG workflow. Before enabling it, the app needs format-specific round-trip tests and clear warnings because proprietary camera originals deserve a stricter safety bar than scans.
+1 -1
View File
@@ -16,7 +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"
APP_VERSION = "0.3.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()
+61 -15
View File
@@ -20,9 +20,13 @@ const elements = {
folderName: $('#folder-name'),
folderAccess: $('#folder-access'),
progressCount: $('#progress-count'),
progressBar: $('#progress-bar'),
progressSaved: $('#progress-saved'),
progressSkipped: $('#progress-skipped'),
progressFailed: $('#progress-failed'),
photoCount: $('#photo-count'),
filter: $('#filter-input'),
statusFilter: $('#status-filter'),
sortSelect: $('#sort-select'),
fileList: $('#file-list'),
viewerEmpty: $('#viewer-empty'),
viewerContent: $('#viewer-content'),
@@ -163,6 +167,8 @@ async function scanFolder() {
elements.folderName.textContent = state.directoryHandle.name;
elements.folderAccess.textContent = 'Read/write access granted';
elements.filter.disabled = !photos.length;
elements.statusFilter.disabled = !photos.length;
elements.sortSelect.disabled = !photos.length;
elements.rescan.disabled = false;
elements.photoCount.textContent = String(photos.length);
@@ -202,15 +208,33 @@ function statusSymbol(status) {
return '';
}
function renderFileList() {
const filter = elements.filter.value.trim().toLowerCase();
const matching = state.photos
const statusSortOrder = { pending: 0, failed: 1, skipped: 2, saved: 3 };
function visiblePhotoEntries() {
const filenameFilter = elements.filter.value.trim().toLowerCase();
const statusFilter = elements.statusFilter.value;
const entries = state.photos
.map((photo, index) => ({ photo, index }))
.filter(({ photo }) => !filter || photo.name.toLowerCase().includes(filter));
.filter(({ photo }) => !filenameFilter || photo.name.toLowerCase().includes(filenameFilter))
.filter(({ photo }) => statusFilter === 'all' || photo.status === statusFilter);
if (elements.sortSelect.value === 'status') {
entries.sort((a, b) => {
const statusDifference = statusSortOrder[a.photo.status] - statusSortOrder[b.photo.status];
return statusDifference || naturalCompare(a.photo.name, b.photo.name);
});
} else {
entries.sort((a, b) => naturalCompare(a.photo.name, b.photo.name));
}
return entries;
}
function renderFileList() {
const matching = visiblePhotoEntries();
elements.fileList.innerHTML = '';
if (!matching.length) {
elements.fileList.innerHTML = '<div class="empty-list">No matching photos.</div>';
elements.fileList.innerHTML = '<div class="empty-list">No photos match the current filters.</div>';
return;
}
@@ -330,9 +354,18 @@ async function selectPhoto(index, force = false) {
}
}
function adjacentVisibleIndex(delta) {
const visible = visiblePhotoEntries();
if (!visible.length) return null;
const position = visible.findIndex(({ index }) => index === state.currentIndex);
if (position === -1) return delta > 0 ? visible[0].index : visible[visible.length - 1].index;
const target = position + delta;
return target >= 0 && target < visible.length ? visible[target].index : null;
}
function updateNavigation() {
const hasPhoto = state.currentIndex >= 0;
elements.previous.disabled = !hasPhoto || state.currentIndex === 0 || state.processing;
elements.previous.disabled = !hasPhoto || adjacentVisibleIndex(-1) === null || state.processing;
elements.skip.disabled = !hasPhoto || state.processing;
elements.save.disabled = !hasPhoto || state.processing;
elements.saveNext.disabled = !hasPhoto || state.processing;
@@ -409,6 +442,7 @@ async function savePhoto(moveNext) {
catch (error) { showToast(error.message, true); return; }
const photo = state.photos[state.currentIndex];
const nextVisibleIndex = moveNext ? adjacentVisibleIndex(1) : null;
state.processing = true;
updateNavigation();
setSaveStatus('saving', 'Saving metadata…', photo.name);
@@ -464,8 +498,8 @@ async function savePhoto(moveNext) {
if (oldThumb) URL.revokeObjectURL(oldThumb);
state.thumbUrls.delete(photo.name);
if (moveNext && state.currentIndex < state.photos.length - 1) {
await selectPhoto(state.currentIndex + 1, true);
if (moveNext && nextVisibleIndex !== null) {
await selectPhoto(nextVisibleIndex, true);
} else {
await selectPhoto(state.currentIndex, true);
}
@@ -485,29 +519,39 @@ async function savePhoto(moveNext) {
function skipCurrentPhoto() {
if (state.processing || state.currentIndex < 0) return;
const photo = state.photos[state.currentIndex];
const nextVisibleIndex = adjacentVisibleIndex(1);
captureCurrentValues();
photo.status = 'skipped';
saveFolderState();
updateProgress();
renderFileList();
showToast(`Skipped ${photo.name}`);
if (state.currentIndex < state.photos.length - 1) goRelative(1);
if (nextVisibleIndex !== null) selectPhoto(nextVisibleIndex);
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 failed = state.photos.filter((photo) => photo.status === 'failed').length;
const pending = state.photos.filter((photo) => photo.status === 'pending').length;
const processed = saved + skipped;
const total = state.photos.length;
const percentage = (count) => total ? `${(count / total) * 100}%` : '0%';
elements.progressCount.textContent = `${processed} / ${total}`;
elements.progressBar.style.width = total ? `${(processed / total) * 100}%` : '0%';
elements.progressCount.title = `${saved} saved, ${skipped} skipped`;
elements.progressSaved.style.width = percentage(saved);
elements.progressSkipped.style.width = percentage(skipped);
elements.progressFailed.style.width = percentage(failed);
elements.progressCount.title = `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`;
const track = elements.progressSaved.parentElement;
track.setAttribute('aria-valuenow', total ? String(Math.round((processed / total) * 100)) : '0');
track.setAttribute('aria-valuetext', `${saved} saved, ${skipped} skipped, ${failed} failed, ${pending} pending`);
}
function goRelative(delta) {
const target = state.currentIndex + delta;
if (target >= 0 && target < state.photos.length) selectPhoto(target);
const target = adjacentVisibleIndex(delta);
if (target !== null) selectPhoto(target);
}
function copyPreviousValues() {
@@ -527,7 +571,9 @@ 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.filter.addEventListener('input', () => { renderFileList(); updateNavigation(); });
elements.statusFilter.addEventListener('change', () => { renderFileList(); updateNavigation(); });
elements.sortSelect.addEventListener('change', () => { renderFileList(); updateNavigation(); });
elements.previous.addEventListener('click', () => goRelative(-1));
elements.skip.addEventListener('click', skipCurrentPhoto);
elements.save.addEventListener('click', () => savePhoto(false));
+19 -2
View File
@@ -42,7 +42,11 @@
<span>Progress</span>
<span id="progress-count">0 / 0</span>
</div>
<div class="progress-track"><div id="progress-bar" class="progress-bar"></div></div>
<div class="progress-track" role="progressbar" aria-label="Photo processing progress" aria-valuemin="0" aria-valuemax="100">
<div id="progress-saved" class="progress-segment saved" title="Saved"></div>
<div id="progress-skipped" class="progress-segment skipped" title="Skipped"></div>
<div id="progress-failed" class="progress-segment failed" title="Failed"></div>
</div>
<div class="status-legend">
<span><i class="dot saved"></i>Saved</span>
<span><i class="dot skipped"></i>Skipped</span>
@@ -53,7 +57,20 @@
<section class="panel-section file-section">
<div class="section-heading"><span>Photos</span><span id="photo-count">0</span></div>
<input id="filter-input" class="input filter" type="search" placeholder="Filter filenames…" disabled>
<div class="file-controls">
<input id="filter-input" class="input filter filename-filter" type="search" placeholder="Filter filenames…" aria-label="Filter photos by filename" disabled>
<select id="status-filter" class="input filter" aria-label="Filter photos by status" disabled>
<option value="all">All statuses</option>
<option value="pending">Pending</option>
<option value="saved">Saved</option>
<option value="skipped">Skipped</option>
<option value="failed">Failed</option>
</select>
<select id="sort-select" class="input filter" aria-label="Sort photos" disabled>
<option value="filename">Sort: Filename</option>
<option value="status">Sort: Status</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>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'photo-date-editor-v2';
const CACHE_NAME = 'photo-date-editor-v3';
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
+7 -2
View File
@@ -41,12 +41,17 @@ button { color: inherit; }
.folder-row strong, .folder-row span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.folder-row strong { max-width: 185px; }
.folder-row span { color: var(--muted); font-size: 12px; margin-top: 4px; max-width: 185px; }
.progress-track { height: 7px; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-bar { width: 0; height: 100%; background: var(--primary); transition: width .2s ease; }
.progress-track { height: 7px; display: flex; background: #2a3440; border-radius: 99px; overflow: hidden; }
.progress-segment { width: 0; height: 100%; flex: 0 0 auto; transition: width .2s ease; }
.progress-segment.saved { background: var(--success); }
.progress-segment.skipped { background: var(--warning); }
.progress-segment.failed { background: var(--danger); }
.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.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-controls { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.filename-filter { grid-column: 1 / -1; }
.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; }
.file-row { width: 100%; border: 0; border-radius: 8px; background: transparent; display: grid; grid-template-columns: 50px minmax(0, 1fr) 22px; align-items: center; gap: 10px; padding: 8px; text-align: left; cursor: pointer; }