mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
feat: sync recurring tasks to Google Calendar as recurring events
Scheduled-based recurring tasks now sync to Google Calendar as native recurring events with RRULE support instead of single events. - Add rruleConverter utility to convert TaskNotes RRULE format to Google Calendar recurrence array format (RRULE + EXDATE entries) - Extend GoogleCalendarService.createEvent/updateEvent with recurrence parameter support - Add shouldSyncAsRecurring() helper to detect eligible recurring tasks - Update taskToCalendarEvent() to include recurrence data with DTSTART extraction and EXDATE generation for completed/skipped instances - Modify completeTaskInCalendar() to update EXDATE instead of adding checkmark for recurring tasks - Add updateRecurringEventExdates() for efficient instance exclusion Completion-based recurring tasks (recurrence_anchor='completion') continue to sync as single events since their DTSTART shifts on each completion, which doesn't map well to Google Calendar.
This commit is contained in:
parent
428025d6d4
commit
a24d7aaee6
6 changed files with 750 additions and 1 deletions
|
|
@ -52,6 +52,8 @@ Example:
|
|||
- Default reminder setting for popup notifications
|
||||
- Bulk sync and unlink actions in settings
|
||||
- Task-event linking stored in frontmatter (`googleCalendarEventId`)
|
||||
- Recurring tasks with scheduled anchor sync as Google Calendar recurring events with RRULE
|
||||
- Completed/skipped instances automatically excluded via EXDATE
|
||||
- Thanks to @someromans and @Leonard-44 for requesting this feature, and @dmantisk, @farangkao, @rayvermey, and @rdpr for their input
|
||||
|
||||
## Fixed
|
||||
|
|
|
|||
|
|
@ -562,6 +562,7 @@ export class GoogleCalendarService extends CalendarProvider {
|
|||
overrides?: Array<{ method: string; minutes: number }>;
|
||||
};
|
||||
colorId?: string;
|
||||
recurrence?: string[];
|
||||
}
|
||||
): Promise<ICSEvent> {
|
||||
// Validate inputs
|
||||
|
|
@ -605,6 +606,9 @@ export class GoogleCalendarService extends CalendarProvider {
|
|||
if (updates.colorId !== undefined) {
|
||||
payload.colorId = updates.colorId;
|
||||
}
|
||||
if (updates.recurrence !== undefined) {
|
||||
payload.recurrence = updates.recurrence;
|
||||
}
|
||||
|
||||
// Handle start/end updates
|
||||
if (updates.start !== undefined) {
|
||||
|
|
@ -711,6 +715,7 @@ export class GoogleCalendarService extends CalendarProvider {
|
|||
overrides?: Array<{ method: string; minutes: number }>;
|
||||
};
|
||||
colorId?: string;
|
||||
recurrence?: string[];
|
||||
}
|
||||
): Promise<ICSEvent> {
|
||||
// Validate inputs
|
||||
|
|
@ -743,6 +748,11 @@ export class GoogleCalendarService extends CalendarProvider {
|
|||
payload.colorId = event.colorId;
|
||||
}
|
||||
|
||||
// Add recurrence rules if provided (for recurring events)
|
||||
if (event.recurrence && event.recurrence.length > 0) {
|
||||
payload.recurrence = event.recurrence;
|
||||
}
|
||||
|
||||
// Handle start/end - could be string or object
|
||||
if (typeof event.start === 'string') {
|
||||
// Determine if all-day based on format (YYYY-MM-DD vs YYYY-MM-DDTHH:mm:ss)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { format } from "date-fns";
|
|||
import TaskNotesPlugin from "../main";
|
||||
import { GoogleCalendarService } from "./GoogleCalendarService";
|
||||
import { TaskInfo } from "../types";
|
||||
import { convertToGoogleRecurrence } from "../utils/rruleConverter";
|
||||
|
||||
/** Debounce delay for rapid task updates (ms) */
|
||||
const SYNC_DEBOUNCE_MS = 500;
|
||||
|
|
@ -108,6 +109,22 @@ export class TaskCalendarSyncService {
|
|||
return task.googleCalendarEventId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a task should be synced as a Google Calendar recurring event.
|
||||
* Only scheduled-based recurring tasks are synced as recurring events.
|
||||
* Completion-based recurring tasks remain as single events (since their
|
||||
* DTSTART shifts on each completion, which doesn't map well to Google Calendar).
|
||||
*/
|
||||
private shouldSyncAsRecurring(task: TaskInfo): boolean {
|
||||
// Must have a recurrence rule
|
||||
if (!task.recurrence) return false;
|
||||
|
||||
// Only scheduled-based recurrence syncs as recurring events
|
||||
// Completion-based recurrence stays as single events (existing behavior)
|
||||
const anchor = task.recurrence_anchor || "scheduled";
|
||||
return anchor === "scheduled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the Google Calendar event ID to the task's frontmatter
|
||||
*/
|
||||
|
|
@ -328,6 +345,7 @@ export class TaskCalendarSyncService {
|
|||
useDefault: boolean;
|
||||
overrides?: Array<{ method: string; minutes: number }>;
|
||||
};
|
||||
recurrence?: string[];
|
||||
} | null {
|
||||
const eventDate = this.getEventDate(task);
|
||||
if (!eventDate) return null;
|
||||
|
|
@ -368,6 +386,7 @@ export class TaskCalendarSyncService {
|
|||
useDefault: boolean;
|
||||
overrides?: Array<{ method: string; minutes: number }>;
|
||||
};
|
||||
recurrence?: string[];
|
||||
} = {
|
||||
summary: this.applyTitleTemplate(task),
|
||||
start,
|
||||
|
|
@ -390,6 +409,44 @@ export class TaskCalendarSyncService {
|
|||
};
|
||||
}
|
||||
|
||||
// Add recurrence rules for scheduled-based recurring tasks
|
||||
if (this.shouldSyncAsRecurring(task) && task.recurrence) {
|
||||
const recurrenceData = convertToGoogleRecurrence(task.recurrence, {
|
||||
completedInstances: task.complete_instances,
|
||||
skippedInstances: task.skipped_instances,
|
||||
});
|
||||
|
||||
if (recurrenceData) {
|
||||
event.recurrence = recurrenceData.recurrence;
|
||||
|
||||
// Override start date with DTSTART from recurrence rule
|
||||
// This ensures the recurring event starts from the correct date
|
||||
if (recurrenceData.dtstart) {
|
||||
if (settings.createAsAllDay || !recurrenceData.hasTime) {
|
||||
event.start = { date: recurrenceData.dtstart };
|
||||
// Recalculate end for all-day event
|
||||
const endDate = new Date(recurrenceData.dtstart + "T00:00:00");
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
event.end = { date: format(endDate, "yyyy-MM-dd") };
|
||||
} else if (recurrenceData.time) {
|
||||
const dateTimeStr = `${recurrenceData.dtstart}T${recurrenceData.time}`;
|
||||
const startDate = new Date(dateTimeStr);
|
||||
event.start = {
|
||||
dateTime: startDate.toISOString(),
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
// Recalculate end based on duration
|
||||
const duration = task.timeEstimate || settings.defaultEventDuration;
|
||||
const endDate = new Date(startDate.getTime() + duration * 60 * 1000);
|
||||
event.end = {
|
||||
dateTime: endDate.toISOString(),
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
|
|
@ -526,7 +583,9 @@ export class TaskCalendarSyncService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handle task completion - update the calendar event
|
||||
* Handle task completion - update the calendar event.
|
||||
* For recurring tasks, updates the EXDATE list to exclude the completed instance.
|
||||
* For non-recurring tasks, adds a checkmark to the event title.
|
||||
*/
|
||||
async completeTaskInCalendar(task: TaskInfo): Promise<void> {
|
||||
if (!this.plugin.settings.googleCalendarExport.syncOnTaskComplete) {
|
||||
|
|
@ -539,6 +598,12 @@ export class TaskCalendarSyncService {
|
|||
return;
|
||||
}
|
||||
|
||||
// For recurring tasks, update EXDATE to exclude completed instance
|
||||
if (this.shouldSyncAsRecurring(task)) {
|
||||
await this.updateRecurringEventExdates(task);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the event title to indicate completion
|
||||
const completedTitle = `✓ ${this.applyTitleTemplate(task)}`;
|
||||
|
|
@ -564,6 +629,42 @@ export class TaskCalendarSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a recurring event's EXDATE list when an instance is completed or skipped.
|
||||
* This adds EXDATE entries for completed/skipped instances to hide them from the calendar.
|
||||
*/
|
||||
private async updateRecurringEventExdates(task: TaskInfo): Promise<void> {
|
||||
if (!this.shouldSyncAsRecurring(task) || !task.recurrence) return;
|
||||
|
||||
const settings = this.plugin.settings.googleCalendarExport;
|
||||
const eventId = this.getTaskEventId(task);
|
||||
if (!eventId) return;
|
||||
|
||||
try {
|
||||
const recurrenceData = convertToGoogleRecurrence(task.recurrence, {
|
||||
completedInstances: task.complete_instances,
|
||||
skippedInstances: task.skipped_instances,
|
||||
});
|
||||
|
||||
if (recurrenceData) {
|
||||
await this.googleCalendarService.updateEvent(
|
||||
settings.targetCalendarId,
|
||||
eventId,
|
||||
{ recurrence: recurrenceData.recurrence }
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.status === 404) {
|
||||
// Event was deleted externally, clean up the link
|
||||
await this.removeTaskEventId(task.path);
|
||||
return;
|
||||
}
|
||||
console.error("[TaskCalendarSync] Failed to update recurring event EXDATEs:", task.path, error);
|
||||
// Fall back to full resync
|
||||
await this.syncTaskToCalendar(task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a task's calendar event
|
||||
*/
|
||||
|
|
|
|||
150
src/utils/rruleConverter.ts
Normal file
150
src/utils/rruleConverter.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Utility for converting TaskNotes RRULE format to Google Calendar recurrence format.
|
||||
*
|
||||
* TaskNotes format: "DTSTART:YYYYMMDD;FREQ=DAILY;INTERVAL=1;BYDAY=MO,TU"
|
||||
* Google format: ["RRULE:FREQ=DAILY;INTERVAL=1;BYDAY=MO,TU", "EXDATE:20240115"]
|
||||
*
|
||||
* Key differences:
|
||||
* 1. DTSTART is NOT included in Google Calendar recurrence array (separate start field)
|
||||
* 2. EXDATE rules are separate entries in the array
|
||||
* 3. Google expects "RRULE:" prefix for recurrence rules
|
||||
*/
|
||||
|
||||
export interface GoogleRecurrenceData {
|
||||
/** Recurrence array for Google Calendar API */
|
||||
recurrence: string[];
|
||||
/** Extracted DTSTART date in YYYY-MM-DD format */
|
||||
dtstart: string;
|
||||
/** Whether the event has a time component */
|
||||
hasTime: boolean;
|
||||
/** Extracted time in HH:MM:SS format if present */
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface ConversionOptions {
|
||||
/** Completed instances to exclude via EXDATE (YYYY-MM-DD format) */
|
||||
completedInstances?: string[];
|
||||
/** Skipped instances to exclude via EXDATE (YYYY-MM-DD format) */
|
||||
skippedInstances?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts TaskNotes RRULE to Google Calendar recurrence format.
|
||||
*
|
||||
* @param tasknotesRrule - TaskNotes RRULE string (with embedded DTSTART)
|
||||
* @param options - Optional completed/skipped instances to convert to EXDATE
|
||||
* @returns Google Calendar recurrence data or null if invalid
|
||||
*/
|
||||
export function convertToGoogleRecurrence(
|
||||
tasknotesRrule: string,
|
||||
options?: ConversionOptions
|
||||
): GoogleRecurrenceData | null {
|
||||
if (!tasknotesRrule) return null;
|
||||
|
||||
// Extract DTSTART from the rule
|
||||
// Supports both date-only (YYYYMMDD) and datetime (YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ)
|
||||
const dtstartMatch = tasknotesRrule.match(/DTSTART:(\d{8})(T(\d{6})Z?)?;?/);
|
||||
if (!dtstartMatch) return null;
|
||||
|
||||
const dateStr = dtstartMatch[1]; // YYYYMMDD
|
||||
const timeStr = dtstartMatch[3]; // HHMMSS or undefined
|
||||
const hasTime = !!timeStr;
|
||||
|
||||
// Convert YYYYMMDD to YYYY-MM-DD
|
||||
const dtstart = `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`;
|
||||
|
||||
// Convert HHMMSS to HH:MM:SS if present
|
||||
const time = timeStr
|
||||
? `${timeStr.slice(0, 2)}:${timeStr.slice(2, 4)}:${timeStr.slice(4, 6)}`
|
||||
: undefined;
|
||||
|
||||
// Remove DTSTART from the RRULE and add RRULE: prefix
|
||||
const rruleWithoutDtstart = tasknotesRrule
|
||||
.replace(/DTSTART:\d{8}(T\d{6}Z?)?;?/, "")
|
||||
.trim();
|
||||
|
||||
// Validate that we have a meaningful RRULE
|
||||
if (!rruleWithoutDtstart || !rruleWithoutDtstart.includes("FREQ=")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the recurrence array
|
||||
const recurrence: string[] = [`RRULE:${rruleWithoutDtstart}`];
|
||||
|
||||
// Add EXDATE entries for completed and skipped instances
|
||||
const exdates = formatExdates([
|
||||
...(options?.completedInstances || []),
|
||||
...(options?.skippedInstances || []),
|
||||
]);
|
||||
recurrence.push(...exdates);
|
||||
|
||||
return {
|
||||
recurrence,
|
||||
dtstart,
|
||||
hasTime,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats date strings as EXDATE entries for Google Calendar.
|
||||
*
|
||||
* @param dates - Array of dates in YYYY-MM-DD format
|
||||
* @returns Array of EXDATE strings in YYYYMMDD format
|
||||
*/
|
||||
export function formatExdates(dates: string[]): string[] {
|
||||
if (!dates || dates.length === 0) return [];
|
||||
|
||||
return dates
|
||||
.filter((date) => date && /^\d{4}-\d{2}-\d{2}$/.test(date))
|
||||
.map((date) => {
|
||||
// Convert YYYY-MM-DD to YYYYMMDD
|
||||
const compact = date.replace(/-/g, "");
|
||||
return `EXDATE:${compact}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an RRULE is compatible with Google Calendar.
|
||||
* Google Calendar supports a subset of RFC 5545.
|
||||
*
|
||||
* @param rrule - RRULE string to validate
|
||||
* @returns true if compatible with Google Calendar
|
||||
*/
|
||||
export function isGoogleCompatibleRrule(rrule: string): boolean {
|
||||
if (!rrule) return false;
|
||||
|
||||
// Must have FREQ component
|
||||
if (!rrule.includes("FREQ=")) return false;
|
||||
|
||||
// Check for supported FREQ values
|
||||
const freqMatch = rrule.match(/FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/);
|
||||
if (!freqMatch) return false;
|
||||
|
||||
// Google Calendar doesn't support these (uncommon) RFC 5545 features:
|
||||
// - BYSECOND, BYMINUTE (too granular)
|
||||
// - BYHOUR (rarely used)
|
||||
// These would silently be ignored or cause issues
|
||||
const unsupportedPatterns = [/BYSECOND=/, /BYMINUTE=/, /BYHOUR=/];
|
||||
for (const pattern of unsupportedPatterns) {
|
||||
if (pattern.test(rrule)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the DTSTART from a TaskNotes RRULE string.
|
||||
*
|
||||
* @param rrule - TaskNotes RRULE string
|
||||
* @returns DTSTART in YYYY-MM-DD format, or null if not found
|
||||
*/
|
||||
export function extractDTSTART(rrule: string): string | null {
|
||||
if (!rrule) return null;
|
||||
|
||||
const match = rrule.match(/DTSTART:(\d{8})/);
|
||||
if (!match) return null;
|
||||
|
||||
const dateStr = match[1];
|
||||
return `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`;
|
||||
}
|
||||
|
|
@ -299,6 +299,77 @@ describe('GoogleCalendarService', () => {
|
|||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should create a recurring event with RRULE', async () => {
|
||||
const newEvent = {
|
||||
title: 'Daily Standup',
|
||||
start: '2025-10-23',
|
||||
end: '2025-10-24',
|
||||
isAllDay: true,
|
||||
recurrence: ['RRULE:FREQ=DAILY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR']
|
||||
};
|
||||
|
||||
mockRequestUrl.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'recurring-event-id',
|
||||
summary: newEvent.title,
|
||||
start: { date: newEvent.start },
|
||||
end: { date: newEvent.end },
|
||||
recurrence: newEvent.recurrence,
|
||||
htmlLink: 'https://calendar.google.com/event'
|
||||
},
|
||||
text: '',
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {}
|
||||
});
|
||||
|
||||
const created = await service.createEvent('primary', newEvent);
|
||||
|
||||
expect(created.id).toContain('recurring-event-id');
|
||||
expect(mockRequestUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('"recurrence":["RRULE:FREQ=DAILY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR"]')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should create a recurring event with EXDATE', async () => {
|
||||
const newEvent = {
|
||||
title: 'Weekly Team Sync',
|
||||
start: '2025-10-20',
|
||||
end: '2025-10-21',
|
||||
isAllDay: true,
|
||||
recurrence: [
|
||||
'RRULE:FREQ=WEEKLY;BYDAY=MO',
|
||||
'EXDATE:20251027',
|
||||
'EXDATE:20251103'
|
||||
]
|
||||
};
|
||||
|
||||
mockRequestUrl.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'recurring-event-with-exceptions',
|
||||
summary: newEvent.title,
|
||||
start: { date: newEvent.start },
|
||||
end: { date: newEvent.end },
|
||||
recurrence: newEvent.recurrence,
|
||||
htmlLink: 'https://calendar.google.com/event'
|
||||
},
|
||||
text: '',
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {}
|
||||
});
|
||||
|
||||
const created = await service.createEvent('primary', newEvent);
|
||||
|
||||
expect(created.id).toContain('recurring-event-with-exceptions');
|
||||
const requestBody = mockRequestUrl.mock.calls[0][0].body as string;
|
||||
expect(requestBody).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO');
|
||||
expect(requestBody).toContain('EXDATE:20251027');
|
||||
expect(requestBody).toContain('EXDATE:20251103');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
|
|
@ -401,6 +472,105 @@ describe('GoogleCalendarService', () => {
|
|||
service.updateEvent('primary', 'nonexistent', { title: 'New Title' })
|
||||
).rejects.toThrow(EventNotFoundError);
|
||||
});
|
||||
|
||||
test('should update recurring event with EXDATE', async () => {
|
||||
// First GET request to fetch current event
|
||||
mockRequestUrl.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'recurring-event-id',
|
||||
summary: 'Daily Standup',
|
||||
start: { date: '2025-10-20' },
|
||||
end: { date: '2025-10-21' },
|
||||
recurrence: ['RRULE:FREQ=DAILY;INTERVAL=1']
|
||||
},
|
||||
text: '',
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {}
|
||||
});
|
||||
|
||||
// Then PUT request with updated recurrence
|
||||
mockRequestUrl.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'recurring-event-id',
|
||||
summary: 'Daily Standup',
|
||||
start: { date: '2025-10-20' },
|
||||
end: { date: '2025-10-21' },
|
||||
recurrence: ['RRULE:FREQ=DAILY;INTERVAL=1', 'EXDATE:20251022'],
|
||||
htmlLink: 'https://calendar.google.com/event'
|
||||
},
|
||||
text: '',
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {}
|
||||
});
|
||||
|
||||
await service.updateEvent('primary', 'recurring-event-id', {
|
||||
recurrence: ['RRULE:FREQ=DAILY;INTERVAL=1', 'EXDATE:20251022']
|
||||
});
|
||||
|
||||
// Check the second call (PUT request) - first is GET, second is PUT, third is refresh
|
||||
expect(mockRequestUrl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: expect.stringContaining('EXDATE:20251022')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should update recurring event with multiple EXDATEs', async () => {
|
||||
// First GET request
|
||||
mockRequestUrl.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'recurring-event-id',
|
||||
summary: 'Weekly Sync',
|
||||
start: { date: '2025-10-20' },
|
||||
end: { date: '2025-10-21' },
|
||||
recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO']
|
||||
},
|
||||
text: '',
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {}
|
||||
});
|
||||
|
||||
// Then PUT request
|
||||
mockRequestUrl.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
json: {
|
||||
id: 'recurring-event-id',
|
||||
summary: 'Weekly Sync',
|
||||
start: { date: '2025-10-20' },
|
||||
end: { date: '2025-10-21' },
|
||||
recurrence: [
|
||||
'RRULE:FREQ=WEEKLY;BYDAY=MO',
|
||||
'EXDATE:20251027',
|
||||
'EXDATE:20251103',
|
||||
'EXDATE:20251110'
|
||||
],
|
||||
htmlLink: 'https://calendar.google.com/event'
|
||||
},
|
||||
text: '',
|
||||
arrayBuffer: new ArrayBuffer(0),
|
||||
headers: {}
|
||||
});
|
||||
|
||||
await service.updateEvent('primary', 'recurring-event-id', {
|
||||
recurrence: [
|
||||
'RRULE:FREQ=WEEKLY;BYDAY=MO',
|
||||
'EXDATE:20251027',
|
||||
'EXDATE:20251103',
|
||||
'EXDATE:20251110'
|
||||
]
|
||||
});
|
||||
|
||||
const requestBody = mockRequestUrl.mock.calls[1][0].body as string;
|
||||
expect(requestBody).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO');
|
||||
expect(requestBody).toContain('EXDATE:20251027');
|
||||
expect(requestBody).toContain('EXDATE:20251103');
|
||||
expect(requestBody).toContain('EXDATE:20251110');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvent', () => {
|
||||
|
|
|
|||
316
tests/unit/utils/rruleConverter.test.ts
Normal file
316
tests/unit/utils/rruleConverter.test.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* RRULE Converter Unit Tests
|
||||
*
|
||||
* Tests for converting TaskNotes RRULE format to Google Calendar recurrence format.
|
||||
*/
|
||||
|
||||
import {
|
||||
convertToGoogleRecurrence,
|
||||
formatExdates,
|
||||
isGoogleCompatibleRrule,
|
||||
extractDTSTART,
|
||||
} from "../../../src/utils/rruleConverter";
|
||||
|
||||
describe("rruleConverter", () => {
|
||||
describe("convertToGoogleRecurrence", () => {
|
||||
describe("basic conversions", () => {
|
||||
test("converts basic daily RRULE", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY;INTERVAL=1";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=DAILY;INTERVAL=1"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("converts weekly RRULE with BYDAY", () => {
|
||||
const input = "DTSTART:20240115;FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("converts monthly RRULE with BYMONTHDAY", () => {
|
||||
const input = "DTSTART:20240315;FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15"],
|
||||
dtstart: "2024-03-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("converts yearly RRULE", () => {
|
||||
const input = "DTSTART:20240315;FREQ=YEARLY;INTERVAL=1;BYMONTH=3;BYMONTHDAY=15";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=3;BYMONTHDAY=15"],
|
||||
dtstart: "2024-03-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("converts bi-weekly RRULE", () => {
|
||||
const input = "DTSTART:20240115;FREQ=WEEKLY;INTERVAL=2;BYDAY=MO";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("converts RRULE with COUNT", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY;INTERVAL=1;COUNT=10";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=DAILY;INTERVAL=1;COUNT=10"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("converts RRULE with UNTIL", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY;INTERVAL=1;UNTIL=20240215";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20240215"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("time component handling", () => {
|
||||
test("converts RRULE with time component (Z suffix)", () => {
|
||||
const input = "DTSTART:20240115T090000Z;FREQ=WEEKLY;BYDAY=MO,WE,FR";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: true,
|
||||
time: "09:00:00",
|
||||
});
|
||||
});
|
||||
|
||||
test("converts RRULE with time component (no Z suffix)", () => {
|
||||
const input = "DTSTART:20240115T143000;FREQ=DAILY;INTERVAL=1";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=DAILY;INTERVAL=1"],
|
||||
dtstart: "2024-01-15",
|
||||
hasTime: true,
|
||||
time: "14:30:00",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("EXDATE generation", () => {
|
||||
test("generates EXDATE entries for completed instances", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY;INTERVAL=1";
|
||||
const result = convertToGoogleRecurrence(input, {
|
||||
completedInstances: ["2024-01-16", "2024-01-18"],
|
||||
});
|
||||
|
||||
expect(result?.recurrence).toContain("RRULE:FREQ=DAILY;INTERVAL=1");
|
||||
expect(result?.recurrence).toContain("EXDATE:20240116");
|
||||
expect(result?.recurrence).toContain("EXDATE:20240118");
|
||||
expect(result?.recurrence).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("generates EXDATE entries for skipped instances", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY;INTERVAL=1";
|
||||
const result = convertToGoogleRecurrence(input, {
|
||||
skippedInstances: ["2024-01-17", "2024-01-19"],
|
||||
});
|
||||
|
||||
expect(result?.recurrence).toContain("RRULE:FREQ=DAILY;INTERVAL=1");
|
||||
expect(result?.recurrence).toContain("EXDATE:20240117");
|
||||
expect(result?.recurrence).toContain("EXDATE:20240119");
|
||||
});
|
||||
|
||||
test("combines completed and skipped instances in EXDATE", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY";
|
||||
const result = convertToGoogleRecurrence(input, {
|
||||
completedInstances: ["2024-01-16"],
|
||||
skippedInstances: ["2024-01-17"],
|
||||
});
|
||||
|
||||
expect(result?.recurrence).toContain("EXDATE:20240116");
|
||||
expect(result?.recurrence).toContain("EXDATE:20240117");
|
||||
expect(result?.recurrence).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("handles empty instance arrays", () => {
|
||||
const input = "DTSTART:20240115;FREQ=DAILY;INTERVAL=1";
|
||||
const result = convertToGoogleRecurrence(input, {
|
||||
completedInstances: [],
|
||||
skippedInstances: [],
|
||||
});
|
||||
|
||||
expect(result?.recurrence).toEqual(["RRULE:FREQ=DAILY;INTERVAL=1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases and error handling", () => {
|
||||
test("returns null for empty string", () => {
|
||||
expect(convertToGoogleRecurrence("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for null/undefined input", () => {
|
||||
expect(convertToGoogleRecurrence(null as any)).toBeNull();
|
||||
expect(convertToGoogleRecurrence(undefined as any)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for RRULE without DTSTART", () => {
|
||||
expect(convertToGoogleRecurrence("FREQ=DAILY;INTERVAL=1")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for DTSTART only (no FREQ)", () => {
|
||||
expect(convertToGoogleRecurrence("DTSTART:20240115")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles DTSTART without trailing semicolon", () => {
|
||||
const input = "DTSTART:20240115FREQ=DAILY;INTERVAL=1";
|
||||
// This should still work because we strip DTSTART regardless of semicolon
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
// The result depends on implementation - check if it works or returns null
|
||||
// In our implementation, we require semicolon, so this may return null or parse
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles complex monthly RRULE with positioned day", () => {
|
||||
const input = "DTSTART:20240320;FREQ=MONTHLY;BYDAY=3MO";
|
||||
const result = convertToGoogleRecurrence(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
recurrence: ["RRULE:FREQ=MONTHLY;BYDAY=3MO"],
|
||||
dtstart: "2024-03-20",
|
||||
hasTime: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatExdates", () => {
|
||||
test("formats single date", () => {
|
||||
expect(formatExdates(["2024-01-15"])).toEqual(["EXDATE:20240115"]);
|
||||
});
|
||||
|
||||
test("formats multiple dates", () => {
|
||||
expect(formatExdates(["2024-01-15", "2024-01-16", "2024-02-01"])).toEqual([
|
||||
"EXDATE:20240115",
|
||||
"EXDATE:20240116",
|
||||
"EXDATE:20240201",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(formatExdates([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty array for null/undefined", () => {
|
||||
expect(formatExdates(null as any)).toEqual([]);
|
||||
expect(formatExdates(undefined as any)).toEqual([]);
|
||||
});
|
||||
|
||||
test("filters out invalid date formats", () => {
|
||||
expect(formatExdates(["2024-01-15", "invalid", "2024-01-16"])).toEqual([
|
||||
"EXDATE:20240115",
|
||||
"EXDATE:20240116",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters out empty strings", () => {
|
||||
expect(formatExdates(["2024-01-15", "", "2024-01-16"])).toEqual([
|
||||
"EXDATE:20240115",
|
||||
"EXDATE:20240116",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGoogleCompatibleRrule", () => {
|
||||
test("returns true for valid DAILY RRULE", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=DAILY;INTERVAL=1")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for valid WEEKLY RRULE", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=WEEKLY;BYDAY=MO,WE,FR")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for valid MONTHLY RRULE", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=MONTHLY;BYMONTHDAY=15")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for valid YEARLY RRULE", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=YEARLY;BYMONTH=3")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isGoogleCompatibleRrule("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for null/undefined", () => {
|
||||
expect(isGoogleCompatibleRrule(null as any)).toBe(false);
|
||||
expect(isGoogleCompatibleRrule(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unsupported FREQ", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=SECONDLY")).toBe(false);
|
||||
expect(isGoogleCompatibleRrule("FREQ=MINUTELY")).toBe(false);
|
||||
expect(isGoogleCompatibleRrule("FREQ=HOURLY")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for RRULE with BYSECOND", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=DAILY;BYSECOND=30")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for RRULE with BYMINUTE", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=DAILY;BYMINUTE=30")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for RRULE with BYHOUR", () => {
|
||||
expect(isGoogleCompatibleRrule("FREQ=DAILY;BYHOUR=9")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for missing FREQ", () => {
|
||||
expect(isGoogleCompatibleRrule("INTERVAL=1;BYDAY=MO")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDTSTART", () => {
|
||||
test("extracts date-only DTSTART", () => {
|
||||
expect(extractDTSTART("DTSTART:20240115;FREQ=DAILY")).toBe("2024-01-15");
|
||||
});
|
||||
|
||||
test("extracts DTSTART with time component", () => {
|
||||
expect(extractDTSTART("DTSTART:20240115T090000Z;FREQ=DAILY")).toBe("2024-01-15");
|
||||
});
|
||||
|
||||
test("returns null for missing DTSTART", () => {
|
||||
expect(extractDTSTART("FREQ=DAILY;INTERVAL=1")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(extractDTSTART("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for null/undefined", () => {
|
||||
expect(extractDTSTART(null as any)).toBeNull();
|
||||
expect(extractDTSTART(undefined as any)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue