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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ make install
Download the universal binary from the [releases page](https://github.com/itspriddle/ical-guy/releases), extract, and move to your PATH:

```
tar -xzf ical-guy-v0.10.0-macos-universal.tar.gz
tar -xzf ical-guy-v0.10.1-macos-universal.tar.gz
mv ical-guy /usr/local/bin/
```

Expand Down Expand Up @@ -173,12 +173,30 @@ ical-guy meeting open
# Open next meeting URL in browser
ical-guy meeting open --next

# Open in a specific browser
ical-guy meeting open --browser "Google Chrome"

# List today's meetings (events with video call URLs)
ical-guy meeting list
```

Meeting subcommands support `--include-calendars` and `--exclude-calendars` for filtering, and `--format`/`--no-color` for output control (except `meeting open`).

#### Browser selection

`meeting open` supports per-vendor browser overrides via the config file and a `--browser` CLI flag. Priority: `--browser` flag > vendor-specific config > `[browsers] default` config > system default.

```toml
[browsers]
default = "Safari"
meet = "Google Chrome"
zoom = "Safari"
teams = "Microsoft Edge"
webex = "Google Chrome"
```

Vendor keys (`meet`, `zoom`, `teams`, `webex`) correspond to the detected meeting URL type. See Configuration below.

### Conflicts

The `conflicts` command detects double-booked events in a date range:
Expand Down Expand Up @@ -456,6 +474,7 @@ Each event object contains:
"notes": "Weekly sync",
"url": null,
"meetingUrl": "https://meet.google.com/abc-defg-hij",
"meetingVendor": "meet",
"calendar": {
"id": "A1B2C3D4-...",
"title": "Work",
Expand Down Expand Up @@ -527,6 +546,13 @@ min-duration = 30 # Minimum free slot in minutes
work-start = "09:00" # Working hours start (HH:MM)
work-end = "17:00" # Working hours end (HH:MM)

[browsers]
default = "Safari" # Default browser for meeting open
meet = "Google Chrome" # Google Meet
zoom = "Safari" # Zoom
teams = "Microsoft Edge" # Microsoft Teams
webex = "Google Chrome" # WebEx

[templates]
time-format = "HH:mm" # ICU time format (default: "h:mm a")
date-format = "yyyy-MM-dd" # ICU date format (default: "EEEE, MMM d, yyyy")
Expand Down
61 changes: 59 additions & 2 deletions Sources/ICalGuyKit/Config/ConfigLoader.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,45 @@
import Foundation
import TOMLKit

public struct BrowserConfig: Sendable, Equatable {
public let defaultBrowser: String?
public let meet: String?
public let zoom: String?
public let teams: String?
public let webex: String?

public init(
defaultBrowser: String? = nil,
meet: String? = nil,
zoom: String? = nil,
teams: String? = nil,
webex: String? = nil
) {
self.defaultBrowser = defaultBrowser
self.meet = meet
self.zoom = zoom
self.teams = teams
self.webex = webex
}

/// Returns the browser name for a given vendor, falling back to default.
public func browser(for vendor: MeetingVendor?) -> String? {
if let vendor {
let vendorBrowser: String? =
switch vendor {
case .meet: meet
case .zoom: zoom
case .teams: teams
case .webex: webex
}
if let vendorBrowser {
return vendorBrowser
}
}
return defaultBrowser
}
}

public struct UserConfig: Sendable, Equatable {
public let format: String?
public let excludeAllDay: Bool?
Expand Down Expand Up @@ -32,6 +71,7 @@ public struct UserConfig: Sendable, Equatable {
public let bullet: String?
public let separator: String?
public let indent: String?
public let browsers: BrowserConfig?

public init(
format: String? = nil,
Expand Down Expand Up @@ -63,7 +103,8 @@ public struct UserConfig: Sendable, Equatable {
truncateLocation: Int? = nil,
bullet: String? = nil,
separator: String? = nil,
indent: String? = nil
indent: String? = nil,
browsers: BrowserConfig? = nil
) {
self.format = format
self.excludeAllDay = excludeAllDay
Expand Down Expand Up @@ -95,6 +136,7 @@ public struct UserConfig: Sendable, Equatable {
self.bullet = bullet
self.separator = separator
self.indent = indent
self.browsers = browsers
}
}

Expand Down Expand Up @@ -162,6 +204,20 @@ public struct ConfigLoader: Sendable {
let text = table["text"] as? TOMLTable
let free = table["free"] as? TOMLTable
let templates = table["templates"] as? TOMLTable
let browsersTable = table["browsers"] as? TOMLTable

let browserConfig: BrowserConfig? =
if let browsersTable {
BrowserConfig(
defaultBrowser: browsersTable["default"] as? String,
meet: browsersTable["meet"] as? String,
zoom: browsersTable["zoom"] as? String,
teams: browsersTable["teams"] as? String,
webex: browsersTable["webex"] as? String
)
} else {
nil
}

return UserConfig(
format: format,
Expand Down Expand Up @@ -193,7 +249,8 @@ public struct ConfigLoader: Sendable {
truncateLocation: templates?["truncate-location"] as? Int,
bullet: templates?["bullet"] as? String,
separator: templates?["separator"] as? String,
indent: templates?["indent"] as? String
indent: templates?["indent"] as? String,
browsers: browserConfig
)
}

Expand Down
3 changes: 3 additions & 0 deletions Sources/ICalGuyKit/Models/CalendarEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public struct CalendarEvent: Codable, Equatable, Sendable {
public let notes: String?
public let url: String?
public let meetingUrl: String?
public let meetingVendor: MeetingVendor?
public let calendar: CalendarInfo
public let attendees: [Attendee]
public let organizer: Organizer?
Expand All @@ -40,6 +41,7 @@ public struct CalendarEvent: Codable, Equatable, Sendable {
notes: String?,
url: String?,
meetingUrl: String? = nil,
meetingVendor: MeetingVendor? = nil,
calendar: CalendarInfo,
attendees: [Attendee] = [],
organizer: Organizer? = nil,
Expand All @@ -59,6 +61,7 @@ public struct CalendarEvent: Codable, Equatable, Sendable {
self.notes = notes
self.url = url
self.meetingUrl = meetingUrl
self.meetingVendor = meetingVendor
self.calendar = calendar
self.attendees = attendees
self.organizer = organizer
Expand Down
6 changes: 6 additions & 0 deletions Sources/ICalGuyKit/Models/MeetingVendor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
public enum MeetingVendor: String, Codable, Sendable, CaseIterable {
case meet
case zoom
case teams
case webex
}
58 changes: 58 additions & 0 deletions Sources/ICalGuyKit/Services/BrowserResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation

public enum BrowserResolverError: Error, LocalizedError {
case applicationNotFound(String)

public var errorDescription: String? {
switch self {
case .applicationNotFound(let name):
return "Application not found: \(name)"
}
}
}

public struct BrowserResolver: Sendable {
public init() {}

/// Resolves which browser to use based on the priority chain:
/// CLI flag > vendor-specific config > default config > nil (system default)
public func resolveBrowserName(
cliBrowser: String?,
vendor: MeetingVendor?,
config: BrowserConfig?
) -> String? {
if let cliBrowser {
return cliBrowser
}
return config?.browser(for: vendor)
}

/// Resolves an application name to a file URL.
/// Accepts bare names ("Google Chrome") or absolute paths ("/Applications/Firefox.app").
public func resolveApplicationURL(_ name: String) -> Result<URL, BrowserResolverError> {
// If it's an absolute path, check directly
if name.hasPrefix("/") {
let path = name.hasSuffix(".app") ? name : "\(name).app"
if FileManager.default.fileExists(atPath: path) {
return .success(URL(fileURLWithPath: path))
}
return .failure(.applicationNotFound(name))
}

let appName = name.hasSuffix(".app") ? name : "\(name).app"
let searchPaths = [
"/Applications",
"\(NSHomeDirectory())/Applications",
"/System/Applications",
]

for dir in searchPaths {
let path = "\(dir)/\(appName)"
if FileManager.default.fileExists(atPath: path) {
return .success(URL(fileURLWithPath: path))
}
}

return .failure(.applicationNotFound(name))
}
}
11 changes: 7 additions & 4 deletions Sources/ICalGuyKit/Services/EventService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,11 @@ public struct EventService: Sendable {
// MARK: - Conversion

private func convertToCalendarEvent(_ raw: RawEvent) -> CalendarEvent {
CalendarEvent(
let meetingMatch = meetingURLParser.extractMeetingURLMatch(
url: raw.url, location: raw.location, notes: raw.notes
)

return CalendarEvent(
id: raw.id,
title: raw.title,
startDate: raw.startDate,
Expand All @@ -166,9 +170,8 @@ public struct EventService: Sendable {
location: raw.location,
notes: raw.notes,
url: raw.url,
meetingUrl: meetingURLParser.extractMeetingURL(
url: raw.url, location: raw.location, notes: raw.notes
),
meetingUrl: meetingMatch?.url,
meetingVendor: meetingMatch?.vendor,
calendar: CalendarInfo(
id: raw.calendarId,
title: raw.calendarTitle,
Expand Down
51 changes: 31 additions & 20 deletions Sources/ICalGuyKit/Services/MeetingURLParser.swift
Original file line number Diff line number Diff line change
@@ -1,44 +1,55 @@
import Foundation

public struct MeetingURLMatch: Sendable, Equatable {
public let url: String
public let vendor: MeetingVendor

public init(url: String, vendor: MeetingVendor) {
self.url = url
self.vendor = vendor
}
}

public struct MeetingURLParser: Sendable {
private static let patterns: [String] = [
private static let vendorPatterns: [(MeetingVendor, String)] = [
// Google Meet
"https?://meet\\.google\\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}[^\\s]*",
(.meet, "https?://meet\\.google\\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}[^\\s]*"),
// Zoom
"https?://(?:[a-z0-9]+\\.)?zoom\\.us/j/[0-9]+[^\\s]*",
(.zoom, "https?://(?:[a-z0-9]+\\.)?zoom\\.us/j/[0-9]+[^\\s]*"),
// Microsoft Teams
"https?://teams\\.microsoft\\.com/l/meetup-join/[^\\s]+",
(.teams, "https?://teams\\.microsoft\\.com/l/meetup-join/[^\\s]+"),
// WebEx
"https?://[a-z0-9]+\\.webex\\.com/[^\\s]*(?:meet|join)[^\\s]*",
(.webex, "https?://[a-z0-9]+\\.webex\\.com/[^\\s]*(?:meet|join)[^\\s]*"),
]

private static let combinedPattern: String = patterns.map { "(\($0))" }.joined(separator: "|")

public init() {}

public func extractMeetingURL(url: String?, location: String?, notes: String?) -> String? {
public func extractMeetingURLMatch(
url: String?, location: String?, notes: String?
) -> MeetingURLMatch? {
// Check fields in priority order: url > location > notes
if let url, let match = findMeetingURL(in: url) {
if let url, let match = findMeetingURLMatch(in: url) {
return match
}
if let location, let match = findMeetingURL(in: location) {
if let location, let match = findMeetingURLMatch(in: location) {
return match
}
if let notes, let match = findMeetingURL(in: notes) {
if let notes, let match = findMeetingURLMatch(in: notes) {
return match
}
return nil
}

private func findMeetingURL(in text: String) -> String? {
guard
let range = text.range(
of: Self.combinedPattern,
options: .regularExpression
)
else {
return nil
public func extractMeetingURL(url: String?, location: String?, notes: String?) -> String? {
extractMeetingURLMatch(url: url, location: location, notes: notes)?.url
}

private func findMeetingURLMatch(in text: String) -> MeetingURLMatch? {
for (vendor, pattern) in Self.vendorPatterns {
if let range = text.range(of: pattern, options: .regularExpression) {
return MeetingURLMatch(url: String(text[range]), vendor: vendor)
}
}
return String(text[range])
return nil
}
}
23 changes: 22 additions & 1 deletion Sources/ical-guy/Commands/MeetingCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ struct MeetingOpenCommand: AsyncParsableCommand {
@Flag(name: .long, help: "Open the next upcoming meeting instead of the current one.")
var next: Bool = false

@Option(name: .long, help: "Open meeting URL in this browser (e.g. \"Google Chrome\").")
var browser: String?

@Option(name: .long, help: "Only include these calendars (comma-separated titles).")
var includeCalendars: String?

Expand Down Expand Up @@ -137,7 +140,25 @@ struct MeetingOpenCommand: AsyncParsableCommand {
throw CleanExit.message("Invalid meeting URL: \(meetingUrl)")
}

NSWorkspace.shared.open(url)
let config = try? ConfigLoader.load()
let resolver = BrowserResolver()
let browserName = resolver.resolveBrowserName(
cliBrowser: browser,
vendor: context.event.meetingVendor,
config: config?.browsers
)

if let browserName {
switch resolver.resolveApplicationURL(browserName) {
case .success(let appURL):
let configuration = NSWorkspace.OpenConfiguration()
try await NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: configuration)
case .failure(let error):
throw CleanExit.message(error.localizedDescription)
}
} else {
NSWorkspace.shared.open(url)
}
}
}

Expand Down
Loading