flycal-cli 1.1 → 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: fcbd8c2cfb6f90272cf8cc8cfa489bc99a7487efca11f68560b2d7f96ac92f34
4
- data.tar.gz: 21e92239db0dc5b3f587abdd1e859b5e67e67293706f6ec1f50ca34ca4fe0e1d
3
+ metadata.gz: 55605292daf7d56f71dc210b4c607ebceeb0aa1be1bc464ba73d82ce26583c4a
4
+ data.tar.gz: 256c9dd132265a9d7b36ac7a144c516b06d5c298f9a8e8fb8ac29e1fe2a7bc53
5
5
  SHA512:
6
- metadata.gz: 2524fb58d1faecdf6ac6643761315f2586f6fe10b4535f2f50b87418f871665f13c84110c3d627ad2131bb9b2ef95aa171c0e6e1e41b52f87d7b76fd7261493c
7
- data.tar.gz: 906c85b9dd3711aa16874a6f41be9851be1accbd07d131b09fb9f6173ed26d0e081f6607b457d606f538ea8861ab98dcff89b6cdab4f482b79ffe0c8af41eb7b
6
+ metadata.gz: b7a93a60e2cfd438b9b9ef9b52bf1c1a06e07c329bf3e86d3e8ef2253a5bf96c8bbbb390c5258b5250d71e0f87d6669b1088c1b7d10a3bebac0ad9809dc4f638
7
+ data.tar.gz: 5fc7e9f8a1d202e84caf771498a5758023a8aa16a240e6490108eb31d73507889857ca40a2db00309a78f7c15de34f16db1a334037bc44cd684531bac8d73053
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
+ }
@@ -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,17 +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
- 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)
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
346
351
  rescue Flycal::Error => e
347
352
  puts "Error: #{e.message}"
348
353
  exit 1
@@ -380,6 +385,7 @@ module Cli
380
385
  free_after: slot_cfg["free_after"] || "0m",
381
386
  template: template_name,
382
387
  locale: Locale.current_locale,
388
+ timezone: timezone,
383
389
  calendar: options[:calendar],
384
390
  hours: hours,
385
391
  days: days,
@@ -404,55 +410,57 @@ module Cli
404
410
  if output.nil?
405
411
  FileCache.delete(cache_key) if nocache
406
412
 
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
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
425
420
 
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
- )
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
436
432
 
437
- 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
+ )
438
443
 
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
- )
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
+ )
456
464
  else
457
465
  SlotFormatter.format_text(
458
466
  slots_by_day: slots_by_day,
@@ -465,8 +473,9 @@ module Cli
465
473
  )
466
474
  end
467
475
 
468
- FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
469
- status = "MISS"
476
+ FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
477
+ status = "MISS"
478
+ end
470
479
  end
471
480
 
472
481
  print annotate_cli_output(output, status, cache_key, format)
@@ -542,6 +551,37 @@ module Cli
542
551
  lines[(blank_idx + 1)..].join("\n").strip
543
552
  end
544
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
+
545
585
  def with_config_interrupt_handling
546
586
  yield
547
587
  rescue Interrupt
@@ -682,8 +722,6 @@ module Cli
682
722
  end
683
723
 
684
724
  def parse_slots_from(value)
685
- return Time.now if value.to_s.strip.downcase == "now"
686
-
687
725
  DateTimeParser.parse(value)
688
726
  end
689
727
 
@@ -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 }
@@ -69,7 +69,7 @@ module Flycal
69
69
  end
70
70
  end
71
71
 
72
- def parse_or_default(str, default: Time.now, end_of_day: false, locale: nil)
72
+ def parse_or_default(str, default: TimeFormat.now, end_of_day: false, locale: nil)
73
73
  return default if str.nil? || str.to_s.strip.empty?
74
74
 
75
75
  parse(str, end_of_day: end_of_day, locale: locale)
@@ -83,13 +83,13 @@ module Flycal
83
83
 
84
84
  case normalized
85
85
  when "now", "adesso", "ora"
86
- return Time.now
86
+ return TimeFormat.now
87
87
  when "today", "oggi"
88
- return time_for_date(Date.today, end_of_day: end_of_day)
88
+ return time_for_date(TimeFormat.today, end_of_day: end_of_day)
89
89
  when "tomorrow", "domani"
90
- return time_for_date(Date.today + 1, end_of_day: end_of_day)
90
+ return time_for_date(TimeFormat.today + 1, end_of_day: end_of_day)
91
91
  when "yesterday", "ieri"
92
- return time_for_date(Date.today - 1, end_of_day: end_of_day)
92
+ return time_for_date(TimeFormat.today - 1, end_of_day: end_of_day)
93
93
  end
94
94
 
95
95
  weekday = parse_weekday_phrase(normalized)
@@ -117,7 +117,7 @@ module Flycal
117
117
  wday = WEEKDAYS[day_token]
118
118
  return nil unless wday
119
119
 
120
- today = Date.today
120
+ today = TimeFormat.today
121
121
 
122
122
  if qualifier.nil? || THIS_WORDS.include?(qualifier)
123
123
  next_weekday(today, wday, inclusive: true)
@@ -142,9 +142,10 @@ module Flycal
142
142
 
143
143
  def time_for_date(date, end_of_day:)
144
144
  return end_of_day_for(date) if end_of_day
145
- return [date.to_time, Time.now].max if date == Date.today
145
+ midnight = TimeFormat.local(date.year, date.month, date.day)
146
+ return [ midnight, TimeFormat.now ].max if date == TimeFormat.today
146
147
 
147
- date.to_time
148
+ midnight
148
149
  end
149
150
 
150
151
  def formats_for(locale)
@@ -192,13 +193,13 @@ module Flycal
192
193
  return time_for_date(date, end_of_day: end_of_day)
193
194
  end
194
195
 
195
- Time.local(year, month, day, hour, min || 0, sec || 0)
196
+ TimeFormat.local(year, month, day, hour, min || 0, sec || 0)
196
197
  rescue ArgumentError
197
198
  raise Flycal::Error, "Invalid date: #{year}-#{month}-#{day}"
198
199
  end
199
200
 
200
201
  def end_of_day_for(date)
201
- Time.local(date.year, date.month, date.day, 23, 59, 59)
202
+ TimeFormat.local(date.year, date.month, date.day, 23, 59, 59)
202
203
  end
203
204
 
204
205
  def time_component?(value)
@@ -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,103 @@
2
2
 
3
3
  require "time"
4
4
  require "date"
5
+ require "active_support/time"
5
6
 
6
7
  module Flycal
7
- # Single clock for every datetime Flycal emits (JSON + text).
8
+ # Named-zone clock for template hours and every datetime Flycal emits.
8
9
  #
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.
10
+ # Wall-clock windows (work 9:30–13, dinner 19–23, template_custom) are local
11
+ # to this zone never the server process TZ (often UTC in production).
12
+ # JSON start/end/from/to always share that same offset.
11
13
  module TimeFormat
14
+ DEFAULT_ZONE = "Europe/Rome"
15
+
12
16
  module_function
13
17
 
14
- def iso8601(value)
15
- time = unified(value)
16
- time&.iso8601
18
+ def with_zone(name)
19
+ previous = Thread.current[:flycal_timezone]
20
+ Thread.current[:flycal_timezone] = coerce_zone(name)
21
+ yield current_zone
22
+ ensure
23
+ Thread.current[:flycal_timezone] = previous
24
+ end
25
+
26
+ def current_zone
27
+ Thread.current[:flycal_timezone] || coerce_zone(DEFAULT_ZONE)
28
+ end
29
+
30
+ def coerce_zone(name)
31
+ return name if name.is_a?(ActiveSupport::TimeZone)
32
+
33
+ key = name.to_s.strip
34
+ key = DEFAULT_ZONE if key.empty?
35
+ ActiveSupport::TimeZone[key] || ActiveSupport::TimeZone[DEFAULT_ZONE]
36
+ end
37
+
38
+ def zone_name(name = nil)
39
+ zone = if name.nil? || name.to_s.strip.empty?
40
+ current_zone
41
+ else
42
+ coerce_zone(name)
43
+ end
44
+ zone.tzinfo&.identifier || zone.name
45
+ end
46
+
47
+ # explicit param → Google calendar timeZone → FLYCAL_TIMEZONE → Europe/Rome.
48
+ # Never uses ENV["TZ"] (Docker/Kamal set that to UTC).
49
+ def resolve(explicit: nil, calendars: nil, calendar_ids: nil)
50
+ key = explicit.to_s.strip
51
+ return zone_name(key) unless key.empty?
52
+
53
+ from_cal = from_calendars(calendars, calendar_ids)
54
+ return from_cal if from_cal
55
+
56
+ env = ENV["FLYCAL_TIMEZONE"].to_s.strip
57
+ return zone_name(env) unless env.empty?
58
+
59
+ DEFAULT_ZONE
60
+ end
61
+
62
+ def from_calendars(calendars, calendar_ids = nil)
63
+ return nil if calendars.nil? || (calendars.respond_to?(:empty?) && calendars.empty?)
64
+
65
+ ids = Array(calendar_ids).map(&:to_s)
66
+ list = Array(calendars)
67
+ selected =
68
+ if ids.empty?
69
+ list
70
+ else
71
+ matched = list.select { |cal| cal.respond_to?(:id) && ids.include?(cal.id.to_s) }
72
+ matched.empty? ? list : matched
73
+ end
74
+ cal = selected.find { |c| c.respond_to?(:primary) && c.primary } || selected.first
75
+ tz = cal.respond_to?(:time_zone) ? cal.time_zone : nil
76
+ return nil if tz.to_s.strip.empty?
77
+
78
+ zone_name(tz)
79
+ end
80
+
81
+ def now
82
+ current_zone.now
83
+ end
84
+
85
+ def today
86
+ current_zone.today
87
+ end
88
+
89
+ def local(year, month, day, hour = 0, min = 0, sec = 0, zone: current_zone)
90
+ coerce_zone(zone).local(year, month, day, hour, min, sec)
91
+ end
92
+
93
+ def iso8601(value, zone: current_zone)
94
+ unified(value, zone: zone)&.iso8601
17
95
  end
18
96
 
19
- def unified(value)
97
+ def unified(value, zone: current_zone)
20
98
  time = coerce(value)
21
99
  return nil unless time
22
100
 
23
- Time.at(time.to_f)
101
+ coerce_zone(zone).at(time.to_f)
24
102
  end
25
103
 
26
104
  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"
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'
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