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,179 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IPodSyncKit
|
||||
|
||||
@Suite("vCard writing")
|
||||
struct VCardWriterTests {
|
||||
private func makeContact() -> IPodContact {
|
||||
IPodContact(
|
||||
id: "1",
|
||||
givenName: "Ada",
|
||||
familyName: "Lovelace",
|
||||
organization: "Analytical Engines, Ltd.",
|
||||
jobTitle: "Mathematician",
|
||||
phoneNumbers: [LabeledValue(label: "CELL", value: "+1 555 0100")],
|
||||
emailAddresses: [LabeledValue(label: "WORK", value: "[email protected]")],
|
||||
postalAddresses: [
|
||||
PostalAddress(
|
||||
label: "HOME",
|
||||
street: "12 Ockham Road",
|
||||
city: "London",
|
||||
postalCode: "SW1",
|
||||
country: "UK"
|
||||
)
|
||||
],
|
||||
birthday: DateComponents(year: 1815, month: 12, day: 10)
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Emits a well-formed vCard 3.0")
|
||||
func shape() {
|
||||
let vcard = VCardWriter.makeVCard(for: makeContact())
|
||||
let lines = vcard.components(separatedBy: "\r\n")
|
||||
|
||||
#expect(lines.first == "BEGIN:VCARD")
|
||||
#expect(lines.contains("VERSION:3.0"))
|
||||
#expect(lines.contains("N:Lovelace;Ada;;;"))
|
||||
#expect(lines.contains("FN:Ada Lovelace"))
|
||||
#expect(lines.contains("TEL;TYPE=CELL:+1 555 0100"))
|
||||
#expect(lines.contains("EMAIL;TYPE=INTERNET,WORK:[email protected]"))
|
||||
#expect(lines.contains("ADR;TYPE=HOME:;;12 Ockham Road;London;;SW1;UK"))
|
||||
#expect(lines.contains("BDAY:1815-12-10"))
|
||||
#expect(vcard.hasSuffix("END:VCARD\r\n"))
|
||||
}
|
||||
|
||||
@Test("Never emits the properties that break the device")
|
||||
func omitsProblemProperties() {
|
||||
let vcard = VCardWriter.makeVCard(for: makeContact()).uppercased()
|
||||
|
||||
#expect(!vcard.contains("PHOTO"))
|
||||
#expect(!vcard.contains("LOGO"))
|
||||
#expect(!vcard.contains("SOUND"))
|
||||
#expect(!vcard.contains("\r\nX-"))
|
||||
#expect(!vcard.contains("QUOTED-PRINTABLE"))
|
||||
}
|
||||
|
||||
@Test("Commas and semicolons in values are escaped, not treated as structure")
|
||||
func escapesValues() {
|
||||
let vcard = VCardWriter.makeVCard(for: makeContact())
|
||||
#expect(vcard.contains("ORG:Analytical Engines\\, Ltd."))
|
||||
}
|
||||
|
||||
@Test("Falls back to organization, then email, for the display name")
|
||||
func displayNameFallbacks() {
|
||||
let company = IPodContact(organization: "Acme")
|
||||
#expect(company.displayName == "Acme")
|
||||
|
||||
let emailOnly = IPodContact(
|
||||
emailAddresses: [LabeledValue(label: "", value: "[email protected]")]
|
||||
)
|
||||
#expect(emailOnly.displayName == "[email protected]")
|
||||
#expect(VCardWriter.makeVCard(for: emailOnly).contains("EMAIL;TYPE=INTERNET:"))
|
||||
}
|
||||
|
||||
@Test("Year-less birthdays are dropped rather than invented")
|
||||
func yearlessBirthday() {
|
||||
#expect(VCardWriter.formatBirthday(DateComponents(month: 5, day: 12)) == nil)
|
||||
#expect(VCardWriter.formatBirthday(DateComponents(year: 1990, month: 5, day: 12))
|
||||
== "1990-05-12")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("iCalendar writing")
|
||||
struct ICalendarWriterTests {
|
||||
private let zone = TimeZone(identifier: "America/Toronto")!
|
||||
|
||||
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0)
|
||||
-> Date {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = zone
|
||||
return calendar.date(from: DateComponents(
|
||||
year: year, month: month, day: day, hour: hour, minute: minute
|
||||
))!
|
||||
}
|
||||
|
||||
@Test("Times are floating — no Z, no TZID, no VTIMEZONE")
|
||||
func floatingTimes() {
|
||||
let event = IPodEvent(
|
||||
uid: "abc@ipod",
|
||||
summary: "Standup",
|
||||
start: date(2026, 8, 10, 9, 30),
|
||||
end: date(2026, 8, 10, 10, 0)
|
||||
)
|
||||
let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone)
|
||||
|
||||
#expect(ics.contains("DTSTART:20260810T093000"))
|
||||
#expect(ics.contains("DTEND:20260810T100000"))
|
||||
#expect(!ics.contains("TZID"))
|
||||
#expect(!ics.contains("VTIMEZONE"))
|
||||
#expect(!ics.contains("DTSTART:20260810T093000Z"))
|
||||
}
|
||||
|
||||
@Test("Alarms and recurrence rules are structurally absent")
|
||||
func omitsProblemComponents() {
|
||||
let ics = ICalendarWriter.makeCalendar(
|
||||
events: [IPodEvent(uid: "a", summary: "x", start: date(2026, 8, 10), end: date(2026, 8, 11))],
|
||||
timeZone: zone
|
||||
)
|
||||
|
||||
#expect(!ics.contains("VALARM"))
|
||||
#expect(!ics.contains("RRULE"))
|
||||
#expect(!ics.contains("\r\nX-"))
|
||||
}
|
||||
|
||||
@Test("All-day events use exclusive DATE ends")
|
||||
func allDayEnds() {
|
||||
// EventKit reports a one-day all-day event as ending at 23:59:59.
|
||||
let oneDay = IPodEvent(
|
||||
uid: "a",
|
||||
summary: "Holiday",
|
||||
start: date(2026, 8, 10),
|
||||
end: date(2026, 8, 10, 23, 59),
|
||||
isAllDay: true
|
||||
)
|
||||
let ics = ICalendarWriter.makeCalendar(events: [oneDay], timeZone: zone)
|
||||
|
||||
#expect(ics.contains("DTSTART;VALUE=DATE:20260810"))
|
||||
#expect(ics.contains("DTEND;VALUE=DATE:20260811"))
|
||||
}
|
||||
|
||||
@Test("An all-day event already ending at midnight is not pushed a day further")
|
||||
func allDayMidnightEnd() {
|
||||
let event = IPodEvent(
|
||||
uid: "a",
|
||||
summary: "Holiday",
|
||||
start: date(2026, 8, 10),
|
||||
end: date(2026, 8, 11),
|
||||
isAllDay: true
|
||||
)
|
||||
let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone)
|
||||
|
||||
#expect(ics.contains("DTEND;VALUE=DATE:20260811"))
|
||||
}
|
||||
|
||||
@Test("Zero-length events omit DTEND rather than emit an invalid range")
|
||||
func zeroLengthEvent() {
|
||||
let event = IPodEvent(
|
||||
uid: "a",
|
||||
summary: "Marker",
|
||||
start: date(2026, 8, 10, 9, 0),
|
||||
end: date(2026, 8, 10, 9, 0)
|
||||
)
|
||||
let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone)
|
||||
|
||||
#expect(ics.contains("DTSTART:20260810T090000"))
|
||||
#expect(!ics.contains("DTEND"))
|
||||
}
|
||||
|
||||
@Test("Events are sorted and titles always present")
|
||||
func sortingAndTitles() {
|
||||
let later = IPodEvent(uid: "b", summary: "Later", start: date(2026, 8, 11), end: date(2026, 8, 11, 1))
|
||||
let earlier = IPodEvent(uid: "a", summary: "", start: date(2026, 8, 10), end: date(2026, 8, 10, 1))
|
||||
let ics = ICalendarWriter.makeCalendar(events: [later, earlier], timeZone: zone)
|
||||
|
||||
let firstSummary = ics.range(of: "SUMMARY:")!
|
||||
#expect(ics[firstSummary.upperBound...].hasPrefix("(No title)"))
|
||||
#expect(ics.contains("SUMMARY:Later"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user