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:
@@ -0,0 +1,127 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
/// A calendar the user can choose to export.
|
||||
public struct CalendarSource: Sendable, Identifiable, Equatable, Hashable {
|
||||
public var id: String
|
||||
public var title: String
|
||||
public var accountName: String
|
||||
|
||||
public init(id: String, title: String, accountName: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.accountName = accountName
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads calendar events into `IPodEvent` values.
|
||||
///
|
||||
/// Events come back from EventKit already expanded — a weekly standup returns
|
||||
/// one event per week, not one event plus a rule — which is why nothing
|
||||
/// downstream ever has to emit an `RRULE` the device may not understand.
|
||||
public actor CalendarReader {
|
||||
private let store = EKEventStore()
|
||||
|
||||
public init() {}
|
||||
|
||||
public nonisolated static var authorizationStatus: EKAuthorizationStatus {
|
||||
EKEventStore.authorizationStatus(for: .event)
|
||||
}
|
||||
|
||||
/// Prompts for full calendar access, returning whether it was granted.
|
||||
///
|
||||
/// Full rather than write-only access: the app's entire job is reading
|
||||
/// events out.
|
||||
public func requestAccess() async -> Bool {
|
||||
(try? await store.requestFullAccessToEvents()) ?? false
|
||||
}
|
||||
|
||||
public func calendars() -> [CalendarSource] {
|
||||
store.calendars(for: .event)
|
||||
.map {
|
||||
CalendarSource(
|
||||
id: $0.calendarIdentifier,
|
||||
title: $0.title,
|
||||
accountName: $0.source?.title ?? ""
|
||||
)
|
||||
}
|
||||
.sorted { ($0.accountName, $0.title) < ($1.accountName, $1.title) }
|
||||
}
|
||||
|
||||
/// Fetches occurrences in `[start, end)` from the named calendars.
|
||||
public func events(
|
||||
calendarIdentifiers: [String],
|
||||
from start: Date,
|
||||
to end: Date
|
||||
) -> [IPodEvent] {
|
||||
let selected = store.calendars(for: .event)
|
||||
.filter { calendarIdentifiers.contains($0.calendarIdentifier) }
|
||||
guard !selected.isEmpty, start < end else { return [] }
|
||||
|
||||
var collected: [String: IPodEvent] = [:]
|
||||
|
||||
// EventKit rejects a predicate spanning more than four years, so long
|
||||
// ranges are fetched in windows. Events straddling a window boundary
|
||||
// come back twice, hence keying by UID.
|
||||
for (windowStart, windowEnd) in Self.windows(from: start, to: end) {
|
||||
let predicate = store.predicateForEvents(
|
||||
withStart: windowStart,
|
||||
end: windowEnd,
|
||||
calendars: selected
|
||||
)
|
||||
for event in store.events(matching: predicate) {
|
||||
let converted = Self.convert(event)
|
||||
collected[converted.uid] = converted
|
||||
}
|
||||
}
|
||||
|
||||
return collected.values.sorted { $0.start < $1.start }
|
||||
}
|
||||
|
||||
/// Splits a range into windows comfortably inside EventKit's four-year limit.
|
||||
static func windows(from start: Date, to end: Date) -> [(Date, Date)] {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
var windows: [(Date, Date)] = []
|
||||
var cursor = start
|
||||
|
||||
while cursor < end {
|
||||
let next = calendar.date(byAdding: .year, value: 2, to: cursor) ?? end
|
||||
windows.append((cursor, min(next, end)))
|
||||
cursor = next
|
||||
}
|
||||
|
||||
return windows
|
||||
}
|
||||
|
||||
static func convert(_ event: EKEvent) -> IPodEvent {
|
||||
IPodEvent(
|
||||
id: event.eventIdentifier ?? UUID().uuidString,
|
||||
uid: uid(for: event),
|
||||
summary: event.title ?? "",
|
||||
location: event.location ?? "",
|
||||
notes: event.notes ?? "",
|
||||
start: event.startDate,
|
||||
end: event.endDate,
|
||||
isAllDay: event.isAllDay,
|
||||
calendarTitle: event.calendar?.title ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
/// A UID unique per *occurrence*.
|
||||
///
|
||||
/// Every occurrence of a recurring event shares one external identifier, so
|
||||
/// the start instant is folded in — otherwise a weekly meeting would emit
|
||||
/// fifty-two events all claiming the same UID, and importers are entitled to
|
||||
/// treat those as one event.
|
||||
static func uid(for event: EKEvent) -> String {
|
||||
let base = event.calendarItemExternalIdentifier
|
||||
?? event.eventIdentifier
|
||||
?? UUID().uuidString
|
||||
|
||||
let safe = base.unicodeScalars
|
||||
.map { CharacterSet.alphanumerics.contains($0) || $0 == "-" ? Character($0) : "-" }
|
||||
let stamp = Int(event.startDate.timeIntervalSince1970)
|
||||
|
||||
return "\(String(safe).prefix(64))-\(stamp)@ipod-sync"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Contacts
|
||||
import Foundation
|
||||
|
||||
/// Reads the system address book into `IPodContact` values.
|
||||
///
|
||||
/// An actor rather than a plain type so the non-`Sendable` `CNContactStore`
|
||||
/// stays isolated, and so enumerating a large address book happens off the main
|
||||
/// thread.
|
||||
public actor ContactsReader {
|
||||
private let store = CNContactStore()
|
||||
|
||||
public init() {}
|
||||
|
||||
public nonisolated static var authorizationStatus: CNAuthorizationStatus {
|
||||
CNContactStore.authorizationStatus(for: .contacts)
|
||||
}
|
||||
|
||||
/// Prompts for Contacts access, returning whether it was granted.
|
||||
public func requestAccess() async -> Bool {
|
||||
(try? await store.requestAccess(for: .contacts)) ?? false
|
||||
}
|
||||
|
||||
/// The keys fetched for every contact.
|
||||
///
|
||||
/// `CNContactNoteKey` is absent on purpose: since macOS 11 it requires the
|
||||
/// restricted `com.apple.developer.contacts.notes` entitlement, and asking
|
||||
/// for it without one makes the whole fetch throw.
|
||||
///
|
||||
/// Computed rather than stored because `CNKeyDescriptor` is not `Sendable`,
|
||||
/// so a static constant would count as shared mutable state.
|
||||
private static var keysToFetch: [CNKeyDescriptor] {
|
||||
[
|
||||
CNContactIdentifierKey,
|
||||
CNContactNamePrefixKey,
|
||||
CNContactGivenNameKey,
|
||||
CNContactMiddleNameKey,
|
||||
CNContactFamilyNameKey,
|
||||
CNContactNameSuffixKey,
|
||||
CNContactOrganizationNameKey,
|
||||
CNContactJobTitleKey,
|
||||
CNContactPhoneNumbersKey,
|
||||
CNContactEmailAddressesKey,
|
||||
CNContactPostalAddressesKey,
|
||||
CNContactBirthdayKey,
|
||||
] as [CNKeyDescriptor]
|
||||
}
|
||||
|
||||
/// Every contact in every account, in the user's preferred sort order.
|
||||
public func fetchContacts() throws -> [IPodContact] {
|
||||
let request = CNContactFetchRequest(keysToFetch: Self.keysToFetch)
|
||||
request.sortOrder = .userDefault
|
||||
request.unifyResults = true
|
||||
|
||||
var contacts: [IPodContact] = []
|
||||
try store.enumerateContacts(with: request) { contact, _ in
|
||||
contacts.append(Self.convert(contact))
|
||||
}
|
||||
return contacts
|
||||
}
|
||||
|
||||
static func convert(_ contact: CNContact) -> IPodContact {
|
||||
IPodContact(
|
||||
id: contact.identifier,
|
||||
namePrefix: contact.namePrefix,
|
||||
givenName: contact.givenName,
|
||||
middleName: contact.middleName,
|
||||
familyName: contact.familyName,
|
||||
nameSuffix: contact.nameSuffix,
|
||||
organization: contact.organizationName,
|
||||
jobTitle: contact.jobTitle,
|
||||
phoneNumbers: contact.phoneNumbers.map {
|
||||
LabeledValue(
|
||||
label: phoneType(for: $0.label),
|
||||
value: $0.value.stringValue
|
||||
)
|
||||
},
|
||||
emailAddresses: contact.emailAddresses.map {
|
||||
LabeledValue(
|
||||
label: emailType(for: $0.label),
|
||||
value: $0.value as String
|
||||
)
|
||||
},
|
||||
postalAddresses: contact.postalAddresses.map {
|
||||
PostalAddress(
|
||||
label: postalType(for: $0.label),
|
||||
street: $0.value.street,
|
||||
city: $0.value.city,
|
||||
state: $0.value.state,
|
||||
postalCode: $0.value.postalCode,
|
||||
country: $0.value.country
|
||||
)
|
||||
},
|
||||
birthday: contact.birthday
|
||||
)
|
||||
}
|
||||
|
||||
/// Maps a Contacts label to vCard 3.0 `TYPE` tokens.
|
||||
///
|
||||
/// Only tokens vCard 3.0 actually defines are emitted — Contacts labels
|
||||
/// such as "Other" or a user's custom label have no equivalent, and an
|
||||
/// invented `TYPE` is another thing for the device's parser to trip on, so
|
||||
/// those fall back to a bare `VOICE`.
|
||||
static func phoneType(for label: String?) -> String {
|
||||
switch label {
|
||||
case CNLabelPhoneNumberMobile, CNLabelPhoneNumberiPhone: "CELL"
|
||||
case CNLabelHome: "HOME,VOICE"
|
||||
case CNLabelWork: "WORK,VOICE"
|
||||
case CNLabelPhoneNumberMain: "PREF,VOICE"
|
||||
case CNLabelPhoneNumberHomeFax: "HOME,FAX"
|
||||
case CNLabelPhoneNumberWorkFax, CNLabelPhoneNumberOtherFax: "WORK,FAX"
|
||||
case CNLabelPhoneNumberPager: "PAGER"
|
||||
default: "VOICE"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an empty label when there is no standard equivalent; the writer
|
||||
/// then emits `TYPE=INTERNET` alone.
|
||||
static func emailType(for label: String?) -> String {
|
||||
switch label {
|
||||
case CNLabelHome: "HOME"
|
||||
case CNLabelWork: "WORK"
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
|
||||
static func postalType(for label: String?) -> String {
|
||||
switch label {
|
||||
case CNLabelHome: "HOME"
|
||||
case CNLabelWork: "WORK"
|
||||
default: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
/// One file written during an export.
|
||||
public struct ExportedFile: Sendable, Equatable, Identifiable {
|
||||
public var id: String { path.path }
|
||||
public var path: URL
|
||||
/// Which of the device's folders this belongs in.
|
||||
public var destination: ExportSession.Folder
|
||||
|
||||
public init(path: URL, destination: ExportSession.Folder) {
|
||||
self.path = path
|
||||
self.destination = destination
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes sanitized output into a destination folder.
|
||||
///
|
||||
/// Output is laid out as `Contacts/` and `Calendars/` subfolders, mirroring the
|
||||
/// folders on the iPod itself, so the result can be dragged across in Finder
|
||||
/// without sorting anything by hand.
|
||||
public struct ExportSession {
|
||||
public enum Folder: String, Sendable, CaseIterable {
|
||||
case contacts = "Contacts"
|
||||
case calendars = "Calendars"
|
||||
}
|
||||
|
||||
public let destination: URL
|
||||
|
||||
/// One allocator per folder: names only need to be unique among their peers.
|
||||
private var contactNames = FileNameAllocator()
|
||||
private var calendarNames = FileNameAllocator()
|
||||
|
||||
public private(set) var writtenFiles: [ExportedFile] = []
|
||||
public private(set) var warnings: [String] = []
|
||||
|
||||
public init(destination: URL) {
|
||||
self.destination = destination
|
||||
}
|
||||
|
||||
// MARK: - Writing from the system databases
|
||||
|
||||
/// Writes one vCard per contact.
|
||||
public mutating func write(contacts: [IPodContact]) throws {
|
||||
var skipped = 0
|
||||
|
||||
for contact in contacts {
|
||||
guard !contact.isEmpty else {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
let name = contactNames.allocate(
|
||||
preferred: contact.displayName,
|
||||
fallback: "contact",
|
||||
fileExtension: "vcf"
|
||||
)
|
||||
try write(VCardWriter.makeVCard(for: contact), named: name, in: .contacts)
|
||||
}
|
||||
|
||||
if skipped > 0 {
|
||||
warnings.append("\(skipped) contact(s) had no name or details and were skipped.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes one `.ics` per calendar.
|
||||
public mutating func write(
|
||||
events: [IPodEvent],
|
||||
calendarName: String,
|
||||
timeZone: TimeZone = .current
|
||||
) throws {
|
||||
guard !events.isEmpty else { return }
|
||||
|
||||
let name = calendarNames.allocate(
|
||||
preferred: calendarName,
|
||||
fallback: "calendar",
|
||||
fileExtension: "ics"
|
||||
)
|
||||
let contents = ICalendarWriter.makeCalendar(events: events, timeZone: timeZone)
|
||||
try write(contents, named: name, in: .calendars)
|
||||
}
|
||||
|
||||
// MARK: - Writing from sanitized input files
|
||||
|
||||
public mutating func write(sanitized: FileSanitizationResult, as folder: Folder) throws {
|
||||
let fileExtension = folder == .contacts ? "vcf" : "ics"
|
||||
|
||||
for file in sanitized.files {
|
||||
let fallback = folder == .contacts ? "contact" : "calendar"
|
||||
let name = folder == .contacts
|
||||
? contactNames.allocate(
|
||||
preferred: file.suggestedName,
|
||||
fallback: fallback,
|
||||
fileExtension: fileExtension
|
||||
)
|
||||
: calendarNames.allocate(
|
||||
preferred: file.suggestedName,
|
||||
fallback: fallback,
|
||||
fileExtension: fileExtension
|
||||
)
|
||||
try write(file.contents, named: name, in: folder)
|
||||
}
|
||||
|
||||
warnings.append(contentsOf: sanitized.warnings)
|
||||
}
|
||||
|
||||
/// Records something the caller noticed that the user should see.
|
||||
public mutating func noteWarning(_ message: String) {
|
||||
warnings.append(message)
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
private mutating func write(_ contents: String, named name: String, in folder: Folder) throws {
|
||||
let directory = destination.appendingPathComponent(folder.rawValue, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let path = directory.appendingPathComponent(name)
|
||||
try Data(contents.utf8).write(to: path, options: .atomic)
|
||||
|
||||
writtenFiles.append(ExportedFile(path: path, destination: folder))
|
||||
}
|
||||
|
||||
public var summary: String {
|
||||
let contacts = writtenFiles.filter { $0.destination == .contacts }.count
|
||||
let calendars = writtenFiles.filter { $0.destination == .calendars }.count
|
||||
|
||||
var parts: [String] = []
|
||||
if contacts > 0 { parts.append("\(contacts) contact file\(contacts == 1 ? "" : "s")") }
|
||||
if calendars > 0 { parts.append("\(calendars) calendar file\(calendars == 1 ? "" : "s")") }
|
||||
|
||||
return parts.isEmpty ? "Nothing was written." : "Wrote " + parts.joined(separator: " and ") + "."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
/// Produces filenames safe for an iPod's volume, and unique within one export.
|
||||
///
|
||||
/// Click-wheel iPods are usually FAT32 and their Contacts/Calendars browsers
|
||||
/// handle non-ASCII filenames poorly, so names are folded to ASCII rather than
|
||||
/// passed through. Accents fold to their base letter (`Renée` → `Renee`);
|
||||
/// anything still outside the safe set is dropped.
|
||||
public struct FileNameAllocator {
|
||||
/// Lowercased names already handed out, so collisions can be numbered.
|
||||
private var used: Set<String> = []
|
||||
|
||||
public static let maximumLength = 60
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Returns a unique filename with `fileExtension`, based on `preferred`.
|
||||
public mutating func allocate(
|
||||
preferred: String,
|
||||
fallback: String = "item",
|
||||
fileExtension: String
|
||||
) -> String {
|
||||
let base = Self.sanitize(preferred, fallback: fallback)
|
||||
|
||||
var candidate = base
|
||||
var counter = 1
|
||||
while used.contains(candidate.lowercased()) {
|
||||
counter += 1
|
||||
let suffix = "_\(counter)"
|
||||
let trimmed = String(base.prefix(Self.maximumLength - suffix.count))
|
||||
candidate = trimmed + suffix
|
||||
}
|
||||
used.insert(candidate.lowercased())
|
||||
|
||||
return "\(candidate).\(fileExtension)"
|
||||
}
|
||||
|
||||
/// Folds to ASCII, keeps `[A-Za-z0-9 ._-]`, collapses runs of whitespace.
|
||||
static func sanitize(_ name: String, fallback: String) -> String {
|
||||
let folded = name.folding(
|
||||
options: [.diacriticInsensitive, .widthInsensitive],
|
||||
locale: Locale(identifier: "en_US_POSIX")
|
||||
)
|
||||
|
||||
let allowed = folded.map { character -> Character in
|
||||
character.isASCII && (character.isLetter || character.isNumber
|
||||
|| character == " " || character == "." || character == "_"
|
||||
|| character == "-")
|
||||
? character
|
||||
: " "
|
||||
}
|
||||
|
||||
let collapsed = String(allowed)
|
||||
.split(separator: " ", omittingEmptySubsequences: true)
|
||||
.joined(separator: " ")
|
||||
|
||||
// A name of only dots would collide with "." / ".." on the device.
|
||||
let trimmed = collapsed
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " ."))
|
||||
.prefix(maximumLength)
|
||||
|
||||
return trimmed.isEmpty ? fallback : String(trimmed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
/// Emits iCalendar for a click-wheel iPod.
|
||||
///
|
||||
/// The three things that made Calendar.app's own exports fail on the device are
|
||||
/// structurally absent here rather than filtered out afterwards:
|
||||
///
|
||||
/// - **No `VTIMEZONE`, no `TZID`.** Times are written as *floating* local wall
|
||||
/// time. The device has no timezone database, so a `TZID` it cannot resolve
|
||||
/// can cost you the whole file.
|
||||
/// - **No `VALARM`.** Alarms are meaningless on a device that cannot fire them.
|
||||
/// - **No `RRULE`.** Recurring events arrive here already expanded into one
|
||||
/// `VEVENT` per occurrence, so recurrence support never has to be relied on.
|
||||
public enum ICalendarWriter {
|
||||
public static let productIdentifier = "-//unsupervised.ca//iPod Contacts and Calendar Sync//EN"
|
||||
|
||||
/// Renders events as a complete, CRLF-terminated `VCALENDAR`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - timeZone: the zone whose wall-clock reading is baked into the
|
||||
/// floating times. Defaults to the Mac's current zone, which is what
|
||||
/// someone syncing their own calendar expects to see on the device.
|
||||
/// - now: the `DTSTAMP` instant, injectable for tests.
|
||||
public static func makeCalendar(
|
||||
events: [IPodEvent],
|
||||
timeZone: TimeZone = .current,
|
||||
now: Date = Date()
|
||||
) -> String {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = timeZone
|
||||
|
||||
let floating = formatter("yyyyMMdd'T'HHmmss", timeZone: timeZone)
|
||||
let dateOnly = formatter("yyyyMMdd", timeZone: timeZone)
|
||||
let utc = formatter("yyyyMMdd'T'HHmmss'Z'", timeZone: TimeZone(identifier: "UTC")!)
|
||||
|
||||
var builder = ContentLineBuilder()
|
||||
builder.appendVerbatim("BEGIN", "VCALENDAR")
|
||||
builder.appendVerbatim("VERSION", "2.0")
|
||||
builder.appendVerbatim("PRODID", productIdentifier)
|
||||
builder.appendVerbatim("CALSCALE", "GREGORIAN")
|
||||
|
||||
let stamp = utc.string(from: now)
|
||||
|
||||
for event in events.sorted(by: { $0.start < $1.start }) {
|
||||
builder.appendVerbatim("BEGIN", "VEVENT")
|
||||
builder.appendVerbatim("UID", event.uid)
|
||||
builder.appendVerbatim("DTSTAMP", stamp)
|
||||
|
||||
if event.isAllDay {
|
||||
builder.appendRawLine("DTSTART;VALUE=DATE:\(dateOnly.string(from: event.start))")
|
||||
let exclusiveEnd = allDayExclusiveEnd(for: event, calendar: calendar)
|
||||
builder.appendRawLine("DTEND;VALUE=DATE:\(dateOnly.string(from: exclusiveEnd))")
|
||||
} else {
|
||||
builder.appendVerbatim("DTSTART", floating.string(from: event.start))
|
||||
// A zero- or negative-length event is legal with DTSTART alone;
|
||||
// emitting DTEND <= DTSTART is not.
|
||||
if event.end > event.start {
|
||||
builder.appendVerbatim("DTEND", floating.string(from: event.end))
|
||||
}
|
||||
}
|
||||
|
||||
builder.append("SUMMARY", event.summary.isEmpty ? "(No title)" : event.summary)
|
||||
if !event.location.isEmpty {
|
||||
builder.append("LOCATION", event.location)
|
||||
}
|
||||
if !event.notes.isEmpty {
|
||||
builder.append("DESCRIPTION", event.notes)
|
||||
}
|
||||
|
||||
builder.appendVerbatim("END", "VEVENT")
|
||||
}
|
||||
|
||||
builder.appendVerbatim("END", "VCALENDAR")
|
||||
|
||||
return builder.render()
|
||||
}
|
||||
|
||||
/// All-day `DTEND` is exclusive — the day *after* the last day covered.
|
||||
///
|
||||
/// EventKit is inconsistent about whether an all-day event's `endDate` is
|
||||
/// the last day at 23:59:59 or the next day at midnight, so both are
|
||||
/// normalised to the same exclusive boundary here.
|
||||
static func allDayExclusiveEnd(for event: IPodEvent, calendar: Calendar) -> Date {
|
||||
let endDay = calendar.startOfDay(for: event.end)
|
||||
let exclusive = endDay == event.end
|
||||
? endDay
|
||||
: calendar.date(byAdding: .day, value: 1, to: endDay) ?? endDay
|
||||
|
||||
// Never emit a range that ends before it starts.
|
||||
let startDay = calendar.startOfDay(for: event.start)
|
||||
if exclusive <= startDay {
|
||||
return calendar.date(byAdding: .day, value: 1, to: startDay) ?? exclusive
|
||||
}
|
||||
return exclusive
|
||||
}
|
||||
|
||||
private static func formatter(_ format: String, timeZone: TimeZone) -> DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.timeZone = timeZone
|
||||
formatter.dateFormat = format
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
/// A typed value with a vCard `TYPE` label, e.g. a `WORK` phone number.
|
||||
public struct LabeledValue: Sendable, Equatable {
|
||||
/// Already normalised to a vCard TYPE token: `HOME`, `WORK`, `CELL`, …
|
||||
public var label: String
|
||||
public var value: String
|
||||
|
||||
public init(label: String, value: String) {
|
||||
self.label = label
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
/// A postal address, held as the components vCard's `ADR` expects.
|
||||
public struct PostalAddress: Sendable, Equatable {
|
||||
public var label: String
|
||||
public var street: String
|
||||
public var city: String
|
||||
public var state: String
|
||||
public var postalCode: String
|
||||
public var country: String
|
||||
|
||||
public init(
|
||||
label: String,
|
||||
street: String = "",
|
||||
city: String = "",
|
||||
state: String = "",
|
||||
postalCode: String = "",
|
||||
country: String = ""
|
||||
) {
|
||||
self.label = label
|
||||
self.street = street
|
||||
self.city = city
|
||||
self.state = state
|
||||
self.postalCode = postalCode
|
||||
self.country = country
|
||||
}
|
||||
}
|
||||
|
||||
/// A contact reduced to the fields a click-wheel iPod can actually display.
|
||||
///
|
||||
/// Deliberately has no notes field. `CNContactNoteKey` has required the
|
||||
/// restricted `com.apple.developer.contacts.notes` entitlement since macOS 11,
|
||||
/// and fetching with it unentitled throws — so notes are never read.
|
||||
public struct IPodContact: Sendable, Equatable, Identifiable {
|
||||
public var id: String
|
||||
public var namePrefix: String
|
||||
public var givenName: String
|
||||
public var middleName: String
|
||||
public var familyName: String
|
||||
public var nameSuffix: String
|
||||
public var organization: String
|
||||
public var jobTitle: String
|
||||
public var phoneNumbers: [LabeledValue]
|
||||
public var emailAddresses: [LabeledValue]
|
||||
public var postalAddresses: [PostalAddress]
|
||||
public var birthday: DateComponents?
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
namePrefix: String = "",
|
||||
givenName: String = "",
|
||||
middleName: String = "",
|
||||
familyName: String = "",
|
||||
nameSuffix: String = "",
|
||||
organization: String = "",
|
||||
jobTitle: String = "",
|
||||
phoneNumbers: [LabeledValue] = [],
|
||||
emailAddresses: [LabeledValue] = [],
|
||||
postalAddresses: [PostalAddress] = [],
|
||||
birthday: DateComponents? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.namePrefix = namePrefix
|
||||
self.givenName = givenName
|
||||
self.middleName = middleName
|
||||
self.familyName = familyName
|
||||
self.nameSuffix = nameSuffix
|
||||
self.organization = organization
|
||||
self.jobTitle = jobTitle
|
||||
self.phoneNumbers = phoneNumbers
|
||||
self.emailAddresses = emailAddresses
|
||||
self.postalAddresses = postalAddresses
|
||||
self.birthday = birthday
|
||||
}
|
||||
|
||||
/// The `FN` value, and the basis for the contact's filename.
|
||||
public var displayName: String {
|
||||
let parts = [namePrefix, givenName, middleName, familyName, nameSuffix]
|
||||
.filter { !$0.isEmpty }
|
||||
if !parts.isEmpty { return parts.joined(separator: " ") }
|
||||
if !organization.isEmpty { return organization }
|
||||
if let email = emailAddresses.first?.value, !email.isEmpty { return email }
|
||||
return "Contact"
|
||||
}
|
||||
|
||||
/// True when the contact carries nothing worth putting on the device.
|
||||
public var isEmpty: Bool {
|
||||
phoneNumbers.isEmpty
|
||||
&& emailAddresses.isEmpty
|
||||
&& postalAddresses.isEmpty
|
||||
&& organization.isEmpty
|
||||
&& givenName.isEmpty
|
||||
&& familyName.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// A single dated occurrence, already expanded out of any recurrence rule.
|
||||
///
|
||||
/// Times are absolute `Date`s here; they are rendered as *floating* local times
|
||||
/// at write time, because click-wheel iPods have no timezone database and
|
||||
/// reject or misread `TZID`-qualified values.
|
||||
public struct IPodEvent: Sendable, Equatable, Identifiable {
|
||||
public var id: String
|
||||
public var uid: String
|
||||
public var summary: String
|
||||
public var location: String
|
||||
public var notes: String
|
||||
public var start: Date
|
||||
public var end: Date
|
||||
public var isAllDay: Bool
|
||||
public var calendarTitle: String
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
uid: String,
|
||||
summary: String,
|
||||
location: String = "",
|
||||
notes: String = "",
|
||||
start: Date,
|
||||
end: Date,
|
||||
isAllDay: Bool = false,
|
||||
calendarTitle: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.uid = uid
|
||||
self.summary = summary
|
||||
self.location = location
|
||||
self.notes = notes
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.isAllDay = isAllDay
|
||||
self.calendarTitle = calendarTitle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Decodes `ENCODING=QUOTED-PRINTABLE` values, as found in vCard 2.1 exports.
|
||||
///
|
||||
/// Older address books encode any non-ASCII character this way, so a contact
|
||||
/// named `José` arrives as `Jos=C3=A9` — which a click-wheel iPod displays
|
||||
/// literally rather than decoding.
|
||||
public enum QuotedPrintable {
|
||||
/// Decodes `=XX` escapes and returns the bytes as text.
|
||||
///
|
||||
/// `charset` is the value of the vCard `CHARSET` parameter when one was
|
||||
/// present. It is only a hint: whatever it claims, valid UTF-8 is decoded
|
||||
/// as UTF-8, since that is overwhelmingly what modern exports contain even
|
||||
/// when they label themselves otherwise.
|
||||
public static func decode(_ value: String, charset: String? = nil) -> String {
|
||||
var bytes: [UInt8] = []
|
||||
let source = Array(value.utf8)
|
||||
var index = 0
|
||||
|
||||
while index < source.count {
|
||||
if source[index] == UInt8(ascii: "="), index + 2 < source.count,
|
||||
let high = hexDigit(source[index + 1]),
|
||||
let low = hexDigit(source[index + 2]) {
|
||||
bytes.append(high << 4 | low)
|
||||
index += 3
|
||||
} else if source[index] == UInt8(ascii: "="), index + 1 == source.count - 1 {
|
||||
// A trailing '=' is a soft line break that was never joined.
|
||||
index += 2
|
||||
} else {
|
||||
bytes.append(source[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return decode(bytes: bytes, charset: charset)
|
||||
}
|
||||
|
||||
static func decode(bytes: [UInt8], charset: String?) -> String {
|
||||
if let text = String(bytes: bytes, encoding: .utf8) {
|
||||
return text
|
||||
}
|
||||
if let charset, let encoding = encoding(named: charset),
|
||||
let text = String(bytes: bytes, encoding: encoding) {
|
||||
return text
|
||||
}
|
||||
if let text = String(bytes: bytes, encoding: .windowsCP1252) {
|
||||
return text
|
||||
}
|
||||
return String(decoding: bytes, as: UTF8.self)
|
||||
}
|
||||
|
||||
private static func encoding(named charset: String) -> String.Encoding? {
|
||||
switch charset.uppercased() {
|
||||
case "UTF-8", "UTF8": return .utf8
|
||||
case "ISO-8859-1", "LATIN1", "ISO8859-1": return .isoLatin1
|
||||
case "WINDOWS-1252", "CP1252": return .windowsCP1252
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func hexDigit(_ byte: UInt8) -> UInt8? {
|
||||
switch byte {
|
||||
case UInt8(ascii: "0")...UInt8(ascii: "9"): return byte - UInt8(ascii: "0")
|
||||
case UInt8(ascii: "A")...UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10
|
||||
case UInt8(ascii: "a")...UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Reads text files of unknown provenance.
|
||||
///
|
||||
/// `.vcf` and `.ics` files reaching the app may have come from any address book
|
||||
/// or calendar going back twenty years, so the declared encoding — if there is
|
||||
/// one — cannot be trusted. Candidates are tried in order of likelihood.
|
||||
public enum TextFile {
|
||||
public static func read(contentsOf url: URL) throws -> String {
|
||||
let data = try Data(contentsOf: url)
|
||||
return decode(data)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> String {
|
||||
// A UTF-8 BOM would otherwise survive into the first property name.
|
||||
var bytes = data
|
||||
if bytes.starts(with: [0xEF, 0xBB, 0xBF]) {
|
||||
bytes = bytes.dropFirst(3)
|
||||
}
|
||||
|
||||
for encoding in [String.Encoding.utf8, .windowsCP1252, .isoLatin1] {
|
||||
if let text = String(data: bytes, encoding: encoding) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return String(decoding: bytes, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
/// One output file produced by sanitizing.
|
||||
public struct SanitizedFile: Sendable, Equatable {
|
||||
/// Base name without extension; the caller allocates the final filename.
|
||||
public var suggestedName: String
|
||||
public var contents: String
|
||||
|
||||
public init(suggestedName: String, contents: String) {
|
||||
self.suggestedName = suggestedName
|
||||
self.contents = contents
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of sanitizing one input file.
|
||||
public struct FileSanitizationResult: Sendable, Equatable {
|
||||
public var files: [SanitizedFile]
|
||||
public var warnings: [String]
|
||||
|
||||
public init(files: [SanitizedFile], warnings: [String] = []) {
|
||||
self.files = files
|
||||
self.warnings = warnings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
|
||||
/// Splits and cleans `.vcf` files the user already has.
|
||||
///
|
||||
/// This is the path for files exported from somewhere else. A single combined
|
||||
/// `.vcf` holding hundreds of contacts is the failure mode that started all
|
||||
/// this — click-wheel firmware reads the first card and then stops, or stops at
|
||||
/// the first card it cannot parse — so every card becomes its own file.
|
||||
public enum VCardFileSanitizer {
|
||||
/// Properties carrying binary payloads the device's parser chokes on.
|
||||
static let strippedProperties: Set<String> = ["PHOTO", "LOGO", "SOUND", "KEY"]
|
||||
|
||||
public static func sanitize(contentsOf url: URL) throws -> FileSanitizationResult {
|
||||
sanitize(text: try TextFile.read(contentsOf: url))
|
||||
}
|
||||
|
||||
public static func sanitize(text: String) -> FileSanitizationResult {
|
||||
let lines = joiningSoftLineBreaks(ContentLine.unfold(text))
|
||||
|
||||
var cards: [[String]] = []
|
||||
var current: [String] = []
|
||||
var insideCard = false
|
||||
var unterminated = 0
|
||||
|
||||
for line in lines {
|
||||
let upper = line.uppercased()
|
||||
if upper.hasPrefix("BEGIN:VCARD") {
|
||||
if insideCard { unterminated += 1 }
|
||||
insideCard = true
|
||||
current = [line]
|
||||
} else if upper.hasPrefix("END:VCARD") {
|
||||
if insideCard {
|
||||
current.append(line)
|
||||
cards.append(current)
|
||||
}
|
||||
insideCard = false
|
||||
current = []
|
||||
} else if insideCard {
|
||||
current.append(line)
|
||||
}
|
||||
}
|
||||
if insideCard { unterminated += 1 }
|
||||
|
||||
var warnings: [String] = []
|
||||
if cards.isEmpty {
|
||||
warnings.append("No vCards found — the file may not be a vCard export.")
|
||||
}
|
||||
if unterminated > 0 {
|
||||
warnings.append("\(unterminated) card(s) had no END:VCARD and were skipped.")
|
||||
}
|
||||
|
||||
let files = cards.map { card -> SanitizedFile in
|
||||
let sanitized = sanitize(cardLines: card)
|
||||
var builder = ContentLineBuilder()
|
||||
for line in sanitized {
|
||||
builder.appendRawLine(line)
|
||||
}
|
||||
// The display name is read back from the *sanitized* lines, so a
|
||||
// quoted-printable name yields "José" rather than "Jos=C3=A9".
|
||||
return SanitizedFile(
|
||||
suggestedName: displayName(in: sanitized),
|
||||
contents: builder.render()
|
||||
)
|
||||
}
|
||||
|
||||
return FileSanitizationResult(files: files, warnings: warnings)
|
||||
}
|
||||
|
||||
/// Applies the per-property filtering to one card's lines.
|
||||
static func sanitize(cardLines: [String]) -> [String] {
|
||||
var output: [String] = []
|
||||
|
||||
for line in cardLines {
|
||||
let upper = line.uppercased()
|
||||
if upper.hasPrefix("BEGIN:VCARD") || upper.hasPrefix("END:VCARD") {
|
||||
output.append(line)
|
||||
continue
|
||||
}
|
||||
|
||||
guard var property = PropertyLine(line) else { continue }
|
||||
|
||||
if strippedProperties.contains(property.name) { continue }
|
||||
if property.name.hasPrefix("X-") { continue }
|
||||
|
||||
// Any remaining base64 payload is binary the device cannot use.
|
||||
if property.parameterValue(named: "ENCODING")
|
||||
.map({ ["B", "BASE64"].contains($0.uppercased()) }) == true {
|
||||
continue
|
||||
}
|
||||
|
||||
if property.parameterValue(named: "ENCODING")?.uppercased() == "QUOTED-PRINTABLE" {
|
||||
property.value = QuotedPrintable.decode(
|
||||
property.value,
|
||||
charset: property.parameterValue(named: "CHARSET")
|
||||
)
|
||||
property.removeParameters(named: ["ENCODING", "CHARSET"])
|
||||
}
|
||||
|
||||
output.append(property.rendered)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/// vCard 2.1 continues a quoted-printable value on the next line when the
|
||||
/// current one ends in `=`. Unlike RFC folding the continuation is *not*
|
||||
/// indented, so `ContentLine.unfold` leaves it split and it is rejoined here.
|
||||
static func joiningSoftLineBreaks(_ lines: [String]) -> [String] {
|
||||
var output: [String] = []
|
||||
var awaitingContinuation = false
|
||||
|
||||
for line in lines {
|
||||
if awaitingContinuation, !output.isEmpty {
|
||||
output[output.count - 1] = String(output[output.count - 1].dropLast()) + line
|
||||
} else {
|
||||
output.append(line)
|
||||
}
|
||||
|
||||
let last = output[output.count - 1]
|
||||
awaitingContinuation = last.hasSuffix("=")
|
||||
&& last.uppercased().contains("QUOTED-PRINTABLE")
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
/// Best available human name for the card, used as its filename.
|
||||
static func displayName(in lines: [String]) -> String {
|
||||
var structuredName: String?
|
||||
var organization: String?
|
||||
|
||||
for line in lines {
|
||||
guard let property = PropertyLine(line) else { continue }
|
||||
switch property.name {
|
||||
case "FN" where !property.value.isEmpty:
|
||||
return PropertyLine.unescape(property.value)
|
||||
case "N" where structuredName == nil:
|
||||
// N is family;given;middle;prefix;suffix — display it given-first.
|
||||
let parts = property.value
|
||||
.components(separatedBy: ";")
|
||||
.map { PropertyLine.unescape($0).trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
if !parts.isEmpty {
|
||||
structuredName = parts.count >= 2
|
||||
? "\(parts[1]) \(parts[0])"
|
||||
: parts[0]
|
||||
}
|
||||
case "ORG" where organization == nil:
|
||||
organization = PropertyLine.unescape(property.value)
|
||||
.components(separatedBy: ";")
|
||||
.first?
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return structuredName ?? organization ?? "contact"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Emits vCard 3.0 for a click-wheel iPod.
|
||||
///
|
||||
/// Version 3.0 rather than 4.0 deliberately: the click-wheel address book was
|
||||
/// written against 2.1/3.0 and does not recognise 4.0's `VERSION` or its
|
||||
/// property forms. Nothing here emits `PHOTO`, `LOGO`, `SOUND` or `X-`
|
||||
/// properties — the categories that made hand-exported files fail — because
|
||||
/// they are simply never written rather than stripped afterwards.
|
||||
public enum VCardWriter {
|
||||
/// Renders one contact as a complete, CRLF-terminated vCard.
|
||||
public static func makeVCard(for contact: IPodContact) -> String {
|
||||
var builder = ContentLineBuilder()
|
||||
|
||||
builder.appendVerbatim("BEGIN", "VCARD")
|
||||
builder.appendVerbatim("VERSION", "3.0")
|
||||
|
||||
// N is ordered family;given;middle;prefix;suffix and is required in 3.0.
|
||||
builder.appendStructured("N", components: [
|
||||
contact.familyName,
|
||||
contact.givenName,
|
||||
contact.middleName,
|
||||
contact.namePrefix,
|
||||
contact.nameSuffix,
|
||||
])
|
||||
builder.append("FN", contact.displayName)
|
||||
|
||||
if !contact.organization.isEmpty {
|
||||
builder.appendStructured("ORG", components: [contact.organization])
|
||||
}
|
||||
if !contact.jobTitle.isEmpty {
|
||||
builder.append("TITLE", contact.jobTitle)
|
||||
}
|
||||
|
||||
for phone in contact.phoneNumbers where !phone.value.isEmpty {
|
||||
let type = phone.label.isEmpty ? "VOICE" : phone.label
|
||||
builder.append("TEL", parameters: [("TYPE", type)], value: phone.value)
|
||||
}
|
||||
|
||||
for email in contact.emailAddresses where !email.value.isEmpty {
|
||||
let type = email.label.isEmpty ? "INTERNET" : "INTERNET,\(email.label)"
|
||||
builder.append("EMAIL", parameters: [("TYPE", type)], value: email.value)
|
||||
}
|
||||
|
||||
for address in contact.postalAddresses {
|
||||
// ADR is po-box;extended;street;locality;region;postal-code;country.
|
||||
let components = [
|
||||
"", "",
|
||||
address.street,
|
||||
address.city,
|
||||
address.state,
|
||||
address.postalCode,
|
||||
address.country,
|
||||
]
|
||||
guard components.contains(where: { !$0.isEmpty }) else { continue }
|
||||
let value = components.map(ContentLine.escapeText).joined(separator: ";")
|
||||
let name = address.label.isEmpty ? "ADR" : "ADR;TYPE=\(address.label)"
|
||||
builder.appendRawLine("\(name):\(value)")
|
||||
}
|
||||
|
||||
if let birthday = contact.birthday, let formatted = formatBirthday(birthday) {
|
||||
builder.appendVerbatim("BDAY", formatted)
|
||||
}
|
||||
|
||||
builder.appendVerbatim("END", "VCARD")
|
||||
|
||||
return builder.render()
|
||||
}
|
||||
|
||||
/// Formats a birthday as `YYYY-MM-DD`.
|
||||
///
|
||||
/// Returns nil for year-less birthdays. Contacts.app allows them, but vCard
|
||||
/// 3.0 has no representation Apple doesn't express through an `X-` parameter
|
||||
/// (`X-APPLE-OMIT-YEAR`), and an invented year would show a wrong age on the
|
||||
/// device — so those birthdays are dropped instead.
|
||||
static func formatBirthday(_ components: DateComponents) -> String? {
|
||||
guard let year = components.year,
|
||||
let month = components.month,
|
||||
let day = components.day,
|
||||
year > 1
|
||||
else { return nil }
|
||||
|
||||
return String(format: "%04d-%02d-%02d", year, month, day)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user