flycal-cli 1.1 → 1.2.1

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: fcbd8c2cfb6f90272cf8cc8cfa489bc99a7487efca11f68560b2d7f96ac92f34
4
- data.tar.gz: 21e92239db0dc5b3f587abdd1e859b5e67e67293706f6ec1f50ca34ca4fe0e1d
3
+ metadata.gz: 01bbfc45999ccc7c458c48f5131a8476271e50e8b166e819d53371768a43fa51
4
+ data.tar.gz: 455dd466af400747d3d7bcb97fec8f4a9f87abd5db12d52395bc04a0c4edfcc5
5
5
  SHA512:
6
- metadata.gz: 2524fb58d1faecdf6ac6643761315f2586f6fe10b4535f2f50b87418f871665f13c84110c3d627ad2131bb9b2ef95aa171c0e6e1e41b52f87d7b76fd7261493c
7
- data.tar.gz: 906c85b9dd3711aa16874a6f41be9851be1accbd07d131b09fb9f6173ed26d0e081f6607b457d606f538ea8861ab98dcff89b6cdab4f482b79ffe0c8af41eb7b
6
+ metadata.gz: 38af1efd8c2d88d6b5f9e0b2e98461bcb44dfa8a9f3b45e4fd665bc05707331d02055aa32e473c0debd0d6c8e7a9b9290a6a920d7d2bddf18b38fbfc156497ef
7
+ data.tar.gz: b07bcdda58dfcc654219c5106c96af6f93f943b55e5aa3f20777d6dd57eacb9e166bb524be5c3e2720c7d4c7f8b95b9ce344272796ac47ef7124a78b4c2ea608
data/config/defaults.yml CHANGED
@@ -1,4 +1,5 @@
1
1
  calendar_default: ~
2
2
  locale: en
3
+ timezone: Europe/Rome
3
4
  # `slots` is injected from Flycal::DefaultSlots at load/setup time.
4
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
+ }
@@ -3,6 +3,7 @@
3
3
  require "thor"
4
4
  require "time"
5
5
  require "date"
6
+ require "active_support"
6
7
  require "active_support/core_ext/date"
7
8
  require "active_support/core_ext/integer"
8
9
  require "active_support/core_ext/time"
@@ -22,29 +23,23 @@ module Cli
22
23
  end
23
24
 
24
25
  desc "login", "Connect to your Google account"
26
+ option :client, type: :string,
27
+ desc: "OAuth client: flycal (Flycal Desktop app) or json (~/.flycal/credentials.json)"
25
28
  def login
26
29
  apply_locale_override
27
30
  if Auth.logged_in?
28
- puts "✓ You are already connected to your Google account."
29
- puts "\nRun 'flycal config' to set the default calendar."
31
+ puts Locale.t("login.already_connected")
32
+ puts "\n#{Locale.t('login.next_config')}"
30
33
  return
31
34
  end
32
35
 
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
36
+ mode = resolve_login_mode
37
+ return unless mode
43
38
 
44
39
  begin
45
- Auth.login
46
- puts "\n✓ Authentication completed successfully!"
47
- puts "\nRun 'flycal config' to set the default calendar."
40
+ Auth.login(mode)
41
+ puts "\n#{Locale.t('login.success')}"
42
+ puts "\n#{Locale.t('login.next_config')}"
48
43
  rescue Flycal::Error => e
49
44
  puts "Error: #{e.message}"
50
45
  exit 1
@@ -332,17 +327,28 @@ module Cli
332
327
  from_value = options[:from].to_s.strip
333
328
  from_value = defaults["from"].to_s.strip if from_value.empty?
334
329
  from_value = "now" if from_value.empty?
330
+ timezone = Config.timezone
331
+ template_name = nil
332
+ hours = nil
333
+ days = nil
334
+ slot_duration = nil
335
+ free_before = nil
336
+ free_after = nil
337
+ time_min = nil
338
+ time_max = nil
335
339
 
336
340
  begin
337
- resolved = SlotTemplates.resolve(name: options[:template], catalog: slot_cfg["templates"])
338
- template_name = resolved[:name]
339
- hours = resolved[:hours]
340
- days = resolved[:days]
341
- slot_duration = DurationParser.to_seconds(duration_value)
342
- free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
343
- free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
344
- time_min = parse_slots_from(from_value)
345
- time_max = DurationParser.add_to_time(in_value, time_min)
341
+ TimeFormat.with_zone(timezone) do
342
+ resolved = SlotTemplates.resolve(name: options[:template], catalog: slot_cfg["templates"])
343
+ template_name = resolved[:name]
344
+ hours = resolved[:hours]
345
+ days = resolved[:days]
346
+ slot_duration = DurationParser.to_seconds(duration_value)
347
+ free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
348
+ free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
349
+ time_min = parse_slots_from(from_value)
350
+ time_max = DurationParser.add_to_time(in_value, time_min)
351
+ end
346
352
  rescue Flycal::Error => e
347
353
  puts "Error: #{e.message}"
348
354
  exit 1
@@ -380,6 +386,7 @@ module Cli
380
386
  free_after: slot_cfg["free_after"] || "0m",
381
387
  template: template_name,
382
388
  locale: Locale.current_locale,
389
+ timezone: timezone,
383
390
  calendar: options[:calendar],
384
391
  hours: hours,
385
392
  days: days,
@@ -404,55 +411,57 @@ module Cli
404
411
  if output.nil?
405
412
  FileCache.delete(cache_key) if nocache
406
413
 
407
- calendars = client.list_calendars
408
- calendar_meta = exclude_calendar_ids.filter_map do |id|
409
- cal = calendars.find { |c| c.id == id }
410
- name = cal&.summary || id
411
- { id: id, name: name }
412
- end
413
-
414
- fetch_from = time_min - free_before
415
- events = exclude_calendar_ids.flat_map do |calendar_id|
416
- client.list_events(
417
- calendar_id,
418
- time_min: fetch_from,
419
- time_max: time_max
420
- )
421
- rescue Google::Apis::Error => e
422
- warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
423
- []
424
- end
414
+ TimeFormat.with_zone(timezone) do
415
+ calendars = client.list_calendars
416
+ calendar_meta = exclude_calendar_ids.filter_map do |id|
417
+ cal = calendars.find { |c| c.id == id }
418
+ name = cal&.summary || id
419
+ { id: id, name: name }
420
+ end
425
421
 
426
- finder = SlotFinder.new(
427
- events: events,
428
- time_min: time_min,
429
- time_max: time_max,
430
- slot_duration_seconds: slot_duration,
431
- hours: hours,
432
- days: days,
433
- free_before_seconds: free_before,
434
- free_after_seconds: free_after
435
- )
422
+ fetch_from = time_min - free_before
423
+ events = exclude_calendar_ids.flat_map do |calendar_id|
424
+ client.list_events(
425
+ calendar_id,
426
+ time_min: fetch_from,
427
+ time_max: time_max
428
+ )
429
+ rescue Google::Apis::Error => e
430
+ warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
431
+ []
432
+ end
436
433
 
437
- slots_by_day = finder.slots_by_day
434
+ finder = SlotFinder.new(
435
+ events: events,
436
+ time_min: time_min,
437
+ time_max: time_max,
438
+ slot_duration_seconds: slot_duration,
439
+ hours: hours,
440
+ days: days,
441
+ free_before_seconds: free_before,
442
+ free_after_seconds: free_after
443
+ )
438
444
 
439
- output =
440
- if format == "json"
441
- SlotFormatter.format_json(
442
- slots_by_day: slots_by_day,
443
- time_min: time_min,
444
- time_max: time_max,
445
- duration: duration_value,
446
- template: template_name,
447
- calendars: calendar_meta,
448
- locale: Locale.current_locale,
449
- calendar_option: options[:calendar],
450
- from_option: options[:from],
451
- in_option: options[:in],
452
- free_before: slot_cfg["free_before"],
453
- free_after: slot_cfg["free_after"],
454
- empty_message: Locale.t("slots.no_available")
455
- )
445
+ slots_by_day = finder.slots_by_day
446
+
447
+ output =
448
+ if format == "json"
449
+ SlotFormatter.format_json(
450
+ slots_by_day: slots_by_day,
451
+ time_min: time_min,
452
+ time_max: time_max,
453
+ duration: duration_value,
454
+ template: template_name,
455
+ calendars: calendar_meta,
456
+ locale: Locale.current_locale,
457
+ timezone: timezone,
458
+ calendar_option: options[:calendar],
459
+ from_option: options[:from],
460
+ in_option: options[:in],
461
+ free_before: slot_cfg["free_before"],
462
+ free_after: slot_cfg["free_after"],
463
+ empty_message: Locale.t("slots.no_available")
464
+ )
456
465
  else
457
466
  SlotFormatter.format_text(
458
467
  slots_by_day: slots_by_day,
@@ -465,8 +474,9 @@ module Cli
465
474
  )
466
475
  end
467
476
 
468
- FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
469
- status = "MISS"
477
+ FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
478
+ status = "MISS"
479
+ end
470
480
  end
471
481
 
472
482
  print annotate_cli_output(output, status, cache_key, format)
@@ -542,6 +552,37 @@ module Cli
542
552
  lines[(blank_idx + 1)..].join("\n").strip
543
553
  end
544
554
 
555
+ def resolve_login_mode
556
+ requested = options[:client].to_s.strip.downcase
557
+ unless requested.empty?
558
+ unless [ Auth::CLIENT_FLYCAL, Auth::CLIENT_JSON ].include?(requested)
559
+ puts Locale.t("login.unknown_client", client: options[:client])
560
+ exit 1
561
+ end
562
+ return requested
563
+ end
564
+
565
+ return Auth::CLIENT_FLYCAL unless $stdin.tty?
566
+
567
+ begin
568
+ prompt = TTY::Prompt.new
569
+ choices = {
570
+ Locale.t("login.options.flycal") => Auth::CLIENT_FLYCAL,
571
+ Locale.t("login.options.json") => Auth::CLIENT_JSON
572
+ }
573
+ default_label =
574
+ if Config.auth_client == Auth::CLIENT_JSON
575
+ Locale.t("login.options.json")
576
+ else
577
+ Locale.t("login.options.flycal")
578
+ end
579
+ prompt.select(Locale.t("login.prompt"), choices, default: default_label, per_page: 5)
580
+ rescue Interrupt
581
+ puts "\n#{Locale.t('login.cancelled')}"
582
+ exit 0
583
+ end
584
+ end
585
+
545
586
  def with_config_interrupt_handling
546
587
  yield
547
588
  rescue Interrupt
@@ -682,8 +723,6 @@ module Cli
682
723
  end
683
724
 
684
725
  def parse_slots_from(value)
685
- return Time.now if value.to_s.strip.downcase == "now"
686
-
687
726
  DateTimeParser.parse(value)
688
727
  end
689
728
 
@@ -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
data/lib/flycal/client.rb CHANGED
@@ -73,55 +73,75 @@ module Flycal
73
73
  # calendar_ids (required), duration, from, to, in,
74
74
  # free_before, free_after, hours, days,
75
75
  # template (work|dinner), template_custom ({ days:, hours: } YAML shape),
76
- # locale, calendar (label for meta)
76
+ # locale, timezone (IANA, default Europe/Rome or the Google calendar TZ),
77
+ # calendar (label for meta)
77
78
  # Pass +events:+ to skip Google (tests / mock).
78
79
  def slots(params = {}, events: nil)
79
80
  previous_locale = Thread.current[:flycal_locale_override]
80
81
  p = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
81
- q = resolve_slots_query(p)
82
- Locale.override!(q[:locale])
83
-
84
- calendar_meta, loaded_events = load_slot_events(q, events)
85
- finder = SlotFinder.new(
86
- events: loaded_events,
87
- time_min: q[:time_min],
88
- time_max: q[:time_max],
89
- slot_duration_seconds: q[:slot_duration_seconds],
90
- hours: q[:hours],
91
- days: q[:days],
92
- free_before_seconds: q[:free_before_seconds],
93
- free_after_seconds: q[:free_after_seconds]
94
- )
95
82
 
96
- SlotFormatter.payload_hash(
97
- slots_by_day: finder.slots_by_day,
98
- time_min: q[:time_min],
99
- time_max: q[:time_max],
100
- duration: q[:duration],
101
- template: q[:template],
102
- calendars: calendar_meta,
103
- locale: q[:locale],
104
- calendar_option: q[:calendar],
105
- from_option: p[:from],
106
- to_option: p[:to],
107
- in_option: p[:in],
108
- free_before: q[:free_before],
109
- free_after: q[:free_after],
110
- empty_message: Locale.t("slots.no_available")
83
+ calendar_list = nil
84
+ unless events
85
+ calendar_list = begin
86
+ list_calendars
87
+ rescue StandardError
88
+ nil
89
+ end
90
+ end
91
+
92
+ timezone = TimeFormat.resolve(
93
+ explicit: p[:timezone],
94
+ calendars: calendar_list,
95
+ calendar_ids: p[:calendar_ids]
111
96
  )
97
+
98
+ TimeFormat.with_zone(timezone) do
99
+ q = resolve_slots_query(p.merge(timezone: timezone))
100
+ Locale.override!(q[:locale])
101
+
102
+ calendar_meta, loaded_events = load_slot_events(q, events, calendars: calendar_list)
103
+ finder = SlotFinder.new(
104
+ events: loaded_events,
105
+ time_min: q[:time_min],
106
+ time_max: q[:time_max],
107
+ slot_duration_seconds: q[:slot_duration_seconds],
108
+ hours: q[:hours],
109
+ days: q[:days],
110
+ free_before_seconds: q[:free_before_seconds],
111
+ free_after_seconds: q[:free_after_seconds]
112
+ )
113
+
114
+ SlotFormatter.payload_hash(
115
+ slots_by_day: finder.slots_by_day,
116
+ time_min: q[:time_min],
117
+ time_max: q[:time_max],
118
+ duration: q[:duration],
119
+ template: q[:template],
120
+ calendars: calendar_meta,
121
+ locale: q[:locale],
122
+ timezone: timezone,
123
+ calendar_option: q[:calendar],
124
+ from_option: p[:from],
125
+ to_option: p[:to],
126
+ in_option: p[:in],
127
+ free_before: q[:free_before],
128
+ free_after: q[:free_after],
129
+ empty_message: Locale.t("slots.no_available")
130
+ )
131
+ end
112
132
  ensure
113
133
  Thread.current[:flycal_locale_override] = previous_locale
114
134
  end
115
135
 
116
136
  private
117
137
 
118
- def load_slot_events(query, events)
138
+ def load_slot_events(query, events, calendars: nil)
119
139
  if events
120
140
  meta = query[:calendar_ids].map { |id| { id: id, name: id } }
121
141
  return [ meta, Array(events) ]
122
142
  end
123
143
 
124
- calendars = list_calendars
144
+ calendars ||= list_calendars
125
145
  meta = query[:calendar_ids].filter_map do |id|
126
146
  cal = calendars.find { |c| c.id == id }
127
147
  { id: id, name: cal&.summary || id }
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "date"
4
4
  require "time"
5
+ require "active_support"
5
6
  require "active_support/core_ext/time"
6
7
  require "active_support/core_ext/date"
7
8
 
@@ -69,7 +70,7 @@ module Flycal
69
70
  end
70
71
  end
71
72
 
72
- def parse_or_default(str, default: Time.now, end_of_day: false, locale: nil)
73
+ def parse_or_default(str, default: TimeFormat.now, end_of_day: false, locale: nil)
73
74
  return default if str.nil? || str.to_s.strip.empty?
74
75
 
75
76
  parse(str, end_of_day: end_of_day, locale: locale)
@@ -83,13 +84,13 @@ module Flycal
83
84
 
84
85
  case normalized
85
86
  when "now", "adesso", "ora"
86
- return Time.now
87
+ return TimeFormat.now
87
88
  when "today", "oggi"
88
- return time_for_date(Date.today, end_of_day: end_of_day)
89
+ return time_for_date(TimeFormat.today, end_of_day: end_of_day)
89
90
  when "tomorrow", "domani"
90
- return time_for_date(Date.today + 1, end_of_day: end_of_day)
91
+ return time_for_date(TimeFormat.today + 1, end_of_day: end_of_day)
91
92
  when "yesterday", "ieri"
92
- return time_for_date(Date.today - 1, end_of_day: end_of_day)
93
+ return time_for_date(TimeFormat.today - 1, end_of_day: end_of_day)
93
94
  end
94
95
 
95
96
  weekday = parse_weekday_phrase(normalized)
@@ -117,7 +118,7 @@ module Flycal
117
118
  wday = WEEKDAYS[day_token]
118
119
  return nil unless wday
119
120
 
120
- today = Date.today
121
+ today = TimeFormat.today
121
122
 
122
123
  if qualifier.nil? || THIS_WORDS.include?(qualifier)
123
124
  next_weekday(today, wday, inclusive: true)
@@ -142,9 +143,10 @@ module Flycal
142
143
 
143
144
  def time_for_date(date, end_of_day:)
144
145
  return end_of_day_for(date) if end_of_day
145
- return [date.to_time, Time.now].max if date == Date.today
146
+ midnight = TimeFormat.local(date.year, date.month, date.day)
147
+ return [ midnight, TimeFormat.now ].max if date == TimeFormat.today
146
148
 
147
- date.to_time
149
+ midnight
148
150
  end
149
151
 
150
152
  def formats_for(locale)
@@ -192,13 +194,13 @@ module Flycal
192
194
  return time_for_date(date, end_of_day: end_of_day)
193
195
  end
194
196
 
195
- Time.local(year, month, day, hour, min || 0, sec || 0)
197
+ TimeFormat.local(year, month, day, hour, min || 0, sec || 0)
196
198
  rescue ArgumentError
197
199
  raise Flycal::Error, "Invalid date: #{year}-#{month}-#{day}"
198
200
  end
199
201
 
200
202
  def end_of_day_for(date)
201
- Time.local(date.year, date.month, date.day, 23, 59, 59)
203
+ TimeFormat.local(date.year, date.month, date.day, 23, 59, 59)
202
204
  end
203
205
 
204
206
  def time_component?(value)
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support"
3
4
  require "active_support/core_ext/numeric/time"
4
5
  require "active_support/core_ext/integer/time"
5
6
 
data/lib/flycal/locale.rb CHANGED
@@ -34,8 +34,8 @@ module Flycal
34
34
  end
35
35
 
36
36
  def format_long_date(date_or_time)
37
- t = date_or_time.respond_to?(:to_time) ? date_or_time.to_time : date_or_time
38
- d = t.to_date
37
+ # Calendar date in the value's own zone — not TimeWithZone#to_time (system TZ / AS 8.1).
38
+ d = Date.new(date_or_time.year, date_or_time.month, date_or_time.day)
39
39
  "#{day_abbr(d).downcase} #{d.day} #{month_name(d)} #{d.year}"
40
40
  end
41
41
 
@@ -58,8 +58,8 @@ module Flycal
58
58
  def random_start_on(day, duration_seconds)
59
59
  from_h, from_m = @config.hours_from
60
60
  to_h, to_m = @config.hours_to
61
- window_start = day.to_time + (from_h * 3600) + (from_m * 60)
62
- window_end = day.to_time + (to_h * 3600) + (to_m * 60)
61
+ window_start = TimeFormat.local(day.year, day.month, day.day, from_h, from_m)
62
+ window_end = TimeFormat.local(day.year, day.month, day.day, to_h, to_m)
63
63
  latest_start = window_end - duration_seconds
64
64
  if latest_start < window_start
65
65
  raise Flycal::Error, "Mock event duration does not fit in the daily hours window."
@@ -28,22 +28,25 @@ module Flycal
28
28
  mock_opts[:mockSeed] = @seed
29
29
 
30
30
  @config = Config.from_options(mock_opts)
31
- @events = EventGenerator.new(@config).generate
32
- @query = SlotQuery.resolve(
33
- { calendar_ids: [ @config.calendar_name ] }.merge(symbolize(slots))
34
- )
35
- finder = SlotFinder.new(
36
- events: @events,
37
- time_min: @query[:time_min],
38
- time_max: @query[:time_max],
39
- slot_duration_seconds: @query[:slot_duration_seconds],
40
- hours: @query[:hours],
41
- days: @query[:days],
42
- free_before_seconds: @query[:free_before_seconds],
43
- free_after_seconds: @query[:free_after_seconds]
44
- )
45
- @slots_by_day = finder.slots_by_day
46
- @expected_count = @slots_by_day.values.sum(&:size)
31
+ timezone = TimeFormat.resolve(explicit: slots[:timezone] || slots["timezone"])
32
+ TimeFormat.with_zone(timezone) do
33
+ @events = EventGenerator.new(@config).generate
34
+ @query = SlotQuery.resolve(
35
+ { calendar_ids: [ @config.calendar_name ], timezone: timezone }.merge(symbolize(slots))
36
+ )
37
+ finder = SlotFinder.new(
38
+ events: @events,
39
+ time_min: @query[:time_min],
40
+ time_max: @query[:time_max],
41
+ slot_duration_seconds: @query[:slot_duration_seconds],
42
+ hours: @query[:hours],
43
+ days: @query[:days],
44
+ free_before_seconds: @query[:free_before_seconds],
45
+ free_after_seconds: @query[:free_after_seconds]
46
+ )
47
+ @slots_by_day = finder.slots_by_day
48
+ @expected_count = @slots_by_day.values.sum(&:size)
49
+ end
47
50
  end
48
51
 
49
52
  def to_h
@@ -69,11 +69,11 @@ module Flycal
69
69
 
70
70
  def day_intervals(date)
71
71
  @hours.map do |start_h, start_m, end_h, end_m|
72
- start_at = Time.local(date.year, date.month, date.day, start_h, start_m, 0)
73
- end_at = Time.local(date.year, date.month, date.day, end_h, end_m, 0)
74
- start_at = [@time_min, start_at].max
75
- end_at = [@time_max, end_at].min
76
- [start_at, end_at]
72
+ start_at = TimeFormat.local(date.year, date.month, date.day, start_h, start_m, 0)
73
+ end_at = TimeFormat.local(date.year, date.month, date.day, end_h, end_m, 0)
74
+ start_at = [ @time_min, start_at ].max
75
+ end_at = [ @time_max, end_at ].min
76
+ [ start_at, end_at ]
77
77
  end
78
78
  end
79
79
 
@@ -77,6 +77,7 @@ module Flycal
77
77
  template:,
78
78
  calendars:,
79
79
  locale: nil,
80
+ timezone: nil,
80
81
  calendar_option: nil,
81
82
  from_option: nil,
82
83
  to_option: nil,
@@ -121,6 +122,7 @@ module Flycal
121
122
  "calendar_ids" => Array(calendars).map { |c| c[:id] },
122
123
  "format" => "json",
123
124
  "locale" => locale || Locale.current_locale,
125
+ "timezone" => timezone || TimeFormat.zone_name,
124
126
  "from_option" => blank_to_nil(from_option),
125
127
  "to_option" => blank_to_nil(to_option),
126
128
  "in_option" => blank_to_nil(in_option),
@@ -133,6 +135,7 @@ module Flycal
133
135
  "to" => format_iso(time_max),
134
136
  "duration" => duration,
135
137
  "template" => template,
138
+ "timezone" => timezone || TimeFormat.zone_name,
136
139
  "calendars" => Array(calendars).map { |c| { "id" => c[:id], "name" => c[:name] } },
137
140
  "link" => google_calendar_day_url(time_min)
138
141
  },
@@ -12,6 +12,13 @@ module Flycal
12
12
  calendar_ids = Array(p[:calendar_ids]).map(&:to_s).reject(&:empty?)
13
13
  raise Error, "calendar_ids are required" if calendar_ids.empty?
14
14
 
15
+ timezone = TimeFormat.resolve(explicit: p[:timezone])
16
+ TimeFormat.with_zone(timezone) { build(p, calendar_ids, timezone) }
17
+ end
18
+
19
+ private
20
+
21
+ def build(p, calendar_ids, timezone)
15
22
  duration_value = p[:duration].presence || "45min"
16
23
  from_value = p[:from].presence || "now"
17
24
  free_before_value = p[:free_before].presence || "0m"
@@ -46,6 +53,7 @@ module Flycal
46
53
  free_after: free_after_value,
47
54
  template: template[:name],
48
55
  locale: locale,
56
+ timezone: timezone,
49
57
  calendar: p[:calendar],
50
58
  hours: hours,
51
59
  days: days,
@@ -58,8 +66,6 @@ module Flycal
58
66
  }
59
67
  end
60
68
 
61
- private
62
-
63
69
  def resolve_time_max(params, time_min, locale)
64
70
  if params[:in].present?
65
71
  DurationParser.add_to_time(params[:in], time_min)
@@ -2,25 +2,107 @@
2
2
 
3
3
  require "time"
4
4
  require "date"
5
+ # Full ActiveSupport, not only active_support/time: AS 8.1 reads
6
+ # ActiveSupport.utc_to_local_returns_utc_offset_times in TimeZone#utc_to_local,
7
+ # and that accessor lives on the main active_support.rb.
8
+ require "active_support"
9
+ require "active_support/time"
5
10
 
6
11
  module Flycal
7
- # Single clock for every datetime Flycal emits (JSON + text).
12
+ # Named-zone clock for template hours and every datetime Flycal emits.
8
13
  #
9
- # Instants are converted to process-local Time so start/end/from/to never mix
10
- # offsets (e.g. +02:00 vs +00:00) in the same payload.
14
+ # Wall-clock windows (work 9:30–13, dinner 19–23, template_custom) are local
15
+ # to this zone never the server process TZ (often UTC in production).
16
+ # JSON start/end/from/to always share that same offset.
11
17
  module TimeFormat
18
+ DEFAULT_ZONE = "Europe/Rome"
19
+
12
20
  module_function
13
21
 
14
- def iso8601(value)
15
- time = unified(value)
16
- time&.iso8601
22
+ def with_zone(name)
23
+ previous = Thread.current[:flycal_timezone]
24
+ Thread.current[:flycal_timezone] = coerce_zone(name)
25
+ yield current_zone
26
+ ensure
27
+ Thread.current[:flycal_timezone] = previous
28
+ end
29
+
30
+ def current_zone
31
+ Thread.current[:flycal_timezone] || coerce_zone(DEFAULT_ZONE)
32
+ end
33
+
34
+ def coerce_zone(name)
35
+ return name if name.is_a?(ActiveSupport::TimeZone)
36
+
37
+ key = name.to_s.strip
38
+ key = DEFAULT_ZONE if key.empty?
39
+ ActiveSupport::TimeZone[key] || ActiveSupport::TimeZone[DEFAULT_ZONE]
40
+ end
41
+
42
+ def zone_name(name = nil)
43
+ zone = if name.nil? || name.to_s.strip.empty?
44
+ current_zone
45
+ else
46
+ coerce_zone(name)
47
+ end
48
+ zone.tzinfo&.identifier || zone.name
49
+ end
50
+
51
+ # explicit param → Google calendar timeZone → FLYCAL_TIMEZONE → Europe/Rome.
52
+ # Never uses ENV["TZ"] (Docker/Kamal set that to UTC).
53
+ def resolve(explicit: nil, calendars: nil, calendar_ids: nil)
54
+ key = explicit.to_s.strip
55
+ return zone_name(key) unless key.empty?
56
+
57
+ from_cal = from_calendars(calendars, calendar_ids)
58
+ return from_cal if from_cal
59
+
60
+ env = ENV["FLYCAL_TIMEZONE"].to_s.strip
61
+ return zone_name(env) unless env.empty?
62
+
63
+ DEFAULT_ZONE
64
+ end
65
+
66
+ def from_calendars(calendars, calendar_ids = nil)
67
+ return nil if calendars.nil? || (calendars.respond_to?(:empty?) && calendars.empty?)
68
+
69
+ ids = Array(calendar_ids).map(&:to_s)
70
+ list = Array(calendars)
71
+ selected =
72
+ if ids.empty?
73
+ list
74
+ else
75
+ matched = list.select { |cal| cal.respond_to?(:id) && ids.include?(cal.id.to_s) }
76
+ matched.empty? ? list : matched
77
+ end
78
+ cal = selected.find { |c| c.respond_to?(:primary) && c.primary } || selected.first
79
+ tz = cal.respond_to?(:time_zone) ? cal.time_zone : nil
80
+ return nil if tz.to_s.strip.empty?
81
+
82
+ zone_name(tz)
83
+ end
84
+
85
+ def now
86
+ current_zone.now
87
+ end
88
+
89
+ def today
90
+ current_zone.today
91
+ end
92
+
93
+ def local(year, month, day, hour = 0, min = 0, sec = 0, zone: current_zone)
94
+ coerce_zone(zone).local(year, month, day, hour, min, sec)
95
+ end
96
+
97
+ def iso8601(value, zone: current_zone)
98
+ unified(value, zone: zone)&.iso8601
17
99
  end
18
100
 
19
- def unified(value)
101
+ def unified(value, zone: current_zone)
20
102
  time = coerce(value)
21
103
  return nil unless time
22
104
 
23
- Time.at(time.to_f)
105
+ coerce_zone(zone).at(time.to_f)
24
106
  end
25
107
 
26
108
  def coerce(value)
data/lib/flycal.rb CHANGED
@@ -4,7 +4,7 @@
4
4
  # Under Rails, Zeitwerk autoloads lib/flycal/*.
5
5
  # The CLI gem entrypoint (flycal_cli/lib/flycal_cli.rb) eager-requires what it needs.
6
6
  module Flycal
7
- VERSION = "1.1"
7
+ VERSION = "1.2.1"
8
8
 
9
9
  def self.connect(credentials:)
10
10
  Client.new(credentials: credentials)
data/locales/en.json CHANGED
@@ -28,6 +28,24 @@
28
28
  "failed": "Error: could not open config with %{editor}."
29
29
  }
30
30
  },
31
+ "login": {
32
+ "prompt": "How do you want to sign in?",
33
+ "options": {
34
+ "flycal": "Google account (Flycal)",
35
+ "json": "Your own Google Cloud client JSON (~/.flycal/credentials.json)"
36
+ },
37
+ "already_connected": "✓ You are already connected to your Google account.",
38
+ "next_config": "Run 'flycal config' to set the default calendar.",
39
+ "success": "✓ Authentication completed successfully!",
40
+ "cancelled": "Login cancelled.",
41
+ "unknown_client": "Unknown OAuth client %{client}. Use flycal or json.",
42
+ "open_link": "Open this link in your browser to authenticate:",
43
+ "waiting": "After authentication, you will be redirected back here automatically.",
44
+ "browser_done_title": "Authentication complete!",
45
+ "browser_done_body": "You can close this window and return to the terminal.",
46
+ "flycal_missing": "Flycal OAuth client is missing from this install (%{path}).",
47
+ "json_missing": "Credentials file not found at %{path}.\nCreate a Desktop app at https://console.cloud.google.com/apis/credentials\nand save the JSON there.\nAuthorized redirect URI: %{redirect}"
48
+ },
31
49
  "slots": {
32
50
  "no_available": "No available slots found.",
33
51
  "no_calendar": "No calendar found. Run 'flycal config' to set a default.",
data/locales/it.json CHANGED
@@ -28,6 +28,24 @@
28
28
  "failed": "Errore: impossibile aprire il config con %{editor}."
29
29
  }
30
30
  },
31
+ "login": {
32
+ "prompt": "Come vuoi accedere?",
33
+ "options": {
34
+ "flycal": "Account Google (Flycal)",
35
+ "json": "Il tuo client JSON di Google Cloud (~/.flycal/credentials.json)"
36
+ },
37
+ "already_connected": "✓ Sei già connesso al tuo account Google.",
38
+ "next_config": "Esegui 'flycal config' per impostare il calendario predefinito.",
39
+ "success": "✓ Autenticazione completata!",
40
+ "cancelled": "Login annullato.",
41
+ "unknown_client": "Client OAuth sconosciuto: %{client}. Usa flycal o json.",
42
+ "open_link": "Apri questo link nel browser per autenticarti:",
43
+ "waiting": "Dopo l'autenticazione verrai reindirizzato qui automaticamente.",
44
+ "browser_done_title": "Autenticazione completata!",
45
+ "browser_done_body": "Puoi chiudere questa finestra e tornare al terminale.",
46
+ "flycal_missing": "Il client OAuth Flycal manca da questa installazione (%{path}).",
47
+ "json_missing": "File credenziali non trovato: %{path}.\nCrea un'app Desktop su https://console.cloud.google.com/apis/credentials\ne salva il JSON lì.\nURI di reindirizzamento autorizzato: %{redirect}"
48
+ },
31
49
  "slots": {
32
50
  "no_available": "Nessuno slot disponibile.",
33
51
  "no_calendar": "Nessun calendario trovato. Esegui 'flycal config' per impostare il predefinito.",
data/mcp/tools.json CHANGED
@@ -82,7 +82,7 @@
82
82
  "path": "~/.flycal/config.yml",
83
83
  "credentials_path": "~/.flycal/credentials.json",
84
84
  "tokens_path": "~/.flycal/tokens.yml",
85
- "credentials_note": "Default/test phase: project-provided API credentials just run flycal login. Creating your own Google Cloud Console OAuth client is optional and only needed if you want to use your own API project; save that JSON as ~/.flycal/credentials.json then run flycal login.",
85
+ "credentials_note": "flycal login offers two clients: (1) Flycal Desktop OAuth, no Google Cloud project needed; (2) optional ~/.flycal/credentials.json from your own Desktop app. Non-interactive default is flycal (`flycal login --client flycal`). For your own JSON: `flycal login --client json`.",
86
86
  "keys": {
87
87
  "calendar_default": {
88
88
  "type": ["string", "null"],
@@ -206,7 +206,7 @@
206
206
  {
207
207
  "name": "flycal_login",
208
208
  "title": "Connect Google account",
209
- "description": "Start OAuth browser login. Requires ~/.flycal/credentials.json and a browser/TTY. Interactive.",
209
+ "description": "Start OAuth browser login. Interactive menu: Flycal Desktop client (default) or ~/.flycal/credentials.json. Use --client flycal|json to skip the menu.",
210
210
  "interactive": true,
211
211
  "requires_auth": false,
212
212
  "side_effects": ["writes_tokens"],
@@ -214,6 +214,13 @@
214
214
  "argv": ["flycal", "login"]
215
215
  },
216
216
  "parameters": {
217
+ "client": {
218
+ "cli": ["--client"],
219
+ "type": "string",
220
+ "enum": ["flycal", "json"],
221
+ "required": false,
222
+ "description": "Skip the login menu. flycal = Flycal Desktop OAuth; json = ~/.flycal/credentials.json."
223
+ },
217
224
  "locale": {
218
225
  "cli": ["--locale"],
219
226
  "type": "string",
@@ -226,6 +233,11 @@
226
233
  "type": "object",
227
234
  "additionalProperties": false,
228
235
  "properties": {
236
+ "client": {
237
+ "type": "string",
238
+ "enum": ["flycal", "json"],
239
+ "description": "OAuth client (--client flycal|json)."
240
+ },
229
241
  "locale": {
230
242
  "type": "string",
231
243
  "enum": ["en", "it"],
@@ -595,7 +607,7 @@
595
607
  "execution": "Spawn the flycal binary with argv from command.argv, or build argv from argv_template + option_map for each provided parameter. Capture stdout/stderr and exit code. Return stdout as MCP text content.",
596
608
  "parameter_building": "For each non-null input property present in option_map, append the mapped flag and value. Quote values with spaces. Always append --locale when locale is set.",
597
609
  "interactive_tools": "Do not auto-run tools with interactive=true unless the host provides a TTY. Tell the user to run them in a terminal.",
598
- "auth_errors": "If output indicates the user is not connected, suggest flycal login (after credentials.json is in place).",
610
+ "auth_errors": "If output indicates the user is not connected, suggest flycal login (Flycal Desktop client by default; credentials.json only if they use their own Google Cloud project).",
599
611
  "tool_filter": "Prefer exposing flycal_search, flycal_slots, flycal_calendars, and flycal_version to agents."
600
612
  }
601
613
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flycal-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: '1.1'
4
+ version: 1.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - flycal-cli
@@ -134,6 +134,7 @@ files:
134
134
  - README.md
135
135
  - bin/flycal
136
136
  - config/defaults.yml
137
+ - config/oauth_client.json
137
138
  - lib/flycal.rb
138
139
  - lib/flycal/cache_annotator.rb
139
140
  - lib/flycal/cache_key.rb