From 573bb5a06b1fc784ea5784224ec4ca78e2b8edc7 Mon Sep 17 00:00:00 2001 From: wgj-ai Date: Mon, 16 Feb 2026 00:17:17 -0700 Subject: [PATCH 1/4] Add read-only reminder section support --- Sources/RemindCore/EventKitStore.swift | 96 +++----- Sources/RemindCore/Models.swift | 5 +- Sources/RemindCore/SectionResolver.swift | 301 +++++++++++++++++++++++ Sources/remindctl/OutputFormatting.swift | 13 +- 4 files changed, 351 insertions(+), 64 deletions(-) create mode 100644 Sources/RemindCore/SectionResolver.swift diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..10f1d97 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -5,6 +5,18 @@ public actor RemindersStore { private let eventStore = EKEventStore() private let calendar: Calendar + private struct ReminderData: Sendable { + let id: String + let title: String + let notes: String? + let isCompleted: Bool + let completionDate: Date? + let priority: Int + let dueDateComponents: DateComponents? + let listID: String + let listName: String + } + public init(calendar: Calendar = .current) { self.calendar = calendar } @@ -104,17 +116,7 @@ public actor RemindersStore { reminder.dueDateComponents = calendarComponents(from: dueDate) } try eventStore.save(reminder, commit: true) - return ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) + return item(from: reminder) } public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem { @@ -145,17 +147,7 @@ public actor RemindersStore { try eventStore.save(reminder, commit: true) - return ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) + return item(from: reminder) } public func completeReminders(ids: [String]) async throws -> [ReminderItem] { @@ -164,19 +156,7 @@ public actor RemindersStore { let reminder = try reminder(withID: id) reminder.isCompleted = true try eventStore.save(reminder, commit: true) - updated.append( - ReminderItem( - id: reminder.calendarItemIdentifier, - title: reminder.title ?? "", - notes: reminder.notes, - isCompleted: reminder.isCompleted, - completionDate: reminder.completionDate, - priority: ReminderPriority(eventKitValue: Int(reminder.priority)), - dueDate: date(from: reminder.dueDateComponents), - listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title - ) - ) + updated.append(item(from: reminder)) } return updated } @@ -204,18 +184,6 @@ public actor RemindersStore { } private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] { - struct ReminderData: Sendable { - let id: String - let title: String - let notes: String? - let isCompleted: Bool - let completionDate: Date? - let priority: Int - let dueDateComponents: DateComponents? - let listID: String - let listName: String - } - let reminderData = await withCheckedContinuation { (continuation: CheckedContinuation<[ReminderData], Never>) in let predicate = eventStore.predicateForReminders(in: calendars) eventStore.fetchReminders(matching: predicate) { reminders in @@ -236,18 +204,10 @@ public actor RemindersStore { } } + let sectionNames = SectionResolver().resolveSectionNames(for: reminderData.map { $0.id }) + return reminderData.map { data in - ReminderItem( - id: data.id, - title: data.title, - notes: data.notes, - isCompleted: data.isCompleted, - completionDate: data.completionDate, - priority: ReminderPriority(eventKitValue: data.priority), - dueDate: date(from: data.dueDateComponents), - listID: data.listID, - listName: data.listName - ) + item(from: data, sectionName: sectionNames[data.id]) } } @@ -275,7 +235,7 @@ public actor RemindersStore { return calendar.date(from: components) } - private func item(from reminder: EKReminder) -> ReminderItem { + private func item(from reminder: EKReminder, sectionName: String? = nil) -> ReminderItem { ReminderItem( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", @@ -285,7 +245,23 @@ public actor RemindersStore { priority: ReminderPriority(eventKitValue: Int(reminder.priority)), dueDate: date(from: reminder.dueDateComponents), listID: reminder.calendar.calendarIdentifier, - listName: reminder.calendar.title + listName: reminder.calendar.title, + sectionName: sectionName + ) + } + + private func item(from data: ReminderData, sectionName: String? = nil) -> ReminderItem { + ReminderItem( + id: data.id, + title: data.title, + notes: data.notes, + isCompleted: data.isCompleted, + completionDate: data.completionDate, + priority: ReminderPriority(eventKitValue: data.priority), + dueDate: date(from: data.dueDateComponents), + listID: data.listID, + listName: data.listName, + sectionName: sectionName ) } } diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..fd26bd3 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -53,6 +53,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public let dueDate: Date? public let listID: String public let listName: String + public let sectionName: String? public init( id: String, @@ -63,7 +64,8 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { priority: ReminderPriority, dueDate: Date?, listID: String, - listName: String + listName: String, + sectionName: String? = nil ) { self.id = id self.title = title @@ -74,6 +76,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { self.dueDate = dueDate self.listID = listID self.listName = listName + self.sectionName = sectionName } } diff --git a/Sources/RemindCore/SectionResolver.swift b/Sources/RemindCore/SectionResolver.swift new file mode 100644 index 0000000..f91efe7 --- /dev/null +++ b/Sources/RemindCore/SectionResolver.swift @@ -0,0 +1,301 @@ +import Foundation +import SQLite3 + +struct SectionResolver { + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func resolveSectionNames(for reminderIDs: [String]) -> [String: String] { + guard !reminderIDs.isEmpty else { return [:] } + guard let databaseURL = findDatabase() else { return [:] } + guard let db = openDatabase(at: databaseURL) else { return [:] } + defer { sqlite3_close(db) } + return loadSectionNames(from: db, reminderIDs: reminderIDs) + } + + private func findDatabase() -> URL? { + guard let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first else { + return nil + } + + let candidateRoots: [URL] = [ + libraryURL.appendingPathComponent("Reminders/Container_v1/Stores", isDirectory: true), + libraryURL.appendingPathComponent("Reminders/Container/Stores", isDirectory: true), + libraryURL.appendingPathComponent("Reminders/Stores", isDirectory: true), + libraryURL.appendingPathComponent("Group Containers/group.com.apple.reminders/Container_v1/Stores", isDirectory: true), + libraryURL.appendingPathComponent("Group Containers/group.com.apple.reminders/Container/Stores", isDirectory: true), + libraryURL.appendingPathComponent("Group Containers/group.com.apple.reminders/Stores", isDirectory: true), + ] + + var latestURL: URL? + var latestDate: Date? + + for root in candidateRoots where fileManager.fileExists(atPath: root.path) { + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { continue } + + for case let fileURL as URL in enumerator { + guard fileURL.lastPathComponent.hasPrefix("Data-"), fileURL.pathExtension == "sqlite" else { continue } + guard let values = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]), + values.isRegularFile == true + else { continue } + let modified = values.contentModificationDate ?? .distantPast + if let latestDate, modified <= latestDate { continue } + latestDate = modified + latestURL = fileURL + } + } + + return latestURL + } + + private func openDatabase(at url: URL) -> OpaquePointer? { + var db: OpaquePointer? + let flags = SQLITE_OPEN_READONLY + if sqlite3_open_v2(url.path, &db, flags, nil) != SQLITE_OK { + if db != nil { sqlite3_close(db) } + return nil + } + sqlite3_busy_timeout(db, 2000) + return db + } + + private func loadSectionNames(from db: OpaquePointer, reminderIDs: [String]) -> [String: String] { + let reminderIDSet = Set(reminderIDs) + + let sectionColumns = columns(in: "ZREMCDSECTION", db: db) + guard !sectionColumns.isEmpty else { return [:] } + + let sectionNameColumn = firstExistingColumn( + in: sectionColumns, + candidates: ["ZNAME", "ZNAME1", "ZTITLE", "ZTITLE1"] + ) + + guard let sectionNameColumn else { return [:] } + + let sectionCKColumn = sectionColumns.contains("ZCKIDENTIFIER") ? "ZCKIDENTIFIER" : nil + + var sectionsByPK: [Int64: String] = [:] + var sectionsByCK: [String: String] = [:] + + var sectionQueryColumns = ["Z_PK", sectionNameColumn] + if let sectionCKColumn { + sectionQueryColumns.insert(sectionCKColumn, at: 1) + } + + let sectionQuery = "SELECT \(sectionQueryColumns.joined(separator: ", ")) FROM ZREMCDSECTION" + if let statement = prepare(db: db, query: sectionQuery) { + defer { sqlite3_finalize(statement) } + while sqlite3_step(statement) == SQLITE_ROW { + let pk = sqlite3_column_int64(statement, 0) + let ckIndex = sectionCKColumn == nil ? nil : Int32(1) + let nameIndex: Int32 = sectionCKColumn == nil ? 1 : 2 + + let name = stringValue(statement, index: nameIndex)?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard let name, !name.isEmpty else { continue } + sectionsByPK[pk] = name + + if let ckIndex, let ckValue = stringValue(statement, index: ckIndex) { + let ck = ckValue.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if !ck.isEmpty { + sectionsByCK[ck] = name + } + } + } + } + + let listOrdering = loadSectionOrdering(from: db) + + let reminderColumns = columns(in: "ZREMCDREMINDER", db: db) + guard !reminderColumns.isEmpty else { return [:] } + + let reminderIdentifierCandidates = [ + "ZDACALENDARITEMUNIQUEIDENTIFIER", + "ZREMINDERIDENTIFIER", + "ZCKIDENTIFIER", + ] + let reminderIdentifierColumns = reminderIdentifierCandidates.filter { reminderColumns.contains($0) } + guard !reminderIdentifierColumns.isEmpty else { return [:] } + + let sectionRefColumn = firstExistingColumn( + in: reminderColumns, + candidates: ["ZSECTION", "ZSECTION1", "ZSECTIONID"] + ) + let listRefColumn = reminderColumns.contains("ZLIST") ? "ZLIST" : nil + + var selectColumns = reminderIdentifierColumns + if let sectionRefColumn { + selectColumns.append(sectionRefColumn) + } + if let listRefColumn { + selectColumns.append(listRefColumn) + } + + let reminderQuery = "SELECT \(selectColumns.joined(separator: ", ")) FROM ZREMCDREMINDER" + guard let reminderStatement = prepare(db: db, query: reminderQuery) else { return [:] } + defer { sqlite3_finalize(reminderStatement) } + + var results: [String: String] = [:] + while sqlite3_step(reminderStatement) == SQLITE_ROW { + var identifiers: [String] = [] + for index in 0..= 0 { + let index = Int(pk) + if index < ordered.count { + sectionName = sectionsByCK[ordered[index]] + } + } + case .ck(let ck): + sectionName = sectionsByCK[ck] + } + } + + if let sectionName { + results[matchedIdentifier] = sectionName + } + } + + return results + } + + private func loadSectionOrdering(from db: OpaquePointer) -> [Int64: [String]] { + let listColumns = columns(in: "ZREMCDLIST", db: db) + guard listColumns.contains("ZSECTIONIDSORDERINGASDATA") else { return [:] } + + let query = "SELECT Z_PK, ZSECTIONIDSORDERINGASDATA FROM ZREMCDLIST" + guard let statement = prepare(db: db, query: query) else { return [:] } + defer { sqlite3_finalize(statement) } + + var ordering: [Int64: [String]] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + let pk = sqlite3_column_int64(statement, 0) + guard let data = blobValue(statement, index: 1) else { continue } + if let list = decodeStringArray(from: data), !list.isEmpty { + ordering[pk] = list + } + } + return ordering + } + + private func decodeStringArray(from data: Data) -> [String]? { + if let object = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, NSString.self], from: data) { + if let strings = object as? [String] { + let filtered = strings.filter { !$0.isEmpty } + return filtered.isEmpty ? nil : filtered + } + if let anyArray = object as? [Any] { + let strings = anyArray.compactMap { $0 as? String }.filter { !$0.isEmpty } + if !strings.isEmpty { return strings } + } + } + + if let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { + if let strings = plist as? [String] { + let filtered = strings.filter { !$0.isEmpty } + return filtered.isEmpty ? nil : filtered + } + if let anyArray = plist as? [Any] { + let strings = anyArray.compactMap { $0 as? String }.filter { !$0.isEmpty } + if !strings.isEmpty { return strings } + } + } + + return nil + } + + private func columns(in table: String, db: OpaquePointer) -> Set { + let query = "PRAGMA table_info(\(table))" + guard let statement = prepare(db: db, query: query) else { return [] } + defer { sqlite3_finalize(statement) } + + var columns: Set = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let name = stringValue(statement, index: 1) { + columns.insert(name) + } + } + return columns + } + + private func prepare(db: OpaquePointer, query: String) -> OpaquePointer? { + var statement: OpaquePointer? + if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { + return nil + } + return statement + } + + private func firstExistingColumn(in columns: Set, candidates: [String]) -> String? { + candidates.first(where: { columns.contains($0) }) + } + + private func stringValue(_ statement: OpaquePointer, index: Int32) -> String? { + guard sqlite3_column_type(statement, index) != SQLITE_NULL, + let cString = sqlite3_column_text(statement, index) + else { return nil } + return String(cString: cString) + } + + private func blobValue(_ statement: OpaquePointer, index: Int32) -> Data? { + guard sqlite3_column_type(statement, index) != SQLITE_NULL, + let bytes = sqlite3_column_blob(statement, index) + else { return nil } + let length = Int(sqlite3_column_bytes(statement, index)) + return Data(bytes: bytes, count: length) + } + + private enum SectionReference { + case pk(Int64) + case ck(String) + } + + private func sectionReference(from statement: OpaquePointer, index: Int32) -> SectionReference? { + switch sqlite3_column_type(statement, index) { + case SQLITE_INTEGER: + return .pk(sqlite3_column_int64(statement, index)) + case SQLITE_TEXT: + if let rawValue = stringValue(statement, index: index) { + let value = rawValue.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if !value.isEmpty { + return .ck(value) + } + } + return nil + default: + return nil + } + } +} diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index ee85c61..bd2617b 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -51,7 +51,7 @@ enum OutputRenderer { switch format { case .standard: let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" - Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)") + Swift.print("✓ \(reminder.title) [\(listDisplayName(for: reminder))] — \(due)") case .plain: Swift.print(plainLine(for: reminder)) case .json: @@ -98,7 +98,7 @@ enum OutputRenderer { let status = reminder.isCompleted ? "x" : " " let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" - Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)") + Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(listDisplayName(for: reminder))] — \(due)\(priority)") } } @@ -113,7 +113,7 @@ enum OutputRenderer { let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? "" return [ reminder.id, - reminder.listName, + listDisplayName(for: reminder), reminder.isCompleted ? "1" : "0", reminder.priority.rawValue, due, @@ -121,6 +121,13 @@ enum OutputRenderer { ].joined(separator: "\t") } + private static func listDisplayName(for reminder: ReminderItem) -> String { + guard let sectionName = reminder.sectionName, !sectionName.isEmpty else { + return reminder.listName + } + return "\(reminder.listName)/\(sectionName)" + } + private static func printListsStandard(_ summaries: [ListSummary]) { guard !summaries.isEmpty else { Swift.print("No reminder lists found") From 24bef49fef16e6e39ae9116eb66f901ff5806450 Mon Sep 17 00:00:00 2001 From: wgj-ai Date: Mon, 16 Feb 2026 17:26:08 -0700 Subject: [PATCH 2/4] Fix section lookup query and add tests --- Sources/RemindCore/SectionResolver.swift | 78 +++-------------- .../SectionResolverTests.swift | 83 +++++++++++++++++++ 2 files changed, 96 insertions(+), 65 deletions(-) create mode 100644 Tests/RemindCoreTests/SectionResolverTests.swift diff --git a/Sources/RemindCore/SectionResolver.swift b/Sources/RemindCore/SectionResolver.swift index f91efe7..12f4065 100644 --- a/Sources/RemindCore/SectionResolver.swift +++ b/Sources/RemindCore/SectionResolver.swift @@ -3,6 +3,7 @@ import SQLite3 struct SectionResolver { private let fileManager: FileManager + private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) init(fileManager: FileManager = .default) { self.fileManager = fileManager @@ -110,7 +111,6 @@ struct SectionResolver { } } - let listOrdering = loadSectionOrdering(from: db) let reminderColumns = columns(in: "ZREMCDREMINDER", db: db) guard !reminderColumns.isEmpty else { return [:] } @@ -127,20 +127,25 @@ struct SectionResolver { in: reminderColumns, candidates: ["ZSECTION", "ZSECTION1", "ZSECTIONID"] ) - let listRefColumn = reminderColumns.contains("ZLIST") ? "ZLIST" : nil - var selectColumns = reminderIdentifierColumns if let sectionRefColumn { selectColumns.append(sectionRefColumn) } - if let listRefColumn { - selectColumns.append(listRefColumn) - } - - let reminderQuery = "SELECT \(selectColumns.joined(separator: ", ")) FROM ZREMCDREMINDER" + let reminderIDList = Array(reminderIDSet) + let placeholders = Array(repeating: "?", count: reminderIDList.count).joined(separator: ", ") + let whereClauses = reminderIdentifierColumns.map { "\($0) IN (\(placeholders))" } + let reminderQuery = "SELECT \(selectColumns.joined(separator: ", ")) FROM ZREMCDREMINDER WHERE \(whereClauses.joined(separator: " OR "))" guard let reminderStatement = prepare(db: db, query: reminderQuery) else { return [:] } defer { sqlite3_finalize(reminderStatement) } + var bindIndex: Int32 = 1 + for _ in reminderIdentifierColumns { + for reminderID in reminderIDList { + sqlite3_bind_text(reminderStatement, bindIndex, reminderID, -1, sqliteTransient) + bindIndex += 1 + } + } + var results: [String: String] = [:] while sqlite3_step(reminderStatement) == SQLITE_ROW { var identifiers: [String] = [] @@ -162,22 +167,10 @@ struct SectionResolver { sectionRef = sectionReference(from: reminderStatement, index: sectionIndex) } - let listIndexOffset = reminderIdentifierColumns.count + (sectionRefColumn == nil ? 0 : 1) - var listPK: Int64? - if listRefColumn != nil { - listPK = sqlite3_column_int64(reminderStatement, Int32(listIndexOffset)) - } - if let sectionRef { switch sectionRef { case .pk(let pk): sectionName = sectionsByPK[pk] - if sectionName == nil, let listPK, let ordered = listOrdering[listPK], pk >= 0 { - let index = Int(pk) - if index < ordered.count { - sectionName = sectionsByCK[ordered[index]] - } - } case .ck(let ck): sectionName = sectionsByCK[ck] } @@ -191,51 +184,6 @@ struct SectionResolver { return results } - private func loadSectionOrdering(from db: OpaquePointer) -> [Int64: [String]] { - let listColumns = columns(in: "ZREMCDLIST", db: db) - guard listColumns.contains("ZSECTIONIDSORDERINGASDATA") else { return [:] } - - let query = "SELECT Z_PK, ZSECTIONIDSORDERINGASDATA FROM ZREMCDLIST" - guard let statement = prepare(db: db, query: query) else { return [:] } - defer { sqlite3_finalize(statement) } - - var ordering: [Int64: [String]] = [:] - while sqlite3_step(statement) == SQLITE_ROW { - let pk = sqlite3_column_int64(statement, 0) - guard let data = blobValue(statement, index: 1) else { continue } - if let list = decodeStringArray(from: data), !list.isEmpty { - ordering[pk] = list - } - } - return ordering - } - - private func decodeStringArray(from data: Data) -> [String]? { - if let object = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, NSString.self], from: data) { - if let strings = object as? [String] { - let filtered = strings.filter { !$0.isEmpty } - return filtered.isEmpty ? nil : filtered - } - if let anyArray = object as? [Any] { - let strings = anyArray.compactMap { $0 as? String }.filter { !$0.isEmpty } - if !strings.isEmpty { return strings } - } - } - - if let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) { - if let strings = plist as? [String] { - let filtered = strings.filter { !$0.isEmpty } - return filtered.isEmpty ? nil : filtered - } - if let anyArray = plist as? [Any] { - let strings = anyArray.compactMap { $0 as? String }.filter { !$0.isEmpty } - if !strings.isEmpty { return strings } - } - } - - return nil - } - private func columns(in table: String, db: OpaquePointer) -> Set { let query = "PRAGMA table_info(\(table))" guard let statement = prepare(db: db, query: query) else { return [] } diff --git a/Tests/RemindCoreTests/SectionResolverTests.swift b/Tests/RemindCoreTests/SectionResolverTests.swift new file mode 100644 index 0000000..f9d0abc --- /dev/null +++ b/Tests/RemindCoreTests/SectionResolverTests.swift @@ -0,0 +1,83 @@ +import XCTest +import SQLite3 +@testable import RemindCore + +final class SectionResolverTests: XCTestCase { + func testResolvesSectionNameFromPrimaryKey() throws { + let tempRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let libraryURL = tempRoot.appendingPathComponent("Library", isDirectory: true) + let storesURL = libraryURL.appendingPathComponent("Reminders/Container/Stores", isDirectory: true) + try FileManager.default.createDirectory(at: storesURL, withIntermediateDirectories: true) + + let dbURL = storesURL.appendingPathComponent("Data-1.sqlite") + try createDatabase(at: dbURL, statements: [ + "CREATE TABLE ZREMCDSECTION (Z_PK INTEGER PRIMARY KEY, ZNAME TEXT, ZCKIDENTIFIER TEXT);", + "CREATE TABLE ZREMCDREMINDER (Z_PK INTEGER PRIMARY KEY, ZDACALENDARITEMUNIQUEIDENTIFIER TEXT, ZSECTION INTEGER, ZLIST INTEGER);", + "INSERT INTO ZREMCDSECTION (Z_PK, ZNAME, ZCKIDENTIFIER) VALUES (5, 'Work', 'section-ck');", + "INSERT INTO ZREMCDREMINDER (Z_PK, ZDACALENDARITEMUNIQUEIDENTIFIER, ZSECTION, ZLIST) VALUES (10, 'rem-1', 5, NULL);", + "INSERT INTO ZREMCDREMINDER (Z_PK, ZDACALENDARITEMUNIQUEIDENTIFIER, ZSECTION, ZLIST) VALUES (11, 'rem-2', NULL, NULL);", + ]) + + let resolver = SectionResolver(fileManager: TestFileManager(libraryURL: libraryURL)) + let results = resolver.resolveSectionNames(for: ["rem-1"]) + + XCTAssertEqual(results, ["rem-1": "Work"]) + } + + func testResolvesSectionNameFromCloudKitIdentifier() throws { + let tempRoot = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let libraryURL = tempRoot.appendingPathComponent("Library", isDirectory: true) + let storesURL = libraryURL.appendingPathComponent("Reminders/Container/Stores", isDirectory: true) + try FileManager.default.createDirectory(at: storesURL, withIntermediateDirectories: true) + + let dbURL = storesURL.appendingPathComponent("Data-2.sqlite") + try createDatabase(at: dbURL, statements: [ + "CREATE TABLE ZREMCDSECTION (Z_PK INTEGER PRIMARY KEY, ZNAME TEXT, ZCKIDENTIFIER TEXT);", + "CREATE TABLE ZREMCDREMINDER (Z_PK INTEGER PRIMARY KEY, ZDACALENDARITEMUNIQUEIDENTIFIER TEXT, ZSECTION TEXT);", + "INSERT INTO ZREMCDSECTION (Z_PK, ZNAME, ZCKIDENTIFIER) VALUES (3, 'Home', 'section-home');", + "INSERT INTO ZREMCDREMINDER (Z_PK, ZDACALENDARITEMUNIQUEIDENTIFIER, ZSECTION) VALUES (20, 'rem-3', 'section-home');", + ]) + + let resolver = SectionResolver(fileManager: TestFileManager(libraryURL: libraryURL)) + let results = resolver.resolveSectionNames(for: ["rem-3"]) + + XCTAssertEqual(results, ["rem-3": "Home"]) + } +} + +private final class TestFileManager: FileManager { + private let libraryURL: URL + + init(libraryURL: URL) { + self.libraryURL = libraryURL + super.init() + } + + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + if directory == .libraryDirectory { + return [libraryURL] + } + return super.urls(for: directory, in: domainMask) + } +} + +private func createDatabase(at url: URL, statements: [String]) throws { + var db: OpaquePointer? + if sqlite3_open(url.path, &db) != SQLITE_OK { + defer { sqlite3_close(db) } + throw DatabaseError.openFailed + } + defer { sqlite3_close(db) } + + for statement in statements { + if sqlite3_exec(db, statement, nil, nil, nil) != SQLITE_OK { + let message = String(cString: sqlite3_errmsg(db)) + throw DatabaseError.execFailed(message) + } + } +} + +private enum DatabaseError: Error { + case openFailed + case execFailed(String) +} From 977d2f02bba214d6761e19e90eafd2f4853097f5 Mon Sep 17 00:00:00 2001 From: wgj-ai Date: Tue, 17 Feb 2026 12:30:46 -0700 Subject: [PATCH 3/4] Fix section resolution from memberships JSON --- Sources/RemindCore/SectionResolver.swift | 160 +++++++++++------- .../SectionResolverTests.swift | 38 +++-- 2 files changed, 120 insertions(+), 78 deletions(-) diff --git a/Sources/RemindCore/SectionResolver.swift b/Sources/RemindCore/SectionResolver.swift index 12f4065..0e6b470 100644 --- a/Sources/RemindCore/SectionResolver.swift +++ b/Sources/RemindCore/SectionResolver.swift @@ -70,50 +70,9 @@ struct SectionResolver { private func loadSectionNames(from db: OpaquePointer, reminderIDs: [String]) -> [String: String] { let reminderIDSet = Set(reminderIDs) - let sectionColumns = columns(in: "ZREMCDSECTION", db: db) - guard !sectionColumns.isEmpty else { return [:] } - - let sectionNameColumn = firstExistingColumn( - in: sectionColumns, - candidates: ["ZNAME", "ZNAME1", "ZTITLE", "ZTITLE1"] - ) - - guard let sectionNameColumn else { return [:] } - - let sectionCKColumn = sectionColumns.contains("ZCKIDENTIFIER") ? "ZCKIDENTIFIER" : nil - - var sectionsByPK: [Int64: String] = [:] - var sectionsByCK: [String: String] = [:] - - var sectionQueryColumns = ["Z_PK", sectionNameColumn] - if let sectionCKColumn { - sectionQueryColumns.insert(sectionCKColumn, at: 1) - } - - let sectionQuery = "SELECT \(sectionQueryColumns.joined(separator: ", ")) FROM ZREMCDSECTION" - if let statement = prepare(db: db, query: sectionQuery) { - defer { sqlite3_finalize(statement) } - while sqlite3_step(statement) == SQLITE_ROW { - let pk = sqlite3_column_int64(statement, 0) - let ckIndex = sectionCKColumn == nil ? nil : Int32(1) - let nameIndex: Int32 = sectionCKColumn == nil ? 1 : 2 - - let name = stringValue(statement, index: nameIndex)?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - guard let name, !name.isEmpty else { continue } - sectionsByPK[pk] = name - - if let ckIndex, let ckValue = stringValue(statement, index: ckIndex) { - let ck = ckValue.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - if !ck.isEmpty { - sectionsByCK[ck] = name - } - } - } - } - - let reminderColumns = columns(in: "ZREMCDREMINDER", db: db) guard !reminderColumns.isEmpty else { return [:] } + guard reminderColumns.contains("ZLIST") else { return [:] } let reminderIdentifierCandidates = [ "ZDACALENDARITEMUNIQUEIDENTIFIER", @@ -123,14 +82,7 @@ struct SectionResolver { let reminderIdentifierColumns = reminderIdentifierCandidates.filter { reminderColumns.contains($0) } guard !reminderIdentifierColumns.isEmpty else { return [:] } - let sectionRefColumn = firstExistingColumn( - in: reminderColumns, - candidates: ["ZSECTION", "ZSECTION1", "ZSECTIONID"] - ) - var selectColumns = reminderIdentifierColumns - if let sectionRefColumn { - selectColumns.append(sectionRefColumn) - } + let selectColumns = reminderIdentifierColumns + ["ZLIST"] let reminderIDList = Array(reminderIDSet) let placeholders = Array(repeating: "?", count: reminderIDList.count).joined(separator: ", ") let whereClauses = reminderIdentifierColumns.map { "\($0) IN (\(placeholders))" } @@ -146,38 +98,116 @@ struct SectionResolver { } } - var results: [String: String] = [:] + var reminderToMemberID: [String: String] = [:] + var reminderToListPK: [String: Int64] = [:] + var listPKs: Set = [] + while sqlite3_step(reminderStatement) == SQLITE_ROW { var identifiers: [String] = [] - for index in 0.. Date: Tue, 17 Feb 2026 12:45:22 -0700 Subject: [PATCH 4/4] Remove dead code from SectionResolver --- Sources/RemindCore/SectionResolver.swift | 25 ------------------------ 1 file changed, 25 deletions(-) diff --git a/Sources/RemindCore/SectionResolver.swift b/Sources/RemindCore/SectionResolver.swift index 0e6b470..930f370 100644 --- a/Sources/RemindCore/SectionResolver.swift +++ b/Sources/RemindCore/SectionResolver.swift @@ -236,10 +236,6 @@ struct SectionResolver { return statement } - private func firstExistingColumn(in columns: Set, candidates: [String]) -> String? { - candidates.first(where: { columns.contains($0) }) - } - private func stringValue(_ statement: OpaquePointer, index: Int32) -> String? { guard sqlite3_column_type(statement, index) != SQLITE_NULL, let cString = sqlite3_column_text(statement, index) @@ -255,25 +251,4 @@ struct SectionResolver { return Data(bytes: bytes, count: length) } - private enum SectionReference { - case pk(Int64) - case ck(String) - } - - private func sectionReference(from statement: OpaquePointer, index: Int32) -> SectionReference? { - switch sqlite3_column_type(statement, index) { - case SQLITE_INTEGER: - return .pk(sqlite3_column_int64(statement, index)) - case SQLITE_TEXT: - if let rawValue = stringValue(statement, index: index) { - let value = rawValue.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - if !value.isEmpty { - return .ck(value) - } - } - return nil - default: - return nil - } - } }