Files
iPod-export/Sources/IPodSyncKit/PropertyLine.swift
T
thatguygriffandClaude Opus 5 7b9d16e442 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
2026-08-08 20:21:09 -03:00

77 lines
2.5 KiB
Swift

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
}
}