feat: initial remindctl cli
This commit is contained in:
parent
e8a3d14f52
commit
d8f9510112
26
.github/workflows/ci.yml
vendored
Normal file
26
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Swift version
|
||||
run: swift --version
|
||||
- name: Install SwiftLint
|
||||
run: brew install swiftlint
|
||||
- name: Generate version files
|
||||
run: scripts/generate-version.sh
|
||||
- name: Swift format lint
|
||||
run: swift format lint --recursive Sources Tests
|
||||
- name: SwiftLint
|
||||
run: swiftlint
|
||||
- name: Swift test + coverage
|
||||
run: scripts/check-coverage.sh
|
||||
- name: Swift build
|
||||
run: swift build -c release --product remindctl
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@ -1,32 +1,6 @@
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Code coverage profiles and other test artifacts
|
||||
*.out
|
||||
coverage.*
|
||||
*.coverprofile
|
||||
profile.cov
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
.DS_Store
|
||||
.build
|
||||
.swiftpm
|
||||
Packages
|
||||
DerivedData
|
||||
bin
|
||||
|
||||
58
.swift-format
Normal file
58
.swift-format
Normal file
@ -0,0 +1,58 @@
|
||||
{
|
||||
"version": 1,
|
||||
"lineLength": 120,
|
||||
"indentation": {
|
||||
"spaces": 2
|
||||
},
|
||||
"respectsExistingLineBreaks": true,
|
||||
"lineBreakBeforeControlFlowKeywords": false,
|
||||
"lineBreakBeforeEachArgument": false,
|
||||
"lineBreakBeforeEachGenericRequirement": false,
|
||||
"prioritizeKeepingFunctionOutputTogether": false,
|
||||
"indentConditionalCompilationBlocks": true,
|
||||
"lineBreakAroundMultilineExpressionChainComponents": false,
|
||||
"fileScopedDeclarationPrivacy": {
|
||||
"accessLevel": "private"
|
||||
},
|
||||
"rules": {
|
||||
"AllPublicDeclarationsHaveDocumentation": false,
|
||||
"AlwaysUseLiteralForEmptyCollectionInit": false,
|
||||
"AlwaysUseLowerCamelCase": true,
|
||||
"AmbiguousTrailingClosureOverload": true,
|
||||
"BeginDocumentationCommentWithOneLineSummary": false,
|
||||
"DoNotUseSemicolons": true,
|
||||
"DontRepeatTypeInStaticProperties": true,
|
||||
"FileScopedDeclarationPrivacy": true,
|
||||
"FullyIndirectEnum": true,
|
||||
"GroupNumericLiterals": true,
|
||||
"IdentifiersMustBeASCII": true,
|
||||
"NeverForceUnwrap": false,
|
||||
"NeverUseForceTry": false,
|
||||
"NeverUseImplicitlyUnwrappedOptionals": false,
|
||||
"NoAccessLevelOnExtensionDeclaration": true,
|
||||
"NoAssignmentInExpressions": true,
|
||||
"NoBlockComments": true,
|
||||
"NoCasesWithOnlyFallthrough": true,
|
||||
"NoEmptyTrailingClosureParentheses": true,
|
||||
"NoLabelsInCasePatterns": true,
|
||||
"NoLeadingUnderscores": false,
|
||||
"NoParensAroundConditions": true,
|
||||
"NoPlaygroundLiterals": true,
|
||||
"NoVoidReturnOnFunctionSignature": true,
|
||||
"OmitExplicitReturns": false,
|
||||
"OneCasePerLine": true,
|
||||
"OneVariableDeclarationPerLine": true,
|
||||
"OnlyOneTrailingClosureArgument": true,
|
||||
"OrderedImports": true,
|
||||
"ReplaceForEachWithForLoop": true,
|
||||
"ReturnVoidInsteadOfEmptyTuple": true,
|
||||
"UseEarlyExits": false,
|
||||
"UseLetInEveryBoundCaseVariable": true,
|
||||
"UseShorthandTypeNames": true,
|
||||
"UseSingleLinePropertyGetter": true,
|
||||
"UseSynthesizedInitializer": true,
|
||||
"UseTripleSlashForDocumentationComments": true,
|
||||
"UseWhereClausesInForLoops": false,
|
||||
"ValidateDocumentationComments": false
|
||||
}
|
||||
}
|
||||
93
.swiftlint.yml
Normal file
93
.swiftlint.yml
Normal file
@ -0,0 +1,93 @@
|
||||
included:
|
||||
- Sources
|
||||
- Tests
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
- .swiftpm
|
||||
- Packages
|
||||
- .git
|
||||
- DerivedData
|
||||
- "**/Generated"
|
||||
- "**/Resources"
|
||||
|
||||
opt_in_rules:
|
||||
- array_init
|
||||
- closure_spacing
|
||||
- contains_over_first_not_nil
|
||||
- empty_count
|
||||
- empty_string
|
||||
- explicit_init
|
||||
- fallthrough
|
||||
- fatal_error_message
|
||||
- first_where
|
||||
- joined_default_parameter
|
||||
- last_where
|
||||
- literal_expression_end_indentation
|
||||
- multiline_arguments
|
||||
- multiline_parameters
|
||||
- operator_usage_whitespace
|
||||
- overridden_super_call
|
||||
- private_outlet
|
||||
- redundant_nil_coalescing
|
||||
- sorted_first_last
|
||||
- switch_case_alignment
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- vertical_parameter_alignment_on_call
|
||||
|
||||
disabled_rules:
|
||||
- explicit_self
|
||||
- identifier_name
|
||||
- file_header
|
||||
- explicit_acl
|
||||
- explicit_top_level_acl
|
||||
- explicit_type_interface
|
||||
- missing_docs
|
||||
- required_deinit
|
||||
- trailing_whitespace
|
||||
- trailing_newline
|
||||
- trailing_comma
|
||||
- vertical_whitespace
|
||||
- indentation_width
|
||||
- sorted_imports
|
||||
- file_name
|
||||
|
||||
force_cast: warning
|
||||
force_try: warning
|
||||
|
||||
line_length:
|
||||
warning: 120
|
||||
error: 140
|
||||
ignores_comments: true
|
||||
ignores_urls: true
|
||||
|
||||
file_length:
|
||||
warning: 450
|
||||
error: 500
|
||||
ignore_comment_only_lines: true
|
||||
|
||||
type_body_length:
|
||||
warning: 250
|
||||
error: 400
|
||||
|
||||
function_body_length:
|
||||
warning: 80
|
||||
error: 120
|
||||
|
||||
cyclomatic_complexity:
|
||||
warning: 15
|
||||
error: 25
|
||||
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 4
|
||||
error: 6
|
||||
function_level:
|
||||
warning: 5
|
||||
error: 7
|
||||
|
||||
large_tuple:
|
||||
warning: 4
|
||||
error: 5
|
||||
|
||||
reporter: "xcode"
|
||||
10
CHANGELOG.md
Normal file
10
CHANGELOG.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
- Manage lists (list, create, rename, delete)
|
||||
- Add, edit, complete, and delete reminders
|
||||
- JSON and plain output modes for scripting
|
||||
- Flexible date parsing (relative, ISO 8601, and common formats)
|
||||
- GitHub Actions CI with lint, tests, and coverage gate
|
||||
15
Package.resolved
Normal file
15
Package.resolved
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "a669a19d7dad51d9569b07c55a81b6066ee617b2f82a0f83681682c9ff35bc10",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
55
Package.swift
Normal file
55
Package.swift
Normal file
@ -0,0 +1,55 @@
|
||||
// swift-tools-version: 6.0
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "remindctl",
|
||||
platforms: [.macOS(.v14)],
|
||||
products: [
|
||||
.library(name: "RemindCore", targets: ["RemindCore"]),
|
||||
.executable(name: "remindctl", targets: ["remindctl"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "RemindCore",
|
||||
dependencies: [],
|
||||
linkerSettings: [
|
||||
.linkedFramework("EventKit"),
|
||||
]
|
||||
),
|
||||
.executableTarget(
|
||||
name: "remindctl",
|
||||
dependencies: [
|
||||
"RemindCore",
|
||||
.product(name: "Commander", package: "Commander"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
],
|
||||
linkerSettings: [
|
||||
.unsafeFlags([
|
||||
"-Xlinker", "-sectcreate",
|
||||
"-Xlinker", "__TEXT",
|
||||
"-Xlinker", "__info_plist",
|
||||
"-Xlinker", "Sources/remindctl/Resources/Info.plist",
|
||||
]),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "RemindCoreTests",
|
||||
dependencies: [
|
||||
"RemindCore",
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "remindctlTests",
|
||||
dependencies: [
|
||||
"remindctl",
|
||||
"RemindCore",
|
||||
]
|
||||
),
|
||||
],
|
||||
swiftLanguageModes: [.v6]
|
||||
)
|
||||
63
README.md
Normal file
63
README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# remindctl
|
||||
|
||||
Fast CLI for Apple Reminders on macOS.
|
||||
|
||||
## Install
|
||||
|
||||
### Homebrew (Home Pro)
|
||||
```bash
|
||||
brew install steipete/tap/remindctl
|
||||
```
|
||||
|
||||
### From source
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
# binary at ./bin/remindctl
|
||||
```
|
||||
|
||||
## Requirements
|
||||
- macOS 14+ (Sonoma or later)
|
||||
- Swift 6.2+
|
||||
- Reminders permission (System Settings → Privacy & Security → Reminders)
|
||||
|
||||
## 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 edit 1 --title "New title" --due 2026-01-04
|
||||
remindctl complete 1 2 3
|
||||
remindctl delete 4A83 --force
|
||||
```
|
||||
|
||||
## Output formats
|
||||
- `--json` emits JSON arrays/objects.
|
||||
- `--plain` emits tab-separated lines.
|
||||
- `--quiet` emits counts only.
|
||||
|
||||
## 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`)
|
||||
|
||||
## Permissions
|
||||
On first run, macOS will prompt for Reminders access. You can manage access in:
|
||||
System Settings → Privacy & Security → Reminders.
|
||||
83
Sources/RemindCore/DateParsing.swift
Normal file
83
Sources/RemindCore/DateParsing.swift
Normal file
@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
public enum DateParsing {
|
||||
public static func parseUserDate(
|
||||
_ input: String,
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> Date? {
|
||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
if let relative = parseRelativeDate(lower, now: now, calendar: calendar) {
|
||||
return relative
|
||||
}
|
||||
|
||||
let iso =
|
||||
isoFormatter(withFraction: true).date(from: trimmed)
|
||||
?? isoFormatter(withFraction: false).date(from: trimmed)
|
||||
if let iso {
|
||||
return iso
|
||||
}
|
||||
|
||||
for formatter in dateFormatters() {
|
||||
if let date = formatter.date(from: trimmed) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func formatDisplay(_ date: Date, calendar: Calendar = .current) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale.current
|
||||
formatter.timeZone = calendar.timeZone
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func parseRelativeDate(_ input: String, now: Date, calendar: Calendar) -> Date? {
|
||||
switch input {
|
||||
case "today":
|
||||
return calendar.startOfDay(for: now)
|
||||
case "tomorrow":
|
||||
return calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))
|
||||
case "yesterday":
|
||||
return calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now))
|
||||
case "now":
|
||||
return now
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func isoFormatter(withFraction: Bool) -> ISO8601DateFormatter {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions =
|
||||
withFraction
|
||||
? [.withInternetDateTime, .withFractionalSeconds]
|
||||
: [.withInternetDateTime]
|
||||
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",
|
||||
]
|
||||
return formats.map { format in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone.current
|
||||
formatter.dateFormat = format
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Sources/RemindCore/Errors.swift
Normal file
36
Sources/RemindCore/Errors.swift
Normal file
@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
public enum RemindCoreError: LocalizedError, Sendable, Equatable {
|
||||
case accessDenied
|
||||
case writeOnlyAccess
|
||||
case listNotFound(String)
|
||||
case reminderNotFound(String)
|
||||
case ambiguousIdentifier(String, matches: [String])
|
||||
case invalidIdentifier(String)
|
||||
case invalidDate(String)
|
||||
case unsupported(String)
|
||||
case operationFailed(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .accessDenied:
|
||||
return "Access to Reminders denied. Grant access in System Settings > Privacy & Security > Reminders."
|
||||
case .writeOnlyAccess:
|
||||
return "Reminders access is write-only. Full access is required to read reminders."
|
||||
case .listNotFound(let name):
|
||||
return "List not found: \"\(name)\"."
|
||||
case .reminderNotFound(let id):
|
||||
return "Reminder not found: \"\(id)\"."
|
||||
case .ambiguousIdentifier(let input, let matches):
|
||||
return "Identifier \"\(input)\" matches multiple reminders: \(matches.joined(separator: ", "))."
|
||||
case .invalidIdentifier(let input):
|
||||
return "Invalid identifier: \"\(input)\"."
|
||||
case .invalidDate(let input):
|
||||
return "Invalid date: \"\(input)\"."
|
||||
case .unsupported(let message):
|
||||
return message
|
||||
case .operationFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
242
Sources/RemindCore/EventKitStore.swift
Normal file
242
Sources/RemindCore/EventKitStore.swift
Normal file
@ -0,0 +1,242 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
public actor RemindersStore {
|
||||
private let eventStore = EKEventStore()
|
||||
private let calendar: Calendar
|
||||
|
||||
public init(calendar: Calendar = .current) {
|
||||
self.calendar = calendar
|
||||
}
|
||||
|
||||
public func requestAccess() async throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
let granted = try await requestFullAccess()
|
||||
if !granted {
|
||||
throw RemindCoreError.accessDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw RemindCoreError.accessDenied
|
||||
case .writeOnly:
|
||||
throw RemindCoreError.writeOnlyAccess
|
||||
case .fullAccess, .authorized:
|
||||
break
|
||||
@unknown default:
|
||||
throw RemindCoreError.operationFailed("Unknown authorization status")
|
||||
}
|
||||
}
|
||||
|
||||
public func lists() async -> [ReminderList] {
|
||||
eventStore.calendars(for: .reminder).map { calendar in
|
||||
ReminderList(id: calendar.calendarIdentifier, title: calendar.title)
|
||||
}
|
||||
}
|
||||
|
||||
public func defaultListName() -> String? {
|
||||
eventStore.defaultCalendarForNewReminders()?.title
|
||||
}
|
||||
|
||||
public func reminders(in listName: String? = nil) async throws -> [ReminderItem] {
|
||||
let calendars: [EKCalendar]
|
||||
if let listName {
|
||||
calendars = eventStore.calendars(for: .reminder).filter { $0.title == listName }
|
||||
if calendars.isEmpty {
|
||||
throw RemindCoreError.listNotFound(listName)
|
||||
}
|
||||
} else {
|
||||
calendars = eventStore.calendars(for: .reminder)
|
||||
}
|
||||
|
||||
return await fetchReminders(in: calendars)
|
||||
}
|
||||
|
||||
public func createList(name: String) async throws -> ReminderList {
|
||||
let list = EKCalendar(for: .reminder, eventStore: eventStore)
|
||||
list.title = name
|
||||
guard let source = eventStore.defaultCalendarForNewReminders()?.source else {
|
||||
throw RemindCoreError.operationFailed("Unable to determine default reminder source")
|
||||
}
|
||||
list.source = source
|
||||
try eventStore.saveCalendar(list, commit: true)
|
||||
return ReminderList(id: list.calendarIdentifier, title: list.title)
|
||||
}
|
||||
|
||||
public func renameList(oldName: String, newName: String) async throws {
|
||||
let calendar = try calendar(named: oldName)
|
||||
guard calendar.allowsContentModifications else {
|
||||
throw RemindCoreError.operationFailed("Cannot modify system list")
|
||||
}
|
||||
calendar.title = newName
|
||||
try eventStore.saveCalendar(calendar, commit: true)
|
||||
}
|
||||
|
||||
public func deleteList(name: String) async throws {
|
||||
let calendar = try calendar(named: name)
|
||||
guard calendar.allowsContentModifications else {
|
||||
throw RemindCoreError.operationFailed("Cannot delete system list")
|
||||
}
|
||||
try eventStore.removeCalendar(calendar, commit: true)
|
||||
}
|
||||
|
||||
public func createReminder(_ draft: ReminderDraft, listName: String) async throws -> ReminderItem {
|
||||
let calendar = try calendar(named: listName)
|
||||
let reminder = EKReminder(eventStore: eventStore)
|
||||
reminder.title = draft.title
|
||||
reminder.notes = draft.notes
|
||||
reminder.calendar = calendar
|
||||
reminder.priority = draft.priority.eventKitValue
|
||||
if let dueDate = draft.dueDate {
|
||||
reminder.dueDateComponents = calendarComponents(from: dueDate)
|
||||
}
|
||||
try eventStore.save(reminder, commit: true)
|
||||
return ReminderItem(
|
||||
id: reminder.calendarItemIdentifier,
|
||||
title: reminder.title ?? "",
|
||||
notes: reminder.notes,
|
||||
isCompleted: reminder.isCompleted,
|
||||
completionDate: reminder.completionDate,
|
||||
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
|
||||
dueDate: date(from: reminder.dueDateComponents),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
}
|
||||
|
||||
public func updateReminder(id: String, update: ReminderUpdate) async throws -> ReminderItem {
|
||||
let reminder = try reminder(withID: id)
|
||||
|
||||
if let title = update.title {
|
||||
reminder.title = title
|
||||
}
|
||||
if let notes = update.notes {
|
||||
reminder.notes = notes
|
||||
}
|
||||
if let dueDateUpdate = update.dueDate {
|
||||
if let dueDate = dueDateUpdate {
|
||||
reminder.dueDateComponents = calendarComponents(from: dueDate)
|
||||
} else {
|
||||
reminder.dueDateComponents = nil
|
||||
}
|
||||
}
|
||||
if let priority = update.priority {
|
||||
reminder.priority = priority.eventKitValue
|
||||
}
|
||||
if let listName = update.listName {
|
||||
reminder.calendar = try calendar(named: listName)
|
||||
}
|
||||
if let isCompleted = update.isCompleted {
|
||||
reminder.isCompleted = isCompleted
|
||||
}
|
||||
|
||||
try eventStore.save(reminder, commit: true)
|
||||
|
||||
return ReminderItem(
|
||||
id: reminder.calendarItemIdentifier,
|
||||
title: reminder.title ?? "",
|
||||
notes: reminder.notes,
|
||||
isCompleted: reminder.isCompleted,
|
||||
completionDate: reminder.completionDate,
|
||||
priority: ReminderPriority(eventKitValue: Int(reminder.priority)),
|
||||
dueDate: date(from: reminder.dueDateComponents),
|
||||
listID: reminder.calendar.calendarIdentifier,
|
||||
listName: reminder.calendar.title
|
||||
)
|
||||
}
|
||||
|
||||
public func completeReminders(ids: [String]) async throws -> [ReminderItem] {
|
||||
var updated: [ReminderItem] = []
|
||||
for id in ids {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
public func deleteReminders(ids: [String]) async throws -> Int {
|
||||
var deleted = 0
|
||||
for id in ids {
|
||||
let reminder = try reminder(withID: id)
|
||||
try eventStore.remove(reminder, commit: true)
|
||||
deleted += 1
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
private func requestFullAccess() async throws -> Bool {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
eventStore.requestFullAccessToReminders { granted, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchReminders(in calendars: [EKCalendar]) async -> [ReminderItem] {
|
||||
await withCheckedContinuation { continuation in
|
||||
let predicate = eventStore.predicateForReminders(in: calendars)
|
||||
eventStore.fetchReminders(matching: predicate) { reminders in
|
||||
let mapped = (reminders ?? []).map { reminder in
|
||||
self.item(from: reminder)
|
||||
}
|
||||
continuation.resume(returning: mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reminder(withID id: String) throws -> EKReminder {
|
||||
guard let item = eventStore.calendarItem(withIdentifier: id) as? EKReminder else {
|
||||
throw RemindCoreError.reminderNotFound(id)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private func calendar(named name: String) throws -> EKCalendar {
|
||||
let calendars = eventStore.calendars(for: .reminder).filter { $0.title == name }
|
||||
guard let calendar = calendars.first else {
|
||||
throw RemindCoreError.listNotFound(name)
|
||||
}
|
||||
return calendar
|
||||
}
|
||||
|
||||
private func calendarComponents(from date: Date) -> DateComponents {
|
||||
calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date)
|
||||
}
|
||||
|
||||
private func date(from components: DateComponents?) -> Date? {
|
||||
guard let components else { return nil }
|
||||
return calendar.date(from: components)
|
||||
}
|
||||
|
||||
private func item(from reminder: EKReminder) -> ReminderItem {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
40
Sources/RemindCore/IDResolver.swift
Normal file
40
Sources/RemindCore/IDResolver.swift
Normal file
@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
public enum IDResolver {
|
||||
public static let minimumPrefixLength = 4
|
||||
|
||||
public static func resolve(
|
||||
_ inputs: [String],
|
||||
from reminders: [ReminderItem]
|
||||
) throws -> [ReminderItem] {
|
||||
let sorted = ReminderFiltering.sort(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 {
|
||||
throw RemindCoreError.invalidIdentifier(trimmed)
|
||||
}
|
||||
resolved.append(sorted[idx])
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.count < minimumPrefixLength {
|
||||
throw RemindCoreError.invalidIdentifier(trimmed)
|
||||
}
|
||||
|
||||
let matches = sorted.filter { $0.id.lowercased().hasPrefix(trimmed.lowercased()) }
|
||||
if matches.isEmpty {
|
||||
throw RemindCoreError.reminderNotFound(trimmed)
|
||||
}
|
||||
if matches.count > 1 {
|
||||
throw RemindCoreError.ambiguousIdentifier(trimmed, matches: matches.map { $0.id })
|
||||
}
|
||||
if let match = matches.first {
|
||||
resolved.append(match)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
117
Sources/RemindCore/Models.swift
Normal file
117
Sources/RemindCore/Models.swift
Normal file
@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReminderPriority: String, Codable, CaseIterable, Sendable {
|
||||
case none
|
||||
case low
|
||||
case medium
|
||||
case high
|
||||
|
||||
public init(eventKitValue: Int) {
|
||||
switch eventKitValue {
|
||||
case 1...4:
|
||||
self = .high
|
||||
case 5:
|
||||
self = .medium
|
||||
case 6...9:
|
||||
self = .low
|
||||
default:
|
||||
self = .none
|
||||
}
|
||||
}
|
||||
|
||||
public var eventKitValue: Int {
|
||||
switch self {
|
||||
case .none:
|
||||
return 0
|
||||
case .high:
|
||||
return 1
|
||||
case .medium:
|
||||
return 5
|
||||
case .low:
|
||||
return 9
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReminderList: Identifiable, Codable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
|
||||
public init(id: String, title: String) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReminderItem: Identifiable, Codable, Sendable, Equatable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let notes: String?
|
||||
public let isCompleted: Bool
|
||||
public let completionDate: Date?
|
||||
public let priority: ReminderPriority
|
||||
public let dueDate: Date?
|
||||
public let listID: String
|
||||
public let listName: String
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
notes: String?,
|
||||
isCompleted: Bool,
|
||||
completionDate: Date?,
|
||||
priority: ReminderPriority,
|
||||
dueDate: Date?,
|
||||
listID: String,
|
||||
listName: String
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.notes = notes
|
||||
self.isCompleted = isCompleted
|
||||
self.completionDate = completionDate
|
||||
self.priority = priority
|
||||
self.dueDate = dueDate
|
||||
self.listID = listID
|
||||
self.listName = listName
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReminderDraft: Sendable {
|
||||
public let title: String
|
||||
public let notes: String?
|
||||
public let dueDate: Date?
|
||||
public let priority: ReminderPriority
|
||||
|
||||
public init(title: String, notes: String?, dueDate: Date?, priority: ReminderPriority) {
|
||||
self.title = title
|
||||
self.notes = notes
|
||||
self.dueDate = dueDate
|
||||
self.priority = priority
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReminderUpdate: Sendable {
|
||||
public let title: String?
|
||||
public let notes: String?
|
||||
public let dueDate: Date??
|
||||
public let priority: ReminderPriority?
|
||||
public let listName: String?
|
||||
public let isCompleted: Bool?
|
||||
|
||||
public init(
|
||||
title: String? = nil,
|
||||
notes: String? = nil,
|
||||
dueDate: Date?? = nil,
|
||||
priority: ReminderPriority? = nil,
|
||||
listName: String? = nil,
|
||||
isCompleted: Bool? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.notes = notes
|
||||
self.dueDate = dueDate
|
||||
self.priority = priority
|
||||
self.listName = listName
|
||||
self.isCompleted = isCompleted
|
||||
}
|
||||
}
|
||||
108
Sources/RemindCore/ReminderFilter.swift
Normal file
108
Sources/RemindCore/ReminderFilter.swift
Normal file
@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReminderFilter: Equatable, Sendable {
|
||||
case today
|
||||
case tomorrow
|
||||
case week
|
||||
case overdue
|
||||
case upcoming
|
||||
case completed
|
||||
case date(Date)
|
||||
case all
|
||||
}
|
||||
|
||||
public enum ReminderFiltering {
|
||||
public static func parse(_ input: String, now: Date = Date(), calendar: Calendar = .current) -> ReminderFilter? {
|
||||
let token = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
switch token {
|
||||
case "today", "tday":
|
||||
return .today
|
||||
case "tomorrow", "t":
|
||||
return .tomorrow
|
||||
case "week", "w":
|
||||
return .week
|
||||
case "overdue", "o":
|
||||
return .overdue
|
||||
case "upcoming", "u":
|
||||
return .upcoming
|
||||
case "completed", "done", "c":
|
||||
return .completed
|
||||
case "all", "a":
|
||||
return .all
|
||||
default:
|
||||
if let date = DateParsing.parseUserDate(token, now: now, calendar: calendar) {
|
||||
return .date(date)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public static func apply(
|
||||
_ reminders: [ReminderItem],
|
||||
filter: ReminderFilter,
|
||||
now: Date = Date(),
|
||||
calendar: Calendar = .current
|
||||
) -> [ReminderItem] {
|
||||
let startOfToday = calendar.startOfDay(for: now)
|
||||
let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday
|
||||
let startOfDayAfterTomorrow =
|
||||
calendar.date(byAdding: .day, value: 2, to: startOfToday) ?? startOfTomorrow
|
||||
switch filter {
|
||||
case .today:
|
||||
return reminders.filter { reminder in
|
||||
let isToday = reminder.dueDate.map { $0 >= startOfToday && $0 < startOfTomorrow } ?? false
|
||||
let isOverdue = reminder.dueDate.map { $0 < startOfToday } ?? false
|
||||
return !reminder.isCompleted && (isToday || isOverdue)
|
||||
}
|
||||
case .tomorrow:
|
||||
return reminders.filter { reminder in
|
||||
let isTomorrow = reminder.dueDate.map { $0 >= startOfTomorrow && $0 < startOfDayAfterTomorrow } ?? false
|
||||
return !reminder.isCompleted && isTomorrow
|
||||
}
|
||||
case .week:
|
||||
let interval = calendar.dateInterval(of: .weekOfYear, for: now)
|
||||
let start = interval?.start ?? startOfToday
|
||||
let end = interval?.end ?? now
|
||||
return reminders.filter { reminder in
|
||||
let inWeek = reminder.dueDate.map { $0 >= start && $0 <= end } ?? false
|
||||
return !reminder.isCompleted && inWeek
|
||||
}
|
||||
case .overdue:
|
||||
return reminders.filter { reminder in
|
||||
let isOverdue = reminder.dueDate.map { $0 < startOfToday } ?? false
|
||||
return !reminder.isCompleted && isOverdue
|
||||
}
|
||||
case .upcoming:
|
||||
return reminders.filter { reminder in
|
||||
!reminder.isCompleted && reminder.dueDate != nil
|
||||
}
|
||||
case .completed:
|
||||
return reminders.filter { $0.isCompleted }
|
||||
case .date(let date):
|
||||
return reminders.filter { reminder in
|
||||
let matches = reminder.dueDate.map { calendar.isDate($0, inSameDayAs: date) } ?? false
|
||||
return !reminder.isCompleted && matches
|
||||
}
|
||||
case .all:
|
||||
return reminders
|
||||
}
|
||||
}
|
||||
|
||||
public static func sort(_ reminders: [ReminderItem]) -> [ReminderItem] {
|
||||
reminders.sorted { lhs, rhs in
|
||||
switch (lhs.dueDate, rhs.dueDate) {
|
||||
case (nil, nil):
|
||||
return lhs.title < rhs.title
|
||||
case (nil, _?):
|
||||
return false
|
||||
case (_?, nil):
|
||||
return true
|
||||
case (let left?, let right?):
|
||||
if left == right {
|
||||
return lhs.title < rhs.title
|
||||
}
|
||||
return left < right
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Sources/remindctl/CommandHelpers.swift
Normal file
26
Sources/remindctl/CommandHelpers.swift
Normal file
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum CommandHelpers {
|
||||
static func parsePriority(_ value: String) throws -> ReminderPriority {
|
||||
switch value.lowercased() {
|
||||
case "none":
|
||||
return .none
|
||||
case "low":
|
||||
return .low
|
||||
case "medium", "med":
|
||||
return .medium
|
||||
case "high":
|
||||
return .high
|
||||
default:
|
||||
throw RemindCoreError.operationFailed("Invalid priority: \"\(value)\" (use none|low|medium|high)")
|
||||
}
|
||||
}
|
||||
|
||||
static func parseDueDate(_ value: String) throws -> Date {
|
||||
guard let date = DateParsing.parseUserDate(value) else {
|
||||
throw RemindCoreError.invalidDate(value)
|
||||
}
|
||||
return date
|
||||
}
|
||||
}
|
||||
153
Sources/remindctl/CommandRouter.swift
Normal file
153
Sources/remindctl/CommandRouter.swift
Normal file
@ -0,0 +1,153 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
struct CommandRouter {
|
||||
let rootName = "remindctl"
|
||||
let version: String
|
||||
let specs: [CommandSpec]
|
||||
let program: Program
|
||||
|
||||
init() {
|
||||
self.version = CommandRouter.resolveVersion()
|
||||
self.specs = [
|
||||
ShowCommand.spec,
|
||||
ListCommand.spec,
|
||||
AddCommand.spec,
|
||||
EditCommand.spec,
|
||||
CompleteCommand.spec,
|
||||
DeleteCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
name: rootName,
|
||||
abstract: "Manage Apple Reminders from the terminal",
|
||||
discussion: nil,
|
||||
signature: CommandSignature(),
|
||||
subcommands: specs.map { $0.descriptor },
|
||||
defaultSubcommandName: "show"
|
||||
)
|
||||
self.program = Program(descriptors: [descriptor])
|
||||
}
|
||||
|
||||
func run() async -> Int32 {
|
||||
await run(argv: CommandLine.arguments)
|
||||
}
|
||||
|
||||
func run(argv: [String]) async -> Int32 {
|
||||
var argv = normalizeArguments(argv)
|
||||
argv = applyAliases(argv)
|
||||
|
||||
if argv.contains("--version") || argv.contains("-V") {
|
||||
Swift.print(version)
|
||||
return 0
|
||||
}
|
||||
|
||||
if argv.contains("--help") || argv.contains("-h") {
|
||||
printHelp(for: argv)
|
||||
return 0
|
||||
}
|
||||
|
||||
argv = rewriteImplicitShow(argv)
|
||||
|
||||
do {
|
||||
let invocation = try program.resolve(argv: argv)
|
||||
guard let commandName = invocation.path.last,
|
||||
let spec = specs.first(where: { $0.name == commandName })
|
||||
else {
|
||||
Console.printError("Unknown command")
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
return 1
|
||||
}
|
||||
let runtime = RuntimeOptions(parsedValues: invocation.parsedValues)
|
||||
do {
|
||||
try await spec.run(invocation.parsedValues, runtime)
|
||||
return 0
|
||||
} catch {
|
||||
Console.printError(error.localizedDescription)
|
||||
return 1
|
||||
}
|
||||
} catch let error as CommanderProgramError {
|
||||
Console.printError(error.description)
|
||||
if case .missingSubcommand = error {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
}
|
||||
return 1
|
||||
} catch {
|
||||
Console.printError(error.localizedDescription)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizeArguments(_ argv: [String]) -> [String] {
|
||||
guard !argv.isEmpty else { return argv }
|
||||
var copy = argv
|
||||
copy[0] = URL(fileURLWithPath: argv[0]).lastPathComponent
|
||||
return copy
|
||||
}
|
||||
|
||||
private func applyAliases(_ argv: [String]) -> [String] {
|
||||
guard argv.count >= 2 else { return argv }
|
||||
var copy = argv
|
||||
if copy[1] == "lists" || copy[1] == "ls" {
|
||||
copy[1] = "list"
|
||||
}
|
||||
if copy[1] == "rm" {
|
||||
copy[1] = "delete"
|
||||
}
|
||||
if copy[1] == "done" {
|
||||
copy[1] = "complete"
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
private func rewriteImplicitShow(_ argv: [String]) -> [String] {
|
||||
guard argv.count >= 2 else { return argv }
|
||||
let token = argv[1]
|
||||
if token.hasPrefix("-") {
|
||||
return argv
|
||||
}
|
||||
|
||||
let commandNames = Set(specs.map { $0.name })
|
||||
if commandNames.contains(token) {
|
||||
return argv
|
||||
}
|
||||
|
||||
if ReminderFiltering.parse(token) != nil {
|
||||
var copy = argv
|
||||
copy.insert("show", at: 1)
|
||||
return copy
|
||||
}
|
||||
|
||||
return argv
|
||||
}
|
||||
|
||||
private func printHelp(for argv: [String]) {
|
||||
let path = helpPath(from: argv)
|
||||
if path.count <= 1 {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
return
|
||||
}
|
||||
if let spec = specs.first(where: { $0.name == path[1] }) {
|
||||
HelpPrinter.printCommand(rootName: rootName, spec: spec)
|
||||
} else {
|
||||
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
|
||||
}
|
||||
}
|
||||
|
||||
private func helpPath(from argv: [String]) -> [String] {
|
||||
var path: [String] = []
|
||||
for token in argv {
|
||||
if token == "--help" || token == "-h" { continue }
|
||||
if token.hasPrefix("-") { break }
|
||||
path.append(token)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
private static func resolveVersion() -> String {
|
||||
if let envVersion = ProcessInfo.processInfo.environment["REMINDCTL_VERSION"], !envVersion.isEmpty {
|
||||
return envVersion
|
||||
}
|
||||
return RemindctlVersion.current
|
||||
}
|
||||
}
|
||||
42
Sources/remindctl/CommandSignatures.swift
Normal file
42
Sources/remindctl/CommandSignatures.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Commander
|
||||
|
||||
enum CommandSignatures {
|
||||
static func runtimeFlags() -> [FlagDefinition] {
|
||||
[
|
||||
.make(
|
||||
label: "jsonOutput",
|
||||
names: [.short("j"), .long("json"), .aliasLong("json-output"), .aliasLong("jsonOutput")],
|
||||
help: "Emit machine-readable JSON output"
|
||||
),
|
||||
.make(
|
||||
label: "plainOutput",
|
||||
names: [.long("plain")],
|
||||
help: "Emit stable line-based output"
|
||||
),
|
||||
.make(
|
||||
label: "quiet",
|
||||
names: [.short("q"), .long("quiet")],
|
||||
help: "Only emit minimal output"
|
||||
),
|
||||
.make(
|
||||
label: "noColor",
|
||||
names: [.long("no-color")],
|
||||
help: "Disable colored output"
|
||||
),
|
||||
.make(
|
||||
label: "noInput",
|
||||
names: [.long("no-input")],
|
||||
help: "Disable interactive prompts"
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
static func withRuntimeFlags(_ signature: CommandSignature) -> CommandSignature {
|
||||
CommandSignature(
|
||||
arguments: signature.arguments,
|
||||
options: signature.options,
|
||||
flags: signature.flags + runtimeFlags(),
|
||||
optionGroups: signature.optionGroups
|
||||
)
|
||||
}
|
||||
}
|
||||
19
Sources/remindctl/CommandSpec.swift
Normal file
19
Sources/remindctl/CommandSpec.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import Commander
|
||||
|
||||
struct CommandSpec: @unchecked Sendable {
|
||||
let name: String
|
||||
let abstract: String
|
||||
let discussion: String?
|
||||
let signature: CommandSignature
|
||||
let usageExamples: [String]
|
||||
let run: (ParsedValues, RuntimeOptions) async throws -> Void
|
||||
|
||||
var descriptor: CommandDescriptor {
|
||||
CommandDescriptor(
|
||||
name: name,
|
||||
abstract: abstract,
|
||||
discussion: discussion,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
}
|
||||
81
Sources/remindctl/Commands/AddCommand.swift
Normal file
81
Sources/remindctl/Commands/AddCommand.swift
Normal file
@ -0,0 +1,81 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum AddCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "add",
|
||||
abstract: "Add a reminder",
|
||||
discussion: "Provide a title as an argument or via --title.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "title", help: "Reminder title", isOptional: true)
|
||||
],
|
||||
options: [
|
||||
.make(label: "title", names: [.long("title")], help: "Reminder title", parsing: .singleValue),
|
||||
.make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue),
|
||||
.make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue),
|
||||
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", parsing: .singleValue),
|
||||
.make(
|
||||
label: "priority",
|
||||
names: [.short("p"), .long("priority")],
|
||||
help: "none|low|medium|high",
|
||||
parsing: .singleValue
|
||||
),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl add \"Buy milk\"",
|
||||
"remindctl add --title \"Call mom\" --list Personal --due tomorrow",
|
||||
"remindctl add \"Review docs\" --priority high",
|
||||
]
|
||||
) { values, runtime in
|
||||
let titleOption = values.option("title")
|
||||
let titleArg = values.argument(0)
|
||||
if titleOption != nil && titleArg != nil {
|
||||
throw RemindCoreError.operationFailed("Provide title either as argument or via --title")
|
||||
}
|
||||
|
||||
var title = titleOption ?? titleArg
|
||||
if title == nil {
|
||||
if runtime.noInput || !Console.isTTY {
|
||||
throw RemindCoreError.operationFailed("Missing title. Provide it as an argument or via --title.")
|
||||
}
|
||||
title = Console.readLine(prompt: "Title:")?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title?.isEmpty == true { title = nil }
|
||||
}
|
||||
|
||||
guard let title else {
|
||||
throw RemindCoreError.operationFailed("Missing title.")
|
||||
}
|
||||
|
||||
let listName = values.option("list")
|
||||
let notes = values.option("notes")
|
||||
let dueValue = values.option("due")
|
||||
let priorityValue = values.option("priority")
|
||||
|
||||
let dueDate = try dueValue.map(CommandHelpers.parseDueDate)
|
||||
let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none
|
||||
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
|
||||
let targetList: String?
|
||||
if let listName {
|
||||
targetList = listName
|
||||
} else {
|
||||
targetList = await store.defaultListName()
|
||||
}
|
||||
guard let targetList else {
|
||||
throw RemindCoreError.operationFailed("No default list found. Specify --list.")
|
||||
}
|
||||
|
||||
let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority)
|
||||
let reminder = try await store.createReminder(draft, listName: targetList)
|
||||
OutputRenderer.printReminder(reminder, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Sources/remindctl/Commands/CompleteCommand.swift
Normal file
46
Sources/remindctl/Commands/CompleteCommand.swift
Normal file
@ -0,0 +1,46 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum CompleteCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "complete",
|
||||
abstract: "Mark reminders complete",
|
||||
discussion: "Use indexes or ID prefixes from show output.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "ids", help: "Indexes or ID prefixes", isOptional: true)
|
||||
],
|
||||
flags: [
|
||||
.make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl complete 1",
|
||||
"remindctl complete 1 2 3",
|
||||
"remindctl complete 4A83",
|
||||
]
|
||||
) { values, runtime in
|
||||
let inputs = values.positional
|
||||
guard !inputs.isEmpty else {
|
||||
throw ParsedValuesError.missingArgument("ids")
|
||||
}
|
||||
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
let reminders = try await store.reminders(in: nil)
|
||||
let resolved = try IDResolver.resolve(inputs, from: reminders)
|
||||
|
||||
if values.flag("dryRun") {
|
||||
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
|
||||
return
|
||||
}
|
||||
|
||||
let updated = try await store.completeReminders(ids: resolved.map { $0.id })
|
||||
OutputRenderer.printReminders(updated, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Sources/remindctl/Commands/DeleteCommand.swift
Normal file
54
Sources/remindctl/Commands/DeleteCommand.swift
Normal file
@ -0,0 +1,54 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum DeleteCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "delete",
|
||||
abstract: "Delete reminders",
|
||||
discussion: "Use indexes or ID prefixes from show output.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "ids", help: "Indexes or ID prefixes", isOptional: true)
|
||||
],
|
||||
flags: [
|
||||
.make(label: "dryRun", names: [.short("n"), .long("dry-run")], help: "Preview without changes"),
|
||||
.make(label: "force", names: [.short("f"), .long("force")], help: "Skip confirmation"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl delete 1",
|
||||
"remindctl delete 4A83",
|
||||
"remindctl delete 1 2 3 --force",
|
||||
]
|
||||
) { values, runtime in
|
||||
let inputs = values.positional
|
||||
guard !inputs.isEmpty else {
|
||||
throw ParsedValuesError.missingArgument("ids")
|
||||
}
|
||||
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
let reminders = try await store.reminders(in: nil)
|
||||
let resolved = try IDResolver.resolve(inputs, from: reminders)
|
||||
|
||||
if values.flag("dryRun") {
|
||||
OutputRenderer.printReminders(resolved, format: runtime.outputFormat)
|
||||
return
|
||||
}
|
||||
|
||||
if !values.flag("force") && !runtime.noInput && Console.isTTY {
|
||||
let prompt = "Delete \(resolved.count) reminder(s)?"
|
||||
if !Console.confirm(prompt, defaultValue: false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let count = try await store.deleteReminders(ids: resolved.map { $0.id })
|
||||
OutputRenderer.printDeleteResult(count, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Sources/remindctl/Commands/EditCommand.swift
Normal file
98
Sources/remindctl/Commands/EditCommand.swift
Normal file
@ -0,0 +1,98 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum EditCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "edit",
|
||||
abstract: "Edit a reminder",
|
||||
discussion: "Use an index or ID prefix from the show output.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "id", help: "Index or ID prefix", isOptional: false)
|
||||
],
|
||||
options: [
|
||||
.make(label: "title", names: [.short("t"), .long("title")], help: "New title", parsing: .singleValue),
|
||||
.make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue),
|
||||
.make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue),
|
||||
.make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", parsing: .singleValue),
|
||||
.make(
|
||||
label: "priority",
|
||||
names: [.short("p"), .long("priority")],
|
||||
help: "none|low|medium|high",
|
||||
parsing: .singleValue
|
||||
),
|
||||
],
|
||||
flags: [
|
||||
.make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"),
|
||||
.make(label: "complete", names: [.long("complete")], help: "Mark completed"),
|
||||
.make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl edit 1 --title \"New title\"",
|
||||
"remindctl edit 4A83 --due tomorrow",
|
||||
"remindctl edit 2 --priority high --notes \"Call before noon\"",
|
||||
"remindctl edit 3 --clear-due",
|
||||
]
|
||||
) { values, runtime in
|
||||
guard let input = values.argument(0) else {
|
||||
throw ParsedValuesError.missingArgument("id")
|
||||
}
|
||||
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
let reminders = try await store.reminders(in: nil)
|
||||
let resolved = try IDResolver.resolve([input], from: reminders)
|
||||
guard let reminder = resolved.first else {
|
||||
throw RemindCoreError.reminderNotFound(input)
|
||||
}
|
||||
|
||||
let title = values.option("title")
|
||||
let listName = values.option("list")
|
||||
let notes = values.option("notes")
|
||||
|
||||
var dueUpdate: Date??
|
||||
if let dueValue = values.option("due") {
|
||||
dueUpdate = try CommandHelpers.parseDueDate(dueValue)
|
||||
}
|
||||
if values.flag("clearDue") {
|
||||
if dueUpdate != nil {
|
||||
throw RemindCoreError.operationFailed("Use either --due or --clear-due, not both")
|
||||
}
|
||||
dueUpdate = .some(nil)
|
||||
}
|
||||
|
||||
var priority: ReminderPriority?
|
||||
if let priorityValue = values.option("priority") {
|
||||
priority = try CommandHelpers.parsePriority(priorityValue)
|
||||
}
|
||||
|
||||
let completeFlag = values.flag("complete")
|
||||
let incompleteFlag = values.flag("incomplete")
|
||||
if completeFlag && incompleteFlag {
|
||||
throw RemindCoreError.operationFailed("Use either --complete or --incomplete, not both")
|
||||
}
|
||||
let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil)
|
||||
|
||||
if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil {
|
||||
throw RemindCoreError.operationFailed("No changes specified")
|
||||
}
|
||||
|
||||
let update = ReminderUpdate(
|
||||
title: title,
|
||||
notes: notes,
|
||||
dueDate: dueUpdate,
|
||||
priority: priority,
|
||||
listName: listName,
|
||||
isCompleted: isCompleted
|
||||
)
|
||||
|
||||
let updated = try await store.updateReminder(id: reminder.id, update: update)
|
||||
OutputRenderer.printReminder(updated, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
112
Sources/remindctl/Commands/ListCommand.swift
Normal file
112
Sources/remindctl/Commands/ListCommand.swift
Normal file
@ -0,0 +1,112 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum ListCommand {
|
||||
static var spec: CommandSpec {
|
||||
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.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(label: "name", help: "List name", isOptional: true)
|
||||
],
|
||||
options: [
|
||||
.make(
|
||||
label: "rename",
|
||||
names: [.short("r"), .long("rename")],
|
||||
help: "Rename the list",
|
||||
parsing: .singleValue
|
||||
)
|
||||
],
|
||||
flags: [
|
||||
.make(label: "delete", names: [.short("d"), .long("delete")], help: "Delete the list"),
|
||||
.make(label: "create", names: [.long("create")], help: "Create list if missing"),
|
||||
.make(label: "force", names: [.short("f"), .long("force")], help: "Skip confirmation prompts"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl list",
|
||||
"remindctl list Work",
|
||||
"remindctl list Work --rename Office",
|
||||
"remindctl list Work --delete",
|
||||
"remindctl list Projects --create",
|
||||
]
|
||||
) { values, runtime in
|
||||
let name = values.argument(0)
|
||||
let renameTo = values.option("rename")
|
||||
let deleteList = values.flag("delete")
|
||||
let createList = values.flag("create")
|
||||
let force = values.flag("force")
|
||||
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
|
||||
if let name {
|
||||
if deleteList {
|
||||
if !force && !runtime.noInput && Console.isTTY {
|
||||
if !Console.confirm("Delete list \"\(name)\"?", defaultValue: false) {
|
||||
return
|
||||
}
|
||||
}
|
||||
try await store.deleteList(name: name)
|
||||
if runtime.outputFormat == .standard {
|
||||
Swift.print("Deleted list \"\(name)\"")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let renameTo {
|
||||
try await store.renameList(oldName: name, newName: renameTo)
|
||||
if runtime.outputFormat == .standard {
|
||||
Swift.print("Renamed list \"\(name)\" -> \"\(renameTo)\"")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if createList {
|
||||
let list = try await store.createList(name: name)
|
||||
if runtime.outputFormat == .json {
|
||||
OutputRenderer.printLists(
|
||||
[ListSummary(id: list.id, title: list.title, reminderCount: 0, overdueCount: 0)],
|
||||
format: runtime.outputFormat
|
||||
)
|
||||
} else if runtime.outputFormat == .standard {
|
||||
Swift.print("Created list \"\(list.title)\"")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let reminders = try await store.reminders(in: name)
|
||||
OutputRenderer.printReminders(reminders, format: runtime.outputFormat)
|
||||
return
|
||||
}
|
||||
|
||||
let lists = await store.lists()
|
||||
let reminders = try await store.reminders(in: nil)
|
||||
|
||||
let startOfToday = Calendar.current.startOfDay(for: Date())
|
||||
var counts: [String: (total: Int, overdue: Int)] = [:]
|
||||
for reminder in reminders where !reminder.isCompleted {
|
||||
let entry = counts[reminder.listID] ?? (0, 0)
|
||||
let overdue = (reminder.dueDate.map { $0 < startOfToday } ?? false) ? 1 : 0
|
||||
counts[reminder.listID] = (entry.total + 1, entry.overdue + overdue)
|
||||
}
|
||||
|
||||
let summaries = lists.map { list in
|
||||
let entry = counts[list.id] ?? (0, 0)
|
||||
return ListSummary(
|
||||
id: list.id,
|
||||
title: list.title,
|
||||
reminderCount: entry.total,
|
||||
overdueCount: entry.overdue
|
||||
)
|
||||
}
|
||||
|
||||
OutputRenderer.printLists(summaries, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
58
Sources/remindctl/Commands/ShowCommand.swift
Normal file
58
Sources/remindctl/Commands/ShowCommand.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum ShowCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "show",
|
||||
abstract: "Show reminders",
|
||||
discussion: "Filters: today, tomorrow, week, overdue, upcoming, completed, all, or a date string.",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
arguments: [
|
||||
.make(
|
||||
label: "filter",
|
||||
help: "today|tomorrow|week|overdue|upcoming|completed|all|<date>",
|
||||
isOptional: true
|
||||
)
|
||||
],
|
||||
options: [
|
||||
.make(
|
||||
label: "list",
|
||||
names: [.short("l"), .long("list")],
|
||||
help: "Limit to a specific list",
|
||||
parsing: .singleValue
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"remindctl",
|
||||
"remindctl today",
|
||||
"remindctl show overdue",
|
||||
"remindctl show 2026-01-04",
|
||||
"remindctl show --list Work",
|
||||
]
|
||||
) { values, runtime in
|
||||
let listName = values.option("list")
|
||||
let filterToken = values.argument(0)
|
||||
|
||||
let filter: ReminderFilter
|
||||
if let token = filterToken {
|
||||
guard let parsed = ReminderFiltering.parse(token) else {
|
||||
throw RemindCoreError.operationFailed("Unknown filter: \"\(token)\"")
|
||||
}
|
||||
filter = parsed
|
||||
} else {
|
||||
filter = .today
|
||||
}
|
||||
|
||||
let store = RemindersStore()
|
||||
try await store.requestAccess()
|
||||
let reminders = try await store.reminders(in: listName)
|
||||
let filtered = ReminderFiltering.apply(reminders, filter: filter)
|
||||
OutputRenderer.printReminders(filtered, format: runtime.outputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Sources/remindctl/Console.swift
Normal file
41
Sources/remindctl/Console.swift
Normal file
@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
struct Console {
|
||||
static var isTTY: Bool {
|
||||
isatty(STDIN_FILENO) != 0
|
||||
}
|
||||
|
||||
static func readLine(prompt: String) -> String? {
|
||||
Swift.print(prompt, terminator: " ")
|
||||
return Swift.readLine()
|
||||
}
|
||||
|
||||
static func confirm(_ prompt: String, defaultValue: Bool = false) -> Bool {
|
||||
let suffix = defaultValue ? "[Y/n]" : "[y/N]"
|
||||
guard let input = readLine(prompt: "\(prompt) \(suffix)")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!input.isEmpty
|
||||
else {
|
||||
return defaultValue
|
||||
}
|
||||
switch input.lowercased() {
|
||||
case "y", "yes":
|
||||
return true
|
||||
case "n", "no":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
static func printError(_ message: String) {
|
||||
var stderr = StandardErrorOutputStream()
|
||||
Swift.print(message, to: &stderr)
|
||||
}
|
||||
}
|
||||
|
||||
struct StandardErrorOutputStream: TextOutputStream {
|
||||
mutating func write(_ string: String) {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
FileHandle.standardError.write(data)
|
||||
}
|
||||
}
|
||||
108
Sources/remindctl/HelpPrinter.swift
Normal file
108
Sources/remindctl/HelpPrinter.swift
Normal file
@ -0,0 +1,108 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
struct HelpPrinter {
|
||||
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
|
||||
for line in renderRoot(version: version, rootName: rootName, commands: commands) {
|
||||
Swift.print(line)
|
||||
}
|
||||
}
|
||||
|
||||
static func printCommand(rootName: String, spec: CommandSpec) {
|
||||
for line in renderCommand(rootName: rootName, spec: spec) {
|
||||
Swift.print(line)
|
||||
}
|
||||
}
|
||||
|
||||
static func renderRoot(version: String, rootName: String, commands: [CommandSpec]) -> [String] {
|
||||
var lines: [String] = []
|
||||
lines.append("\(rootName) \(version)")
|
||||
lines.append("Manage Apple Reminders from the terminal")
|
||||
lines.append("")
|
||||
lines.append("Usage:")
|
||||
lines.append(" \(rootName) [command] [options]")
|
||||
lines.append("")
|
||||
lines.append("Commands:")
|
||||
for command in commands {
|
||||
lines.append(" \(command.name)\t\(command.abstract)")
|
||||
}
|
||||
lines.append("")
|
||||
lines.append("Run '\(rootName) <command> --help' for details.")
|
||||
return lines
|
||||
}
|
||||
|
||||
static func renderCommand(rootName: String, spec: CommandSpec) -> [String] {
|
||||
var lines: [String] = []
|
||||
lines.append("\(rootName) \(spec.name)")
|
||||
lines.append(spec.abstract)
|
||||
if let discussion = spec.discussion, !discussion.isEmpty {
|
||||
lines.append("\n\(discussion)")
|
||||
}
|
||||
lines.append("")
|
||||
lines.append("Usage:")
|
||||
lines.append(" \(rootName) \(spec.name) \(usageFragment(for: spec.signature))")
|
||||
lines.append("")
|
||||
|
||||
if !spec.signature.arguments.isEmpty {
|
||||
lines.append("Arguments:")
|
||||
for arg in spec.signature.arguments {
|
||||
let optionalMark = arg.isOptional ? "?" : ""
|
||||
lines.append(" \(arg.label)\(optionalMark)\t\(arg.help ?? "")")
|
||||
}
|
||||
lines.append("")
|
||||
}
|
||||
|
||||
let options = spec.signature.options
|
||||
let flags = spec.signature.flags
|
||||
if !options.isEmpty || !flags.isEmpty {
|
||||
lines.append("Options:")
|
||||
for option in options {
|
||||
let names = formatNames(option.names, expectsValue: true)
|
||||
lines.append(" \(names)\t\(option.help ?? "")")
|
||||
}
|
||||
for flag in flags {
|
||||
let names = formatNames(flag.names, expectsValue: false)
|
||||
lines.append(" \(names)\t\(flag.help ?? "")")
|
||||
}
|
||||
lines.append("")
|
||||
}
|
||||
|
||||
if !spec.usageExamples.isEmpty {
|
||||
lines.append("Examples:")
|
||||
for example in spec.usageExamples {
|
||||
lines.append(" \(example)")
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
private static func usageFragment(for signature: CommandSignature) -> String {
|
||||
var parts: [String] = []
|
||||
for argument in signature.arguments {
|
||||
let token = argument.isOptional ? "[\(argument.label)]" : "<\(argument.label)>"
|
||||
parts.append(token)
|
||||
}
|
||||
if !signature.options.isEmpty || !signature.flags.isEmpty {
|
||||
parts.append("[options]")
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func formatNames(_ names: [CommanderName], expectsValue: Bool) -> String {
|
||||
let parts = names.map { name -> String in
|
||||
switch name {
|
||||
case .short(let char):
|
||||
return "-\(char)"
|
||||
case .long(let value):
|
||||
return "--\(value)"
|
||||
case .aliasShort(let char):
|
||||
return "-\(char)"
|
||||
case .aliasLong(let value):
|
||||
return "--\(value)"
|
||||
}
|
||||
}
|
||||
let suffix = expectsValue ? " <value>" : ""
|
||||
return parts.joined(separator: ", ") + suffix
|
||||
}
|
||||
}
|
||||
142
Sources/remindctl/OutputFormatting.swift
Normal file
142
Sources/remindctl/OutputFormatting.swift
Normal file
@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum OutputFormat {
|
||||
case standard
|
||||
case plain
|
||||
case json
|
||||
case quiet
|
||||
}
|
||||
|
||||
struct ListSummary: Codable, Sendable, Equatable {
|
||||
let id: String
|
||||
let title: String
|
||||
let reminderCount: Int
|
||||
let overdueCount: Int
|
||||
}
|
||||
|
||||
enum OutputRenderer {
|
||||
static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) {
|
||||
switch format {
|
||||
case .standard:
|
||||
printRemindersStandard(reminders)
|
||||
case .plain:
|
||||
printRemindersPlain(reminders)
|
||||
case .json:
|
||||
printJSON(reminders)
|
||||
case .quiet:
|
||||
Swift.print(reminders.count)
|
||||
}
|
||||
}
|
||||
|
||||
static func printLists(_ summaries: [ListSummary], format: OutputFormat) {
|
||||
switch format {
|
||||
case .standard:
|
||||
printListsStandard(summaries)
|
||||
case .plain:
|
||||
printListsPlain(summaries)
|
||||
case .json:
|
||||
printJSON(summaries)
|
||||
case .quiet:
|
||||
Swift.print(summaries.count)
|
||||
}
|
||||
}
|
||||
|
||||
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)")
|
||||
case .plain:
|
||||
Swift.print(plainLine(for: reminder))
|
||||
case .json:
|
||||
printJSON(reminder)
|
||||
case .quiet:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
static func printDeleteResult(_ count: Int, format: OutputFormat) {
|
||||
switch format {
|
||||
case .standard:
|
||||
Swift.print("Deleted \(count) reminder(s)")
|
||||
case .plain:
|
||||
Swift.print("\(count)")
|
||||
case .json:
|
||||
let payload = ["deleted": count]
|
||||
printJSON(payload)
|
||||
case .quiet:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func printRemindersStandard(_ reminders: [ReminderItem]) {
|
||||
let sorted = ReminderFiltering.sort(reminders)
|
||||
guard !sorted.isEmpty else {
|
||||
Swift.print("No reminders found")
|
||||
return
|
||||
}
|
||||
for (index, reminder) in sorted.enumerated() {
|
||||
let status = reminder.isCompleted ? "x" : " "
|
||||
let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date"
|
||||
let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)"
|
||||
Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func printRemindersPlain(_ reminders: [ReminderItem]) {
|
||||
let sorted = ReminderFiltering.sort(reminders)
|
||||
for reminder in sorted {
|
||||
Swift.print(plainLine(for: reminder))
|
||||
}
|
||||
}
|
||||
|
||||
private static func plainLine(for reminder: ReminderItem) -> String {
|
||||
let due = reminder.dueDate.map { isoFormatter().string(from: $0) } ?? ""
|
||||
return [
|
||||
reminder.id,
|
||||
reminder.listName,
|
||||
reminder.isCompleted ? "1" : "0",
|
||||
reminder.priority.rawValue,
|
||||
due,
|
||||
reminder.title,
|
||||
].joined(separator: "\t")
|
||||
}
|
||||
|
||||
private static func printListsStandard(_ summaries: [ListSummary]) {
|
||||
guard !summaries.isEmpty else {
|
||||
Swift.print("No reminder lists found")
|
||||
return
|
||||
}
|
||||
for summary in summaries.sorted(by: { $0.title < $1.title }) {
|
||||
let overdue = summary.overdueCount > 0 ? " (\(summary.overdueCount) overdue)" : ""
|
||||
Swift.print("\(summary.title) — \(summary.reminderCount) reminders\(overdue)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func printListsPlain(_ summaries: [ListSummary]) {
|
||||
for summary in summaries.sorted(by: { $0.title < $1.title }) {
|
||||
Swift.print("\(summary.title)\t\(summary.reminderCount)\t\(summary.overdueCount)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func printJSON<T: Encodable>(_ payload: T) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
do {
|
||||
let data = try encoder.encode(payload)
|
||||
if let json = String(data: data, encoding: .utf8) {
|
||||
Swift.print(json)
|
||||
}
|
||||
} catch {
|
||||
Swift.print("Failed to encode JSON: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func isoFormatter() -> ISO8601DateFormatter {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
49
Sources/remindctl/ParsedValues+Decode.swift
Normal file
49
Sources/remindctl/ParsedValues+Decode.swift
Normal file
@ -0,0 +1,49 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
|
||||
enum ParsedValuesError: LocalizedError, CustomStringConvertible {
|
||||
case missingOption(String)
|
||||
case invalidOption(String)
|
||||
case missingArgument(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .missingOption(let name):
|
||||
return "Missing required option: --\(name)"
|
||||
case .invalidOption(let name):
|
||||
return "Invalid value for option: --\(name)"
|
||||
case .missingArgument(let name):
|
||||
return "Missing required argument: \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
description
|
||||
}
|
||||
}
|
||||
|
||||
extension ParsedValues {
|
||||
func flag(_ label: String) -> Bool {
|
||||
flags.contains(label)
|
||||
}
|
||||
|
||||
func option(_ label: String) -> String? {
|
||||
options[label]?.last
|
||||
}
|
||||
|
||||
func optionValues(_ label: String) -> [String] {
|
||||
options[label] ?? []
|
||||
}
|
||||
|
||||
func optionRequired(_ label: String) throws -> String {
|
||||
guard let value = option(label), !value.isEmpty else {
|
||||
throw ParsedValuesError.missingOption(label)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func argument(_ index: Int) -> String? {
|
||||
guard positional.indices.contains(index) else { return nil }
|
||||
return positional[index]
|
||||
}
|
||||
}
|
||||
9
Sources/remindctl/RemindctlMain.swift
Normal file
9
Sources/remindctl/RemindctlMain.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
enum RemindctlMain {
|
||||
static func main() async {
|
||||
let code = await CommandRouter().run()
|
||||
exit(code)
|
||||
}
|
||||
}
|
||||
20
Sources/remindctl/Resources/Info.plist
Normal file
20
Sources/remindctl/Resources/Info.plist
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.steipete.remindctl</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>remindctl</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>remindctl</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>NSRemindersUsageDescription</key>
|
||||
<string>Manage your reminders from the terminal.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
24
Sources/remindctl/RuntimeOptions.swift
Normal file
24
Sources/remindctl/RuntimeOptions.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import Commander
|
||||
|
||||
struct RuntimeOptions: Sendable {
|
||||
let jsonOutput: Bool
|
||||
let plainOutput: Bool
|
||||
let quiet: Bool
|
||||
let noColor: Bool
|
||||
let noInput: Bool
|
||||
|
||||
init(parsedValues: ParsedValues) {
|
||||
self.jsonOutput = parsedValues.flags.contains("jsonOutput")
|
||||
self.plainOutput = parsedValues.flags.contains("plainOutput")
|
||||
self.quiet = parsedValues.flags.contains("quiet")
|
||||
self.noColor = parsedValues.flags.contains("noColor")
|
||||
self.noInput = parsedValues.flags.contains("noInput")
|
||||
}
|
||||
|
||||
var outputFormat: OutputFormat {
|
||||
if jsonOutput { return .json }
|
||||
if plainOutput { return .plain }
|
||||
if quiet { return .quiet }
|
||||
return .standard
|
||||
}
|
||||
}
|
||||
4
Sources/remindctl/Version.swift
Normal file
4
Sources/remindctl/Version.swift
Normal file
@ -0,0 +1,4 @@
|
||||
// Generated by scripts/generate-version.sh. Do not edit.
|
||||
enum RemindctlVersion {
|
||||
static let current = "0.1.0"
|
||||
}
|
||||
46
Tests/RemindCoreTests/DateParsingTests.swift
Normal file
46
Tests/RemindCoreTests/DateParsingTests.swift
Normal file
@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct DateParsingTests {
|
||||
private let calendar: Calendar = {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current
|
||||
return calendar
|
||||
}()
|
||||
|
||||
@Test("Relative date parsing")
|
||||
func relativeDates() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let today = DateParsing.parseUserDate("today", now: now, calendar: calendar)
|
||||
let tomorrow = DateParsing.parseUserDate("tomorrow", now: now, calendar: calendar)
|
||||
let yesterday = DateParsing.parseUserDate("yesterday", now: now, calendar: calendar)
|
||||
|
||||
#expect(today == calendar.startOfDay(for: now))
|
||||
#expect(tomorrow == calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)))
|
||||
#expect(yesterday == calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)))
|
||||
}
|
||||
|
||||
@Test("ISO 8601 parsing")
|
||||
func isoParsing() {
|
||||
let input = "2026-01-03T12:34:56Z"
|
||||
let parsed = DateParsing.parseUserDate(input)
|
||||
#expect(parsed != nil)
|
||||
}
|
||||
|
||||
@Test("Formatted date parsing")
|
||||
func formattedParsing() {
|
||||
let input = "2026-01-03 10:30"
|
||||
let parsed = DateParsing.parseUserDate(input)
|
||||
#expect(parsed != nil)
|
||||
}
|
||||
|
||||
@Test("Format display output")
|
||||
func displayFormatting() {
|
||||
let date = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let output = DateParsing.formatDisplay(date, calendar: calendar)
|
||||
#expect(output.isEmpty == false)
|
||||
}
|
||||
}
|
||||
19
Tests/RemindCoreTests/ErrorsTests.swift
Normal file
19
Tests/RemindCoreTests/ErrorsTests.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct ErrorsTests {
|
||||
@Test("Error descriptions")
|
||||
func descriptions() {
|
||||
#expect(RemindCoreError.accessDenied.localizedDescription.contains("Reminders"))
|
||||
#expect(RemindCoreError.writeOnlyAccess.localizedDescription.contains("write-only"))
|
||||
#expect(RemindCoreError.listNotFound("Work").localizedDescription.contains("Work"))
|
||||
#expect(RemindCoreError.reminderNotFound("abc").localizedDescription.contains("abc"))
|
||||
#expect(RemindCoreError.ambiguousIdentifier("a", matches: ["1", "2"]).localizedDescription.contains("matches"))
|
||||
#expect(RemindCoreError.invalidIdentifier("x").localizedDescription.contains("Invalid identifier"))
|
||||
#expect(RemindCoreError.invalidDate("bad").localizedDescription.contains("Invalid date"))
|
||||
#expect(RemindCoreError.unsupported("nope").localizedDescription.contains("nope"))
|
||||
#expect(RemindCoreError.operationFailed("fail").localizedDescription.contains("fail"))
|
||||
}
|
||||
}
|
||||
53
Tests/RemindCoreTests/IDResolverTests.swift
Normal file
53
Tests/RemindCoreTests/IDResolverTests.swift
Normal file
@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct IDResolverTests {
|
||||
private func sampleReminders() -> [ReminderItem] {
|
||||
[
|
||||
ReminderItem(
|
||||
id: "abcd1234",
|
||||
title: "First",
|
||||
notes: nil,
|
||||
isCompleted: false,
|
||||
completionDate: nil,
|
||||
priority: .none,
|
||||
dueDate: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
listID: "list1",
|
||||
listName: "Work"
|
||||
),
|
||||
ReminderItem(
|
||||
id: "abce5678",
|
||||
title: "Second",
|
||||
notes: nil,
|
||||
isCompleted: false,
|
||||
completionDate: nil,
|
||||
priority: .none,
|
||||
dueDate: Date(timeIntervalSince1970: 1_700_000_100),
|
||||
listID: "list1",
|
||||
listName: "Work"
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@Test("Resolve by index")
|
||||
func resolveIndex() throws {
|
||||
let resolved = try IDResolver.resolve(["1"], from: sampleReminders())
|
||||
#expect(resolved.first?.title == "First")
|
||||
}
|
||||
|
||||
@Test("Resolve by prefix")
|
||||
func resolvePrefix() throws {
|
||||
let resolved = try IDResolver.resolve(["abcd"], from: sampleReminders())
|
||||
#expect(resolved.first?.title == "First")
|
||||
}
|
||||
|
||||
@Test("Reject short prefix")
|
||||
func rejectShortPrefix() {
|
||||
#expect(throws: RemindCoreError.invalidIdentifier("ab")) {
|
||||
_ = try IDResolver.resolve(["ab"], from: sampleReminders())
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Tests/RemindCoreTests/PriorityTests.swift
Normal file
17
Tests/RemindCoreTests/PriorityTests.swift
Normal file
@ -0,0 +1,17 @@
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct PriorityTests {
|
||||
@Test("EventKit priority mapping")
|
||||
func mapping() {
|
||||
#expect(ReminderPriority(eventKitValue: 0) == .none)
|
||||
#expect(ReminderPriority(eventKitValue: 1) == .high)
|
||||
#expect(ReminderPriority(eventKitValue: 5) == .medium)
|
||||
#expect(ReminderPriority(eventKitValue: 9) == .low)
|
||||
#expect(ReminderPriority.high.eventKitValue == 1)
|
||||
#expect(ReminderPriority.medium.eventKitValue == 5)
|
||||
#expect(ReminderPriority.low.eventKitValue == 9)
|
||||
}
|
||||
}
|
||||
22
Tests/RemindCoreTests/ReminderFilterParseTests.swift
Normal file
22
Tests/RemindCoreTests/ReminderFilterParseTests.swift
Normal file
@ -0,0 +1,22 @@
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct ReminderFilterParseTests {
|
||||
@Test("Parse filter aliases")
|
||||
func parseAliases() {
|
||||
#expect(ReminderFiltering.parse("t") == .tomorrow)
|
||||
#expect(ReminderFiltering.parse("w") == .week)
|
||||
#expect(ReminderFiltering.parse("o") == .overdue)
|
||||
#expect(ReminderFiltering.parse("u") == .upcoming)
|
||||
#expect(ReminderFiltering.parse("done") == .completed)
|
||||
#expect(ReminderFiltering.parse("all") == .all)
|
||||
}
|
||||
|
||||
@Test("Parse date filter")
|
||||
func parseDate() {
|
||||
let parsed = ReminderFiltering.parse("2026-01-03")
|
||||
#expect(parsed != nil)
|
||||
}
|
||||
}
|
||||
146
Tests/RemindCoreTests/ReminderFilteringTests.swift
Normal file
146
Tests/RemindCoreTests/ReminderFilteringTests.swift
Normal file
@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct ReminderFilteringTests {
|
||||
private let calendar: Calendar = {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current
|
||||
return calendar
|
||||
}()
|
||||
|
||||
private func reminders(now: Date) -> [ReminderItem] {
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today
|
||||
let tomorrow = calendar.date(byAdding: .day, value: 1, to: today) ?? today
|
||||
return [
|
||||
ReminderItem(
|
||||
id: "1",
|
||||
title: "Overdue",
|
||||
notes: nil,
|
||||
isCompleted: false,
|
||||
completionDate: nil,
|
||||
priority: .none,
|
||||
dueDate: yesterday,
|
||||
listID: "a",
|
||||
listName: "Home"
|
||||
),
|
||||
ReminderItem(
|
||||
id: "2",
|
||||
title: "Today",
|
||||
notes: nil,
|
||||
isCompleted: false,
|
||||
completionDate: nil,
|
||||
priority: .none,
|
||||
dueDate: today,
|
||||
listID: "a",
|
||||
listName: "Home"
|
||||
),
|
||||
ReminderItem(
|
||||
id: "3",
|
||||
title: "Tomorrow",
|
||||
notes: nil,
|
||||
isCompleted: false,
|
||||
completionDate: nil,
|
||||
priority: .none,
|
||||
dueDate: tomorrow,
|
||||
listID: "a",
|
||||
listName: "Home"
|
||||
),
|
||||
ReminderItem(
|
||||
id: "5",
|
||||
title: "No Due",
|
||||
notes: nil,
|
||||
isCompleted: false,
|
||||
completionDate: nil,
|
||||
priority: .none,
|
||||
dueDate: nil,
|
||||
listID: "a",
|
||||
listName: "Home"
|
||||
),
|
||||
ReminderItem(
|
||||
id: "4",
|
||||
title: "Completed",
|
||||
notes: nil,
|
||||
isCompleted: true,
|
||||
completionDate: now,
|
||||
priority: .none,
|
||||
dueDate: today,
|
||||
listID: "a",
|
||||
listName: "Home"
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@Test("Today filter includes overdue")
|
||||
func todayIncludesOverdue() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .today, now: now, calendar: calendar)
|
||||
#expect(result.count == 2)
|
||||
}
|
||||
|
||||
@Test("Tomorrow filter")
|
||||
func tomorrowFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .tomorrow, now: now, calendar: calendar)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.title == "Tomorrow")
|
||||
}
|
||||
|
||||
@Test("Overdue filter")
|
||||
func overdueFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .overdue, now: now, calendar: calendar)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.title == "Overdue")
|
||||
}
|
||||
|
||||
@Test("Upcoming filter ignores no due date")
|
||||
func upcomingFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .upcoming, now: now, calendar: calendar)
|
||||
#expect(result.count == 3)
|
||||
}
|
||||
|
||||
@Test("Date filter")
|
||||
func dateFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .date(today), now: now, calendar: calendar)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.title == "Today")
|
||||
}
|
||||
|
||||
@Test("All filter includes completed")
|
||||
func allFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .all, now: now, calendar: calendar)
|
||||
#expect(result.count == items.count)
|
||||
}
|
||||
|
||||
@Test("Sort orders by due date then title")
|
||||
func sortOrder() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let sorted = ReminderFiltering.sort(items)
|
||||
#expect(sorted.first?.title == "Overdue")
|
||||
#expect(sorted.last?.title == "No Due")
|
||||
}
|
||||
|
||||
@Test("Completed filter")
|
||||
func completedFilter() {
|
||||
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
let items = reminders(now: now)
|
||||
let result = ReminderFiltering.apply(items, filter: .completed, now: now, calendar: calendar)
|
||||
#expect(result.count == 1)
|
||||
#expect(result.first?.title == "Completed")
|
||||
}
|
||||
}
|
||||
20
Tests/remindctlTests/HelpPrinterTests.swift
Normal file
20
Tests/remindctlTests/HelpPrinterTests.swift
Normal file
@ -0,0 +1,20 @@
|
||||
import Testing
|
||||
|
||||
@testable import remindctl
|
||||
|
||||
@MainActor
|
||||
struct HelpPrinterTests {
|
||||
@Test("Root help includes commands")
|
||||
func rootHelp() {
|
||||
let specs = [
|
||||
ShowCommand.spec,
|
||||
ListCommand.spec,
|
||||
AddCommand.spec,
|
||||
]
|
||||
let lines = HelpPrinter.renderRoot(version: "0.0.0", rootName: "remindctl", commands: specs)
|
||||
let joined = lines.joined(separator: "\n")
|
||||
#expect(joined.contains("show"))
|
||||
#expect(joined.contains("list"))
|
||||
#expect(joined.contains("add"))
|
||||
}
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "remindctl",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"version:sync": "scripts/generate-version.sh",
|
||||
"remindctl": "pnpm -s version:sync && swift run remindctl",
|
||||
"start": "pnpm -s remindctl",
|
||||
"format": "swift format --in-place --recursive Sources Tests",
|
||||
"lint": "swift format lint --recursive Sources Tests && swiftlint",
|
||||
"test": "pnpm -s version:sync && swift test --enable-code-coverage",
|
||||
"coverage": "scripts/check-coverage.sh",
|
||||
"build": "pnpm -s version:sync && mkdir -p bin && swift build -c release --product remindctl && cp .build/release/remindctl bin/remindctl && codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl"
|
||||
}
|
||||
}
|
||||
84
scripts/check-coverage.sh
Executable file
84
scripts/check-coverage.sh
Executable file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
CACHE_PATH="${HOME}/Library/Caches/remindctl/swiftpm"
|
||||
COVERAGE_BUILD_PATH="${ROOT_DIR}/.build/coverage"
|
||||
mkdir -p "${CACHE_PATH}"
|
||||
|
||||
MIN_COVERAGE="${COVERAGE_MIN:-80}"
|
||||
INCLUDE_REGEX="${COVERAGE_INCLUDE_REGEX:-/Sources/RemindCore/}"
|
||||
EXCLUDE_REGEX="${COVERAGE_EXCLUDE_REGEX:-/Sources/RemindCore/EventKitStore.swift}"
|
||||
|
||||
echo "==> swift test --enable-code-coverage (isolated build dir)"
|
||||
swift test --enable-code-coverage --build-path "${COVERAGE_BUILD_PATH}" --cache-path "${CACHE_PATH}" >/dev/null
|
||||
|
||||
REPORT_JSON="$(
|
||||
find "${COVERAGE_BUILD_PATH}" -type f -path "*debug/codecov/remindctl.json" -print0 2>/dev/null \
|
||||
| xargs -0 ls -t 2>/dev/null \
|
||||
| head -n 1
|
||||
)"
|
||||
|
||||
if [ -z "${REPORT_JSON}" ] || [ ! -f "${REPORT_JSON}" ]; then
|
||||
echo "ERROR: Coverage report not found (expected .build/**/debug/codecov/remindctl.json)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$REPORT_JSON" "$INCLUDE_REGEX" "$EXCLUDE_REGEX" "$MIN_COVERAGE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
report_path, include_re, exclude_re, min_str = sys.argv[1:5]
|
||||
min_coverage = float(min_str)
|
||||
|
||||
with open(report_path, "r", encoding="utf-8") as f:
|
||||
obj = json.load(f)
|
||||
|
||||
files = obj["data"][0]["files"]
|
||||
|
||||
include = re.compile(include_re)
|
||||
exclude = re.compile(exclude_re) if exclude_re else None
|
||||
|
||||
selected = []
|
||||
for item in files:
|
||||
filename = item["filename"]
|
||||
if not include.search(filename):
|
||||
continue
|
||||
if exclude and exclude.search(filename):
|
||||
continue
|
||||
summary = item.get("summary", {}).get("lines", {})
|
||||
count = int(summary.get("count", 0))
|
||||
covered = int(summary.get("covered", 0))
|
||||
selected.append((filename, covered, count))
|
||||
|
||||
if not selected:
|
||||
print(f"ERROR: No files matched coverage include regex: {include_re}", file=sys.stderr)
|
||||
print(f" exclude regex: {exclude_re or '(none)'}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
total_lines = sum(count for _, _, count in selected)
|
||||
total_covered = sum(covered for _, covered, _ in selected)
|
||||
percent = (total_covered / total_lines * 100.0) if total_lines else 0.0
|
||||
|
||||
repo_root = os.getcwd() + os.sep
|
||||
|
||||
def rel(path: str) -> str:
|
||||
return path[len(repo_root):] if path.startswith(repo_root) else path
|
||||
|
||||
print(f"==> Coverage (lines): {percent:.1f}% ({total_covered}/{total_lines})")
|
||||
print(f" Scope: include={include_re} exclude={exclude_re or '(none)'}")
|
||||
print(f" Min: {min_coverage:.1f}%")
|
||||
|
||||
worst = sorted(selected, key=lambda t: (t[1] / t[2] if t[2] else 0.0, -t[2]))[:10]
|
||||
print(" Lowest covered files:")
|
||||
for filename, covered, count in worst:
|
||||
p = (covered / count * 100.0) if count else 0.0
|
||||
print(f" - {p:5.1f}% {covered:4d}/{count:4d} {rel(filename)}")
|
||||
|
||||
if percent + 1e-9 < min_coverage:
|
||||
sys.exit(2)
|
||||
PY
|
||||
37
scripts/generate-version.sh
Executable file
37
scripts/generate-version.sh
Executable file
@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
source "$ROOT/version.env"
|
||||
OUTPUT="$ROOT/Sources/remindctl/Version.swift"
|
||||
PLIST_OUTPUT="$ROOT/Sources/remindctl/Resources/Info.plist"
|
||||
mkdir -p "$(dirname "$OUTPUT")"
|
||||
mkdir -p "$(dirname "$PLIST_OUTPUT")"
|
||||
cat > "$OUTPUT" <<SWIFT
|
||||
// Generated by scripts/generate-version.sh. Do not edit.
|
||||
enum RemindctlVersion {
|
||||
static let current = "${MARKETING_VERSION}"
|
||||
}
|
||||
SWIFT
|
||||
|
||||
cat > "$PLIST_OUTPUT" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.steipete.remindctl</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>remindctl</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>remindctl</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${MARKETING_VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${MARKETING_VERSION}</string>
|
||||
<key>NSRemindersUsageDescription</key>
|
||||
<string>Manage your reminders from the terminal.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
1
version.env
Normal file
1
version.env
Normal file
@ -0,0 +1 @@
|
||||
MARKETING_VERSION=0.1.0
|
||||
Loading…
Reference in New Issue
Block a user