From b07edc1399d8848ea34a5be4fabcc5e3584370c8 Mon Sep 17 00:00:00 2001 From: Josh Priddle Date: Tue, 17 Feb 2026 14:30:13 -0500 Subject: [PATCH] Support natural language in date ranges --- README.md | 3 +- Sources/ICalGuyKit/Services/DateParser.swift | 22 ++++- .../ical-guy/Commands/BirthdaysCommand.swift | 2 +- .../ical-guy/Commands/ConflictsCommand.swift | 2 +- Sources/ical-guy/Commands/EventsCommand.swift | 2 +- Sources/ical-guy/Commands/FreeCommand.swift | 2 +- .../ical-guy/Commands/RemindersCommand.swift | 2 +- .../NaturalLanguageDateParserTests.swift | 89 +++++++++++++++++++ man/man1/ical-guy.1 | 10 +++ 9 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 Tests/ICalGuyKitTests/NaturalLanguageDateParserTests.swift diff --git a/README.md b/README.md index 0e8ec30..0883c75 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ OPTIONS: --no-color Disable colored output. --group-by Group output: none, date, or calendar. --show-empty-dates Show dates with no events (implies --group-by date). - --from Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N'). + --from Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N', or natural language). --to End date (same formats as --from). --include-calendars Only include these calendars (comma-separated titles). --exclude-calendars Exclude these calendars (comma-separated titles). @@ -413,6 +413,7 @@ The `meetingUrl` field is automatically populated when a Google Meet, Zoom, Micr | `today-N` | `--from today-3` | N days before today | | `now` | `--from now` | Current date/time | | ISO 8601 | `--from 2024-03-15` | Specific date | +| Natural language | `--from "june 10 at 6pm"` | English date phrases (e.g. "next friday", "march 1, 2026") | ### JSON output diff --git a/Sources/ICalGuyKit/Services/DateParser.swift b/Sources/ICalGuyKit/Services/DateParser.swift index b6986f7..46216f8 100644 --- a/Sources/ICalGuyKit/Services/DateParser.swift +++ b/Sources/ICalGuyKit/Services/DateParser.swift @@ -7,7 +7,8 @@ public enum DateParseError: Error, LocalizedError { switch self { case .invalidFormat(let input): return "Invalid date format: '\(input)'. " - + "Use ISO 8601 (2024-03-15), 'today', 'tomorrow', 'yesterday', or 'today+N'/'today-N'." + + "Use ISO 8601 (2024-03-15), 'today', 'tomorrow', 'yesterday', 'today+N'/'today-N', " + + "or natural language (e.g. 'june 10 at 6pm', 'next friday')." } } } @@ -61,6 +62,11 @@ public struct DateParser: Sendable { return date } + // Natural language: "june 10 at 6pm", "next friday", etc. + if let date = parseNaturalLanguage(input.trimmingCharacters(in: .whitespaces)) { + return date + } + throw DateParseError.invalidFormat(input) } @@ -113,4 +119,18 @@ public struct DateParser: Sendable { formatter.timeZone = calendar.timeZone return formatter.date(from: input) } + + private func parseNaturalLanguage(_ input: String) -> Date? { + guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.date.rawValue) + else { + return nil + } + let range = NSRange(input.startIndex..., in: input) + guard let match = detector.firstMatch(in: input, options: [], range: range), + match.range == range + else { + return nil + } + return match.date + } } diff --git a/Sources/ical-guy/Commands/BirthdaysCommand.swift b/Sources/ical-guy/Commands/BirthdaysCommand.swift index 922a693..368d7f6 100644 --- a/Sources/ical-guy/Commands/BirthdaysCommand.swift +++ b/Sources/ical-guy/Commands/BirthdaysCommand.swift @@ -10,7 +10,7 @@ struct BirthdaysCommand: AsyncParsableCommand { @OptionGroup var globalOptions: GlobalOptions - @Option(name: .long, help: "Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N').") + @Option(name: .long, help: "Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N', or natural language).") var from: String? @Option(name: .long, help: "End date (same formats as --from).") diff --git a/Sources/ical-guy/Commands/ConflictsCommand.swift b/Sources/ical-guy/Commands/ConflictsCommand.swift index 1b6ecb8..e697bb5 100644 --- a/Sources/ical-guy/Commands/ConflictsCommand.swift +++ b/Sources/ical-guy/Commands/ConflictsCommand.swift @@ -12,7 +12,7 @@ struct ConflictsCommand: AsyncParsableCommand { @Option( name: .long, - help: "Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N')." + help: "Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N', or natural language)." ) var from: String? diff --git a/Sources/ical-guy/Commands/EventsCommand.swift b/Sources/ical-guy/Commands/EventsCommand.swift index 2f68b13..a07d0b0 100644 --- a/Sources/ical-guy/Commands/EventsCommand.swift +++ b/Sources/ical-guy/Commands/EventsCommand.swift @@ -10,7 +10,7 @@ struct EventsCommand: AsyncParsableCommand { @OptionGroup var globalOptions: GlobalOptions - @Option(name: .long, help: "Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N').") + @Option(name: .long, help: "Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N', or natural language).") var from: String? @Option(name: .long, help: "End date (same formats as --from).") diff --git a/Sources/ical-guy/Commands/FreeCommand.swift b/Sources/ical-guy/Commands/FreeCommand.swift index e2edfd0..c330cda 100644 --- a/Sources/ical-guy/Commands/FreeCommand.swift +++ b/Sources/ical-guy/Commands/FreeCommand.swift @@ -12,7 +12,7 @@ struct FreeCommand: AsyncParsableCommand { @Option( name: .long, - help: "Start date (ISO 8601, 'today', 'tomorrow', 'now', 'today+N')." + help: "Start date (ISO 8601, 'today', 'tomorrow', 'now', 'today+N', or natural language)." ) var from: String? diff --git a/Sources/ical-guy/Commands/RemindersCommand.swift b/Sources/ical-guy/Commands/RemindersCommand.swift index d413a66..bcfbe70 100644 --- a/Sources/ical-guy/Commands/RemindersCommand.swift +++ b/Sources/ical-guy/Commands/RemindersCommand.swift @@ -30,7 +30,7 @@ struct RemindersListCommand: AsyncParsableCommand { @Flag(name: .long, help: "Show all reminders (completed and incomplete).") var all: Bool = false - @Option(name: .long, help: "Start date for due date filter (ISO 8601, 'today', etc.).") + @Option(name: .long, help: "Start date for due date filter (ISO 8601, 'today', 'today+N', or natural language).") var from: String? @Option(name: .long, help: "End date for due date filter (same formats as --from).") diff --git a/Tests/ICalGuyKitTests/NaturalLanguageDateParserTests.swift b/Tests/ICalGuyKitTests/NaturalLanguageDateParserTests.swift new file mode 100644 index 0000000..39f58dc --- /dev/null +++ b/Tests/ICalGuyKitTests/NaturalLanguageDateParserTests.swift @@ -0,0 +1,89 @@ +import Foundation +import XCTest + +@testable import ICalGuyKit + +final class NaturalLanguageDateParserTests: XCTestCase { + private var parser: DateParser! + private var calendar: Calendar! + + override func setUp() { + super.setUp() + calendar = Calendar.current + parser = DateParser() + } + + // MARK: - Absolute dates + + func testJune10() throws { + let result = try parser.parse("june 10") + let components = calendar.dateComponents([.month, .day], from: result) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 10) + } + + func testMarch1_2026() throws { + let result = try parser.parse("march 1, 2026") + let components = calendar.dateComponents([.year, .month, .day], from: result) + XCTAssertEqual(components.year, 2026) + XCTAssertEqual(components.month, 3) + XCTAssertEqual(components.day, 1) + } + + func testJune10At6pm() throws { + let result = try parser.parse("june 10 at 6pm") + let components = calendar.dateComponents([.month, .day, .hour], from: result) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 10) + XCTAssertEqual(components.hour, 18) + } + + func testDecember25() throws { + let result = try parser.parse("december 25") + let components = calendar.dateComponents([.month, .day], from: result) + XCTAssertEqual(components.month, 12) + XCTAssertEqual(components.day, 25) + } + + func testCaseInsensitivity() throws { + let result = try parser.parse("June 10 at 6PM") + let components = calendar.dateComponents([.month, .day, .hour], from: result) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 10) + XCTAssertEqual(components.hour, 18) + } + + // MARK: - Relative dates + + func testTomorrowAtNoon() throws { + let result = try parser.parse("tomorrow at noon") + let expectedDate = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date()))! + let resultComponents = calendar.dateComponents([.year, .month, .day, .hour], from: result) + let expectedComponents = calendar.dateComponents([.year, .month, .day], from: expectedDate) + XCTAssertEqual(resultComponents.year, expectedComponents.year) + XCTAssertEqual(resultComponents.month, expectedComponents.month) + XCTAssertEqual(resultComponents.day, expectedComponents.day) + XCTAssertEqual(resultComponents.hour, 12) + } + + func testNextFriday() throws { + let result = try parser.parse("next friday") + let components = calendar.dateComponents([.weekday], from: result) + XCTAssertEqual(components.weekday, 6) // Friday = 6 in Calendar + XCTAssertGreaterThan(result, Date()) + } + + // MARK: - Rejection tests + + func testPartialMatchRejects() { + XCTAssertThrowsError(try parser.parse("meet me tomorrow")) { error in + XCTAssertTrue(error is DateParseError) + } + } + + func testNonsenseRejects() { + XCTAssertThrowsError(try parser.parse("not-a-date-at-all")) { error in + XCTAssertTrue(error is DateParseError) + } + } +} diff --git a/man/man1/ical-guy.1 b/man/man1/ical-guy.1 index 9cfb91e..4497ec9 100644 --- a/man/man1/ical-guy.1 +++ b/man/man1/ical-guy.1 @@ -460,6 +460,16 @@ Current date and time. .I YYYY\-MM\-DD ISO 8601 date (e.g., .BR 2026\-03\-15 ). +.TP +.I "natural language" +English date phrases such as +.BR "june 10" , +.BR "march 1, 2026" , +.BR "june 10 at 6pm" , +.BR "next friday" , +or +.BR "tomorrow at noon" . +Uses macOS natural language date detection. .SH OUTPUT .SS Text Output (default for TTY) Event display depends on the