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 +4 -4
- data/config/defaults.yml +1 -0
- data/config/oauth_client.json +14 -0
- data/lib/flycal/cli/app.rb +112 -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 +11 -10
- 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 +86 -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: 55605292daf7d56f71dc210b4c607ebceeb0aa1be1bc464ba73d82ce26583c4a
|
|
4
|
+
data.tar.gz: 256c9dd132265a9d7b36ac7a144c516b06d5c298f9a8e8fb8ac29e1fe2a7bc53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7a93a60e2cfd438b9b9ef9b52bf1c1a06e07c329bf3e86d3e8ef2253a5bf96c8bbbb390c5258b5250d71e0f87d6669b1088c1b7d10a3bebac0ad9809dc4f638
|
|
7
|
+
data.tar.gz: 5fc7e9f8a1d202e84caf771498a5758023a8aa16a240e6490108eb31d73507889857ca40a2db00309a78f7c15de34f16db1a334037bc44cd684531bac8d73053
|
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
|
@@ -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 "
|
|
29
|
-
puts "\
|
|
30
|
+
puts Locale.t("login.already_connected")
|
|
31
|
+
puts "\n#{Locale.t('login.next_config')}"
|
|
30
32
|
return
|
|
31
33
|
end
|
|
32
34
|
|
|
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
|
|
35
|
+
mode = resolve_login_mode
|
|
36
|
+
return unless mode
|
|
43
37
|
|
|
44
38
|
begin
|
|
45
|
-
Auth.login
|
|
46
|
-
puts "\n
|
|
47
|
-
puts "\
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
427
|
-
events
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
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 }
|
|
@@ -69,7 +69,7 @@ module Flycal
|
|
|
69
69
|
end
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
def parse_or_default(str, default:
|
|
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
|
|
86
|
+
return TimeFormat.now
|
|
87
87
|
when "today", "oggi"
|
|
88
|
-
return time_for_date(
|
|
88
|
+
return time_for_date(TimeFormat.today, end_of_day: end_of_day)
|
|
89
89
|
when "tomorrow", "domani"
|
|
90
|
-
return time_for_date(
|
|
90
|
+
return time_for_date(TimeFormat.today + 1, end_of_day: end_of_day)
|
|
91
91
|
when "yesterday", "ieri"
|
|
92
|
-
return time_for_date(
|
|
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 =
|
|
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
|
-
|
|
145
|
+
midnight = TimeFormat.local(date.year, date.month, date.day)
|
|
146
|
+
return [ midnight, TimeFormat.now ].max if date == TimeFormat.today
|
|
146
147
|
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,103 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
require "date"
|
|
5
|
+
require "active_support/time"
|
|
5
6
|
|
|
6
7
|
module Flycal
|
|
7
|
-
#
|
|
8
|
+
# Named-zone clock for template hours and every datetime Flycal emits.
|
|
8
9
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
101
|
+
coerce_zone(zone).at(time.to_f)
|
|
24
102
|
end
|
|
25
103
|
|
|
26
104
|
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: '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
|