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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift
Normal file
103
Packages/MagnumOpusCore/Sources/TaskStore/VTODOFormatter.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
164
Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift
Normal file
164
Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift
Normal 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")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user