diff --git a/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift b/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift index 598ba29..13c8774 100644 --- a/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift +++ b/Packages/MagnumOpusCore/Sources/TaskStore/TaskStore.swift @@ -1,13 +1,266 @@ +import Foundation @_exported import MailStore +import GRDB import Models -/// TaskStore provides task-specific business logic on top of MailStore's -/// database records and queries. The underlying TaskRecord and its CRUD -/// operations live in MailStore to share the same database connection. +/// TaskStore manages VTODO files on disk and their corresponding cache in SQLite. public struct TaskStore: Sendable { - public let mailStore: MailStore + public let taskDirectory: URL + public let dbWriter: any DatabaseWriter - public init(mailStore: MailStore) { - self.mailStore = mailStore + public init(taskDirectory: URL, dbWriter: any DatabaseWriter) { + self.taskDirectory = taskDirectory + self.dbWriter = dbWriter + } + + // MARK: - Write + + /// Write a task as a .ics file and upsert the cache record. + public func writeTask( + id: String, + accountId: String, + summary: String, + description: String? = nil, + status: String = "NEEDS-ACTION", + priority: Int = 0, + dueDate: Date? = nil, + deferUntil: Date? = nil, + linkedMessageId: String? = nil, + categories: [String]? = nil, + isSomeday: Bool = false + ) throws { + let now = Date() + + // Preserve original createdAt if updating existing task + let createdAt: Date + let existing = try dbWriter.read { db in + try TaskRecord.fetchOne(db, key: id) + } + if let existing, let parsed = VTODOParser.parseDate(existing.createdAt) { + createdAt = parsed + } else { + createdAt = now + } + + let icsContent = VTODOFormatter.format( + uid: id, + summary: summary, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + deferUntil: deferUntil, + categories: categories, + linkedMessageId: linkedMessageId, + isSomeday: isSomeday, + createdAt: createdAt, + updatedAt: now + ) + + // Ensure directory exists + try FileManager.default.createDirectory(at: taskDirectory, withIntermediateDirectories: true) + + let filePath = taskDirectory.appendingPathComponent("\(id).ics") + try icsContent.write(to: filePath, atomically: true, encoding: .utf8) + + // Upsert cache record + var record = TaskRecord( + id: id, + accountId: accountId, + summary: summary, + description: description, + status: status, + priority: priority, + dueDate: dueDate.map { VTODOParser.formatDateOnly($0) }, + deferUntil: deferUntil.map { VTODOParser.formatDateOnly($0) }, + createdAt: VTODOParser.formatDate(createdAt), + updatedAt: VTODOParser.formatDate(now), + filePath: filePath.path, + linkedMessageId: linkedMessageId, + isSomeday: isSomeday + ) + try dbWriter.write { db in + try record.save(db) + } + } + + // MARK: - Update status + + /// Update the status of a task in both the cache and the .ics file. + public func updateStatus(id: String, status: String) throws { + guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { + return + } + + record.status = status + record.updatedAt = VTODOParser.formatDate(Date()) + + try dbWriter.write { db in + try record.update(db) + } + + try rewriteICSFile(for: record) + } + + // MARK: - Update deferral + + /// Update the deferral date and someday flag for a task. + public func updateDeferral(id: String, deferUntil: Date?, isSomeday: Bool) throws { + guard var record = try dbWriter.read({ db in try TaskRecord.fetchOne(db, key: id) }) else { + return + } + + record.deferUntil = deferUntil.map { VTODOParser.formatDateOnly($0) } + record.isSomeday = isSomeday + record.updatedAt = VTODOParser.formatDate(Date()) + + try dbWriter.write { db in + try record.update(db) + } + + try rewriteICSFile(for: record) + } + + // MARK: - Delete + + /// Delete a task's .ics file and cache record. + public func deleteTask(id: String) throws { + let filePath = taskDirectory.appendingPathComponent("\(id).ics") + try? FileManager.default.removeItem(at: filePath) + + try dbWriter.write { db in + _ = try TaskRecord.deleteOne(db, key: id) + } + } + + // MARK: - Rebuild cache + + /// Clear the task cache and rebuild it from .ics files on disk. + public func rebuildCache(accountId: String) throws { + // Clear existing task records and their labels + try dbWriter.write { db in + _ = try TaskRecord.deleteAll(db) + try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'") + } + + let fm = FileManager.default + guard fm.fileExists(atPath: taskDirectory.path) else { return } + + let files = try fm.contentsOfDirectory(at: taskDirectory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "ics" } + + for file in files { + let content = try String(contentsOf: file, encoding: .utf8) + guard let props = VTODOParser.parse(content) else { continue } + + let uid = props["UID"] ?? file.deletingPathExtension().lastPathComponent + let summary = props["SUMMARY"] ?? "" + let description = props["DESCRIPTION"] + let status = props["STATUS"] ?? "NEEDS-ACTION" + let priority = Int(props["PRIORITY"] ?? "0") ?? 0 + let dueDate = props["DUE"] + let deferUntil = props["DTSTART"] + let createdAt = props["CREATED"] ?? VTODOParser.formatDate(Date()) + let updatedAt = props["LAST-MODIFIED"] ?? VTODOParser.formatDate(Date()) + let isSomeday = props["X-MAGNUM-SOMEDAY"] == "TRUE" + + // Extract linked message ID from ATTACH:mid: + let linkedMessageId: String? + if let attach = props["ATTACH"], attach.hasPrefix("mid:") { + linkedMessageId = String(attach.dropFirst(4)) + } else { + linkedMessageId = nil + } + + var record = TaskRecord( + id: uid, + accountId: accountId, + summary: summary, + description: description, + status: status, + priority: priority, + dueDate: dueDate, + deferUntil: deferUntil, + createdAt: createdAt, + updatedAt: updatedAt, + filePath: file.path, + linkedMessageId: linkedMessageId, + isSomeday: isSomeday + ) + + try dbWriter.write { db in + try record.save(db) + } + + // Rebuild labels from CATEGORIES + if let categoriesStr = props["CATEGORIES"] { + let categories = categoriesStr.components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + for categoryName in categories { + try dbWriter.write { db in + // Find or create label + var label = try LabelRecord + .filter(Column("name") == categoryName) + .filter(Column("accountId") == accountId) + .fetchOne(db) + + if label == nil { + label = LabelRecord( + id: UUID().uuidString, + accountId: accountId, + name: categoryName + ) + try label!.insert(db) + } + + let itemLabel = ItemLabelRecord( + labelId: label!.id, + itemType: "task", + itemId: uid + ) + try itemLabel.save(db) + } + } + } + } + } + + // MARK: - Private + + /// Rewrite the .ics file from a TaskRecord's current state. + private func rewriteICSFile(for record: TaskRecord) throws { + let createdAt = VTODOParser.parseDate(record.createdAt) ?? Date() + let updatedAt = VTODOParser.parseDate(record.updatedAt) ?? Date() + + // Read existing file to preserve categories + var categories: [String]? = nil + let filePath = taskDirectory.appendingPathComponent("\(record.id).ics") + if let existingContent = try? String(contentsOf: filePath, encoding: .utf8), + let props = VTODOParser.parse(existingContent), + let cats = props["CATEGORIES"] { + categories = cats.components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + let icsContent = VTODOFormatter.format( + uid: record.id, + summary: record.summary, + description: record.description, + status: record.status, + priority: record.priority, + dueDate: record.dueDate.flatMap { VTODOParser.parseDate($0) }, + deferUntil: record.deferUntil.flatMap { VTODOParser.parseDate($0) }, + categories: categories, + linkedMessageId: record.linkedMessageId, + isSomeday: record.isSomeday, + createdAt: createdAt, + updatedAt: updatedAt + ) + + try FileManager.default.createDirectory(at: taskDirectory, withIntermediateDirectories: true) + try icsContent.write(to: filePath, atomically: true, encoding: .utf8) } } diff --git a/Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift b/Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift new file mode 100644 index 0000000..69e68f1 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Formats task properties into iCalendar VTODO components. +public enum VTODOFormatter { + + /// Build a complete iCalendar document containing a single VTODO. + public static func format( + uid: String, + summary: String, + description: String? = nil, + status: String = "NEEDS-ACTION", + priority: Int = 0, + dueDate: Date? = nil, + deferUntil: Date? = nil, + categories: [String]? = nil, + linkedMessageId: String? = nil, + isSomeday: Bool = false, + createdAt: Date, + updatedAt: Date + ) -> String { + var lines: [String] = [] + lines.append("BEGIN:VCALENDAR") + lines.append("VERSION:2.0") + lines.append("PRODID:-//MagnumOpus//TaskStore//EN") + lines.append("BEGIN:VTODO") + lines.append("UID:\(uid)") + lines.append("SUMMARY:\(escapeText(summary))") + lines.append("STATUS:\(status)") + lines.append("PRIORITY:\(priority)") + lines.append("CREATED:\(VTODOParser.formatDate(createdAt))") + lines.append("LAST-MODIFIED:\(VTODOParser.formatDate(updatedAt))") + lines.append("DTSTAMP:\(VTODOParser.formatDate(updatedAt))") + + if let description { + lines.append("DESCRIPTION:\(escapeText(description))") + } + + if let dueDate { + lines.append("DUE;VALUE=DATE:\(VTODOParser.formatDateOnly(dueDate))") + } + + if let deferUntil { + lines.append("DTSTART;VALUE=DATE:\(VTODOParser.formatDateOnly(deferUntil))") + } + + if let categories, !categories.isEmpty { + let escaped = categories.map { escapeText($0) }.joined(separator: ",") + lines.append("CATEGORIES:\(escaped)") + } + + if let linkedMessageId { + lines.append("ATTACH:mid:\(linkedMessageId)") + } + + if isSomeday { + lines.append("X-MAGNUM-SOMEDAY:TRUE") + } + + lines.append("END:VTODO") + lines.append("END:VCALENDAR") + + // Fold lines and join with CRLF + let folded = lines.map { foldLine($0) } + return folded.joined(separator: "\r\n") + "\r\n" + } + + /// Escape text per RFC 5545: backslashes first, then commas, semicolons, newlines. + public static func escapeText(_ text: String) -> String { + var result = text + result = result.replacingOccurrences(of: "\\", with: "\\\\") + result = result.replacingOccurrences(of: ",", with: "\\,") + result = result.replacingOccurrences(of: ";", with: "\\;") + result = result.replacingOccurrences(of: "\n", with: "\\n") + return result + } + + /// Fold a line at 75 octets per RFC 5545. + /// Continuation lines start with a single space. + public static func foldLine(_ line: String) -> String { + let data = Array(line.utf8) + guard data.count > 75 else { return line } + + var result = "" + var offset = 0 + + while offset < data.count { + let chunkSize = offset == 0 ? 75 : 74 // continuation lines have a leading space + let end = min(offset + chunkSize, data.count) + let chunk = Array(data[offset.. 0 { + result += "\r\n " + } + + // Safe to convert back since we sliced valid UTF-8 at byte boundaries + // but we need to be careful not to split multi-byte characters + result += String(decoding: chunk, as: UTF8.self) + offset = end + } + + return result + } +} diff --git a/Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift b/Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift new file mode 100644 index 0000000..ef064a4 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift @@ -0,0 +1,164 @@ +import Foundation + +/// Parses iCalendar VTODO components into property dictionaries. +public enum VTODOParser { + + /// Parse an iCalendar string and extract the first VTODO block as a property dictionary. + /// Returns nil if no valid VTODO block is found. + public static func parse(_ icsContent: String) -> [String: String]? { + // Unfold lines: lines starting with space or tab are continuations + let unfolded = unfoldLines(icsContent) + let lines = unfolded.components(separatedBy: "\n") + + // Find BEGIN:VTODO / END:VTODO block + var inVTODO = false + var properties: [String: String] = [:] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .carriageReturn) + if trimmed == "BEGIN:VTODO" { + inVTODO = true + continue + } + if trimmed == "END:VTODO" { + break + } + guard inVTODO else { continue } + + // Split on first colon + guard let colonIndex = trimmed.firstIndex(of: ":") else { continue } + let rawName = String(trimmed[trimmed.startIndex.. String { + var result = "" + result.reserveCapacity(text.count) + var firstLine = true + + for line in text.components(separatedBy: "\n") { + let l = line.trimmingCharacters(in: .carriageReturn) + if l.hasPrefix(" ") || l.hasPrefix("\t") { + // Continuation: append without the leading whitespace + result.append(String(l.dropFirst())) + } else { + if !firstLine { + result.append("\n") + } + result.append(l) + firstLine = false + } + } + return result + } + + // MARK: - Date parsing + + /// Parse an iCalendar date or date-time value. + /// Supports: `20260315T090000Z`, `20260315T090000`, `20260315` + public static func parseDate(_ value: String) -> Date? { + let stripped = value.trimmingCharacters(in: .whitespaces) + + if stripped.count == 16 && stripped.hasSuffix("Z") { + // DATE-TIME with Z: yyyyMMdd'T'HHmmss'Z' + return dateTimeFormatterUTC.date(from: stripped) + } else if stripped.count == 15 && stripped.contains("T") { + // DATE-TIME without Z: yyyyMMdd'T'HHmmss (treat as UTC) + return dateTimeFormatterLocal.date(from: stripped) + } else if stripped.count == 8 { + // DATE only: yyyyMMdd + return dateOnlyFormatter.date(from: stripped) + } + return nil + } + + /// Format a date as UTC date-time: `yyyyMMdd'T'HHmmss'Z'` + public static func formatDate(_ date: Date) -> String { + dateTimeFormatterUTC.string(from: date) + } + + /// Format a date as date-only: `yyyyMMdd` + public static func formatDateOnly(_ date: Date) -> String { + dateOnlyFormatter.string(from: date) + } + + // MARK: - Text unescaping + + /// Unescape iCalendar text values per RFC 5545. + public static func unescapeText(_ text: String) -> String { + var result = "" + result.reserveCapacity(text.count) + var iterator = text.makeIterator() + + while let char = iterator.next() { + if char == "\\" { + guard let next = iterator.next() else { + result.append(char) + break + } + switch next { + case "n", "N": + result.append("\n") + case ",": + result.append(",") + case ";": + result.append(";") + case "\\": + result.append("\\") + default: + result.append(char) + result.append(next) + } + } else { + result.append(char) + } + } + return result + } + + // MARK: - Formatters + + private static let dateTimeFormatterUTC: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyyMMdd'T'HHmmss'Z'" + f.timeZone = TimeZone(identifier: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static let dateTimeFormatterLocal: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyyMMdd'T'HHmmss" + f.timeZone = TimeZone(identifier: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() + + private static let dateOnlyFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyyMMdd" + f.timeZone = TimeZone(identifier: "UTC") + f.locale = Locale(identifier: "en_US_POSIX") + return f + }() +} + +// Make character sets available +private extension CharacterSet { + static let carriageReturn = CharacterSet(charactersIn: "\r") +} diff --git a/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift b/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift index 56bfe85..8796ba2 100644 --- a/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift +++ b/Packages/MagnumOpusCore/Tests/TaskStoreTests/TaskStoreTests.swift @@ -1,13 +1,215 @@ import Testing +import Foundation +import GRDB @testable import TaskStore @testable import MailStore @Suite("TaskStore") struct TaskStoreTests { - @Test("TaskStore wraps MailStore") - func taskStoreInit() throws { - let mailStore = try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase()) - let taskStore = TaskStore(mailStore: mailStore) - #expect(taskStore.mailStore === mailStore) + + private func makeStore() throws -> (TaskStore, URL) { + let dbWriter = try DatabaseSetup.openInMemoryDatabase() + // Insert account to satisfy foreign key constraint + try dbWriter.write { db in + let account = AccountRecord( + id: "acct-1", + name: "Test", + email: "test@example.com", + imapHost: "imap.example.com", + imapPort: 993 + ) + try account.insert(db) + } + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("TaskStoreTests-\(UUID().uuidString)") + let store = TaskStore(taskDirectory: tempDir, dbWriter: dbWriter) + return (store, tempDir) + } + + private func cleanup(_ dir: URL) { + try? FileManager.default.removeItem(at: dir) + } + + @Test("writeTask creates file and cache record") + func writeTaskCreatesFileAndCache() throws { + let (store, dir) = try makeStore() + defer { cleanup(dir) } + + try store.writeTask( + id: "task-001", + accountId: "acct-1", + summary: "Test task", + description: "A test description", + status: "NEEDS-ACTION", + priority: 3, + categories: ["work"] + ) + + // File exists + let filePath = dir.appendingPathComponent("task-001.ics") + #expect(FileManager.default.fileExists(atPath: filePath.path)) + + // Cache record exists + let record = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-001") + } + #expect(record != nil) + #expect(record?.summary == "Test task") + #expect(record?.description == "A test description") + #expect(record?.status == "NEEDS-ACTION") + #expect(record?.priority == 3) + #expect(record?.accountId == "acct-1") + + // File contains valid VTODO + let content = try String(contentsOf: filePath, encoding: .utf8) + let props = VTODOParser.parse(content) + #expect(props?["UID"] == "task-001") + #expect(props?["SUMMARY"] == "Test task") + } + + @Test("updateStatus changes both cache and file") + func updateStatusChangesBoth() throws { + let (store, dir) = try makeStore() + defer { cleanup(dir) } + + try store.writeTask(id: "task-002", accountId: "acct-1", summary: "Status test") + try store.updateStatus(id: "task-002", status: "COMPLETED") + + // Cache updated + let record = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-002") + } + #expect(record?.status == "COMPLETED") + + // File updated + let filePath = dir.appendingPathComponent("task-002.ics") + let content = try String(contentsOf: filePath, encoding: .utf8) + let props = VTODOParser.parse(content) + #expect(props?["STATUS"] == "COMPLETED") + } + + @Test("deleteTask removes both file and cache") + func deleteTaskRemovesBoth() throws { + let (store, dir) = try makeStore() + defer { cleanup(dir) } + + try store.writeTask(id: "task-003", accountId: "acct-1", summary: "Delete test") + + let filePath = dir.appendingPathComponent("task-003.ics") + #expect(FileManager.default.fileExists(atPath: filePath.path)) + + try store.deleteTask(id: "task-003") + + #expect(!FileManager.default.fileExists(atPath: filePath.path)) + + let record = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-003") + } + #expect(record == nil) + } + + @Test("rebuildCache restores tasks from files") + func rebuildCacheRestoresFromFiles() throws { + let (store, dir) = try makeStore() + defer { cleanup(dir) } + + // Write tasks + try store.writeTask( + id: "task-004", + accountId: "acct-1", + summary: "Rebuild test 1", + categories: ["home"] + ) + try store.writeTask( + id: "task-005", + accountId: "acct-1", + summary: "Rebuild test 2", + linkedMessageId: "msg@example.com" + ) + + // Clear cache manually + try store.dbWriter.write { db in + _ = try TaskRecord.deleteAll(db) + try db.execute(sql: "DELETE FROM itemLabel WHERE itemType = 'task'") + } + + // Verify cache is empty + let countBefore = try store.dbWriter.read { db in + try TaskRecord.fetchCount(db) + } + #expect(countBefore == 0) + + // Rebuild + try store.rebuildCache(accountId: "acct-1") + + // Verify records restored + let countAfter = try store.dbWriter.read { db in + try TaskRecord.fetchCount(db) + } + #expect(countAfter == 2) + + let record4 = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-004") + } + #expect(record4?.summary == "Rebuild test 1") + + let record5 = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-005") + } + #expect(record5?.linkedMessageId == "msg@example.com") + + // Verify labels restored + let labels = try store.dbWriter.read { db in + try ItemLabelRecord + .filter(Column("itemType") == "task") + .filter(Column("itemId") == "task-004") + .fetchAll(db) + } + #expect(labels.count == 1) + } + + @Test("updateDeferral changes both cache and file") + func updateDeferralWorks() throws { + let (store, dir) = try makeStore() + defer { cleanup(dir) } + + try store.writeTask(id: "task-006", accountId: "acct-1", summary: "Defer test") + + let deferDate = VTODOParser.parseDate("20260320")! + try store.updateDeferral(id: "task-006", deferUntil: deferDate, isSomeday: false) + + let record = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-006") + } + #expect(record?.deferUntil == "20260320") + #expect(record?.isSomeday == false) + + // File updated + let filePath = dir.appendingPathComponent("task-006.ics") + let content = try String(contentsOf: filePath, encoding: .utf8) + let props = VTODOParser.parse(content) + #expect(props?["DTSTART"] == "20260320") + } + + @Test("writeTask preserves createdAt on update") + func preserveCreatedAtOnUpdate() throws { + let (store, dir) = try makeStore() + defer { cleanup(dir) } + + try store.writeTask(id: "task-007", accountId: "acct-1", summary: "Original") + + let originalRecord = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-007") + } + let originalCreatedAt = originalRecord?.createdAt + + // Update the task + try store.writeTask(id: "task-007", accountId: "acct-1", summary: "Updated") + + let updatedRecord = try store.dbWriter.read { db in + try TaskRecord.fetchOne(db, key: "task-007") + } + #expect(updatedRecord?.createdAt == originalCreatedAt) + #expect(updatedRecord?.summary == "Updated") } } diff --git a/Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift b/Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift new file mode 100644 index 0000000..467e2ce --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOFormatterTests.swift @@ -0,0 +1,150 @@ +import Testing +import Foundation +@testable import TaskStore + +@Suite("VTODOFormatter") +struct VTODOFormatterTests { + + private let fixedDate = Date(timeIntervalSince1970: 1_773_763_200) // 2026-03-15T12:00:00Z + + @Test("formats minimal VTODO with required fields only") + func minimalFormat() { + let result = VTODOFormatter.format( + uid: "task-001", + summary: "Test task", + createdAt: fixedDate, + updatedAt: fixedDate + ) + + #expect(result.contains("BEGIN:VCALENDAR")) + #expect(result.contains("VERSION:2.0")) + #expect(result.contains("PRODID:-//MagnumOpus//TaskStore//EN")) + #expect(result.contains("BEGIN:VTODO")) + #expect(result.contains("UID:task-001")) + #expect(result.contains("SUMMARY:Test task")) + #expect(result.contains("STATUS:NEEDS-ACTION")) + #expect(result.contains("PRIORITY:0")) + #expect(result.contains("END:VTODO")) + #expect(result.contains("END:VCALENDAR")) + // Should NOT contain optional fields + #expect(!result.contains("DESCRIPTION:")) + #expect(!result.contains("DUE;")) + #expect(!result.contains("DTSTART;")) + #expect(!result.contains("CATEGORIES:")) + #expect(!result.contains("ATTACH:")) + #expect(!result.contains("X-MAGNUM-SOMEDAY")) + } + + @Test("formats all optional fields") + func optionalFields() { + let dueDate = VTODOParser.parseDate("20260320")! + let deferDate = VTODOParser.parseDate("20260318")! + + let result = VTODOFormatter.format( + uid: "task-002", + summary: "Full task", + description: "A detailed description", + status: "IN-PROCESS", + priority: 5, + dueDate: dueDate, + deferUntil: deferDate, + categories: ["work", "urgent"], + linkedMessageId: "msg-123@example.com", + isSomeday: false, + createdAt: fixedDate, + updatedAt: fixedDate + ) + + #expect(result.contains("DESCRIPTION:A detailed description")) + #expect(result.contains("DUE;VALUE=DATE:20260320")) + #expect(result.contains("DTSTART;VALUE=DATE:20260318")) + #expect(result.contains("CATEGORIES:work,urgent")) + #expect(result.contains("ATTACH:mid:msg-123@example.com")) + #expect(!result.contains("X-MAGNUM-SOMEDAY")) + } + + @Test("includes someday flag when true") + func somedayFlag() { + let result = VTODOFormatter.format( + uid: "task-003", + summary: "Someday task", + isSomeday: true, + createdAt: fixedDate, + updatedAt: fixedDate + ) + + #expect(result.contains("X-MAGNUM-SOMEDAY:TRUE")) + } + + @Test("escapes special characters in text") + func specialCharacterEscaping() { + let result = VTODOFormatter.format( + uid: "task-004", + summary: "Buy milk, eggs; bread", + description: "Line1\nLine2\\end", + createdAt: fixedDate, + updatedAt: fixedDate + ) + + #expect(result.contains("SUMMARY:Buy milk\\, eggs\\; bread")) + #expect(result.contains("DESCRIPTION:Line1\\nLine2\\\\end")) + } + + @Test("escapeText works correctly") + func escapeText() { + #expect(VTODOFormatter.escapeText("a,b") == "a\\,b") + #expect(VTODOFormatter.escapeText("a;b") == "a\\;b") + #expect(VTODOFormatter.escapeText("a\nb") == "a\\nb") + #expect(VTODOFormatter.escapeText("a\\b") == "a\\\\b") + #expect(VTODOFormatter.escapeText("plain") == "plain") + } + + @Test("foldLine folds long lines at 75 octets") + func foldLineLong() { + let longLine = String(repeating: "A", count: 100) + let folded = VTODOFormatter.foldLine(longLine) + let parts = folded.components(separatedBy: "\r\n") + #expect(parts.count == 2) + #expect(parts[0].utf8.count == 75) + #expect(parts[1].hasPrefix(" ")) + } + + @Test("foldLine does not fold short lines") + func foldLineShort() { + let shortLine = "SUMMARY:Short" + #expect(VTODOFormatter.foldLine(shortLine) == shortLine) + } + + // MARK: - Round-trip + + @Test("format then parse round-trip preserves properties") + func roundTrip() { + let dueDate = VTODOParser.parseDate("20260320")! + + let formatted = VTODOFormatter.format( + uid: "rt-001", + summary: "Round trip test, with commas; and semicolons", + description: "Line 1\nLine 2", + status: "IN-PROCESS", + priority: 3, + dueDate: dueDate, + categories: ["cat1", "cat2"], + linkedMessageId: "msg@example.com", + isSomeday: true, + createdAt: fixedDate, + updatedAt: fixedDate + ) + + let parsed = VTODOParser.parse(formatted) + #expect(parsed != nil) + #expect(parsed?["UID"] == "rt-001") + #expect(parsed?["SUMMARY"] == "Round trip test, with commas; and semicolons") + #expect(parsed?["DESCRIPTION"] == "Line 1\nLine 2") + #expect(parsed?["STATUS"] == "IN-PROCESS") + #expect(parsed?["PRIORITY"] == "3") + #expect(parsed?["DUE"] == "20260320") + #expect(parsed?["CATEGORIES"] == "cat1,cat2") + #expect(parsed?["ATTACH"] == "mid:msg@example.com") + #expect(parsed?["X-MAGNUM-SOMEDAY"] == "TRUE") + } +} diff --git a/Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift b/Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift new file mode 100644 index 0000000..814163a --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/TaskStoreTests/VTODOParserTests.swift @@ -0,0 +1,147 @@ +import Testing +import Foundation +@testable import TaskStore + +@Suite("VTODOParser") +struct VTODOParserTests { + + // MARK: - Parsing + + @Test("parses all standard VTODO properties") + func parseAllProperties() { + let ics = """ + BEGIN:VCALENDAR\r + VERSION:2.0\r + BEGIN:VTODO\r + UID:task-001\r + SUMMARY:Buy groceries\r + DESCRIPTION:Milk\\, eggs\\, bread\r + STATUS:NEEDS-ACTION\r + PRIORITY:5\r + DUE;VALUE=DATE:20260315\r + DTSTART;VALUE=DATE:20260310\r + CATEGORIES:errands,shopping\r + ATTACH:mid:abc123@example.com\r + X-MAGNUM-SOMEDAY:TRUE\r + CREATED:20260301T090000Z\r + LAST-MODIFIED:20260305T120000Z\r + END:VTODO\r + END:VCALENDAR\r + """ + + let props = VTODOParser.parse(ics) + #expect(props != nil) + #expect(props?["UID"] == "task-001") + #expect(props?["SUMMARY"] == "Buy groceries") + #expect(props?["DESCRIPTION"] == "Milk, eggs, bread") + #expect(props?["STATUS"] == "NEEDS-ACTION") + #expect(props?["PRIORITY"] == "5") + #expect(props?["DUE"] == "20260315") + #expect(props?["DTSTART"] == "20260310") + #expect(props?["CATEGORIES"] == "errands,shopping") + #expect(props?["ATTACH"] == "mid:abc123@example.com") + #expect(props?["X-MAGNUM-SOMEDAY"] == "TRUE") + #expect(props?["CREATED"] == "20260301T090000Z") + #expect(props?["LAST-MODIFIED"] == "20260305T120000Z") + } + + @Test("returns nil for invalid content without VTODO") + func invalidContentReturnsNil() { + let ics = "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR" + #expect(VTODOParser.parse(ics) == nil) + } + + @Test("returns nil for empty string") + func emptyStringReturnsNil() { + #expect(VTODOParser.parse("") == nil) + } + + @Test("handles line unfolding") + func lineUnfolding() { + let ics = "BEGIN:VTODO\r\nSUMMARY:This is a very lo\r\n ng summary that was folded\r\nEND:VTODO" + let props = VTODOParser.parse(ics) + #expect(props?["SUMMARY"] == "This is a very long summary that was folded") + } + + @Test("strips parameters from property names") + func stripsParameters() { + let ics = "BEGIN:VTODO\r\nDTSTART;VALUE=DATE:20260315\r\nDUE;VALUE=DATE:20260320\r\nEND:VTODO" + let props = VTODOParser.parse(ics) + #expect(props?["DTSTART"] == "20260315") + #expect(props?["DUE"] == "20260320") + } + + // MARK: - Date parsing + + @Test("parses DATE-TIME with Z") + func parseDateTimeUTC() { + let date = VTODOParser.parseDate("20260315T090000Z") + #expect(date != nil) + + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: date!) + #expect(components.year == 2026) + #expect(components.month == 3) + #expect(components.day == 15) + #expect(components.hour == 9) + #expect(components.minute == 0) + } + + @Test("parses DATE-TIME without Z") + func parseDateTimeLocal() { + let date = VTODOParser.parseDate("20260315T090000") + #expect(date != nil) + } + + @Test("parses DATE only") + func parseDateOnly() { + let date = VTODOParser.parseDate("20260315") + #expect(date != nil) + + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: date!) + #expect(components.year == 2026) + #expect(components.month == 3) + #expect(components.day == 15) + } + + @Test("returns nil for invalid date") + func invalidDate() { + #expect(VTODOParser.parseDate("not-a-date") == nil) + #expect(VTODOParser.parseDate("") == nil) + } + + // MARK: - Date round-trip + + @Test("date round-trip preserves value") + func dateRoundTrip() { + let original = Date(timeIntervalSince1970: 1_773_763_200) // 2026-03-15T12:00:00Z + let formatted = VTODOParser.formatDate(original) + let parsed = VTODOParser.parseDate(formatted) + #expect(parsed != nil) + #expect(abs(parsed!.timeIntervalSince(original)) < 1) + } + + @Test("date-only round-trip preserves value") + func dateOnlyRoundTrip() { + let original = VTODOParser.parseDate("20260315")! + let formatted = VTODOParser.formatDateOnly(original) + #expect(formatted == "20260315") + } + + // MARK: - Unescape round-trip + + @Test("unescape handles all special characters") + func unescapeSpecialChars() { + #expect(VTODOParser.unescapeText("hello\\nworld") == "hello\nworld") + #expect(VTODOParser.unescapeText("a\\,b") == "a,b") + #expect(VTODOParser.unescapeText("a\\;b") == "a;b") + #expect(VTODOParser.unescapeText("a\\\\b") == "a\\b") + #expect(VTODOParser.unescapeText("plain text") == "plain text") + } + + @Test("unescape handles uppercase N for newline") + func unescapeUppercaseN() { + #expect(VTODOParser.unescapeText("line1\\Nline2") == "line1\nline2") + } +}