Proof of concept for the other one
1import type { CalendarEvent } from "./parser";
2
3export interface GeocodedLocation {
4 lat: number;
5 lng: number;
6}
7
8const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
9const RATE_LIMIT_MS = 1100; // Nominatim requires max 1 req/sec
10
11/**
12 * Build a geocoding query string from an event's venue and location.
13 * Returns null if there's nothing to geocode.
14 */
15function buildQuery(event: CalendarEvent): string | null {
16 const parts: string[] = [];
17 if (event.venue) parts.push(event.venue);
18 if (event.location) parts.push(event.location);
19 if (parts.length === 0) return null;
20 return parts.join(", ");
21}
22
23/**
24 * Normalize a query string for deduplication.
25 */
26function normalizeQuery(query: string): string {
27 return query.toLowerCase().trim();
28}
29
30/**
31 * Fetch a single geocode result from Nominatim.
32 */
33async function fetchGeocode(query: string): Promise<GeocodedLocation | null> {
34 try {
35 console.debug(`[CalendarViewer] Geocoding: "${query}"`);
36 const params = new URLSearchParams({
37 q: query,
38 format: "json",
39 limit: "1",
40 });
41 const response = await fetch(`${NOMINATIM_URL}?${params}`, {
42 headers: {
43 "User-Agent": "ObsidianCalendarViewer/1.0",
44 },
45 });
46 if (!response.ok) {
47 console.warn(`[CalendarViewer] Geocoding failed for "${query}": HTTP ${response.status}`);
48 return null;
49 }
50 const results = await response.json();
51 if (results.length > 0) {
52 const loc = {
53 lat: parseFloat(results[0].lat),
54 lng: parseFloat(results[0].lon),
55 };
56 console.debug(`[CalendarViewer] Geocoded "${query}" → ${loc.lat}, ${loc.lng}`);
57 return loc;
58 }
59 console.warn(`[CalendarViewer] Geocoding returned no results for "${query}"`);
60 return null;
61 } catch (e) {
62 console.warn(`[CalendarViewer] Geocoding error for "${query}":`, e);
63 return null;
64 }
65}
66
67/**
68 * Sleep for a given number of milliseconds.
69 */
70function sleep(ms: number): Promise<void> {
71 return new Promise((resolve) => setTimeout(resolve, ms));
72}
73
74/**
75 * Geocode a list of events. Events that already have lat/lng (e.g.
76 * from a "geo:" line in the document) are skipped. Uncached venues
77 * are geocoded sequentially with rate limiting.
78 *
79 * The onProgress callback is called after each newly geocoded event,
80 * so the map can progressively populate.
81 *
82 * Returns the list of events that were newly geocoded (so the caller
83 * can write "geo:" lines back into the document).
84 */
85export async function geocodeEvents(
86 events: CalendarEvent[],
87 onProgress: () => void,
88): Promise<CalendarEvent[]> {
89 const needsGeocoding: Array<{ event: CalendarEvent; query: string; key: string }> = [];
90
91 for (const event of events) {
92 // Already has coordinates (from "geo:" line in doc)
93 if (event.lat !== undefined && event.lng !== undefined) {
94 console.debug(`[CalendarViewer] Skipping "${event.title}" — already has coords`);
95 continue;
96 }
97
98 const query = buildQuery(event);
99 if (!query) {
100 console.debug(`[CalendarViewer] Skipping "${event.title}" — no venue/location`);
101 continue;
102 }
103
104 needsGeocoding.push({ event, query, key: normalizeQuery(query) });
105 }
106
107 // Deduplicate by normalized query (multiple events at same venue)
108 const seen = new Set<string>();
109 const unique: typeof needsGeocoding = [];
110 for (const item of needsGeocoding) {
111 if (!seen.has(item.key)) {
112 seen.add(item.key);
113 unique.push(item);
114 }
115 }
116
117 if (unique.length > 0) {
118 console.debug(`[CalendarViewer] Geocoding ${unique.length} unique location(s)...`);
119 }
120
121 const newlyGeocoded: CalendarEvent[] = [];
122
123 // Geocode sequentially with rate limiting
124 for (let i = 0; i < unique.length; i++) {
125 const { query, key } = unique[i];
126
127 if (i > 0) {
128 await sleep(RATE_LIMIT_MS);
129 }
130
131 const result = await fetchGeocode(query);
132
133 // Apply result to ALL events with the same normalized query
134 if (result) {
135 for (const item of needsGeocoding) {
136 if (item.key === key) {
137 item.event.lat = result.lat;
138 item.event.lng = result.lng;
139 newlyGeocoded.push(item.event);
140 }
141 }
142 }
143
144 onProgress();
145 }
146
147 return newlyGeocoded;
148}