flycal-cli 0.7.7 → 1.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/README.md +6 -181
- data/bin/flycal +2 -2
- data/config/defaults.yml +2 -28
- data/lib/flycal/cache_annotator.rb +83 -0
- data/lib/flycal/cache_key.rb +82 -0
- data/lib/flycal/calendar_query.rb +60 -0
- data/lib/{flycal_cli → flycal}/calendar_service.rb +48 -4
- data/lib/{flycal_cli/cli.rb → flycal/cli/app.rb} +246 -171
- data/lib/{flycal_cli → flycal/cli}/auth.rb +5 -3
- data/lib/{flycal_cli → flycal/cli}/clipboard.rb +3 -1
- data/lib/{flycal_cli → flycal/cli}/config.rb +6 -3
- data/lib/flycal/cli/file_cache.rb +44 -0
- data/lib/flycal/cli/locale.rb +58 -0
- data/lib/flycal/cli.rb +14 -0
- data/lib/flycal/client.rb +141 -0
- data/lib/flycal/credentials.rb +34 -0
- data/lib/{flycal_cli → flycal}/date_time_parser.rb +4 -4
- data/lib/flycal/default_slots.rb +77 -0
- data/lib/{flycal_cli → flycal}/description_query.rb +3 -3
- data/lib/{flycal_cli → flycal}/duration_parser.rb +3 -3
- data/lib/flycal/error.rb +5 -0
- data/lib/flycal/event_mapper.rb +58 -0
- data/lib/{flycal_cli → flycal}/locale.rb +35 -3
- data/lib/{flycal_cli → flycal}/mock/calendar_service.rb +1 -1
- data/lib/{flycal_cli → flycal}/mock/config.rb +14 -14
- data/lib/{flycal_cli → flycal}/mock/event_generator.rb +6 -4
- data/lib/flycal/mock/slot_scenario.rb +75 -0
- data/lib/flycal/mock.rb +6 -0
- data/lib/{flycal_cli → flycal}/pipeline/aggregator.rb +21 -24
- data/lib/{flycal_cli → flycal}/pipeline/json_renderer.rb +16 -17
- data/lib/{flycal_cli → flycal}/pipeline/params.rb +1 -1
- data/lib/{flycal_cli → flycal}/pipeline/renderer.rb +4 -4
- data/lib/{flycal_cli → flycal}/pipeline/retriever.rb +1 -1
- data/lib/{flycal_cli → flycal}/pipeline/search_pipeline.rb +1 -1
- data/lib/{flycal_cli → flycal}/pipeline/text_renderer.rb +14 -12
- data/lib/flycal/pipeline.rb +6 -0
- data/lib/{flycal_cli → flycal}/slot_finder.rb +12 -12
- data/lib/flycal/slot_formatter.rb +196 -0
- data/lib/flycal/slot_query.rb +74 -0
- data/lib/flycal/slot_templates.rb +129 -0
- data/lib/flycal/time_format.rb +36 -0
- data/lib/flycal/version.rb +11 -0
- data/lib/flycal.rb +12 -0
- data/lib/flycal_cli.rb +42 -17
- data/mcp/tools.json +21 -8
- metadata +41 -25
- data/lib/flycal_cli/mock.rb +0 -10
- data/lib/flycal_cli/pipeline.rb +0 -14
- data/lib/flycal_cli/slot_formatter.rb +0 -68
- data/lib/flycal_cli/version.rb +0 -5
|
@@ -9,8 +9,9 @@ require "active_support/core_ext/time"
|
|
|
9
9
|
require "tty-prompt"
|
|
10
10
|
require "tty-spinner"
|
|
11
11
|
|
|
12
|
-
module
|
|
13
|
-
|
|
12
|
+
module Flycal
|
|
13
|
+
module Cli
|
|
14
|
+
class App < Thor
|
|
14
15
|
package_name "flycal"
|
|
15
16
|
class_option :locale, type: :string, desc: "Override locale for this command (e.g. en, it)"
|
|
16
17
|
class_option :format, type: :string, default: "text",
|
|
@@ -44,7 +45,7 @@ module FlycalCli
|
|
|
44
45
|
Auth.login
|
|
45
46
|
puts "\n✓ Authentication completed successfully!"
|
|
46
47
|
puts "\nRun 'flycal config' to set the default calendar."
|
|
47
|
-
rescue
|
|
48
|
+
rescue Flycal::Error => e
|
|
48
49
|
puts "Error: #{e.message}"
|
|
49
50
|
exit 1
|
|
50
51
|
end
|
|
@@ -137,16 +138,20 @@ module FlycalCli
|
|
|
137
138
|
flycal search -i 1months --description placeholder
|
|
138
139
|
flycal search -f 2025-03-01 --in 2months
|
|
139
140
|
flycal search --mockTemplate mock1 --description work --calendar mock1
|
|
140
|
-
flycal search
|
|
141
|
+
flycal search --groupBy "rui|solver" --format json
|
|
142
|
+
flycal search -c "Work|Personal"
|
|
143
|
+
flycal search --calendar Work --calendar Personal
|
|
141
144
|
LONGDESC
|
|
142
|
-
option :calendar, type: :string, aliases: "-c",
|
|
145
|
+
option :calendar, type: :string, aliases: "-c", repeatable: true,
|
|
146
|
+
desc: "Calendar name or ID (repeatable, or pipe-separated: Work|Personal)"
|
|
147
|
+
|
|
143
148
|
option :from, type: :string, aliases: "-f", desc: "Start (default: today midnight)"
|
|
144
149
|
option :to, type: :string, aliases: "-t", desc: "End (default: 23:59 of day 30)"
|
|
145
150
|
option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
|
|
146
151
|
option :description, type: :string, aliases: "-d",
|
|
147
152
|
desc: "Filter text in event (OR with |, e.g. rui|solver)"
|
|
148
153
|
option :groupBy, type: :string,
|
|
149
|
-
desc: "Grouping: day, week, month, or
|
|
154
|
+
desc: "Grouping: day, week, month, or a string like rui|solver (default: auto from timeframe)"
|
|
150
155
|
option :mockTemplate, type: :string, desc: "Load mock defaults from mocks/<name>.json"
|
|
151
156
|
option :mockCalendar, type: :string, desc: "Use a generated mock calendar (skips Google API)"
|
|
152
157
|
option :mockSeed, type: :numeric, desc: "Seed for reproducible mock events"
|
|
@@ -158,6 +163,8 @@ module FlycalCli
|
|
|
158
163
|
option :mockEventDurationMax, type: :string, desc: "Max mock event duration (e.g. 4h)"
|
|
159
164
|
option :mockEventHoursFrom, type: :string, desc: "Daily start window for mock events (HH:MM)"
|
|
160
165
|
option :mockEventHoursTo, type: :string, desc: "Daily end window for mock events (HH:MM)"
|
|
166
|
+
option :nocache, type: :boolean, default: false,
|
|
167
|
+
desc: "Bypass cache and refresh from Google"
|
|
161
168
|
def search
|
|
162
169
|
apply_locale_override
|
|
163
170
|
|
|
@@ -166,7 +173,7 @@ module FlycalCli
|
|
|
166
173
|
if mock_mode
|
|
167
174
|
begin
|
|
168
175
|
mock_config = Mock::Config.from_options(options)
|
|
169
|
-
rescue
|
|
176
|
+
rescue Flycal::Error => e
|
|
170
177
|
puts "Error: #{e.message}"
|
|
171
178
|
exit 1
|
|
172
179
|
end
|
|
@@ -182,7 +189,7 @@ module FlycalCli
|
|
|
182
189
|
else
|
|
183
190
|
DurationParser.add_to_time(options[:in], time_min)
|
|
184
191
|
end
|
|
185
|
-
rescue
|
|
192
|
+
rescue Flycal::Error => e
|
|
186
193
|
puts "Error: #{e.message}"
|
|
187
194
|
exit 1
|
|
188
195
|
end
|
|
@@ -196,7 +203,7 @@ module FlycalCli
|
|
|
196
203
|
if mock_config
|
|
197
204
|
Mock::CalendarService.new(mock_config)
|
|
198
205
|
else
|
|
199
|
-
|
|
206
|
+
flycal_client.calendar
|
|
200
207
|
end
|
|
201
208
|
|
|
202
209
|
calendar_ids =
|
|
@@ -229,10 +236,49 @@ module FlycalCli
|
|
|
229
236
|
mock_template: mock_config&.template_name
|
|
230
237
|
)
|
|
231
238
|
|
|
239
|
+
fingerprint = {
|
|
240
|
+
command: "search",
|
|
241
|
+
time_min: time_min,
|
|
242
|
+
time_max: time_max,
|
|
243
|
+
calendar_ids: calendar_ids,
|
|
244
|
+
description: options[:description],
|
|
245
|
+
calendar: options[:calendar],
|
|
246
|
+
from_option: options[:from],
|
|
247
|
+
to_option: options[:to],
|
|
248
|
+
in_option: options[:in],
|
|
249
|
+
group_by_option: options[:groupBy],
|
|
250
|
+
format: options[:format] || "text",
|
|
251
|
+
locale: Locale.current_locale,
|
|
252
|
+
use_mock: !mock_config.nil?,
|
|
253
|
+
mock_seed: mock_config&.seed,
|
|
254
|
+
mock_calendar: mock_config&.calendar_name,
|
|
255
|
+
mock_template: mock_config&.template_name
|
|
256
|
+
}
|
|
257
|
+
cache_key = Flycal::CacheKey.generate("search", fingerprint)
|
|
258
|
+
ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
|
|
259
|
+
nocache = options[:nocache]
|
|
260
|
+
|
|
232
261
|
begin
|
|
233
|
-
output =
|
|
234
|
-
|
|
235
|
-
|
|
262
|
+
output = nil
|
|
263
|
+
status = nil
|
|
264
|
+
|
|
265
|
+
unless nocache
|
|
266
|
+
cached = FileCache.read(cache_key, ttl_minutes: ttl)
|
|
267
|
+
if cached
|
|
268
|
+
output = cached
|
|
269
|
+
status = "HIT"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if output.nil?
|
|
274
|
+
FileCache.delete(cache_key) if nocache
|
|
275
|
+
output = Pipeline::SearchPipeline.new(service).run(params)
|
|
276
|
+
FileCache.write(cache_key, strip_cache_marker_from_output(output, params[:format]))
|
|
277
|
+
status = "MISS"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
print annotate_cli_output(output, status, cache_key, params[:format])
|
|
281
|
+
rescue Flycal::Error => e
|
|
236
282
|
puts "Error: #{e.message}"
|
|
237
283
|
exit 1
|
|
238
284
|
end
|
|
@@ -262,7 +308,11 @@ module FlycalCli
|
|
|
262
308
|
desc: "Start (default: now). Dates, or relative: monday, next monday, tomorrow, lunedi..."
|
|
263
309
|
option :template, type: :string, aliases: "-T",
|
|
264
310
|
desc: "Slot template name from config (default: first template, usually work)"
|
|
265
|
-
option :calendar, type: :string, aliases: "-c",
|
|
311
|
+
option :calendar, type: :string, aliases: "-c", repeatable: true,
|
|
312
|
+
desc: "Calendar name or ID (repeatable, or pipe-separated: Work|Personal)"
|
|
313
|
+
option :nocache, type: :boolean, default: false,
|
|
314
|
+
desc: "Bypass cache and refresh from Google"
|
|
315
|
+
|
|
266
316
|
def slots
|
|
267
317
|
apply_locale_override
|
|
268
318
|
unless Auth.logged_in?
|
|
@@ -284,15 +334,16 @@ module FlycalCli
|
|
|
284
334
|
from_value = "now" if from_value.empty?
|
|
285
335
|
|
|
286
336
|
begin
|
|
287
|
-
|
|
337
|
+
resolved = SlotTemplates.resolve(name: options[:template], catalog: slot_cfg["templates"])
|
|
338
|
+
template_name = resolved[:name]
|
|
339
|
+
hours = resolved[:hours]
|
|
340
|
+
days = resolved[:days]
|
|
288
341
|
slot_duration = DurationParser.to_seconds(duration_value)
|
|
289
342
|
free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
|
|
290
343
|
free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
|
|
291
344
|
time_min = parse_slots_from(from_value)
|
|
292
345
|
time_max = DurationParser.add_to_time(in_value, time_min)
|
|
293
|
-
|
|
294
|
-
days = parse_template_days(template["days"])
|
|
295
|
-
rescue FlycalCli::Error => e
|
|
346
|
+
rescue Flycal::Error => e
|
|
296
347
|
puts "Error: #{e.message}"
|
|
297
348
|
exit 1
|
|
298
349
|
end
|
|
@@ -302,8 +353,8 @@ module FlycalCli
|
|
|
302
353
|
exit 1
|
|
303
354
|
end
|
|
304
355
|
|
|
305
|
-
|
|
306
|
-
service =
|
|
356
|
+
client = flycal_client
|
|
357
|
+
service = client.calendar
|
|
307
358
|
|
|
308
359
|
exclude_calendar_ids = resolve_slots_exclude_calendar_ids(service, slot_cfg, options[:calendar])
|
|
309
360
|
if exclude_calendar_ids.empty?
|
|
@@ -311,53 +362,118 @@ module FlycalCli
|
|
|
311
362
|
exit 1
|
|
312
363
|
end
|
|
313
364
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
cal = calendars.find { |c| c.id == id }
|
|
317
|
-
name = cal&.summary || id
|
|
318
|
-
{ id: id, name: name }
|
|
319
|
-
end
|
|
365
|
+
format = (options[:format] || "text").to_s.strip.downcase
|
|
366
|
+
format = "text" if format.empty?
|
|
320
367
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
calendar_id,
|
|
325
|
-
time_min: fetch_from,
|
|
326
|
-
time_max: time_max
|
|
327
|
-
)
|
|
328
|
-
rescue Google::Apis::Errors::Error => e
|
|
329
|
-
warn Locale.t("errors.calendar_fetch", calendar: calendar_id, message: e.message)
|
|
330
|
-
[]
|
|
368
|
+
unless %w[json text].include?(format)
|
|
369
|
+
puts "Error: Unsupported format #{format.inspect}. Available: text, json"
|
|
370
|
+
exit 1
|
|
331
371
|
end
|
|
332
372
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
373
|
+
fingerprint = {
|
|
374
|
+
command: "slots",
|
|
375
|
+
calendar_ids: exclude_calendar_ids,
|
|
376
|
+
duration: duration_value,
|
|
377
|
+
from: from_value,
|
|
378
|
+
in: in_value,
|
|
379
|
+
free_before: slot_cfg["free_before"] || "0m",
|
|
380
|
+
free_after: slot_cfg["free_after"] || "0m",
|
|
381
|
+
template: template_name,
|
|
382
|
+
locale: Locale.current_locale,
|
|
383
|
+
calendar: options[:calendar],
|
|
338
384
|
hours: hours,
|
|
339
385
|
days: days,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
386
|
+
time_min: time_min,
|
|
387
|
+
time_max: time_max,
|
|
388
|
+
format: format
|
|
389
|
+
}
|
|
390
|
+
cache_key = Flycal::CacheKey.generate("slots", fingerprint)
|
|
391
|
+
ttl = Flycal::CacheKey::DEFAULT_TTL_MINUTES
|
|
392
|
+
nocache = options[:nocache]
|
|
393
|
+
|
|
394
|
+
output = nil
|
|
395
|
+
status = nil
|
|
396
|
+
unless nocache
|
|
397
|
+
cached = FileCache.read(cache_key, ttl_minutes: ttl)
|
|
398
|
+
if cached
|
|
399
|
+
output = cached
|
|
400
|
+
status = "HIT"
|
|
401
|
+
end
|
|
402
|
+
end
|
|
343
403
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
404
|
+
if output.nil?
|
|
405
|
+
FileCache.delete(cache_key) if nocache
|
|
406
|
+
|
|
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
|
|
425
|
+
|
|
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
|
+
)
|
|
436
|
+
|
|
437
|
+
slots_by_day = finder.slots_by_day
|
|
438
|
+
|
|
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
|
+
)
|
|
456
|
+
else
|
|
457
|
+
SlotFormatter.format_text(
|
|
458
|
+
slots_by_day: slots_by_day,
|
|
459
|
+
time_min: time_min,
|
|
460
|
+
time_max: time_max,
|
|
461
|
+
duration: duration_value,
|
|
462
|
+
calendars: calendar_meta,
|
|
463
|
+
template: template_name,
|
|
464
|
+
empty_message: Locale.t("slots.no_available")
|
|
465
|
+
)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
FileCache.write(cache_key, strip_cache_marker_from_output(output, format))
|
|
469
|
+
status = "MISS"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
print annotate_cli_output(output, status, cache_key, format)
|
|
473
|
+
|
|
474
|
+
if format == "text"
|
|
475
|
+
body = extract_slots_body_for_clipboard(output)
|
|
476
|
+
copy_slots_to_clipboard(body) unless body.empty?
|
|
361
477
|
end
|
|
362
478
|
end
|
|
363
479
|
|
|
@@ -375,7 +491,7 @@ module FlycalCli
|
|
|
375
491
|
desc "version", "Show current flycal-cli version"
|
|
376
492
|
def version
|
|
377
493
|
apply_locale_override
|
|
378
|
-
puts "flycal #{
|
|
494
|
+
puts "flycal #{Flycal::VERSION}"
|
|
379
495
|
end
|
|
380
496
|
|
|
381
497
|
default_task :help
|
|
@@ -394,6 +510,38 @@ module FlycalCli
|
|
|
394
510
|
Locale.override!(options[:locale])
|
|
395
511
|
end
|
|
396
512
|
|
|
513
|
+
def annotate_cli_output(output, status, cache_key, format)
|
|
514
|
+
fmt = format.to_s.strip.downcase
|
|
515
|
+
if fmt == "json"
|
|
516
|
+
payload = JSON.parse(output)
|
|
517
|
+
Flycal::CacheAnnotator.annotate_payload!(payload, status, cache_key)
|
|
518
|
+
JSON.pretty_generate(payload) + "\n"
|
|
519
|
+
else
|
|
520
|
+
Flycal::CacheAnnotator.annotate_text(output, status, cache_key)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def strip_cache_marker_from_output(output, format)
|
|
525
|
+
fmt = format.to_s.strip.downcase
|
|
526
|
+
if fmt == "json"
|
|
527
|
+
payload = JSON.parse(output)
|
|
528
|
+
JSON.pretty_generate(Flycal::CacheAnnotator.strip_for_storage(payload)) + "\n"
|
|
529
|
+
else
|
|
530
|
+
lines = output.to_s.sub(/\n\z/, "").split("\n")
|
|
531
|
+
lines.reject! { |l| l.start_with?("Cache:") }
|
|
532
|
+
"#{lines.join("\n")}\n"
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def extract_slots_body_for_clipboard(text)
|
|
537
|
+
lines = text.to_s.split("\n")
|
|
538
|
+
lines.reject! { |l| l.start_with?("Cache:") }
|
|
539
|
+
blank_idx = lines.index { |l| l == "" }
|
|
540
|
+
return "" unless blank_idx
|
|
541
|
+
|
|
542
|
+
lines[(blank_idx + 1)..].join("\n").strip
|
|
543
|
+
end
|
|
544
|
+
|
|
397
545
|
def with_config_interrupt_handling
|
|
398
546
|
yield
|
|
399
547
|
rescue Interrupt
|
|
@@ -402,12 +550,11 @@ module FlycalCli
|
|
|
402
550
|
end
|
|
403
551
|
|
|
404
552
|
def load_calendars
|
|
405
|
-
|
|
406
|
-
service = CalendarService.new(creds)
|
|
553
|
+
client = flycal_client
|
|
407
554
|
|
|
408
555
|
spinner = TTY::Spinner.new("#{Locale.t('common.loading_calendars')} ", format: :dots)
|
|
409
556
|
spinner.auto_spin
|
|
410
|
-
calendars =
|
|
557
|
+
calendars = client.list_calendars
|
|
411
558
|
spinner.stop("✓")
|
|
412
559
|
|
|
413
560
|
if calendars.empty?
|
|
@@ -418,6 +565,13 @@ module FlycalCli
|
|
|
418
565
|
calendars
|
|
419
566
|
end
|
|
420
567
|
|
|
568
|
+
def flycal_client
|
|
569
|
+
creds = Auth.credentials
|
|
570
|
+
raise Flycal::Error, Locale.t("errors.not_connected") if creds.nil?
|
|
571
|
+
|
|
572
|
+
Flycal.connect(credentials: creds)
|
|
573
|
+
end
|
|
574
|
+
|
|
421
575
|
def calendar_choices(calendars, highlight_ids: [])
|
|
422
576
|
highlights = Array(highlight_ids).compact
|
|
423
577
|
calendars.to_h do |cal|
|
|
@@ -491,24 +645,11 @@ module FlycalCli
|
|
|
491
645
|
end
|
|
492
646
|
|
|
493
647
|
def resolve_calendar_refs(calendars, refs)
|
|
494
|
-
|
|
495
|
-
resolve_calendar_ref(calendars, ref)
|
|
496
|
-
end.uniq
|
|
648
|
+
Flycal::CalendarQuery.resolve_ids(calendars, refs)
|
|
497
649
|
end
|
|
498
650
|
|
|
499
651
|
def resolve_calendar_ref(calendars, ref)
|
|
500
|
-
|
|
501
|
-
return nil if ref.empty?
|
|
502
|
-
|
|
503
|
-
exact = calendars.find { |cal| cal.id == ref }
|
|
504
|
-
return exact.id if exact
|
|
505
|
-
|
|
506
|
-
matches = calendars.select do |cal|
|
|
507
|
-
cal.id == ref ||
|
|
508
|
-
(cal.summary && cal.summary.downcase.include?(ref.downcase))
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
matches.first&.id
|
|
652
|
+
Flycal::CalendarQuery.resolve_ref(calendars, ref)
|
|
512
653
|
end
|
|
513
654
|
|
|
514
655
|
def resolve_slots_exclude_calendar_ids(service, slot_cfg, calendar_override)
|
|
@@ -516,21 +657,27 @@ module FlycalCli
|
|
|
516
657
|
calendars = service.list_calendars
|
|
517
658
|
|
|
518
659
|
refs = if configured.nil? || (configured.is_a?(Array) && configured.empty?)
|
|
519
|
-
|
|
520
|
-
|
|
660
|
+
override_refs = Flycal::CalendarQuery.refs(calendar_override)
|
|
661
|
+
if override_refs.any?
|
|
662
|
+
override_refs
|
|
663
|
+
else
|
|
664
|
+
fallback = Config.calendar_default
|
|
665
|
+
fallback ? [ fallback ] : []
|
|
666
|
+
end
|
|
521
667
|
else
|
|
522
668
|
configured
|
|
523
669
|
end
|
|
524
670
|
|
|
525
|
-
ids =
|
|
671
|
+
ids = Flycal::CalendarQuery.resolve_ids(calendars, refs)
|
|
526
672
|
return ids unless ids.empty?
|
|
527
673
|
|
|
528
|
-
|
|
529
|
-
if
|
|
530
|
-
|
|
674
|
+
fallback_refs = Flycal::CalendarQuery.refs(calendar_override)
|
|
675
|
+
fallback_refs = [ Config.calendar_default ].compact if fallback_refs.empty?
|
|
676
|
+
if fallback_refs.any?
|
|
677
|
+
Flycal::CalendarQuery.resolve_ids(calendars, fallback_refs)
|
|
531
678
|
else
|
|
532
679
|
primary = calendars.find(&:primary)
|
|
533
|
-
primary ? [primary.id] : calendars.first(1).map(&:id)
|
|
680
|
+
primary ? [ primary.id ] : calendars.first(1).map(&:id)
|
|
534
681
|
end
|
|
535
682
|
end
|
|
536
683
|
|
|
@@ -540,32 +687,6 @@ module FlycalCli
|
|
|
540
687
|
DateTimeParser.parse(value)
|
|
541
688
|
end
|
|
542
689
|
|
|
543
|
-
def resolve_slots_template(slot_cfg, requested_name)
|
|
544
|
-
templates = slot_cfg["templates"]
|
|
545
|
-
raise FlycalCli::Error, "slots.templates is missing in config.yml." unless templates.is_a?(Hash) && !templates.empty?
|
|
546
|
-
|
|
547
|
-
name = requested_name.to_s.strip
|
|
548
|
-
name = templates.keys.first.to_s if name.empty?
|
|
549
|
-
|
|
550
|
-
template = templates[name]
|
|
551
|
-
unless template.is_a?(Hash)
|
|
552
|
-
available = templates.keys.join(", ")
|
|
553
|
-
raise FlycalCli::Error, "Unknown slots template #{name.inspect}. Available: #{available}"
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
[name, template]
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
def parse_template_days(values)
|
|
560
|
-
days = Array(values).map(&:to_i)
|
|
561
|
-
raise FlycalCli::Error, "slots template days cannot be empty." if days.empty?
|
|
562
|
-
|
|
563
|
-
invalid = days.reject { |d| d.between?(1, 7) }
|
|
564
|
-
raise FlycalCli::Error, "Invalid template days #{invalid.inspect}. Use 1 (Mon) .. 7 (Sun)." unless invalid.empty?
|
|
565
|
-
|
|
566
|
-
days.uniq
|
|
567
|
-
end
|
|
568
|
-
|
|
569
690
|
def copy_slots_to_clipboard(text)
|
|
570
691
|
tool = Clipboard.copy(SlotFormatter.strip_ansi(text))
|
|
571
692
|
return unless tool
|
|
@@ -574,73 +695,27 @@ module FlycalCli
|
|
|
574
695
|
puts Locale.t("slots.copied_to_clipboard", tool: tool)
|
|
575
696
|
end
|
|
576
697
|
|
|
577
|
-
def
|
|
578
|
-
match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
|
|
579
|
-
raise FlycalCli::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
|
|
580
|
-
|
|
581
|
-
hour = match[1].to_i
|
|
582
|
-
minute = match[2].to_i
|
|
583
|
-
unless hour.between?(0, 23) && minute.between?(0, 59)
|
|
584
|
-
raise FlycalCli::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
|
|
585
|
-
end
|
|
586
|
-
|
|
587
|
-
[hour, minute]
|
|
588
|
-
end
|
|
589
|
-
|
|
590
|
-
def parse_workhours(values)
|
|
591
|
-
ranges = Array(values).map do |item|
|
|
592
|
-
start_str, end_str = item.to_s.strip.split("-", 2)
|
|
593
|
-
raise FlycalCli::Error, "Invalid hours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
|
|
594
|
-
|
|
595
|
-
sh, sm = parse_hour_minute_flexible(start_str)
|
|
596
|
-
eh, em = parse_hour_minute_flexible(end_str)
|
|
597
|
-
start_minutes = (sh * 60) + sm
|
|
598
|
-
end_minutes = (eh * 60) + em
|
|
599
|
-
raise FlycalCli::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
|
|
600
|
-
|
|
601
|
-
[sh, sm, eh, em]
|
|
602
|
-
end
|
|
603
|
-
|
|
604
|
-
raise FlycalCli::Error, "template hours cannot be empty in config.yml." if ranges.empty?
|
|
605
|
-
|
|
606
|
-
ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
|
|
607
|
-
end
|
|
608
|
-
|
|
609
|
-
def parse_hour_minute_flexible(value)
|
|
610
|
-
str = value.to_s.strip
|
|
611
|
-
if str.match?(/\A\d{1,2}\z/)
|
|
612
|
-
hour = str.to_i
|
|
613
|
-
raise FlycalCli::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
|
|
614
|
-
|
|
615
|
-
return [hour, 0]
|
|
616
|
-
end
|
|
617
|
-
|
|
618
|
-
parse_hour_minute(str)
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
def resolve_calendar_ids(service, calendar_name)
|
|
698
|
+
def resolve_calendar_ids(service, calendar_option)
|
|
622
699
|
calendar_lists = service.list_calendars
|
|
700
|
+
refs = Flycal::CalendarQuery.refs(calendar_option)
|
|
623
701
|
|
|
624
|
-
if
|
|
702
|
+
if refs.empty?
|
|
625
703
|
default_id = Config.calendar_default
|
|
626
|
-
if default_id
|
|
627
|
-
|
|
628
|
-
end
|
|
629
|
-
# Use primary calendar if available
|
|
704
|
+
return [ default_id ] if default_id
|
|
705
|
+
|
|
630
706
|
primary = calendar_lists.find(&:primary)
|
|
631
|
-
return [primary.id] if primary
|
|
632
|
-
|
|
633
|
-
|
|
707
|
+
return [ primary.id ] if primary
|
|
708
|
+
return [ calendar_lists.first.id ] if calendar_lists.any?
|
|
709
|
+
|
|
634
710
|
return []
|
|
635
711
|
end
|
|
636
712
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
cal.id == calendar_name ||
|
|
640
|
-
(cal.summary && cal.summary.downcase.include?(calendar_name.downcase))
|
|
641
|
-
end
|
|
713
|
+
ids = Flycal::CalendarQuery.resolve_ids(calendar_lists, refs)
|
|
714
|
+
return ids unless ids.empty?
|
|
642
715
|
|
|
643
|
-
|
|
716
|
+
# Fall back to treating unresolved refs as literal calendar IDs
|
|
717
|
+
refs
|
|
644
718
|
end
|
|
645
719
|
end
|
|
646
720
|
end
|
|
721
|
+
end
|
|
@@ -5,7 +5,8 @@ require "googleauth"
|
|
|
5
5
|
require "googleauth/stores/file_token_store"
|
|
6
6
|
require "fileutils"
|
|
7
7
|
|
|
8
|
-
module
|
|
8
|
+
module Flycal
|
|
9
|
+
module Cli
|
|
9
10
|
class Auth
|
|
10
11
|
SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
|
|
11
12
|
REDIRECT_PORT = 9292
|
|
@@ -42,7 +43,7 @@ module FlycalCli
|
|
|
42
43
|
|
|
43
44
|
def login
|
|
44
45
|
unless Config.credentials_exist?
|
|
45
|
-
raise
|
|
46
|
+
raise Flycal::Error,
|
|
46
47
|
"Credentials file not found. Create #{Config.credentials_path} with OAuth credentials from Google Cloud Console.\n" \
|
|
47
48
|
"Go to: https://console.cloud.google.com/apis/credentials\n" \
|
|
48
49
|
"Create 'Desktop app' credentials and download the JSON as credentials.json"
|
|
@@ -65,7 +66,7 @@ module FlycalCli
|
|
|
65
66
|
|
|
66
67
|
# Start local server to receive the auth code
|
|
67
68
|
code = start_redirect_server(authorizer)
|
|
68
|
-
raise
|
|
69
|
+
raise Flycal::Error, "Authentication cancelled or failed" if code.nil? || code.empty?
|
|
69
70
|
|
|
70
71
|
authorizer.get_and_store_credentials_from_code(
|
|
71
72
|
user_id: "flycal_user",
|
|
@@ -130,3 +131,4 @@ module FlycalCli
|
|
|
130
131
|
end
|
|
131
132
|
end
|
|
132
133
|
end
|
|
134
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module Flycal
|
|
4
|
+
module Cli
|
|
4
5
|
# Cross-platform clipboard helper.
|
|
5
6
|
# Prefers: pbcopy (macOS) → wl-copy (Wayland) → xclip (X11) → clip (Windows).
|
|
6
7
|
module Clipboard
|
|
@@ -52,3 +53,4 @@ module FlycalCli
|
|
|
52
53
|
end
|
|
53
54
|
end
|
|
54
55
|
end
|
|
56
|
+
end
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
require "yaml"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
|
-
module
|
|
6
|
+
module Flycal
|
|
7
|
+
module Cli
|
|
7
8
|
class Config
|
|
8
9
|
CONFIG_DIR = File.expand_path("~/.flycal")
|
|
9
10
|
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
10
11
|
CREDENTIALS_FILE = File.join(CONFIG_DIR, "credentials.json")
|
|
11
12
|
TOKENS_FILE = File.join(CONFIG_DIR, "tokens.yml")
|
|
12
|
-
DEFAULTS_FILE = File.expand_path("
|
|
13
|
+
DEFAULTS_FILE = File.expand_path("../../../config/defaults.yml", __dir__)
|
|
13
14
|
|
|
14
15
|
class << self
|
|
15
16
|
def load
|
|
@@ -158,7 +159,8 @@ module FlycalCli
|
|
|
158
159
|
@default_values ||= begin
|
|
159
160
|
raise "Defaults file not found: #{DEFAULTS_FILE}" unless File.exist?(DEFAULTS_FILE)
|
|
160
161
|
|
|
161
|
-
YAML.load_file(DEFAULTS_FILE) || {}
|
|
162
|
+
file = YAML.load_file(DEFAULTS_FILE) || {}
|
|
163
|
+
file.merge("slots" => Flycal::DefaultSlots.config_fragment)
|
|
162
164
|
end
|
|
163
165
|
end
|
|
164
166
|
|
|
@@ -195,3 +197,4 @@ module FlycalCli
|
|
|
195
197
|
end
|
|
196
198
|
end
|
|
197
199
|
end
|
|
200
|
+
end
|