From 35b57ac42aabc94e452b25ae249d1df3e01aff35 Mon Sep 17 00:00:00 2001 From: Pheidon Date: Thu, 11 Jun 2026 21:53:27 +0000 Subject: [PATCH] Support Apple Notes store schema --- Sources/ICloudCLICore/LocalInventories.swift | 61 +++++++++++++++++++ .../LocalInventoriesTests.swift | 42 +++++++++++++ 2 files changed, 103 insertions(+) diff --git a/Sources/ICloudCLICore/LocalInventories.swift b/Sources/ICloudCLICore/LocalInventories.swift index 8f94f1c..5b6245b 100644 --- a/Sources/ICloudCLICore/LocalInventories.swift +++ b/Sources/ICloudCLICore/LocalInventories.swift @@ -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))'" }, @@ -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;") } @@ -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 { @@ -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() @@ -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: "''") } diff --git a/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift b/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift index 597ceeb..c72de73 100644 --- a/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift +++ b/Tests/ICloudCLICoreTests/LocalInventoriesTests.swift @@ -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) } @@ -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")