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 = [ 'id', 'name', 'weight', 'parent_id', 'archived', 'archived_token' ].freeze attr_accessor :id alias_method :tag, :id attr_accessor :name, :weight attr_accessor :archived, :archived_token attr_reader :parent attr_reader :nodes, :children_count attr_reader :depth, :path attr_reader :source include Enumerable # @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) @depth = depth @parent = parent case tagtree when String @source = JSON.parse(tagtree) else @source = tagtree end raise ArgumentError, "You are trying to initialize a TagTree with a null tagtree" if !@source if @source.is_a?(Array) @id = id @name = name @raw_nodes = @source else @id = @source.values_at('tag', 'id').compact.first&.upcase @name = @source['name'] @archived = @source['archived'] || false @archived_token = @source['archived_token'] @source['weight'] = @weight = @source['weight'] || _weight @raw_nodes = @source['nodes'] || [] end @path = path || [] @path.push(@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(self.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) raise ArgumentError, "Expecting Eco::API::Organization::TagTree. Given: #{other.class}" unless other.is_a?(Eco::API::Organization::TagTree) mid = [self.id, other.id].join('|') mname = [self.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(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 end if top? children_json else values = [id, name, weight, parent_id, archived, archived_token] 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 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 has_children? deepest_node = nodes.max_by do |nd| nd.total_depth end deepest_node.total_depth else depth end end # @return [Integer] if there's only top level. def flat? self.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 < 0 @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.select do |tag| !node(tag).has_children? end end # @return [Integer] def children_count nodes.count end # @return [Boolean] it has subnodes def has_children? children_count > 0 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 && 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 if !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 = 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) values = filter_tags(values) nodes = []; ddepth = -1 values.each do |tag| raise("Couldn't find the node of #{tag} in the tag-tree definition") unless cnode = node(tag) if cnode.depth > ddepth nodes = [cnode] ddepth = cnode.depth elsif cnode.depth == ddepth nodes.push(cnode) end end default_tag = nil if nodes.length > 1 common = nodes.reduce(self.tags.reverse) {|com, cnode| com & cnode.path.reverse} default_tag = common.first if common.length > 0 && ddepth > 0 end default_tag ||= nodes.first&.tag if (ddepth > 0) || flat? default_tag end protected def hash @hash_tags end def hash_paths @hash_paths end private 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 end end end end