Add chronic_duration in our code to customize it

This commit is contained in:
Christophe Bliard
2024-06-07 10:07:44 +02:00
parent 4c4d17617e
commit 6b9ce751aa
4 changed files with 689 additions and 6 deletions
-2
View File
@@ -230,8 +230,6 @@ gem "turbo-rails", "~> 2.0.0"
gem "httpx"
gem "gitlab_chronic_duration"
group :test do
gem "launchy", "~> 3.0.0"
gem "rack-test", "~> 2.1.0"
-4
View File
@@ -556,8 +556,6 @@ GEM
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
gitlab_chronic_duration (0.12.0)
numerizer (~> 0.2)
glob (0.4.0)
globalid (1.2.1)
activesupport (>= 6.1)
@@ -749,7 +747,6 @@ GEM
nokogiri (1.16.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
numerizer (0.2.0)
oj (3.16.3)
bigdecimal (>= 3.0)
okcomputer (1.18.5)
@@ -1214,7 +1211,6 @@ DEPENDENCIES
fog-aws
friendly_id (~> 5.5.0)
fuubar (~> 2.5.0)
gitlab_chronic_duration
gon (~> 6.4.0)
good_job (= 3.26.2)
google-apis-gmail_v1
+343
View File
@@ -0,0 +1,343 @@
# Copied from https://gitlab.com/gitlab-org/ruby/gems/gitlab-chronic-duration
# version 0.12.0
#
# Copyright (c) Henry Poydar
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# NOTE:
# Changes to this file should be kept in sync with
# frontend/src/app/shared/helpers/chronic_duration.js.
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/PerceivedComplexity
module ChronicDuration
extend self
class DurationParseError < StandardError
end
# On average, there's a little over 4 weeks in month.
FULL_WEEKS_PER_MONTH = 4
@@raise_exceptions = false
@@hours_per_day = 24
@@days_per_month = 30
def self.raise_exceptions
!!@@raise_exceptions
end
def self.raise_exceptions=(value)
@@raise_exceptions = !!value
end
def self.hours_per_day
@@hours_per_day
end
def self.hours_per_day=(value)
@@hours_per_day = value
end
def self.days_per_month
@@days_per_month
end
def self.days_per_month=(value)
@@days_per_month = value
end
# Given a string representation of elapsed time,
# return an integer (or float, if fractions of a
# second are input)
def parse(string, opts = {})
result = calculate_from_words(cleanup(string), opts)
!opts[:keep_zero] && result == 0 ? nil : result
end
# Given an integer and an optional format,
# returns a formatted string representing elapsed time
# rubocop:disable Lint/UselessAssignment
def output(seconds, opts = {})
int = seconds.to_i
seconds = int if seconds - int == 0 # if seconds end with .0
opts[:format] ||= :default
opts[:keep_zero] ||= false
hours_per_day = opts[:hours_per_day] || ChronicDuration.hours_per_day
days_per_month = opts[:days_per_month] || ChronicDuration.days_per_month
days_per_week = days_per_month / FULL_WEEKS_PER_MONTH
years = months = weeks = days = hours = minutes = 0
decimal_places = seconds.to_s.split(".").last.length if seconds.is_a?(Float)
minute = 60
hour = 60 * minute
day = hours_per_day * hour
month = days_per_month * day
year = 31_557_600
if seconds >= 31_557_600 && seconds % year < seconds % month
years = seconds / year
months = seconds % year / month
days = seconds % year % month / day
hours = seconds % year % month % day / hour
minutes = seconds % year % month % day % hour / minute
seconds = seconds % year % month % day % hour % minute
elsif seconds >= 60
minutes = (seconds / 60).to_i
seconds %= 60
if minutes >= 60
hours = (minutes / 60).to_i
minutes = (minutes % 60).to_i
if !opts[:limit_to_hours] && (hours >= hours_per_day)
days = (hours / hours_per_day).to_i
hours = (hours % hours_per_day).to_i
if opts[:weeks]
if days >= days_per_week
weeks = (days / days_per_week).to_i
days = (days % days_per_week).to_i
if weeks >= FULL_WEEKS_PER_MONTH
months = (weeks / FULL_WEEKS_PER_MONTH).to_i
weeks = (weeks % FULL_WEEKS_PER_MONTH).to_i
end
end
elsif days >= days_per_month
months = (days / days_per_month).to_i
days = (days % days_per_month).to_i
end
end
end
end
joiner = opts.fetch(:joiner) { " " }
process = nil
case opts[:format]
when :micro
dividers = {
years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s"
}
joiner = ""
when :short
dividers = {
years: "y", months: "mo", weeks: "w", days: "d", hours: "h", minutes: "m", seconds: "s"
}
when :default
dividers = {
years: " yr", months: " mo", weeks: " wk", days: " day", hours: " hr", minutes: " min", seconds: " sec",
pluralize: true
}
when :long
dividers = {
years: " year", months: " month", weeks: " week", days: " day", hours: " hour", minutes: " minute", seconds: " second",
pluralize: true
}
when :chrono
dividers = {
years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true
}
process = lambda do |str|
# Pad zeros
# Get rid of lead off times if they are zero
# Get rid of lead off zero
# Get rid of trailing :
divider = ":"
str.split(divider).map do |n|
# add zeros only if n is an integer
n.include?(".") ? ("%04.#{decimal_places}f" % n) : ("%02d" % n)
end.join(divider).gsub(/^(00:)+/, "").gsub(/^0/, "").gsub(/:$/, "")
end
joiner = ""
end
result = %i[years months weeks days hours minutes seconds].map do |t|
next if t == :weeks && !opts[:weeks]
num = eval(t.to_s) # rubocop:disable Security/Eval
num = ("%.#{decimal_places}f" % num) if num.is_a?(Float) && t == :seconds
keep_zero = dividers[:keep_zero]
keep_zero ||= opts[:keep_zero] if t == :seconds
humanize_time_unit(num, dividers[t], dividers[:pluralize], keep_zero)
end.compact!
result = result[0...opts[:units]] if opts[:units]
result = result.join(joiner)
result = process.call(result) if process
result.empty? ? nil : result
end
# rubocop:enable Lint/UselessAssignment
private
def humanize_time_unit(number, unit, pluralize, keep_zero)
return nil if number == 0 && !keep_zero
res = "#{number}#{unit}"
# A poor man's pluralizer
res << "s" if (number != 1) && pluralize
res
end
def calculate_from_words(string, opts)
val = 0
words = string.split
words.each_with_index do |v, k|
next unless v&.match?(float_matcher)
val += (convert_to_number(v) * duration_units_seconds_multiplier(
words[k + 1] || (opts[:default_unit] || "seconds"), opts
))
end
val
end
def cleanup(string)
res = string.downcase
res = filter_by_type(res)
res = res.gsub(float_matcher) { |n| " #{n} " }.squeeze(" ").strip
filter_through_white_list(res)
end
def convert_to_number(string)
string.to_f % 1 > 0 ? string.to_f : string.to_i
end
def duration_units_list
%w[seconds minutes hours days weeks months years]
end
def duration_units_seconds_multiplier(unit, opts)
return 0 unless duration_units_list.include?(unit)
hours_per_day = opts[:hours_per_day] || ChronicDuration.hours_per_day
days_per_month = opts[:days_per_month] || ChronicDuration.days_per_month
days_per_week = days_per_month / FULL_WEEKS_PER_MONTH
case unit
when "years" then 31_557_600
when "months" then 3600 * hours_per_day * days_per_month
when "weeks" then 3600 * hours_per_day * days_per_week
when "days" then 3600 * hours_per_day
when "hours" then 3600
when "minutes" then 60
when "seconds" then 1
end
end
# Parse 3:41:59 and return 3 hours 41 minutes 59 seconds
def filter_by_type(string)
chrono_units_list = duration_units_list.reject { |v| v == "weeks" }
if string.delete(" ")&.match?(time_matcher)
res = []
string.delete(" ").split(":").reverse.each_with_index do |v, k|
return unless chrono_units_list[k] # rubocop:disable Lint/NonLocalExitFromIterator
res << "#{v} #{chrono_units_list[k]}"
end
res = res.reverse.join(" ")
else
res = string
end
res
end
def time_matcher
/^[0-9]+:[0-9]+(:[0-9]+){0,4}(\.[0-9]*)?$/
end
def float_matcher
/[0-9]*\.?[0-9]+/
end
# Get rid of unknown words and map found
# words to defined time units
def filter_through_white_list(string)
res = []
string.split.each do |word|
if word&.match?(float_matcher)
res << word.strip
next
end
stripped_word = word.strip.gsub(/^,/, "").gsub(/,$/, "")
if mappings.has_key?(stripped_word)
res << mappings[stripped_word]
elsif !join_words.include?(stripped_word) and ChronicDuration.raise_exceptions # rubocop:disable Rails/NegateInclude
raise DurationParseError, "An invalid word #{word.inspect} was used in the string to be parsed."
end
end
# add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec'
res.unshift(1) if !res.empty? && mappings[res[0]]
res.join(" ")
end
def mappings
{
"seconds" => "seconds",
"second" => "seconds",
"secs" => "seconds",
"sec" => "seconds",
"s" => "seconds",
"minutes" => "minutes",
"minute" => "minutes",
"mins" => "minutes",
"min" => "minutes",
"m" => "minutes",
"hours" => "hours",
"hour" => "hours",
"hrs" => "hours",
"hr" => "hours",
"h" => "hours",
"days" => "days",
"day" => "days",
"dy" => "days",
"d" => "days",
"weeks" => "weeks",
"week" => "weeks",
"wks" => "weeks",
"wk" => "weeks",
"w" => "weeks",
"months" => "months",
"mo" => "months",
"mos" => "months",
"month" => "months",
"years" => "years",
"year" => "years",
"yrs" => "years",
"yr" => "years",
"y" => "years"
}
end
def join_words
%w[and with plus]
end
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/PerceivedComplexity
+346
View File
@@ -0,0 +1,346 @@
# Copied from https://gitlab.com/gitlab-org/ruby/gems/gitlab-chronic-duration
# version 0.12.0
#
# Copyright (c) Henry Poydar
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation
# files (the "Software"), to deal in the Software without
# restriction, including without limitation the rights to use,
# copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following
# conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
# NOTE:
# Changes to this file should be kept in sync with
# frontend/src/app/shared/helpers/chronic_duration.js.
require "spec_helper"
RSpec.describe ChronicDuration do
describe ".parse" do
exemplars = {
"1:20" => 60 + 20,
"1:20.51" => 60 + 20.51,
"4:01:01" => (4 * 3600) + 60 + 1,
"3 mins 4 sec" => (3 * 60) + 4,
"3 Mins 4 Sec" => (3 * 60) + 4,
"2 hrs 20 min" => (2 * 3600) + (20 * 60),
"2h20min" => (2 * 3600) + (20 * 60),
"6 mos 1 day" => (6 * 30 * 24 * 3600) + (24 * 3600),
"1 year 6 mos 1 day" => (1 * 31557600) + (6 * 30 * 24 * 3600) + (24 * 3600),
"2.5 hrs" => 2.5 * 3600,
"47 yrs 6 mos and 4.5d" => (47 * 31557600) + (6 * 30 * 24 * 3600) + (4.5 * 24 * 3600),
"3 weeks and, 2 days" => (3600 * 24 * 7 * 3) + (3600 * 24 * 2),
"3 weeks, plus 2 days" => (3600 * 24 * 7 * 3) + (3600 * 24 * 2),
"3 weeks with 2 days" => (3600 * 24 * 7 * 3) + (3600 * 24 * 2),
"1 month" => 3600 * 24 * 30,
"2 months" => 3600 * 24 * 30 * 2,
"18 months" => 3600 * 24 * 30 * 18,
"1 year 6 months" => (3600 * 24 * (365.25 + (6 * 30))).to_i,
"day" => 3600 * 24,
"minute 30s" => 90
}
context "when string can't be parsed" do
it "returns nil" do
expect(described_class.parse("gobblygoo")).to be_nil
end
it "cannot parse zero" do
expect(described_class.parse("0")).to be_nil
end
context "when @@raise_exceptions set to true" do
it "raises with ChronicDuration::DurationParseError" do
described_class.raise_exceptions = true
expect { described_class.parse("23 gobblygoos") }.to raise_error(ChronicDuration::DurationParseError)
described_class.raise_exceptions = false
end
end
end
it "returns zero if the string parses as zero and the keep_zero option is true" do
expect(described_class.parse("0", keep_zero: true)).to eq(0)
end
it "returns a float if seconds are in decimals" do
expect(described_class.parse("12 mins 3.141 seconds")).to be_a(Float)
end
it "returns an integer unless the seconds are in decimals" do
expect(described_class.parse("12 mins 3 seconds")).to be_a(Integer)
end
it "is able to parse minutes by default" do
expect(described_class.parse("5", default_unit: "minutes")).to eq(300)
end
exemplars.each do |k, v|
it "parses a duration like #{k}" do
expect(described_class.parse(k)).to eq(v)
end
end
context "with :hours_per_day and :days_per_month params" do
it "uses provided :hours_per_day" do
expect(described_class.parse("1d", hours_per_day: 24)).to eq(24 * 60 * 60)
expect(described_class.parse("1d", hours_per_day: 8)).to eq(8 * 60 * 60)
end
it "uses provided :days_per_month" do
expect(described_class.parse("1mo", days_per_month: 30)).to eq(30 * 24 * 60 * 60)
expect(described_class.parse("1mo", days_per_month: 20)).to eq(20 * 24 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 30)).to eq(7 * 24 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 20)).to eq(5 * 24 * 60 * 60)
end
it "uses provided both :hours_per_day and :days_per_month" do
expect(described_class.parse("1mo", days_per_month: 30, hours_per_day: 24)).to eq(30 * 24 * 60 * 60)
expect(described_class.parse("1mo", days_per_month: 20, hours_per_day: 8)).to eq(20 * 8 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 30, hours_per_day: 24)).to eq(7 * 24 * 60 * 60)
expect(described_class.parse("1w", days_per_month: 20, hours_per_day: 8)).to eq(5 * 8 * 60 * 60)
end
end
end
describe ".output" do
exemplars = {
(60 + 20) =>
{
micro: "1m20s",
short: "1m 20s",
default: "1 min 20 secs",
long: "1 minute 20 seconds",
chrono: "1:20"
},
(60 + 20.51) =>
{
micro: "1m20.51s",
short: "1m 20.51s",
default: "1 min 20.51 secs",
long: "1 minute 20.51 seconds",
chrono: "1:20.51"
},
(60 + 20.51928) =>
{
micro: "1m20.51928s",
short: "1m 20.51928s",
default: "1 min 20.51928 secs",
long: "1 minute 20.51928 seconds",
chrono: "1:20.51928"
},
((4 * 3600) + 60 + 1) =>
{
micro: "4h1m1s",
short: "4h 1m 1s",
default: "4 hrs 1 min 1 sec",
long: "4 hours 1 minute 1 second",
chrono: "4:01:01"
},
((2 * 3600) + (20 * 60)) =>
{
micro: "2h20m",
short: "2h 20m",
default: "2 hrs 20 mins",
long: "2 hours 20 minutes",
chrono: "2:20"
},
((2 * 3600) + (20 * 60)) =>
{
micro: "2h20m",
short: "2h 20m",
default: "2 hrs 20 mins",
long: "2 hours 20 minutes",
chrono: "2:20:00"
},
((6 * 30 * 24 * 3600) + (24 * 3600)) =>
{
micro: "6mo1d",
short: "6mo 1d",
default: "6 mos 1 day",
long: "6 months 1 day",
chrono: "6:01:00:00:00" # Yuck. FIXME
},
((365.25 * 24 * 3600) + (24 * 3600)).to_i =>
{
micro: "1y1d",
short: "1y 1d",
default: "1 yr 1 day",
long: "1 year 1 day",
chrono: "1:00:01:00:00:00"
},
((3 * 365.25 * 24 * 3600) + (24 * 3600)).to_i =>
{
micro: "3y1d",
short: "3y 1d",
default: "3 yrs 1 day",
long: "3 years 1 day",
chrono: "3:00:01:00:00:00"
},
(3600 * 24 * 30 * 18) =>
{
micro: "18mo",
short: "18mo",
default: "18 mos",
long: "18 months",
chrono: "18:00:00:00:00"
}
}
exemplars.each do |k, v|
v.each do |key, val|
it "properly outputs a duration of #{k} seconds as #{val} using the #{key} format option" do
expect(described_class.output(k, format: key)).to eq(val)
end
end
end
keep_zero_exemplars = {
true =>
{
micro: "0s",
short: "0s",
default: "0 secs",
long: "0 seconds",
chrono: "0"
},
false =>
{
micro: nil,
short: nil,
default: nil,
long: nil,
chrono: "0"
}
}
keep_zero_exemplars.each do |k, v|
v.each do |key, val|
it "outputs properly a duration of 0 seconds as #{val.nil? ? 'nil' : val} using the #{key} format option, " \
"if the keep_zero option is #{k}" do
expect(described_class.output(0, format: key, keep_zero: k)).to eq(val)
end
end
end
it "returns weeks when needed" do
expect(described_class.output(45 * 24 * 60 * 60, weeks: true)).to match(/.*wk.*/)
end
it "returns hours and minutes only when :hours_only option specified" do
expect(described_class.output((395 * 24 * 60 * 60) + (15 * 60), limit_to_hours: true)).to eq("9480 hrs 15 mins")
end
context "with :hours_per_day and :days_per_month params" do
it "uses provided :hours_per_day" do
expect(described_class.output(24 * 60 * 60, hours_per_day: 24)).to eq("1 day")
expect(described_class.output(24 * 60 * 60, hours_per_day: 8)).to eq("3 days")
end
it "uses provided :days_per_month" do
expect(described_class.output(7 * 24 * 60 * 60, weeks: true, days_per_month: 30)).to eq("1 wk")
expect(described_class.output(7 * 24 * 60 * 60, weeks: true, days_per_month: 20)).to eq("1 wk 2 days")
end
it "uses provided both :hours_per_day and :days_per_month" do
expect(described_class.output(7 * 24 * 60 * 60, weeks: true, days_per_month: 30, hours_per_day: 24)).to eq("1 wk")
expect(described_class.output(5 * 8 * 60 * 60, weeks: true, days_per_month: 20, hours_per_day: 8)).to eq("1 wk")
end
it "uses provided params alongside with :weeks when converting to months" do
expect(described_class.output(30 * 24 * 60 * 60, days_per_month: 30, hours_per_day: 24)).to eq("1 mo")
expect(described_class.output(30 * 24 * 60 * 60, days_per_month: 30, hours_per_day: 24, weeks: true)).to eq("1 mo 2 days")
expect(described_class.output(20 * 8 * 60 * 60, days_per_month: 20, hours_per_day: 8)).to eq("1 mo")
expect(described_class.output(20 * 8 * 60 * 60, days_per_month: 20, hours_per_day: 8, weeks: true)).to eq("1 mo")
end
end
it "returns the specified number of units if provided" do
expect(described_class.output((4 * 3600) + 60 + 1, units: 2)).to eq("4 hrs 1 min")
expect(described_class.output((6 * 30 * 24 * 3600) + (24 * 3600) + 3600 + 60 + 1,
units: 3,
format: :long)).to eq("6 months 1 day 1 hour")
end
context "when the format is not specified" do
it "uses the default format" do
expect(described_class.output((2 * 3600) + (20 * 60))).to eq("2 hrs 20 mins")
end
end
exemplars.each do |seconds, format_spec|
format_spec.each_key do |format|
it "outputs a duration for #{seconds} that parses back to the same thing when using the #{format} format" do
expect(described_class.parse(
described_class.output(seconds, format:, use_complete_matcher: true)
)).to eq(seconds)
end
end
end
it "uses user-specified joiner if provided" do
expect(described_class.output((2 * 3600) + (20 * 60), joiner: ", ")).to eq("2 hrs, 20 mins")
end
end
describe ".filter_by_type" do
it "receives a chrono-formatted time like 3:14 and return a human time like 3 minutes 14 seconds" do
expect(described_class.instance_eval("filter_by_type('3:14')", __FILE__, __LINE__)).to eq("3 minutes 14 seconds")
end
it "receives chrono-formatted time like 12:10:14 and return a human time like 12 hours 10 minutes 14 seconds" do
expect(described_class.instance_eval("filter_by_type('12:10:14')", __FILE__,
__LINE__ - 1)).to eq("12 hours 10 minutes 14 seconds")
end
it "returns the input if it's not a chrono-formatted time" do
expect(described_class.instance_eval("filter_by_type('4 hours')", __FILE__, __LINE__)).to eq("4 hours")
end
end
describe ".cleanup" do
it "cleans up extraneous words" do
expect(described_class.instance_eval("cleanup('4 days and 11 hours')", __FILE__, __LINE__)).to eq("4 days 11 hours")
end
it "cleans up extraneous spaces" do
expect(described_class.instance_eval("cleanup(' 4 days and 11 hours')", __FILE__, __LINE__)).to eq("4 days 11 hours")
end
it "inserts spaces where there aren't any" do
expect(described_class.instance_eval("cleanup('4m11.5s')", __FILE__, __LINE__)).to eq("4 minutes 11.5 seconds")
end
end
describe "work week" do
before do
allow(described_class).to receive_messages(
hours_per_day: 8,
days_per_month: 20
)
end
it "parses knowing the work week" do
week = described_class.parse("5d")
expect(described_class.parse("40h")).to eq(week)
expect(described_class.parse("1w")).to eq(week)
end
end
end