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 +4 -4
- data/config/defaults.yml +1 -0
- data/config/oauth_client.json +14 -0
- data/lib/flycal/cli/app.rb +113 -74
- data/lib/flycal/cli/auth.rb +113 -42
- data/lib/flycal/cli/config.rb +22 -0
- data/lib/flycal/client.rb +52 -32
- data/lib/flycal/date_time_parser.rb +12 -10
- data/lib/flycal/duration_parser.rb +1 -0
- data/lib/flycal/locale.rb +2 -2
- data/lib/flycal/mock/event_generator.rb +2 -2
- data/lib/flycal/mock/slot_scenario.rb +19 -16
- data/lib/flycal/slot_finder.rb +5 -5
- data/lib/flycal/slot_formatter.rb +3 -0
- data/lib/flycal/slot_query.rb +8 -2
- data/lib/flycal/time_format.rb +90 -8
- data/lib/flycal.rb +1 -1
- data/locales/en.json +18 -0
- data/locales/it.json +18 -0
- data/mcp/tools.json +15 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01bbfc45999ccc7c458c48f5131a8476271e50e8b166e819d53371768a43fa51
|
|
4
|
+
data.tar.gz: 455dd466af400747d3d7bcb97fec8f4a9f87abd5db12d52395bc04a0c4edfcc5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38af1efd8c2d88d6b5f9e0b2e98461bcb44dfa8a9f3b45e4fd665bc05707331d02055aa32e473c0debd0d6c8e7a9b9290a6a920d7d2bddf18b38fbfc156497ef
|
|
7
|
+
data.tar.gz: b07bcdda58dfcc654219c5106c96af6f93f943b55e5aa3f20777d6dd57eacb9e166bb524be5c3e2720c7d4c7f8b95b9ce344272796ac47ef7124a78b4c2ea608
|
data/config/defaults.yml
CHANGED
|
@@ -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
|
+
}
|
data/lib/flycal/cli/app.rb
CHANGED
|
@@ -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 "
|
|
29
|
-
puts "\
|
|
31
|
+
puts Locale.t("login.already_connected")
|
|
32
|
+
puts "\n#{Locale.t('login.next_config')}"
|
|
30
33
|
return
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
47
|
-
puts "\
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
-
|
|
427
|
-
events
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
data/lib/flycal/cli/auth.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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(
|
|
49
|
+
creds = authorizer.get_credentials(USER_ID)
|
|
62
50
|
if creds
|
|
63
|
-
|
|
64
|
-
|
|
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, "
|
|
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:
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
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 "\
|
|
177
|
+
puts "\n#{Locale.t('login.open_link')}\n\n"
|
|
125
178
|
puts " #{url}\n\n"
|
|
126
|
-
puts "
|
|
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
|
data/lib/flycal/cli/config.rb
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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:
|
|
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
|
|
87
|
+
return TimeFormat.now
|
|
87
88
|
when "today", "oggi"
|
|
88
|
-
return time_for_date(
|
|
89
|
+
return time_for_date(TimeFormat.today, end_of_day: end_of_day)
|
|
89
90
|
when "tomorrow", "domani"
|
|
90
|
-
return time_for_date(
|
|
91
|
+
return time_for_date(TimeFormat.today + 1, end_of_day: end_of_day)
|
|
91
92
|
when "yesterday", "ieri"
|
|
92
|
-
return time_for_date(
|
|
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 =
|
|
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
|
-
|
|
146
|
+
midnight = TimeFormat.local(date.year, date.month, date.day)
|
|
147
|
+
return [ midnight, TimeFormat.now ].max if date == TimeFormat.today
|
|
146
148
|
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
TimeFormat.local(date.year, date.month, date.day, 23, 59, 59)
|
|
202
204
|
end
|
|
203
205
|
|
|
204
206
|
def time_component?(value)
|
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
|
-
|
|
38
|
-
d =
|
|
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.
|
|
62
|
-
window_end = day.
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
data/lib/flycal/slot_finder.rb
CHANGED
|
@@ -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 =
|
|
73
|
-
end_at =
|
|
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
|
},
|
data/lib/flycal/slot_query.rb
CHANGED
|
@@ -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)
|
data/lib/flycal/time_format.rb
CHANGED
|
@@ -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
|
-
#
|
|
12
|
+
# Named-zone clock for template hours and every datetime Flycal emits.
|
|
8
13
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
105
|
+
coerce_zone(zone).at(time.to_f)
|
|
24
106
|
end
|
|
25
107
|
|
|
26
108
|
def coerce(value)
|
data/lib/flycal.rb
CHANGED
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": "
|
|
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.
|
|
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 (
|
|
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:
|
|
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
|