macsync/scripts/import-existing-names.swift

114 lines
4 KiB
Swift

#!/usr/bin/env swift
// import-existing-names.swift
//
// Walk the local CNContactStore, extract (handle, givenName) pairs for every
// contact that has a phone number or email, and POST them to quinn.api so the
// server can seed `clients.display_name` for handles it already tracks.
//
// This runs ONCE before the first real contact-render write without it, the
// renderer emits `[LAX] {firstname}` for contacts whose human name Quinn had
// already saved manually, which would effectively erase those names on first
// write.
//
// Usage:
// export QUINN_API_URL="http://black.local:3100"
// export QUINN_API_SERVICE_TOKEN="..."
// swift scripts/import-existing-names.swift
import Contacts
import Foundation
struct SeedEntry: Codable {
let handle: String
let channel: String
let displayName: String
}
struct SeedBody: Codable {
let items: [SeedEntry]
}
enum SeedError: Error {
case accessDenied
case missingEnv(String)
case http(Int, String)
case fetch(Error)
}
@MainActor
func main() async throws {
guard let base = ProcessInfo.processInfo.environment["QUINN_API_URL"],
let url = URL(string: base) else {
throw SeedError.missingEnv("QUINN_API_URL")
}
guard let token = ProcessInfo.processInfo.environment["QUINN_API_SERVICE_TOKEN"], !token.isEmpty else {
throw SeedError.missingEnv("QUINN_API_SERVICE_TOKEN")
}
let store = CNContactStore()
let granted: Bool = try await withCheckedThrowingContinuation { cont in
store.requestAccess(for: .contacts) { ok, err in
if let err = err { cont.resume(throwing: err); return }
cont.resume(returning: ok)
}
}
guard granted else { throw SeedError.accessDenied }
let keys: [CNKeyDescriptor] = [
CNContactGivenNameKey as CNKeyDescriptor,
CNContactPhoneNumbersKey as CNKeyDescriptor,
CNContactEmailAddressesKey as CNKeyDescriptor,
]
let request = CNContactFetchRequest(keysToFetch: keys)
request.unifyResults = true
var items: [SeedEntry] = []
do {
try store.enumerateContacts(with: request) { contact, _ in
let given = contact.givenName.trimmingCharacters(in: .whitespaces)
guard !given.isEmpty else { return }
// Skip our own managed contacts.
if given.hasPrefix("[") { return }
for phone in contact.phoneNumbers {
let raw = phone.value.stringValue.filter { "+0123456789".contains($0) }
if raw.isEmpty { continue }
items.append(SeedEntry(handle: raw, channel: "imessage", displayName: given))
}
for email in contact.emailAddresses {
let raw = (email.value as String).trimmingCharacters(in: .whitespaces)
if raw.isEmpty { continue }
items.append(SeedEntry(handle: raw, channel: "email", displayName: given))
}
}
} catch {
throw SeedError.fetch(error)
}
print("import-existing-names: collected \(items.count) handle→name pairs")
let body = SeedBody(items: items)
let endpoint = url.appendingPathComponent("m/contact-render/seed-names")
var req = URLRequest(url: endpoint)
req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw SeedError.http(0, "no http response")
}
guard (200..<300).contains(http.statusCode) else {
throw SeedError.http(http.statusCode, String(data: data, encoding: .utf8) ?? "")
}
print("import-existing-names: seeded \(items.count) names — server \(http.statusCode)")
}
do {
try await main()
} catch {
FileHandle.standardError.write(Data("import-existing-names: \(error)\n".utf8))
exit(1)
}