module Eco module API module Organization # Provides helpers to deal with tagtrees. # @note that currenlty the parsing assumes top level to be array. # This does not allow to capture the `name` and `id` of the locations # structure itself into the json storing model. class TagTree HEADER = %w[ id name weight parent_id archived archived_token classifications classification_names level ].freeze include Enumerable attr_reader :source attr_accessor :id alias_method :tag, :id attr_accessor :name, :weight attr_accessor :archived, :archived_token attr_accessor :classifications, :classification_names attr_reader :parent attr_reader :nodes attr_reader :depth # @example Node format: # {"tag": "NODE NAME", "nodes": subtree} # @example Tree/subtree format: # [[Node], ...] # @example Input format example: # tree = [{"tag" => "AUSTRALIA", "nodes" => [ # {"tag" => "SYDNEY", "nodes" => []} # ]}] # tree = TagTree.new(tree.to_json) # @param tagtree [String] representation of the tagtree in json. def initialize( tagtree = [], name: nil, id: nil, depth: -1, path: [], parent: nil, _weight: nil # rubocop:disable Lint/UnderscorePrefixedVariableName ) @source = parse_source_input(tagtree) msg = "You are trying to initialize a TagTree with a null tagtree" raise ArgumentError, msg unless source @parent = parent @depth = depth @path = path || [] if source.is_a?(Array) @id = id @name = name @raw_nodes = source else source['weight'] ||= _weight init_node end self.path.push(self.id) unless top? @nodes = @raw_nodes.map.with_index do |cnode, idx| self.class.new(cnode, depth: depth + 1, path: @path.dup, parent: self, _weight: idx) end init_hashes end def archived? @archived end def active? !archived? end # @note that archived nodes will also be passed over to the copy # @return [Eco::API::Organization::TagTree] def dup self.class.new(as_json, name: name, id: id) end # @return [Array] with the differences def diff(tagtree, _differences: {}, _level: 0, **options) require 'hashdiff' Hashdiff.diff(as_json, tagtree.as_json, **options.slice(:array_path, :similarity, :use_lcs)) end # It generates a merged tagtree out of two sources # @note it merges the first level nodes (and their children) as it comes # @return [Eco::API::Organization::TagTree] result of merging both trees def merge(other) msg = "Expecting Eco::API::Organization::TagTree. Given: #{other.class}" raise ArgumentError, msg unless other.is_a?(Eco::API::Organization::TagTree) mid = [id, other.id].join('|') mname = [name, other.name].join('|') self.class.new(as_json | other.as_json, id: mid, name: mname) end # @return [Eco::API::Organization::TagTree] with **non** `archived` nodes only def active_tree self.class.new(as_json(include_archived: false), name: name, id: id) end # @return [Eco::API::Organization::TagTree] with nodes up to `max_depth` def truncate(max_depth: total_depth) self.class.new(as_json(max_depth: max_depth), name: name, id: id) end # Iterate through all the nodes of this tree # @yield [node] do some stuff with one of the nodes of the tree # @yieldparam node [Eco::API::Organization::TagTree] a node of the tree # @return [Enumerable] def each(&block) return to_enum(:each) unless block all_nodes.each(&block) end # @note rejected nodes will not include their children nodes # @return [Array] plain list of nodes def select(when_is: true, &block) raise ArgumentError, "Missing block" unless block_given? [].tap do |out_nodes| selected = false selected = (yield(self) == when_is) unless top? out_nodes.push(self) if selected next unless selected || top? nodes.each do |nd| out_nodes.concat(nd.select(when_is: when_is, &block)) end end end # @note rejected nodes will not include their children nodes # @return [Array] plain list of nodes def reject(&block) select(when_is: false, &block) end # All actual nodes of this tree # @note order is that of the parent to child relationships # @return [Array] def all_nodes(&block) [].tap do |out_nodes| unless top? out_nodes.push(self) yield(self) if block_given? end nodes.each do |nd| out_nodes.concat(nd.all_nodes(&block)) end end end # All the acenstor nodes of the current node # @note it does not include the current node # @return [Array] ancestors sorted from top to bottom. def ancestors [].tap do |ans| unless parent.top? ans << parent ans.concat(parent.ancestors) end end end # @return [String] the `id` of the parent (unless we are on a top level node) def parent_id parent.id unless parent.top? end # @return [String] the `name` of the parent (unless we are on a top level node) def parent_name parent.name unless parent.top? end def top? depth == -1 end # Returns a tree of Hashes form nested via `nodes` (or just a list of hash nodes) # @yield [node_json, node] block for custom output json model # @yiledreturn [Hash] the custom json model. # @param include_children [Boolean] whether it should return a tree hash or just a list of hash nodes. # @param include_archived [Boolean] whether it should include archived nodes. # @param max_depth [Boolean] up to what level `depth` nodes should be included. # @return [Array[Hash]] where `Hash` is a `node` (i.e. `{"tag" => TAG, "nodes": Array[Hash]}`) def as_json( # rubocop:disable Metrics/AbcSize include_children: true, include_archived: true, max_depth: total_depth, &block ) max_depth ||= total_depth return nil if max_depth < depth return [] if top? && !include_children return nil if archived? && !include_archived if include_children child_nodes = nodes child_nodes = child_nodes.select(&:active?) unless include_archived kargs = { include_children: include_children, include_archived: include_archived, max_depth: max_depth } children_json = child_nodes.map {|nd| nd.as_json(**kargs, &block)}.compact return children_json if top? end values = [ id, name, weight, parent_id, archived, archived_token, classifications.dup, classification_names.dup, depth + 1 ] node_json = self.class::HEADER.zip(values).to_h node_json["nodes"] = children_json if include_children node_json = yield(node_json, self) if block_given? node_json end # Returns a plain list form of hash nodes. # @return [Array[Hash]] where `Hash` is a plain `node` def as_nodes_json(&block) all_nodes.map {|nd| nd.as_json(include_children: false, &block)} end # @return [Boolean] `true` if there are tags in the node, `false` otherwise. def empty? count <= 1 end # @return [Integer] the number of locations def count @hash_tags.keys.count end # @return [Integer] the highest `depth` of all the children. def total_depth @total_depth ||= if children? nodes.max_by(&:total_depth)&.total_depth else depth end end # @return [Integer] if there's only top level. def flat? total_depth <= 0 end # Gets all the tags of the current node tree. # @note # - this will include the upper level tag(s) as well # - to get all but the upper level tag(s) use `subtags` method instead # @param depth [Integer] if empty, returns the list of tag nodes of that level. # Otherwise the list of tag nodes of the entire subtree. # @return [Array] def tags(depth: nil) if !depth || depth&.negative? @hash_tags.keys else @hash_tags.select do |_t, n| n.depth == depth end.keys end end # Gets all but the upper level tags of the current node tree. # @return [Array] def subtags tags - tags(depth: depth) end # Verifies if a tag exists in the subtree(s). # @param key [String] tag to verify. # @return [Boolean] def subtag?(key) subtags.include?(key&.upcase) end # Returns all the tags with no children # @return [Array] def leafs tags.reject do |tag| node(tag).children? end end # @return [Integer] def children_count nodes.count end # @return [Boolean] it has subnodes def children? children_count&.positive? end # Verifies if a tag exists in the tree. # @param key [String] tag to verify. # @return [Boolean] def tag?(key) @hash_tags.key?(key&.upcase) end # Finds a subtree node. # @param key [String] parent node of subtree. # @return [TagTree, nil] if the tag `key` is a node, returns that node. def node(key) return nil unless tag?(key) @hash_tags[key.upcase] end # Filters tags out that do not belong to the tree # @param list [Array] source tags. # @return [Array] def filter_tags(list) return [] unless list.is_a?(Array) list.select {|str| tag?(str)} end # Finds the path from a node `key` to its root node in the tree. # If `key` is not specified, returns the path from current node to root. # @note the `path` is not relative to the subtree, but absolute to the entire tree. # @param key [String] tag to find the path to. # @return [Array] def path(key = nil) return @path.dup unless key @hash_paths[key.upcase].dup end # Helper to assign tags to a person account. # * It preserves the `:initial` order, in case the `:final` tags are the same # # @example Usage example: # tree = [{"tag" => "Australia", "nodes" => [ # {"tag" => "SYDNEY", "nodes" => []}, # {"tag" => "MELBOURNE", "nodes" => []} # ]}] # # tree = TagTree.new(tree.to_json) # original = ["SYDNEY", "RISK"] # final = ["MELBOURNE", "EVENT"] # # tree.user_tags(initial: original, final: final) # # out: ["MELBOURNE", "RISK"] # tree.user_tags(initial: original, final: final, preserve_custom: false) # # out: ["MELBOURNE"] # tree.user_tags(initial: original, final: final, add_custom: true) # # out: ["MELBOURNE", "RISK", "EVENT"] # tree.user_tags(initial: original, final: final, preserve_custom: false, add_custom: true) # # out: ["MELBOURNE", "EVENT"] # # @param initial [Array] original tags a person has in their account. # @param final [Array] target tags the person should have in # their account afterwards. # @param preserve_custom [Boolean] indicates if original tags # that are not in the tree should be added/preserved. # @param add_custom [Boolean] indicates if target tags that are # not in the tree should be really added. # @return [Array] with the treated final tags. def user_tags(initial: [], final: [], preserve_custom: true, add_custom: false) initial = [initial].flatten.compact final = [final].flatten.compact raise "Expected Array for initial: and final:" unless initial.is_a?(Array) && final.is_a?(Array) final = filter_tags(final) unless add_custom custom = initial - filter_tags(initial) final |= custom if preserve_custom new_tags = final - initial # keep same order as they where (initial & final) + new_tags end # Helper to decide which among the tags will be the default. # * take the deepest tag (the one that is further down in the tree) # * if there are different options (several nodes at the same depth): # * take the common node between them (i.e. you have Hamilton and Auckland -> take New Zealand) # * if there's no common node between them, take the `first`, unless they are at top level of the tree # * to the above, take the `first` also on top level, but only if there's 1 level for the entire tree # @param [Array] values list of tags. # @return [String] default tag. def default_tag(*values) default_tag = nil values = filter_tags(values) tnodes, ddepth = get_deepest_nodes_among_tags(*values) unless tnodes.empty? common = tnodes.reduce(tags.reverse) do |com, cnode| com & cnode.path.reverse end default_tag = common.first if common.any? && ddepth&.positive? end default_tag ||= tnodes.first&.tag if ddepth&.positive? || flat? default_tag end protected attr_reader :hash_paths def hash @hash_tags end private def parse_source_input(value) return value unless value.is_a?(String) JSON.parse(value) end def init_node return if source.is_a?(Array) @id = source.values_at('tag', 'id').compact.first&.upcase @name = source['name'] @weight = source['weight'] @archived = as_boolean(source['archived']) @archived_token = source['archived_token'] @classifications = into_a(source['classifications']).map do |value| treat_classication(value) end @classification_names = into_a(source['classification_names']) @raw_nodes = source['nodes'] || [] end def init_hashes @hash_tags = {} @hash_tags[@id] = self unless top? @hash_tags = nodes.reduce(@hash_tags) do |h, n| h.merge(n.hash) end @hash_paths = {} @hash_paths[@id] = @path unless top? @hash_paths = nodes.reduce(@hash_paths) do |h, n| h.merge(n.hash_paths) end end ## Helpers # Gathers the deepest nodes among `values` # @note it will only return multiple nodes if they # they are all at the same level # @return [Array] def get_deepest_nodes_among_tags(*values) ddepth = -1 tnodes = [] values.each do |ttag| msg = "Couldn't find the node of #{ttag} in the tag-tree definition" raise msg unless (cnode = node(ttag)) next unless cnode.depth >= ddepth next tnodes.push(cnode) if cnode.depth == ddepth # cnode.depth > ddepth tnodes = [cnode] ddepth = cnode.depth end [tnodes, ddepth] end def treat_classication(value) return value unless value.is_a?(String) value.strip.gsub(/\W+/, '').downcase end # Helper to convert to array def into_a(value) if value.is_a?(String) value.split('|') else [value].flatten end.compact end def as_boolean(value) return false if value.nil? || value == false return true if value == true return false if value.to_s.strip.empty? return true if %w[yes x true].include?(value.downcase) false end end end end end