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,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,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
|