opf/openproject

View on GitHub
lib/chronic_duration.rb

Summary

Maintainability
D
2 days
Test Coverage
# 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

  # 365.25 days in a year.
  SECONDS_PER_YEAR = 31_557_600

  @@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 = SECONDS_PER_YEAR

    if seconds >= SECONDS_PER_YEAR && 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 :days_and_hours
      dividers = {
        hours: "h", keep_zero: true
      }

      days += weeks * days_per_week
      days += months * days_per_month
      days += years * SECONDS_PER_YEAR / 3600 / 24
      dividers[:days] = "d" if days > 0
      years = months = weeks = 0

      hours = (hours + (((minutes * 60) + seconds) / 3600.0)).round(2)
      hours_int = hours.to_i
      hours = hours_int if hours - hours_int == 0 # if hours end with .0
      minutes = seconds = 0
    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
    return unless unit

    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