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,160 @@
import Foundation
/// Splits and cleans `.vcf` files the user already has.
///
/// This is the path for files exported from somewhere else. A single combined
/// `.vcf` holding hundreds of contacts is the failure mode that started all
/// this click-wheel firmware reads the first card and then stops, or stops at
/// the first card it cannot parse so every card becomes its own file.
public enum VCardFileSanitizer {
/// Properties carrying binary payloads the device's parser chokes on.
static let strippedProperties: Set<String> = ["PHOTO", "LOGO", "SOUND", "KEY"]
public static func sanitize(contentsOf url: URL) throws -> FileSanitizationResult {
sanitize(text: try TextFile.read(contentsOf: url))
}
public static func sanitize(text: String) -> FileSanitizationResult {
let lines = joiningSoftLineBreaks(ContentLine.unfold(text))
var cards: [[String]] = []
var current: [String] = []
var insideCard = false
var unterminated = 0
for line in lines {
let upper = line.uppercased()
if upper.hasPrefix("BEGIN:VCARD") {
if insideCard { unterminated += 1 }
insideCard = true
current = [line]
} else if upper.hasPrefix("END:VCARD") {
if insideCard {
current.append(line)
cards.append(current)
}
insideCard = false
current = []
} else if insideCard {
current.append(line)
}
}
if insideCard { unterminated += 1 }
var warnings: [String] = []
if cards.isEmpty {
warnings.append("No vCards found — the file may not be a vCard export.")
}
if unterminated > 0 {
warnings.append("\(unterminated) card(s) had no END:VCARD and were skipped.")
}
let files = cards.map { card -> SanitizedFile in
let sanitized = sanitize(cardLines: card)
var builder = ContentLineBuilder()
for line in sanitized {
builder.appendRawLine(line)
}
// The display name is read back from the *sanitized* lines, so a
// quoted-printable name yields "José" rather than "Jos=C3=A9".
return SanitizedFile(
suggestedName: displayName(in: sanitized),
contents: builder.render()
)
}
return FileSanitizationResult(files: files, warnings: warnings)
}
/// Applies the per-property filtering to one card's lines.
static func sanitize(cardLines: [String]) -> [String] {
var output: [String] = []
for line in cardLines {
let upper = line.uppercased()
if upper.hasPrefix("BEGIN:VCARD") || upper.hasPrefix("END:VCARD") {
output.append(line)
continue
}
guard var property = PropertyLine(line) else { continue }
if strippedProperties.contains(property.name) { continue }
if property.name.hasPrefix("X-") { continue }
// Any remaining base64 payload is binary the device cannot use.
if property.parameterValue(named: "ENCODING")
.map({ ["B", "BASE64"].contains($0.uppercased()) }) == true {
continue
}
if property.parameterValue(named: "ENCODING")?.uppercased() == "QUOTED-PRINTABLE" {
property.value = QuotedPrintable.decode(
property.value,
charset: property.parameterValue(named: "CHARSET")
)
property.removeParameters(named: ["ENCODING", "CHARSET"])
}
output.append(property.rendered)
}
return output
}
/// vCard 2.1 continues a quoted-printable value on the next line when the
/// current one ends in `=`. Unlike RFC folding the continuation is *not*
/// indented, so `ContentLine.unfold` leaves it split and it is rejoined here.
static func joiningSoftLineBreaks(_ lines: [String]) -> [String] {
var output: [String] = []
var awaitingContinuation = false
for line in lines {
if awaitingContinuation, !output.isEmpty {
output[output.count - 1] = String(output[output.count - 1].dropLast()) + line
} else {
output.append(line)
}
let last = output[output.count - 1]
awaitingContinuation = last.hasSuffix("=")
&& last.uppercased().contains("QUOTED-PRINTABLE")
}
return output
}
/// Best available human name for the card, used as its filename.
static func displayName(in lines: [String]) -> String {
var structuredName: String?
var organization: String?
for line in lines {
guard let property = PropertyLine(line) else { continue }
switch property.name {
case "FN" where !property.value.isEmpty:
return PropertyLine.unescape(property.value)
case "N" where structuredName == nil:
// N is family;given;middle;prefix;suffix display it given-first.
let parts = property.value
.components(separatedBy: ";")
.map { PropertyLine.unescape($0).trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
if !parts.isEmpty {
structuredName = parts.count >= 2
? "\(parts[1]) \(parts[0])"
: parts[0]
}
case "ORG" where organization == nil:
organization = PropertyLine.unescape(property.value)
.components(separatedBy: ";")
.first?
.trimmingCharacters(in: .whitespaces)
default:
break
}
}
return structuredName ?? organization ?? "contact"
}
}