Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47b8d42295 | ||
|
|
3acf6bb7ec | ||
|
|
0541790dba | ||
|
|
e250a83b6d | ||
|
|
9b7d302064 | ||
|
|
e290bd1fff | ||
|
|
cbf0daf062 |
@@ -9,3 +9,18 @@ APP_PORT=8080
|
||||
APP_NAME=Photo Date Editor
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Optional TIFF preview limits. The original TIFF is never converted or replaced by the preview.
|
||||
PREVIEW_MAX_EDGE=2400
|
||||
PREVIEW_MAX_PIXELS=300000000
|
||||
|
||||
# Optional map/geocoder overrides. Existing .env files may omit these.
|
||||
# Map tiles are fetched by the backend and served same-origin to the browser.
|
||||
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
MAP_ATTRIBUTION=© OpenStreetMap contributors
|
||||
DEFAULT_MAP_LAT=64.5
|
||||
DEFAULT_MAP_LON=11.0
|
||||
DEFAULT_MAP_ZOOM=5
|
||||
GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
||||
# Optional custom identification sent to the geocoder. The default uses APP_NAME, version and APP_URL.
|
||||
# GEOCODER_USER_AGENT=MorkPhotoDateEditor/0.5.0 (+https://edit.mork.fyi)
|
||||
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0
|
||||
|
||||
- Add HEIC, HEIF, and HIF folder scanning and in-place metadata processing.
|
||||
- Normalize all three HEIF-family extensions to a `HEIF` format badge and include them in format sorting.
|
||||
- Generate temporary browser-friendly JPEG previews for HEIF-family images while preserving and overwriting the original container on save.
|
||||
- Add `pillow-heif` and the container HEIF runtime needed to decode previews.
|
||||
- Validate HEIF-family files as ISO Base Media File Format containers with recognized HEVC image brands before processing, without admitting renamed AVIF files.
|
||||
- Read and write HEIF EXIF/XMP dates, precision marker, description, keywords, GPS coordinates, and location label without applying IPTC-IIM fields.
|
||||
- Return updated files using HEIC/HEIF media types while keeping the original filename and extension.
|
||||
- Update the interface, manifest, documentation, and empty-folder guidance for the new formats.
|
||||
- Preserve the established JPG, PNG, and TIFF processing paths.
|
||||
- Bump the application and service-worker cache version to 0.6.0.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- Add in-place PNG metadata reading and writing.
|
||||
- Add in-place TIFF/TIF metadata reading and writing.
|
||||
- Generate temporary browser-friendly JPEG previews for TIFF files while preserving and overwriting the original TIFF on save.
|
||||
- Validate JPEG, PNG, and TIFF file signatures before metadata processing.
|
||||
- Add normalized JPG, PNG, and TIFF format badges to the left photo list.
|
||||
- Show the current file format alongside size and modified time in the viewer.
|
||||
- Add **Sort: Format**, grouping by normalized format and then filename.
|
||||
- Keep TIFF list thumbnails lazy: a format tile is shown until that TIFF has been opened and previewed.
|
||||
- Add configurable TIFF preview edge and pixel limits.
|
||||
- Bump the application and service-worker cache version to 0.5.0.
|
||||
|
||||
## 0.4.3
|
||||
|
||||
- Fix favorite locations appearing empty after a browser refresh.
|
||||
- Load the shared server-side favorite list during application startup instead of waiting for **Open photo folder**.
|
||||
- Keep the existing refresh when opening a folder as a defensive re-sync for changes made by another family member.
|
||||
- Preserve all favorites already stored in the `photo-date-editor-data` Docker volume.
|
||||
- Bump the application and service-worker cache version to 0.4.3.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- Added shared favorite locations to the Location tab.
|
||||
- Favorite names and coordinates are stored server-side in the persistent `photo-date-editor-data` Docker volume.
|
||||
- Added **Use**, **Add current location**, and **Delete selected** controls for favorite locations.
|
||||
- Favorite locations are available to every browser and family member using the same Docker stack.
|
||||
- Duplicate favorite names are rejected case-insensitively.
|
||||
- Documented MP4 as a later media phase after the planned photo formats.
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- Fix `Shift+Tab` so it activates the previous metadata tab immediately and focuses that tab's first field.
|
||||
- Keep Left/Right arrow keys dedicated to previous/next photo navigation.
|
||||
- Remove the tab-header Arrow/Home/End behavior that conflicted with photo navigation and was awkward on compact Mac keyboards.
|
||||
- Proxy OpenStreetMap tiles through the backend and serve them same-origin through Caddy, avoiding Brave third-party tile blocking.
|
||||
- Add an in-memory map-tile cache and clear map loading/error status text.
|
||||
- Add an explicit message when the Leaflet library fails to load instead of silently showing a dead map.
|
||||
- Add `ca-certificates` explicitly to the container image for outbound HTTPS map and geocoder requests.
|
||||
- Keep all map environment variables optional; existing `.env` files remain valid.
|
||||
- Bump the application and service-worker cache version to 0.4.1.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Add a dedicated Location tab with an interactive Leaflet map.
|
||||
- Add user-triggered address/place search through a backend geocoder endpoint.
|
||||
- Add click-to-place and draggable map markers.
|
||||
- Add editable decimal latitude and longitude fields plus an optional location label.
|
||||
- Read existing EXIF/XMP GPS metadata and repopulate the map and fields.
|
||||
- Write GPS coordinates to EXIF and XMP while preserving existing GPS unless the user changes it.
|
||||
- Add explicit Clear location behavior that removes app-managed GPS and location tags on save.
|
||||
- Add configurable tile, geocoder, and default map settings through optional environment variables.
|
||||
- Rate-limit uncached public geocoder calls to one request per second and cache results in memory.
|
||||
- Add `Shift+Tab` to cycle backward through right-side metadata tabs while keeping normal `Tab` field navigation.
|
||||
- Add arrow-key navigation when a tab header has focus.
|
||||
- Avoid service-worker caching of third-party map tiles and scripts.
|
||||
- Bump the application and service-worker cache version to 0.4.0.
|
||||
|
||||
## 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.
|
||||
|
||||
+34
-1
@@ -4,7 +4,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libimage-exiftool-perl \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates libheif1 libimage-exiftool-perl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -14,6 +14,39 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./
|
||||
|
||||
ARG LEAFLET_VERSION=1.9.4
|
||||
RUN LEAFLET_VERSION="$LEAFLET_VERSION" python - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
version = os.environ["LEAFLET_VERSION"]
|
||||
base = Path("/app/static/vendor/leaflet")
|
||||
(base / "images").mkdir(parents=True, exist_ok=True)
|
||||
files = {
|
||||
"leaflet.css": "leaflet.css",
|
||||
"leaflet.js": "leaflet.js",
|
||||
"images/marker-icon.png": "images/marker-icon.png",
|
||||
"images/marker-icon-2x.png": "images/marker-icon-2x.png",
|
||||
"images/marker-shadow.png": "images/marker-shadow.png",
|
||||
}
|
||||
mirrors = (
|
||||
f"https://unpkg.com/leaflet@{version}/dist",
|
||||
f"https://cdn.jsdelivr.net/npm/leaflet@{version}/dist",
|
||||
)
|
||||
for destination, source in files.items():
|
||||
target = base / destination
|
||||
last_error = None
|
||||
for mirror in mirrors:
|
||||
try:
|
||||
urllib.request.urlretrieve(f"{mirror}/{source}", target)
|
||||
break
|
||||
except Exception as exc: # build-time fallback to the second CDN
|
||||
last_error = exc
|
||||
else:
|
||||
raise RuntimeError(f"Could not download Leaflet asset {source}: {last_error}")
|
||||
PY
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
@@ -1,22 +1,33 @@
|
||||
# Photo Date Editor
|
||||
|
||||
Version 0.2.0
|
||||
Version 0.6.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.
|
||||
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 image at a time to the Docker backend for ExifTool processing, and overwrites the same local file after processing.
|
||||
|
||||
## Current scope
|
||||
|
||||
- JPG and JPEG
|
||||
- JPG/JPEG, PNG, TIFF/TIF, and HEIC/HEIF/HIF
|
||||
- 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
|
||||
- GPS geotagging with address search, map click, draggable pin, manual coordinates, and location removal
|
||||
- Shared favorite locations persisted in a Docker volume for all family members and browsers
|
||||
- In-place overwrite: no browser download and no `_original` file
|
||||
- Saved/skipped/pending/failed state stored in browser local storage
|
||||
- Existing metadata is read from the JPEG when a photo is selected
|
||||
- Filename search, status filtering, and filename/status/format sorting
|
||||
- Normalized JPG, PNG, TIFF, and HEIF format badges in the photo list
|
||||
- Segmented progress bar: saved is green, skipped is yellow, failed is red, and pending remains grey
|
||||
- Existing metadata is read from supported files when selected
|
||||
- Files previously edited by this app are recognized from their XMP marker, even in a different browser
|
||||
- Keyboard navigation
|
||||
- Keyboard navigation, including `Shift+Tab` between right-side tabs
|
||||
|
||||
## Preview behavior
|
||||
|
||||
JPG and PNG are displayed directly by the browser. Chromium browsers do not reliably display TIFF or HEIF-family images, so TIFF, HEIC, HEIF, and HIF files are temporarily uploaded to `/api/preview` and rendered as a JPEG preview by Pillow with `pillow-heif`. This preview exists only in memory and is never written over the source file.
|
||||
|
||||
When a TIFF or HEIF-family image is saved, ExifTool updates the original format and the browser overwrites the same local file. The original is not converted to JPEG and no additional copy is intentionally left behind.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -25,7 +36,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
|
||||
- HTTPS when accessed from another device
|
||||
- A backup of irreplaceable scans before testing any metadata editor
|
||||
|
||||
The browser File System Access API is required. Firefox and Safari are not supported by this first version.
|
||||
The browser File System Access API is required. Firefox and Safari are not supported by this version.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -43,11 +54,23 @@ APP_PORT=8080
|
||||
APP_NAME=Photo Date Editor
|
||||
MAX_UPLOAD_MB=150
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Optional TIFF/HEIF preview limits
|
||||
PREVIEW_MAX_EDGE=2400
|
||||
PREVIEW_MAX_PIXELS=300000000
|
||||
|
||||
# Optional map defaults and service overrides
|
||||
MAP_TILE_SOURCE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||
MAP_ATTRIBUTION=© OpenStreetMap contributors
|
||||
DEFAULT_MAP_LAT=64.5
|
||||
DEFAULT_MAP_LON=11.0
|
||||
DEFAULT_MAP_ZOOM=5
|
||||
GEOCODER_URL=https://nominatim.openstreetmap.org/search
|
||||
```
|
||||
|
||||
Open the configured HTTPS URL through Caddy, click **Open photo folder**, and grant read/write access.
|
||||
|
||||
## Upgrade from 0.1
|
||||
## Upgrade from 0.1 through 0.5
|
||||
|
||||
Replace the project files with this version and rebuild:
|
||||
|
||||
@@ -55,7 +78,7 @@ Replace the project files with this version and rebuild:
|
||||
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.
|
||||
The existing `.env` can be kept. The preview variables are optional. Browser progress from earlier releases remains compatible. A normal refresh should load the new service-worker cache; use a hard refresh if an old UI remains visible.
|
||||
|
||||
## Caddy example
|
||||
|
||||
@@ -77,15 +100,24 @@ The browser directory picker requires a secure context. Use a certificate truste
|
||||
|
||||
## What happens when Save is clicked
|
||||
|
||||
1. The browser reads the selected local JPEG.
|
||||
1. The browser reads the selected local JPG, PNG, TIFF, HEIC, HEIF, or HIF.
|
||||
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.
|
||||
4. The backend returns the modified file using its original media type.
|
||||
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 client computer or Docker host.
|
||||
|
||||
## Metadata behavior by format
|
||||
|
||||
- **JPG/JPEG:** EXIF, XMP, and IPTC fields are written.
|
||||
- **TIFF/TIF:** EXIF, XMP, and IPTC fields are written.
|
||||
- **PNG:** EXIF and XMP fields are written. PNG metadata support varies more between third-party viewers than JPEG/TIFF support, so test the applications that will consume the files.
|
||||
- **HEIC/HEIF/HIF:** EXIF and XMP fields are written. IPTC-IIM is not used for these ISO Base Media File Format containers. Date precision, description, keywords, GPS coordinates, and the location label remain represented through EXIF/XMP fields.
|
||||
|
||||
The app writes its precision marker to XMP for all supported formats. GPS coordinates are written to EXIF and XMP. The optional location label is written to XMP IPTC Core Location.
|
||||
|
||||
## Date behavior
|
||||
|
||||
EXIF date fields require a complete timestamp:
|
||||
@@ -106,6 +138,21 @@ The chosen precision is also written into XMP Photoshop Instructions.
|
||||
- `Left` / `Right`: Previous / next photo when not typing
|
||||
- `S`: Mark the photo as skipped and open the next photo when not typing
|
||||
- `C`: Copy previous values when not typing
|
||||
- `Shift+Tab`: Open the previous right-side metadata tab and place focus in its first field; normal `Tab` still advances through fields
|
||||
|
||||
## Location behavior
|
||||
|
||||
Favorite locations are loaded automatically when the page opens and are shared by everyone using the same Docker stack. Choose a saved place and click **Use**, or place a pin/set coordinates and click **Add current location** in the favorite-locations card. The favorite list is stored in the named Docker volume `photo-date-editor-data`, so it survives image rebuilds and container recreation. Deleting a favorite only removes it from the reusable list; it does not alter photos that were already geotagged.
|
||||
|
||||
The **Location** tab supports five ways to set GPS metadata:
|
||||
|
||||
1. Choose a shared favorite location.
|
||||
2. Search for an address or place and choose a result.
|
||||
3. Click directly on the map.
|
||||
4. Drag the existing marker.
|
||||
5. Enter decimal latitude and longitude manually.
|
||||
|
||||
The default map tiles and address search are external OpenStreetMap services. Both are requested by the Docker backend rather than directly by the browser. Tiles are cached in container memory. Search is user-triggered, rate-limited, and cached in memory. The tile source and geocoder URLs remain configurable in `.env`.
|
||||
|
||||
## Important limitations
|
||||
|
||||
@@ -113,14 +160,32 @@ The chosen precision is also written into XMP Photoshop Instructions.
|
||||
- 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.
|
||||
- Preview rotation is visual only; it does not rotate image pixels or write orientation metadata.
|
||||
- Multi-page TIFF files display the first page/frame in the editor. Metadata is written to the TIFF container, not to separate pages.
|
||||
- HEIF image sequences display their primary/first image in the editor. Metadata is written to the HEIF container.
|
||||
- Very large TIFF or HEIF files may require raising `MAX_UPLOAD_MB` or `PREVIEW_MAX_PIXELS`.
|
||||
- HEIF metadata compatibility varies between operating-system galleries and photo-management applications. Verify the fields in the software that will consume your library.
|
||||
- The Docker host needs outbound HTTPS access for the default map tiles and address search.
|
||||
- Test with copies first, then use your normal backup routine for the originals.
|
||||
|
||||
## 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 both safe metadata writing and a browser-friendly preview. Formats that browsers do not reliably display 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. **DNG** — in-place metadata writing and preview extraction/rendering.
|
||||
2. **Canon RAW** — `CR2` and `CR3`, using per-format tag rules and embedded/server-generated previews.
|
||||
3. **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.
|
||||
|
||||
## Future video support
|
||||
|
||||
MP4 remains feasible after the planned photo formats, but it will be a separate media phase rather than just another image extension. The existing fields can be mapped to compatible QuickTime and XMP metadata, but large videos need streamed/chunked transfer, progress reporting, larger limits, cancellation, and compatibility testing before in-place support is enabled.
|
||||
|
||||
Binary file not shown.
+477
-21
@@ -1,26 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from pillow_heif import register_heif_opener
|
||||
|
||||
register_heif_opener()
|
||||
|
||||
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.6.0"
|
||||
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||
PREVIEW_MAX_EDGE = int(os.getenv("PREVIEW_MAX_EDGE", "2400"))
|
||||
PREVIEW_MAX_PIXELS = int(os.getenv("PREVIEW_MAX_PIXELS", "300000000"))
|
||||
Image.MAX_IMAGE_PIXELS = PREVIEW_MAX_PIXELS
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".heic", ".heif", ".hif"}
|
||||
TIFF_EXTENSIONS = {".tif", ".tiff"}
|
||||
HEIF_EXTENSIONS = {".heic", ".heif", ".hif"}
|
||||
SERVER_PREVIEW_EXTENSIONS = TIFF_EXTENSIONS | HEIF_EXTENSIONS
|
||||
MEDIA_TYPES = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".hif": "image/heif",
|
||||
}
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
MAP_TILE_SOURCE_URL = os.getenv(
|
||||
"MAP_TILE_SOURCE_URL",
|
||||
os.getenv("MAP_TILE_URL", "https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
|
||||
)
|
||||
MAP_ATTRIBUTION = os.getenv(
|
||||
"MAP_ATTRIBUTION",
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
||||
)
|
||||
DEFAULT_MAP_LAT = float(os.getenv("DEFAULT_MAP_LAT", "64.5"))
|
||||
DEFAULT_MAP_LON = float(os.getenv("DEFAULT_MAP_LON", "11.0"))
|
||||
DEFAULT_MAP_ZOOM = int(os.getenv("DEFAULT_MAP_ZOOM", "5"))
|
||||
GEOCODER_URL = os.getenv("GEOCODER_URL", "https://nominatim.openstreetmap.org/search")
|
||||
GEOCODER_USER_AGENT = os.getenv(
|
||||
"GEOCODER_USER_AGENT",
|
||||
f"{APP_NAME.replace(' ', '')}/{APP_VERSION} (+{APP_URL})",
|
||||
)
|
||||
|
||||
_geocode_lock = asyncio.Lock()
|
||||
_geocode_cache: dict[str, list[dict[str, Any]]] = {}
|
||||
_last_geocode_request = 0.0
|
||||
_tile_cache: OrderedDict[str, tuple[bytes, str]] = OrderedDict()
|
||||
_tile_cache_limit = 512
|
||||
_tile_lock = asyncio.Lock()
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
@@ -29,6 +85,16 @@ logger = logging.getLogger("photo-date-editor")
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "/data"))
|
||||
FAVORITES_FILE = DATA_DIR / "favorite-locations.json"
|
||||
_favorites_lock = threading.Lock()
|
||||
|
||||
|
||||
class FavoriteLocationInput(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
latitude: float = Field(ge=-90, le=90)
|
||||
longitude: float = Field(ge=-180, le=180)
|
||||
|
||||
|
||||
app = FastAPI(title=APP_NAME, docs_url=None, redoc_url=None)
|
||||
|
||||
@@ -39,15 +105,241 @@ def health() -> dict[str, str]:
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
def config() -> dict[str, str | int]:
|
||||
def config() -> dict[str, str | int | float]:
|
||||
return {
|
||||
"appName": APP_NAME,
|
||||
"appUrl": APP_URL,
|
||||
"version": APP_VERSION,
|
||||
"maxUploadMb": MAX_UPLOAD_MB,
|
||||
"mapTileUrl": "/api/map/tiles/{z}/{x}/{y}.png",
|
||||
"mapAttribution": MAP_ATTRIBUTION,
|
||||
"defaultMapLat": DEFAULT_MAP_LAT,
|
||||
"defaultMapLon": DEFAULT_MAP_LON,
|
||||
"defaultMapZoom": DEFAULT_MAP_ZOOM,
|
||||
}
|
||||
|
||||
|
||||
def _load_favorites_unlocked() -> list[dict[str, Any]]:
|
||||
if not FAVORITES_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(FAVORITES_FILE.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Could not read favorite locations: %s", exc)
|
||||
return []
|
||||
raw = payload.get("favorites", []) if isinstance(payload, dict) else []
|
||||
favorites: list[dict[str, Any]] = []
|
||||
for item in raw if isinstance(raw, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
latitude = float(item["latitude"])
|
||||
longitude = float(item["longitude"])
|
||||
name = str(item["name"]).strip()
|
||||
favorite_id = str(item["id"]).strip()
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not name or not favorite_id or not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
|
||||
continue
|
||||
favorites.append({
|
||||
"id": favorite_id,
|
||||
"name": name,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
})
|
||||
return sorted(favorites, key=lambda item: item["name"].casefold())
|
||||
|
||||
|
||||
def _write_favorites_unlocked(favorites: list[dict[str, Any]]) -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"favorites": sorted(favorites, key=lambda item: item["name"].casefold())}
|
||||
temporary = FAVORITES_FILE.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
temporary.replace(FAVORITES_FILE)
|
||||
|
||||
|
||||
@app.get("/api/favorites")
|
||||
def list_favorites() -> dict[str, list[dict[str, Any]]]:
|
||||
with _favorites_lock:
|
||||
return {"favorites": _load_favorites_unlocked()}
|
||||
|
||||
|
||||
@app.post("/api/favorites", status_code=201)
|
||||
def create_favorite(value: FavoriteLocationInput) -> dict[str, dict[str, Any]]:
|
||||
name = " ".join(value.name.split())
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="Favorite name cannot be empty.")
|
||||
with _favorites_lock:
|
||||
favorites = _load_favorites_unlocked()
|
||||
if any(item["name"].casefold() == name.casefold() for item in favorites):
|
||||
raise HTTPException(status_code=409, detail="A favorite with that name already exists.")
|
||||
favorite = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"name": name,
|
||||
"latitude": round(value.latitude, 6),
|
||||
"longitude": round(value.longitude, 6),
|
||||
}
|
||||
favorites.append(favorite)
|
||||
_write_favorites_unlocked(favorites)
|
||||
return {"favorite": favorite}
|
||||
|
||||
|
||||
@app.delete("/api/favorites/{favorite_id}", status_code=204)
|
||||
def delete_favorite(favorite_id: str) -> Response:
|
||||
with _favorites_lock:
|
||||
favorites = _load_favorites_unlocked()
|
||||
remaining = [item for item in favorites if item["id"] != favorite_id]
|
||||
if len(remaining) == len(favorites):
|
||||
raise HTTPException(status_code=404, detail="Favorite location not found.")
|
||||
_write_favorites_unlocked(remaining)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
def _fetch_map_tile(z: int, x: int, y: int) -> tuple[bytes, str]:
|
||||
tile_url = (
|
||||
MAP_TILE_SOURCE_URL
|
||||
.replace("{z}", str(z))
|
||||
.replace("{x}", str(x))
|
||||
.replace("{y}", str(y))
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
tile_url,
|
||||
headers={
|
||||
"User-Agent": GEOCODER_USER_AGENT,
|
||||
"Accept": "image/avif,image/webp,image/png,image/*,*/*;q=0.8",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
content_type = response.headers.get_content_type() or "image/png"
|
||||
return response.read(), content_type
|
||||
|
||||
|
||||
@app.get("/api/map/tiles/{z}/{x}/{y}.png", include_in_schema=False)
|
||||
async def map_tile(z: int, x: int, y: int) -> Response:
|
||||
if not 0 <= z <= 19:
|
||||
raise HTTPException(status_code=404, detail="Unsupported map zoom level.")
|
||||
limit = 1 << z
|
||||
if not 0 <= x < limit or not 0 <= y < limit:
|
||||
raise HTTPException(status_code=404, detail="Invalid map tile coordinates.")
|
||||
|
||||
cache_key = f"{z}/{x}/{y}"
|
||||
cached = _tile_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
_tile_cache.move_to_end(cache_key)
|
||||
payload, content_type = cached
|
||||
return Response(payload, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
async with _tile_lock:
|
||||
cached = _tile_cache.get(cache_key)
|
||||
if cached is None:
|
||||
try:
|
||||
cached = await asyncio.to_thread(_fetch_map_tile, z, x, y)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
logger.warning("Map tile request failed for %s: %s", cache_key, exc)
|
||||
raise HTTPException(status_code=502, detail="The map tile service could not be reached.") from exc
|
||||
_tile_cache[cache_key] = cached
|
||||
if len(_tile_cache) > _tile_cache_limit:
|
||||
_tile_cache.popitem(last=False)
|
||||
else:
|
||||
_tile_cache.move_to_end(cache_key)
|
||||
|
||||
payload, content_type = cached
|
||||
return Response(payload, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
|
||||
|
||||
def _fetch_geocode_results(query: str) -> list[dict[str, Any]]:
|
||||
parameters = urllib.parse.urlencode(
|
||||
{
|
||||
"q": query,
|
||||
"format": "jsonv2",
|
||||
"addressdetails": "1",
|
||||
"limit": "5",
|
||||
}
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
f"{GEOCODER_URL}?{parameters}",
|
||||
headers={
|
||||
"User-Agent": GEOCODER_USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
raw = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in raw if isinstance(raw, list) else []:
|
||||
try:
|
||||
latitude = float(item["lat"])
|
||||
longitude = float(item["lon"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
address = item.get("address") if isinstance(item.get("address"), dict) else {}
|
||||
results.append(
|
||||
{
|
||||
"label": str(item.get("display_name") or "").strip(),
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"type": str(item.get("type") or item.get("category") or "place"),
|
||||
"address": {
|
||||
key: str(address[key])
|
||||
for key in (
|
||||
"house_number",
|
||||
"road",
|
||||
"neighbourhood",
|
||||
"suburb",
|
||||
"city",
|
||||
"town",
|
||||
"village",
|
||||
"municipality",
|
||||
"county",
|
||||
"state",
|
||||
"postcode",
|
||||
"country",
|
||||
"country_code",
|
||||
)
|
||||
if address.get(key)
|
||||
},
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@app.get("/api/geocode")
|
||||
async def geocode(q: str) -> dict[str, list[dict[str, Any]]]:
|
||||
global _last_geocode_request
|
||||
|
||||
query = " ".join(q.split())
|
||||
if len(query) < 3:
|
||||
raise HTTPException(status_code=422, detail="Enter at least three characters to search.")
|
||||
if len(query) > 200:
|
||||
raise HTTPException(status_code=422, detail="The address search is too long.")
|
||||
|
||||
cache_key = query.casefold()
|
||||
if cache_key in _geocode_cache:
|
||||
return {"results": _geocode_cache[cache_key]}
|
||||
|
||||
async with _geocode_lock:
|
||||
if cache_key in _geocode_cache:
|
||||
return {"results": _geocode_cache[cache_key]}
|
||||
|
||||
delay = 1.0 - (time.monotonic() - _last_geocode_request)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
try:
|
||||
results = await asyncio.to_thread(_fetch_geocode_results, query)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Geocoding request failed: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="The address search service could not be reached.") from exc
|
||||
finally:
|
||||
_last_geocode_request = time.monotonic()
|
||||
|
||||
if len(_geocode_cache) >= 200:
|
||||
_geocode_cache.pop(next(iter(_geocode_cache)))
|
||||
_geocode_cache[cache_key] = results
|
||||
return {"results": results}
|
||||
|
||||
def _normalise_datetime(
|
||||
precision: str,
|
||||
year: int,
|
||||
@@ -98,7 +390,47 @@ def _normalise_datetime(
|
||||
return exif_datetime, xmp_date, precision_labels[precision]
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: Path) -> None:
|
||||
def _format_name(suffix: str) -> str:
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
return "JPEG"
|
||||
if suffix == ".png":
|
||||
return "PNG"
|
||||
if suffix in TIFF_EXTENSIONS:
|
||||
return "TIFF"
|
||||
if suffix in HEIF_EXTENSIONS:
|
||||
return "HEIF"
|
||||
return suffix.lstrip(".").upper() or "image"
|
||||
|
||||
|
||||
def _validate_image_signature(path: Path, suffix: str) -> None:
|
||||
with path.open("rb") as source:
|
||||
signature = source.read(64)
|
||||
|
||||
valid = False
|
||||
if suffix in {".jpg", ".jpeg"}:
|
||||
valid = signature.startswith(b"\xff\xd8\xff")
|
||||
elif suffix == ".png":
|
||||
valid = signature.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
elif suffix in TIFF_EXTENSIONS:
|
||||
valid = signature.startswith((b"II*\x00", b"MM\x00*", b"II+\x00", b"MM\x00+"))
|
||||
elif suffix in HEIF_EXTENSIONS:
|
||||
hevc_brands = {
|
||||
b"heic", b"heix", b"hevc", b"hevx",
|
||||
b"heim", b"heis", b"hevm", b"hevs",
|
||||
}
|
||||
valid = len(signature) >= 16 and signature[4:8] == b"ftyp" and any(
|
||||
signature[offset:offset + 4] in hevc_brands
|
||||
for offset in range(8, len(signature) - 3, 4)
|
||||
)
|
||||
|
||||
if not valid:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail=f"The uploaded file is not a valid {_format_name(suffix)} image.",
|
||||
)
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: Path, suffix: str) -> None:
|
||||
total = 0
|
||||
with destination.open("wb") as output:
|
||||
while chunk := await upload.read(1024 * 1024):
|
||||
@@ -110,10 +442,7 @@ async def _save_upload(upload: UploadFile, destination: Path) -> None:
|
||||
)
|
||||
output.write(chunk)
|
||||
|
||||
with destination.open("rb") as source:
|
||||
signature = source.read(3)
|
||||
if signature[:2] != b"\xff\xd8":
|
||||
raise HTTPException(status_code=415, detail="The uploaded file is not a valid JPEG image.")
|
||||
_validate_image_signature(destination, suffix)
|
||||
|
||||
|
||||
def _first_value(metadata: dict[str, Any], *keys: str) -> Any:
|
||||
@@ -149,7 +478,20 @@ def _as_keywords(*values: Any) -> list[str]:
|
||||
return output
|
||||
|
||||
|
||||
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, str]]:
|
||||
|
||||
def _signed_coordinate(value: Any, reference: Any, negative_letter: str) -> float | None:
|
||||
try:
|
||||
coordinate = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if coordinate < 0:
|
||||
return coordinate
|
||||
reference_text = _as_text(reference).upper()
|
||||
if reference_text == negative_letter or reference_text == "1":
|
||||
return -coordinate
|
||||
return coordinate
|
||||
|
||||
def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, Any]]:
|
||||
date_text = _as_text(
|
||||
_first_value(
|
||||
metadata,
|
||||
@@ -169,6 +511,11 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, st
|
||||
)
|
||||
)
|
||||
keywords = _as_keywords(metadata.get("Subject"), metadata.get("Keywords"))
|
||||
latitude = _signed_coordinate(metadata.get("GPSLatitude"), metadata.get("GPSLatitudeRef"), "S")
|
||||
longitude = _signed_coordinate(metadata.get("GPSLongitude"), metadata.get("GPSLongitudeRef"), "W")
|
||||
location_name = _as_text(
|
||||
_first_value(metadata, "Location", "LocationShownLocationName", "City")
|
||||
)
|
||||
|
||||
precision = "exact"
|
||||
time_mode = "known"
|
||||
@@ -209,27 +556,80 @@ def _metadata_values(metadata: dict[str, Any]) -> tuple[bool, bool, dict[str, st
|
||||
"time": parsed_date.strftime("%H:%M:%S") if parsed_date else "12:00:00",
|
||||
"description": description,
|
||||
"keywords": ", ".join(keywords),
|
||||
"latitude": f"{latitude:.8f}" if latitude is not None else "",
|
||||
"longitude": f"{longitude:.8f}" if longitude is not None else "",
|
||||
"locationName": location_name,
|
||||
"locationDirty": False,
|
||||
}
|
||||
has_metadata = bool(parsed_date or description or keywords or marker)
|
||||
has_metadata = bool(parsed_date or description or keywords or marker or latitude is not None or longitude is not None or location_name)
|
||||
edited_by_app = bool(marker)
|
||||
return has_metadata, edited_by_app, values
|
||||
|
||||
|
||||
@app.post("/api/preview")
|
||||
async def create_preview(file: Annotated[UploadFile, File(...)]) -> Response:
|
||||
"""Generate a temporary browser-friendly preview for TIFF and HEIF images."""
|
||||
filename = file.filename or "photo.tiff"
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SERVER_PREVIEW_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Server-generated previews are only required for TIFF and HEIF files.",
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-preview-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"source{suffix}"
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
try:
|
||||
with Image.open(temp_path) as source:
|
||||
source.seek(0)
|
||||
preview = ImageOps.exif_transpose(source)
|
||||
preview.thumbnail((PREVIEW_MAX_EDGE, PREVIEW_MAX_EDGE), Image.Resampling.LANCZOS)
|
||||
|
||||
if preview.mode in {"RGBA", "LA"} or (preview.mode == "P" and "transparency" in preview.info):
|
||||
rgba = preview.convert("RGBA")
|
||||
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
|
||||
background.alpha_composite(rgba)
|
||||
preview = background.convert("RGB")
|
||||
elif preview.mode != "RGB":
|
||||
preview = preview.convert("RGB")
|
||||
|
||||
output = io.BytesIO()
|
||||
preview.save(output, format="JPEG", quality=88, optimize=True)
|
||||
except (Image.DecompressionBombError, UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
format_name = _format_name(suffix)
|
||||
logger.warning("%s preview generation failed for %s: %s", format_name, filename, exc)
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"The {format_name} image could not be rendered for preview.",
|
||||
) from exc
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/metadata")
|
||||
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, str]]:
|
||||
async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, bool | dict[str, Any]]:
|
||||
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.")
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, and HIF.",
|
||||
)
|
||||
|
||||
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)
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"exiftool",
|
||||
"-json",
|
||||
"-s",
|
||||
"-n",
|
||||
"-DateTimeOriginal",
|
||||
"-CreateDate",
|
||||
"-ModifyDate",
|
||||
@@ -240,6 +640,13 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
|
||||
"-Caption-Abstract",
|
||||
"-Subject",
|
||||
"-Keywords",
|
||||
"-GPSLatitude",
|
||||
"-GPSLatitudeRef",
|
||||
"-GPSLongitude",
|
||||
"-GPSLongitudeRef",
|
||||
"-Location",
|
||||
"-LocationShownLocationName",
|
||||
"-City",
|
||||
str(temp_path),
|
||||
],
|
||||
capture_output=True,
|
||||
@@ -270,11 +677,18 @@ async def process_photo(
|
||||
time_value: Annotated[str | None, Form()] = None,
|
||||
description: Annotated[str, Form()] = "",
|
||||
keywords_json: Annotated[str, Form()] = "[]",
|
||||
gps_action: Annotated[str, Form()] = "preserve",
|
||||
latitude: Annotated[float | None, Form()] = None,
|
||||
longitude: Annotated[float | None, Form()] = None,
|
||||
location_name: Annotated[str, Form()] = "",
|
||||
) -> Response:
|
||||
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.")
|
||||
if suffix not in SUPPORTED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Supported formats are JPG, JPEG, PNG, TIF, TIFF, HEIC, HEIF, and HIF.",
|
||||
)
|
||||
|
||||
exif_datetime, xmp_date, precision_label = _normalise_datetime(
|
||||
precision=precision,
|
||||
@@ -293,14 +707,24 @@ async def process_photo(
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail="Keywords must be a JSON array.") from exc
|
||||
|
||||
if gps_action not in {"preserve", "set", "clear"}:
|
||||
raise HTTPException(status_code=422, detail="Unsupported GPS action.")
|
||||
if gps_action == "set":
|
||||
if latitude is None or longitude is None:
|
||||
raise HTTPException(status_code=422, detail="Both latitude and longitude are required.")
|
||||
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
|
||||
raise HTTPException(status_code=422, detail="The GPS coordinates are outside the valid range.")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="photo-date-editor-") as temp_dir:
|
||||
temp_path = Path(temp_dir) / f"working{suffix}"
|
||||
await _save_upload(file, temp_path)
|
||||
await _save_upload(file, temp_path, suffix)
|
||||
|
||||
supports_iptc = suffix in {".jpg", ".jpeg", ".tif", ".tiff"}
|
||||
command = [
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-m",
|
||||
"-n",
|
||||
f"-EXIF:DateTimeOriginal={exif_datetime}",
|
||||
f"-EXIF:CreateDate={exif_datetime}",
|
||||
f"-EXIF:ModifyDate={exif_datetime}",
|
||||
@@ -311,10 +735,10 @@ async def process_photo(
|
||||
f"-XMP-photoshop:Instructions=Photo Date Editor precision: {precision_label}; time: {time_label}",
|
||||
"-EXIF:ImageDescription=",
|
||||
"-XMP-dc:Description=",
|
||||
"-IPTC:Caption-Abstract=",
|
||||
"-XMP-dc:Subject=",
|
||||
"-IPTC:Keywords=",
|
||||
]
|
||||
if supports_iptc:
|
||||
command.extend(["-IPTC:Caption-Abstract=", "-IPTC:Keywords="])
|
||||
|
||||
clean_description = description.strip()
|
||||
if clean_description:
|
||||
@@ -322,13 +746,45 @@ async def process_photo(
|
||||
[
|
||||
f"-EXIF:ImageDescription={clean_description}",
|
||||
f"-XMP-dc:Description={clean_description}",
|
||||
f"-IPTC:Caption-Abstract={clean_description}",
|
||||
]
|
||||
)
|
||||
if supports_iptc:
|
||||
command.append(f"-IPTC:Caption-Abstract={clean_description}")
|
||||
|
||||
for keyword in keywords:
|
||||
command.append(f"-XMP-dc:Subject+={keyword}")
|
||||
command.append(f"-IPTC:Keywords+={keyword}")
|
||||
if supports_iptc:
|
||||
command.append(f"-IPTC:Keywords+={keyword}")
|
||||
|
||||
if gps_action == "clear":
|
||||
command.extend(
|
||||
[
|
||||
"-EXIF:GPSLatitude=",
|
||||
"-EXIF:GPSLatitudeRef=",
|
||||
"-EXIF:GPSLongitude=",
|
||||
"-EXIF:GPSLongitudeRef=",
|
||||
"-XMP-exif:GPSLatitude=",
|
||||
"-XMP-exif:GPSLongitude=",
|
||||
"-XMP-iptcCore:Location=",
|
||||
]
|
||||
)
|
||||
elif gps_action == "set" and latitude is not None and longitude is not None:
|
||||
latitude_ref = "N" if latitude >= 0 else "S"
|
||||
longitude_ref = "E" if longitude >= 0 else "W"
|
||||
command.extend(
|
||||
[
|
||||
f"-EXIF:GPSLatitude={abs(latitude):.8f}",
|
||||
f"-EXIF:GPSLatitudeRef={latitude_ref}",
|
||||
f"-EXIF:GPSLongitude={abs(longitude):.8f}",
|
||||
f"-EXIF:GPSLongitudeRef={longitude_ref}",
|
||||
f"-XMP-exif:GPSLatitude={latitude:.8f}",
|
||||
f"-XMP-exif:GPSLongitude={longitude:.8f}",
|
||||
"-XMP-iptcCore:Location=",
|
||||
]
|
||||
)
|
||||
clean_location_name = location_name.strip()
|
||||
if clean_location_name:
|
||||
command.append(f"-XMP-iptcCore:Location={clean_location_name}")
|
||||
|
||||
command.append(str(temp_path))
|
||||
|
||||
@@ -348,7 +804,7 @@ async def process_photo(
|
||||
|
||||
return Response(
|
||||
content=updated_bytes,
|
||||
media_type="image/jpeg",
|
||||
media_type=MEDIA_TYPES[suffix],
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="{Path(filename).name}"',
|
||||
"Cache-Control": "no-store",
|
||||
|
||||
+494
-37
@@ -8,6 +8,11 @@ const state = {
|
||||
rotation: 0,
|
||||
processing: false,
|
||||
selectionToken: 0,
|
||||
mapConfig: null,
|
||||
map: null,
|
||||
mapMarker: null,
|
||||
locationDirty: false,
|
||||
favoriteLocations: [],
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -20,9 +25,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'),
|
||||
@@ -51,6 +60,19 @@ const elements = {
|
||||
description: $('#description-input'),
|
||||
descriptionCount: $('#description-count'),
|
||||
keywords: $('#keywords-input'),
|
||||
addressSearch: $('#address-search'),
|
||||
addressSearchButton: $('#address-search-button'),
|
||||
addressResults: $('#address-results'),
|
||||
locationMap: $('#location-map'),
|
||||
mapStatus: $('#map-status'),
|
||||
latitude: $('#latitude-input'),
|
||||
longitude: $('#longitude-input'),
|
||||
locationName: $('#location-name-input'),
|
||||
clearLocation: $('#clear-location'),
|
||||
favoriteSelect: $('#favorite-location-select'),
|
||||
useFavorite: $('#use-favorite-location'),
|
||||
saveFavorite: $('#save-favorite-location'),
|
||||
deleteFavorite: $('#delete-favorite-location'),
|
||||
saveStatus: $('#save-status'),
|
||||
shortcutsButton: $('#shortcuts-button'),
|
||||
shortcutsDialog: $('#shortcuts-dialog'),
|
||||
@@ -91,6 +113,19 @@ 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';
|
||||
if (extension === 'heic' || extension === 'heif' || extension === 'hif') return 'HEIF';
|
||||
return extension.toUpperCase();
|
||||
}
|
||||
|
||||
function requiresServerPreview(photo) {
|
||||
return photo.format === 'TIFF' || photo.format === 'HEIF';
|
||||
}
|
||||
|
||||
function storageKey() {
|
||||
return state.directoryHandle ? `photo-date-editor:${state.directoryHandle.name}` : null;
|
||||
}
|
||||
@@ -117,6 +152,8 @@ function saveFolderState() {
|
||||
}
|
||||
|
||||
async function openFolder() {
|
||||
await loadFavoriteLocations();
|
||||
|
||||
if (!isSupported()) {
|
||||
showToast('Use a current Chromium browser over HTTPS. Folder access is unavailable here.', true);
|
||||
return;
|
||||
@@ -140,13 +177,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?|heic|heif|hif)$/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',
|
||||
@@ -163,6 +201,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);
|
||||
|
||||
@@ -176,7 +216,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, TIFF, HEIC, HEIF, or HIF files were found in that folder.', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,14 +227,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 '→';
|
||||
@@ -202,15 +262,35 @@ 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 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));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -220,12 +300,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';
|
||||
@@ -253,6 +337,7 @@ function formatMetadataSummary(values) {
|
||||
if (values.description?.trim()) parts.push('description set');
|
||||
const keywordCount = parseKeywords(values.keywords || '').length;
|
||||
if (keywordCount) parts.push(`${keywordCount} keyword${keywordCount === 1 ? '' : 's'}`);
|
||||
if (validCoordinates(values.latitude, values.longitude)) parts.push(values.locationName?.trim() || 'location set');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -301,11 +386,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);
|
||||
@@ -315,8 +398,22 @@ 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 ${photo.format} 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;
|
||||
@@ -330,9 +427,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;
|
||||
@@ -359,12 +465,16 @@ function formValues() {
|
||||
time: elements.time.value,
|
||||
description: elements.description.value,
|
||||
keywords: elements.keywords.value,
|
||||
latitude: elements.latitude.value,
|
||||
longitude: elements.longitude.value,
|
||||
locationName: elements.locationName.value,
|
||||
locationDirty: state.locationDirty,
|
||||
};
|
||||
}
|
||||
|
||||
function formHasUserInput() {
|
||||
const values = formValues();
|
||||
return Boolean(values.year || values.description.trim() || values.keywords.trim());
|
||||
return Boolean(values.year || values.description.trim() || values.keywords.trim() || validCoordinates(values.latitude, values.longitude));
|
||||
}
|
||||
|
||||
function captureCurrentValues() {
|
||||
@@ -375,7 +485,8 @@ function captureCurrentValues() {
|
||||
function restoreValues(values) {
|
||||
const defaults = {
|
||||
precision: 'exact', year: '', month: '1', day: '1', timeMode: 'unknown',
|
||||
time: '12:00:00', description: '', keywords: '',
|
||||
time: '12:00:00', description: '', keywords: '', latitude: '', longitude: '',
|
||||
locationName: '', locationDirty: false,
|
||||
};
|
||||
const value = { ...defaults, ...(values || {}) };
|
||||
const radio = $(`input[name="precision"][value="${CSS.escape(value.precision)}"]`);
|
||||
@@ -387,9 +498,21 @@ function restoreValues(values) {
|
||||
elements.time.value = value.time;
|
||||
elements.description.value = value.description;
|
||||
elements.keywords.value = value.keywords;
|
||||
elements.latitude.value = value.latitude || '';
|
||||
elements.longitude.value = value.longitude || '';
|
||||
elements.locationName.value = value.locationName || '';
|
||||
state.locationDirty = Boolean(value.locationDirty);
|
||||
elements.timeField.classList.toggle('hidden', value.timeMode !== 'known');
|
||||
elements.descriptionCount.textContent = `${elements.description.value.length} / 2000`;
|
||||
updatePrecisionFields();
|
||||
syncMapFromFields(false);
|
||||
}
|
||||
|
||||
function validCoordinates(latitude, longitude) {
|
||||
if (latitude === '' || longitude === '' || latitude === null || longitude === null) return false;
|
||||
const lat = Number(latitude);
|
||||
const lon = Number(longitude);
|
||||
return Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180;
|
||||
}
|
||||
|
||||
function parseKeywords(value) {
|
||||
@@ -409,6 +532,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);
|
||||
@@ -429,6 +553,14 @@ async function savePhoto(moveNext) {
|
||||
if (values.timeMode === 'known') payload.append('time_value', values.time);
|
||||
payload.append('description', values.description);
|
||||
payload.append('keywords_json', JSON.stringify(parseKeywords(values.keywords)));
|
||||
const hasCoordinates = validCoordinates(values.latitude, values.longitude);
|
||||
const gpsAction = values.locationDirty ? (hasCoordinates ? 'set' : 'clear') : 'preserve';
|
||||
payload.append('gps_action', gpsAction);
|
||||
if (gpsAction === 'set') {
|
||||
payload.append('latitude', values.latitude);
|
||||
payload.append('longitude', values.longitude);
|
||||
payload.append('location_name', values.locationName);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/process', { method: 'POST', body: payload });
|
||||
if (!response.ok) {
|
||||
@@ -451,12 +583,14 @@ async function savePhoto(moveNext) {
|
||||
photo.size = updatedFile.size;
|
||||
photo.lastModified = updatedFile.lastModified;
|
||||
photo.status = 'saved';
|
||||
photo.values = values;
|
||||
photo.fileValues = { ...values };
|
||||
const savedValues = { ...values, locationDirty: false };
|
||||
state.locationDirty = false;
|
||||
photo.values = savedValues;
|
||||
photo.fileValues = { ...savedValues };
|
||||
photo.metadataLoaded = true;
|
||||
photo.metadataPresent = true;
|
||||
saveFolderState();
|
||||
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(values));
|
||||
setSaveStatus('saved', 'Saved in place', formatMetadataSummary(savedValues));
|
||||
showToast(`Saved ${photo.name}`);
|
||||
updateProgress();
|
||||
|
||||
@@ -464,8 +598,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 +619,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() {
|
||||
@@ -515,11 +659,304 @@ function copyPreviousValues() {
|
||||
captureCurrentValues();
|
||||
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 });
|
||||
restoreValues({ ...previous, locationDirty: true });
|
||||
state.locationDirty = true;
|
||||
captureCurrentValues();
|
||||
showToast('Copied values from the previous photo.');
|
||||
}
|
||||
|
||||
function activeTabIndex() {
|
||||
return $$('.tab').findIndex((tab) => tab.classList.contains('active'));
|
||||
}
|
||||
|
||||
function focusFirstControl(tabName) {
|
||||
const panel = document.querySelector(`.tab-panel[data-panel="${tabName}"]`);
|
||||
const first = panel?.querySelector('input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])');
|
||||
first?.focus();
|
||||
}
|
||||
|
||||
function activateTab(tabName, focusMode = 'none') {
|
||||
const tabs = $$('.tab');
|
||||
const target = tabs.find((tab) => tab.dataset.tab === tabName);
|
||||
if (!target) return;
|
||||
tabs.forEach((item) => {
|
||||
const active = item === target;
|
||||
item.classList.toggle('active', active);
|
||||
item.setAttribute('aria-selected', String(active));
|
||||
item.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tabName));
|
||||
|
||||
if (tabName === 'location') {
|
||||
initialiseMap();
|
||||
window.setTimeout(() => {
|
||||
state.map?.invalidateSize();
|
||||
syncMapFromFields(false);
|
||||
if (focusMode === 'field') focusFirstControl(tabName);
|
||||
else if (focusMode === 'tab') target.focus();
|
||||
}, 0);
|
||||
} else if (focusMode === 'field') {
|
||||
window.setTimeout(() => focusFirstControl(tabName), 0);
|
||||
} else if (focusMode === 'tab') {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function cycleTabs(delta, focusMode = 'field') {
|
||||
const tabs = $$('.tab');
|
||||
if (!tabs.length) return;
|
||||
const current = Math.max(0, activeTabIndex());
|
||||
const next = (current + delta + tabs.length) % tabs.length;
|
||||
activateTab(tabs[next].dataset.tab, focusMode);
|
||||
}
|
||||
|
||||
function markLocationDirty() {
|
||||
state.locationDirty = true;
|
||||
captureCurrentValues();
|
||||
}
|
||||
|
||||
function setMapStatus(message, isError = false) {
|
||||
if (!elements.mapStatus) return;
|
||||
elements.mapStatus.textContent = message;
|
||||
elements.mapStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function initialiseMap() {
|
||||
if (state.map || !elements.locationMap) return;
|
||||
if (typeof window.L === 'undefined') {
|
||||
setMapStatus('Map library failed to load. Rebuild the container and hard-refresh the page.', true);
|
||||
return;
|
||||
}
|
||||
const config = state.mapConfig || {};
|
||||
try {
|
||||
state.map = L.map(elements.locationMap, { zoomControl: true }).setView(
|
||||
[Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)],
|
||||
Number(config.defaultMapZoom ?? 5),
|
||||
);
|
||||
const layer = L.tileLayer(config.mapTileUrl || '/api/map/tiles/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: config.mapAttribution || '© OpenStreetMap contributors',
|
||||
}).addTo(state.map);
|
||||
layer.on('loading', () => setMapStatus('Loading map…'));
|
||||
layer.on('load', () => setMapStatus('Click the map to place a pin.'));
|
||||
layer.on('tileerror', () => setMapStatus('Map tiles could not be loaded. Address search and manual coordinates still work.', true));
|
||||
state.map.on('click', (event) => {
|
||||
setLocation(event.latlng.lat, event.latlng.lng, '', true, false);
|
||||
});
|
||||
syncMapFromFields(false);
|
||||
} catch (error) {
|
||||
state.map = null;
|
||||
setMapStatus(error.message || 'The map could not be initialized.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function setMarker(latitude, longitude, center = false) {
|
||||
if (!state.map || !validCoordinates(latitude, longitude)) return;
|
||||
const lat = Number(latitude);
|
||||
const lon = Number(longitude);
|
||||
if (!state.mapMarker) {
|
||||
state.mapMarker = L.marker([lat, lon], { draggable: true }).addTo(state.map);
|
||||
state.mapMarker.on('dragend', () => {
|
||||
const point = state.mapMarker.getLatLng();
|
||||
setLocation(point.lat, point.lng, '', true, false);
|
||||
});
|
||||
} else {
|
||||
state.mapMarker.setLatLng([lat, lon]);
|
||||
}
|
||||
if (center) state.map.setView([lat, lon], Math.max(state.map.getZoom(), 15));
|
||||
}
|
||||
|
||||
function removeMarker() {
|
||||
if (state.map && state.mapMarker) state.map.removeLayer(state.mapMarker);
|
||||
state.mapMarker = null;
|
||||
}
|
||||
|
||||
function setLocation(latitude, longitude, label = '', dirty = true, center = true) {
|
||||
elements.latitude.value = Number(latitude).toFixed(6);
|
||||
elements.longitude.value = Number(longitude).toFixed(6);
|
||||
elements.locationName.value = label;
|
||||
if (dirty) state.locationDirty = true;
|
||||
setMarker(latitude, longitude, center);
|
||||
captureCurrentValues();
|
||||
}
|
||||
|
||||
function syncMapFromFields(center = false) {
|
||||
if (!state.map) return;
|
||||
if (validCoordinates(elements.latitude.value, elements.longitude.value)) {
|
||||
setMarker(elements.latitude.value, elements.longitude.value, center);
|
||||
} else {
|
||||
removeMarker();
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocation() {
|
||||
elements.latitude.value = '';
|
||||
elements.longitude.value = '';
|
||||
elements.locationName.value = '';
|
||||
state.locationDirty = true;
|
||||
removeMarker();
|
||||
captureCurrentValues();
|
||||
showToast('Location cleared. Save the photo to remove GPS metadata.');
|
||||
}
|
||||
|
||||
function renderFavoriteLocations(selectedId = '') {
|
||||
const favorites = state.favoriteLocations;
|
||||
elements.favoriteSelect.innerHTML = '';
|
||||
if (!favorites.length) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = 'No favorites saved';
|
||||
elements.favoriteSelect.appendChild(option);
|
||||
elements.favoriteSelect.disabled = true;
|
||||
elements.useFavorite.disabled = true;
|
||||
elements.deleteFavorite.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = 'Choose a favorite…';
|
||||
elements.favoriteSelect.appendChild(placeholder);
|
||||
for (const favorite of favorites) {
|
||||
const option = document.createElement('option');
|
||||
option.value = favorite.id;
|
||||
option.textContent = `${favorite.name} — ${Number(favorite.latitude).toFixed(5)}, ${Number(favorite.longitude).toFixed(5)}`;
|
||||
elements.favoriteSelect.appendChild(option);
|
||||
}
|
||||
elements.favoriteSelect.disabled = false;
|
||||
elements.favoriteSelect.value = favorites.some((item) => item.id === selectedId) ? selectedId : '';
|
||||
updateFavoriteButtons();
|
||||
}
|
||||
|
||||
function updateFavoriteButtons() {
|
||||
const selected = Boolean(elements.favoriteSelect.value);
|
||||
elements.useFavorite.disabled = !selected;
|
||||
elements.deleteFavorite.disabled = !selected;
|
||||
}
|
||||
|
||||
async function loadFavoriteLocations(selectedId = '') {
|
||||
try {
|
||||
const response = await fetch('/api/favorites', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`Could not load favorites (${response.status}).`);
|
||||
const data = await response.json();
|
||||
state.favoriteLocations = Array.isArray(data.favorites) ? data.favorites : [];
|
||||
renderFavoriteLocations(selectedId);
|
||||
} catch (error) {
|
||||
state.favoriteLocations = [];
|
||||
renderFavoriteLocations();
|
||||
showToast(error.message || 'Could not load favorite locations.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function useSelectedFavorite() {
|
||||
const favorite = state.favoriteLocations.find((item) => item.id === elements.favoriteSelect.value);
|
||||
if (!favorite) return;
|
||||
setLocation(favorite.latitude, favorite.longitude, favorite.name, true, true);
|
||||
showToast(`Location set to ${favorite.name}.`);
|
||||
}
|
||||
|
||||
async function saveCurrentAsFavorite() {
|
||||
if (!validCoordinates(elements.latitude.value, elements.longitude.value)) {
|
||||
showToast('Set a valid location before saving it as a favorite.', true);
|
||||
return;
|
||||
}
|
||||
const suggested = elements.locationName.value.trim() || 'Favorite location';
|
||||
const entered = window.prompt('Name this favorite location:', suggested);
|
||||
if (entered === null) return;
|
||||
const name = entered.trim();
|
||||
if (!name) {
|
||||
showToast('Favorite name cannot be empty.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
elements.saveFavorite.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
latitude: Number(elements.latitude.value),
|
||||
longitude: Number(elements.longitude.value),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `Could not save favorite (${response.status}).`;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
await loadFavoriteLocations(data.favorite?.id || '');
|
||||
showToast(`${name} added to favorite locations.`);
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Could not save favorite location.', true);
|
||||
} finally {
|
||||
elements.saveFavorite.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedFavorite() {
|
||||
const favorite = state.favoriteLocations.find((item) => item.id === elements.favoriteSelect.value);
|
||||
if (!favorite) return;
|
||||
if (!window.confirm(`Delete favorite location “${favorite.name}”?`)) return;
|
||||
|
||||
elements.deleteFavorite.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${encodeURIComponent(favorite.id)}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
let message = `Could not delete favorite (${response.status}).`;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
await loadFavoriteLocations();
|
||||
showToast(`${favorite.name} removed from favorite locations.`);
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Could not delete favorite location.', true);
|
||||
updateFavoriteButtons();
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAddress() {
|
||||
const query = elements.addressSearch.value.trim();
|
||||
if (query.length < 3) {
|
||||
showToast('Enter at least three characters to search.', true);
|
||||
return;
|
||||
}
|
||||
elements.addressSearchButton.disabled = true;
|
||||
elements.addressResults.innerHTML = '<div class="search-message">Searching…</div>';
|
||||
try {
|
||||
const response = await fetch(`/api/geocode?q=${encodeURIComponent(query)}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
let message = `Address search failed (${response.status}).`;
|
||||
try { message = (await response.json()).detail || message; } catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await response.json();
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
elements.addressResults.innerHTML = '';
|
||||
if (!results.length) {
|
||||
elements.addressResults.innerHTML = '<div class="search-message">No matching places found.</div>';
|
||||
return;
|
||||
}
|
||||
for (const result of results) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'address-result';
|
||||
button.innerHTML = `<strong>${escapeHtml(result.label)}</strong><span>${Number(result.latitude).toFixed(5)}, ${Number(result.longitude).toFixed(5)}</span>`;
|
||||
button.addEventListener('click', () => {
|
||||
setLocation(result.latitude, result.longitude, result.label, true, true);
|
||||
elements.addressResults.innerHTML = '';
|
||||
});
|
||||
elements.addressResults.appendChild(button);
|
||||
}
|
||||
} catch (error) {
|
||||
elements.addressResults.innerHTML = '';
|
||||
showToast(error.message || 'Address search failed.', true);
|
||||
} finally {
|
||||
elements.addressSearchButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
elements.preview.style.transform = `scale(${state.zoom}) rotate(${state.rotation}deg)`;
|
||||
}
|
||||
@@ -527,12 +964,26 @@ 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));
|
||||
elements.saveNext.addEventListener('click', () => savePhoto(true));
|
||||
elements.copyPrevious.addEventListener('click', copyPreviousValues);
|
||||
elements.addressSearchButton.addEventListener('click', searchAddress);
|
||||
elements.addressSearch.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); searchAddress(); } });
|
||||
elements.clearLocation.addEventListener('click', clearLocation);
|
||||
elements.favoriteSelect.addEventListener('change', updateFavoriteButtons);
|
||||
elements.useFavorite.addEventListener('click', useSelectedFavorite);
|
||||
elements.saveFavorite.addEventListener('click', saveCurrentAsFavorite);
|
||||
elements.deleteFavorite.addEventListener('click', deleteSelectedFavorite);
|
||||
for (const input of [elements.latitude, elements.longitude]) {
|
||||
input.addEventListener('input', () => { state.locationDirty = true; syncMapFromFields(false); });
|
||||
input.addEventListener('change', () => syncMapFromFields(true));
|
||||
}
|
||||
elements.locationName.addEventListener('input', markLocationDirty);
|
||||
elements.zoomOut.addEventListener('click', () => { state.zoom = Math.max(.25, state.zoom - .15); applyView(); });
|
||||
elements.zoomIn.addEventListener('click', () => { state.zoom = Math.min(4, state.zoom + .15); applyView(); });
|
||||
elements.fit.addEventListener('click', resetView);
|
||||
@@ -545,11 +996,9 @@ elements.form.addEventListener('input', captureCurrentValues);
|
||||
for (const radio of $$('input[name="precision"]')) radio.addEventListener('change', updatePrecisionFields);
|
||||
|
||||
for (const tab of $$('.tab')) {
|
||||
tab.addEventListener('click', () => {
|
||||
$$('.tab').forEach((item) => item.classList.toggle('active', item === tab));
|
||||
$$('.tab-panel').forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === tab.dataset.tab));
|
||||
});
|
||||
tab.addEventListener('click', () => activateTab(tab.dataset.tab));
|
||||
}
|
||||
activateTab('date');
|
||||
|
||||
elements.shortcutsButton.addEventListener('click', () => elements.shortcutsDialog.showModal());
|
||||
elements.closeShortcuts.addEventListener('click', () => elements.shortcutsDialog.close());
|
||||
@@ -559,6 +1008,9 @@ window.addEventListener('keydown', (event) => {
|
||||
const tag = document.activeElement?.tagName;
|
||||
const typing = ['INPUT', 'TEXTAREA', 'SELECT'].includes(tag);
|
||||
|
||||
if (event.key === 'Tab' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey && document.activeElement?.closest('.right-panel')) {
|
||||
event.preventDefault(); event.stopPropagation(); cycleTabs(-1, 'field'); return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault(); savePhoto(false); return;
|
||||
}
|
||||
@@ -579,9 +1031,14 @@ async function initialise() {
|
||||
const config = await response.json();
|
||||
elements.appName.textContent = config.appName;
|
||||
document.title = config.appName;
|
||||
state.mapConfig = config;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Favorite locations are server-side shared data and must be loaded on every page start,
|
||||
// independently of whether the user opens a photo folder in this browser session.
|
||||
await loadFavoriteLocations();
|
||||
|
||||
if (!isSupported()) {
|
||||
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';
|
||||
|
||||
+91
-10
@@ -6,6 +6,7 @@
|
||||
<meta name="theme-color" content="#121820">
|
||||
<title>Photo Date Editor</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="stylesheet" href="/vendor/leaflet/leaflet.css">
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,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, TIFF and HEIF metadata</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
@@ -42,7 +43,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,9 +58,23 @@
|
||||
|
||||
<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>
|
||||
<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, TIFF, HEIC, HEIF, or HIF photos.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -106,13 +125,14 @@
|
||||
|
||||
<aside class="sidebar right-panel">
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="date" role="tab">Date & time</button>
|
||||
<button class="tab" data-tab="details" role="tab">Details</button>
|
||||
<button class="tab" data-tab="keywords" role="tab">Keywords</button>
|
||||
<button id="tab-date" class="tab active" data-tab="date" role="tab" aria-selected="true" aria-controls="panel-date">Date & time</button>
|
||||
<button id="tab-details" class="tab" data-tab="details" role="tab" aria-selected="false" aria-controls="panel-details" tabindex="-1">Details</button>
|
||||
<button id="tab-keywords" class="tab" data-tab="keywords" role="tab" aria-selected="false" aria-controls="panel-keywords" tabindex="-1">Keywords</button>
|
||||
<button id="tab-location" class="tab" data-tab="location" role="tab" aria-selected="false" aria-controls="panel-location" tabindex="-1">Location</button>
|
||||
</div>
|
||||
|
||||
<form id="metadata-form" autocomplete="off">
|
||||
<section class="tab-panel active" data-panel="date">
|
||||
<section id="panel-date" class="tab-panel active" data-panel="date" role="tabpanel" aria-labelledby="tab-date">
|
||||
<fieldset>
|
||||
<legend>Date precision</legend>
|
||||
<label class="radio-row"><input type="radio" name="precision" value="exact" checked> <span>Exact date</span></label>
|
||||
@@ -161,7 +181,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-panel="details">
|
||||
<section id="panel-details" class="tab-panel" data-panel="details" role="tabpanel" aria-labelledby="tab-details">
|
||||
<label class="field">
|
||||
<span>Description</span>
|
||||
<textarea id="description-input" class="input textarea" rows="7" maxlength="2000" placeholder="Who, where, occasion, or other context…"></textarea>
|
||||
@@ -169,13 +189,72 @@
|
||||
<div class="helper-row"><span>Written to EXIF, XMP and IPTC</span><span id="description-count">0 / 2000</span></div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-panel="keywords">
|
||||
<section id="panel-keywords" class="tab-panel" data-panel="keywords" role="tabpanel" aria-labelledby="tab-keywords">
|
||||
<label class="field">
|
||||
<span>People and keywords</span>
|
||||
<textarea id="keywords-input" class="input textarea" rows="7" placeholder="Daniel, family, birthday, Bergen"></textarea>
|
||||
</label>
|
||||
<p class="field-help">Separate entries with commas or new lines. Written as XMP Subject and IPTC Keywords.</p>
|
||||
</section>
|
||||
|
||||
<section id="panel-location" class="tab-panel" data-panel="location" role="tabpanel" aria-labelledby="tab-location">
|
||||
<div class="favorite-locations">
|
||||
<div class="section-heading-row">
|
||||
<div>
|
||||
<strong>Favorite locations</strong>
|
||||
<span>Shared by everyone using this Docker stack</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="favorite-controls">
|
||||
<select id="favorite-location-select" class="input" aria-label="Favorite locations">
|
||||
<option value="">No favorites saved</option>
|
||||
</select>
|
||||
<button id="use-favorite-location" class="button" type="button" disabled>Use</button>
|
||||
</div>
|
||||
<div class="favorite-actions">
|
||||
<button id="save-favorite-location" class="button ghost" type="button">★ Add current location</button>
|
||||
<button id="delete-favorite-location" class="button ghost danger-text" type="button" disabled>Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="location-search">
|
||||
<label class="field location-search-field">
|
||||
<span>Search for a place or address</span>
|
||||
<input id="address-search" class="input" type="search" placeholder="Cabin address, town, landmark…">
|
||||
</label>
|
||||
<button id="address-search-button" class="button" type="button">Search</button>
|
||||
</div>
|
||||
<p class="field-help search-privacy">Search queries are sent to the configured geocoder. You can place a pin manually instead.</p>
|
||||
<div id="address-results" class="address-results" aria-live="polite"></div>
|
||||
|
||||
<div id="location-map" class="location-map" aria-label="Map for selecting photo location"></div>
|
||||
<p id="map-status" class="field-help map-status" role="status">Open this tab to load the map.</p>
|
||||
<p class="field-help map-help">Click the map to place a pin, or drag the pin to fine-tune the position.</p>
|
||||
|
||||
<div class="field-grid coordinate-grid">
|
||||
<label class="field">
|
||||
<span>Latitude</span>
|
||||
<input id="latitude-input" class="input" type="number" min="-90" max="90" step="0.000001" placeholder="60.391263">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Longitude</span>
|
||||
<input id="longitude-input" class="input" type="number" min="-180" max="180" step="0.000001" placeholder="5.322054">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>Location label</span>
|
||||
<input id="location-name-input" class="input" type="text" maxlength="500" placeholder="Cabin, Voss, Norway">
|
||||
</label>
|
||||
|
||||
<div class="location-actions">
|
||||
<button id="clear-location" class="button ghost" type="button">Clear location</button>
|
||||
</div>
|
||||
<div class="info-card location-info">
|
||||
<strong>What gets written</strong>
|
||||
<p>Coordinates are written to EXIF and XMP GPS fields. The optional label is written to XMP Location.</p>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section class="right-footer">
|
||||
@@ -197,10 +276,12 @@
|
||||
<div><dt>← / →</dt><dd>Previous or 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>
|
||||
<div><dt>Shift+Tab</dt><dd>Open the previous metadata tab and focus its first field</dd></div>
|
||||
</dl>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/vendor/leaflet/leaflet.js"></script>
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 JPG, PNG, TIFF, and HEIF photos."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v2';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
|
||||
const CACHE_NAME = 'photo-date-editor-v6-0';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
@@ -14,7 +14,13 @@ self.addEventListener('activate', (event) => {
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return;
|
||||
const url = new URL(event.request.url);
|
||||
if (
|
||||
event.request.method !== 'GET'
|
||||
|| url.origin !== self.location.origin
|
||||
|| url.pathname.startsWith('/api/')
|
||||
) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request).then((response) => {
|
||||
const copy = response.clone();
|
||||
|
||||
+56
-4
@@ -41,21 +41,29 @@ 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; }
|
||||
.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; }
|
||||
@@ -106,3 +114,47 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
|
||||
.topbar { height: auto; min-height: 70px; flex-wrap: wrap; gap: 10px; padding: 12px; }.brand p { display: none; }
|
||||
.workspace { display: block; overflow: visible; }.left-panel, .right-panel { border: 0; }.left-panel { max-height: 480px; }.viewer-panel { min-height: 620px; }.right-panel { min-height: 600px; }
|
||||
}
|
||||
|
||||
/* v0.4 location editor */
|
||||
.tabs { grid-template-columns: repeat(4, 1fr); }
|
||||
.location-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 8px; }
|
||||
.location-search-field { margin-bottom: 0; }
|
||||
.address-results { display: grid; gap: 6px; margin: 10px 0; max-height: 160px; overflow: auto; }
|
||||
.address-result { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #10171f; padding: 9px 10px; text-align: left; cursor: pointer; }
|
||||
.address-result:hover { border-color: var(--primary); background: #172131; }
|
||||
.address-result strong, .address-result span { display: block; }
|
||||
.address-result strong { font-size: 12px; line-height: 1.4; }
|
||||
.address-result span { margin-top: 3px; color: var(--muted); font-size: 10px; }
|
||||
.address-results .search-message { color: var(--muted); font-size: 11px; padding: 4px 1px; }
|
||||
.location-map { height: 310px; min-height: 240px; border: 1px solid var(--line); border-radius: 9px; overflow: hidden; background: #0b1118; }
|
||||
.location-map:focus-within { outline: 2px solid rgba(79,135,255,.35); }
|
||||
.map-help { margin: 8px 0 15px; }
|
||||
.coordinate-grid { grid-template-columns: 1fr 1fr; }
|
||||
.location-actions { display: flex; justify-content: flex-end; margin: -3px 0 16px; }
|
||||
.location-info { margin-top: 0; }
|
||||
.leaflet-container { background: #0b1118; font-family: inherit; }
|
||||
.leaflet-control-attribution { font-size: 9px; }
|
||||
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: #18212b; color: var(--text); }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.tab { font-size: 12px; padding-inline: 5px; }
|
||||
}
|
||||
.search-privacy { margin: 7px 0 0; }
|
||||
.map-status { margin-top: 8px; min-height: 1.2em; }
|
||||
.map-status.error { color: var(--danger); }
|
||||
|
||||
/* v0.4.2 shared favorite locations */
|
||||
.favorite-locations {
|
||||
margin: 0 0 18px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: rgba(16, 23, 31, .72);
|
||||
}
|
||||
.section-heading-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 9px; }
|
||||
.section-heading-row strong, .section-heading-row span { display: block; }
|
||||
.section-heading-row strong { font-size: 12px; }
|
||||
.section-heading-row span { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.favorite-controls { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.favorite-actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-top: 8px; }
|
||||
.danger-text { color: var(--danger); }
|
||||
|
||||
@@ -12,6 +12,9 @@ services:
|
||||
APP_NAME: "${APP_NAME:-Photo Date Editor}"
|
||||
MAX_UPLOAD_MB: "${MAX_UPLOAD_MB:-150}"
|
||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||
DATA_DIR: "/data"
|
||||
volumes:
|
||||
- photo-date-editor-data:/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
@@ -22,3 +25,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
photo-date-editor-data:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
fastapi>=0.116,<1
|
||||
uvicorn[standard]>=0.35,<1
|
||||
python-multipart>=0.0.20,<1
|
||||
Pillow>=11.3,<13
|
||||
pillow-heif>=1.1,<2
|
||||
|
||||
Reference in New Issue
Block a user