Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf0daf062 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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) => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user