Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ OPTIONS:
--no-color Disable colored output.
--group-by <mode> Group output: none, date, or calendar.
--show-empty-dates Show dates with no events (implies --group-by date).
--from <from> Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N').
--from <from> Start date (ISO 8601, 'today', 'tomorrow', 'yesterday', 'today+N', or natural language).
--to <to> End date (same formats as --from).
--include-calendars Only include these calendars (comma-separated titles).
--exclude-calendars Exclude these calendars (comma-separated titles).
Expand Down Expand Up @@ -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

Expand Down
22 changes: 21 additions & 1 deletion Sources/ICalGuyKit/Services/DateParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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')."
}
}
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
}
}
2 changes: 1 addition & 1 deletion Sources/ical-guy/Commands/BirthdaysCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).")
Expand Down
2 changes: 1 addition & 1 deletion Sources/ical-guy/Commands/ConflictsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
2 changes: 1 addition & 1 deletion Sources/ical-guy/Commands/EventsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).")
Expand Down
2 changes: 1 addition & 1 deletion Sources/ical-guy/Commands/FreeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
2 changes: 1 addition & 1 deletion Sources/ical-guy/Commands/RemindersCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).")
Expand Down
89 changes: 89 additions & 0 deletions Tests/ICalGuyKitTests/NaturalLanguageDateParserTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
10 changes: 10 additions & 0 deletions man/man1/ical-guy.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down