Skip to content
Open
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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ let package = Package(
dependencies: [],
linkerSettings: [
.linkedFramework("EventKit"),
.linkedLibrary("sqlite3"),
]
),
.executableTarget(
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 10 additions & 39 deletions Sources/RemindCore/EventKitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
)
}
}
Expand Down Expand Up @@ -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 ?? "",
Expand All @@ -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]
)
}
}
12 changes: 10 additions & 2 deletions Sources/RemindCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -74,6 +76,7 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
self.dueDate = dueDate
self.listID = listID
self.listName = listName
self.sectionName = sectionName
}
}

Expand All @@ -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
Expand Down
166 changes: 166 additions & 0 deletions Sources/RemindCore/SectionResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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 {
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 [:] }

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"
return newestReadableDataStore(in: storesDir)
}

/// 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
}

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
}

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.
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)
guard ck.isEmpty == false else { continue }

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)
guard ck.isEmpty == false, ek.isEmpty == false else { continue }

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,
memberID.isEmpty == false,
groupID.isEmpty == false
else { continue }
map[memberID] = groupID
}
}
}
14 changes: 12 additions & 2 deletions Sources/remindctl/OutputFormatting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)")
}
}

Expand All @@ -114,6 +116,7 @@ enum OutputRenderer {
return [
reminder.id,
reminder.listName,
reminder.sectionName ?? "",
reminder.isCompleted ? "1" : "0",
reminder.priority.rawValue,
due,
Expand Down Expand Up @@ -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]
Expand Down
31 changes: 31 additions & 0 deletions Tests/RemindCoreTests/SectionResolverTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}