diff --git a/Sources/Ontology/Types/Event.swift b/Sources/Ontology/Types/Event.swift index 16434a0..b878461 100644 --- a/Sources/Ontology/Types/Event.swift +++ b/Sources/Ontology/Types/Event.swift @@ -24,6 +24,28 @@ public struct Event: Hashable, Sendable { /// URLs associated with the event public var url: URL? + /// Whether the event lasts all day. + /// + /// This property isn't part of Schema.org. + public var isAllDay: Bool? + + /// Event status values based on Schema.org EventStatusType + public enum Status: String, Codable, Hashable, Sendable { + case scheduled = "EventScheduled" + case cancelled = "EventCancelled" + case postponed = "EventPostponed" + case rescheduled = "EventRescheduled" + } + + /// Status of the event + public var eventStatus: Status? + + /// The person who organizes the event + public var organizer: Person? + + /// People who attend the event + public var attendee: [Person]? + public init( name: String, dates: Range @@ -56,6 +78,27 @@ public struct Event: Hashable, Sendable { self.endDate = DateTime(event.endDate, timeZone: event.timeZone) self.location = event.location self.url = event.url + self.isAllDay = event.isAllDay ? true : nil + self.eventStatus = Status(event.status) + self.organizer = event.organizer.map(Person.init) + if let attendees = event.attendees, !attendees.isEmpty { + self.attendee = attendees.map(Person.init) + } + } + } + + extension Event.Status { + /// Initialize an event status with an EventKit event status. + /// + /// Returns `nil` for `EKEventStatus.none` and `EKEventStatus.tentative`, + /// because Schema.org has no matching `EventStatusType` member. + init?(_ status: EKEventStatus) { + switch status { + case .confirmed: self = .scheduled + case .canceled: self = .cancelled + case .none, .tentative: return nil + @unknown default: return nil + } } } #endif @@ -63,6 +106,7 @@ public struct Event: Hashable, Sendable { extension Event: Codable { private enum CodingKeys: String, CodingKey { case name, description, startDate, endDate, location, url, calendar + case isAllDay, eventStatus, organizer, attendee } public func encode(to encoder: Encoder) throws { @@ -87,6 +131,10 @@ extension Event: Codable { try container.encodeIfPresent(endDate, forKey: .attribute(.endDate)) try container.encodeIfPresent(location, forKey: .attribute(.location)) try container.encodeIfPresent(url, forKey: .attribute(.url)) + try container.encodeIfPresent(isAllDay, forKey: .attribute(.isAllDay)) + try container.encodeIfPresent(eventStatus, forKey: .attribute(.eventStatus)) + try container.encodeIfPresent(organizer, forKey: .attribute(.organizer)) + try container.encodeIfPresent(attendee, forKey: .attribute(.attendee)) } public init(from decoder: Decoder) throws { @@ -114,5 +162,9 @@ extension Event: Codable { endDate = try container.decodeIfPresent(DateTime.self, forKey: .attribute(.endDate)) location = try container.decodeIfPresent(String.self, forKey: .attribute(.location)) url = try container.decodeIfPresent(URL.self, forKey: .attribute(.url)) + isAllDay = try container.decodeIfPresent(Bool.self, forKey: .attribute(.isAllDay)) + eventStatus = try container.decodeIfPresent(Status.self, forKey: .attribute(.eventStatus)) + organizer = try container.decodeIfPresent(Person.self, forKey: .attribute(.organizer)) + attendee = try container.decodeIfPresent([Person].self, forKey: .attribute(.attendee)) } } diff --git a/Sources/Ontology/Types/Person.swift b/Sources/Ontology/Types/Person.swift index 9500634..36db49e 100644 --- a/Sources/Ontology/Types/Person.swift +++ b/Sources/Ontology/Types/Person.swift @@ -198,6 +198,37 @@ public struct Person: Hashable, Sendable { } #endif +#if canImport(EventKit) + import EventKit + + extension Person { + /// Initialize a Person from an EventKit event participant. + /// + /// The participant's display name is split into given and family names, + /// which is imperfect for some names. + /// The email address comes from the participant's `mailto:` URL, if any. + /// The participant's status, role, and type aren't preserved. + public init(_ participant: EKParticipant) { + self.init(participantName: participant.name, url: participant.url) + } + + init(participantName: String?, url: URL?) { + if let name = participantName, !name.isEmpty { + let person = Person(name: name) + self.givenName = person.givenName + self.familyName = person.familyName + } + + if let url, url.scheme?.lowercased() == "mailto", + let address = URLComponents(url: url, resolvingAgainstBaseURL: false)?.path, + !address.isEmpty + { + self.email = [address] + } + } + } +#endif + extension Person: Codable { private enum CodingKeys: String, CodingKey { case givenName, familyName, email, telephone, address diff --git a/Tests/OntologyTests/EventTests.swift b/Tests/OntologyTests/EventTests.swift index b6c25d8..96e5370 100644 --- a/Tests/OntologyTests/EventTests.swift +++ b/Tests/OntologyTests/EventTests.swift @@ -107,6 +107,123 @@ struct EventTests { #expect(decoded.description == original.description) } + @Test("Event initialization preserves all-day status") + func testAllDayPreservation() throws { + let eventStore = EKEventStore() + let event = EKEvent(eventStore: eventStore) + + event.title = "Holiday" + event.startDate = Date(timeIntervalSinceReferenceDate: 0) + event.endDate = Date(timeIntervalSinceReferenceDate: 86400) + event.isAllDay = true + + #expect(Event(event).isAllDay == true) + + event.isAllDay = false + + #expect(Event(event).isAllDay == nil) + } + + @Test("Event status maps from EventKit event status") + func testEventStatusMapping() throws { + #expect(Event.Status(EKEventStatus.confirmed) == .scheduled) + #expect(Event.Status(EKEventStatus.canceled) == .cancelled) + #expect(Event.Status(EKEventStatus.tentative) == nil) + #expect(Event.Status(EKEventStatus.none) == nil) + + let eventStore = EKEventStore() + let event = EKEvent(eventStore: eventStore) + event.title = "Test Event" + event.startDate = Date(timeIntervalSinceReferenceDate: 0) + event.endDate = Date(timeIntervalSinceReferenceDate: 3600) + + #expect(Event(event).eventStatus == nil) + } + + @Test("Participant maps to Person with name and email") + func testParticipantMapping() throws { + let person = Person( + participantName: "Jane Appleseed", + url: URL(string: "mailto:jane@example.com") + ) + #expect(person.givenName == "Jane") + #expect(person.familyName == "Appleseed") + #expect(person.email == ["jane@example.com"]) + + let unnamed = Person( + participantName: nil, + url: URL(string: "mailto:room@example.com") + ) + #expect(unnamed.givenName == nil) + #expect(unnamed.familyName == nil) + #expect(unnamed.email == ["room@example.com"]) + + let withoutEmail = Person( + participantName: "", + url: URL(string: "urn:uuid:00000000-0000-0000-0000-000000000000") + ) + #expect(withoutEmail.givenName == nil) + #expect(withoutEmail.email == nil) + } + + @Test("Event without new properties omits their keys") + func testNewPropertiesOmittedWhenAbsent() throws { + let eventStore = EKEventStore() + let event = EKEvent(eventStore: eventStore) + + event.title = "Test Event" + event.startDate = Date(timeIntervalSinceReferenceDate: 0) + event.endDate = Date(timeIntervalSinceReferenceDate: 3600) + + let encoded = try JSONEncoder().encode(Event(event)) + let json = try JSONSerialization.jsonObject(with: encoded) as! [String: Any] + + #expect(json["isAllDay"] == nil) + #expect(json["eventStatus"] == nil) + #expect(json["organizer"] == nil) + #expect(json["attendee"] == nil) + } + + @Test("Event round-trip serialization preserves status and participants") + func testStatusAndParticipantsRoundTrip() throws { + let start = Date(timeIntervalSinceReferenceDate: 0) + let end = Date(timeIntervalSinceReferenceDate: 3600) + var original = Event(name: "Planning", dates: start..