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
+69
View File
@@ -0,0 +1,69 @@
import Foundation
/// Decodes `ENCODING=QUOTED-PRINTABLE` values, as found in vCard 2.1 exports.
///
/// Older address books encode any non-ASCII character this way, so a contact
/// named `José` arrives as `Jos=C3=A9` which a click-wheel iPod displays
/// literally rather than decoding.
public enum QuotedPrintable {
/// Decodes `=XX` escapes and returns the bytes as text.
///
/// `charset` is the value of the vCard `CHARSET` parameter when one was
/// present. It is only a hint: whatever it claims, valid UTF-8 is decoded
/// as UTF-8, since that is overwhelmingly what modern exports contain even
/// when they label themselves otherwise.
public static func decode(_ value: String, charset: String? = nil) -> String {
var bytes: [UInt8] = []
let source = Array(value.utf8)
var index = 0
while index < source.count {
if source[index] == UInt8(ascii: "="), index + 2 < source.count,
let high = hexDigit(source[index + 1]),
let low = hexDigit(source[index + 2]) {
bytes.append(high << 4 | low)
index += 3
} else if source[index] == UInt8(ascii: "="), index + 1 == source.count - 1 {
// A trailing '=' is a soft line break that was never joined.
index += 2
} else {
bytes.append(source[index])
index += 1
}
}
return decode(bytes: bytes, charset: charset)
}
static func decode(bytes: [UInt8], charset: String?) -> String {
if let text = String(bytes: bytes, encoding: .utf8) {
return text
}
if let charset, let encoding = encoding(named: charset),
let text = String(bytes: bytes, encoding: encoding) {
return text
}
if let text = String(bytes: bytes, encoding: .windowsCP1252) {
return text
}
return String(decoding: bytes, as: UTF8.self)
}
private static func encoding(named charset: String) -> String.Encoding? {
switch charset.uppercased() {
case "UTF-8", "UTF8": return .utf8
case "ISO-8859-1", "LATIN1", "ISO8859-1": return .isoLatin1
case "WINDOWS-1252", "CP1252": return .windowsCP1252
default: return nil
}
}
private static func hexDigit(_ byte: UInt8) -> UInt8? {
switch byte {
case UInt8(ascii: "0")...UInt8(ascii: "9"): return byte - UInt8(ascii: "0")
case UInt8(ascii: "A")...UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10
case UInt8(ascii: "a")...UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10
default: return nil
}
}
}