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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user