commit 7b9d16e4420bada38dc1ffbf39a9e93e59f6abaf Author: James Griffin Date: Sat Aug 8 20:21:09 2026 -0300 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 Claude-Session: https://claude.ai/code/session_01C5X1tYo9oxAfvoiFMh1QQr diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8b25b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.build/ +build/ +.swiftpm/ +.DS_Store +*.xcuserdatad diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..0c346fb --- /dev/null +++ b/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "iPodContactsAndCalendarSync", + platforms: [.macOS(.v14)], + targets: [ + .target(name: "IPodSyncKit"), + .executableTarget( + name: "iPodSyncApp", + dependencies: ["IPodSyncKit"] + ), + .testTarget( + name: "IPodSyncKitTests", + dependencies: ["IPodSyncKit"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4db7479 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# iPod Contacts and Calendar Sync + +A macOS app that writes contacts and calendars in a form a click-wheel iPod +can actually read. + +## Why this exists + +Since Catalina, Finder has had no contacts/calendar sync for a click-wheel iPod +— that lived in iTunes' "Info" tab, which was removed in 2019. The device still +mounts in Finder with `Contacts` and `Calendars` folders that accept dropped +`.vcf` and `.ics` files, but exporting from Contacts.app and Calendar.app and +dragging the results across mostly doesn't work: + +- A single `.vcf` holding hundreds of contacts shows up as **one** contact, or + a few. The firmware reads the first card and stops, or stops at the first + card it can't parse. +- A `.ics` from Calendar.app often shows up as **nothing at all**. The exports + carry `VTIMEZONE` blocks, `TZID`-qualified date-times and `VALARM` blocks, + none of which the device implements — and a file it can't parse is a file it + discards entirely, not one it partially reads. + +This app reads Contacts and Calendars directly and writes output shaped around +those limits. There's no export-then-clean step: the problem properties are +never written in the first place. + +## What it produces + +Pick an output folder and you get: + +``` +YourFolder/ + Contacts/ one .vcf per person → drag into the iPod's Contacts folder + Calendars/ one .ics per calendar → drag into the iPod's Calendars folder +``` + +The subfolders mirror the device's own layout, so the drag across in Finder is +unambiguous. + +### Contacts + +- **One file per contact.** The single biggest fix — it sidesteps the + multi-card parsing failure entirely. +- vCard **3.0**, not 4.0. The click-wheel address book was written against + 2.1/3.0 and doesn't recognise 4.0's property forms. +- No `PHOTO`, `LOGO`, `SOUND` or `X-` properties. +- Contacts labels map to the `TYPE` tokens vCard 3.0 actually defines; labels + with no standard equivalent ("Other", custom ones) fall back to a bare type + rather than an invented one. +- Notes are not exported. `CNContactNoteKey` has required a restricted Apple + entitlement since macOS 11, and requesting it unentitled makes the whole + fetch fail. +- Birthdays without a year are dropped rather than given an invented one. + +### Calendars + +- **One file per calendar**, so one bad calendar can't take the others down + with it on the device. +- **Repeating events are expanded into individual events.** Nothing emits an + `RRULE`, so the device never has to interpret one — this was the open + question in the previous version and it's now moot. +- Times are **floating**: local wall-clock, no `TZID`, no `VTIMEZONE`, no `Z`. +- No `VALARM`, no `X-` properties. +- You choose the date range; the default is one month back to a year ahead. + +### Existing `.vcf` / `.ics` files + +The app can also clean files you already have, which is the path the old Python +script covered. Files are split and filtered rather than regenerated: + +- Multi-card `.vcf` files split into one file per card. +- `PHOTO`/`LOGO`/`SOUND`/`KEY` and any base64 payload removed; `X-` properties + removed; Apple's `item1.` property groups unwrapped. +- `ENCODING=QUOTED-PRINTABLE` values decoded to UTF-8, including vCard 2.1 soft + line breaks. Filenames come from the *decoded* name, so you get + `Renée Fleming.vcf`, not `Ren=C3=A9e Fleming.vcf`. +- `VALARM` and `VTIMEZONE` blocks removed (including their nested + `STANDARD`/`DAYLIGHT` components); `TZID` parameters dropped; UTC `Z` times + converted to local floating times. +- `RRULE`s in these files are **kept and flagged**, not expanded — there's no + recurrence engine on this path. Export from Calendars instead if you want + them expanded. + +## Building + +Requires Xcode (for the macOS SDK). No third-party dependencies. + +```sh +./Scripts/bundle.sh # build/iPod Contacts and Calendar Sync.app, native arch +./Scripts/bundle.sh --universal # arm64 + x86_64 +swift test # run the test suite +``` + +`Package.swift` opens directly in Xcode if you'd rather work there. + +The bundle is **ad-hoc signed**, which is not the same as signed for +distribution. Ad-hoc signing is required — Contacts and Calendars identify an +app by its code signature, and an unsigned bundle gets denied or re-prompts +forever. Gatekeeper will still block a plain double-click the first time: +right-click → **Open**, then confirm. That's once per build. + +If you rebuild and the permission prompts come back, that's expected: the +signature changed, so macOS treats it as a different app. Clearing the old +entry in System Settings › Privacy & Security is sometimes needed. + +## Project layout + +``` +Sources/IPodSyncKit/ all logic, no UI — what the tests cover + ContentLine.swift folding, escaping, unfolding (both formats) + PropertyLine.swift NAME;PARAM=value:VALUE parsing + Models.swift IPodContact, IPodEvent + VCardWriter.swift IPodContact → vCard 3.0 + ICalendarWriter.swift [IPodEvent] → VCALENDAR + ContactsReader.swift Contacts.framework → IPodContact + CalendarReader.swift EventKit → IPodEvent, recurrences expanded + VCardFileSanitizer.swift existing .vcf → split + filtered + ICalendarFileSanitizer.swift existing .ics → filtered + FileNaming.swift ASCII-safe, collision-free filenames + ExportSession.swift writes the Contacts/ and Calendars/ layout + QuotedPrintable.swift =XX decoding + TextFile.swift encoding-tolerant reading + +Sources/iPodSyncApp/ SwiftUI window app +Tests/IPodSyncKitTests/ 43 tests +Scripts/bundle.sh assembles and signs the .app +``` + +## Status + +Everything is verified at the file level by the test suite. **None of it has +been checked against a physical iPod Classic yet** — the format decisions come +from the documented firmware limitations, not from a device that has been +confirmed to read the output. If something still doesn't appear on the device, +the calendar path is the more likely suspect than the contacts path. diff --git a/Scripts/bundle.sh b/Scripts/bundle.sh new file mode 100755 index 0000000..67b2f0e --- /dev/null +++ b/Scripts/bundle.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# +# Assembles build/iPod Contacts and Calendar Sync.app from the SwiftPM executable. +# +# SwiftPM builds a bare binary; macOS needs it inside a bundle with an +# Info.plist before Contacts and EventKit will even prompt for access, so the +# bundle is put together here rather than by an Xcode target. +# +# ./Scripts/bundle.sh # native architecture +# ./Scripts/bundle.sh --universal # arm64 + x86_64 +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_NAME="iPod Contacts and Calendar Sync" +BUNDLE_ID="ca.unsupervised.ipodsync" +VERSION="2.0" +MIN_MACOS="14.0" + +BUILD_DIR="$ROOT/build" +APP="$BUILD_DIR/$APP_NAME.app" + +# Bash 3.2 ships with macOS and treats an empty array as unbound under +# `set -u`, hence the ${ARCH_FLAGS[@]+…} guard at each use site. +ARCH_FLAGS=() +if [ "${1:-}" = "--universal" ]; then + ARCH_FLAGS=(--arch arm64 --arch x86_64) +fi + +echo "Building…" +swift build --package-path "$ROOT" -c release ${ARCH_FLAGS[@]+"${ARCH_FLAGS[@]}"} +BIN_PATH="$(swift build --package-path "$ROOT" -c release ${ARCH_FLAGS[@]+"${ARCH_FLAGS[@]}"} --show-bin-path)" + +echo "Assembling $APP_NAME.app…" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +cp "$BIN_PATH/iPodSyncApp" "$APP/Contents/MacOS/$APP_NAME" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName + $APP_NAME + CFBundleDisplayName + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundlePackageType + APPL + CFBundleExecutable + $APP_NAME + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + $MIN_MACOS + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSContactsUsageDescription + iPod Contacts and Calendar Sync reads your contacts so it can write one iPod-compatible vCard file per person. + NSCalendarsFullAccessUsageDescription + iPod Contacts and Calendar Sync reads your calendars so it can write iPod-compatible calendar files. + NSCalendarsUsageDescription + iPod Contacts and Calendar Sync reads your calendars so it can write iPod-compatible calendar files. + +PLIST +echo "" >> "$APP/Contents/Info.plist" + +# Ad-hoc signature. Contacts and Calendars identify an app by its code +# signature, so an unsigned bundle either re-prompts constantly or is denied +# outright. This is not a Developer ID signature — Gatekeeper still requires +# right-click → Open the first time. +echo "Signing (ad-hoc)…" +codesign --force --sign - --timestamp=none "$APP" >/dev/null 2>&1 + +echo "Built $APP" diff --git a/Sources/IPodSyncKit/CalendarReader.swift b/Sources/IPodSyncKit/CalendarReader.swift new file mode 100644 index 0000000..2266055 --- /dev/null +++ b/Sources/IPodSyncKit/CalendarReader.swift @@ -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" + } +} diff --git a/Sources/IPodSyncKit/ContactsReader.swift b/Sources/IPodSyncKit/ContactsReader.swift new file mode 100644 index 0000000..ecf3826 --- /dev/null +++ b/Sources/IPodSyncKit/ContactsReader.swift @@ -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: "" + } + } +} diff --git a/Sources/IPodSyncKit/ContentLine.swift b/Sources/IPodSyncKit/ContentLine.swift new file mode 100644 index 0000000..e2e4ea7 --- /dev/null +++ b/Sources/IPodSyncKit/ContentLine.swift @@ -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.. [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" + } +} diff --git a/Sources/IPodSyncKit/ExportSession.swift b/Sources/IPodSyncKit/ExportSession.swift new file mode 100644 index 0000000..1253d0f --- /dev/null +++ b/Sources/IPodSyncKit/ExportSession.swift @@ -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 ") + "." + } +} diff --git a/Sources/IPodSyncKit/FileNaming.swift b/Sources/IPodSyncKit/FileNaming.swift new file mode 100644 index 0000000..9cac18a --- /dev/null +++ b/Sources/IPodSyncKit/FileNaming.swift @@ -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 = [] + + 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) + } +} diff --git a/Sources/IPodSyncKit/ICalendarFileSanitizer.swift b/Sources/IPodSyncKit/ICalendarFileSanitizer.swift new file mode 100644 index 0000000..fdcbb90 --- /dev/null +++ b/Sources/IPodSyncKit/ICalendarFileSanitizer.swift @@ -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 = ["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 = [ + "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) + } +} diff --git a/Sources/IPodSyncKit/ICalendarWriter.swift b/Sources/IPodSyncKit/ICalendarWriter.swift new file mode 100644 index 0000000..bfb0507 --- /dev/null +++ b/Sources/IPodSyncKit/ICalendarWriter.swift @@ -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 + } +} diff --git a/Sources/IPodSyncKit/Models.swift b/Sources/IPodSyncKit/Models.swift new file mode 100644 index 0000000..cd94b0f --- /dev/null +++ b/Sources/IPodSyncKit/Models.swift @@ -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 + } +} diff --git a/Sources/IPodSyncKit/PropertyLine.swift b/Sources/IPodSyncKit/PropertyLine.swift new file mode 100644 index 0000000..568cf7f --- /dev/null +++ b/Sources/IPodSyncKit/PropertyLine.swift @@ -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.. 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 + } +} diff --git a/Sources/IPodSyncKit/QuotedPrintable.swift b/Sources/IPodSyncKit/QuotedPrintable.swift new file mode 100644 index 0000000..a8cff35 --- /dev/null +++ b/Sources/IPodSyncKit/QuotedPrintable.swift @@ -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 + } + } +} diff --git a/Sources/IPodSyncKit/TextFile.swift b/Sources/IPodSyncKit/TextFile.swift new file mode 100644 index 0000000..508b894 --- /dev/null +++ b/Sources/IPodSyncKit/TextFile.swift @@ -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 + } +} diff --git a/Sources/IPodSyncKit/VCardFileSanitizer.swift b/Sources/IPodSyncKit/VCardFileSanitizer.swift new file mode 100644 index 0000000..c6eb30c --- /dev/null +++ b/Sources/IPodSyncKit/VCardFileSanitizer.swift @@ -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 = ["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" + } +} diff --git a/Sources/IPodSyncKit/VCardWriter.swift b/Sources/IPodSyncKit/VCardWriter.swift new file mode 100644 index 0000000..ba01ca2 --- /dev/null +++ b/Sources/IPodSyncKit/VCardWriter.swift @@ -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) + } +} diff --git a/Sources/iPodSyncApp/ExportModel.swift b/Sources/iPodSyncApp/ExportModel.swift new file mode 100644 index 0000000..1e4d959 --- /dev/null +++ b/Sources/iPodSyncApp/ExportModel.swift @@ -0,0 +1,195 @@ +import Foundation +import IPodSyncKit +import Observation + +@MainActor +@Observable +final class ExportModel { + enum Phase: Equatable { + case idle + case working(String) + case finished + } + + // What to include + var includeContacts = true + var includeCalendars = true + var includeFiles = false + + // Access + var contactsGranted = ContactsReader.authorizationStatus == .authorized + var calendarsGranted = CalendarReader.authorizationStatus == .fullAccess + + // Calendars + var calendars: [CalendarSource] = [] + var selectedCalendarIDs: Set = [] + var rangeStart: Date + var rangeEnd: Date + + // Existing files + var inputFiles: [URL] = [] + + // Output + var destination: URL? + + // Results + var phase: Phase = .idle + var summary = "" + var warnings: [String] = [] + var writtenFiles: [ExportedFile] = [] + var errorMessage: String? + + private let contactsReader = ContactsReader() + private let calendarReader = CalendarReader() + + init() { + // A window wide enough to cover what you'd actually browse to on the + // device, without dragging a decade of history across. + let calendar = Calendar.current + let now = Date() + rangeStart = calendar.date(byAdding: .month, value: -1, to: now) ?? now + rangeEnd = calendar.date(byAdding: .year, value: 1, to: now) ?? now + } + + var isWorking: Bool { + if case .working = phase { return true } + return false + } + + var canExport: Bool { + guard destination != nil, !isWorking else { return false } + if includeContacts && contactsGranted { return true } + if includeCalendars && calendarsGranted && !selectedCalendarIDs.isEmpty { return true } + if includeFiles && !inputFiles.isEmpty { return true } + return false + } + + // MARK: - Access + + func requestContactsAccess() async { + contactsGranted = await contactsReader.requestAccess() + if !contactsGranted { + errorMessage = "Contacts access was denied. Grant it in System Settings › " + + "Privacy & Security › Contacts, then reopen the app." + } + } + + func requestCalendarAccess() async { + calendarsGranted = await calendarReader.requestAccess() + if calendarsGranted { + await loadCalendars() + } else { + errorMessage = "Calendar access was denied. Grant it in System Settings › " + + "Privacy & Security › Calendars, then reopen the app." + } + } + + func loadCalendars() async { + guard calendarsGranted else { return } + calendars = await calendarReader.calendars() + // Everything is selected by default; unchecking is the rarer action. + if selectedCalendarIDs.isEmpty { + selectedCalendarIDs = Set(calendars.map(\.id)) + } + } + + func refreshAccessOnAppear() async { + contactsGranted = ContactsReader.authorizationStatus == .authorized + calendarsGranted = CalendarReader.authorizationStatus == .fullAccess + await loadCalendars() + } + + func isSelected(_ calendar: CalendarSource) -> Bool { + selectedCalendarIDs.contains(calendar.id) + } + + func setSelected(_ calendar: CalendarSource, _ selected: Bool) { + if selected { + selectedCalendarIDs.insert(calendar.id) + } else { + selectedCalendarIDs.remove(calendar.id) + } + } + + func addInputFiles(_ urls: [URL]) { + let supported = urls.filter { + ["vcf", "ics"].contains($0.pathExtension.lowercased()) + } + for url in supported where !inputFiles.contains(url) { + inputFiles.append(url) + } + if supported.count < urls.count { + errorMessage = "Only .vcf and .ics files can be sanitized; the rest were ignored." + } + } + + // MARK: - Export + + func export() async { + guard let destination else { return } + + phase = .working("Preparing…") + summary = "" + warnings = [] + writtenFiles = [] + errorMessage = nil + + var session = ExportSession(destination: destination) + + do { + if includeContacts && contactsGranted { + phase = .working("Reading contacts…") + let contacts = try await contactsReader.fetchContacts() + phase = .working("Writing \(contacts.count) contacts…") + try session.write(contacts: contacts) + } + + if includeCalendars && calendarsGranted { + // One file per calendar, so a bad calendar can't take the + // others down with it on the device. + for calendar in calendars where selectedCalendarIDs.contains(calendar.id) { + phase = .working("Reading “\(calendar.title)”…") + let events = await calendarReader.events( + calendarIdentifiers: [calendar.id], + from: rangeStart, + to: rangeEnd + ) + if events.isEmpty { + session.noteWarning( + "“\(calendar.title)” had no events in the selected dates." + ) + continue + } + try session.write(events: events, calendarName: calendar.title) + } + } + + if includeFiles { + for url in inputFiles { + phase = .working("Sanitizing \(url.lastPathComponent)…") + switch url.pathExtension.lowercased() { + case "vcf": + let result = try VCardFileSanitizer.sanitize(contentsOf: url) + try session.write(sanitized: result, as: .contacts) + case "ics": + let result = try ICalendarFileSanitizer.sanitize(contentsOf: url) + try session.write(sanitized: result, as: .calendars) + default: + continue + } + } + } + + summary = session.summary + warnings = session.warnings + writtenFiles = session.writtenFiles + phase = .finished + } catch { + errorMessage = error.localizedDescription + summary = session.summary + warnings = session.warnings + writtenFiles = session.writtenFiles + phase = .finished + } + } +} diff --git a/Sources/iPodSyncApp/ExportView.swift b/Sources/iPodSyncApp/ExportView.swift new file mode 100644 index 0000000..5581355 --- /dev/null +++ b/Sources/iPodSyncApp/ExportView.swift @@ -0,0 +1,227 @@ +import AppKit +import IPodSyncKit +import SwiftUI +import UniformTypeIdentifiers + +struct ExportView: View { + @State private var model = ExportModel() + @State private var isChoosingFiles = false + @State private var isChoosingDestination = false + + var body: some View { + Form { + Section { + Text( + "Prepares contacts and calendars for a click-wheel iPod, which reads " + + "dropped .vcf and .ics files but rejects the timezone, alarm and " + + "embedded-image data a normal export contains." + ) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + contactsSection + calendarsSection + filesSection + destinationSection + resultsSection + } + .formStyle(.grouped) + .frame(minWidth: 520, idealWidth: 580, minHeight: 560) + .safeAreaInset(edge: .bottom) { footer } + .task { await model.refreshAccessOnAppear() } + .fileImporter( + isPresented: $isChoosingFiles, + allowedContentTypes: Self.importableTypes, + allowsMultipleSelection: true + ) { result in + if case .success(let urls) = result { model.addInputFiles(urls) } + } + .fileImporter( + isPresented: $isChoosingDestination, + allowedContentTypes: [.folder] + ) { result in + if case .success(let url) = result { model.destination = url } + } + .alert( + "Something went wrong", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + ) + ) { + Button("OK") { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "") + } + } + + private static var importableTypes: [UTType] { + [.vCard, UTType(filenameExtension: "ics") ?? .calendarEvent] + } + + // MARK: - Sections + + private var contactsSection: some View { + Section("Contacts") { + Toggle("Export contacts", isOn: $model.includeContacts) + + if model.includeContacts { + if model.contactsGranted { + Label("Access granted — one .vcf per contact", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + .font(.callout) + } else { + HStack { + Text("Contacts access is needed to read your address book.") + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + Button("Grant Access…") { + Task { await model.requestContactsAccess() } + } + } + } + } + } + } + + private var calendarsSection: some View { + Section("Calendars") { + Toggle("Export calendars", isOn: $model.includeCalendars) + + if model.includeCalendars { + if model.calendarsGranted { + DatePicker("From", selection: $model.rangeStart, displayedComponents: .date) + DatePicker("To", selection: $model.rangeEnd, displayedComponents: .date) + + if model.calendars.isEmpty { + Text("No calendars found.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(model.calendars) { calendar in + Toggle(isOn: Binding( + get: { model.isSelected(calendar) }, + set: { model.setSelected(calendar, $0) } + )) { + VStack(alignment: .leading, spacing: 1) { + Text(calendar.title) + if !calendar.accountName.isEmpty { + Text(calendar.accountName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + Text("Repeating events are written out one occurrence at a time, so the " + + "iPod never has to interpret a recurrence rule.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + HStack { + Text("Calendar access is needed to read your events.") + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + Button("Grant Access…") { + Task { await model.requestCalendarAccess() } + } + } + } + } + } + } + + private var filesSection: some View { + Section("Existing files") { + Toggle("Sanitize .vcf / .ics files I already have", isOn: $model.includeFiles) + + if model.includeFiles { + HStack { + Text(model.inputFiles.isEmpty + ? "No files chosen." + : "\(model.inputFiles.count) file(s) chosen.") + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + if !model.inputFiles.isEmpty { + Button("Clear") { model.inputFiles.removeAll() } + } + Button("Choose Files…") { isChoosingFiles = true } + } + + ForEach(model.inputFiles, id: \.self) { url in + Text(url.lastPathComponent) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + } + + private var destinationSection: some View { + Section("Output folder") { + HStack { + Text(model.destination?.path(percentEncoded: false) ?? "Not chosen.") + .font(.callout) + .foregroundStyle(model.destination == nil ? .secondary : .primary) + .lineLimit(2) + .truncationMode(.middle) + Spacer() + Button("Choose…") { isChoosingDestination = true } + } + + Text("Files are written into Contacts and Calendars subfolders, matching the " + + "folders on the iPod, so you can drag them straight across in Finder.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var resultsSection: some View { + if model.phase == .finished { + Section("Result") { + Text(model.summary) + + ForEach(Array(model.warnings.enumerated()), id: \.offset) { _, warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let first = model.writtenFiles.first { + Button("Reveal in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([first.path]) + } + } + } + } + } + + private var footer: some View { + HStack { + if case .working(let message) = model.phase { + ProgressView().controlSize(.small) + Text(message).font(.callout).foregroundStyle(.secondary) + } + Spacer() + Button("Export") { + Task { await model.export() } + } + .keyboardShortcut(.defaultAction) + .disabled(!model.canExport) + } + .padding(.horizontal) + .padding(.vertical, 10) + .background(.bar) + } +} diff --git a/Sources/iPodSyncApp/iPodSyncApp.swift b/Sources/iPodSyncApp/iPodSyncApp.swift new file mode 100644 index 0000000..cc76ed3 --- /dev/null +++ b/Sources/iPodSyncApp/iPodSyncApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct IPodSyncApp: App { + var body: some Scene { + Window("iPod Contacts and Calendar Sync", id: "main") { + ExportView() + } + .windowResizability(.contentMinSize) + .commands { + CommandGroup(replacing: .newItem) {} + } + } +} diff --git a/Tests/IPodSyncKitTests/ContentLineTests.swift b/Tests/IPodSyncKitTests/ContentLineTests.swift new file mode 100644 index 0000000..40ec236 --- /dev/null +++ b/Tests/IPodSyncKitTests/ContentLineTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing + +@testable import IPodSyncKit + +@Suite("Content line folding and escaping") +struct ContentLineTests { + @Test("Short lines are left alone") + func shortLineUnchanged() { + #expect(ContentLine.fold("FN:Ada Lovelace") == "FN:Ada Lovelace") + } + + @Test("Long lines fold to CRLF + space, within the octet limit") + func longLineFolds() { + let line = "NOTE:" + String(repeating: "a", count: 300) + let folded = ContentLine.fold(line) + + #expect(folded.contains("\r\n ")) + + for piece in folded.components(separatedBy: "\r\n") { + #expect(piece.utf8.count <= ContentLine.octetLimit) + } + // Unfolding is lossless. + #expect(ContentLine.unfold(folded) == [line]) + } + + @Test("Folding never splits a multi-byte character") + func foldingRespectsUTF8Boundaries() { + // Four-byte scalars, so a naive byte split lands mid-character. + let line = "NOTE:" + String(repeating: "🎧", count: 60) + let folded = ContentLine.fold(line) + + for piece in folded.components(separatedBy: "\r\n") { + #expect(piece.utf8.count <= ContentLine.octetLimit) + // A replacement character would mean we cut a scalar in half. + #expect(!piece.contains("\u{FFFD}")) + } + #expect(ContentLine.unfold(folded) == [line]) + } + + @Test("A single character wider than the budget is emitted rather than looping") + func oversizedCharacterTerminates() { + let line = String(repeating: "🎧", count: 2) + #expect(ContentLine.fold(line) == line) + } + + @Test("TEXT values escape backslash, semicolon, comma and newline") + func escaping() { + #expect(ContentLine.escapeText("a;b,c\\d") == "a\\;b\\,c\\\\d") + #expect(ContentLine.escapeText("line1\nline2") == "line1\\nline2") + #expect(ContentLine.escapeText("crlf\r\nhere") == "crlf\\nhere") + } + + @Test("Unfolding accepts CRLF, LF and bare CR") + func unfoldingLineEndings() { + #expect(ContentLine.unfold("A:1\r\nB:2") == ["A:1", "B:2"]) + #expect(ContentLine.unfold("A:1\nB:2") == ["A:1", "B:2"]) + #expect(ContentLine.unfold("A:1\rB:2") == ["A:1", "B:2"]) + #expect(ContentLine.unfold("A:12\r\n 34") == ["A:1234"]) + #expect(ContentLine.unfold("A:12\r\n\t34") == ["A:1234"]) + } + + @Test("Rendered documents end with CRLF") + func builderRendersCRLF() { + var builder = ContentLineBuilder() + builder.appendVerbatim("BEGIN", "VCARD") + builder.appendVerbatim("END", "VCARD") + + #expect(builder.render() == "BEGIN:VCARD\r\nEND:VCARD\r\n") + } +} + +@Suite("Filename allocation") +struct FileNamingTests { + @Test("Accents fold to ASCII and unsafe characters are dropped") + func sanitizing() { + var allocator = FileNameAllocator() + #expect(allocator.allocate(preferred: "Renée Fleming", fileExtension: "vcf") + == "Renee Fleming.vcf") + + var other = FileNameAllocator() + #expect(other.allocate(preferred: "A/B:C*D", fileExtension: "vcf") == "A B C D.vcf") + } + + @Test("Empty and dot-only names fall back") + func fallbacks() { + var allocator = FileNameAllocator() + #expect(allocator.allocate(preferred: "", fileExtension: "vcf") == "item.vcf") + #expect(allocator.allocate(preferred: "...", fallback: "contact", fileExtension: "vcf") + == "contact.vcf") + } + + @Test("Repeated names are numbered") + func deduplication() { + var allocator = FileNameAllocator() + #expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith.vcf") + #expect(allocator.allocate(preferred: "John Smith", fileExtension: "vcf") == "John Smith_2.vcf") + // Matching is case-insensitive, since the device's volume is usually FAT32. + #expect(allocator.allocate(preferred: "john smith", fileExtension: "vcf") == "john smith_3.vcf") + } + + @Test("Names are capped, including their numbering suffix") + func lengthCap() { + var allocator = FileNameAllocator() + let long = String(repeating: "x", count: 200) + + let first = allocator.allocate(preferred: long, fileExtension: "vcf") + let second = allocator.allocate(preferred: long, fileExtension: "vcf") + + #expect(first.dropLast(4).count == FileNameAllocator.maximumLength) + #expect(second.dropLast(4).count <= FileNameAllocator.maximumLength) + #expect(second != first) + } +} diff --git a/Tests/IPodSyncKitTests/SanitizerTests.swift b/Tests/IPodSyncKitTests/SanitizerTests.swift new file mode 100644 index 0000000..e0f0c50 --- /dev/null +++ b/Tests/IPodSyncKitTests/SanitizerTests.swift @@ -0,0 +1,306 @@ +import Foundation +import Testing + +@testable import IPodSyncKit + +@Suite("Sanitizing existing .vcf files") +struct VCardFileSanitizerTests { + /// Mixed 2.1/3.0, an embedded photo, a CRM extension, an Apple property + /// group, and a quoted-printable name — i.e. what a real export looks like. + private let sample = """ + BEGIN:VCARD + VERSION:3.0 + N:Lovelace;Ada;;; + FN:Ada Lovelace + TEL;TYPE=CELL:+15550100 + PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD + X-CRM-ID:12345 + item1.EMAIL;TYPE=INTERNET:ada@example.com + item1.X-ABLabel:_$!!$_ + END:VCARD + BEGIN:VCARD + VERSION:2.1 + N;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Fleming;Ren=C3=A9e;;; + FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Ren=C3=A9e Fleming + TEL;HOME;VOICE:+15550111 + END:VCARD + """ + + @Test("Each card becomes its own file") + func splitsCards() { + let result = VCardFileSanitizer.sanitize(text: sample) + + #expect(result.files.count == 2) + #expect(result.files[0].suggestedName == "Ada Lovelace") + #expect(result.files.allSatisfy { $0.contents.hasPrefix("BEGIN:VCARD\r\n") }) + #expect(result.files.allSatisfy { $0.contents.hasSuffix("END:VCARD\r\n") }) + } + + @Test("Binary payloads and X- extensions are removed") + func stripsProblemProperties() { + let contents = VCardFileSanitizer.sanitize(text: sample).files[0].contents + + #expect(!contents.contains("PHOTO")) + #expect(!contents.contains("X-CRM-ID")) + #expect(!contents.contains("X-ABLabel")) + #expect(contents.contains("TEL;TYPE=CELL:+15550100")) + } + + @Test("Apple's property groups are unwrapped") + func removesGroupPrefixes() { + let contents = VCardFileSanitizer.sanitize(text: sample).files[0].contents + + #expect(contents.contains("EMAIL;TYPE=INTERNET:ada@example.com")) + #expect(!contents.contains("item1.")) + } + + @Test("Quoted-printable is decoded, and the filename uses the decoded name") + func decodesQuotedPrintable() { + let file = VCardFileSanitizer.sanitize(text: sample).files[1] + + #expect(file.contents.contains("FN:Renée Fleming")) + #expect(!file.contents.contains("QUOTED-PRINTABLE")) + #expect(!file.contents.contains("CHARSET")) + // The bug worth guarding: naming from the raw field yields "Ren=C3=A9e". + #expect(file.suggestedName == "Renée Fleming") + } + + @Test("Soft line breaks in quoted-printable values are rejoined") + func rejoinsSoftLineBreaks() { + let text = """ + BEGIN:VCARD + VERSION:2.1 + FN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Ren= + =C3=A9e Fleming + END:VCARD + """ + let file = VCardFileSanitizer.sanitize(text: text).files[0] + + #expect(file.contents.contains("FN:Renée Fleming")) + } + + @Test("Cards without END:VCARD are reported, not silently written") + func unterminatedCard() { + let result = VCardFileSanitizer.sanitize(text: "BEGIN:VCARD\nFN:Broken") + + #expect(result.files.isEmpty) + #expect(result.warnings.contains { $0.contains("END:VCARD") }) + } + + @Test("A file with no cards is flagged") + func notAVCard() { + let result = VCardFileSanitizer.sanitize(text: "hello, world") + + #expect(result.files.isEmpty) + #expect(result.warnings.contains { $0.contains("No vCards") }) + } + + @Test("Names fall back from FN to N to ORG") + func nameFallbacks() { + #expect(VCardFileSanitizer.displayName(in: ["N:Smith;John;;;"]) == "John Smith") + #expect(VCardFileSanitizer.displayName(in: ["ORG:Acme;Sales"]) == "Acme") + #expect(VCardFileSanitizer.displayName(in: ["TEL:123"]) == "contact") + } +} + +@Suite("Sanitizing existing .ics files") +struct ICalendarFileSanitizerTests { + private let zone = TimeZone(identifier: "America/Toronto")! + + private let sample = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Apple Inc.//macOS 26.6//EN + CALSCALE:GREGORIAN + BEGIN:VTIMEZONE + TZID:America/Toronto + BEGIN:DAYLIGHT + TZOFFSETFROM:-0500 + TZNAME:EDT + END:DAYLIGHT + BEGIN:STANDARD + TZOFFSETFROM:-0400 + TZNAME:EST + END:STANDARD + END:VTIMEZONE + BEGIN:VEVENT + UID:1234@example.com + DTSTAMP:20260801T120000Z + DTSTART;TZID=America/Toronto:20260810T093000 + DTEND;TZID=America/Toronto:20260810T100000 + SUMMARY:Standup + X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC + BEGIN:VALARM + ACTION:DISPLAY + TRIGGER:-PT15M + END:VALARM + END:VEVENT + END:VCALENDAR + """ + + @Test("Timezone blocks, alarms and X- properties are removed") + func stripsProblemComponents() { + let contents = ICalendarFileSanitizer + .sanitize(text: sample, name: "Work", timeZone: zone) + .files[0].contents + + #expect(!contents.contains("VTIMEZONE")) + #expect(!contents.contains("DAYLIGHT")) + #expect(!contents.contains("STANDARD")) + #expect(!contents.contains("VALARM")) + #expect(!contents.contains("TRIGGER")) + #expect(!contents.contains("X-APPLE")) + } + + @Test("TZID is dropped, leaving the same wall-clock reading as a floating time") + func stripsTZID() { + let contents = ICalendarFileSanitizer + .sanitize(text: sample, name: "Work", timeZone: zone) + .files[0].contents + + #expect(contents.contains("DTSTART:20260810T093000")) + #expect(contents.contains("DTEND:20260810T100000")) + #expect(!contents.contains("TZID")) + } + + @Test("The surrounding calendar structure survives") + func keepsStructure() { + let contents = ICalendarFileSanitizer + .sanitize(text: sample, name: "Work", timeZone: zone) + .files[0].contents + + #expect(contents.hasPrefix("BEGIN:VCALENDAR\r\n")) + #expect(contents.hasSuffix("END:VCALENDAR\r\n")) + #expect(contents.contains("BEGIN:VEVENT")) + #expect(contents.contains("SUMMARY:Standup")) + #expect(contents.contains("UID:1234@example.com")) + } + + @Test("UTC start times become local floating times") + func convertsUTCToFloating() { + let text = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + UID:1 + DTSTART:20260810T133000Z + DTEND:20260810T140000Z + SUMMARY:Call + END:VEVENT + END:VCALENDAR + """ + let contents = ICalendarFileSanitizer + .sanitize(text: text, name: "Work", timeZone: zone) + .files[0].contents + + // 13:30 UTC is 09:30 in Toronto on that date. + #expect(contents.contains("DTSTART:20260810T093000")) + #expect(contents.contains("DTEND:20260810T100000")) + } + + @Test("DTSTAMP stays in UTC, since it is bookkeeping the device never shows") + func leavesDTSTAMP() { + let contents = ICalendarFileSanitizer + .sanitize(text: sample, name: "Work", timeZone: zone) + .files[0].contents + + #expect(contents.contains("DTSTAMP:20260801T120000Z")) + } + + @Test("Recurring events are kept but flagged") + func flagsRecurrence() { + let text = sample.replacingOccurrences( + of: "SUMMARY:Standup", + with: "SUMMARY:Standup\nRRULE:FREQ=WEEKLY;BYDAY=MO" + ) + let result = ICalendarFileSanitizer.sanitize(text: text, name: "Work", timeZone: zone) + + #expect(result.files[0].contents.contains("RRULE:FREQ=WEEKLY")) + #expect(result.warnings.contains { $0.contains("RRULE") }) + } + + @Test("A file with no events is flagged") + func notACalendar() { + let result = ICalendarFileSanitizer.sanitize(text: "hello", name: "x", timeZone: zone) + + #expect(result.warnings.contains { $0.contains("No events") }) + } +} + +@Suite("Quoted-printable decoding") +struct QuotedPrintableTests { + @Test("Decodes UTF-8 escapes") + func utf8() { + #expect(QuotedPrintable.decode("Jos=C3=A9") == "José") + #expect(QuotedPrintable.decode("plain text") == "plain text") + } + + @Test("Falls back for bytes that are not valid UTF-8") + func latin1Fallback() { + // 0xE9 is é in Latin-1 but an invalid UTF-8 sequence on its own. + #expect(QuotedPrintable.decode("Jos=E9", charset: "ISO-8859-1") == "José") + } + + @Test("Leaves malformed escapes alone") + func malformed() { + #expect(QuotedPrintable.decode("100=% sure") == "100=% sure") + } +} + +@Suite("Writing an export") +struct ExportSessionTests { + private func temporaryDirectory() throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ipod-sync-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test("Contacts and calendars land in folders matching the device") + func folderLayout() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + var session = ExportSession(destination: directory) + try session.write(contacts: [IPodContact(givenName: "Ada", familyName: "Lovelace")]) + try session.write( + events: [IPodEvent(uid: "a", summary: "Standup", start: .now, end: .now.addingTimeInterval(1800))], + calendarName: "Work" + ) + + let contactFile = directory.appendingPathComponent("Contacts/Ada Lovelace.vcf") + let calendarFile = directory.appendingPathComponent("Calendars/Work.ics") + + #expect(FileManager.default.fileExists(atPath: contactFile.path)) + #expect(FileManager.default.fileExists(atPath: calendarFile.path)) + #expect(session.writtenFiles.count == 2) + #expect(session.summary == "Wrote 1 contact file and 1 calendar file.") + } + + @Test("Contacts with nothing on them are skipped and reported") + func skipsEmptyContacts() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + var session = ExportSession(destination: directory) + try session.write(contacts: [IPodContact(), IPodContact(givenName: "Ada")]) + + #expect(session.writtenFiles.count == 1) + #expect(session.warnings.contains { $0.contains("skipped") }) + } + + @Test("Two contacts with the same name do not overwrite each other") + func collidingNames() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + var session = ExportSession(destination: directory) + try session.write(contacts: [ + IPodContact(givenName: "John", familyName: "Smith", phoneNumbers: [LabeledValue(label: "CELL", value: "1")]), + IPodContact(givenName: "John", familyName: "Smith", phoneNumbers: [LabeledValue(label: "CELL", value: "2")]), + ]) + + #expect(session.writtenFiles.count == 2) + let names = Set(session.writtenFiles.map(\.path.lastPathComponent)) + #expect(names == ["John Smith.vcf", "John Smith_2.vcf"]) + } +} diff --git a/Tests/IPodSyncKitTests/WriterTests.swift b/Tests/IPodSyncKitTests/WriterTests.swift new file mode 100644 index 0000000..a66e1f0 --- /dev/null +++ b/Tests/IPodSyncKitTests/WriterTests.swift @@ -0,0 +1,179 @@ +import Foundation +import Testing + +@testable import IPodSyncKit + +@Suite("vCard writing") +struct VCardWriterTests { + private func makeContact() -> IPodContact { + IPodContact( + id: "1", + givenName: "Ada", + familyName: "Lovelace", + organization: "Analytical Engines, Ltd.", + jobTitle: "Mathematician", + phoneNumbers: [LabeledValue(label: "CELL", value: "+1 555 0100")], + emailAddresses: [LabeledValue(label: "WORK", value: "ada@example.com")], + postalAddresses: [ + PostalAddress( + label: "HOME", + street: "12 Ockham Road", + city: "London", + postalCode: "SW1", + country: "UK" + ) + ], + birthday: DateComponents(year: 1815, month: 12, day: 10) + ) + } + + @Test("Emits a well-formed vCard 3.0") + func shape() { + let vcard = VCardWriter.makeVCard(for: makeContact()) + let lines = vcard.components(separatedBy: "\r\n") + + #expect(lines.first == "BEGIN:VCARD") + #expect(lines.contains("VERSION:3.0")) + #expect(lines.contains("N:Lovelace;Ada;;;")) + #expect(lines.contains("FN:Ada Lovelace")) + #expect(lines.contains("TEL;TYPE=CELL:+1 555 0100")) + #expect(lines.contains("EMAIL;TYPE=INTERNET,WORK:ada@example.com")) + #expect(lines.contains("ADR;TYPE=HOME:;;12 Ockham Road;London;;SW1;UK")) + #expect(lines.contains("BDAY:1815-12-10")) + #expect(vcard.hasSuffix("END:VCARD\r\n")) + } + + @Test("Never emits the properties that break the device") + func omitsProblemProperties() { + let vcard = VCardWriter.makeVCard(for: makeContact()).uppercased() + + #expect(!vcard.contains("PHOTO")) + #expect(!vcard.contains("LOGO")) + #expect(!vcard.contains("SOUND")) + #expect(!vcard.contains("\r\nX-")) + #expect(!vcard.contains("QUOTED-PRINTABLE")) + } + + @Test("Commas and semicolons in values are escaped, not treated as structure") + func escapesValues() { + let vcard = VCardWriter.makeVCard(for: makeContact()) + #expect(vcard.contains("ORG:Analytical Engines\\, Ltd.")) + } + + @Test("Falls back to organization, then email, for the display name") + func displayNameFallbacks() { + let company = IPodContact(organization: "Acme") + #expect(company.displayName == "Acme") + + let emailOnly = IPodContact( + emailAddresses: [LabeledValue(label: "", value: "someone@example.com")] + ) + #expect(emailOnly.displayName == "someone@example.com") + #expect(VCardWriter.makeVCard(for: emailOnly).contains("EMAIL;TYPE=INTERNET:")) + } + + @Test("Year-less birthdays are dropped rather than invented") + func yearlessBirthday() { + #expect(VCardWriter.formatBirthday(DateComponents(month: 5, day: 12)) == nil) + #expect(VCardWriter.formatBirthday(DateComponents(year: 1990, month: 5, day: 12)) + == "1990-05-12") + } +} + +@Suite("iCalendar writing") +struct ICalendarWriterTests { + private let zone = TimeZone(identifier: "America/Toronto")! + + private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0) + -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + return calendar.date(from: DateComponents( + year: year, month: month, day: day, hour: hour, minute: minute + ))! + } + + @Test("Times are floating — no Z, no TZID, no VTIMEZONE") + func floatingTimes() { + let event = IPodEvent( + uid: "abc@ipod", + summary: "Standup", + start: date(2026, 8, 10, 9, 30), + end: date(2026, 8, 10, 10, 0) + ) + let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone) + + #expect(ics.contains("DTSTART:20260810T093000")) + #expect(ics.contains("DTEND:20260810T100000")) + #expect(!ics.contains("TZID")) + #expect(!ics.contains("VTIMEZONE")) + #expect(!ics.contains("DTSTART:20260810T093000Z")) + } + + @Test("Alarms and recurrence rules are structurally absent") + func omitsProblemComponents() { + let ics = ICalendarWriter.makeCalendar( + events: [IPodEvent(uid: "a", summary: "x", start: date(2026, 8, 10), end: date(2026, 8, 11))], + timeZone: zone + ) + + #expect(!ics.contains("VALARM")) + #expect(!ics.contains("RRULE")) + #expect(!ics.contains("\r\nX-")) + } + + @Test("All-day events use exclusive DATE ends") + func allDayEnds() { + // EventKit reports a one-day all-day event as ending at 23:59:59. + let oneDay = IPodEvent( + uid: "a", + summary: "Holiday", + start: date(2026, 8, 10), + end: date(2026, 8, 10, 23, 59), + isAllDay: true + ) + let ics = ICalendarWriter.makeCalendar(events: [oneDay], timeZone: zone) + + #expect(ics.contains("DTSTART;VALUE=DATE:20260810")) + #expect(ics.contains("DTEND;VALUE=DATE:20260811")) + } + + @Test("An all-day event already ending at midnight is not pushed a day further") + func allDayMidnightEnd() { + let event = IPodEvent( + uid: "a", + summary: "Holiday", + start: date(2026, 8, 10), + end: date(2026, 8, 11), + isAllDay: true + ) + let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone) + + #expect(ics.contains("DTEND;VALUE=DATE:20260811")) + } + + @Test("Zero-length events omit DTEND rather than emit an invalid range") + func zeroLengthEvent() { + let event = IPodEvent( + uid: "a", + summary: "Marker", + start: date(2026, 8, 10, 9, 0), + end: date(2026, 8, 10, 9, 0) + ) + let ics = ICalendarWriter.makeCalendar(events: [event], timeZone: zone) + + #expect(ics.contains("DTSTART:20260810T090000")) + #expect(!ics.contains("DTEND")) + } + + @Test("Events are sorted and titles always present") + func sortingAndTitles() { + let later = IPodEvent(uid: "b", summary: "Later", start: date(2026, 8, 11), end: date(2026, 8, 11, 1)) + let earlier = IPodEvent(uid: "a", summary: "", start: date(2026, 8, 10), end: date(2026, 8, 10, 1)) + let ics = ICalendarWriter.makeCalendar(events: [later, earlier], timeZone: zone) + + let firstSummary = ics.range(of: "SUMMARY:")! + #expect(ics[firstSummary.upperBound...].hasPrefix("(No title)")) + #expect(ics.contains("SUMMARY:Later")) + } +}