calendar.ts•22 kB
/**
* Represents a Nextcloud Calendar
*/
export interface Calendar {
/**
* Unique identifier for the calendar
*/
id: string;
/**
* Display name of the calendar
*/
displayName: string;
/**
* Color of the calendar for visual distinction (in hex format)
*/
color: string;
/**
* Calendar owner/user
*/
owner: string;
/**
* Whether the calendar is the default calendar
*/
isDefault: boolean;
/**
* Whether the calendar is shared with others
*/
isShared: boolean;
/**
* Whether the calendar is read-only
*/
isReadOnly: boolean;
/**
* Permissions for the current user
*/
permissions: CalendarPermissions;
/**
* The URL to access the calendar
*/
url: string;
/**
* Visual category or tag for ADHD-friendly organization
*/
category?: string | null;
/**
* Priority level for ADHD focus management (1-10, 10 being highest)
*/
focusPriority?: number | null;
/**
* Additional metadata for the calendar
*/
metadata?: Record<string, unknown> | null;
}
/**
* Defines access permissions for a calendar
*/
export interface CalendarPermissions {
/**
* Whether the user can read events
*/
canRead: boolean;
/**
* Whether the user can create/add events
*/
canWrite: boolean;
/**
* Whether the user can share the calendar
*/
canShare: boolean;
/**
* Whether the user can delete the calendar
*/
canDelete: boolean;
}
/**
* Represents a calendar event
*/
export interface Event {
/**
* Unique identifier for the event
*/
id: string;
/**
* ID of the calendar this event belongs to
*/
calendarId: string;
/**
* Title/summary of the event
*/
title: string;
/**
* Detailed description of the event
*/
description?: string | null;
/**
* Start date and time of the event
*/
start: Date;
/**
* End date and time of the event
*/
end: Date;
/**
* Whether the event is an all-day event
*/
isAllDay: boolean;
/**
* Location of the event
*/
location?: string | null;
/**
* Organizer of the event
*/
organizer?: string | null;
/**
* List of event participants/attendees
*/
participants?: Participant[];
/**
* Recurrence rule for repeating events
*/
recurrenceRule?: RecurrenceRule;
/**
* Status of the event (confirmed, tentative, cancelled)
*/
status?: 'confirmed' | 'tentative' | 'cancelled';
/**
* Visibility of the event (public, private, confidential)
*/
visibility?: 'public' | 'private' | 'confidential';
/**
* Whether the event is free or busy time
*/
availability?: 'free' | 'busy';
/**
* Reminders associated with the event
*/
reminders?: EventReminder[];
/**
* Color override for the event (hex format)
*/
color?: string | null;
/**
* Tags or categories for the event
*/
categories?: string[];
/**
* Visual category for ADHD-friendly organization
*/
adhdCategory?: string;
/**
* Importance/priority level for ADHD focus (1-10, 10 being highest)
*/
focusPriority?: number;
/**
* Estimated energy requirement (1-5, 5 being highest)
*/
energyLevel?: number;
/**
* Tasks associated with this event
*/
relatedTasks?: string[];
/**
* Creation time of the event
*/
created: Date;
/**
* Last modification time of the event
*/
lastModified: Date;
/**
* Additional metadata for the event
*/
metadata?: Record<string, unknown> | null;
}
/**
* Represents a participant/attendee of an event
*/
export interface Participant {
/**
* Email address of the participant
*/
email: string;
/**
* Display name of the participant
*/
name?: string | null;
/**
* Participation status (accepted, declined, tentative, needs-action)
*/
status: 'accepted' | 'declined' | 'tentative' | 'needs-action';
/**
* Role of the participant (required, optional)
*/
role?: 'required' | 'optional';
/**
* Type of participant (individual, group, resource, room)
*/
type?: 'individual' | 'group' | 'resource' | 'room';
/**
* Response comment from the participant
*/
comment?: string | null;
}
/**
* Defines a recurrence rule for repeating events
*/
export interface RecurrenceRule {
/**
* Frequency of the recurrence (daily, weekly, monthly, yearly)
*/
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
/**
* Interval between occurrences
*/
interval?: number;
/**
* End date of the recurrence
*/
until?: Date;
/**
* Number of occurrences
*/
count?: number;
/**
* Days of the week the event occurs on (for weekly recurrence)
*/
byDay?: ('MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU')[];
/**
* Days of the month the event occurs on (for monthly recurrence)
*/
byMonthDay?: number[];
/**
* Months the event occurs on (for yearly recurrence)
*/
byMonth?: number[];
/**
* Positions within the frequency period (e.g., 1st, 2nd, last)
*/
bySetPos?: number[];
/**
* Dates to exclude from the recurrence
*/
exDates?: Date[];
}
/**
* Represents a reminder for an event
*/
export interface EventReminder {
/**
* Type of reminder (email, notification)
*/
type: 'email' | 'notification';
/**
* Time before the event to trigger the reminder (in minutes)
*/
minutesBefore: number;
/**
* Whether the reminder has been sent/triggered
*/
isSent?: boolean;
}
/**
* Type for JSON objects coming from or going to the API
*/
export type JSONObject = Record<string, unknown>;
/**
* Type guards for validating enum values
*/
/**
* Validates if a value is a valid participant status
*/
function isValidParticipantStatus(
status: unknown,
): status is 'accepted' | 'declined' | 'tentative' | 'needs-action' {
return (
status === 'accepted' ||
status === 'declined' ||
status === 'tentative' ||
status === 'needs-action'
);
}
/**
* Validates if a value is a valid event status
*/
function isValidEventStatus(status: unknown): status is 'confirmed' | 'tentative' | 'cancelled' {
return status === 'confirmed' || status === 'tentative' || status === 'cancelled';
}
/**
* Validates if a value is a valid visibility setting
*/
function isValidVisibility(
visibility: unknown,
): visibility is 'public' | 'private' | 'confidential' {
return visibility === 'public' || visibility === 'private' || visibility === 'confidential';
}
/**
* Validates if a value is a valid availability setting
*/
function isValidAvailability(availability: unknown): availability is 'free' | 'busy' {
return availability === 'free' || availability === 'busy';
}
/**
* Validates if a value is a valid participant role
*/
function isValidParticipantRole(role: unknown): role is 'required' | 'optional' {
return role === 'required' || role === 'optional';
}
/**
* Validates if a value is a valid participant type
*/
function isValidParticipantType(
type: unknown,
): type is 'individual' | 'group' | 'resource' | 'room' {
return type === 'individual' || type === 'group' || type === 'resource' || type === 'room';
}
/**
* Validates if a value is a valid reminder type
*/
function isValidReminderType(type: unknown): type is 'email' | 'notification' {
return type === 'email' || type === 'notification';
}
/**
* Validates if a value is a valid recurrence frequency
*/
function isValidFrequency(
frequency: unknown,
): frequency is 'daily' | 'weekly' | 'monthly' | 'yearly' {
return (
frequency === 'daily' ||
frequency === 'weekly' ||
frequency === 'monthly' ||
frequency === 'yearly'
);
}
/**
* Validates if a value is a valid hex color code
*/
function isValidHexColor(color: unknown): boolean {
if (typeof color !== 'string') return false;
return /^#([0-9A-F]{3}){1,2}$/i.test(color);
}
/**
* Validates and normalizes a focus priority value (1-10)
*/
function validateFocusPriority(priority: unknown): number | undefined {
if (priority === null || priority === undefined) return undefined;
const num = Number(priority);
if (isNaN(num)) return undefined;
return Math.min(Math.max(1, Math.round(num)), 10);
}
/**
* Validates and normalizes an energy level value (1-5)
*/
function validateEnergyLevel(level: unknown): number | undefined {
if (level === null || level === undefined) return undefined;
const num = Number(level);
if (isNaN(num)) return undefined;
return Math.min(Math.max(1, Math.round(num)), 5);
}
/**
* Safely parses a date string, returning undefined if invalid
*/
function safelyParseDate(dateString: unknown): Date | undefined {
if (!dateString) return undefined;
try {
if (dateString instanceof Date) return dateString;
if (typeof dateString === 'string') {
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) return undefined;
return date;
}
if (typeof dateString === 'number') {
const date = new Date(dateString);
if (isNaN(date.getTime())) return undefined;
return date;
}
return undefined;
} catch {
// Return undefined on any error
return undefined;
}
}
/**
* Helper functions for serializing and deserializing calendar objects
*/
export const CalendarUtils = {
/**
* Converts a raw JSON object to a Calendar interface
*/
toCalendar(data: JSONObject): Calendar {
if (!data) {
throw new Error('Invalid calendar data: data object is required');
}
if (!data.id) {
throw new Error('Invalid calendar data: id is required');
}
// Extract required properties with validation
const displayName = (data.displayName as string) || (data.display_name as string) || '';
if (!displayName) {
throw new Error('Invalid calendar data: displayName is required');
}
// Extract permissions with proper fallbacks
const permissionsData = (data.permissions as JSONObject) || {};
// Validate color
const color = (data.color as string) || '#0082c9';
if (!isValidHexColor(color)) {
console.warn(`Invalid color format: ${color}, defaulting to #0082c9`);
}
return {
id: data.id as string,
displayName,
color: isValidHexColor(color) ? color : '#0082c9',
owner: (data.owner as string) || '',
isDefault: Boolean(data.isDefault || data.is_default),
isShared: Boolean(data.isShared || data.is_shared),
isReadOnly: Boolean(data.isReadOnly || data.is_read_only),
permissions: {
// We default canRead to true and others to false as a sensible security default:
// Users should be able to read calendars by default but need explicit permission for other actions
canRead:
permissionsData?.canRead === false || permissionsData?.can_read === false ? false : true,
canWrite: Boolean(permissionsData?.canWrite || permissionsData?.can_write),
canShare: Boolean(permissionsData?.canShare || permissionsData?.can_share),
canDelete: Boolean(permissionsData?.canDelete || permissionsData?.can_delete),
},
url: (data.url as string) || '',
category: (data.category as string) || undefined,
focusPriority: validateFocusPriority(data.focusPriority || data.focus_priority),
metadata: (data.metadata as Record<string, unknown>) || null,
};
},
/**
* Converts a Calendar object to a JSON object for API requests
*/
fromCalendar(calendar: Calendar): JSONObject {
return {
id: calendar.id,
display_name: calendar.displayName,
color: calendar.color,
owner: calendar.owner,
is_default: calendar.isDefault,
is_shared: calendar.isShared,
is_read_only: calendar.isReadOnly,
permissions: {
can_read: calendar.permissions.canRead,
can_write: calendar.permissions.canWrite,
can_share: calendar.permissions.canShare,
can_delete: calendar.permissions.canDelete,
},
url: calendar.url,
category: calendar.category,
focus_priority: calendar.focusPriority,
metadata: calendar.metadata,
};
},
};
/**
* Helper functions for serializing and deserializing event objects
*/
export const EventUtils = {
/**
* Converts a raw JSON object to an Event interface
*/
toEvent(data: JSONObject): Event {
if (!data) {
throw new Error('Invalid event data: data object is required');
}
if (!data.id) {
throw new Error('Invalid event data: id is required');
}
if (!data.start || !data.end) {
throw new Error('Invalid event data: start and end dates are required');
}
// Safely parse dates
const startDate = safelyParseDate(data.start);
const endDate = safelyParseDate(data.end);
const createdDate = safelyParseDate(data.created);
const modifiedDate = safelyParseDate(data.lastModified) || safelyParseDate(data.last_modified);
if (!startDate || !endDate) {
throw new Error('Invalid event data: invalid start or end date format');
}
if (!createdDate || !modifiedDate) {
throw new Error('Invalid event data: invalid created or lastModified date format');
}
const calendarId = (data.calendarId as string) || (data.calendar_id as string) || '';
if (!calendarId) {
throw new Error('Invalid event data: calendarId is required');
}
const title = (data.title as string) || (data.summary as string) || '';
if (!title) {
throw new Error('Invalid event data: title/summary is required');
}
// Validate color
const color = data.color as string | null | undefined;
if (color !== undefined && color !== null && !isValidHexColor(color)) {
console.warn(`Invalid color format: ${color}`);
}
return {
id: data.id as string,
calendarId,
title,
description: data.description as string | null | undefined,
start: startDate,
end: endDate,
isAllDay: Boolean(data.isAllDay || data.is_all_day),
location: data.location as string | null | undefined,
organizer: data.organizer as string | null | undefined,
participants: Array.isArray(data.participants)
? (data.participants as JSONObject[])
.filter((p) => p && typeof p === 'object')
.map((p) => ParticipantUtils.toParticipant(p))
: undefined,
recurrenceRule: data.recurrenceRule
? RecurrenceUtils.toRecurrenceRule(data.recurrenceRule as JSONObject)
: data.recurrence_rule
? RecurrenceUtils.toRecurrenceRule(data.recurrence_rule as JSONObject)
: undefined,
status: isValidEventStatus(data.status) ? data.status : undefined,
visibility: isValidVisibility(data.visibility) ? data.visibility : undefined,
availability: isValidAvailability(data.availability) ? data.availability : undefined,
reminders: Array.isArray(data.reminders)
? (data.reminders as JSONObject[])
.filter((r) => r && typeof r === 'object')
.map((r) => ReminderUtils.toReminder(r))
: undefined,
color: color !== null && color !== undefined && !isValidHexColor(color) ? undefined : color,
categories: Array.isArray(data.categories) ? (data.categories as string[]) : undefined,
adhdCategory: (data.adhdCategory as string) || (data.adhd_category as string) || undefined,
focusPriority: validateFocusPriority(data.focusPriority || data.focus_priority),
energyLevel: validateEnergyLevel(data.energyLevel || data.energy_level),
relatedTasks: Array.isArray(data.relatedTasks)
? (data.relatedTasks as string[])
: Array.isArray(data.related_tasks)
? (data.related_tasks as string[])
: undefined,
created: createdDate,
lastModified: modifiedDate,
metadata: (data.metadata as Record<string, unknown>) || null,
};
},
/**
* Converts an Event object to a JSON object for API requests
*/
fromEvent(event: Event): JSONObject {
return {
id: event.id,
calendar_id: event.calendarId,
title: event.title,
description: event.description,
start: event.start.toISOString(),
end: event.end.toISOString(),
is_all_day: event.isAllDay,
location: event.location,
organizer: event.organizer,
participants: event.participants
? event.participants.map((p) => ParticipantUtils.fromParticipant(p))
: undefined,
recurrence_rule: event.recurrenceRule
? RecurrenceUtils.fromRecurrenceRule(event.recurrenceRule)
: undefined,
status: event.status,
visibility: event.visibility,
availability: event.availability,
reminders: event.reminders
? event.reminders.map((r) => ReminderUtils.fromReminder(r))
: undefined,
color: event.color,
categories: event.categories,
adhd_category: event.adhdCategory,
focus_priority: event.focusPriority,
energy_level: event.energyLevel,
related_tasks: event.relatedTasks,
created: event.created.toISOString(),
last_modified: event.lastModified.toISOString(),
metadata: event.metadata,
};
},
};
/**
* Helper functions for serializing and deserializing participant objects
*/
export const ParticipantUtils = {
/**
* Converts a raw JSON object to a Participant interface
*/
toParticipant(data: JSONObject): Participant {
if (!data) {
throw new Error('Invalid participant data: data object is required');
}
if (!data.email) {
throw new Error('Invalid participant data: email is required');
}
return {
email: (data.email as string) || '',
name: data.name as string | null | undefined,
status: isValidParticipantStatus(data.status) ? data.status : 'needs-action',
role: isValidParticipantRole(data.role) ? data.role : undefined,
type: isValidParticipantType(data.type) ? data.type : undefined,
comment: data.comment as string | null | undefined,
};
},
/**
* Converts a Participant object to a JSON object for API requests
*/
fromParticipant(participant: Participant): JSONObject {
return {
email: participant.email,
name: participant.name,
status: participant.status,
role: participant.role,
type: participant.type,
comment: participant.comment,
};
},
};
/**
* Helper functions for serializing and deserializing recurrence rule objects
*/
export const RecurrenceUtils = {
/**
* Converts a raw JSON object to a RecurrenceRule interface
*/
toRecurrenceRule(data: JSONObject): RecurrenceRule {
if (!data) {
throw new Error('Invalid recurrence rule data: data object is required');
}
if (!data.frequency && !isValidFrequency(data.frequency)) {
throw new Error('Invalid recurrence rule data: valid frequency is required');
}
// Safely parse the until date if present
let untilDate: Date | undefined = undefined;
if (data.until) {
untilDate = safelyParseDate(data.until);
if (data.until && !untilDate) {
throw new Error('Invalid recurrence rule data: invalid until date format');
}
}
// Safely process exDates if present
let exDates: Date[] | undefined = undefined;
if (Array.isArray(data.exDates) || Array.isArray(data.ex_dates)) {
const exDateStrings = (data.exDates || data.ex_dates) as unknown[];
exDates = exDateStrings
.map((dateStr) => safelyParseDate(dateStr))
.filter((date) => date !== undefined) as Date[];
}
return {
frequency: isValidFrequency(data.frequency) ? data.frequency : 'daily',
interval: data.interval as number | undefined,
until: untilDate,
count: data.count as number | undefined,
byDay: Array.isArray(data.byDay || data.by_day)
? ((data.byDay || data.by_day) as ('MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU')[])
: undefined,
byMonthDay: Array.isArray(data.byMonthDay || data.by_month_day)
? ((data.byMonthDay || data.by_month_day) as number[])
: undefined,
byMonth: Array.isArray(data.byMonth || data.by_month)
? ((data.byMonth || data.by_month) as number[])
: undefined,
bySetPos: Array.isArray(data.bySetPos || data.by_set_pos)
? ((data.bySetPos || data.by_set_pos) as number[])
: undefined,
exDates,
};
},
/**
* Converts a RecurrenceRule object to a JSON object for API requests
*/
fromRecurrenceRule(rule: RecurrenceRule): JSONObject {
return {
frequency: rule.frequency,
interval: rule.interval,
until: rule.until?.toISOString(),
count: rule.count,
by_day: rule.byDay,
by_month_day: rule.byMonthDay,
by_month: rule.byMonth,
by_set_pos: rule.bySetPos,
ex_dates: rule.exDates?.map((d) => d.toISOString()),
};
},
};
/**
* Helper functions for serializing and deserializing reminder objects
*/
export const ReminderUtils = {
/**
* Converts a raw JSON object to an EventReminder interface
*/
toReminder(data: JSONObject): EventReminder {
if (!data) {
throw new Error('Invalid reminder data: data object is required');
}
// Convert minutesBefore to a number and validate it
const minutesBefore = Number(data.minutesBefore) || Number(data.minutes_before) || 10;
if (isNaN(minutesBefore) || minutesBefore < 0) {
throw new Error('Invalid reminder data: minutesBefore must be a positive number');
}
return {
type: isValidReminderType(data.type) ? data.type : 'notification',
minutesBefore,
isSent: Boolean(data.isSent || data.is_sent),
};
},
/**
* Converts an EventReminder object to a JSON object for API requests
*/
fromReminder(reminder: EventReminder): JSONObject {
return {
type: reminder.type,
minutes_before: reminder.minutesBefore,
is_sent: reminder.isSent,
};
},
};