Add implementation plan for calendar extraction module#14
Conversation
This plan details a new feature that extracts calendar events from RSS feed articles using OpenAI. Key features include: - Extract both explicit and implicit calendar events from articles - Confidence levels (high/medium/low) for extracted events - New Calendar view in the UI with event list and filtering - Add to Google Calendar / Outlook buttons and .ics download - Integration with existing OpenAI API key and feature flag system - Data mirror support for Obsidian export
Implement AI-powered calendar event extraction from RSS feed articles. Features: - Extract both explicit and implicit calendar events using OpenAI - New Calendar view in sidebar with event list grouped by date - Confidence levels (high/medium/low) for extracted events - Add to Google Calendar, Outlook, or download .ics file - Extract Events button in article toolbar for individual extraction - Batch extraction from all articles via Calendar view - Calendar events exported to data mirror as Markdown files - New calendarExtraction feature flag (opt-in via Settings) Backend changes: - Add calendar-events.json storage with IPC handlers - Add calendarExtraction to feature flags - Add calendarEventToMarkdown helper for data mirror Frontend changes: - Calendar section in sidebar with event count badge - Calendar event list with date grouping and filtering - Event detail view with calendar integration buttons - Extract Events button in toolbar when viewing articles
|
@copilot generate screenshots of the UI affected by this PR, using either your native Playwright ability (preferred) or the in-repo screenshot helper. Post them in a reply to the issue (preferred) or somewhere else that I am able to view them you are unable to post them here. |
There was a problem hiding this comment.
Pull request overview
This pull request adds a comprehensive calendar extraction feature that uses OpenAI to automatically identify and extract event information from RSS feed articles. The implementation includes a dedicated Calendar view in the UI with event filtering by confidence level, calendar export capabilities (Google Calendar, Outlook, .ics download), and integration with the existing data mirror for Obsidian export.
Key Changes:
- New calendar extraction module using OpenAI API to identify both explicit and implicit events from articles with confidence scoring (high/medium/low)
- Calendar view UI with event list grouped by time periods, filtering controls, and detailed event display
- Calendar export functionality supporting Google Calendar, Outlook Calendar, and .ics file downloads
- Data mirror integration to export calendar events as Markdown files organized by month
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 17 comments.
| File | Description |
|---|---|
| PLAN-calendar-extraction.md | Planning document detailing the calendar extraction feature architecture, data model, implementation phases, and UI mockups |
| preload.js | Exposes new IPC methods for calendar event operations (read, write, delete, clear past events) |
| main.js | Implements IPC handlers for calendar data storage, adds calendar event Markdown export for data mirror, and integrates calendar extraction into feature flags |
| index.html | Adds comprehensive calendar UI including styles, event list/detail views, extraction logic with OpenAI integration, calendar link generation, and filtering capabilities |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const filteredEvents = calendarConfidenceFilter === 'all' | ||
| ? calendarEvents | ||
| : calendarEvents.filter(e => e.confidence === calendarConfidenceFilter); |
There was a problem hiding this comment.
The filter dropdown has an option for 'Medium+' which suggests it should show both medium and high confidence events, but the filtering logic only checks for exact matches when calendarConfidenceFilter is not 'all'. This means selecting 'medium' will only show medium confidence events, not medium and high. The filter implementation doesn't match the expected behavior implied by the label.
| const filteredEvents = calendarConfidenceFilter === 'all' | |
| ? calendarEvents | |
| : calendarEvents.filter(e => e.confidence === calendarConfidenceFilter); | |
| let filteredEvents; | |
| if (calendarConfidenceFilter === 'all') { | |
| filteredEvents = calendarEvents; | |
| } else if (calendarConfidenceFilter === 'medium') { | |
| // "Medium+" should include both medium and high confidence events | |
| filteredEvents = calendarEvents.filter(e => e.confidence === 'medium' || e.confidence === 'high'); | |
| } else { | |
| // For "high" and any other specific filters, use exact match | |
| filteredEvents = calendarEvents.filter(e => e.confidence === calendarConfidenceFilter); | |
| } |
| readCalendarEvents: () => ipcRenderer.invoke('read-calendar-events'), | ||
| writeCalendarEvents: (calendarData) => ipcRenderer.invoke('write-calendar-events', calendarData), | ||
| deleteCalendarEvent: (eventId) => ipcRenderer.invoke('delete-calendar-event', eventId), | ||
| clearPastCalendarEvents: () => ipcRenderer.invoke('clear-past-calendar-events'), |
There was a problem hiding this comment.
The handler name is inconsistent with the IPC handler defined in main.js. In main.js (line 986), the handler is named 'clear-past-calendar-events', but this preload method is named 'clearPastCalendarEvents'. While this is the correct camelCase convention for JavaScript, there's a mismatch in terminology: the main.js handler filters events to keep future ones, but the name suggests clearing past ones. The functionality is correct, but the naming could be clearer.
| function generateGoogleCalendarUrl(event) { | ||
| const baseUrl = 'https://calendar.google.com/calendar/render?action=TEMPLATE'; | ||
| const params = new URLSearchParams(); | ||
|
|
||
| params.set('text', event.title || 'Event'); | ||
|
|
||
| if (event.startDate) { | ||
| const start = new Date(event.startDate); | ||
| const end = event.endDate ? new Date(event.endDate) : new Date(start.getTime() + 60 * 60 * 1000); | ||
|
|
||
| if (event.isAllDay) { | ||
| // All-day events use YYYYMMDD format | ||
| const startStr = start.toISOString().split('T')[0].replace(/-/g, ''); | ||
| const endDate = new Date(end); | ||
| endDate.setDate(endDate.getDate() + 1); | ||
| const endStr = endDate.toISOString().split('T')[0].replace(/-/g, ''); | ||
| params.set('dates', `${startStr}/${endStr}`); | ||
| } else { | ||
| // Timed events use full ISO format without special chars | ||
| const startStr = start.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; | ||
| const endStr = end.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; | ||
| params.set('dates', `${startStr}/${endStr}`); | ||
| } | ||
| } | ||
|
|
||
| if (event.description) { | ||
| let details = event.description; | ||
| if (event.sourceArticle && event.sourceArticle.link) { | ||
| details += `\n\nSource: ${event.sourceArticle.link}`; | ||
| } | ||
| params.set('details', details); | ||
| } | ||
|
|
||
| if (event.location) { | ||
| params.set('location', event.location); | ||
| } | ||
|
|
||
| return `${baseUrl}&${params.toString()}`; | ||
| } | ||
|
|
||
| // Generate Outlook Calendar URL | ||
| function generateOutlookCalendarUrl(event) { | ||
| const baseUrl = 'https://outlook.office.com/calendar/0/deeplink/compose'; | ||
| const params = new URLSearchParams(); | ||
|
|
||
| params.set('subject', event.title || 'Event'); | ||
| params.set('path', '/calendar/action/compose'); | ||
|
|
||
| if (event.startDate) { | ||
| params.set('startdt', event.startDate); | ||
| if (event.endDate) { | ||
| params.set('enddt', event.endDate); | ||
| } | ||
| } | ||
|
|
||
| if (event.description) { | ||
| let body = event.description; | ||
| if (event.sourceArticle && event.sourceArticle.link) { | ||
| body += `\n\nSource: ${event.sourceArticle.link}`; | ||
| } | ||
| params.set('body', body); | ||
| } | ||
|
|
||
| if (event.location) { | ||
| params.set('location', event.location); | ||
| } | ||
|
|
||
| if (event.isAllDay) { | ||
| params.set('allday', 'true'); | ||
| } | ||
|
|
||
| return `${baseUrl}?${params.toString()}`; | ||
| } | ||
|
|
||
| // Generate ICS file content | ||
| function generateICSContent(event) { | ||
| const formatDate = (dateStr, isAllDay) => { | ||
| const date = new Date(dateStr); | ||
| if (isAllDay) { | ||
| return date.toISOString().split('T')[0].replace(/-/g, ''); | ||
| } | ||
| return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; | ||
| }; | ||
|
|
||
| const escapeICS = (str) => { | ||
| if (!str) return ''; | ||
| return str.replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n'); | ||
| }; | ||
|
|
||
| const start = event.startDate ? formatDate(event.startDate, event.isAllDay) : ''; | ||
| const end = event.endDate | ||
| ? formatDate(event.endDate, event.isAllDay) | ||
| : (event.startDate ? formatDate(new Date(new Date(event.startDate).getTime() + 60 * 60 * 1000).toISOString(), event.isAllDay) : ''); | ||
|
|
||
| let description = event.description || ''; | ||
| if (event.sourceArticle && event.sourceArticle.link) { | ||
| description += `\n\nSource: ${event.sourceArticle.link}`; | ||
| } | ||
|
|
||
| const lines = [ | ||
| 'BEGIN:VCALENDAR', | ||
| 'VERSION:2.0', | ||
| 'PRODID:-//RSS Reader//Calendar Event//EN', | ||
| 'BEGIN:VEVENT', | ||
| `UID:${event.id}@rss-reader`, | ||
| `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').split('.')[0]}Z`, | ||
| event.isAllDay ? `DTSTART;VALUE=DATE:${start}` : `DTSTART:${start}`, | ||
| event.isAllDay ? `DTEND;VALUE=DATE:${end}` : `DTEND:${end}`, | ||
| `SUMMARY:${escapeICS(event.title)}`, | ||
| description ? `DESCRIPTION:${escapeICS(description)}` : '', | ||
| event.location ? `LOCATION:${escapeICS(event.location)}` : '', | ||
| 'END:VEVENT', | ||
| 'END:VCALENDAR' | ||
| ].filter(Boolean); | ||
|
|
||
| return lines.join('\r\n'); | ||
| } |
There was a problem hiding this comment.
The calendar event generation functions generateGoogleCalendarUrl, generateOutlookCalendarUrl, and generateICSContent all have duplicate logic for handling all-day events and calculating end dates when not provided. This violates the DRY principle and makes the code harder to maintain. Consider extracting this common date formatting logic into a shared helper function.
| if (!e.startDate) return true; // Keep events without dates | ||
| const eventDate = new Date(e.startDate); | ||
| return eventDate >= now; |
There was a problem hiding this comment.
The date comparison logic here has a potential issue. When comparing dates to filter out past events, this only checks if the start date is greater than or equal to "now". However, events with an end date that has passed but a start date in the future would be incorrectly kept. For multi-day events, you should check the end date if it exists, otherwise fall back to the start date.
| if (!e.startDate) return true; // Keep events without dates | |
| const eventDate = new Date(e.startDate); | |
| return eventDate >= now; | |
| // Keep events without any date information | |
| if (!e.startDate && !e.endDate) return true; | |
| // For multi-day events, prefer endDate; otherwise fall back to startDate | |
| const comparisonSource = e.endDate || e.startDate; | |
| const comparisonDate = new Date(comparisonSource); | |
| // If the date is invalid, keep the event to avoid accidental deletion | |
| if (isNaN(comparisonDate.getTime())) return true; | |
| // Remove events that have fully ended in the past | |
| return comparisonDate >= now; |
| if (!e.startDate) return true; | ||
| return new Date(e.startDate) >= now; |
There was a problem hiding this comment.
The filtering logic doesn't properly handle multi-day events that may have already started but haven't ended yet. An event with a start date in the past but an end date in the future would be filtered out. Consider checking the end date if it exists, and only filter out events where both start and end dates are in the past.
| if (!e.startDate) return true; | |
| return new Date(e.startDate) >= now; | |
| const start = e.startDate ? new Date(e.startDate) : null; | |
| const end = e.endDate ? new Date(e.endDate) : null; | |
| // If no dates are available, keep the event (Date TBD) | |
| if (!start && !end) return true; | |
| // If start is in the future, keep the event | |
| if (start && start >= now) return true; | |
| // If end exists and is in the future, the event is ongoing, keep it | |
| if (end && end >= now) return true; | |
| // Otherwise, both dates (if present) are in the past; filter it out | |
| return false; |
| if (eventDate.getTime() === today.getTime()) { | ||
| groups.today.push(event); | ||
| } else if (eventDate.getTime() === tomorrow.getTime()) { | ||
| groups.tomorrow.push(event); | ||
| } else if (eventDate < nextWeek) { | ||
| groups.thisWeek.push(event); | ||
| } else if (eventDate < nextMonth) { |
There was a problem hiding this comment.
The grouping logic for 'thisWeek' has an edge case issue. An event happening exactly 7 days from now will be placed in 'thisWeek' due to the less-than comparison, but logically it might be considered 'next week'. Additionally, the comparison 'eventDate < nextWeek' doesn't account for events that fall between 'tomorrow' and 'nextWeek' boundary correctly - an event 2 days away could be in either 'thisWeek' or 'tomorrow' depending on the exact time. Consider using more explicit date range checks.
| <button class="calendar-btn calendar-btn-ics" onclick="downloadICS(calendarEvents.find(e => e.id === '${event.id}'))"> | ||
| <span>⬇️</span> Download .ics | ||
| </button> | ||
| </div> | ||
| </div> | ||
|
|
There was a problem hiding this comment.
The calendarEvents variable is being accessed from the global scope within an inline onclick handler. This creates tight coupling and makes the code harder to maintain. Additionally, the downloadICS function is called but not defined in the visible scope, which could cause a runtime error if it's not properly exposed to the global scope.
| <button class="calendar-btn calendar-btn-ics" onclick="downloadICS(calendarEvents.find(e => e.id === '${event.id}'))"> | |
| <span>⬇️</span> Download .ics | |
| </button> | |
| </div> | |
| </div> | |
| <button class="calendar-btn calendar-btn-ics" onclick="handleDownloadIcs('${event.id}')"> | |
| <span>⬇️</span> Download .ics | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| function handleDownloadIcs(eventId) { | |
| const events = window.calendarEvents; | |
| const downloadFn = window.downloadICS; | |
| if (!Array.isArray(events)) { | |
| console.error('calendarEvents is not available or not an array.'); | |
| return; | |
| } | |
| if (typeof downloadFn !== 'function') { | |
| console.error('downloadICS function is not available on window.'); | |
| return; | |
| } | |
| const event = events.find(e => e && e.id === eventId); | |
| if (!event) { | |
| console.error('Event not found for id:', eventId); | |
| return; | |
| } | |
| downloadFn(event); | |
| } | |
| </script> |
| a.click(); | ||
| document.body.removeChild(a); | ||
| URL.revokeObjectURL(url); | ||
| } |
There was a problem hiding this comment.
The function downloadICS is defined later in the code (line 3080) but is referenced in an inline onclick handler (line 3264). For this to work, downloadICS must be exposed to the global scope. However, the function is defined within the script's scope and won't be accessible from the inline handler unless explicitly attached to the window object. This will cause a runtime error when the button is clicked.
| } | |
| } | |
| // Expose downloadICS for inline onclick handlers | |
| window.downloadICS = downloadICS; |
| <a href="${outlookUrl}" target="_blank" rel="noopener noreferrer" class="calendar-btn calendar-btn-outlook"> | ||
| <span>📅</span> Outlook | ||
| </a> | ||
| <button class="calendar-btn calendar-btn-ics" onclick="downloadICS(calendarEvents.find(e => e.id === '${event.id}'))"> |
There was a problem hiding this comment.
The inline onclick handler poses the same security concern as line 3276. The event.id is directly interpolated into the onclick attribute. Consider using addEventListener instead of inline onclick handlers for better security and separation of concerns.
| // Parse JSON response | ||
| const jsonMatch = content.match(/\[[\s\S]*\]/); | ||
| if (!jsonMatch) { | ||
| return { events: [] }; | ||
| } | ||
|
|
||
| const events = JSON.parse(jsonMatch[0]); | ||
|
|
There was a problem hiding this comment.
The prompt instructs the AI to return "ONLY a JSON array (no markdown, no explanation)" but then the parsing code attempts to extract JSON using a regex match on the content. This approach is fragile because if the AI returns the JSON wrapped in markdown code blocks (which is common), the regex will match the entire block. Consider either being more strict in the prompt or using more robust JSON extraction that handles markdown code blocks.
| // Parse JSON response | |
| const jsonMatch = content.match(/\[[\s\S]*\]/); | |
| if (!jsonMatch) { | |
| return { events: [] }; | |
| } | |
| const events = JSON.parse(jsonMatch[0]); | |
| // Parse JSON response robustly, handling possible markdown code fences | |
| if (!content || typeof content !== 'string') { | |
| return { events: [] }; | |
| } | |
| let events = []; | |
| let jsonText = content.trim(); | |
| // 1. Try parsing the whole content as JSON | |
| try { | |
| const parsed = JSON.parse(jsonText); | |
| if (Array.isArray(parsed)) { | |
| events = parsed; | |
| } else if (parsed && Array.isArray(parsed.events)) { | |
| events = parsed.events; | |
| } | |
| } catch (e) { | |
| // Ignore and try other strategies | |
| } | |
| // 2. If that failed, look for a fenced code block ```json ... ``` or ``` ... ``` | |
| if (events.length === 0) { | |
| const codeBlockMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/i); | |
| if (codeBlockMatch && codeBlockMatch[1]) { | |
| const fencedText = codeBlockMatch[1].trim(); | |
| try { | |
| const parsed = JSON.parse(fencedText); | |
| if (Array.isArray(parsed)) { | |
| events = parsed; | |
| } else if (parsed && Array.isArray(parsed.events)) { | |
| events = parsed.events; | |
| } | |
| } catch (e) { | |
| // Ignore and try final fallback | |
| } | |
| } | |
| } | |
| // 3. Final fallback: extract the first JSON array substring | |
| if (events.length === 0) { | |
| const arrayMatch = jsonText.match(/\[[\s\S]*\]/); | |
| if (!arrayMatch) { | |
| return { events: [] }; | |
| } | |
| try { | |
| const parsed = JSON.parse(arrayMatch[0]); | |
| if (Array.isArray(parsed)) { | |
| events = parsed; | |
| } else if (parsed && Array.isArray(parsed.events)) { | |
| events = parsed.events; | |
| } | |
| } catch (e) { | |
| return { events: [] }; | |
| } | |
| } | |
| // If still nothing usable, return empty | |
| if (!Array.isArray(events) || events.length === 0) { | |
| return { events: [] }; | |
| } |
The AI was incorrectly interpreting dates relative to "today" instead of the article's publication date. This caused events from older articles to be assigned incorrect years (e.g., 2026 instead of 2025). Changes: - Extract article publication year from pubDate - Update prompt to emphasize using article date as reference point - Add explicit rules for year interpretation - Handle cases where article title contains year (e.g., "February 2025 Recap")
Move electron-updater import to lazy loading to prevent accessing app.getVersion() before Electron app is ready. This fixes the TypeError that was preventing the app from starting.
This plan details a new feature that extracts calendar events from RSS
feed articles using OpenAI. Key features include: