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
161 lines
6.0 KiB
Swift
161 lines
6.0 KiB
Swift
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"
|
|
}
|
|
}
|