Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Sources/Ontology/Types/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date>
Expand Down Expand Up @@ -56,13 +78,35 @@ 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

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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
}
}
31 changes: 31 additions & 0 deletions Sources/Ontology/Types/Person.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions Tests/OntologyTests/EventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<end)
original.isAllDay = true
original.eventStatus = .cancelled
original.organizer = Person(
participantName: "Jane Appleseed",
url: URL(string: "mailto:jane@example.com")
)
original.attendee = [
Person(
participantName: "John Appleseed",
url: URL(string: "mailto:john@example.com")
),
Person(participantName: nil, url: URL(string: "mailto:room@example.com")),
]

let encoded = try JSONEncoder().encode(original)
let json = try JSONSerialization.jsonObject(with: encoded) as! [String: Any]

#expect(json["isAllDay"] as? Bool == true)
#expect(json["eventStatus"] as? String == "EventCancelled")

let organizer = json["organizer"] as? [String: Any]
#expect(organizer?["@type"] as? String == "Person")
#expect(organizer?["@context"] == nil)
#expect(organizer?["email"] as? [String] == ["jane@example.com"])

let attendees = json["attendee"] as? [[String: Any]]
#expect(attendees?.count == 2)

let decoded = try JSONDecoder().decode(Event.self, from: encoded)
#expect(decoded.isAllDay == original.isAllDay)
#expect(decoded.eventStatus == original.eventStatus)
#expect(decoded.organizer == original.organizer)
#expect(decoded.attendee == original.attendee)
}

@Test("Event JSON-LD encoding preserves all properties")
func testJSONLDEncoding() throws {
let eventStore = EKEventStore()
Expand Down
Loading