Add geotagging and location map v0.4
This commit is contained in:
1 parent
cbf0daf062
commit
e290bd1fff
12 files changed
+578
-45
No files matched your search
Binary file not shown.
+199
-5
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
@@ -16,11 +21,29 @@ 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.3.0"
|
||||
APP_VERSION = "0.4.0"
|
||||
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "150"))
|
||||
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
|
||||
MAP_TILE_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
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
@@ -39,15 +62,113 @@ 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": MAP_TILE_URL,
|
||||
"mapAttribution": MAP_ATTRIBUTION,
|
||||
"defaultMapLat": DEFAULT_MAP_LAT,
|
||||
"defaultMapLon": DEFAULT_MAP_LON,
|
||||
"defaultMapZoom": DEFAULT_MAP_ZOOM,
|
||||
}
|
||||
|
||||
|
||||
|
||||
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,
|
||||
@@ -149,7 +270,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 +303,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,14 +348,18 @@ 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/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"}:
|
||||
@@ -230,6 +373,7 @@ async def read_metadata(file: Annotated[UploadFile, File(...)]) -> dict[str, boo
|
||||
"exiftool",
|
||||
"-json",
|
||||
"-s",
|
||||
"-n",
|
||||
"-DateTimeOriginal",
|
||||
"-CreateDate",
|
||||
"-ModifyDate",
|
||||
@@ -240,6 +384,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,6 +421,10 @@ 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()
|
||||
@@ -293,6 +448,14 @@ 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)
|
||||
@@ -301,6 +464,7 @@ async def process_photo(
|
||||
"exiftool",
|
||||
"-overwrite_original",
|
||||
"-m",
|
||||
"-n",
|
||||
f"-EXIF:DateTimeOriginal={exif_datetime}",
|
||||
f"-EXIF:CreateDate={exif_datetime}",
|
||||
f"-EXIF:ModifyDate={exif_datetime}",
|
||||
@@ -330,6 +494,36 @@ async def process_photo(
|
||||
command.append(f"-XMP-dc:Subject+={keyword}")
|
||||
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))
|
||||
|
||||
logger.info("Processing %s with precision %s", filename, precision)
|
||||
|
||||
+211
-9
@@ -8,6 +8,10 @@ const state = {
|
||||
rotation: 0,
|
||||
processing: false,
|
||||
selectionToken: 0,
|
||||
mapConfig: null,
|
||||
map: null,
|
||||
mapMarker: null,
|
||||
locationDirty: false,
|
||||
};
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
@@ -55,6 +59,14 @@ const elements = {
|
||||
description: $('#description-input'),
|
||||
descriptionCount: $('#description-count'),
|
||||
keywords: $('#keywords-input'),
|
||||
addressSearch: $('#address-search'),
|
||||
addressSearchButton: $('#address-search-button'),
|
||||
addressResults: $('#address-results'),
|
||||
locationMap: $('#location-map'),
|
||||
latitude: $('#latitude-input'),
|
||||
longitude: $('#longitude-input'),
|
||||
locationName: $('#location-name-input'),
|
||||
clearLocation: $('#clear-location'),
|
||||
saveStatus: $('#save-status'),
|
||||
shortcutsButton: $('#shortcuts-button'),
|
||||
shortcutsDialog: $('#shortcuts-dialog'),
|
||||
@@ -277,6 +289,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(' · ');
|
||||
}
|
||||
|
||||
@@ -392,12 +405,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() {
|
||||
@@ -408,7 +425,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)}"]`);
|
||||
@@ -420,9 +438,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) {
|
||||
@@ -463,6 +493,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) {
|
||||
@@ -485,12 +523,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();
|
||||
|
||||
@@ -559,11 +599,157 @@ 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 activateTab(tabName, focusTab = false) {
|
||||
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 (focusTab) target.focus();
|
||||
if (tabName === 'location') {
|
||||
initialiseMap();
|
||||
window.setTimeout(() => {
|
||||
state.map?.invalidateSize();
|
||||
syncMapFromFields(false);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleTabs(delta) {
|
||||
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, true);
|
||||
}
|
||||
|
||||
function markLocationDirty() {
|
||||
state.locationDirty = true;
|
||||
captureCurrentValues();
|
||||
}
|
||||
|
||||
function initialiseMap() {
|
||||
if (state.map || typeof window.L === 'undefined' || !elements.locationMap) return;
|
||||
const config = state.mapConfig || {};
|
||||
state.map = L.map(elements.locationMap, { zoomControl: true }).setView(
|
||||
[Number(config.defaultMapLat ?? 64.5), Number(config.defaultMapLon ?? 11.0)],
|
||||
Number(config.defaultMapZoom ?? 5),
|
||||
);
|
||||
L.tileLayer(config.mapTileUrl || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: config.mapAttribution || '© OpenStreetMap contributors',
|
||||
}).addTo(state.map);
|
||||
state.map.on('click', (event) => {
|
||||
setLocation(event.latlng.lat, event.latlng.lng, '', true, false);
|
||||
});
|
||||
syncMapFromFields(false);
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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)`;
|
||||
}
|
||||
@@ -579,6 +765,14 @@ 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);
|
||||
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);
|
||||
@@ -591,11 +785,15 @@ 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));
|
||||
tab.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); cycleTabs(-1); }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); cycleTabs(1); }
|
||||
if (event.key === 'Home') { event.preventDefault(); activateTab($$('.tab')[0].dataset.tab, true); }
|
||||
if (event.key === 'End') { const tabs = $$('.tab'); event.preventDefault(); activateTab(tabs[tabs.length - 1].dataset.tab, true); }
|
||||
});
|
||||
}
|
||||
activateTab('date');
|
||||
|
||||
elements.shortcutsButton.addEventListener('click', () => elements.shortcutsDialog.showModal());
|
||||
elements.closeShortcuts.addEventListener('click', () => elements.shortcutsDialog.close());
|
||||
@@ -605,6 +803,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(); cycleTabs(-1); return;
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault(); savePhoto(false); return;
|
||||
}
|
||||
@@ -625,6 +826,7 @@ async function initialise() {
|
||||
const config = await response.json();
|
||||
elements.appName.textContent = config.appName;
|
||||
document.title = config.appName;
|
||||
state.mapConfig = config;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -126,6 +127,7 @@
|
||||
<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 class="tab" data-tab="location" role="tab">Location</button>
|
||||
</div>
|
||||
|
||||
<form id="metadata-form" autocomplete="off">
|
||||
@@ -193,6 +195,45 @@
|
||||
</label>
|
||||
<p class="field-help">Separate entries with commas or new lines. Written as XMP Subject and IPTC Keywords.</p>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel" data-panel="location">
|
||||
<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 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">
|
||||
@@ -214,10 +255,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>Switch to the previous right-side tab</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>
|
||||
@@ -1,5 +1,5 @@
|
||||
const CACHE_NAME = 'photo-date-editor-v3';
|
||||
const SHELL = ['/', '/styles.css', '/app.js', '/manifest.webmanifest'];
|
||||
const CACHE_NAME = 'photo-date-editor-v4';
|
||||
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();
|
||||
|
||||
@@ -111,3 +111,29 @@ 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; }
|
||||
Reference in new issue
Block a user