caldav...

This commit is contained in:
2025-02-16 10:05:35 +03:00
parent 7ae4f7dbd0
commit 3f9a3fb1cd
20 changed files with 3928 additions and 0 deletions

View 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
}

View 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'
}
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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}') }
}
}

View 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)!
}

View 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
}

View 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)
}

View 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)!
}

View 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
}

View 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')
}

View 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'
}
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}