Add shared favorite location v0.4.2
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Photo Date Editor
|
||||
|
||||
Version 0.4.1
|
||||
Version 0.4.2
|
||||
|
||||
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.
|
||||
|
||||
@@ -13,6 +13,7 @@ A self-hosted browser UI for manually dating scanned photographs. The browser re
|
||||
- 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
|
||||
- Filename search, status filtering, and filename/status sorting
|
||||
@@ -58,7 +59,7 @@ 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 through 0.4.0
|
||||
## Upgrade from 0.1 through 0.4.1
|
||||
|
||||
Replace the project files with this version and rebuild:
|
||||
|
||||
@@ -122,12 +123,16 @@ The chosen precision is also written into XMP Photoshop Instructions.
|
||||
|
||||
## Location behavior
|
||||
|
||||
The **Location** tab supports four ways to set GPS metadata:
|
||||
Favorite locations 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.
|
||||
|
||||
1. Search for an address or place and choose a result.
|
||||
2. Click directly on the map.
|
||||
3. Drag the existing marker.
|
||||
4. Enter decimal latitude and longitude manually.
|
||||
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.
|
||||
|
||||
On save, coordinates are written to EXIF and XMP GPS fields. The optional location label is written to XMP IPTC Core Location. Use **Clear location** and save to remove GPS coordinates and the app-managed location label.
|
||||
|
||||
@@ -165,3 +170,9 @@ Format support needs two separate capabilities: safe metadata writing and a prev
|
||||
- **SVG, PDF, EPS, WebP, AVIF, EXR, XCF** — intentionally outside the scope of this photo workflow.
|
||||
|
||||
RAW support will remain **in-place** by default to match the JPEG workflow. Before enabling it, the app needs format-specific round-trip tests and clear warnings because proprietary camera originals deserve a stricter safety bar than scans.
|
||||
|
||||
## Future video support
|
||||
|
||||
MP4 is feasible after the planned photo formats, but it will be a separate media phase rather than just another image extension. Browsers can preview MP4 directly, while ExifTool can write selected QuickTime/MP4 metadata such as creation dates, descriptive fields, and static GPS coordinates. Videos do not use the same EXIF model as JPEGs, so the app will map the existing fields to compatible QuickTime and XMP tags.
|
||||
|
||||
The current browser-to-Docker-to-browser workflow transfers the complete file for every save. That is acceptable for photos but inefficient for large videos. Before enabling MP4, the app should add streamed/chunked transfer, larger configurable upload limits, clear progress reporting, and round-trip tests against common players and photo libraries. MP4 support is planned after PNG, TIFF, HEIC/HEIF, DNG, Canon RAW, and Nikon RAW.
|
||||
|
||||
+89
-1
@@ -7,7 +7,9 @@ import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -17,12 +19,13 @@ 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
|
||||
|
||||
APP_NAME = os.getenv("APP_NAME", "Photo Date Editor")
|
||||
APP_URL = os.getenv("APP_URL", "http://localhost:8080")
|
||||
APP_VERSION = "0.4.1"
|
||||
APP_VERSION = "0.4.2"
|
||||
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
@@ -59,6 +62,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)
|
||||
|
||||
@@ -83,6 +96,81 @@ def config() -> dict[str, str | int | float]:
|
||||
}
|
||||
|
||||
|
||||
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 = (
|
||||
|
||||
@@ -12,6 +12,7 @@ const state = {
|
||||
map: null,
|
||||
mapMarker: null,
|
||||
locationDirty: false,
|
||||
favoriteLocations: [],
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -68,6 +69,10 @@ const elements = {
|
||||
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'),
|
||||
@@ -134,6 +139,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;
|
||||
@@ -740,6 +747,123 @@ function clearLocation() {
|
||||
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) {
|
||||
@@ -799,6 +923,10 @@ 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));
|
||||
|
||||
@@ -197,6 +197,25 @@
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v4-1';
|
||||
const CACHE_NAME = 'photo-date-editor-v4-2';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest', '/vendor/leaflet/leaflet.css', '/vendor/leaflet/leaflet.js'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -139,3 +139,19 @@ fieldset { border: 0; padding: 0; margin: 0 0 22px; } legend { font-weight: 650;
|
||||
.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:
|
||||
|
||||
Reference in New Issue
Block a user