Proof of concept for the other one
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 375 lines 11 kB view raw
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}