Replaces the bash/AppleScript/Python app bundle with a SwiftUI app backed by a testable library target. The app now reads Contacts and EventKit directly rather than sanitizing files someone exported by hand, so the properties that break click-wheel firmware are never written instead of being stripped afterwards: no PHOTO, no X-, no VALARM, no VTIMEZONE, no TZID. Recurring events arrive from EventKit already expanded into occurrences, so no RRULE is emitted either - the open question from the previous version no longer applies. Sanitizing existing .vcf/.ics files is kept as a second path, and gains handling the Python lacked: vCard 2.1 quoted-printable soft line breaks, Apple's item1. property groups, nested STANDARD/DAYLIGHT components inside VTIMEZONE, and UTC times converted to local floating times. Output is laid out as Contacts/ and Calendars/ subfolders mirroring the device, one file per contact and one per calendar. Built via SwiftPM plus Scripts/bundle.sh, which assembles and ad-hoc signs the bundle. Ad-hoc signing is required rather than cosmetic: Contacts and Calendars key permissions off the code signature. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01C5X1tYo9oxAfvoiFMh1QQr
106 lines
4.5 KiB
Swift
106 lines
4.5 KiB
Swift
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
|
|
}
|
|
}
|