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,76 @@
|
||||
import Foundation
|
||||
|
||||
/// A parsed `NAME;PARAM=value:VALUE` content line.
|
||||
///
|
||||
/// vCard and iCalendar share this grammar, so both sanitizers parse through
|
||||
/// this type.
|
||||
struct PropertyLine {
|
||||
/// Apple exports group related properties as `item1.EMAIL` / `item1.X-ABLabel`.
|
||||
/// The group is parsed off and never re-emitted — click-wheel parsers do not
|
||||
/// understand grouping, and the labels it points at are `X-` properties that
|
||||
/// get dropped anyway.
|
||||
var name: String
|
||||
var parameters: [String]
|
||||
var value: String
|
||||
|
||||
init?(_ line: String) {
|
||||
guard let colon = line.firstIndex(of: ":") else { return nil }
|
||||
|
||||
let head = String(line[line.startIndex..<colon])
|
||||
value = String(line[line.index(after: colon)...])
|
||||
|
||||
var segments = head.components(separatedBy: ";")
|
||||
guard var rawName = segments.first, !rawName.isEmpty else { return nil }
|
||||
segments.removeFirst()
|
||||
|
||||
if let dot = rawName.lastIndex(of: ".") {
|
||||
rawName = String(rawName[rawName.index(after: dot)...])
|
||||
}
|
||||
|
||||
name = rawName.uppercased()
|
||||
parameters = segments
|
||||
}
|
||||
|
||||
/// The value of `name`, for parameters written as `NAME=value`.
|
||||
func parameterValue(named name: String) -> String? {
|
||||
let prefix = name.uppercased() + "="
|
||||
for parameter in parameters where parameter.uppercased().hasPrefix(prefix) {
|
||||
return String(parameter.dropFirst(prefix.count))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mutating func removeParameters(named names: [String]) {
|
||||
let prefixes = names.map { $0.uppercased() + "=" }
|
||||
parameters.removeAll { parameter in
|
||||
prefixes.contains { parameter.uppercased().hasPrefix($0) }
|
||||
}
|
||||
}
|
||||
|
||||
var rendered: String {
|
||||
let head = ([name] + parameters).joined(separator: ";")
|
||||
return "\(head):\(value)"
|
||||
}
|
||||
}
|
||||
|
||||
extension PropertyLine {
|
||||
/// Unescapes a TEXT value: `\n` to a newline, `\;` `\,` `\\` to their literals.
|
||||
static func unescape(_ value: String) -> String {
|
||||
var output = ""
|
||||
var escaped = false
|
||||
for character in value {
|
||||
if escaped {
|
||||
switch character {
|
||||
case "n", "N": output.append("\n")
|
||||
default: output.append(character)
|
||||
}
|
||||
escaped = false
|
||||
} else if character == "\\" {
|
||||
escaped = true
|
||||
} else {
|
||||
output.append(character)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user