114 lines
4 KiB
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)
|
|
}
|