Proof of concept for the other one
1import { ItemView, MarkdownView, WorkspaceLeaf, debounce } from "obsidian";
2import type CalendarViewerPlugin from "./main";
3import { parseEvents, CalendarEvent } from "./parser";
4import { renderCalendar, CalendarController } from "./renderer";
5import { createMap, MapController } from "./mapRenderer";
6import { geocodeEvents } from "./geocoder";
7
8export const VIEW_TYPE_CALENDAR = "calendar-viewer";
9
10export class CalendarView extends ItemView {
11 private plugin: CalendarViewerPlugin;
12 private currentMonth: Date;
13 private events: CalendarEvent[] = [];
14 private selectedEvent: CalendarEvent | null = null;
15 private hasUserNavigated = false;
16
17 private calendarEl: HTMLElement | null = null;
18 private mapEl: HTMLElement | null = null;
19 private calendarController: CalendarController | null = null;
20 private mapController: MapController | null = null;
21
22 private resizeObserver: ResizeObserver | null = null;
23 /** Guard: true while we're writing geo lines back to the doc */
24 private isWritingGeoLines = false;
25 private eventsFingerprint: string = "";
26 private cursorPollInterval: ReturnType<typeof setInterval> | null = null;
27 private lastCursorLine: number = -1;
28
29 constructor(leaf: WorkspaceLeaf, plugin: CalendarViewerPlugin) {
30 super(leaf);
31 this.plugin = plugin;
32 this.currentMonth = new Date();
33 this.currentMonth.setDate(1);
34 }
35
36 getViewType(): string {
37 return VIEW_TYPE_CALENDAR;
38 }
39
40 getDisplayText(): string {
41 return "Calendar";
42 }
43
44 getIcon(): string {
45 return "calendar";
46 }
47
48 async onOpen(): Promise<void> {
49 const content = this.containerEl.children[1] as HTMLElement;
50 content.empty();
51 content.addClass("cal-view-root");
52
53 this.calendarEl = content.createDiv({ cls: "cal-pane-top" });
54 this.mapEl = content.createDiv({ cls: "cal-pane-bottom" });
55
56 // Watch for resize to keep the Leaflet map valid
57 this.resizeObserver = new ResizeObserver(() => {
58 this.mapController?.invalidateSize();
59 });
60 this.resizeObserver.observe(this.mapEl);
61
62 // Poll editor cursor position to sync selection
63 this.cursorPollInterval = setInterval(() => {
64 this.pollCursorPosition();
65 }, 200);
66
67 await this.refresh();
68 }
69
70 async onClose(): Promise<void> {
71 if (this.cursorPollInterval !== null) {
72 clearInterval(this.cursorPollInterval);
73 this.cursorPollInterval = null;
74 }
75 this.mapController?.destroy();
76 this.mapController = null;
77 this.resizeObserver?.disconnect();
78 this.resizeObserver = null;
79 }
80
81 /**
82 * Re-parse the active note, geocode events, and re-render everything.
83 */
84 refresh = debounce(async () => {
85 // Skip re-parse while we're writing geo lines back to the document
86 if (this.isWritingGeoLines) return;
87
88 const file = this.app.workspace.getActiveFile();
89 if (file) {
90 const content = await this.app.vault.read(file);
91 this.events = parseEvents(content);
92
93 if (this.events.length > 0 && !this.hasUserNavigated) {
94 this.jumpToNearestMonth();
95 }
96 } else {
97 this.events = [];
98 }
99
100 const newFingerprint = this.computeFingerprint(this.events);
101 const eventsChanged = newFingerprint !== this.eventsFingerprint;
102 this.eventsFingerprint = newFingerprint;
103
104 this.selectedEvent = null;
105 this.renderCalendar();
106
107 if (eventsChanged) {
108 this.initOrUpdateMap();
109 // Geocode in background — progressively updates the map
110 await this.geocodeAndUpdateMap();
111 }
112 }, 300, true);
113
114 /**
115 * Reset the user-navigated flag when switching notes.
116 */
117 resetNavigation(): void {
118 this.hasUserNavigated = false;
119 }
120
121 // ── Private ──
122
123 /**
124 * Compute a simple fingerprint of the event list (titles + dates).
125 * Used to detect whether events actually changed between refreshes.
126 */
127 private computeFingerprint(events: CalendarEvent[]): string {
128 return events
129 .map((e) => `${e.title}::${e.date.getTime()}::${e.venue ?? ""}::${e.location ?? ""}`)
130 .join("|");
131 }
132
133 private jumpToNearestMonth(): void {
134 const now = new Date();
135 const sorted = [...this.events].sort(
136 (a, b) => a.date.getTime() - b.date.getTime()
137 );
138 const upcoming = sorted.find((e) => e.date.getTime() >= now.getTime());
139 const target = upcoming ?? sorted[sorted.length - 1];
140 if (target) {
141 this.currentMonth = new Date(target.date.getFullYear(), target.date.getMonth(), 1);
142 }
143 }
144
145 private renderCalendar(): void {
146 if (!this.calendarEl) return;
147
148 this.calendarController = renderCalendar(
149 this.calendarEl,
150 this.currentMonth,
151 this.events,
152 {
153 onPrevMonth: () => {
154 this.hasUserNavigated = true;
155 this.currentMonth = new Date(
156 this.currentMonth.getFullYear(),
157 this.currentMonth.getMonth() - 1,
158 1,
159 );
160 this.renderCalendar();
161 },
162 onNextMonth: () => {
163 this.hasUserNavigated = true;
164 this.currentMonth = new Date(
165 this.currentMonth.getFullYear(),
166 this.currentMonth.getMonth() + 1,
167 1,
168 );
169 this.renderCalendar();
170 },
171 onEventClick: (event: CalendarEvent) => {
172 this.selectedEvent = event;
173 // Tell the map to select this event
174 this.mapController?.selectEvent(event);
175 // Scroll the editor to this event's line
176 this.scrollEditorToEvent(event);
177 },
178 },
179 this.selectedEvent,
180 );
181 }
182
183 private initOrUpdateMap(): void {
184 if (!this.mapEl) return;
185
186 if (this.mapController) {
187 // Map already exists — just update markers
188 this.mapController.updateMarkers(this.events);
189 } else {
190 // Create map for the first time
191 this.mapController = createMap(this.mapEl, this.events, {
192 onMarkerClick: (event: CalendarEvent) => {
193 this.selectedEvent = event;
194
195 // Navigate calendar to the event's month and highlight it
196 const evMonth = event.date.getMonth();
197 const evYear = event.date.getFullYear();
198 if (
199 evYear !== this.currentMonth.getFullYear() ||
200 evMonth !== this.currentMonth.getMonth()
201 ) {
202 this.hasUserNavigated = true;
203 this.currentMonth = new Date(evYear, evMonth, 1);
204 }
205 this.renderCalendar();
206
207 // Scroll the editor to this event's line
208 this.scrollEditorToEvent(event);
209 },
210 });
211 }
212 }
213
214 private async geocodeAndUpdateMap(): Promise<void> {
215 if (this.events.length === 0) return;
216
217 const newlyGeocoded = await geocodeEvents(
218 this.events,
219 // onProgress: update the map with new markers but don't re-fit bounds
220 () => {
221 this.mapController?.updateMarkers(this.events, false);
222 },
223 );
224
225 // Write geo: lines back into the document for newly geocoded events
226 if (newlyGeocoded.length > 0) {
227 await this.writeGeoLinesToDoc(newlyGeocoded);
228 }
229 }
230
231 /**
232 * Poll the active editor's cursor position and sync selection
233 * to the event whose block contains the cursor line.
234 */
235 private pollCursorPosition(): void {
236 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView);
237 if (!mdView) return;
238
239 const cursor = mdView.editor.getCursor();
240 const line = cursor.line;
241 if (line === this.lastCursorLine) return;
242 this.lastCursorLine = line;
243
244 this.selectEventByLine(line);
245 }
246
247 /**
248 * Find the event whose startLine..endLine range contains the given
249 * line number and select it in the calendar and map.
250 */
251 private selectEventByLine(line: number): void {
252 const event = this.events.find(
253 (e) =>
254 e.startLine !== undefined &&
255 e.endLine !== undefined &&
256 line >= e.startLine &&
257 line <= e.endLine,
258 );
259
260 if (!event) {
261 // Cursor is outside any event block — clear selection
262 if (this.selectedEvent) {
263 this.selectedEvent = null;
264 this.calendarController?.selectEvent(null);
265 this.mapController?.selectEvent(null);
266 }
267 return;
268 }
269
270 // Already selected
271 if (
272 this.selectedEvent &&
273 this.selectedEvent.title === event.title &&
274 this.selectedEvent.date.getTime() === event.date.getTime()
275 ) {
276 return;
277 }
278
279 this.selectedEvent = event;
280
281 // Navigate calendar to the event's month if needed, then highlight
282 const evMonth = event.date.getMonth();
283 const evYear = event.date.getFullYear();
284 if (
285 evYear !== this.currentMonth.getFullYear() ||
286 evMonth !== this.currentMonth.getMonth()
287 ) {
288 this.currentMonth = new Date(evYear, evMonth, 1);
289 this.renderCalendar();
290 } else {
291 this.calendarController?.selectEvent(event);
292 }
293
294 this.mapController?.selectEvent(event);
295 }
296
297 /**
298 * Scroll the editor to the event's start line.
299 */
300 private scrollEditorToEvent(event: CalendarEvent): void {
301 if (event.startLine === undefined) return;
302 const mdView = this.app.workspace.getActiveViewOfType(MarkdownView);
303 if (!mdView) return;
304
305 const editor = mdView.editor;
306 editor.setCursor({ line: event.startLine, ch: 0 });
307 editor.scrollIntoView(
308 {
309 from: { line: event.startLine, ch: 0 },
310 to: { line: event.startLine, ch: 0 },
311 },
312 true,
313 );
314
315 // Update tracked cursor so we don't re-trigger polling
316 this.lastCursorLine = event.startLine;
317 }
318
319 /**
320 * Write "geo: lat,lng" sub-bullets back into the active document
321 * for events that were just geocoded. Inserts after each event's
322 * last line (endLine), processing from bottom to top so line
323 * numbers don't shift for earlier insertions.
324 */
325 private async writeGeoLinesToDoc(newlyGeocoded: CalendarEvent[]): Promise<void> {
326 const file = this.app.workspace.getActiveFile();
327 if (!file) return;
328
329 // Filter to events that have valid line info and coords
330 const toWrite = newlyGeocoded.filter(
331 (e) =>
332 e.endLine !== undefined &&
333 e.lat !== undefined &&
334 e.lng !== undefined,
335 );
336 if (toWrite.length === 0) return;
337
338 // Sort by endLine descending so insertions don't shift earlier lines
339 toWrite.sort((a, b) => b.endLine! - a.endLine!);
340
341 this.isWritingGeoLines = true;
342 try {
343 await this.app.vault.process(file, (content) => {
344 const lines = content.split("\n");
345
346 for (const event of toWrite) {
347 const insertAfter = event.endLine!;
348 const geoLine = `\t* geo: ${event.lat},${event.lng}`;
349
350 // Check if a geo: line already exists in this block
351 // (in case of a race between geocoding and re-parse)
352 let alreadyHasGeo = false;
353 for (let i = event.startLine ?? insertAfter; i <= insertAfter; i++) {
354 if (/^\t[*\-]\s+geo:\s/.test(lines[i]) || /^\s{2,}[*\-]\s+geo:\s/.test(lines[i])) {
355 alreadyHasGeo = true;
356 break;
357 }
358 }
359 if (alreadyHasGeo) continue;
360
361 // Insert the geo line after endLine
362 lines.splice(insertAfter + 1, 0, geoLine);
363 }
364
365 return lines.join("\n");
366 });
367 } finally {
368 // Small delay before clearing the guard so the modify event
369 // from vault.process has time to fire and be ignored
370 setTimeout(() => {
371 this.isWritingGeoLines = false;
372 }, 500);
373 }
374 }
375}