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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: <id>` 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.
Expand Down
10 changes: 10 additions & 0 deletions REVIEW_NOTES.md
Original file line number Diff line number Diff line change
@@ -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: <id>`).
- 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.
73 changes: 51 additions & 22 deletions Sources/RemindCore/EventKitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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")
}
}
15 changes: 13 additions & 2 deletions Sources/RemindCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -98,20 +106,23 @@ 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,
notes: String? = nil,
dueDate: Date?? = nil,
priority: ReminderPriority? = nil,
listName: String? = nil,
isCompleted: Bool? = nil
isCompleted: Bool? = nil,
parentID: String? = nil
) {
self.title = title
self.notes = notes
self.dueDate = dueDate
self.priority = priority
self.listName = listName
self.isCompleted = isCompleted
self.parentID = parentID
}
}
32 changes: 30 additions & 2 deletions Sources/remindctl/Commands/AddCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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)
}
Expand Down
39 changes: 36 additions & 3 deletions Sources/remindctl/Commands/EditCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand All @@ -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") {
Expand All @@ -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")
}

Expand All @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions Tests/remindctlTests/SubtaskHelpTests.swift
Original file line number Diff line number Diff line change
@@ -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"))
}
}
2 changes: 2 additions & 0 deletions docs/manual-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down