✨(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:
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
import type { IcsEvent } from 'ts-ics'
|
||||||
|
|
||||||
|
export function isEventAllDay(event: IcsEvent) {
|
||||||
|
return event.start.type === 'DATE' || event.end?.type === 'DATE'
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './dav-helper'
|
||||||
|
export * from './ics-helper'
|
||||||
|
export * from './types-helper'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user