flycal-cli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 980bab82020402b0031c0fc3d84a240626a8a26145cc8a86973390777b21db12
4
- data.tar.gz: b26c8bc3fec6e7f68a291d4e6124fc41680c15e5204a1217d6331d5c097cdf91
3
+ metadata.gz: b7333fcbb8ba227c7694b96cba4b35893e324c13e38bdc6d996e69f54cc3d64a
4
+ data.tar.gz: aab159e8f8e68368db1e45d1a799dd93386f79a0f2f9c29bb92aba776bcefa21
5
5
  SHA512:
6
- metadata.gz: bce52224114636c4a330ebcdd6740409add46791789544e42cd04380d080056d5b9dddabe8ad0a4a395448958c23631702c05864b3617d3e4ee9fe9eda616b3b
7
- data.tar.gz: fa998fbf3213fabd004a58e4d709fd05f28eee1951b9efef4c7c9ccc17fcf903af73d8e9cf7e0345569b774c8369f6422cdf4f018cd7ca1dc92e5126f0730aa8
6
+ metadata.gz: 2036031048ebcb74e1954ff1d574051945407bdef922f161f4b1ad7e8be7cd1f3dfdec6dea2efe671502a1310ba834cee44426d837a3b7dfee4fb76fbd504117
7
+ data.tar.gz: 40646b4ce7bfaa3256a35c435cdf6dcb3913aaf2a3811370219e32c510fc88d7469d652b573973016d96b9e1d5f49cca8da159bfad7d787742dc8c0bba9239d0
data/README.md CHANGED
@@ -128,8 +128,8 @@ flycal search -i 2months -d placeholder
128
128
 
129
129
  **Options:**
130
130
 
131
- - `--from` / `-f` — Start date/time. Default: midnight of current day. Format: `2025-01-01` or `2025-01-01T09:00`
132
- - `--to` / `-t` — End date/time. Default: 23:59 of the 30th day from today. Format: same as `--from`
131
+ - `--from` / `-f` — Start date/time. Default: midnight of current day. Formats: `YYYY-MM-DD`, locale forms (`DD-MM-YYYY` for `it`, `MM-DD-YYYY` for `en`, `/` or `-`), or with time `YYYY-MM-DDTHH:MM`
132
+ - `--to` / `-t` — End date/time. Default: 23:59 of the 30th day from today. Same formats as `--from`
133
133
  - `--in` / `-i` — Duration from `--from`, overrides `--to`. Format: `30days`, `48hours`, `2months`, `1year` (no space). With space use quotes: `--in "30 days"`
134
134
  - `--calendar` / `-c` — Calendar name or ID. Default: calendar set via `flycal config`
135
135
  - `--description` / `-d` — Filter events by text. Matches events where the string appears in title or description (case-insensitive, contains)
@@ -153,28 +153,56 @@ For time frames longer than 7 days, a weekly breakdown is added (week number, st
153
153
 
154
154
  ### slots
155
155
 
156
- Find free time slots in your calendar (weekdays, 9:00–18:00). Output is a simple list for copy/paste into email or other tools.
156
+ Find free time slots in your calendar. Output is a simple list for copy/paste into email or other tools.
157
157
 
158
158
  ```bash
159
- flycal slots --in "3 days" --duration 1h
160
- flycal slots --in 1week --duration 30min
161
- flycal slots --in "48 hours" --duration 1hour -c Work
159
+ flycal slots
160
+ flycal slots --duration 1h
161
+ flycal slots --from 2026-08-01 --in "2 weeks" --duration 1h
162
+ flycal slots --from 01/08/2026 --in 3days --duration 30min
163
+ flycal slots --in 1week -c Work
162
164
  ```
163
165
 
164
166
  **Options:**
165
167
 
166
- - `--duration` — Minimum slot length. Examples: `1h`, `1 hour`, `30 minutes`, `90min`
167
- - `--in` / `-i` — Search window from now. Examples: `3 days`, `1 week`, `48 hours` (use quotes when the value contains a space)
168
+ - `--duration` — Slot length. Default: `slots.defaults.default_duration` in config (`45min`). Examples: `1h`, `30 minutes`
169
+ - `--from` / `-f` — Start date/time. Default: `slots.defaults.from` in config (`now`). If a date without time is today, starts from the current time.
170
+ - `--in` / `-i` — Search window from `--from`. Default: `1 week`.
168
171
  - `--calendar` / `-c` — Calendar name or ID used as fallback when `exclude_calendars` is not configured
169
172
 
173
+ **Output:**
174
+
175
+ Always starts with a header and clickable calendar links (OSC 8), then the availability list:
176
+
177
+ ```
178
+ found 3 slots
179
+ from wed 29 July 2026 to wed 5 August 2026
180
+ with duration 45min
181
+ for calendars
182
+ Incode - Antonio
183
+ Personal
184
+ https://calendar.google.com/calendar/r/day/2026/7/29
185
+
186
+ friday 15/7
187
+ 10-12
188
+ 13-15.30
189
+ ```
190
+
191
+ If `pbcopy` is available (macOS), the slot list without the header is also copied to the clipboard.
192
+
170
193
  Slot search windows are configured in `~/.flycal/config.yml`:
171
194
 
172
195
  ```yaml
173
196
  slots:
197
+ defaults:
198
+ from: now
199
+ default_duration: 45min
200
+ free_before: 0m
201
+ free_after: 15m
174
202
  exclude_calendars:
175
203
  - user@example.com
176
204
  - user@gmail.com
177
- workhours:
205
+ hours:
178
206
  - 9:30-13:00
179
207
  - 14:00-18:30
180
208
  weekdays_only: true
@@ -183,8 +211,12 @@ locale: en
183
211
 
184
212
  Missing keys are filled from `config/defaults.yml` in the gem and saved to your `config.yml` on first read.
185
213
 
214
+ - `defaults.from` — default `--from` for slots (`now` or a date string)
215
+ - `defaults.default_duration` — default slot length when `--duration` is omitted
216
+ - `free_before` — buffer before each slot; busy intervals are extended backward so no event may fall in this window before a slot
217
+ - `free_after` — buffer after each slot; gaps must fit `duration + free_after`, but output shows slots of `duration` only
186
218
  - `exclude_calendars` lists calendars whose events block free slots; if empty or not set, events from `calendar_default` are used
187
- - `workhours` accepts one or more ranges (`H-H`, `HH:MM-HH:MM`, mixed)
219
+ - `hours` accepts one or more ranges (`H-H`, `HH:MM-HH:MM`, mixed)
188
220
  - `weekdays_only: true` limits slots to Monday-Friday
189
221
  - `weekdays_only: false` includes weekends
190
222
  - `locale` supports `en` and `it` (default is `en`)
data/config/defaults.yml CHANGED
@@ -1,7 +1,12 @@
1
1
  locale: en
2
2
  slots:
3
+ defaults:
4
+ from: now
5
+ default_duration: 45min
6
+ free_before: 0m
7
+ free_after: 15m
3
8
  exclude_calendars: []
4
- workhours:
9
+ hours:
5
10
  - 9:30-13:00
6
11
  - 14:00-18:30
7
12
  weekdays_only: true
@@ -137,12 +137,12 @@ module FlycalCli
137
137
  exit 1
138
138
  end
139
139
 
140
- time_min = options[:from].to_s.empty? ? Date.today.to_time : parse_datetime(options[:from])
140
+ time_min = options[:from].to_s.empty? ? Date.today.to_time : DateTimeParser.parse(options[:from])
141
141
  begin
142
142
  time_max = if options[:in].to_s.empty?
143
- options[:to].to_s.empty? ? (Date.today + 30).to_time + 86400 - 1 : parse_datetime(options[:to], end_of_day: true)
143
+ options[:to].to_s.empty? ? (Date.today + 30).to_time + 86400 - 1 : DateTimeParser.parse(options[:to], end_of_day: true)
144
144
  else
145
- parse_duration_in(options[:in], time_min)
145
+ DurationParser.add_to_time(options[:in], time_min)
146
146
  end
147
147
  rescue FlycalCli::Error => e
148
148
  puts "Error: #{e.message}"
@@ -178,14 +178,22 @@ module FlycalCli
178
178
  long_desc <<-LONGDESC
179
179
  List free time slots long enough for a given duration.
180
180
 
181
+ Defaults:
182
+ --from from config (default: now)
183
+ --in 1 week
184
+ --duration from config (default: 45min)
185
+
181
186
  Examples:
182
- flycal slots --in "3 days" --duration 1h
183
- flycal slots --in 1week --duration 30min -c Work
187
+ flycal slots
188
+ flycal slots --duration 1h
189
+ flycal slots --from 2026-08-01 --in "2 weeks" --duration 1h
184
190
  LONGDESC
185
- option :duration, type: :string, required: true,
186
- desc: "Minimum slot length (e.g. 1h, 30 minutes, 1 hour)"
187
- option :in, type: :string, aliases: "-i", required: true,
188
- desc: "Search window from now (e.g. 3 days, 1 week, 48 hours)"
191
+ option :duration, type: :string,
192
+ desc: "Slot length (default from config: slots.defaults.default_duration)"
193
+ option :in, type: :string, aliases: "-i", default: "1 week",
194
+ desc: "Search window from --from (default: 1 week)"
195
+ option :from, type: :string, aliases: "-f",
196
+ desc: "Start date/time (default from config: slots.defaults.from)"
189
197
  option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
190
198
  def slots
191
199
  apply_locale_override
@@ -194,12 +202,26 @@ module FlycalCli
194
202
  exit 1
195
203
  end
196
204
 
205
+ slot_cfg = Config.slots_config
206
+ defaults = slot_cfg.fetch("defaults", {})
207
+ in_value = options[:in].to_s.strip
208
+ in_value = "1 week" if in_value.empty?
209
+
210
+ duration_value = options[:duration].to_s.strip
211
+ duration_value = defaults["default_duration"].to_s.strip if duration_value.empty?
212
+ duration_value = "45min" if duration_value.empty?
213
+
214
+ from_value = options[:from].to_s.strip
215
+ from_value = defaults["from"].to_s.strip if from_value.empty?
216
+ from_value = "now" if from_value.empty?
217
+
197
218
  begin
198
- slot_duration = DurationParser.to_seconds(options[:duration])
199
- time_min = Time.now
200
- time_max = DurationParser.add_to_time(options[:in], time_min)
201
- slot_cfg = Config.slots_config
202
- workhours = parse_workhours(slot_cfg["workhours"])
219
+ slot_duration = DurationParser.to_seconds(duration_value)
220
+ free_before = DurationParser.to_seconds(slot_cfg["free_before"] || "0m")
221
+ free_after = DurationParser.to_seconds(slot_cfg["free_after"] || "0m")
222
+ time_min = parse_slots_from(from_value)
223
+ time_max = DurationParser.add_to_time(in_value, time_min)
224
+ workhours = parse_workhours(slot_cfg["hours"])
203
225
  weekdays_only = !!slot_cfg["weekdays_only"]
204
226
  rescue FlycalCli::Error => e
205
227
  puts "Error: #{e.message}"
@@ -220,10 +242,18 @@ module FlycalCli
220
242
  exit 1
221
243
  end
222
244
 
245
+ calendars = service.list_calendars
246
+ calendar_meta = exclude_calendar_ids.filter_map do |id|
247
+ cal = calendars.find { |c| c.id == id }
248
+ name = cal&.summary || id
249
+ { id: id, name: name }
250
+ end
251
+
252
+ fetch_from = time_min - free_before
223
253
  events = exclude_calendar_ids.flat_map do |calendar_id|
224
254
  service.list_events(
225
255
  calendar_id,
226
- time_min: time_min,
256
+ time_min: fetch_from,
227
257
  time_max: time_max
228
258
  )
229
259
  rescue Google::Apis::Errors::Error => e
@@ -237,11 +267,28 @@ module FlycalCli
237
267
  time_max: time_max,
238
268
  slot_duration_seconds: slot_duration,
239
269
  workhours: workhours,
240
- weekdays_only: weekdays_only
270
+ weekdays_only: weekdays_only,
271
+ free_before_seconds: free_before,
272
+ free_after_seconds: free_after
241
273
  )
242
274
 
243
- output = SlotFormatter.format_output(finder.slots_by_day)
244
- puts output.empty? ? Locale.t("slots.no_available") : output
275
+ slots_by_day = finder.slots_by_day
276
+ slot_count = slots_by_day.values.sum(&:size)
277
+ puts SlotFormatter.format_header(
278
+ from: time_min,
279
+ to: time_max,
280
+ duration: duration_value,
281
+ calendars: calendar_meta,
282
+ count: slot_count
283
+ )
284
+ puts ""
285
+ output = SlotFormatter.format_output(slots_by_day)
286
+ if output.empty?
287
+ puts Locale.t("slots.no_available")
288
+ else
289
+ puts output
290
+ copy_slots_to_clipboard(output)
291
+ end
245
292
  end
246
293
 
247
294
  desc "update", "Update flycal-cli to the latest gem version"
@@ -419,38 +466,24 @@ module FlycalCli
419
466
  end
420
467
  end
421
468
 
422
- def parse_datetime(str, end_of_day: false)
423
- return nil if str.nil? || str.empty?
469
+ def parse_slots_from(value)
470
+ return Time.now if value.to_s.strip.downcase == "now"
424
471
 
425
- if str.include?("T")
426
- Time.parse(str)
427
- else
428
- d = Date.parse(str)
429
- t = d.to_time
430
- end_of_day ? t + 86400 - 1 : t # 23:59:59 for --to when date-only
431
- end
472
+ DateTimeParser.parse(value)
432
473
  end
433
474
 
434
- def parse_duration_in(str, from_time)
435
- # Support both "30days" and "30 days" (no space avoids shell splitting)
436
- m = str.to_s.strip.match(/\A(\d+)\s*(day|days|d|hour|hours|h|month|months|m|year|years|y)\z/i)
437
- raise FlycalCli::Error, "Invalid --in format. Use: 30days, 48hours, 2months, 1year (no space, or quote: --in \"30 days\")" unless m
475
+ def copy_slots_to_clipboard(text)
476
+ return unless pbcopy_available?
438
477
 
439
- n = m[1].to_i
440
- unit = m[2].downcase
478
+ IO.popen("pbcopy", "w") { |io| io.write(text) }
479
+ puts ""
480
+ puts Locale.t("slots.copied_to_clipboard")
481
+ rescue StandardError
482
+ nil
483
+ end
441
484
 
442
- case unit
443
- when "hour", "hours", "h"
444
- from_time + n * 3600
445
- when "day", "days", "d"
446
- from_time + n * 86400
447
- when "month", "months", "m"
448
- (from_time.to_date >> n).to_time + (from_time.hour * 3600 + from_time.min * 60 + from_time.sec)
449
- when "year", "years", "y"
450
- (from_time.to_date >> (n * 12)).to_time + (from_time.hour * 3600 + from_time.min * 60 + from_time.sec)
451
- else
452
- raise FlycalCli::Error, "Invalid unit: #{unit}. Use: days, hours, months, years"
453
- end
485
+ def pbcopy_available?
486
+ system("which", "pbcopy", out: File::NULL, err: File::NULL)
454
487
  end
455
488
 
456
489
  def parse_hour_minute(value)
@@ -469,18 +502,18 @@ module FlycalCli
469
502
  def parse_workhours(values)
470
503
  ranges = Array(values).map do |item|
471
504
  start_str, end_str = item.to_s.strip.split("-", 2)
472
- raise FlycalCli::Error, "Invalid workhours item #{item.inspect}. Use format like '9-13' or '14:30-18:00'." if start_str.nil? || end_str.nil?
505
+ 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?
473
506
 
474
507
  sh, sm = parse_hour_minute_flexible(start_str)
475
508
  eh, em = parse_hour_minute_flexible(end_str)
476
509
  start_minutes = (sh * 60) + sm
477
510
  end_minutes = (eh * 60) + em
478
- raise FlycalCli::Error, "Invalid workhours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
511
+ raise FlycalCli::Error, "Invalid hours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
479
512
 
480
513
  [sh, sm, eh, em]
481
514
  end
482
515
 
483
- raise FlycalCli::Error, "slots.workhours cannot be empty in config.yml." if ranges.empty?
516
+ raise FlycalCli::Error, "slots.hours cannot be empty in config.yml." if ranges.empty?
484
517
 
485
518
  ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
486
519
  end
@@ -93,6 +93,11 @@ module FlycalCli
93
93
  changed = true
94
94
  end
95
95
 
96
+ if normalized.key?("workhours") && !normalized.key?("hours")
97
+ normalized["hours"] = normalized.delete("workhours")
98
+ changed = true
99
+ end
100
+
96
101
  return [data, false] unless changed
97
102
 
98
103
  data = data.dup
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "active_support/core_ext/time"
6
+ require "active_support/core_ext/date"
7
+
8
+ module FlycalCli
9
+ # Parses CLI date/time arguments in a locale-aware way.
10
+ #
11
+ # Always accepts ISO-like forms:
12
+ # YYYY-MM-DD, YYYY/MM/DD
13
+ # YYYY-MM-DDTHH:MM, YYYY-MM-DD HH:MM(:SS)
14
+ #
15
+ # With locale +it+: DD-MM-YYYY / DD/MM/YYYY (+ optional time)
16
+ # With locale +en+ (default): MM-DD-YYYY / MM/DD/YYYY (+ optional time)
17
+ class DateTimeParser
18
+ TIME_SUFFIX = '(?:\s+|T)(\d{1,2}):(\d{2})(?::(\d{2}))?'.freeze
19
+
20
+ class << self
21
+ def parse(str, end_of_day: false, locale: nil)
22
+ value = str.to_s.strip
23
+ raise FlycalCli::Error, invalid_message(str) if value.empty?
24
+
25
+ loc = (locale || Locale.current_locale).to_s
26
+ formats = formats_for(loc)
27
+
28
+ formats.each do |pattern, order|
29
+ match = value.match(pattern)
30
+ next unless match
31
+
32
+ year, month, day, hour, min, sec = extract_parts(match, order)
33
+ return build_time(year, month, day, hour, min, sec, end_of_day: end_of_day)
34
+ end
35
+
36
+ # Last resort: ISO8601 / Time.parse for full timestamps
37
+ begin
38
+ return Time.iso8601(value)
39
+ rescue ArgumentError
40
+ # fall through
41
+ end
42
+
43
+ begin
44
+ parsed = Time.parse(value)
45
+ return end_of_day && !time_component?(value) ? end_of_day_for(parsed.to_date) : parsed
46
+ rescue ArgumentError
47
+ raise FlycalCli::Error, invalid_message(str)
48
+ end
49
+ end
50
+
51
+ def parse_or_default(str, default: Time.now, end_of_day: false, locale: nil)
52
+ return default if str.nil? || str.to_s.strip.empty?
53
+
54
+ parse(str, end_of_day: end_of_day, locale: locale)
55
+ end
56
+
57
+ private
58
+
59
+ def formats_for(locale)
60
+ iso = [
61
+ [/\A(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})#{TIME_SUFFIX}\z/i, %i[year month day hour min sec]],
62
+ [/\A(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})\z/, %i[year month day]]
63
+ ]
64
+
65
+ local =
66
+ if locale == "it"
67
+ [
68
+ [/\A(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})#{TIME_SUFFIX}\z/i, %i[day month year hour min sec]],
69
+ [/\A(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})\z/, %i[day month year]]
70
+ ]
71
+ else
72
+ [
73
+ [/\A(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})#{TIME_SUFFIX}\z/i, %i[month day year hour min sec]],
74
+ [/\A(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})\z/, %i[month day year]]
75
+ ]
76
+ end
77
+
78
+ iso + local
79
+ end
80
+
81
+ def extract_parts(match, order)
82
+ parts = {}
83
+ order.each_with_index do |key, idx|
84
+ parts[key] = match[idx + 1]
85
+ end
86
+
87
+ [
88
+ parts[:year].to_i,
89
+ parts[:month].to_i,
90
+ parts[:day].to_i,
91
+ parts[:hour]&.to_i,
92
+ parts[:min]&.to_i,
93
+ parts[:sec]&.to_i
94
+ ]
95
+ end
96
+
97
+ def build_time(year, month, day, hour, min, sec, end_of_day:)
98
+ date = Date.new(year, month, day)
99
+
100
+ if hour.nil?
101
+ return end_of_day_for(date) if end_of_day
102
+ return [date.to_time, Time.now].max if date == Date.today
103
+
104
+ return date.to_time
105
+ end
106
+
107
+ Time.local(year, month, day, hour, min || 0, sec || 0)
108
+ rescue ArgumentError
109
+ raise FlycalCli::Error, "Invalid date: #{year}-#{month}-#{day}"
110
+ end
111
+
112
+ def end_of_day_for(date)
113
+ Time.local(date.year, date.month, date.day, 23, 59, 59)
114
+ end
115
+
116
+ def time_component?(value)
117
+ value.match?(/T|\d{1,2}:\d{2}/)
118
+ end
119
+
120
+ def invalid_message(str)
121
+ if Locale.current_locale.to_s == "it"
122
+ "Data non valida: #{str.inspect}. Usa YYYY-MM-DD, DD-MM-YYYY (anche con /) o con orario."
123
+ else
124
+ "Invalid date: #{str.inspect}. Use YYYY-MM-DD, MM-DD-YYYY (also with /), or with time."
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -10,7 +10,9 @@ module FlycalCli
10
10
  "m" => :minutes, "min" => :minutes, "mins" => :minutes, "minute" => :minutes, "minutes" => :minutes,
11
11
  "h" => :hours, "hr" => :hours, "hour" => :hours, "hours" => :hours,
12
12
  "d" => :days, "day" => :days, "days" => :days,
13
- "w" => :weeks, "week" => :weeks, "weeks" => :weeks
13
+ "w" => :weeks, "week" => :weeks, "weeks" => :weeks,
14
+ "month" => :months, "months" => :months,
15
+ "y" => :years, "year" => :years, "years" => :years
14
16
  }.freeze
15
17
 
16
18
  class << self
@@ -21,11 +23,18 @@ module FlycalCli
21
23
  raise FlycalCli::Error, invalid_message(str) unless match
22
24
 
23
25
  value = match[1].to_f
24
- unit = UNITS[match[2]]
26
+ unit_key = match[2]
27
+ unit = UNITS[unit_key]
25
28
  raise FlycalCli::Error, invalid_message(str) unless unit
26
-
27
- duration = value.public_send(unit)
28
- duration
29
+ # Ambiguous bare "m": treat as minutes (not months)
30
+ unit = :minutes if unit_key == "m"
31
+
32
+ # months/years are Integer-only ActiveSupport extensions
33
+ if %i[months years].include?(unit)
34
+ value.to_i.public_send(unit)
35
+ else
36
+ value.public_send(unit)
37
+ end
29
38
  end
30
39
 
31
40
  def to_seconds(str)
@@ -39,7 +48,7 @@ module FlycalCli
39
48
  private
40
49
 
41
50
  def invalid_message(str)
42
- "Invalid duration: #{str.inspect}. Examples: 1h, 1 hour, 30 minutes, 3 days"
51
+ "Invalid duration: #{str.inspect}. Examples: 1h, 30 minutes, 3 days, 1 week, 2 months, 1 year"
43
52
  end
44
53
  end
45
54
  end
@@ -31,6 +31,12 @@ module FlycalCli
31
31
  months[idx] || date_or_time.strftime("%B")
32
32
  end
33
33
 
34
+ def format_long_date(date_or_time)
35
+ t = date_or_time.respond_to?(:to_time) ? date_or_time.to_time : date_or_time
36
+ d = t.to_date
37
+ "#{day_abbr(d).downcase} #{d.day} #{month_name(d)} #{d.year}"
38
+ end
39
+
34
40
  def current_locale
35
41
  Thread.current[:flycal_locale_override] || Config.locale
36
42
  rescue StandardError
@@ -3,6 +3,7 @@
3
3
  module FlycalCli
4
4
  class SlotFinder
5
5
  DEFAULT_WORKHOURS = [[9, 0, 18, 0]].freeze
6
+ STEP_SECONDS = 900
6
7
 
7
8
  def initialize(
8
9
  events:,
@@ -10,7 +11,9 @@ module FlycalCli
10
11
  time_max:,
11
12
  slot_duration_seconds:,
12
13
  workhours: DEFAULT_WORKHOURS,
13
- weekdays_only: true
14
+ weekdays_only: true,
15
+ free_before_seconds: 0,
16
+ free_after_seconds: 0
14
17
  )
15
18
  @events = events
16
19
  @time_min = time_min
@@ -18,6 +21,8 @@ module FlycalCli
18
21
  @slot_duration_seconds = slot_duration_seconds
19
22
  @workhours = workhours
20
23
  @weekdays_only = weekdays_only
24
+ @free_before_seconds = free_before_seconds
25
+ @free_after_seconds = free_after_seconds
21
26
  end
22
27
 
23
28
  def slots_by_day
@@ -30,7 +35,9 @@ module FlycalCli
30
35
 
31
36
  busy = busy_intervals_for(date, day_start, day_end)
32
37
  gaps = free_gaps(day_start, day_end, busy)
33
- slots.concat(gaps.filter_map { |start_at, end_at| slot_if_fits(start_at, end_at) })
38
+ gaps.each do |gap_start, gap_end|
39
+ slots.concat(slots_in_gap(gap_start, gap_end))
40
+ end
34
41
  end
35
42
  result[date] = slots unless slots.empty?
36
43
  end
@@ -74,7 +81,14 @@ module FlycalCli
74
81
  intervals = @events.filter_map do |event|
75
82
  interval_for_event(event, date, day_start, day_end)
76
83
  end
77
- merge_intervals(intervals)
84
+ merge_intervals(intervals.map { |start_at, end_at| pad_busy_interval(start_at, end_at) })
85
+ end
86
+
87
+ def pad_busy_interval(start_at, end_at)
88
+ [
89
+ start_at - @free_before_seconds,
90
+ end_at + @free_after_seconds
91
+ ]
78
92
  end
79
93
 
80
94
  def interval_for_event(event, date, day_start, day_end)
@@ -127,26 +141,27 @@ module FlycalCli
127
141
  gaps
128
142
  end
129
143
 
130
- def slot_if_fits(start_at, end_at)
144
+ def slots_in_gap(start_at, end_at)
131
145
  rounded_start = round_up_15(start_at)
132
146
  rounded_end = round_down_15(end_at)
133
- return nil if rounded_start >= rounded_end
134
- return nil if (rounded_end - rounded_start) < @slot_duration_seconds
147
+ return [] if rounded_start >= rounded_end
148
+ return [] if (rounded_end - rounded_start) < @slot_duration_seconds
135
149
 
136
- [rounded_start, rounded_end]
150
+ # One continuous free range (not sliding windows of fixed duration)
151
+ [[rounded_start, rounded_end]]
137
152
  end
138
153
 
139
154
  def round_up_15(time)
140
155
  sec = time.to_i
141
- remainder = sec % 900
156
+ remainder = sec % STEP_SECONDS
142
157
  return time if remainder.zero?
143
158
 
144
- Time.at(sec + (900 - remainder))
159
+ Time.at(sec + (STEP_SECONDS - remainder))
145
160
  end
146
161
 
147
162
  def round_down_15(time)
148
163
  sec = time.to_i
149
- Time.at(sec - (sec % 900))
164
+ Time.at(sec - (sec % STEP_SECONDS))
150
165
  end
151
166
 
152
167
  def to_time(value)
@@ -3,6 +3,22 @@
3
3
  module FlycalCli
4
4
  class SlotFormatter
5
5
  class << self
6
+ def format_header(from:, to:, duration:, calendars:, count:)
7
+ lines = []
8
+ lines << Locale.t(
9
+ "slots.header",
10
+ count: count,
11
+ from: underline(Locale.format_long_date(from)),
12
+ to: underline(Locale.format_long_date(to)),
13
+ duration: underline(duration)
14
+ )
15
+ calendars.each do |cal|
16
+ lines << cal[:name].to_s
17
+ end
18
+ lines << google_calendar_day_url(from)
19
+ lines.join("\n")
20
+ end
21
+
6
22
  def format_output(slots_by_day)
7
23
  lines = []
8
24
 
@@ -19,6 +35,15 @@ module FlycalCli
19
35
 
20
36
  private
21
37
 
38
+ def underline(str)
39
+ "\e[4m#{str}\e[0m"
40
+ end
41
+
42
+ def google_calendar_day_url(from)
43
+ t = from.respond_to?(:to_time) ? from.to_time : from
44
+ "https://calendar.google.com/calendar/r/day/#{t.year}/#{t.month}/#{t.day}"
45
+ end
46
+
22
47
  def day_header(date)
23
48
  "#{Locale.day_name(date)} #{date.day}/#{date.month}"
24
49
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlycalCli
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.1'
5
5
  end
data/lib/flycal_cli.rb CHANGED
@@ -5,6 +5,7 @@ require "flycal_cli/config"
5
5
  require "flycal_cli/locale"
6
6
  require "flycal_cli/auth"
7
7
  require "flycal_cli/duration_parser"
8
+ require "flycal_cli/date_time_parser"
8
9
  require "flycal_cli/calendar_service"
9
10
  require "flycal_cli/slot_finder"
10
11
  require "flycal_cli/slot_formatter"
data/locales/en.json CHANGED
@@ -30,7 +30,9 @@
30
30
  },
31
31
  "slots": {
32
32
  "no_available": "No available slots found.",
33
- "no_calendar": "No calendar found. Run 'flycal config' to set a default."
33
+ "no_calendar": "No calendar found. Run 'flycal config' to set a default.",
34
+ "header": "found %{count} slots\nfrom %{from} to %{to}\nwith duration %{duration}\nfor calendars",
35
+ "copied_to_clipboard": "✓ Slot list copied to clipboard (pbcopy)"
34
36
  },
35
37
  "errors": {
36
38
  "not_connected": "You are not connected. Run 'flycal login' first.",
data/locales/it.json CHANGED
@@ -30,7 +30,9 @@
30
30
  },
31
31
  "slots": {
32
32
  "no_available": "Nessuno slot disponibile.",
33
- "no_calendar": "Nessun calendario trovato. Esegui 'flycal config' per impostare il predefinito."
33
+ "no_calendar": "Nessun calendario trovato. Esegui 'flycal config' per impostare il predefinito.",
34
+ "header": "trovati %{count} slot\nda %{from} a %{to}\ncon durata %{duration}\nper i calendari",
35
+ "copied_to_clipboard": "✓ Lista slot copiata negli appunti (pbcopy)"
34
36
  },
35
37
  "errors": {
36
38
  "not_connected": "Non sei connesso. Esegui prima 'flycal login'.",
data/mcp/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # flycal MCP catalog
2
+
3
+ This directory contains a machine-readable description of **flycal-cli** commands for MCP (Model Context Protocol) servers.
4
+
5
+ ## File
6
+
7
+ - [`tools.json`](./tools.json) — tools catalog with MCP-style `inputSchema`, CLI argv templates, auth/interactivity flags, and config keys.
8
+
9
+ ## How an MCP server can use it
10
+
11
+ 1. Load `tools.json` at startup.
12
+ 2. Register each entry in `tools` with your MCP SDK (`server.tool(...)`), using `name`, `description`, and `inputSchema`.
13
+ 3. On tool call, build the argv:
14
+ - static tools → `command.argv`
15
+ - parameterized tools → start from `command.argv_template`, then append pairs from `command.option_map` for each provided argument
16
+ - if `locale` is set → append `--locale <value>`
17
+ 4. Run `flycal` as a subprocess and return stdout as text content.
18
+ 5. Skip or gate tools with `"interactive": true` (`flycal_login`, `flycal_config`) unless a TTY is available.
19
+
20
+ ## Suggested tools to expose
21
+
22
+ | MCP tool | CLI | Notes |
23
+ |---|---|---|
24
+ | `flycal_search` | `flycal search ...` | Primary read tool |
25
+ | `flycal_slots` | `flycal slots --in ... --duration ...` | Free slots |
26
+ | `flycal_calendars` | `flycal calendars` | List name + id |
27
+ | `flycal_version` | `flycal version` | Health / version check |
28
+
29
+ Interactive / side-effect tools (`login`, `logout`, `config`, `update`) are documented in the catalog but are usually better left to the user terminal.
data/mcp/tools.json ADDED
@@ -0,0 +1,318 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "name": "flycal-cli",
4
+ "title": "flycal CLI MCP tools catalog",
5
+ "description": "Machine-readable catalog of flycal-cli commands for MCP servers. Each tool maps to a CLI invocation via the `command` template. Non-interactive tools are safe to run from an MCP host; interactive tools require a TTY and should be surfaced as guidance only or refused unless the client can attach a terminal.",
6
+ "version": "0.5.0",
7
+ "binary": "flycal",
8
+ "homepage": "https://github.com/magnum/flycal-cli",
9
+ "locale": {
10
+ "global_option": "--locale",
11
+ "supported": ["en", "it"],
12
+ "default": "en",
13
+ "config_key": "locale"
14
+ },
15
+ "config": {
16
+ "path": "~/.flycal/config.yml",
17
+ "credentials_path": "~/.flycal/credentials.json",
18
+ "keys": {
19
+ "calendar_default": {
20
+ "type": "string",
21
+ "description": "Default Google Calendar ID used by search and as slots fallback"
22
+ },
23
+ "locale": {
24
+ "type": "string",
25
+ "enum": ["en", "it"],
26
+ "description": "Default CLI locale"
27
+ },
28
+ "slots.hours": {
29
+ "type": "array",
30
+ "items": { "type": "string" },
31
+ "description": "Slot search hour ranges, e.g. [\"9:30-13:00\", \"14:00-18:30\"]"
32
+ },
33
+ "slots.defaults.from": {
34
+ "type": "string",
35
+ "description": "Default --from for slots (e.g. now or a date string)"
36
+ },
37
+ "slots.defaults.default_duration": {
38
+ "type": "string",
39
+ "description": "Default slot duration when --duration is omitted (e.g. 45min)"
40
+ },
41
+ "slots.free_before": {
42
+ "type": "string",
43
+ "description": "Buffer before each slot; events block this window before slot start"
44
+ },
45
+ "slots.free_after": {
46
+ "type": "string",
47
+ "description": "Buffer after each slot; gaps must fit duration + free_after"
48
+ },
49
+ "slots.weekdays_only": {
50
+ "type": "boolean",
51
+ "description": "When true, slots are searched Mon–Fri only"
52
+ },
53
+ "slots.exclude_calendars": {
54
+ "type": "array",
55
+ "items": { "type": "string" },
56
+ "description": "Calendar IDs whose events block free slots. If empty, calendar_default is used."
57
+ }
58
+ }
59
+ },
60
+ "notes": [
61
+ "flycal is read-only against Google Calendar (no create/update/delete of events).",
62
+ "Auth uses OAuth desktop flow; tokens live under ~/.flycal/.",
63
+ "Prefer quoting duration values that contain spaces (e.g. --in \"3 days\").",
64
+ "Duration formats without spaces also work (e.g. 30days, 48hours, 1h, 30min)."
65
+ ],
66
+ "tools": [
67
+ {
68
+ "name": "flycal_version",
69
+ "title": "Show flycal version",
70
+ "description": "Print the installed flycal-cli version.",
71
+ "interactive": false,
72
+ "requires_auth": false,
73
+ "command": {
74
+ "argv": ["flycal", "version"],
75
+ "alternatives": [["flycal", "--version"], ["flycal", "-v"]]
76
+ },
77
+ "inputSchema": {
78
+ "type": "object",
79
+ "additionalProperties": false,
80
+ "properties": {
81
+ "locale": {
82
+ "type": "string",
83
+ "enum": ["en", "it"],
84
+ "description": "Optional locale override for this invocation"
85
+ }
86
+ }
87
+ }
88
+ },
89
+ {
90
+ "name": "flycal_update",
91
+ "title": "Update flycal gem",
92
+ "description": "Run gem update flycal-cli to install the latest published version.",
93
+ "interactive": false,
94
+ "requires_auth": false,
95
+ "side_effects": ["updates_local_gem"],
96
+ "command": {
97
+ "argv": ["flycal", "update"]
98
+ },
99
+ "inputSchema": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "locale": {
104
+ "type": "string",
105
+ "enum": ["en", "it"]
106
+ }
107
+ }
108
+ }
109
+ },
110
+ {
111
+ "name": "flycal_login",
112
+ "title": "Connect Google account",
113
+ "description": "Start OAuth browser login for Google Calendar. Requires ~/.flycal/credentials.json and a browser. Interactive.",
114
+ "interactive": true,
115
+ "requires_auth": false,
116
+ "side_effects": ["writes_tokens"],
117
+ "command": {
118
+ "argv": ["flycal", "login"]
119
+ },
120
+ "inputSchema": {
121
+ "type": "object",
122
+ "additionalProperties": false,
123
+ "properties": {
124
+ "locale": {
125
+ "type": "string",
126
+ "enum": ["en", "it"]
127
+ }
128
+ }
129
+ }
130
+ },
131
+ {
132
+ "name": "flycal_logout",
133
+ "title": "Disconnect Google account",
134
+ "description": "Remove stored Google OAuth tokens and disconnect the account.",
135
+ "interactive": false,
136
+ "requires_auth": false,
137
+ "side_effects": ["deletes_tokens"],
138
+ "command": {
139
+ "argv": ["flycal", "logout"]
140
+ },
141
+ "inputSchema": {
142
+ "type": "object",
143
+ "additionalProperties": false,
144
+ "properties": {
145
+ "locale": {
146
+ "type": "string",
147
+ "enum": ["en", "it"]
148
+ }
149
+ }
150
+ }
151
+ },
152
+ {
153
+ "name": "flycal_calendars",
154
+ "title": "List calendars",
155
+ "description": "List available Google calendars as lines of \"name id\". Does not change configuration.",
156
+ "interactive": false,
157
+ "requires_auth": true,
158
+ "command": {
159
+ "argv": ["flycal", "calendars"]
160
+ },
161
+ "output": {
162
+ "format": "text/lines",
163
+ "example": "Work molinari@incode.it\nPersonal user@gmail.com"
164
+ },
165
+ "inputSchema": {
166
+ "type": "object",
167
+ "additionalProperties": false,
168
+ "properties": {
169
+ "locale": {
170
+ "type": "string",
171
+ "enum": ["en", "it"]
172
+ }
173
+ }
174
+ }
175
+ },
176
+ {
177
+ "name": "flycal_config",
178
+ "title": "Configure flycal (interactive)",
179
+ "description": "Interactive configuration menu. Options: calendar_default (single select), exclude_calendars (multi-select, min 1), edit config (opens ~/.flycal/config.yml in $EDITOR or vi). Ctrl+C prints \"config cancelled...\". Prefer editing config.yml or using dedicated non-interactive flows from MCP hosts.",
180
+ "interactive": true,
181
+ "requires_auth": true,
182
+ "side_effects": ["writes_config"],
183
+ "command": {
184
+ "argv": ["flycal", "config"]
185
+ },
186
+ "menu_options": [
187
+ "calendar_default",
188
+ "exclude_calendars",
189
+ "edit config"
190
+ ],
191
+ "inputSchema": {
192
+ "type": "object",
193
+ "additionalProperties": false,
194
+ "properties": {
195
+ "locale": {
196
+ "type": "string",
197
+ "enum": ["en", "it"]
198
+ }
199
+ }
200
+ }
201
+ },
202
+ {
203
+ "name": "flycal_search",
204
+ "title": "Search calendar events",
205
+ "description": "Search Google Calendar events. Prints one line per event (Calendar | Start | End | Title) plus a summary with total occupied time in hours/minutes and 8-hour working days. For ranges >7 days adds a weekly breakdown; for >30 days a monthly breakdown. Contains-filter on title/description when description is set. Read-only.",
206
+ "interactive": false,
207
+ "requires_auth": true,
208
+ "command": {
209
+ "argv_template": ["flycal", "search"],
210
+ "option_map": {
211
+ "calendar": ["--calendar", "{value}"],
212
+ "from": ["--from", "{value}"],
213
+ "to": ["--to", "{value}"],
214
+ "in": ["--in", "{value}"],
215
+ "description": ["--description", "{value}"],
216
+ "locale": ["--locale", "{value}"]
217
+ },
218
+ "examples": [
219
+ ["flycal", "search"],
220
+ ["flycal", "search", "--in", "30days", "--description", "placeholder"],
221
+ ["flycal", "search", "-f", "2025-03-01", "-t", "2025-03-31", "-c", "Work"],
222
+ ["flycal", "search", "--in", "2months", "-d", "placeholder", "--locale", "it"]
223
+ ]
224
+ },
225
+ "inputSchema": {
226
+ "type": "object",
227
+ "additionalProperties": false,
228
+ "properties": {
229
+ "calendar": {
230
+ "type": "string",
231
+ "description": "Calendar name or ID. Default: calendar_default from config."
232
+ },
233
+ "from": {
234
+ "type": "string",
235
+ "description": "Start datetime. Default: today midnight. Formats: YYYY-MM-DD or YYYY-MM-DDTHH:MM"
236
+ },
237
+ "to": {
238
+ "type": "string",
239
+ "description": "End datetime. Default: 23:59 of day 30 from today. Ignored when `in` is set."
240
+ },
241
+ "in": {
242
+ "type": "string",
243
+ "description": "Duration from `from` (or now/from default), overrides `to`. Prefer no spaces or quote values: 30days, 48hours, 2months, 1year, or \"30 days\"."
244
+ },
245
+ "description": {
246
+ "type": "string",
247
+ "description": "Case-insensitive contains filter on event summary or description."
248
+ },
249
+ "locale": {
250
+ "type": "string",
251
+ "enum": ["en", "it"]
252
+ }
253
+ }
254
+ }
255
+ },
256
+ {
257
+ "name": "flycal_slots",
258
+ "title": "Find free time slots",
259
+ "description": "Find free slots within configured hours, subtracting busy events from slots.exclude_calendars (or calendar_default if that list is empty). Starts from --from (default: config slots.defaults.from or now) for a window of --in (default: 1 week). Duration defaults to slots.defaults.default_duration (45min). Respects free_before and free_after buffers from config. Output begins with a multi-line header, calendar names, a Google Calendar day link, then continuous free ranges (each at least as long as duration). Read-only.",
260
+ "interactive": false,
261
+ "requires_auth": true,
262
+ "command": {
263
+ "argv_template": ["flycal", "slots"],
264
+ "required_options": [],
265
+ "option_map": {
266
+ "duration": ["--duration", "{value}"],
267
+ "from": ["--from", "{value}"],
268
+ "in": ["--in", "{value}"],
269
+ "calendar": ["--calendar", "{value}"],
270
+ "locale": ["--locale", "{value}"]
271
+ },
272
+ "examples": [
273
+ ["flycal", "slots"],
274
+ ["flycal", "slots", "--duration", "1h"],
275
+ ["flycal", "slots", "--from", "2026-08-01", "--in", "2 weeks", "--duration", "1h"]
276
+ ]
277
+ },
278
+ "output": {
279
+ "format": "text/plain",
280
+ "example": "found 3 slots\nfrom wed 29 July 2026 to wed 5 August 2026\nwith duration 45min\nfor calendars\nWork\nhttps://calendar.google.com/calendar/r/day/2026/7/29\n\nfriday 15/7\n10-12\n13-15.30"
281
+ },
282
+ "inputSchema": {
283
+ "type": "object",
284
+ "additionalProperties": false,
285
+ "required": [],
286
+ "properties": {
287
+ "duration": {
288
+ "type": "string",
289
+ "description": "Slot length. Default from config slots.defaults.default_duration (45min)."
290
+ },
291
+ "from": {
292
+ "type": "string",
293
+ "description": "Start date/time. Default: now. YYYY-MM-DD always; with locale it also DD-MM-YYYY; with locale en also MM-DD-YYYY. Separators - or /. Optional time."
294
+ },
295
+ "in": {
296
+ "type": "string",
297
+ "description": "Search window from --from. Default: 1 week. Examples: 3 days, 1 week, 48 hours (quote if spaces)."
298
+ },
299
+ "calendar": {
300
+ "type": "string",
301
+ "description": "Fallback calendar name/ID when exclude_calendars is not configured."
302
+ },
303
+ "locale": {
304
+ "type": "string",
305
+ "enum": ["en", "it"]
306
+ }
307
+ }
308
+ }
309
+ }
310
+ ],
311
+ "mcp_implementation_hints": {
312
+ "execution": "Spawn the flycal binary with argv from command.argv or build argv from option_map. Capture stdout/stderr and exit code. Return stdout as MCP text content.",
313
+ "interactive_tools": "Do not auto-run tools with interactive=true unless the host provides a TTY. Instead tell the user to run the command in a terminal.",
314
+ "auth_errors": "If stdout/stderr indicates the user is not connected, suggest flycal login.",
315
+ "locale_arg": "If input.locale is set, append --locale <value> to argv for any tool.",
316
+ "tool_filter": "Expose at least flycal_search, flycal_slots, flycal_calendars, and flycal_version for agent use."
317
+ }
318
+ }
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: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - flycal-cli
@@ -139,6 +139,7 @@ files:
139
139
  - lib/flycal_cli/calendar_service.rb
140
140
  - lib/flycal_cli/cli.rb
141
141
  - lib/flycal_cli/config.rb
142
+ - lib/flycal_cli/date_time_parser.rb
142
143
  - lib/flycal_cli/duration_parser.rb
143
144
  - lib/flycal_cli/locale.rb
144
145
  - lib/flycal_cli/slot_finder.rb
@@ -146,6 +147,8 @@ files:
146
147
  - lib/flycal_cli/version.rb
147
148
  - locales/en.json
148
149
  - locales/it.json
150
+ - mcp/README.md
151
+ - mcp/tools.json
149
152
  homepage: https://github.com/magnum/flycal-cli
150
153
  licenses:
151
154
  - MIT