diff --git a/README.md b/README.md index 7b6a444..76626a5 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ remindctl list Projects --create remindctl add "Buy milk" remindctl add --title "Call mom" --list Personal --due tomorrow +remindctl add "Follow up" --parent 4A83 remindctl edit 1 --title "New title" --due 2026-01-04 +remindctl edit 2 --parent 4A83 remindctl complete 1 2 3 remindctl delete 4A83 --force remindctl status # permission status @@ -68,6 +70,11 @@ Accepted by `--due` and filters: - `YYYY-MM-DD HH:mm` - ISO 8601 (`2026-01-03T12:34:56Z`) +## Subtasks +Use `--parent` (or `--under`) with `add` and `edit` to attach a reminder to an existing one. If the underlying +EventKit API exposes parent relationships on your system, remindctl will set a real subtask. Otherwise it stores +fallback metadata in the reminder notes as `remindctl-parent: ` so the link is preserved. + ## Permissions Run `remindctl authorize` to trigger the system prompt. If access is denied, enable Terminal (or remindctl) in System Settings → Privacy & Security → Reminders. diff --git a/REVIEW_NOTES.md b/REVIEW_NOTES.md new file mode 100644 index 0000000..98b1ced --- /dev/null +++ b/REVIEW_NOTES.md @@ -0,0 +1,10 @@ +# Review notes: subtasks support + +- Added `--parent`/`--under` options to `remindctl add` and `remindctl edit` to attach or re-parent reminders. +- Reminders are validated to stay in the same list as their parent; `edit` will auto-move lists if needed. +- EventKit parent support is attempted via dynamic selectors (`setParent:` / `setParentReminder:`). If unavailable, remindctl stores fallback metadata in notes (`remindctl-parent: `). +- Added help/usage examples plus README and manual test updates; new test checks that help includes the parent options. + +Things to review: +- Confirm the dynamic parent selector names are correct for any EventKit version that supports subtasks. +- Verify the fallback notes behavior is acceptable for your workflow. diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift index e44dc89..a075380 100644 --- a/Sources/RemindCore/EventKitStore.swift +++ b/Sources/RemindCore/EventKitStore.swift @@ -103,18 +103,15 @@ public actor RemindersStore { if let dueDate = draft.dueDate { reminder.dueDateComponents = calendarComponents(from: dueDate) } + if let parentID = draft.parentID { + let parent = try reminder(withID: parentID) + try validateParent(parent, matches: calendar) + if !applyParent(parent, to: reminder) { + reminder.notes = updateParentNotes(current: reminder.notes, parentID: parentID) + } + } 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 +139,17 @@ public actor RemindersStore { if let isCompleted = update.isCompleted { reminder.isCompleted = isCompleted } + if let parentID = update.parentID { + let parent = try reminder(withID: parentID) + try validateParent(parent, matches: reminder.calendar) + if !applyParent(parent, to: reminder) { + reminder.notes = updateParentNotes(current: reminder.notes, parentID: parentID) + } + } 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] { @@ -288,4 +282,39 @@ public actor RemindersStore { listName: reminder.calendar.title ) } + + private func validateParent(_ parent: EKReminder, matches calendar: EKCalendar) throws { + if parent.calendar.calendarIdentifier != calendar.calendarIdentifier { + throw RemindCoreError.operationFailed( + "Parent reminder is in list \"\(parent.calendar.title)\". Subtasks must be in the same list." + ) + } + } + + private func applyParent(_ parent: EKReminder, to reminder: EKReminder) -> Bool { + if reminder.responds(to: Selector(("setParent:"))) { + _ = reminder.perform(Selector(("setParent:")), with: parent) + return true + } + if reminder.responds(to: Selector(("setParentReminder:"))) { + _ = reminder.perform(Selector(("setParentReminder:")), with: parent) + return true + } + return false + } + + private func updateParentNotes(current: String?, parentID: String) -> String { + let parentLine = "remindctl-parent: \(parentID)" + let existing = current?.components(separatedBy: .newlines) ?? [] + if existing.isEmpty || (existing.count == 1 && existing[0].isEmpty) { + return parentLine + } + var updated = existing + if let index = existing.firstIndex(where: { $0.hasPrefix("remindctl-parent: ") }) { + updated[index] = parentLine + } else { + updated.append(parentLine) + } + return updated.joined(separator: "\n") + } } diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..46a699b 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -82,12 +82,20 @@ public struct ReminderDraft: Sendable { public let notes: String? public let dueDate: Date? public let priority: ReminderPriority + public let parentID: String? - public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { + public init( + title: String, + notes: String?, + dueDate: Date?, + priority: ReminderPriority, + parentID: String? = nil + ) { self.title = title self.notes = notes self.dueDate = dueDate self.priority = priority + self.parentID = parentID } } @@ -98,6 +106,7 @@ public struct ReminderUpdate: Sendable { public let priority: ReminderPriority? public let listName: String? public let isCompleted: Bool? + public let parentID: String? public init( title: String? = nil, @@ -105,7 +114,8 @@ public struct ReminderUpdate: Sendable { dueDate: Date?? = nil, priority: ReminderPriority? = nil, listName: String? = nil, - isCompleted: Bool? = nil + isCompleted: Bool? = nil, + parentID: String? = nil ) { self.title = title self.notes = notes @@ -113,5 +123,6 @@ public struct ReminderUpdate: Sendable { self.priority = priority self.listName = listName self.isCompleted = isCompleted + self.parentID = parentID } } diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index 571bc25..e31c61a 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -16,6 +16,12 @@ enum AddCommand { options: [ .make(label: "title", names: [.long("title")], help: "Reminder title", parsing: .singleValue), .make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue), + .make( + label: "parent", + names: [.long("parent"), .aliasLong("under")], + help: "Parent reminder (index or ID prefix)", + parsing: .singleValue + ), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), .make( @@ -31,6 +37,7 @@ enum AddCommand { "remindctl add \"Buy milk\"", "remindctl add --title \"Call mom\" --list Personal --due tomorrow", "remindctl add \"Review docs\" --priority high", + "remindctl add \"Follow up\" --parent 4A83", ] ) { values, runtime in let titleOption = values.option("title") @@ -53,6 +60,7 @@ enum AddCommand { } let listName = values.option("list") + let parentInput = values.option("parent") let notes = values.option("notes") let dueValue = values.option("due") let priorityValue = values.option("priority") @@ -63,8 +71,22 @@ enum AddCommand { let store = RemindersStore() try await store.requestAccess() + var parentReminder: ReminderItem? + if let parentInput { + let reminders = try await store.reminders(in: nil) + let resolved = try IDResolver.resolve([parentInput], from: reminders) + parentReminder = resolved.first + } + let targetList: String? - if let listName { + if let parentReminder { + if let listName, listName != parentReminder.listName { + throw RemindCoreError.operationFailed( + "Parent reminder is in list \"\(parentReminder.listName)\". Use that list or omit --list." + ) + } + targetList = parentReminder.listName + } else if let listName { targetList = listName } else { targetList = await store.defaultListName() @@ -73,7 +95,13 @@ enum AddCommand { throw RemindCoreError.operationFailed("No default list found. Specify --list.") } - let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority) + let draft = ReminderDraft( + title: title, + notes: notes, + dueDate: dueDate, + priority: priority, + parentID: parentReminder?.id + ) let reminder = try await store.createReminder(draft, listName: targetList) OutputRenderer.printReminder(reminder, format: runtime.outputFormat) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index 75a3c31..7123d3a 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -16,6 +16,12 @@ enum EditCommand { options: [ .make(label: "title", names: [.short("t"), .long("title")], help: "New title", parsing: .singleValue), .make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue), + .make( + label: "parent", + names: [.long("parent"), .aliasLong("under")], + help: "Set parent reminder (index or ID prefix)", + parsing: .singleValue + ), .make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue), .make( @@ -37,6 +43,7 @@ enum EditCommand { "remindctl edit 4A83 --due tomorrow", "remindctl edit 2 --priority high --notes \"Call before noon\"", "remindctl edit 3 --clear-due", + "remindctl edit 2 --parent 4A83", ] ) { values, runtime in guard let input = values.argument(0) else { @@ -52,8 +59,9 @@ enum EditCommand { } let title = values.option("title") - let listName = values.option("list") + var listName = values.option("list") let notes = values.option("notes") + let parentInput = values.option("parent") var dueUpdate: Date?? if let dueValue = values.option("due") { @@ -78,7 +86,31 @@ enum EditCommand { } let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil) - if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil { + var parentReminder: ReminderItem? + if let parentInput { + let resolvedParent = try IDResolver.resolve([parentInput], from: reminders) + parentReminder = resolvedParent.first + if parentReminder?.id == reminder.id { + throw RemindCoreError.operationFailed("Parent reminder cannot be the reminder being edited") + } + if let parentReminder, let listName, listName != parentReminder.listName { + throw RemindCoreError.operationFailed( + "Parent reminder is in list \"\(parentReminder.listName)\". Use that list or omit --list." + ) + } + if let parentReminder, listName == nil, reminder.listName != parentReminder.listName { + listName = parentReminder.listName + } + } + + if title == nil + && listName == nil + && notes == nil + && dueUpdate == nil + && priority == nil + && isCompleted == nil + && parentReminder == nil + { throw RemindCoreError.operationFailed("No changes specified") } @@ -88,7 +120,8 @@ enum EditCommand { dueDate: dueUpdate, priority: priority, listName: listName, - isCompleted: isCompleted + isCompleted: isCompleted, + parentID: parentReminder?.id ) let updated = try await store.updateReminder(id: reminder.id, update: update) diff --git a/Tests/remindctlTests/SubtaskHelpTests.swift b/Tests/remindctlTests/SubtaskHelpTests.swift new file mode 100644 index 0000000..762e7a9 --- /dev/null +++ b/Tests/remindctlTests/SubtaskHelpTests.swift @@ -0,0 +1,17 @@ +import Testing + +@testable import remindctl + +@MainActor +struct SubtaskHelpTests { + @Test("Add/edit help includes parent options") + func helpIncludesParent() { + let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n") + #expect(addHelp.contains("--parent")) + #expect(addHelp.contains("--under")) + + let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n") + #expect(editHelp.contains("--parent")) + #expect(editHelp.contains("--under")) + } +} diff --git a/docs/manual-tests.md b/docs/manual-tests.md index 45499ef..f03b7b8 100644 --- a/docs/manual-tests.md +++ b/docs/manual-tests.md @@ -16,8 +16,10 @@ Run on a local GUI session (not SSH-only) so the Reminders permission prompt can - list lists: `remindctl list` - list list contents: `remindctl list "remindctl-manual-YYYYMMDD"` - add reminders (3 variants) +- add subtask: `remindctl add "Follow up" --parent 1` - show filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `completed`, `all` - edit: update title/notes/priority/due date +- edit: re-parent reminder with `--parent` - complete: mark one reminder complete - delete: remove reminders, then delete list