95 lines
2.8 KiB
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)
|
|
}
|