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 +4 -4
- data/Gemfile.lock +2 -2
- data/lib/rcal/adapters/calendar/base.rb +4 -0
- data/lib/rcal/adapters/calendar/google.rb +21 -6
- data/lib/rcal/calendar_service.rb +4 -0
- data/lib/rcal/color_map.rb +40 -0
- data/lib/rcal/commands/add.rb +65 -3
- data/lib/rcal/commands/colors.rb +34 -0
- data/lib/rcal/commands/edit.rb +84 -2
- data/lib/rcal/commands.rb +1 -0
- data/lib/rcal/models/event.rb +5 -2
- data/lib/rcal/recurrence_builder.rb +113 -0
- data/lib/rcal/timezone_resolver.rb +57 -0
- data/lib/rcal/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3197ee3a981b8c987ffc5aaf6e2676a91e602359562e7143ed9158d712e43711
|
|
4
|
+
data.tar.gz: 26d484aa6eb13877e489215f353c6f035d208e054201643ca346aa19ba8c74fd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
data/lib/rcal/commands/add.rb
CHANGED
|
@@ -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
|
data/lib/rcal/commands/edit.rb
CHANGED
|
@@ -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"
|
data/lib/rcal/models/event.rb
CHANGED
|
@@ -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
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.
|
|
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
|