diff --git a/README.md b/README.md index 0883c75..2760fc3 100644 --- a/README.md +++ b/README.md @@ -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/ ``` @@ -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: @@ -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", @@ -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") diff --git a/Sources/ICalGuyKit/Config/ConfigLoader.swift b/Sources/ICalGuyKit/Config/ConfigLoader.swift index 497fa9a..1d23727 100644 --- a/Sources/ICalGuyKit/Config/ConfigLoader.swift +++ b/Sources/ICalGuyKit/Config/ConfigLoader.swift @@ -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? @@ -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, @@ -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 @@ -95,6 +136,7 @@ public struct UserConfig: Sendable, Equatable { self.bullet = bullet self.separator = separator self.indent = indent + self.browsers = browsers } } @@ -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, @@ -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 ) } diff --git a/Sources/ICalGuyKit/Models/CalendarEvent.swift b/Sources/ICalGuyKit/Models/CalendarEvent.swift index 074e1ad..41f8457 100644 --- a/Sources/ICalGuyKit/Models/CalendarEvent.swift +++ b/Sources/ICalGuyKit/Models/CalendarEvent.swift @@ -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? @@ -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, @@ -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 diff --git a/Sources/ICalGuyKit/Models/MeetingVendor.swift b/Sources/ICalGuyKit/Models/MeetingVendor.swift new file mode 100644 index 0000000..d996a2a --- /dev/null +++ b/Sources/ICalGuyKit/Models/MeetingVendor.swift @@ -0,0 +1,6 @@ +public enum MeetingVendor: String, Codable, Sendable, CaseIterable { + case meet + case zoom + case teams + case webex +} diff --git a/Sources/ICalGuyKit/Services/BrowserResolver.swift b/Sources/ICalGuyKit/Services/BrowserResolver.swift new file mode 100644 index 0000000..103ec0b --- /dev/null +++ b/Sources/ICalGuyKit/Services/BrowserResolver.swift @@ -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 { + // 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)) + } +} diff --git a/Sources/ICalGuyKit/Services/EventService.swift b/Sources/ICalGuyKit/Services/EventService.swift index 88c9384..38dca74 100644 --- a/Sources/ICalGuyKit/Services/EventService.swift +++ b/Sources/ICalGuyKit/Services/EventService.swift @@ -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, @@ -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, diff --git a/Sources/ICalGuyKit/Services/MeetingURLParser.swift b/Sources/ICalGuyKit/Services/MeetingURLParser.swift index 4d46f32..872102f 100644 --- a/Sources/ICalGuyKit/Services/MeetingURLParser.swift +++ b/Sources/ICalGuyKit/Services/MeetingURLParser.swift @@ -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 } } diff --git a/Sources/ical-guy/Commands/MeetingCommand.swift b/Sources/ical-guy/Commands/MeetingCommand.swift index 7efbdd8..2d644fe 100644 --- a/Sources/ical-guy/Commands/MeetingCommand.swift +++ b/Sources/ical-guy/Commands/MeetingCommand.swift @@ -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? @@ -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) + } } } diff --git a/Sources/ical-guy/ICalGuy.swift b/Sources/ical-guy/ICalGuy.swift index a202944..deda516 100644 --- a/Sources/ical-guy/ICalGuy.swift +++ b/Sources/ical-guy/ICalGuy.swift @@ -5,7 +5,7 @@ struct ICalGuy: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "ical-guy", abstract: "Query macOS calendar events and output as JSON or text.", - version: "0.10.0", + version: "0.10.1", subcommands: [ EventsCommand.self, CalendarsCommand.self, MeetingCommand.self, WeekCommand.self, RemindersCommand.self, BirthdaysCommand.self, ConflictsCommand.self, FreeCommand.self, diff --git a/Tests/ICalGuyKitTests/BrowserConfigTests.swift b/Tests/ICalGuyKitTests/BrowserConfigTests.swift new file mode 100644 index 0000000..ea83fef --- /dev/null +++ b/Tests/ICalGuyKitTests/BrowserConfigTests.swift @@ -0,0 +1,52 @@ +import XCTest + +@testable import ICalGuyKit + +final class BrowserConfigTests: XCTestCase { + func testVendorSpecificBrowser() { + let config = BrowserConfig( + defaultBrowser: "Safari", + meet: "Google Chrome", + zoom: "Firefox" + ) + + XCTAssertEqual(config.browser(for: .meet), "Google Chrome") + XCTAssertEqual(config.browser(for: .zoom), "Firefox") + } + + func testFallsBackToDefault() { + let config = BrowserConfig( + defaultBrowser: "Safari", + meet: "Google Chrome" + ) + + XCTAssertEqual(config.browser(for: .teams), "Safari") + XCTAssertEqual(config.browser(for: .webex), "Safari") + } + + func testNilVendorReturnsDefault() { + let config = BrowserConfig(defaultBrowser: "Safari", meet: "Google Chrome") + XCTAssertEqual(config.browser(for: nil), "Safari") + } + + func testReturnsNilWhenEmpty() { + let config = BrowserConfig() + XCTAssertNil(config.browser(for: .meet)) + XCTAssertNil(config.browser(for: nil)) + } + + func testVendorOverridesDefault() { + let config = BrowserConfig( + defaultBrowser: "Safari", + meet: "Google Chrome", + zoom: "Safari", + teams: "Microsoft Edge", + webex: "Google Chrome" + ) + + XCTAssertEqual(config.browser(for: .meet), "Google Chrome") + XCTAssertEqual(config.browser(for: .zoom), "Safari") + XCTAssertEqual(config.browser(for: .teams), "Microsoft Edge") + XCTAssertEqual(config.browser(for: .webex), "Google Chrome") + } +} diff --git a/Tests/ICalGuyKitTests/BrowserResolverTests.swift b/Tests/ICalGuyKitTests/BrowserResolverTests.swift new file mode 100644 index 0000000..8d1bd5c --- /dev/null +++ b/Tests/ICalGuyKitTests/BrowserResolverTests.swift @@ -0,0 +1,109 @@ +import XCTest + +@testable import ICalGuyKit + +final class BrowserResolverTests: XCTestCase { + private let resolver = BrowserResolver() + + // MARK: - Priority Chain + + func testCLIBrowserTakesPriority() { + let config = BrowserConfig(defaultBrowser: "Safari", meet: "Firefox") + let result = resolver.resolveBrowserName( + cliBrowser: "Google Chrome", + vendor: .meet, + config: config + ) + XCTAssertEqual(result, "Google Chrome") + } + + func testVendorConfigOverridesDefault() { + let config = BrowserConfig(defaultBrowser: "Safari", meet: "Google Chrome") + let result = resolver.resolveBrowserName( + cliBrowser: nil, + vendor: .meet, + config: config + ) + XCTAssertEqual(result, "Google Chrome") + } + + func testDefaultConfigUsedWhenNoVendorMatch() { + let config = BrowserConfig(defaultBrowser: "Safari") + let result = resolver.resolveBrowserName( + cliBrowser: nil, + vendor: .teams, + config: config + ) + XCTAssertEqual(result, "Safari") + } + + func testReturnsNilForSystemDefault() { + let result = resolver.resolveBrowserName( + cliBrowser: nil, + vendor: .meet, + config: nil + ) + XCTAssertNil(result) + } + + func testReturnsNilWhenNoConfigAndNoVendor() { + let result = resolver.resolveBrowserName( + cliBrowser: nil, + vendor: nil, + config: nil + ) + XCTAssertNil(result) + } + + func testDefaultConfigUsedWhenVendorNil() { + let config = BrowserConfig(defaultBrowser: "Firefox") + let result = resolver.resolveBrowserName( + cliBrowser: nil, + vendor: nil, + config: config + ) + XCTAssertEqual(result, "Firefox") + } + + // MARK: - App Resolution + + func testResolveSafariInSystemApplications() { + let result = resolver.resolveApplicationURL("Safari") + switch result { + case .success(let url): + XCTAssertTrue(url.path.contains("Safari.app")) + case .failure: + XCTFail("Expected Safari to be found in /System/Applications") + } + } + + func testResolveAbsolutePath() { + let result = resolver.resolveApplicationURL("/Applications/Safari.app") + switch result { + case .success(let url): + XCTAssertEqual(url.path, "/Applications/Safari.app") + case .failure: + XCTFail("Expected absolute path to Safari to resolve") + } + } + + func testResolveNonexistentAppReturnsError() { + let result = resolver.resolveApplicationURL("Nonexistent Browser XYZ") + switch result { + case .success: + XCTFail("Expected failure for nonexistent app") + case .failure(let error): + XCTAssertTrue(error.localizedDescription.contains("Nonexistent Browser XYZ")) + } + } + + func testResolveAbsolutePathNonexistent() { + let result = resolver.resolveApplicationURL("/Applications/FakeBrowser.app") + switch result { + case .success: + XCTFail("Expected failure for nonexistent absolute path") + case .failure(let error): + XCTAssertTrue(error.localizedDescription.contains("FakeBrowser")) + } + } +} diff --git a/Tests/ICalGuyKitTests/MeetingURLParserTests.swift b/Tests/ICalGuyKitTests/MeetingURLParserTests.swift index 0caf74c..37497b9 100644 --- a/Tests/ICalGuyKitTests/MeetingURLParserTests.swift +++ b/Tests/ICalGuyKitTests/MeetingURLParserTests.swift @@ -101,4 +101,50 @@ final class MeetingURLParserTests: XCTestCase { url: "https://example.com/page", location: nil, notes: nil) XCTAssertNil(result) } + + // MARK: - Vendor Identification + + func testMatchIdentifiesMeetVendor() { + let match = parser.extractMeetingURLMatch( + url: nil, location: nil, notes: "https://meet.google.com/abc-defg-hij") + XCTAssertEqual(match?.vendor, .meet) + XCTAssertEqual(match?.url, "https://meet.google.com/abc-defg-hij") + } + + func testMatchIdentifiesZoomVendor() { + let match = parser.extractMeetingURLMatch( + url: nil, location: nil, notes: "https://us02web.zoom.us/j/1234567890") + XCTAssertEqual(match?.vendor, .zoom) + XCTAssertEqual(match?.url, "https://us02web.zoom.us/j/1234567890") + } + + func testMatchIdentifiesTeamsVendor() { + let url = "https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc123" + let match = parser.extractMeetingURLMatch(url: nil, location: nil, notes: url) + XCTAssertEqual(match?.vendor, .teams) + XCTAssertEqual(match?.url, url) + } + + func testMatchIdentifiesWebexVendor() { + let match = parser.extractMeetingURLMatch( + url: nil, location: nil, notes: "https://company.webex.com/meet/jsmith") + XCTAssertEqual(match?.vendor, .webex) + XCTAssertEqual(match?.url, "https://company.webex.com/meet/jsmith") + } + + func testMatchReturnsNilForNoMatch() { + let match = parser.extractMeetingURLMatch( + url: nil, location: "Conference Room B", notes: "Bring your laptop") + XCTAssertNil(match) + } + + func testMatchPreservesFieldPriority() { + let match = parser.extractMeetingURLMatch( + url: "https://meet.google.com/aaa-bbbb-ccc", + location: "https://us02web.zoom.us/j/111", + notes: nil + ) + XCTAssertEqual(match?.vendor, .meet) + XCTAssertEqual(match?.url, "https://meet.google.com/aaa-bbbb-ccc") + } } diff --git a/man/man1/ical-guy.1 b/man/man1/ical-guy.1 index 4497ec9..127f36e 100644 --- a/man/man1/ical-guy.1 +++ b/man/man1/ical-guy.1 @@ -1,4 +1,4 @@ -.TH ICAL\-GUY 1 "2026-02-17" "ical-guy 0.10.0" "General Commands Manual" +.TH ICAL\-GUY 1 "2026-02-17" "ical-guy 0.10.1" "General Commands Manual" .SH NAME ical\-guy \- query macOS calendar events, reminders, and manage meetings .SH SYNOPSIS @@ -43,7 +43,10 @@ Show current meeting details with time remaining. Show next upcoming meeting with time until start. .TP .B meeting open -Open current meeting URL in the default browser. +Open current meeting URL in a browser. +Supports per\-vendor browser selection via config or the +.B \-\-browser +flag. Use .B \-\-next to open the next upcoming meeting instead. @@ -238,6 +241,18 @@ subcommand also supports: .TP .B \-\-next Open the next upcoming meeting instead of the current one. +.TP +.BI \-\-browser " name" +Open the meeting URL in the specified browser application instead of the +system default. +Accepts an application name (e.g. "Google Chrome") or an absolute path +(e.g. "/Applications/Firefox.app"). +This flag takes precedence over any +.B [browsers] +config settings. +See +.B CONFIGURATION +below. .SH BIRTHDAYS OPTIONS .TP .BI \-\-from " date" @@ -569,6 +584,10 @@ Event URL, or null. .B meetingUrl Detected meeting URL (Zoom, Google Meet, Teams, WebEx), or null. .TP +.B meetingVendor +Detected meeting vendor ("meet", "zoom", "teams", "webex"), or null. +Used for per\-vendor browser selection. +.TP .B calendar Object with .BR id , @@ -846,6 +865,13 @@ min\-duration = 30 work\-start = "09:00" work\-end = "17:00" +[browsers] +default = "Safari" +meet = "Google Chrome" +zoom = "Safari" +teams = "Microsoft Edge" +webex = "Google Chrome" + [templates] time\-format = "HH:mm" date\-format = "yyyy\-MM\-dd" @@ -862,6 +888,41 @@ truncate\-notes = 80 truncate\-location = 40 .fi .RE +.SS Browser Selection +The +.B [browsers] +section configures which browser application to use when opening meeting +URLs with +.BR "meeting open" . +.PP +Priority chain: +.B \-\-browser +CLI flag > vendor\-specific config key > config +.B default +key > system default browser. +.TP +.B default +Default browser for all meeting vendors. +.TP +.B meet +Browser for Google Meet URLs. +.TP +.B zoom +Browser for Zoom URLs. +.TP +.B teams +Browser for Microsoft Teams URLs. +.TP +.B webex +Browser for WebEx URLs. +.PP +Values can be application names (e.g. "Google Chrome", "Safari") or absolute +paths (e.g. "/Applications/Firefox.app"). +Applications are searched in +.IR /Applications , +.IR ~/Applications , +and +.IR /System/Applications . .SH TEMPLATES .B ical\-guy uses Mustache templates for text output. @@ -1233,6 +1294,14 @@ $ ical\-guy meeting open .fi .RE .PP +Open current meeting in Google Chrome: +.PP +.RS +.nf +$ ical\-guy meeting open \-\-browser "Google Chrome" +.fi +.RE +.PP List today's meetings: .PP .RS