flycal-cli 0.4.1 → 0.6.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.
@@ -20,7 +20,8 @@ module FlycalCli
20
20
  end
21
21
 
22
22
  merged, changed = merge_missing_defaults(user_data, default_values)
23
- save(merged) if changed || !File.exist?(CONFIG_FILE)
23
+ merged, migrated = normalize_slots_keys(merged)
24
+ save(merged) if changed || migrated || !File.exist?(CONFIG_FILE)
24
25
 
25
26
  merged
26
27
  end
@@ -64,6 +65,50 @@ module FlycalCli
64
65
  load.fetch("slots", {})
65
66
  end
66
67
 
68
+ def exclude_calendars
69
+ Array(slots_config["exclude_calendars"]).map(&:to_s).reject(&:empty?)
70
+ end
71
+
72
+ def exclude_calendars=(calendar_ids)
73
+ data = load
74
+ data["slots"] ||= {}
75
+ data["slots"]["exclude_calendars"] = Array(calendar_ids).map(&:to_s)
76
+ save(data)
77
+ end
78
+
79
+ def normalize_slots_keys(data)
80
+ slots = data["slots"]
81
+ return [data, false] unless slots.is_a?(Hash)
82
+
83
+ changed = false
84
+ normalized = slots.dup
85
+
86
+ if normalized.key?("exclude-calendars") && !normalized.key?("exclude_calendars")
87
+ normalized["exclude_calendars"] = normalized.delete("exclude-calendars")
88
+ changed = true
89
+ end
90
+
91
+ if normalized.key?("weekdays-only") && !normalized.key?("weekdays_only")
92
+ normalized["weekdays_only"] = normalized.delete("weekdays-only")
93
+ changed = true
94
+ end
95
+
96
+ if normalized.key?("workhours") && !normalized.key?("hours")
97
+ normalized["hours"] = normalized.delete("workhours")
98
+ changed = true
99
+ end
100
+
101
+ return [data, false] unless changed
102
+
103
+ data = data.dup
104
+ data["slots"] = normalized
105
+ [data, true]
106
+ end
107
+
108
+ def config_file
109
+ CONFIG_FILE
110
+ end
111
+
67
112
  def locale
68
113
  load["locale"].to_s
69
114
  end
@@ -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,30 @@ module FlycalCli
127
141
  gaps
128
142
  end
129
143
 
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
144
+ def slots_in_gap(start_at, end_at)
145
+ slots = []
146
+ cursor = round_up_15(start_at)
147
+ gap_limit = round_down_15(end_at)
148
+
149
+ while cursor + @slot_duration_seconds <= gap_limit
150
+ slots << [cursor, cursor + @slot_duration_seconds]
151
+ cursor += STEP_SECONDS
152
+ end
135
153
 
136
- [rounded_start, rounded_end]
154
+ slots
137
155
  end
138
156
 
139
157
  def round_up_15(time)
140
158
  sec = time.to_i
141
- remainder = sec % 900
159
+ remainder = sec % STEP_SECONDS
142
160
  return time if remainder.zero?
143
161
 
144
- Time.at(sec + (900 - remainder))
162
+ Time.at(sec + (STEP_SECONDS - remainder))
145
163
  end
146
164
 
147
165
  def round_down_15(time)
148
166
  sec = time.to_i
149
- Time.at(sec - (sec % 900))
167
+ Time.at(sec - (sec % STEP_SECONDS))
150
168
  end
151
169
 
152
170
  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.4.1'
4
+ VERSION = '0.6.0'
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
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "common": {
3
+ "loading_calendars": "Loading calendars...",
3
4
  "weekdays": {
4
5
  "full": ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"],
5
6
  "abbr": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
@@ -8,12 +9,35 @@
8
9
  "full": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
9
10
  }
10
11
  },
12
+ "config": {
13
+ "prompt": "Choose a configuration option:",
14
+ "options": {
15
+ "calendar_default": "calendar_default",
16
+ "exclude_calendars": "exclude_calendars",
17
+ "edit_config": "edit config"
18
+ },
19
+ "calendar_default": {
20
+ "prompt": "Choose the default calendar:",
21
+ "saved": "✓ Default calendar set to: %{name}"
22
+ },
23
+ "exclude_calendars": {
24
+ "prompt": "Choose calendars whose events block free slots:",
25
+ "saved": "✓ Exclude calendars set to: %{names}"
26
+ },
27
+ "edit": {
28
+ "failed": "Error: could not open config with %{editor}."
29
+ }
30
+ },
11
31
  "slots": {
12
32
  "no_available": "No available slots found.",
13
- "no_calendar": "No calendar found. Run 'flycal calendars' 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)"
14
36
  },
15
37
  "errors": {
16
38
  "not_connected": "You are not connected. Run 'flycal login' first.",
17
- "duration_positive": "Error: --in must be a positive duration."
39
+ "duration_positive": "Error: --in must be a positive duration.",
40
+ "no_calendars": "No calendars found.",
41
+ "calendar_fetch": "Error in calendar %{calendar}: %{message}"
18
42
  }
19
43
  }
data/locales/it.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "common": {
3
+ "loading_calendars": "Caricamento calendari...",
3
4
  "weekdays": {
4
5
  "full": ["domenica", "lunedi", "martedi", "mercoledi", "giovedi", "venerdi", "sabato"],
5
6
  "abbr": ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"]
@@ -8,12 +9,35 @@
8
9
  "full": ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"]
9
10
  }
10
11
  },
12
+ "config": {
13
+ "prompt": "Scegli un'opzione di configurazione:",
14
+ "options": {
15
+ "calendar_default": "calendar_default",
16
+ "exclude_calendars": "exclude_calendars",
17
+ "edit_config": "edit config"
18
+ },
19
+ "calendar_default": {
20
+ "prompt": "Scegli il calendario predefinito:",
21
+ "saved": "✓ Calendario predefinito impostato su: %{name}"
22
+ },
23
+ "exclude_calendars": {
24
+ "prompt": "Scegli i calendari i cui eventi bloccano gli slot liberi:",
25
+ "saved": "✓ Calendari esclusi impostati su: %{names}"
26
+ },
27
+ "edit": {
28
+ "failed": "Errore: impossibile aprire il config con %{editor}."
29
+ }
30
+ },
11
31
  "slots": {
12
32
  "no_available": "Nessuno slot disponibile.",
13
- "no_calendar": "Nessun calendario trovato. Esegui 'flycal calendars' 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)"
14
36
  },
15
37
  "errors": {
16
38
  "not_connected": "Non sei connesso. Esegui prima 'flycal login'.",
17
- "duration_positive": "Errore: --in deve essere una durata positiva."
39
+ "duration_positive": "Errore: --in deve essere una durata positiva.",
40
+ "no_calendars": "Nessun calendario trovato.",
41
+ "calendar_fetch": "Errore nel calendario %{calendar}: %{message}"
18
42
  }
19
43
  }
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.