From d8f95101127db8bd00da3d98e3c4dd4e44a8d17e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 07:05:13 +0100 Subject: [PATCH] feat: initial remindctl cli --- .github/workflows/ci.yml | 26 ++ .gitignore | 38 +-- .swift-format | 58 +++++ .swiftlint.yml | 93 +++++++ CHANGELOG.md | 10 + Package.resolved | 15 ++ Package.swift | 55 ++++ README.md | 63 +++++ Sources/RemindCore/DateParsing.swift | 83 ++++++ Sources/RemindCore/Errors.swift | 36 +++ Sources/RemindCore/EventKitStore.swift | 242 ++++++++++++++++++ Sources/RemindCore/IDResolver.swift | 40 +++ Sources/RemindCore/Models.swift | 117 +++++++++ Sources/RemindCore/ReminderFilter.swift | 108 ++++++++ Sources/remindctl/CommandHelpers.swift | 26 ++ Sources/remindctl/CommandRouter.swift | 153 +++++++++++ Sources/remindctl/CommandSignatures.swift | 42 +++ Sources/remindctl/CommandSpec.swift | 19 ++ Sources/remindctl/Commands/AddCommand.swift | 81 ++++++ .../remindctl/Commands/CompleteCommand.swift | 46 ++++ .../remindctl/Commands/DeleteCommand.swift | 54 ++++ Sources/remindctl/Commands/EditCommand.swift | 98 +++++++ Sources/remindctl/Commands/ListCommand.swift | 112 ++++++++ Sources/remindctl/Commands/ShowCommand.swift | 58 +++++ Sources/remindctl/Console.swift | 41 +++ Sources/remindctl/HelpPrinter.swift | 108 ++++++++ Sources/remindctl/OutputFormatting.swift | 142 ++++++++++ Sources/remindctl/ParsedValues+Decode.swift | 49 ++++ Sources/remindctl/RemindctlMain.swift | 9 + Sources/remindctl/Resources/Info.plist | 20 ++ Sources/remindctl/RuntimeOptions.swift | 24 ++ Sources/remindctl/Version.swift | 4 + Tests/RemindCoreTests/DateParsingTests.swift | 46 ++++ Tests/RemindCoreTests/ErrorsTests.swift | 19 ++ Tests/RemindCoreTests/IDResolverTests.swift | 53 ++++ Tests/RemindCoreTests/PriorityTests.swift | 17 ++ .../ReminderFilterParseTests.swift | 22 ++ .../ReminderFilteringTests.swift | 146 +++++++++++ Tests/remindctlTests/HelpPrinterTests.swift | 20 ++ package.json | 15 ++ scripts/check-coverage.sh | 84 ++++++ scripts/generate-version.sh | 37 +++ version.env | 1 + 43 files changed, 2498 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-format create mode 100644 .swiftlint.yml create mode 100644 CHANGELOG.md create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/RemindCore/DateParsing.swift create mode 100644 Sources/RemindCore/Errors.swift create mode 100644 Sources/RemindCore/EventKitStore.swift create mode 100644 Sources/RemindCore/IDResolver.swift create mode 100644 Sources/RemindCore/Models.swift create mode 100644 Sources/RemindCore/ReminderFilter.swift create mode 100644 Sources/remindctl/CommandHelpers.swift create mode 100644 Sources/remindctl/CommandRouter.swift create mode 100644 Sources/remindctl/CommandSignatures.swift create mode 100644 Sources/remindctl/CommandSpec.swift create mode 100644 Sources/remindctl/Commands/AddCommand.swift create mode 100644 Sources/remindctl/Commands/CompleteCommand.swift create mode 100644 Sources/remindctl/Commands/DeleteCommand.swift create mode 100644 Sources/remindctl/Commands/EditCommand.swift create mode 100644 Sources/remindctl/Commands/ListCommand.swift create mode 100644 Sources/remindctl/Commands/ShowCommand.swift create mode 100644 Sources/remindctl/Console.swift create mode 100644 Sources/remindctl/HelpPrinter.swift create mode 100644 Sources/remindctl/OutputFormatting.swift create mode 100644 Sources/remindctl/ParsedValues+Decode.swift create mode 100644 Sources/remindctl/RemindctlMain.swift create mode 100644 Sources/remindctl/Resources/Info.plist create mode 100644 Sources/remindctl/RuntimeOptions.swift create mode 100644 Sources/remindctl/Version.swift create mode 100644 Tests/RemindCoreTests/DateParsingTests.swift create mode 100644 Tests/RemindCoreTests/ErrorsTests.swift create mode 100644 Tests/RemindCoreTests/IDResolverTests.swift create mode 100644 Tests/RemindCoreTests/PriorityTests.swift create mode 100644 Tests/RemindCoreTests/ReminderFilterParseTests.swift create mode 100644 Tests/RemindCoreTests/ReminderFilteringTests.swift create mode 100644 Tests/remindctlTests/HelpPrinterTests.swift create mode 100644 package.json create mode 100755 scripts/check-coverage.sh create mode 100755 scripts/generate-version.sh create mode 100644 version.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..caf2804 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index aaadf73..3eb0b67 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..677f452 --- /dev/null +++ b/.swift-format @@ -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 + } +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..6cd14b6 --- /dev/null +++ b/.swiftlint.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..26e4074 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..ed35c8d --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2683d24 --- /dev/null +++ b/Package.swift @@ -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] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f563f7 --- /dev/null +++ b/README.md @@ -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. diff --git a/Sources/RemindCore/DateParsing.swift b/Sources/RemindCore/DateParsing.swift new file mode 100644 index 0000000..b3be087 --- /dev/null +++ b/Sources/RemindCore/DateParsing.swift @@ -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 + } + } +} diff --git a/Sources/RemindCore/Errors.swift b/Sources/RemindCore/Errors.swift new file mode 100644 index 0000000..cbd6911 --- /dev/null +++ b/Sources/RemindCore/Errors.swift @@ -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 + } + } +} diff --git a/Sources/RemindCore/EventKitStore.swift b/Sources/RemindCore/EventKitStore.swift new file mode 100644 index 0000000..08b3cee --- /dev/null +++ b/Sources/RemindCore/EventKitStore.swift @@ -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 + ) + } +} diff --git a/Sources/RemindCore/IDResolver.swift b/Sources/RemindCore/IDResolver.swift new file mode 100644 index 0000000..47ec595 --- /dev/null +++ b/Sources/RemindCore/IDResolver.swift @@ -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 + } +} diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift new file mode 100644 index 0000000..5f4fe90 --- /dev/null +++ b/Sources/RemindCore/Models.swift @@ -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 + } +} diff --git a/Sources/RemindCore/ReminderFilter.swift b/Sources/RemindCore/ReminderFilter.swift new file mode 100644 index 0000000..d602373 --- /dev/null +++ b/Sources/RemindCore/ReminderFilter.swift @@ -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 + } + } + } +} diff --git a/Sources/remindctl/CommandHelpers.swift b/Sources/remindctl/CommandHelpers.swift new file mode 100644 index 0000000..6323cdd --- /dev/null +++ b/Sources/remindctl/CommandHelpers.swift @@ -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 + } +} diff --git a/Sources/remindctl/CommandRouter.swift b/Sources/remindctl/CommandRouter.swift new file mode 100644 index 0000000..0e80b2e --- /dev/null +++ b/Sources/remindctl/CommandRouter.swift @@ -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 + } +} diff --git a/Sources/remindctl/CommandSignatures.swift b/Sources/remindctl/CommandSignatures.swift new file mode 100644 index 0000000..acb029c --- /dev/null +++ b/Sources/remindctl/CommandSignatures.swift @@ -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 + ) + } +} diff --git a/Sources/remindctl/CommandSpec.swift b/Sources/remindctl/CommandSpec.swift new file mode 100644 index 0000000..7dea258 --- /dev/null +++ b/Sources/remindctl/CommandSpec.swift @@ -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 + ) + } +} diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift new file mode 100644 index 0000000..571bc25 --- /dev/null +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -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) + } + } +} diff --git a/Sources/remindctl/Commands/CompleteCommand.swift b/Sources/remindctl/Commands/CompleteCommand.swift new file mode 100644 index 0000000..36fd26a --- /dev/null +++ b/Sources/remindctl/Commands/CompleteCommand.swift @@ -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) + } + } +} diff --git a/Sources/remindctl/Commands/DeleteCommand.swift b/Sources/remindctl/Commands/DeleteCommand.swift new file mode 100644 index 0000000..c79f258 --- /dev/null +++ b/Sources/remindctl/Commands/DeleteCommand.swift @@ -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) + } + } +} diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift new file mode 100644 index 0000000..75a3c31 --- /dev/null +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -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) + } + } +} diff --git a/Sources/remindctl/Commands/ListCommand.swift b/Sources/remindctl/Commands/ListCommand.swift new file mode 100644 index 0000000..ac20ba8 --- /dev/null +++ b/Sources/remindctl/Commands/ListCommand.swift @@ -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) + } + } +} diff --git a/Sources/remindctl/Commands/ShowCommand.swift b/Sources/remindctl/Commands/ShowCommand.swift new file mode 100644 index 0000000..c484634 --- /dev/null +++ b/Sources/remindctl/Commands/ShowCommand.swift @@ -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|", + 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) + } + } +} diff --git a/Sources/remindctl/Console.swift b/Sources/remindctl/Console.swift new file mode 100644 index 0000000..7389389 --- /dev/null +++ b/Sources/remindctl/Console.swift @@ -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) + } +} diff --git a/Sources/remindctl/HelpPrinter.swift b/Sources/remindctl/HelpPrinter.swift new file mode 100644 index 0000000..7f21240 --- /dev/null +++ b/Sources/remindctl/HelpPrinter.swift @@ -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) --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 ? " " : "" + return parts.joined(separator: ", ") + suffix + } +} diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift new file mode 100644 index 0000000..318c5d1 --- /dev/null +++ b/Sources/remindctl/OutputFormatting.swift @@ -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(_ 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 + } +} diff --git a/Sources/remindctl/ParsedValues+Decode.swift b/Sources/remindctl/ParsedValues+Decode.swift new file mode 100644 index 0000000..36b8893 --- /dev/null +++ b/Sources/remindctl/ParsedValues+Decode.swift @@ -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] + } +} diff --git a/Sources/remindctl/RemindctlMain.swift b/Sources/remindctl/RemindctlMain.swift new file mode 100644 index 0000000..ca6f4e2 --- /dev/null +++ b/Sources/remindctl/RemindctlMain.swift @@ -0,0 +1,9 @@ +import Foundation + +@main +enum RemindctlMain { + static func main() async { + let code = await CommandRouter().run() + exit(code) + } +} diff --git a/Sources/remindctl/Resources/Info.plist b/Sources/remindctl/Resources/Info.plist new file mode 100644 index 0000000..e558642 --- /dev/null +++ b/Sources/remindctl/Resources/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleIdentifier + com.steipete.remindctl + CFBundleName + remindctl + CFBundleExecutable + remindctl + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + NSRemindersUsageDescription + Manage your reminders from the terminal. + + diff --git a/Sources/remindctl/RuntimeOptions.swift b/Sources/remindctl/RuntimeOptions.swift new file mode 100644 index 0000000..402f4f0 --- /dev/null +++ b/Sources/remindctl/RuntimeOptions.swift @@ -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 + } +} diff --git a/Sources/remindctl/Version.swift b/Sources/remindctl/Version.swift new file mode 100644 index 0000000..b131980 --- /dev/null +++ b/Sources/remindctl/Version.swift @@ -0,0 +1,4 @@ +// Generated by scripts/generate-version.sh. Do not edit. +enum RemindctlVersion { + static let current = "0.1.0" +} diff --git a/Tests/RemindCoreTests/DateParsingTests.swift b/Tests/RemindCoreTests/DateParsingTests.swift new file mode 100644 index 0000000..984c8d8 --- /dev/null +++ b/Tests/RemindCoreTests/DateParsingTests.swift @@ -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) + } +} diff --git a/Tests/RemindCoreTests/ErrorsTests.swift b/Tests/RemindCoreTests/ErrorsTests.swift new file mode 100644 index 0000000..9c84413 --- /dev/null +++ b/Tests/RemindCoreTests/ErrorsTests.swift @@ -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")) + } +} diff --git a/Tests/RemindCoreTests/IDResolverTests.swift b/Tests/RemindCoreTests/IDResolverTests.swift new file mode 100644 index 0000000..bcb3910 --- /dev/null +++ b/Tests/RemindCoreTests/IDResolverTests.swift @@ -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()) + } + } +} diff --git a/Tests/RemindCoreTests/PriorityTests.swift b/Tests/RemindCoreTests/PriorityTests.swift new file mode 100644 index 0000000..7f77daa --- /dev/null +++ b/Tests/RemindCoreTests/PriorityTests.swift @@ -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) + } +} diff --git a/Tests/RemindCoreTests/ReminderFilterParseTests.swift b/Tests/RemindCoreTests/ReminderFilterParseTests.swift new file mode 100644 index 0000000..3519a2c --- /dev/null +++ b/Tests/RemindCoreTests/ReminderFilterParseTests.swift @@ -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) + } +} diff --git a/Tests/RemindCoreTests/ReminderFilteringTests.swift b/Tests/RemindCoreTests/ReminderFilteringTests.swift new file mode 100644 index 0000000..35d45f2 --- /dev/null +++ b/Tests/RemindCoreTests/ReminderFilteringTests.swift @@ -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") + } +} diff --git a/Tests/remindctlTests/HelpPrinterTests.swift b/Tests/remindctlTests/HelpPrinterTests.swift new file mode 100644 index 0000000..35c4323 --- /dev/null +++ b/Tests/remindctlTests/HelpPrinterTests.swift @@ -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")) + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e6663f9 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..0ce10da --- /dev/null +++ b/scripts/check-coverage.sh @@ -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 diff --git a/scripts/generate-version.sh b/scripts/generate-version.sh new file mode 100755 index 0000000..0786b4e --- /dev/null +++ b/scripts/generate-version.sh @@ -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" < "$PLIST_OUTPUT" < + + + + CFBundleIdentifier + com.steipete.remindctl + CFBundleName + remindctl + CFBundleExecutable + remindctl + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MARKETING_VERSION} + CFBundleVersion + ${MARKETING_VERSION} + NSRemindersUsageDescription + Manage your reminders from the terminal. + + +PLIST diff --git a/version.env b/version.env new file mode 100644 index 0000000..6fea2b3 --- /dev/null +++ b/version.env @@ -0,0 +1 @@ +MARKETING_VERSION=0.1.0