Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883ae25791 | ||
|
|
de80b37c45 | ||
|
|
74beefa96a | ||
|
|
f3cd4eb683 | ||
|
|
1aff71567c | ||
|
|
568457e27e | ||
|
|
cd0ab4c1f4 | ||
|
|
4b79188ab5 | ||
|
|
85a589366e | ||
|
|
20e868d615 | ||
|
|
09b25089e1 | ||
|
|
b7f1ac959f | ||
|
|
57a73aaae1 | ||
|
|
50cc4f56ba | ||
|
|
0dcfe4cdf1 | ||
|
|
f948ec77ea | ||
|
|
bc92d10262 | ||
|
|
0c1b48dad2 | ||
|
|
3d8d1a9e01 | ||
|
|
a2215b6e8b | ||
|
|
49026a5d54 | ||
|
|
ce8556df82 | ||
|
|
7c902d083b |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -9,7 +9,7 @@ jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Swift version
|
||||
run: swift --version
|
||||
- name: Install SwiftLint
|
||||
|
||||
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@ -56,7 +56,7 @@ jobs:
|
||||
)
|
||||
|
||||
- name: Publish release assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: dist/remindctl-macos.zip
|
||||
env:
|
||||
@ -83,3 +83,57 @@ jobs:
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@ -1,5 +1,24 @@
|
||||
# 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
|
||||
- Fix Swift 6 strict concurrency crash when fetching reminders
|
||||
|
||||
## 0.1.0 - 2026-01-03
|
||||
- Reminders CLI with Commander-based command router
|
||||
- Show reminders with filters (today/tomorrow/week/overdue/upcoming/completed/all/date)
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||
"version" : "0.2.0"
|
||||
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
|
||||
"version" : "0.2.2"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@ -16,6 +16,7 @@ let package = Package(
|
||||
name: "RemindCore",
|
||||
dependencies: [],
|
||||
linkerSettings: [
|
||||
.linkedFramework("CoreLocation"),
|
||||
.linkedFramework("EventKit"),
|
||||
]
|
||||
),
|
||||
|
||||
247
README.md
247
README.md
@ -1,74 +1,237 @@
|
||||
# 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
|
||||
|
||||
### Homebrew (Home Pro)
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew install steipete/tap/remindctl
|
||||
```
|
||||
|
||||
### From source
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
# binary at ./bin/remindctl
|
||||
```
|
||||
|
||||
## Development
|
||||
```bash
|
||||
make remindctl ARGS="status" # clean build + run
|
||||
make check # lint + test + coverage gate
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 14+ (Sonoma or later)
|
||||
- Swift 6.2+
|
||||
- Reminders permission (System Settings → Privacy & Security → Reminders)
|
||||
- Swift 6.2+ when building from source
|
||||
- Full Reminders access for the terminal app that runs `remindctl`
|
||||
|
||||
## Quick Start
|
||||
|
||||
## Usage
|
||||
```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 --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 complete 1 2 3
|
||||
remindctl delete 4A83 --force
|
||||
remindctl status # permission status
|
||||
remindctl authorize # request permissions
|
||||
```
|
||||
|
||||
## Output formats
|
||||
- `--json` emits JSON arrays/objects.
|
||||
- `--plain` emits tab-separated lines.
|
||||
- `--quiet` emits counts only.
|
||||
Indexes such as `1` come from the default reminder listing. Most commands also accept an ID prefix such as `4A83`.
|
||||
|
||||
## Commands
|
||||
|
||||
| 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`
|
||||
- `YYYY-MM-DD`
|
||||
- `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
|
||||
Run `remindctl authorize` to trigger the system prompt. If access is denied, enable
|
||||
Terminal (or remindctl) in System Settings → Privacy & Security → Reminders.
|
||||
If running over SSH, grant access on the Mac that runs the command.
|
||||
|
||||
Check access:
|
||||
|
||||
```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
167
SKILL.md
Normal 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
|
||||
@ -1,11 +1,29 @@
|
||||
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 static func parseUserDate(
|
||||
_ input: String,
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> 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 lower = trimmed.lowercased()
|
||||
|
||||
@ -17,37 +35,48 @@ public enum DateParsing {
|
||||
isoFormatter(withFraction: true).date(from: trimmed)
|
||||
?? isoFormatter(withFraction: false).date(from: trimmed)
|
||||
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) {
|
||||
return date
|
||||
return ParsedUserDate(date: date, isDateOnly: isDateOnly)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
formatter.timeStyle = isDateOnly ? .none : .short
|
||||
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 {
|
||||
case "today":
|
||||
return calendar.startOfDay(for: now)
|
||||
return ParsedUserDate(date: calendar.startOfDay(for: now), isDateOnly: true)
|
||||
case "tomorrow":
|
||||
return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
|
||||
.map { ParsedUserDate(date: $0, isDateOnly: true) }
|
||||
case "yesterday":
|
||||
return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
|
||||
.map { ParsedUserDate(date: $0, isDateOnly: true) }
|
||||
case "now":
|
||||
return now
|
||||
return ParsedUserDate(date: now, isDateOnly: false)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@ -62,22 +91,30 @@ public enum DateParsing {
|
||||
return formatter
|
||||
}
|
||||
|
||||
private static func dateFormatters() -> [DateFormatter] {
|
||||
let formats = [
|
||||
"yyyy-MM-dd",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"MM/dd/yyyy",
|
||||
"MM/dd/yyyy HH:mm",
|
||||
"dd-MM-yy",
|
||||
"dd-MM-yyyy",
|
||||
private static func localISOFormatter(format: String) -> DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = format
|
||||
return formatter
|
||||
}
|
||||
|
||||
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()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = format
|
||||
return formatter
|
||||
return (formatter, isDateOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@ public enum RemindCoreError: LocalizedError, Sendable, Equatable {
|
||||
"Reminders access denied.",
|
||||
"Run `remindctl authorize` to trigger the prompt, then allow Terminal (or remindctl)",
|
||||
"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.",
|
||||
].joined(separator: " ")
|
||||
case .writeOnlyAccess:
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import CoreLocation
|
||||
import EventKit
|
||||
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 {
|
||||
private let eventStore = EKEventStore()
|
||||
private let calendar: Calendar
|
||||
@ -103,18 +109,19 @@ public actor RemindersStore {
|
||||
if let dueDate = draft.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)
|
||||
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 {
|
||||
@ -128,11 +135,21 @@ public actor RemindersStore {
|
||||
}
|
||||
if let dueDateUpdate = update.dueDate {
|
||||
if let dueDate = dueDateUpdate {
|
||||
reminder.dueDateComponents = nil
|
||||
reminder.dueDateComponents = calendarComponents(from: dueDate)
|
||||
if update.alarmDate == nil && !dueDate.isDateOnly {
|
||||
replaceAlarms(on: reminder, with: dueDate.date)
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
reminder.priority = priority.eventKitValue
|
||||
}
|
||||
@ -145,17 +162,7 @@ public actor RemindersStore {
|
||||
|
||||
try eventStore.save(reminder, commit: true)
|
||||
|
||||
return ReminderItem(
|
||||
id: reminder.calendarItemIdentifier,
|
||||
title: reminder.title ?? "",
|
||||
notes: reminder.notes,
|
||||
isCompleted: reminder.isCompleted,
|
||||
completionDate: reminder.completionDate,
|
||||
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
|
||||
dueDate: date(from: reminder.dueDateComponents),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
return item(from: reminder)
|
||||
}
|
||||
|
||||
public func completeReminders(ids: [String]) async throws -> [ReminderItem] {
|
||||
@ -164,19 +171,7 @@ public actor RemindersStore {
|
||||
let reminder = try reminder(withID: id)
|
||||
reminder.isCompleted = true
|
||||
try eventStore.save(reminder, commit: true)
|
||||
updated.append(
|
||||
ReminderItem(
|
||||
id: reminder.calendarItemIdentifier,
|
||||
title: reminder.title ?? "",
|
||||
notes: reminder.notes,
|
||||
isCompleted: reminder.isCompleted,
|
||||
completionDate: reminder.completionDate,
|
||||
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
|
||||
dueDate: date(from: reminder.dueDateComponents),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
)
|
||||
updated.append(item(from: reminder))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
@ -190,7 +185,9 @@ public actor RemindersStore {
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
}
|
||||
|
||||
extension RemindersStore {
|
||||
private func requestFullAccess() async throws -> Bool {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
eventStore.requestFullAccessToReminders { granted, error in
|
||||
@ -204,15 +201,73 @@ public actor RemindersStore {
|
||||
}
|
||||
|
||||
private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] {
|
||||
await withCheckedContinuation { continuation in
|
||||
struct ReminderData: Sendable {
|
||||
let id: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let url: URL?
|
||||
let isCompleted: Bool
|
||||
let completionDate: Date?
|
||||
let creationDate: Date?
|
||||
let lastModifiedDate: Date?
|
||||
let priority: Int
|
||||
let dueDateComponents: DateComponents?
|
||||
let dueDateIsAllDay: Bool
|
||||
let alarmDate: Date?
|
||||
let recurrenceRule: RecurrenceRule?
|
||||
let locationTrigger: LocationTrigger?
|
||||
let listID: String
|
||||
let listName: String
|
||||
}
|
||||
|
||||
let reminderData = await withCheckedContinuation { (continuation: CheckedContinuation<[ReminderData], Never>) in
|
||||
let predicate = eventStore.predicateForReminders(in: calendars)
|
||||
eventStore.fetchReminders(matching: predicate) { reminders in
|
||||
let mapped = (reminders ?? []).map { reminder in
|
||||
self.item(from: reminder)
|
||||
let data = (reminders ?? []).map { reminder in
|
||||
let components = reminder.dueDateComponents
|
||||
return ReminderData(
|
||||
id: reminder.calendarItemIdentifier,
|
||||
title: reminder.title ?? "",
|
||||
notes: reminder.notes,
|
||||
url: reminder.url,
|
||||
isCompleted: reminder.isCompleted,
|
||||
completionDate: reminder.completionDate,
|
||||
creationDate: reminder.creationDate,
|
||||
lastModifiedDate: reminder.lastModifiedDate,
|
||||
priority: Int(reminder.priority),
|
||||
dueDateComponents: components,
|
||||
dueDateIsAllDay: isAllDay(components),
|
||||
alarmDate: Self.alarmDate(from: reminder),
|
||||
recurrenceRule: Self.recurrenceRule(from: reminder),
|
||||
locationTrigger: Self.locationTrigger(from: reminder),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
}
|
||||
continuation.resume(returning: mapped)
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
}
|
||||
|
||||
return reminderData.map { data in
|
||||
ReminderItem(
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
notes: data.notes,
|
||||
url: data.url,
|
||||
isCompleted: data.isCompleted,
|
||||
completionDate: data.completionDate,
|
||||
creationDate: data.creationDate,
|
||||
lastModifiedDate: data.lastModifiedDate,
|
||||
priority: ReminderPriority(eventKitValue: data.priority),
|
||||
dueDate: date(from: data.dueDateComponents),
|
||||
dueDateIsAllDay: data.dueDateIsAllDay,
|
||||
alarmDate: data.alarmDate,
|
||||
recurrenceRule: data.recurrenceRule,
|
||||
locationTrigger: data.locationTrigger,
|
||||
listID: data.listID,
|
||||
listName: data.listName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func reminder(withID id: String) throws -> EKReminder {
|
||||
@ -230,8 +285,15 @@ public actor RemindersStore {
|
||||
return calendar
|
||||
}
|
||||
|
||||
private func calendarComponents(from date: Date) -> DateComponents {
|
||||
calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||
private func calendarComponents(from parsed: ParsedUserDate) -> DateComponents {
|
||||
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? {
|
||||
@ -240,16 +302,141 @@ public actor RemindersStore {
|
||||
}
|
||||
|
||||
private func item(from reminder: EKReminder) -> ReminderItem {
|
||||
ReminderItem(
|
||||
let components = reminder.dueDateComponents
|
||||
return ReminderItem(
|
||||
id: reminder.calendarItemIdentifier,
|
||||
title: reminder.title ?? "",
|
||||
notes: reminder.notes,
|
||||
url: reminder.url,
|
||||
isCompleted: reminder.isCompleted,
|
||||
completionDate: reminder.completionDate,
|
||||
creationDate: reminder.creationDate,
|
||||
lastModifiedDate: reminder.lastModifiedDate,
|
||||
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,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,18 +5,20 @@ public enum IDResolver {
|
||||
|
||||
public static func resolve(
|
||||
_ inputs: [String],
|
||||
from reminders: [ReminderItem]
|
||||
from reminders: [ReminderItem],
|
||||
numericFrom numericReminders: [ReminderItem]? = nil
|
||||
) throws -> [ReminderItem] {
|
||||
let sorted = ReminderFiltering.sort(reminders)
|
||||
let numericSorted = ReminderFiltering.sort(numericReminders ?? reminders)
|
||||
var resolved: [ReminderItem] = []
|
||||
for input in inputs {
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let index = Int(trimmed) {
|
||||
let idx = index - 1
|
||||
guard idx >= 0 && idx < sorted.count else {
|
||||
guard idx >= 0 && idx < numericSorted.count else {
|
||||
throw RemindCoreError.invalidIdentifier(trimmed)
|
||||
}
|
||||
resolved.append(sorted[idx])
|
||||
resolved.append(numericSorted[idx])
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@ -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 let id: 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 let id: String
|
||||
public let title: String
|
||||
public let notes: String?
|
||||
public let url: URL?
|
||||
public let isCompleted: Bool
|
||||
public let completionDate: Date?
|
||||
public let creationDate: Date?
|
||||
public let lastModifiedDate: Date?
|
||||
public let priority: ReminderPriority
|
||||
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 listName: String
|
||||
|
||||
@ -58,20 +123,34 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
id: String,
|
||||
title: String,
|
||||
notes: String?,
|
||||
url: URL? = nil,
|
||||
isCompleted: Bool,
|
||||
completionDate: Date?,
|
||||
creationDate: Date? = nil,
|
||||
lastModifiedDate: Date? = nil,
|
||||
priority: ReminderPriority,
|
||||
dueDate: Date?,
|
||||
dueDateIsAllDay: Bool = false,
|
||||
alarmDate: Date? = nil,
|
||||
recurrenceRule: RecurrenceRule? = nil,
|
||||
locationTrigger: LocationTrigger? = nil,
|
||||
listID: String,
|
||||
listName: String
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.notes = notes
|
||||
self.url = url
|
||||
self.isCompleted = isCompleted
|
||||
self.completionDate = completionDate
|
||||
self.creationDate = creationDate
|
||||
self.lastModifiedDate = lastModifiedDate
|
||||
self.priority = priority
|
||||
self.dueDate = dueDate
|
||||
self.dueDateIsAllDay = dueDateIsAllDay
|
||||
self.alarmDate = alarmDate
|
||||
self.recurrenceRule = recurrenceRule
|
||||
self.locationTrigger = locationTrigger
|
||||
self.listID = listID
|
||||
self.listName = listName
|
||||
}
|
||||
@ -80,13 +159,27 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
public struct ReminderDraft: Sendable {
|
||||
public let title: 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 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.notes = notes
|
||||
self.dueDate = dueDate
|
||||
self.alarmDate = alarmDate
|
||||
self.recurrenceRule = recurrenceRule
|
||||
self.locationTrigger = locationTrigger
|
||||
self.priority = priority
|
||||
}
|
||||
}
|
||||
@ -94,7 +187,9 @@ public struct ReminderDraft: Sendable {
|
||||
public struct ReminderUpdate: Sendable {
|
||||
public let title: 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 listName: String?
|
||||
public let isCompleted: Bool?
|
||||
@ -102,7 +197,9 @@ public struct ReminderUpdate: Sendable {
|
||||
public init(
|
||||
title: String? = nil,
|
||||
notes: String? = nil,
|
||||
dueDate: Date?? = nil,
|
||||
dueDate: ParsedUserDate?? = nil,
|
||||
alarmDate: ParsedUserDate?? = nil,
|
||||
recurrenceRule: RecurrenceRule?? = nil,
|
||||
priority: ReminderPriority? = nil,
|
||||
listName: String? = nil,
|
||||
isCompleted: Bool? = nil
|
||||
@ -110,6 +207,8 @@ public struct ReminderUpdate: Sendable {
|
||||
self.title = title
|
||||
self.notes = notes
|
||||
self.dueDate = dueDate
|
||||
self.alarmDate = alarmDate
|
||||
self.recurrenceRule = recurrenceRule
|
||||
self.priority = priority
|
||||
self.listName = listName
|
||||
self.isCompleted = isCompleted
|
||||
|
||||
@ -6,6 +6,7 @@ public enum ReminderFilter: Equatable, Sendable {
|
||||
case week
|
||||
case overdue
|
||||
case upcoming
|
||||
case open
|
||||
case completed
|
||||
case date(Date)
|
||||
case all
|
||||
@ -25,6 +26,8 @@ public enum ReminderFiltering {
|
||||
return .overdue
|
||||
case "upcoming", "u":
|
||||
return .upcoming
|
||||
case "open":
|
||||
return .open
|
||||
case "completed", "done", "c":
|
||||
return .completed
|
||||
case "all", "a":
|
||||
@ -76,6 +79,8 @@ public enum ReminderFiltering {
|
||||
return reminders.filter { reminder in
|
||||
!reminder.isCompleted && reminder.dueDate != nil
|
||||
}
|
||||
case .open:
|
||||
return reminders.filter { !$0.isCompleted }
|
||||
case .completed:
|
||||
return reminders.filter { $0.isCompleted }
|
||||
case .date(let date):
|
||||
|
||||
@ -17,10 +17,64 @@ enum CommandHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
static func parseDueDate(_ value: String) throws -> Date {
|
||||
guard let date = DateParsing.parseUserDate(value) else {
|
||||
static func parseDueDate(_ value: String) throws -> ParsedUserDate {
|
||||
guard let parsed = DateParsing.parseUserDateWithMetadata(value) else {
|
||||
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")
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,19 +17,44 @@ enum AddCommand {
|
||||
.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: "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: "repeat",
|
||||
names: [.short("r"), .long("repeat")],
|
||||
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
|
||||
parsing: .singleValue
|
||||
),
|
||||
.make(
|
||||
label: "priority",
|
||||
names: [.short("p"), .long("priority")],
|
||||
help: "none|low|medium|high",
|
||||
parsing: .singleValue
|
||||
),
|
||||
],
|
||||
flags: [
|
||||
.make(label: "leaving", names: [.long("leaving")], help: "Trigger when leaving location")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl add \"Buy milk\"",
|
||||
"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",
|
||||
]
|
||||
) { values, runtime in
|
||||
@ -55,9 +80,20 @@ enum AddCommand {
|
||||
let listName = values.option("list")
|
||||
let notes = values.option("notes")
|
||||
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 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 store = RemindersStore()
|
||||
@ -73,9 +109,44 @@ 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,
|
||||
alarmDate: alarmDate,
|
||||
recurrenceRule: recurrenceRule,
|
||||
locationTrigger: locationTrigger,
|
||||
priority: priority
|
||||
)
|
||||
let reminder = try await store.createReminder(draft, listName: targetList)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ enum CompleteCommand {
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
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") {
|
||||
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
|
||||
|
||||
@ -33,7 +33,7 @@ enum DeleteCommand {
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
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") {
|
||||
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
|
||||
|
||||
@ -17,7 +17,14 @@ enum EditCommand {
|
||||
.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: "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: "repeat",
|
||||
names: [.short("r"), .long("repeat")],
|
||||
help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years",
|
||||
parsing: .singleValue
|
||||
),
|
||||
.make(
|
||||
label: "priority",
|
||||
names: [.short("p"), .long("priority")],
|
||||
@ -27,6 +34,8 @@ enum EditCommand {
|
||||
],
|
||||
flags: [
|
||||
.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: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
|
||||
]
|
||||
@ -35,8 +44,10 @@ enum EditCommand {
|
||||
usageExamples: [
|
||||
"remindctl edit 1 --title \"New title\"",
|
||||
"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 3 --clear-due",
|
||||
"remindctl edit 3 --clear-due --clear-alarm --no-repeat",
|
||||
]
|
||||
) { values, runtime in
|
||||
guard let input = values.argument(0) else {
|
||||
@ -46,7 +57,7 @@ enum EditCommand {
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
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 {
|
||||
throw RemindCoreError.reminderNotFound(input)
|
||||
}
|
||||
@ -54,8 +65,10 @@ enum EditCommand {
|
||||
let title = values.option("title")
|
||||
let listName = values.option("list")
|
||||
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") {
|
||||
dueUpdate = try CommandHelpers.parseDueDate(dueValue)
|
||||
}
|
||||
@ -66,6 +79,28 @@ enum EditCommand {
|
||||
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?
|
||||
if let priorityValue = values.option("priority") {
|
||||
priority = try CommandHelpers.parsePriority(priorityValue)
|
||||
@ -78,7 +113,10 @@ enum EditCommand {
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@ -86,6 +124,8 @@ enum EditCommand {
|
||||
title: title,
|
||||
notes: notes,
|
||||
dueDate: dueUpdate,
|
||||
alarmDate: alarmUpdate,
|
||||
recurrenceRule: recurrenceUpdate,
|
||||
priority: priority,
|
||||
listName: listName,
|
||||
isCompleted: isCompleted
|
||||
|
||||
@ -7,11 +7,11 @@ enum ListCommand {
|
||||
CommandSpec(
|
||||
name: "list",
|
||||
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(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "name", help: "List name", isOptional: true)
|
||||
.make(label: "name", help: "List name(s)", isOptional: true)
|
||||
],
|
||||
options: [
|
||||
.make(
|
||||
@ -31,12 +31,13 @@ enum ListCommand {
|
||||
usageExamples: [
|
||||
"remindctl list",
|
||||
"remindctl list Work",
|
||||
"remindctl list Work Errands",
|
||||
"remindctl list Work --rename Office",
|
||||
"remindctl list Work --delete",
|
||||
"remindctl list Projects --create",
|
||||
]
|
||||
) { values, runtime in
|
||||
let name = values.argument(0)
|
||||
let names = values.positional
|
||||
let renameTo = values.option("rename")
|
||||
let deleteList = values.flag("delete")
|
||||
let createList = values.flag("create")
|
||||
@ -45,7 +46,11 @@ enum ListCommand {
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
|
||||
if let name {
|
||||
if !names.isEmpty {
|
||||
let name = try singleListName(
|
||||
names,
|
||||
forMutation: deleteList || renameTo != nil || createList
|
||||
)
|
||||
if deleteList {
|
||||
if !force && !runtime.noInput && Console.isTTY {
|
||||
if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) {
|
||||
@ -80,7 +85,7 @@ enum ListCommand {
|
||||
return
|
||||
}
|
||||
|
||||
let reminders = try await store.reminders(in: name)
|
||||
let reminders = try await reminders(in: names, store: store)
|
||||
OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
|
||||
return
|
||||
}
|
||||
@ -109,4 +114,23 @@ enum ListCommand {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,13 +7,13 @@ enum ShowCommand {
|
||||
CommandSpec(
|
||||
name: "show",
|
||||
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(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(
|
||||
label: "filter",
|
||||
help: "today|tomorrow|week|overdue|upcoming|completed|all|<date>",
|
||||
help: "today|tomorrow|week|overdue|upcoming|open|completed|all|<date>",
|
||||
isOptional: true
|
||||
)
|
||||
],
|
||||
|
||||
@ -50,8 +50,12 @@ enum OutputRenderer {
|
||||
static func printReminder(_ reminder: ReminderItem, format: OutputFormat) {
|
||||
switch format {
|
||||
case .standard:
|
||||
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
|
||||
Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)")
|
||||
let 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:
|
||||
Swift.print(plainLine(for: reminder))
|
||||
case .json:
|
||||
@ -96,9 +100,14 @@ enum OutputRenderer {
|
||||
}
|
||||
for (index, reminder) in sorted.enumerated() {
|
||||
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)"
|
||||
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 {
|
||||
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 [
|
||||
reminder.id,
|
||||
reminder.listName,
|
||||
@ -157,4 +174,16 @@ enum OutputRenderer {
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
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)" } ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import RemindCore
|
||||
|
||||
enum PermissionsHelp {
|
||||
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] {
|
||||
switch status {
|
||||
@ -15,6 +16,7 @@ enum PermissionsHelp {
|
||||
case .denied, .restricted:
|
||||
return [
|
||||
"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.",
|
||||
]
|
||||
case .writeOnly:
|
||||
|
||||
@ -11,9 +11,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.2.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<string>0.2.0</string>
|
||||
<key>NSRemindersUsageDescription</key>
|
||||
<string>Manage your reminders from the terminal.</string>
|
||||
</dict>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// Generated by scripts/generate-version.sh. Do not edit.
|
||||
enum RemindctlVersion {
|
||||
static let current = "0.1.0"
|
||||
static let current = "0.2.0"
|
||||
}
|
||||
|
||||
@ -30,6 +30,14 @@ struct DateParsingTests {
|
||||
#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")
|
||||
func formattedParsing() {
|
||||
let input = "2026-01-03 10:30"
|
||||
@ -43,4 +51,28 @@ struct DateParsingTests {
|
||||
let output = DateParsing.formatDisplay(date, calendar: calendar)
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +38,13 @@ struct IDResolverTests {
|
||||
#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")
|
||||
func resolvePrefix() throws {
|
||||
let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders())
|
||||
|
||||
@ -10,6 +10,7 @@ struct ReminderFilterParseTests {
|
||||
#expect(ReminderFiltering.parse("w") == .week)
|
||||
#expect(ReminderFiltering.parse("o") == .overdue)
|
||||
#expect(ReminderFiltering.parse("u") == .upcoming)
|
||||
#expect(ReminderFiltering.parse("open") == .open)
|
||||
#expect(ReminderFiltering.parse("done") == .completed)
|
||||
#expect(ReminderFiltering.parse("all") == .all)
|
||||
}
|
||||
|
||||
@ -108,6 +108,16 @@ struct ReminderFilteringTests {
|
||||
#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")
|
||||
func dateFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
45
Tests/RemindCoreTests/ReminderItemCodingTests.swift
Normal file
45
Tests/RemindCoreTests/ReminderItemCodingTests.swift
Normal 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""#))
|
||||
}
|
||||
}
|
||||
@ -21,4 +21,19 @@ struct HelpPrinterTests {
|
||||
#expect(joined.contains("status"))
|
||||
#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"))
|
||||
}
|
||||
}
|
||||
|
||||
19
Tests/remindctlTests/ListCommandTests.swift
Normal file
19
Tests/remindctlTests/ListCommandTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Tests/remindctlTests/PermissionsHelpTests.swift
Normal file
14
Tests/remindctlTests/PermissionsHelpTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
34
Tests/remindctlTests/RecurrenceParsingTests.swift
Normal file
34
Tests/remindctlTests/RecurrenceParsingTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@
|
||||
|
||||
## Steps
|
||||
1. Update changelog and version
|
||||
- Ensure `CHANGELOG.md` has `## 0.1.0 - YYYY-MM-DD` with final notes.
|
||||
- Update `version.env` to `0.1.0` (already set for the first release).
|
||||
- Ensure `CHANGELOG.md` has `## 0.2.0 - YYYY-MM-DD` with final notes.
|
||||
- Update `version.env` to `0.2.0`.
|
||||
- Run `scripts/generate-version.sh` (refreshes `Sources/remindctl/Version.swift` + embedded Info.plist).
|
||||
2. Ensure checks are green
|
||||
- `make check`
|
||||
@ -14,11 +14,11 @@
|
||||
- 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).
|
||||
4. Tag, push, and publish
|
||||
- `git tag -a v0.1.0 -m "v0.1.0"`
|
||||
- `git push origin v0.1.0`
|
||||
- `git tag -a v0.2.0 -m "v0.2.0"`
|
||||
- `git push origin v0.2.0`
|
||||
- Extract release notes:
|
||||
```sh
|
||||
version=0.1.0
|
||||
version=0.2.0
|
||||
notes_file=/tmp/release-notes.txt
|
||||
awk -v v="$version" '
|
||||
$0 ~ ("^## " v "($|[[:space:]]-)") { in_section=1; next }
|
||||
@ -28,10 +28,11 @@
|
||||
```
|
||||
- Create GitHub release:
|
||||
```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
|
||||
- Update `../homebrew-tap/Formula/remindctl.rb` to point at the GitHub release asset.
|
||||
5. Update Homebrew tap
|
||||
- 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
|
||||
- Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`.
|
||||
|
||||
@ -16,7 +16,7 @@ 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)
|
||||
- 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
|
||||
- complete: mark one reminder complete
|
||||
- delete: remove reminders, then delete list
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "remindctl",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"version:sync": "scripts/generate-version.sh",
|
||||
|
||||
50
scripts/update-homebrew.sh
Executable file
50
scripts/update-homebrew.sh
Executable 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
|
||||
@ -1 +1 @@
|
||||
MARKETING_VERSION=0.1.0
|
||||
MARKETING_VERSION=0.2.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user