Files
iPod-export/Sources/IPodSyncKit/ICalendarFileSanitizer.swift
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

141 lines
5.0 KiB
Swift

import Foundation
/// Cleans `.ics` files the user already has.
///
/// A Calendar.app export carries `VTIMEZONE` blocks, `TZID`-qualified
/// date-times and `VALARM` blocks — none of which click-wheel firmware
/// implements, and any of which can cost you the entire file rather than just
/// the offending event.
public enum ICalendarFileSanitizer {
/// Components removed wholesale, including anything nested inside them.
static let strippedComponents: Set<String> = ["VALARM", "VTIMEZONE"]
/// Date-time properties whose UTC form is rewritten as floating local time.
///
/// `DTSTAMP`, `CREATED` and `LAST-MODIFIED` are deliberately absent: they
/// are bookkeeping the device never displays, and RFC 5545 requires them in
/// UTC.
static let localisedDateProperties: Set<String> = [
"DTSTART", "DTEND", "DUE", "RECURRENCE-ID",
]
public static func sanitize(
contentsOf url: URL,
timeZone: TimeZone = .current
) throws -> FileSanitizationResult {
sanitize(
text: try TextFile.read(contentsOf: url),
name: url.deletingPathExtension().lastPathComponent,
timeZone: timeZone
)
}
public static func sanitize(
text: String,
name: String,
timeZone: TimeZone = .current
) -> FileSanitizationResult {
var output: [String] = []
var warnings: [String] = []
var skipDepth = 0
var eventCount = 0
var hasRecurrence = false
for line in ContentLine.unfold(text) {
let upper = line.uppercased()
if skipDepth > 0 {
// Nested components (STANDARD/DAYLIGHT inside VTIMEZONE) have
// their own BEGIN/END pairs, so track depth rather than
// stopping at the first END.
if upper.hasPrefix("BEGIN:") {
skipDepth += 1
} else if upper.hasPrefix("END:") {
skipDepth -= 1
}
continue
}
if line.trimmingCharacters(in: .whitespaces).isEmpty { continue }
if upper.hasPrefix("BEGIN:") {
let component = String(upper.dropFirst("BEGIN:".count))
.trimmingCharacters(in: .whitespaces)
if strippedComponents.contains(component) {
skipDepth = 1
continue
}
if component == "VEVENT" { eventCount += 1 }
output.append(line)
continue
}
if upper.hasPrefix("END:") {
output.append(line)
continue
}
guard var property = PropertyLine(line) else { continue }
if property.name.hasPrefix("X-") { continue }
if property.name == "RRULE" { hasRecurrence = true }
// Dropping TZID leaves the wall-clock reading untouched, which is
// exactly the floating interpretation we want.
property.removeParameters(named: ["TZID"])
if localisedDateProperties.contains(property.name),
let floating = floatingLocalTime(from: property.value, timeZone: timeZone) {
property.value = floating
}
output.append(property.rendered)
}
if eventCount == 0 {
warnings.append("No events found — the file may not be a calendar export.")
}
if hasRecurrence {
warnings.append(
"Contains recurring events (RRULE), which were left as-is. "
+ "Click-wheel support for recurrence is unverified — check them on the device. "
+ "Exporting from Calendars instead expands recurrences into individual events."
)
}
var builder = ContentLineBuilder()
for line in output {
builder.appendRawLine(line)
}
return FileSanitizationResult(
files: [SanitizedFile(suggestedName: name, contents: builder.render())],
warnings: warnings
)
}
/// Rewrites a UTC date-time (`…Z`) as the equivalent local wall-clock time.
///
/// Returns nil for values that are already floating or are dates rather
/// than date-times, leaving them untouched.
static func floatingLocalTime(from value: String, timeZone: TimeZone) -> String? {
guard value.hasSuffix("Z") else { return nil }
let parser = DateFormatter()
parser.locale = Locale(identifier: "en_US_POSIX")
parser.calendar = Calendar(identifier: .gregorian)
parser.timeZone = TimeZone(identifier: "UTC")
parser.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
guard let date = parser.date(from: value) else { return nil }
let renderer = DateFormatter()
renderer.locale = Locale(identifier: "en_US_POSIX")
renderer.calendar = Calendar(identifier: .gregorian)
renderer.timeZone = timeZone
renderer.dateFormat = "yyyyMMdd'T'HHmmss"
return renderer.string(from: date)
}
}