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