particle-calendar 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.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ BusyPeriod = Data.define(:starts_at, :ends_at) do
5
+ alias_method :initialize_data, :initialize
6
+
7
+ def initialize(starts_at:, ends_at:)
8
+ raise ArgumentError, 'busy period end must be after start' unless ends_at > starts_at
9
+
10
+ initialize_data(starts_at: starts_at.getutc.freeze, ends_at: ends_at.getutc.freeze)
11
+ end
12
+
13
+ def intersects?(starts_at, ends_at)
14
+ self.starts_at < ends_at && self.ends_at > starts_at
15
+ end
16
+
17
+ private :initialize_data
18
+ end
19
+
20
+ Slot = Data.define(:starts_at, :ends_at) do
21
+ alias_method :initialize_data, :initialize
22
+
23
+ def initialize(starts_at:, ends_at:)
24
+ raise ArgumentError, 'slot end must be after start' unless ends_at > starts_at
25
+
26
+ initialize_data(starts_at: starts_at.getutc.freeze, ends_at: ends_at.getutc.freeze)
27
+ end
28
+
29
+ private :initialize_data
30
+ end
31
+
32
+ DayAvailability = Data.define(:date, :slots) do
33
+ alias_method :initialize_data, :initialize
34
+
35
+ def initialize(date:, slots:)
36
+ initialize_data(date: date, slots: slots.dup.freeze)
37
+ end
38
+
39
+ private :initialize_data
40
+ end
41
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ require_relative 'calendar_url'
8
+
9
+ module Availability
10
+ # Downloads private calendar feeds with bounded redirects, timeouts, and size.
11
+ class CalendarFetcher
12
+ MAX_REDIRECTS = 5
13
+ MAX_BYTES = 20 * 1024 * 1024
14
+
15
+ def initialize(open_timeout: 10, read_timeout: 30)
16
+ @open_timeout = open_timeout
17
+ @read_timeout = read_timeout
18
+ end
19
+
20
+ def fetch(url, label: 'Calendar', redirects_left: MAX_REDIRECTS)
21
+ uri = URI.parse(CalendarUrl.normalize(url))
22
+ validate_uri!(uri, label)
23
+ response, body = request(uri, label)
24
+ handle_response(response, body, uri, label, redirects_left)
25
+ rescue FetchError
26
+ raise
27
+ rescue URI::InvalidURIError
28
+ raise FetchError, "#{label} has an invalid URL"
29
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
30
+ raise FetchError, "#{label} download failed (#{e.class})"
31
+ end
32
+
33
+ private
34
+
35
+ def handle_response(response, body, uri, label, redirects_left)
36
+ return body if response.is_a?(Net::HTTPSuccess)
37
+ return follow_redirect(response, uri, label, redirects_left) if response.is_a?(Net::HTTPRedirection)
38
+
39
+ raise FetchError, "#{label} download failed with HTTP #{response.code}"
40
+ end
41
+
42
+ def follow_redirect(response, uri, label, redirects_left)
43
+ raise FetchError, "#{label} returned too many redirects" if redirects_left.zero?
44
+
45
+ location = response['location']
46
+ raise FetchError, "#{label} returned a redirect without a location" if location.nil? || location.empty?
47
+
48
+ fetch(URI.join(uri, location).to_s, label: label, redirects_left: redirects_left - 1)
49
+ end
50
+
51
+ def validate_uri!(uri, label)
52
+ return if uri.is_a?(URI::HTTP) && uri.host && %w[http https].include?(uri.scheme)
53
+
54
+ raise FetchError, "#{label} has an invalid HTTP(S) URL"
55
+ end
56
+
57
+ def request(uri, label)
58
+ request = build_request(uri)
59
+ Net::HTTP.start(uri.host, uri.port, **connection_options(uri)) do |http|
60
+ read_response(http, request, label)
61
+ end
62
+ end
63
+
64
+ def build_request(uri)
65
+ Net::HTTP::Get.new(uri.request_uri).tap do |request|
66
+ request['User-Agent'] = 'availability-static-generator/1.0'
67
+ request['Accept'] = 'text/calendar, text/plain;q=0.9, */*;q=0.1'
68
+ end
69
+ end
70
+
71
+ def connection_options(uri)
72
+ {
73
+ use_ssl: uri.scheme == 'https',
74
+ open_timeout: @open_timeout,
75
+ read_timeout: @read_timeout
76
+ }
77
+ end
78
+
79
+ def read_response(http, request, label)
80
+ result = nil
81
+ http.request(request) do |response|
82
+ body = stream_body(response, label) if response.is_a?(Net::HTTPSuccess)
83
+ result = [response, body]
84
+ end
85
+ result
86
+ end
87
+
88
+ def stream_body(response, label)
89
+ body = String.new(encoding: Encoding::BINARY)
90
+ response.read_body do |chunk|
91
+ if body.bytesize + chunk.bytesize > MAX_BYTES
92
+ raise FetchError, "#{label} response exceeded #{MAX_BYTES / 1_048_576} MB"
93
+ end
94
+
95
+ body << chunk
96
+ end
97
+ body
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/time'
4
+ require 'icalendar'
5
+ require 'icalendar/recurrence'
6
+ require 'logger'
7
+
8
+ require_relative 'event_timezone_validator'
9
+ require_relative 'recurrence_expander'
10
+ require_relative 'tolerant_icalendar_parser'
11
+
12
+ module Availability
13
+ # Parses ICS events and expands occurrences into privacy-safe busy periods.
14
+ class CalendarParser
15
+ MAX_EVENTS_PER_CALENDAR = 10_000
16
+ NULL_LOGGER = Logger.new(IO::NULL)
17
+
18
+ attr_reader :ignored_event_count
19
+
20
+ def initialize(timezone:)
21
+ @timezone = timezone
22
+ @ignored_event_count = 0
23
+ end
24
+
25
+ def parse(ics, range_start:, range_end:, label: 'Calendar')
26
+ reset_diagnostics
27
+ with_silenced_dependency_logging { parse_periods(ics, range_start, range_end, label) }
28
+ rescue RecurrenceExpander::LimitError
29
+ raise ParseError, "#{label} exceeded the safe recurrence expansion limit"
30
+ rescue ParseError
31
+ raise
32
+ rescue StandardError => e
33
+ raise ParseError, "#{label} could not be parsed (#{e.class})"
34
+ end
35
+
36
+ private
37
+
38
+ def parse_periods(ics, range_start, range_end, label)
39
+ events = parse_events(ics, label)
40
+ valid_events = events.select { |event| valid_event?(event) }
41
+ @ignored_event_count += events.length - valid_events.length
42
+ override_keys = recurrence_override_keys(valid_events)
43
+ busy_periods(valid_events, override_keys, range_start, range_end)
44
+ end
45
+
46
+ def parse_events(ics, label)
47
+ calendars = parse_calendars(ics)
48
+ raise ParseError, "#{label} did not contain a calendar" if calendars.empty?
49
+
50
+ events = calendars.flat_map(&:events)
51
+ raise ParseError, "#{label} exceeded the safe event limit" if events.length > MAX_EVENTS_PER_CALENDAR
52
+
53
+ events
54
+ end
55
+
56
+ def parse_calendars(ics)
57
+ parse_with_tolerance(ics)
58
+ rescue ArgumentError
59
+ parse_with_tolerance(Icalendar::Parser.clean_bad_wrapping(ics))
60
+ end
61
+
62
+ def parse_with_tolerance(ics)
63
+ TolerantIcalendarParser.new(ics).tap do |parser|
64
+ parser.component_class = Icalendar::Calendar
65
+ end.parse
66
+ end
67
+
68
+ def valid_event?(event)
69
+ raise InvalidEventError, 'event contains an invalid property' if TolerantIcalendarParser.invalid_event?(event)
70
+
71
+ validate_event!(event)
72
+ value_to_utc(event.recurrence_id) if event.recurrence_id
73
+ true
74
+ rescue StandardError
75
+ false
76
+ end
77
+
78
+ def validate_event!(event)
79
+ EventTimezoneValidator.validate!(event)
80
+ return if cancelled?(event)
81
+
82
+ raise InvalidEventError, 'event is missing DTSTART' unless event.dtstart
83
+
84
+ return unless event.dtend || event.duration
85
+
86
+ starts_at = event.schedule.start_time
87
+ ends_at = event.schedule.end_time
88
+ raise InvalidEventError, 'event duration must be positive' unless ends_at > starts_at
89
+ end
90
+
91
+ def busy_periods(events, override_keys, range_start, range_end)
92
+ events.reject { |event| cancelled?(event) || implicit_instant?(event) }.flat_map do |event|
93
+ event_periods(event, override_keys, range_start, range_end)
94
+ end
95
+ end
96
+
97
+ def event_periods(event, override_keys, range_start, range_end)
98
+ occurrences(event, range_start, range_end).filter_map do |occurrence|
99
+ next if master_occurrence_overridden?(event, occurrence, override_keys)
100
+
101
+ period = occurrence_to_period(event, occurrence)
102
+ period if period&.intersects?(range_start, range_end)
103
+ end
104
+ rescue RecurrenceExpander::LimitError
105
+ raise
106
+ rescue StandardError
107
+ @ignored_event_count += 1
108
+ []
109
+ end
110
+
111
+ def occurrences(event, range_start, range_end)
112
+ return [] unless event.dtstart
113
+
114
+ @recurrence_expander.expand(event, range_start: range_start, range_end: range_end)
115
+ end
116
+
117
+ def occurrence_to_period(event, occurrence)
118
+ starts_at, ends_at = period_boundaries(event, occurrence)
119
+ raise InvalidEventError, 'event occurrence duration must be positive' unless ends_at > starts_at
120
+
121
+ BusyPeriod.new(starts_at, ends_at)
122
+ end
123
+
124
+ def period_boundaries(event, occurrence)
125
+ return all_day_boundaries(occurrence) if all_day?(event)
126
+ return floating_boundaries(occurrence) if floating?(event)
127
+
128
+ [occurrence.start_time.to_time.utc, occurrence.end_time.to_time.utc]
129
+ end
130
+
131
+ def all_day_boundaries(occurrence)
132
+ # DATE values are created in the process zone; recover their calendar date
133
+ # before placing the all-day span in the configured zone.
134
+ start_date = occurrence.start_time.getlocal.to_date
135
+ end_date = [occurrence.end_time.getlocal.to_date, start_date + 1].max
136
+ [local_midnight(start_date), local_midnight(end_date)]
137
+ end
138
+
139
+ def floating_boundaries(occurrence)
140
+ [floating_time_to_utc(occurrence.start_time), floating_time_to_utc(occurrence.end_time)]
141
+ end
142
+
143
+ def all_day?(event)
144
+ event.dtstart.is_a?(Icalendar::Values::Date)
145
+ end
146
+
147
+ def floating?(event)
148
+ event.dtstart.ical_params['tzid'].nil?
149
+ end
150
+
151
+ def implicit_instant?(event)
152
+ !all_day?(event) && !event.dtend && !event.duration
153
+ end
154
+
155
+ def floating_time_to_utc(time)
156
+ @timezone.local_time(time.year, time.month, time.day, time.hour, time.min, time.sec).utc
157
+ end
158
+
159
+ def local_midnight(date)
160
+ @timezone.local_time(date.year, date.month, date.day, 0, 0, 0).utc
161
+ end
162
+
163
+ def cancelled?(event)
164
+ event.status && event.status.to_s.casecmp('CANCELLED').zero?
165
+ end
166
+
167
+ def recurrence_override_keys(events)
168
+ events.each_with_object({}) do |event, keys|
169
+ next unless event.recurrence_id && event.uid
170
+
171
+ keys[[event.uid.to_s, value_to_utc(event.recurrence_id)]] = true
172
+ end
173
+ end
174
+
175
+ def master_occurrence_overridden?(event, occurrence, override_keys)
176
+ return false if event.recurrence_id || !event.uid
177
+
178
+ override_keys.key?([event.uid.to_s, occurrence.start_time.to_time.utc])
179
+ end
180
+
181
+ def value_to_utc(value)
182
+ Icalendar::Recurrence::TimeUtil.to_time(value).to_time.utc
183
+ end
184
+
185
+ def reset_diagnostics
186
+ @ignored_event_count = 0
187
+ @recurrence_expander = RecurrenceExpander.new
188
+ end
189
+
190
+ def with_silenced_dependency_logging
191
+ previous_logger = Icalendar.logger
192
+ Icalendar.logger = NULL_LOGGER
193
+ yield
194
+ ensure
195
+ Icalendar.logger = previous_logger
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ # Normalizes calendar subscription schemes into URLs Net::HTTP can fetch.
5
+ module CalendarUrl
6
+ module_function
7
+
8
+ def normalize(value)
9
+ value.sub(/\Awebcal:/i, 'https:')
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'securerandom'
5
+
6
+ module Availability
7
+ # Command-line interface exposed by the installed particle executable.
8
+ class CLI
9
+ SUCCESS = 0
10
+ ERROR = 1
11
+ USAGE_ERROR = 2
12
+
13
+ def initialize(args:, output: $stdout, error: $stderr, env: ENV, working_directory: Dir.pwd,
14
+ random_path: -> { SecureRandom.hex(12) })
15
+ @args = args.dup
16
+ @output = output
17
+ @error = error
18
+ @env = env
19
+ @working_directory = working_directory
20
+ @random_path = random_path
21
+ end
22
+
23
+ def run
24
+ dispatch(@args.shift)
25
+ rescue OptionParser::ParseError => e
26
+ @error.puts(e.message)
27
+ USAGE_ERROR
28
+ rescue Availability::Error => e
29
+ @error.puts(e.message)
30
+ ERROR
31
+ rescue StandardError => e
32
+ @error.puts("Particle failed unexpectedly (#{e.class}).")
33
+ ERROR
34
+ end
35
+
36
+ private
37
+
38
+ def dispatch(command)
39
+ return show_help if command.nil? || %w[help --help -h].include?(command)
40
+
41
+ case command
42
+ when 'generate' then generate
43
+ when 'setup' then setup
44
+ when 'version', '--version', '-v' then show_version
45
+ else
46
+ @error.puts("Unknown command: #{command}")
47
+ @error.puts(help)
48
+ USAGE_ERROR
49
+ end
50
+ end
51
+
52
+ def generate
53
+ options = generation_defaults
54
+ parser = generation_parser(options)
55
+ parser.parse!(@args)
56
+ return show_parser(parser) if options.delete(:help)
57
+
58
+ reject_arguments!(parser)
59
+ run_application(options)
60
+ SUCCESS
61
+ rescue Availability::Error => e
62
+ raise e.class, "Generation failed: #{e.message}"
63
+ end
64
+
65
+ def run_application(options)
66
+ Application.new(
67
+ config_path: options.fetch(:config),
68
+ output_dir: options.fetch(:output),
69
+ template_path: Assets.index_template_path,
70
+ favicon_path: Assets.favicon_path,
71
+ output: @output
72
+ ).run
73
+ end
74
+
75
+ def generation_defaults
76
+ {
77
+ config: @env.fetch('PARTICLE_CONFIG') do
78
+ @env.fetch('AVAILABILITY_CONFIG', File.join(@working_directory, 'particle.yml'))
79
+ end,
80
+ output: @env.fetch('PARTICLE_OUTPUT', File.join(@working_directory, 'public'))
81
+ }
82
+ end
83
+
84
+ def generation_parser(options)
85
+ OptionParser.new do |parser|
86
+ parser.banner = 'Usage: particle generate [options]'
87
+ parser.on('-c', '--config PATH', 'Configuration file (or PARTICLE_CONFIG)') do |path|
88
+ options[:config] = path
89
+ end
90
+ parser.on('-o', '--output DIR', 'Static output directory (or PARTICLE_OUTPUT)') do |path|
91
+ options[:output] = path
92
+ end
93
+ parser.on('-h', '--help', 'Show this help') { options[:help] = true }
94
+ end
95
+ end
96
+
97
+ def setup
98
+ options = setup_defaults
99
+ parser = setup_parser(options)
100
+ parser.parse!(@args)
101
+ return show_parser(parser) if options.delete(:help)
102
+
103
+ reject_arguments!(parser)
104
+
105
+ setup = Setup.new(**options)
106
+ setup.run
107
+ print_setup_result(setup)
108
+ SUCCESS
109
+ end
110
+
111
+ def setup_defaults
112
+ {
113
+ config_path: File.join(@working_directory, 'particle.yml'),
114
+ nginx_path: File.join(@working_directory, 'particle.nginx.conf'),
115
+ output_dir: File.join(@working_directory, 'public'),
116
+ server_name: 'example.com',
117
+ url_path: "/#{@random_path.call}/"
118
+ }
119
+ end
120
+
121
+ def setup_parser(options)
122
+ OptionParser.new do |parser|
123
+ parser.banner = 'Usage: particle setup [options]'
124
+ add_setup_path_options(parser, options)
125
+ add_setup_nginx_options(parser, options)
126
+ parser.on('-h', '--help', 'Show this help') { options[:help] = true }
127
+ end
128
+ end
129
+
130
+ def add_setup_path_options(parser, options)
131
+ parser.on('-c', '--config PATH', 'Sample configuration destination') { |path| options[:config_path] = path }
132
+ parser.on('-n', '--nginx PATH', 'Sample Nginx configuration destination') { |path| options[:nginx_path] = path }
133
+ parser.on('-o', '--output DIR', 'Generated static output directory') { |path| options[:output_dir] = path }
134
+ end
135
+
136
+ def add_setup_nginx_options(parser, options)
137
+ parser.on('--server-name NAME', 'Nginx server_name value') { |name| options[:server_name] = name }
138
+ parser.on('--url-path PATH', 'Private URL path, including leading/trailing slashes') do |path|
139
+ options[:url_path] = path
140
+ end
141
+ end
142
+
143
+ def reject_arguments!(parser)
144
+ return if @args.empty?
145
+
146
+ raise OptionParser::InvalidArgument, "Unexpected arguments: #{@args.join(' ')}\n#{parser}"
147
+ end
148
+
149
+ def print_setup_result(setup)
150
+ @output.puts("Created #{setup.config_path} (mode 0600)")
151
+ @output.puts("Created #{setup.nginx_path}")
152
+ @output.puts("Created output directory #{setup.output_dir}")
153
+ @output.puts('Next: edit the config, run particle generate, then review and install the Nginx file.')
154
+ end
155
+
156
+ def show_help
157
+ @output.puts(help)
158
+ SUCCESS
159
+ end
160
+
161
+ def show_parser(parser)
162
+ @output.puts(parser)
163
+ SUCCESS
164
+ end
165
+
166
+ def show_version
167
+ @output.puts("Particle #{VERSION}")
168
+ SUCCESS
169
+ end
170
+
171
+ def help
172
+ <<~HELP
173
+ Usage: particle COMMAND [options]
174
+
175
+ Commands:
176
+ setup Create sample config and Nginx files
177
+ generate Fetch calendars and generate static HTML
178
+ version Print the installed version
179
+
180
+ Run `particle COMMAND --help` for command-specific options.
181
+ HELP
182
+ end
183
+ end
184
+ end