Add Swift app for iPod contacts and calendar export
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
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user