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:
2026-08-08 20:21:09 -03:00
co-authored by Claude Opus 5
commit 7b9d16e442
23 changed files with 2712 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
import Foundation
import Testing
@testable import IPodSyncKit
@Suite("Sanitizing existing .vcf files")
struct VCardFileSanitizerTests {
/// Mixed 2.1/3.0, an embedded photo, a CRM extension, an Apple property
/// group, and a quoted-printable name i.e. what a real export looks like.
private let sample = """
BEGIN:VCARD
VERSION:3.0
N:Lovelace;Ada;;;
FN:Ada Lovelace
TEL;TYPE=CELL:+15550100
PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD
X-CRM-ID:12345
item1.EMAIL;TYPE=INTERNET:[email protected]
item1.X-ABLabel:_$!<Work>!$_
END:VCARD
BEGIN:VCARD
VERSION:2.1
N;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Fleming;Ren=C3=A9e;;;
FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Ren=C3=A9e Fleming
TEL;HOME;VOICE:+15550111
END:VCARD
"""
@Test("Each card becomes its own file")
func splitsCards() {
let result = VCardFileSanitizer.sanitize(text: sample)
#expect(result.files.count == 2)
#expect(result.files[0].suggestedName == "Ada Lovelace")
#expect(result.files.allSatisfy { $0.contents.hasPrefix("BEGIN:VCARD\r\n") })
#expect(result.files.allSatisfy { $0.contents.hasSuffix("END:VCARD\r\n") })
}
@Test("Binary payloads and X- extensions are removed")
func stripsProblemProperties() {
let contents = VCardFileSanitizer.sanitize(text: sample).files[0].contents
#expect(!contents.contains("PHOTO"))
#expect(!contents.contains("X-CRM-ID"))
#expect(!contents.contains("X-ABLabel"))
#expect(contents.contains("TEL;TYPE=CELL:+15550100"))
}
@Test("Apple's property groups are unwrapped")
func removesGroupPrefixes() {
let contents = VCardFileSanitizer.sanitize(text: sample).files[0].contents
#expect(contents.contains("EMAIL;TYPE=INTERNET:[email protected]"))
#expect(!contents.contains("item1."))
}
@Test("Quoted-printable is decoded, and the filename uses the decoded name")
func decodesQuotedPrintable() {
let file = VCardFileSanitizer.sanitize(text: sample).files[1]
#expect(file.contents.contains("FN:Renée Fleming"))
#expect(!file.contents.contains("QUOTED-PRINTABLE"))
#expect(!file.contents.contains("CHARSET"))
// The bug worth guarding: naming from the raw field yields "Ren=C3=A9e".
#expect(file.suggestedName == "Renée Fleming")
}
@Test("Soft line breaks in quoted-printable values are rejoined")
func rejoinsSoftLineBreaks() {
let text = """
BEGIN:VCARD
VERSION:2.1
FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Ren=
=C3=A9e Fleming
END:VCARD
"""
let file = VCardFileSanitizer.sanitize(text: text).files[0]
#expect(file.contents.contains("FN:Renée Fleming"))
}
@Test("Cards without END:VCARD are reported, not silently written")
func unterminatedCard() {
let result = VCardFileSanitizer.sanitize(text: "BEGIN:VCARD\nFN:Broken")
#expect(result.files.isEmpty)
#expect(result.warnings.contains { $0.contains("END:VCARD") })
}
@Test("A file with no cards is flagged")
func notAVCard() {
let result = VCardFileSanitizer.sanitize(text: "hello, world")
#expect(result.files.isEmpty)
#expect(result.warnings.contains { $0.contains("No vCards") })
}
@Test("Names fall back from FN to N to ORG")
func nameFallbacks() {
#expect(VCardFileSanitizer.displayName(in: ["N:Smith;John;;;"]) == "John Smith")
#expect(VCardFileSanitizer.displayName(in: ["ORG:Acme;Sales"]) == "Acme")
#expect(VCardFileSanitizer.displayName(in: ["TEL:123"]) == "contact")
}
}
@Suite("Sanitizing existing .ics files")
struct ICalendarFileSanitizerTests {
private let zone = TimeZone(identifier: "America/Toronto")!
private let sample = """
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//macOS 26.6//EN
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:America/Toronto
BEGIN:DAYLIGHT
TZOFFSETFROM:-0500
TZNAME:EDT
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:-0400
TZNAME:EST
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:[email protected]
DTSTAMP:20260801T120000Z
DTSTART;TZID=America/Toronto:20260810T093000
DTEND;TZID=America/Toronto:20260810T100000
SUMMARY:Standup
X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER:-PT15M
END:VALARM
END:VEVENT
END:VCALENDAR
"""
@Test("Timezone blocks, alarms and X- properties are removed")
func stripsProblemComponents() {
let contents = ICalendarFileSanitizer
.sanitize(text: sample, name: "Work", timeZone: zone)
.files[0].contents
#expect(!contents.contains("VTIMEZONE"))
#expect(!contents.contains("DAYLIGHT"))
#expect(!contents.contains("STANDARD"))
#expect(!contents.contains("VALARM"))
#expect(!contents.contains("TRIGGER"))
#expect(!contents.contains("X-APPLE"))
}
@Test("TZID is dropped, leaving the same wall-clock reading as a floating time")
func stripsTZID() {
let contents = ICalendarFileSanitizer
.sanitize(text: sample, name: "Work", timeZone: zone)
.files[0].contents
#expect(contents.contains("DTSTART:20260810T093000"))
#expect(contents.contains("DTEND:20260810T100000"))
#expect(!contents.contains("TZID"))
}
@Test("The surrounding calendar structure survives")
func keepsStructure() {
let contents = ICalendarFileSanitizer
.sanitize(text: sample, name: "Work", timeZone: zone)
.files[0].contents
#expect(contents.hasPrefix("BEGIN:VCALENDAR\r\n"))
#expect(contents.hasSuffix("END:VCALENDAR\r\n"))
#expect(contents.contains("BEGIN:VEVENT"))
#expect(contents.contains("SUMMARY:Standup"))
#expect(contents.contains("UID:[email protected]"))
}
@Test("UTC start times become local floating times")
func convertsUTCToFloating() {
let text = """
BEGIN:VCALENDAR
BEGIN:VEVENT
UID:1
DTSTART:20260810T133000Z
DTEND:20260810T140000Z
SUMMARY:Call
END:VEVENT
END:VCALENDAR
"""
let contents = ICalendarFileSanitizer
.sanitize(text: text, name: "Work", timeZone: zone)
.files[0].contents
// 13:30 UTC is 09:30 in Toronto on that date.
#expect(contents.contains("DTSTART:20260810T093000"))
#expect(contents.contains("DTEND:20260810T100000"))
}
@Test("DTSTAMP stays in UTC, since it is bookkeeping the device never shows")
func leavesDTSTAMP() {
let contents = ICalendarFileSanitizer
.sanitize(text: sample, name: "Work", timeZone: zone)
.files[0].contents
#expect(contents.contains("DTSTAMP:20260801T120000Z"))
}
@Test("Recurring events are kept but flagged")
func flagsRecurrence() {
let text = sample.replacingOccurrences(
of: "SUMMARY:Standup",
with: "SUMMARY:Standup\nRRULE:FREQ=WEEKLY;BYDAY=MO"
)
let result = ICalendarFileSanitizer.sanitize(text: text, name: "Work", timeZone: zone)
#expect(result.files[0].contents.contains("RRULE:FREQ=WEEKLY"))
#expect(result.warnings.contains { $0.contains("RRULE") })
}
@Test("A file with no events is flagged")
func notACalendar() {
let result = ICalendarFileSanitizer.sanitize(text: "hello", name: "x", timeZone: zone)
#expect(result.warnings.contains { $0.contains("No events") })
}
}
@Suite("Quoted-printable decoding")
struct QuotedPrintableTests {
@Test("Decodes UTF-8 escapes")
func utf8() {
#expect(QuotedPrintable.decode("Jos=C3=A9") == "José")
#expect(QuotedPrintable.decode("plain text") == "plain text")
}
@Test("Falls back for bytes that are not valid UTF-8")
func latin1Fallback() {
// 0xE9 is é in Latin-1 but an invalid UTF-8 sequence on its own.
#expect(QuotedPrintable.decode("Jos=E9", charset: "ISO-8859-1") == "José")
}
@Test("Leaves malformed escapes alone")
func malformed() {
#expect(QuotedPrintable.decode("100=% sure") == "100=% sure")
}
}
@Suite("Writing an export")
struct ExportSessionTests {
private func temporaryDirectory() throws -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("ipod-sync-tests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@Test("Contacts and calendars land in folders matching the device")
func folderLayout() throws {
let directory = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
var session = ExportSession(destination: directory)
try session.write(contacts: [IPodContact(givenName: "Ada", familyName: "Lovelace")])
try session.write(
events: [IPodEvent(uid: "a", summary: "Standup", start: .now, end: .now.addingTimeInterval(1800))],
calendarName: "Work"
)
let contactFile = directory.appendingPathComponent("Contacts/Ada Lovelace.vcf")
let calendarFile = directory.appendingPathComponent("Calendars/Work.ics")
#expect(FileManager.default.fileExists(atPath: contactFile.path))
#expect(FileManager.default.fileExists(atPath: calendarFile.path))
#expect(session.writtenFiles.count == 2)
#expect(session.summary == "Wrote 1 contact file and 1 calendar file.")
}
@Test("Contacts with nothing on them are skipped and reported")
func skipsEmptyContacts() throws {
let directory = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
var session = ExportSession(destination: directory)
try session.write(contacts: [IPodContact(), IPodContact(givenName: "Ada")])
#expect(session.writtenFiles.count == 1)
#expect(session.warnings.contains { $0.contains("skipped") })
}
@Test("Two contacts with the same name do not overwrite each other")
func collidingNames() throws {
let directory = try temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
var session = ExportSession(destination: directory)
try session.write(contacts: [
IPodContact(givenName: "John", familyName: "Smith", phoneNumbers: [LabeledValue(label: "CELL", value: "1")]),
IPodContact(givenName: "John", familyName: "Smith", phoneNumbers: [LabeledValue(label: "CELL", value: "2")]),
])
#expect(session.writtenFiles.count == 2)
let names = Set(session.writtenFiles.map(\.path.lastPathComponent))
#expect(names == ["John Smith.vcf", "John Smith_2.vcf"])
}
}