flycal-cli 1.0 → 1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0683f1fd5316c424662349a75956309890f9fe5d38fd0cd50a4e158f9a7085e1'
4
- data.tar.gz: b7507dee7df050d2e4f11bfb80002f82c2d4cb2689daff89c1d4be62f244b5e1
3
+ metadata.gz: 55605292daf7d56f71dc210b4c607ebceeb0aa1be1bc464ba73d82ce26583c4a
4
+ data.tar.gz: 256c9dd132265a9d7b36ac7a144c516b06d5c298f9a8e8fb8ac29e1fe2a7bc53
5
5
  SHA512:
6
- metadata.gz: ccabe113d4314bacfd16f3072986d9686919acf4c489a7d8cf9ec05e2bb6dddec59576a859cc210485f9ef56afe7ad9a8522432ba37f0976ae10e5385ffe6e93
7
- data.tar.gz: 6fb81eb48cb1bc8ed7ea84b8764eb8c677f1a6b346c5a0841a9498aecd63a06b73f1c801e8dee0f915d3ebf20901e74195356d83a3323edf3ef2725c85fdb4d3
6
+ metadata.gz: b7a93a60e2cfd438b9b9ef9b52bf1c1a06e07c329bf3e86d3e8ef2253a5bf96c8bbbb390c5258b5250d71e0f87d6669b1088c1b7d10a3bebac0ad9809dc4f638
7
+ data.tar.gz: 5fc7e9f8a1d202e84caf771498a5758023a8aa16a240e6490108eb31d73507889857ca40a2db00309a78f7c15de34f16db1a334037bc44cd684531bac8d73053
data/config/defaults.yml CHANGED
@@ -1,30 +1,5 @@
1
1
  calendar_default: ~
2
2
  locale: en
3
- slots:
4
- templates:
5
- work:
6
- days:
7
- - 1
8
- - 2
9
- - 3
10
- - 4
11
- - 5
12
- hours:
13
- - 9:30-13:00
14
- - 14:00-18:30
15
- dinner:
16
- days:
17
- - 1
18
- - 2
19
- - 3
20
- - 4
21
- - 5
22
- - 6
23
- hours:
24
- - 19-23
25
- exclude_calendars: []
26
- defaults:
27
- from: now
28
- default_duration: 45min
29
- free_before: 0m
30
- free_after: 15m
3
+ timezone: Europe/Rome
4
+ # `slots` is injected from Flycal::DefaultSlots at load/setup time.
5
+ # After first write, the CLI reads templates from this file on disk.
@@ -0,0 +1,14 @@
1
+ {
2
+ "installed": {
3
+ "client_id": "854938129342-tnf0miouj03b4vgi17aol1j8aann25mn.apps.googleusercontent.com",
4
+ "project_id": "flycal-387320",
5
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
6
+ "token_uri": "https://oauth2.googleapis.com/token",
7
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
8
+ "client_secret": "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",
9
+ "redirect_uris": [
10
+ "http://localhost",
11
+ "http://127.0.0.1:9292/oauth2callback"
12
+ ]
13
+ }
14
+ }
@@ -22,29 +22,23 @@ module Cli
22
22
  end
23
23
 
24
24
  desc "login", "Connect to your Google account"
25
+ option :client, type: :string,
26
+ desc: "OAuth client: flycal (Flycal Desktop app) or json (~/.flycal/credentials.json)"
25
27
  def login
26
28
  apply_locale_override
27
29
  if Auth.logged_in?
28
- puts "✓ You are already connected to your Google account."
29
- puts "\nRun 'flycal config' to set the default calendar."
30
+ puts Locale.t("login.already_connected")
31
+ puts "\n#{Locale.t('login.next_config')}"
30
32
  return
31
33
  end
32
34
 
33
- unless Config.credentials_exist?
34
- puts "Error: Credentials file not found."
35
- puts "\nTo configure flycal:"
36
- puts "1. Go to https://console.cloud.google.com/apis/credentials"
37
- puts "2. Create 'Desktop app' credentials"
38
- puts "3. Download the JSON and save it as: #{Config.credentials_path}"
39
- puts "\nAlso add this URI as an authorized redirect:"
40
- puts " http://127.0.0.1:9292/oauth2callback"
41
- return
42
- end
35
+ mode = resolve_login_mode
36
+ return unless mode
43
37
 
44
38
  begin
45
- Auth.login
46
- puts "\n✓ Authentication completed successfully!"
47
- puts "\nRun 'flycal config' to set the default calendar."
39
+ Auth.login(mode)
40
+ puts "\n#{Locale.t('login.success')}"
41
+ puts "\n#{Locale.t('login.next_config')}"
48
42
  rescue Flycal::Error => e
49
43
  puts "Error: #{e.message}"
50
44
  exit 1
@@ -332,16 +326,28 @@ module Cli
332
326
  from_value = options[:from].to_s.strip
333
327
  from_value = defaults["from"].to_s.strip if from_value.empty?
334
328
  from_value = "now" if from_value.empty?
329
+ timezone = Config.timezone
330
+ template_name = nil
331
+ hours = nil
332
+ days = nil
333
+ slot_duration = nil
334
+ free_before = nil
335
+ free_after = nil
336
+ time_min = nil
337
+ time_max = nil
335
338
 
336
339
  begin
337
- template_name, template = resolve_slots_template(slot_cfg, options[:template])
338
- slot_duration = DurationParser.to_seconds(duration_value)
339
- free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
340
- free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
341
- time_min = parse_slots_from(from_value)
342
- time_max = DurationParser.add_to_time(in_value, time_min)
343
- hours = parse_workhours(template["hours"])
344
- days = parse_template_days(template["days"])
340
+ TimeFormat.with_zone(timezone) do
341
+ resolved = SlotTemplates.resolve(name: options[:template], catalog: slot_cfg["templates"])
342
+ template_name = resolved[:name]
343
+ hours = resolved[:hours]
344
+ days = resolved[:days]
345
+ slot_duration = DurationParser.to_seconds(duration_value)
346
+ free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
347
+ free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
348
+ time_min = parse_slots_from(from_value)
349
+ time_max = DurationParser.add_to_time(in_value, time_min)
350
+ end
345
351
  rescue Flycal::Error => e
346
352
  puts "Error: #{e.message}"
347
353
  exit 1
@@ -379,6 +385,7 @@ module Cli
379
385
  free_after: slot_cfg["free_after"] || "0m",
380
386
  template: template_name,
381
387
  locale: Locale.current_locale,
388
+ timezone: timezone,
382
389
  calendar: options[:calendar],
383
390
  hours: hours,
384
391
  days: days,
@@ -403,55 +410,57 @@ module Cli
403
410
  if output.nil?
404
411
  FileCache.delete(cache_key) if nocache
405
412
 
406
- calendars = client.list_calendars
407
- calendar_meta = exclude_calendar_ids.filter_map do |id|
408
- cal = calendars.find { |c| c.id == id }
409
- name = cal&.summary || id
410
- { id: id, name: name }
411
- end
412
-
413
- fetch_from = time_min - free_before
414
- events = exclude_calendar_ids.flat_map do |calendar_id|
415
- client.list_events(
416
- calendar_id,
417
- time_min: fetch_from,
418
- time_max: time_max
419
- )
420
- rescue Google::Apis::Error => e
421
- warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
422
- []
423
- end
413
+ TimeFormat.with_zone(timezone) do
414
+ calendars = client.list_calendars
415
+ calendar_meta = exclude_calendar_ids.filter_map do |id|
416
+ cal = calendars.find { |c| c.id == id }
417
+ name = cal&.summary || id
418
+ { id: id, name: name }
419
+ end
424
420
 
425
- finder = SlotFinder.new(
426
- events: events,
427
- time_min: time_min,
428
- time_max: time_max,
429
- slot_duration_seconds: slot_duration,
430
- hours: hours,
431
- days: days,
432
- free_before_seconds: free_before,
433
- free_after_seconds: free_after
434
- )
421
+ fetch_from = time_min - free_before
422
+ events = exclude_calendar_ids.flat_map do |calendar_id|
423
+ client.list_events(
424
+ calendar_id,
425
+ time_min: fetch_from,
426
+ time_max: time_max
427
+ )
428
+ rescue Google::Apis::Error => e
429
+ warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
430
+ []
431
+ end
435
432
 
436
- slots_by_day = finder.slots_by_day
433
+ finder = SlotFinder.new(
434
+ events: events,
435
+ time_min: time_min,
436
+ time_max: time_max,
437
+ slot_duration_seconds: slot_duration,
438
+ hours: hours,
439
+ days: days,
440
+ free_before_seconds: free_before,
441
+ free_after_seconds: free_after
442
+ )
437
443
 
438
- output =
439
- if format == "json"
440
- SlotFormatter.format_json(
441
- slots_by_day: slots_by_day,
442
- time_min: time_min,
443
- time_max: time_max,
444
- duration: duration_value,
445
- template: template_name,
446
- calendars: calendar_meta,
447
- locale: Locale.current_locale,
448
- calendar_option: options[:calendar],
449
- from_option: options[:from],
450
- in_option: options[:in],
451
- free_before: slot_cfg["free_before"],
452
- free_after: slot_cfg["free_after"],
453
- empty_message: Locale.t("slots.no_available")
454
- )
444
+ slots_by_day = finder.slots_by_day
445
+
446
+ output =
447
+ if format == "json"
448
+ SlotFormatter.format_json(
449
+ slots_by_day: slots_by_day,
450
+ time_min: time_min,
451
+ time_max: time_max,
452
+ duration: duration_value,
453
+ template: template_name,
454
+ calendars: calendar_meta,
455
+ locale: Locale.current_locale,
456
+ timezone: timezone,
457
+ calendar_option: options[:calendar],
458
+ from_option: options[:from],
459
+ in_option: options[:in],
460
+ free_before: slot_cfg["free_before"],
461
+ free_after: slot_cfg["free_after"],
462
+ empty_message: Locale.t("slots.no_available")
463
+ )
455
464
  else
456
465
  SlotFormatter.format_text(
457
466
  slots_by_day: slots_by_day,
@@ -464,8 +473,9 @@ module Cli
464
473
  )
465
474
  end
466
475
 
467
- FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
468
- status = "MISS"
476
+ FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
477
+ status = "MISS"
478
+ end
469
479
  end
470
480
 
471
481
  print annotate_cli_output(output, status, cache_key, format)
@@ -541,6 +551,37 @@ module Cli
541
551
  lines[(blank_idx + 1)..].join("\n").strip
542
552
  end
543
553
 
554
+ def resolve_login_mode
555
+ requested = options[:client].to_s.strip.downcase
556
+ unless requested.empty?
557
+ unless [ Auth::CLIENT_FLYCAL, Auth::CLIENT_JSON ].include?(requested)
558
+ puts Locale.t("login.unknown_client", client: options[:client])
559
+ exit 1
560
+ end
561
+ return requested
562
+ end
563
+
564
+ return Auth::CLIENT_FLYCAL unless $stdin.tty?
565
+
566
+ begin
567
+ prompt = TTY::Prompt.new
568
+ choices = {
569
+ Locale.t("login.options.flycal") => Auth::CLIENT_FLYCAL,
570
+ Locale.t("login.options.json") => Auth::CLIENT_JSON
571
+ }
572
+ default_label =
573
+ if Config.auth_client == Auth::CLIENT_JSON
574
+ Locale.t("login.options.json")
575
+ else
576
+ Locale.t("login.options.flycal")
577
+ end
578
+ prompt.select(Locale.t("login.prompt"), choices, default: default_label, per_page: 5)
579
+ rescue Interrupt
580
+ puts "\n#{Locale.t('login.cancelled')}"
581
+ exit 0
582
+ end
583
+ end
584
+
544
585
  def with_config_interrupt_handling
545
586
  yield
546
587
  rescue Interrupt
@@ -681,37 +722,9 @@ module Cli
681
722
  end
682
723
 
683
724
  def parse_slots_from(value)
684
- return Time.now if value.to_s.strip.downcase == "now"
685
-
686
725
  DateTimeParser.parse(value)
687
726
  end
688
727
 
689
- def resolve_slots_template(slot_cfg, requested_name)
690
- templates = slot_cfg["templates"]
691
- raise Flycal::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
692
-
693
- name = requested_name.to_s.strip
694
- name = templates.keys.first.to_s if name.empty?
695
-
696
- template = templates[name]
697
- unless template.is_a?(Hash)
698
- available = templates.keys.join(", ")
699
- raise Flycal::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
700
- end
701
-
702
- [name, template]
703
- end
704
-
705
- def parse_template_days(values)
706
- days = Array(values).map(&:to_i)
707
- raise Flycal::Error, "slots template days cannot be empty." if days.empty?
708
-
709
- invalid = days.reject { |d| d.between?(1, 7) }
710
- raise Flycal::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
711
-
712
- days.uniq
713
- end
714
-
715
728
  def copy_slots_to_clipboard(text)
716
729
  tool = Clipboard.copy(SlotFormatter.strip_ansi(text))
717
730
  return unless tool
@@ -720,50 +733,6 @@ module Cli
720
733
  puts Locale.t("slots.copied_to_clipboard", tool: tool)
721
734
  end
722
735
 
723
- def parse_hour_minute(value)
724
- match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
725
- raise Flycal::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
726
-
727
- hour = match[1].to_i
728
- minute = match[2].to_i
729
- unless hour.between?(0, 23) && minute.between?(0, 59)
730
- raise Flycal::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
731
- end
732
-
733
- [hour, minute]
734
- end
735
-
736
- def parse_workhours(values)
737
- ranges = Array(values).map do |item|
738
- start_str, end_str = item.to_s.strip.split("-", 2)
739
- raise Flycal::Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
740
-
741
- sh, sm = parse_hour_minute_flexible(start_str)
742
- eh, em = parse_hour_minute_flexible(end_str)
743
- start_minutes = (sh * 60) + sm
744
- end_minutes = (eh * 60) + em
745
- raise Flycal::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
746
-
747
- [sh, sm, eh, em]
748
- end
749
-
750
- raise Flycal::Error, "template hours cannot be empty in config.yml." if ranges.empty?
751
-
752
- ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
753
- end
754
-
755
- def parse_hour_minute_flexible(value)
756
- str = value.to_s.strip
757
- if str.match?(/\A\d{1,2}\z/)
758
- hour = str.to_i
759
- raise Flycal::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
760
-
761
- return [hour, 0]
762
- end
763
-
764
- parse_hour_minute(str)
765
- end
766
-
767
736
  def resolve_calendar_ids(service, calendar_option)
768
737
  calendar_lists = service.list_calendars
769
738
  refs = Flycal::CalendarQuery.refs(calendar_option)
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "webrick"
4
3
  require "googleauth"
5
4
  require "googleauth/stores/file_token_store"
6
5
  require "fileutils"
6
+ require "rbconfig"
7
7
 
8
8
  module Flycal
9
9
  module Cli
@@ -11,23 +11,19 @@ module Cli
11
11
  SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
12
12
  REDIRECT_PORT = 9292
13
13
  REDIRECT_URI = "http://127.0.0.1:#{REDIRECT_PORT}/oauth2callback"
14
+ CLIENT_FLYCAL = "flycal"
15
+ CLIENT_JSON = "json"
16
+ USER_ID = "flycal_user"
17
+ OAUTH_CLIENT_FILE = File.expand_path("../../../config/oauth_client.json", __dir__)
14
18
 
15
19
  class << self
16
20
  def credentials
17
- return nil unless Config.credentials_exist?
18
-
19
- token_store = Google::Auth::Stores::FileTokenStore.new(file: Config.tokens_path)
20
- client_id = Google::Auth::ClientId.from_file(Config.credentials_path)
21
- authorizer = Google::Auth::UserAuthorizer.new(
22
- client_id,
23
- SCOPE,
24
- token_store,
25
- "/oauth2callback"
26
- )
27
-
28
- creds = authorizer.get_credentials("flycal_user")
29
- return creds if creds
21
+ mode = resolved_mode
22
+ return nil unless mode
23
+ return nil unless File.exist?(Config.tokens_path)
30
24
 
25
+ authorizer_for(mode).get_credentials(USER_ID)
26
+ rescue Signet::AuthorizationError, Flycal::Error
31
27
  nil
32
28
  end
33
29
 
@@ -41,52 +37,109 @@ module Cli
41
37
  false
42
38
  end
43
39
 
44
- def login
45
- unless Config.credentials_exist?
46
- raise Flycal::Error,
47
- "Credentials file not found. Create #{Config.credentials_path} with OAuth credentials from Google Cloud Console.\n" \
48
- "Go to: https://console.cloud.google.com/apis/credentials\n" \
49
- "Create 'Desktop app' credentials and download the JSON as credentials.json"
40
+ def login(mode)
41
+ mode = normalize_mode(mode)
42
+ if Config.auth_client && Config.auth_client != mode && File.exist?(Config.tokens_path)
43
+ logout
50
44
  end
51
45
 
52
- token_store = Google::Auth::Stores::FileTokenStore.new(file: Config.tokens_path)
53
- client_id = Google::Auth::ClientId.from_file(Config.credentials_path)
54
- authorizer = Google::Auth::UserAuthorizer.new(
55
- client_id,
56
- SCOPE,
57
- token_store,
58
- "/oauth2callback"
59
- )
46
+ Config.auth_client = mode
47
+ authorizer = authorizer_for(mode)
60
48
 
61
- creds = authorizer.get_credentials("flycal_user")
49
+ creds = authorizer.get_credentials(USER_ID)
62
50
  if creds
63
- creds.fetch_access_token!
64
- return creds
51
+ begin
52
+ creds.fetch_access_token!
53
+ return creds
54
+ rescue Signet::AuthorizationError
55
+ token_store = Google::Auth::Stores::FileTokenStore.new(file: Config.tokens_path)
56
+ token_store.delete(USER_ID)
57
+ File.delete(Config.tokens_path) if File.exist?(Config.tokens_path)
58
+ authorizer = authorizer_for(mode)
59
+ end
65
60
  end
66
61
 
67
- # Start local server to receive the auth code
68
62
  code = start_redirect_server(authorizer)
69
- raise Flycal::Error, "Authentication cancelled or failed" if code.nil? || code.empty?
63
+ raise Flycal::Error, Locale.t("login.cancelled") if code.nil? || code.empty?
70
64
 
71
65
  authorizer.get_and_store_credentials_from_code(
72
- user_id: "flycal_user",
66
+ user_id: USER_ID,
73
67
  code: code,
74
68
  base_url: "http://127.0.0.1:#{REDIRECT_PORT}"
75
69
  )
76
70
  end
77
71
 
78
72
  def logout
79
- return true unless File.exist?(Config.tokens_path)
80
-
81
- token_store = Google::Auth::Stores::FileTokenStore.new(file: Config.tokens_path)
82
- token_store.delete("flycal_user")
83
- File.delete(Config.tokens_path) if File.exist?(Config.tokens_path)
73
+ if File.exist?(Config.tokens_path)
74
+ token_store = Google::Auth::Stores::FileTokenStore.new(file: Config.tokens_path)
75
+ token_store.delete(USER_ID)
76
+ File.delete(Config.tokens_path) if File.exist?(Config.tokens_path)
77
+ end
78
+ Config.auth_client = nil
84
79
  true
85
80
  end
86
81
 
82
+ def client_id_for(mode)
83
+ mode = normalize_mode(mode)
84
+ path = client_id_path(mode)
85
+ unless File.exist?(path)
86
+ raise Flycal::Error, missing_client_message(mode, path)
87
+ end
88
+
89
+ Google::Auth::ClientId.from_file(path)
90
+ end
91
+
92
+ def bundled_client_path
93
+ OAUTH_CLIENT_FILE
94
+ end
95
+
87
96
  private
88
97
 
98
+ def resolved_mode
99
+ stored = Config.auth_client
100
+ return stored if stored
101
+ return CLIENT_JSON if Config.credentials_exist?
102
+ return CLIENT_FLYCAL if File.exist?(Config.tokens_path)
103
+
104
+ nil
105
+ end
106
+
107
+ def normalize_mode(mode)
108
+ value = mode.to_s.strip.downcase
109
+ return value if [ CLIENT_FLYCAL, CLIENT_JSON ].include?(value)
110
+
111
+ raise Flycal::Error, Locale.t("login.unknown_client", client: mode)
112
+ end
113
+
114
+ def client_id_path(mode)
115
+ mode == CLIENT_JSON ? Config.credentials_path : bundled_client_path
116
+ end
117
+
118
+ def authorizer_for(mode)
119
+ token_store = Google::Auth::Stores::FileTokenStore.new(file: Config.tokens_path)
120
+ Google::Auth::UserAuthorizer.new(
121
+ client_id_for(mode),
122
+ SCOPE,
123
+ token_store,
124
+ "/oauth2callback"
125
+ )
126
+ end
127
+
128
+ def missing_client_message(mode, path)
129
+ if mode == CLIENT_JSON
130
+ Locale.t(
131
+ "login.json_missing",
132
+ path: path,
133
+ redirect: REDIRECT_URI
134
+ )
135
+ else
136
+ Locale.t("login.flycal_missing", path: path)
137
+ end
138
+ end
139
+
89
140
  def start_redirect_server(authorizer)
141
+ require "webrick"
142
+
90
143
  auth_code = nil
91
144
  state = SecureRandom.hex(16)
92
145
  code_verifier = Google::Auth::UserAuthorizer.generate_code_verifier
@@ -110,7 +163,7 @@ module Cli
110
163
  elsif params["code"]
111
164
  auth_code = params["code"]
112
165
  res.status = 200
113
- res.body = "<h1>Authentication complete!</h1><p>You can close this window and return to the terminal.</p>"
166
+ res.body = "<h1>#{Locale.t('login.browser_done_title')}</h1><p>#{Locale.t('login.browser_done_body')}</p>"
114
167
  end
115
168
 
116
169
  Thread.new { server.shutdown }
@@ -121,13 +174,31 @@ module Cli
121
174
  state: state
122
175
  )
123
176
 
124
- puts "\nOpen this link in your browser to authenticate:\n\n"
177
+ puts "\n#{Locale.t('login.open_link')}\n\n"
125
178
  puts " #{url}\n\n"
126
- puts "After authentication, you will be redirected back here automatically.\n\n"
179
+ puts "#{Locale.t('login.waiting')}\n\n"
180
+ open_browser(url)
127
181
 
128
182
  server.start
129
183
  auth_code
130
184
  end
185
+
186
+ def open_browser(url)
187
+ host_os = RbConfig::CONFIG["host_os"].to_s
188
+ cmd =
189
+ if host_os.match?(/darwin/)
190
+ [ "open", url ]
191
+ elsif host_os.match?(/linux/)
192
+ [ "xdg-open", url ]
193
+ elsif host_os.match?(/mswin|mingw|cygwin/)
194
+ [ "cmd", "/c", "start", "", url ]
195
+ end
196
+ return unless cmd
197
+
198
+ system(*cmd, out: File::NULL, err: File::NULL)
199
+ rescue StandardError
200
+ nil
201
+ end
131
202
  end
132
203
  end
133
204
  end
@@ -53,6 +53,23 @@ module Cli
53
53
  File.exist?(CREDENTIALS_FILE)
54
54
  end
55
55
 
56
+ def auth_client
57
+ value = load["auth_client"].to_s.strip.downcase
58
+ return value if %w[flycal json].include?(value)
59
+
60
+ nil
61
+ end
62
+
63
+ def auth_client=(value)
64
+ data = load
65
+ if value.to_s.strip.empty?
66
+ data.delete("auth_client")
67
+ else
68
+ data["auth_client"] = value.to_s.strip.downcase
69
+ end
70
+ save(data)
71
+ end
72
+
56
73
  def tokens_path
57
74
  TOKENS_FILE
58
75
  end
@@ -149,6 +166,11 @@ module Cli
149
166
  load["locale"].to_s
150
167
  end
151
168
 
169
+ def timezone
170
+ value = load["timezone"].to_s.strip
171
+ value.empty? ? Flycal::TimeFormat::DEFAULT_ZONE : value
172
+ end
173
+
152
174
  def locale=(value)
153
175
  data = load
154
176
  data["locale"] = value.to_s
@@ -159,7 +181,8 @@ module Cli
159
181
  @default_values ||= begin
160
182
  raise "Defaults file not found: #{DEFAULTS_FILE}" unless File.exist?(DEFAULTS_FILE)
161
183
 
162
- YAML.load_file(DEFAULTS_FILE) || {}
184
+ file = YAML.load_file(DEFAULTS_FILE) || {}
185
+ file.merge("slots" => Flycal::DefaultSlots.config_fragment)
163
186
  end
164
187
  end
165
188