diff --git a/Sources/ICloudCLICore/LocalInventories.swift b/Sources/ICloudCLICore/LocalInventories.swift index 5b6245b..b97e81b 100644 --- a/Sources/ICloudCLICore/LocalInventories.swift +++ b/Sources/ICloudCLICore/LocalInventories.swift @@ -383,6 +383,16 @@ public struct LocalSQLiteInventoryReader: Sendable { } public func contacts(search: String?, limit: Int, includeNotes: Bool) throws -> [ContactEntry] { + if try tableExists("contacts") { + return try syntheticContacts(search: search, limit: limit, includeNotes: includeNotes) + } + if try tableExists("ZABCDRECORD") { + return try appleContacts(search: search, limit: limit, includeNotes: includeNotes) + } + throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "no such table: contacts; missing contacts or ZABCDRECORD tables") + } + + private func syntheticContacts(search: String?, limit: Int, includeNotes: Bool) throws -> [ContactEntry] { let noteColumn = includeNotes ? "note" : "NULL AS note" let whereClause = search.map { let term = sqlEscape($0) @@ -402,6 +412,62 @@ public struct LocalSQLiteInventoryReader: Sendable { } } + private func appleContacts(search: String?, limit: Int, includeNotes: Bool) throws -> [ContactEntry] { + let givenNameColumn = try contactColumnExpression(["ZFIRSTNAME", "ZGIVENNAME", "ZFIRSTNAME1"], alias: "givenName") + let familyNameColumn = try contactColumnExpression(["ZLASTNAME", "ZFAMILYNAME", "ZLASTNAME1"], alias: "familyName") + let organizationColumn = try contactColumnExpression(["ZORGANIZATION", "ZORGANIZATIONNAME", "ZCOMPANY"], alias: "organizationName") + let noteColumn = includeNotes ? try contactColumnExpression(["ZNOTE", "ZNOTES"], alias: "note") : "NULL AS note" + let displayNameExpression = try contactDisplayNameExpression() + let emailsColumn = try contactFieldListSubquery( + table: "ZABCDEMAILADDRESS", + ownerCandidates: ["ZOWNER", "Z22_OWNER"], + valueCandidates: ["ZADDRESS", "ZADDRESSNORMALIZED"], + alias: "emails" + ) + let phonesColumn = try contactFieldListSubquery( + table: "ZABCDPHONENUMBER", + ownerCandidates: ["ZOWNER", "Z22_OWNER"], + valueCandidates: ["ZFULLNUMBER", "ZLOCALNUMBER"], + alias: "phones" + ) + let searchClause = search.map { term -> String in + let escaped = sqlEscape(term) + return """ + WHERE \(displayNameExpression) LIKE '%\(escaped)%' + OR COALESCE(\(contactColumnReference(["ZFIRSTNAME", "ZGIVENNAME", "ZFIRSTNAME1"])), '') LIKE '%\(escaped)%' + OR COALESCE(\(contactColumnReference(["ZLASTNAME", "ZFAMILYNAME", "ZLASTNAME1"])), '') LIKE '%\(escaped)%' + OR COALESCE(\(contactColumnReference(["ZORGANIZATION", "ZORGANIZATIONNAME", "ZCOMPANY"])), '') LIKE '%\(escaped)%' + OR COALESCE(\(emailsColumn.searchExpression), '') LIKE '%\(escaped)%' + OR COALESCE(\(phonesColumn.searchExpression), '') LIKE '%\(escaped)%' + """ + } ?? "" + let rows: [RawContactRow] = try query(""" + SELECT + \(displayNameExpression) AS displayName, + \(givenNameColumn), + \(familyNameColumn), + \(organizationColumn), + \(emailsColumn.selectExpression), + \(phonesColumn.selectExpression), + \(noteColumn) + FROM ZABCDRECORD + \(searchClause) + ORDER BY \(displayNameExpression) ASC + LIMIT \(bounded(limit, defaultValue: 50, max: 1000)); + """) + return rows.map { row in + ContactEntry( + displayName: row.displayName, + givenName: row.givenName, + familyName: row.familyName, + organizationName: row.organizationName, + emails: parseContactFields(row.emails), + phones: parseContactFields(row.phones), + note: row.note + ) + } + } + public func mapFavorites() throws -> [MapPlace] { let rows: [RawMapPlace] = try query("SELECT name, address, latitude, longitude, category, NULL AS searchedAt FROM map_favorites ORDER BY name ASC;") return rows.map { $0.place() } @@ -452,6 +518,53 @@ public struct LocalSQLiteInventoryReader: Sendable { let rows: [SQLitePragmaColumnRow] = try query("PRAGMA table_info('\(sqlEscape(table))');") return rows.contains { $0.name == column } } + + private func contactColumnReference(_ candidates: [String]) -> String { + for column in candidates { + if (try? columnExists("ZABCDRECORD", column)) == true { + return column + } + } + return "NULL" + } + + private func contactColumnExpression(_ candidates: [String], alias: String) throws -> String { + for column in candidates { + if try columnExists("ZABCDRECORD", column) { + return "\(column) AS \(alias)" + } + } + return "NULL AS \(alias)" + } + + private func contactDisplayNameExpression() throws -> String { + let display = try ["ZDISPLAYNAME", "ZFULLNAME", "ZCOMPOSITEIDENTIFIER"].first { try columnExists("ZABCDRECORD", $0) } + let given = contactColumnReference(["ZFIRSTNAME", "ZGIVENNAME", "ZFIRSTNAME1"]) + let family = contactColumnReference(["ZLASTNAME", "ZFAMILYNAME", "ZLASTNAME1"]) + let organization = contactColumnReference(["ZORGANIZATION", "ZORGANIZATIONNAME", "ZCOMPANY"]) + let preferred = display.map { "\($0)" } ?? "NULL" + return "COALESCE(NULLIF(\(preferred), ''), NULLIF(TRIM(COALESCE(\(given), '') || ' ' || COALESCE(\(family), '')), ''), NULLIF(\(organization), ''), 'Contact ' || Z_PK)" + } + + private func contactFieldListSubquery(table: String, ownerCandidates: [String], valueCandidates: [String], alias: String) throws -> ContactFieldListSQL { + guard try tableExists(table) else { + return ContactFieldListSQL(selectExpression: "NULL AS \(alias)", searchExpression: "NULL") + } + let ownerColumn = try ownerCandidates.first { try columnExists(table, $0) } + let valueColumn = try valueCandidates.first { try columnExists(table, $0) } + guard let ownerColumn, let valueColumn else { + return ContactFieldListSQL(selectExpression: "NULL AS \(alias)", searchExpression: "NULL") + } + let labelExpression = try columnExists(table, "ZLABEL") ? "COALESCE(NULLIF(ZLABEL, ''), 'other')" : "'other'" + let subquery = """ + (SELECT group_concat(\(labelExpression) || ':' || \(valueColumn), ',') + FROM \(table) + WHERE \(table).\(ownerColumn) = ZABCDRECORD.Z_PK + AND \(valueColumn) IS NOT NULL + AND \(valueColumn) != '') + """ + return ContactFieldListSQL(selectExpression: "\(subquery) AS \(alias)", searchExpression: subquery) + } } public struct AddressBookStoreResolver: Sendable { @@ -493,6 +606,11 @@ private struct SQLitePragmaColumnRow: Decodable { let name: String } +private struct ContactFieldListSQL { + let selectExpression: String + let searchExpression: String +} + func sqliteError(from errorData: Data, store: String) -> LocalInventoryError { let message = String(decoding: errorData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) let lowercased = message.lowercased() diff --git a/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift b/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift index c72de73..76efb01 100644 --- a/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift +++ b/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift @@ -100,6 +100,25 @@ import Testing #expect(recent.first?.body == nil) } +@Test func readsAppleAddressBookRecordSchema() throws { + let database = try appleAddressBookFixtureDatabase() + defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } + let reader = LocalSQLiteInventoryReader(database: database) + + let contacts = try reader.contacts(search: "Alice", limit: 10, includeNotes: false) + + #expect(contacts.map(\.displayName) == ["Alice Example"]) + #expect(contacts.first?.givenName == "Alice") + #expect(contacts.first?.familyName == "Example") + #expect(contacts.first?.organizationName == "Example Org") + #expect(contacts.first?.emails == [ContactEntry.Field(label: "work", value: "alice@example.com")]) + #expect(contacts.first?.phones == [ContactEntry.Field(label: "mobile", value: "+15550101")]) + #expect(contacts.first?.note == nil) + + let withNotes = try reader.contacts(search: nil, limit: 10, includeNotes: true) + #expect(withNotes.first?.note == "private note") +} + @Test func readsAppleNotesStoreSchema() throws { let database = try appleNotesFixtureDatabase() defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } @@ -164,6 +183,36 @@ private func appleMessagesFixtureDatabase() throws -> URL { return database } +private func appleAddressBookFixtureDatabase() throws -> URL { + let root = try temporaryDirectory(named: "apple-addressbook") + let database = root.appendingPathComponent("AddressBook-v22.abcddb") + let sql = """ + CREATE TABLE ZABCDRECORD ( + Z_PK INTEGER PRIMARY KEY, + ZFIRSTNAME TEXT, + ZLASTNAME TEXT, + ZORGANIZATION TEXT, + ZNOTE TEXT + ); + CREATE TABLE ZABCDEMAILADDRESS ( + ZOWNER INTEGER, + ZADDRESS TEXT, + ZLABEL TEXT + ); + CREATE TABLE ZABCDPHONENUMBER ( + ZOWNER INTEGER, + ZFULLNUMBER TEXT, + ZLABEL TEXT + ); + INSERT INTO ZABCDRECORD VALUES (1, 'Alice', 'Example', 'Example Org', 'private note'); + INSERT INTO ZABCDRECORD VALUES (2, NULL, NULL, 'Example Org', 'org note'); + INSERT INTO ZABCDEMAILADDRESS VALUES (1, 'alice@example.com', 'work'); + INSERT INTO ZABCDPHONENUMBER VALUES (1, '+15550101', 'mobile'); + """ + try runSQLite(database: database, sql: sql) + return database +} + private func appleNotesFixtureDatabase() throws -> URL { let root = try temporaryDirectory(named: "apple-notes") let database = root.appendingPathComponent("NoteStore.sqlite")