r_cal 0.1.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +239 -0
  4. data/LICENSE +21 -0
  5. data/README.md +174 -0
  6. data/Rakefile +47 -0
  7. data/dev.yml +3 -0
  8. data/exe/rcal +17 -0
  9. data/lib/rcal/adapters/auth/base.rb +27 -0
  10. data/lib/rcal/adapters/auth/google.rb +71 -0
  11. data/lib/rcal/adapters/calendar/base.rb +35 -0
  12. data/lib/rcal/adapters/calendar/google.rb +161 -0
  13. data/lib/rcal/adapters/date_parser/base.rb +11 -0
  14. data/lib/rcal/adapters/date_parser/chronic.rb +86 -0
  15. data/lib/rcal/adapters/duration_parser/base.rb +15 -0
  16. data/lib/rcal/adapters/duration_parser/chronic_duration.rb +47 -0
  17. data/lib/rcal/adapters/ics_parser/base.rb +15 -0
  18. data/lib/rcal/adapters/ics_parser/icalendar.rb +68 -0
  19. data/lib/rcal/auth.rb +46 -0
  20. data/lib/rcal/calendar_service.rb +103 -0
  21. data/lib/rcal/command.rb +33 -0
  22. data/lib/rcal/commands/add.rb +147 -0
  23. data/lib/rcal/commands/agenda.rb +143 -0
  24. data/lib/rcal/commands/edit.rb +158 -0
  25. data/lib/rcal/commands/help.rb +21 -0
  26. data/lib/rcal/commands/import.rb +121 -0
  27. data/lib/rcal/commands/init.rb +158 -0
  28. data/lib/rcal/commands/list.rb +49 -0
  29. data/lib/rcal/commands/quick.rb +80 -0
  30. data/lib/rcal/commands.rb +21 -0
  31. data/lib/rcal/config.rb +68 -0
  32. data/lib/rcal/date_parser.rb +27 -0
  33. data/lib/rcal/duration_parser.rb +31 -0
  34. data/lib/rcal/entry_point.rb +10 -0
  35. data/lib/rcal/errors.rb +7 -0
  36. data/lib/rcal/formatters/agenda.rb +118 -0
  37. data/lib/rcal/ics_parser.rb +31 -0
  38. data/lib/rcal/models/calendar.rb +61 -0
  39. data/lib/rcal/models/event.rb +140 -0
  40. data/lib/rcal/predicate_collection.rb +65 -0
  41. data/lib/rcal/presenters/calendar_presenter.rb +34 -0
  42. data/lib/rcal/presenters/event_presenter.rb +98 -0
  43. data/lib/rcal/version.rb +5 -0
  44. data/lib/rcal.rb +27 -0
  45. data/mise.toml +2 -0
  46. metadata +328 -0
@@ -0,0 +1,161 @@
1
+ require "time"
2
+ require "google/apis/calendar_v3"
3
+ require_relative "base"
4
+ require_relative "../../models/calendar"
5
+ require_relative "../../models/event"
6
+
7
+ module Rcal
8
+ module Adapters
9
+ module Calendar
10
+ class Google < Base
11
+ def initialize(service:)
12
+ @service = service
13
+ end
14
+
15
+ def list_calendars
16
+ response = @service.list_calendar_lists
17
+ items = response.items || []
18
+
19
+ items.map { |cal| build_calendar(cal) }
20
+ end
21
+
22
+ def list_events(calendar_id:, time_min:, time_max:)
23
+ response = @service.list_events(
24
+ calendar_id,
25
+ time_min: time_min.iso8601,
26
+ time_max: time_max.iso8601,
27
+ single_events: true,
28
+ order_by: "startTime"
29
+ )
30
+
31
+ items = response.items || []
32
+
33
+ items.map { |evt| build_event(evt, calendar_id: calendar_id) }
34
+ end
35
+
36
+ def get_event(calendar_id:, event_id:)
37
+ google_event = @service.get_event(calendar_id, event_id)
38
+ build_event(google_event, calendar_id: calendar_id)
39
+ end
40
+
41
+ def create_event(calendar_id:, event:)
42
+ google_event = build_google_event(event)
43
+ response = @service.insert_event(calendar_id, google_event)
44
+ build_event(response, calendar_id: calendar_id)
45
+ end
46
+
47
+ def update_event(calendar_id:, event_id:, event:)
48
+ google_event = build_google_event(event)
49
+ response = @service.update_event(calendar_id, event_id, google_event)
50
+ build_event(response, calendar_id: calendar_id)
51
+ end
52
+
53
+ def delete_event(calendar_id:, event_id:)
54
+ @service.delete_event(calendar_id, event_id)
55
+ true
56
+ end
57
+
58
+ def quick_add(calendar_id:, text:)
59
+ response = @service.quick_add_event(calendar_id, text)
60
+ build_event(response, calendar_id: calendar_id)
61
+ end
62
+
63
+ private
64
+
65
+ def build_calendar(google_calendar)
66
+ Rcal::Calendar.new(
67
+ id: google_calendar.id,
68
+ name: google_calendar.summary,
69
+ description: google_calendar.description,
70
+ timezone: google_calendar.time_zone,
71
+ color: google_calendar.background_color,
72
+ access_role: google_calendar.access_role,
73
+ primary: google_calendar.primary || false,
74
+ selected: google_calendar.selected || false
75
+ )
76
+ end
77
+
78
+ def build_event(google_event, calendar_id:)
79
+ start_time, all_day = parse_event_time(google_event.start)
80
+ end_time, = parse_event_time(google_event.end)
81
+
82
+ Rcal::Event.new(
83
+ id: google_event.id,
84
+ summary: google_event.summary,
85
+ description: google_event.description,
86
+ location: google_event.location,
87
+ start_time: start_time,
88
+ end_time: end_time,
89
+ all_day: all_day,
90
+ calendar_id: calendar_id,
91
+ transparency: google_event.transparency,
92
+ recurrence: google_event.recurrence,
93
+ recurring_event_id: google_event.recurring_event_id,
94
+ response_status: extract_self_response_status(google_event.attendees),
95
+ attendees: build_attendees(google_event.attendees),
96
+ timezone: google_event.start&.time_zone
97
+ )
98
+ end
99
+
100
+ def parse_event_time(time_obj)
101
+ return [Time.now, false] if time_obj.nil?
102
+
103
+ if time_obj.date_time
104
+ # Google returns DateTime; convert to Time for consistent arithmetic
105
+ [time_obj.date_time.to_time, false]
106
+ elsif time_obj.date
107
+ [Date.parse(time_obj.date).to_time, true]
108
+ else
109
+ [Time.now, false]
110
+ end
111
+ end
112
+
113
+ def extract_self_response_status(attendees)
114
+ return nil if attendees.nil?
115
+
116
+ self_attendee = attendees.find { |a| a.self }
117
+ self_attendee&.response_status
118
+ end
119
+
120
+ def build_attendees(google_attendees)
121
+ return nil if google_attendees.nil?
122
+
123
+ google_attendees.map do |attendee|
124
+ {
125
+ email: attendee.email,
126
+ response_status: attendee.response_status,
127
+ self: attendee.self || false,
128
+ resource: attendee.resource || false
129
+ }
130
+ end
131
+ end
132
+
133
+ def build_google_event(event)
134
+ google_event = ::Google::Apis::CalendarV3::Event.new(
135
+ summary: event.summary,
136
+ description: event.description,
137
+ location: event.location
138
+ )
139
+
140
+ if event.all_day?
141
+ google_event.start = ::Google::Apis::CalendarV3::EventDateTime.new(
142
+ date: event.start_time.to_date.to_s
143
+ )
144
+ google_event.end = ::Google::Apis::CalendarV3::EventDateTime.new(
145
+ date: event.end_time.to_date.to_s
146
+ )
147
+ else
148
+ google_event.start = ::Google::Apis::CalendarV3::EventDateTime.new(
149
+ date_time: event.start_time.iso8601
150
+ )
151
+ google_event.end = ::Google::Apis::CalendarV3::EventDateTime.new(
152
+ date_time: event.end_time.iso8601
153
+ )
154
+ end
155
+
156
+ google_event
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,11 @@
1
+ module Rcal
2
+ module Adapters
3
+ module DateParser
4
+ class Base
5
+ def parse(input)
6
+ raise NotImplementedError, "#{self.class} must implement #parse"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,86 @@
1
+ require "chronic"
2
+ require "date"
3
+ require_relative "base"
4
+ require_relative "../../errors"
5
+
6
+ module Rcal
7
+ module Adapters
8
+ module DateParser
9
+ class Chronic < Base
10
+ # Custom relative date pattern: +N, +Nd, +Nw
11
+ RELATIVE_PATTERN = /\A\+(\d+)(d|w)?\z/i
12
+
13
+ class << self
14
+ def parse(input)
15
+ new.parse(input)
16
+ end
17
+ end
18
+
19
+ def parse(input)
20
+ normalized = normalize_input(input)
21
+ validate_input!(normalized)
22
+
23
+ result = parse_relative(normalized) ||
24
+ parse_with_chronic(normalized)
25
+
26
+ raise Rcal::ParseError, "Could not parse: #{input.inspect}" if result.nil?
27
+
28
+ normalize_result(result, has_time_component?(normalized))
29
+ end
30
+
31
+ private
32
+
33
+ def normalize_input(input)
34
+ input.to_s.strip
35
+ end
36
+
37
+ def validate_input!(input)
38
+ raise Rcal::ParseError, "Input cannot be empty" if input.empty?
39
+ end
40
+
41
+ def parse_relative(input)
42
+ match = input.match(RELATIVE_PATTERN)
43
+ return nil unless match
44
+
45
+ count = match[1].to_i
46
+ unit = match[2]&.downcase
47
+
48
+ case unit
49
+ when "w"
50
+ Date.today + (count * 7)
51
+ else
52
+ Date.today + count
53
+ end
54
+ end
55
+
56
+ def parse_with_chronic(input)
57
+ ::Chronic.parse(input)
58
+ end
59
+
60
+ def has_time_component?(input)
61
+ # Check if input contains time-related patterns
62
+ time_patterns = [
63
+ /\d{1,2}:\d{2}/, # 14:30, 2:30
64
+ /\d{1,2}\s*(am|pm)/i, # 3pm, 3 pm
65
+ /at\s+\d/i, # at 3, at 14
66
+ /noon/i,
67
+ /midnight/i
68
+ ]
69
+
70
+ time_patterns.any? { |pattern| input.match?(pattern) }
71
+ end
72
+
73
+ def normalize_result(result, preserve_time)
74
+ case result
75
+ when Time
76
+ preserve_time ? result : result.to_date
77
+ when Date
78
+ result
79
+ else
80
+ result.to_date
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,15 @@
1
+ module Rcal
2
+ module Adapters
3
+ module DurationParser
4
+ class Base
5
+ def parse(input)
6
+ raise NotImplementedError, "#{self.class} must implement #parse"
7
+ end
8
+
9
+ def parse_minutes(input)
10
+ raise NotImplementedError, "#{self.class} must implement #parse_minutes"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ require "chronic_duration"
2
+ require_relative "base"
3
+ require_relative "../../errors"
4
+
5
+ module Rcal
6
+ module Adapters
7
+ module DurationParser
8
+ class ChronicDuration < Base
9
+ class << self
10
+ def parse(input)
11
+ new.parse(input)
12
+ end
13
+
14
+ def parse_minutes(input)
15
+ new.parse_minutes(input)
16
+ end
17
+ end
18
+
19
+ def parse(input)
20
+ normalized = normalize_input(input)
21
+ validate_input!(normalized)
22
+
23
+ result = ::ChronicDuration.parse(normalized)
24
+
25
+ raise Rcal::ParseError, "Could not parse duration: #{input.inspect}" if result.nil?
26
+
27
+ result
28
+ end
29
+
30
+ def parse_minutes(input)
31
+ seconds = parse(input)
32
+ seconds / 60
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_input(input)
38
+ input.to_s.strip
39
+ end
40
+
41
+ def validate_input!(input)
42
+ raise Rcal::ParseError, "Input cannot be empty" if input.empty?
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ module Rcal
2
+ module Adapters
3
+ module IcsParser
4
+ class Base
5
+ def parse(content)
6
+ raise NotImplementedError, "#{self.class} must implement #parse"
7
+ end
8
+
9
+ def parse_file(path)
10
+ raise NotImplementedError, "#{self.class} must implement #parse_file"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ require "icalendar"
2
+ require_relative "base"
3
+ require_relative "../../models/event"
4
+ require_relative "../../errors"
5
+
6
+ module Rcal
7
+ module Adapters
8
+ module IcsParser
9
+ class Icalendar < Base
10
+ def parse(content)
11
+ calendars = ::Icalendar::Calendar.parse(content)
12
+ raise Rcal::ParseError, "Invalid ICS content" if calendars.empty?
13
+
14
+ extract_events(calendars)
15
+ rescue ::Icalendar::Parser::ParseError => e
16
+ raise Rcal::ParseError, "Failed to parse ICS: #{e.message}"
17
+ end
18
+
19
+ def parse_file(path)
20
+ raise Rcal::ParseError, "File not found: #{path}" unless File.exist?(path)
21
+
22
+ content = File.read(path)
23
+ parse(content)
24
+ end
25
+
26
+ private
27
+
28
+ def extract_events(calendars)
29
+ calendars.flat_map do |calendar|
30
+ calendar.events.map { |ics_event| build_event(ics_event) }
31
+ end
32
+ end
33
+
34
+ def build_event(ics_event)
35
+ start_time, all_day = parse_datetime(ics_event.dtstart)
36
+ end_time, = parse_datetime(ics_event.dtend)
37
+
38
+ Rcal::Event.new(
39
+ summary: ics_event.summary&.to_s,
40
+ description: presence(ics_event.description&.to_s),
41
+ location: presence(ics_event.location&.to_s),
42
+ start_time: start_time,
43
+ end_time: end_time,
44
+ all_day: all_day
45
+ )
46
+ end
47
+
48
+ def presence(value)
49
+ return nil if value.nil? || value.empty?
50
+ value
51
+ end
52
+
53
+ def parse_datetime(dt)
54
+ return [Time.now, false] if dt.nil?
55
+
56
+ case dt
57
+ when ::Icalendar::Values::Date
58
+ [dt.to_time, true]
59
+ when ::Icalendar::Values::DateTime
60
+ [dt.to_time, false]
61
+ else
62
+ [dt.to_time, false]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/rcal/auth.rb ADDED
@@ -0,0 +1,46 @@
1
+ require_relative "adapters/auth/google"
2
+ require_relative "config"
3
+
4
+ module Rcal
5
+ class Auth
6
+ class << self
7
+ def store_credentials(credentials)
8
+ adapter.store_credentials(credentials)
9
+ end
10
+
11
+ def load_credentials
12
+ adapter.load_credentials
13
+ end
14
+
15
+ def authenticated?
16
+ adapter.authenticated?
17
+ end
18
+
19
+ def token_expired?
20
+ adapter.token_expired?
21
+ end
22
+
23
+ def clear_credentials
24
+ adapter.clear_credentials
25
+ end
26
+
27
+ def adapter
28
+ @adapter ||= default_adapter
29
+ end
30
+
31
+ attr_writer :adapter
32
+
33
+ def reset_adapter!
34
+ @adapter = nil
35
+ end
36
+
37
+ private
38
+
39
+ def default_adapter
40
+ Adapters::Auth::Google.new(
41
+ token_path: File.join(Configuration.data_dir, "tokens.json")
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,103 @@
1
+ require "json"
2
+ require "yaml"
3
+ require "google/apis/calendar_v3"
4
+ require "googleauth"
5
+ require_relative "adapters/calendar/google"
6
+ require_relative "auth"
7
+ require_relative "config"
8
+
9
+ module Rcal
10
+ class CalendarService
11
+ class << self
12
+ def list_calendars
13
+ adapter.list_calendars
14
+ end
15
+
16
+ def list_events(calendar_id:, time_min:, time_max:)
17
+ adapter.list_events(
18
+ calendar_id: calendar_id,
19
+ time_min: time_min,
20
+ time_max: time_max
21
+ )
22
+ end
23
+
24
+ def get_event(calendar_id:, event_id:)
25
+ adapter.get_event(calendar_id: calendar_id, event_id: event_id)
26
+ end
27
+
28
+ def create_event(calendar_id:, event:)
29
+ adapter.create_event(calendar_id: calendar_id, event: event)
30
+ end
31
+
32
+ def update_event(calendar_id:, event_id:, event:)
33
+ adapter.update_event(calendar_id: calendar_id, event_id: event_id, event: event)
34
+ end
35
+
36
+ def delete_event(calendar_id:, event_id:)
37
+ adapter.delete_event(calendar_id: calendar_id, event_id: event_id)
38
+ end
39
+
40
+ def quick_add(calendar_id:, text:)
41
+ adapter.quick_add(calendar_id: calendar_id, text: text)
42
+ end
43
+
44
+ def adapter
45
+ @adapter ||= default_adapter
46
+ end
47
+
48
+ attr_writer :adapter
49
+
50
+ def reset_adapter!
51
+ @adapter = nil
52
+ end
53
+
54
+ private
55
+
56
+ def default_adapter
57
+ Adapters::Calendar::Google.new(service: build_calendar_service)
58
+ end
59
+
60
+ def build_calendar_service
61
+ service = Google::Apis::CalendarV3::CalendarService.new
62
+ service.authorization = load_credentials
63
+ service
64
+ end
65
+
66
+ def load_credentials
67
+ client_creds = load_client_credentials
68
+ token_data = load_google_token_data
69
+
70
+ return nil if client_creds.nil? || token_data.nil?
71
+
72
+ Google::Auth::UserRefreshCredentials.new(
73
+ client_id: client_creds["client_id"],
74
+ client_secret: client_creds["client_secret"],
75
+ access_token: token_data["access_token"],
76
+ refresh_token: token_data["refresh_token"],
77
+ expires_at: token_data["expiration_time_millis"] ? Time.at(token_data["expiration_time_millis"] / 1000) : nil
78
+ )
79
+ end
80
+
81
+ def load_client_credentials
82
+ creds_file = File.join(Configuration.data_dir, "client_credentials.json")
83
+ return nil unless File.exist?(creds_file)
84
+
85
+ JSON.parse(File.read(creds_file))
86
+ rescue JSON::ParserError
87
+ nil
88
+ end
89
+
90
+ def load_google_token_data
91
+ token_file = File.join(Configuration.data_dir, "google_tokens.yaml")
92
+ return nil unless File.exist?(token_file)
93
+
94
+ yaml_data = YAML.safe_load_file(token_file)
95
+ return nil unless yaml_data&.key?("default")
96
+
97
+ JSON.parse(yaml_data["default"])
98
+ rescue JSON::ParserError, Psych::SyntaxError
99
+ nil
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,33 @@
1
+ require "cli/kit"
2
+
3
+ module Rcal
4
+ class Command < CLI::Kit::BaseCommand
5
+ def call(args, command_name)
6
+ if help_requested?(args)
7
+ display_help
8
+ return
9
+ end
10
+
11
+ run(args, command_name)
12
+ end
13
+
14
+ # Subclasses implement this instead of call
15
+ def run(args, command_name)
16
+ raise NotImplementedError, "#{self.class} must implement #run"
17
+ end
18
+
19
+ private
20
+
21
+ def help_requested?(args)
22
+ args.any? { |arg| arg == "help" || arg == "-h" || arg == "--help" }
23
+ end
24
+
25
+ def display_help
26
+ if self.class.respond_to?(:help) && self.class.help
27
+ puts self.class.help
28
+ else
29
+ puts "No help available for this command."
30
+ end
31
+ end
32
+ end
33
+ end