diff --git a/Sources/Ontology/Types/ItemList.swift b/Sources/Ontology/Types/ItemList.swift new file mode 100644 index 0000000..2559220 --- /dev/null +++ b/Sources/Ontology/Types/ItemList.swift @@ -0,0 +1,70 @@ +import Foundation + +/// An ItemList model following Schema.org ontology (https://schema.org/ItemList) +public struct ItemList: Hashable, Sendable { + /// Unique identifier for the item list + public var identifier: String? + + /// The name/title of the item list + public var name: String? + + /// URL associated with the item list + public var url: URL? + + /// The number of items in the list + public var numberOfItems: Int? + + public init(name: String? = nil, numberOfItems: Int? = nil) { + self.name = name + self.numberOfItems = numberOfItems + } +} + +#if canImport(EventKit) + import EventKit + + extension ItemList { + /// Initialize an ItemList from an EKCalendar + public init(_ calendar: EKCalendar) { + self.identifier = calendar.calendarIdentifier + self.name = calendar.title + } + } +#endif + +extension ItemList: Codable { + private enum CodingKeys: String, CodingKey { + case name, url, numberOfItems + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: JSONLDCodingKey.self) + + if encoder.codingPath.isEmpty { + try container.encode(schema.org, forKey: .context) + } + + try container.encode("ItemList", forKey: .type) + try container.encodeIfPresent(identifier, forKey: .id) + try container.encodeIfPresent(name, forKey: .attribute(.name)) + try container.encodeIfPresent(url, forKey: .attribute(.url)) + try container.encodeIfPresent(numberOfItems, forKey: .attribute(.numberOfItems)) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: JSONLDCodingKey.self) + let decodedType = try container.decode(String.self, forKey: .type) + guard decodedType == "ItemList" else { + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Expected type to be 'ItemList', but found \(decodedType)" + ) + } + + identifier = try container.decodeIfPresent(String.self, forKey: .id) + name = try container.decodeIfPresent(String.self, forKey: .attribute(.name)) + url = try container.decodeIfPresent(URL.self, forKey: .attribute(.url)) + numberOfItems = try container.decodeIfPresent(Int.self, forKey: .attribute(.numberOfItems)) + } +} diff --git a/Sources/Ontology/Types/PlanAction.swift b/Sources/Ontology/Types/PlanAction.swift index 2a5f3cf..c5c831b 100644 --- a/Sources/Ontology/Types/PlanAction.swift +++ b/Sources/Ontology/Types/PlanAction.swift @@ -29,6 +29,9 @@ public struct PlanAction: Hashable, Sendable { /// URLs associated with the plan action public var url: URL? + /// The list this reminder belongs to, modeled as a Schema.org ItemList. + public var object: ItemList? + public init( name: String, dueDate: Date? = nil, @@ -58,6 +61,9 @@ public struct PlanAction: Hashable, Sendable { self.status = reminder.isCompleted ? .completed : .potential self.priority = reminder.priority > 0 ? reminder.priority : nil self.url = reminder.url + if let calendar = reminder.calendar { + self.object = ItemList(calendar) + } } } #endif @@ -66,7 +72,7 @@ extension PlanAction: Codable { private enum CodingKeys: String, CodingKey { case name, description, scheduledTime case status = "actionStatus" - case priority, url + case priority, url, object } public func encode(to encoder: Encoder) throws { @@ -90,6 +96,7 @@ extension PlanAction: Codable { try container.encodeIfPresent(status?.rawValue, forKey: .attribute(.status)) try container.encodeIfPresent(priority, forKey: .attribute(.priority)) try container.encodeIfPresent(url, forKey: .attribute(.url)) + try container.encodeIfPresent(object, forKey: .attribute(.object)) } public init(from decoder: Decoder) throws { @@ -123,5 +130,6 @@ extension PlanAction: Codable { priority = try container.decodeIfPresent(Int.self, forKey: .attribute(.priority)) url = try container.decodeIfPresent(URL.self, forKey: .attribute(.url)) + object = try container.decodeIfPresent(ItemList.self, forKey: .attribute(.object)) } } diff --git a/Tests/OntologyTests/ItemListTests.swift b/Tests/OntologyTests/ItemListTests.swift new file mode 100644 index 0000000..9d75db5 --- /dev/null +++ b/Tests/OntologyTests/ItemListTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing + +@testable import Ontology + +@Suite +struct ItemListTests { + @Test("ItemList basic initialization") + func testBasicInitialization() throws { + let itemList = ItemList(name: "Shopping List", numberOfItems: 5) + + #expect(itemList.name == "Shopping List") + #expect(itemList.numberOfItems == 5) + #expect(itemList.identifier == nil) + #expect(itemList.url == nil) + } + + @Test("ItemList JSON-LD encoding") + func testJSONLDEncoding() throws { + var itemList = ItemList(name: "Test List", numberOfItems: 3) + itemList.identifier = "list-id" + itemList.url = URL(string: "https://example.com/list") + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(itemList) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + #expect(json["@context"] as? String == "https://schema.org") + #expect(json["@type"] as? String == "ItemList") + #expect(json["@id"] as? String == "list-id") + #expect(json["name"] as? String == "Test List") + #expect(json["numberOfItems"] as? Int == 3) + #expect(json["url"] as? String == "https://example.com/list") + } + + @Test("ItemList JSON-LD decoding") + func testJSONLDDecoding() throws { + let json = """ + { + "@context": "https://schema.org", + "@type": "ItemList", + "@id": "decoded-list", + "name": "Decoded List", + "numberOfItems": 7, + "url": "https://example.com/decoded" + } + """ + + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + let itemList = try decoder.decode(ItemList.self, from: data) + + #expect(itemList.identifier == "decoded-list") + #expect(itemList.name == "Decoded List") + #expect(itemList.numberOfItems == 7) + #expect(itemList.url?.absoluteString == "https://example.com/decoded") + } + + @Test("ItemList equality and hashing") + func testEqualityAndHashing() throws { + let itemList1 = ItemList(name: "Test List", numberOfItems: 5) + let itemList2 = ItemList(name: "Test List", numberOfItems: 5) + let itemList3 = ItemList(name: "Different List", numberOfItems: 5) + + #expect(itemList1 == itemList2) + #expect(itemList1 != itemList3) + #expect(itemList1.hashValue == itemList2.hashValue) + } + + @Test("ItemList type validation on decode") + func testTypeValidation() throws { + let invalidJson = """ + { + "@context": "https://schema.org", + "@type": "WrongType", + "name": "Invalid" + } + """ + + let data = invalidJson.data(using: .utf8)! + let decoder = JSONDecoder() + + #expect(throws: DecodingError.self) { + try decoder.decode(ItemList.self, from: data) + } + } +} diff --git a/Tests/OntologyTests/PlanActionTests.swift b/Tests/OntologyTests/PlanActionTests.swift new file mode 100644 index 0000000..1833eb8 --- /dev/null +++ b/Tests/OntologyTests/PlanActionTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing + +@testable import Ontology + +@Suite +struct PlanActionTests { + @Test("PlanAction basic initialization") + func testBasicInitialization() throws { + let planAction = PlanAction( + name: "Buy groceries", + dueDate: Date(timeIntervalSince1970: 1_640_995_200), // 2022-01-01 + description: "Weekly grocery shopping", + completed: false + ) + + #expect(planAction.name == "Buy groceries") + #expect(planAction.description == "Weekly grocery shopping") + #expect(planAction.status == .potential) + #expect(planAction.scheduledTime?.value == Date(timeIntervalSince1970: 1_640_995_200)) + } + + @Test("PlanAction completed status") + func testCompletedStatus() throws { + let planAction = PlanAction( + name: "Complete project", + completed: true + ) + + #expect(planAction.status == .completed) + } + + @Test("PlanAction status enum values") + func testStatusEnumValues() throws { + #expect(PlanAction.Status.active.rawValue == "ActiveAction") + #expect(PlanAction.Status.completed.rawValue == "CompletedAction") + #expect(PlanAction.Status.failed.rawValue == "FailedAction") + #expect(PlanAction.Status.potential.rawValue == "PotentialAction") + } + + @Test("PlanAction JSON-LD encoding") + func testJSONLDEncoding() throws { + var planAction = PlanAction( + name: "Test task", + dueDate: Date(timeIntervalSince1970: 1_640_995_200), + description: "A test task", + completed: false + ) + planAction.identifier = "test-id" + planAction.priority = 5 + planAction.url = URL(string: "https://example.com/task") + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(planAction) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + #expect(json["@context"] as? String == "https://schema.org") + #expect(json["@type"] as? String == "PlanAction") + #expect(json["@id"] as? String == "test-id") + #expect(json["name"] as? String == "Test task") + #expect(json["description"] as? String == "A test task") + #expect(json["actionStatus"] as? String == "PotentialAction") + #expect(json["priority"] as? Int == 5) + #expect(json["url"] as? String == "https://example.com/task") + } + + @Test("PlanAction JSON-LD decoding") + func testJSONLDDecoding() throws { + let json = """ + { + "@context": "https://schema.org", + "@type": "PlanAction", + "@id": "test-id", + "name": "Decoded task", + "description": "A decoded task", + "actionStatus": "CompletedAction", + "priority": 3, + "url": "https://example.com/decoded" + } + """ + + let data = json.data(using: .utf8)! + let decoder = JSONDecoder() + let planAction = try decoder.decode(PlanAction.self, from: data) + + #expect(planAction.identifier == "test-id") + #expect(planAction.name == "Decoded task") + #expect(planAction.description == "A decoded task") + #expect(planAction.status == .completed) + #expect(planAction.priority == 3) + #expect(planAction.url?.absoluteString == "https://example.com/decoded") + } + + @Test("PlanAction with ItemList object") + func testPlanActionWithItemList() throws { + var planAction = PlanAction( + name: "Task in list", + completed: false + ) + + let itemList = ItemList(name: "My Tasks", numberOfItems: 10) + planAction.object = itemList + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(planAction) + let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + #expect(json["@context"] as? String == "https://schema.org") + #expect(json["@type"] as? String == "PlanAction") + #expect(json["name"] as? String == "Task in list") + + let object = json["object"] as! [String: Any] + #expect(object["@type"] as? String == "ItemList") + #expect(object["name"] as? String == "My Tasks") + #expect(object["numberOfItems"] as? Int == 10) + } + + @Test("PlanAction equality and hashing") + func testEqualityAndHashing() throws { + let planAction1 = PlanAction(name: "Test", completed: false) + let planAction2 = PlanAction(name: "Test", completed: false) + let planAction3 = PlanAction(name: "Different", completed: false) + + #expect(planAction1 == planAction2) + #expect(planAction1 != planAction3) + #expect(planAction1.hashValue == planAction2.hashValue) + } +}