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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'uri'
5
+ require 'yaml'
6
+ require 'tzinfo'
7
+
8
+ require_relative 'calendar_url'
9
+ require_relative 'config_value_parser'
10
+
11
+ module Availability
12
+ # Loads, normalizes, and validates generator configuration.
13
+ class Config
14
+ include ConfigValueParser
15
+
16
+ TOP_LEVEL_KEYS = %w[
17
+ enabled timezone calendar_urls days_to_show minimum_slot_minutes event_buffer availability
18
+ ].freeze
19
+ EVENT_BUFFER_KEYS = %w[before_minutes after_minutes].freeze
20
+ WEEKDAYS = %w[sunday monday tuesday wednesday thursday friday saturday].freeze
21
+ ENV_REFERENCE = /\A\$\{([A-Z][A-Z0-9_]*)\}\z/
22
+ TIME_FORMAT = /\A(?:[01]\d|2[0-3]):[0-5]\d\z/
23
+ MAX_DAYS_TO_SHOW = 366
24
+ MAX_CALENDAR_URLS = 20
25
+
26
+ attr_reader :enabled, :timezone, :calendar_urls, :days_to_show,
27
+ :minimum_slot_minutes, :buffer_before_minutes, :buffer_after_minutes
28
+
29
+ def self.load(path, env: ENV)
30
+ raise ConfigError, "configuration file not found: #{path}" unless File.file?(path)
31
+
32
+ raw = YAML.safe_load_file(path, permitted_classes: [], permitted_symbols: [], aliases: false)
33
+ raise ConfigError, 'configuration root must be a mapping' unless raw.is_a?(Hash)
34
+
35
+ new(raw, env: env)
36
+ rescue Psych::Exception => e
37
+ raise ConfigError, "invalid YAML in #{path}: #{e.message.lines.first.strip}"
38
+ end
39
+
40
+ def initialize(raw, env: ENV)
41
+ @raw = stringify_keys(raw)
42
+ @env = env
43
+ validate_known_keys(@raw, TOP_LEVEL_KEYS, 'configuration')
44
+ @enabled = boolean('enabled', default: true)
45
+ @timezone = parse_timezone
46
+ @days_to_show = bounded_positive_integer('days_to_show', default: 28, maximum: MAX_DAYS_TO_SHOW)
47
+ @minimum_slot_minutes = nonnegative_integer('minimum_slot_minutes', default: 0)
48
+ parse_buffers
49
+ @availability = parse_availability
50
+ @calendar_urls = parse_calendar_urls
51
+ end
52
+
53
+ def windows_for(date)
54
+ @availability.fetch(WEEKDAYS.fetch(date.wday), @availability.fetch('default'))
55
+ end
56
+
57
+ private
58
+
59
+ def stringify_keys(value)
60
+ if value.is_a?(Hash)
61
+ return value.each_with_object({}) do |(key, item), hash|
62
+ hash[key.to_s] = stringify_keys(item)
63
+ end
64
+ end
65
+ return value.map { |item| stringify_keys(item) } if value.is_a?(Array)
66
+
67
+ value
68
+ end
69
+
70
+ def parse_timezone
71
+ name = @raw.fetch('timezone', 'Europe/Berlin')
72
+ raise ConfigError, 'timezone must be a string' unless name.is_a?(String)
73
+
74
+ TZInfo::Timezone.get(name)
75
+ rescue TZInfo::InvalidTimezoneIdentifier
76
+ raise ConfigError, "timezone is unknown: #{name}"
77
+ end
78
+
79
+ def parse_buffers
80
+ buffer = @raw.fetch('event_buffer', {})
81
+ raise ConfigError, 'event_buffer must be a mapping' unless buffer.is_a?(Hash)
82
+
83
+ validate_known_keys(buffer, EVENT_BUFFER_KEYS, 'event_buffer')
84
+ @buffer_before_minutes = nested_nonnegative_integer(buffer, 'before_minutes', 0, 'event_buffer.before_minutes')
85
+ @buffer_after_minutes = nested_nonnegative_integer(buffer, 'after_minutes', 0, 'event_buffer.after_minutes')
86
+ end
87
+
88
+ def parse_calendar_urls
89
+ # Disabled mode must not depend on feed secrets being present.
90
+ return [].freeze unless enabled
91
+
92
+ urls = @raw.fetch('calendar_urls', [])
93
+ raise ConfigError, 'calendar_urls must be an array' unless urls.is_a?(Array)
94
+ if urls.length > MAX_CALENDAR_URLS
95
+ raise ConfigError, "calendar_urls must contain at most #{MAX_CALENDAR_URLS} URLs"
96
+ end
97
+
98
+ resolved = urls.each_with_index.map { |value, index| parse_calendar_url(value, index) }
99
+ raise ConfigError, 'calendar_urls must contain at least one URL when enabled' if resolved.empty?
100
+
101
+ resolved.freeze
102
+ end
103
+
104
+ def parse_calendar_url(value, index)
105
+ label = "calendar_urls[#{index}]"
106
+ raise ConfigError, "#{label} must be a non-empty string" unless value.is_a?(String) && !value.strip.empty?
107
+
108
+ match = ENV_REFERENCE.match(value.strip)
109
+ value = resolve_environment(match[1], label) if match
110
+ validate_url(value, label)
111
+ end
112
+
113
+ def resolve_environment(name, label)
114
+ value = @env[name]
115
+ raise ConfigError, "#{label} references missing environment variable #{name}" if value.nil? || value.empty?
116
+
117
+ value
118
+ end
119
+
120
+ def validate_url(value, label)
121
+ normalized = CalendarUrl.normalize(value)
122
+ uri = URI.parse(normalized)
123
+ unless uri.is_a?(URI::HTTP) && uri.host && %w[http https].include?(uri.scheme)
124
+ raise ConfigError, "#{label} must be an HTTP(S) or webcal URL"
125
+ end
126
+
127
+ normalized
128
+ rescue URI::InvalidURIError
129
+ raise ConfigError, "#{label} must be a valid HTTP(S) or webcal URL"
130
+ end
131
+
132
+ def parse_availability
133
+ source = @raw['availability']
134
+ raise ConfigError, 'availability must be a mapping' unless source.is_a?(Hash)
135
+
136
+ unknown = source.keys - (['default'] + WEEKDAYS)
137
+ raise ConfigError, "availability has unknown key: #{unknown.first}" unless unknown.empty?
138
+ raise ConfigError, 'availability.default is required' unless source.key?('default')
139
+
140
+ source.each_with_object({}) do |(day, definition), result|
141
+ result[day] = parse_day_definition(definition, "availability.#{day}")
142
+ end.freeze
143
+ end
144
+
145
+ def parse_day_definition(definition, key)
146
+ return unavailable_day(definition, key) if unavailable?(definition)
147
+
148
+ windows = definition.is_a?(Array) ? definition : [definition]
149
+ raise ConfigError, "#{key} must contain at least one window or unavailable: true" if windows.empty?
150
+
151
+ parsed = windows.each_with_index.map { |window, index| parse_window(window, "#{key}[#{index}]") }
152
+ parsed.sort_by!(&:first)
153
+ validate_nonoverlapping_windows(parsed, key)
154
+ parsed.freeze
155
+ end
156
+
157
+ def unavailable?(definition)
158
+ definition.is_a?(Hash) && definition['unavailable'] == true
159
+ end
160
+
161
+ def unavailable_day(definition, key)
162
+ extra = definition.keys - ['unavailable']
163
+ raise ConfigError, "#{key} cannot combine unavailable with windows" unless extra.empty?
164
+
165
+ [].freeze
166
+ end
167
+
168
+ def validate_nonoverlapping_windows(windows, key)
169
+ windows.each_cons(2) do |left, right|
170
+ raise ConfigError, "#{key} windows must not overlap" if left.last > right.first
171
+ end
172
+ end
173
+
174
+ def parse_window(window, key)
175
+ raise ConfigError, "#{key} must be a start/end mapping" unless window.is_a?(Hash)
176
+
177
+ unknown = window.keys - %w[start end]
178
+ raise ConfigError, "#{key} has unknown key: #{unknown.first}" unless unknown.empty?
179
+
180
+ starts = parse_time(window['start'], "#{key}.start")
181
+ ends = parse_time(window['end'], "#{key}.end", end_of_day: true)
182
+ raise ConfigError, "#{key}.start must be earlier than #{key}.end" unless starts < ends
183
+
184
+ [starts, ends].freeze
185
+ end
186
+
187
+ def parse_time(value, key, end_of_day: false)
188
+ raise ConfigError, "#{key} must use HH:MM (24-hour time)" unless value.is_a?(String) && TIME_FORMAT.match?(value)
189
+ return 24 * 60 if end_of_day && value == '00:00'
190
+
191
+ hour, minute = value.split(':').map(&:to_i)
192
+ (hour * 60) + minute
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ # Shared scalar and schema validation helpers for configuration loading.
5
+ module ConfigValueParser
6
+ private
7
+
8
+ def validate_known_keys(hash, allowed, label)
9
+ unknown = hash.keys - allowed
10
+ raise ConfigError, "#{label} has unknown key: #{unknown.first}" unless unknown.empty?
11
+ end
12
+
13
+ def boolean(key, default:)
14
+ value = @raw.fetch(key, default)
15
+ return value if [true, false].include?(value)
16
+
17
+ raise ConfigError, "#{key} must be true or false"
18
+ end
19
+
20
+ def positive_integer(key, default:)
21
+ value = @raw.fetch(key, default)
22
+ return value if value.is_a?(Integer) && value.positive?
23
+
24
+ raise ConfigError, "#{key} must be a positive integer"
25
+ end
26
+
27
+ def bounded_positive_integer(key, default:, maximum:)
28
+ value = positive_integer(key, default: default)
29
+ return value if value <= maximum
30
+
31
+ raise ConfigError, "#{key} must be at most #{maximum}"
32
+ end
33
+
34
+ def nonnegative_integer(key, default:)
35
+ value = @raw.fetch(key, default)
36
+ return value if value.is_a?(Integer) && value >= 0
37
+
38
+ raise ConfigError, "#{key} must be a non-negative integer"
39
+ end
40
+
41
+ def nested_nonnegative_integer(hash, key, default, label)
42
+ value = hash.fetch(key, default)
43
+ return value if value.is_a?(Integer) && value >= 0
44
+
45
+ raise ConfigError, "#{label} must be a non-negative integer"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ class Error < StandardError; end
5
+ class ConfigError < Error; end
6
+ class FetchError < Error; end
7
+ class ParseError < Error; end
8
+ class InvalidEventError < Error; end
9
+ class SetupError < Error; end
10
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/time'
4
+ require 'icalendar'
5
+
6
+ module Availability
7
+ # Rejects TZID values the iCalendar parser could not resolve safely.
8
+ class EventTimezoneValidator
9
+ def self.validate!(event)
10
+ new(event).validate!
11
+ end
12
+
13
+ def initialize(event)
14
+ @event = event
15
+ end
16
+
17
+ def validate!
18
+ temporal_values.each do |value|
19
+ raise InvalidEventError, 'event contains an unresolved timezone' if unresolved_timezone?(value)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def temporal_values
26
+ values = [@event.dtstart, @event.dtend, @event.recurrence_id, @event.exdate, @event.rdate]
27
+ values.flat_map { |value| flatten_temporal_value(value) }.compact
28
+ end
29
+
30
+ def flatten_temporal_value(value)
31
+ return [] if value.nil?
32
+ return value.flat_map { |item| flatten_temporal_value(item) } if value.is_a?(Array)
33
+ if value.is_a?(Icalendar::Values::Helpers::Array)
34
+ return value.value.flat_map { |item| flatten_temporal_value(item) }
35
+ end
36
+
37
+ [value]
38
+ end
39
+
40
+ def unresolved_timezone?(value)
41
+ return false unless value.respond_to?(:ical_params)
42
+
43
+ tzid = Array(value.ical_params['tzid']).first
44
+ return false if tzid.nil? || tzid == 'UTC'
45
+ return false if resolved_time_value?(value.value)
46
+
47
+ timezone_store = value.respond_to?(:timezone_store) && value.timezone_store
48
+ timezone_store.nil? || timezone_store.retrieve(tzid).nil?
49
+ end
50
+
51
+ def resolved_time_value?(value)
52
+ value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ # Expands recurrences in bounded chunks and enforces occurrence-count limits.
5
+ class RecurrenceExpander
6
+ MAX_OCCURRENCES_PER_EVENT = 10_000
7
+ MAX_OCCURRENCES_PER_CALENDAR = 50_000
8
+ SECONDLY_CHUNK_SECONDS = 60 * 60
9
+ MINUTELY_CHUNK_SECONDS = 24 * 60 * 60
10
+
11
+ class LimitError < StandardError; end
12
+
13
+ def initialize
14
+ @calendar_occurrence_count = 0
15
+ end
16
+
17
+ def expand(event, range_start:, range_end:)
18
+ occurrences = {}
19
+ cursor = range_start
20
+ chunk_seconds = expansion_chunk_seconds(event, range_start, range_end)
21
+
22
+ while cursor < range_end
23
+ chunk_end = [cursor + chunk_seconds, range_end].min
24
+ add_occurrences(occurrences, event, cursor, chunk_end)
25
+ cursor = chunk_end
26
+ end
27
+
28
+ occurrences.values
29
+ end
30
+
31
+ private
32
+
33
+ def add_occurrences(occurrences, event, range_start, range_end)
34
+ event.occurrences_between(range_start, range_end, spans: true).each do |occurrence|
35
+ key = occurrence_key(occurrence)
36
+ next if occurrences.key?(key)
37
+
38
+ track_occurrence!(occurrences.length + 1)
39
+ occurrences[key] = occurrence
40
+ end
41
+ end
42
+
43
+ def expansion_chunk_seconds(event, range_start, range_end)
44
+ frequencies = event.rrule.map { |rule| rule.value_ical[/FREQ=([^;]+)/, 1] }
45
+ return SECONDLY_CHUNK_SECONDS if frequencies.include?('SECONDLY')
46
+ return MINUTELY_CHUNK_SECONDS if frequencies.include?('MINUTELY')
47
+
48
+ range_end - range_start
49
+ end
50
+
51
+ def occurrence_key(occurrence)
52
+ [occurrence.start_time.to_time.utc, occurrence.end_time.to_time.utc]
53
+ end
54
+
55
+ def track_occurrence!(event_count)
56
+ @calendar_occurrence_count += 1
57
+ return if within_limits?(event_count)
58
+
59
+ raise LimitError
60
+ end
61
+
62
+ def within_limits?(event_count)
63
+ event_count <= MAX_OCCURRENCES_PER_EVENT && @calendar_occurrence_count <= MAX_OCCURRENCES_PER_CALENDAR
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'erb'
5
+
6
+ module Availability
7
+ # Renders calculated availability into a self-contained static HTML page.
8
+ class Renderer
9
+ ROBOTS = "User-agent: *\nDisallow: /\n"
10
+
11
+ def initialize(template_path:)
12
+ @template = ERB.new(File.read(template_path), trim_mode: '-')
13
+ end
14
+
15
+ def render(days:, generated_at:, timezone:, enabled:, today:, days_to_show:)
16
+ view = View.new(
17
+ days: days,
18
+ generated_at: generated_at,
19
+ timezone: timezone,
20
+ enabled: enabled,
21
+ today: today,
22
+ days_to_show: days_to_show
23
+ )
24
+ @template.result(view.template_binding)
25
+ end
26
+
27
+ # Exposes only display-safe availability values and formatting helpers to ERB.
28
+ class View
29
+ WEEKDAYS = %w[monday tuesday wednesday thursday friday saturday sunday].freeze
30
+
31
+ attr_reader :enabled, :today
32
+
33
+ def initialize(days:, generated_at:, timezone:, enabled:, today:, days_to_show:)
34
+ @days = days
35
+ @generated_at = generated_at
36
+ @timezone = timezone
37
+ @enabled = enabled
38
+ @today = today
39
+ @days_to_show = days_to_show
40
+ end
41
+
42
+ def template_binding
43
+ binding
44
+ end
45
+
46
+ def h(value)
47
+ CGI.escapeHTML(value.to_s)
48
+ end
49
+
50
+ def format_date(date)
51
+ "#{date.strftime('%A')}, #{date.day} #{date.strftime('%B %Y')}"
52
+ end
53
+
54
+ def format_compact_date(date)
55
+ date.strftime('%a %-d %b')
56
+ end
57
+
58
+ def format_week_label(week)
59
+ first_date = week.compact.first.date
60
+ monday = first_date - (first_date.cwday - 1)
61
+ "Week of #{monday.day} #{monday.strftime('%B %Y')}"
62
+ end
63
+
64
+ def format_time(time)
65
+ @timezone.to_local(time.getutc).strftime('%H:%M')
66
+ end
67
+
68
+ def weeks
69
+ return [] if @days.empty?
70
+
71
+ padded_days = Array.new(@days.first.date.cwday - 1) + @days
72
+ padded_days.concat(Array.new((7 - padded_days.length) % 7))
73
+ padded_days.each_slice(7).to_a
74
+ end
75
+
76
+ def weekdays
77
+ WEEKDAYS
78
+ end
79
+
80
+ def updated_at
81
+ @timezone.to_local(@generated_at.getutc).strftime('%-d %b %Y, %H:%M %Z')
82
+ end
83
+
84
+ def timezone_name
85
+ @timezone.identifier
86
+ end
87
+
88
+ def period_description
89
+ final_date = @today + @days_to_show - 1
90
+ return @today.strftime('%-d %b %Y') if final_date == @today
91
+
92
+ start_with_year = @today.strftime('%-d %b %Y')
93
+ final_with_year = final_date.strftime('%-d %b %Y')
94
+ return "#{start_with_year}–#{final_with_year}" if @today.year != final_date.year
95
+
96
+ return "#{@today.day}–#{final_with_year}" if @today.month == final_date.month
97
+
98
+ "#{@today.strftime('%-d %b')}–#{final_with_year}"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+
6
+ module Availability
7
+ # Creates deployment starter files without replacing existing configuration.
8
+ class Setup
9
+ SERVER_NAME_PATTERN = /\A[a-zA-Z0-9.-]+\z/
10
+ URL_PATH_PATTERN = %r{\A/[a-zA-Z0-9_-]+/\z}
11
+
12
+ def initialize(config_path:, nginx_path:, output_dir:, server_name:, url_path:)
13
+ @config_path = File.expand_path(config_path)
14
+ @nginx_path = File.expand_path(nginx_path)
15
+ @output_dir = File.expand_path(output_dir)
16
+ @server_name = server_name
17
+ @url_path = url_path
18
+ end
19
+
20
+ attr_reader :config_path, :nginx_path, :output_dir
21
+
22
+ def run
23
+ validate!
24
+ ensure_targets_are_available!
25
+ create_files
26
+ FileUtils.mkdir_p(output_dir)
27
+ true
28
+ rescue SystemCallError => e
29
+ cleanup_created_files
30
+ raise SetupError, "Setup failed: #{e.message}"
31
+ end
32
+
33
+ private
34
+
35
+ def validate!
36
+ raise SetupError, 'Config and Nginx paths must be different' if config_path == nginx_path
37
+ raise SetupError, 'Output directory path must not contain newlines' if output_dir.match?(/[\r\n]/)
38
+ raise SetupError, 'Server name must contain only letters, numbers, dots, and hyphens' unless valid_server_name?
39
+ raise SetupError, 'URL path must look like /private-path/' unless @url_path.match?(URL_PATH_PATTERN)
40
+ end
41
+
42
+ def valid_server_name?
43
+ !@server_name.empty? && @server_name.match?(SERVER_NAME_PATTERN)
44
+ end
45
+
46
+ def ensure_targets_are_available!
47
+ existing = [config_path, nginx_path].select { |path| File.exist?(path) || File.symlink?(path) }
48
+ return if existing.empty?
49
+
50
+ raise SetupError, "Refusing to overwrite existing file: #{existing.first}"
51
+ end
52
+
53
+ def create_files
54
+ @created_files = []
55
+ write_exclusive(config_path, File.binread(Assets.example_config_path), 0o600)
56
+ write_exclusive(nginx_path, rendered_nginx, 0o644)
57
+ end
58
+
59
+ def write_exclusive(path, contents, mode)
60
+ FileUtils.mkdir_p(File.dirname(path))
61
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, mode) { |file| file.write(contents) }
62
+ @created_files << path
63
+ File.chmod(mode, path)
64
+ end
65
+
66
+ def rendered_nginx
67
+ template = ERB.new(File.read(Assets.nginx_template_path), trim_mode: '-')
68
+ template.result(binding)
69
+ end
70
+
71
+ def escaped_output_dir
72
+ output_dir.gsub('\\', '\\\\').gsub('"', '\\"')
73
+ end
74
+
75
+ def cleanup_created_files
76
+ @created_files&.each { |path| File.unlink(path) if File.file?(path) }
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'icalendar'
4
+
5
+ module Availability
6
+ # Isolates property-level iCalendar failures to the VEVENT that contains them.
7
+ class TolerantIcalendarParser < Icalendar::Parser
8
+ INVALID_MARKER = :@availability_invalid_event
9
+ SUPPORTED_VALUE_TYPES = %w[
10
+ BINARY BOOLEAN CAL-ADDRESS DATE DATE-TIME DURATION FLOAT INTEGER PERIOD RECUR TEXT TIME URI UTC-OFFSET
11
+ ].freeze
12
+
13
+ def self.invalid_event?(event)
14
+ event.instance_variable_defined?(INVALID_MARKER)
15
+ end
16
+
17
+ private
18
+
19
+ def parse_property(component, fields = nil)
20
+ if component.is_a?(Icalendar::Event) && unsupported_value_type?(fields)
21
+ mark_invalid(component)
22
+ return
23
+ end
24
+
25
+ super
26
+ rescue StandardError
27
+ raise unless component.is_a?(Icalendar::Event)
28
+
29
+ mark_invalid(component)
30
+ end
31
+
32
+ def parse_component(component)
33
+ super
34
+ rescue StandardError
35
+ raise unless component.is_a?(Icalendar::Event)
36
+
37
+ mark_invalid(component)
38
+ discard_remaining_event
39
+ component
40
+ end
41
+
42
+ def mark_invalid(event)
43
+ event.instance_variable_set(INVALID_MARKER, true)
44
+ end
45
+
46
+ def unsupported_value_type?(fields)
47
+ value_type = fields&.dig(:params, 'value')&.first
48
+ value_type && !SUPPORTED_VALUE_TYPES.include?(value_type.upcase)
49
+ end
50
+
51
+ def discard_remaining_event
52
+ loop do
53
+ fields = next_fields
54
+ return if fields.nil? || event_end?(fields)
55
+ rescue StandardError
56
+ next
57
+ end
58
+ end
59
+
60
+ def event_end?(fields)
61
+ fields[:name] == 'end' && fields[:value].casecmp('VEVENT').zero?
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Availability
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'availability/version'
4
+ require_relative 'availability/assets'
5
+ require_relative 'availability/errors'
6
+ require_relative 'availability/calendar_url'
7
+ require_relative 'availability/config_value_parser'
8
+ require_relative 'availability/config'
9
+ require_relative 'availability/calendar_fetcher'
10
+ require_relative 'availability/busy_period'
11
+ require_relative 'availability/tolerant_icalendar_parser'
12
+ require_relative 'availability/event_timezone_validator'
13
+ require_relative 'availability/recurrence_expander'
14
+ require_relative 'availability/calendar_parser'
15
+ require_relative 'availability/availability_calculator'
16
+ require_relative 'availability/renderer'
17
+ require_relative 'availability/atomic_writer'
18
+ require_relative 'availability/application'
19
+ require_relative 'availability/setup'
20
+ require_relative 'availability/cli'