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
307 lines
11 KiB
Swift
307 lines
11 KiB
Swift
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"])
|
|
}
|
|
}
|