(front) add DAV helper utilities

Add utility functions for DAV operations including ICS
parsing/generation, event-calendar conversions, timezone
handling and type helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:33:44 +01:00
parent 250e852762
commit 158cbc8a7f
6 changed files with 2003 additions and 0 deletions

View File

@@ -0,0 +1,279 @@
import { convertIcsCalendar, convertIcsTimezone, generateIcsCalendar, type IcsCalendar } from 'ts-ics'
import { getIcalTimezoneBlock } from './ical-timezones'
import {
createAccount,
fetchCalendars as davFetchCalendars,
fetchCalendarObjects as davFetchCalendarObjects,
createCalendarObject as davCreateCalendarObject,
updateCalendarObject as davUpdateCalendarObject,
deleteCalendarObject as davDeleteCalendarObject,
DAVNamespaceShort,
propfind,
type DAVCalendar,
type DAVCalendarObject,
type DAVAddressBook,
fetchAddressBooks as davFetchAddressBooks,
fetchVCards as davFetchVCards,
} from 'tsdav'
import { isServerSource } from './types-helper'
import type { Calendar, CalendarObject } from '../types/calendar'
import type { CalendarSource, ServerSource, CalendarResponse, AddressBookSource } from '../types/options'
import type { AddressBook, AddressBookObject } from '../types/addressbook'
import ICAL from 'ical.js'
export function getEventObjectString(event: IcsCalendar) {
return generateIcsCalendar(event)
}
export async function fetchCalendars(source: ServerSource | CalendarSource): Promise<Calendar[]> {
if (isServerSource(source)) {
const account = await createAccount({
account: { serverUrl: source.serverUrl, accountType: 'caldav' },
headers: source.headers,
fetchOptions: source.fetchOptions,
})
const calendars = await davFetchCalendars({ account, headers: source.headers, fetchOptions: source.fetchOptions })
const result = calendars.map(calendar => ({ ...calendar, headers: source.headers, fetchOptions: source.fetchOptions, uid: crypto.randomUUID() }))
return result
} else {
const calendar = await davFetchCalendar({
url: source.calendarUrl,
headers: source.headers,
fetchOptions: source.fetchOptions,
})
const result = [{ ...calendar, headers: source.headers, fetchOptions: source.fetchOptions, uid: source.calendarUid }]
return result
}
}
export async function fetchCalendarObjects(
calendar: Calendar,
timeRange?: { start: string; end: string; },
expand?: boolean,
): Promise<{ calendarObjects: CalendarObject[], recurringObjects: CalendarObject[] }> {
const davCalendarObjects = await davFetchCalendarObjects({
calendar: calendar,
timeRange, expand,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
const calendarObjects = davCalendarObjects.map(o => ({
url: o.url,
etag: o.etag,
data: convertIcsCalendar(undefined, o.data),
calendarUrl: calendar.url,
}))
const recurringObjectsUrls = new Set(
calendarObjects
.filter(c => c.data.events?.find(e => e.recurrenceId))
.map(c => c.url),
)
const davRecurringObjects = recurringObjectsUrls.size == 0
? []
: await davFetchCalendarObjects({
calendar: calendar,
objectUrls: Array.from(recurringObjectsUrls),
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
const recurringObjects = davRecurringObjects.map(o => ({
url: o.url,
etag: o.etag,
data: convertIcsCalendar(undefined, o.data),
calendarUrl: calendar.url,
}))
return { calendarObjects, recurringObjects }
}
export async function createCalendarObject(
calendar: Calendar,
calendarObjectData: IcsCalendar,
): Promise<CalendarResponse> {
validateTimezones(calendarObjectData)
for (const event of calendarObjectData.events ?? []) event.uid = crypto.randomUUID()
const uid = calendarObjectData.events?.[0].uid ?? crypto.randomUUID()
const iCalString = getEventObjectString(calendarObjectData)
const response = await davCreateCalendarObject({
calendar,
iCalString,
filename: `${uid}.ics`,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
return { response, ical: iCalString }
}
export async function updateCalendarObject(
calendar: Calendar,
calendarObject: CalendarObject,
): Promise<CalendarResponse> {
validateTimezones(calendarObject.data)
const davCalendarObject: DAVCalendarObject = {
url: calendarObject.url,
etag: calendarObject.etag,
data: getEventObjectString(calendarObject.data),
}
const response = await davUpdateCalendarObject({
calendarObject: davCalendarObject,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
return { response, ical: davCalendarObject.data }
}
export async function deleteCalendarObject(
calendar: Calendar,
calendarObject: CalendarObject,
): Promise<CalendarResponse> {
validateTimezones(calendarObject.data)
const davCalendarObject: DAVCalendarObject = {
url: calendarObject.url,
etag: calendarObject.etag,
data: getEventObjectString(calendarObject.data),
}
const response = await davDeleteCalendarObject({
calendarObject: davCalendarObject,
headers: calendar.headers,
fetchOptions: calendar.fetchOptions,
})
return { response, ical: davCalendarObject.data }
}
function validateTimezones(calendarObjectData: IcsCalendar) {
const calendar = calendarObjectData
const usedTimezones = calendar.events?.flatMap(e => [e.start.local?.timezone, e.end?.local?.timezone])
const wantedTzIds = new Set(usedTimezones?.filter(s => s !== undefined))
calendar.timezones ??= []
// Remove extra timezones
calendar.timezones = calendar.timezones.filter(tz => wantedTzIds.has(tz.id))
// Add missing timezones
wantedTzIds.forEach(tzid => {
if (tzid && calendar.timezones!.findIndex(t => t.id === tzid) === -1) {
const tzBlock = getIcalTimezoneBlock(tzid)[0]
if (tzBlock) {
calendar.timezones!.push(convertIcsTimezone(undefined, tzBlock))
}
}
})
}
// NOTE - CJ - 2025/07/03 - Inspired from https://github.com/natelindev/tsdav/blob/master/src/calendar.ts, fetchCalendars
async function davFetchCalendar(params: {
url: string,
headers?: Record<string, string>,
fetchOptions?: RequestInit
}): Promise<DAVCalendar> {
const { url, headers, fetchOptions } = params
const response = await propfind({
url,
props: {
[`${DAVNamespaceShort.CALDAV}:calendar-description`]: {},
[`${DAVNamespaceShort.CALDAV}:calendar-timezone`]: {},
[`${DAVNamespaceShort.DAV}:displayname`]: {},
[`${DAVNamespaceShort.CALDAV_APPLE}:calendar-color`]: {},
[`${DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
[`${DAVNamespaceShort.DAV}:resourcetype`]: {},
[`${DAVNamespaceShort.CALDAV}:supported-calendar-component-set`]: {},
[`${DAVNamespaceShort.DAV}:sync-token`]: {},
},
headers,
fetchOptions,
})
const rs = response[0]
if (!rs.ok) {
throw new Error(`Calendar ${url} does not exists. ${rs.status} ${rs.statusText}`)
}
if (Object.keys(rs.props?.resourceType ?? {}).includes('calendar')) {
throw new Error(`${url} is not a ${rs.props?.resourceType} and not a calendar`)
}
const description = rs.props?.calendarDescription
const timezone = rs.props?.calendarTimezone
return {
description: typeof description === 'string' ? description : '',
timezone: typeof timezone === 'string' ? timezone : '',
url: params.url,
ctag: rs.props?.getctag,
calendarColor: rs.props?.calendarColor,
displayName: rs.props?.displayname._cdata ?? rs.props?.displayname,
components: Array.isArray(rs.props?.supportedCalendarComponentSet.comp)
// NOTE - CJ - 2025-07-03 - comp represents an list of XML nodes in the DAVResponse format
// sc could be `<C:comp name="VEVENT" />`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? rs.props?.supportedCalendarComponentSet.comp.map((sc: any) => sc._attributes.name)
: [rs.props?.supportedCalendarComponentSet.comp?._attributes.name],
resourcetype: Object.keys(rs.props?.resourcetype),
syncToken: rs.props?.syncToken,
}
}
export async function fetchAddressBooks(source: ServerSource | AddressBookSource): Promise<AddressBook[]> {
if (isServerSource(source)) {
const account = await createAccount({
account: { serverUrl: source.serverUrl, accountType: 'caldav' },
headers: source.headers,
fetchOptions: source.fetchOptions,
})
const books = await davFetchAddressBooks({ account, headers: source.headers, fetchOptions: source.fetchOptions })
return books.map(book => ({ ...book, headers: source.headers, fetchOptions: source.fetchOptions }))
} else {
const book = await davFetchAddressBook({
url: source.addressBookUrl,
headers: source.headers,
fetchOptions: source.fetchOptions,
})
return [{ ...book, headers: source.headers, fetchOptions: source.fetchOptions, uid: source.addressBookUid }]
}
}
// NOTE - CJ - 2025/07/03 - Inspired from https://github.com/natelindev/tsdav/blob/master/src/addressBook.ts#L73
async function davFetchAddressBook(params: {
url: string,
headers?: Record<string, string>,
fetchOptions?: RequestInit
}): Promise<DAVAddressBook> {
const { url, headers, fetchOptions } = params
const response = await propfind({
url,
props: {
[`${DAVNamespaceShort.DAV}:displayname`]: {},
[`${DAVNamespaceShort.CALENDAR_SERVER}:getctag`]: {},
[`${DAVNamespaceShort.DAV}:resourcetype`]: {},
[`${DAVNamespaceShort.DAV}:sync-token`]: {},
},
headers,
fetchOptions,
})
const rs = response[0]
if (!rs.ok) {
throw new Error(`Address book ${url} does not exists. ${rs.status} ${rs.statusText}`)
}
if (Object.keys(rs.props?.resourceType ?? {}).includes('addressbook')) {
throw new Error(`${url} is not a ${rs.props?.resourceType} and not an addressbook`)
}
const displayName = rs.props?.displayname?._cdata ?? rs.props?.displayname
return {
url: url,
ctag: rs.props?.getctag,
displayName: typeof displayName === 'string' ? displayName : '',
resourcetype: Object.keys(rs.props?.resourcetype),
syncToken: rs.props?.syncToken,
}
}
export async function fetchAddressBookObjects(addressBook: AddressBook): Promise<AddressBookObject[]> {
const davVCards = await davFetchVCards({
addressBook: addressBook,
headers: addressBook.headers,
fetchOptions: addressBook.fetchOptions,
})
return davVCards.map(o => ({
url: o.url,
etag: o.etag,
data: new ICAL.Component(ICAL.parse(o.data)),
addressBookUrl: addressBook.url,
}))
}

View File

@@ -0,0 +1,667 @@
/**
* Event Calendar Helper Functions
*
* Utility functions for working with EventCalendar (vkurko/calendar) format.
* These are standalone helpers that don't require the full adapter.
*
* @see https://github.com/vkurko/calendar
*/
import type { IcsEvent, IcsDateObject, IcsRecurrenceRule } from 'ts-ics'
import type {
EventCalendarEvent,
EventCalendarEventInput,
EventCalendarDuration,
EventCalendarDurationInput,
EventCalendarView,
EventCalendarResource,
} from '../types/event-calendar'
import type { CalDavAttendee } from '../types/caldav-service'
// ============================================================================
// Date/Time Helpers
// ============================================================================
/**
* Format a date for EventCalendar (ISO string or Date object)
*/
export function formatEventCalendarDate(date: Date | string): string {
if (typeof date === 'string') return date
return date.toISOString()
}
/**
* Parse an EventCalendar date to a JavaScript Date
*/
export function parseEventCalendarDate(date: Date | string): Date {
if (date instanceof Date) return date
return new Date(date)
}
/**
* Check if two dates are on the same day
*/
export function isSameDay(date1: Date | string, date2: Date | string): boolean {
const d1 = parseEventCalendarDate(date1)
const d2 = parseEventCalendarDate(date2)
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
)
}
/**
* Get the start of day for a date
*/
export function startOfDay(date: Date | string): Date {
const d = parseEventCalendarDate(date)
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0)
}
/**
* Get the end of day for a date
*/
export function endOfDay(date: Date | string): Date {
const d = parseEventCalendarDate(date)
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999)
}
/**
* Get the start of week for a date (configurable first day)
*/
export function startOfWeek(date: Date | string, firstDay: number = 1): Date {
const d = parseEventCalendarDate(date)
const day = d.getDay()
const diff = (day < firstDay ? 7 : 0) + day - firstDay
d.setDate(d.getDate() - diff)
return startOfDay(d)
}
/**
* Get the end of week for a date
*/
export function endOfWeek(date: Date | string, firstDay: number = 1): Date {
const start = startOfWeek(date, firstDay)
start.setDate(start.getDate() + 6)
return endOfDay(start)
}
/**
* Get the start of month for a date
*/
export function startOfMonth(date: Date | string): Date {
const d = parseEventCalendarDate(date)
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0)
}
/**
* Get the end of month for a date
*/
export function endOfMonth(date: Date | string): Date {
const d = parseEventCalendarDate(date)
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999)
}
// ============================================================================
// Duration Helpers
// ============================================================================
/**
* Parse duration input to EventCalendarDuration
*/
export function parseDuration(input: EventCalendarDurationInput): EventCalendarDuration {
if (typeof input === 'number') {
// Input is total seconds
return secondsToDuration(input)
}
if (typeof input === 'string') {
// Input is 'hh:mm:ss' or 'hh:mm' format
const parts = input.split(':').map(Number)
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] }
} else if (parts.length === 3) {
return { hours: parts[0], minutes: parts[1], seconds: parts[2] }
}
return {}
}
// Input is already an object
return input
}
/**
* Convert duration to total seconds
*/
export function durationToSeconds(duration: EventCalendarDuration): number {
let seconds = 0
if (duration.years) seconds += duration.years * 365.25 * 24 * 60 * 60
if (duration.months) seconds += duration.months * 30.44 * 24 * 60 * 60
if (duration.weeks) seconds += duration.weeks * 7 * 24 * 60 * 60
if (duration.days) seconds += duration.days * 24 * 60 * 60
if (duration.hours) seconds += duration.hours * 60 * 60
if (duration.minutes) seconds += duration.minutes * 60
if (duration.seconds) seconds += duration.seconds
return Math.round(seconds)
}
/**
* Convert seconds to duration object
*/
export function secondsToDuration(totalSeconds: number): EventCalendarDuration {
const days = Math.floor(totalSeconds / (24 * 60 * 60))
const remainingAfterDays = totalSeconds % (24 * 60 * 60)
const hours = Math.floor(remainingAfterDays / (60 * 60))
const remainingAfterHours = remainingAfterDays % (60 * 60)
const minutes = Math.floor(remainingAfterHours / 60)
const seconds = remainingAfterHours % 60
const duration: EventCalendarDuration = {}
if (days) duration.days = days
if (hours) duration.hours = hours
if (minutes) duration.minutes = minutes
if (seconds) duration.seconds = seconds
return duration
}
/**
* Format duration as 'hh:mm:ss' string
*/
export function formatDuration(duration: EventCalendarDuration): string {
const totalSeconds = durationToSeconds(duration)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const pad = (n: number) => n.toString().padStart(2, '0')
if (seconds > 0) {
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
}
return `${pad(hours)}:${pad(minutes)}`
}
/**
* Add duration to a date
*/
export function addDuration(date: Date | string, duration: EventCalendarDuration): Date {
const result = new Date(parseEventCalendarDate(date))
const seconds = durationToSeconds(duration)
result.setTime(result.getTime() + seconds * 1000)
return result
}
/**
* Subtract duration from a date
*/
export function subtractDuration(date: Date | string, duration: EventCalendarDuration): Date {
const result = new Date(parseEventCalendarDate(date))
const seconds = durationToSeconds(duration)
result.setTime(result.getTime() - seconds * 1000)
return result
}
// ============================================================================
// Event Helpers
// ============================================================================
/**
* Check if an event is an all-day event
*/
export function isAllDayEvent(event: EventCalendarEvent | EventCalendarEventInput): boolean {
return event.allDay === true
}
/**
* Check if an event spans multiple days
*/
export function isMultiDayEvent(event: EventCalendarEvent | EventCalendarEventInput): boolean {
const start = parseEventCalendarDate(event.start)
const end = event.end ? parseEventCalendarDate(event.end) : start
return !isSameDay(start, end)
}
/**
* Get the duration of an event in minutes
*/
export function getEventDurationMinutes(event: EventCalendarEvent | EventCalendarEventInput): number {
const start = parseEventCalendarDate(event.start)
const end = event.end ? parseEventCalendarDate(event.end) : start
return Math.round((end.getTime() - start.getTime()) / (1000 * 60))
}
/**
* Check if an event overlaps with a time range
*/
export function eventOverlapsRange(
event: EventCalendarEvent | EventCalendarEventInput,
rangeStart: Date | string,
rangeEnd: Date | string
): boolean {
const eventStart = parseEventCalendarDate(event.start)
const eventEnd = event.end ? parseEventCalendarDate(event.end) : eventStart
const start = parseEventCalendarDate(rangeStart)
const end = parseEventCalendarDate(rangeEnd)
return eventStart < end && eventEnd > start
}
/**
* Filter events that overlap with a time range
*/
export function filterEventsInRange(
events: EventCalendarEvent[],
rangeStart: Date | string,
rangeEnd: Date | string
): EventCalendarEvent[] {
return events.filter((event) => eventOverlapsRange(event, rangeStart, rangeEnd))
}
/**
* Sort events by start date
*/
export function sortEventsByStart(events: EventCalendarEvent[]): EventCalendarEvent[] {
return [...events].sort((a, b) => {
const aStart = parseEventCalendarDate(a.start)
const bStart = parseEventCalendarDate(b.start)
return aStart.getTime() - bStart.getTime()
})
}
/**
* Group events by date
*/
export function groupEventsByDate(events: EventCalendarEvent[]): Map<string, EventCalendarEvent[]> {
const groups = new Map<string, EventCalendarEvent[]>()
for (const event of events) {
const dateKey = startOfDay(event.start).toISOString().split('T')[0]
const existing = groups.get(dateKey) ?? []
existing.push(event)
groups.set(dateKey, existing)
}
return groups
}
/**
* Create a new event with updated times
*/
export function moveEvent(
event: EventCalendarEvent,
newStart: Date | string,
newEnd?: Date | string
): EventCalendarEvent {
const start = parseEventCalendarDate(newStart)
const originalStart = parseEventCalendarDate(event.start)
const originalEnd = event.end ? parseEventCalendarDate(event.end) : originalStart
// Calculate original duration
const duration = originalEnd.getTime() - originalStart.getTime()
// Calculate new end if not provided
const end = newEnd ? parseEventCalendarDate(newEnd) : new Date(start.getTime() + duration)
return {
...event,
start,
end,
}
}
/**
* Resize an event by changing its end time
*/
export function resizeEvent(
event: EventCalendarEvent,
newEnd: Date | string
): EventCalendarEvent {
return {
...event,
end: parseEventCalendarDate(newEnd),
}
}
// ============================================================================
// View Helpers
// ============================================================================
/**
* Get the date range for a view
*/
export function getViewDateRange(
view: EventCalendarView,
currentDate: Date | string
): { start: Date; end: Date } {
const date = parseEventCalendarDate(currentDate)
switch (view) {
case 'dayGridDay':
case 'timeGridDay':
case 'listDay':
case 'resourceTimeGridDay':
case 'resourceTimelineDay':
return { start: startOfDay(date), end: endOfDay(date) }
case 'dayGridWeek':
case 'timeGridWeek':
case 'listWeek':
case 'resourceTimeGridWeek':
case 'resourceTimelineWeek':
return { start: startOfWeek(date), end: endOfWeek(date) }
case 'dayGridMonth':
case 'listMonth':
case 'resourceTimelineMonth':
return { start: startOfMonth(date), end: endOfMonth(date) }
case 'listYear':
return {
start: new Date(date.getFullYear(), 0, 1),
end: new Date(date.getFullYear(), 11, 31, 23, 59, 59, 999),
}
default:
return { start: startOfWeek(date), end: endOfWeek(date) }
}
}
/**
* Check if a view is a list view
*/
export function isListView(view: EventCalendarView): boolean {
return view.startsWith('list')
}
/**
* Check if a view is a resource view
*/
export function isResourceView(view: EventCalendarView): boolean {
return view.startsWith('resource')
}
/**
* Check if a view is a timeline view
*/
export function isTimelineView(view: EventCalendarView): boolean {
return view.toLowerCase().includes('timeline')
}
// ============================================================================
// Resource Helpers
// ============================================================================
/**
* Find a resource by ID
*/
export function findResourceById(
resources: EventCalendarResource[],
id: string | number
): EventCalendarResource | undefined {
for (const resource of resources) {
if (resource.id === id) return resource
if (resource.children) {
const found = findResourceById(resource.children, id)
if (found) return found
}
}
return undefined
}
/**
* Flatten nested resources
*/
export function flattenResources(resources: EventCalendarResource[]): EventCalendarResource[] {
const result: EventCalendarResource[] = []
for (const resource of resources) {
result.push(resource)
if (resource.children) {
result.push(...flattenResources(resource.children))
}
}
return result
}
/**
* Get events for a specific resource
*/
export function getEventsForResource(
events: EventCalendarEvent[],
resourceId: string | number
): EventCalendarEvent[] {
return events.filter((event) => event.resourceIds?.includes(resourceId))
}
// ============================================================================
// ICS Conversion Helpers
// ============================================================================
/**
* Convert IcsDateObject to JavaScript Date
*/
export function icsDateToJsDate(icsDate: IcsDateObject): Date {
if (icsDate.local?.date) {
return icsDate.local.date
}
return icsDate.date
}
/**
* Convert JavaScript Date to IcsDateObject
*/
export function jsDateToIcsDate(date: Date, allDay: boolean = false, timezone?: string): IcsDateObject {
if (allDay) {
return {
type: 'DATE',
date,
}
}
const icsDate: IcsDateObject = {
type: 'DATE-TIME',
date,
}
if (timezone) {
icsDate.local = {
date,
timezone,
tzoffset: '+0000', // Default, will be recalculated when needed
}
}
return icsDate
}
/**
* Check if an IcsEvent is an all-day event
*/
export function isIcsEventAllDay(event: IcsEvent): boolean {
return event.start.type === 'DATE'
}
/**
* Get the timezone from an IcsEvent
*/
export function getIcsEventTimezone(event: IcsEvent): string | undefined {
return event.start.local?.timezone
}
// ============================================================================
// Recurrence Helpers
// ============================================================================
/**
* Check if an event has a recurrence rule
*/
export function hasRecurrence(event: IcsEvent): boolean {
return !!event.recurrenceRule
}
/**
* Check if an event is a recurring instance (has recurrenceId)
*/
export function isRecurringInstance(event: IcsEvent): boolean {
return !!event.recurrenceId
}
/**
* Get a human-readable description of recurrence rule
*/
export function describeRecurrence(rule: IcsRecurrenceRule): string {
const parts: string[] = []
// Frequency
const freqMap: Record<string, string> = {
DAILY: 'day',
WEEKLY: 'week',
MONTHLY: 'month',
YEARLY: 'year',
}
const freq = freqMap[rule.frequency] ?? rule.frequency.toLowerCase()
// Interval
const interval = rule.interval ?? 1
if (interval === 1) {
parts.push(`Every ${freq}`)
} else {
parts.push(`Every ${interval} ${freq}s`)
}
// Days of week
if (rule.byDay && rule.byDay.length > 0) {
const dayMap: Record<string, string> = {
SU: 'Sunday',
MO: 'Monday',
TU: 'Tuesday',
WE: 'Wednesday',
TH: 'Thursday',
FR: 'Friday',
SA: 'Saturday',
}
const days = rule.byDay.map((d) => {
const dayCode = typeof d === 'string' ? d : d.day
return dayMap[dayCode] ?? dayCode
})
parts.push(`on ${days.join(', ')}`)
}
// Count
if (rule.count) {
parts.push(`${rule.count} times`)
}
// Until
if (rule.until) {
const untilDate = rule.until instanceof Date ? rule.until : new Date(rule.until.date)
parts.push(`until ${untilDate.toLocaleDateString()}`)
}
return parts.join(' ')
}
// ============================================================================
// Color Helpers
// ============================================================================
/**
* Generate a color from a string (for consistent calendar colors)
*/
export function stringToColor(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
const h = hash % 360
return `hsl(${h}, 65%, 50%)`
}
/**
* Check if a color is dark (for text contrast)
*/
export function isColorDark(color: string): boolean {
// Convert hex to RGB
let r: number, g: number, b: number
if (color.startsWith('#')) {
const hex = color.slice(1)
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16)
g = parseInt(hex[1] + hex[1], 16)
b = parseInt(hex[2] + hex[2], 16)
} else {
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
}
} else if (color.startsWith('rgb')) {
const match = color.match(/\d+/g)
if (!match) return false
r = parseInt(match[0])
g = parseInt(match[1])
b = parseInt(match[2])
} else {
return false
}
// Calculate relative luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance < 0.5
}
/**
* Get contrasting text color (black or white)
*/
export function getContrastingTextColor(backgroundColor: string): string {
return isColorDark(backgroundColor) ? '#ffffff' : '#000000'
}
// ============================================================================
// Attendee Helpers
// ============================================================================
/**
* Get display name for an attendee
*/
export function getAttendeeDisplayName(attendee: CalDavAttendee): string {
return attendee.name ?? attendee.email
}
/**
* Get status icon for an attendee
*/
export function getAttendeeStatusIcon(status?: CalDavAttendee['partstat']): string {
switch (status) {
case 'ACCEPTED':
return '✓'
case 'DECLINED':
return '✗'
case 'TENTATIVE':
return '?'
case 'NEEDS-ACTION':
default:
return '○'
}
}
/**
* Get status color for an attendee
*/
export function getAttendeeStatusColor(status?: CalDavAttendee['partstat']): string {
switch (status) {
case 'ACCEPTED':
return '#22c55e' // green
case 'DECLINED':
return '#ef4444' // red
case 'TENTATIVE':
return '#f59e0b' // amber
case 'NEEDS-ACTION':
default:
return '#6b7280' // gray
}
}

View File

@@ -0,0 +1,5 @@
import type { IcsEvent } from 'ts-ics'
export function isEventAllDay(event: IcsEvent) {
return event.start.type === 'DATE' || event.end?.type === 'DATE'
}

View File

@@ -0,0 +1,3 @@
export * from './dav-helper'
export * from './ics-helper'
export * from './types-helper'

View File

@@ -0,0 +1,22 @@
import type { CalendarOptions,
SelectCalendarHandlers,
EventEditHandlers,
ServerSource,
VCardProvider,
} from '../types/options'
export function isServerSource(source: ServerSource | unknown): source is ServerSource {
return (source as ServerSource).serverUrl !== undefined
}
export function isVCardProvider(source: VCardProvider | unknown): source is VCardProvider {
return (source as VCardProvider).fetchContacts !== undefined
}
export function hasEventHandlers(options: CalendarOptions): options is EventEditHandlers {
return (options as EventEditHandlers).onCreateEvent !== undefined
}
export function hasCalendarHandlers(options: CalendarOptions): options is SelectCalendarHandlers {
return (options as SelectCalendarHandlers).onClickSelectCalendars !== undefined
}