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
61 changes: 61 additions & 0 deletions Sources/ICloudCLICore/LocalInventories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,16 @@ public struct LocalSQLiteInventoryReader: Sendable {
}

public func notes(folder: String?, modifiedSince: String?, includeBody: Bool) throws -> [NoteEntry] {
if try tableExists("notes") {
return try syntheticNotes(folder: folder, modifiedSince: modifiedSince, includeBody: includeBody)
}
if try tableExists("ZICCLOUDSYNCINGOBJECT") {
return try appleNotes(folder: folder, modifiedSince: modifiedSince, includeBody: includeBody)
}
throw LocalInventoryError.unsupportedSchema(store: database.path, detail: "no such table: notes; missing notes or ZICCLOUDSYNCINGOBJECT tables")
}

private func syntheticNotes(folder: String?, modifiedSince: String?, includeBody: Bool) throws -> [NoteEntry] {
let whereClause = andClause([
folder.map { "folderName = '\(sqlEscape($0))'" },
modifiedSince.map { "modifiedAt >= '\(sqlEscape($0))'" },
Expand All @@ -254,6 +264,44 @@ public struct LocalSQLiteInventoryReader: Sendable {
return try query("SELECT title, folderName, createdAt, modifiedAt, isPinned, \(bodyColumn) FROM notes\(whereClause) ORDER BY modifiedAt DESC, title ASC;")
}

private func appleNotes(folder: String?, modifiedSince: String?, includeBody: Bool) throws -> [NoteEntry] {
let hasNoteData = try tableExists("ZICNOTEDATA")
let bodyJoin = hasNoteData ? "LEFT JOIN ZICNOTEDATA d ON d.ZNOTE = n.Z_PK" : ""
let bodyColumn = includeBody && hasNoteData ? "CAST(d.ZDATA AS TEXT)" : "NULL"
let folderTitleColumn = try columnExists("ZICCLOUDSYNCINGOBJECT", "ZTITLE2") ? "f.ZTITLE2" : "f.ZTITLE1"
let hasModifiedDate = try columnExists("ZICCLOUDSYNCINGOBJECT", "ZMODIFICATIONDATE1")
let modifiedExpression = hasModifiedDate ? appleDateExpression("n.ZMODIFICATIONDATE1") : "NULL"
let createdExpression = try columnExists("ZICCLOUDSYNCINGOBJECT", "ZCREATIONDATE1") ? appleDateExpression("n.ZCREATIONDATE1") : "NULL"
let pinnedColumn = try columnExists("ZICCLOUDSYNCINGOBJECT", "ZISPINNED") ? "COALESCE(n.ZISPINNED, 0)" : "0"
let orderClause = hasModifiedDate ? "ORDER BY n.ZMODIFICATIONDATE1 DESC, n.ZTITLE1 ASC" : "ORDER BY n.ZTITLE1 ASC"
var filters: [String?] = [
"n.ZTITLE1 IS NOT NULL",
folder.map { "\(folderTitleColumn) = '\(sqlEscape($0))'" },
]
if try columnExists("ZICCLOUDSYNCINGOBJECT", "ZMARKEDFORDELETION") {
filters.append("(n.ZMARKEDFORDELETION IS NULL OR n.ZMARKEDFORDELETION = 0)")
}
if let modifiedSince {
filters.append("\(modifiedExpression) >= '\(sqlEscape(modifiedSince))'")
}
let whereClause = andClause(filters)

return try query("""
SELECT
n.ZTITLE1 AS title,
\(folderTitleColumn) AS folderName,
\(createdExpression) AS createdAt,
\(modifiedExpression) AS modifiedAt,
\(pinnedColumn) AS isPinned,
\(bodyColumn) AS body
FROM ZICCLOUDSYNCINGOBJECT n
LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON f.Z_PK = n.ZFOLDER
\(bodyJoin)
\(whereClause)
\(orderClause);
""")
}

public func reminderLists() throws -> [ReminderListSummary] {
try query("SELECT listName AS name, COUNT(*) AS itemCount FROM reminders GROUP BY listName ORDER BY listName ASC;")
}
Expand Down Expand Up @@ -399,6 +447,11 @@ public struct LocalSQLiteInventoryReader: Sendable {
let rows: [SQLiteTableRow] = try query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = '\(sqlEscape(name))' LIMIT 1;")
return !rows.isEmpty
}

private func columnExists(_ table: String, _ column: String) throws -> Bool {
let rows: [SQLitePragmaColumnRow] = try query("PRAGMA table_info('\(sqlEscape(table))');")
return rows.contains { $0.name == column }
}
}

public struct AddressBookStoreResolver: Sendable {
Expand Down Expand Up @@ -436,6 +489,10 @@ private struct SQLiteTableRow: Decodable {
let name: String
}

private struct SQLitePragmaColumnRow: Decodable {
let name: 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 Expand Up @@ -493,6 +550,10 @@ private func bounded(_ value: Int, defaultValue: Int, max: Int) -> Int {
return Swift.min(value, max)
}

private func appleDateExpression(_ column: String) -> String {
"CASE WHEN \(column) IS NULL THEN NULL ELSE strftime('%Y-%m-%dT%H:%M:%SZ', \(column) + 978307200, 'unixepoch') END"
}

private func sqlEscape(_ value: String) -> String {
value.replacingOccurrences(of: "'", with: "''")
}
Expand Down
42 changes: 42 additions & 0 deletions Tests/ICloudCLICoreTests/LocalInventoriesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ import Testing
#expect(recent.first?.body == nil)
}

@Test func readsAppleNotesStoreSchema() throws {
let database = try appleNotesFixtureDatabase()
defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) }
let reader = LocalSQLiteInventoryReader(database: database)

let notes = try reader.notes(folder: "Quick Notes", modifiedSince: "2026-01-01T00:00:00Z", includeBody: false)

#expect(notes.map(\.title) == ["Plan"])
#expect(notes.first?.folderName == "Quick Notes")
#expect(notes.first?.createdAt == "2026-01-01T00:00:00Z")
#expect(notes.first?.modifiedAt == "2026-01-02T00:00:00Z")
#expect(notes.first?.isPinned == true)
#expect(notes.first?.body == nil)

let withBody = try reader.notes(folder: nil, modifiedSince: nil, includeBody: true)
#expect(withBody.first?.body == "Sensitive body")
}

@Test func resolvesAddressBookDatabaseInsideSourcesDirectory() throws {
let root = try temporaryDirectory(named: "addressbook")
defer { try? FileManager.default.removeItem(at: root) }
Expand Down Expand Up @@ -146,6 +164,30 @@ private func appleMessagesFixtureDatabase() throws -> URL {
return database
}

private func appleNotesFixtureDatabase() throws -> URL {
let root = try temporaryDirectory(named: "apple-notes")
let database = root.appendingPathComponent("NoteStore.sqlite")
let sql = """
CREATE TABLE ZICCLOUDSYNCINGOBJECT (
Z_PK INTEGER PRIMARY KEY,
ZTITLE1 TEXT,
ZTITLE2 TEXT,
ZFOLDER INTEGER,
ZCREATIONDATE1 REAL,
ZMODIFICATIONDATE1 REAL,
ZISPINNED INTEGER,
ZMARKEDFORDELETION INTEGER
);
CREATE TABLE ZICNOTEDATA (ZNOTE INTEGER, ZDATA BLOB);
INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (1, NULL, 'Quick Notes', NULL, NULL, NULL, NULL, 0);
INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (2, 'Plan', NULL, 1, 788918400, 789004800, 1, 0);
INSERT INTO ZICCLOUDSYNCINGOBJECT VALUES (3, 'Deleted', NULL, 1, 788918400, 789091200, 0, 1);
INSERT INTO ZICNOTEDATA VALUES (2, CAST('Sensitive body' AS BLOB));
"""
try runSQLite(database: database, sql: sql)
return database
}

private func syntheticInventoryDatabase() throws -> URL {
let root = try temporaryDirectory(named: "sqlite")
let database = root.appendingPathComponent("inventory.sqlite")
Expand Down
Loading