import type { CalendarEvent } from "./parser"; export interface GeocodedLocation { lat: number; lng: number; } const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; const RATE_LIMIT_MS = 1100; // Nominatim requires max 1 req/sec /** * Build a geocoding query string from an event's venue and location. * Returns null if there's nothing to geocode. */ function buildQuery(event: CalendarEvent): string | null { const parts: string[] = []; if (event.venue) parts.push(event.venue); if (event.location) parts.push(event.location); if (parts.length === 0) return null; return parts.join(", "); } /** * Normalize a query string for deduplication. */ function normalizeQuery(query: string): string { return query.toLowerCase().trim(); } /** * Fetch a single geocode result from Nominatim. */ async function fetchGeocode(query: string): Promise { try { console.debug(`[CalendarViewer] Geocoding: "${query}"`); const params = new URLSearchParams({ q: query, format: "json", limit: "1", }); const response = await fetch(`${NOMINATIM_URL}?${params}`, { headers: { "User-Agent": "ObsidianCalendarViewer/1.0", }, }); if (!response.ok) { console.warn(`[CalendarViewer] Geocoding failed for "${query}": HTTP ${response.status}`); return null; } const results = await response.json(); if (results.length > 0) { const loc = { lat: parseFloat(results[0].lat), lng: parseFloat(results[0].lon), }; console.debug(`[CalendarViewer] Geocoded "${query}" → ${loc.lat}, ${loc.lng}`); return loc; } console.warn(`[CalendarViewer] Geocoding returned no results for "${query}"`); return null; } catch (e) { console.warn(`[CalendarViewer] Geocoding error for "${query}":`, e); return null; } } /** * Sleep for a given number of milliseconds. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Geocode a list of events. Events that already have lat/lng (e.g. * from a "geo:" line in the document) are skipped. Uncached venues * are geocoded sequentially with rate limiting. * * The onProgress callback is called after each newly geocoded event, * so the map can progressively populate. * * Returns the list of events that were newly geocoded (so the caller * can write "geo:" lines back into the document). */ export async function geocodeEvents( events: CalendarEvent[], onProgress: () => void, ): Promise { const needsGeocoding: Array<{ event: CalendarEvent; query: string; key: string }> = []; for (const event of events) { // Already has coordinates (from "geo:" line in doc) if (event.lat !== undefined && event.lng !== undefined) { console.debug(`[CalendarViewer] Skipping "${event.title}" — already has coords`); continue; } const query = buildQuery(event); if (!query) { console.debug(`[CalendarViewer] Skipping "${event.title}" — no venue/location`); continue; } needsGeocoding.push({ event, query, key: normalizeQuery(query) }); } // Deduplicate by normalized query (multiple events at same venue) const seen = new Set(); const unique: typeof needsGeocoding = []; for (const item of needsGeocoding) { if (!seen.has(item.key)) { seen.add(item.key); unique.push(item); } } if (unique.length > 0) { console.debug(`[CalendarViewer] Geocoding ${unique.length} unique location(s)...`); } const newlyGeocoded: CalendarEvent[] = []; // Geocode sequentially with rate limiting for (let i = 0; i < unique.length; i++) { const { query, key } = unique[i]; if (i > 0) { await sleep(RATE_LIMIT_MS); } const result = await fetchGeocode(query); // Apply result to ALL events with the same normalized query if (result) { for (const item of needsGeocoding) { if (item.key === key) { item.event.lat = result.lat; item.event.lng = result.lng; newlyGeocoded.push(item.event); } } } onProgress(); } return newlyGeocoded; }