add VTODO parser, formatter, TaskStore file I/O with cache rebuild

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 08:58:31 +01:00
parent a4f0761f25
commit 39c0fd40bf
6 changed files with 1030 additions and 11 deletions

View File

@@ -1,13 +1,266 @@
import Foundation
@_exported import MailStore @_exported import MailStore
import GRDB
import Models import Models
/// TaskStore provides task-specific business logic on top of MailStore's /// TaskStore manages VTODO files on disk and their corresponding cache in SQLite.
/// database records and queries. The underlying TaskRecord and its CRUD
/// operations live in MailStore to share the same database connection.
public struct TaskStore: Sendable { public struct TaskStore: Sendable {
public let mailStore: MailStore public let taskDirectory: URL
public let dbWriter: any DatabaseWriter
public init(mailStore: MailStore) { public init(taskDirectory: URL, dbWriter: any DatabaseWriter) {
self.mailStore = mailStore 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:<id>
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)
} }
} }

View File

@@ -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..<end])
if 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
}
}

View File

@@ -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..<colonIndex])
let value = String(trimmed[trimmed.index(after: colonIndex)...])
// Strip parameters from property name (e.g., "DTSTART;VALUE=DATE" "DTSTART")
let propertyName: String
if let semiIndex = rawName.firstIndex(of: ";") {
propertyName = String(rawName[rawName.startIndex..<semiIndex])
} else {
propertyName = rawName
}
properties[propertyName] = unescapeText(value)
}
guard !properties.isEmpty else { return nil }
return properties
}
/// Unfold lines per RFC 5545: lines starting with a space or tab are continuations.
private static func unfoldLines(_ text: String) -> 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")
}

View File

@@ -1,13 +1,215 @@
import Testing import Testing
import Foundation
import GRDB
@testable import TaskStore @testable import TaskStore
@testable import MailStore @testable import MailStore
@Suite("TaskStore") @Suite("TaskStore")
struct TaskStoreTests { struct TaskStoreTests {
@Test("TaskStore wraps MailStore")
func taskStoreInit() throws { private func makeStore() throws -> (TaskStore, URL) {
let mailStore = try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase()) let dbWriter = try DatabaseSetup.openInMemoryDatabase()
let taskStore = TaskStore(mailStore: mailStore) // Insert account to satisfy foreign key constraint
#expect(taskStore.mailStore === mailStore) 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")
} }
} }

View File

@@ -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")
}
}

View File

@@ -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")
}
}