Files
nmemmertandClaude Sonnet 4.6 3429ec1d80 feat: places (reverse geocoding) + folder browser
Places:
- worker/geocode.js: Nominatim reverse geocoder, 1 req/sec rate limit,
  in-memory cache keyed to 3-decimal-place coordinates
- Ingest calls reverseGeocode() for any photo with GPS data; stores
  place_city and place_country on the photo row
- DB migrations add place_city / place_country columns + index on place_city
- Stats API returns cities grouped by count
- buildFilter() supports ?city= parameter
- FilterSidebar shows a Places section with city list + country sublabel

Folder browser:
- GET /api/fs/browse?path=… lists subdirectories (path-normalized to prevent traversal)
- Settings panel: 📁 button opens inline directory browser below the add
  input — navigate with up arrow / directory clicks, Select fills the input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-18 08:27:41 -04:00

37 lines
1.1 KiB
JavaScript

const cache = new Map();
let lastCallMs = 0;
export async function reverseGeocode(lat, lon) {
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`;
if (cache.has(key)) return cache.get(key);
// Nominatim ToS: max 1 request/second
const wait = 1100 - (Date.now() - lastCallMs);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
lastCallMs = Date.now();
try {
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
const res = await fetch(url, {
headers: { 'User-Agent': 'Luma/1.0 self-hosted-photo-manager' },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(res.status);
const data = await res.json();
const place = {
city: data.address?.city
?? data.address?.town
?? data.address?.village
?? data.address?.county
?? null,
country: data.address?.country ?? null,
};
cache.set(key, place);
return place;
} catch (err) {
console.warn(`[geocode] ${lat.toFixed(4)},${lon.toFixed(4)}: ${err.message}`);
cache.set(key, null);
return null;
}
}