165 lines
4.5 KiB
Swift
165 lines
4.5 KiB
Swift
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")
|
|
}
|