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