macsync/scripts/restore-contacts.swift

95 lines
2.8 KiB
Swift

#!/usr/bin/env swift
// restore-contacts.swift
//
// Restore the CNContactStore from a full .vcf backup produced by ContactsBackup.swift.
//
// Usage: swift scripts/restore-contacts.swift <path-to-full-*.vcf>
//
// The script asks for Contacts access on first invocation grant it through the
// prompt, or pre-grant Terminal (or your shell host) under Privacy & Security
// Contacts. The store is NOT wiped prior to import; new identifiers are issued
// for all restored contacts. To do a clean restore after a bad run, first delete
// contacts whose familyName matches the lilith-sync emoji-only regex via Contacts.app
// before invoking this, or use the per-row rollback JSONL instead.
import Contacts
import Foundation
enum RestoreError: Error {
case missingArgument
case readFailed(Error)
case deserializationFailed(Error)
case accessDenied
case saveFailed(Error)
}
@MainActor
func main() async throws {
let args = CommandLine.arguments
guard args.count >= 2 else {
FileHandle.standardError.write(Data("usage: restore-contacts.swift <path-to-full-*.vcf>\n".utf8))
throw RestoreError.missingArgument
}
let path = args[1]
let url = URL(fileURLWithPath: path)
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw RestoreError.readFailed(error)
}
let contacts: [CNContact]
do {
let parsed = try CNContactVCardSerialization.contacts(with: data)
contacts = parsed
} catch {
throw RestoreError.deserializationFailed(error)
}
print("restore-contacts: parsed \(contacts.count) contacts from \(url.lastPathComponent)")
let store = CNContactStore()
let granted: Bool
do {
granted = try await store.requestAccess(for: .contacts)
} catch {
throw RestoreError.accessDenied
}
guard granted else { throw RestoreError.accessDenied }
let save = CNSaveRequest()
for contact in contacts {
guard let mutable = contact.mutableCopy() as? CNMutableContact else { continue }
save.add(mutable, toContainerWithIdentifier: nil)
}
do {
try store.execute(save)
} catch {
throw RestoreError.saveFailed(error)
}
print("restore-contacts: added \(contacts.count) contacts")
}
private extension CNContactStore {
func requestAccess(for entity: CNEntityType) async throws -> Bool {
try await withCheckedThrowingContinuation { cont in
self.requestAccess(for: entity) { granted, err in
if let err = err { cont.resume(throwing: err); return }
cont.resume(returning: granted)
}
}
}
}
do {
try await main()
} catch {
FileHandle.standardError.write(Data("restore-contacts: \(error)\n".utf8))
exit(1)
}