import Foundation /// Emits iCalendar for a click-wheel iPod. /// /// The three things that made Calendar.app's own exports fail on the device are /// structurally absent here rather than filtered out afterwards: /// /// - **No `VTIMEZONE`, no `TZID`.** Times are written as *floating* local wall /// time. The device has no timezone database, so a `TZID` it cannot resolve /// can cost you the whole file. /// - **No `VALARM`.** Alarms are meaningless on a device that cannot fire them. /// - **No `RRULE`.** Recurring events arrive here already expanded into one /// `VEVENT` per occurrence, so recurrence support never has to be relied on. public enum ICalendarWriter { public static let productIdentifier = "-//unsupervised.ca//iPod Contacts and Calendar Sync//EN" /// Renders events as a complete, CRLF-terminated `VCALENDAR`. /// /// - Parameters: /// - timeZone: the zone whose wall-clock reading is baked into the /// floating times. Defaults to the Mac's current zone, which is what /// someone syncing their own calendar expects to see on the device. /// - now: the `DTSTAMP` instant, injectable for tests. public static func makeCalendar( events: [IPodEvent], timeZone: TimeZone = .current, now: Date = Date() ) -> String { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone let floating = formatter("yyyyMMdd'T'HHmmss", timeZone: timeZone) let dateOnly = formatter("yyyyMMdd", timeZone: timeZone) let utc = formatter("yyyyMMdd'T'HHmmss'Z'", timeZone: TimeZone(identifier: "UTC")!) var builder = ContentLineBuilder() builder.appendVerbatim("BEGIN", "VCALENDAR") builder.appendVerbatim("VERSION", "2.0") builder.appendVerbatim("PRODID", productIdentifier) builder.appendVerbatim("CALSCALE", "GREGORIAN") let stamp = utc.string(from: now) for event in events.sorted(by: { $0.start < $1.start }) { builder.appendVerbatim("BEGIN", "VEVENT") builder.appendVerbatim("UID", event.uid) builder.appendVerbatim("DTSTAMP", stamp) if event.isAllDay { builder.appendRawLine("DTSTART;VALUE=DATE:\(dateOnly.string(from: event.start))") let exclusiveEnd = allDayExclusiveEnd(for: event, calendar: calendar) builder.appendRawLine("DTEND;VALUE=DATE:\(dateOnly.string(from: exclusiveEnd))") } else { builder.appendVerbatim("DTSTART", floating.string(from: event.start)) // A zero- or negative-length event is legal with DTSTART alone; // emitting DTEND <= DTSTART is not. if event.end > event.start { builder.appendVerbatim("DTEND", floating.string(from: event.end)) } } builder.append("SUMMARY", event.summary.isEmpty ? "(No title)" : event.summary) if !event.location.isEmpty { builder.append("LOCATION", event.location) } if !event.notes.isEmpty { builder.append("DESCRIPTION", event.notes) } builder.appendVerbatim("END", "VEVENT") } builder.appendVerbatim("END", "VCALENDAR") return builder.render() } /// All-day `DTEND` is exclusive — the day *after* the last day covered. /// /// EventKit is inconsistent about whether an all-day event's `endDate` is /// the last day at 23:59:59 or the next day at midnight, so both are /// normalised to the same exclusive boundary here. static func allDayExclusiveEnd(for event: IPodEvent, calendar: Calendar) -> Date { let endDay = calendar.startOfDay(for: event.end) let exclusive = endDay == event.end ? endDay : calendar.date(byAdding: .day, value: 1, to: endDay) ?? endDay // Never emit a range that ends before it starts. let startDay = calendar.startOfDay(for: event.start) if exclusive <= startDay { return calendar.date(byAdding: .day, value: 1, to: startDay) ?? exclusive } return exclusive } private static func formatter(_ format: String, timeZone: TimeZone) -> DateFormatter { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar(identifier: .gregorian) formatter.timeZone = timeZone formatter.dateFormat = format return formatter } }