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
@@ -0,0 +1,114 @@
import Foundation
import Testing
@testable import IPodSyncKit
@Suite("Content line folding and escaping")
struct ContentLineTests {
@Test("Short lines are left alone")
func shortLineUnchanged() {
#expect(ContentLine.fold("FN:Ada Lovelace") == "FN:Ada Lovelace")
}
@Test("Long lines fold to CRLF + space, within the octet limit")
func longLineFolds() {
let line = "NOTE:" + String(repeating: "a", count: 300)
let folded = ContentLine.fold(line)
#expect(folded.contains("\r\n "))
for piece in folded.components(separatedBy: "\r\n") {
#expect(piece.utf8.count <= ContentLine.octetLimit)
}
// Unfolding is lossless.
#expect(ContentLine.unfold(folded) == [line])
}
@Test("Folding never splits a multi-byte character")
func foldingRespectsUTF8Boundaries() {
// Four-byte scalars, so a naive byte split lands mid-character.
let line = "NOTE:" + String(repeating: "🎧", count: 60)
let folded = ContentLine.fold(line)
for piece in folded.components(separatedBy: "\r\n") {
#expect(piece.utf8.count <= ContentLine.octetLimit)
// A replacement character would mean we cut a scalar in half.
#expect(!piece.contains("\u{FFFD}"))
}
#expect(ContentLine.unfold(folded) == [line])
}
@Test("A single character wider than the budget is emitted rather than looping")
func oversizedCharacterTerminates() {
let line = String(repeating: "🎧", count: 2)
#expect(ContentLine.fold(line) == line)
}
@Test("TEXT values escape backslash, semicolon, comma and newline")
func escaping() {
#expect(ContentLine.escapeText("a;b,c\\d") == "a\\;b\\,c\\\\d")
#expect(ContentLine.escapeText("line1\nline2") == "line1\\nline2")
#expect(ContentLine.escapeText("crlf\r\nhere") == "crlf\\nhere")
}
@Test("Unfolding accepts CRLF, LF and bare CR")
func unfoldingLineEndings() {
#expect(ContentLine.unfold("A:1\r\nB:2") == ["A:1", "B:2"])
#expect(ContentLine.unfold("A:1\nB:2") == ["A:1", "B:2"])
#expect(ContentLine.unfold("A:1\rB:2") == ["A:1", "B:2"])
#expect(ContentLine.unfold("A:12\r\n 34") == ["A:1234"])
#expect(ContentLine.unfold("A:12\r\n\t34") == ["A:1234"])
}
@Test("Rendered documents end with CRLF")
func builderRendersCRLF() {
var builder = ContentLineBuilder()
builder.appendVerbatim("BEGIN", "VCARD")
builder.appendVerbatim("END", "VCARD")
#expect(builder.render() == "BEGIN:VCARD\r\nEND:VCARD\r\n")
}
}
@Suite("Filename allocation")
struct FileNamingTests {
@Test("Accents fold to ASCII and unsafe characters are dropped")
func sanitizing() {
var allocator = FileNameAllocator()
#expect(allocator.allocate(preferred: "Renée Fleming", fileExtension: "vcf")
== "Renee Fleming.vcf")
var other = FileNameAllocator()
#expect(other.allocate(preferred: "A/B:C*D", fileExtension: "vcf") == "A B C D.vcf")
}
@Test("Empty and dot-only names fall back")
func fallbacks() {
var allocator = FileNameAllocator()
#expect(allocator.allocate(preferred: "", fileExtension: "vcf") == "item.vcf")
#expect(allocator.allocate(preferred: "...", fallback: "contact", fileExtension: "vcf")
== "contact.vcf")
}
@Test("Repeated names are numbered")
func deduplication() {
var allocator = FileNameAllocator()
#expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith.vcf")
#expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith_2.vcf")
// Matching is case-insensitive, since the device's volume is usually FAT32.
#expect(allocator.allocate(preferred: "john smith", fileExtension: "vcf") == "john smith_3.vcf")
}
@Test("Names are capped, including their numbering suffix")
func lengthCap() {
var allocator = FileNameAllocator()
let long = String(repeating: "x", count: 200)
let first = allocator.allocate(preferred: long, fileExtension: "vcf")
let second = allocator.allocate(preferred: long, fileExtension: "vcf")
#expect(first.dropLast(4).count == FileNameAllocator.maximumLength)
#expect(second.dropLast(4).count <= FileNameAllocator.maximumLength)
#expect(second != first)
}
}
+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"])
}
}
+179
View File
@@ -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"))
}
}