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