Proof of concept for the other one
0
fork

Configure Feed

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

init

Anish Lakhwara d128180d

+2110
+104
calendarView.ts
··· 1 + import { ItemView, WorkspaceLeaf, debounce, TFile } from "obsidian"; 2 + import { parseEvents, CalendarEvent } from "./parser"; 3 + import { renderCalendar } from "./renderer"; 4 + 5 + export const VIEW_TYPE_CALENDAR = "calendar-viewer"; 6 + 7 + export class CalendarView extends ItemView { 8 + private currentMonth: Date; 9 + private events: CalendarEvent[] = []; 10 + 11 + constructor(leaf: WorkspaceLeaf) { 12 + super(leaf); 13 + // Default to current month 14 + this.currentMonth = new Date(); 15 + this.currentMonth.setDate(1); 16 + } 17 + 18 + getViewType(): string { 19 + return VIEW_TYPE_CALENDAR; 20 + } 21 + 22 + getDisplayText(): string { 23 + return "Calendar"; 24 + } 25 + 26 + getIcon(): string { 27 + return "calendar"; 28 + } 29 + 30 + async onOpen(): Promise<void> { 31 + await this.refresh(); 32 + } 33 + 34 + async onClose(): Promise<void> { 35 + // cleanup handled by plugin 36 + } 37 + 38 + /** 39 + * Re-parse the active note and re-render the calendar. 40 + */ 41 + refresh = debounce(async () => { 42 + const file = this.app.workspace.getActiveFile(); 43 + if (file) { 44 + const content = await this.app.vault.read(file); 45 + this.events = parseEvents(content); 46 + 47 + // If we have events, jump to the month of the nearest upcoming event 48 + if (this.events.length > 0 && !this.hasUserNavigated) { 49 + this.jumpToNearestMonth(); 50 + } 51 + } else { 52 + this.events = []; 53 + } 54 + 55 + this.render(); 56 + }, 300, true); 57 + 58 + private hasUserNavigated = false; 59 + 60 + private jumpToNearestMonth(): void { 61 + const now = new Date(); 62 + // Find the nearest upcoming event (or the latest if all are past) 63 + const sorted = [...this.events].sort( 64 + (a, b) => a.date.getTime() - b.date.getTime() 65 + ); 66 + const upcoming = sorted.find((e) => e.date.getTime() >= now.getTime()); 67 + const target = upcoming ?? sorted[sorted.length - 1]; 68 + if (target) { 69 + this.currentMonth = new Date(target.date.getFullYear(), target.date.getMonth(), 1); 70 + } 71 + } 72 + 73 + private render(): void { 74 + const container = this.containerEl.children[1] as HTMLElement; 75 + renderCalendar(container, this.currentMonth, this.events, { 76 + onPrevMonth: () => { 77 + this.hasUserNavigated = true; 78 + this.currentMonth = new Date( 79 + this.currentMonth.getFullYear(), 80 + this.currentMonth.getMonth() - 1, 81 + 1 82 + ); 83 + this.render(); 84 + }, 85 + onNextMonth: () => { 86 + this.hasUserNavigated = true; 87 + this.currentMonth = new Date( 88 + this.currentMonth.getFullYear(), 89 + this.currentMonth.getMonth() + 1, 90 + 1 91 + ); 92 + this.render(); 93 + }, 94 + }); 95 + } 96 + 97 + /** 98 + * Reset the user-navigated flag so the view jumps to the 99 + * relevant month when switching to a new note. 100 + */ 101 + resetNavigation(): void { 102 + this.hasUserNavigated = false; 103 + } 104 + }
+39
esbuild.config.mjs
··· 1 + import esbuild from "esbuild"; 2 + import process from "process"; 3 + import builtins from "builtin-modules"; 4 + 5 + const prod = process.argv[2] === "production"; 6 + 7 + const context = await esbuild.context({ 8 + entryPoints: ["main.ts"], 9 + bundle: true, 10 + external: [ 11 + "obsidian", 12 + "electron", 13 + "@codemirror/autocomplete", 14 + "@codemirror/collab", 15 + "@codemirror/commands", 16 + "@codemirror/language", 17 + "@codemirror/lint", 18 + "@codemirror/search", 19 + "@codemirror/state", 20 + "@codemirror/view", 21 + "@lezer/common", 22 + "@lezer/highlight", 23 + "@lezer/lr", 24 + ...builtins, 25 + ], 26 + format: "cjs", 27 + target: "es2018", 28 + logLevel: "info", 29 + sourcemap: prod ? false : "inline", 30 + treeShaking: true, 31 + outfile: "main.js", 32 + }); 33 + 34 + if (prod) { 35 + await context.rebuild(); 36 + process.exit(0); 37 + } else { 38 + await context.watch(); 39 + }
+57
flake.lock
··· 1 + { 2 + "nodes": { 3 + "flake-utils": { 4 + "inputs": { 5 + "systems": "systems" 6 + }, 7 + "locked": { 8 + "lastModified": 1731533236, 9 + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 + "owner": "numtide", 11 + "repo": "flake-utils", 12 + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 + "type": "github" 14 + }, 15 + "original": { 16 + "owner": "numtide", 17 + "repo": "flake-utils", 18 + "type": "github" 19 + } 20 + }, 21 + "nixpkgs": { 22 + "locked": { 23 + "lastModified": 0, 24 + "narHash": "sha256-sJERJIYTKPFXkoz/gBaBtRKke82h4DkX3BBSsKbfbvI=", 25 + "path": "/nix/store/x2r24fmnvsmcb8sz2fqszbnp72v14hs2-source", 26 + "type": "path" 27 + }, 28 + "original": { 29 + "path": "/nix/store/x2r24fmnvsmcb8sz2fqszbnp72v14hs2-source", 30 + "type": "path" 31 + } 32 + }, 33 + "root": { 34 + "inputs": { 35 + "flake-utils": "flake-utils", 36 + "nixpkgs": "nixpkgs" 37 + } 38 + }, 39 + "systems": { 40 + "locked": { 41 + "lastModified": 1681028828, 42 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 43 + "owner": "nix-systems", 44 + "repo": "default", 45 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 46 + "type": "github" 47 + }, 48 + "original": { 49 + "owner": "nix-systems", 50 + "repo": "default", 51 + "type": "github" 52 + } 53 + } 54 + }, 55 + "root": "root", 56 + "version": 7 57 + }
+33
flake.nix
··· 1 + { 2 + description = "Obsidian Calendar Viewer plugin dev environment"; 3 + 4 + inputs = { 5 + nixpkgs.url = "path:/nix/store/x2r24fmnvsmcb8sz2fqszbnp72v14hs2-source"; 6 + flake-utils.url = "github:numtide/flake-utils"; 7 + }; 8 + 9 + outputs = 10 + { 11 + self, 12 + nixpkgs, 13 + flake-utils, 14 + }: 15 + flake-utils.lib.eachDefaultSystem ( 16 + system: 17 + let 18 + pkgs = nixpkgs.legacyPackages.${system}; 19 + in 20 + { 21 + devShells.default = pkgs.mkShell { 22 + buildInputs = with pkgs; [ 23 + nodejs_22 24 + ]; 25 + 26 + shellHook = '' 27 + echo "obs-calendar-viewer dev shell" 28 + echo "node $(node --version) | npm $(npm --version)" 29 + ''; 30 + }; 31 + } 32 + ); 33 + }
+505
main.js
··· 1 + var __defProp = Object.defineProperty; 2 + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; 3 + var __getOwnPropNames = Object.getOwnPropertyNames; 4 + var __hasOwnProp = Object.prototype.hasOwnProperty; 5 + var __export = (target, all) => { 6 + for (var name in all) 7 + __defProp(target, name, { get: all[name], enumerable: true }); 8 + }; 9 + var __copyProps = (to, from, except, desc) => { 10 + if (from && typeof from === "object" || typeof from === "function") { 11 + for (let key of __getOwnPropNames(from)) 12 + if (!__hasOwnProp.call(to, key) && key !== except) 13 + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); 14 + } 15 + return to; 16 + }; 17 + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); 18 + 19 + // main.ts 20 + var main_exports = {}; 21 + __export(main_exports, { 22 + default: () => CalendarViewerPlugin 23 + }); 24 + module.exports = __toCommonJS(main_exports); 25 + var import_obsidian2 = require("obsidian"); 26 + 27 + // calendarView.ts 28 + var import_obsidian = require("obsidian"); 29 + 30 + // parser.ts 31 + var MONTHS = { 32 + january: 0, 33 + february: 1, 34 + march: 2, 35 + april: 3, 36 + may: 4, 37 + june: 5, 38 + july: 6, 39 + august: 7, 40 + september: 8, 41 + october: 9, 42 + november: 10, 43 + december: 11 44 + }; 45 + var URL_RE = /^https?:\/\/\S+$/; 46 + var DATE_WEEKDAY_DD_MONTH_YYYY = /^(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(\d{1,2})\s+(\w+)\s+(\d{4})/i; 47 + var DATE_WEEKDAY_COMMA_MONTH_DD = /^(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday),?\s+(\w+)\s+(\d{1,2})(?:,?\s+(\d{4}))?(?:,?\s+(.+))?/i; 48 + var DATE_MONTH_DD_YYYY = /^(\w+)\s+(\d{1,2})(?:,?\s+(\d{4}))?/i; 49 + function parseMonth(s) { 50 + const m = MONTHS[s.toLowerCase()]; 51 + return m !== void 0 ? m : null; 52 + } 53 + function inferYear(month, day) { 54 + const now = /* @__PURE__ */ new Date(); 55 + const thisYear = now.getFullYear(); 56 + const candidate = new Date(thisYear, month, day); 57 + if (candidate.getTime() < now.getTime() - 30 * 24 * 60 * 60 * 1e3) { 58 + return thisYear + 1; 59 + } 60 + return thisYear; 61 + } 62 + function tryParseDate(line) { 63 + var _a; 64 + let m; 65 + m = line.match(DATE_WEEKDAY_DD_MONTH_YYYY); 66 + if (m) { 67 + const day = parseInt(m[1], 10); 68 + const month = parseMonth(m[2]); 69 + const year = parseInt(m[3], 10); 70 + if (month !== null) { 71 + return { date: new Date(year, month, day) }; 72 + } 73 + } 74 + m = line.match(DATE_WEEKDAY_COMMA_MONTH_DD); 75 + if (m) { 76 + const month = parseMonth(m[1]); 77 + const day = parseInt(m[2], 10); 78 + if (month !== null) { 79 + const year = m[3] ? parseInt(m[3], 10) : inferYear(month, day); 80 + const rawTime = ((_a = m[4]) == null ? void 0 : _a.trim()) || void 0; 81 + return { date: new Date(year, month, day), rawTime }; 82 + } 83 + } 84 + m = line.match(DATE_MONTH_DD_YYYY); 85 + if (m) { 86 + const month = parseMonth(m[1]); 87 + const day = parseInt(m[2], 10); 88 + if (month !== null) { 89 + const year = m[3] ? parseInt(m[3], 10) : inferYear(month, day); 90 + return { date: new Date(year, month, day) }; 91 + } 92 + } 93 + return null; 94 + } 95 + function extractMarkdownLinks(line) { 96 + const parts = []; 97 + const re = /\[([^\]]+)\]\(([^)]+)\)/g; 98 + let lastIndex = 0; 99 + let match; 100 + while ((match = re.exec(line)) !== null) { 101 + if (match.index > lastIndex) { 102 + const before = line.slice(lastIndex, match.index).trim(); 103 + if (before) 104 + parts.push({ text: before }); 105 + } 106 + parts.push({ text: match[1], url: match[2] }); 107 + lastIndex = re.lastIndex; 108 + } 109 + if (lastIndex < line.length) { 110 + const rest = line.slice(lastIndex).trim(); 111 + if (rest) 112 + parts.push({ text: rest }); 113 + } 114 + return parts; 115 + } 116 + function parseSoldOut(titleLine) { 117 + const soldOutRe = /\s*\(sold\s*out\)\s*/i; 118 + if (soldOutRe.test(titleLine)) { 119 + return { title: titleLine.replace(soldOutRe, "").trim(), soldOut: true }; 120 + } 121 + return { title: titleLine.trim(), soldOut: false }; 122 + } 123 + function parseEvents(markdown) { 124 + const lines = markdown.split("\n"); 125 + const events = []; 126 + const blocks = []; 127 + let currentBlock = null; 128 + for (const line of lines) { 129 + if (/^[*\-]\s/.test(line)) { 130 + currentBlock = [line.replace(/^[*\-]\s+/, "").trim()]; 131 + blocks.push(currentBlock); 132 + } else if (currentBlock && /^\t[*\-]\s/.test(line)) { 133 + currentBlock.push(line.replace(/^\t[*\-]\s+/, "").trim()); 134 + } else if (currentBlock && /^\s{2,}[*\-]\s/.test(line)) { 135 + currentBlock.push(line.replace(/^\s+[*\-]\s+/, "").trim()); 136 + } 137 + } 138 + for (const block of blocks) { 139 + if (block.length === 0) 140 + continue; 141 + const firstLine = block[0]; 142 + if (!firstLine) 143 + continue; 144 + const event = { soldOut: false }; 145 + if (URL_RE.test(firstLine)) { 146 + event.url = firstLine; 147 + } else { 148 + const { title, soldOut } = parseSoldOut(firstLine); 149 + event.title = title; 150 + event.soldOut = soldOut; 151 + } 152 + const subs = block.slice(1); 153 + let dateFound = false; 154 + for (const sub of subs) { 155 + if (!sub) 156 + continue; 157 + if (!dateFound) { 158 + const parsed = tryParseDate(sub); 159 + if (parsed) { 160 + event.date = parsed.date; 161 + event.rawTime = parsed.rawTime; 162 + dateFound = true; 163 + continue; 164 + } 165 + } 166 + if (!event.title) { 167 + const { title, soldOut } = parseSoldOut(sub); 168 + event.title = title; 169 + event.soldOut = soldOut; 170 + continue; 171 + } 172 + if (/\[.*\]\(.*\)/.test(sub)) { 173 + const links = extractMarkdownLinks(sub); 174 + if (links.length >= 1) { 175 + event.venue = links[0].text; 176 + event.venueUrl = links[0].url; 177 + } 178 + if (links.length >= 2) { 179 + event.location = links[1].text; 180 + event.locationUrl = links[1].url; 181 + } 182 + continue; 183 + } 184 + if (event.notes) { 185 + event.notes += "\n" + sub; 186 + } else { 187 + event.notes = sub; 188 + } 189 + } 190 + if (event.title && event.date) { 191 + events.push(event); 192 + } 193 + } 194 + return events; 195 + } 196 + 197 + // renderer.ts 198 + var DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 199 + var MONTH_NAMES = [ 200 + "January", 201 + "February", 202 + "March", 203 + "April", 204 + "May", 205 + "June", 206 + "July", 207 + "August", 208 + "September", 209 + "October", 210 + "November", 211 + "December" 212 + ]; 213 + function mondayIndex(date) { 214 + return (date.getDay() + 6) % 7; 215 + } 216 + function eventsByDay(events, year, month) { 217 + const map = /* @__PURE__ */ new Map(); 218 + for (const ev of events) { 219 + if (ev.date.getFullYear() === year && ev.date.getMonth() === month) { 220 + const day = ev.date.getDate(); 221 + if (!map.has(day)) 222 + map.set(day, []); 223 + map.get(day).push(ev); 224 + } 225 + } 226 + return map; 227 + } 228 + function createPopover(container, ev, anchor) { 229 + const popover = container.createDiv({ cls: "cal-popover" }); 230 + const titleEl = popover.createDiv({ cls: "cal-popover-title" }); 231 + if (ev.url) { 232 + const a = titleEl.createEl("a", { text: ev.title, href: ev.url }); 233 + a.setAttr("target", "_blank"); 234 + } else { 235 + titleEl.setText(ev.title); 236 + } 237 + if (ev.soldOut) { 238 + titleEl.createSpan({ cls: "cal-sold-out", text: " (Sold out)" }); 239 + } 240 + const dateStr = ev.date.toLocaleDateString("en-US", { 241 + weekday: "long", 242 + month: "long", 243 + day: "numeric", 244 + year: "numeric" 245 + }); 246 + const dateLine = ev.rawTime ? `${dateStr}, ${ev.rawTime}` : dateStr; 247 + popover.createDiv({ cls: "cal-popover-date", text: dateLine }); 248 + if (ev.venue) { 249 + const venueEl = popover.createDiv({ cls: "cal-popover-venue" }); 250 + if (ev.venueUrl) { 251 + const a = venueEl.createEl("a", { text: ev.venue, href: ev.venueUrl }); 252 + a.setAttr("target", "_blank"); 253 + } else { 254 + venueEl.setText(ev.venue); 255 + } 256 + if (ev.location) { 257 + venueEl.appendText(", "); 258 + if (ev.locationUrl) { 259 + const a = venueEl.createEl("a", { text: ev.location, href: ev.locationUrl }); 260 + a.setAttr("target", "_blank"); 261 + } else { 262 + venueEl.appendText(ev.location); 263 + } 264 + } 265 + } 266 + if (ev.notes) { 267 + popover.createDiv({ cls: "cal-popover-notes", text: ev.notes }); 268 + } 269 + positionPopover(popover, anchor, container); 270 + return popover; 271 + } 272 + function positionPopover(popover, anchor, container) { 273 + requestAnimationFrame(() => { 274 + const anchorRect = anchor.getBoundingClientRect(); 275 + const containerRect = container.getBoundingClientRect(); 276 + const popoverRect = popover.getBoundingClientRect(); 277 + let top = anchorRect.bottom - containerRect.top + 4; 278 + let left = anchorRect.left - containerRect.left; 279 + if (left + popoverRect.width > containerRect.width) { 280 + left = containerRect.width - popoverRect.width - 8; 281 + } 282 + if (left < 0) 283 + left = 4; 284 + if (top + popoverRect.height > containerRect.height) { 285 + top = anchorRect.top - containerRect.top - popoverRect.height - 4; 286 + } 287 + popover.style.top = `${top}px`; 288 + popover.style.left = `${left}px`; 289 + }); 290 + } 291 + function renderCalendar(container, currentMonth, events, callbacks) { 292 + container.empty(); 293 + container.addClass("cal-container"); 294 + const year = currentMonth.getFullYear(); 295 + const month = currentMonth.getMonth(); 296 + const header = container.createDiv({ cls: "cal-header" }); 297 + const prevBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u2039" }); 298 + prevBtn.addEventListener("click", callbacks.onPrevMonth); 299 + header.createSpan({ cls: "cal-month-label", text: `${MONTH_NAMES[month]} ${year}` }); 300 + const nextBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u203A" }); 301 + nextBtn.addEventListener("click", callbacks.onNextMonth); 302 + const dowRow = container.createDiv({ cls: "cal-dow-row" }); 303 + for (const name of DAY_NAMES) { 304 + dowRow.createDiv({ cls: "cal-dow-cell", text: name }); 305 + } 306 + const grid = container.createDiv({ cls: "cal-grid" }); 307 + const firstOfMonth = new Date(year, month, 1); 308 + const daysInMonth = new Date(year, month + 1, 0).getDate(); 309 + const startOffset = mondayIndex(firstOfMonth); 310 + const dayEvents = eventsByDay(events, year, month); 311 + let activePopover = null; 312 + const removePopover = () => { 313 + if (activePopover) { 314 + activePopover.remove(); 315 + activePopover = null; 316 + } 317 + }; 318 + for (let i = 0; i < startOffset; i++) { 319 + grid.createDiv({ cls: "cal-cell cal-cell-empty" }); 320 + } 321 + for (let day = 1; day <= daysInMonth; day++) { 322 + const cell = grid.createDiv({ cls: "cal-cell" }); 323 + cell.createDiv({ cls: "cal-day-number", text: String(day) }); 324 + const eventsForDay = dayEvents.get(day); 325 + if (eventsForDay) { 326 + cell.addClass("cal-cell-has-events"); 327 + const eventsContainer = cell.createDiv({ cls: "cal-cell-events" }); 328 + for (const ev of eventsForDay) { 329 + const chip = eventsContainer.createDiv({ 330 + cls: `cal-event-chip${ev.soldOut ? " cal-event-sold-out" : ""}`, 331 + text: ev.title 332 + }); 333 + chip.addEventListener("mouseenter", () => { 334 + removePopover(); 335 + activePopover = createPopover(container, ev, chip); 336 + }); 337 + chip.addEventListener("mouseleave", (e) => { 338 + setTimeout(() => { 339 + if (activePopover && !activePopover.contains(e.relatedTarget)) { 340 + removePopover(); 341 + } 342 + }, 100); 343 + }); 344 + } 345 + } 346 + const now = /* @__PURE__ */ new Date(); 347 + if (year === now.getFullYear() && month === now.getMonth() && day === now.getDate()) { 348 + cell.addClass("cal-cell-today"); 349 + } 350 + } 351 + const totalCells = startOffset + daysInMonth; 352 + const trailingCells = totalCells % 7 === 0 ? 0 : 7 - totalCells % 7; 353 + for (let i = 0; i < trailingCells; i++) { 354 + grid.createDiv({ cls: "cal-cell cal-cell-empty" }); 355 + } 356 + container.addEventListener("click", (e) => { 357 + if (activePopover && !activePopover.contains(e.target)) { 358 + removePopover(); 359 + } 360 + }); 361 + } 362 + 363 + // calendarView.ts 364 + var VIEW_TYPE_CALENDAR = "calendar-viewer"; 365 + var CalendarView = class extends import_obsidian.ItemView { 366 + constructor(leaf) { 367 + super(leaf); 368 + this.events = []; 369 + /** 370 + * Re-parse the active note and re-render the calendar. 371 + */ 372 + this.refresh = (0, import_obsidian.debounce)(async () => { 373 + const file = this.app.workspace.getActiveFile(); 374 + if (file) { 375 + const content = await this.app.vault.read(file); 376 + this.events = parseEvents(content); 377 + if (this.events.length > 0 && !this.hasUserNavigated) { 378 + this.jumpToNearestMonth(); 379 + } 380 + } else { 381 + this.events = []; 382 + } 383 + this.render(); 384 + }, 300, true); 385 + this.hasUserNavigated = false; 386 + this.currentMonth = /* @__PURE__ */ new Date(); 387 + this.currentMonth.setDate(1); 388 + } 389 + getViewType() { 390 + return VIEW_TYPE_CALENDAR; 391 + } 392 + getDisplayText() { 393 + return "Calendar"; 394 + } 395 + getIcon() { 396 + return "calendar"; 397 + } 398 + async onOpen() { 399 + await this.refresh(); 400 + } 401 + async onClose() { 402 + } 403 + jumpToNearestMonth() { 404 + const now = /* @__PURE__ */ new Date(); 405 + const sorted = [...this.events].sort( 406 + (a, b) => a.date.getTime() - b.date.getTime() 407 + ); 408 + const upcoming = sorted.find((e) => e.date.getTime() >= now.getTime()); 409 + const target = upcoming != null ? upcoming : sorted[sorted.length - 1]; 410 + if (target) { 411 + this.currentMonth = new Date(target.date.getFullYear(), target.date.getMonth(), 1); 412 + } 413 + } 414 + render() { 415 + const container = this.containerEl.children[1]; 416 + renderCalendar(container, this.currentMonth, this.events, { 417 + onPrevMonth: () => { 418 + this.hasUserNavigated = true; 419 + this.currentMonth = new Date( 420 + this.currentMonth.getFullYear(), 421 + this.currentMonth.getMonth() - 1, 422 + 1 423 + ); 424 + this.render(); 425 + }, 426 + onNextMonth: () => { 427 + this.hasUserNavigated = true; 428 + this.currentMonth = new Date( 429 + this.currentMonth.getFullYear(), 430 + this.currentMonth.getMonth() + 1, 431 + 1 432 + ); 433 + this.render(); 434 + } 435 + }); 436 + } 437 + /** 438 + * Reset the user-navigated flag so the view jumps to the 439 + * relevant month when switching to a new note. 440 + */ 441 + resetNavigation() { 442 + this.hasUserNavigated = false; 443 + } 444 + }; 445 + 446 + // main.ts 447 + var CalendarViewerPlugin = class extends import_obsidian2.Plugin { 448 + async onload() { 449 + this.registerView(VIEW_TYPE_CALENDAR, (leaf) => new CalendarView(leaf)); 450 + this.addRibbonIcon("calendar", "Open Calendar View", () => { 451 + this.activateView(); 452 + }); 453 + this.addCommand({ 454 + id: "open-calendar-view", 455 + name: "Open Calendar View", 456 + callback: () => { 457 + this.activateView(); 458 + } 459 + }); 460 + this.registerEvent( 461 + this.app.workspace.on("active-leaf-change", () => { 462 + const view = this.getCalendarView(); 463 + if (view) { 464 + view.resetNavigation(); 465 + view.refresh(); 466 + } 467 + }) 468 + ); 469 + this.registerEvent( 470 + this.app.vault.on("modify", (file) => { 471 + const activeFile = this.app.workspace.getActiveFile(); 472 + if (activeFile && file.path === activeFile.path) { 473 + const view = this.getCalendarView(); 474 + if (view) { 475 + view.refresh(); 476 + } 477 + } 478 + }) 479 + ); 480 + } 481 + onunload() { 482 + } 483 + getCalendarView() { 484 + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CALENDAR); 485 + if (leaves.length > 0) { 486 + return leaves[0].view; 487 + } 488 + return null; 489 + } 490 + async activateView() { 491 + const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_CALENDAR); 492 + if (existing.length === 0) { 493 + const leaf = this.app.workspace.getRightLeaf(false); 494 + if (leaf) { 495 + await leaf.setViewState({ 496 + type: VIEW_TYPE_CALENDAR, 497 + active: true 498 + }); 499 + this.app.workspace.revealLeaf(leaf); 500 + } 501 + } else { 502 + this.app.workspace.revealLeaf(existing[0]); 503 + } 504 + } 505 + };
+75
main.ts
··· 1 + import { Plugin, WorkspaceLeaf } from "obsidian"; 2 + import { CalendarView, VIEW_TYPE_CALENDAR } from "./calendarView"; 3 + 4 + export default class CalendarViewerPlugin extends Plugin { 5 + async onload(): Promise<void> { 6 + this.registerView(VIEW_TYPE_CALENDAR, (leaf) => new CalendarView(leaf)); 7 + 8 + // Ribbon icon to open the calendar sidebar 9 + this.addRibbonIcon("calendar", "Open Calendar View", () => { 10 + this.activateView(); 11 + }); 12 + 13 + // Command to open the calendar sidebar 14 + this.addCommand({ 15 + id: "open-calendar-view", 16 + name: "Open Calendar View", 17 + callback: () => { 18 + this.activateView(); 19 + }, 20 + }); 21 + 22 + // Re-parse when the active file changes 23 + this.registerEvent( 24 + this.app.workspace.on("active-leaf-change", () => { 25 + const view = this.getCalendarView(); 26 + if (view) { 27 + view.resetNavigation(); 28 + view.refresh(); 29 + } 30 + }) 31 + ); 32 + 33 + // Re-parse when a file is modified (live update) 34 + this.registerEvent( 35 + this.app.vault.on("modify", (file) => { 36 + const activeFile = this.app.workspace.getActiveFile(); 37 + if (activeFile && file.path === activeFile.path) { 38 + const view = this.getCalendarView(); 39 + if (view) { 40 + view.refresh(); 41 + } 42 + } 43 + }) 44 + ); 45 + } 46 + 47 + onunload(): void { 48 + // Obsidian handles view cleanup via detachLeavesOfType 49 + } 50 + 51 + private getCalendarView(): CalendarView | null { 52 + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CALENDAR); 53 + if (leaves.length > 0) { 54 + return leaves[0].view as CalendarView; 55 + } 56 + return null; 57 + } 58 + 59 + private async activateView(): Promise<void> { 60 + const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_CALENDAR); 61 + 62 + if (existing.length === 0) { 63 + const leaf = this.app.workspace.getRightLeaf(false); 64 + if (leaf) { 65 + await leaf.setViewState({ 66 + type: VIEW_TYPE_CALENDAR, 67 + active: true, 68 + }); 69 + this.app.workspace.revealLeaf(leaf); 70 + } 71 + } else { 72 + this.app.workspace.revealLeaf(existing[0]); 73 + } 74 + } 75 + }
+9
manifest.json
··· 1 + { 2 + "id": "calendar-viewer", 3 + "name": "Calendar Viewer", 4 + "version": "1.0.0", 5 + "minAppVersion": "0.15.0", 6 + "description": "Parses event lists from notes and displays them in a navigable month calendar sidebar.", 7 + "author": "Anish Lakhwara", 8 + "isDesktopOnly": false 9 + }
+611
package-lock.json
··· 1 + { 2 + "name": "obs-calendar-viewer", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "obs-calendar-viewer", 9 + "version": "1.0.0", 10 + "license": "MIT", 11 + "devDependencies": { 12 + "@types/node": "^20.11.0", 13 + "builtin-modules": "^3.3.0", 14 + "esbuild": "^0.20.0", 15 + "obsidian": "latest", 16 + "tslib": "^2.6.0", 17 + "typescript": "^5.3.0" 18 + } 19 + }, 20 + "node_modules/@codemirror/state": { 21 + "version": "6.5.0", 22 + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", 23 + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", 24 + "dev": true, 25 + "license": "MIT", 26 + "peer": true, 27 + "dependencies": { 28 + "@marijn/find-cluster-break": "^1.0.0" 29 + } 30 + }, 31 + "node_modules/@codemirror/view": { 32 + "version": "6.38.6", 33 + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", 34 + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", 35 + "dev": true, 36 + "license": "MIT", 37 + "peer": true, 38 + "dependencies": { 39 + "@codemirror/state": "^6.5.0", 40 + "crelt": "^1.0.6", 41 + "style-mod": "^4.1.0", 42 + "w3c-keyname": "^2.2.4" 43 + } 44 + }, 45 + "node_modules/@esbuild/aix-ppc64": { 46 + "version": "0.20.2", 47 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", 48 + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", 49 + "cpu": [ 50 + "ppc64" 51 + ], 52 + "dev": true, 53 + "license": "MIT", 54 + "optional": true, 55 + "os": [ 56 + "aix" 57 + ], 58 + "engines": { 59 + "node": ">=12" 60 + } 61 + }, 62 + "node_modules/@esbuild/android-arm": { 63 + "version": "0.20.2", 64 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", 65 + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", 66 + "cpu": [ 67 + "arm" 68 + ], 69 + "dev": true, 70 + "license": "MIT", 71 + "optional": true, 72 + "os": [ 73 + "android" 74 + ], 75 + "engines": { 76 + "node": ">=12" 77 + } 78 + }, 79 + "node_modules/@esbuild/android-arm64": { 80 + "version": "0.20.2", 81 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", 82 + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", 83 + "cpu": [ 84 + "arm64" 85 + ], 86 + "dev": true, 87 + "license": "MIT", 88 + "optional": true, 89 + "os": [ 90 + "android" 91 + ], 92 + "engines": { 93 + "node": ">=12" 94 + } 95 + }, 96 + "node_modules/@esbuild/android-x64": { 97 + "version": "0.20.2", 98 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", 99 + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", 100 + "cpu": [ 101 + "x64" 102 + ], 103 + "dev": true, 104 + "license": "MIT", 105 + "optional": true, 106 + "os": [ 107 + "android" 108 + ], 109 + "engines": { 110 + "node": ">=12" 111 + } 112 + }, 113 + "node_modules/@esbuild/darwin-arm64": { 114 + "version": "0.20.2", 115 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", 116 + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", 117 + "cpu": [ 118 + "arm64" 119 + ], 120 + "dev": true, 121 + "license": "MIT", 122 + "optional": true, 123 + "os": [ 124 + "darwin" 125 + ], 126 + "engines": { 127 + "node": ">=12" 128 + } 129 + }, 130 + "node_modules/@esbuild/darwin-x64": { 131 + "version": "0.20.2", 132 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", 133 + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", 134 + "cpu": [ 135 + "x64" 136 + ], 137 + "dev": true, 138 + "license": "MIT", 139 + "optional": true, 140 + "os": [ 141 + "darwin" 142 + ], 143 + "engines": { 144 + "node": ">=12" 145 + } 146 + }, 147 + "node_modules/@esbuild/freebsd-arm64": { 148 + "version": "0.20.2", 149 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", 150 + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", 151 + "cpu": [ 152 + "arm64" 153 + ], 154 + "dev": true, 155 + "license": "MIT", 156 + "optional": true, 157 + "os": [ 158 + "freebsd" 159 + ], 160 + "engines": { 161 + "node": ">=12" 162 + } 163 + }, 164 + "node_modules/@esbuild/freebsd-x64": { 165 + "version": "0.20.2", 166 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", 167 + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", 168 + "cpu": [ 169 + "x64" 170 + ], 171 + "dev": true, 172 + "license": "MIT", 173 + "optional": true, 174 + "os": [ 175 + "freebsd" 176 + ], 177 + "engines": { 178 + "node": ">=12" 179 + } 180 + }, 181 + "node_modules/@esbuild/linux-arm": { 182 + "version": "0.20.2", 183 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", 184 + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", 185 + "cpu": [ 186 + "arm" 187 + ], 188 + "dev": true, 189 + "license": "MIT", 190 + "optional": true, 191 + "os": [ 192 + "linux" 193 + ], 194 + "engines": { 195 + "node": ">=12" 196 + } 197 + }, 198 + "node_modules/@esbuild/linux-arm64": { 199 + "version": "0.20.2", 200 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", 201 + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", 202 + "cpu": [ 203 + "arm64" 204 + ], 205 + "dev": true, 206 + "license": "MIT", 207 + "optional": true, 208 + "os": [ 209 + "linux" 210 + ], 211 + "engines": { 212 + "node": ">=12" 213 + } 214 + }, 215 + "node_modules/@esbuild/linux-ia32": { 216 + "version": "0.20.2", 217 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", 218 + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", 219 + "cpu": [ 220 + "ia32" 221 + ], 222 + "dev": true, 223 + "license": "MIT", 224 + "optional": true, 225 + "os": [ 226 + "linux" 227 + ], 228 + "engines": { 229 + "node": ">=12" 230 + } 231 + }, 232 + "node_modules/@esbuild/linux-loong64": { 233 + "version": "0.20.2", 234 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", 235 + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", 236 + "cpu": [ 237 + "loong64" 238 + ], 239 + "dev": true, 240 + "license": "MIT", 241 + "optional": true, 242 + "os": [ 243 + "linux" 244 + ], 245 + "engines": { 246 + "node": ">=12" 247 + } 248 + }, 249 + "node_modules/@esbuild/linux-mips64el": { 250 + "version": "0.20.2", 251 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", 252 + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", 253 + "cpu": [ 254 + "mips64el" 255 + ], 256 + "dev": true, 257 + "license": "MIT", 258 + "optional": true, 259 + "os": [ 260 + "linux" 261 + ], 262 + "engines": { 263 + "node": ">=12" 264 + } 265 + }, 266 + "node_modules/@esbuild/linux-ppc64": { 267 + "version": "0.20.2", 268 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", 269 + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", 270 + "cpu": [ 271 + "ppc64" 272 + ], 273 + "dev": true, 274 + "license": "MIT", 275 + "optional": true, 276 + "os": [ 277 + "linux" 278 + ], 279 + "engines": { 280 + "node": ">=12" 281 + } 282 + }, 283 + "node_modules/@esbuild/linux-riscv64": { 284 + "version": "0.20.2", 285 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", 286 + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", 287 + "cpu": [ 288 + "riscv64" 289 + ], 290 + "dev": true, 291 + "license": "MIT", 292 + "optional": true, 293 + "os": [ 294 + "linux" 295 + ], 296 + "engines": { 297 + "node": ">=12" 298 + } 299 + }, 300 + "node_modules/@esbuild/linux-s390x": { 301 + "version": "0.20.2", 302 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", 303 + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", 304 + "cpu": [ 305 + "s390x" 306 + ], 307 + "dev": true, 308 + "license": "MIT", 309 + "optional": true, 310 + "os": [ 311 + "linux" 312 + ], 313 + "engines": { 314 + "node": ">=12" 315 + } 316 + }, 317 + "node_modules/@esbuild/linux-x64": { 318 + "version": "0.20.2", 319 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", 320 + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", 321 + "cpu": [ 322 + "x64" 323 + ], 324 + "dev": true, 325 + "license": "MIT", 326 + "optional": true, 327 + "os": [ 328 + "linux" 329 + ], 330 + "engines": { 331 + "node": ">=12" 332 + } 333 + }, 334 + "node_modules/@esbuild/netbsd-x64": { 335 + "version": "0.20.2", 336 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", 337 + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", 338 + "cpu": [ 339 + "x64" 340 + ], 341 + "dev": true, 342 + "license": "MIT", 343 + "optional": true, 344 + "os": [ 345 + "netbsd" 346 + ], 347 + "engines": { 348 + "node": ">=12" 349 + } 350 + }, 351 + "node_modules/@esbuild/openbsd-x64": { 352 + "version": "0.20.2", 353 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", 354 + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", 355 + "cpu": [ 356 + "x64" 357 + ], 358 + "dev": true, 359 + "license": "MIT", 360 + "optional": true, 361 + "os": [ 362 + "openbsd" 363 + ], 364 + "engines": { 365 + "node": ">=12" 366 + } 367 + }, 368 + "node_modules/@esbuild/sunos-x64": { 369 + "version": "0.20.2", 370 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", 371 + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", 372 + "cpu": [ 373 + "x64" 374 + ], 375 + "dev": true, 376 + "license": "MIT", 377 + "optional": true, 378 + "os": [ 379 + "sunos" 380 + ], 381 + "engines": { 382 + "node": ">=12" 383 + } 384 + }, 385 + "node_modules/@esbuild/win32-arm64": { 386 + "version": "0.20.2", 387 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", 388 + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", 389 + "cpu": [ 390 + "arm64" 391 + ], 392 + "dev": true, 393 + "license": "MIT", 394 + "optional": true, 395 + "os": [ 396 + "win32" 397 + ], 398 + "engines": { 399 + "node": ">=12" 400 + } 401 + }, 402 + "node_modules/@esbuild/win32-ia32": { 403 + "version": "0.20.2", 404 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", 405 + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", 406 + "cpu": [ 407 + "ia32" 408 + ], 409 + "dev": true, 410 + "license": "MIT", 411 + "optional": true, 412 + "os": [ 413 + "win32" 414 + ], 415 + "engines": { 416 + "node": ">=12" 417 + } 418 + }, 419 + "node_modules/@esbuild/win32-x64": { 420 + "version": "0.20.2", 421 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", 422 + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", 423 + "cpu": [ 424 + "x64" 425 + ], 426 + "dev": true, 427 + "license": "MIT", 428 + "optional": true, 429 + "os": [ 430 + "win32" 431 + ], 432 + "engines": { 433 + "node": ">=12" 434 + } 435 + }, 436 + "node_modules/@marijn/find-cluster-break": { 437 + "version": "1.0.2", 438 + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", 439 + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", 440 + "dev": true, 441 + "license": "MIT", 442 + "peer": true 443 + }, 444 + "node_modules/@types/codemirror": { 445 + "version": "5.60.8", 446 + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", 447 + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", 448 + "dev": true, 449 + "license": "MIT", 450 + "dependencies": { 451 + "@types/tern": "*" 452 + } 453 + }, 454 + "node_modules/@types/estree": { 455 + "version": "1.0.8", 456 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", 457 + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", 458 + "dev": true, 459 + "license": "MIT" 460 + }, 461 + "node_modules/@types/node": { 462 + "version": "20.19.33", 463 + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", 464 + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", 465 + "dev": true, 466 + "license": "MIT", 467 + "dependencies": { 468 + "undici-types": "~6.21.0" 469 + } 470 + }, 471 + "node_modules/@types/tern": { 472 + "version": "0.23.9", 473 + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", 474 + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", 475 + "dev": true, 476 + "license": "MIT", 477 + "dependencies": { 478 + "@types/estree": "*" 479 + } 480 + }, 481 + "node_modules/builtin-modules": { 482 + "version": "3.3.0", 483 + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", 484 + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", 485 + "dev": true, 486 + "license": "MIT", 487 + "engines": { 488 + "node": ">=6" 489 + }, 490 + "funding": { 491 + "url": "https://github.com/sponsors/sindresorhus" 492 + } 493 + }, 494 + "node_modules/crelt": { 495 + "version": "1.0.6", 496 + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", 497 + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", 498 + "dev": true, 499 + "license": "MIT", 500 + "peer": true 501 + }, 502 + "node_modules/esbuild": { 503 + "version": "0.20.2", 504 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", 505 + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", 506 + "dev": true, 507 + "hasInstallScript": true, 508 + "license": "MIT", 509 + "bin": { 510 + "esbuild": "bin/esbuild" 511 + }, 512 + "engines": { 513 + "node": ">=12" 514 + }, 515 + "optionalDependencies": { 516 + "@esbuild/aix-ppc64": "0.20.2", 517 + "@esbuild/android-arm": "0.20.2", 518 + "@esbuild/android-arm64": "0.20.2", 519 + "@esbuild/android-x64": "0.20.2", 520 + "@esbuild/darwin-arm64": "0.20.2", 521 + "@esbuild/darwin-x64": "0.20.2", 522 + "@esbuild/freebsd-arm64": "0.20.2", 523 + "@esbuild/freebsd-x64": "0.20.2", 524 + "@esbuild/linux-arm": "0.20.2", 525 + "@esbuild/linux-arm64": "0.20.2", 526 + "@esbuild/linux-ia32": "0.20.2", 527 + "@esbuild/linux-loong64": "0.20.2", 528 + "@esbuild/linux-mips64el": "0.20.2", 529 + "@esbuild/linux-ppc64": "0.20.2", 530 + "@esbuild/linux-riscv64": "0.20.2", 531 + "@esbuild/linux-s390x": "0.20.2", 532 + "@esbuild/linux-x64": "0.20.2", 533 + "@esbuild/netbsd-x64": "0.20.2", 534 + "@esbuild/openbsd-x64": "0.20.2", 535 + "@esbuild/sunos-x64": "0.20.2", 536 + "@esbuild/win32-arm64": "0.20.2", 537 + "@esbuild/win32-ia32": "0.20.2", 538 + "@esbuild/win32-x64": "0.20.2" 539 + } 540 + }, 541 + "node_modules/moment": { 542 + "version": "2.29.4", 543 + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", 544 + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", 545 + "dev": true, 546 + "license": "MIT", 547 + "engines": { 548 + "node": "*" 549 + } 550 + }, 551 + "node_modules/obsidian": { 552 + "version": "1.12.0", 553 + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.0.tgz", 554 + "integrity": "sha512-goA2DNTIPO3IRtsqzUs6UQmVJkwZayIKXnI3tLZX/NO+t1yl7YuZrfVEL1JyUkthCJyaZMn8WOlp7mX18acWxA==", 555 + "dev": true, 556 + "license": "MIT", 557 + "dependencies": { 558 + "@types/codemirror": "5.60.8", 559 + "moment": "2.29.4" 560 + }, 561 + "peerDependencies": { 562 + "@codemirror/state": "6.5.0", 563 + "@codemirror/view": "6.38.6" 564 + } 565 + }, 566 + "node_modules/style-mod": { 567 + "version": "4.1.3", 568 + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", 569 + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", 570 + "dev": true, 571 + "license": "MIT", 572 + "peer": true 573 + }, 574 + "node_modules/tslib": { 575 + "version": "2.8.1", 576 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 577 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 578 + "dev": true, 579 + "license": "0BSD" 580 + }, 581 + "node_modules/typescript": { 582 + "version": "5.9.3", 583 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 584 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 585 + "dev": true, 586 + "license": "Apache-2.0", 587 + "bin": { 588 + "tsc": "bin/tsc", 589 + "tsserver": "bin/tsserver" 590 + }, 591 + "engines": { 592 + "node": ">=14.17" 593 + } 594 + }, 595 + "node_modules/undici-types": { 596 + "version": "6.21.0", 597 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 598 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 599 + "dev": true, 600 + "license": "MIT" 601 + }, 602 + "node_modules/w3c-keyname": { 603 + "version": "2.2.8", 604 + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", 605 + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", 606 + "dev": true, 607 + "license": "MIT", 608 + "peer": true 609 + } 610 + } 611 + }
+21
package.json
··· 1 + { 2 + "name": "obs-calendar-viewer", 3 + "version": "1.0.0", 4 + "description": "Obsidian plugin that renders event lists as a calendar month view", 5 + "main": "main.js", 6 + "scripts": { 7 + "dev": "node esbuild.config.mjs", 8 + "build": "node esbuild.config.mjs production" 9 + }, 10 + "keywords": [], 11 + "author": "", 12 + "license": "MIT", 13 + "devDependencies": { 14 + "@types/node": "^20.11.0", 15 + "builtin-modules": "^3.3.0", 16 + "esbuild": "^0.20.0", 17 + "obsidian": "latest", 18 + "typescript": "^5.3.0", 19 + "tslib": "^2.6.0" 20 + } 21 + }
+226
parser.ts
··· 1 + export interface CalendarEvent { 2 + title: string; 3 + date: Date; 4 + venue?: string; 5 + venueUrl?: string; 6 + location?: string; 7 + locationUrl?: string; 8 + url?: string; 9 + soldOut: boolean; 10 + rawTime?: string; 11 + notes?: string; 12 + } 13 + 14 + const MONTHS: Record<string, number> = { 15 + january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, 16 + july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, 17 + }; 18 + 19 + const URL_RE = /^https?:\/\/\S+$/; 20 + 21 + // "Monday 27 April 2026" — Songkick style 22 + const DATE_WEEKDAY_DD_MONTH_YYYY = 23 + /^(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(\d{1,2})\s+(\w+)\s+(\d{4})/i; 24 + 25 + // "Tuesday, March 24, 7 - 11pm PDT" or "Tuesday, March 24" 26 + const DATE_WEEKDAY_COMMA_MONTH_DD = 27 + /^(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday),?\s+(\w+)\s+(\d{1,2})(?:,?\s+(\d{4}))?(?:,?\s+(.+))?/i; 28 + 29 + // "April 3, 2026" or "March 24" 30 + const DATE_MONTH_DD_YYYY = 31 + /^(\w+)\s+(\d{1,2})(?:,?\s+(\d{4}))?/i; 32 + 33 + function parseMonth(s: string): number | null { 34 + const m = MONTHS[s.toLowerCase()]; 35 + return m !== undefined ? m : null; 36 + } 37 + 38 + function inferYear(month: number, day: number): number { 39 + const now = new Date(); 40 + const thisYear = now.getFullYear(); 41 + const candidate = new Date(thisYear, month, day); 42 + // If the date is more than 30 days in the past, assume next year 43 + if (candidate.getTime() < now.getTime() - 30 * 24 * 60 * 60 * 1000) { 44 + return thisYear + 1; 45 + } 46 + return thisYear; 47 + } 48 + 49 + function tryParseDate(line: string): { date: Date; rawTime?: string } | null { 50 + let m: RegExpMatchArray | null; 51 + 52 + // "Monday 27 April 2026" 53 + m = line.match(DATE_WEEKDAY_DD_MONTH_YYYY); 54 + if (m) { 55 + const day = parseInt(m[1], 10); 56 + const month = parseMonth(m[2]); 57 + const year = parseInt(m[3], 10); 58 + if (month !== null) { 59 + return { date: new Date(year, month, day) }; 60 + } 61 + } 62 + 63 + // "Tuesday, March 24, 7 - 11pm PDT" 64 + m = line.match(DATE_WEEKDAY_COMMA_MONTH_DD); 65 + if (m) { 66 + const month = parseMonth(m[1]); 67 + const day = parseInt(m[2], 10); 68 + if (month !== null) { 69 + const year = m[3] ? parseInt(m[3], 10) : inferYear(month, day); 70 + const rawTime = m[4]?.trim() || undefined; 71 + return { date: new Date(year, month, day), rawTime }; 72 + } 73 + } 74 + 75 + // "April 3, 2026" 76 + m = line.match(DATE_MONTH_DD_YYYY); 77 + if (m) { 78 + const month = parseMonth(m[1]); 79 + const day = parseInt(m[2], 10); 80 + if (month !== null) { 81 + const year = m[3] ? parseInt(m[3], 10) : inferYear(month, day); 82 + return { date: new Date(year, month, day) }; 83 + } 84 + } 85 + 86 + return null; 87 + } 88 + 89 + function extractMarkdownLinks(line: string): Array<{ text: string; url?: string }> { 90 + const parts: Array<{ text: string; url?: string }> = []; 91 + const re = /\[([^\]]+)\]\(([^)]+)\)/g; 92 + let lastIndex = 0; 93 + let match: RegExpExecArray | null; 94 + 95 + while ((match = re.exec(line)) !== null) { 96 + if (match.index > lastIndex) { 97 + const before = line.slice(lastIndex, match.index).trim(); 98 + if (before) parts.push({ text: before }); 99 + } 100 + parts.push({ text: match[1], url: match[2] }); 101 + lastIndex = re.lastIndex; 102 + } 103 + 104 + if (lastIndex < line.length) { 105 + const rest = line.slice(lastIndex).trim(); 106 + if (rest) parts.push({ text: rest }); 107 + } 108 + 109 + return parts; 110 + } 111 + 112 + function parseSoldOut(titleLine: string): { title: string; soldOut: boolean } { 113 + const soldOutRe = /\s*\(sold\s*out\)\s*/i; 114 + if (soldOutRe.test(titleLine)) { 115 + return { title: titleLine.replace(soldOutRe, "").trim(), soldOut: true }; 116 + } 117 + return { title: titleLine.trim(), soldOut: false }; 118 + } 119 + 120 + /** 121 + * Parse a markdown note into CalendarEvent[]. 122 + * 123 + * Expected structure: 124 + * * <url or event name> 125 + * * <artist/title> 126 + * * <date> 127 + * * <venue/location or notes> 128 + * 129 + * Top-level bullets start with `* ` (no leading whitespace or one level). 130 + * Sub-bullets are indented with a tab or spaces under their parent. 131 + */ 132 + export function parseEvents(markdown: string): CalendarEvent[] { 133 + const lines = markdown.split("\n"); 134 + const events: CalendarEvent[] = []; 135 + 136 + // Group lines into blocks: each top-level bullet starts a block 137 + const blocks: string[][] = []; 138 + let currentBlock: string[] | null = null; 139 + 140 + for (const line of lines) { 141 + // Top-level bullet: starts with `* ` (possibly after stripping leading whitespace at level 0) 142 + if (/^[*\-]\s/.test(line)) { 143 + currentBlock = [line.replace(/^[*\-]\s+/, "").trim()]; 144 + blocks.push(currentBlock); 145 + } else if (currentBlock && /^\t[*\-]\s/.test(line)) { 146 + // Sub-bullet (tab-indented) 147 + currentBlock.push(line.replace(/^\t[*\-]\s+/, "").trim()); 148 + } else if (currentBlock && /^\s{2,}[*\-]\s/.test(line)) { 149 + // Sub-bullet (space-indented) 150 + currentBlock.push(line.replace(/^\s+[*\-]\s+/, "").trim()); 151 + } 152 + } 153 + 154 + for (const block of blocks) { 155 + if (block.length === 0) continue; 156 + 157 + const firstLine = block[0]; 158 + if (!firstLine) continue; 159 + 160 + const event: Partial<CalendarEvent> = { soldOut: false }; 161 + 162 + // First line: URL or plain title 163 + if (URL_RE.test(firstLine)) { 164 + event.url = firstLine; 165 + } else { 166 + const { title, soldOut } = parseSoldOut(firstLine); 167 + event.title = title; 168 + event.soldOut = soldOut; 169 + } 170 + 171 + // Process sub-bullets 172 + const subs = block.slice(1); 173 + let dateFound = false; 174 + 175 + for (const sub of subs) { 176 + if (!sub) continue; 177 + 178 + // Try to parse as date first 179 + if (!dateFound) { 180 + const parsed = tryParseDate(sub); 181 + if (parsed) { 182 + event.date = parsed.date; 183 + event.rawTime = parsed.rawTime; 184 + dateFound = true; 185 + continue; 186 + } 187 + } 188 + 189 + // If we don't have a title yet (URL was first line), first non-date sub is title 190 + if (!event.title) { 191 + const { title, soldOut } = parseSoldOut(sub); 192 + event.title = title; 193 + event.soldOut = soldOut; 194 + continue; 195 + } 196 + 197 + // Try to parse as venue/location (contains markdown links) 198 + if (/\[.*\]\(.*\)/.test(sub)) { 199 + const links = extractMarkdownLinks(sub); 200 + if (links.length >= 1) { 201 + event.venue = links[0].text; 202 + event.venueUrl = links[0].url; 203 + } 204 + if (links.length >= 2) { 205 + event.location = links[1].text; 206 + event.locationUrl = links[1].url; 207 + } 208 + continue; 209 + } 210 + 211 + // Everything else is notes 212 + if (event.notes) { 213 + event.notes += "\n" + sub; 214 + } else { 215 + event.notes = sub; 216 + } 217 + } 218 + 219 + // Only include events that have at least a title and a date 220 + if (event.title && event.date) { 221 + events.push(event as CalendarEvent); 222 + } 223 + } 224 + 225 + return events; 226 + }
+219
renderer.ts
··· 1 + import type { CalendarEvent } from "./parser"; 2 + 3 + const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; 4 + const MONTH_NAMES = [ 5 + "January", "February", "March", "April", "May", "June", 6 + "July", "August", "September", "October", "November", "December", 7 + ]; 8 + 9 + export interface CalendarCallbacks { 10 + onPrevMonth: () => void; 11 + onNextMonth: () => void; 12 + } 13 + 14 + /** 15 + * Get the Monday-based day-of-week (0=Mon, 6=Sun). 16 + */ 17 + function mondayIndex(date: Date): number { 18 + return (date.getDay() + 6) % 7; 19 + } 20 + 21 + /** 22 + * Build a map from day-of-month to events for a given month. 23 + */ 24 + function eventsByDay(events: CalendarEvent[], year: number, month: number): Map<number, CalendarEvent[]> { 25 + const map = new Map<number, CalendarEvent[]>(); 26 + for (const ev of events) { 27 + if (ev.date.getFullYear() === year && ev.date.getMonth() === month) { 28 + const day = ev.date.getDate(); 29 + if (!map.has(day)) map.set(day, []); 30 + map.get(day)!.push(ev); 31 + } 32 + } 33 + return map; 34 + } 35 + 36 + function createPopover(container: HTMLElement, ev: CalendarEvent, anchor: HTMLElement): HTMLElement { 37 + const popover = container.createDiv({ cls: "cal-popover" }); 38 + 39 + const titleEl = popover.createDiv({ cls: "cal-popover-title" }); 40 + if (ev.url) { 41 + const a = titleEl.createEl("a", { text: ev.title, href: ev.url }); 42 + a.setAttr("target", "_blank"); 43 + } else { 44 + titleEl.setText(ev.title); 45 + } 46 + 47 + if (ev.soldOut) { 48 + titleEl.createSpan({ cls: "cal-sold-out", text: " (Sold out)" }); 49 + } 50 + 51 + // Date line 52 + const dateStr = ev.date.toLocaleDateString("en-US", { 53 + weekday: "long", 54 + month: "long", 55 + day: "numeric", 56 + year: "numeric", 57 + }); 58 + const dateLine = ev.rawTime ? `${dateStr}, ${ev.rawTime}` : dateStr; 59 + popover.createDiv({ cls: "cal-popover-date", text: dateLine }); 60 + 61 + // Venue 62 + if (ev.venue) { 63 + const venueEl = popover.createDiv({ cls: "cal-popover-venue" }); 64 + if (ev.venueUrl) { 65 + const a = venueEl.createEl("a", { text: ev.venue, href: ev.venueUrl }); 66 + a.setAttr("target", "_blank"); 67 + } else { 68 + venueEl.setText(ev.venue); 69 + } 70 + if (ev.location) { 71 + venueEl.appendText(", "); 72 + if (ev.locationUrl) { 73 + const a = venueEl.createEl("a", { text: ev.location, href: ev.locationUrl }); 74 + a.setAttr("target", "_blank"); 75 + } else { 76 + venueEl.appendText(ev.location); 77 + } 78 + } 79 + } 80 + 81 + // Notes 82 + if (ev.notes) { 83 + popover.createDiv({ cls: "cal-popover-notes", text: ev.notes }); 84 + } 85 + 86 + // Position the popover near the anchor 87 + positionPopover(popover, anchor, container); 88 + 89 + return popover; 90 + } 91 + 92 + function positionPopover(popover: HTMLElement, anchor: HTMLElement, container: HTMLElement): void { 93 + // We'll position after it's in the DOM so we can measure 94 + requestAnimationFrame(() => { 95 + const anchorRect = anchor.getBoundingClientRect(); 96 + const containerRect = container.getBoundingClientRect(); 97 + const popoverRect = popover.getBoundingClientRect(); 98 + 99 + let top = anchorRect.bottom - containerRect.top + 4; 100 + let left = anchorRect.left - containerRect.left; 101 + 102 + // Keep popover within container bounds 103 + if (left + popoverRect.width > containerRect.width) { 104 + left = containerRect.width - popoverRect.width - 8; 105 + } 106 + if (left < 0) left = 4; 107 + 108 + // If popover would go below container, show it above 109 + if (top + popoverRect.height > containerRect.height) { 110 + top = anchorRect.top - containerRect.top - popoverRect.height - 4; 111 + } 112 + 113 + popover.style.top = `${top}px`; 114 + popover.style.left = `${left}px`; 115 + }); 116 + } 117 + 118 + export function renderCalendar( 119 + container: HTMLElement, 120 + currentMonth: Date, 121 + events: CalendarEvent[], 122 + callbacks: CalendarCallbacks, 123 + ): void { 124 + container.empty(); 125 + container.addClass("cal-container"); 126 + 127 + const year = currentMonth.getFullYear(); 128 + const month = currentMonth.getMonth(); 129 + 130 + // Header: < Month Year > 131 + const header = container.createDiv({ cls: "cal-header" }); 132 + const prevBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u2039" }); 133 + prevBtn.addEventListener("click", callbacks.onPrevMonth); 134 + header.createSpan({ cls: "cal-month-label", text: `${MONTH_NAMES[month]} ${year}` }); 135 + const nextBtn = header.createEl("button", { cls: "cal-nav-btn", text: "\u203A" }); 136 + nextBtn.addEventListener("click", callbacks.onNextMonth); 137 + 138 + // Day-of-week row 139 + const dowRow = container.createDiv({ cls: "cal-dow-row" }); 140 + for (const name of DAY_NAMES) { 141 + dowRow.createDiv({ cls: "cal-dow-cell", text: name }); 142 + } 143 + 144 + // Build grid 145 + const grid = container.createDiv({ cls: "cal-grid" }); 146 + const firstOfMonth = new Date(year, month, 1); 147 + const daysInMonth = new Date(year, month + 1, 0).getDate(); 148 + const startOffset = mondayIndex(firstOfMonth); // how many blank cells before day 1 149 + 150 + const dayEvents = eventsByDay(events, year, month); 151 + 152 + // Track active popover so we can remove it 153 + let activePopover: HTMLElement | null = null; 154 + 155 + const removePopover = () => { 156 + if (activePopover) { 157 + activePopover.remove(); 158 + activePopover = null; 159 + } 160 + }; 161 + 162 + // Leading blank cells 163 + for (let i = 0; i < startOffset; i++) { 164 + grid.createDiv({ cls: "cal-cell cal-cell-empty" }); 165 + } 166 + 167 + // Day cells 168 + for (let day = 1; day <= daysInMonth; day++) { 169 + const cell = grid.createDiv({ cls: "cal-cell" }); 170 + cell.createDiv({ cls: "cal-day-number", text: String(day) }); 171 + 172 + const eventsForDay = dayEvents.get(day); 173 + if (eventsForDay) { 174 + cell.addClass("cal-cell-has-events"); 175 + const eventsContainer = cell.createDiv({ cls: "cal-cell-events" }); 176 + 177 + for (const ev of eventsForDay) { 178 + const chip = eventsContainer.createDiv({ 179 + cls: `cal-event-chip${ev.soldOut ? " cal-event-sold-out" : ""}`, 180 + text: ev.title, 181 + }); 182 + 183 + chip.addEventListener("mouseenter", () => { 184 + removePopover(); 185 + activePopover = createPopover(container, ev, chip); 186 + }); 187 + 188 + chip.addEventListener("mouseleave", (e: MouseEvent) => { 189 + // Small delay to allow moving to popover 190 + setTimeout(() => { 191 + if (activePopover && !activePopover.contains(e.relatedTarget as Node)) { 192 + removePopover(); 193 + } 194 + }, 100); 195 + }); 196 + } 197 + } 198 + 199 + // Check if this is today 200 + const now = new Date(); 201 + if (year === now.getFullYear() && month === now.getMonth() && day === now.getDate()) { 202 + cell.addClass("cal-cell-today"); 203 + } 204 + } 205 + 206 + // Trailing blank cells to fill the last row 207 + const totalCells = startOffset + daysInMonth; 208 + const trailingCells = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); 209 + for (let i = 0; i < trailingCells; i++) { 210 + grid.createDiv({ cls: "cal-cell cal-cell-empty" }); 211 + } 212 + 213 + // Dismiss popover when clicking on the container background 214 + container.addEventListener("click", (e: MouseEvent) => { 215 + if (activePopover && !activePopover.contains(e.target as Node)) { 216 + removePopover(); 217 + } 218 + }); 219 + }
+194
styles.css
··· 1 + /* Calendar Viewer - styles.css */ 2 + 3 + .cal-container { 4 + padding: 8px; 5 + font-family: var(--font-interface); 6 + position: relative; 7 + overflow-y: auto; 8 + height: 100%; 9 + } 10 + 11 + /* ── Header ── */ 12 + 13 + .cal-header { 14 + display: flex; 15 + align-items: center; 16 + justify-content: space-between; 17 + margin-bottom: 8px; 18 + padding: 0 4px; 19 + } 20 + 21 + .cal-nav-btn { 22 + background: none; 23 + border: 1px solid var(--background-modifier-border); 24 + border-radius: 4px; 25 + cursor: pointer; 26 + font-size: 18px; 27 + line-height: 1; 28 + padding: 2px 8px; 29 + color: var(--text-normal); 30 + } 31 + 32 + .cal-nav-btn:hover { 33 + background: var(--background-modifier-hover); 34 + } 35 + 36 + .cal-month-label { 37 + font-weight: 600; 38 + font-size: 14px; 39 + color: var(--text-normal); 40 + } 41 + 42 + /* ── Day-of-week row ── */ 43 + 44 + .cal-dow-row { 45 + display: grid; 46 + grid-template-columns: repeat(7, 1fr); 47 + gap: 1px; 48 + margin-bottom: 2px; 49 + } 50 + 51 + .cal-dow-cell { 52 + text-align: center; 53 + font-size: 11px; 54 + font-weight: 600; 55 + color: var(--text-muted); 56 + padding: 2px 0; 57 + } 58 + 59 + /* ── Grid ── */ 60 + 61 + .cal-grid { 62 + display: grid; 63 + grid-template-columns: repeat(7, 1fr); 64 + gap: 1px; 65 + } 66 + 67 + .cal-cell { 68 + min-height: 48px; 69 + border: 1px solid var(--background-modifier-border); 70 + border-radius: 3px; 71 + padding: 2px; 72 + display: flex; 73 + flex-direction: column; 74 + overflow: hidden; 75 + } 76 + 77 + .cal-cell-empty { 78 + border-color: transparent; 79 + } 80 + 81 + .cal-day-number { 82 + font-size: 11px; 83 + color: var(--text-muted); 84 + text-align: right; 85 + padding: 1px 3px 0 0; 86 + line-height: 1.2; 87 + } 88 + 89 + .cal-cell-today { 90 + background: var(--background-modifier-hover); 91 + } 92 + 93 + .cal-cell-today .cal-day-number { 94 + color: var(--text-accent); 95 + font-weight: 700; 96 + } 97 + 98 + .cal-cell-has-events { 99 + background: var(--background-primary); 100 + } 101 + 102 + /* ── Event chips ── */ 103 + 104 + .cal-cell-events { 105 + display: flex; 106 + flex-direction: column; 107 + gap: 1px; 108 + margin-top: 1px; 109 + overflow: hidden; 110 + } 111 + 112 + .cal-event-chip { 113 + font-size: 10px; 114 + line-height: 1.3; 115 + padding: 1px 3px; 116 + border-radius: 2px; 117 + background: var(--interactive-accent); 118 + color: var(--text-on-accent); 119 + white-space: nowrap; 120 + overflow: hidden; 121 + text-overflow: ellipsis; 122 + cursor: pointer; 123 + } 124 + 125 + .cal-event-chip:hover { 126 + opacity: 0.85; 127 + } 128 + 129 + .cal-event-sold-out { 130 + background: var(--text-muted); 131 + opacity: 0.6; 132 + } 133 + 134 + /* ── Popover ── */ 135 + 136 + .cal-popover { 137 + position: absolute; 138 + z-index: 100; 139 + background: var(--background-primary); 140 + border: 1px solid var(--background-modifier-border); 141 + border-radius: 6px; 142 + padding: 10px 12px; 143 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 144 + max-width: 260px; 145 + font-size: 12px; 146 + line-height: 1.5; 147 + } 148 + 149 + .cal-popover-title { 150 + font-weight: 600; 151 + font-size: 13px; 152 + color: var(--text-normal); 153 + margin-bottom: 4px; 154 + } 155 + 156 + .cal-popover-title a { 157 + color: var(--text-accent); 158 + text-decoration: none; 159 + } 160 + 161 + .cal-popover-title a:hover { 162 + text-decoration: underline; 163 + } 164 + 165 + .cal-sold-out { 166 + color: var(--text-muted); 167 + font-weight: 400; 168 + font-size: 11px; 169 + } 170 + 171 + .cal-popover-date { 172 + color: var(--text-muted); 173 + margin-bottom: 4px; 174 + } 175 + 176 + .cal-popover-venue { 177 + color: var(--text-normal); 178 + margin-bottom: 4px; 179 + } 180 + 181 + .cal-popover-venue a { 182 + color: var(--text-accent); 183 + text-decoration: none; 184 + } 185 + 186 + .cal-popover-venue a:hover { 187 + text-decoration: underline; 188 + } 189 + 190 + .cal-popover-notes { 191 + color: var(--text-faint); 192 + font-style: italic; 193 + margin-top: 4px; 194 + }
+17
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "baseUrl": ".", 4 + "inlineSourceMap": true, 5 + "inlineSources": true, 6 + "module": "ESNext", 7 + "target": "ES6", 8 + "allowJs": true, 9 + "noImplicitAny": true, 10 + "moduleResolution": "node", 11 + "importHelpers": true, 12 + "isolatedModules": true, 13 + "strictNullChecks": true, 14 + "lib": ["DOM", "ES5", "ES6", "ES7"] 15 + }, 16 + "include": ["**/*.ts"] 17 + }