dotiw 5.3.3 → 5.6.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/.github/workflows/ruby.yml +41 -16
- data/.gitignore +1 -0
- data/Appraisals +25 -4
- data/CHANGELOG.md +26 -0
- data/CODEOWNERS +2 -0
- data/README.markdown +73 -5
- data/RELEASING.md +71 -0
- data/benchmarks/distance_of_time_in_words_bench.rb +38 -0
- data/dotiw.gemspec +4 -1
- data/gemfiles/{rails_4.gemfile → rails_7.0.gemfile} +2 -1
- data/gemfiles/rails_7.1.gemfile +11 -0
- data/gemfiles/rails_7.2.gemfile +11 -0
- data/gemfiles/rails_8.0.gemfile +11 -0
- data/gemfiles/rails_8.1.gemfile +11 -0
- data/gemfiles/rails_8.1_i18n.gemfile +11 -0
- data/lib/dotiw/core.rb +45 -0
- data/lib/dotiw/locale/dz.yml +60 -0
- data/lib/dotiw/locale/ru.yml +9 -9
- data/lib/dotiw/methods.rb +127 -21
- data/lib/dotiw/time_hash.rb +60 -12
- data/lib/dotiw/version.rb +1 -1
- data/lib/dotiw.rb +1 -40
- data/spec/lib/dotiw_module_spec.rb +52 -0
- data/spec/lib/dotiw_spec.rb +225 -14
- data/spec/lib/i18n/dz.yml +21 -0
- data/spec/lib/i18n/ru.yml +18 -0
- data/spec/spec_helper.rb +19 -0
- metadata +65 -9
data/lib/dotiw/methods.rb
CHANGED
|
@@ -44,6 +44,31 @@ module DOTIW
|
|
|
44
44
|
|
|
45
45
|
private
|
|
46
46
|
|
|
47
|
+
# How many of each measure is necessary to round up to one of the next-largest measure. Note
|
|
48
|
+
# that for simplicity of implementation, we only check one measure when seeing if we should
|
|
49
|
+
# round up. This means that rounding up days to weeks, for instance, cannot draw the line at 3
|
|
50
|
+
# days and 12 hours, but instead either 3 or 4 (whole) days. Let's not even talk about weeks
|
|
51
|
+
# rounding up to months.
|
|
52
|
+
ROUNDING_THRESHOLDS = {
|
|
53
|
+
seconds: 30,
|
|
54
|
+
minutes: 30,
|
|
55
|
+
hours: 12,
|
|
56
|
+
days: 4,
|
|
57
|
+
weeks: 2,
|
|
58
|
+
months: 6,
|
|
59
|
+
years: Float::INFINITY,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
ROLLUP_THRESHOLDS = {
|
|
63
|
+
seconds: 60,
|
|
64
|
+
minutes: 60,
|
|
65
|
+
hours: 24,
|
|
66
|
+
days: 7,
|
|
67
|
+
weeks: 4, # !!!
|
|
68
|
+
months: 12,
|
|
69
|
+
years: Float::INFINITY,
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
def normalize_distance_of_time_argument_to_time(value)
|
|
48
73
|
if value.is_a?(Numeric)
|
|
49
74
|
Time.at(value)
|
|
@@ -63,47 +88,72 @@ module DOTIW
|
|
|
63
88
|
end
|
|
64
89
|
|
|
65
90
|
def _display_time_in_words(hash, options = {})
|
|
91
|
+
hash = hash.dup
|
|
92
|
+
|
|
66
93
|
options = options.reverse_merge(
|
|
67
94
|
include_seconds: false
|
|
68
95
|
).symbolize_keys!
|
|
69
96
|
|
|
97
|
+
discarded_hash = {}
|
|
98
|
+
|
|
70
99
|
include_seconds = options.delete(:include_seconds)
|
|
71
|
-
hash.delete(:seconds) if !include_seconds && hash[:minutes]
|
|
100
|
+
discarded_hash[:seconds] = hash.delete(:seconds) if !include_seconds && hash[:minutes]
|
|
72
101
|
|
|
73
102
|
options[:except] = Array.wrap(options[:except]).map!(&:to_sym) if options[:except]
|
|
74
103
|
options[:only] = Array.wrap(options[:only]).map!(&:to_sym) if options[:only]
|
|
75
104
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
if options[:except] || options[:only]
|
|
106
|
+
# Fold excluded fractions into the next smaller fraction instead of discarding their
|
|
107
|
+
# duration outright, e.g. weeks excluded via only: [:years, :months, :days] are converted
|
|
108
|
+
# to days and added to the days count, rather than losing those weeks entirely. Larger
|
|
109
|
+
# fractions are processed first so that a run of consecutive excluded fractions cascades
|
|
110
|
+
# down to the largest fraction that's still included (or is dropped if none remain).
|
|
111
|
+
DOTIW::TimeHash::TIME_FRACTIONS.reverse_each do |fraction|
|
|
112
|
+
next unless hash.key?(fraction)
|
|
113
|
+
next unless options[:except]&.include?(fraction) || (options[:only] && !options[:only].include?(fraction))
|
|
114
|
+
|
|
115
|
+
value = hash.delete(fraction)
|
|
116
|
+
discarded_hash[fraction] = value
|
|
117
|
+
|
|
118
|
+
next_fraction = _next_smaller_fraction(fraction)
|
|
119
|
+
next unless next_fraction
|
|
120
|
+
|
|
121
|
+
hash[next_fraction] = hash.fetch(next_fraction, 0) + (value * ROLLUP_THRESHOLDS[next_fraction])
|
|
122
|
+
end
|
|
81
123
|
end
|
|
82
124
|
|
|
125
|
+
hash.delete_if { |key, value| value.nil? || value.zero? }
|
|
126
|
+
discarded_hash.delete_if { |key, value| value.nil? || value.zero? }
|
|
127
|
+
|
|
83
128
|
i18n_scope = options.delete(:scope) || DOTIW::DEFAULT_I18N_SCOPE
|
|
84
129
|
if hash.empty?
|
|
85
130
|
fractions = DOTIW::TimeHash::TIME_FRACTIONS
|
|
86
131
|
fractions &= options[:only] if options[:only]
|
|
87
132
|
fractions -= options[:except] if options[:except]
|
|
88
133
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
output = []
|
|
98
|
-
I18n.with_options locale: options[:locale], scope: i18n_scope do |locale|
|
|
99
|
-
output = hash.map { |key, value| locale.t(key, count: value) }
|
|
134
|
+
locale = options[:locale]
|
|
135
|
+
# e.g. try to format 'less than 1 days', fallback to '0 days'
|
|
136
|
+
return I18n.translate :less_than_x,
|
|
137
|
+
locale: locale,
|
|
138
|
+
scope: i18n_scope,
|
|
139
|
+
distance: I18n.translate(fractions.first, locale: locale, scope: i18n_scope, count: 1),
|
|
140
|
+
default: I18n.translate(fractions.first, locale: locale, scope: i18n_scope, count: 0)
|
|
100
141
|
end
|
|
101
142
|
|
|
102
143
|
options.delete(:except)
|
|
103
144
|
options.delete(:only)
|
|
104
|
-
|
|
105
|
-
highest_measures =
|
|
106
|
-
|
|
145
|
+
|
|
146
|
+
highest_measures = _compute_highest_measures! options
|
|
147
|
+
if highest_measures
|
|
148
|
+
high_entries, low_entries = hash.to_a.partition.with_index { |_, index| index < highest_measures[:max] }
|
|
149
|
+
hash = high_entries.to_h
|
|
150
|
+
discarded_hash.merge! low_entries.to_h
|
|
151
|
+
|
|
152
|
+
_maybe_round! hash, discarded_hash, highest_measures[:remainder]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
locale = options[:locale]
|
|
156
|
+
phrases = hash.map { |key, value| I18n.translate(key, locale: locale, scope: i18n_scope, count: value) }
|
|
107
157
|
|
|
108
158
|
options[:words_connector] ||= I18n.translate :"#{i18n_scope}.words_connector",
|
|
109
159
|
default: :'support.array.words_connector',
|
|
@@ -115,7 +165,63 @@ module DOTIW
|
|
|
115
165
|
default: :'support.array.last_word_connector',
|
|
116
166
|
locale: options[:locale]
|
|
117
167
|
|
|
118
|
-
|
|
168
|
+
phrases.to_sentence(options.except(:accumulate_on, :max_unit, :compact))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def _compute_highest_measures!(options)
|
|
172
|
+
highest_measures = options.delete(:highest_measures)
|
|
173
|
+
highest_measures = 1 if options.delete(:highest_measure_only)
|
|
174
|
+
highest_measures = { max: highest_measures } if highest_measures.is_a?(Integer)
|
|
175
|
+
highest_measures = highest_measures.reverse_merge(max: 1, remainder: :floor) if highest_measures
|
|
176
|
+
|
|
177
|
+
highest_measures
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def _maybe_round!(hash, discarded_hash, remainder)
|
|
181
|
+
smallest_measure_index = DOTIW::TimeHash::TIME_FRACTIONS.index hash.to_a.last[0]
|
|
182
|
+
smallest_measure = DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index]
|
|
183
|
+
|
|
184
|
+
case remainder
|
|
185
|
+
when :floor
|
|
186
|
+
# Nothing to do.
|
|
187
|
+
when :ceiling
|
|
188
|
+
# We already filtered out zeroes, so non-empty also means non-zero.
|
|
189
|
+
if !discarded_hash.empty?
|
|
190
|
+
hash[smallest_measure] += 1
|
|
191
|
+
_rollup! hash, smallest_measure_index
|
|
192
|
+
end
|
|
193
|
+
when :round
|
|
194
|
+
# If our smallest measure is already the smallest possible measure, there is no next
|
|
195
|
+
# smallest measure to inspect to see if we need to round up.
|
|
196
|
+
return if smallest_measure_index == 0
|
|
197
|
+
|
|
198
|
+
next_smallest_measure = DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index - 1]
|
|
199
|
+
if discarded_hash.fetch(next_smallest_measure, 0) >= ROUNDING_THRESHOLDS[next_smallest_measure]
|
|
200
|
+
hash[smallest_measure] += 1
|
|
201
|
+
_rollup! hash, smallest_measure_index
|
|
202
|
+
end
|
|
203
|
+
else
|
|
204
|
+
raise ArgumentError, "unrecognized remainder value #{remainder.inspect}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def _next_smaller_fraction(fraction)
|
|
209
|
+
index = DOTIW::TimeHash::TIME_FRACTIONS.index(fraction)
|
|
210
|
+
return nil if index.nil? || index.zero?
|
|
211
|
+
|
|
212
|
+
DOTIW::TimeHash::TIME_FRACTIONS[index - 1]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def _rollup!(hash, smallest_measure_index)
|
|
216
|
+
DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index..-1].each_with_index do |fraction, index|
|
|
217
|
+
if hash.fetch(fraction, 0) >= ROLLUP_THRESHOLDS[fraction]
|
|
218
|
+
hash.delete fraction
|
|
219
|
+
next_fraction = DOTIW::TimeHash::TIME_FRACTIONS[smallest_measure_index + index + 1]
|
|
220
|
+
hash[next_fraction] = hash.fetch(next_fraction, 0) + 1
|
|
221
|
+
else
|
|
222
|
+
break
|
|
223
|
+
end
|
|
224
|
+
end
|
|
119
225
|
end
|
|
120
226
|
end
|
|
121
227
|
end
|
data/lib/dotiw/time_hash.rb
CHANGED
|
@@ -13,15 +13,9 @@ module DOTIW
|
|
|
13
13
|
@from_time = from_time || Time.current
|
|
14
14
|
@to_time = to_time || (@to_time_not_given = true && @from_time + distance.seconds)
|
|
15
15
|
@smallest, @largest = [@from_time, @to_time].minmax
|
|
16
|
-
@to_time
|
|
17
|
-
@to_time -= 1.hour if @to_time_not_given && !smallest.dst? && largest.dst?
|
|
16
|
+
@to_time += offset_delta(smallest, largest) if @to_time_not_given
|
|
18
17
|
@smallest, @largest = [@from_time, @to_time].minmax
|
|
19
|
-
@distance ||=
|
|
20
|
-
d = largest - smallest
|
|
21
|
-
d -= 1.hour if smallest.dst? && !largest.dst?
|
|
22
|
-
d += 1.hour if !smallest.dst? && largest.dst?
|
|
23
|
-
d
|
|
24
|
-
end
|
|
18
|
+
@distance ||= (largest - smallest) + offset_delta(smallest, largest)
|
|
25
19
|
|
|
26
20
|
build_time_hash
|
|
27
21
|
end
|
|
@@ -40,11 +34,23 @@ module DOTIW
|
|
|
40
34
|
ONE_WEEK = 7.days.freeze
|
|
41
35
|
FOUR_WEEKS = 28.days.freeze
|
|
42
36
|
|
|
37
|
+
# The distance between two Time objects computed via subtraction already
|
|
38
|
+
# reflects any change in UTC offset between them (whether from a DST
|
|
39
|
+
# transition or a permanent tzdata rule change, e.g. Pacific/Norfolk's
|
|
40
|
+
# 2015 UTC offset change), which is what we want when reporting a raw
|
|
41
|
+
# elapsed duration. However, when we split that distance into calendar
|
|
42
|
+
# fields (years/months/weeks/days), we want the offset difference
|
|
43
|
+
# folded away so the leftover hours/minutes/seconds reflect only actual
|
|
44
|
+
# elapsed wall-clock time, not artifacts of an offset shift.
|
|
45
|
+
def offset_delta(smallest, largest)
|
|
46
|
+
largest.utc_offset - smallest.utc_offset
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
def build_time_hash
|
|
44
|
-
if
|
|
50
|
+
if (max_unit = options[:max_unit])
|
|
51
|
+
build_max_unit(max_unit.to_sym)
|
|
52
|
+
elsif accumulate_on = options[:accumulate_on]
|
|
45
53
|
accumulate_on = accumulate_on.to_sym
|
|
46
|
-
return build_time_hash if accumulate_on == :years
|
|
47
|
-
|
|
48
54
|
TIME_FRACTIONS.index(accumulate_on).downto(0) { |i| send("build_#{TIME_FRACTIONS[i]}") }
|
|
49
55
|
else
|
|
50
56
|
while distance > 0
|
|
@@ -88,14 +94,56 @@ module DOTIW
|
|
|
88
94
|
output[:weeks], @distance = distance.divmod(ONE_WEEK.to_i) unless output[:weeks]
|
|
89
95
|
end
|
|
90
96
|
|
|
97
|
+
# Expresses the entire distance as a single number in the given unit, discarding
|
|
98
|
+
# (flooring) any remainder smaller than that unit, unlike +accumulate_on+ which keeps
|
|
99
|
+
# showing the smaller units below the target.
|
|
100
|
+
def build_max_unit(unit)
|
|
101
|
+
case unit
|
|
102
|
+
when :seconds
|
|
103
|
+
output[:seconds] = distance.to_i
|
|
104
|
+
when :minutes
|
|
105
|
+
output[:minutes] = (distance / ONE_MINUTE).floor
|
|
106
|
+
when :hours
|
|
107
|
+
output[:hours] = (distance / ONE_HOUR).floor
|
|
108
|
+
when :days
|
|
109
|
+
output[:days] = (distance / ONE_DAY).floor
|
|
110
|
+
when :weeks
|
|
111
|
+
output[:weeks] = (distance / ONE_WEEK).floor
|
|
112
|
+
when :months
|
|
113
|
+
output[:months] = whole_months_between(smallest, largest)
|
|
114
|
+
when :years
|
|
115
|
+
output[:years] = whole_years_between(smallest, largest)
|
|
116
|
+
else
|
|
117
|
+
raise ArgumentError, "unrecognized max_unit #{unit.inspect}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@distance = 0
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def whole_months_between(from, to)
|
|
124
|
+
months = (to.year - from.year) * 12 + (to.month - from.month)
|
|
125
|
+
months -= 1 if to.advance(months: -months) < from
|
|
126
|
+
months
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def whole_years_between(from, to)
|
|
130
|
+
years = to.year - from.year
|
|
131
|
+
years -= 1 if to.advance(years: -years) < from
|
|
132
|
+
years
|
|
133
|
+
end
|
|
134
|
+
|
|
91
135
|
def build_months
|
|
92
136
|
build_years_months_weeks_days
|
|
93
137
|
|
|
94
|
-
if (years = output.delete(:years)) > 0
|
|
138
|
+
if options[:accumulate_on]&.to_sym == :months && (years = output.delete(:years)) > 0
|
|
95
139
|
output[:months] += (years * 12)
|
|
96
140
|
end
|
|
97
141
|
end
|
|
98
142
|
|
|
143
|
+
def build_years
|
|
144
|
+
build_years_months_weeks_days
|
|
145
|
+
end
|
|
146
|
+
|
|
99
147
|
def build_years_months_weeks_days
|
|
100
148
|
months = (largest.year - smallest.year) * 12 + (largest.month - smallest.month)
|
|
101
149
|
years, months = months.divmod(12)
|
data/lib/dotiw/version.rb
CHANGED
data/lib/dotiw.rb
CHANGED
|
@@ -1,45 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require 'active_support'
|
|
6
|
-
require 'active_support/core_ext'
|
|
7
|
-
|
|
8
|
-
module DOTIW
|
|
9
|
-
extend ActiveSupport::Autoload
|
|
10
|
-
|
|
11
|
-
eager_autoload do
|
|
12
|
-
autoload :VERSION, 'dotiw/version'
|
|
13
|
-
autoload :TimeHash, 'dotiw/time_hash'
|
|
14
|
-
autoload :Methods, 'dotiw/methods'
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
extend self
|
|
18
|
-
|
|
19
|
-
DEFAULT_I18N_SCOPE = :'datetime.dotiw'
|
|
20
|
-
DEFAULT_I18N_SCOPE_COMPACT = :'datetime.dotiw_compact'
|
|
21
|
-
|
|
22
|
-
def init_i18n!
|
|
23
|
-
I18n.load_path.unshift(*locale_files)
|
|
24
|
-
I18n.reload!
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def languages
|
|
28
|
-
@languages ||= (locale_files.map { |path| path.split(%r{[/.]})[-2].to_sym })
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def locale_files
|
|
32
|
-
files 'dotiw/locale', '*.yml'
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
protected
|
|
36
|
-
|
|
37
|
-
def files(directory, ext)
|
|
38
|
-
Dir[File.join File.dirname(__FILE__), directory, ext]
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
DOTIW.init_i18n!
|
|
3
|
+
require_relative 'dotiw/core'
|
|
43
4
|
|
|
44
5
|
begin
|
|
45
6
|
require 'action_view'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
describe DOTIW do
|
|
6
|
+
START_TIME_MODULE = '01-08-2009'.to_time(:utc)
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
I18n.locale = :en
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '.distance_of_time_in_words' do
|
|
13
|
+
it 'returns the exact distance' do
|
|
14
|
+
expect(
|
|
15
|
+
described_class.distance_of_time_in_words(START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes)
|
|
16
|
+
).to eq('1 hour and 30 minutes')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'accepts options' do
|
|
20
|
+
expect(
|
|
21
|
+
described_class.distance_of_time_in_words(
|
|
22
|
+
START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes, highest_measures: 1
|
|
23
|
+
)
|
|
24
|
+
).to eq('1 hour')
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '.time_ago_in_words' do
|
|
29
|
+
it 'returns the distance to now' do
|
|
30
|
+
allow(Time).to receive(:now).and_return(START_TIME_MODULE)
|
|
31
|
+
allow(Time.zone).to receive(:now).and_return(START_TIME_MODULE)
|
|
32
|
+
|
|
33
|
+
expect(
|
|
34
|
+
described_class.time_ago_in_words(START_TIME_MODULE - 90, include_seconds: true)
|
|
35
|
+
).to eq('1 minute and 30 seconds')
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '.distance_of_time' do
|
|
40
|
+
it 'returns the distance for a number of seconds' do
|
|
41
|
+
expect(described_class.distance_of_time(90)).to eq('1 minute and 30 seconds')
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '.distance_of_time_in_words_hash' do
|
|
46
|
+
it 'returns the distance as a hash' do
|
|
47
|
+
expect(
|
|
48
|
+
described_class.distance_of_time_in_words_hash(START_TIME_MODULE, START_TIME_MODULE + 1.hour + 30.minutes)
|
|
49
|
+
).to include(hours: 1, minutes: 30)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|