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
146 lines
5.6 KiB
Swift
146 lines
5.6 KiB
Swift
import Foundation
|
|
|
|
/// Emitting helpers shared by vCard (RFC 6350) and iCalendar (RFC 5545).
|
|
///
|
|
/// Both formats use the same content-line grammar: `NAME;PARAM=value:VALUE`,
|
|
/// CRLF terminators, and folding of long lines. Click-wheel iPod firmware is
|
|
/// unforgiving about all three, so everything we write goes through here.
|
|
public enum ContentLine {
|
|
/// Maximum octets in a content line, excluding the CRLF terminator.
|
|
public static let octetLimit = 75
|
|
|
|
/// Escapes a TEXT value per RFC 6350 §3.4 / RFC 5545 §3.3.11.
|
|
///
|
|
/// Also applies to individual components of structured values (`N`, `ADR`),
|
|
/// which are escaped the same way before being joined with `;`.
|
|
public static func escapeText(_ value: String) -> String {
|
|
// Swift treats CRLF as a *single* Character, so iterating without
|
|
// normalising first lets a CRLF fall through unescaped and terminate
|
|
// the content line early.
|
|
let normalized = value
|
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
|
.replacingOccurrences(of: "\r", with: "\n")
|
|
|
|
var out = ""
|
|
out.reserveCapacity(normalized.count)
|
|
for character in normalized {
|
|
switch character {
|
|
case "\\": out += "\\\\"
|
|
case ";": out += "\\;"
|
|
case ",": out += "\\,"
|
|
case "\n": out += "\\n"
|
|
default: out.append(character)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// Folds a content line to `octetLimit` octets, continuing with CRLF + space.
|
|
///
|
|
/// Folding is defined in octets, not characters, so this splits on the UTF-8
|
|
/// representation — backing off to a leading byte so a multi-byte character
|
|
/// is never cut in half.
|
|
public static func fold(_ line: String) -> String {
|
|
let bytes = Array(line.utf8)
|
|
guard bytes.count > octetLimit else { return line }
|
|
|
|
var pieces: [String] = []
|
|
var start = 0
|
|
// The first line spends all 75 octets on content; every continuation
|
|
// line spends one on the leading space.
|
|
var budget = octetLimit
|
|
|
|
while start < bytes.count {
|
|
var end = min(start + budget, bytes.count)
|
|
if end < bytes.count {
|
|
// 0b10xxxxxx marks a UTF-8 continuation byte; walk back to the
|
|
// start of the character it belongs to.
|
|
while end > start, bytes[end] & 0xC0 == 0x80 {
|
|
end -= 1
|
|
}
|
|
// A single character wider than the budget can't be split at
|
|
// all — emit it over-length rather than looping forever.
|
|
if end == start {
|
|
end = min(start + budget, bytes.count)
|
|
while end < bytes.count, bytes[end] & 0xC0 == 0x80 {
|
|
end += 1
|
|
}
|
|
}
|
|
}
|
|
pieces.append(String(decoding: bytes[start..<end], as: UTF8.self))
|
|
start = end
|
|
budget = octetLimit - 1
|
|
}
|
|
|
|
return pieces.joined(separator: "\r\n ")
|
|
}
|
|
|
|
/// Undoes RFC folding: a line beginning with space or tab continues the previous one.
|
|
///
|
|
/// Accepts CRLF, LF or bare CR input, since files that reach us from other
|
|
/// tools are not reliably terminated.
|
|
public static func unfold(_ text: String) -> [String] {
|
|
let normalized = text
|
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
|
.replacingOccurrences(of: "\r", with: "\n")
|
|
|
|
var lines: [String] = []
|
|
for line in normalized.split(separator: "\n", omittingEmptySubsequences: false) {
|
|
if let first = line.first, first == " " || first == "\t", !lines.isEmpty {
|
|
lines[lines.count - 1] += line.dropFirst()
|
|
} else {
|
|
lines.append(String(line))
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
}
|
|
|
|
/// Accumulates content lines and renders them as a folded, CRLF-terminated document.
|
|
public struct ContentLineBuilder {
|
|
private var lines: [String] = []
|
|
|
|
public init() {}
|
|
|
|
/// Appends `NAME:VALUE`, escaping the value as TEXT.
|
|
public mutating func append(_ name: String, _ value: String) {
|
|
lines.append("\(name):\(ContentLine.escapeText(value))")
|
|
}
|
|
|
|
/// Appends `NAME;PARAM=…:VALUE`, escaping the value as TEXT.
|
|
public mutating func append(
|
|
_ name: String,
|
|
parameters: [(String, String)],
|
|
value: String
|
|
) {
|
|
let rendered = parameters.map { "\($0.0)=\($0.1)" }.joined(separator: ";")
|
|
let prefix = rendered.isEmpty ? name : "\(name);\(rendered)"
|
|
lines.append("\(prefix):\(ContentLine.escapeText(value))")
|
|
}
|
|
|
|
/// Appends a structured value whose components are escaped individually
|
|
/// and joined with `;` — the shape used by `N` and `ADR`.
|
|
public mutating func appendStructured(_ name: String, components: [String]) {
|
|
let value = components.map(ContentLine.escapeText).joined(separator: ";")
|
|
lines.append("\(name):\(value)")
|
|
}
|
|
|
|
/// Appends a line whose value is already in its final form (dates, UIDs,
|
|
/// version numbers) and must not be escaped.
|
|
public mutating func appendVerbatim(_ name: String, _ value: String) {
|
|
lines.append("\(name):\(value)")
|
|
}
|
|
|
|
/// Appends an entire pre-built line, used when splicing in nested components.
|
|
public mutating func appendRawLine(_ line: String) {
|
|
lines.append(line)
|
|
}
|
|
|
|
public var isEmpty: Bool { lines.isEmpty }
|
|
|
|
/// Folds every line and joins with CRLF, including a trailing CRLF.
|
|
public func render() -> String {
|
|
lines.map(ContentLine.fold).joined(separator: "\r\n") + "\r\n"
|
|
}
|
|
}
|