#!/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.lan: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) }