r_cal 0.1.1 → 0.2.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: 4b8e1b4227647300ed2520994313f6c1646af59edb0b09832981309bd3f0c57f
4
- data.tar.gz: '006687ea8d92378c0c4ccd82128c7a597b8584469c8aa2c2eadd6878432896ee'
3
+ metadata.gz: 3197ee3a981b8c987ffc5aaf6e2676a91e602359562e7143ed9158d712e43711
4
+ data.tar.gz: 26d484aa6eb13877e489215f353c6f035d208e054201643ca346aa19ba8c74fd
5
5
  SHA512:
6
- metadata.gz: f142a4a0075c242752b4ff9f954e8861be232df1900084660958ed9a7c76cad1ee0d283854befae76100ab49c468621ebe14497da39c9b9b5a4a81d20dc9c077
7
- data.tar.gz: 508b0c8cc9030dd8d28ef0652910fad027f79391768395e9aa745deb8e9cad05fb097edf34cca74c34cc35ecbc70dd3aa61747ae464c2a6738e10a90e032b39f
6
+ metadata.gz: 507cdd4df0c1ac7c015632e230a34230bc3925c824e4398b25127ba8077f30595e842318d6d5dbafe4ed2f1090c68ad4eeea7f262162d54ee25592295da3da56
7
+ data.tar.gz: 36b299e87193a9fae11871056449ff89d552e8cbec669ebce391cd133591473300ff0d8968b9ceae09555bcc4fca2ef87ff5b4c0ff6e67ffbe98a105f1bc5d42
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- r_cal (0.1.0)
4
+ r_cal (0.1.1)
5
5
  chronic (~> 0.10)
6
6
  chronic_duration (~> 0.10)
7
7
  cli-kit (~> 5.2.0)
@@ -210,7 +210,7 @@ CHECKSUMS
210
210
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
211
211
  pstore (0.2.0) sha256=d6e5c7e8e22392235e88bbe82959059ba768a797b5bd0ebf5ac80a3311ce74a8
212
212
  public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
213
- r_cal (0.1.0)
213
+ r_cal (0.1.1)
214
214
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
215
215
  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
216
216
  rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
@@ -10,6 +10,10 @@ module Rcal
10
10
  raise NotImplementedError, "#{self.class} must implement #list_events"
11
11
  end
12
12
 
13
+ def get_calendar(calendar_id:)
14
+ raise NotImplementedError, "#{self.class} must implement #get_calendar"
15
+ end
16
+
13
17
  def get_event(calendar_id:, event_id:)
14
18
  raise NotImplementedError, "#{self.class} must implement #get_event"
15
19
  end
@@ -19,6 +19,11 @@ module Rcal
19
19
  items.map { |cal| build_calendar(cal) }
20
20
  end
21
21
 
22
+ def get_calendar(calendar_id:)
23
+ google_calendar = @service.get_calendar(calendar_id)
24
+ build_calendar(google_calendar)
25
+ end
26
+
22
27
  def list_events(calendar_id:, time_min:, time_max:)
23
28
  response = @service.list_events(
24
29
  calendar_id,
@@ -93,7 +98,8 @@ module Rcal
93
98
  recurring_event_id: google_event.recurring_event_id,
94
99
  response_status: extract_self_response_status(google_event.attendees),
95
100
  attendees: build_attendees(google_event.attendees),
96
- timezone: google_event.start&.time_zone
101
+ timezone: google_event.start&.time_zone,
102
+ color_id: google_event.color_id
97
103
  )
98
104
  end
99
105
 
@@ -134,22 +140,31 @@ module Rcal
134
140
  google_event = ::Google::Apis::CalendarV3::Event.new(
135
141
  summary: event.summary,
136
142
  description: event.description,
137
- location: event.location
143
+ location: event.location,
144
+ color_id: event.color_id,
145
+ recurrence: event.recurrence,
146
+ transparency: event.transparency
138
147
  )
139
148
 
149
+ tz = event.timezone
150
+
140
151
  if event.all_day?
141
152
  google_event.start = ::Google::Apis::CalendarV3::EventDateTime.new(
142
- date: event.start_time.to_date.to_s
153
+ date: event.start_time.to_date.to_s,
154
+ time_zone: tz
143
155
  )
144
156
  google_event.end = ::Google::Apis::CalendarV3::EventDateTime.new(
145
- date: event.end_time.to_date.to_s
157
+ date: event.end_time.to_date.to_s,
158
+ time_zone: tz
146
159
  )
147
160
  else
148
161
  google_event.start = ::Google::Apis::CalendarV3::EventDateTime.new(
149
- date_time: event.start_time.iso8601
162
+ date_time: event.start_time.iso8601,
163
+ time_zone: tz
150
164
  )
151
165
  google_event.end = ::Google::Apis::CalendarV3::EventDateTime.new(
152
- date_time: event.end_time.iso8601
166
+ date_time: event.end_time.iso8601,
167
+ time_zone: tz
153
168
  )
154
169
  end
155
170
 
@@ -13,6 +13,10 @@ module Rcal
13
13
  adapter.list_calendars
14
14
  end
15
15
 
16
+ def get_calendar(calendar_id:)
17
+ adapter.get_calendar(calendar_id: calendar_id)
18
+ end
19
+
16
20
  def list_events(calendar_id:, time_min:, time_max:)
17
21
  adapter.list_events(
18
22
  calendar_id: calendar_id,
@@ -0,0 +1,40 @@
1
+ module Rcal
2
+ module ColorMap
3
+ COLORS = {
4
+ "lavender" => "1",
5
+ "sage" => "2",
6
+ "grape" => "3",
7
+ "flamingo" => "4",
8
+ "banana" => "5",
9
+ "tangerine" => "6",
10
+ "peacock" => "7",
11
+ "graphite" => "8",
12
+ "blueberry" => "9",
13
+ "basil" => "10",
14
+ "tomato" => "11"
15
+ }.freeze
16
+
17
+ IDS = COLORS.values.freeze
18
+
19
+ class << self
20
+ # Resolves a color name or numeric ID to a Google Calendar color ID string.
21
+ # Accepts names ("tomato"), IDs ("11"), or IDs as integers (11).
22
+ # Raises Rcal::Error for invalid input.
23
+ def resolve(input)
24
+ normalized = input.to_s.strip.downcase
25
+
26
+ # Try as a name first
27
+ return COLORS[normalized] if COLORS.key?(normalized)
28
+
29
+ # Try as a numeric ID
30
+ return normalized if IDS.include?(normalized)
31
+
32
+ raise Rcal::Error, "Unknown color: #{input}. Run 'rcal colors' to see available colors."
33
+ end
34
+
35
+ def all
36
+ COLORS
37
+ end
38
+ end
39
+ end
40
+ end
@@ -5,6 +5,9 @@ require_relative "../date_parser"
5
5
  require_relative "../duration_parser"
6
6
  require_relative "../models/event"
7
7
  require_relative "../presenters/event_presenter"
8
+ require_relative "../color_map"
9
+ require_relative "../timezone_resolver"
10
+ require_relative "../recurrence_builder"
8
11
 
9
12
  module Rcal
10
13
  module Commands
@@ -26,12 +29,26 @@ module Rcal
26
29
  --location=TEXT Event location
27
30
  --description=TEXT Event description
28
31
  --calendar=ID Calendar to add event to (default: primary)
32
+ --color=COLOR Event color (name or ID). Run 'rcal colors' to see options
33
+ --timezone=TZ IANA timezone (e.g., "America/New_York"). Default: calendar's timezone
29
34
  --all-day Create an all-day event
35
+ --free Mark event as free (default: busy)
36
+
37
+ Recurrence:
38
+ --repeat=FREQ Recurrence frequency: daily, weekly, monthly, yearly
39
+ --days=DAYS Days of week (e.g., "MO,WE,FR" or "Monday,Wed"). Requires --repeat
40
+ --count=N Number of occurrences. Cannot be used with --until
41
+ --until=DATE End date for recurrence. Cannot be used with --count
42
+ --interval=N Repeat every N periods (e.g., --repeat=weekly --interval=2 = biweekly)
30
43
 
31
44
  Examples:
32
45
  rcal add --title="Team Meeting" --when="tomorrow 3pm"
33
46
  rcal add --title="Lunch" --when="friday noon" --duration=1h --location="Cafe"
34
47
  rcal add --title="Vacation" --when="monday" --all-day
48
+ rcal add --title="Important" --when="tomorrow 9am" --color=tomato
49
+ rcal add --title="Focus Time" --when="tomorrow 2pm" --duration=2h --free
50
+ rcal add --title="Standup" --when="monday 9am" --repeat=weekly --days=MO,WE,FR
51
+ rcal add --title="1:1" --when="monday 2pm" --repeat=weekly --interval=2 --count=10
35
52
  HELP
36
53
  end
37
54
 
@@ -53,8 +70,8 @@ module Rcal
53
70
 
54
71
  private
55
72
 
56
- VALUE_OPTIONS = %w[title when duration location description calendar].freeze
57
- FLAG_OPTIONS = {"all-day" => :all_day}.freeze
73
+ VALUE_OPTIONS = %w[title when duration location description calendar color timezone repeat days count until interval].freeze
74
+ FLAG_OPTIONS = {"all-day" => :all_day, "free" => :free}.freeze
58
75
 
59
76
  def parse_options(args)
60
77
  options = {calendar: "primary", all_day: false}
@@ -91,12 +108,27 @@ module Rcal
91
108
  raise CLI::Kit::Abort, "When is required.\n" \
92
109
  "Usage: rcal add --title=\"Meeting\" --when=\"tomorrow 3pm\""
93
110
  end
111
+
112
+ validate_recurrence_options!(options)
113
+ end
114
+
115
+ def validate_recurrence_options!(options)
116
+ recurrence_modifiers = %i[days count until interval]
117
+ has_modifiers = recurrence_modifiers.any? { |key| options[key] }
118
+
119
+ if has_modifiers && options[:repeat].nil?
120
+ raise CLI::Kit::Abort,
121
+ "Recurrence modifiers (--days, --count, --until, --interval) require --repeat.\n" \
122
+ "Usage: rcal add --title=\"Meeting\" --when=\"monday 9am\" --repeat=weekly --days=MO,WE,FR"
123
+ end
94
124
  end
95
125
 
96
126
  def build_event(options)
97
127
  start_time = parse_start_time(options[:when])
98
128
  duration = parse_duration(options[:duration])
99
129
  end_time = calculate_end_time(start_time, duration, options[:all_day])
130
+ timezone = resolve_timezone(options[:timezone], options[:calendar])
131
+ recurrence = build_recurrence(options)
100
132
 
101
133
  Event.new(
102
134
  summary: options[:title],
@@ -104,7 +136,11 @@ module Rcal
104
136
  end_time: end_time,
105
137
  location: options[:location],
106
138
  description: options[:description],
107
- all_day: options[:all_day]
139
+ all_day: options[:all_day],
140
+ color_id: resolve_color(options[:color]),
141
+ timezone: timezone,
142
+ recurrence: recurrence,
143
+ transparency: options[:free] ? "transparent" : nil
108
144
  )
109
145
  end
110
146
 
@@ -131,6 +167,32 @@ module Rcal
131
167
  end
132
168
  end
133
169
 
170
+ def build_recurrence(options)
171
+ return nil unless options[:repeat]
172
+
173
+ RecurrenceBuilder.build(
174
+ freq: options[:repeat],
175
+ days: options[:days],
176
+ count: options[:count],
177
+ until_date: options[:until],
178
+ interval: options[:interval]
179
+ )
180
+ rescue Rcal::Error => e
181
+ raise CLI::Kit::Abort, e.message
182
+ end
183
+
184
+ def resolve_timezone(timezone_input, calendar_id)
185
+ TimezoneResolver.resolve(explicit: timezone_input, calendar_id: calendar_id)
186
+ end
187
+
188
+ def resolve_color(color_input)
189
+ return nil if color_input.nil?
190
+
191
+ ColorMap.resolve(color_input)
192
+ rescue Rcal::Error => e
193
+ raise CLI::Kit::Abort, e.message
194
+ end
195
+
134
196
  def display_created_event(event)
135
197
  presenter = Presenters::EventPresenter.new(event)
136
198
  puts CLI::UI.fmt("{{v}} Event created: #{presenter.to_s_with_date}")
@@ -0,0 +1,34 @@
1
+ require "rcal"
2
+ require_relative "../color_map"
3
+
4
+ module Rcal
5
+ module Commands
6
+ class Colors < Rcal::Command
7
+ def self.help
8
+ <<~HELP
9
+ List available event colors.
10
+
11
+ Usage: rcal colors
12
+
13
+ Shows the color names and IDs that can be used with the --color option
14
+ on the 'add' and 'edit' commands.
15
+
16
+ Examples:
17
+ rcal colors
18
+ rcal add --title="Meeting" --when="tomorrow 3pm" --color=tomato
19
+ rcal edit abc123 --color=peacock
20
+ HELP
21
+ end
22
+
23
+ def run(_args, _name)
24
+ puts "Available event colors:\n\n"
25
+
26
+ ColorMap.all.each do |name, id|
27
+ puts " #{id.rjust(2)} #{name}"
28
+ end
29
+
30
+ puts "\nUsage: rcal add --color=NAME or rcal add --color=ID"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -5,6 +5,9 @@ require_relative "../date_parser"
5
5
  require_relative "../duration_parser"
6
6
  require_relative "../models/event"
7
7
  require_relative "../presenters/event_presenter"
8
+ require_relative "../color_map"
9
+ require_relative "../timezone_resolver"
10
+ require_relative "../recurrence_builder"
8
11
 
9
12
  module Rcal
10
13
  module Commands
@@ -25,11 +28,27 @@ module Rcal
25
28
  --location=TEXT New event location
26
29
  --description=TEXT New event description
27
30
  --calendar=ID Calendar containing the event (default: primary)
31
+ --color=COLOR New event color (name or ID). Run 'rcal colors' to see options
32
+ --timezone=TZ IANA timezone (e.g., "America/New_York")
33
+ --free Mark event as free (does not block calendar)
34
+ --busy Mark event as busy (blocks calendar)
35
+
36
+ Recurrence:
37
+ --repeat=FREQ Change recurrence: daily, weekly, monthly, yearly, or "none" to remove
38
+ --days=DAYS Days of week (e.g., "MO,WE,FR" or "Monday,Wed"). Requires --repeat
39
+ --count=N Number of occurrences. Cannot be used with --until
40
+ --until=DATE End date for recurrence. Cannot be used with --count
41
+ --interval=N Repeat every N periods (e.g., --repeat=weekly --interval=2 = biweekly)
28
42
 
29
43
  Examples:
30
44
  rcal edit abc123 --title="Updated Meeting"
31
45
  rcal edit abc123 --when="tomorrow 4pm" --duration=30m
32
46
  rcal edit abc123 --location="Room 202" --calendar=work@company.com
47
+ rcal edit abc123 --color=peacock
48
+ rcal edit abc123 --free
49
+ rcal edit abc123 --busy
50
+ rcal edit abc123 --repeat=weekly --days=MO,WE,FR
51
+ rcal edit abc123 --repeat=none
33
52
  HELP
34
53
  end
35
54
 
@@ -55,7 +74,8 @@ module Rcal
55
74
 
56
75
  private
57
76
 
58
- VALUE_OPTIONS = %w[title when duration location description calendar].freeze
77
+ VALUE_OPTIONS = %w[title when duration location description calendar color timezone repeat days count until interval].freeze
78
+ FLAG_OPTIONS = {"free" => :free, "busy" => :busy}.freeze
59
79
 
60
80
  def parse_options(args)
61
81
  options = {calendar: "primary"}
@@ -64,11 +84,15 @@ module Rcal
64
84
  case parse_arg(arg)
65
85
  in {option:, value:} if VALUE_OPTIONS.include?(option)
66
86
  options[option.tr("-", "_").to_sym] = value
87
+ in {flag:} if FLAG_OPTIONS.key?(flag)
88
+ options[FLAG_OPTIONS[flag]] = true
67
89
  else
68
90
  nil
69
91
  end
70
92
  end
71
93
 
94
+ validate_transparency_options!(options)
95
+
72
96
  options
73
97
  end
74
98
 
@@ -91,6 +115,22 @@ module Rcal
91
115
  end
92
116
  end
93
117
 
118
+ def validate_transparency_options!(options)
119
+ if options[:free] && options[:busy]
120
+ raise CLI::Kit::Abort, "Cannot use --free and --busy together."
121
+ end
122
+ end
123
+
124
+ def resolve_transparency(options, existing_event)
125
+ if options[:free]
126
+ "transparent"
127
+ elsif options[:busy]
128
+ "opaque"
129
+ else
130
+ existing_event.transparency
131
+ end
132
+ end
133
+
94
134
  def fetch_event(calendar_id, event_id)
95
135
  CalendarService.get_event(calendar_id: calendar_id, event_id: event_id)
96
136
  rescue => e
@@ -101,6 +141,10 @@ module Rcal
101
141
  summary = options[:title] || existing_event.summary
102
142
  location = options.key?(:location) ? options[:location] : existing_event.location
103
143
  description = options.key?(:description) ? options[:description] : existing_event.description
144
+ color_id = options.key?(:color) ? resolve_color(options[:color]) : existing_event.color_id
145
+ timezone = resolve_timezone(options[:timezone], existing_event.timezone, options[:calendar])
146
+ recurrence = resolve_recurrence(options, existing_event)
147
+ transparency = resolve_transparency(options, existing_event)
104
148
 
105
149
  start_time = if options[:when]
106
150
  parse_start_time(options[:when])
@@ -127,8 +171,46 @@ module Rcal
127
171
  location: location,
128
172
  description: description,
129
173
  all_day: existing_event.all_day?,
130
- calendar_id: existing_event.calendar_id
174
+ calendar_id: existing_event.calendar_id,
175
+ color_id: color_id,
176
+ timezone: timezone,
177
+ recurrence: recurrence,
178
+ transparency: transparency
179
+ )
180
+ end
181
+
182
+ def resolve_recurrence(options, existing_event)
183
+ return existing_event.recurrence unless options[:repeat]
184
+
185
+ # --repeat=none removes recurrence
186
+ return nil if options[:repeat].downcase == "none"
187
+
188
+ build_recurrence(options)
189
+ end
190
+
191
+ def build_recurrence(options)
192
+ RecurrenceBuilder.build(
193
+ freq: options[:repeat],
194
+ days: options[:days],
195
+ count: options[:count],
196
+ until_date: options[:until],
197
+ interval: options[:interval]
131
198
  )
199
+ rescue Rcal::Error => e
200
+ raise CLI::Kit::Abort, e.message
201
+ end
202
+
203
+ def resolve_timezone(timezone_input, existing_timezone, calendar_id)
204
+ # Explicit flag wins, then preserve existing event timezone, then resolve from calendar/system
205
+ timezone_input || existing_timezone || TimezoneResolver.resolve(calendar_id: calendar_id)
206
+ end
207
+
208
+ def resolve_color(color_input)
209
+ return nil if color_input.nil?
210
+
211
+ ColorMap.resolve(color_input)
212
+ rescue Rcal::Error => e
213
+ raise CLI::Kit::Abort, e.message
132
214
  end
133
215
 
134
216
  def parse_start_time(when_text)
data/lib/rcal/commands.rb CHANGED
@@ -11,6 +11,7 @@ module Rcal
11
11
 
12
12
  register :Add, "add", "rcal/commands/add"
13
13
  register :Agenda, "agenda", "rcal/commands/agenda"
14
+ register :Colors, "colors", "rcal/commands/colors"
14
15
  register :Edit, "edit", "rcal/commands/edit"
15
16
  register :Help, "help", "rcal/commands/help"
16
17
  register :Import, "import", "rcal/commands/import"
@@ -4,7 +4,8 @@ module Rcal
4
4
  class Event
5
5
  attr_reader :id, :summary, :start_time, :end_time, :description,
6
6
  :location, :calendar_id, :transparency, :recurrence,
7
- :recurring_event_id, :response_status, :attendees, :timezone
7
+ :recurring_event_id, :response_status, :attendees, :timezone,
8
+ :color_id
8
9
 
9
10
  def initialize(
10
11
  summary:,
@@ -20,7 +21,8 @@ module Rcal
20
21
  recurring_event_id: nil,
21
22
  response_status: nil,
22
23
  attendees: nil,
23
- timezone: nil
24
+ timezone: nil,
25
+ color_id: nil
24
26
  )
25
27
  @id = id
26
28
  @summary = summary
@@ -36,6 +38,7 @@ module Rcal
36
38
  @response_status = response_status
37
39
  @attendees = normalize_attendees(attendees)
38
40
  @timezone = timezone
41
+ @color_id = color_id
39
42
  end
40
43
 
41
44
  def all_day?
@@ -0,0 +1,113 @@
1
+ require_relative "errors"
2
+
3
+ module Rcal
4
+ module RecurrenceBuilder
5
+ VALID_FREQUENCIES = %w[daily weekly monthly yearly].freeze
6
+
7
+ VALID_DAYS = %w[SU MO TU WE TH FR SA].freeze
8
+
9
+ # Maps full and abbreviated day names to RFC 5545 BYDAY abbreviations.
10
+ # Case-insensitive lookup via downcase key.
11
+ DAY_ALIASES = {
12
+ "sunday" => "SU", "sun" => "SU", "su" => "SU",
13
+ "monday" => "MO", "mon" => "MO", "mo" => "MO",
14
+ "tuesday" => "TU", "tue" => "TU", "tu" => "TU",
15
+ "wednesday" => "WE", "wed" => "WE", "we" => "WE",
16
+ "thursday" => "TH", "thu" => "TH", "th" => "TH",
17
+ "friday" => "FR", "fri" => "FR", "fr" => "FR",
18
+ "saturday" => "SA", "sat" => "SA", "sa" => "SA"
19
+ }.freeze
20
+
21
+ class << self
22
+ # Builds an RFC 5545 RRULE string from structured options.
23
+ #
24
+ # @param freq [String] Recurrence frequency: daily, weekly, monthly, yearly (required)
25
+ # @param days [String, nil] Comma-separated day names/abbreviations (e.g., "MO,WE,FR" or "Monday,Wed")
26
+ # @param count [String, Integer, nil] Number of occurrences
27
+ # @param until_date [String, nil] End date for recurrence (ISO 8601 or natural language date string)
28
+ # @param interval [String, Integer, nil] Repeat every N periods (default: 1, omitted when 1)
29
+ #
30
+ # @return [Array<String>] Single-element array with the RRULE string, e.g., ["RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10"]
31
+ # @raise [Rcal::Error] If options are invalid
32
+ def build(freq:, days: nil, count: nil, until_date: nil, interval: nil)
33
+ validate_freq!(freq)
34
+ validate_count_until_exclusivity!(count, until_date)
35
+
36
+ parts = ["FREQ=#{freq.upcase}"]
37
+
38
+ interval_val = parse_interval(interval)
39
+ parts << "INTERVAL=#{interval_val}" if interval_val && interval_val > 1
40
+ parts << "BYDAY=#{normalize_days(days).join(",")}" if days
41
+ parts << "COUNT=#{parse_count(count)}" if count
42
+ parts << "UNTIL=#{format_until(until_date)}" if until_date
43
+
44
+ ["RRULE:#{parts.join(";")}"]
45
+ end
46
+
47
+ private
48
+
49
+ def validate_freq!(freq)
50
+ unless VALID_FREQUENCIES.include?(freq.to_s.downcase)
51
+ raise Rcal::Error,
52
+ "Invalid recurrence frequency: #{freq}. " \
53
+ "Valid values: #{VALID_FREQUENCIES.join(", ")}"
54
+ end
55
+ end
56
+
57
+ def validate_count_until_exclusivity!(count, until_date)
58
+ if count && until_date
59
+ raise Rcal::Error, "Cannot specify both --count and --until. Use one or the other."
60
+ end
61
+ end
62
+
63
+ def normalize_days(days_input)
64
+ day_list = days_input.to_s.split(",").map(&:strip)
65
+
66
+ day_list.map do |day|
67
+ normalized = DAY_ALIASES[day.downcase]
68
+
69
+ if normalized.nil?
70
+ raise Rcal::Error,
71
+ "Invalid day: #{day}. " \
72
+ "Valid values: #{VALID_DAYS.join(", ")} (or full names like Monday, Tue, etc.)"
73
+ end
74
+
75
+ normalized
76
+ end
77
+ end
78
+
79
+ def parse_count(count)
80
+ val = count.to_i
81
+
82
+ unless val.positive?
83
+ raise Rcal::Error, "Count must be a positive integer, got: #{count}"
84
+ end
85
+
86
+ val
87
+ end
88
+
89
+ def parse_interval(interval)
90
+ return if interval.nil?
91
+
92
+ val = interval.to_i
93
+
94
+ unless val.positive?
95
+ raise Rcal::Error, "Interval must be a positive integer, got: #{interval}"
96
+ end
97
+
98
+ val
99
+ end
100
+
101
+ def format_until(until_date)
102
+ # Parse the date and format as YYYYMMDD for all-day style UNTIL,
103
+ # or YYYYMMDDTHHMMSSZ for datetime UNTIL.
104
+ # Google Calendar accepts both; we use the date-only form for simplicity.
105
+ date = Date.parse(until_date.to_s)
106
+ date.strftime("%Y%m%dT235959Z")
107
+ rescue Date::Error, ArgumentError
108
+ raise Rcal::Error,
109
+ "Could not parse until date: #{until_date}. Use a date like '2024-12-31'."
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,57 @@
1
+ require_relative "calendar_service"
2
+
3
+ module Rcal
4
+ module TimezoneResolver
5
+ # IANA timezone mappings for common Ruby Time#zone abbreviations.
6
+ # Ruby's Time#zone returns abbreviated names (e.g., "EST") which are
7
+ # ambiguous and not valid IANA identifiers. This maps the most common
8
+ # US/international abbreviations to their IANA equivalents.
9
+ ZONE_ABBREVIATIONS = {
10
+ "EST" => "America/New_York",
11
+ "EDT" => "America/New_York",
12
+ "CST" => "America/Chicago",
13
+ "CDT" => "America/Chicago",
14
+ "MST" => "America/Denver",
15
+ "MDT" => "America/Denver",
16
+ "PST" => "America/Los_Angeles",
17
+ "PDT" => "America/Los_Angeles",
18
+ "AKST" => "America/Anchorage",
19
+ "AKDT" => "America/Anchorage",
20
+ "HST" => "Pacific/Honolulu",
21
+ "UTC" => "Etc/UTC",
22
+ "GMT" => "Etc/GMT"
23
+ }.freeze
24
+
25
+ class << self
26
+ # Resolves the timezone to use for an event, using a layered approach:
27
+ # 1. Explicit timezone (from --timezone flag)
28
+ # 2. Calendar's default timezone (from Google Calendar API)
29
+ # 3. System local timezone (last resort fallback)
30
+ #
31
+ # Returns an IANA timezone string (e.g., "America/New_York").
32
+ def resolve(explicit: nil, calendar_id: nil)
33
+ explicit || calendar_timezone(calendar_id) || system_timezone
34
+ end
35
+
36
+ # Fetches the calendar's default timezone from the API.
37
+ # Returns nil on any failure (network error, missing calendar, etc.)
38
+ def calendar_timezone(calendar_id)
39
+ return if calendar_id.nil?
40
+
41
+ CalendarService.get_calendar(calendar_id: calendar_id)&.timezone
42
+ rescue => _e
43
+ nil
44
+ end
45
+
46
+ # Returns the system's local timezone as an IANA identifier.
47
+ # Tries ENV["TZ"] first, then falls back to mapping Ruby's
48
+ # Time#zone abbreviation.
49
+ def system_timezone
50
+ env_tz = ENV["TZ"]
51
+ return env_tz if env_tz && !env_tz.empty? && env_tz.include?("/")
52
+
53
+ ZONE_ABBREVIATIONS[Time.now.zone] || "Etc/UTC"
54
+ end
55
+ end
56
+ end
57
+ end
data/lib/rcal/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rcal
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: r_cal
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drew Bragg
@@ -277,10 +277,12 @@ files:
277
277
  - lib/rcal/adapters/ics_parser/icalendar.rb
278
278
  - lib/rcal/auth.rb
279
279
  - lib/rcal/calendar_service.rb
280
+ - lib/rcal/color_map.rb
280
281
  - lib/rcal/command.rb
281
282
  - lib/rcal/commands.rb
282
283
  - lib/rcal/commands/add.rb
283
284
  - lib/rcal/commands/agenda.rb
285
+ - lib/rcal/commands/colors.rb
284
286
  - lib/rcal/commands/edit.rb
285
287
  - lib/rcal/commands/help.rb
286
288
  - lib/rcal/commands/import.rb
@@ -299,6 +301,8 @@ files:
299
301
  - lib/rcal/predicate_collection.rb
300
302
  - lib/rcal/presenters/calendar_presenter.rb
301
303
  - lib/rcal/presenters/event_presenter.rb
304
+ - lib/rcal/recurrence_builder.rb
305
+ - lib/rcal/timezone_resolver.rb
302
306
  - lib/rcal/version.rb
303
307
  - mise.toml
304
308
  homepage: https://github.com/DRBragg/rcal