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:[email protected]
|
||||||
|
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:[email protected]
|
||||||
|
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