caldav...
This commit is contained in:
500
lib/servers/calendar/calbox/calbox.v
Normal file
500
lib/servers/calendar/calbox/calbox.v
Normal file
@@ -0,0 +1,500 @@
|
||||
module calbox
|
||||
|
||||
// Represents a calendar attendee
|
||||
@[heap]
|
||||
pub struct Attendee {
|
||||
pub mut:
|
||||
email string
|
||||
name string
|
||||
role AttendeeRole
|
||||
partstat AttendeePartStat
|
||||
rsvp bool
|
||||
delegated_to []string
|
||||
delegated_from []string
|
||||
}
|
||||
|
||||
// Represents a recurrence rule
|
||||
@[heap]
|
||||
pub struct RecurrenceRule {
|
||||
pub mut:
|
||||
frequency RecurrenceFrequency
|
||||
interval int // How often the recurrence rule repeats
|
||||
count ?int // Number of occurrences
|
||||
until ?i64 // End date timestamp
|
||||
by_second []int
|
||||
by_minute []int
|
||||
by_hour []int
|
||||
by_day []string // MO, TU, WE, TH, FR, SA, SU with optional +/-prefix
|
||||
by_monthday []int
|
||||
by_yearday []int
|
||||
by_weekno []int
|
||||
by_month []int
|
||||
by_setpos []int
|
||||
week_start string // MO, TU, WE, TH, FR, SA, SU
|
||||
}
|
||||
|
||||
// Represents an alarm/reminder
|
||||
@[heap]
|
||||
pub struct Alarm {
|
||||
pub mut:
|
||||
action AlarmAction
|
||||
trigger string // When the alarm triggers (relative or absolute)
|
||||
description string // Used for DISPLAY and EMAIL
|
||||
summary string // Used for EMAIL
|
||||
attendees []Attendee // Used for EMAIL
|
||||
attach []string // Used for AUDIO and EMAIL attachments
|
||||
}
|
||||
|
||||
// Base calendar component fields
|
||||
@[heap]
|
||||
pub struct CalendarComponent {
|
||||
pub mut:
|
||||
uid string
|
||||
etag string // Entity tag for change tracking
|
||||
created i64 // Creation timestamp
|
||||
modified i64 // Last modified timestamp
|
||||
summary string
|
||||
description string
|
||||
categories []string
|
||||
status ComponentStatus
|
||||
class ComponentClass
|
||||
url string
|
||||
location string
|
||||
geo ?GeoLocation
|
||||
alarms []Alarm
|
||||
}
|
||||
|
||||
// Geographic location
|
||||
pub struct GeoLocation {
|
||||
pub mut:
|
||||
latitude f64
|
||||
longitude f64
|
||||
}
|
||||
|
||||
// Represents an event
|
||||
@[heap]
|
||||
pub struct Event {
|
||||
pub mut:
|
||||
CalendarComponent
|
||||
start_time i64
|
||||
end_time ?i64 // Either end_time or duration must be set
|
||||
duration ?string // ISO 8601 duration format
|
||||
rrule ?RecurrenceRule
|
||||
rdate []i64 // Additional recurrence dates
|
||||
exdate []i64 // Dates to exclude
|
||||
transp EventTransp
|
||||
attendees []Attendee
|
||||
organizer ?Attendee
|
||||
}
|
||||
|
||||
// Represents a todo/task
|
||||
@[heap]
|
||||
pub struct Todo {
|
||||
pub mut:
|
||||
CalendarComponent
|
||||
start_time ?i64 // Optional start time
|
||||
due_time ?i64 // When the todo is due
|
||||
duration ?string // Estimated duration
|
||||
completed ?i64 // When the todo was completed
|
||||
percent ?int // Percent complete (0-100)
|
||||
rrule ?RecurrenceRule
|
||||
attendees []Attendee
|
||||
organizer ?Attendee
|
||||
}
|
||||
|
||||
// Represents a journal entry
|
||||
@[heap]
|
||||
pub struct Journal {
|
||||
pub mut:
|
||||
CalendarComponent
|
||||
start_time i64 // Date of the journal entry
|
||||
attendees []Attendee
|
||||
organizer ?Attendee
|
||||
}
|
||||
|
||||
// Represents a calendar object resource (event, todo, journal)
|
||||
@[heap]
|
||||
pub struct CalendarObject {
|
||||
pub mut:
|
||||
comp_type string // VEVENT, VTODO, VJOURNAL
|
||||
event ?Event // Set if comp_type is VEVENT
|
||||
todo ?Todo // Set if comp_type is VTODO
|
||||
journal ?Journal // Set if comp_type is VJOURNAL
|
||||
}
|
||||
|
||||
// Represents a calendar collection
|
||||
@[heap]
|
||||
pub struct CalBox {
|
||||
mut:
|
||||
name string
|
||||
objects []CalendarObject
|
||||
description string
|
||||
timezone string // Calendar timezone as iCalendar VTIMEZONE
|
||||
read_only bool // Whether calendar is read-only
|
||||
|
||||
// Properties from CalDAV spec
|
||||
supported_components []string // e.g. ["VEVENT", "VTODO"]
|
||||
min_date_time string // Earliest date allowed
|
||||
max_date_time string // Latest date allowed
|
||||
max_instances int // Max recurrence instances
|
||||
max_attendees int // Max attendees per instance
|
||||
max_resource_size int // Max size of calendar object
|
||||
}
|
||||
|
||||
// Creates a new calendar collection
|
||||
pub fn new(name string) &CalBox {
|
||||
return &CalBox{
|
||||
name: name
|
||||
objects: []CalendarObject{}
|
||||
supported_components: ['VEVENT', 'VTODO', 'VJOURNAL']
|
||||
min_date_time: '19000101T000000Z'
|
||||
max_date_time: '20491231T235959Z'
|
||||
max_instances: 1000
|
||||
max_attendees: 100
|
||||
max_resource_size: 1024 * 1024 // 1MB
|
||||
}
|
||||
}
|
||||
|
||||
// Returns all calendar objects
|
||||
pub fn (mut self CalBox) list() ![]CalendarObject {
|
||||
return self.objects
|
||||
}
|
||||
|
||||
// Gets a calendar object by UID
|
||||
pub fn (mut self CalBox) get_by_uid(uid string) !CalendarObject {
|
||||
for obj in self.objects {
|
||||
match obj.comp_type {
|
||||
'VEVENT' { if obj.event?.uid == uid { return obj } }
|
||||
'VTODO' { if obj.todo?.uid == uid { return obj } }
|
||||
'VJOURNAL' { if obj.journal?.uid == uid { return obj } }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
return error('Calendar object with UID ${uid} not found')
|
||||
}
|
||||
|
||||
// Deletes a calendar object by UID
|
||||
pub fn (mut self CalBox) delete(uid string) ! {
|
||||
if self.read_only {
|
||||
return error('Calendar is read-only')
|
||||
}
|
||||
|
||||
for i, obj in self.objects {
|
||||
mut found := false
|
||||
match obj.comp_type {
|
||||
'VEVENT' { found = obj.event?.uid == uid }
|
||||
'VTODO' { found = obj.todo?.uid == uid }
|
||||
'VJOURNAL' { found = obj.journal?.uid == uid }
|
||||
else {}
|
||||
}
|
||||
if found {
|
||||
self.objects.delete(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
return error('Calendar object with UID ${uid} not found')
|
||||
}
|
||||
|
||||
// Validates a calendar object
|
||||
fn (mut self CalBox) validate(obj CalendarObject) ! {
|
||||
// Validate component type is supported
|
||||
if obj.comp_type !in self.supported_components {
|
||||
return error('Calendar component type ${obj.comp_type} not supported')
|
||||
}
|
||||
|
||||
// Validate based on component type
|
||||
match obj.comp_type {
|
||||
'VEVENT' {
|
||||
event := obj.event or { return error('VEVENT component missing') }
|
||||
|
||||
// Validate required fields
|
||||
if event.uid.len == 0 {
|
||||
return error('UID is required')
|
||||
}
|
||||
if event.start_time == 0 {
|
||||
return error('Start time is required')
|
||||
}
|
||||
if event.end_time == none && event.duration == none {
|
||||
return error('Either end time or duration is required')
|
||||
}
|
||||
|
||||
// Validate attendees count
|
||||
if event.attendees.len > self.max_attendees {
|
||||
return error('Exceeds maximum attendees limit of ${self.max_attendees}')
|
||||
}
|
||||
|
||||
// Validate recurrence
|
||||
if event.rrule != none {
|
||||
// TODO: Validate max instances once recurrence expansion is implemented
|
||||
}
|
||||
}
|
||||
'VTODO' {
|
||||
todo := obj.todo or { return error('VTODO component missing') }
|
||||
|
||||
// Validate required fields
|
||||
if todo.uid.len == 0 {
|
||||
return error('UID is required')
|
||||
}
|
||||
|
||||
// Validate attendees count
|
||||
if todo.attendees.len > self.max_attendees {
|
||||
return error('Exceeds maximum attendees limit of ${self.max_attendees}')
|
||||
}
|
||||
}
|
||||
'VJOURNAL' {
|
||||
journal := obj.journal or { return error('VJOURNAL component missing') }
|
||||
|
||||
// Validate required fields
|
||||
if journal.uid.len == 0 {
|
||||
return error('UID is required')
|
||||
}
|
||||
if journal.start_time == 0 {
|
||||
return error('Start time is required')
|
||||
}
|
||||
|
||||
// Validate attendees count
|
||||
if journal.attendees.len > self.max_attendees {
|
||||
return error('Exceeds maximum attendees limit of ${self.max_attendees}')
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
// Adds or updates a calendar object
|
||||
pub fn (mut self CalBox) put(obj CalendarObject) ! {
|
||||
if self.read_only {
|
||||
return error('Calendar is read-only')
|
||||
}
|
||||
|
||||
// Validate the object
|
||||
self.validate(obj) or { return err }
|
||||
|
||||
mut found := false
|
||||
for i, existing in self.objects {
|
||||
mut match_uid := false
|
||||
match obj.comp_type {
|
||||
'VEVENT' { match_uid = obj.event?.uid == existing.event?.uid }
|
||||
'VTODO' { match_uid = obj.todo?.uid == existing.todo?.uid }
|
||||
'VJOURNAL' { match_uid = obj.journal?.uid == existing.journal?.uid }
|
||||
else {}
|
||||
}
|
||||
if match_uid {
|
||||
self.objects[i] = obj
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
self.objects << obj
|
||||
}
|
||||
}
|
||||
|
||||
@[params]
|
||||
pub struct TimeRange {
|
||||
pub mut:
|
||||
start i64 // UTC timestamp
|
||||
end i64 // UTC timestamp
|
||||
}
|
||||
|
||||
// Checks if a timestamp falls within a time range
|
||||
fn is_in_range(ts i64, tr TimeRange) bool {
|
||||
return ts >= tr.start && ts < tr.end
|
||||
}
|
||||
|
||||
// Checks if an event overlaps with a time range
|
||||
fn (event Event) overlaps(tr TimeRange) bool {
|
||||
// Get end time from either end_time or duration
|
||||
mut end_ts := event.end_time or {
|
||||
// TODO: Add duration parsing to get actual end time
|
||||
event.start_time + 3600 // Default 1 hour if no end/duration
|
||||
}
|
||||
|
||||
// Check basic overlap
|
||||
if is_in_range(event.start_time, tr) || is_in_range(end_ts, tr) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check recurrences if any
|
||||
if rule := event.rrule {
|
||||
// TODO: Implement recurrence expansion
|
||||
// For now just check if the rule's until date (if any) is after range start
|
||||
if until := rule.until {
|
||||
return until >= tr.start
|
||||
}
|
||||
return true // Infinite recurrence overlaps everything
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Checks if a todo overlaps with a time range
|
||||
fn (todo Todo) overlaps(tr TimeRange) bool {
|
||||
if start := todo.start_time {
|
||||
if is_in_range(start, tr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if due := todo.due_time {
|
||||
if is_in_range(due, tr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if completed := todo.completed {
|
||||
if is_in_range(completed, tr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Checks if a journal entry overlaps with a time range
|
||||
fn (journal Journal) overlaps(tr TimeRange) bool {
|
||||
return is_in_range(journal.start_time, tr)
|
||||
}
|
||||
|
||||
// Finds calendar objects in the given time range
|
||||
pub fn (mut self CalBox) find_by_time(tr TimeRange) ![]CalendarObject {
|
||||
mut results := []CalendarObject{}
|
||||
|
||||
for obj in self.objects {
|
||||
match obj.comp_type {
|
||||
'VEVENT' {
|
||||
if event := obj.event {
|
||||
// Get all instances in the time range
|
||||
instances := event.get_instances(tr)!
|
||||
if instances.len > 0 {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
}
|
||||
'VTODO' {
|
||||
if todo := obj.todo {
|
||||
// Check todo timing
|
||||
mut overlaps := false
|
||||
|
||||
// Check start time if set
|
||||
if start := todo.start_time {
|
||||
if is_in_range(start, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check due time if set
|
||||
if due := todo.due_time {
|
||||
if is_in_range(due, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check completed time if set
|
||||
if completed := todo.completed {
|
||||
if is_in_range(completed, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
// If no timing info, include if created in range
|
||||
if todo.start_time == none && todo.due_time == none && todo.completed == none {
|
||||
if is_in_range(todo.created, tr) {
|
||||
overlaps = true
|
||||
}
|
||||
}
|
||||
|
||||
if overlaps {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
}
|
||||
'VJOURNAL' {
|
||||
if journal := obj.journal {
|
||||
// Journal entries are point-in-time
|
||||
if is_in_range(journal.start_time, tr) {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Gets all instances of calendar objects in a time range
|
||||
pub fn (mut self CalBox) get_instances(tr TimeRange) ![]EventInstance {
|
||||
mut instances := []EventInstance{}
|
||||
|
||||
for obj in self.objects {
|
||||
if obj.comp_type == 'VEVENT' {
|
||||
if event := obj.event {
|
||||
event_instances := event.get_instances(tr)!
|
||||
instances << event_instances
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start time
|
||||
instances.sort(a.start_time < b.start_time)
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// Gets free/busy time in a given range
|
||||
pub fn (mut self CalBox) get_freebusy(tr TimeRange) ![]TimeRange {
|
||||
mut busy_ranges := []TimeRange{}
|
||||
|
||||
// Get all event instances in the range
|
||||
instances := self.get_instances(tr)!
|
||||
|
||||
// Convert instances to busy time ranges
|
||||
for instance in instances {
|
||||
// Skip transparent events
|
||||
if instance.original_event.transp == 'TRANSPARENT' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip cancelled events
|
||||
if instance.original_event.status == 'CANCELLED' {
|
||||
continue
|
||||
}
|
||||
|
||||
busy_ranges << TimeRange{
|
||||
start: instance.start_time
|
||||
end: instance.end_time
|
||||
}
|
||||
}
|
||||
|
||||
// Merge overlapping ranges
|
||||
if busy_ranges.len > 0 {
|
||||
busy_ranges.sort(a.start < b.start)
|
||||
mut merged := []TimeRange{}
|
||||
mut current := busy_ranges[0]
|
||||
|
||||
for i := 1; i < busy_ranges.len; i++ {
|
||||
if busy_ranges[i].start <= current.end {
|
||||
// Ranges overlap, extend current range
|
||||
if busy_ranges[i].end > current.end {
|
||||
current.end = busy_ranges[i].end
|
||||
}
|
||||
} else {
|
||||
// No overlap, start new range
|
||||
merged << current
|
||||
current = busy_ranges[i]
|
||||
}
|
||||
}
|
||||
merged << current
|
||||
return merged
|
||||
}
|
||||
|
||||
return busy_ranges
|
||||
}
|
||||
|
||||
// Returns number of calendar objects
|
||||
pub fn (mut self CalBox) len() int {
|
||||
return self.objects.len
|
||||
}
|
||||
267
lib/servers/calendar/calbox/calbox_test.v
Normal file
267
lib/servers/calendar/calbox/calbox_test.v
Normal file
@@ -0,0 +1,267 @@
|
||||
module calbox
|
||||
|
||||
fn test_create_event() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create an event with all fields
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'event1@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
modified: 1708070400
|
||||
summary: 'Team Meeting'
|
||||
description: 'Weekly team sync'
|
||||
categories: ['Work', 'Meeting']
|
||||
status: 'CONFIRMED'
|
||||
class: 'PUBLIC'
|
||||
location: 'Conference Room'
|
||||
alarms: [
|
||||
Alarm{
|
||||
action: 'DISPLAY'
|
||||
trigger: '-PT15M'
|
||||
description: 'Meeting starts in 15 minutes'
|
||||
}
|
||||
]
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end_time: 1708077600 // 2024-02-16 11:00:00 UTC
|
||||
transp: 'OPAQUE'
|
||||
attendees: [
|
||||
Attendee{
|
||||
email: 'john@example.com'
|
||||
name: 'John Doe'
|
||||
role: 'REQ-PARTICIPANT'
|
||||
partstat: 'ACCEPTED'
|
||||
rsvp: true
|
||||
},
|
||||
Attendee{
|
||||
email: 'jane@example.com'
|
||||
name: 'Jane Smith'
|
||||
role: 'REQ-PARTICIPANT'
|
||||
partstat: 'NEEDS-ACTION'
|
||||
rsvp: true
|
||||
}
|
||||
]
|
||||
organizer: Attendee{
|
||||
email: 'boss@example.com'
|
||||
name: 'The Boss'
|
||||
role: 'CHAIR'
|
||||
partstat: 'ACCEPTED'
|
||||
}
|
||||
}
|
||||
|
||||
obj := CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
assert cal.len() == 1
|
||||
|
||||
// Verify retrieval
|
||||
found := cal.get_by_uid('event1@example.com') or { panic(err) }
|
||||
assert found.comp_type == 'VEVENT'
|
||||
|
||||
if e := found.event {
|
||||
assert e.summary == 'Team Meeting'
|
||||
assert e.start_time == 1708074000
|
||||
assert e.end_time? == 1708077600
|
||||
assert e.attendees.len == 2
|
||||
assert e.organizer?.email == 'boss@example.com'
|
||||
} else {
|
||||
assert false, 'Event not found'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_create_recurring_event() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create a daily recurring event
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'recurring@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400
|
||||
modified: 1708070400
|
||||
summary: 'Daily Standup'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT30M' // 30 minutes
|
||||
rrule: RecurrenceRule{
|
||||
frequency: 'DAILY'
|
||||
interval: 1
|
||||
count: 5
|
||||
}
|
||||
}
|
||||
|
||||
obj := CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
|
||||
// Test time range search
|
||||
tr := TimeRange{
|
||||
start: 1708160400 // 2024-02-17 10:00:00 UTC
|
||||
end: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
}
|
||||
|
||||
results := cal.find_by_time(tr) or { panic(err) }
|
||||
assert results.len == 1 // Should find the recurring event
|
||||
}
|
||||
|
||||
fn test_create_todo() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create a todo with due date
|
||||
todo := Todo{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'todo1@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400
|
||||
modified: 1708070400
|
||||
summary: 'Write Documentation'
|
||||
status: 'NEEDS-ACTION'
|
||||
}
|
||||
due_time: 1708160400 // 2024-02-17 10:00:00 UTC
|
||||
percent: 0
|
||||
}
|
||||
|
||||
obj := CalendarObject{
|
||||
comp_type: 'VTODO'
|
||||
todo: todo
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
|
||||
// Test completion
|
||||
mut updated_todo := todo
|
||||
updated_todo.status = 'COMPLETED'
|
||||
updated_todo.completed = 1708074000
|
||||
updated_todo.percent = 100
|
||||
|
||||
updated_obj := CalendarObject{
|
||||
comp_type: 'VTODO'
|
||||
todo: updated_todo
|
||||
}
|
||||
|
||||
cal.put(updated_obj) or { panic(err) }
|
||||
|
||||
// Verify update
|
||||
found := cal.get_by_uid('todo1@example.com') or { panic(err) }
|
||||
if t := found.todo {
|
||||
assert t.status == 'COMPLETED'
|
||||
assert t.completed? == 1708074000
|
||||
assert t.percent? == 100
|
||||
} else {
|
||||
assert false, 'Todo not found'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_create_journal() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Create a journal entry
|
||||
journal := Journal{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'journal1@example.com'
|
||||
etag: '"1"'
|
||||
created: 1708070400
|
||||
modified: 1708070400
|
||||
summary: 'Project Notes'
|
||||
description: 'Today we discussed the new features...'
|
||||
categories: ['Work', 'Notes']
|
||||
}
|
||||
start_time: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
}
|
||||
|
||||
obj := CalendarObject{
|
||||
comp_type: 'VJOURNAL'
|
||||
journal: journal
|
||||
}
|
||||
|
||||
cal.put(obj) or { panic(err) }
|
||||
assert cal.len() == 1
|
||||
|
||||
// Test time range search
|
||||
tr := TimeRange{
|
||||
start: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
end: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
}
|
||||
|
||||
results := cal.find_by_time(tr) or { panic(err) }
|
||||
assert results.len == 1
|
||||
}
|
||||
|
||||
fn test_validation() {
|
||||
mut cal := new('test_calendar')
|
||||
|
||||
// Test invalid component type
|
||||
invalid_type := CalendarObject{
|
||||
comp_type: 'INVALID'
|
||||
}
|
||||
if _ := cal.put(invalid_type) {
|
||||
assert false, 'Should reject invalid component type'
|
||||
}
|
||||
|
||||
// Test missing required fields
|
||||
invalid_event := CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: Event{}
|
||||
}
|
||||
if _ := cal.put(invalid_event) {
|
||||
assert false, 'Should reject event without required fields'
|
||||
}
|
||||
|
||||
// Test too many attendees
|
||||
mut many_attendees := []Attendee{cap: 200}
|
||||
for i in 0..200 {
|
||||
many_attendees << Attendee{
|
||||
email: 'user${i}@example.com'
|
||||
name: 'User ${i}'
|
||||
}
|
||||
}
|
||||
|
||||
event_many_attendees := CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'many@example.com'
|
||||
}
|
||||
start_time: 1708070400
|
||||
end_time: 1708074000
|
||||
attendees: many_attendees
|
||||
}
|
||||
}
|
||||
if _ := cal.put(event_many_attendees) {
|
||||
assert false, 'Should reject event with too many attendees'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_read_only() {
|
||||
mut cal := new('test_calendar')
|
||||
cal.read_only = true
|
||||
|
||||
event := CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'test@example.com'
|
||||
}
|
||||
start_time: 1708070400
|
||||
end_time: 1708074000
|
||||
}
|
||||
}
|
||||
|
||||
// Test put
|
||||
if _ := cal.put(event) {
|
||||
assert false, 'Should reject put on read-only calendar'
|
||||
}
|
||||
|
||||
// Test delete
|
||||
if _ := cal.delete('test@example.com') {
|
||||
assert false, 'Should reject delete on read-only calendar'
|
||||
}
|
||||
}
|
||||
104
lib/servers/calendar/calbox/datetime.v
Normal file
104
lib/servers/calendar/calbox/datetime.v
Normal file
@@ -0,0 +1,104 @@
|
||||
module calbox
|
||||
|
||||
import time
|
||||
|
||||
// Converts a timestamp to iCalendar UTC date-time format (YYYYMMDDTHHMMSSZ)
|
||||
pub fn format_datetime_utc(ts i64) string {
|
||||
t := time.unix(ts)
|
||||
return t.strftime('%Y%m%dT%H%M%SZ')
|
||||
}
|
||||
|
||||
// Converts a timestamp to iCalendar date format (YYYYMMDD)
|
||||
pub fn format_date(ts i64) string {
|
||||
t := time.unix(ts)
|
||||
return t.strftime('%Y%m%d')
|
||||
}
|
||||
|
||||
// Parses an iCalendar date-time string to timestamp
|
||||
pub fn parse_datetime(dt string) !i64 {
|
||||
if dt.len < 8 {
|
||||
return error('Invalid date-time format')
|
||||
}
|
||||
|
||||
// Parse date part (YYYYMMDD)
|
||||
year := dt[0..4].int()
|
||||
month := dt[4..6].int()
|
||||
day := dt[6..8].int()
|
||||
|
||||
mut hour := 0
|
||||
mut min := 0
|
||||
mut sec := 0
|
||||
mut is_utc := false
|
||||
|
||||
// Parse time part if present (THHMMSS[Z])
|
||||
if dt.len > 8 {
|
||||
if dt[8] != `T` {
|
||||
return error('Invalid date-time format: missing T separator')
|
||||
}
|
||||
if dt.len < 15 {
|
||||
return error('Invalid date-time format: incomplete time')
|
||||
}
|
||||
|
||||
hour = dt[9..11].int()
|
||||
min = dt[11..13].int()
|
||||
sec = dt[13..15].int()
|
||||
|
||||
is_utc = dt.ends_with('Z')
|
||||
}
|
||||
|
||||
// Create time.Time
|
||||
mut t := time.new_time(
|
||||
year: year
|
||||
month: month
|
||||
day: day
|
||||
hour: hour
|
||||
minute: min
|
||||
second: sec
|
||||
)
|
||||
|
||||
// Convert to UTC if needed
|
||||
if !is_utc {
|
||||
// TODO: Handle local time conversion
|
||||
// For now assume UTC
|
||||
}
|
||||
|
||||
return t.unix
|
||||
}
|
||||
|
||||
// Parses an iCalendar date string to timestamp
|
||||
pub fn parse_date(d string) !i64 {
|
||||
if d.len != 8 {
|
||||
return error('Invalid date format: must be YYYYMMDD')
|
||||
}
|
||||
|
||||
year := d[0..4].int()
|
||||
month := d[4..6].int()
|
||||
day := d[6..8].int()
|
||||
|
||||
t := time.new_time(
|
||||
year: year
|
||||
month: month
|
||||
day: day
|
||||
hour: 0
|
||||
minute: 0
|
||||
second: 0
|
||||
)
|
||||
|
||||
return t.unix
|
||||
}
|
||||
|
||||
// Parses a date or date-time string
|
||||
pub fn parse_date_or_datetime(value string) !i64 {
|
||||
if value.contains('T') {
|
||||
return parse_datetime(value)!
|
||||
}
|
||||
return parse_date(value)!
|
||||
}
|
||||
|
||||
// Formats a timestamp as either date or date-time based on has_time flag
|
||||
pub fn format_date_or_datetime(ts i64, has_time bool) string {
|
||||
if has_time {
|
||||
return format_datetime_utc(ts)
|
||||
}
|
||||
return format_date(ts)
|
||||
}
|
||||
103
lib/servers/calendar/calbox/datetime_test.v
Normal file
103
lib/servers/calendar/calbox/datetime_test.v
Normal file
@@ -0,0 +1,103 @@
|
||||
module calbox
|
||||
|
||||
fn test_format_datetime_utc() {
|
||||
// Test specific timestamp: 2024-02-16 10:00:00 UTC
|
||||
ts := i64(1708074000)
|
||||
formatted := format_datetime_utc(ts)
|
||||
assert formatted == '20240216T100000Z'
|
||||
|
||||
// Test midnight
|
||||
midnight := i64(1708041600) // 2024-02-16 00:00:00 UTC
|
||||
midnight_formatted := format_datetime_utc(midnight)
|
||||
assert midnight_formatted == '20240216T000000Z'
|
||||
}
|
||||
|
||||
fn test_format_date() {
|
||||
// Test specific date
|
||||
ts := i64(1708074000) // 2024-02-16 10:00:00 UTC
|
||||
formatted := format_date(ts)
|
||||
assert formatted == '20240216'
|
||||
|
||||
// Time part should be ignored
|
||||
later := i64(1708117200) // 2024-02-16 22:00:00 UTC
|
||||
later_formatted := format_date(later)
|
||||
assert later_formatted == '20240216'
|
||||
}
|
||||
|
||||
fn test_parse_datetime() {
|
||||
// Test UTC date-time
|
||||
ts := parse_datetime('20240216T100000Z')!
|
||||
assert ts == 1708074000
|
||||
|
||||
// Test date-time without seconds
|
||||
no_seconds := parse_datetime('20240216T1000')!
|
||||
assert no_seconds == 1708074000
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_datetime('invalid') {
|
||||
assert false, 'Should reject invalid format'
|
||||
}
|
||||
if _ := parse_datetime('20240216') {
|
||||
assert false, 'Should reject date without time'
|
||||
}
|
||||
if _ := parse_datetime('20240216T') {
|
||||
assert false, 'Should reject incomplete time'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_date() {
|
||||
// Test basic date
|
||||
ts := parse_date('20240216')!
|
||||
assert ts == 1708041600 // 2024-02-16 00:00:00 UTC
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_date('2024021') {
|
||||
assert false, 'Should reject too short date'
|
||||
}
|
||||
if _ := parse_date('202402166') {
|
||||
assert false, 'Should reject too long date'
|
||||
}
|
||||
if _ := parse_date('20240216T100000Z') {
|
||||
assert false, 'Should reject date-time format'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_date_or_datetime() {
|
||||
// Test date format
|
||||
date_ts := parse_date_or_datetime('20240216')!
|
||||
assert date_ts == 1708041600 // 2024-02-16 00:00:00 UTC
|
||||
|
||||
// Test date-time format
|
||||
datetime_ts := parse_date_or_datetime('20240216T100000Z')!
|
||||
assert datetime_ts == 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_date_or_datetime('invalid') {
|
||||
assert false, 'Should reject invalid format'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_date_or_datetime() {
|
||||
ts := i64(1708074000) // 2024-02-16 10:00:00 UTC
|
||||
|
||||
// Test as date-time
|
||||
datetime := format_date_or_datetime(ts, true)
|
||||
assert datetime == '20240216T100000Z'
|
||||
|
||||
// Test as date
|
||||
date := format_date_or_datetime(ts, false)
|
||||
assert date == '20240216'
|
||||
}
|
||||
|
||||
fn test_roundtrip() {
|
||||
// Test date-time roundtrip
|
||||
original_ts := i64(1708074000)
|
||||
formatted := format_datetime_utc(original_ts)
|
||||
parsed_ts := parse_datetime(formatted)!
|
||||
assert parsed_ts == original_ts
|
||||
|
||||
// Test date roundtrip
|
||||
date_formatted := format_date(original_ts)
|
||||
date_ts := parse_date(date_formatted)!
|
||||
assert date_ts == i64(1708041600) // Should be start of day
|
||||
}
|
||||
167
lib/servers/calendar/calbox/duration.v
Normal file
167
lib/servers/calendar/calbox/duration.v
Normal file
@@ -0,0 +1,167 @@
|
||||
module calbox
|
||||
|
||||
// Represents a duration in seconds
|
||||
pub struct Duration {
|
||||
pub:
|
||||
seconds i64
|
||||
}
|
||||
|
||||
// Parses an ISO 8601 duration string (e.g. PT1H30M)
|
||||
pub fn parse_duration(iso_duration string) !Duration {
|
||||
if iso_duration.len < 2 || !iso_duration.starts_with('P') {
|
||||
return error('Invalid duration format: must start with P')
|
||||
}
|
||||
|
||||
mut seconds := i64(0)
|
||||
mut number_str := ''
|
||||
mut time_part := false
|
||||
|
||||
for i := 1; i < iso_duration.len; i++ {
|
||||
c := iso_duration[i]
|
||||
match c {
|
||||
`T` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: duplicate T')
|
||||
}
|
||||
time_part = true
|
||||
}
|
||||
`0`...`9` {
|
||||
number_str += c.ascii_str()
|
||||
}
|
||||
`Y` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: Y in time part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before Y')
|
||||
}
|
||||
years := number_str.i64()
|
||||
seconds += years * 365 * 24 * 60 * 60 // Approximate
|
||||
number_str = ''
|
||||
}
|
||||
`M` {
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before M')
|
||||
}
|
||||
if time_part {
|
||||
// Minutes
|
||||
minutes := number_str.i64()
|
||||
seconds += minutes * 60
|
||||
} else {
|
||||
// Months
|
||||
months := number_str.i64()
|
||||
seconds += months * 30 * 24 * 60 * 60 // Approximate
|
||||
}
|
||||
number_str = ''
|
||||
}
|
||||
`W` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: W in time part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before W')
|
||||
}
|
||||
weeks := number_str.i64()
|
||||
seconds += weeks * 7 * 24 * 60 * 60
|
||||
number_str = ''
|
||||
}
|
||||
`D` {
|
||||
if time_part {
|
||||
return error('Invalid duration format: D in time part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before D')
|
||||
}
|
||||
days := number_str.i64()
|
||||
seconds += days * 24 * 60 * 60
|
||||
number_str = ''
|
||||
}
|
||||
`H` {
|
||||
if !time_part {
|
||||
return error('Invalid duration format: H in date part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before H')
|
||||
}
|
||||
hours := number_str.i64()
|
||||
seconds += hours * 60 * 60
|
||||
number_str = ''
|
||||
}
|
||||
`S` {
|
||||
if !time_part {
|
||||
return error('Invalid duration format: S in date part')
|
||||
}
|
||||
if number_str == '' {
|
||||
return error('Invalid duration format: missing number before S')
|
||||
}
|
||||
seconds += number_str.i64()
|
||||
number_str = ''
|
||||
}
|
||||
else {
|
||||
return error('Invalid duration format: unknown character ${c}')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if number_str != '' {
|
||||
return error('Invalid duration format: number without unit')
|
||||
}
|
||||
|
||||
return Duration{seconds}
|
||||
}
|
||||
|
||||
// Formats the duration as an ISO 8601 duration string
|
||||
pub fn (d Duration) str() string {
|
||||
mut s := 'P'
|
||||
mut remaining := d.seconds
|
||||
|
||||
// Years (approximate)
|
||||
years := remaining / (365 * 24 * 60 * 60)
|
||||
if years > 0 {
|
||||
s += '${years}Y'
|
||||
remaining = remaining % (365 * 24 * 60 * 60)
|
||||
}
|
||||
|
||||
// Days
|
||||
days := remaining / (24 * 60 * 60)
|
||||
if days > 0 {
|
||||
s += '${days}D'
|
||||
remaining = remaining % (24 * 60 * 60)
|
||||
}
|
||||
|
||||
// Time part (hours, minutes, seconds)
|
||||
if remaining > 0 {
|
||||
s += 'T'
|
||||
|
||||
// Hours
|
||||
hours := remaining / (60 * 60)
|
||||
if hours > 0 {
|
||||
s += '${hours}H'
|
||||
remaining = remaining % (60 * 60)
|
||||
}
|
||||
|
||||
// Minutes
|
||||
minutes := remaining / 60
|
||||
if minutes > 0 {
|
||||
s += '${minutes}M'
|
||||
remaining = remaining % 60
|
||||
}
|
||||
|
||||
// Seconds
|
||||
if remaining > 0 {
|
||||
s += '${remaining}S'
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Adds the duration to a timestamp
|
||||
pub fn (d Duration) add_to(timestamp i64) i64 {
|
||||
return timestamp + d.seconds
|
||||
}
|
||||
|
||||
// Subtracts the duration from a timestamp
|
||||
pub fn (d Duration) subtract_from(timestamp i64) i64 {
|
||||
return timestamp - d.seconds
|
||||
}
|
||||
98
lib/servers/calendar/calbox/duration_test.v
Normal file
98
lib/servers/calendar/calbox/duration_test.v
Normal file
@@ -0,0 +1,98 @@
|
||||
module calbox
|
||||
|
||||
fn test_parse_duration() {
|
||||
// Test simple durations
|
||||
assert parse_duration('PT1H')!.seconds == 3600
|
||||
assert parse_duration('PT30M')!.seconds == 1800
|
||||
assert parse_duration('PT15S')!.seconds == 15
|
||||
|
||||
// Test combined durations
|
||||
assert parse_duration('PT1H30M')!.seconds == 5400
|
||||
assert parse_duration('PT1H30M15S')!.seconds == 5415
|
||||
|
||||
// Test days
|
||||
assert parse_duration('P1D')!.seconds == 86400
|
||||
assert parse_duration('P1DT12H')!.seconds == 129600
|
||||
|
||||
// Test weeks
|
||||
assert parse_duration('P2W')!.seconds == 1209600
|
||||
|
||||
// Test years (approximate)
|
||||
assert parse_duration('P1Y')!.seconds == 31536000
|
||||
|
||||
// Test zero duration
|
||||
assert parse_duration('PT0S')!.seconds == 0
|
||||
|
||||
// Test invalid formats
|
||||
if _ := parse_duration('invalid') {
|
||||
assert false, 'Should reject invalid format'
|
||||
}
|
||||
if _ := parse_duration('P') {
|
||||
assert false, 'Should reject empty duration'
|
||||
}
|
||||
if _ := parse_duration('PT1H30') {
|
||||
assert false, 'Should reject number without unit'
|
||||
}
|
||||
if _ := parse_duration('PT1HM') {
|
||||
assert false, 'Should reject missing number before unit'
|
||||
}
|
||||
if _ := parse_duration('P1H') {
|
||||
assert false, 'Should reject time unit in date part'
|
||||
}
|
||||
if _ := parse_duration('PT1D') {
|
||||
assert false, 'Should reject date unit in time part'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_duration_string() {
|
||||
// Test simple durations
|
||||
assert parse_duration('PT1H')!.str() == 'PT1H'
|
||||
assert parse_duration('PT30M')!.str() == 'PT30M'
|
||||
assert parse_duration('PT15S')!.str() == 'PT15S'
|
||||
|
||||
// Test combined durations
|
||||
assert parse_duration('PT1H30M')!.str() == 'PT1H30M'
|
||||
assert parse_duration('PT1H30M15S')!.str() == 'PT1H30M15S'
|
||||
|
||||
// Test days
|
||||
assert parse_duration('P1D')!.str() == 'P1D'
|
||||
assert parse_duration('P1DT12H')!.str() == 'P1DT12H'
|
||||
|
||||
// Test normalization
|
||||
d := parse_duration('PT90M')!
|
||||
assert d.str() == 'PT1H30M' // 90 minutes normalized to 1 hour 30 minutes
|
||||
|
||||
// Test zero duration
|
||||
assert parse_duration('PT0S')!.str() == 'P'
|
||||
}
|
||||
|
||||
fn test_duration_arithmetic() {
|
||||
// Base timestamp: 2024-02-16 10:00:00 UTC
|
||||
base_ts := i64(1708074000)
|
||||
|
||||
// Test addition
|
||||
one_hour := parse_duration('PT1H')!
|
||||
assert one_hour.add_to(base_ts) == base_ts + 3600
|
||||
|
||||
// Test subtraction
|
||||
assert one_hour.subtract_from(base_ts) == base_ts - 3600
|
||||
|
||||
// Test complex duration
|
||||
complex := parse_duration('P1DT2H30M')!
|
||||
expected := base_ts + (24 * 3600) + (2 * 3600) + (30 * 60)
|
||||
assert complex.add_to(base_ts) == expected
|
||||
}
|
||||
|
||||
fn test_duration_edge_cases() {
|
||||
// Test very large durations
|
||||
large := parse_duration('P100Y')!
|
||||
assert large.seconds == i64(100 * 365 * 24 * 60 * 60)
|
||||
|
||||
// Test combined date and time
|
||||
mixed := parse_duration('P1Y2DT3H4M5S')!
|
||||
assert mixed.str() == 'P1Y2DT3H4M5S'
|
||||
|
||||
// Test months (approximate)
|
||||
with_months := parse_duration('P2M')!
|
||||
assert with_months.seconds == i64(2 * 30 * 24 * 60 * 60)
|
||||
}
|
||||
216
lib/servers/calendar/calbox/enums.v
Normal file
216
lib/servers/calendar/calbox/enums.v
Normal file
@@ -0,0 +1,216 @@
|
||||
module calbox
|
||||
|
||||
// Attendee role types
|
||||
pub enum AttendeeRole {
|
||||
chair // Meeting chair/organizer
|
||||
req_participant // Required participant
|
||||
opt_participant // Optional participant
|
||||
non_participant // Non-participant (e.g. room, resource)
|
||||
}
|
||||
|
||||
// String representation of attendee role
|
||||
pub fn (r AttendeeRole) str() string {
|
||||
return match r {
|
||||
.chair { 'CHAIR' }
|
||||
.req_participant { 'REQ-PARTICIPANT' }
|
||||
.opt_participant { 'OPT-PARTICIPANT' }
|
||||
.non_participant { 'NON-PARTICIPANT' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse attendee role from string
|
||||
pub fn parse_attendee_role(s string) !AttendeeRole {
|
||||
return match s {
|
||||
'CHAIR' { AttendeeRole.chair }
|
||||
'REQ-PARTICIPANT' { AttendeeRole.req_participant }
|
||||
'OPT-PARTICIPANT' { AttendeeRole.opt_participant }
|
||||
'NON-PARTICIPANT' { AttendeeRole.non_participant }
|
||||
else { error('Invalid attendee role: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Attendee participation status
|
||||
pub enum AttendeePartStat {
|
||||
needs_action // No response yet
|
||||
accepted // Accepted invitation
|
||||
declined // Declined invitation
|
||||
tentative // Tentatively accepted
|
||||
delegated // Delegated to another
|
||||
}
|
||||
|
||||
// String representation of participation status
|
||||
pub fn (p AttendeePartStat) str() string {
|
||||
return match p {
|
||||
.needs_action { 'NEEDS-ACTION' }
|
||||
.accepted { 'ACCEPTED' }
|
||||
.declined { 'DECLINED' }
|
||||
.tentative { 'TENTATIVE' }
|
||||
.delegated { 'DELEGATED' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse participation status from string
|
||||
pub fn parse_attendee_partstat(s string) !AttendeePartStat {
|
||||
return match s {
|
||||
'NEEDS-ACTION' { AttendeePartStat.needs_action }
|
||||
'ACCEPTED' { AttendeePartStat.accepted }
|
||||
'DECLINED' { AttendeePartStat.declined }
|
||||
'TENTATIVE' { AttendeePartStat.tentative }
|
||||
'DELEGATED' { AttendeePartStat.delegated }
|
||||
else { error('Invalid participation status: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence frequency types
|
||||
pub enum RecurrenceFrequency {
|
||||
secondly
|
||||
minutely
|
||||
hourly
|
||||
daily
|
||||
weekly
|
||||
monthly
|
||||
yearly
|
||||
}
|
||||
|
||||
// String representation of recurrence frequency
|
||||
pub fn (f RecurrenceFrequency) str() string {
|
||||
return match f {
|
||||
.secondly { 'SECONDLY' }
|
||||
.minutely { 'MINUTELY' }
|
||||
.hourly { 'HOURLY' }
|
||||
.daily { 'DAILY' }
|
||||
.weekly { 'WEEKLY' }
|
||||
.monthly { 'MONTHLY' }
|
||||
.yearly { 'YEARLY' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse recurrence frequency from string
|
||||
pub fn parse_recurrence_frequency(s string) !RecurrenceFrequency {
|
||||
return match s {
|
||||
'SECONDLY' { RecurrenceFrequency.secondly }
|
||||
'MINUTELY' { RecurrenceFrequency.minutely }
|
||||
'HOURLY' { RecurrenceFrequency.hourly }
|
||||
'DAILY' { RecurrenceFrequency.daily }
|
||||
'WEEKLY' { RecurrenceFrequency.weekly }
|
||||
'MONTHLY' { RecurrenceFrequency.monthly }
|
||||
'YEARLY' { RecurrenceFrequency.yearly }
|
||||
else { error('Invalid recurrence frequency: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Alarm action types
|
||||
pub enum AlarmAction {
|
||||
audio // Play a sound
|
||||
display // Display a message
|
||||
email // Send an email
|
||||
}
|
||||
|
||||
// String representation of alarm action
|
||||
pub fn (a AlarmAction) str() string {
|
||||
return match a {
|
||||
.audio { 'AUDIO' }
|
||||
.display { 'DISPLAY' }
|
||||
.email { 'EMAIL' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse alarm action from string
|
||||
pub fn parse_alarm_action(s string) !AlarmAction {
|
||||
return match s {
|
||||
'AUDIO' { AlarmAction.audio }
|
||||
'DISPLAY' { AlarmAction.display }
|
||||
'EMAIL' { AlarmAction.email }
|
||||
else { error('Invalid alarm action: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar component status
|
||||
pub enum ComponentStatus {
|
||||
tentative // Tentatively scheduled
|
||||
confirmed // Confirmed
|
||||
cancelled // Cancelled/deleted
|
||||
needs_action // Todo needs action
|
||||
completed // Todo completed
|
||||
in_process // Todo in progress
|
||||
draft // Journal draft
|
||||
final // Journal final
|
||||
}
|
||||
|
||||
// String representation of component status
|
||||
pub fn (s ComponentStatus) str() string {
|
||||
return match s {
|
||||
.tentative { 'TENTATIVE' }
|
||||
.confirmed { 'CONFIRMED' }
|
||||
.cancelled { 'CANCELLED' }
|
||||
.needs_action { 'NEEDS-ACTION' }
|
||||
.completed { 'COMPLETED' }
|
||||
.in_process { 'IN-PROCESS' }
|
||||
.draft { 'DRAFT' }
|
||||
.final { 'FINAL' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse component status from string
|
||||
pub fn parse_component_status(s string) !ComponentStatus {
|
||||
return match s {
|
||||
'TENTATIVE' { ComponentStatus.tentative }
|
||||
'CONFIRMED' { ComponentStatus.confirmed }
|
||||
'CANCELLED' { ComponentStatus.cancelled }
|
||||
'NEEDS-ACTION' { ComponentStatus.needs_action }
|
||||
'COMPLETED' { ComponentStatus.completed }
|
||||
'IN-PROCESS' { ComponentStatus.in_process }
|
||||
'DRAFT' { ComponentStatus.draft }
|
||||
'FINAL' { ComponentStatus.final }
|
||||
else { error('Invalid component status: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar component class (visibility/privacy)
|
||||
pub enum ComponentClass {
|
||||
public // Visible to everyone
|
||||
private // Only visible to owner
|
||||
confidential // Limited visibility
|
||||
}
|
||||
|
||||
// String representation of component class
|
||||
pub fn (c ComponentClass) str() string {
|
||||
return match c {
|
||||
.public { 'PUBLIC' }
|
||||
.private { 'PRIVATE' }
|
||||
.confidential { 'CONFIDENTIAL' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse component class from string
|
||||
pub fn parse_component_class(s string) !ComponentClass {
|
||||
return match s {
|
||||
'PUBLIC' { ComponentClass.public }
|
||||
'PRIVATE' { ComponentClass.private }
|
||||
'CONFIDENTIAL' { ComponentClass.confidential }
|
||||
else { error('Invalid component class: ${s}') }
|
||||
}
|
||||
}
|
||||
|
||||
// Event transparency (busy time)
|
||||
pub enum EventTransp {
|
||||
opaque // Blocks time (shows as busy)
|
||||
transparent // Does not block time (shows as free)
|
||||
}
|
||||
|
||||
// String representation of event transparency
|
||||
pub fn (t EventTransp) str() string {
|
||||
return match t {
|
||||
.opaque { 'OPAQUE' }
|
||||
.transparent { 'TRANSPARENT' }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse event transparency from string
|
||||
pub fn parse_event_transp(s string) !EventTransp {
|
||||
return match s {
|
||||
'OPAQUE' { EventTransp.opaque }
|
||||
'TRANSPARENT' { EventTransp.transparent }
|
||||
else { error('Invalid event transparency: ${s}') }
|
||||
}
|
||||
}
|
||||
154
lib/servers/calendar/calbox/recurrence.v
Normal file
154
lib/servers/calendar/calbox/recurrence.v
Normal file
@@ -0,0 +1,154 @@
|
||||
module calbox
|
||||
|
||||
// Represents a single instance of a recurring event
|
||||
pub struct EventInstance {
|
||||
pub:
|
||||
original_event Event // Reference to original event
|
||||
start_time i64 // Start time of this instance
|
||||
end_time i64 // End time of this instance
|
||||
recurrence_id i64 // RECURRENCE-ID for this instance
|
||||
is_override bool // Whether this is an overridden instance
|
||||
}
|
||||
|
||||
// Gets the next occurrence after a given timestamp based on a recurrence rule
|
||||
fn (rule RecurrenceRule) next_occurrence(base_time i64, after i64) ?i64 {
|
||||
if rule.until != none && after >= rule.until? {
|
||||
return none
|
||||
}
|
||||
|
||||
// Calculate interval in seconds based on frequency
|
||||
mut interval_seconds := i64(0)
|
||||
match rule.frequency {
|
||||
'SECONDLY' { interval_seconds = rule.interval }
|
||||
'MINUTELY' { interval_seconds = rule.interval * 60 }
|
||||
'HOURLY' { interval_seconds = rule.interval * 3600 }
|
||||
'DAILY' { interval_seconds = rule.interval * 86400 }
|
||||
'WEEKLY' { interval_seconds = rule.interval * 7 * 86400 }
|
||||
'MONTHLY' { interval_seconds = rule.interval * 30 * 86400 } // Approximate
|
||||
'YEARLY' { interval_seconds = rule.interval * 365 * 86400 } // Approximate
|
||||
else { return none }
|
||||
}
|
||||
|
||||
// Find next occurrence
|
||||
mut next := base_time
|
||||
for {
|
||||
next += interval_seconds
|
||||
if next > after {
|
||||
// TODO: Apply BYDAY, BYMONTHDAY etc. rules
|
||||
if rule.until != none && next > rule.until? {
|
||||
return none
|
||||
}
|
||||
return next
|
||||
}
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Expands a recurring event into individual instances within a time range
|
||||
pub fn expand_recurring_event(event Event, tr TimeRange) ![]EventInstance {
|
||||
mut instances := []EventInstance{}
|
||||
|
||||
// Get event duration
|
||||
mut duration := i64(0)
|
||||
if end := event.end_time {
|
||||
duration = end - event.start_time
|
||||
} else if dur_str := event.duration {
|
||||
duration = parse_duration(dur_str)!.seconds
|
||||
} else {
|
||||
duration = 3600 // Default 1 hour
|
||||
}
|
||||
|
||||
// Add base instance if it falls in range
|
||||
if event.start_time >= tr.start && event.start_time < tr.end {
|
||||
instances << EventInstance{
|
||||
original_event: event
|
||||
start_time: event.start_time
|
||||
end_time: event.start_time + duration
|
||||
recurrence_id: event.start_time
|
||||
is_override: false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle recurrence rule if any
|
||||
if rule := event.rrule {
|
||||
mut current := event.start_time
|
||||
|
||||
for {
|
||||
// Get next occurrence
|
||||
current = rule.next_occurrence(event.start_time, current) or { break }
|
||||
if current >= tr.end {
|
||||
break
|
||||
}
|
||||
|
||||
// Check count limit if specified
|
||||
if rule.count != none && instances.len >= rule.count? {
|
||||
break
|
||||
}
|
||||
|
||||
// Add instance if not excluded
|
||||
if current !in event.exdate {
|
||||
instances << EventInstance{
|
||||
original_event: event
|
||||
start_time: current
|
||||
end_time: current + duration
|
||||
recurrence_id: current
|
||||
is_override: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add any additional dates
|
||||
for rdate in event.rdate {
|
||||
if rdate >= tr.start && rdate < tr.end && rdate !in event.exdate {
|
||||
instances << EventInstance{
|
||||
original_event: event
|
||||
start_time: rdate
|
||||
end_time: rdate + duration
|
||||
recurrence_id: rdate
|
||||
is_override: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort instances by start time
|
||||
instances.sort(a.start_time < b.start_time)
|
||||
|
||||
return instances
|
||||
}
|
||||
|
||||
// Gets the effective end time of an event
|
||||
pub fn (event Event) get_effective_end_time() !i64 {
|
||||
if end := event.end_time {
|
||||
return end
|
||||
}
|
||||
if dur_str := event.duration {
|
||||
duration := parse_duration(dur_str)!
|
||||
return duration.add_to(event.start_time)
|
||||
}
|
||||
// Default 1 hour duration
|
||||
return event.start_time + 3600
|
||||
}
|
||||
|
||||
// Gets all instances of an event that overlap with a time range
|
||||
pub fn (event Event) get_instances(tr TimeRange) ![]EventInstance {
|
||||
// For non-recurring events, just check if it overlaps
|
||||
if event.rrule == none && event.rdate.len == 0 {
|
||||
end_time := event.get_effective_end_time()!
|
||||
if event.start_time < tr.end && end_time > tr.start {
|
||||
return [
|
||||
EventInstance{
|
||||
original_event: event
|
||||
start_time: event.start_time
|
||||
end_time: end_time
|
||||
recurrence_id: event.start_time
|
||||
is_override: false
|
||||
}
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Expand recurring event
|
||||
return expand_recurring_event(event, tr)!
|
||||
}
|
||||
190
lib/servers/calendar/calbox/recurrence_test.v
Normal file
190
lib/servers/calendar/calbox/recurrence_test.v
Normal file
@@ -0,0 +1,190 @@
|
||||
module calbox
|
||||
|
||||
fn test_simple_recurrence() {
|
||||
// Create a daily recurring event
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'daily@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: 'DAILY'
|
||||
interval: 1
|
||||
count: 3
|
||||
}
|
||||
}
|
||||
|
||||
// Search for instances over 3 days
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 3
|
||||
|
||||
// Verify instance times
|
||||
assert instances[0].start_time == 1708074000 // Feb 16 10:00
|
||||
assert instances[1].start_time == 1708160400 // Feb 17 10:00
|
||||
assert instances[2].start_time == 1708246800 // Feb 18 10:00
|
||||
|
||||
// Verify duration
|
||||
for instance in instances {
|
||||
duration := instance.end_time - instance.start_time
|
||||
assert duration == 3600 // 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
fn test_recurrence_with_until() {
|
||||
// Create event recurring daily until a specific time
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'until@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: 'DAILY'
|
||||
interval: 1
|
||||
until: 1708246800 // 2024-02-18 10:00:00 UTC
|
||||
}
|
||||
}
|
||||
|
||||
// Search beyond the until date
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 3 // Should include the until date
|
||||
}
|
||||
|
||||
fn test_recurrence_with_interval() {
|
||||
// Create event recurring every 2 days
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'interval@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: 'DAILY'
|
||||
interval: 2
|
||||
count: 3
|
||||
}
|
||||
}
|
||||
|
||||
// Search for a week
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708592400 // 2024-02-22 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 3
|
||||
|
||||
// Verify instance times (every 2 days)
|
||||
assert instances[0].start_time == 1708074000 // Feb 16 10:00
|
||||
assert instances[1].start_time == 1708246800 // Feb 18 10:00
|
||||
assert instances[2].start_time == 1708419600 // Feb 20 10:00
|
||||
}
|
||||
|
||||
fn test_recurrence_with_exclusions() {
|
||||
// Create daily event with exclusions
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'exclude@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: 'DAILY'
|
||||
interval: 1
|
||||
count: 5
|
||||
}
|
||||
exdate: [i64(1708246800)] // Exclude Feb 18 10:00
|
||||
}
|
||||
|
||||
// Search for a week
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708592400 // 2024-02-22 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 4 // 5 occurrences - 1 exclusion
|
||||
|
||||
// Verify excluded date is not present
|
||||
for instance in instances {
|
||||
assert instance.start_time != 1708246800
|
||||
}
|
||||
}
|
||||
|
||||
fn test_recurrence_with_additional_dates() {
|
||||
// Create event with additional dates
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'rdate@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
duration: 'PT1H'
|
||||
rrule: RecurrenceRule{
|
||||
frequency: 'DAILY'
|
||||
interval: 1
|
||||
count: 2
|
||||
}
|
||||
rdate: [
|
||||
i64(1708333200), // Feb 19 10:00
|
||||
i64(1708419600) // Feb 20 10:00
|
||||
]
|
||||
}
|
||||
|
||||
// Search for a week
|
||||
tr := TimeRange{
|
||||
start: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end: 1708592400 // 2024-02-22 10:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 4 // 2 regular + 2 additional
|
||||
|
||||
// Verify additional dates are included
|
||||
mut found_additional := false
|
||||
for instance in instances {
|
||||
if instance.start_time == 1708333200 || instance.start_time == 1708419600 {
|
||||
found_additional = true
|
||||
}
|
||||
}
|
||||
assert found_additional
|
||||
}
|
||||
|
||||
fn test_non_recurring_event() {
|
||||
// Create single event
|
||||
event := Event{
|
||||
CalendarComponent: CalendarComponent{
|
||||
uid: 'single@example.com'
|
||||
}
|
||||
start_time: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
end_time: 1708077600 // 2024-02-16 11:00:00 UTC
|
||||
}
|
||||
|
||||
// Search including event time
|
||||
tr := TimeRange{
|
||||
start: 1708070400 // 2024-02-16 09:00:00 UTC
|
||||
end: 1708084800 // 2024-02-16 12:00:00 UTC
|
||||
}
|
||||
|
||||
instances := event.get_instances(tr)!
|
||||
assert instances.len == 1
|
||||
|
||||
// Search outside event time
|
||||
tr_outside := TimeRange{
|
||||
start: 1708333200 // 2024-02-19 10:00:00 UTC
|
||||
end: 1708336800 // 2024-02-19 11:00:00 UTC
|
||||
}
|
||||
|
||||
outside_instances := event.get_instances(tr_outside)!
|
||||
assert outside_instances.len == 0
|
||||
}
|
||||
114
lib/servers/calendar/caldav/acl.v
Normal file
114
lib/servers/calendar/caldav/acl.v
Normal file
@@ -0,0 +1,114 @@
|
||||
module caldav
|
||||
|
||||
// CalDAV privileges
|
||||
pub const (
|
||||
read_free_busy = 'read-free-busy' // Allows reading free/busy information
|
||||
read = 'read' // Allows reading calendar data
|
||||
write = 'write' // Allows writing calendar data
|
||||
write_content = 'write-content' // Allows modifying calendar object resources
|
||||
write_props = 'write-props' // Allows modifying collection properties
|
||||
bind = 'bind' // Allows creating new calendar object resources
|
||||
unbind = 'unbind' // Allows deleting calendar object resources
|
||||
admin = 'admin' // Allows administrative operations
|
||||
)
|
||||
|
||||
// Principal represents a user or group
|
||||
pub struct Principal {
|
||||
pub mut:
|
||||
id string
|
||||
name string
|
||||
email string
|
||||
calendar_home_set string
|
||||
}
|
||||
|
||||
// ACLEntry represents an access control entry
|
||||
pub struct ACLEntry {
|
||||
pub mut:
|
||||
principal Principal
|
||||
privileges []string
|
||||
inherited bool
|
||||
protected bool
|
||||
}
|
||||
|
||||
// ACL represents an access control list
|
||||
pub struct ACL {
|
||||
pub mut:
|
||||
entries []ACLEntry
|
||||
}
|
||||
|
||||
// Creates a new ACL
|
||||
pub fn new_acl() ACL {
|
||||
return ACL{
|
||||
entries: []ACLEntry{}
|
||||
}
|
||||
}
|
||||
|
||||
// Adds an ACL entry
|
||||
pub fn (mut acl ACL) add_entry(principal Principal, privileges []string, inherited bool, protected bool) {
|
||||
acl.entries << ACLEntry{
|
||||
principal: principal
|
||||
privileges: privileges
|
||||
inherited: inherited
|
||||
protected: protected
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if a principal has a privilege
|
||||
pub fn (acl ACL) has_privilege(principal Principal, privilege string) bool {
|
||||
for entry in acl.entries {
|
||||
if entry.principal.id == principal.id {
|
||||
return privilege in entry.privileges
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Gets all privileges for a principal
|
||||
pub fn (acl ACL) get_privileges(principal Principal) []string {
|
||||
for entry in acl.entries {
|
||||
if entry.principal.id == principal.id {
|
||||
return entry.privileges
|
||||
}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Removes an ACL entry
|
||||
pub fn (mut acl ACL) remove_entry(principal Principal) {
|
||||
for i, entry in acl.entries {
|
||||
if entry.principal.id == principal.id {
|
||||
acl.entries.delete(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Checks if a principal has read access
|
||||
pub fn (acl ACL) can_read(principal Principal) bool {
|
||||
return acl.has_privilege(principal, read) || acl.has_privilege(principal, admin)
|
||||
}
|
||||
|
||||
// Checks if a principal has write access
|
||||
pub fn (acl ACL) can_write(principal Principal) bool {
|
||||
return acl.has_privilege(principal, write) || acl.has_privilege(principal, admin)
|
||||
}
|
||||
|
||||
// Checks if a principal has free/busy read access
|
||||
pub fn (acl ACL) can_read_freebusy(principal Principal) bool {
|
||||
return acl.has_privilege(principal, read_free_busy) || acl.can_read(principal)
|
||||
}
|
||||
|
||||
// Checks if a principal can bind (create) resources
|
||||
pub fn (acl ACL) can_bind(principal Principal) bool {
|
||||
return acl.has_privilege(principal, bind) || acl.has_privilege(principal, write)
|
||||
}
|
||||
|
||||
// Checks if a principal can unbind (delete) resources
|
||||
pub fn (acl ACL) can_unbind(principal Principal) bool {
|
||||
return acl.has_privilege(principal, unbind) || acl.has_privilege(principal, write)
|
||||
}
|
||||
|
||||
// Checks if a principal has admin access
|
||||
pub fn (acl ACL) is_admin(principal Principal) bool {
|
||||
return acl.has_privilege(principal, admin)
|
||||
}
|
||||
155
lib/servers/calendar/caldav/collection.v
Normal file
155
lib/servers/calendar/caldav/collection.v
Normal file
@@ -0,0 +1,155 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Represents a CalDAV collection
|
||||
pub struct Collection {
|
||||
pub mut:
|
||||
path string
|
||||
cal &calbox.CalBox
|
||||
props map[string]string
|
||||
acl ACL
|
||||
parent ?&Collection
|
||||
children map[string]&Collection
|
||||
is_calendar bool
|
||||
}
|
||||
|
||||
// Creates a new collection
|
||||
pub fn new_collection(path string, is_calendar bool) &Collection {
|
||||
mut col := &Collection{
|
||||
path: path
|
||||
props: map[string]string{}
|
||||
acl: new_acl()
|
||||
children: map[string]&Collection{}
|
||||
is_calendar: is_calendar
|
||||
}
|
||||
|
||||
if is_calendar {
|
||||
col.cal = calbox.new(path.all_after_last('/'))
|
||||
}
|
||||
|
||||
return col
|
||||
}
|
||||
|
||||
// Gets a child collection by name
|
||||
pub fn (c Collection) get_child(name string) ?&Collection {
|
||||
return c.children[name]
|
||||
}
|
||||
|
||||
// Adds a child collection
|
||||
pub fn (mut c Collection) add_child(name string, child &Collection) {
|
||||
c.children[name] = child
|
||||
child.parent = c
|
||||
}
|
||||
|
||||
// Removes a child collection
|
||||
pub fn (mut c Collection) remove_child(name string) {
|
||||
c.children.delete(name)
|
||||
}
|
||||
|
||||
// Gets all child collections
|
||||
pub fn (c Collection) list_children() []&Collection {
|
||||
mut children := []&Collection{}
|
||||
for _, child in c.children {
|
||||
children << child
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// Gets all calendar collections
|
||||
pub fn (c Collection) list_calendars() []&Collection {
|
||||
mut calendars := []&Collection{}
|
||||
for _, child in c.children {
|
||||
if child.is_calendar {
|
||||
calendars << child
|
||||
}
|
||||
calendars << child.list_calendars()
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
// Gets all calendar collections for a principal
|
||||
pub fn (c Collection) list_principal_calendars(principal Principal) []&Collection {
|
||||
mut calendars := []&Collection{}
|
||||
for cal in c.list_calendars() {
|
||||
if cal.acl.can_read(principal) {
|
||||
calendars << cal
|
||||
}
|
||||
}
|
||||
return calendars
|
||||
}
|
||||
|
||||
// Gets a collection by path
|
||||
pub fn (c Collection) find_by_path(path string) ?&Collection {
|
||||
if c.path == path {
|
||||
return c
|
||||
}
|
||||
|
||||
parts := path.trim_left('/').split('/')
|
||||
mut current := &c
|
||||
|
||||
for part in parts {
|
||||
if child := current.get_child(part) {
|
||||
current = child
|
||||
} else {
|
||||
return none
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// Gets a calendar object by href
|
||||
pub fn (c Collection) get_object_by_href(href string) ?calbox.CalendarObject {
|
||||
if !c.is_calendar {
|
||||
return none
|
||||
}
|
||||
|
||||
uid := href.all_after_last('/')
|
||||
return c.cal.get_by_uid(uid)
|
||||
}
|
||||
|
||||
// Gets all calendar objects
|
||||
pub fn (c Collection) list_objects() []calbox.CalendarObject {
|
||||
if !c.is_calendar {
|
||||
return []calbox.CalendarObject{}
|
||||
}
|
||||
|
||||
return c.cal.list() or { []calbox.CalendarObject{} }
|
||||
}
|
||||
|
||||
// Gets all calendar objects matching a filter
|
||||
pub fn (c Collection) find_objects(filter CalendarQueryFilter) []calbox.CalendarObject {
|
||||
if !c.is_calendar {
|
||||
return []calbox.CalendarObject{}
|
||||
}
|
||||
|
||||
return c.cal.find_by_filter(filter) or { []calbox.CalendarObject{} }
|
||||
}
|
||||
|
||||
// Gets free/busy information
|
||||
pub fn (c Collection) get_freebusy(tr calbox.TimeRange) []calbox.TimeRange {
|
||||
if !c.is_calendar {
|
||||
return []calbox.TimeRange{}
|
||||
}
|
||||
|
||||
return c.cal.get_freebusy(tr) or { []calbox.TimeRange{} }
|
||||
}
|
||||
|
||||
// Adds/updates a calendar object
|
||||
pub fn (mut c Collection) put_object(obj calbox.CalendarObject) ! {
|
||||
if !c.is_calendar {
|
||||
return error('Not a calendar collection')
|
||||
}
|
||||
|
||||
c.cal.put(obj)!
|
||||
}
|
||||
|
||||
// Deletes a calendar object
|
||||
pub fn (mut c Collection) delete_object(uid string) ! {
|
||||
if !c.is_calendar {
|
||||
return error('Not a calendar collection')
|
||||
}
|
||||
|
||||
c.cal.delete(uid)!
|
||||
}
|
||||
132
lib/servers/calendar/caldav/errors.v
Normal file
132
lib/servers/calendar/caldav/errors.v
Normal file
@@ -0,0 +1,132 @@
|
||||
module caldav
|
||||
|
||||
// Error codes
|
||||
pub const (
|
||||
err_not_found = 404
|
||||
err_forbidden = 403
|
||||
err_conflict = 409
|
||||
err_precondition_failed = 412
|
||||
err_insufficient_storage = 507
|
||||
)
|
||||
|
||||
// Error types
|
||||
pub const (
|
||||
err_calendar_not_found = 'Calendar collection not found'
|
||||
err_resource_not_found = 'Calendar object resource not found'
|
||||
err_calendar_exists = 'Calendar collection already exists'
|
||||
err_resource_exists = 'Calendar object resource already exists'
|
||||
err_invalid_calendar = 'Invalid calendar collection'
|
||||
err_invalid_resource = 'Invalid calendar object resource'
|
||||
err_uid_conflict = 'UID already in use'
|
||||
err_no_privilege = 'Insufficient privileges'
|
||||
err_storage_full = 'Insufficient storage space'
|
||||
)
|
||||
|
||||
// Precondition errors
|
||||
pub const (
|
||||
err_supported_calendar_data = 'Unsupported calendar data format'
|
||||
err_valid_calendar_data = 'Invalid calendar data'
|
||||
err_valid_calendar_object = 'Invalid calendar object'
|
||||
err_supported_component = 'Unsupported calendar component'
|
||||
err_calendar_collection_location = 'Invalid calendar collection location'
|
||||
err_resource_must_be_null = 'Resource already exists'
|
||||
err_valid_calendar_timezone = 'Invalid calendar timezone'
|
||||
)
|
||||
|
||||
// CalDAV error with code and message
|
||||
pub struct CalDAVError {
|
||||
pub:
|
||||
code int
|
||||
message string
|
||||
href string // Optional resource href for errors like UID conflicts
|
||||
}
|
||||
|
||||
// Creates a new CalDAV error
|
||||
pub fn new_error(code int, message string) CalDAVError {
|
||||
return CalDAVError{
|
||||
code: code
|
||||
message: message
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a new CalDAV error with href
|
||||
pub fn new_error_with_href(code int, message string, href string) CalDAVError {
|
||||
return CalDAVError{
|
||||
code: code
|
||||
message: message
|
||||
href: href
|
||||
}
|
||||
}
|
||||
|
||||
// Common error constructors
|
||||
|
||||
// Resource not found error
|
||||
pub fn err_not_found_error(message string) CalDAVError {
|
||||
return new_error(err_not_found, message)
|
||||
}
|
||||
|
||||
// Permission denied error
|
||||
pub fn err_forbidden_error(message string) CalDAVError {
|
||||
return new_error(err_forbidden, message)
|
||||
}
|
||||
|
||||
// Resource conflict error
|
||||
pub fn err_conflict_error(message string) CalDAVError {
|
||||
return new_error(err_conflict, message)
|
||||
}
|
||||
|
||||
// Precondition failed error
|
||||
pub fn err_precondition_error(message string) CalDAVError {
|
||||
return new_error(err_precondition_failed, message)
|
||||
}
|
||||
|
||||
// Storage full error
|
||||
pub fn err_storage_error(message string) CalDAVError {
|
||||
return new_error(err_insufficient_storage, message)
|
||||
}
|
||||
|
||||
// UID conflict error with href
|
||||
pub fn err_uid_conflict_error(href string) CalDAVError {
|
||||
return new_error_with_href(err_conflict, err_uid_conflict, href)
|
||||
}
|
||||
|
||||
// Generates XML error response
|
||||
pub fn (err CalDAVError) to_xml() string {
|
||||
mut xml := '<?xml version="1.0" encoding="utf-8" ?>\n'
|
||||
xml += '<D:error xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
|
||||
|
||||
match err.code {
|
||||
err_forbidden {
|
||||
xml += ' <D:need-privileges>\n'
|
||||
xml += ' <D:resource>\n'
|
||||
xml += ' <D:privilege>${err.message}</D:privilege>\n'
|
||||
xml += ' </D:resource>\n'
|
||||
xml += ' </D:need-privileges>\n'
|
||||
}
|
||||
err_conflict {
|
||||
if err.message == err_uid_conflict && err.href != '' {
|
||||
xml += ' <C:no-uid-conflict><D:href>${err.href}</D:href></C:no-uid-conflict>\n'
|
||||
} else {
|
||||
xml += ' <D:resource-must-be-null/>\n'
|
||||
}
|
||||
}
|
||||
err_precondition_failed {
|
||||
xml += ' <C:supported-calendar-data/>\n'
|
||||
xml += ' <C:valid-calendar-data/>\n'
|
||||
xml += ' <C:valid-calendar-object/>\n'
|
||||
xml += ' <C:supported-calendar-component/>\n'
|
||||
xml += ' <C:calendar-collection-location-ok/>\n'
|
||||
xml += ' <C:max-resource-size/>\n'
|
||||
xml += ' <C:min-date-time/>\n'
|
||||
xml += ' <C:max-date-time/>\n'
|
||||
xml += ' <C:max-instances/>\n'
|
||||
xml += ' <C:max-attendees-per-instance/>\n'
|
||||
}
|
||||
else {
|
||||
xml += ' <D:error><![CDATA[${err.message}]]></D:error>\n'
|
||||
}
|
||||
}
|
||||
|
||||
xml += '</D:error>'
|
||||
return xml
|
||||
}
|
||||
570
lib/servers/calendar/caldav/ical.v
Normal file
570
lib/servers/calendar/caldav/ical.v
Normal file
@@ -0,0 +1,570 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
import time
|
||||
|
||||
// Formats a timestamp as an iCalendar date-time string
|
||||
fn format_datetime(ts i64) string {
|
||||
return calbox.format_datetime_utc(ts)
|
||||
}
|
||||
|
||||
// Formats a date-time property with optional parameters
|
||||
fn format_datetime_property(name string, params map[string]string, ts i64) string {
|
||||
return format_property(name, params, calbox.format_datetime_utc(ts))
|
||||
}
|
||||
|
||||
// Formats a date property with optional parameters
|
||||
fn format_date_property(name string, params map[string]string, ts i64) string {
|
||||
return format_property(name, params, calbox.format_date(ts))
|
||||
}
|
||||
|
||||
// Formats a date or date-time property based on has_time flag
|
||||
fn format_date_or_datetime_property(name string, params map[string]string, ts i64, has_time bool) string {
|
||||
return format_property(name, params, calbox.format_date_or_datetime(ts, has_time))
|
||||
}
|
||||
|
||||
// Splits a line into property name, parameters, and value
|
||||
fn parse_property(line string) !(string, map[string]string, string) {
|
||||
// Find the colon that separates name+params from value
|
||||
value_start := line.index(':') or {
|
||||
return error('Invalid property format: missing colon')
|
||||
}
|
||||
|
||||
// Split into name+params and value
|
||||
name_params := line[..value_start].trim_space()
|
||||
value := line[value_start + 1..].trim_space()
|
||||
|
||||
// Split name and parameters
|
||||
parts := name_params.split(';')
|
||||
name := parts[0]
|
||||
|
||||
// Parse parameters
|
||||
mut params := map[string]string{}
|
||||
for i := 1; i < parts.len; i++ {
|
||||
param_parts := parts[i].split('=')
|
||||
if param_parts.len == 2 {
|
||||
params[param_parts[0]] = param_parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
return name, params, value
|
||||
}
|
||||
|
||||
// Parses an iCalendar content line
|
||||
fn parse_content_line(line string) !string {
|
||||
// Handle line unfolding (lines starting with space/tab are continuations)
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
return line[1..]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// Parses an attendee property value
|
||||
fn parse_attendee(value string, params map[string]string) !calbox.Attendee {
|
||||
mut attendee := calbox.Attendee{
|
||||
email: value.replace('mailto:', '')
|
||||
}
|
||||
|
||||
if cn := params['CN'] {
|
||||
attendee.name = cn
|
||||
}
|
||||
if role := params['ROLE'] {
|
||||
attendee.role = calbox.parse_attendee_role(role)!
|
||||
}
|
||||
if partstat := params['PARTSTAT'] {
|
||||
attendee.partstat = calbox.parse_attendee_partstat(partstat)!
|
||||
}
|
||||
if rsvp := params['RSVP'] {
|
||||
attendee.rsvp = rsvp == 'TRUE'
|
||||
}
|
||||
|
||||
return attendee
|
||||
}
|
||||
|
||||
// Parses a recurrence rule
|
||||
fn parse_rrule(value string) !calbox.RecurrenceRule {
|
||||
mut rule := calbox.RecurrenceRule{
|
||||
interval: 1 // Default interval
|
||||
}
|
||||
|
||||
parts := value.split(';')
|
||||
for part in parts {
|
||||
name_value := part.split('=')
|
||||
if name_value.len != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
name := name_value[0]
|
||||
value := name_value[1]
|
||||
|
||||
match name {
|
||||
'FREQ' { rule.frequency = calbox.parse_recurrence_frequency(value)! }
|
||||
'INTERVAL' { rule.interval = value.int() }
|
||||
'COUNT' { rule.count = value.int() }
|
||||
'UNTIL' { rule.until = calbox.parse_datetime(value)! }
|
||||
'BYDAY' { rule.by_day = value.split(',') }
|
||||
'BYMONTH' { rule.by_month = value.split(',').map(it.int()) }
|
||||
'BYMONTHDAY' { rule.by_monthday = value.split(',').map(it.int()) }
|
||||
'WKST' { rule.week_start = value }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
|
||||
// Parses an alarm component
|
||||
fn parse_alarm(lines []string) !calbox.Alarm {
|
||||
mut alarm := calbox.Alarm{}
|
||||
mut i := 0
|
||||
|
||||
while i < lines.len {
|
||||
line := lines[i]
|
||||
if line == 'END:VALARM' {
|
||||
break
|
||||
}
|
||||
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'ACTION' { alarm.action = calbox.parse_alarm_action(value)! }
|
||||
'TRIGGER' { alarm.trigger = value }
|
||||
'DESCRIPTION' { alarm.description = value }
|
||||
'SUMMARY' { alarm.summary = value }
|
||||
'ATTENDEE' { alarm.attendees << parse_attendee(value, params)! }
|
||||
'ATTACH' { alarm.attach << value }
|
||||
else {}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return alarm
|
||||
}
|
||||
|
||||
// Parses common component properties
|
||||
fn parse_component(lines []string, mut comp calbox.CalendarComponent) ! {
|
||||
for line in lines {
|
||||
if line.starts_with('BEGIN:') || line.starts_with('END:') {
|
||||
continue
|
||||
}
|
||||
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'UID' { comp.uid = value }
|
||||
'DTSTAMP' { comp.created = calbox.parse_datetime(value)! }
|
||||
'LAST-MODIFIED' { comp.modified = calbox.parse_datetime(value)! }
|
||||
'SUMMARY' { comp.summary = value }
|
||||
'DESCRIPTION' { comp.description = value }
|
||||
'CATEGORIES' { comp.categories = value.split(',') }
|
||||
'STATUS' { comp.status = calbox.parse_component_status(value)! }
|
||||
'CLASS' { comp.class = calbox.parse_component_class(value)! }
|
||||
'URL' { comp.url = value }
|
||||
'LOCATION' { comp.location = value }
|
||||
'GEO' {
|
||||
parts := value.split(';')
|
||||
if parts.len == 2 {
|
||||
comp.geo = calbox.GeoLocation{
|
||||
latitude: parts[0].f64()
|
||||
longitude: parts[1].f64()
|
||||
}
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parses an iCalendar string into a calendar object
|
||||
pub fn from_ical(ical string) !calbox.CalendarObject {
|
||||
lines := ical.split_into_lines().map(parse_content_line)!
|
||||
|
||||
mut obj := calbox.CalendarObject{}
|
||||
mut in_component := false
|
||||
mut component_lines := []string{}
|
||||
|
||||
for line in lines {
|
||||
if line.starts_with('BEGIN:V') {
|
||||
if line == 'BEGIN:VCALENDAR' {
|
||||
continue
|
||||
}
|
||||
in_component = true
|
||||
obj.comp_type = line.replace('BEGIN:', '')
|
||||
continue
|
||||
}
|
||||
|
||||
if line.starts_with('END:V') {
|
||||
if line == 'END:VCALENDAR' {
|
||||
continue
|
||||
}
|
||||
in_component = false
|
||||
|
||||
// Parse the collected component
|
||||
match obj.comp_type {
|
||||
'VEVENT' {
|
||||
mut event := calbox.Event{}
|
||||
parse_component(component_lines, mut event.CalendarComponent)!
|
||||
for line in component_lines {
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'DTSTART' { event.start_time = calbox.parse_datetime(value)! }
|
||||
'DTEND' { event.end_time = calbox.parse_datetime(value)! }
|
||||
'DURATION' { event.duration = value }
|
||||
'RRULE' { event.rrule = parse_rrule(value)! }
|
||||
'RDATE' { event.rdate << calbox.parse_datetime(value)! }
|
||||
'EXDATE' { event.exdate << calbox.parse_datetime(value)! }
|
||||
'TRANSP' { event.transp = calbox.parse_event_transp(value)! }
|
||||
'ATTENDEE' { event.attendees << parse_attendee(value, params)! }
|
||||
'ORGANIZER' { event.organizer = parse_attendee(value, params)! }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
obj.event = event
|
||||
}
|
||||
'VTODO' {
|
||||
mut todo := calbox.Todo{}
|
||||
parse_component(component_lines, mut todo.CalendarComponent)!
|
||||
for line in component_lines {
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'DTSTART' { todo.start_time = calbox.parse_datetime(value)! }
|
||||
'DUE' { todo.due_time = calbox.parse_datetime(value)! }
|
||||
'DURATION' { todo.duration = value }
|
||||
'COMPLETED' { todo.completed = calbox.parse_datetime(value)! }
|
||||
'PERCENT-COMPLETE' { todo.percent = value.int() }
|
||||
'RRULE' { todo.rrule = parse_rrule(value)! }
|
||||
'ATTENDEE' { todo.attendees << parse_attendee(value, params)! }
|
||||
'ORGANIZER' { todo.organizer = parse_attendee(value, params)! }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
obj.todo = todo
|
||||
}
|
||||
'VJOURNAL' {
|
||||
mut journal := calbox.Journal{}
|
||||
parse_component(component_lines, mut journal.CalendarComponent)!
|
||||
for line in component_lines {
|
||||
name, params, value := parse_property(line)!
|
||||
match name {
|
||||
'DTSTART' { journal.start_time = calbox.parse_datetime(value)! }
|
||||
'ATTENDEE' { journal.attendees << parse_attendee(value, params)! }
|
||||
'ORGANIZER' { journal.organizer = parse_attendee(value, params)! }
|
||||
else {}
|
||||
}
|
||||
}
|
||||
obj.journal = journal
|
||||
}
|
||||
else {}
|
||||
}
|
||||
|
||||
component_lines.clear()
|
||||
continue
|
||||
}
|
||||
|
||||
if in_component {
|
||||
component_lines << line
|
||||
}
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// Folds a line according to iCalendar spec (max 75 chars)
|
||||
fn fold_line(line string) string {
|
||||
if line.len <= 75 {
|
||||
return line
|
||||
}
|
||||
|
||||
mut result := []string{}
|
||||
mut current := line[0..75]
|
||||
mut remaining := line[75..]
|
||||
result << current
|
||||
|
||||
for remaining.len > 0 {
|
||||
if remaining.len <= 74 {
|
||||
result << ' ${remaining}'
|
||||
break
|
||||
}
|
||||
current = remaining[0..74]
|
||||
remaining = remaining[74..]
|
||||
result << ' ${current}'
|
||||
}
|
||||
|
||||
return result.join('\r\n')
|
||||
}
|
||||
|
||||
// Formats a property with optional parameters
|
||||
fn format_property(name string, params map[string]string, value string) string {
|
||||
mut param_str := ''
|
||||
if params.len > 0 {
|
||||
param_parts := params.keys().map(fn (k string, v map[string]string) string {
|
||||
return '${k}=${v[k]}'
|
||||
})
|
||||
param_str = ';${param_parts.join(";")}'
|
||||
}
|
||||
|
||||
line := '${name}${param_str}:${value}'
|
||||
return fold_line(line)
|
||||
}
|
||||
|
||||
// Formats attendee properties
|
||||
fn format_attendee(a calbox.Attendee) string {
|
||||
mut params := map[string]string{}
|
||||
mut props := []string{}
|
||||
|
||||
props << 'ROLE=${a.role.str()}'
|
||||
props << 'PARTSTAT=${a.partstat.str()}'
|
||||
|
||||
if a.rsvp {
|
||||
props << 'RSVP=TRUE'
|
||||
}
|
||||
if a.name != '' {
|
||||
props << 'CN=${a.name}'
|
||||
}
|
||||
|
||||
params := if props.len > 0 { ';${props.join(";")}' } else { '' }
|
||||
return 'ATTENDEE${params}:mailto:${a.email}'
|
||||
}
|
||||
|
||||
// Formats a recurrence rule
|
||||
fn format_rrule(r calbox.RecurrenceRule) string {
|
||||
mut parts := []string{}
|
||||
parts << 'FREQ=${r.frequency.str()}'
|
||||
|
||||
if r.interval > 1 {
|
||||
parts << 'INTERVAL=${r.interval}'
|
||||
}
|
||||
|
||||
if count := r.count {
|
||||
parts << 'COUNT=${count}'
|
||||
}
|
||||
|
||||
if until := r.until {
|
||||
parts << 'UNTIL=${format_datetime(until)}'
|
||||
}
|
||||
|
||||
if r.by_day.len > 0 {
|
||||
parts << 'BYDAY=${r.by_day.join(",")}'
|
||||
}
|
||||
|
||||
if r.by_month.len > 0 {
|
||||
parts << 'BYMONTH=${r.by_month.map(it.str()).join(",")}'
|
||||
}
|
||||
|
||||
if r.by_monthday.len > 0 {
|
||||
parts << 'BYMONTHDAY=${r.by_monthday.map(it.str()).join(",")}'
|
||||
}
|
||||
|
||||
if r.week_start != '' {
|
||||
parts << 'WKST=${r.week_start}'
|
||||
}
|
||||
|
||||
return 'RRULE:${parts.join(";")}'
|
||||
}
|
||||
|
||||
// Formats alarm properties
|
||||
fn format_alarm(a calbox.Alarm) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VALARM'
|
||||
lines << 'ACTION:${a.action.str()}'
|
||||
lines << 'TRIGGER:${a.trigger}'
|
||||
|
||||
if a.description != '' {
|
||||
lines << 'DESCRIPTION:${a.description}'
|
||||
}
|
||||
|
||||
if a.summary != '' {
|
||||
lines << 'SUMMARY:${a.summary}'
|
||||
}
|
||||
|
||||
for attendee in a.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
for attach in a.attach {
|
||||
lines << 'ATTACH:${attach}'
|
||||
}
|
||||
|
||||
lines << 'END:VALARM'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Formats common component properties
|
||||
fn format_component(c calbox.CalendarComponent) []string {
|
||||
mut lines := []string{}
|
||||
|
||||
lines << 'UID:${c.uid}'
|
||||
lines << 'DTSTAMP:${format_datetime(c.created)}'
|
||||
|
||||
if c.summary != '' {
|
||||
lines << 'SUMMARY:${c.summary}'
|
||||
}
|
||||
|
||||
if c.description != '' {
|
||||
lines << 'DESCRIPTION:${c.description}'
|
||||
}
|
||||
|
||||
if c.categories.len > 0 {
|
||||
lines << 'CATEGORIES:${c.categories.join(",")}'
|
||||
}
|
||||
|
||||
lines << 'STATUS:${c.status.str()}'
|
||||
lines << 'CLASS:${c.class.str()}'
|
||||
|
||||
if c.url != '' {
|
||||
lines << 'URL:${c.url}'
|
||||
}
|
||||
|
||||
if c.location != '' {
|
||||
lines << 'LOCATION:${c.location}'
|
||||
}
|
||||
|
||||
if geo := c.geo {
|
||||
lines << 'GEO:${geo.latitude};${geo.longitude}'
|
||||
}
|
||||
|
||||
for alarm in c.alarms {
|
||||
lines << format_alarm(alarm)
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// Converts an event to iCalendar format
|
||||
pub fn to_ical_event(event calbox.Event) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VEVENT'
|
||||
|
||||
// Add common properties
|
||||
lines << format_component(event.CalendarComponent)
|
||||
|
||||
// Add event-specific properties
|
||||
lines << format_datetime_property('DTSTART', map[string]string{}, event.start_time)
|
||||
|
||||
if end := event.end_time {
|
||||
lines << format_datetime_property('DTEND', map[string]string{}, end)
|
||||
} else if dur := event.duration {
|
||||
lines << format_property('DURATION', map[string]string{}, dur)
|
||||
}
|
||||
|
||||
lines << 'TRANSP:${event.transp.str()}'
|
||||
|
||||
if rule := event.rrule {
|
||||
lines << format_rrule(rule)
|
||||
}
|
||||
|
||||
for ts in event.rdate {
|
||||
lines << 'RDATE:${format_datetime(ts)}'
|
||||
}
|
||||
|
||||
for ts in event.exdate {
|
||||
lines << 'EXDATE:${format_datetime(ts)}'
|
||||
}
|
||||
|
||||
for attendee in event.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
if organizer := event.organizer {
|
||||
lines << format_attendee(organizer).replace('ATTENDEE:', 'ORGANIZER:')
|
||||
}
|
||||
|
||||
lines << 'END:VEVENT'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Converts a todo to iCalendar format
|
||||
pub fn to_ical_todo(todo calbox.Todo) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VTODO'
|
||||
|
||||
// Add common properties
|
||||
lines << format_component(todo.CalendarComponent)
|
||||
|
||||
// Add todo-specific properties
|
||||
if start := todo.start_time {
|
||||
lines << format_datetime_property('DTSTART', map[string]string{}, start)
|
||||
}
|
||||
|
||||
if due := todo.due_time {
|
||||
lines << format_datetime_property('DUE', map[string]string{}, due)
|
||||
}
|
||||
|
||||
if dur := todo.duration {
|
||||
lines << format_property('DURATION', map[string]string{}, dur)
|
||||
}
|
||||
|
||||
if completed := todo.completed {
|
||||
lines << format_datetime_property('COMPLETED', map[string]string{}, completed)
|
||||
}
|
||||
|
||||
if percent := todo.percent {
|
||||
lines << 'PERCENT-COMPLETE:${percent}'
|
||||
}
|
||||
|
||||
if rule := todo.rrule {
|
||||
lines << format_rrule(rule)
|
||||
}
|
||||
|
||||
for attendee in todo.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
if organizer := todo.organizer {
|
||||
lines << format_attendee(organizer).replace('ATTENDEE:', 'ORGANIZER:')
|
||||
}
|
||||
|
||||
lines << 'END:VTODO'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Converts a journal entry to iCalendar format
|
||||
pub fn to_ical_journal(journal calbox.Journal) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VJOURNAL'
|
||||
|
||||
// Add common properties
|
||||
lines << format_component(journal.CalendarComponent)
|
||||
|
||||
// Add journal-specific properties
|
||||
lines << 'DTSTART:${format_datetime(journal.start_time)}'
|
||||
|
||||
for attendee in journal.attendees {
|
||||
lines << format_attendee(attendee)
|
||||
}
|
||||
|
||||
if organizer := journal.organizer {
|
||||
lines << format_attendee(organizer).replace('ATTENDEE:', 'ORGANIZER:')
|
||||
}
|
||||
|
||||
lines << 'END:VJOURNAL'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
// Converts a calendar object to iCalendar format
|
||||
pub fn to_ical(obj calbox.CalendarObject) string {
|
||||
mut lines := []string{}
|
||||
lines << 'BEGIN:VCALENDAR'
|
||||
lines << 'VERSION:2.0'
|
||||
lines << 'PRODID:-//HeroLib//CalDAV Client//EN'
|
||||
|
||||
match obj.comp_type {
|
||||
'VEVENT' {
|
||||
if event := obj.event {
|
||||
lines << to_ical_event(event)
|
||||
}
|
||||
}
|
||||
'VTODO' {
|
||||
if todo := obj.todo {
|
||||
lines << to_ical_todo(todo)
|
||||
}
|
||||
}
|
||||
'VJOURNAL' {
|
||||
if journal := obj.journal {
|
||||
lines << to_ical_journal(journal)
|
||||
}
|
||||
}
|
||||
else {}
|
||||
}
|
||||
|
||||
lines << 'END:VCALENDAR'
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
325
lib/servers/calendar/caldav/ical_test.v
Normal file
325
lib/servers/calendar/caldav/ical_test.v
Normal file
@@ -0,0 +1,325 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
fn test_format_event() {
|
||||
// Create a test event with all fields
|
||||
event := calbox.Event{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'test@example.com'
|
||||
created: 1708074000 // 2024-02-16 10:00:00 UTC
|
||||
modified: 1708074000
|
||||
summary: 'Test Event'
|
||||
description: 'Test Description'
|
||||
categories: ['Work', 'Meeting']
|
||||
status: .confirmed
|
||||
class: .public
|
||||
location: 'Conference Room'
|
||||
alarms: [
|
||||
calbox.Alarm{
|
||||
action: .display
|
||||
trigger: '-PT15M'
|
||||
description: 'Meeting starts in 15 minutes'
|
||||
}
|
||||
]
|
||||
}
|
||||
start_time: 1708074000
|
||||
end_time: 1708077600 // 1 hour later
|
||||
transp: .opaque
|
||||
attendees: [
|
||||
calbox.Attendee{
|
||||
email: 'john@example.com'
|
||||
name: 'John Doe'
|
||||
role: .req_participant
|
||||
partstat: .accepted
|
||||
rsvp: true
|
||||
}
|
||||
]
|
||||
organizer: calbox.Attendee{
|
||||
email: 'boss@example.com'
|
||||
name: 'The Boss'
|
||||
role: .chair
|
||||
partstat: .accepted
|
||||
}
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify required fields
|
||||
assert ical.contains('BEGIN:VCALENDAR')
|
||||
assert ical.contains('VERSION:2.0')
|
||||
assert ical.contains('BEGIN:VEVENT')
|
||||
assert ical.contains('UID:test@example.com')
|
||||
assert ical.contains('DTSTART:20240216T100000Z')
|
||||
assert ical.contains('DTEND:20240216T110000Z')
|
||||
assert ical.contains('SUMMARY:Test Event')
|
||||
assert ical.contains('STATUS:CONFIRMED')
|
||||
assert ical.contains('CLASS:PUBLIC')
|
||||
assert ical.contains('TRANSP:OPAQUE')
|
||||
assert ical.contains('END:VEVENT')
|
||||
assert ical.contains('END:VCALENDAR')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
assert parsed.comp_type == 'VEVENT'
|
||||
|
||||
if e := parsed.event {
|
||||
assert e.uid == event.uid
|
||||
assert e.summary == event.summary
|
||||
assert e.start_time == event.start_time
|
||||
assert e.end_time? == event.end_time?
|
||||
assert e.status == event.status
|
||||
assert e.class == event.class
|
||||
assert e.transp == event.transp
|
||||
assert e.attendees.len == event.attendees.len
|
||||
assert e.attendees[0].role == event.attendees[0].role
|
||||
assert e.attendees[0].partstat == event.attendees[0].partstat
|
||||
assert e.organizer?.role == event.organizer?.role
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_recurring_event() {
|
||||
// Create a recurring event
|
||||
event := calbox.Event{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'recurring@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Daily Meeting'
|
||||
status: .confirmed
|
||||
class: .public
|
||||
}
|
||||
start_time: 1708074000
|
||||
duration: 'PT1H'
|
||||
transp: .opaque
|
||||
rrule: calbox.RecurrenceRule{
|
||||
frequency: .daily
|
||||
interval: 1
|
||||
count: 5
|
||||
by_day: ['MO', 'WE', 'FR']
|
||||
}
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify recurrence fields
|
||||
assert ical.contains('RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5;BYDAY=MO,WE,FR')
|
||||
assert ical.contains('DURATION:PT1H')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
if e := parsed.event {
|
||||
assert e.duration? == event.duration?
|
||||
if rule := e.rrule {
|
||||
assert rule.frequency == .daily
|
||||
assert rule.interval == 1
|
||||
assert rule.count? == 5
|
||||
assert rule.by_day == ['MO', 'WE', 'FR']
|
||||
} else {
|
||||
assert false, 'Failed to parse recurrence rule'
|
||||
}
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_todo() {
|
||||
// Create a todo
|
||||
todo := calbox.Todo{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'todo@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Test Todo'
|
||||
status: .needs_action
|
||||
class: .private
|
||||
}
|
||||
due_time: 1708160400 // Next day
|
||||
percent: 0
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VTODO'
|
||||
todo: todo
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify todo fields
|
||||
assert ical.contains('BEGIN:VTODO')
|
||||
assert ical.contains('DUE:20240217T100000Z')
|
||||
assert ical.contains('PERCENT-COMPLETE:0')
|
||||
assert ical.contains('STATUS:NEEDS-ACTION')
|
||||
assert ical.contains('CLASS:PRIVATE')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
if t := parsed.todo {
|
||||
assert t.uid == todo.uid
|
||||
assert t.summary == todo.summary
|
||||
assert t.due_time? == todo.due_time?
|
||||
assert t.percent? == todo.percent?
|
||||
assert t.status == todo.status
|
||||
assert t.class == todo.class
|
||||
} else {
|
||||
assert false, 'Failed to parse todo'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_format_journal() {
|
||||
// Create a journal entry
|
||||
journal := calbox.Journal{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'journal@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Test Journal'
|
||||
description: 'Today we discussed...'
|
||||
categories: ['Notes', 'Work']
|
||||
status: .draft
|
||||
class: .confidential
|
||||
}
|
||||
start_time: 1708074000
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VJOURNAL'
|
||||
journal: journal
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
|
||||
// Verify journal fields
|
||||
assert ical.contains('BEGIN:VJOURNAL')
|
||||
assert ical.contains('DTSTART:20240216T100000Z')
|
||||
assert ical.contains('CATEGORIES:Notes,Work')
|
||||
assert ical.contains('STATUS:DRAFT')
|
||||
assert ical.contains('CLASS:CONFIDENTIAL')
|
||||
|
||||
// Parse back
|
||||
parsed := from_ical(ical)!
|
||||
if j := parsed.journal {
|
||||
assert j.uid == journal.uid
|
||||
assert j.summary == journal.summary
|
||||
assert j.description == journal.description
|
||||
assert j.categories == journal.categories
|
||||
assert j.start_time == journal.start_time
|
||||
assert j.status == journal.status
|
||||
assert j.class == journal.class
|
||||
} else {
|
||||
assert false, 'Failed to parse journal'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_attendees() {
|
||||
ical := 'BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test@example.com
|
||||
DTSTAMP:20240216T100000Z
|
||||
DTSTART:20240216T100000Z
|
||||
DTEND:20240216T110000Z
|
||||
SUMMARY:Test Event
|
||||
ATTENDEE;CN=John Doe;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE:mailto:john@example.com
|
||||
ORGANIZER;CN=The Boss;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:boss@example.com
|
||||
END:VEVENT
|
||||
END:VCALENDAR'
|
||||
|
||||
obj := from_ical(ical)!
|
||||
if event := obj.event {
|
||||
assert event.attendees.len == 1
|
||||
attendee := event.attendees[0]
|
||||
assert attendee.email == 'john@example.com'
|
||||
assert attendee.name == 'John Doe'
|
||||
assert attendee.role == .req_participant
|
||||
assert attendee.partstat == .accepted
|
||||
assert attendee.rsvp == true
|
||||
|
||||
if org := event.organizer {
|
||||
assert org.email == 'boss@example.com'
|
||||
assert org.name == 'The Boss'
|
||||
assert org.role == .chair
|
||||
assert org.partstat == .accepted
|
||||
} else {
|
||||
assert false, 'Failed to parse organizer'
|
||||
}
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_parse_alarms() {
|
||||
ical := 'BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:test@example.com
|
||||
DTSTAMP:20240216T100000Z
|
||||
DTSTART:20240216T100000Z
|
||||
DTEND:20240216T110000Z
|
||||
SUMMARY:Test Event
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:-PT15M
|
||||
DESCRIPTION:Meeting starts in 15 minutes
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR'
|
||||
|
||||
obj := from_ical(ical)!
|
||||
if event := obj.event {
|
||||
assert event.alarms.len == 1
|
||||
alarm := event.alarms[0]
|
||||
assert alarm.action == .display
|
||||
assert alarm.trigger == '-PT15M'
|
||||
assert alarm.description == 'Meeting starts in 15 minutes'
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
|
||||
fn test_line_folding() {
|
||||
// Test long description that should be folded
|
||||
event := calbox.Event{
|
||||
CalendarComponent: calbox.CalendarComponent{
|
||||
uid: 'test@example.com'
|
||||
created: 1708074000
|
||||
summary: 'Test Event'
|
||||
description: 'This is a very long description that should be folded into multiple lines according to the iCalendar specification which states that lines longer than 75 characters should be folded'
|
||||
status: .confirmed
|
||||
class: .public
|
||||
}
|
||||
start_time: 1708074000
|
||||
end_time: 1708077600
|
||||
transp: .opaque
|
||||
}
|
||||
|
||||
obj := calbox.CalendarObject{
|
||||
comp_type: 'VEVENT'
|
||||
event: event
|
||||
}
|
||||
|
||||
ical := to_ical(obj)
|
||||
lines := ical.split_into_lines()
|
||||
|
||||
// Verify no line is longer than 75 characters
|
||||
for line in lines {
|
||||
assert line.len <= 75, 'Line exceeds 75 characters: ${line}'
|
||||
}
|
||||
|
||||
// Parse back and verify description is reconstructed
|
||||
parsed := from_ical(ical)!
|
||||
if e := parsed.event {
|
||||
assert e.description == event.description
|
||||
} else {
|
||||
assert false, 'Failed to parse event'
|
||||
}
|
||||
}
|
||||
146
lib/servers/calendar/caldav/methods.v
Normal file
146
lib/servers/calendar/caldav/methods.v
Normal file
@@ -0,0 +1,146 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Handles MKCALENDAR request
|
||||
pub fn handle_mkcalendar(path string, props map[string]string, principal Principal) !&calbox.CalBox {
|
||||
// Check preconditions
|
||||
check_mkcalendar_preconditions(path, props)!
|
||||
|
||||
// Create calendar collection
|
||||
mut cal := calbox.new(props['displayname'] or { path.all_after_last('/') })
|
||||
|
||||
// Set properties
|
||||
if desc := props[calendar_description] {
|
||||
cal.description = desc
|
||||
}
|
||||
if tz := props[calendar_timezone] {
|
||||
cal.timezone = tz
|
||||
}
|
||||
if components := get_prop_array(props, supported_calendar_component_set) {
|
||||
cal.supported_components = components
|
||||
}
|
||||
|
||||
// Set ACL
|
||||
cal.acl = new_acl()
|
||||
cal.acl.add_entry(principal, [admin], false, true)
|
||||
|
||||
return cal
|
||||
}
|
||||
|
||||
// Handles PUT request
|
||||
pub fn handle_put(cal &calbox.CalBox, obj calbox.CalendarObject, principal Principal) !bool {
|
||||
// Check privileges
|
||||
if !cal.acl.can_write(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Check preconditions
|
||||
check_put_preconditions(cal, obj)!
|
||||
|
||||
// Add/update object
|
||||
cal.put(obj)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles DELETE request
|
||||
pub fn handle_delete(cal &calbox.CalBox, uid string, principal Principal) !bool {
|
||||
// Check privileges
|
||||
if !cal.acl.can_unbind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Delete object
|
||||
cal.delete(uid)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles COPY request
|
||||
pub fn handle_copy(src_cal &calbox.CalBox, dst_cal &calbox.CalBox, uid string, principal Principal) !bool {
|
||||
// Check source privileges
|
||||
if !src_cal.acl.can_read(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Check destination privileges
|
||||
if !dst_cal.acl.can_bind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Get source object
|
||||
obj := src_cal.get_by_uid(uid) or { return error(err_resource_not_found) }
|
||||
|
||||
// Check preconditions
|
||||
check_copy_move_preconditions(src_cal, dst_cal, obj)!
|
||||
|
||||
// Copy object
|
||||
dst_cal.put(obj)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles MOVE request
|
||||
pub fn handle_move(src_cal &calbox.CalBox, dst_cal &calbox.CalBox, uid string, principal Principal) !bool {
|
||||
// Check source privileges
|
||||
if !src_cal.acl.can_unbind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Check destination privileges
|
||||
if !dst_cal.acl.can_bind(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Get source object
|
||||
obj := src_cal.get_by_uid(uid) or { return error(err_resource_not_found) }
|
||||
|
||||
// Check preconditions
|
||||
check_copy_move_preconditions(src_cal, dst_cal, obj)!
|
||||
|
||||
// Move object
|
||||
dst_cal.put(obj)!
|
||||
src_cal.delete(uid)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles PROPFIND request
|
||||
pub fn handle_propfind(cal &calbox.CalBox, props []string, principal Principal) !map[string]string {
|
||||
// Check privileges
|
||||
if !cal.acl.can_read(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
mut result := map[string]string{}
|
||||
|
||||
// Get requested properties
|
||||
for prop in props {
|
||||
if value := get_prop_string(cal.props, prop) {
|
||||
result[prop] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Handles PROPPATCH request
|
||||
pub fn handle_proppatch(mut cal &calbox.CalBox, props map[string]string, principal Principal) !bool {
|
||||
// Check privileges
|
||||
if !cal.acl.can_write_props(principal) {
|
||||
return error(err_no_privilege)
|
||||
}
|
||||
|
||||
// Validate and set properties
|
||||
for name, value in props {
|
||||
match name {
|
||||
calendar_timezone { validate_calendar_timezone(value)! }
|
||||
supported_calendar_component_set { validate_supported_component_set(value.split(','))! }
|
||||
else {}
|
||||
}
|
||||
set_prop(mut cal.props, name, value)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
125
lib/servers/calendar/caldav/preconditions.v
Normal file
125
lib/servers/calendar/caldav/preconditions.v
Normal file
@@ -0,0 +1,125 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Checks preconditions for MKCALENDAR
|
||||
pub fn check_mkcalendar_preconditions(path string, props map[string]string) !bool {
|
||||
// Resource must not exist
|
||||
if resource_exists(path) {
|
||||
return error(err_resource_must_be_null)
|
||||
}
|
||||
|
||||
// Valid calendar timezone
|
||||
if tz := props[calendar_timezone] {
|
||||
validate_calendar_timezone(tz)!
|
||||
}
|
||||
|
||||
// Valid component set
|
||||
if comp_set := get_prop_array(props, supported_calendar_component_set) {
|
||||
validate_supported_component_set(comp_set)!
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Checks preconditions for PUT
|
||||
pub fn check_put_preconditions(cal &calbox.CalBox, obj calbox.CalendarObject) !bool {
|
||||
// Supported calendar data
|
||||
validate_calendar_data('text/calendar', '2.0')!
|
||||
|
||||
// Valid calendar data
|
||||
if !is_valid_calendar_data(obj) {
|
||||
return error(err_valid_calendar_data)
|
||||
}
|
||||
|
||||
// Valid calendar object
|
||||
if !is_valid_calendar_object(obj) {
|
||||
return error(err_valid_calendar_object)
|
||||
}
|
||||
|
||||
// Supported component
|
||||
if !cal.supports_component(obj.comp_type) {
|
||||
return error(err_supported_component)
|
||||
}
|
||||
|
||||
// Resource size
|
||||
if max_size := cal.max_resource_size {
|
||||
if obj.size() > max_size {
|
||||
return error('Resource size exceeds maximum allowed')
|
||||
}
|
||||
}
|
||||
|
||||
// Date range
|
||||
if !check_date_range(cal, obj) {
|
||||
return error('Date/time outside allowed range')
|
||||
}
|
||||
|
||||
// Instance count
|
||||
if !check_instance_count(cal, obj) {
|
||||
return error('Too many recurrence instances')
|
||||
}
|
||||
|
||||
// Attendee count
|
||||
if !check_attendee_count(cal, obj) {
|
||||
return error('Too many attendees')
|
||||
}
|
||||
|
||||
// UID conflict
|
||||
if existing := cal.find_by_uid(obj.uid()) {
|
||||
if existing.uid() != obj.uid() {
|
||||
return error_with_href(err_uid_conflict, existing.href())
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Checks preconditions for COPY/MOVE
|
||||
pub fn check_copy_move_preconditions(src_cal &calbox.CalBox, dst_cal &calbox.CalBox, obj calbox.CalendarObject) !bool {
|
||||
// Valid calendar collection location
|
||||
if !is_valid_calendar_location(dst_cal.path) {
|
||||
return error(err_calendar_collection_location)
|
||||
}
|
||||
|
||||
// Check PUT preconditions on destination
|
||||
check_put_preconditions(dst_cal, obj)!
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
fn resource_exists(path string) bool {
|
||||
// TODO: Implement resource existence check
|
||||
return false
|
||||
}
|
||||
|
||||
fn is_valid_calendar_data(obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement calendar data validation
|
||||
return true
|
||||
}
|
||||
|
||||
fn is_valid_calendar_object(obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement calendar object validation
|
||||
return true
|
||||
}
|
||||
|
||||
fn is_valid_calendar_location(path string) bool {
|
||||
// TODO: Implement calendar location validation
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_date_range(cal &calbox.CalBox, obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement date range check
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_instance_count(cal &calbox.CalBox, obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement instance count check
|
||||
return true
|
||||
}
|
||||
|
||||
fn check_attendee_count(cal &calbox.CalBox, obj calbox.CalendarObject) bool {
|
||||
// TODO: Implement attendee count check
|
||||
return true
|
||||
}
|
||||
120
lib/servers/calendar/caldav/props.v
Normal file
120
lib/servers/calendar/caldav/props.v
Normal file
@@ -0,0 +1,120 @@
|
||||
module caldav
|
||||
|
||||
// CalDAV property names
|
||||
pub const (
|
||||
calendar_description = 'calendar-description'
|
||||
calendar_timezone = 'calendar-timezone'
|
||||
supported_calendar_component_set = 'supported-calendar-component-set'
|
||||
supported_calendar_data = 'supported-calendar-data'
|
||||
max_resource_size = 'max-resource-size'
|
||||
min_date_time = 'min-date-time'
|
||||
max_date_time = 'max-date-time'
|
||||
max_instances = 'max-instances'
|
||||
max_attendees_per_instance = 'max-attendees-per-instance'
|
||||
calendar_home_set = 'calendar-home-set'
|
||||
)
|
||||
|
||||
// Property validation errors
|
||||
pub const (
|
||||
err_invalid_timezone = 'Invalid timezone: must be a valid iCalendar object containing a single VTIMEZONE component'
|
||||
err_invalid_component_set = 'Invalid component set: must contain at least one component type'
|
||||
err_invalid_calendar_data = 'Invalid calendar data: must specify a supported media type'
|
||||
err_invalid_resource_size = 'Invalid resource size: must be a positive integer'
|
||||
err_invalid_date_time = 'Invalid date/time: must be a valid UTC date-time value'
|
||||
err_invalid_instances = 'Invalid instances value: must be a positive integer'
|
||||
err_invalid_attendees = 'Invalid attendees value: must be a positive integer'
|
||||
)
|
||||
|
||||
// Property validation functions
|
||||
|
||||
// Validates a calendar timezone property value
|
||||
pub fn validate_calendar_timezone(value string) ! {
|
||||
// TODO: Implement timezone validation
|
||||
// Should parse value as iCalendar and verify it contains exactly one VTIMEZONE
|
||||
if value.len == 0 {
|
||||
return error(err_invalid_timezone)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a supported calendar component set property value
|
||||
pub fn validate_supported_component_set(components []string) ! {
|
||||
if components.len == 0 {
|
||||
return error(err_invalid_component_set)
|
||||
}
|
||||
|
||||
valid := ['VEVENT', 'VTODO', 'VJOURNAL', 'VFREEBUSY']
|
||||
for comp in components {
|
||||
if comp !in valid {
|
||||
return error(err_invalid_component_set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a supported calendar data property value
|
||||
pub fn validate_calendar_data(content_type string, version string) ! {
|
||||
if content_type != 'text/calendar' || version != '2.0' {
|
||||
return error(err_invalid_calendar_data)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a max resource size property value
|
||||
pub fn validate_max_resource_size(size int) ! {
|
||||
if size <= 0 {
|
||||
return error(err_invalid_resource_size)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a min/max date time property value
|
||||
pub fn validate_date_time(value string) ! {
|
||||
// TODO: Implement UTC date-time validation
|
||||
if value.len == 0 {
|
||||
return error(err_invalid_date_time)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a max instances property value
|
||||
pub fn validate_max_instances(count int) ! {
|
||||
if count <= 0 {
|
||||
return error(err_invalid_instances)
|
||||
}
|
||||
}
|
||||
|
||||
// Validates a max attendees per instance property value
|
||||
pub fn validate_max_attendees(count int) ! {
|
||||
if count <= 0 {
|
||||
return error(err_invalid_attendees)
|
||||
}
|
||||
}
|
||||
|
||||
// Property value getters/setters
|
||||
|
||||
// Gets a property value as string
|
||||
pub fn get_prop_string(props map[string]string, name string) ?string {
|
||||
return props[name]
|
||||
}
|
||||
|
||||
// Gets a property value as int
|
||||
pub fn get_prop_int(props map[string]string, name string) ?int {
|
||||
if value := props[name] {
|
||||
return value.int()
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Gets a property value as string array
|
||||
pub fn get_prop_array(props map[string]string, name string) ?[]string {
|
||||
if value := props[name] {
|
||||
return value.split(',')
|
||||
}
|
||||
return none
|
||||
}
|
||||
|
||||
// Sets a property value
|
||||
pub fn set_prop(mut props map[string]string, name string, value string) {
|
||||
props[name] = value
|
||||
}
|
||||
|
||||
// Removes a property
|
||||
pub fn remove_prop(mut props map[string]string, name string) {
|
||||
props.delete(name)
|
||||
}
|
||||
105
lib/servers/calendar/caldav/protocol.v
Normal file
105
lib/servers/calendar/caldav/protocol.v
Normal file
@@ -0,0 +1,105 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Represents a CalDAV server instance
|
||||
pub struct Server {
|
||||
mut:
|
||||
collections map[string]&calbox.CalBox // Map of calendar collections by path
|
||||
}
|
||||
|
||||
// Creates a new CalDAV server instance
|
||||
pub fn new() &Server {
|
||||
return &Server{
|
||||
collections: map[string]&calbox.CalBox{}
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a calendar collection to the server
|
||||
pub fn (mut s Server) add_collection(path string, cal &calbox.CalBox) {
|
||||
s.collections[path] = cal
|
||||
}
|
||||
|
||||
// Gets a calendar collection by path
|
||||
pub fn (s Server) get_collection(path string) ?&calbox.CalBox {
|
||||
return s.collections[path] or { none }
|
||||
}
|
||||
|
||||
// Handles a MKCALENDAR request
|
||||
pub fn (mut s Server) handle_mkcalendar(path string, props map[string]string) !&calbox.CalBox {
|
||||
// Create new calendar collection
|
||||
mut cal := calbox.new(props['displayname'] or { path.all_after_last('/') })
|
||||
|
||||
// Set optional properties
|
||||
if desc := props['calendar-description'] {
|
||||
cal.description = desc
|
||||
}
|
||||
if tz := props['calendar-timezone'] {
|
||||
cal.timezone = tz
|
||||
}
|
||||
if components := props['supported-calendar-component-set'] {
|
||||
cal.supported_components = components.split(',')
|
||||
}
|
||||
|
||||
// Add to server
|
||||
s.add_collection(path, cal)
|
||||
|
||||
return cal
|
||||
}
|
||||
|
||||
// Handles a calendar-query REPORT request
|
||||
pub fn (s Server) handle_calendar_query(path string, filter CalendarQueryFilter) ![]calbox.CalendarObject {
|
||||
// Get calendar collection
|
||||
cal := s.get_collection(path) or { return error('Calendar not found') }
|
||||
|
||||
// Apply filter
|
||||
mut results := []calbox.CalendarObject{}
|
||||
|
||||
if filter.time_range != none {
|
||||
tr := filter.time_range or { return error('Invalid time range') }
|
||||
results = cal.find_by_time(calbox.TimeRange{
|
||||
start: tr.start
|
||||
end: tr.end
|
||||
})!
|
||||
} else {
|
||||
results = cal.list()!
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Handles a calendar-multiget REPORT request
|
||||
pub fn (s Server) handle_calendar_multiget(path string, hrefs []string) ![]calbox.CalendarObject {
|
||||
// Get calendar collection
|
||||
cal := s.get_collection(path) or { return error('Calendar not found') }
|
||||
|
||||
mut results := []calbox.CalendarObject{}
|
||||
|
||||
// Get requested resources
|
||||
for href in hrefs {
|
||||
obj_path := href.all_after(path)
|
||||
if obj := cal.get_by_uid(obj_path) {
|
||||
results << obj
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Handles a free-busy-query REPORT request
|
||||
pub fn (s Server) handle_freebusy_query(path string, tr calbox.TimeRange) ![]calbox.TimeRange {
|
||||
// Get calendar collection
|
||||
cal := s.get_collection(path) or { return error('Calendar not found') }
|
||||
|
||||
// Get free/busy info
|
||||
return cal.get_freebusy(tr)
|
||||
}
|
||||
|
||||
// Filter for calendar-query REPORT
|
||||
pub struct CalendarQueryFilter {
|
||||
pub mut:
|
||||
time_range ?calbox.TimeRange
|
||||
comp_type string
|
||||
prop_name string
|
||||
text_match string
|
||||
}
|
||||
177
lib/servers/calendar/caldav/reports.v
Normal file
177
lib/servers/calendar/caldav/reports.v
Normal file
@@ -0,0 +1,177 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
|
||||
// Report types
|
||||
pub const (
|
||||
calendar_query = 'calendar-query'
|
||||
calendar_multiget = 'calendar-multiget'
|
||||
freebusy_query = 'free-busy-query'
|
||||
)
|
||||
|
||||
// Report request parameters
|
||||
pub struct ReportParams {
|
||||
pub mut:
|
||||
depth int // Depth header value
|
||||
props []string // Properties to return
|
||||
filter CalendarQueryFilter // Query filter
|
||||
hrefs []string // Resource hrefs for multiget
|
||||
timezone string // Timezone for floating time conversion
|
||||
expand bool // Whether to expand recurrences
|
||||
limit_set bool // Whether to limit recurrence/freebusy set
|
||||
time_range ?calbox.TimeRange // Time range for limiting results
|
||||
}
|
||||
|
||||
// Report response
|
||||
pub struct ReportResponse {
|
||||
pub mut:
|
||||
status int // HTTP status code
|
||||
objects []calbox.CalendarObject // Calendar objects
|
||||
ranges []calbox.TimeRange // Free/busy ranges
|
||||
error ?CalDAVError // Error if any
|
||||
}
|
||||
|
||||
// Handles a calendar-query REPORT
|
||||
pub fn handle_calendar_query(cal &calbox.CalBox, params ReportParams) ReportResponse {
|
||||
mut response := ReportResponse{
|
||||
status: 207 // Multi-Status
|
||||
}
|
||||
|
||||
// Check privileges
|
||||
if !cal.acl.can_read(params.principal) {
|
||||
response.status = 403
|
||||
response.error = err_forbidden_error(err_no_privilege)
|
||||
return response
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
objects := cal.find_by_filter(params.filter) or {
|
||||
response.status = 500
|
||||
response.error = new_error(500, err.str())
|
||||
return response
|
||||
}
|
||||
|
||||
// Apply time range if specified
|
||||
if tr := params.time_range {
|
||||
for obj in objects {
|
||||
if obj.overlaps(tr) {
|
||||
response.objects << obj
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.objects = objects
|
||||
}
|
||||
|
||||
// Expand recurrences if requested
|
||||
if params.expand {
|
||||
response.objects = expand_recurrences(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
// Limit recurrence set if requested
|
||||
if params.limit_set {
|
||||
response.objects = limit_recurrence_set(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Handles a calendar-multiget REPORT
|
||||
pub fn handle_calendar_multiget(cal &calbox.CalBox, params ReportParams) ReportResponse {
|
||||
mut response := ReportResponse{
|
||||
status: 207 // Multi-Status
|
||||
}
|
||||
|
||||
// Check privileges
|
||||
if !cal.acl.can_read(params.principal) {
|
||||
response.status = 403
|
||||
response.error = err_forbidden_error(err_no_privilege)
|
||||
return response
|
||||
}
|
||||
|
||||
// Get requested resources
|
||||
for href in params.hrefs {
|
||||
if obj := cal.get_by_href(href) {
|
||||
response.objects << obj
|
||||
}
|
||||
}
|
||||
|
||||
// Expand recurrences if requested
|
||||
if params.expand {
|
||||
response.objects = expand_recurrences(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
// Limit recurrence set if requested
|
||||
if params.limit_set {
|
||||
response.objects = limit_recurrence_set(response.objects, params.time_range)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Handles a free-busy-query REPORT
|
||||
pub fn handle_freebusy_query(cal &calbox.CalBox, params ReportParams) ReportResponse {
|
||||
mut response := ReportResponse{
|
||||
status: 200 // OK
|
||||
}
|
||||
|
||||
// Check privileges
|
||||
if !cal.acl.can_read_freebusy(params.principal) {
|
||||
response.status = 403
|
||||
response.error = err_forbidden_error(err_no_privilege)
|
||||
return response
|
||||
}
|
||||
|
||||
// Get time range
|
||||
tr := params.time_range or {
|
||||
response.status = 400
|
||||
response.error = new_error(400, 'Missing time range')
|
||||
return response
|
||||
}
|
||||
|
||||
// Get free/busy ranges
|
||||
ranges := cal.get_freebusy(tr) or {
|
||||
response.status = 500
|
||||
response.error = new_error(500, err.str())
|
||||
return response
|
||||
}
|
||||
|
||||
response.ranges = ranges
|
||||
return response
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
fn expand_recurrences(objects []calbox.CalendarObject, tr ?calbox.TimeRange) []calbox.CalendarObject {
|
||||
mut expanded := []calbox.CalendarObject{}
|
||||
|
||||
for obj in objects {
|
||||
if obj.is_recurring() {
|
||||
if range := tr {
|
||||
expanded << obj.expand(range)
|
||||
} else {
|
||||
expanded << obj.expand_all()
|
||||
}
|
||||
} else {
|
||||
expanded << obj
|
||||
}
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
fn limit_recurrence_set(objects []calbox.CalendarObject, tr ?calbox.TimeRange) []calbox.CalendarObject {
|
||||
if tr == none {
|
||||
return objects
|
||||
}
|
||||
|
||||
mut limited := []calbox.CalendarObject{}
|
||||
range := tr or { return objects }
|
||||
|
||||
for obj in objects {
|
||||
if obj.overlaps(range) {
|
||||
limited << obj
|
||||
}
|
||||
}
|
||||
|
||||
return limited
|
||||
}
|
||||
160
lib/servers/calendar/caldav/xml.v
Normal file
160
lib/servers/calendar/caldav/xml.v
Normal file
@@ -0,0 +1,160 @@
|
||||
module caldav
|
||||
|
||||
import calendar.calbox
|
||||
import encoding.xml
|
||||
|
||||
// XML namespace for CalDAV
|
||||
const caldav_ns = 'urn:ietf:params:xml:ns:caldav'
|
||||
|
||||
// Parses a MKCALENDAR request body
|
||||
pub fn parse_mkcalendar(body string) !map[string]string {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
mut props := map[string]string{}
|
||||
|
||||
// Find set element
|
||||
set := doc.get_element('set') or { return error('Missing set element') }
|
||||
|
||||
// Find prop element
|
||||
prop := set.get_element('prop') or { return error('Missing prop element') }
|
||||
|
||||
// Parse properties
|
||||
for child in prop.children {
|
||||
match child.name {
|
||||
'displayname' { props['displayname'] = child.text() }
|
||||
'calendar-description' { props['calendar-description'] = child.text() }
|
||||
'calendar-timezone' { props['calendar-timezone'] = child.text() }
|
||||
'supported-calendar-component-set' {
|
||||
mut components := []string{}
|
||||
for comp in child.children {
|
||||
if comp.name == 'comp' {
|
||||
if name := comp.attributes['name'] {
|
||||
components << name
|
||||
}
|
||||
}
|
||||
}
|
||||
props['supported-calendar-component-set'] = components.join(',')
|
||||
}
|
||||
else {}
|
||||
}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
// Parses a calendar-query REPORT request body
|
||||
pub fn parse_calendar_query(body string) !CalendarQueryFilter {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
mut filter := CalendarQueryFilter{}
|
||||
|
||||
// Find filter element
|
||||
filter_elem := doc.get_element('filter') or { return error('Missing filter element') }
|
||||
|
||||
// Parse filter
|
||||
if comp := filter_elem.get_element('comp-filter') {
|
||||
filter.comp_type = comp.attributes['name'] or { '' }
|
||||
|
||||
// Check for time-range
|
||||
if tr := comp.get_element('time-range') {
|
||||
start := tr.attributes['start'] or { '' }
|
||||
end := tr.attributes['end'] or { '' }
|
||||
if start != '' && end != '' {
|
||||
filter.time_range = calbox.TimeRange{
|
||||
start: calbox.parse_datetime(start)!
|
||||
end: calbox.parse_datetime(end)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for property filter
|
||||
if prop := comp.get_element('prop-filter') {
|
||||
filter.prop_name = prop.attributes['name'] or { '' }
|
||||
|
||||
// Check for text match
|
||||
if tm := prop.get_element('text-match') {
|
||||
filter.text_match = tm.text()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filter
|
||||
}
|
||||
|
||||
// Parses a calendar-multiget REPORT request body
|
||||
pub fn parse_calendar_multiget(body string) ![]string {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
mut hrefs := []string{}
|
||||
|
||||
// Find href elements
|
||||
for href in doc.find_all_elements('href') {
|
||||
hrefs << href.text()
|
||||
}
|
||||
|
||||
return hrefs
|
||||
}
|
||||
|
||||
// Parses a free-busy-query REPORT request body
|
||||
pub fn parse_freebusy_query(body string) !calbox.TimeRange {
|
||||
doc := xml.parse(body) or { return error('Invalid XML') }
|
||||
|
||||
// Find time-range element
|
||||
tr := doc.get_element('time-range') or { return error('Missing time-range element') }
|
||||
|
||||
start := tr.attributes['start'] or { return error('Missing start attribute') }
|
||||
end := tr.attributes['end'] or { return error('Missing end attribute') }
|
||||
|
||||
return calbox.TimeRange{
|
||||
start: calbox.parse_datetime(start)!
|
||||
end: calbox.parse_datetime(end)!
|
||||
}
|
||||
}
|
||||
|
||||
// Generates XML response for MKCALENDAR
|
||||
pub fn generate_mkcalendar_response() string {
|
||||
return '<?xml version="1.0" encoding="utf-8" ?>\n<mkcalendar-response xmlns="urn:ietf:params:xml:ns:caldav"/>'
|
||||
}
|
||||
|
||||
// Generates XML response for calendar-query REPORT
|
||||
pub fn generate_calendar_query_response(objects []calbox.CalendarObject) string {
|
||||
mut response := '<?xml version="1.0" encoding="utf-8" ?>\n<multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">\n'
|
||||
|
||||
for obj in objects {
|
||||
response += ' <response>\n'
|
||||
response += ' <href>${obj.uid}</href>\n'
|
||||
response += ' <propstat>\n'
|
||||
response += ' <prop>\n'
|
||||
response += ' <getetag>${obj.etag}</getetag>\n'
|
||||
response += ' <C:calendar-data>${obj.to_ical()}</C:calendar-data>\n'
|
||||
response += ' </prop>\n'
|
||||
response += ' <status>HTTP/1.1 200 OK</status>\n'
|
||||
response += ' </propstat>\n'
|
||||
response += ' </response>\n'
|
||||
}
|
||||
|
||||
response += '</multistatus>'
|
||||
return response
|
||||
}
|
||||
|
||||
// Generates XML response for calendar-multiget REPORT
|
||||
pub fn generate_calendar_multiget_response(objects []calbox.CalendarObject) string {
|
||||
// Same format as calendar-query response
|
||||
return generate_calendar_query_response(objects)
|
||||
}
|
||||
|
||||
// Generates XML response for free-busy-query REPORT
|
||||
pub fn generate_freebusy_response(ranges []calbox.TimeRange) string {
|
||||
mut response := 'BEGIN:VCALENDAR\r\n'
|
||||
response += 'VERSION:2.0\r\n'
|
||||
response += 'PRODID:-//Example Corp.//CalDAV Server//EN\r\n'
|
||||
response += 'BEGIN:VFREEBUSY\r\n'
|
||||
|
||||
for r in ranges {
|
||||
response += 'FREEBUSY:${calbox.format_datetime_utc(r.start)}/${calbox.format_datetime_utc(r.end)}\r\n'
|
||||
}
|
||||
|
||||
response += 'END:VFREEBUSY\r\n'
|
||||
response += 'END:VCALENDAR\r\n'
|
||||
return response
|
||||
}
|
||||
Reference in New Issue
Block a user