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
141 lines
5.0 KiB
Swift
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)
|
|
}
|
|
}
|