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,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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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