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:
2026-08-08 20:21:09 -03:00
co-authored by Claude Opus 5
commit 7b9d16e442
23 changed files with 2712 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
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"
}
}