Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f5c91138 |
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Read EXIF/XMP/IPTC metadata from the selected JPEG and populate the editor fields.
|
||||
- Recognize files previously edited by Photo Date Editor from their XMP marker, including in another browser.
|
||||
- Show a saved metadata summary such as date, time precision, description presence, and keyword count.
|
||||
- Add a persistent Skipped state and sidebar icon.
|
||||
- Count Saved + Skipped photos as processed in the progress bar.
|
||||
- Rename the secondary save action to **Save current**.
|
||||
- Clarify `Ctrl+S` as **Save current photo and stay on it**.
|
||||
- Record whether an entered time was known or unknown in the app's XMP marker.
|
||||
- Preserve compatibility with progress state and files created by 0.1.
|
||||
@@ -1,23 +1,27 @@
|
||||
# Photo Date Editor
|
||||
|
||||
A self-hosted browser UI for manually dating scanned photographs. The browser receives temporary read/write access to a local Chromebook or computer folder, sends one JPEG at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
|
||||
Version 0.2.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.
|
||||
|
||||
## Current scope
|
||||
|
||||
- JPG and JPEG
|
||||
- Local directory picker in Chrome/Edge
|
||||
- Local directory picker in supported Chromium browsers
|
||||
- Three-column photo workflow
|
||||
- Exact date, month/year, year-only, approximate year
|
||||
- Optional time; unknown time defaults to noon
|
||||
- Description and keywords
|
||||
- In-place overwrite: no browser download and no `_original` file
|
||||
- Saved/pending/failed state stored in browser local storage
|
||||
- Saved/skipped/pending/failed state stored in browser local storage
|
||||
- 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
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker with Docker Compose
|
||||
- A current Chromium browser such as Chrome or Edge
|
||||
- A current Chromium browser with the File System Access API enabled, such as Chrome, Edge, or Brave with the relevant flag enabled
|
||||
- HTTPS when accessed from another device
|
||||
- A backup of irreplaceable scans before testing any metadata editor
|
||||
|
||||
@@ -43,6 +47,16 @@ LOG_LEVEL=INFO
|
||||
|
||||
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
|
||||
|
||||
## Upgrade from 0.1
|
||||
|
||||
Replace the project files with this version and rebuild:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The existing `.env` can be kept. Browser progress from 0.1 remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
|
||||
|
||||
## Caddy example
|
||||
|
||||
Caddy may run in another container or host. Proxy to the Docker host and exposed port:
|
||||
@@ -59,7 +73,7 @@ photos.example.internal {
|
||||
}
|
||||
```
|
||||
|
||||
The browser directory picker requires a secure context. Use a certificate trusted by the Chromebook. A real domain with internal DNS is usually easier than deploying a private CA certificate to every family device.
|
||||
The browser directory picker requires a secure context. Use a certificate trusted by the client computer. A real domain with internal DNS is usually easier than deploying a private CA certificate to every family device.
|
||||
|
||||
## What happens when Save is clicked
|
||||
|
||||
@@ -67,10 +81,10 @@ The browser directory picker requires a secure context. Use a certificate truste
|
||||
2. It sends the file and entered fields to `/api/process`.
|
||||
3. ExifTool modifies a temporary file inside the container using `-overwrite_original`.
|
||||
4. The backend returns the modified JPEG.
|
||||
5. Chrome writes the returned bytes over the selected original file with `createWritable()`.
|
||||
5. The browser writes the returned bytes over the selected original file with `createWritable()`.
|
||||
6. The backend temporary directory is deleted automatically.
|
||||
|
||||
No second photo is intentionally left on the Chromebook or Docker host.
|
||||
No second photo is intentionally left on the client computer or Docker host.
|
||||
|
||||
## Date behavior
|
||||
|
||||
@@ -88,15 +102,16 @@ The chosen precision is also written into XMP Photoshop Instructions.
|
||||
## Keyboard shortcuts
|
||||
|
||||
- `Enter` or `Ctrl+Enter`: Save & Next
|
||||
- `Ctrl+S`: Save without moving
|
||||
- `Ctrl+S`: Save the current photo and remain on it
|
||||
- `Left` / `Right`: Previous / next photo when not typing
|
||||
- `S`: Skip when not typing
|
||||
- `S`: Mark the photo as skipped and open the next photo when not typing
|
||||
- `C`: Copy previous values when not typing
|
||||
|
||||
## Important limitations
|
||||
|
||||
- The app cannot silently access a Chromebook folder. The user must choose it and approve read/write access.
|
||||
- The app cannot silently access a local folder. The user must choose it and approve read/write access.
|
||||
- Browser permission may need to be granted again after closing/reopening the browser.
|
||||
- Saved and skipped progress is stored per browser and folder name. The app also reads its own XMP marker from edited files so saved metadata can be recognized on another browser.
|
||||
- Overwriting a file through the browser generally changes its filesystem modified time to the time of the save. EXIF/XMP photo dates are independent of that filesystem timestamp.
|
||||
- Preview rotation is visual only in this MVP; it does not rotate image pixels or write orientation metadata.
|
||||
- Test with copies first, then use your own normal backup routine for the originals.
|
||||
|
||||
Binary file not shown.
+151
-3
@@ -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
@@ -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(() => {});
|
||||
|
||||
@@ -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 & 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,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) => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user