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..930f370 --- /dev/null +++ b/Sources/RemindCore/SectionResolver.swift @@ -0,0 +1,254 @@ +import Foundation +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 + } + + 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 reminderColumns = columns(in: "ZREMCDREMINDER", db: db) + guard !reminderColumns.isEmpty else { return [:] } + guard reminderColumns.contains("ZLIST") else { return [:] } + + let reminderIdentifierCandidates = [ + "ZDACALENDARITEMUNIQUEIDENTIFIER", + "ZREMINDERIDENTIFIER", + "ZCKIDENTIFIER", + ] + let reminderIdentifierColumns = reminderIdentifierCandidates.filter { reminderColumns.contains($0) } + guard !reminderIdentifierColumns.isEmpty else { return [:] } + + let selectColumns = reminderIdentifierColumns + ["ZLIST"] + 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 reminderToMemberID: [String: String] = [:] + var reminderToListPK: [String: Int64] = [:] + var listPKs: Set = [] + + while sqlite3_step(reminderStatement) == SQLITE_ROW { + var identifiers: [String] = [] + var memberID: String? + for (index, column) in reminderIdentifierColumns.enumerated() { + if let rawValue = stringValue(reminderStatement, index: Int32(index)) { + let value = rawValue.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if !value.isEmpty { + identifiers.append(value) + if column == "ZDACALENDARITEMUNIQUEIDENTIFIER" { + memberID = value + } + } + } + } + + guard let matchedIdentifier = identifiers.first(where: { reminderIDSet.contains($0) }) else { continue } + if memberID == nil { + memberID = matchedIdentifier + } + + let listIndex = Int32(reminderIdentifierColumns.count) + guard sqlite3_column_type(reminderStatement, listIndex) != SQLITE_NULL else { continue } + let listPK = sqlite3_column_int64(reminderStatement, listIndex) + + guard let memberID else { continue } + reminderToMemberID[matchedIdentifier] = memberID + reminderToListPK[matchedIdentifier] = listPK + listPKs.insert(listPK) + } + + guard !listPKs.isEmpty else { return [:] } + + let memberIDs = Set(reminderToMemberID.values) + var memberToGroupID: [String: String] = [:] + + let listPKList = Array(listPKs) + let listPlaceholders = Array(repeating: "?", count: listPKList.count).joined(separator: ", ") + let listQuery = "SELECT Z_PK, ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA FROM ZREMCDBASELIST WHERE Z_PK IN (\(listPlaceholders))" + if let listStatement = prepare(db: db, query: listQuery) { + defer { sqlite3_finalize(listStatement) } + var listBindIndex: Int32 = 1 + for listPK in listPKList { + sqlite3_bind_int64(listStatement, listBindIndex, listPK) + listBindIndex += 1 + } + + while sqlite3_step(listStatement) == SQLITE_ROW { + let data: Data? + if let blob = blobValue(listStatement, index: 1) { + data = blob + } else if let text = stringValue(listStatement, index: 1) { + data = text.data(using: .utf8) + } else { + data = nil + } + + guard let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let memberships = json["memberships"] as? [[String: Any]] + else { continue } + + for membership in memberships { + guard let memberID = membership["memberID"] as? String, + let groupID = membership["groupID"] as? String + else { continue } + if memberIDs.contains(memberID) { + memberToGroupID[memberID] = groupID + } + } + } + } + + let sectionColumns = columns(in: "ZREMCDBASESECTION", db: db) + guard sectionColumns.contains("ZCKIDENTIFIER"), sectionColumns.contains("ZDISPLAYNAME") else { return [:] } + + var sectionsByGroupID: [String: String] = [:] + var sectionQuery = "SELECT ZCKIDENTIFIER, ZDISPLAYNAME FROM ZREMCDBASESECTION" + if sectionColumns.contains("ZLIST") { + sectionQuery += " WHERE ZLIST IN (\(listPlaceholders))" + } + + if let sectionStatement = prepare(db: db, query: sectionQuery) { + defer { sqlite3_finalize(sectionStatement) } + if sectionColumns.contains("ZLIST") { + var sectionBindIndex: Int32 = 1 + for listPK in listPKList { + sqlite3_bind_int64(sectionStatement, sectionBindIndex, listPK) + sectionBindIndex += 1 + } + } + + while sqlite3_step(sectionStatement) == SQLITE_ROW { + guard let rawGroupID = stringValue(sectionStatement, index: 0), + let rawName = stringValue(sectionStatement, index: 1) + else { continue } + let groupID = rawGroupID.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + let name = rawName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + guard !groupID.isEmpty, !name.isEmpty else { continue } + sectionsByGroupID[groupID] = name + } + } + + var results: [String: String] = [:] + for (reminderID, memberID) in reminderToMemberID { + if let groupID = memberToGroupID[memberID], let name = sectionsByGroupID[groupID] { + results[reminderID] = name + } + } + + return results + } + + 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 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) + } + +} 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") diff --git a/Tests/RemindCoreTests/SectionResolverTests.swift b/Tests/RemindCoreTests/SectionResolverTests.swift new file mode 100644 index 0000000..6c120c1 --- /dev/null +++ b/Tests/RemindCoreTests/SectionResolverTests.swift @@ -0,0 +1,95 @@ +import XCTest +import SQLite3 +@testable import RemindCore + +final class SectionResolverTests: XCTestCase { + func testResolvesSectionNameFromMembershipData() 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") + let membershipsJSON = """ + {"minimumSupportedVersion":20230430,"memberships":[{"memberID":"rem-1","groupID":"section-ck"}]} + """ + + try createDatabase(at: dbURL, statements: [ + "CREATE TABLE ZREMCDBASELIST (Z_PK INTEGER PRIMARY KEY, ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA TEXT);", + "CREATE TABLE ZREMCDBASESECTION (Z_PK INTEGER PRIMARY KEY, ZCKIDENTIFIER TEXT, ZDISPLAYNAME TEXT, ZLIST INTEGER);", + "CREATE TABLE ZREMCDREMINDER (Z_PK INTEGER PRIMARY KEY, ZDACALENDARITEMUNIQUEIDENTIFIER TEXT, ZLIST INTEGER);", + "INSERT INTO ZREMCDBASELIST (Z_PK, ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA) VALUES (1, '\(membershipsJSON)');", + "INSERT INTO ZREMCDBASESECTION (Z_PK, ZCKIDENTIFIER, ZDISPLAYNAME, ZLIST) VALUES (5, 'section-ck', 'Work', 1);", + "INSERT INTO ZREMCDREMINDER (Z_PK, ZDACALENDARITEMUNIQUEIDENTIFIER, ZLIST) VALUES (10, 'rem-1', 1);", + "INSERT INTO ZREMCDREMINDER (Z_PK, ZDACALENDARITEMUNIQUEIDENTIFIER, ZLIST) VALUES (11, 'rem-2', 1);", + ]) + + let resolver = SectionResolver(fileManager: TestFileManager(libraryURL: libraryURL)) + let results = resolver.resolveSectionNames(for: ["rem-1"]) + + XCTAssertEqual(results, ["rem-1": "Work"]) + } + + func testResolvesSectionNameWhenMatchingNonMemberIdentifier() 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") + let membershipsJSON = """ + {"minimumSupportedVersion":20230430,"memberships":[{"memberID":"rem-4","groupID":"section-home"}]} + """ + + try createDatabase(at: dbURL, statements: [ + "CREATE TABLE ZREMCDBASELIST (Z_PK INTEGER PRIMARY KEY, ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA TEXT);", + "CREATE TABLE ZREMCDBASESECTION (Z_PK INTEGER PRIMARY KEY, ZCKIDENTIFIER TEXT, ZDISPLAYNAME TEXT, ZLIST INTEGER);", + "CREATE TABLE ZREMCDREMINDER (Z_PK INTEGER PRIMARY KEY, ZDACALENDARITEMUNIQUEIDENTIFIER TEXT, ZCKIDENTIFIER TEXT, ZLIST INTEGER);", + "INSERT INTO ZREMCDBASELIST (Z_PK, ZMEMBERSHIPSOFREMINDERSINSECTIONSASDATA) VALUES (2, '\(membershipsJSON)');", + "INSERT INTO ZREMCDBASESECTION (Z_PK, ZCKIDENTIFIER, ZDISPLAYNAME, ZLIST) VALUES (7, 'section-home', 'Home', 2);", + "INSERT INTO ZREMCDREMINDER (Z_PK, ZDACALENDARITEMUNIQUEIDENTIFIER, ZCKIDENTIFIER, ZLIST) VALUES (20, 'rem-4', 'ck-rem-4', 2);", + ]) + + let resolver = SectionResolver(fileManager: TestFileManager(libraryURL: libraryURL)) + let results = resolver.resolveSectionNames(for: ["ck-rem-4"]) + + XCTAssertEqual(results, ["ck-rem-4": "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) +}