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.
- checksums.yaml +7 -0
- data/README.md +305 -0
- data/assets/favicon.svg +14 -0
- data/config/availability.example.yml +29 -0
- data/exe/particle +6 -0
- data/lib/availability/application.rb +106 -0
- data/lib/availability/assets.rb +26 -0
- data/lib/availability/atomic_writer.rb +62 -0
- data/lib/availability/availability_calculator.rb +85 -0
- data/lib/availability/busy_period.rb +41 -0
- data/lib/availability/calendar_fetcher.rb +100 -0
- data/lib/availability/calendar_parser.rb +198 -0
- data/lib/availability/calendar_url.rb +12 -0
- data/lib/availability/cli.rb +184 -0
- data/lib/availability/config.rb +195 -0
- data/lib/availability/config_value_parser.rb +48 -0
- data/lib/availability/errors.rb +10 -0
- data/lib/availability/event_timezone_validator.rb +55 -0
- data/lib/availability/recurrence_expander.rb +66 -0
- data/lib/availability/renderer.rb +102 -0
- data/lib/availability/setup.rb +79 -0
- data/lib/availability/tolerant_icalendar_parser.rb +64 -0
- data/lib/availability/version.rb +5 -0
- data/lib/availability.rb +20 -0
- data/templates/index.html.erb +453 -0
- data/templates/nginx.conf.erb +34 -0
- metadata +125 -0
|
@@ -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
|
data/lib/availability.rb
ADDED
|
@@ -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'
|