Files
MagnumOpus/Packages/MagnumOpusCore/Sources/TaskStore/VTODOParser.swift
2026-03-14 10:31:00 +01:00

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