opening_hours_converter 1.15.0 → 1.17.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 +4 -4
- data/lib/opening_hours_converter/constants.rb +3 -0
- data/lib/opening_hours_converter/date_range.rb +3 -1
- data/lib/opening_hours_converter/interval.rb +16 -2
- data/lib/opening_hours_converter/iterator.rb +10 -0
- data/lib/opening_hours_converter/open_intervals.rb +262 -0
- data/lib/opening_hours_converter/opening_hours_builder.rb +130 -4
- data/lib/opening_hours_converter/opening_hours_date.rb +11 -5
- data/lib/opening_hours_converter/opening_hours_parser.rb +327 -61
- data/lib/opening_hours_converter/opening_hours_rule.rb +38 -7
- data/lib/opening_hours_converter/opening_hours_time.rb +15 -2
- data/lib/opening_hours_converter/regex_handler.rb +64 -7
- data/lib/opening_hours_converter/token.rb +24 -1
- data/lib/opening_hours_converter/tokenizer.rb +2 -2
- data/lib/opening_hours_converter/tokens_handler.rb +99 -3
- data/lib/opening_hours_converter/week_index.rb +11 -1
- data/lib/opening_hours_converter/wide_interval.rb +69 -15
- data/lib/opening_hours_converter/year.rb +18 -3
- data/lib/opening_hours_converter.rb +1 -0
- data/readme.md +34 -0
- metadata +4 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecd7eb467184d013601216945d337758dd52b4824640a61fc5826e28f74c2e4f
|
|
4
|
+
data.tar.gz: 5804ea9f19e7d0f6f5f538cba377a8d7aa1b0700384588b8214aaf6c2634b933
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d697f34e86039dd4854e3e7dd0ab8332654d239971d3fcd12f69703ebf629ea2ab0380811bd460afde0ab06aceca1ae0ffac7eda30592c557e27c1ea76fdfef
|
|
7
|
+
data.tar.gz: 985cf1ab1967b7c7d69e4d1b593e83976ba4799bca329849fb26582d55d66ce10bba1a76de4d75cf206d4ff434f153628248b8dbb0d4640d3ea83b183b8a008e
|
|
@@ -11,6 +11,8 @@ module OpeningHoursConverter
|
|
|
11
11
|
DIMANCHE: 6
|
|
12
12
|
}.freeze
|
|
13
13
|
OSM_DAYS = %w[Mo Tu We Th Fr Sa Su].freeze
|
|
14
|
+
# Not specification, but common enough in the data to be read.
|
|
15
|
+
OSM_DAYS_ABBREVIATIONS = %w[Mon Tue Wed Thu Fri Sat Sun].freeze
|
|
14
16
|
IRL_DAYS = %w[Lundi Mardi Mercredi Jeudi Vendredi Samedi Dimanche].freeze
|
|
15
17
|
OSM_MONTHS = %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec].freeze
|
|
16
18
|
IRL_MONTHS = %W[Janvier Fevrier Mars Avril Mai Juin Juillet Aout Septembre
|
|
@@ -20,5 +22,6 @@ module OpeningHoursConverter
|
|
|
20
22
|
DAYS_MAX = 6
|
|
21
23
|
YEAR_DAYS_MAX = 365
|
|
22
24
|
PH_WEEKDAY = -2
|
|
25
|
+
EASTER_WEEKDAY = -3
|
|
23
26
|
end
|
|
24
27
|
end
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
module OpeningHoursConverter
|
|
2
2
|
class DateRange
|
|
3
|
-
attr_accessor :wide_interval, :typical, :comment
|
|
3
|
+
attr_accessor :wide_interval, :typical, :comment, :fallback_suffix, :fallback_ranges
|
|
4
4
|
|
|
5
5
|
def initialize(wide_interval = nil)
|
|
6
6
|
@wide_interval = nil
|
|
7
7
|
@typical = nil
|
|
8
8
|
@comment = ''
|
|
9
|
+
@fallback_suffix = nil
|
|
10
|
+
@fallback_ranges = []
|
|
9
11
|
update_range(wide_interval)
|
|
10
12
|
end
|
|
11
13
|
|
|
@@ -3,14 +3,28 @@ require 'opening_hours_converter/constants'
|
|
|
3
3
|
module OpeningHoursConverter
|
|
4
4
|
class Interval
|
|
5
5
|
include Constants
|
|
6
|
-
attr_reader :day_start, :day_end, :start, :end, :is_off
|
|
6
|
+
attr_reader :day_start, :day_end, :start, :end, :is_off, :index, :is_unknown, :open_ended, :day_offset
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# is_unknown is the OSM third state ("unknown", alongside open/off) and
|
|
9
|
+
# open_ended is a time with no known closing time ("18:00+"). Both are
|
|
10
|
+
# carried purely for round-trip serialization; for interval computation
|
|
11
|
+
# is_unknown behaves like is_off (not open) and open_ended is treated as
|
|
12
|
+
# running to the end of the day.
|
|
13
|
+
#
|
|
14
|
+
# day_offset is the OSM day offset ("easter +1 day"): the interval falls
|
|
15
|
+
# that many days after the date day_start selects.
|
|
16
|
+
def initialize(day_start, min_start, day_end = 0, min_end = 0, is_off = false, index = nil, is_unknown = false, open_ended = false, day_offset = 0)
|
|
9
17
|
@day_start = day_start
|
|
10
18
|
@day_end = day_end
|
|
11
19
|
@start = min_start
|
|
12
20
|
@end = min_end
|
|
13
21
|
@is_off = is_off
|
|
22
|
+
# The weekday index is an OSM nth_entry list ("Sa[1,3]"), so it is always
|
|
23
|
+
# held as an array; a lone index is wrapped rather than special-cased.
|
|
24
|
+
@index = index.nil? ? nil : Array(index)
|
|
25
|
+
@is_unknown = is_unknown
|
|
26
|
+
@open_ended = open_ended
|
|
27
|
+
@day_offset = day_offset || 0
|
|
14
28
|
|
|
15
29
|
if @day_end == 0 && @end == 0
|
|
16
30
|
@day_end = DAYS_MAX
|
|
@@ -112,6 +112,16 @@ module OpeningHoursConverter
|
|
|
112
112
|
datetime_result.sort_by { |a| a[:start] }
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# Open intervals of an opening hours string inside a concrete window, as
|
|
116
|
+
# [{ start: Time, end: Time }], sorted and clamped to [from, to].
|
|
117
|
+
#
|
|
118
|
+
# Unlike get_time_iterator, this applies "off" rules and intervals crossing
|
|
119
|
+
# midnight, and covers every year the window touches rather than the
|
|
120
|
+
# current one.
|
|
121
|
+
def get_open_intervals(opening_hours_string, from, to)
|
|
122
|
+
OpeningHoursConverter::OpenIntervals.call(opening_hours_string, from, to)
|
|
123
|
+
end
|
|
124
|
+
|
|
115
125
|
def get_datetime_iterator(date_ranges)
|
|
116
126
|
result = get_iterator(date_ranges)
|
|
117
127
|
datetime_result = []
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
require 'opening_hours_converter/constants'
|
|
2
|
+
|
|
3
|
+
module OpeningHoursConverter
|
|
4
|
+
# Projects parsed date ranges onto a concrete window and returns the open
|
|
5
|
+
# intervals it contains.
|
|
6
|
+
#
|
|
7
|
+
# Rules are applied in the order they were parsed, and a later rule overrides
|
|
8
|
+
# an earlier one on the minutes it selects, as the OpenStreetMap
|
|
9
|
+
# specification requires: "Jul-Aug off; Mo-Fr 09:00-17:00" is open in July,
|
|
10
|
+
# while "Mo-Fr 09:00-17:00; Jul-Aug off" is closed.
|
|
11
|
+
#
|
|
12
|
+
# A rule behind a "||" is the exception. It is a fallback: it speaks only for
|
|
13
|
+
# the minutes the rules before it leave closed, and never over the ones they
|
|
14
|
+
# open. "Mo-Fr 08:00-12:00 || Sa 10:00-12:00" opens on Saturday and changes
|
|
15
|
+
# nothing from Monday to Friday.
|
|
16
|
+
class OpenIntervals
|
|
17
|
+
include Constants
|
|
18
|
+
include Utils
|
|
19
|
+
|
|
20
|
+
# The state of a minute an open-ended time covers, told apart from a plain
|
|
21
|
+
# open minute so that the two never run into a single interval.
|
|
22
|
+
OPEN_ENDED = :open_ended
|
|
23
|
+
|
|
24
|
+
def self.call(opening_hours_string, from, to)
|
|
25
|
+
date_ranges = OpeningHoursConverter::OpeningHoursParser.new.parse(opening_hours_string)
|
|
26
|
+
new(from, to).apply(date_ranges).intervals
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(from, to)
|
|
30
|
+
@from = from
|
|
31
|
+
@to = to
|
|
32
|
+
# One day before the window, so an interval crossing midnight into it is
|
|
33
|
+
# not lost.
|
|
34
|
+
@first_date = from.to_date - 1
|
|
35
|
+
@last_date = to.to_date
|
|
36
|
+
@masks = {}
|
|
37
|
+
@fallback = false
|
|
38
|
+
@holidays = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply(date_ranges)
|
|
42
|
+
expand(date_ranges).each { |date_range, bounds| apply_date_range(date_range, bounds) }
|
|
43
|
+
|
|
44
|
+
fallback = date_ranges.first&.fallback_ranges
|
|
45
|
+
return self if fallback.nil? || fallback.empty?
|
|
46
|
+
|
|
47
|
+
# Everything past a "||" is a fallback, and stays one: a chained
|
|
48
|
+
# "a || b || c" nests, c falling back on what a and b leave closed.
|
|
49
|
+
@fallback = true
|
|
50
|
+
apply(fallback)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def intervals
|
|
54
|
+
runs.select { |run| run[:end] > @from && run[:start] < @to }
|
|
55
|
+
.map { |run| { start: [run[:start], @from].max, end: [run[:end], @to].min } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Week and holiday selectors carry several date ranges rather than one, and
|
|
61
|
+
# WideInterval#to_day resolves them for Time.now.year only. They are
|
|
62
|
+
# expanded here for every year the window touches instead.
|
|
63
|
+
#
|
|
64
|
+
# Returns [date_range, bounds] pairs, bounds being the dates the range is
|
|
65
|
+
# allowed to cover (nil for the whole window).
|
|
66
|
+
def expand(date_ranges)
|
|
67
|
+
date_ranges.flat_map do |date_range|
|
|
68
|
+
case date_range.wide_interval.type
|
|
69
|
+
when 'week' then expand_to_days(date_range, :get_weeks_for_year)
|
|
70
|
+
when 'holiday' then expand_to_days(date_range, :get_public_holidays_for_year)
|
|
71
|
+
when 'variable_day' then expand_to_days(date_range, :get_variable_days_for_year)
|
|
72
|
+
else [[clamp_open_ended_year(date_range), nil]]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# "2020+" applies from its start year through the end of the window:
|
|
78
|
+
# Year#build_day_array_from_date_range has no concept of an unbounded
|
|
79
|
+
# range, so it needs a concrete (if window-dependent) end year here.
|
|
80
|
+
def clamp_open_ended_year(date_range)
|
|
81
|
+
wide_interval = date_range.wide_interval
|
|
82
|
+
return date_range unless wide_interval.type == 'year' && wide_interval.open_ended && wide_interval.end.nil?
|
|
83
|
+
|
|
84
|
+
copy = date_range.dup
|
|
85
|
+
copy.update_range(OpeningHoursConverter::WideInterval.new.year(wide_interval.start[:year], @last_date.year))
|
|
86
|
+
copy
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# An ISO week declared with a year can start in the previous year or end in
|
|
90
|
+
# the next one ("2025 week 1" starts on 2025-12-29), so the neighbouring
|
|
91
|
+
# years are expanded too and their surplus days are cut back by the declared
|
|
92
|
+
# years. Without a declared year the window itself selects the years.
|
|
93
|
+
def expand_to_days(date_range, builder)
|
|
94
|
+
wide_interval = date_range.wide_interval
|
|
95
|
+
bounds = declared_bounds(wide_interval)
|
|
96
|
+
years = if bounds
|
|
97
|
+
(bounds.first.year - 1)..(bounds.last.year + 1)
|
|
98
|
+
else
|
|
99
|
+
@first_date.year..@last_date.year
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
years.flat_map { |year| wide_interval.send(builder, year) }
|
|
103
|
+
.map { |day| [with_range(date_range, day), bounds] }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def declared_bounds(wide_interval)
|
|
107
|
+
return nil if wide_interval.start.nil? || wide_interval.start[:year].nil?
|
|
108
|
+
|
|
109
|
+
last_year = wide_interval.end && wide_interval.end[:year] || wide_interval.start[:year]
|
|
110
|
+
Date.new(wide_interval.start[:year], 1, 1)..Date.new(last_year, 12, 31)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def with_range(date_range, wide_interval)
|
|
114
|
+
copy = date_range.dup
|
|
115
|
+
copy.update_range(wide_interval)
|
|
116
|
+
copy
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def apply_date_range(date_range, bounds = nil)
|
|
120
|
+
intervals = date_range.typical.intervals.compact
|
|
121
|
+
return if intervals.empty?
|
|
122
|
+
|
|
123
|
+
weekly = date_range.typical.is_a?(OpeningHoursConverter::Week)
|
|
124
|
+
days = covered_days(date_range)
|
|
125
|
+
days = days.select { |date| bounds.cover?(date) } if bounds
|
|
126
|
+
|
|
127
|
+
days.each do |date|
|
|
128
|
+
intervals.each do |interval|
|
|
129
|
+
next if weekly && !selects?(interval, date)
|
|
130
|
+
|
|
131
|
+
write(date, interval)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A day array keyed by "always" is a yearly pattern and applies to every
|
|
137
|
+
# year of the window; one keyed by a year applies to that year only.
|
|
138
|
+
def covered_days(date_range)
|
|
139
|
+
years = OpeningHoursConverter::Year.build_day_array_from_date_range(date_range, false)
|
|
140
|
+
|
|
141
|
+
(@first_date..@last_date).select do |date|
|
|
142
|
+
months = years[date.year] || years['always']
|
|
143
|
+
next false if months.nil?
|
|
144
|
+
|
|
145
|
+
days = months[date.month - 1]
|
|
146
|
+
# February rows are MONTH_END_DAY long, so Feb 29 follows Feb 28.
|
|
147
|
+
!days.nil? && days[[date.day, days.size].min - 1]
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def selects?(interval, date)
|
|
152
|
+
# A day offset shifts the date the selector names onto another one, so
|
|
153
|
+
# the selector is asked about the day it counts from rather than the day
|
|
154
|
+
# the interval is written on.
|
|
155
|
+
date -= interval.day_offset
|
|
156
|
+
return public_holiday?(date) if interval.day_start == PH_WEEKDAY
|
|
157
|
+
return easter?(date) if interval.day_start == EASTER_WEEKDAY
|
|
158
|
+
return false unless interval.day_start == reindex_sunday_week_to_monday_week(date.wday)
|
|
159
|
+
return true if interval.index.nil?
|
|
160
|
+
|
|
161
|
+
interval.index.any? { |index| nth_weekday_of_month?(index, interval.day_start, date) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def easter?(date)
|
|
165
|
+
OpeningHoursConverter::PublicHoliday.easter(date.year).to_date == date
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def nth_weekday_of_month?(index, weekday, date)
|
|
169
|
+
target = OpeningHoursConverter::WeekIndex.nth_wday_of_month(index, weekday, date.month, date.year)
|
|
170
|
+
target == date
|
|
171
|
+
rescue ArgumentError
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def public_holiday?(date)
|
|
176
|
+
@holidays[date.year] ||= OpeningHoursConverter::PublicHoliday.ph_for_year(date.year)
|
|
177
|
+
.map { |holiday| [holiday.month, holiday.day] }
|
|
178
|
+
@holidays[date.year].include?([date.month, date.day])
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# An interval runs from (day_start, start) to (day_end, end). Only midnight
|
|
182
|
+
# crossings give day_end > day_start, and their remaining minutes are
|
|
183
|
+
# written on the dates that follow.
|
|
184
|
+
#
|
|
185
|
+
# A fallback rule skips the minutes that are already open: what the rules
|
|
186
|
+
# before the "||" leave closed is what it answers for, whether they were
|
|
187
|
+
# silent about it or closed it with an "off".
|
|
188
|
+
def write(date, interval)
|
|
189
|
+
remaining = (interval.day_end - interval.day_start) * MINUTES_MAX + interval.end
|
|
190
|
+
first_minute = interval.start
|
|
191
|
+
current = date
|
|
192
|
+
open = !interval.is_off
|
|
193
|
+
|
|
194
|
+
while remaining > 0
|
|
195
|
+
mask = mask_for(current)
|
|
196
|
+
minutes = [first_minute, 0].max...[remaining, MINUTES_MAX].min
|
|
197
|
+
minutes.each { |minute| mask[minute] = open unless @fallback && mask[minute] } unless mask.nil?
|
|
198
|
+
remaining -= MINUTES_MAX
|
|
199
|
+
first_minute = 0
|
|
200
|
+
current += 1
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
write_open_end(date, interval) if interval.open_ended && !interval.is_off
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# An open end names no closing time ("10:00-12:00+"), so the rest of the
|
|
207
|
+
# day stays open without being certain. It is written as a state of its
|
|
208
|
+
# own, which keeps it from merging with the hours the string does name:
|
|
209
|
+
# the reference reports the two as two intervals.
|
|
210
|
+
def write_open_end(date, interval)
|
|
211
|
+
mask = mask_for(date + (interval.day_end - interval.day_start))
|
|
212
|
+
return if mask.nil?
|
|
213
|
+
|
|
214
|
+
(interval.end...MINUTES_MAX).each { |minute| mask[minute] = OPEN_ENDED }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def mask_for(date)
|
|
218
|
+
return nil unless (@first_date..@last_date).cover?(date)
|
|
219
|
+
|
|
220
|
+
@masks[date] ||= Array.new(MINUTES_MAX, false)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Walks the window as a continuous timeline, so a run of open minutes that
|
|
224
|
+
# spans midnight comes out as a single interval. A minute carries the state
|
|
225
|
+
# that opened it rather than a plain flag, so an open end closes the
|
|
226
|
+
# interval before it instead of extending it.
|
|
227
|
+
def runs
|
|
228
|
+
result = []
|
|
229
|
+
start = nil
|
|
230
|
+
state = nil
|
|
231
|
+
|
|
232
|
+
(@first_date..(@last_date + 1)).each do |date|
|
|
233
|
+
mask = @masks[date]
|
|
234
|
+
|
|
235
|
+
if mask.nil?
|
|
236
|
+
next if start.nil?
|
|
237
|
+
|
|
238
|
+
result << { start: start, end: time_at(date, 0) }
|
|
239
|
+
start = nil
|
|
240
|
+
state = nil
|
|
241
|
+
next
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
(0...MINUTES_MAX).each do |minute|
|
|
245
|
+
next if mask[minute] == state
|
|
246
|
+
|
|
247
|
+
result << { start: start, end: time_at(date, minute) } if start
|
|
248
|
+
start = mask[minute] ? time_at(date, minute) : nil
|
|
249
|
+
state = mask[minute]
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
result
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Built from wall clock components rather than by adding seconds, so a
|
|
257
|
+
# daylight saving change does not shift the time of day.
|
|
258
|
+
def time_at(date, minute)
|
|
259
|
+
Time.new(date.year, date.month, date.day, minute / 60, minute % 60)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -19,7 +19,7 @@ module OpeningHoursConverter
|
|
|
19
19
|
phs = []
|
|
20
20
|
|
|
21
21
|
date_range.typical.intervals.each_with_index do |interval, interval_id|
|
|
22
|
-
next unless interval&.day_start ==
|
|
22
|
+
next unless interval&.day_start == PH_WEEKDAY && interval&.day_start == interval&.day_end
|
|
23
23
|
|
|
24
24
|
if interval.is_off
|
|
25
25
|
off_day_ph = true
|
|
@@ -31,6 +31,23 @@ module OpeningHoursConverter
|
|
|
31
31
|
date_range.typical.remove_interval(interval_id)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
day_easter = false
|
|
35
|
+
off_day_easter = false
|
|
36
|
+
easters = []
|
|
37
|
+
|
|
38
|
+
date_range.typical.intervals.each_with_index do |interval, interval_id|
|
|
39
|
+
next unless interval&.day_start == EASTER_WEEKDAY && interval&.day_start == interval&.day_end
|
|
40
|
+
|
|
41
|
+
if interval.is_off
|
|
42
|
+
off_day_easter = true
|
|
43
|
+
else
|
|
44
|
+
day_easter = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
easters << interval
|
|
48
|
+
date_range.typical.remove_interval(interval_id)
|
|
49
|
+
end
|
|
50
|
+
|
|
34
51
|
range_general = nil
|
|
35
52
|
range_general_for = nil
|
|
36
53
|
range_general_id = date_range_index - 1
|
|
@@ -120,6 +137,36 @@ module OpeningHoursConverter
|
|
|
120
137
|
end
|
|
121
138
|
end
|
|
122
139
|
|
|
140
|
+
if off_day_easter || day_easter
|
|
141
|
+
if date_range.typical.intervals.uniq == [nil]
|
|
142
|
+
oh_rule.date.first.weekdays = [EASTER_WEEKDAY]
|
|
143
|
+
if off_day_easter
|
|
144
|
+
oh_rule.is_defined_off = off_day_easter
|
|
145
|
+
easters = []
|
|
146
|
+
else
|
|
147
|
+
easters.reverse.each do |interval|
|
|
148
|
+
oh_rule.add_time(OpeningHoursConverter::OpeningHoursTime.new(interval.start, interval.end))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
if times_are_compatible?(oh_rule, easters)
|
|
153
|
+
easter_intervals = get_compatible_intervals(oh_rule, easters)
|
|
154
|
+
oh_rule.add_easter_weekday if easter_intervals[:compatible].length > 0
|
|
155
|
+
rules << oh_rule if !oh_rule_added
|
|
156
|
+
oh_rule_added = true
|
|
157
|
+
easter_intervals[:incompatible].each do |interval|
|
|
158
|
+
rules += build_off_easter(date_range) if off_day_easter
|
|
159
|
+
rules += build_easter(date_range) if day_easter
|
|
160
|
+
end
|
|
161
|
+
else
|
|
162
|
+
rules << oh_rule if !oh_rule_added
|
|
163
|
+
oh_rule_added = true
|
|
164
|
+
rules += build_off_easter(date_range) if off_day_easter
|
|
165
|
+
rules += build_easter(date_range) if day_easter
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
123
170
|
rules << oh_rule if !oh_rule_added
|
|
124
171
|
|
|
125
172
|
next unless oh_rule == oh_rules.last && oh_rule.has_overwritten_weekday?
|
|
@@ -142,7 +189,9 @@ module OpeningHoursConverter
|
|
|
142
189
|
result += rules.map(&:get).join('; ')
|
|
143
190
|
end
|
|
144
191
|
|
|
145
|
-
result.strip
|
|
192
|
+
result = result.strip
|
|
193
|
+
result += " || #{date_ranges.first.fallback_suffix}" if date_ranges.first&.fallback_suffix
|
|
194
|
+
result
|
|
146
195
|
end
|
|
147
196
|
|
|
148
197
|
def times_are_compatible?(oh_rule, phs)
|
|
@@ -220,6 +269,53 @@ module OpeningHoursConverter
|
|
|
220
269
|
[rule]
|
|
221
270
|
end
|
|
222
271
|
|
|
272
|
+
def build_off_easter(date_range)
|
|
273
|
+
start_year = date_range.wide_interval.start&.key?(:year) ? date_range.wide_interval.start[:year] : date_range.wide_interval.start
|
|
274
|
+
end_year = date_range.wide_interval.end&.key?(:year) ? date_range.wide_interval.end[:year] : date_range.wide_interval.end
|
|
275
|
+
|
|
276
|
+
date_range = OpeningHoursConverter::DateRange.new(OpeningHoursConverter::WideInterval.new.holiday('easter', start_year, end_year))
|
|
277
|
+
|
|
278
|
+
rule = OpeningHoursConverter::OpeningHoursRule.new
|
|
279
|
+
date = OpeningHoursConverter::OpeningHoursDate.new(date_range.wide_interval, [-1])
|
|
280
|
+
rule.add_date(date)
|
|
281
|
+
rule.is_defined_off = true
|
|
282
|
+
|
|
283
|
+
[rule]
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def build_easter(date_range)
|
|
287
|
+
start_year = date_range.wide_interval.start&.key?(:year) ? date_range.wide_interval.start[:year] : date_range.wide_interval.start
|
|
288
|
+
end_year = date_range.wide_interval.end&.key?(:year) ? date_range.wide_interval.end[:year] : date_range.wide_interval.end
|
|
289
|
+
intervals = date_range.typical.get_intervals(true)
|
|
290
|
+
|
|
291
|
+
if date_range.wide_interval.type == 'week'
|
|
292
|
+
intervals.each do |interval|
|
|
293
|
+
date_range.typical.add_interval(OpeningHoursConverter::Interval.new(EASTER_WEEKDAY, interval.start, EASTER_WEEKDAY, interval.end, interval.is_off))
|
|
294
|
+
end
|
|
295
|
+
else
|
|
296
|
+
date_range = OpeningHoursConverter::DateRange.new(OpeningHoursConverter::WideInterval.new.holiday('easter', start_year, end_year))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
for i in 0..6
|
|
300
|
+
intervals.each do |interval|
|
|
301
|
+
if !interval.nil?
|
|
302
|
+
date_range.typical.add_interval(OpeningHoursConverter::Interval.new(i, interval.start, i, interval.end, interval.is_off))
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
rule = OpeningHoursConverter::OpeningHoursRule.new
|
|
307
|
+
date = OpeningHoursConverter::OpeningHoursDate.new(date_range.wide_interval, [-1])
|
|
308
|
+
rule.add_date(date)
|
|
309
|
+
|
|
310
|
+
date_range.typical.intervals.each do |interval|
|
|
311
|
+
if !interval.nil?
|
|
312
|
+
rule.add_time(OpeningHoursConverter::OpeningHoursTime.new(interval.start, interval.end))
|
|
313
|
+
rule.is_defined_off = rule.is_defined_off || interval.is_off
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
[rule]
|
|
317
|
+
end
|
|
318
|
+
|
|
223
319
|
def build_day(date_range)
|
|
224
320
|
intervals = date_range.typical.get_intervals(true)
|
|
225
321
|
|
|
@@ -240,7 +336,7 @@ module OpeningHoursConverter
|
|
|
240
336
|
def build_week(date_range)
|
|
241
337
|
result = []
|
|
242
338
|
|
|
243
|
-
intervals = date_range.typical
|
|
339
|
+
intervals = clean_intervals_keeping_index(date_range.typical)
|
|
244
340
|
days = create_time_intervals(date_range.wide_interval, intervals)
|
|
245
341
|
|
|
246
342
|
days_status = Array.new(OSM_DAYS.length, 0)
|
|
@@ -385,6 +481,26 @@ module OpeningHoursConverter
|
|
|
385
481
|
result
|
|
386
482
|
end
|
|
387
483
|
|
|
484
|
+
# An index ("We[1]"), the "unknown" state, and an open-ended time
|
|
485
|
+
# ("18:00+") don't survive Week#get_intervals(true), which rebuilds
|
|
486
|
+
# intervals from a per-minute true/false/"off" grid that has no room for
|
|
487
|
+
# any of them. All three are pulled out first and passed through
|
|
488
|
+
# untouched instead; none of them can collide with anything the grid
|
|
489
|
+
# pass produces (an index never applies to more than a single day, and
|
|
490
|
+
# "unknown"/open-ended are only ever full-interval markers here).
|
|
491
|
+
def clean_intervals_keeping_index(typical)
|
|
492
|
+
special = typical.intervals.compact.select { |interval| carries_metadata?(interval) }
|
|
493
|
+
return typical.get_intervals(true) if special.empty?
|
|
494
|
+
|
|
495
|
+
plain = OpeningHoursConverter::Week.new
|
|
496
|
+
typical.intervals.compact.each { |interval| plain.add_interval(interval) unless carries_metadata?(interval) }
|
|
497
|
+
plain.get_intervals(true) + special
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def carries_metadata?(interval)
|
|
501
|
+
!interval.index.nil? || interval.is_unknown || interval.open_ended
|
|
502
|
+
end
|
|
503
|
+
|
|
388
504
|
def create_time_intervals(wide_interval, intervals)
|
|
389
505
|
days = []
|
|
390
506
|
|
|
@@ -398,16 +514,26 @@ module OpeningHoursConverter
|
|
|
398
514
|
|
|
399
515
|
begin
|
|
400
516
|
if interval.day_start == interval.day_end
|
|
401
|
-
days[interval.day_start].add_time(OpeningHoursConverter::OpeningHoursTime.new(interval.start, interval.end))
|
|
517
|
+
days[interval.day_start].add_time(OpeningHoursConverter::OpeningHoursTime.new(interval.start, interval.end, interval.open_ended))
|
|
402
518
|
days[interval.day_start].is_defined_off = days[interval.day_start].is_defined_off ? true : interval.is_off
|
|
519
|
+
days[interval.day_start].is_defined_unknown = days[interval.day_start].is_defined_unknown ? true : interval.is_unknown
|
|
520
|
+
if interval.index
|
|
521
|
+
# One weekday can carry several intervals, one per entry of its
|
|
522
|
+
# index list ("Sa[2],Sa[4]"), and the list is their union.
|
|
523
|
+
date = days[interval.day_start].date.first
|
|
524
|
+
date.weekday_index = Array(date.weekday_index) | interval.index
|
|
525
|
+
end
|
|
403
526
|
elsif interval.day_end - interval.day_start == 1
|
|
404
527
|
days[interval.day_start].add_time(OpeningHoursConverter::OpeningHoursTime.new(interval.start, MINUTES_MAX))
|
|
405
528
|
days[interval.day_start].is_defined_off = days[interval.day_start].is_defined_off ? true : interval.is_off
|
|
529
|
+
days[interval.day_start].is_defined_unknown = days[interval.day_start].is_defined_unknown ? true : interval.is_unknown
|
|
406
530
|
days[interval.day_end].add_time(OpeningHoursConverter::OpeningHoursTime.new(0, interval.end))
|
|
407
531
|
days[interval.day_end].is_defined_off = days[interval.day_end].is_defined_off ? true : interval.is_off
|
|
532
|
+
days[interval.day_end].is_defined_unknown = days[interval.day_end].is_defined_unknown ? true : interval.is_unknown
|
|
408
533
|
else
|
|
409
534
|
for j in interval.day_start..interval.day_end
|
|
410
535
|
days[j].is_defined_off = days[j].is_defined_off ? true : interval.is_off
|
|
536
|
+
days[j].is_defined_unknown = days[j].is_defined_unknown ? true : interval.is_unknown
|
|
411
537
|
if j == interval.day_start
|
|
412
538
|
days[j].add_time(OpeningHoursConverter::OpeningHoursTime.new(interval.start, MINUTES_MAX))
|
|
413
539
|
elsif j == interval.day_end
|
|
@@ -3,10 +3,10 @@ require 'opening_hours_converter/constants'
|
|
|
3
3
|
module OpeningHoursConverter
|
|
4
4
|
class OpeningHoursDate
|
|
5
5
|
include Constants
|
|
6
|
-
attr_accessor :weekdays, :weekdays_over
|
|
6
|
+
attr_accessor :weekdays, :weekdays_over, :weekday_index
|
|
7
7
|
attr_reader :wide_interval
|
|
8
8
|
|
|
9
|
-
def initialize(wide_interval, weekdays)
|
|
9
|
+
def initialize(wide_interval, weekdays, weekday_index = nil)
|
|
10
10
|
if wide_interval.nil? || weekdays.nil? || !wide_interval.is_a?(OpeningHoursConverter::WideInterval)
|
|
11
11
|
raise ArgumentError
|
|
12
12
|
end
|
|
@@ -14,14 +14,15 @@ module OpeningHoursConverter
|
|
|
14
14
|
@wide_interval = wide_interval
|
|
15
15
|
@weekdays = weekdays.sort
|
|
16
16
|
@weekdays_over = []
|
|
17
|
+
@weekday_index = weekday_index
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def get_weekdays
|
|
20
21
|
result = ''
|
|
21
22
|
weekdays = @weekdays.concat(@weekdays_over).sort.uniq
|
|
22
23
|
|
|
23
|
-
if !weekdays.empty? && weekdays[0] ==
|
|
24
|
-
result = 'PH'
|
|
24
|
+
if !weekdays.empty? && (weekdays[0] == PH_WEEKDAY || weekdays[0] == EASTER_WEEKDAY)
|
|
25
|
+
result = weekdays[0] == PH_WEEKDAY ? 'PH' : 'easter'
|
|
25
26
|
weekdays.shift
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -94,6 +95,7 @@ module OpeningHoursConverter
|
|
|
94
95
|
end
|
|
95
96
|
|
|
96
97
|
result = '' if result == 'Mo-Su'
|
|
98
|
+
result += "[#{Array(@weekday_index).join(',')}]" if weekdays.length == 1 && !@weekday_index.nil?
|
|
97
99
|
result
|
|
98
100
|
end
|
|
99
101
|
|
|
@@ -105,7 +107,11 @@ module OpeningHoursConverter
|
|
|
105
107
|
end
|
|
106
108
|
|
|
107
109
|
def add_ph_weekday
|
|
108
|
-
add_weekday(
|
|
110
|
+
add_weekday(PH_WEEKDAY)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def add_easter_weekday
|
|
114
|
+
add_weekday(EASTER_WEEKDAY)
|
|
109
115
|
end
|
|
110
116
|
|
|
111
117
|
def add_overwritten_weekday(weekday)
|