module AIXM module Refinements UPTRANS_FILTER = %r( [^A-Z0-9, !"&#$%'\(\)\*\+\-\./:;<=>\?@\[\\\]\^_\|\{\}] )x.freeze UPTRANS_MAP = { 'Ä' => 'AE', 'Ö' => 'OE', 'Ü' => 'UE', 'Æ' => 'AE', 'Œ' => 'OE', "Å" => "Aa", "Ø" => "Oe" }.freeze PRETTY_XSLT = <<~END.then { Nokogiri::XSLT(_1) } END # @!method to_digest # Builds a 4 byte hex digest from the Array payload. # # @example # ['foo', :bar, nil, [123]].to_digest # # => "f3920098" # # @note This is a refinement for +Array+ # @return [String] 4 byte hex refine Array do def to_digest ::Digest::SHA512.hexdigest(flatten.map(&:to_s).join('|'.freeze))[0, 8] end end # @!method to_dms(padding=3) # Convert DD angle to DMS with the degrees zero padded to +padding+ # length. # # @example # 43.22164444444445.to_dms(2) # # => "43°12'77.92\"" # 43.22164444444445.to_dms # # => "043°12'77.92\"" # # @note This is a refinement for +Float+ # @param padding [Integer] number of digits for the degree part # @return [String] angle in DMS notation +{-}D°MM'SS.SS"+ refine Float do def to_dms(padding=3) degrees = self.abs.floor minutes = ((self.abs - degrees) * 60).floor seconds = (self.abs - degrees - minutes.to_f / 60) * 3600 minutes, seconds = minutes + 1, 0 if seconds.round(2) == 60 degrees, minutes = degrees + 1, 0 if minutes == 60 %Q(%s%0#{padding}d°%02d'%05.2f") % [ ('-' if self.negative?), self.abs.truncate, minutes.abs.truncate, seconds.abs ] end end # @!method to_rad # Convert an angle from degree to radian. # # @example # 45.to_rad # # => 0.7853981633974483 # # @note This is a refinement for +Float+ # @return [Float] radian angle refine Float do def to_rad self * Math::PI / 180 end end # @!method trim # Convert whole numbers to Integer and leave all other untouched. # # @example # 3.0.trim # # => 3 # 3.3.trim # # => 3.3 # # @note This is a refinement for +Float+ # @return [Integer, Float] converted Float refine Float do def trim (self % 1).zero? ? self.to_i : self end end # @!method lookup(key_or_value, fallback=omitted=true) # Fetch a value from the hash, but unlike +Hash#fetch+, if +key_or_value+ # is no hash key, check whether +key_or_value+ is a hash value and if so # return it. # # @example # h = { one: 1, two: 2, three: 3, four: :three } # h.lookup(:one) # => 1 # h.lookup(1) # => 1 # h.lookup(:three) # => 3 (key has priority over value) # h.lookup(:foo) # => KeyError # h.lookup(:foo, :fallback) # => :fallback # h.lookup(:foo, nil) # => nil # # @note This is a refinement for +Hash+ # @param key_or_value [Object] key or value of the hash # @param fallback [Object] fallback value # @return [Object] # @raise [KeyError] if neither a matching hash key nor hash value are # found and no fallback value has been passed refine Hash do def lookup(key_or_value, fallback=omitted=true) self[key_or_value] || (key_or_value if has_value?(key_or_value)) || (omitted ? fail(KeyError, "key or value `#{key_or_value}' not found") : fallback) end end # @!method to_pretty_xml # Pretty printing alternative of +to_xml+ # # @example # xml = <<~END # # # # # # # END # Nokogiri::XML::DocumentFragment.parse(xml).to_pretty_xml # # => # # # # # # @note This is a refinement for +Nokogiri::XML::DocumentFragment+ # @return [String] refine Nokogiri::XML::DocumentFragment do def to_pretty_xml builder = Nokogiri::XML::Builder.new builder.DocumentFragment { _1 << self.to_html } AIXM::Refinements::PRETTY_XSLT.transform(builder.doc).at_css('DocumentFragment').children.map(&:to_xml).join("\n") end end # @!method then_if # Same as +Object#then+ but only applied if the condition is true. # # @example # "foobar".then_if(false) { _1.gsub(/o/, 'i') } # => "foobar" # "foobar".then_if(true) { _1.gsub(/o/, 'i') } # => "fiibar" # # @note This is a refinement for +Object+ # @return [Object] refine Object do def then_if(condition, &block) # TODO: [ruby-3.1] use anonymous block "&" on this and next line condition ? self.then(&block) : self end end # @!method Range.from # Returns a Range covering the given object. # # To ease coverage tests in mixed arrays of single objects and object # ranges, this method assures you're always dealing with objects. It # returns self if it is already a Range, otherwise builds one with the # given single object as both beginning and end. # # @example # Range.from(5) # => (5..5) # Range.from(1..3) # => (1..3) # # @note This is a refinement for +Range+ # @param object [Object] # @return [Range] #refine Range do refine Range.singleton_class do def from(object) object.is_a?(Range) ? object : (object..object) end end # @!method decapture # Replace all groups with non-caputuring groups # # @example # /^(foo)(?bar)/.decapture # => /^(?:foo)(?:bar)/ # # @note This is a refinement for +Regexp+ # @return [Regexp] refine Regexp do def decapture Regexp.new(to_s.gsub(/\(\?<\w+>|(? " foobar " # # @note This is a refinement for +String+ # @param padding [String] string to prepend and append # @return [String] refine String do def dress(padding=' ') [padding, strip, padding].join end end # @!method to_class # Convert string to class # # @example # "AIXM::Feature::NavigationalAid".to_class # # => AIXM::Feature::NavigationalAid # # @note This is a refinement for +String+ # @return [Class] refine String do def to_class Object.const_get(self) end end # @!method inflect # Apply inflections from the +dry-inflector+ gem # # @example # s = "AIXM::Feature::NavigationalAid" # s.inflect(:demodulize, :tableize, :pluralize) # # => "navigational_aids" # # @see https://www.rubydoc.info/gems/dry-inflector # @note This is a refinement for +String+ # @return [String] refine String do def inflect(*inflections) inflections.inject(self) do |memo, inflection| AIXM.config.inflector.send(inflection, memo) end end end # @!method compact # Collapse whitespace to one space, but leave +\n+ untouched, then strip # what's left. # # @example # " foo\n\nbar baz \r".compact # => "foo\n\nbar baz" # # @note This is a refinement for +String+ # @return [String] compacted string refine String do def compact split("\n").map { _1.gsub(/\s+/, ' ') }.join("\n").strip end end # @!method indent(number) # Indent every line of a string with +number+ spaces. # # @example # "foo\nbar".indent(2) # # => " foo\n bar" # # @note This is a refinement for +String+ # @param number [Integer] number of spaces # @return [String] line indended string refine String do def indent(number) whitespace = ' ' * number gsub(/^/, whitespace) end end # @!method to_dd # Convert DMS angle to DD or +nil+ if the notation is not recognized. # # @example # %q(43°12'77.92").to_dd # # => 43.22164444444445 # "431277.92S".to_dd # # => -43.22164444444445 # %q(-123).to_dd # # => nil # # Supported notations: # * +{-}{DD}D°MM'SS{.SS}"{[NESW]}+ # * +{-}{DD}D MM SS{.SS} {[NESW]}+ # * +{-}{DD}DMMSS{.SS}[NESW]+ # # Quite a number of typos are tolerated such as the wrong use of # minute +'+ and second +"+ markers as well as the use of decimal # comma +,+ instead of dot +.+. # # @note This is a refinement for +String+ # @return [Float] angle in DD notation refine String do def to_dd if match = self.match(DMS_RE) "#{match['sgn']}1".to_i * "#{:- if match['hem_sw']}1".to_i * ( match['deg'].to_f + match['min'].to_f/60 + match['sec'].tr(',', '.').to_f/3600 ) end end end # @!method to_time # Parse string to date and time. # # @example # '2018-01-01 15:00'.to_time # # => 2018-01-01 15:00:00 +0100 # # @note This is a refinement for +String+ # @return [Time] date and time refine String do def to_time Time.parse(self) end end # @!method uptrans # Upcase and transliterate to match the reduced character set for # AIXM names and titles. # # See {UPTRANS_MAP} for supported diacryts and {UPTRANS_FILTER} for the # list of allowed characters in the returned value. # # @example # "Nîmes-Alès".uptrans # # => "NIMES-ALES" # "Zürich".uptrans # # => "ZUERICH" # # @note This is a refinement for +String+ # @return [String] upcased and transliterated String refine String do def uptrans self.dup.tap do |string| string.upcase! string.gsub!(/(#{UPTRANS_MAP.keys.join('|'.freeze)})/, UPTRANS_MAP) string.unicode_normalize!(:nfd) string.gsub!(UPTRANS_FILTER, '') end end end end end