Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions Sources/ICloudCLICore/LocalInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
49 changes: 49 additions & 0 deletions Tests/ICloudCLICoreTests/LocalInventoriesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()) }
Expand Down Expand Up @@ -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")
Expand Down
Loading