✨(front) implement EventCalendarAdapter
Add EventCalendarAdapter for converting between CalDAV events and scheduler-compatible format. Include VCard component for contact handling in calendar invitations. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,942 @@
|
|||||||
|
/**
|
||||||
|
* EventCalendarAdapter - Conversion service between CalDAV and EventCalendar formats
|
||||||
|
*
|
||||||
|
* This adapter provides bidirectional conversion between CalDAV data structures
|
||||||
|
* (IcsEvent, CalDavCalendar, etc.) and EventCalendar (vkurko/calendar) format.
|
||||||
|
*
|
||||||
|
* EventCalendar: https://github.com/vkurko/calendar
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const adapter = new EventCalendarAdapter()
|
||||||
|
*
|
||||||
|
* // Convert CalDAV events to EventCalendar format
|
||||||
|
* const ecEvents = adapter.toEventCalendarEvents(caldavEvents, { calendarColors })
|
||||||
|
*
|
||||||
|
* // Convert EventCalendar event back to CalDAV
|
||||||
|
* const icsEvent = adapter.toIcsEvent(ecEvent, { defaultTimezone: 'Europe/Paris' })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IcsCalendar, IcsDateObject, IcsEvent, IcsRecurrenceRule, IcsAttendee, IcsOrganizer, IcsDuration } from 'ts-ics'
|
||||||
|
import type { CalDavCalendar, CalDavEvent } from './types/caldav-service'
|
||||||
|
import type {
|
||||||
|
EventCalendarEvent,
|
||||||
|
EventCalendarEventInput,
|
||||||
|
EventCalendarResource,
|
||||||
|
EventCalendarDuration,
|
||||||
|
CalDavToEventCalendarOptions,
|
||||||
|
EventCalendarToCalDavOptions,
|
||||||
|
} from './types/event-calendar'
|
||||||
|
|
||||||
|
// Extended attendee/organizer types for UI display (has displayName instead of name)
|
||||||
|
type ExtendedAttendee = {
|
||||||
|
email: string
|
||||||
|
displayName?: string
|
||||||
|
role?: IcsAttendee['role']
|
||||||
|
status?: string
|
||||||
|
rsvp?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtendedOrganizer = {
|
||||||
|
email: string
|
||||||
|
displayName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Extended Types for conversion metadata
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended props stored in EventCalendarEvent.extendedProps
|
||||||
|
* Contains original CalDAV data for round-trip conversion
|
||||||
|
*/
|
||||||
|
export type CalDavExtendedProps = {
|
||||||
|
/** Original ICS UID */
|
||||||
|
uid: string
|
||||||
|
/** Calendar URL this event belongs to */
|
||||||
|
calendarUrl: string
|
||||||
|
/** Event URL for updates/deletes */
|
||||||
|
eventUrl?: string
|
||||||
|
/** ETag for optimistic concurrency */
|
||||||
|
etag?: string
|
||||||
|
/** Original recurrence rule */
|
||||||
|
recurrenceRule?: IcsRecurrenceRule
|
||||||
|
/** Recurrence ID for recurring event instances */
|
||||||
|
recurrenceId?: Date
|
||||||
|
/** Is this a recurring event instance */
|
||||||
|
isRecurringInstance?: boolean
|
||||||
|
/** Original timezone */
|
||||||
|
timezone?: string
|
||||||
|
/** Event sequence number */
|
||||||
|
sequence?: number
|
||||||
|
/** Event status */
|
||||||
|
status?: string
|
||||||
|
/** Event location */
|
||||||
|
location?: string
|
||||||
|
/** Event description */
|
||||||
|
description?: string
|
||||||
|
/** Event organizer */
|
||||||
|
organizer?: ExtendedOrganizer
|
||||||
|
/** Event attendees */
|
||||||
|
attendees?: ExtendedAttendee[]
|
||||||
|
/** Event categories/tags */
|
||||||
|
categories?: string[]
|
||||||
|
/** Event priority (1-9, 1 highest) */
|
||||||
|
priority?: number
|
||||||
|
/** Event URL */
|
||||||
|
url?: string
|
||||||
|
/** Creation timestamp */
|
||||||
|
created?: Date
|
||||||
|
/** Last modified timestamp */
|
||||||
|
lastModified?: Date
|
||||||
|
/** Custom X-properties */
|
||||||
|
customProperties?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EventCalendarAdapter Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class EventCalendarAdapter {
|
||||||
|
private defaultOptions: CalDavToEventCalendarOptions = {
|
||||||
|
defaultEventColor: '#3788d8',
|
||||||
|
defaultTextColor: '#ffffff',
|
||||||
|
includeRecurringInstances: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CalDAV -> EventCalendar Conversions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert CalDAV events to EventCalendar format
|
||||||
|
*/
|
||||||
|
public toEventCalendarEvents(
|
||||||
|
caldavEvents: CalDavEvent[],
|
||||||
|
options?: CalDavToEventCalendarOptions
|
||||||
|
): EventCalendarEvent[] {
|
||||||
|
const opts = { ...this.defaultOptions, ...options }
|
||||||
|
const events: EventCalendarEvent[] = []
|
||||||
|
|
||||||
|
for (const caldavEvent of caldavEvents) {
|
||||||
|
const icsEvents = caldavEvent.data.events ?? []
|
||||||
|
|
||||||
|
// Build a map of source events (with recurrenceRule) by UID
|
||||||
|
const sourceEventRules = new Map<string, IcsRecurrenceRule>()
|
||||||
|
for (const icsEvent of icsEvents) {
|
||||||
|
if (icsEvent.recurrenceRule && !icsEvent.recurrenceId) {
|
||||||
|
sourceEventRules.set(icsEvent.uid, icsEvent.recurrenceRule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const icsEvent of icsEvents) {
|
||||||
|
// Skip recurring source events if we only want instances
|
||||||
|
if (icsEvent.recurrenceRule && !icsEvent.recurrenceId && !opts.includeRecurringInstances) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a recurring instance without recurrenceRule, copy it from source
|
||||||
|
const enrichedEvent = { ...icsEvent }
|
||||||
|
if (icsEvent.recurrenceId && !icsEvent.recurrenceRule) {
|
||||||
|
const sourceRule = sourceEventRules.get(icsEvent.uid)
|
||||||
|
if (sourceRule) {
|
||||||
|
enrichedEvent.recurrenceRule = sourceRule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ecEvent = this.icsEventToEventCalendarEvent(
|
||||||
|
enrichedEvent,
|
||||||
|
caldavEvent.calendarUrl,
|
||||||
|
caldavEvent.url,
|
||||||
|
caldavEvent.etag,
|
||||||
|
opts
|
||||||
|
)
|
||||||
|
|
||||||
|
events.push(ecEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a single IcsEvent to EventCalendarEvent
|
||||||
|
*/
|
||||||
|
public icsEventToEventCalendarEvent(
|
||||||
|
icsEvent: IcsEvent,
|
||||||
|
calendarUrl: string,
|
||||||
|
eventUrl?: string,
|
||||||
|
etag?: string,
|
||||||
|
options?: CalDavToEventCalendarOptions
|
||||||
|
): EventCalendarEvent {
|
||||||
|
const opts = { ...this.defaultOptions, ...options }
|
||||||
|
|
||||||
|
// Generate unique ID
|
||||||
|
const id = opts.eventIdGenerator
|
||||||
|
? opts.eventIdGenerator(icsEvent, calendarUrl)
|
||||||
|
: this.generateEventId(icsEvent)
|
||||||
|
|
||||||
|
// Determine colors
|
||||||
|
const calendarColor = opts.calendarColors?.get(calendarUrl)
|
||||||
|
const backgroundColor = calendarColor ?? opts.defaultEventColor
|
||||||
|
const textColor = opts.defaultTextColor
|
||||||
|
|
||||||
|
// Convert dates
|
||||||
|
const start = this.icsDateToJsDate(icsEvent.start)
|
||||||
|
const end = icsEvent.end
|
||||||
|
? this.icsDateToJsDate(icsEvent.end)
|
||||||
|
: icsEvent.duration
|
||||||
|
? this.addIcsDurationToDate(start, icsEvent.duration)
|
||||||
|
: start
|
||||||
|
|
||||||
|
// Determine if all-day event
|
||||||
|
const allDay = icsEvent.start.type === 'DATE'
|
||||||
|
|
||||||
|
// EventCalendar expects ISO strings but interprets them in browser local time
|
||||||
|
// We need to create ISO strings that preserve the local time components
|
||||||
|
// instead of using toISOString() which converts to UTC
|
||||||
|
|
||||||
|
// Build extended props
|
||||||
|
const extendedProps: CalDavExtendedProps = {
|
||||||
|
uid: icsEvent.uid,
|
||||||
|
calendarUrl,
|
||||||
|
eventUrl,
|
||||||
|
etag,
|
||||||
|
recurrenceRule: icsEvent.recurrenceRule,
|
||||||
|
recurrenceId: icsEvent.recurrenceId?.value.date,
|
||||||
|
isRecurringInstance: !!icsEvent.recurrenceId,
|
||||||
|
timezone: icsEvent.start.local?.timezone,
|
||||||
|
sequence: icsEvent.sequence,
|
||||||
|
status: icsEvent.status,
|
||||||
|
location: icsEvent.location,
|
||||||
|
description: icsEvent.description,
|
||||||
|
organizer: icsEvent.organizer
|
||||||
|
? {
|
||||||
|
email: icsEvent.organizer.email,
|
||||||
|
displayName: icsEvent.organizer.name,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
attendees: this.deduplicateAttendees(icsEvent.attendees?.map((att) => ({
|
||||||
|
email: att.email,
|
||||||
|
displayName: att.name,
|
||||||
|
role: att.role as ExtendedAttendee['role'],
|
||||||
|
status: att.partstat as ExtendedAttendee['status'],
|
||||||
|
rsvp: att.rsvp,
|
||||||
|
}))),
|
||||||
|
categories: icsEvent.categories,
|
||||||
|
priority: icsEvent.priority != null ? Number(icsEvent.priority) : undefined,
|
||||||
|
url: icsEvent.url,
|
||||||
|
created: icsEvent.created ? this.icsDateToJsDate(icsEvent.created) : undefined,
|
||||||
|
lastModified: icsEvent.lastModified ? this.icsDateToJsDate(icsEvent.lastModified) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any custom extended props
|
||||||
|
if (opts.extendedPropsExtractor) {
|
||||||
|
Object.assign(extendedProps, opts.extendedPropsExtractor(icsEvent))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
start: allDay ? this.dateToDateOnlyString(start) : this.dateToLocalISOString(start),
|
||||||
|
end: allDay ? this.dateToDateOnlyString(end) : this.dateToLocalISOString(end),
|
||||||
|
allDay,
|
||||||
|
resourceId: calendarUrl,
|
||||||
|
resourceIds: [calendarUrl],
|
||||||
|
title: icsEvent.summary ?? '',
|
||||||
|
backgroundColor,
|
||||||
|
textColor,
|
||||||
|
editable: true,
|
||||||
|
extendedProps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Date to date-only ISO string (YYYY-MM-DD)
|
||||||
|
* For all-day events, use UTC components since the Date is UTC midnight
|
||||||
|
*/
|
||||||
|
private dateToDateOnlyString(date: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
const year = date.getUTCFullYear()
|
||||||
|
const month = pad(date.getUTCMonth() + 1)
|
||||||
|
const day = pad(date.getUTCDate())
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Date to ISO string preserving local time components
|
||||||
|
* Unlike toISOString() which converts to UTC, this preserves the browser's local time
|
||||||
|
*/
|
||||||
|
private dateToLocalISOString(date: Date): string {
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = pad(date.getMonth() + 1)
|
||||||
|
const day = pad(date.getDate())
|
||||||
|
const hours = pad(date.getHours())
|
||||||
|
const minutes = pad(date.getMinutes())
|
||||||
|
const seconds = pad(date.getSeconds())
|
||||||
|
const ms = date.getMilliseconds().toString().padStart(3, '0')
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert CalDAV calendars to EventCalendar resources
|
||||||
|
*/
|
||||||
|
public toEventCalendarResources(calendars: CalDavCalendar[]): EventCalendarResource[] {
|
||||||
|
return calendars.map((cal) => ({
|
||||||
|
id: cal.url,
|
||||||
|
title: cal.displayName || 'Unnamed Calendar',
|
||||||
|
eventBackgroundColor: cal.color,
|
||||||
|
extendedProps: {
|
||||||
|
description: cal.description,
|
||||||
|
timezone: cal.timezone,
|
||||||
|
ctag: cal.ctag,
|
||||||
|
syncToken: cal.syncToken,
|
||||||
|
components: cal.components,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EventCalendar -> CalDAV Conversions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert EventCalendar event to IcsEvent
|
||||||
|
*/
|
||||||
|
public toIcsEvent(
|
||||||
|
ecEvent: EventCalendarEvent | EventCalendarEventInput,
|
||||||
|
options?: EventCalendarToCalDavOptions
|
||||||
|
): IcsEvent {
|
||||||
|
const opts = options ?? {}
|
||||||
|
const extProps = (ecEvent.extendedProps ?? {}) as Partial<CalDavExtendedProps>
|
||||||
|
|
||||||
|
// Get or generate UID
|
||||||
|
const uid = extProps.uid ?? opts.uidGenerator?.() ?? crypto.randomUUID()
|
||||||
|
|
||||||
|
// Convert dates
|
||||||
|
// For all-day events, parse date strings carefully to avoid timezone issues
|
||||||
|
const parseDate = (dateValue: Date | string, isAllDay: boolean): Date => {
|
||||||
|
if (dateValue instanceof Date) {
|
||||||
|
return dateValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all-day and string format is YYYY-MM-DD, parse components directly as UTC
|
||||||
|
// All-day events in ICS are stored as UTC midnight
|
||||||
|
if (isAllDay && typeof dateValue === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
|
||||||
|
const [year, month, day] = dateValue.split('-').map(Number)
|
||||||
|
const result = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
console.log('[EventCalendarAdapter] Parsing all-day date:', dateValue, '→', result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(dateValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllDay = ecEvent.allDay ?? false
|
||||||
|
console.log('[EventCalendarAdapter] toIcsEvent - allDay:', isAllDay, 'start:', ecEvent.start, 'end:', ecEvent.end)
|
||||||
|
|
||||||
|
const startDate = parseDate(ecEvent.start, isAllDay)
|
||||||
|
const endDate = ecEvent.end
|
||||||
|
? parseDate(ecEvent.end, isAllDay)
|
||||||
|
: startDate
|
||||||
|
|
||||||
|
console.log('[EventCalendarAdapter] Parsed dates - start:', startDate, 'end:', endDate)
|
||||||
|
|
||||||
|
// Determine timezone
|
||||||
|
const timezone = extProps.timezone ?? opts.defaultTimezone
|
||||||
|
|
||||||
|
// If event has extProps.timezone, it came from the server and dates are already "fake UTC"
|
||||||
|
const isFakeUtc = !!extProps.timezone
|
||||||
|
|
||||||
|
// Build IcsEvent
|
||||||
|
// Note: EventCalendar already uses exclusive end dates for all-day events
|
||||||
|
const icsEvent: IcsEvent = {
|
||||||
|
uid,
|
||||||
|
stamp: { date: new Date() },
|
||||||
|
start: this.jsDateToIcsDate(startDate, isAllDay, timezone, isFakeUtc),
|
||||||
|
end: this.jsDateToIcsDate(endDate, isAllDay, timezone, isFakeUtc),
|
||||||
|
summary: typeof ecEvent.title === 'string' ? ecEvent.title : '',
|
||||||
|
sequence: (extProps.sequence ?? 0) + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional properties from extended props
|
||||||
|
if (extProps.location) icsEvent.location = extProps.location
|
||||||
|
if (extProps.description) icsEvent.description = extProps.description
|
||||||
|
if (extProps.status && this.isValidStatus(extProps.status)) {
|
||||||
|
icsEvent.status = extProps.status
|
||||||
|
}
|
||||||
|
if (extProps.categories) icsEvent.categories = extProps.categories
|
||||||
|
if (extProps.priority != null) icsEvent.priority = String(extProps.priority)
|
||||||
|
if (extProps.url) icsEvent.url = extProps.url
|
||||||
|
if (extProps.created) icsEvent.created = this.jsDateToIcsDate(extProps.created, false, timezone, isFakeUtc)
|
||||||
|
if (extProps.recurrenceRule) icsEvent.recurrenceRule = extProps.recurrenceRule
|
||||||
|
|
||||||
|
// Convert recurrence ID for recurring instances
|
||||||
|
if (extProps.recurrenceId) {
|
||||||
|
icsEvent.recurrenceId = {
|
||||||
|
value: this.jsDateToIcsDate(extProps.recurrenceId, ecEvent.allDay ?? false, timezone, isFakeUtc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert organizer
|
||||||
|
if (extProps.organizer) {
|
||||||
|
const organizer: IcsOrganizer = {
|
||||||
|
email: extProps.organizer.email,
|
||||||
|
}
|
||||||
|
if (extProps.organizer.displayName) {
|
||||||
|
organizer.name = extProps.organizer.displayName
|
||||||
|
}
|
||||||
|
icsEvent.organizer = organizer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert attendees
|
||||||
|
if (extProps.attendees && extProps.attendees.length > 0) {
|
||||||
|
icsEvent.attendees = extProps.attendees.map((att): IcsAttendee => ({
|
||||||
|
email: att.email,
|
||||||
|
name: att.displayName,
|
||||||
|
role: att.role,
|
||||||
|
partstat: (att.status as IcsAttendee['partstat']) ?? 'NEEDS-ACTION',
|
||||||
|
rsvp: att.rsvp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return icsEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a status string is a valid ICS event status
|
||||||
|
*/
|
||||||
|
private isValidStatus(status: string): status is 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED' {
|
||||||
|
return ['TENTATIVE', 'CONFIRMED', 'CANCELLED'].includes(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert EventCalendar event to a full IcsCalendar object
|
||||||
|
*/
|
||||||
|
public toIcsCalendar(
|
||||||
|
ecEvent: EventCalendarEvent | EventCalendarEventInput,
|
||||||
|
options?: EventCalendarToCalDavOptions
|
||||||
|
): IcsCalendar {
|
||||||
|
const icsEvent = this.toIcsEvent(ecEvent, options)
|
||||||
|
|
||||||
|
return {
|
||||||
|
prodId: '-//EventCalendarAdapter//NONSGML v1.0//EN',
|
||||||
|
version: '2.0',
|
||||||
|
events: [icsEvent],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the calendar URL from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getCalendarUrl(ecEvent: EventCalendarEvent, defaultCalendarUrl?: string): string | undefined {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.calendarUrl ?? defaultCalendarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the event URL from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getEventUrl(ecEvent: EventCalendarEvent): string | undefined {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.eventUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ETag from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getEtag(ecEvent: EventCalendarEvent): string | undefined {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.etag
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an EventCalendar event is a recurring instance
|
||||||
|
*/
|
||||||
|
public isRecurringInstance(ecEvent: EventCalendarEvent): boolean {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.isRecurringInstance ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an EventCalendar event has recurrence rule
|
||||||
|
*/
|
||||||
|
public hasRecurrenceRule(ecEvent: EventCalendarEvent): boolean {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return !!extProps?.recurrenceRule
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Duration Conversions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert EventCalendar duration to seconds
|
||||||
|
*/
|
||||||
|
public 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 EventCalendar duration
|
||||||
|
*/
|
||||||
|
public 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate duration between two EventCalendar events (for drop/resize delta)
|
||||||
|
*/
|
||||||
|
public calculateDelta(
|
||||||
|
oldEvent: EventCalendarEvent,
|
||||||
|
newEvent: EventCalendarEvent
|
||||||
|
): { startDelta: EventCalendarDuration; endDelta: EventCalendarDuration } {
|
||||||
|
const oldStart = oldEvent.start instanceof Date ? oldEvent.start : new Date(oldEvent.start)
|
||||||
|
const newStart = newEvent.start instanceof Date ? newEvent.start : new Date(newEvent.start)
|
||||||
|
const oldEnd = oldEvent.end instanceof Date ? oldEvent.end : new Date(oldEvent.end ?? oldStart)
|
||||||
|
const newEnd = newEvent.end instanceof Date ? newEvent.end : new Date(newEvent.end ?? newStart)
|
||||||
|
|
||||||
|
const startDeltaMs = newStart.getTime() - oldStart.getTime()
|
||||||
|
const endDeltaMs = newEnd.getTime() - oldEnd.getTime()
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDelta: this.secondsToDuration(Math.round(startDeltaMs / 1000)),
|
||||||
|
endDelta: this.secondsToDuration(Math.round(endDeltaMs / 1000)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attendee/Organizer Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get attendees from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getAttendees(ecEvent: EventCalendarEvent): ExtendedAttendee[] {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.attendees ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get organizer from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getOrganizer(ecEvent: EventCalendarEvent): ExtendedOrganizer | undefined {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.organizer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set attendees on an EventCalendar event (returns new event)
|
||||||
|
*/
|
||||||
|
public setAttendees(
|
||||||
|
ecEvent: EventCalendarEvent,
|
||||||
|
attendees: ExtendedAttendee[]
|
||||||
|
): EventCalendarEvent {
|
||||||
|
return {
|
||||||
|
...ecEvent,
|
||||||
|
extendedProps: {
|
||||||
|
...(ecEvent.extendedProps ?? {}),
|
||||||
|
attendees,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set organizer on an EventCalendar event (returns new event)
|
||||||
|
*/
|
||||||
|
public setOrganizer(
|
||||||
|
ecEvent: EventCalendarEvent,
|
||||||
|
organizer: ExtendedOrganizer
|
||||||
|
): EventCalendarEvent {
|
||||||
|
return {
|
||||||
|
...ecEvent,
|
||||||
|
extendedProps: {
|
||||||
|
...(ecEvent.extendedProps ?? {}),
|
||||||
|
organizer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Property Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get location from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getLocation(ecEvent: EventCalendarEvent): string | undefined {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.location
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get description from an EventCalendar event
|
||||||
|
*/
|
||||||
|
public getDescription(ecEvent: EventCalendarEvent): string | undefined {
|
||||||
|
const extProps = ecEvent.extendedProps as Partial<CalDavExtendedProps> | undefined
|
||||||
|
return extProps?.description
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set location on an EventCalendar event (returns new event)
|
||||||
|
*/
|
||||||
|
public setLocation(ecEvent: EventCalendarEvent, location: string): EventCalendarEvent {
|
||||||
|
return {
|
||||||
|
...ecEvent,
|
||||||
|
extendedProps: {
|
||||||
|
...(ecEvent.extendedProps ?? {}),
|
||||||
|
location,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set description on an EventCalendar event (returns new event)
|
||||||
|
*/
|
||||||
|
public setDescription(ecEvent: EventCalendarEvent, description: string): EventCalendarEvent {
|
||||||
|
return {
|
||||||
|
...ecEvent,
|
||||||
|
extendedProps: {
|
||||||
|
...(ecEvent.extendedProps ?? {}),
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Color Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a color map from calendars
|
||||||
|
*/
|
||||||
|
public createCalendarColorMap(calendars: CalDavCalendar[]): Map<string, string> {
|
||||||
|
const colorMap = new Map<string, string>()
|
||||||
|
for (const cal of calendars) {
|
||||||
|
if (cal.color) {
|
||||||
|
colorMap.set(cal.url, cal.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return colorMap
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply calendar colors to events
|
||||||
|
*/
|
||||||
|
public applyCalendarColors(
|
||||||
|
events: EventCalendarEvent[],
|
||||||
|
calendarColors: Map<string, string>
|
||||||
|
): EventCalendarEvent[] {
|
||||||
|
return events.map((event) => {
|
||||||
|
const calendarUrl = this.getCalendarUrl(event)
|
||||||
|
if (calendarUrl && calendarColors.has(calendarUrl)) {
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
backgroundColor: calendarColors.get(calendarUrl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Helper Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate attendees by email, keeping the one with the most "advanced" status.
|
||||||
|
*
|
||||||
|
* This fixes a SabreDAV bug where REPLY processing can add duplicate attendees
|
||||||
|
* instead of updating the existing one (due to email case sensitivity or format differences).
|
||||||
|
*
|
||||||
|
* Status priority (most to least advanced): ACCEPTED > TENTATIVE > DECLINED > NEEDS-ACTION
|
||||||
|
*/
|
||||||
|
private deduplicateAttendees(attendees?: ExtendedAttendee[]): ExtendedAttendee[] | undefined {
|
||||||
|
if (!attendees || attendees.length === 0) {
|
||||||
|
return attendees
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status priority map (higher = more definitive response)
|
||||||
|
const statusPriority: Record<string, number> = {
|
||||||
|
'ACCEPTED': 4,
|
||||||
|
'TENTATIVE': 3,
|
||||||
|
'DECLINED': 2,
|
||||||
|
'NEEDS-ACTION': 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusPriority = (status?: string): number => {
|
||||||
|
return statusPriority[status ?? 'NEEDS-ACTION'] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by normalized email (lowercase)
|
||||||
|
const byEmail = new Map<string, ExtendedAttendee>()
|
||||||
|
|
||||||
|
for (const attendee of attendees) {
|
||||||
|
const normalizedEmail = attendee.email.toLowerCase().trim()
|
||||||
|
const existing = byEmail.get(normalizedEmail)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
// First occurrence of this email
|
||||||
|
byEmail.set(normalizedEmail, attendee)
|
||||||
|
} else {
|
||||||
|
// Duplicate found - keep the one with higher status priority
|
||||||
|
const existingPriority = getStatusPriority(existing.status)
|
||||||
|
const newPriority = getStatusPriority(attendee.status)
|
||||||
|
|
||||||
|
if (newPriority > existingPriority) {
|
||||||
|
// New attendee has more definitive status - replace
|
||||||
|
byEmail.set(normalizedEmail, attendee)
|
||||||
|
} else if (newPriority === existingPriority && attendee.displayName && !existing.displayName) {
|
||||||
|
// Same status but new one has display name - prefer it
|
||||||
|
byEmail.set(normalizedEmail, attendee)
|
||||||
|
}
|
||||||
|
// Otherwise keep existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byEmail.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique event ID from IcsEvent
|
||||||
|
*/
|
||||||
|
private generateEventId(icsEvent: IcsEvent): string {
|
||||||
|
if (icsEvent.recurrenceId) {
|
||||||
|
return `${icsEvent.uid}_${icsEvent.recurrenceId.value.date.getTime()}`
|
||||||
|
}
|
||||||
|
return icsEvent.uid
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert IcsDateObject to JavaScript Date
|
||||||
|
*/
|
||||||
|
private icsDateToJsDate(icsDate: IcsDateObject): Date {
|
||||||
|
// Use local date if available, otherwise use UTC date
|
||||||
|
if (icsDate.local?.date) {
|
||||||
|
return icsDate.local.date
|
||||||
|
}
|
||||||
|
return icsDate.date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert JavaScript Date to IcsDateObject
|
||||||
|
*
|
||||||
|
* IMPORTANT: EventCalendar returns dates in browser local time.
|
||||||
|
* ts-ics uses date.getUTCHours() etc. to generate ICS, so we need to
|
||||||
|
* create a "fake UTC" date where UTC components match the local time we want.
|
||||||
|
*
|
||||||
|
* Example: User in Paris (UTC+1) drags event to 15:00 local
|
||||||
|
* - Input date: Date representing 15:00 local (14:00 UTC internally)
|
||||||
|
* - We want ICS: DTSTART:20260121T150000Z or DTSTART;TZID=Europe/Paris:20260121T150000
|
||||||
|
* - Solution: Create date where getUTCHours() = 15
|
||||||
|
*
|
||||||
|
* @param date - The date to convert
|
||||||
|
* @param allDay - Whether this is an all-day event
|
||||||
|
* @param timezone - The timezone to use
|
||||||
|
* @param isFakeUtc - If true, date is already "fake UTC" (use getUTC* methods)
|
||||||
|
*/
|
||||||
|
private jsDateToIcsDate(date: Date, allDay: boolean, timezone?: string, isFakeUtc = false): IcsDateObject {
|
||||||
|
if (allDay) {
|
||||||
|
// For all-day events, use DATE type (no time component)
|
||||||
|
// Create a UTC date with the local date components
|
||||||
|
const utcDate = new Date(Date.UTC(
|
||||||
|
isFakeUtc ? date.getUTCFullYear() : date.getFullYear(),
|
||||||
|
isFakeUtc ? date.getUTCMonth() : date.getMonth(),
|
||||||
|
isFakeUtc ? date.getUTCDate() : date.getDate()
|
||||||
|
))
|
||||||
|
return {
|
||||||
|
type: 'DATE',
|
||||||
|
date: utcDate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For timed events, create a "fake UTC" date where UTC components = local components
|
||||||
|
// This ensures ts-ics generates the correct time in the ICS output
|
||||||
|
const fakeUtcDate = isFakeUtc
|
||||||
|
? date // Already fake UTC, use as-is
|
||||||
|
: new Date(Date.UTC(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
date.getHours(),
|
||||||
|
date.getMinutes(),
|
||||||
|
date.getSeconds()
|
||||||
|
))
|
||||||
|
|
||||||
|
const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
const tzOffset = this.getTimezoneOffset(isFakeUtc ? date : new Date(
|
||||||
|
date.getFullYear(),
|
||||||
|
date.getMonth(),
|
||||||
|
date.getDate(),
|
||||||
|
date.getHours(),
|
||||||
|
date.getMinutes()
|
||||||
|
), tz)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'DATE-TIME',
|
||||||
|
date: fakeUtcDate,
|
||||||
|
local: {
|
||||||
|
date: fakeUtcDate,
|
||||||
|
timezone: tz,
|
||||||
|
tzoffset: tzOffset,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timezone offset string for a date and timezone
|
||||||
|
* Returns format like "+0200" or "-0500"
|
||||||
|
*/
|
||||||
|
public getTimezoneOffset(date: Date, timezone: string): string {
|
||||||
|
try {
|
||||||
|
// Create formatter for the timezone
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'longOffset',
|
||||||
|
})
|
||||||
|
const parts = formatter.formatToParts(date)
|
||||||
|
const tzPart = parts.find((p) => p.type === 'timeZoneName')
|
||||||
|
|
||||||
|
if (tzPart?.value) {
|
||||||
|
// Convert "GMT+02:00" to "+0200"
|
||||||
|
const match = tzPart.value.match(/GMT([+-])(\d{1,2}):?(\d{2})?/)
|
||||||
|
if (match) {
|
||||||
|
const sign = match[1]
|
||||||
|
const hours = match[2].padStart(2, '0')
|
||||||
|
const minutes = (match[3] || '00').padStart(2, '0')
|
||||||
|
return `${sign}${hours}${minutes}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '+0000'
|
||||||
|
} catch {
|
||||||
|
return '+0000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add ICS duration object to a date
|
||||||
|
*/
|
||||||
|
private addIcsDurationToDate(date: Date, duration: IcsDuration): Date {
|
||||||
|
const result = new Date(date)
|
||||||
|
|
||||||
|
if (duration.weeks) result.setDate(result.getDate() + duration.weeks * 7)
|
||||||
|
if (duration.days) result.setDate(result.getDate() + duration.days)
|
||||||
|
if (duration.hours) result.setHours(result.getHours() + duration.hours)
|
||||||
|
if (duration.minutes) result.setMinutes(result.getMinutes() + duration.minutes)
|
||||||
|
if (duration.seconds) result.setSeconds(result.getSeconds() + duration.seconds)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add string duration to a date (ISO 8601 format)
|
||||||
|
*/
|
||||||
|
private addDurationToDate(date: Date, duration: string): Date {
|
||||||
|
// Parse ISO 8601 duration (P1D, PT1H, etc.)
|
||||||
|
const result = new Date(date)
|
||||||
|
|
||||||
|
const regex = /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/
|
||||||
|
const match = duration.match(regex)
|
||||||
|
|
||||||
|
if (!match) return result
|
||||||
|
|
||||||
|
const [, years, months, weeks, days, hours, minutes, seconds] = match
|
||||||
|
|
||||||
|
if (years) result.setFullYear(result.getFullYear() + parseInt(years))
|
||||||
|
if (months) result.setMonth(result.getMonth() + parseInt(months))
|
||||||
|
if (weeks) result.setDate(result.getDate() + parseInt(weeks) * 7)
|
||||||
|
if (days) result.setDate(result.getDate() + parseInt(days))
|
||||||
|
if (hours) result.setHours(result.getHours() + parseInt(hours))
|
||||||
|
if (minutes) result.setMinutes(result.getMinutes() + parseInt(minutes))
|
||||||
|
if (seconds) result.setSeconds(result.getSeconds() + parseInt(seconds))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory & Singleton
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let _adapterInstance: EventCalendarAdapter | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a singleton adapter instance
|
||||||
|
*/
|
||||||
|
export function getEventCalendarAdapter(): EventCalendarAdapter {
|
||||||
|
if (!_adapterInstance) {
|
||||||
|
_adapterInstance = new EventCalendarAdapter()
|
||||||
|
}
|
||||||
|
return _adapterInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new adapter instance
|
||||||
|
*/
|
||||||
|
export function createEventCalendarAdapter(): EventCalendarAdapter {
|
||||||
|
return new EventCalendarAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Standalone Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick conversion from CalDAV events to EventCalendar format
|
||||||
|
*/
|
||||||
|
export function caldavToEventCalendar(
|
||||||
|
caldavEvents: CalDavEvent[],
|
||||||
|
options?: CalDavToEventCalendarOptions
|
||||||
|
): EventCalendarEvent[] {
|
||||||
|
return getEventCalendarAdapter().toEventCalendarEvents(caldavEvents, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick conversion from EventCalendar event to IcsEvent
|
||||||
|
*/
|
||||||
|
export function eventCalendarToIcs(
|
||||||
|
ecEvent: EventCalendarEvent | EventCalendarEventInput,
|
||||||
|
options?: EventCalendarToCalDavOptions
|
||||||
|
): IcsEvent {
|
||||||
|
return getEventCalendarAdapter().toIcsEvent(ecEvent, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick conversion from EventCalendar event to IcsCalendar
|
||||||
|
*/
|
||||||
|
export function eventCalendarToIcsCalendar(
|
||||||
|
ecEvent: EventCalendarEvent | EventCalendarEventInput,
|
||||||
|
options?: EventCalendarToCalDavOptions
|
||||||
|
): IcsCalendar {
|
||||||
|
return getEventCalendarAdapter().toIcsCalendar(ecEvent, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick conversion from CalDAV calendars to EventCalendar resources
|
||||||
|
*/
|
||||||
|
export function calendarsToResources(calendars: CalDavCalendar[]): EventCalendarResource[] {
|
||||||
|
return getEventCalendarAdapter().toEventCalendarResources(calendars)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import ICAL from 'ical.js'
|
||||||
|
|
||||||
|
export class VCardComponent {
|
||||||
|
|
||||||
|
public component: ICAL.Component
|
||||||
|
|
||||||
|
public constructor(component: ICAL.Component) {
|
||||||
|
if (component) this.component = component
|
||||||
|
else this.component = new ICAL.Component('vcard')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
get version() { return this._getProp('version') as string }
|
||||||
|
set version(value: string) { this._setProp('version', value) }
|
||||||
|
|
||||||
|
get uid() { return this._getProp('uid') as string }
|
||||||
|
set uid(value: string) { this._setProp('uid', value) }
|
||||||
|
|
||||||
|
get email() { return this._getProp('email') as (string | null) }
|
||||||
|
set email(value: string | null) { this._setProp('email', value) }
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this.version.startsWith('2')
|
||||||
|
? (this._getProp('n') as string[]).filter(n => !!n).reverse().join(' ')
|
||||||
|
: this._getProp('fn') as string
|
||||||
|
}
|
||||||
|
set name(value: string) {
|
||||||
|
if (this.version.startsWith('2')) {
|
||||||
|
const [name, family] = value.split(' ', 1)
|
||||||
|
this._setProp('n', [family ?? '', name, '', '', ''])
|
||||||
|
} else {
|
||||||
|
this._setProp('fn', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setProp(name: string, value: unknown) {
|
||||||
|
this.component.updatePropertyWithValue(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getProp(name: string): unknown {
|
||||||
|
return this.component.getFirstPropertyValue(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user