shopify_product_taxonomy 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/lib/product_taxonomy/alphanumeric_sorter.rb +113 -0
  3. data/lib/product_taxonomy/identifier_formatter.rb +28 -0
  4. data/lib/product_taxonomy/loader.rb +31 -0
  5. data/lib/product_taxonomy/localizations_validator.rb +31 -0
  6. data/lib/product_taxonomy/models/attribute.rb +170 -0
  7. data/lib/product_taxonomy/models/category.rb +313 -0
  8. data/lib/product_taxonomy/models/extended_attribute.rb +47 -0
  9. data/lib/product_taxonomy/models/integration_version.rb +296 -0
  10. data/lib/product_taxonomy/models/mapping_rule.rb +114 -0
  11. data/lib/product_taxonomy/models/mixins/formatted_validation_errors.rb +23 -0
  12. data/lib/product_taxonomy/models/mixins/indexed.rb +133 -0
  13. data/lib/product_taxonomy/models/mixins/localized.rb +70 -0
  14. data/lib/product_taxonomy/models/serializers/attribute/data/data_serializer.rb +48 -0
  15. data/lib/product_taxonomy/models/serializers/attribute/data/localizations_serializer.rb +34 -0
  16. data/lib/product_taxonomy/models/serializers/attribute/dist/json_serializer.rb +41 -0
  17. data/lib/product_taxonomy/models/serializers/attribute/dist/txt_serializer.rb +42 -0
  18. data/lib/product_taxonomy/models/serializers/attribute/docs/base_and_extended_serializer.rb +41 -0
  19. data/lib/product_taxonomy/models/serializers/attribute/docs/reversed_serializer.rb +55 -0
  20. data/lib/product_taxonomy/models/serializers/attribute/docs/search_serializer.rb +32 -0
  21. data/lib/product_taxonomy/models/serializers/category/data/data_serializer.rb +29 -0
  22. data/lib/product_taxonomy/models/serializers/category/data/full_names_serializer.rb +26 -0
  23. data/lib/product_taxonomy/models/serializers/category/data/localizations_serializer.rb +34 -0
  24. data/lib/product_taxonomy/models/serializers/category/dist/json_serializer.rb +60 -0
  25. data/lib/product_taxonomy/models/serializers/category/dist/txt_serializer.rb +42 -0
  26. data/lib/product_taxonomy/models/serializers/category/docs/search_serializer.rb +33 -0
  27. data/lib/product_taxonomy/models/serializers/category/docs/siblings_serializer.rb +39 -0
  28. data/lib/product_taxonomy/models/serializers/value/data/data_serializer.rb +28 -0
  29. data/lib/product_taxonomy/models/serializers/value/data/localizations_serializer.rb +34 -0
  30. data/lib/product_taxonomy/models/serializers/value/dist/json_serializer.rb +31 -0
  31. data/lib/product_taxonomy/models/serializers/value/dist/txt_serializer.rb +38 -0
  32. data/lib/product_taxonomy/models/taxonomy.rb +11 -0
  33. data/lib/product_taxonomy/models/value.rb +147 -0
  34. data/lib/product_taxonomy/version.rb +6 -0
  35. data/lib/product_taxonomy.rb +50 -0
  36. metadata +124 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5cec4d780841db87dfa606e3b36510bbcb2398f03a6cf091f1a1867e0b88b8b0
4
+ data.tar.gz: 3142307e81c2049fedf3fcf38d7ba87bb2594b4a88185aadfb1cb7dceac6c2b3
5
+ SHA512:
6
+ metadata.gz: fb4fbe719cd799f493b7da19f6be9e2c12f776a136a3062a776645fb680cc96fbf4eef2add7eb6ea939c6a76b1c30af90888ce00f23e5633d71cfd571720ae35
7
+ data.tar.gz: 1a272445bdd6bb760cae6e4ac2538a58de1f8588a857ab291a32ab450661fde810e1c26223eeb1b28bf105dd2a2f182655fba1372d6e79ba6d0f41e6dc2374b3
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ module AlphanumericSorter
5
+ class << self
6
+ def sort(values, other_last: false)
7
+ values.sort_by.with_index do |value, idx|
8
+ [
9
+ other_last && value.to_s.downcase == "other" ? 1 : 0,
10
+ *normalize_value(value),
11
+ idx,
12
+ ]
13
+ end
14
+ end
15
+
16
+ def normalize_value(value)
17
+ @normalized_values ||= {}
18
+ @normalized_values[value] ||= if (numerical = value.match(RegexPattern::NUMERIC_PATTERN))
19
+ [0, *normalize_numerical(numerical)]
20
+ elsif (sequential = value.match(RegexPattern::SEQUENTIAL_TEXT_PATTERN))
21
+ [1, *normalize_sequential(sequential)]
22
+ else
23
+ [1, normalize_text(value)]
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def normalize_numerical(match)
30
+ [
31
+ normalize_text(match[:primary_unit] || match[:secondary_unit]) || "",
32
+ normalize_text(match[:seperator]) || "-",
33
+ normalize_single_number(match[:primary_number]),
34
+ normalize_single_number(match[:secondary_number]),
35
+ ]
36
+ end
37
+
38
+ def normalize_sequential(match)
39
+ [
40
+ normalize_text(match[:primary_text]),
41
+ normalize_single_number(match[:primary_step]),
42
+ normalize_text(match[:primary_unit] || match[:secondary_unit]) || "",
43
+ normalize_text(match[:seperator]) || "-",
44
+ normalize_text(match[:secondary_text]),
45
+ normalize_single_number(match[:secondary_step]),
46
+ normalize_text(match[:trailing_text]),
47
+ ]
48
+ end
49
+
50
+ def normalize_single_number(value)
51
+ value = value.split.sum(&:to_r) if value&.include?("/")
52
+ value.to_f
53
+ end
54
+
55
+ def normalize_text(value)
56
+ return if value.nil?
57
+
58
+ ActiveSupport::Inflector.transliterate(value.strip.downcase)
59
+ end
60
+ end
61
+
62
+ module RegexPattern
63
+ # matches numbers like -1, 5, 10.5, 3/4, 2 5/8
64
+ SINGLE_NUMBER = %r{
65
+ -? # Optional negative sign
66
+ (?:
67
+ \d+\.?\d* # Easy numbers like 5, 10.5
68
+ |
69
+ (?:\d+\s)?\d+/[1-9]+\d* # Fractions like 3/4, 2 5/8
70
+ )
71
+ }x
72
+
73
+ # matches units like sq.ft, km/h
74
+ UNITS_OF_MEASURE = %r{
75
+ [^\d\./\-] # Matches any character not a digit, dot, slash or dash
76
+ [^\-\d]* # Matches any character not a dash or digit
77
+ }x
78
+
79
+ # String capturing is simple
80
+ BASIC_TEXT = /\D+/
81
+ SEPERATOR = /[\p{Pd}x~]/
82
+
83
+ # NUMERIC_PATTERN matches a primary number with optional units, and an optional range or dimension
84
+ # with a secondary number and its optional units.
85
+ NUMERIC_PATTERN = %r{
86
+ ^\s*(?<primary_number>#{SINGLE_NUMBER}) # 1. Primary number
87
+ \s*(?<primary_unit>#{UNITS_OF_MEASURE})? # 2. Optional units for primary number
88
+ (?: # Optional range or dimension
89
+ \s*(?<seperator>#{SEPERATOR}) # 3. Separator
90
+ \s*(?<secondary_number>#{SINGLE_NUMBER}) # 4. Secondary number
91
+ \s*(?<secondary_unit>#{UNITS_OF_MEASURE})? # 5. Optional units for secondary number
92
+ )?
93
+ \s*$
94
+ }x
95
+
96
+ # SEQUENTIAL_TEXT_PATTERN matches a primary non-number string, an optional step, and optional units,
97
+ # followed by an optional range or dimension with a secondary non-number string, an optional step,
98
+ # and optional units, and finally an optional trailing text.
99
+ SEQUENTIAL_TEXT_PATTERN = %r{
100
+ ^\s*(?<primary_text>#{BASIC_TEXT}) # 1. Primary non-number string
101
+ \s*(?<primary_step>#{SINGLE_NUMBER})? # 2. Optional step
102
+ \s*(?<primary_unit>#{UNITS_OF_MEASURE})? # 3. Optional units for primary number
103
+ (?: # Optional range or dimension
104
+ \s*(?<seperator>#{SEPERATOR}) # 4. Separator -- capturing allows us to group ranges and dimensions
105
+ \s*(?<secondary_text>#{BASIC_TEXT})? # 5. Optional secondary non-number string
106
+ \s*(?<secondary_step>#{SINGLE_NUMBER}) # 6. Secondary step
107
+ \s*(?<secondary_unit>#{UNITS_OF_MEASURE})? # 7. Optional units for secondary number
108
+ )?
109
+ \s*(?<trailing_text>.*)?$ # 8. Optional trailing text
110
+ }x
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ module IdentifierFormatter
5
+ class << self
6
+ def format_friendly_id(text)
7
+ I18n.transliterate(text)
8
+ .downcase
9
+ .gsub(%r{[^a-z0-9\s\-_/\.\+#]}, "")
10
+ .gsub(/[\s\-\.]+/, "_")
11
+ end
12
+
13
+ def format_handle(text)
14
+ I18n.transliterate(text)
15
+ .downcase
16
+ .gsub(%r{[^a-z0-9\s\-_/\+#]}, "")
17
+ .gsub("+", "-plus-")
18
+ .gsub("#", "-hashtag-")
19
+ .gsub("/", "-")
20
+ .gsub(/[\s\.]+/, "-")
21
+ .gsub("_-_", "-")
22
+ .gsub(/(?<!_)_(?!_)/, "-")
23
+ .gsub(/--+/, "-")
24
+ .gsub(/\A-+|-+\z/, "")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module ProductTaxonomy
6
+ class Loader
7
+ class << self
8
+ def load(values_path:, attributes_path:, categories_glob:)
9
+ return if ProductTaxonomy::Category.all.any?
10
+
11
+ begin
12
+ ProductTaxonomy::Value.load_from_source(YAML.load_file(values_path))
13
+ ProductTaxonomy::Attribute.load_from_source(YAML.load_file(attributes_path))
14
+
15
+ categories_source_data = categories_glob.each_with_object([]) do |file, array|
16
+ array.concat(YAML.safe_load_file(file))
17
+ end
18
+ ProductTaxonomy::Category.load_from_source(categories_source_data)
19
+ rescue Errno::ENOENT => e
20
+ raise ArgumentError, "File not found: #{e.message}"
21
+ rescue Psych::SyntaxError => e
22
+ raise ArgumentError, "Invalid YAML: #{e.message}"
23
+ end
24
+
25
+ # Run validations that can only be run after the taxonomy has been loaded.
26
+ ProductTaxonomy::Value.all.each { |model| model.validate!(:taxonomy_loaded) }
27
+ end
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ module LocalizationsValidator
5
+ class << self
6
+ # Validate that all localizations are present for the given locales. If no locales are provided, all locales
7
+ # will be validated and the consistency of locales across models will be checked.
8
+ #
9
+ # @param locales [Array<String>, nil] The locales to validate. If nil, all locales will be validated.
10
+ def validate!(locales = nil)
11
+ Category.validate_localizations!(locales)
12
+ Attribute.validate_localizations!(locales)
13
+ Value.validate_localizations!(locales)
14
+
15
+ validate_locales_are_consistent! if locales.nil?
16
+ end
17
+
18
+ private
19
+
20
+ def validate_locales_are_consistent!
21
+ categories_locales = Category.localizations.keys
22
+ attributes_locales = Attribute.localizations.keys
23
+ values_locales = Value.localizations.keys
24
+
25
+ error_message = "Not all model localizations have the same set of locales"
26
+ raise ArgumentError,
27
+ error_message unless categories_locales == attributes_locales && attributes_locales == values_locales
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ class Attribute
5
+ include ActiveModel::Validations
6
+ include FormattedValidationErrors
7
+ extend Localized
8
+ extend Indexed
9
+
10
+ class << self
11
+ # Load attributes from source data. By default, this data is deserialized from a YAML file in the `data` directory.
12
+ #
13
+ # @param source_data [Array<Hash>] The source data to load attributes from.
14
+ def load_from_source(source_data)
15
+ raise ArgumentError, "source_data must be a hash" unless source_data.is_a?(Hash)
16
+ raise ArgumentError, "source_data must contain keys \"base_attributes\" and \"extended_attributes\"" unless
17
+ source_data.keys.sort == ["base_attributes", "extended_attributes"]
18
+
19
+ source_data.each do |type, attributes|
20
+ attributes.each do |attribute_data|
21
+ raise ArgumentError, "source_data must contain hashes" unless attribute_data.is_a?(Hash)
22
+
23
+ attribute = case type
24
+ when "base_attributes" then attribute_from(attribute_data)
25
+ when "extended_attributes" then extended_attribute_from(attribute_data)
26
+ end
27
+ Attribute.add(attribute)
28
+ attribute.validate!(:create)
29
+ end
30
+ end
31
+ end
32
+
33
+ # Reset all class-level state
34
+ def reset
35
+ @localizations = nil
36
+ @hashed_models = nil
37
+ end
38
+
39
+ # Get base attributes only, sorted by name.
40
+ #
41
+ # @return [Array<Attribute>] The sorted base attributes.
42
+ def sorted_base_attributes
43
+ all.reject(&:extended?).sort_by(&:name)
44
+ end
45
+
46
+ # Get the next ID for a newly created attribute.
47
+ #
48
+ # @return [Integer] The next ID.
49
+ def next_id = (all.max_by(&:id)&.id || 0) + 1
50
+
51
+ private
52
+
53
+ def attribute_from(attribute_data)
54
+ values_by_friendly_id = attribute_data["values"]&.map { Value.find_by(friendly_id: _1) || _1 }
55
+ Attribute.new(
56
+ id: attribute_data["id"],
57
+ name: attribute_data["name"],
58
+ description: attribute_data["description"],
59
+ friendly_id: attribute_data["friendly_id"],
60
+ handle: attribute_data["handle"],
61
+ values: values_by_friendly_id,
62
+ is_manually_sorted: attribute_data["sorting"] == "custom",
63
+ )
64
+ end
65
+
66
+ def extended_attribute_from(attribute_data)
67
+ value_friendly_id = attribute_data["values_from"]
68
+ ExtendedAttribute.new(
69
+ name: attribute_data["name"],
70
+ handle: attribute_data["handle"],
71
+ description: attribute_data["description"],
72
+ friendly_id: attribute_data["friendly_id"],
73
+ values_from: Attribute.find_by(friendly_id: value_friendly_id) || value_friendly_id,
74
+ )
75
+ end
76
+ end
77
+
78
+ validates :id, presence: true, numericality: { only_integer: true }, if: -> { self.class == Attribute }, on: :create
79
+ validates :name, presence: true, on: :create
80
+ validates :friendly_id, presence: true, on: :create
81
+ validates :handle, presence: true, on: :create
82
+ validates :description, presence: true, on: :create
83
+ validates :values, presence: true, if: -> { self.class == Attribute }, on: :create
84
+ validates_with ProductTaxonomy::Indexed::UniquenessValidator, attributes: [:friendly_id], on: :create
85
+ validate :values_valid?, on: :create
86
+
87
+ localized_attr_reader :name, :description
88
+
89
+ attr_reader :id, :friendly_id, :handle, :values, :extended_attributes
90
+
91
+ # @param id [Integer] The ID of the attribute.
92
+ # @param name [String] The name of the attribute.
93
+ # @param description [String] The description of the attribute.
94
+ # @param friendly_id [String] The friendly ID of the attribute.
95
+ # @param handle [String] The handle of the attribute.
96
+ # @param values [Array<Value, String>] An array of resolved {Value} objects. When resolving fails, use the friendly
97
+ # ID instead.
98
+ def initialize(id:, name:, description:, friendly_id:, handle:, values:, is_manually_sorted: false)
99
+ @id = id
100
+ @name = name
101
+ @description = description
102
+ @friendly_id = friendly_id
103
+ @handle = handle
104
+ @values = values
105
+ @extended_attributes = []
106
+ @is_manually_sorted = is_manually_sorted
107
+ end
108
+
109
+ # Add an extended attribute to the attribute.
110
+ #
111
+ # @param extended_attribute [ExtendedAttribute] The extended attribute to add.
112
+ def add_extended_attribute(extended_attribute)
113
+ @extended_attributes << extended_attribute
114
+ end
115
+
116
+ # Add a value to the attribute.
117
+ #
118
+ # @param value [Value] The value to add.
119
+ def add_value(value)
120
+ @values << value
121
+ end
122
+
123
+ # The global ID of the attribute
124
+ #
125
+ # @return [String]
126
+ def gid
127
+ "gid://shopify/TaxonomyAttribute/#{id}"
128
+ end
129
+
130
+ # Whether the attribute is an extended attribute.
131
+ #
132
+ # @return [Boolean]
133
+ def extended?
134
+ is_a?(ExtendedAttribute)
135
+ end
136
+
137
+ # Whether the attribute is manually sorted.
138
+ #
139
+ # @return [Boolean]
140
+ def manually_sorted?
141
+ @is_manually_sorted
142
+ end
143
+
144
+ # Get the sorted values of the attribute.
145
+ #
146
+ # @param locale [String] The locale to sort by.
147
+ # @return [Array<Value>] The sorted values.
148
+ def sorted_values(locale: "en")
149
+ if manually_sorted?
150
+ values
151
+ else
152
+ Value.sort_by_localized_name(values, locale:)
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def values_valid?
159
+ values&.each do |value|
160
+ next if value.is_a?(Value)
161
+
162
+ errors.add(
163
+ :values,
164
+ :not_found,
165
+ message: "not found for friendly ID \"#{value}\"",
166
+ )
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTaxonomy
4
+ class Category
5
+ include ActiveModel::Validations
6
+ include FormattedValidationErrors
7
+ extend Localized
8
+ extend Indexed
9
+
10
+ class << self
11
+ attr_reader :verticals
12
+
13
+ # Load categories from source data.
14
+ #
15
+ # @param source_data [Array<Hash>] The source data to load categories from.
16
+ def load_from_source(source_data)
17
+ raise ArgumentError, "source_data must be an array" unless source_data.is_a?(Array)
18
+
19
+ # First pass: Create all nodes and add to index
20
+ source_data.each do |item|
21
+ Category.create_validate_and_add!(
22
+ id: item["id"],
23
+ name: item["name"],
24
+ attributes: Array(item["attributes"]).map { Attribute.find_by(friendly_id: _1) || _1 },
25
+ )
26
+ end
27
+
28
+ # Second pass: Build relationships
29
+ source_data.each do |item|
30
+ parent = Category.find_by(id: item["id"])
31
+ add_children(type: "children", item:, parent:)
32
+ add_children(type: "secondary_children", item:, parent:)
33
+ end
34
+
35
+ # Third pass: Validate all nodes, sort contents, and collect root nodes for verticals
36
+ @verticals = Category.all.each_with_object([]) do |node, root_nodes|
37
+ node.validate!(:category_tree_loaded)
38
+ node.children.sort_by!(&:name)
39
+ node.attributes.sort_by!(&:name)
40
+ root_nodes << node if node.root?
41
+ end
42
+ @verticals.sort_by!(&:name)
43
+ end
44
+
45
+ # Reset all class-level state
46
+ def reset
47
+ @localizations = nil
48
+ @hashed_models = nil
49
+ @verticals = nil
50
+ end
51
+
52
+ # Get all categories in depth-first order.
53
+ #
54
+ # @return [Array<Category>] The categories in depth-first order.
55
+ def all_depth_first
56
+ verticals.flat_map(&:descendants_and_self)
57
+ end
58
+
59
+ private
60
+
61
+ def add_children(type:, item:, parent:)
62
+ item[type]&.each do |child_id|
63
+ child = Category.find_by(id: child_id) || child_id
64
+
65
+ case type
66
+ when "children" then parent.add_child(child)
67
+ when "secondary_children" then parent.add_secondary_child(child)
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # Validations that can be performed as soon as the category is created.
74
+ validates :id, format: { with: /\A[a-z]{2}(-\d+)*\z/ }, on: :create
75
+ validates :name, presence: true, on: :create
76
+ validate :attributes_found?, on: :create
77
+ validates_with ProductTaxonomy::Indexed::UniquenessValidator, attributes: [:id], on: :create
78
+
79
+ # Validations that can only be performed after the category tree has been loaded.
80
+ validate :id_matches_depth, on: :category_tree_loaded
81
+ validate :id_starts_with_parent_id, unless: :root?, on: :category_tree_loaded
82
+ validate :children_found?, on: :category_tree_loaded
83
+ validate :secondary_children_found?, on: :category_tree_loaded
84
+
85
+ localized_attr_reader :name, keyed_by: :id
86
+
87
+ attr_reader :id, :children, :secondary_children, :attributes
88
+ attr_accessor :parent, :secondary_parents
89
+
90
+ # @param id [String] The ID of the category.
91
+ # @param name [String] The name of the category.
92
+ # @param attributes [Array<Attribute>] The attributes of the category.
93
+ # @param parent [Category] The parent category of the category.
94
+ def initialize(id:, name:, attributes: [], parent: nil)
95
+ @id = id
96
+ @name = name
97
+ @children = []
98
+ @secondary_children = []
99
+ @attributes = attributes
100
+ @parent = parent
101
+ @secondary_parents = []
102
+ end
103
+
104
+ #
105
+ # Manipulation
106
+ #
107
+
108
+ # Add a child to the category
109
+ #
110
+ # @param [Category|String] child node, or the friendly ID if the node was not found.
111
+ def add_child(child)
112
+ @children << child
113
+
114
+ return unless child.is_a?(Category)
115
+
116
+ child.parent = self
117
+ end
118
+
119
+ # Add a secondary child to the category
120
+ #
121
+ # @param [Category|String] child node, or the friendly ID if the node was not found.
122
+ def add_secondary_child(child)
123
+ @secondary_children << child
124
+
125
+ return unless child.is_a?(Category)
126
+
127
+ child.secondary_parents << self
128
+ end
129
+
130
+ # Add an attribute to the category
131
+ #
132
+ # @param [Attribute] attribute
133
+ def add_attribute(attribute)
134
+ @attributes << attribute
135
+ end
136
+
137
+ #
138
+ # Information
139
+ #
140
+ def inspect
141
+ "#<#{self.class.name} id=#{id} name=#{name}>"
142
+ end
143
+
144
+ # Whether the category is the root category
145
+ #
146
+ # @return [Boolean]
147
+ def root?
148
+ parent.nil?
149
+ end
150
+
151
+ # Whether the category is a leaf category
152
+ #
153
+ # @return [Boolean]
154
+ def leaf?
155
+ children.empty?
156
+ end
157
+
158
+ # The level of the category
159
+ #
160
+ # @return [Integer]
161
+ def level
162
+ ancestors.size
163
+ end
164
+
165
+ # The root category in this category's tree
166
+ #
167
+ # @return [Category]
168
+ def root
169
+ ancestors.last || self
170
+ end
171
+
172
+ # The ancestors of the category
173
+ #
174
+ # @return [Array<Category>]
175
+ def ancestors
176
+ return [] if root?
177
+
178
+ [parent] + parent.ancestors
179
+ end
180
+
181
+ # The full name of the category
182
+ #
183
+ # @return [String]
184
+ def full_name(locale: "en")
185
+ return name(locale:) if root?
186
+
187
+ parent.full_name(locale:) + " > " + name(locale:)
188
+ end
189
+
190
+ # The global ID of the category
191
+ #
192
+ # @return [String]
193
+ def gid
194
+ "gid://shopify/TaxonomyCategory/#{id}"
195
+ end
196
+
197
+ # Split an ID into its parts.
198
+ #
199
+ # @return [Array<String, Integer>] The parts of the ID.
200
+ def id_parts
201
+ parts = id.split("-")
202
+ [parts.first] + parts[1..].map(&:to_i)
203
+ end
204
+
205
+ # Whether the category is a descendant of another category
206
+ #
207
+ # @param [Category] category
208
+ # @return [Boolean]
209
+ def descendant_of?(category)
210
+ ancestors.include?(category)
211
+ end
212
+
213
+ # Iterate over the category and all its descendants
214
+ #
215
+ # @yield [Category]
216
+ def traverse(&block)
217
+ yield self
218
+ children.each { _1.traverse(&block) }
219
+ end
220
+
221
+ # The descendants of the category
222
+ def descendants
223
+ children.flat_map { |child| [child] + child.descendants }
224
+ end
225
+
226
+ # The descendants of the category and the category itself
227
+ #
228
+ # @return [Array<Category>]
229
+ def descendants_and_self
230
+ [self] + descendants
231
+ end
232
+
233
+ # The friendly name of the category
234
+ #
235
+ # @return [String]
236
+ def friendly_name
237
+ "#{id}_#{IdentifierFormatter.format_friendly_id(name)}"
238
+ end
239
+
240
+ # The next child ID for the category
241
+ #
242
+ # @return [String]
243
+ def next_child_id
244
+ largest_child_id = children.map { _1.id.split("-").last.to_i }.max || 0
245
+
246
+ "#{id}-#{largest_child_id + 1}"
247
+ end
248
+
249
+ private
250
+
251
+ #
252
+ # Validation
253
+ #
254
+ def id_matches_depth
255
+ parts_count = id.split("-").size
256
+
257
+ return if parts_count == level + 1
258
+
259
+ if level.zero?
260
+ # In this case, the most likely mistake was not adding the category to the parent's `children` field.
261
+ errors.add(:base, :orphan, message: "\"#{id}\" does not appear in the children of any category")
262
+ else
263
+ errors.add(
264
+ :id,
265
+ :depth_mismatch,
266
+ message: "\"#{id}\" has #{parts_count} #{"part".pluralize(parts_count)} but is at level #{level + 1}",
267
+ )
268
+ end
269
+ end
270
+
271
+ def id_starts_with_parent_id
272
+ return if id.start_with?(parent.id)
273
+
274
+ errors.add(:id, :prefix_mismatch, message: "\"#{id}\" must be prefixed by \"#{parent.id}\"")
275
+ end
276
+
277
+ def attributes_found?
278
+ attributes&.each do |attribute|
279
+ next if attribute.is_a?(Attribute)
280
+
281
+ errors.add(
282
+ :attributes,
283
+ :not_found,
284
+ message: "not found for friendly ID \"#{attribute}\"",
285
+ )
286
+ end
287
+ end
288
+
289
+ def children_found?
290
+ children&.each do |child|
291
+ next if child.is_a?(Category)
292
+
293
+ errors.add(
294
+ :children,
295
+ :not_found,
296
+ message: "not found for friendly ID \"#{child}\"",
297
+ )
298
+ end
299
+ end
300
+
301
+ def secondary_children_found?
302
+ secondary_children&.each do |child|
303
+ next if child.is_a?(Category)
304
+
305
+ errors.add(
306
+ :secondary_children,
307
+ :not_found,
308
+ message: "not found for friendly ID \"#{child}\"",
309
+ )
310
+ end
311
+ end
312
+ end
313
+ end