Compare commits

...

20 Commits
v0.1.1 ... main

Author SHA1 Message Date
Peter Steinberger
883ae25791
fix: resolve reminder indexes from show view
Some checks failed
CI / build (push) Has been cancelled
2026-05-08 15:13:00 +01:00
Peter Steinberger
de80b37c45
ci: update homebrew tap on release
Some checks are pending
CI / build (push) Waiting to run
2026-05-07 03:56:52 +01:00
Dinakar Sarbada
74beefa96a
docs: integrate Homebrew tap update into release flow
Some checks failed
CI / build (push) Has been cancelled
Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-05-04 09:04:27 +01:00
Wenyou Yi
f3cd4eb683
docs: add remindctl skill for agentic tools
Add an apple-reminders skill that documents the current remindctl command surface for agentic tools.

Refined before merge to match the 0.2.0 release: macOS/Homebrew requirements, JSON aliases, open filter, metadata fields, recurrence, alarms, location trigger constraints, permissions, and EventKit limitations.

Co-authored-by: Allen Yi <yiwenyou_allen@outlook.com>
2026-05-04 06:50:51 +01:00
Peter Steinberger
1aff71567c
ci: update actions to node 24 runtime 2026-05-04 06:25:32 +01:00
Peter Steinberger
568457e27e
ci: opt in to node 24 actions runtime 2026-05-04 06:23:45 +01:00
Peter Steinberger
cd0ab4c1f4
docs: rewrite README 2026-05-04 06:21:45 +01:00
Peter Steinberger
4b79188ab5
docs: refresh README feature guide 2026-05-04 06:13:23 +01:00
Peter Steinberger
85a589366e
feat: add location reminder triggers
Co-authored-by: Octavio Froid <froid@bohm.com>
2026-05-04 05:58:14 +01:00
Peter Steinberger
20e868d615
feat: expose reminder modification metadata
Co-authored-by: Allen Yi <yiwenyou_allen@outlook.com>
2026-05-04 05:55:33 +01:00
Peter Steinberger
09b25089e1
feat: add simple recurrence support
Co-authored-by: Weber Wei <weberwcwei@users.noreply.github.com>
2026-05-04 05:54:01 +01:00
Peter Steinberger
b7f1ac959f
feat: add EventKit alarm support
Co-authored-by: 杨林 <yanglin@M1.local>
2026-05-04 05:51:03 +01:00
Peter Steinberger
57a73aaae1
feat: expose reminder URL metadata 2026-05-04 05:44:15 +01:00
Peter Steinberger
50cc4f56ba
feat: expose reminder creation date
Co-authored-by: Travis Irby <travis.irby@gmail.com>
2026-05-04 05:42:54 +01:00
Peter Steinberger
0dcfe4cdf1
docs: note open filter 2026-05-04 05:27:59 +01:00
Peter Peirce
f948ec77ea
feat: add open filter to show all incomplete reminders
The existing filters don't cover incomplete reminders without due dates.
`upcoming` requires a due date, so undated items (e.g. grocery lists)
are only visible via `all` which includes completed items too.

`remindctl open` returns all uncompleted reminders regardless of
due date status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit c5019fd566da5861dafc932d4ca9792cf33b6e46)
2026-05-04 05:27:54 +01:00
Peter Steinberger
bc92d10262
docs: add Reminders prompt workaround 2026-05-04 05:27:06 +01:00
Peter Steinberger
0c1b48dad2
chore: bump version to 0.2.0 2026-05-04 05:24:29 +01:00
Peter Steinberger
3d8d1a9e01
fix: preserve reminder due date intent 2026-05-04 05:24:23 +01:00
Peter Steinberger
a2215b6e8b
chore: update Commander dependency 2026-05-04 02:04:22 +01:00
38 changed files with 1295 additions and 150 deletions

View File

@ -9,7 +9,7 @@ jobs:
build: build:
runs-on: macos-15 runs-on: macos-15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Swift version - name: Swift version
run: swift --version run: swift --version
- name: Install SwiftLint - name: Install SwiftLint

View File

@ -16,7 +16,7 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
@ -56,7 +56,7 @@ jobs:
) )
- name: Publish release assets - name: Publish release assets
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
files: dist/remindctl-macos.zip files: dist/remindctl-macos.zip
env: env:
@ -83,3 +83,57 @@ jobs:
fi fi
gh release edit "$TAG" --notes-file "$notes_file" gh release edit "$TAG" --notes-file "$notes_file"
update-homebrew-tap:
runs-on: ubuntu-latest
needs: release
steps:
- name: Resolve release tag
run: echo "RELEASE_TAG=${{ inputs.tag }}" >> "$GITHUB_ENV"
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="remindctl-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update remindctl for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=remindctl \
-f tag="$RELEASE_TAG" \
-f repository=steipete/remindctl \
-f macos_artifact=remindctl-macos.zip \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10

View File

@ -1,5 +1,21 @@
# Changelog # Changelog
## Unreleased
- Resolve numeric edit/complete/delete indexes against the default `show` view instead of unrelated completed reminders.
- Add a release helper for Homebrew tap updates; thanks @dinakars777.
## 0.2.0 - 2026-05-04
- Add location-based reminder triggers via `--location`, `--leaving`, and `--radius`
- Add simple recurrence support via `--repeat` and `--no-repeat`
- Add EventKit alarm support via `--alarm` and `--clear-alarm`
- Add reminder `url` to JSON output when EventKit exposes one
- Add `lastModifiedDate` to reminder JSON output
- Add `creationDate` to reminder JSON output
- Add `open` filter for all incomplete reminders
- Accept local ISO 8601 due dates without a timezone suffix
- Preserve date-only due inputs as all-day reminders instead of midnight reminders
- Allow `list` to show reminders from multiple list names in one command
## 0.1.1 - 2026-01-11 ## 0.1.1 - 2026-01-11
- Fix Swift 6 strict concurrency crash when fetching reminders - Fix Swift 6 strict concurrency crash when fetching reminders

View File

@ -6,8 +6,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Commander.git", "location" : "https://github.com/steipete/Commander.git",
"state" : { "state" : {
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02", "revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
"version" : "0.2.0" "version" : "0.2.2"
} }
} }
], ],

View File

@ -16,6 +16,7 @@ let package = Package(
name: "RemindCore", name: "RemindCore",
dependencies: [], dependencies: [],
linkerSettings: [ linkerSettings: [
.linkedFramework("CoreLocation"),
.linkedFramework("EventKit"), .linkedFramework("EventKit"),
] ]
), ),

247
README.md
View File

@ -1,74 +1,237 @@
# remindctl # remindctl
Forget the app, not the task ✅ Fast command-line access to Apple Reminders on macOS.
Fast CLI for Apple Reminders on macOS. `remindctl` is for scripts, agents, and terminal workflows that need to read and update the same reminders you see in Reminders.app. It uses Apple's public EventKit APIs, so reminders keep syncing through the normal system/iCloud path.
## Install ## Install
### Homebrew (Home Pro) ### Homebrew
```bash ```bash
brew install steipete/tap/remindctl brew install steipete/tap/remindctl
``` ```
### From source ### From Source
```bash ```bash
pnpm install pnpm install
pnpm build pnpm build
# binary at ./bin/remindctl # binary at ./bin/remindctl
``` ```
## Development
```bash
make remindctl ARGS="status" # clean build + run
make check # lint + test + coverage gate
```
## Requirements ## Requirements
- macOS 14+ (Sonoma or later) - macOS 14+ (Sonoma or later)
- Swift 6.2+ - Swift 6.2+ when building from source
- Reminders permission (System Settings → Privacy & Security → Reminders) - Full Reminders access for the terminal app that runs `remindctl`
## Quick Start
## Usage
```bash ```bash
remindctl # show today (default)
remindctl today # show today
remindctl tomorrow # show tomorrow
remindctl week # show this week
remindctl overdue # overdue
remindctl upcoming # upcoming
remindctl completed # completed
remindctl all # all reminders
remindctl 2026-01-03 # specific date
remindctl list # lists
remindctl list Work # show list
remindctl list Work --rename Office
remindctl list Work --delete
remindctl list Projects --create
remindctl add "Buy milk" remindctl add "Buy milk"
remindctl add --title "Call mom" --list Personal --due tomorrow remindctl add "Call mom" --list Personal --due tomorrow
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
remindctl today
remindctl overdue
remindctl open
remindctl list Work Errands
remindctl edit 1 --title "New title" --due 2026-01-04 remindctl edit 1 --title "New title" --due 2026-01-04
remindctl complete 1 2 3 remindctl complete 1 2 3
remindctl delete 4A83 --force remindctl delete 4A83 --force
remindctl status # permission status
remindctl authorize # request permissions
``` ```
## Output formats Indexes such as `1` come from the default reminder listing. Most commands also accept an ID prefix such as `4A83`.
- `--json` emits JSON arrays/objects.
- `--plain` emits tab-separated lines. ## Commands
- `--quiet` emits counts only.
| Command | Purpose |
| --- | --- |
| `remindctl` / `remindctl today` | Show today's reminders |
| `remindctl show <filter>` | Show reminders by filter or date |
| `remindctl list` | Show reminder lists |
| `remindctl list <name...>` | Show reminders from one or more lists |
| `remindctl add <title>` | Create a reminder |
| `remindctl edit <id>` | Edit a reminder by index or ID prefix |
| `remindctl complete <id...>` | Mark reminders complete |
| `remindctl delete <id...>` | Delete reminders |
| `remindctl status` | Show Reminders permission status |
| `remindctl authorize` | Request Reminders permission when macOS allows it |
Run `remindctl <command> --help` for the full option list.
## Showing Reminders
Common filters:
```bash
remindctl today
remindctl tomorrow
remindctl week
remindctl overdue
remindctl upcoming
remindctl open
remindctl completed
remindctl all
remindctl 2026-01-03
```
Limit a view to one list:
```bash
remindctl show overdue --list Work
```
Show multiple lists together:
```bash
remindctl list Work Errands
```
## Lists
```bash
remindctl list
remindctl list Work
remindctl list Projects --create
remindctl list Work --rename Office
remindctl list OldList --delete --force
```
Mutating list operations accept one list name. Read-only list views can accept multiple names.
## Dates And Due Times
Accepted by `--due` and date filters:
## Date formats
Accepted by `--due` and filters:
- `today`, `tomorrow`, `yesterday` - `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD` - `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm` - `YYYY-MM-DD HH:mm`
- ISO 8601 (`2026-01-03T12:34:56Z`) - ISO 8601 with timezone, such as `2026-01-03T12:34:56Z`
- Local ISO 8601 without timezone, such as `2026-01-03T12:34:56`
Date-only due values create all-day reminders. Date-time values create timed reminders.
## Alarms
Timed due reminders automatically get an EventKit notification alarm at the due time. Use `--alarm` to choose a different alarm time.
```bash
remindctl add "Meeting" --due "2026-01-03 09:00" --alarm "2026-01-03 08:55"
remindctl edit 4A83 --alarm "2026-01-03 08:55"
remindctl edit 4A83 --clear-alarm
```
This is public EventKit alarm support. Apple's private Reminders "Urgent" toggle is not exposed by EventKit.
## Repeat
Use `--repeat` with `add` or `edit` for simple recurrence:
```bash
remindctl add "Take vitamins" --due tomorrow --repeat daily
remindctl add "Water filter" --due "2026-09-13" --repeat "every 6 months"
remindctl edit 4A83 --repeat weekly
remindctl edit 4A83 --no-repeat
```
Supported repeat values:
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`
- `every N days/weeks/months/years`
## Location Triggers
Use `--location` on `add` to create an arriving geofence trigger. Add `--leaving` to trigger when leaving, and `--radius` to customize the geofence radius in meters.
```bash
remindctl add "Check mailbox" --location "1 Apple Park Way, Cupertino, CA"
remindctl add "Lock up" --location "Home" --leaving
remindctl add "Get groceries" --location "123 Main St" --radius 200
```
Location triggers use EventKit and CoreLocation geocoding. They may depend on system location services and network availability.
## Output
Global output flags:
- `--json` emits machine-readable JSON.
- `--plain` emits stable tab-separated lines.
- `--quiet` emits minimal output, usually counts or nothing.
- `--no-color` disables colored output.
- `--no-input` disables interactive prompts.
JSON includes public EventKit metadata when available:
- `creationDate`
- `lastModifiedDate`
- `url`
- `alarmDate`
- `locationTrigger`
- `recurrenceRule`
Example:
```bash
remindctl all --json
remindctl list --json
remindctl status --json
```
## Permissions ## Permissions
Run `remindctl authorize` to trigger the system prompt. If access is denied, enable
Terminal (or remindctl) in System Settings → Privacy & Security → Reminders. Check access:
If running over SSH, grant access on the Mac that runs the command.
```bash
remindctl status
```
Request access:
```bash
remindctl authorize
```
If macOS reports access as denied, enable the terminal app in:
```text
System Settings > Privacy & Security > Reminders
```
If no prompt appears, run this once from the same terminal app:
```bash
osascript -e 'tell application "Reminders" to get name of reminders'
```
Then allow access and rerun:
```bash
remindctl status
```
When running over SSH, grant access on the Mac that actually runs `remindctl`.
## EventKit Limits
`remindctl` intentionally sticks to public EventKit APIs. These Reminders.app features are not exposed through EventKit today:
- Native Reminders sections
- Native Reminders tags and smart lists
- File/image attachments
- Apple's private "Urgent" toggle
Supporting those would require Apple to expose new public APIs or a separate non-EventKit backend.
## Development
```bash
make remindctl ARGS="status" # clean build + run
make check # lint + tests + coverage gate
pnpm build # release build into ./bin/remindctl
```
Release steps live in [docs/RELEASING.md](docs/RELEASING.md).

167
SKILL.md Normal file
View File

@ -0,0 +1,167 @@
---
name: apple-reminders
description: Use remindctl to inspect and manage Apple Reminders on macOS 14+, including show/list/add/edit/complete/delete/status/authorize workflows, due dates, alarms, recurrence, and location triggers.
homepage: https://github.com/steipete/remindctl
---
# Apple Reminders
Use `remindctl` for Apple Reminders on macOS. It uses Apple's public EventKit APIs, so changes sync through the normal Reminders/iCloud path.
## Prerequisites
- macOS 14+ with Reminders.app
- `remindctl` installed and available on `PATH`
- Install with `brew install steipete/tap/remindctl`
- Reminders access for the terminal app that runs `remindctl`
- Use `remindctl status` to check permission state before mutating data
## Use This Skill When
- The user wants to manage Apple Reminders from the terminal
- The user asks for reminders, reminder lists, due dates, completion, deletion, or permission status
- The user wants reminders that appear on iPhone, iPad, or Mac via Apple Reminders
## Do Not Use This Skill When
- The user wants a non-Reminders agent alert, cron job, or timed chatbot reminder
- The user wants calendar events instead of reminders
- The user needs native Reminders sections, tags, smart lists, attachments, or Apple's private "Urgent" toggle
## Current Command Model
- `remindctl` defaults to `show today`
- `show` accepts filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `open`, `completed`, `all`, or a date string
- `show --list <name>` limits a view to one list
- `list` shows all lists with no arguments, or reminders in one or more named lists
- `add` creates a reminder
- `edit` updates a reminder by index or ID prefix
- `complete` marks reminders complete
- `delete` removes reminders
- `status` reports Reminders authorization without prompting
- `authorize` requests permission when possible
## Helpful Aliases
- `lists` and `ls` map to `list`
- `rm` maps to `delete`
- `done` maps to `complete`
## Output And Flags
- `--json`, `-j`, `--json-output`, and `--jsonOutput` emit JSON
- `--plain` emits stable tab-separated output
- `--quiet` emits minimal output
- `--no-color` disables colored output
- `--no-input` disables interactive prompts
Prefer `--json` when another step needs machine-readable data. JSON can include EventKit metadata such as `creationDate`, `lastModifiedDate`, `url`, `alarmDate`, `locationTrigger`, and `recurrenceRule`.
## Reading Reminder Data
- Use `remindctl today --json` or `remindctl show --json` to inspect reminders
- Use `remindctl open --json` for all incomplete reminders, including reminders without due dates
- Use `remindctl list --json` to inspect lists
- Use `remindctl list Work Errands --json` to inspect multiple lists together
- Use `remindctl status --json` to inspect authorization state
- Reminder IDs and display indexes come from `show` output; `complete`, `delete`, and `edit` accept either an index or an ID prefix
## Creating Reminders
`add` accepts the title as a positional argument or via `--title`, but not both.
Important options:
- `--list <name>` choose the target list
- `--due <date>` set the due date
- `--alarm <date>` set the alarm date
- `--notes <text>` add notes
- `--repeat <rule>` set simple recurrence
- `--priority <none|low|medium|high>` set priority
- `--location <address>` create a location trigger
- `--radius <meters>` adjust geofence radius
- `--leaving` trigger on leaving instead of arriving
If no list is provided, `remindctl` uses the Reminders app's default list. Do not assume the default is a specific list name. If the system has no default reminder list, specify `--list`.
Use `--location` whenever using `--radius` or `--leaving`; those flags are invalid on their own.
## Editing Reminders
`edit` can update:
- `--title`
- `--list`
- `--due` or `--clear-due`
- `--alarm` or `--clear-alarm`
- `--notes`
- `--repeat` or `--no-repeat`
- `--priority`
- `--complete` or `--incomplete`
Reject conflicting combinations such as `--due` with `--clear-due`, `--alarm` with `--clear-alarm`, `--repeat` with `--no-repeat`, or `--complete` with `--incomplete`.
Use `remindctl edit <id> --list <new-list>` to move a reminder between lists. Do not tell the user to delete and recreate the reminder for a move; the command already supports moving it directly.
## Dates
Accepted date inputs:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- ISO 8601 with timezone, such as `2026-01-03T12:34:56Z`
- Local ISO 8601 without timezone, such as `2026-01-03T12:34:56`
Rules:
- Date-only inputs create all-day reminders
- Date-time inputs create timed reminders
- Timed due reminders get a notification alarm at the due time unless `--alarm` overrides it
If the user provides only a calendar date, prefer a date-only value instead of inventing a time.
Supported repeat values:
- `daily`, `weekly`, `biweekly`, `monthly`, `yearly`
- `every N days/weeks/months/years`
## Lists
- `remindctl list` prints all lists with reminder and overdue counts
- `remindctl list <name...>` prints reminders in one or more lists
- `remindctl list <name> --create` creates the list if missing
- `remindctl list <name> --delete` deletes the list
- `remindctl list <name> --rename <new-name>` renames the list
- `remindctl list <name> --force` skips confirmation for destructive list deletion
- Create, delete, and rename accept one list name only
## Completion And Deletion
- `complete` and `delete` require one or more IDs or indexes
- `delete` prompts for confirmation unless `--force` or `--no-input` suppresses it
- `complete` supports `--dry-run`
- `delete` supports `--dry-run`
## Permissions
Use `remindctl status` first when permission state matters.
- `status` never prompts
- `authorize` triggers the system prompt when the state is `notDetermined`
- If access is denied, direct the user to `System Settings > Privacy & Security > Reminders`
- If the prompt does not appear, the current workaround is to run:
```bash
osascript -e 'tell application "Reminders" to get name of reminders'
```
When running over SSH, grant access on the Mac that actually runs `remindctl`.
## Response Discipline
- Confirm the reminder title, list, and due date before creating it if any of them are ambiguous
- Use the exact command syntax shown by the current implementation
- Do not reuse stale guidance about deleting and recreating reminders to move them between lists
- Do not assume a fixed default list name
- Do not promise unsupported private Reminders.app features; `remindctl` intentionally stays on public EventKit APIs

View File

@ -1,11 +1,29 @@
import Foundation import Foundation
public struct ParsedUserDate: Equatable, Sendable {
public let date: Date
public let isDateOnly: Bool
public init(date: Date, isDateOnly: Bool) {
self.date = date
self.isDateOnly = isDateOnly
}
}
public enum DateParsing { public enum DateParsing {
public static func parseUserDate( public static func parseUserDate(
_ input: String, _ input: String,
now: Date = Date(), now: Date = Date(),
calendar: Calendar = .current calendar: Calendar = .current
) -> Date? { ) -> Date? {
parseUserDateWithMetadata(input, now: now, calendar: calendar)?.date
}
public static func parseUserDateWithMetadata(
_ input: String,
now: Date = Date(),
calendar: Calendar = .current
) -> ParsedUserDate? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
let lower = trimmed.lowercased() let lower = trimmed.lowercased()
@ -17,37 +35,48 @@ public enum DateParsing {
isoFormatter(withFraction: true).date(from: trimmed) isoFormatter(withFraction: true).date(from: trimmed)
?? isoFormatter(withFraction: false).date(from: trimmed) ?? isoFormatter(withFraction: false).date(from: trimmed)
if let iso { if let iso {
return iso return ParsedUserDate(date: iso, isDateOnly: false)
} }
for formatter in dateFormatters() { let localISO =
localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSSSSS").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss.SSS").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm:ss").date(from: trimmed)
?? localISOFormatter(format: "yyyy-MM-dd'T'HH:mm").date(from: trimmed)
if let localISO {
return ParsedUserDate(date: localISO, isDateOnly: false)
}
for (formatter, isDateOnly) in dateFormatters() {
if let date = formatter.date(from: trimmed) { if let date = formatter.date(from: trimmed) {
return date return ParsedUserDate(date: date, isDateOnly: isDateOnly)
} }
} }
return nil return nil
} }
public static func formatDisplay(_ date: Date, calendar: Calendar = .current) -> String { public static func formatDisplay(_ date: Date, isDateOnly: Bool = false, calendar: Calendar = .current) -> String {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale.current formatter.locale = Locale.current
formatter.timeZone = calendar.timeZone formatter.timeZone = calendar.timeZone
formatter.dateStyle = .medium formatter.dateStyle = .medium
formatter.timeStyle = .short formatter.timeStyle = isDateOnly ? .none : .short
return formatter.string(from: date) return formatter.string(from: date)
} }
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? { private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> ParsedUserDate? {
switch input { switch input {
case "today": case "today":
return calendar.startOfDay(for: now) return ParsedUserDate(date: calendar.startOfDay(for: now), isDateOnly: true)
case "tomorrow": case "tomorrow":
return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
.map { ParsedUserDate(date: $0, isDateOnly: true) }
case "yesterday": case "yesterday":
return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
.map { ParsedUserDate(date: $0, isDateOnly: true) }
case "now": case "now":
return now return ParsedUserDate(date: now, isDateOnly: false)
default: default:
return nil return nil
} }
@ -62,22 +91,30 @@ public enum DateParsing {
return formatter return formatter
} }
private static func dateFormatters() -> [DateFormatter] { private static func localISOFormatter(format: String) -> DateFormatter {
let formats = [ let formatter = DateFormatter()
"yyyy-MM-dd", formatter.locale = Locale(identifier: "en_US_POSIX")
"yyyy-MM-dd HH:mm", formatter.timeZone = TimeZone.current
"yyyy-MM-dd HH:mm:ss", formatter.dateFormat = format
"MM/dd/yyyy", return formatter
"MM/dd/yyyy HH:mm", }
"dd-MM-yy",
"dd-MM-yyyy", private static func dateFormatters() -> [(DateFormatter, Bool)] {
let formats: [(String, Bool)] = [
("yyyy-MM-dd", true),
("yyyy-MM-dd HH:mm", false),
("yyyy-MM-dd HH:mm:ss", false),
("MM/dd/yyyy", true),
("MM/dd/yyyy HH:mm", false),
("dd-MM-yy", true),
("dd-MM-yyyy", true),
] ]
return formats.map { format in return formats.map { format, isDateOnly in
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current formatter.timeZone = TimeZone.current
formatter.dateFormat = format formatter.dateFormat = format
return formatter return (formatter, isDateOnly)
} }
} }
} }

View File

@ -18,6 +18,8 @@ public enum RemindCoreError: LocalizedError, Sendable, Equatable {
"Reminders access denied.", "Reminders access denied.",
"Run `remindctl authorize` to trigger the prompt, then allow Terminal (or remindctl)", "Run `remindctl authorize` to trigger the prompt, then allow Terminal (or remindctl)",
"in System Settings > Privacy & Security > Reminders.", "in System Settings > Privacy & Security > Reminders.",
"If no prompt appears, run `osascript -e 'tell application \"Reminders\" to get name of reminders'`",
"once from the same terminal app.",
"If running over SSH, grant access on the Mac that runs the command.", "If running over SSH, grant access on the Mac that runs the command.",
].joined(separator: " ") ].joined(separator: " ")
case .writeOnlyAccess: case .writeOnlyAccess:

View File

@ -1,6 +1,12 @@
import CoreLocation
import EventKit import EventKit
import Foundation import Foundation
private func isAllDay(_ components: DateComponents?) -> Bool {
guard let components else { return false }
return components.hour == nil && components.minute == nil && components.second == nil
}
public actor RemindersStore { public actor RemindersStore {
private let eventStore = EKEventStore() private let eventStore = EKEventStore()
private let calendar: Calendar private let calendar: Calendar
@ -103,18 +109,19 @@ public actor RemindersStore {
if let dueDate = draft.dueDate { if let dueDate = draft.dueDate {
reminder.dueDateComponents = calendarComponents(from: dueDate) reminder.dueDateComponents = calendarComponents(from: dueDate)
} }
if let alarmDate = draft.alarmDate {
reminder.addAlarm(EKAlarm(absoluteDate: alarmDate.date))
} else if let dueDate = draft.dueDate, !dueDate.isDateOnly {
reminder.addAlarm(EKAlarm(absoluteDate: dueDate.date))
}
if let recurrenceRule = draft.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceRule)
}
if let locationTrigger = draft.locationTrigger {
reminder.addAlarm(try await locationAlarm(from: locationTrigger))
}
try eventStore.save(reminder, commit: true) try eventStore.save(reminder, commit: true)
return ReminderItem( return item(from: reminder)
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
)
} }
public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem { public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem {
@ -128,11 +135,21 @@ public actor RemindersStore {
} }
if let dueDateUpdate = update.dueDate { if let dueDateUpdate = update.dueDate {
if let dueDate = dueDateUpdate { if let dueDate = dueDateUpdate {
reminder.dueDateComponents = nil
reminder.dueDateComponents = calendarComponents(from: dueDate) reminder.dueDateComponents = calendarComponents(from: dueDate)
if update.alarmDate == nil && !dueDate.isDateOnly {
replaceAlarms(on: reminder, with: dueDate.date)
}
} else { } else {
reminder.dueDateComponents = nil reminder.dueDateComponents = nil
} }
} }
if let alarmDateUpdate = update.alarmDate {
replaceAlarms(on: reminder, with: alarmDateUpdate?.date)
}
if let recurrenceUpdate = update.recurrenceRule {
replaceRecurrence(on: reminder, with: recurrenceUpdate)
}
if let priority = update.priority { if let priority = update.priority {
reminder.priority = priority.eventKitValue reminder.priority = priority.eventKitValue
} }
@ -145,17 +162,7 @@ public actor RemindersStore {
try eventStore.save(reminder, commit: true) try eventStore.save(reminder, commit: true)
return ReminderItem( return item(from: reminder)
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
)
} }
public func completeReminders(ids: [String]) async throws -> [ReminderItem] { public func completeReminders(ids: [String]) async throws -> [ReminderItem] {
@ -164,19 +171,7 @@ public actor RemindersStore {
let reminder = try reminder(withID: id) let reminder = try reminder(withID: id)
reminder.isCompleted = true reminder.isCompleted = true
try eventStore.save(reminder, commit: true) try eventStore.save(reminder, commit: true)
updated.append( updated.append(item(from: reminder))
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 updated return updated
} }
@ -190,7 +185,9 @@ public actor RemindersStore {
} }
return deleted return deleted
} }
}
extension RemindersStore {
private func requestFullAccess() async throws -> Bool { private func requestFullAccess() async throws -> Bool {
try await withCheckedThrowingContinuation { continuation in try await withCheckedThrowingContinuation { continuation in
eventStore.requestFullAccessToReminders { granted, error in eventStore.requestFullAccessToReminders { granted, error in
@ -208,10 +205,17 @@ public actor RemindersStore {
let id: String let id: String
let title: String let title: String
let notes: String? let notes: String?
let url: URL?
let isCompleted: Bool let isCompleted: Bool
let completionDate: Date? let completionDate: Date?
let creationDate: Date?
let lastModifiedDate: Date?
let priority: Int let priority: Int
let dueDateComponents: DateComponents? let dueDateComponents: DateComponents?
let dueDateIsAllDay: Bool
let alarmDate: Date?
let recurrenceRule: RecurrenceRule?
let locationTrigger: LocationTrigger?
let listID: String let listID: String
let listName: String let listName: String
} }
@ -220,14 +224,22 @@ public actor RemindersStore {
let predicate = eventStore.predicateForReminders(in: calendars) let predicate = eventStore.predicateForReminders(in: calendars)
eventStore.fetchReminders(matching: predicate) { reminders in eventStore.fetchReminders(matching: predicate) { reminders in
let data = (reminders ?? []).map { reminder in let data = (reminders ?? []).map { reminder in
ReminderData( let components = reminder.dueDateComponents
return ReminderData(
id: reminder.calendarItemIdentifier, id: reminder.calendarItemIdentifier,
title: reminder.title ?? "", title: reminder.title ?? "",
notes: reminder.notes, notes: reminder.notes,
url: reminder.url,
isCompleted: reminder.isCompleted, isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate, completionDate: reminder.completionDate,
creationDate: reminder.creationDate,
lastModifiedDate: reminder.lastModifiedDate,
priority: Int(reminder.priority), priority: Int(reminder.priority),
dueDateComponents: reminder.dueDateComponents, dueDateComponents: components,
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
listID: reminder.calendar.calendarIdentifier, listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title listName: reminder.calendar.title
) )
@ -241,10 +253,17 @@ public actor RemindersStore {
id: data.id, id: data.id,
title: data.title, title: data.title,
notes: data.notes, notes: data.notes,
url: data.url,
isCompleted: data.isCompleted, isCompleted: data.isCompleted,
completionDate: data.completionDate, completionDate: data.completionDate,
creationDate: data.creationDate,
lastModifiedDate: data.lastModifiedDate,
priority: ReminderPriority(eventKitValue: data.priority), priority: ReminderPriority(eventKitValue: data.priority),
dueDate: date(from: data.dueDateComponents), dueDate: date(from: data.dueDateComponents),
dueDateIsAllDay: data.dueDateIsAllDay,
alarmDate: data.alarmDate,
recurrenceRule: data.recurrenceRule,
locationTrigger: data.locationTrigger,
listID: data.listID, listID: data.listID,
listName: data.listName listName: data.listName
) )
@ -266,8 +285,15 @@ public actor RemindersStore {
return calendar return calendar
} }
private func calendarComponents(from date: Date) -> DateComponents { private func calendarComponents(from parsed: ParsedUserDate) -> DateComponents {
calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) let components: Set<Calendar.Component> =
parsed.isDateOnly
? [.year, .month, .day]
: [.year, .month, .day, .hour, .minute, .second]
var result = calendar.dateComponents(components, from: parsed.date)
result.calendar = calendar
result.timeZone = calendar.timeZone
return result
} }
private func date(from components: DateComponents?) -> Date? { private func date(from components: DateComponents?) -> Date? {
@ -276,16 +302,141 @@ public actor RemindersStore {
} }
private func item(from reminder: EKReminder) -> ReminderItem { private func item(from reminder: EKReminder) -> ReminderItem {
ReminderItem( let components = reminder.dueDateComponents
return ReminderItem(
id: reminder.calendarItemIdentifier, id: reminder.calendarItemIdentifier,
title: reminder.title ?? "", title: reminder.title ?? "",
notes: reminder.notes, notes: reminder.notes,
url: reminder.url,
isCompleted: reminder.isCompleted, isCompleted: reminder.isCompleted,
completionDate: reminder.completionDate, completionDate: reminder.completionDate,
creationDate: reminder.creationDate,
lastModifiedDate: reminder.lastModifiedDate,
priority: ReminderPriority(eventKitValue: Int(reminder.priority)), priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
dueDate: date(from: reminder.dueDateComponents), dueDate: date(from: components),
dueDateIsAllDay: isAllDay(components),
alarmDate: Self.alarmDate(from: reminder),
recurrenceRule: Self.recurrenceRule(from: reminder),
locationTrigger: Self.locationTrigger(from: reminder),
listID: reminder.calendar.calendarIdentifier, listID: reminder.calendar.calendarIdentifier,
listName: reminder.calendar.title listName: reminder.calendar.title
) )
} }
private func replaceAlarms(on reminder: EKReminder, with date: Date?) {
for alarm in reminder.alarms ?? [] {
reminder.removeAlarm(alarm)
}
if let date {
reminder.addAlarm(EKAlarm(absoluteDate: date))
}
}
private static func alarmDate(from reminder: EKReminder) -> Date? {
reminder.alarms?
.compactMap(\.absoluteDate)
.min()
}
private func replaceRecurrence(on reminder: EKReminder, with rule: RecurrenceRule?) {
for existing in reminder.recurrenceRules ?? [] {
reminder.removeRecurrenceRule(existing)
}
guard let rule else { return }
reminder.addRecurrenceRule(
EKRecurrenceRule(recurrenceWith: rule.eventKitFrequency, interval: rule.interval, end: nil))
}
private static func recurrenceRule(from reminder: EKReminder) -> RecurrenceRule? {
guard let rule = reminder.recurrenceRules?.first else { return nil }
guard let frequency = RecurrenceFrequency(eventKitFrequency: rule.frequency) else { return nil }
return RecurrenceRule(frequency: frequency, interval: rule.interval)
}
private func locationAlarm(from trigger: LocationTrigger) async throws -> EKAlarm {
let structuredLocation = EKStructuredLocation(title: trigger.address)
let location: CLLocation
if let latitude = trigger.latitude, let longitude = trigger.longitude {
location = CLLocation(latitude: latitude, longitude: longitude)
} else {
let placemarks = try await CLGeocoder().geocodeAddressString(trigger.address)
guard let geocodedLocation = placemarks.first?.location else {
throw RemindCoreError.operationFailed("Could not geocode location: \(trigger.address)")
}
location = geocodedLocation
}
structuredLocation.geoLocation = location
structuredLocation.radius = trigger.radius
let alarm = EKAlarm()
alarm.structuredLocation = structuredLocation
alarm.proximity = trigger.proximity == .arriving ? .enter : .leave
return alarm
}
private static func locationTrigger(from reminder: EKReminder) -> LocationTrigger? {
guard let alarm = reminder.alarms?.first(where: { $0.structuredLocation != nil }),
let structuredLocation = alarm.structuredLocation,
let proximity = LocationProximity(eventKitProximity: alarm.proximity)
else { return nil }
let coordinate = structuredLocation.geoLocation?.coordinate
return LocationTrigger(
address: structuredLocation.title ?? "",
latitude: coordinate?.latitude,
longitude: coordinate?.longitude,
radius: structuredLocation.radius,
proximity: proximity
)
}
}
extension RecurrenceFrequency {
fileprivate init?(eventKitFrequency: EKRecurrenceFrequency) {
switch eventKitFrequency {
case .daily:
self = .daily
case .weekly:
self = .weekly
case .monthly:
self = .monthly
case .yearly:
self = .yearly
@unknown default:
return nil
}
}
fileprivate var eventKitFrequency: EKRecurrenceFrequency {
switch self {
case .daily:
return .daily
case .weekly:
return .weekly
case .monthly:
return .monthly
case .yearly:
return .yearly
}
}
}
extension RecurrenceRule {
fileprivate var eventKitFrequency: EKRecurrenceFrequency {
frequency.eventKitFrequency
}
}
extension LocationProximity {
fileprivate init?(eventKitProximity: EKAlarmProximity) {
switch eventKitProximity {
case .enter:
self = .arriving
case .leave:
self = .leaving
default:
return nil
}
}
} }

View File

@ -5,18 +5,20 @@ public enum IDResolver {
public static func resolve( public static func resolve(
_ inputs: [String], _ inputs: [String],
from reminders: [ReminderItem] from reminders: [ReminderItem],
numericFrom numericReminders: [ReminderItem]? = nil
) throws -> [ReminderItem] { ) throws -> [ReminderItem] {
let sorted = ReminderFiltering.sort(reminders) let sorted = ReminderFiltering.sort(reminders)
let numericSorted = ReminderFiltering.sort(numericReminders ?? reminders)
var resolved: [ReminderItem] = [] var resolved: [ReminderItem] = []
for input in inputs { for input in inputs {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if let index = Int(trimmed) { if let index = Int(trimmed) {
let idx = index - 1 let idx = index - 1
guard idx >= 0 && idx < sorted.count else { guard idx >= 0 && idx < numericSorted.count else {
throw RemindCoreError.invalidIdentifier(trimmed) throw RemindCoreError.invalidIdentifier(trimmed)
} }
resolved.append(sorted[idx]) resolved.append(numericSorted[idx])
continue continue
} }

View File

@ -33,6 +33,37 @@ public enum ReminderPriority: String, Codable, CaseIterable, Sendable {
} }
} }
public enum RecurrenceFrequency: String, Codable, CaseIterable, Sendable {
case daily
case weekly
case monthly
case yearly
}
public struct RecurrenceRule: Codable, Sendable, Equatable {
public let frequency: RecurrenceFrequency
public let interval: Int
public init(frequency: RecurrenceFrequency, interval: Int = 1) {
self.frequency = frequency
self.interval = interval
}
public var displayString: String {
if interval == 1 {
return frequency.rawValue
}
let unit =
switch frequency {
case .daily: "days"
case .weekly: "weeks"
case .monthly: "months"
case .yearly: "years"
}
return "every \(interval) \(unit)"
}
}
public struct ReminderList: Identifiable, Codable, Sendable, Equatable { public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
public let id: String public let id: String
public let title: String public let title: String
@ -43,14 +74,48 @@ public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
} }
} }
public enum LocationProximity: String, Codable, CaseIterable, Sendable {
case arriving
case leaving
}
public struct LocationTrigger: Codable, Sendable, Equatable {
public let address: String
public let latitude: Double?
public let longitude: Double?
public let radius: Double
public let proximity: LocationProximity
public init(
address: String,
latitude: Double? = nil,
longitude: Double? = nil,
radius: Double = 100,
proximity: LocationProximity = .arriving
) {
self.address = address
self.latitude = latitude
self.longitude = longitude
self.radius = radius
self.proximity = proximity
}
}
public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public let id: String public let id: String
public let title: String public let title: String
public let notes: String? public let notes: String?
public let url: URL?
public let isCompleted: Bool public let isCompleted: Bool
public let completionDate: Date? public let completionDate: Date?
public let creationDate: Date?
public let lastModifiedDate: Date?
public let priority: ReminderPriority public let priority: ReminderPriority
public let dueDate: Date? public let dueDate: Date?
public let dueDateIsAllDay: Bool
public let alarmDate: Date?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let listID: String public let listID: String
public let listName: String public let listName: String
@ -58,20 +123,34 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
id: String, id: String,
title: String, title: String,
notes: String?, notes: String?,
url: URL? = nil,
isCompleted: Bool, isCompleted: Bool,
completionDate: Date?, completionDate: Date?,
creationDate: Date? = nil,
lastModifiedDate: Date? = nil,
priority: ReminderPriority, priority: ReminderPriority,
dueDate: Date?, dueDate: Date?,
dueDateIsAllDay: Bool = false,
alarmDate: Date? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
listID: String, listID: String,
listName: String listName: String
) { ) {
self.id = id self.id = id
self.title = title self.title = title
self.notes = notes self.notes = notes
self.url = url
self.isCompleted = isCompleted self.isCompleted = isCompleted
self.completionDate = completionDate self.completionDate = completionDate
self.creationDate = creationDate
self.lastModifiedDate = lastModifiedDate
self.priority = priority self.priority = priority
self.dueDate = dueDate self.dueDate = dueDate
self.dueDateIsAllDay = dueDateIsAllDay
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.listID = listID self.listID = listID
self.listName = listName self.listName = listName
} }
@ -80,13 +159,27 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
public struct ReminderDraft: Sendable { public struct ReminderDraft: Sendable {
public let title: String public let title: String
public let notes: String? public let notes: String?
public let dueDate: Date? public let dueDate: ParsedUserDate?
public let alarmDate: ParsedUserDate?
public let recurrenceRule: RecurrenceRule?
public let locationTrigger: LocationTrigger?
public let priority: ReminderPriority public let priority: ReminderPriority
public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) { public init(
title: String,
notes: String?,
dueDate: ParsedUserDate?,
alarmDate: ParsedUserDate? = nil,
recurrenceRule: RecurrenceRule? = nil,
locationTrigger: LocationTrigger? = nil,
priority: ReminderPriority
) {
self.title = title self.title = title
self.notes = notes self.notes = notes
self.dueDate = dueDate self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.locationTrigger = locationTrigger
self.priority = priority self.priority = priority
} }
} }
@ -94,7 +187,9 @@ public struct ReminderDraft: Sendable {
public struct ReminderUpdate: Sendable { public struct ReminderUpdate: Sendable {
public let title: String? public let title: String?
public let notes: String? public let notes: String?
public let dueDate: Date?? public let dueDate: ParsedUserDate??
public let alarmDate: ParsedUserDate??
public let recurrenceRule: RecurrenceRule??
public let priority: ReminderPriority? public let priority: ReminderPriority?
public let listName: String? public let listName: String?
public let isCompleted: Bool? public let isCompleted: Bool?
@ -102,7 +197,9 @@ public struct ReminderUpdate: Sendable {
public init( public init(
title: String? = nil, title: String? = nil,
notes: String? = nil, notes: String? = nil,
dueDate: Date?? = nil, dueDate: ParsedUserDate?? = nil,
alarmDate: ParsedUserDate?? = nil,
recurrenceRule: RecurrenceRule?? = nil,
priority: ReminderPriority? = nil, priority: ReminderPriority? = nil,
listName: String? = nil, listName: String? = nil,
isCompleted: Bool? = nil isCompleted: Bool? = nil
@ -110,6 +207,8 @@ public struct ReminderUpdate: Sendable {
self.title = title self.title = title
self.notes = notes self.notes = notes
self.dueDate = dueDate self.dueDate = dueDate
self.alarmDate = alarmDate
self.recurrenceRule = recurrenceRule
self.priority = priority self.priority = priority
self.listName = listName self.listName = listName
self.isCompleted = isCompleted self.isCompleted = isCompleted

View File

@ -6,6 +6,7 @@ public enum ReminderFilter: Equatable, Sendable {
case week case week
case overdue case overdue
case upcoming case upcoming
case open
case completed case completed
case date(Date) case date(Date)
case all case all
@ -25,6 +26,8 @@ public enum ReminderFiltering {
return .overdue return .overdue
case "upcoming", "u": case "upcoming", "u":
return .upcoming return .upcoming
case "open":
return .open
case "completed", "done", "c": case "completed", "done", "c":
return .completed return .completed
case "all", "a": case "all", "a":
@ -76,6 +79,8 @@ public enum ReminderFiltering {
return reminders.filter { reminder in return reminders.filter { reminder in
!reminder.isCompleted && reminder.dueDate != nil !reminder.isCompleted && reminder.dueDate != nil
} }
case .open:
return reminders.filter { !$0.isCompleted }
case .completed: case .completed:
return reminders.filter { $0.isCompleted } return reminders.filter { $0.isCompleted }
case .date(let date): case .date(let date):

View File

@ -17,10 +17,64 @@ enum CommandHelpers {
} }
} }
static func parseDueDate(_ value: String) throws -> Date { static func parseDueDate(_ value: String) throws -> ParsedUserDate {
guard let date = DateParsing.parseUserDate(value) else { guard let parsed = DateParsing.parseUserDateWithMetadata(value) else {
throw RemindCoreError.invalidDate(value) throw RemindCoreError.invalidDate(value)
} }
return date return parsed
}
static func parseRecurrence(_ value: String) throws -> RecurrenceRule {
let normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
switch normalized {
case "daily":
return RecurrenceRule(frequency: .daily)
case "weekly":
return RecurrenceRule(frequency: .weekly)
case "biweekly":
return RecurrenceRule(frequency: .weekly, interval: 2)
case "monthly":
return RecurrenceRule(frequency: .monthly)
case "yearly", "annually":
return RecurrenceRule(frequency: .yearly)
default:
return try parseCustomRecurrence(normalized, original: value)
}
}
static func resolveShowIdentifiers(_ inputs: [String], from reminders: [ReminderItem]) throws -> [ReminderItem] {
let defaultShowReminders = ReminderFiltering.apply(reminders, filter: .today)
return try IDResolver.resolve(inputs, from: reminders, numericFrom: defaultShowReminders)
}
private static func parseCustomRecurrence(_ normalized: String, original: String) throws -> RecurrenceRule {
let parts = normalized.split(separator: " ")
guard parts.count == 3, parts[0] == "every", let interval = Int(parts[1]), interval > 0 else {
throw invalidRecurrence(original)
}
let frequency: RecurrenceFrequency
switch parts[2] {
case "day", "days":
frequency = .daily
case "week", "weeks":
frequency = .weekly
case "month", "months":
frequency = .monthly
case "year", "years":
frequency = .yearly
default:
throw invalidRecurrence(original)
}
return RecurrenceRule(frequency: frequency, interval: interval)
}
private static func invalidRecurrence(_ value: String) -> RemindCoreError {
RemindCoreError.operationFailed(
"""
Invalid repeat value: "\(value)" \
(use daily|weekly|biweekly|monthly|yearly or "every N days/weeks/months/years")
"""
)
} }
} }

View File

@ -17,19 +17,44 @@ enum AddCommand {
.make(label: "title", names: [.long("title")], help: "Reminder title", parsing: .singleValue), .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: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Alarm date", parsing: .singleValue),
.make(
label: "location",
names: [.long("location")],
help: "Location address for geofence trigger",
parsing: .singleValue
),
.make(
label: "radius",
names: [.long("radius")],
help: "Geofence radius in meters (default: 100)",
parsing: .singleValue
),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
.make(
label: "repeat",
names: [.short("r"), .long("repeat")],
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
.make( .make(
label: "priority", label: "priority",
names: [.short("p"), .long("priority")], names: [.short("p"), .long("priority")],
help: "none|low|medium|high", help: "none|low|medium|high",
parsing: .singleValue parsing: .singleValue
), ),
],
flags: [
.make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location")
] ]
) )
), ),
usageExamples: [ usageExamples: [
"remindctl add \"Buy milk\"", "remindctl add \"Buy milk\"",
"remindctl add --title \"Call mom\" --list Personal --due tomorrow", "remindctl add --title \"Call mom\" --list Personal --due tomorrow",
"remindctl add \"Call mom\" --due \"2026-01-03 09:00\" --alarm \"2026-01-03 08:55\"",
"remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"",
"remindctl add \"Take vitamins\" --due tomorrow --repeat daily",
"remindctl add \"Review docs\" --priority high", "remindctl add \"Review docs\" --priority high",
] ]
) { values, runtime in ) { values, runtime in
@ -55,9 +80,20 @@ enum AddCommand {
let listName = values.option("list") let listName = values.option("list")
let notes = values.option("notes") let notes = values.option("notes")
let dueValue = values.option("due") let dueValue = values.option("due")
let alarmValue = values.option("alarm")
let locationValue = values.option("location")
let radiusValue = values.option("radius")
let repeatValue = values.option("repeat")
let priorityValue = values.option("priority") let priorityValue = values.option("priority")
let dueDate = try dueValue.map(CommandHelpers.parseDueDate) let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate)
let locationTrigger = try makeLocationTrigger(
location: locationValue,
radius: radiusValue,
leaving: values.flag("leaving")
)
let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence)
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
let store = RemindersStore() let store = RemindersStore()
@ -73,9 +109,44 @@ enum AddCommand {
throw RemindCoreError.operationFailed("No default list found. Specify --list.") 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,
alarmDate: alarmDate,
recurrenceRule: recurrenceRule,
locationTrigger: locationTrigger,
priority: priority
)
let reminder = try await store.createReminder(draft, listName: targetList) let reminder = try await store.createReminder(draft, listName: targetList)
OutputRenderer.printReminder(reminder, format: runtime.outputFormat) OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
} }
} }
private static func makeLocationTrigger(
location: String?,
radius: String?,
leaving: Bool
) throws -> LocationTrigger? {
if location == nil {
if radius != nil || leaving {
throw RemindCoreError.operationFailed("Use --location with --radius or --leaving")
}
return nil
}
guard let location else { return nil }
let radius = try radius.map(parseRadius) ?? 100
return LocationTrigger(
address: location,
radius: radius,
proximity: leaving ? .leaving : .arriving
)
}
private static func parseRadius(_ value: String) throws -> Double {
guard let radius = Double(value), radius > 0 else {
throw RemindCoreError.operationFailed("Invalid radius: \"\(value)\"")
}
return radius
}
} }

View File

@ -32,7 +32,7 @@ enum CompleteCommand {
let store = RemindersStore() let store = RemindersStore()
try await store.requestAccess() try await store.requestAccess()
let reminders = try await store.reminders(in: nil) let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve(inputs, from: reminders) let resolved = try CommandHelpers.resolveShowIdentifiers(inputs, from: reminders)
if values.flag("dryRun") { if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat) OutputRenderer.printReminders(resolved, format: runtime.outputFormat)

View File

@ -33,7 +33,7 @@ enum DeleteCommand {
let store = RemindersStore() let store = RemindersStore()
try await store.requestAccess() try await store.requestAccess()
let reminders = try await store.reminders(in: nil) let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve(inputs, from: reminders) let resolved = try CommandHelpers.resolveShowIdentifiers(inputs, from: reminders)
if values.flag("dryRun") { if values.flag("dryRun") {
OutputRenderer.printReminders(resolved, format: runtime.outputFormat) OutputRenderer.printReminders(resolved, format: runtime.outputFormat)

View File

@ -17,7 +17,14 @@ enum EditCommand {
.make(label: "title", names: [.short("t"), .long("title")], help: "New title", parsing: .singleValue), .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: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue),
.make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue),
.make(label: "alarm", names: [.short("a"), .long("alarm")], help: "Set alarm date", parsing: .singleValue),
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue),
.make(
label: "repeat",
names: [.short("r"), .long("repeat")],
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
parsing: .singleValue
),
.make( .make(
label: "priority", label: "priority",
names: [.short("p"), .long("priority")], names: [.short("p"), .long("priority")],
@ -27,6 +34,8 @@ enum EditCommand {
], ],
flags: [ flags: [
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"), .make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
.make(label: "clearAlarm", names: [.long("clear-alarm")], help: "Clear alarm"),
.make(label: "noRepeat", names: [.long("no-repeat")], help: "Remove recurrence"),
.make(label: "complete", names: [.long("complete")], help: "Mark completed"), .make(label: "complete", names: [.long("complete")], help: "Mark completed"),
.make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"), .make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
] ]
@ -35,8 +44,10 @@ enum EditCommand {
usageExamples: [ usageExamples: [
"remindctl edit 1 --title \"New title\"", "remindctl edit 1 --title \"New title\"",
"remindctl edit 4A83 --due tomorrow", "remindctl edit 4A83 --due tomorrow",
"remindctl edit 4A83 --alarm \"2026-01-03 08:55\"",
"remindctl edit 4A83 --repeat weekly",
"remindctl edit 2 --priority high --notes \"Call before noon\"", "remindctl edit 2 --priority high --notes \"Call before noon\"",
"remindctl edit 3 --clear-due", "remindctl edit 3 --clear-due --clear-alarm --no-repeat",
] ]
) { values, runtime in ) { values, runtime in
guard let input = values.argument(0) else { guard let input = values.argument(0) else {
@ -46,7 +57,7 @@ enum EditCommand {
let store = RemindersStore() let store = RemindersStore()
try await store.requestAccess() try await store.requestAccess()
let reminders = try await store.reminders(in: nil) let reminders = try await store.reminders(in: nil)
let resolved = try IDResolver.resolve([input], from: reminders) let resolved = try CommandHelpers.resolveShowIdentifiers([input], from: reminders)
guard let reminder = resolved.first else { guard let reminder = resolved.first else {
throw RemindCoreError.reminderNotFound(input) throw RemindCoreError.reminderNotFound(input)
} }
@ -54,8 +65,10 @@ enum EditCommand {
let title = values.option("title") let title = values.option("title")
let listName = values.option("list") let listName = values.option("list")
let notes = values.option("notes") let notes = values.option("notes")
let alarmValue = values.option("alarm")
let repeatValue = values.option("repeat")
var dueUpdate: Date?? var dueUpdate: ParsedUserDate??
if let dueValue = values.option("due") { if let dueValue = values.option("due") {
dueUpdate = try CommandHelpers.parseDueDate(dueValue) dueUpdate = try CommandHelpers.parseDueDate(dueValue)
} }
@ -66,6 +79,28 @@ enum EditCommand {
dueUpdate = .some(nil) dueUpdate = .some(nil)
} }
var alarmUpdate: ParsedUserDate??
if let alarmValue {
alarmUpdate = try CommandHelpers.parseDueDate(alarmValue)
}
if values.flag("clearAlarm") {
if alarmUpdate != nil {
throw RemindCoreError.operationFailed("Use either --alarm or --clear-alarm, not both")
}
alarmUpdate = .some(nil)
}
var recurrenceUpdate: RecurrenceRule??
if let repeatValue {
recurrenceUpdate = try CommandHelpers.parseRecurrence(repeatValue)
}
if values.flag("noRepeat") {
if recurrenceUpdate != nil {
throw RemindCoreError.operationFailed("Use either --repeat or --no-repeat, not both")
}
recurrenceUpdate = .some(nil)
}
var priority: ReminderPriority? var priority: ReminderPriority?
if let priorityValue = values.option("priority") { if let priorityValue = values.option("priority") {
priority = try CommandHelpers.parsePriority(priorityValue) priority = try CommandHelpers.parsePriority(priorityValue)
@ -78,7 +113,10 @@ enum EditCommand {
} }
let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil) let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil)
if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil { let hasChanges =
title != nil || listName != nil || notes != nil || dueUpdate != nil || alarmUpdate != nil || priority != nil
|| recurrenceUpdate != nil || isCompleted != nil
if !hasChanges {
throw RemindCoreError.operationFailed("No changes specified") throw RemindCoreError.operationFailed("No changes specified")
} }
@ -86,6 +124,8 @@ enum EditCommand {
title: title, title: title,
notes: notes, notes: notes,
dueDate: dueUpdate, dueDate: dueUpdate,
alarmDate: alarmUpdate,
recurrenceRule: recurrenceUpdate,
priority: priority, priority: priority,
listName: listName, listName: listName,
isCompleted: isCompleted isCompleted: isCompleted

View File

@ -7,11 +7,11 @@ enum ListCommand {
CommandSpec( CommandSpec(
name: "list", name: "list",
abstract: "List reminder lists or show list contents", abstract: "List reminder lists or show list contents",
discussion: "Without a name, shows all lists. With a name, shows reminders in that list.", discussion: "Without a name, shows all lists. With one or more names, shows reminders in those lists.",
signature: CommandSignatures.withRuntimeFlags( signature: CommandSignatures.withRuntimeFlags(
CommandSignature( CommandSignature(
arguments: [ arguments: [
.make(label: "name", help: "List name", isOptional: true) .make(label: "name", help: "List name(s)", isOptional: true)
], ],
options: [ options: [
.make( .make(
@ -31,12 +31,13 @@ enum ListCommand {
usageExamples: [ usageExamples: [
"remindctl list", "remindctl list",
"remindctl list Work", "remindctl list Work",
"remindctl list Work Errands",
"remindctl list Work --rename Office", "remindctl list Work --rename Office",
"remindctl list Work --delete", "remindctl list Work --delete",
"remindctl list Projects --create", "remindctl list Projects --create",
] ]
) { values, runtime in ) { values, runtime in
let name = values.argument(0) let names = values.positional
let renameTo = values.option("rename") let renameTo = values.option("rename")
let deleteList = values.flag("delete") let deleteList = values.flag("delete")
let createList = values.flag("create") let createList = values.flag("create")
@ -45,7 +46,11 @@ enum ListCommand {
let store = RemindersStore() let store = RemindersStore()
try await store.requestAccess() try await store.requestAccess()
if let name { if !names.isEmpty {
let name = try singleListName(
names,
forMutation: deleteList || renameTo != nil || createList
)
if deleteList { if deleteList {
if !force && !runtime.noInput && Console.isTTY { if !force && !runtime.noInput && Console.isTTY {
if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) { if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) {
@ -80,7 +85,7 @@ enum ListCommand {
return return
} }
let reminders = try await store.reminders(in: name) let reminders = try await reminders(in: names, store: store)
OutputRenderer.printReminders(reminders, format: runtime.outputFormat) OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
return return
} }
@ -109,4 +114,23 @@ enum ListCommand {
OutputRenderer.printLists(summaries, format: runtime.outputFormat) OutputRenderer.printLists(summaries, format: runtime.outputFormat)
} }
} }
static func singleListName(_ names: [String], forMutation: Bool) throws -> String {
guard let name = names.first else {
throw ParsedValuesError.missingArgument("name")
}
if forMutation && names.count > 1 {
throw RemindCoreError.operationFailed("Only one list name can be used with create, delete, or rename")
}
return name
}
private static func reminders(in names: [String], store: RemindersStore) async throws -> [ReminderItem] {
var reminders: [ReminderItem] = []
var seen = Set<String>()
for name in names where seen.insert(name).inserted {
reminders.append(contentsOf: try await store.reminders(in: name))
}
return reminders
}
} }

View File

@ -7,13 +7,13 @@ enum ShowCommand {
CommandSpec( CommandSpec(
name: "show", name: "show",
abstract: "Show reminders", abstract: "Show reminders",
discussion: "Filters: today, tomorrow, week, overdue, upcoming, completed, all, or a date string.", discussion: "Filters: today, tomorrow, week, overdue, upcoming, open, completed, all, or a date string.",
signature: CommandSignatures.withRuntimeFlags( signature: CommandSignatures.withRuntimeFlags(
CommandSignature( CommandSignature(
arguments: [ arguments: [
.make( .make(
label: "filter", label: "filter",
help: "today|tomorrow|week|overdue|upcoming|completed|all|<date>", help: "today|tomorrow|week|overdue|upcoming|open|completed|all|<date>",
isOptional: true isOptional: true
) )
], ],

View File

@ -50,8 +50,12 @@ enum OutputRenderer {
static func printReminder(_ reminder: ReminderItem, format: OutputFormat) { static func printReminder(_ reminder: ReminderItem, format: OutputFormat) {
switch format { switch format {
case .standard: case .standard:
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" let due =
Swift.print("\(reminder.title) [\(reminder.listName)] — \(due)") reminder.dueDate.map {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let recurrence = recurrenceSuffix(for: reminder)
Swift.print("\(reminder.title) [\(reminder.listName)] — \(due)\(recurrence)")
case .plain: case .plain:
Swift.print(plainLine(for: reminder)) Swift.print(plainLine(for: reminder))
case .json: case .json:
@ -96,9 +100,14 @@ enum OutputRenderer {
} }
for (index, reminder) in sorted.enumerated() { for (index, reminder) in sorted.enumerated() {
let status = reminder.isCompleted ? "x" : " " let status = reminder.isCompleted ? "x" : " "
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" let due =
reminder.dueDate.map {
DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay)
} ?? "no due date"
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)") let recurrence = recurrenceSuffix(for: reminder)
Swift.print(
"[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recurrence)")
} }
} }
@ -110,7 +119,15 @@ enum OutputRenderer {
} }
private static func plainLine(for reminder: ReminderItem) -> String { private static func plainLine(for reminder: ReminderItem) -> String {
let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? "" let due: String
if let dueDate = reminder.dueDate {
due =
reminder.dueDateIsAllDay
? dateOnlyFormatter().string(from: dueDate)
: isoFormatter().string(from: dueDate)
} else {
due = ""
}
return [ return [
reminder.id, reminder.id,
reminder.listName, reminder.listName,
@ -157,4 +174,16 @@ enum OutputRenderer {
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter return formatter
} }
private static func dateOnlyFormatter() -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}
private static func recurrenceSuffix(for reminder: ReminderItem) -> String {
reminder.recurrenceRule.map { " repeat=\($0.displayString)" } ?? ""
}
} }

View File

@ -2,6 +2,7 @@ import RemindCore
enum PermissionsHelp { enum PermissionsHelp {
static let settingsPath = "System Settings > Privacy & Security > Reminders" static let settingsPath = "System Settings > Privacy & Security > Reminders"
static let promptWorkaround = #"osascript -e 'tell application "Reminders" to get name of reminders'"#
static func guidanceLines(for status: RemindersAuthorizationStatus) -> [String] { static func guidanceLines(for status: RemindersAuthorizationStatus) -> [String] {
switch status { switch status {
@ -15,6 +16,7 @@ enum PermissionsHelp {
case .denied, .restricted: case .denied, .restricted:
return [ return [
"Grant access in \(settingsPath) for Terminal (or remindctl).", "Grant access in \(settingsPath) for Terminal (or remindctl).",
"If no prompt appears, run `\(promptWorkaround)` once from the same terminal app.",
"If running over SSH, grant access on the Mac that runs the command.", "If running over SSH, grant access on the Mac that runs the command.",
] ]
case .writeOnly: case .writeOnly:

View File

@ -11,9 +11,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.1.1</string> <string>0.2.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>0.1.1</string> <string>0.2.0</string>
<key>NSRemindersUsageDescription</key> <key>NSRemindersUsageDescription</key>
<string>Manage your reminders from the terminal.</string> <string>Manage your reminders from the terminal.</string>
</dict> </dict>

View File

@ -1,4 +1,4 @@
// Generated by scripts/generate-version.sh. Do not edit. // Generated by scripts/generate-version.sh. Do not edit.
enum RemindctlVersion { enum RemindctlVersion {
static let current = "0.1.1" static let current = "0.2.0"
} }

View File

@ -30,6 +30,14 @@ struct DateParsingTests {
#expect(parsed != nil) #expect(parsed != nil)
} }
@Test("Local ISO 8601 without timezone parsing")
func localISOParsing() {
let input = "2026-01-03T12:34:56"
let parsed = DateParsing.parseUserDateWithMetadata(input)
#expect(parsed != nil)
#expect(parsed?.isDateOnly == false)
}
@Test("Formatted date parsing") @Test("Formatted date parsing")
func formattedParsing() { func formattedParsing() {
let input = "2026-01-03 10:30" let input = "2026-01-03 10:30"
@ -43,4 +51,28 @@ struct DateParsingTests {
let output = DateParsing.formatDisplay(date, calendar: calendar) let output = DateParsing.formatDisplay(date, calendar: calendar)
#expect(output.isEmpty == false) #expect(output.isEmpty == false)
} }
@Test("Date-only inputs carry metadata")
func dateOnlyMetadata() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let today = DateParsing.parseUserDateWithMetadata("today", now: now, calendar: calendar)
#expect(today?.date == calendar.startOfDay(for: now))
#expect(today?.isDateOnly == true)
let dateOnly = DateParsing.parseUserDateWithMetadata("2026-01-03")
#expect(dateOnly?.isDateOnly == true)
let dateTime = DateParsing.parseUserDateWithMetadata("2026-01-03 10:30")
#expect(dateTime?.isDateOnly == false)
}
@Test("Display can omit time for all-day reminders")
func displayFormattingDateOnly() {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let timed = DateParsing.formatDisplay(date, calendar: calendar)
let dateOnly = DateParsing.formatDisplay(date, isDateOnly: true, calendar: calendar)
#expect(dateOnly.isEmpty == false)
#expect(timed.count > dateOnly.count)
}
} }

View File

@ -38,6 +38,13 @@ struct IDResolverTests {
#expect(resolved.first?.title == "First") #expect(resolved.first?.title == "First")
} }
@Test("Resolve numeric indexes from filtered show output")
func resolveIndexFromFilteredShowOutput() throws {
let all = sampleReminders()
let resolved = try IDResolver.resolve(["1"], from: all, numericFrom: [all[1]])
#expect(resolved.first?.title == "Second")
}
@Test("Resolve by prefix") @Test("Resolve by prefix")
func resolvePrefix() throws { func resolvePrefix() throws {
let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders()) let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders())

View File

@ -10,6 +10,7 @@ struct ReminderFilterParseTests {
#expect(ReminderFiltering.parse("w") == .week) #expect(ReminderFiltering.parse("w") == .week)
#expect(ReminderFiltering.parse("o") == .overdue) #expect(ReminderFiltering.parse("o") == .overdue)
#expect(ReminderFiltering.parse("u") == .upcoming) #expect(ReminderFiltering.parse("u") == .upcoming)
#expect(ReminderFiltering.parse("open") == .open)
#expect(ReminderFiltering.parse("done") == .completed) #expect(ReminderFiltering.parse("done") == .completed)
#expect(ReminderFiltering.parse("all") == .all) #expect(ReminderFiltering.parse("all") == .all)
} }

View File

@ -108,6 +108,16 @@ struct ReminderFilteringTests {
#expect(result.count == 3) #expect(result.count == 3)
} }
@Test("Open filter includes no due date and excludes completed")
func openFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let items = reminders(now: now)
let result = ReminderFiltering.apply(items, filter: .open, now: now, calendar: calendar)
#expect(result.count == 4)
#expect(result.contains(where: { $0.title == "No Due" }))
#expect(result.allSatisfy { !$0.isCompleted })
}
@Test("Date filter") @Test("Date filter")
func dateFilter() { func dateFilter() {
let now = Date(timeIntervalSince1970: 1_700_000_000) let now = Date(timeIntervalSince1970: 1_700_000_000)

View File

@ -0,0 +1,45 @@
import Foundation
import Testing
@testable import RemindCore
@MainActor
struct ReminderItemCodingTests {
@Test("JSON includes EventKit metadata")
func jsonIncludesEventKitMetadata() throws {
let item = ReminderItem(
id: "abc",
title: "Created",
notes: nil,
url: URL(string: "https://example.com"),
isCompleted: false,
completionDate: nil,
creationDate: Date(timeIntervalSince1970: 1_700_000_000),
lastModifiedDate: Date(timeIntervalSince1970: 1_700_000_100),
priority: .none,
dueDate: nil,
alarmDate: Date(timeIntervalSince1970: 1_700_000_300),
recurrenceRule: RecurrenceRule(frequency: .weekly, interval: 2),
locationTrigger: LocationTrigger(
address: "1 Apple Park Way",
latitude: 37.3349,
longitude: -122.0090,
radius: 100,
proximity: .arriving
),
listID: "list",
listName: "Inbox"
)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(item)
let json = try #require(String(data: data, encoding: .utf8))
#expect(json.contains(#""creationDate""#))
#expect(json.contains(#""lastModifiedDate""#))
#expect(json.contains(#""url":"https:\/\/example.com""#))
#expect(json.contains(#""alarmDate""#))
#expect(json.contains(#""recurrenceRule""#))
#expect(json.contains(#""locationTrigger""#))
}
}

View File

@ -21,4 +21,19 @@ struct HelpPrinterTests {
#expect(joined.contains("status")) #expect(joined.contains("status"))
#expect(joined.contains("authorize")) #expect(joined.contains("authorize"))
} }
@Test("Add and edit help include alarm, location, and repeat options")
func alarmLocationAndRepeatHelp() {
let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n")
let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n")
#expect(addHelp.contains("--alarm"))
#expect(addHelp.contains("--location"))
#expect(addHelp.contains("--leaving"))
#expect(addHelp.contains("--repeat"))
#expect(editHelp.contains("--alarm"))
#expect(editHelp.contains("--clear-alarm"))
#expect(editHelp.contains("--repeat"))
#expect(editHelp.contains("--no-repeat"))
}
} }

View File

@ -0,0 +1,19 @@
import Testing
@testable import remindctl
@MainActor
struct ListCommandTests {
@Test("Multiple list names are allowed for read-only listing")
func multipleNamesAllowedForListing() throws {
let name = try ListCommand.singleListName(["Work", "Home"], forMutation: false)
#expect(name == "Work")
}
@Test("Multiple list names are rejected for mutations")
func multipleNamesRejectedForMutations() {
#expect(throws: Error.self) {
_ = try ListCommand.singleListName(["Work", "Home"], forMutation: true)
}
}
}

View File

@ -0,0 +1,14 @@
import Testing
@testable import RemindCore
@testable import remindctl
@MainActor
struct PermissionsHelpTests {
@Test("Denied guidance includes terminal prompt workaround")
func deniedGuidanceIncludesPromptWorkaround() {
let guidance = PermissionsHelp.guidanceLines(for: .denied).joined(separator: "\n")
#expect(guidance.contains("osascript"))
#expect(guidance.contains("Reminders"))
}
}

View File

@ -0,0 +1,34 @@
import Testing
@testable import RemindCore
@testable import remindctl
@MainActor
struct RecurrenceParsingTests {
@Test("Parse repeat presets")
func parsePresets() throws {
#expect(try CommandHelpers.parseRecurrence("daily") == RecurrenceRule(frequency: .daily))
#expect(try CommandHelpers.parseRecurrence("weekly") == RecurrenceRule(frequency: .weekly))
#expect(try CommandHelpers.parseRecurrence("biweekly") == RecurrenceRule(frequency: .weekly, interval: 2))
#expect(try CommandHelpers.parseRecurrence("monthly") == RecurrenceRule(frequency: .monthly))
#expect(try CommandHelpers.parseRecurrence("yearly") == RecurrenceRule(frequency: .yearly))
}
@Test("Parse custom repeat interval")
func parseCustomInterval() throws {
#expect(try CommandHelpers.parseRecurrence("every 3 days") == RecurrenceRule(frequency: .daily, interval: 3))
#expect(try CommandHelpers.parseRecurrence("every 4 weeks") == RecurrenceRule(frequency: .weekly, interval: 4))
#expect(try CommandHelpers.parseRecurrence("every 6 months") == RecurrenceRule(frequency: .monthly, interval: 6))
#expect(try CommandHelpers.parseRecurrence("every 2 years") == RecurrenceRule(frequency: .yearly, interval: 2))
}
@Test("Reject invalid repeat interval")
func rejectInvalidInterval() {
#expect(throws: (any Error).self) {
try CommandHelpers.parseRecurrence("every 0 weeks")
}
#expect(throws: (any Error).self) {
try CommandHelpers.parseRecurrence("weekdaily")
}
}
}

View File

@ -5,8 +5,8 @@
## Steps ## Steps
1. Update changelog and version 1. Update changelog and version
- Ensure `CHANGELOG.md` has `## 0.1.0 - YYYY-MM-DD` with final notes. - Ensure `CHANGELOG.md` has `## 0.2.0 - YYYY-MM-DD` with final notes.
- Update `version.env` to `0.1.0` (already set for the first release). - Update `version.env` to `0.2.0`.
- Run `scripts/generate-version.sh` (refreshes `Sources/remindctl/Version.swift` + embedded Info.plist). - Run `scripts/generate-version.sh` (refreshes `Sources/remindctl/Version.swift` + embedded Info.plist).
2. Ensure checks are green 2. Ensure checks are green
- `make check` - `make check`
@ -14,11 +14,11 @@
- Requires `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`. - Requires `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`.
- `scripts/sign-and-notarize.sh` (outputs `/tmp/remindctl-macos.zip` by default). - `scripts/sign-and-notarize.sh` (outputs `/tmp/remindctl-macos.zip` by default).
4. Tag, push, and publish 4. Tag, push, and publish
- `git tag -a v0.1.0 -m "v0.1.0"` - `git tag -a v0.2.0 -m "v0.2.0"`
- `git push origin v0.1.0` - `git push origin v0.2.0`
- Extract release notes: - Extract release notes:
```sh ```sh
version=0.1.0 version=0.2.0
notes_file=/tmp/release-notes.txt notes_file=/tmp/release-notes.txt
awk -v v="$version" ' awk -v v="$version" '
$0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next } $0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next }
@ -28,10 +28,11 @@
``` ```
- Create GitHub release: - Create GitHub release:
```sh ```sh
gh release create v0.1.0 /tmp/remindctl-macos.zip -t "v0.1.0" -F /tmp/release-notes.txt gh release create v0.2.0 /tmp/remindctl-macos.zip -t "v0.2.0" -F /tmp/release-notes.txt
``` ```
5. Homebrew tap 5. Update Homebrew tap
- Update `../homebrew-tap/Formula/remindctl.rb` to point at the GitHub release asset. - Run `scripts/update-homebrew.sh vX.Y.Z` to trigger and watch the centralized formula updater.
- Requires a GitHub token with workflow dispatch access to `steipete/homebrew-tap`.
## What happens in CI ## What happens in CI
- Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`. - Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`.

View File

@ -16,7 +16,7 @@ Run on a local GUI session (not SSH-only) so the Reminders permission prompt can
- list lists: `remindctl list` - list lists: `remindctl list`
- list list contents: `remindctl list "remindctl-manual-YYYYMMDD"` - list list contents: `remindctl list "remindctl-manual-YYYYMMDD"`
- add reminders (3 variants) - add reminders (3 variants)
- show filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `completed`, `all` - show filters: `today`, `tomorrow`, `week`, `overdue`, `upcoming`, `open`, `completed`, `all`
- edit: update title/notes/priority/due date - edit: update title/notes/priority/due date
- complete: mark one reminder complete - complete: mark one reminder complete
- delete: remove reminders, then delete list - delete: remove reminders, then delete list

View File

@ -1,6 +1,6 @@
{ {
"name": "remindctl", "name": "remindctl",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"version:sync": "scripts/generate-version.sh", "version:sync": "scripts/generate-version.sh",

50
scripts/update-homebrew.sh Executable file
View File

@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: $0 <release-tag>" >&2
exit 1
fi
TAG="$1"
TAP_REPO="steipete/homebrew-tap"
WORKFLOW="update-formula.yml"
SAFE_TAG=$(printf '%s' "$TAG" | tr -c 'A-Za-z0-9._-' '-')
REQUEST_ID="remindctl-${SAFE_TAG}-$(date -u +%Y%m%dT%H%M%SZ)-$$"
gh workflow run "$WORKFLOW" \
--repo "$TAP_REPO" \
--ref main \
-f formula=remindctl \
-f tag="$TAG" \
-f repository=steipete/remindctl \
-f macos_artifact="remindctl-macos.zip" \
-f request_id="$REQUEST_ID"
echo "Homebrew tap update dispatched: $REQUEST_ID"
RUN_ID=""
for _ in {1..30}; do
RUN_ID=$(gh run list \
--repo "$TAP_REPO" \
--workflow "$WORKFLOW" \
--branch main \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle | contains(\"($REQUEST_ID)\")) | .databaseId" \
| head -n 1)
if [[ -n "$RUN_ID" ]]; then
break
fi
sleep 2
done
if [[ -z "$RUN_ID" ]]; then
echo "Timed out waiting for Homebrew tap workflow run: $REQUEST_ID" >&2
echo "Monitor: https://github.com/$TAP_REPO/actions/workflows/$WORKFLOW" >&2
exit 1
fi
gh run watch "$RUN_ID" --repo "$TAP_REPO" --exit-status

View File

@ -1 +1 @@
MARKETING_VERSION=0.1.1 MARKETING_VERSION=0.2.0