import Foundation /// Cleans `.ics` files the user already has. /// /// A Calendar.app export carries `VTIMEZONE` blocks, `TZID`-qualified /// date-times and `VALARM` blocks — none of which click-wheel firmware /// implements, and any of which can cost you the entire file rather than just /// the offending event. public enum ICalendarFileSanitizer { /// Components removed wholesale, including anything nested inside them. static let strippedComponents: Set = ["VALARM", "VTIMEZONE"] /// Date-time properties whose UTC form is rewritten as floating local time. /// /// `DTSTAMP`, `CREATED` and `LAST-MODIFIED` are deliberately absent: they /// are bookkeeping the device never displays, and RFC 5545 requires them in /// UTC. static let localisedDateProperties: Set = [ "DTSTART", "DTEND", "DUE", "RECURRENCE-ID", ] public static func sanitize( contentsOf url: URL, timeZone: TimeZone = .current ) throws -> FileSanitizationResult { sanitize( text: try TextFile.read(contentsOf: url), name: url.deletingPathExtension().lastPathComponent, timeZone: timeZone ) } public static func sanitize( text: String, name: String, timeZone: TimeZone = .current ) -> FileSanitizationResult { var output: [String] = [] var warnings: [String] = [] var skipDepth = 0 var eventCount = 0 var hasRecurrence = false for line in ContentLine.unfold(text) { let upper = line.uppercased() if skipDepth > 0 { // Nested components (STANDARD/DAYLIGHT inside VTIMEZONE) have // their own BEGIN/END pairs, so track depth rather than // stopping at the first END. if upper.hasPrefix("BEGIN:") { skipDepth += 1 } else if upper.hasPrefix("END:") { skipDepth -= 1 } continue } if line.trimmingCharacters(in: .whitespaces).isEmpty { continue } if upper.hasPrefix("BEGIN:") { let component = String(upper.dropFirst("BEGIN:".count)) .trimmingCharacters(in: .whitespaces) if strippedComponents.contains(component) { skipDepth = 1 continue } if component == "VEVENT" { eventCount += 1 } output.append(line) continue } if upper.hasPrefix("END:") { output.append(line) continue } guard var property = PropertyLine(line) else { continue } if property.name.hasPrefix("X-") { continue } if property.name == "RRULE" { hasRecurrence = true } // Dropping TZID leaves the wall-clock reading untouched, which is // exactly the floating interpretation we want. property.removeParameters(named: ["TZID"]) if localisedDateProperties.contains(property.name), let floating = floatingLocalTime(from: property.value, timeZone: timeZone) { property.value = floating } output.append(property.rendered) } if eventCount == 0 { warnings.append("No events found — the file may not be a calendar export.") } if hasRecurrence { warnings.append( "Contains recurring events (RRULE), which were left as-is. " + "Click-wheel support for recurrence is unverified — check them on the device. " + "Exporting from Calendars instead expands recurrences into individual events." ) } var builder = ContentLineBuilder() for line in output { builder.appendRawLine(line) } return FileSanitizationResult( files: [SanitizedFile(suggestedName: name, contents: builder.render())], warnings: warnings ) } /// Rewrites a UTC date-time (`…Z`) as the equivalent local wall-clock time. /// /// Returns nil for values that are already floating or are dates rather /// than date-times, leaving them untouched. static func floatingLocalTime(from value: String, timeZone: TimeZone) -> String? { guard value.hasSuffix("Z") else { return nil } let parser = DateFormatter() parser.locale = Locale(identifier: "en_US_POSIX") parser.calendar = Calendar(identifier: .gregorian) parser.timeZone = TimeZone(identifier: "UTC") parser.dateFormat = "yyyyMMdd'T'HHmmss'Z'" guard let date = parser.date(from: value) else { return nil } let renderer = DateFormatter() renderer.locale = Locale(identifier: "en_US_POSIX") renderer.calendar = Calendar(identifier: .gregorian) renderer.timeZone = timeZone renderer.dateFormat = "yyyyMMdd'T'HHmmss" return renderer.string(from: date) } }