From c66491d0ae6c9ae69b9d93c8aaa779f3a632fbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Bi=C5=82as?= Date: Fri, 6 Feb 2026 23:52:24 +0100 Subject: [PATCH 1/2] feat: expose Reminders sections in output (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read section names from the Reminders CoreData SQLite store and surface them on ReminderItem as an optional sectionName field. Sections appear in standard output as [List/Section], in plain output as an extra column, and in JSON output as a top-level key. Implementation approach: - Add SectionResolver that opens the Reminders SQLite database read-only and joins three tables (ZREMCDBASESECTION, ZREMCDREMINDER, ZREMCDBASELIST) to build an EventKit ID → section-name map. Degrades gracefully to an empty map when the database is unavailable. - Add sectionName: String? to ReminderItem. - Link sqlite3 in Package.swift. - Refactor EventKitStore to use a shared item(from:) helper, eliminating four duplicate ReminderItem construction sites. Co-Authored-By: Claude Opus 4.6 --- Package.swift | 1 + Sources/RemindCore/EventKitStore.swift | 49 ++------- Sources/RemindCore/Models.swift | 12 ++- Sources/RemindCore/SectionResolver.swift | 128 +++++++++++++++++++++++ Sources/remindctl/OutputFormatting.swift | 14 ++- 5 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 Sources/RemindCore/SectionResolver.swift diff --git a/Package.swift b/Package.swift index 2683d24..467b451 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( dependencies: [], linkerSettings: [ .linkedFramework("EventKit"), + .linkedLibrary("sqlite3"), ] ), .executableTarget( diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..9dfe51a 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -104,17 +104,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 { @@ -142,20 +132,9 @@ public actor RemindersStore { if let isCompleted = update.isCompleted { reminder.isCompleted = isCompleted } - 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 +143,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 } @@ -216,6 +183,8 @@ public actor RemindersStore { let listName: String } + let sectionMap = SectionResolver.resolve() + let reminderData = await withCheckedContinuation { (continuation: CheckedContinuation<[ReminderData], Never>) in let predicate = eventStore.predicateForReminders(in: calendars) eventStore.fetchReminders(matching: predicate) { reminders in @@ -246,7 +215,8 @@ public actor RemindersStore { priority: ReminderPriority(eventKitValue: data.priority), dueDate: date(from: data.dueDateComponents), listID: data.listID, - listName: data.listName + listName: data.listName, + sectionName: sectionMap[data.id] ) } } @@ -275,7 +245,7 @@ public actor RemindersStore { return calendar.date(from: components) } - private func item(from reminder: EKReminder) -> ReminderItem { + private func item(from reminder: EKReminder, sectionMap: [String: String] = [:]) -> ReminderItem { ReminderItem( id: reminder.calendarItemIdentifier, title: reminder.title ?? "", @@ -285,7 +255,8 @@ 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: sectionMap[reminder.calendarItemIdentifier] ) } } diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..f3a7cc8 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 } } @@ -83,7 +86,12 @@ public struct ReminderDraft: Sendable { public let dueDate: Date? public let priority: ReminderPriority - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init( + title: String, + notes: String?, + dueDate: Date?, + priority: ReminderPriority + ) { self.title = title self.notes = notes self.dueDate = dueDate diff --git a/Sources/RemindCore/SectionResolver.swift b/Sources/RemindCore/SectionResolver.swift new file mode 100644 index 0000000..ec47b2f --- /dev/null +++ b/Sources/RemindCore/SectionResolver.swift @@ -0,0 +1,128 @@ +import Foundation +import SQLite3 + +/// Reads section data from the Reminders CoreData SQLite store. +/// Degrades gracefully (returns empty map) when the database is unavailable. +public enum SectionResolver { + + /// Builds a mapping of EventKit calendarItemIdentifier → section display name. + /// Opens the database read-only; returns `[:]` on any failure. + public static func resolve() -> [String: String] { + guard let dbPath = findDatabase() else { return [:] } + var db: OpaquePointer? + guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nil) == SQLITE_OK else { + return [:] + } + defer { sqlite3_close(db) } + + let sections = querySections(db: db!) + if sections.isEmpty { return [:] } + + let reminderMap = queryReminderIdentifiers(db: db!) + let memberships = queryMemberships(db: db!) + + var result: [String: String] = [:] + for (reminderCK, sectionCK) in memberships { + guard let sectionName = sections[sectionCK], + let ekID = reminderMap[reminderCK] + else { continue } + result[ekID] = sectionName + } + return result + } + + // MARK: - Private + + private static func findDatabase() -> String? { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let storesDir = + "\(home)/Library/Group Containers/group.com.apple.reminders/Container_v1/Stores" + + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: storesDir) else { + return nil + } + for file in contents where file.hasSuffix(".sqlite") { + let path = "\(storesDir)/\(file)" + if FileManager.default.isReadableFile(atPath: path) { + return path + } + } + return nil + } + + /// Section CK identifier → display name. + private static func querySections(db: OpaquePointer) -> [String: String] { + let sql = "SELECT ZCKIDENTIFIER, ZDISPLAYNAME FROM ZREMCDBASESECTION" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + var map: [String: String] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let ckRaw = sqlite3_column_text(stmt, 0), + let nameRaw = sqlite3_column_text(stmt, 1) + else { continue } + let ck = String(cString: ckRaw) + let name = String(cString: nameRaw) + map[ck] = name + } + return map + } + + /// Reminder CK identifier → EventKit calendarItemIdentifier. + private static func queryReminderIdentifiers(db: OpaquePointer) -> [String: String] { + let sql = """ + SELECT ZCKIDENTIFIER, ZDACALENDARITEMUNIQUEIDENTIFIER \ + FROM ZREMCDREMINDER \ + WHERE ZDACALENDARITEMUNIQUEIDENTIFIER IS NOT NULL + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + var map: [String: String] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let ckRaw = sqlite3_column_text(stmt, 0), + let ekRaw = sqlite3_column_text(stmt, 1) + else { continue } + let ck = String(cString: ckRaw) + let ek = String(cString: ekRaw) + map[ck] = ek + } + return map + } + + /// Reminder CK identifier → section CK identifier (from membership JSON blobs on lists). + private static func queryMemberships(db: OpaquePointer) -> [String: String] { + let sql = """ + SELECT ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA \ + FROM ZREMCDBASELIST \ + WHERE ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA IS NOT NULL + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + var map: [String: String] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let blob = sqlite3_column_blob(stmt, 0) else { continue } + let length = Int(sqlite3_column_bytes(stmt, 0)) + let data = Data(bytes: blob, count: length) + parseMembershipBlob(data, into: &map) + } + return map + } + + private static func parseMembershipBlob(_ data: Data, into map: inout [String: String]) { + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let memberships = json["memberships"] as? [[String: Any]] + else { return } + for entry in memberships { + guard let memberID = entry["memberID"] as? String, + let groupID = entry["groupID"] as? String + else { continue } + map[memberID] = groupID + } + } +} diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index ee85c61..3f5be3d 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -51,7 +51,8 @@ enum OutputRenderer { switch format { case .standard: let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" - Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)") + let listLabel = listLabel(for: reminder) + Swift.print("✓ \(reminder.title) [\(listLabel)] — \(due)") case .plain: Swift.print(plainLine(for: reminder)) case .json: @@ -98,7 +99,8 @@ 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)") + let listLabel = listLabel(for: reminder) + Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(listLabel)] — \(due)\(priority)") } } @@ -114,6 +116,7 @@ enum OutputRenderer { return [ reminder.id, reminder.listName, + reminder.sectionName ?? "", reminder.isCompleted ? "1" : "0", reminder.priority.rawValue, due, @@ -152,6 +155,13 @@ enum OutputRenderer { } } + private static func listLabel(for reminder: ReminderItem) -> String { + if let section = reminder.sectionName { + return "\(reminder.listName)/\(section)" + } + return reminder.listName + } + private static func isoFormatter() -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] From 06bfdaf8985863c29a258f4b009834ee0bec8974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Bi=C5=82as?= Date: Fri, 20 Feb 2026 10:20:10 +0100 Subject: [PATCH 2/2] Improve section resolver robustness --- README.md | 1 + Sources/RemindCore/SectionResolver.swift | 52 ++++++++++++++++--- .../SectionResolverTests.swift | 31 +++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 Tests/RemindCoreTests/SectionResolverTests.swift diff --git a/README.md b/README.md index 7b6a444..b1dbbd7 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ remindctl authorize # request permissions - `--json` emits JSON arrays/objects. - `--plain` emits tab-separated lines. - `--quiet` emits counts only. +- Section names are best-effort metadata from the local Reminders store; section ordering is not currently exposed. ## Date formats Accepted by `--due` and filters: diff --git a/Sources/RemindCore/SectionResolver.swift b/Sources/RemindCore/SectionResolver.swift index ec47b2f..189c4f7 100644 --- a/Sources/RemindCore/SectionResolver.swift +++ b/Sources/RemindCore/SectionResolver.swift @@ -4,17 +4,22 @@ import SQLite3 /// Reads section data from the Reminders CoreData SQLite store. /// Degrades gracefully (returns empty map) when the database is unavailable. public enum SectionResolver { + private static let sqliteBusyTimeoutMs: Int32 = 1_500 /// Builds a mapping of EventKit calendarItemIdentifier → section display name. /// Opens the database read-only; returns `[:]` on any failure. public static func resolve() -> [String: String] { guard let dbPath = findDatabase() else { return [:] } + var db: OpaquePointer? guard sqlite3_open_v2(dbPath, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nil) == SQLITE_OK else { return [:] } defer { sqlite3_close(db) } + // Reduce flaky failures while Reminders is concurrently writing WAL pages. + sqlite3_busy_timeout(db, sqliteBusyTimeoutMs) + let sections = querySections(db: db!) if sections.isEmpty { return [:] } @@ -37,17 +42,41 @@ public enum SectionResolver { let home = FileManager.default.homeDirectoryForCurrentUser.path let storesDir = "\(home)/Library/Group Containers/group.com.apple.reminders/Container_v1/Stores" + return newestReadableDataStore(in: storesDir) + } - guard let contents = try? FileManager.default.contentsOfDirectory(atPath: storesDir) else { + /// Returns the newest readable Reminders store, preferring `Data-*.sqlite` files. + static func newestReadableDataStore(in storesDir: String, fileManager: FileManager = .default) -> String? { + guard let contents = try? fileManager.contentsOfDirectory(atPath: storesDir) else { return nil } - for file in contents where file.hasSuffix(".sqlite") { - let path = "\(storesDir)/\(file)" - if FileManager.default.isReadableFile(atPath: path) { - return path + + func pickNewest(from fileNames: [String]) -> String? { + var bestPath: String? + var bestModified = Date.distantPast + + for file in fileNames { + let path = "\(storesDir)/\(file)" + guard fileManager.isReadableFile(atPath: path) else { continue } + + let attributes = try? fileManager.attributesOfItem(atPath: path) + let modified = (attributes?[.modificationDate] as? Date) ?? .distantPast + if modified > bestModified { + bestModified = modified + bestPath = path + } } + + return bestPath } - return nil + + let remindersStores = contents.filter { $0.hasPrefix("Data-") && $0.hasSuffix(".sqlite") } + if let newestRemindersStore = pickNewest(from: remindersStores) { + return newestRemindersStore + } + + let fallbackStores = contents.filter { $0.hasSuffix(".sqlite") } + return pickNewest(from: fallbackStores) } /// Section CK identifier → display name. @@ -62,7 +91,10 @@ public enum SectionResolver { guard let ckRaw = sqlite3_column_text(stmt, 0), let nameRaw = sqlite3_column_text(stmt, 1) else { continue } + let ck = String(cString: ckRaw) + guard ck.isEmpty == false else { continue } + let name = String(cString: nameRaw) map[ck] = name } @@ -85,8 +117,11 @@ public enum SectionResolver { guard let ckRaw = sqlite3_column_text(stmt, 0), let ekRaw = sqlite3_column_text(stmt, 1) else { continue } + let ck = String(cString: ckRaw) let ek = String(cString: ekRaw) + guard ck.isEmpty == false, ek.isEmpty == false else { continue } + map[ck] = ek } return map @@ -118,9 +153,12 @@ public enum SectionResolver { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let memberships = json["memberships"] as? [[String: Any]] else { return } + for entry in memberships { guard let memberID = entry["memberID"] as? String, - let groupID = entry["groupID"] as? String + let groupID = entry["groupID"] as? String, + memberID.isEmpty == false, + groupID.isEmpty == false else { continue } map[memberID] = groupID } diff --git a/Tests/RemindCoreTests/SectionResolverTests.swift b/Tests/RemindCoreTests/SectionResolverTests.swift new file mode 100644 index 0000000..89a2a52 --- /dev/null +++ b/Tests/RemindCoreTests/SectionResolverTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing + +@testable import RemindCore + +@MainActor +struct SectionResolverTests { + @Test("Prefers newest readable Data-*.sqlite store") + func picksNewestDataStore() throws { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempRoot) } + + let staleStore = tempRoot.appendingPathComponent("Data-stale.sqlite") + let freshStore = tempRoot.appendingPathComponent("Data-fresh.sqlite") + let fallbackStore = tempRoot.appendingPathComponent("Fallback.sqlite") + + fileManager.createFile(atPath: staleStore.path, contents: Data()) + fileManager.createFile(atPath: freshStore.path, contents: Data()) + fileManager.createFile(atPath: fallbackStore.path, contents: Data()) + + let oldDate = Date(timeIntervalSince1970: 1_700_000_000) + let newDate = Date(timeIntervalSince1970: 1_800_000_000) + try fileManager.setAttributes([.modificationDate: oldDate], ofItemAtPath: staleStore.path) + try fileManager.setAttributes([.modificationDate: newDate], ofItemAtPath: freshStore.path) + + let selected = SectionResolver.newestReadableDataStore(in: tempRoot.path) + #expect(selected == freshStore.path) + } +}