/** * geocoder.ts — Nominatim Geocoder (Effectful) * * Convert place names to lat/lng coordinates via OpenStreetMap Nominatim API. * Handles rate limiting, deduplication, cancellation, and progressive updates. */ import type { Place } from "./parser"; export interface GeoResult { lat: number; lng: number; } export interface GeocodeCallbacks { onProgress?: (place: Place, result: GeoResult | null) => void; } /** Minimum delay between sequential Nominatim requests (ms). */ const RATE_LIMIT_MS = 1100; /** Per-request fetch timeout (ms). */ const FETCH_TIMEOUT_MS = 10_000; /** Consecutive failure threshold for showing an Obsidian Notice. */ const CONSECUTIVE_FAILURE_NOTICE_THRESHOLD = 3; const NOMINATIM_BASE = "https://nominatim.openstreetmap.org/search"; const USER_AGENT = "ObsidianMapViewer/1.0"; /** * Geocode an array of places via Nominatim. * * - Only geocodes places where lat AND lng are both undefined/null. * - Deduplicates by case-insensitive trimmed name. * - Rate-limits to 1100ms between sequential requests. * - Supports external cancellation via AbortSignal. * - Reports progress via onProgress callback. */ export async function geocodePlaces( places: Place[], callbacks?: GeocodeCallbacks, signal?: AbortSignal ): Promise { // Contract 13: empty array short-circuit if (places.length === 0) return []; // Build dedup map: normalized name -> { queryName, indices } const dedupMap = new Map< string, { queryName: string; indices: number[] } >(); // Collect which places need geocoding and build dedup groups const geocodeOrder: string[] = []; // order of unique normalized names to geocode for (let i = 0; i < places.length; i++) { const place = places[i]; // Contract 1: skip places with existing coordinates if (place.lat != null && place.lng != null) { continue; } const normalizedName = place.name.trim().toLowerCase(); if (dedupMap.has(normalizedName)) { // Contract 5: deduplication — add index to existing group dedupMap.get(normalizedName)!.indices.push(i); } else { // First encounter of this name dedupMap.set(normalizedName, { queryName: place.name.trim(), indices: [i], }); geocodeOrder.push(normalizedName); } } let consecutiveFailures = 0; // Process each unique name sequentially for (let g = 0; g < geocodeOrder.length; g++) { const normalizedName = geocodeOrder[g]; const group = dedupMap.get(normalizedName)!; // Contract 11: check external abort signal before making request if (signal?.aborted) { break; } // Contract 4: rate limiting — wait 1100ms between requests (not before first) // Contract 11: delay is cancellable — signal aborts immediately, not after full delay if (g > 0) { await delay(RATE_LIMIT_MS, signal); if (signal?.aborted) { break; } } // Perform the geocode const result = await fetchGeocode(group.queryName, signal); if (result === null) { consecutiveFailures++; // Contract 12: Notice on exactly the 3rd consecutive failure (not every subsequent one) if (consecutiveFailures === CONSECUTIVE_FAILURE_NOTICE_THRESHOLD) { try { // eslint-disable-next-line no-undef new (globalThis as any).Notice( "Map Viewer: Geocoding issues — check your network connection" ); } catch { // Notice may not exist outside Obsidian — swallow silently } } } else { consecutiveFailures = 0; } // Contract 7 & 9: apply result to all places in this dedup group for (const idx of group.indices) { if (result) { places[idx].lat = result.lat; places[idx].lng = result.lng; } } // Contract 7: call onProgress for the first place in the group if (callbacks?.onProgress) { const firstIdx = group.indices[0]; callbacks.onProgress(places[firstIdx], result); } } return places; } /** * Fetch geocode result for a single place name from Nominatim. * Returns GeoResult on success, null on failure/empty/timeout. */ async function fetchGeocode( name: string, externalSignal?: AbortSignal ): Promise { // Contract 10: 10-second timeout via AbortController const timeoutController = new AbortController(); const timeoutId = setTimeout(() => timeoutController.abort(), FETCH_TIMEOUT_MS); // Combine external signal and timeout signal const combinedController = new AbortController(); // If external signal aborts, abort combined const onExternalAbort = () => combinedController.abort(); if (externalSignal) { if (externalSignal.aborted) { clearTimeout(timeoutId); return null; } externalSignal.addEventListener("abort", onExternalAbort); } // If timeout aborts, abort combined timeoutController.signal.addEventListener("abort", () => combinedController.abort() ); // Contract 2: build Nominatim URL const url = `${NOMINATIM_BASE}?format=json&limit=1&q=${encodeURIComponent(name)}`; try { // Contract 6: User-Agent header const response = await fetch(url, { headers: { "User-Agent": USER_AGENT }, signal: combinedController.signal, }); const data = await response.json(); // Empty results if (!Array.isArray(data) || data.length === 0) { // Contract 12: warn for empty results console.warn("[MapViewer] No results for:", name); return null; } const lat = parseFloat(data[0].lat); const lng = parseFloat(data[0].lon); if (isNaN(lat) || isNaN(lng)) { console.warn("[MapViewer] Invalid coordinates for:", name); return null; } return { lat, lng }; } catch (err) { // Contract 8 & 12: network failures logged, place skipped console.warn("[MapViewer] Geocode failed for:", name, err); return null; } finally { clearTimeout(timeoutId); if (externalSignal) { externalSignal.removeEventListener("abort", onExternalAbort); } } } /** Promise-based delay that can be cancelled via AbortSignal. */ function delay(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { if (signal?.aborted) { resolve(); return; } const timerId = setTimeout(resolve, ms); signal?.addEventListener("abort", () => { clearTimeout(timerId); resolve(); }, { once: true }); }); }