Skip to content

Add implementation plan for calendar extraction module#14

Merged
busse merged 4 commits intomainfrom
claude/add-calendar-extraction-s6kgf
Jan 5, 2026
Merged

Add implementation plan for calendar extraction module#14
busse merged 4 commits intomainfrom
claude/add-calendar-extraction-s6kgf

Conversation

@busse
Copy link
Copy Markdown
Owner

@busse busse commented Jan 2, 2026

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

claude added 2 commits January 2, 2026 12:56
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
@busse
Copy link
Copy Markdown
Owner Author

busse commented Jan 2, 2026

@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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread index.html
Comment on lines +3096 to +3098
const filteredEvents = calendarConfidenceFilter === 'all'
? calendarEvents
: calendarEvents.filter(e => e.confidence === calendarConfidenceFilter);
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);
}

Copilot uses AI. Check for mistakes.
Comment thread preload.js
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'),
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread index.html
Comment on lines +2961 to +3077
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');
}
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread main.js
Comment on lines +999 to +1001
if (!e.startDate) return true; // Keep events without dates
const eventDate = new Date(e.startDate);
return eventDate >= now;
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment thread index.html
Comment on lines +3103 to +3104
if (!e.startDate) return true;
return new Date(e.startDate) >= now;
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment thread index.html
Comment on lines +2944 to +2950
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) {
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread index.html
Comment on lines +3264 to +3269
<button class="calendar-btn calendar-btn-ics" onclick="downloadICS(calendarEvents.find(e => e.id === '${event.id}'))">
<span>⬇️</span> Download .ics
</button>
</div>
</div>

Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
<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>

Copilot uses AI. Check for mistakes.
Comment thread index.html
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
}
// Expose downloadICS for inline onclick handlers
window.downloadICS = downloadICS;

Copilot uses AI. Check for mistakes.
Comment thread index.html
<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}'))">
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread index.html
Comment on lines +2817 to +2824
// Parse JSON response
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
return { events: [] };
}

const events = JSON.parse(jsonMatch[0]);

Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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: [] };
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI commented Jan 2, 2026

@busse I've opened a new pull request, #15, to work on those changes. Once the pull request is ready, I'll request review from you.

claude and others added 2 commits January 4, 2026 23:35
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.
@busse busse merged commit 895e594 into main Jan 5, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants