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,5 @@
|
||||
.build/
|
||||
build/
|
||||
.swiftpm/
|
||||
.DS_Store
|
||||
*.xcuserdatad
|
||||
@@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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.
|
||||
Executable
+84
@@ -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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$VERSION</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$MIN_MACOS</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<true/>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>iPod Contacts and Calendar Sync reads your contacts so it can write one iPod-compatible vCard file per person.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>iPod Contacts and Calendar Sync reads your calendars so it can write iPod-compatible calendar files.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>iPod Contacts and Calendar Sync reads your calendars so it can write iPod-compatible calendar files.</string>
|
||||
</dict>
|
||||
PLIST
|
||||
echo "</plist>" >> "$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"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:_$!<Work>!$_
|
||||
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:[email protected]"))
|
||||
#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:[email protected]"))
|
||||
}
|
||||
|
||||
@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"])
|
||||
}
|
||||
}
|
||||
@@ -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: "[email protected]")],
|
||||
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:[email protected]"))
|
||||
#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: "[email protected]")]
|
||||
)
|
||||
#expect(emailOnly.displayName == "[email protected]")
|
||||
#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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user