opening_hours_converter 1.15.0 → 1.16.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/iterator.rb +10 -0
- data/lib/opening_hours_converter/open_intervals.rb +193 -0
- data/lib/opening_hours_converter/tokenizer.rb +2 -2
- data/lib/opening_hours_converter/wide_interval.rb +13 -11
- data/lib/opening_hours_converter/year.rb +15 -0
- data/lib/opening_hours_converter.rb +1 -0
- data/readme.md +28 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b633bae1e9cb5898370921f08cd07dc343b837c7139236883930769b91e3738
|
|
4
|
+
data.tar.gz: 675e149ec88ead4dee904967288dc36f9ea40708c3742f9afab291375c75f33b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8348466a3dab6669c2b1ae059dabc1bf32c9ccb81161066f35118a01860e88be85b53e1446e035dfa2f12a76d9c6b3298c7f1b5556fec460feb24f34df850a65
|
|
7
|
+
data.tar.gz: a44a50c9bbc2080d8bc7c31e504149732a47277036ddcaec7e50e5700eb7357259d7e9921175e4508dc468ff25aed6785583fc90792a7346b712d3f97298d78b
|
|
@@ -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,193 @@
|
|
|
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
|
+
class OpenIntervals
|
|
12
|
+
include Constants
|
|
13
|
+
include Utils
|
|
14
|
+
|
|
15
|
+
def self.call(opening_hours_string, from, to)
|
|
16
|
+
date_ranges = OpeningHoursConverter::OpeningHoursParser.new.parse(opening_hours_string)
|
|
17
|
+
new(from, to).apply(date_ranges).intervals
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(from, to)
|
|
21
|
+
@from = from
|
|
22
|
+
@to = to
|
|
23
|
+
# One day before the window, so an interval crossing midnight into it is
|
|
24
|
+
# not lost.
|
|
25
|
+
@first_date = from.to_date - 1
|
|
26
|
+
@last_date = to.to_date
|
|
27
|
+
@masks = {}
|
|
28
|
+
@holidays = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def apply(date_ranges)
|
|
32
|
+
expand(date_ranges).each { |date_range, bounds| apply_date_range(date_range, bounds) }
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def intervals
|
|
37
|
+
runs.select { |run| run[:end] > @from && run[:start] < @to }
|
|
38
|
+
.map { |run| { start: [run[:start], @from].max, end: [run[:end], @to].min } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Week and holiday selectors carry several date ranges rather than one, and
|
|
44
|
+
# WideInterval#to_day resolves them for Time.now.year only. They are
|
|
45
|
+
# expanded here for every year the window touches instead.
|
|
46
|
+
#
|
|
47
|
+
# Returns [date_range, bounds] pairs, bounds being the dates the range is
|
|
48
|
+
# allowed to cover (nil for the whole window).
|
|
49
|
+
def expand(date_ranges)
|
|
50
|
+
date_ranges.flat_map do |date_range|
|
|
51
|
+
case date_range.wide_interval.type
|
|
52
|
+
when 'week' then expand_to_days(date_range, :get_weeks_for_year)
|
|
53
|
+
when 'holiday' then expand_to_days(date_range, :get_public_holidays_for_year)
|
|
54
|
+
else [[date_range, nil]]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# An ISO week declared with a year can start in the previous year or end in
|
|
60
|
+
# the next one ("2025 week 1" starts on 2025-12-29), so the neighbouring
|
|
61
|
+
# years are expanded too and their surplus days are cut back by the declared
|
|
62
|
+
# years. Without a declared year the window itself selects the years.
|
|
63
|
+
def expand_to_days(date_range, builder)
|
|
64
|
+
wide_interval = date_range.wide_interval
|
|
65
|
+
bounds = declared_bounds(wide_interval)
|
|
66
|
+
years = if bounds
|
|
67
|
+
(bounds.first.year - 1)..(bounds.last.year + 1)
|
|
68
|
+
else
|
|
69
|
+
@first_date.year..@last_date.year
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
years.flat_map { |year| wide_interval.send(builder, year) }
|
|
73
|
+
.map { |day| [with_range(date_range, day), bounds] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def declared_bounds(wide_interval)
|
|
77
|
+
return nil if wide_interval.start.nil? || wide_interval.start[:year].nil?
|
|
78
|
+
|
|
79
|
+
last_year = wide_interval.end && wide_interval.end[:year] || wide_interval.start[:year]
|
|
80
|
+
Date.new(wide_interval.start[:year], 1, 1)..Date.new(last_year, 12, 31)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def with_range(date_range, wide_interval)
|
|
84
|
+
copy = date_range.dup
|
|
85
|
+
copy.update_range(wide_interval)
|
|
86
|
+
copy
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def apply_date_range(date_range, bounds = nil)
|
|
90
|
+
intervals = date_range.typical.intervals.compact
|
|
91
|
+
return if intervals.empty?
|
|
92
|
+
|
|
93
|
+
weekly = date_range.typical.is_a?(OpeningHoursConverter::Week)
|
|
94
|
+
days = covered_days(date_range)
|
|
95
|
+
days = days.select { |date| bounds.cover?(date) } if bounds
|
|
96
|
+
|
|
97
|
+
days.each do |date|
|
|
98
|
+
intervals.each do |interval|
|
|
99
|
+
next if weekly && !selects?(interval, date)
|
|
100
|
+
|
|
101
|
+
write(date, interval)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A day array keyed by "always" is a yearly pattern and applies to every
|
|
107
|
+
# year of the window; one keyed by a year applies to that year only.
|
|
108
|
+
def covered_days(date_range)
|
|
109
|
+
years = OpeningHoursConverter::Year.build_day_array_from_date_range(date_range, false)
|
|
110
|
+
|
|
111
|
+
(@first_date..@last_date).select do |date|
|
|
112
|
+
months = years[date.year] || years['always']
|
|
113
|
+
next false if months.nil?
|
|
114
|
+
|
|
115
|
+
days = months[date.month - 1]
|
|
116
|
+
# February rows are MONTH_END_DAY long, so Feb 29 follows Feb 28.
|
|
117
|
+
!days.nil? && days[[date.day, days.size].min - 1]
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def selects?(interval, date)
|
|
122
|
+
return public_holiday?(date) if interval.day_start == PH_WEEKDAY
|
|
123
|
+
|
|
124
|
+
interval.day_start == reindex_sunday_week_to_monday_week(date.wday)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def public_holiday?(date)
|
|
128
|
+
@holidays[date.year] ||= OpeningHoursConverter::PublicHoliday.ph_for_year(date.year)
|
|
129
|
+
.map { |holiday| [holiday.month, holiday.day] }
|
|
130
|
+
@holidays[date.year].include?([date.month, date.day])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# An interval runs from (day_start, start) to (day_end, end). Only midnight
|
|
134
|
+
# crossings give day_end > day_start, and their remaining minutes are
|
|
135
|
+
# written on the dates that follow.
|
|
136
|
+
def write(date, interval)
|
|
137
|
+
remaining = (interval.day_end - interval.day_start) * MINUTES_MAX + interval.end
|
|
138
|
+
first_minute = interval.start
|
|
139
|
+
current = date
|
|
140
|
+
open = !interval.is_off
|
|
141
|
+
|
|
142
|
+
while remaining > 0
|
|
143
|
+
mask = mask_for(current)
|
|
144
|
+
([first_minute, 0].max...[remaining, MINUTES_MAX].min).each { |minute| mask[minute] = open } unless mask.nil?
|
|
145
|
+
remaining -= MINUTES_MAX
|
|
146
|
+
first_minute = 0
|
|
147
|
+
current += 1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def mask_for(date)
|
|
152
|
+
return nil unless (@first_date..@last_date).cover?(date)
|
|
153
|
+
|
|
154
|
+
@masks[date] ||= Array.new(MINUTES_MAX, false)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Walks the window as a continuous timeline, so a run of open minutes that
|
|
158
|
+
# spans midnight comes out as a single interval.
|
|
159
|
+
def runs
|
|
160
|
+
result = []
|
|
161
|
+
start = nil
|
|
162
|
+
|
|
163
|
+
(@first_date..(@last_date + 1)).each do |date|
|
|
164
|
+
mask = @masks[date]
|
|
165
|
+
|
|
166
|
+
if mask.nil?
|
|
167
|
+
next if start.nil?
|
|
168
|
+
|
|
169
|
+
result << { start: start, end: time_at(date, 0) }
|
|
170
|
+
start = nil
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
(0...MINUTES_MAX).each do |minute|
|
|
175
|
+
if mask[minute]
|
|
176
|
+
start ||= time_at(date, minute)
|
|
177
|
+
elsif start
|
|
178
|
+
result << { start: start, end: time_at(date, minute) }
|
|
179
|
+
start = nil
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
result
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Built from wall clock components rather than by adding seconds, so a
|
|
188
|
+
# daylight saving change does not shift the time of day.
|
|
189
|
+
def time_at(date, minute)
|
|
190
|
+
Time.new(date.year, date.month, date.day, minute / 60, minute % 60)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -62,7 +62,7 @@ module OpeningHoursConverter
|
|
|
62
62
|
def handle_string
|
|
63
63
|
type = :string
|
|
64
64
|
start_index = @index
|
|
65
|
-
value =
|
|
65
|
+
value = String.new
|
|
66
66
|
|
|
67
67
|
while string? && current_character?
|
|
68
68
|
value << current_character
|
|
@@ -75,7 +75,7 @@ module OpeningHoursConverter
|
|
|
75
75
|
def handle_integer
|
|
76
76
|
type = :integer
|
|
77
77
|
start_index = @index
|
|
78
|
-
value =
|
|
78
|
+
value = String.new
|
|
79
79
|
|
|
80
80
|
while integer? && current_character?
|
|
81
81
|
value << current_character
|
|
@@ -680,25 +680,17 @@ module OpeningHoursConverter
|
|
|
680
680
|
weeks_as_days = []
|
|
681
681
|
@indexes.each do |week_index|
|
|
682
682
|
if week_index.is_a?(Integer)
|
|
683
|
-
|
|
684
|
-
weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
|
|
685
|
-
week[:to].day, week[:to].month, week[:to].year)
|
|
683
|
+
week_as_day(week_index, year, weeks_as_days)
|
|
686
684
|
else
|
|
687
685
|
if week_index.key?(:modifier)
|
|
688
686
|
i = 0
|
|
689
687
|
(week_index[:from]..week_index[:to]).map do |index|
|
|
690
|
-
if i % week_index[:modifier] == 0
|
|
691
|
-
week = OpeningHoursConverter::WeekIndex.week_from_index(index, year)
|
|
692
|
-
weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
|
|
693
|
-
week[:to].day, week[:to].month, week[:to].year)
|
|
694
|
-
end
|
|
688
|
+
week_as_day(index, year, weeks_as_days) if i % week_index[:modifier] == 0
|
|
695
689
|
i += 1
|
|
696
690
|
end
|
|
697
691
|
else
|
|
698
692
|
(week_index[:from]..week_index[:to]).map do |index|
|
|
699
|
-
|
|
700
|
-
weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
|
|
701
|
-
week[:to].day, week[:to].month, week[:to].year)
|
|
693
|
+
week_as_day(index, year, weeks_as_days)
|
|
702
694
|
end
|
|
703
695
|
end
|
|
704
696
|
end
|
|
@@ -706,6 +698,16 @@ module OpeningHoursConverter
|
|
|
706
698
|
|
|
707
699
|
weeks_as_days
|
|
708
700
|
end
|
|
701
|
+
|
|
702
|
+
# A year has 52 or 53 ISO weeks, so an index past the last one selects
|
|
703
|
+
# nothing rather than a week borrowed from the next year.
|
|
704
|
+
def week_as_day(index, year, weeks_as_days)
|
|
705
|
+
return if index > OpeningHoursConverter::WeekIndex.week_count(year)
|
|
706
|
+
|
|
707
|
+
week = OpeningHoursConverter::WeekIndex.week_from_index(index, year)
|
|
708
|
+
weeks_as_days << OpeningHoursConverter::WideInterval.new.day(week[:from].day, week[:from].month, week[:from].year,
|
|
709
|
+
week[:to].day, week[:to].month, week[:to].year)
|
|
710
|
+
end
|
|
709
711
|
def get_public_holidays_for_year(year = Time.now.year)
|
|
710
712
|
OpeningHoursConverter::PublicHoliday.ph_for_year(year).map do |holiday|
|
|
711
713
|
OpeningHoursConverter::WideInterval.new.day(holiday.day, holiday.month, holiday.year)
|
|
@@ -371,6 +371,21 @@ module OpeningHoursConverter
|
|
|
371
371
|
years[wide_interval.start[:year]][wide_interval.start[:month]-1][day] = true
|
|
372
372
|
end
|
|
373
373
|
end
|
|
374
|
+
elsif wide_interval.end[:month] < wide_interval.start[:month]
|
|
375
|
+
# A range such as "2025 Dec 26-2025 Jan 13" wraps around New Year and
|
|
376
|
+
# covers both ends of the same year.
|
|
377
|
+
for month in wide_interval.start[:month]-1..11
|
|
378
|
+
first_day = month == wide_interval.start[:month]-1 ? wide_interval.start[:day]-1 : 0
|
|
379
|
+
for day in first_day...MONTH_END_DAY[month]
|
|
380
|
+
years[wide_interval.start[:year]][month][day] = true
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
for month in 0..wide_interval.end[:month]-1
|
|
384
|
+
last_day = month == wide_interval.end[:month]-1 ? wide_interval.end[:day]-1 : MONTH_END_DAY[month]-1
|
|
385
|
+
for day in 0..last_day
|
|
386
|
+
years[wide_interval.start[:year]][month][day] = true
|
|
387
|
+
end
|
|
388
|
+
end
|
|
374
389
|
else
|
|
375
390
|
for month in wide_interval.start[:month]-1..wide_interval.end[:month]-1
|
|
376
391
|
if month == wide_interval.start[:month]-1
|
|
@@ -12,6 +12,7 @@ module OpeningHoursConverter
|
|
|
12
12
|
require_relative './opening_hours_converter/year'
|
|
13
13
|
require_relative './opening_hours_converter/public_holiday'
|
|
14
14
|
require_relative './opening_hours_converter/interval'
|
|
15
|
+
require_relative './opening_hours_converter/open_intervals'
|
|
15
16
|
require_relative './opening_hours_converter/iterator'
|
|
16
17
|
require_relative './opening_hours_converter/opening_hours_builder'
|
|
17
18
|
require_relative './opening_hours_converter/opening_hours_date'
|
data/readme.md
CHANGED
|
@@ -21,6 +21,34 @@ parsed_oh = OpeningHoursConverter::OpeningHoursParser.new.parse('Mo 10:00-12:00'
|
|
|
21
21
|
oh_string = OpeningHoursConverter::OpeningHoursBuilder.new.build(parsed_oh)
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
### Open intervals over a period
|
|
25
|
+
|
|
26
|
+
`get_open_intervals` returns the concrete opening periods of an opening hours
|
|
27
|
+
string inside a window, as `[{ start: Time, end: Time }]`, sorted by start and
|
|
28
|
+
clamped to `[from, to]`. It is the equivalent of `getOpenIntervals` in
|
|
29
|
+
[opening_hours.js](https://github.com/opening-hours/opening_hours.js).
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
OpeningHoursConverter::Iterator.new.get_open_intervals(
|
|
33
|
+
'Mo-Fr 08:00-12:00; Tu off',
|
|
34
|
+
Time.new(2026, 6, 1),
|
|
35
|
+
Time.new(2026, 6, 8)
|
|
36
|
+
)
|
|
37
|
+
# => [{ start: 2026-06-01 08:00, end: 2026-06-01 12:00 },
|
|
38
|
+
# { start: 2026-06-03 08:00, end: 2026-06-03 12:00 },
|
|
39
|
+
# { start: 2026-06-04 08:00, end: 2026-06-04 12:00 },
|
|
40
|
+
# { start: 2026-06-05 08:00, end: 2026-06-05 12:00 }]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Rules are applied in the order they were written, so a later one overrides an
|
|
44
|
+
earlier one: `Jul-Aug off; Mo-Fr 09:00-17:00` is open in July, while
|
|
45
|
+
`Mo-Fr 09:00-17:00; Jul-Aug off` is closed. Intervals crossing midnight end on
|
|
46
|
+
the following day, and `Time` values are built from wall clock components, so a
|
|
47
|
+
daylight saving change does not shift the time of day.
|
|
48
|
+
|
|
49
|
+
Unlike `get_time_iterator`, it applies `off` rules, handles midnight crossings
|
|
50
|
+
and covers every year the window touches rather than the current one.
|
|
51
|
+
|
|
24
52
|
## Test
|
|
25
53
|
|
|
26
54
|
Install the dependencies with:
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opening_hours_converter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Publidata
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: json
|
|
@@ -80,6 +80,7 @@ files:
|
|
|
80
80
|
- lib/opening_hours_converter/errors.rb
|
|
81
81
|
- lib/opening_hours_converter/interval.rb
|
|
82
82
|
- lib/opening_hours_converter/iterator.rb
|
|
83
|
+
- lib/opening_hours_converter/open_intervals.rb
|
|
83
84
|
- lib/opening_hours_converter/opening_hours_builder.rb
|
|
84
85
|
- lib/opening_hours_converter/opening_hours_date.rb
|
|
85
86
|
- lib/opening_hours_converter/opening_hours_parser.rb
|