flycal-cli 0.3.2 → 0.4.0

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: 6bf3465bededbb5ea79788ae636545efa83710701dfad008317dff95db661cfb
4
- data.tar.gz: 9ba431d04731b33bff46b4e6abf19e36f167d9750faf54eb547bf92696664d4c
3
+ metadata.gz: 37255bed0559da07bd8fd9be434bc34c2c0104013081108a09039f2bb1cafab0
4
+ data.tar.gz: 9ac906db7b7a28fb6d45f1857064e1a6d091bade7a6b2e41b66e5f1a305ae164
5
5
  SHA512:
6
- metadata.gz: 2d705bd0ae05ace6e411d6d798d27c80b474d06c5ebc1a9a89ed5adec53c61d2cb928c58d63429b27708ceb3dfef19d439fa96433317e55a983835aa66b41dcc
7
- data.tar.gz: ba81a344d0e36865120a84174ade7532bac11bcf86e767e111dcb128196f34fc99adeae2623496dcb1e8e3bc26061d8e6d087ebfe7e886f806f53896b080df49
6
+ metadata.gz: a0274d01052be7d96e8e54442273c4657172623664ed5f4a163b5a465c2913878a520fc5225daf26a56111ad8de3602f528f2033f48dffc49d619fcff52d97a2
7
+ data.tar.gz: b8d596f0b8648843121bad848e80bc04d1b9c33f87eaf0c784b8ef86a50af2abed08b17e37f70dd87147bddf2b9de451bce56dd3f97bb8e004152cde0bbbe298
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # flycal-cli
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/flycal-cli.svg)](https://badge.fury.io/rb/flycal-cli)
4
+
3
5
  A command-line tool to access and search Google Calendar events. Connect your Google account, choose a default calendar, and search events with flexible date ranges and text filters.
4
6
 
5
7
  ## Requirements
@@ -37,6 +39,13 @@ Before using flycal, you need OAuth credentials from Google Cloud Console:
37
39
 
38
40
  ## Commands
39
41
 
42
+ All commands support a per-invocation locale override:
43
+
44
+ ```bash
45
+ flycal search --locale it --in 7days
46
+ flycal slots --locale en --in "3 days" --duration 1h
47
+ ```
48
+
40
49
  ### login
41
50
 
42
51
  Connect to your Google account. Opens a browser for OAuth authentication when not yet connected.
@@ -78,13 +87,11 @@ flycal search -i 2months -d placeholder
78
87
 
79
88
  **Options:**
80
89
 
81
- | Option | Short | Description |
82
- |--------|-------|-------------|
83
- | `--from` | `-f` | Start date/time. Default: midnight of current day. Format: `2025-01-01` or `2025-01-01T09:00` |
84
- | `--to` | `-t` | End date/time. Default: 23:59 of the 30th day from today. Format: same as `--from` |
85
- | `--in` | `-i` | Duration from `--from`, overrides `--to`. Format: `30days`, `48hours`, `2months`, `1year` (no space between number and unit). With space, use quotes: `--in "30 days"` |
86
- | `--calendar` | `-c` | Calendar name or ID. Default: calendar set via `flycal calendars` |
87
- | `--description` | `-d` | Filter events by text. Matches events where the string appears in the title or description (case-insensitive, contains) |
90
+ - `--from` / `-f` Start date/time. Default: midnight of current day. Format: `2025-01-01` or `2025-01-01T09:00`
91
+ - `--to` / `-t` — End date/time. Default: 23:59 of the 30th day from today. Format: same as `--from`
92
+ - `--in` / `-i` Duration from `--from`, overrides `--to`. Format: `30days`, `48hours`, `2months`, `1year` (no space). With space use quotes: `--in "30 days"`
93
+ - `--calendar` / `-c` Calendar name or ID. Default: calendar set via `flycal calendars`
94
+ - `--description` / `-d` Filter events by text. Matches events where the string appears in title or description (case-insensitive, contains)
88
95
 
89
96
  **Time range behavior:**
90
97
 
@@ -103,12 +110,58 @@ The summary shows:
103
110
 
104
111
  For time frames longer than 7 days, a weekly breakdown is added (week number, start/end dates, hours, working days per week). For time frames longer than 30 days, a monthly breakdown is shown instead (month number, month name, hours, working days per month).
105
112
 
113
+ ### slots
114
+
115
+ 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.
116
+
117
+ ```bash
118
+ flycal slots --in "3 days" --duration 1h
119
+ flycal slots --in 1week --duration 30min
120
+ flycal slots --in "48 hours" --duration 1hour -c Work
121
+ ```
122
+
123
+ **Options:**
124
+
125
+ - `--duration` — Minimum slot length. Examples: `1h`, `1 hour`, `30 minutes`, `90min`
126
+ - `--in` / `-i` — Search window from now. Examples: `3 days`, `1 week`, `48 hours` (use quotes when the value contains a space)
127
+ - `--calendar` / `-c` — Calendar name or ID (optional; uses default calendar)
128
+
129
+ Slot search windows are configured in `~/.flycal/config.yml`:
130
+
131
+ ```yaml
132
+ slots:
133
+ workhours:
134
+ - 9:30-13:00
135
+ - 14:00-18:30
136
+ weekdays-only: true
137
+ locale: en
138
+ ```
139
+
140
+ Missing keys are filled from `config/defaults.yml` in the gem and saved to your `config.yml` on first read.
141
+
142
+ - `workhours` accepts one or more ranges (`H-H`, `HH:MM-HH:MM`, mixed)
143
+ - `weekdays-only: true` limits slots to Monday-Friday
144
+ - `weekdays-only: false` includes weekends
145
+ - `locale` supports `en` and `it` (default is `en`)
146
+
147
+ **Output example:**
148
+
149
+ ```
150
+ friday 15/7
151
+ 10-12
152
+ 13-15.30
153
+
154
+ monday 21/7
155
+ 12-13
156
+ 14-15
157
+ ```
158
+
106
159
  ## Configuration
107
160
 
108
161
  Data is stored in `~/.flycal/`:
109
162
 
110
163
  | File | Purpose |
111
- |------|---------|
164
+ |:-----|---------|
112
165
  | `config.yml` | Default calendar ID and other settings |
113
166
  | `credentials.json` | OAuth credentials (created manually from Google Cloud Console) |
114
167
  | `tokens.yml` | Access tokens (managed automatically) |
@@ -0,0 +1,6 @@
1
+ locale: en
2
+ slots:
3
+ workhours:
4
+ - 9:30-13:00
5
+ - 14:00-18:30
6
+ weekdays-only: true
@@ -12,6 +12,7 @@ require "tty-spinner"
12
12
  module FlycalCli
13
13
  class Cli < Thor
14
14
  package_name "flycal"
15
+ class_option :locale, type: :string, desc: "Override locale for this command (e.g. en, it)"
15
16
 
16
17
  def self.exit_on_failure?
17
18
  true
@@ -19,6 +20,7 @@ module FlycalCli
19
20
 
20
21
  desc "login", "Connect to your Google account"
21
22
  def login
23
+ apply_locale_override
22
24
  if Auth.logged_in?
23
25
  puts "✓ You are already connected to your Google account."
24
26
  puts "\nRun 'flycal calendars' to set the default calendar."
@@ -48,6 +50,7 @@ module FlycalCli
48
50
 
49
51
  desc "logout", "Disconnect from Google account"
50
52
  def logout
53
+ apply_locale_override
51
54
  unless Auth.logged_in?
52
55
  puts "You are not connected to any Google account."
53
56
  return
@@ -59,6 +62,7 @@ module FlycalCli
59
62
 
60
63
  desc "calendars", "List available calendars and set the default one"
61
64
  def calendars
65
+ apply_locale_override
62
66
  unless Auth.logged_in?
63
67
  puts "You are not connected. Run 'flycal login' first."
64
68
  exit 1
@@ -125,6 +129,7 @@ module FlycalCli
125
129
  option :in, type: :string, aliases: "-i", desc: "Duration: 30days, 48hours, 2months, 1year (overrides --to)"
126
130
  option :description, type: :string, aliases: "-d", desc: "Filter by text in event"
127
131
  def search
132
+ apply_locale_override
128
133
  unless Auth.logged_in?
129
134
  puts "You are not connected. Run 'flycal login' first."
130
135
  exit 1
@@ -167,6 +172,71 @@ module FlycalCli
167
172
  print_search_summary(events, time_min: time_min, time_max: time_max)
168
173
  end
169
174
 
175
+ desc "slots", "Find available time slots in your calendar"
176
+ long_desc <<-LONGDESC
177
+ List free time slots long enough for a given duration.
178
+
179
+ Examples:
180
+ flycal slots --in "3 days" --duration 1h
181
+ flycal slots --in 1week --duration 30min -c Work
182
+ LONGDESC
183
+ option :duration, type: :string, required: true,
184
+ desc: "Minimum slot length (e.g. 1h, 30 minutes, 1 hour)"
185
+ option :in, type: :string, aliases: "-i", required: true,
186
+ desc: "Search window from now (e.g. 3 days, 1 week, 48 hours)"
187
+ option :calendar, type: :string, aliases: "-c", desc: "Calendar name or ID"
188
+ def slots
189
+ apply_locale_override
190
+ unless Auth.logged_in?
191
+ puts Locale.t("errors.not_connected")
192
+ exit 1
193
+ end
194
+
195
+ begin
196
+ slot_duration = DurationParser.to_seconds(options[:duration])
197
+ time_min = Time.now
198
+ time_max = DurationParser.add_to_time(options[:in], time_min)
199
+ slot_cfg = Config.slots_config
200
+ workhours = parse_workhours(slot_cfg["workhours"])
201
+ weekdays_only = !!slot_cfg["weekdays-only"]
202
+ rescue FlycalCli::Error => e
203
+ puts "Error: #{e.message}"
204
+ exit 1
205
+ end
206
+
207
+ if time_min >= time_max
208
+ puts Locale.t("errors.duration_positive")
209
+ exit 1
210
+ end
211
+
212
+ creds = Auth.credentials
213
+ service = CalendarService.new(creds)
214
+
215
+ calendar_id = resolve_single_calendar_id(service, options[:calendar])
216
+ if calendar_id.nil?
217
+ puts Locale.t("slots.no_calendar")
218
+ exit 1
219
+ end
220
+
221
+ events = service.list_events(
222
+ calendar_id,
223
+ time_min: time_min,
224
+ time_max: time_max
225
+ )
226
+
227
+ finder = SlotFinder.new(
228
+ events: events,
229
+ time_min: time_min,
230
+ time_max: time_max,
231
+ slot_duration_seconds: slot_duration,
232
+ workhours: workhours,
233
+ weekdays_only: weekdays_only
234
+ )
235
+
236
+ output = SlotFormatter.format_output(finder.slots_by_day)
237
+ puts output.empty? ? Locale.t("slots.no_available") : output
238
+ end
239
+
170
240
  default_task :help
171
241
 
172
242
  private
@@ -177,6 +247,10 @@ module FlycalCli
177
247
  "\e[1m#{str}\e[0m"
178
248
  end
179
249
 
250
+ def apply_locale_override
251
+ Locale.override!(options[:locale])
252
+ end
253
+
180
254
  def parse_datetime(str, end_of_day: false)
181
255
  return nil if str.nil? || str.empty?
182
256
 
@@ -211,6 +285,50 @@ module FlycalCli
211
285
  end
212
286
  end
213
287
 
288
+ def parse_hour_minute(value)
289
+ match = value.to_s.strip.match(/\A(\d{1,2}):(\d{2})\z/)
290
+ raise FlycalCli::Error, "Invalid time format #{value.inspect}. Use HH:MM (e.g. 9:00, 18:30)." unless match
291
+
292
+ hour = match[1].to_i
293
+ minute = match[2].to_i
294
+ unless hour.between?(0, 23) && minute.between?(0, 59)
295
+ raise FlycalCli::Error, "Invalid time #{value.inspect}. Hour must be 0-23 and minute 0-59."
296
+ end
297
+
298
+ [hour, minute]
299
+ end
300
+
301
+ def parse_workhours(values)
302
+ ranges = Array(values).map do |item|
303
+ start_str, end_str = item.to_s.strip.split("-", 2)
304
+ 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?
305
+
306
+ sh, sm = parse_hour_minute_flexible(start_str)
307
+ eh, em = parse_hour_minute_flexible(end_str)
308
+ start_minutes = (sh * 60) + sm
309
+ end_minutes = (eh * 60) + em
310
+ raise FlycalCli::Error, "Invalid workhours range #{item.inspect}: end must be after start." if end_minutes <= start_minutes
311
+
312
+ [sh, sm, eh, em]
313
+ end
314
+
315
+ raise FlycalCli::Error, "slots.workhours cannot be empty in config.yml." if ranges.empty?
316
+
317
+ ranges.sort_by { |sh, sm, _eh, _em| (sh * 60) + sm }
318
+ end
319
+
320
+ def parse_hour_minute_flexible(value)
321
+ str = value.to_s.strip
322
+ if str.match?(/\A\d{1,2}\z/)
323
+ hour = str.to_i
324
+ raise FlycalCli::Error, "Invalid hour #{value.inspect}. Must be 0-23." unless hour.between?(0, 23)
325
+
326
+ return [hour, 0]
327
+ end
328
+
329
+ parse_hour_minute(str)
330
+ end
331
+
214
332
  def resolve_calendar_ids(service, calendar_name)
215
333
  calendar_lists = service.list_calendars
216
334
 
@@ -236,6 +354,22 @@ module FlycalCli
236
354
  matches.map(&:id)
237
355
  end
238
356
 
357
+ def resolve_single_calendar_id(service, calendar_name)
358
+ if calendar_name && !calendar_name.empty?
359
+ ids = resolve_calendar_ids(service, calendar_name)
360
+ return ids.first
361
+ end
362
+
363
+ default_id = Config.calendar_default
364
+ return default_id if default_id
365
+
366
+ calendars = service.list_calendars
367
+ primary = calendars.find(&:primary)
368
+ return primary.id if primary
369
+
370
+ calendars.first&.id
371
+ end
372
+
239
373
  def print_events(service, events)
240
374
  # Use calendar list for names (avoids extra API calls)
241
375
  calendar_list = service.list_calendars
@@ -264,7 +398,7 @@ module FlycalCli
264
398
  if dt.is_a?(String)
265
399
  dt
266
400
  else
267
- dt.strftime("%a %Y-%m-%d %H:%M")
401
+ "#{Locale.day_abbr(dt)} #{dt.strftime("%Y-%m-%d %H:%M")}"
268
402
  end
269
403
  end
270
404
 
@@ -272,7 +406,7 @@ module FlycalCli
272
406
  return "-" if dt.nil?
273
407
 
274
408
  t = dt.respond_to?(:to_time) ? dt.to_time : dt
275
- t.strftime("%a %Y-%m-%d")
409
+ "#{Locale.day_abbr(t)} #{t.strftime("%Y-%m-%d")}"
276
410
  end
277
411
 
278
412
  def print_search_summary(events, time_min:, time_max:)
@@ -362,7 +496,7 @@ module FlycalCli
362
496
  mins = minutes_in_period(event_ranges, period_start, period_end)
363
497
  hours = (mins / 60).round(1)
364
498
  working_days = (mins / 60.0 / HOURS_PER_WORKING_DAY).round(1)
365
- month_name = current.strftime("%B %Y")
499
+ month_name = "#{Locale.month_name(current)} #{current.year}"
366
500
  start_str = format_date_with_day(period_start)
367
501
  last_day = (period_end.to_date - 1.day)
368
502
  end_str = format_date_with_day(last_day)
@@ -9,12 +9,20 @@ module FlycalCli
9
9
  CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
10
10
  CREDENTIALS_FILE = File.join(CONFIG_DIR, "credentials.json")
11
11
  TOKENS_FILE = File.join(CONFIG_DIR, "tokens.yml")
12
+ DEFAULTS_FILE = File.expand_path("../../config/defaults.yml", __dir__)
12
13
 
13
14
  class << self
14
15
  def load
15
- return {} unless File.exist?(CONFIG_FILE)
16
+ user_data = if File.exist?(CONFIG_FILE)
17
+ YAML.load_file(CONFIG_FILE) || {}
18
+ else
19
+ {}
20
+ end
16
21
 
17
- YAML.load_file(CONFIG_FILE) || {}
22
+ merged, changed = merge_missing_defaults(user_data, default_values)
23
+ save(merged) if changed || !File.exist?(CONFIG_FILE)
24
+
25
+ merged
18
26
  end
19
27
 
20
28
  def save(data)
@@ -51,6 +59,59 @@ module FlycalCli
51
59
  def clear_all
52
60
  FileUtils.rm_rf(CONFIG_DIR)
53
61
  end
62
+
63
+ def slots_config
64
+ load.fetch("slots", {})
65
+ end
66
+
67
+ def locale
68
+ load["locale"].to_s
69
+ end
70
+
71
+ def locale=(value)
72
+ data = load
73
+ data["locale"] = value.to_s
74
+ save(data)
75
+ end
76
+
77
+ def default_values
78
+ @default_values ||= begin
79
+ raise "Defaults file not found: #{DEFAULTS_FILE}" unless File.exist?(DEFAULTS_FILE)
80
+
81
+ YAML.load_file(DEFAULTS_FILE) || {}
82
+ end
83
+ end
84
+
85
+ def merge_missing_defaults(target, defaults)
86
+ merged = target.dup
87
+ changed = false
88
+
89
+ defaults.each do |key, default_value|
90
+ if !merged.key?(key)
91
+ merged[key] = deep_dup(default_value)
92
+ changed = true
93
+ elsif default_value.is_a?(Hash) && merged[key].is_a?(Hash)
94
+ nested, nested_changed = merge_missing_defaults(merged[key], default_value)
95
+ merged[key] = nested
96
+ changed ||= nested_changed
97
+ end
98
+ end
99
+
100
+ [merged, changed]
101
+ end
102
+
103
+ def deep_dup(value)
104
+ case value
105
+ when Hash
106
+ value.transform_values { |v| deep_dup(v) }
107
+ when Array
108
+ value.map { |v| deep_dup(v) }
109
+ else
110
+ value
111
+ end
112
+ end
113
+
114
+ private :default_values, :merge_missing_defaults, :deep_dup
54
115
  end
55
116
  end
56
117
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+ require "active_support/core_ext/integer/time"
5
+
6
+ module FlycalCli
7
+ class DurationParser
8
+ UNITS = {
9
+ "s" => :seconds, "sec" => :seconds, "second" => :seconds, "seconds" => :seconds,
10
+ "m" => :minutes, "min" => :minutes, "mins" => :minutes, "minute" => :minutes, "minutes" => :minutes,
11
+ "h" => :hours, "hr" => :hours, "hour" => :hours, "hours" => :hours,
12
+ "d" => :days, "day" => :days, "days" => :days,
13
+ "w" => :weeks, "week" => :weeks, "weeks" => :weeks
14
+ }.freeze
15
+
16
+ class << self
17
+ def parse(str)
18
+ normalized = str.to_s.strip.downcase
19
+ match = normalized.match(/\A(\d+(?:\.\d+)?)\s*([a-z]+)\z/) ||
20
+ normalized.match(/\A(\d+(?:\.\d+)?)([a-z]+)\z/)
21
+ raise FlycalCli::Error, invalid_message(str) unless match
22
+
23
+ value = match[1].to_f
24
+ unit = UNITS[match[2]]
25
+ raise FlycalCli::Error, invalid_message(str) unless unit
26
+
27
+ duration = value.public_send(unit)
28
+ duration
29
+ end
30
+
31
+ def to_seconds(str)
32
+ parse(str).to_i
33
+ end
34
+
35
+ def add_to_time(str, from_time)
36
+ from_time + parse(str)
37
+ end
38
+
39
+ private
40
+
41
+ def invalid_message(str)
42
+ "Invalid duration: #{str.inspect}. Examples: 1h, 1 hour, 30 minutes, 3 days"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module FlycalCli
6
+ module Locale
7
+ module_function
8
+
9
+ FALLBACK_LOCALE = "en"
10
+
11
+ def t(key, vars = {})
12
+ value = lookup(translations(current_locale), key) || lookup(translations(FALLBACK_LOCALE), key) || key
13
+ interpolate(value, vars)
14
+ end
15
+
16
+ def day_name(date_or_time)
17
+ idx = date_or_time.to_date.wday
18
+ days = t("common.weekdays.full")
19
+ days[idx] || date_or_time.strftime("%A").downcase
20
+ end
21
+
22
+ def day_abbr(date_or_time)
23
+ idx = date_or_time.to_date.wday
24
+ days = t("common.weekdays.abbr")
25
+ days[idx] || date_or_time.strftime("%a")
26
+ end
27
+
28
+ def month_name(date_or_time)
29
+ idx = date_or_time.to_date.month - 1
30
+ months = t("common.months.full")
31
+ months[idx] || date_or_time.strftime("%B")
32
+ end
33
+
34
+ def current_locale
35
+ Thread.current[:flycal_locale_override] || Config.locale
36
+ rescue StandardError
37
+ FALLBACK_LOCALE
38
+ end
39
+
40
+ def override!(locale)
41
+ Thread.current[:flycal_locale_override] = locale.to_s if locale && !locale.to_s.strip.empty?
42
+ end
43
+
44
+ def translations(locale)
45
+ @cache ||= {}
46
+ loc = locale.to_s
47
+ @cache[loc] ||= begin
48
+ path = File.expand_path("../../locales/#{loc}.json", __dir__)
49
+ File.exist?(path) ? JSON.parse(File.read(path)) : {}
50
+ end
51
+ end
52
+
53
+ def lookup(hash, dotted_key)
54
+ dotted_key.to_s.split(".").reduce(hash) do |acc, part|
55
+ acc.is_a?(Hash) ? acc[part] : nil
56
+ end
57
+ end
58
+
59
+ def interpolate(value, vars)
60
+ return value unless value.is_a?(String)
61
+
62
+ value.gsub(/%\{(\w+)\}/) do
63
+ vars.fetch(Regexp.last_match(1).to_sym, Regexp.last_match(0)).to_s
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlycalCli
4
+ class SlotFinder
5
+ DEFAULT_WORKHOURS = [[9, 0, 18, 0]].freeze
6
+
7
+ def initialize(
8
+ events:,
9
+ time_min:,
10
+ time_max:,
11
+ slot_duration_seconds:,
12
+ workhours: DEFAULT_WORKHOURS,
13
+ weekdays_only: true
14
+ )
15
+ @events = events
16
+ @time_min = time_min
17
+ @time_max = time_max
18
+ @slot_duration_seconds = slot_duration_seconds
19
+ @workhours = workhours
20
+ @weekdays_only = weekdays_only
21
+ end
22
+
23
+ def slots_by_day
24
+ result = {}
25
+
26
+ each_workday do |date|
27
+ slots = []
28
+ day_intervals(date).each do |day_start, day_end|
29
+ next if day_end <= day_start
30
+
31
+ busy = busy_intervals_for(date, day_start, day_end)
32
+ 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) })
34
+ end
35
+ result[date] = slots unless slots.empty?
36
+ end
37
+
38
+ result
39
+ end
40
+
41
+ private
42
+
43
+ def each_workday
44
+ date = @time_min.to_date
45
+ end_date = @time_max.to_date
46
+
47
+ while date <= end_date
48
+ yield date if include_day?(date)
49
+ date += 1
50
+ end
51
+ end
52
+
53
+ def include_day?(date)
54
+ return true unless @weekdays_only
55
+
56
+ workday?(date)
57
+ end
58
+
59
+ def workday?(date)
60
+ !date.saturday? && !date.sunday?
61
+ end
62
+
63
+ def day_intervals(date)
64
+ @workhours.map do |start_h, start_m, end_h, end_m|
65
+ start_at = Time.local(date.year, date.month, date.day, start_h, start_m, 0)
66
+ end_at = Time.local(date.year, date.month, date.day, end_h, end_m, 0)
67
+ start_at = [@time_min, start_at].max
68
+ end_at = [@time_max, end_at].min
69
+ [start_at, end_at]
70
+ end
71
+ end
72
+
73
+ def busy_intervals_for(date, day_start, day_end)
74
+ intervals = @events.filter_map do |event|
75
+ interval_for_event(event, date, day_start, day_end)
76
+ end
77
+ merge_intervals(intervals)
78
+ end
79
+
80
+ def interval_for_event(event, date, day_start, day_end)
81
+ return nil if event.status == "cancelled"
82
+ return nil if event.transparency == "transparent"
83
+
84
+ if event.start&.date
85
+ event_date = Date.parse(event.start.date.to_s)
86
+ event_end_date = Date.parse(event.end.date.to_s)
87
+ return nil unless date >= event_date && date < event_end_date
88
+
89
+ return [day_start, day_end]
90
+ end
91
+
92
+ start_at = to_time(event.start.date_time)
93
+ end_at = to_time(event.end.date_time)
94
+ return nil if end_at <= day_start || start_at >= day_end
95
+
96
+ [[start_at, day_start].max, [end_at, day_end].min]
97
+ end
98
+
99
+ def merge_intervals(intervals)
100
+ return [] if intervals.empty?
101
+
102
+ sorted = intervals.sort_by(&:first)
103
+ merged = [sorted.first]
104
+
105
+ sorted[1..].each do |start_at, end_at|
106
+ last_start, last_end = merged.last
107
+ if start_at <= last_end
108
+ merged[-1] = [last_start, [last_end, end_at].max]
109
+ else
110
+ merged << [start_at, end_at]
111
+ end
112
+ end
113
+
114
+ merged
115
+ end
116
+
117
+ def free_gaps(day_start, day_end, busy)
118
+ gaps = []
119
+ cursor = day_start
120
+
121
+ busy.each do |start_at, end_at|
122
+ gaps << [cursor, start_at] if start_at > cursor
123
+ cursor = [cursor, end_at].max
124
+ end
125
+
126
+ gaps << [cursor, day_end] if cursor < day_end
127
+ gaps
128
+ end
129
+
130
+ def slot_if_fits(start_at, end_at)
131
+ rounded_start = round_up_15(start_at)
132
+ 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
135
+
136
+ [rounded_start, rounded_end]
137
+ end
138
+
139
+ def round_up_15(time)
140
+ sec = time.to_i
141
+ remainder = sec % 900
142
+ return time if remainder.zero?
143
+
144
+ Time.at(sec + (900 - remainder))
145
+ end
146
+
147
+ def round_down_15(time)
148
+ sec = time.to_i
149
+ Time.at(sec - (sec % 900))
150
+ end
151
+
152
+ def to_time(value)
153
+ return value if value.is_a?(Time)
154
+ return value.to_time if value.respond_to?(:to_time) && !value.is_a?(String)
155
+
156
+ Time.parse(value.to_s)
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FlycalCli
4
+ class SlotFormatter
5
+ class << self
6
+ def format_output(slots_by_day)
7
+ lines = []
8
+
9
+ slots_by_day.sort_by(&:first).each do |date, slots|
10
+ lines << "" unless lines.empty?
11
+ lines << day_header(date)
12
+ slots.each do |start_at, end_at|
13
+ lines << slot_range(start_at, end_at)
14
+ end
15
+ end
16
+
17
+ lines.join("\n")
18
+ end
19
+
20
+ private
21
+
22
+ def day_header(date)
23
+ "#{Locale.day_name(date)} #{date.day}/#{date.month}"
24
+ end
25
+
26
+ def slot_range(start_at, end_at)
27
+ "#{format_time(start_at)}-#{format_time(end_at)}"
28
+ end
29
+
30
+ def format_time(time)
31
+ return time.strftime("%-H") if time.min.zero?
32
+
33
+ minutes = sprintf("%02d", time.min)
34
+ "#{time.hour}.#{minutes}"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FlycalCli
4
- VERSION = '0.3.2'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/flycal_cli.rb CHANGED
@@ -2,8 +2,12 @@
2
2
 
3
3
  require "flycal_cli/version"
4
4
  require "flycal_cli/config"
5
+ require "flycal_cli/locale"
5
6
  require "flycal_cli/auth"
7
+ require "flycal_cli/duration_parser"
6
8
  require "flycal_cli/calendar_service"
9
+ require "flycal_cli/slot_finder"
10
+ require "flycal_cli/slot_formatter"
7
11
  require "flycal_cli/cli"
8
12
 
9
13
  module FlycalCli
data/locales/en.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "common": {
3
+ "weekdays": {
4
+ "full": ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
5
+ "abbr": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
6
+ },
7
+ "months": {
8
+ "full": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
9
+ }
10
+ },
11
+ "slots": {
12
+ "no_available": "No available slots found.",
13
+ "no_calendar": "No calendar found. Run 'flycal calendars' to set a default."
14
+ },
15
+ "errors": {
16
+ "not_connected": "You are not connected. Run 'flycal login' first.",
17
+ "duration_positive": "Error: --in must be a positive duration."
18
+ }
19
+ }
data/locales/it.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "common": {
3
+ "weekdays": {
4
+ "full": ["domenica", "lunedi", "martedi", "mercoledi", "giovedi", "venerdi", "sabato"],
5
+ "abbr": ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"]
6
+ },
7
+ "months": {
8
+ "full": ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]
9
+ }
10
+ },
11
+ "slots": {
12
+ "no_available": "Nessuno slot disponibile.",
13
+ "no_calendar": "Nessun calendario trovato. Esegui 'flycal calendars' per impostare il predefinito."
14
+ },
15
+ "errors": {
16
+ "not_connected": "Non sei connesso. Esegui prima 'flycal login'.",
17
+ "duration_positive": "Errore: --in deve essere una durata positiva."
18
+ }
19
+ }
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.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - flycal-cli
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '6.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pstore
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: google-apis-calendar_v3
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -119,12 +133,19 @@ files:
119
133
  - LICENSE
120
134
  - README.md
121
135
  - bin/flycal
136
+ - config/defaults.yml
122
137
  - lib/flycal_cli.rb
123
138
  - lib/flycal_cli/auth.rb
124
139
  - lib/flycal_cli/calendar_service.rb
125
140
  - lib/flycal_cli/cli.rb
126
141
  - lib/flycal_cli/config.rb
142
+ - lib/flycal_cli/duration_parser.rb
143
+ - lib/flycal_cli/locale.rb
144
+ - lib/flycal_cli/slot_finder.rb
145
+ - lib/flycal_cli/slot_formatter.rb
127
146
  - lib/flycal_cli/version.rb
147
+ - locales/en.json
148
+ - locales/it.json
128
149
  homepage: https://github.com/magnum/flycal-cli
129
150
  licenses:
130
151
  - MIT
@@ -146,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
167
  - !ruby/object:Gem::Version
147
168
  version: '0'
148
169
  requirements: []
149
- rubygems_version: 4.0.6
170
+ rubygems_version: 4.0.17
150
171
  specification_version: 4
151
172
  summary: CLI to access Google calendars
152
173
  test_files: []