Files
thatguygriffandClaude Opus 5 7b9d16e442 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
2026-08-08 20:21:09 -03:00

180 lines
6.5 KiB
Swift

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"))
}
}