shopify_product_taxonomy 1.1.1 → 1.2.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.
- checksums.yaml +4 -4
- data/lib/product_taxonomy/alphanumeric_sorter.rb +3 -3
- data/lib/product_taxonomy/command_executor.rb +29 -0
- data/lib/product_taxonomy/dist_asset_stager.rb +114 -0
- data/lib/product_taxonomy/loader.rb +3 -0
- data/lib/product_taxonomy/localizations_validator.rb +5 -2
- data/lib/product_taxonomy/models/category.rb +52 -6
- data/lib/product_taxonomy/models/disclosure.rb +289 -0
- data/lib/product_taxonomy/models/mixins/formatted_validation_errors.rb +7 -1
- data/lib/product_taxonomy/models/return_reason.rb +20 -0
- data/lib/product_taxonomy/models/serializers/category/data/data_serializer.rb +16 -9
- data/lib/product_taxonomy/models/serializers/category/dist/json_serializer.rb +1 -0
- data/lib/product_taxonomy/models/serializers/category/docs/siblings_serializer.rb +1 -0
- data/lib/product_taxonomy/models/serializers/disclosure/data/data_serializer.rb +48 -0
- data/lib/product_taxonomy/models/serializers/disclosure/data/localizations_serializer.rb +37 -0
- data/lib/product_taxonomy/models/serializers/disclosure/dist/json_serializer.rb +52 -0
- data/lib/product_taxonomy/models/serializers/disclosure/dist/txt_serializer.rb +46 -0
- data/lib/product_taxonomy/models/serializers/disclosure/docs/search_serializer.rb +32 -0
- data/lib/product_taxonomy/models/serializers/disclosure/docs/yaml_serializer.rb +37 -0
- data/lib/product_taxonomy/models/serializers/value/docs/reversed_serializer.rb +37 -0
- data/lib/product_taxonomy/models/serializers/value/docs/search_serializer.rb +32 -0
- data/lib/product_taxonomy/release_asset_publisher.rb +93 -0
- data/lib/product_taxonomy/tagged_release_workspace.rb +59 -0
- data/lib/product_taxonomy/version.rb +1 -1
- data/lib/product_taxonomy.rb +13 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: da6547214a08a1014e88188ab157220a6f5cd23d60d0bb31074ff3e5418c5a7d
|
|
4
|
+
data.tar.gz: 3a06da2e5a5500d7a000b46e68307921291072120a52fd828b644b9188ca6760
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97d4b42d26d93a1566f3e216f6be82357fcae6f30b771640a137182035d74f0bf545c3f1c3db69572f6b639d5d1d501b11217475bc9a15dd813da3b837c35c41
|
|
7
|
+
data.tar.gz: 188d03a6b94f9082fc80c3eee0d4f4400ad6dffed29a53542324ab6c7503d56c5aaf2a5b6213d34731c38b9ff96966a5ef3a0ff7bd4a3d7dc7c0beadbbb75ccf
|
|
@@ -37,13 +37,13 @@ module ProductTaxonomy
|
|
|
37
37
|
|
|
38
38
|
def normalize_sequential(match)
|
|
39
39
|
[
|
|
40
|
-
normalize_text(match[:primary_text]),
|
|
40
|
+
normalize_text(match[:primary_text]) || "",
|
|
41
41
|
normalize_single_number(match[:primary_step]),
|
|
42
42
|
normalize_text(match[:primary_unit] || match[:secondary_unit]) || "",
|
|
43
43
|
normalize_text(match[:seperator]) || "-",
|
|
44
|
-
normalize_text(match[:secondary_text]),
|
|
44
|
+
normalize_text(match[:secondary_text]) || "",
|
|
45
45
|
normalize_single_number(match[:secondary_step]),
|
|
46
|
-
normalize_text(match[:trailing_text]),
|
|
46
|
+
normalize_text(match[:trailing_text]) || "",
|
|
47
47
|
]
|
|
48
48
|
end
|
|
49
49
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module ProductTaxonomy
|
|
6
|
+
class CommandExecutor
|
|
7
|
+
DEFAULT_RUNNER = Open3.method(:capture3)
|
|
8
|
+
|
|
9
|
+
def initialize(command_runner: DEFAULT_RUNNER)
|
|
10
|
+
@command_runner = command_runner
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run!(*command, chdir:, failure_message:)
|
|
14
|
+
stdout, stderr, status = capture(*command, chdir:, failure_message:)
|
|
15
|
+
return stdout if status.success?
|
|
16
|
+
|
|
17
|
+
details = stderr.strip
|
|
18
|
+
details = stdout.strip if details.empty?
|
|
19
|
+
message = details.empty? ? failure_message : "#{failure_message} #{details}"
|
|
20
|
+
raise message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def capture(*command, chdir:, failure_message:)
|
|
24
|
+
@command_runner.call(*command, chdir:)
|
|
25
|
+
rescue SystemCallError => error
|
|
26
|
+
raise "#{failure_message} #{error.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module ProductTaxonomy
|
|
7
|
+
class DistAssetStager
|
|
8
|
+
ALLOWED_ROOT_FILES = ["README.md"].freeze
|
|
9
|
+
DATA_EXTENSIONS = [".json", ".txt"].freeze
|
|
10
|
+
LOCALE_PATTERN = /\A[a-z]{2,3}(?:-[A-Z]{2})?\z/
|
|
11
|
+
|
|
12
|
+
def initialize(input_path:, output_path:)
|
|
13
|
+
@input_path = File.expand_path(input_path)
|
|
14
|
+
@output_path = File.expand_path(output_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def stage
|
|
18
|
+
validate_paths!
|
|
19
|
+
staging_plan = build_staging_plan
|
|
20
|
+
validate_collisions!(staging_plan)
|
|
21
|
+
|
|
22
|
+
FileUtils.rm_rf(@output_path)
|
|
23
|
+
FileUtils.mkdir_p(@output_path)
|
|
24
|
+
|
|
25
|
+
staging_plan.map do |source_path, asset_name|
|
|
26
|
+
destination_path = File.join(@output_path, asset_name)
|
|
27
|
+
gzip_file(source_path, destination_path)
|
|
28
|
+
destination_path
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def validate_paths!
|
|
35
|
+
raise ArgumentError, "Input path does not exist: #{@input_path}" unless File.directory?(@input_path)
|
|
36
|
+
|
|
37
|
+
paths_overlap = @output_path == @input_path ||
|
|
38
|
+
@output_path.start_with?("#{@input_path}#{File::SEPARATOR}") ||
|
|
39
|
+
@input_path.start_with?("#{@output_path}#{File::SEPARATOR}")
|
|
40
|
+
raise ArgumentError, "Input and output paths must not overlap" if paths_overlap
|
|
41
|
+
|
|
42
|
+
root_entries = Dir.children(@input_path).sort
|
|
43
|
+
unexpected_files = root_entries.reject do |entry|
|
|
44
|
+
path = File.join(@input_path, entry)
|
|
45
|
+
File.directory?(path) || (ALLOWED_ROOT_FILES.include?(entry) && File.file?(path))
|
|
46
|
+
end
|
|
47
|
+
unless unexpected_files.empty?
|
|
48
|
+
raise ArgumentError, "Unexpected files at distribution root: #{unexpected_files.join(", ")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unexpected_directories = root_entries.select do |entry|
|
|
52
|
+
File.directory?(File.join(@input_path, entry)) && !entry.match?(LOCALE_PATTERN)
|
|
53
|
+
end
|
|
54
|
+
return if unexpected_directories.empty?
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "Unexpected directories at distribution root: #{unexpected_directories.join(", ")}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_staging_plan
|
|
60
|
+
locale_directories.flat_map do |locale_directory|
|
|
61
|
+
locale = File.basename(locale_directory)
|
|
62
|
+
data_files(locale_directory).map do |source_path|
|
|
63
|
+
[source_path, asset_name(source_path, locale_directory, locale)]
|
|
64
|
+
end
|
|
65
|
+
end.sort_by(&:last)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def locale_directories
|
|
69
|
+
Dir.children(@input_path).sort.filter_map do |entry|
|
|
70
|
+
path = File.join(@input_path, entry)
|
|
71
|
+
path if File.directory?(path)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def data_files(locale_directory)
|
|
76
|
+
files = Dir.glob(File.join(locale_directory, "**", "*"), File::FNM_DOTMATCH).select { File.file?(_1) }.sort
|
|
77
|
+
unexpected_files = files.reject { DATA_EXTENSIONS.include?(File.extname(_1)) }
|
|
78
|
+
unless unexpected_files.empty?
|
|
79
|
+
relative_paths = unexpected_files.map { _1.delete_prefix("#{@input_path}#{File::SEPARATOR}") }
|
|
80
|
+
raise ArgumentError, "Unexpected files in locale directories: #{relative_paths.join(", ")}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
files
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def asset_name(source_path, locale_directory, locale)
|
|
87
|
+
relative_path = source_path.delete_prefix("#{locale_directory}#{File::SEPARATOR}")
|
|
88
|
+
extension = File.extname(relative_path)
|
|
89
|
+
basename = File.basename(relative_path, extension)
|
|
90
|
+
relative_directory = File.dirname(relative_path)
|
|
91
|
+
directory_parts = relative_directory == "." ? [] : relative_directory.split(File::SEPARATOR)
|
|
92
|
+
|
|
93
|
+
[*directory_parts, basename, locale].join(".") + extension + ".gz"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def gzip_file(source_path, destination_path)
|
|
97
|
+
Zlib::GzipWriter.open(destination_path) do |gzip_writer|
|
|
98
|
+
gzip_writer.mtime = 0
|
|
99
|
+
File.open(source_path, "rb") { IO.copy_stream(_1, gzip_writer) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_collisions!(staging_plan)
|
|
104
|
+
collisions = staging_plan.group_by(&:last).select { |_, sources| sources.length > 1 }
|
|
105
|
+
return if collisions.empty?
|
|
106
|
+
|
|
107
|
+
details = collisions.sort.map do |asset_name, sources|
|
|
108
|
+
source_paths = sources.map(&:first).join(", ")
|
|
109
|
+
"#{asset_name} (#{source_paths})"
|
|
110
|
+
end
|
|
111
|
+
raise ArgumentError, "Distribution asset naming collision: #{details.join("; ")}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -13,12 +13,14 @@ module ProductTaxonomy
|
|
|
13
13
|
values_path = File.join(data_path, "values.yml")
|
|
14
14
|
attributes_path = File.join(data_path, "attributes.yml")
|
|
15
15
|
return_reasons_path = File.join(data_path, "return_reasons.yml")
|
|
16
|
+
disclosures_path = File.join(data_path, "disclosures.yml")
|
|
16
17
|
categories_glob = Dir.glob(File.join(data_path, "categories", "*.yml"))
|
|
17
18
|
|
|
18
19
|
begin
|
|
19
20
|
ProductTaxonomy::Value.load_from_source(YAML.load_file(values_path))
|
|
20
21
|
ProductTaxonomy::Attribute.load_from_source(YAML.load_file(attributes_path))
|
|
21
22
|
ProductTaxonomy::ReturnReason.load_from_source(YAML.load_file(return_reasons_path))
|
|
23
|
+
ProductTaxonomy::Disclosure.load_from_source(YAML.load_file(disclosures_path))
|
|
22
24
|
|
|
23
25
|
categories_source_data = categories_glob.each_with_object([]) do |file, array|
|
|
24
26
|
array.concat(YAML.safe_load_file(file))
|
|
@@ -32,6 +34,7 @@ module ProductTaxonomy
|
|
|
32
34
|
|
|
33
35
|
# Run validations that can only be run after the taxonomy has been loaded.
|
|
34
36
|
ProductTaxonomy::Value.all.each { |model| model.validate!(:taxonomy_loaded) }
|
|
37
|
+
ProductTaxonomy::Disclosure.all.each { |model| model.validate!(:taxonomy_loaded) }
|
|
35
38
|
end
|
|
36
39
|
end
|
|
37
40
|
end
|
|
@@ -11,6 +11,7 @@ module ProductTaxonomy
|
|
|
11
11
|
Category.validate_localizations!(locales)
|
|
12
12
|
Attribute.validate_localizations!(locales)
|
|
13
13
|
Value.validate_localizations!(locales)
|
|
14
|
+
ReturnReason.validate_localizations!(locales)
|
|
14
15
|
|
|
15
16
|
validate_locales_are_consistent! if locales.nil?
|
|
16
17
|
end
|
|
@@ -21,10 +22,12 @@ module ProductTaxonomy
|
|
|
21
22
|
categories_locales = Category.localizations.keys
|
|
22
23
|
attributes_locales = Attribute.localizations.keys
|
|
23
24
|
values_locales = Value.localizations.keys
|
|
25
|
+
return_reasons_locales = ReturnReason.localizations.keys
|
|
24
26
|
|
|
25
27
|
error_message = "Not all model localizations have the same set of locales"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
all_locales_match = [attributes_locales, values_locales, return_reasons_locales]
|
|
29
|
+
.all? { |locales| locales == categories_locales }
|
|
30
|
+
raise ArgumentError, error_message unless all_locales_match
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
end
|
|
@@ -22,7 +22,7 @@ module ProductTaxonomy
|
|
|
22
22
|
id: item["id"],
|
|
23
23
|
name: item["name"],
|
|
24
24
|
attributes: Array(item["attributes"]).map { Attribute.find_by(friendly_id: _1) || _1 },
|
|
25
|
-
return_reasons:
|
|
25
|
+
return_reasons: parse_return_reasons(item["return_reasons"]),
|
|
26
26
|
)
|
|
27
27
|
end
|
|
28
28
|
|
|
@@ -42,6 +42,10 @@ module ProductTaxonomy
|
|
|
42
42
|
root_nodes << node if node.root?
|
|
43
43
|
end
|
|
44
44
|
@verticals.sort_by!(&:name)
|
|
45
|
+
|
|
46
|
+
# Fourth pass: derive each category's effective return reasons — inherited from the closest defining ancestor,
|
|
47
|
+
# falling back to the global reasons when nothing is defined.
|
|
48
|
+
Category.all.each(&:resolve_return_reasons)
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
# Reset all class-level state
|
|
@@ -60,6 +64,13 @@ module ProductTaxonomy
|
|
|
60
64
|
|
|
61
65
|
private
|
|
62
66
|
|
|
67
|
+
# `return_reasons: inherit` in the data marks a category as inheriting; anything else is an explicit list.
|
|
68
|
+
def parse_return_reasons(value)
|
|
69
|
+
return :inherit if value == "inherit"
|
|
70
|
+
|
|
71
|
+
Array(value).map { |friendly_id| ReturnReason.find_by(friendly_id:) || friendly_id }
|
|
72
|
+
end
|
|
73
|
+
|
|
63
74
|
def add_children(type:, item:, parent:)
|
|
64
75
|
item[type]&.each do |child_id|
|
|
65
76
|
child = Category.find_by(id: child_id) || child_id
|
|
@@ -84,16 +95,24 @@ module ProductTaxonomy
|
|
|
84
95
|
validate :id_starts_with_parent_id, unless: :root?, on: :category_tree_loaded
|
|
85
96
|
validate :children_found?, on: :category_tree_loaded
|
|
86
97
|
validate :secondary_children_found?, on: :category_tree_loaded
|
|
98
|
+
validate :root_does_not_inherit_return_reasons, if: :root?, on: :category_tree_loaded
|
|
87
99
|
|
|
88
100
|
localized_attr_reader :name, keyed_by: :id
|
|
89
101
|
|
|
90
|
-
attr_reader :id,
|
|
102
|
+
attr_reader :id,
|
|
103
|
+
:children,
|
|
104
|
+
:secondary_children,
|
|
105
|
+
:attributes,
|
|
106
|
+
:return_reasons,
|
|
107
|
+
:defined_return_reasons,
|
|
108
|
+
:inherits_return_reasons
|
|
91
109
|
attr_accessor :parent, :secondary_parents
|
|
92
110
|
|
|
93
111
|
# @param id [String] The ID of the category.
|
|
94
112
|
# @param name [String] The name of the category.
|
|
95
113
|
# @param attributes [Array<Attribute>] The attributes of the category.
|
|
96
|
-
# @param return_reasons [Array<ReturnReason
|
|
114
|
+
# @param return_reasons [Array<ReturnReason>, :inherit] The return reasons for the category, or `:inherit` to copy
|
|
115
|
+
# them from the closest ancestor that defines its own.
|
|
97
116
|
# @param parent [Category] The parent category of the category.
|
|
98
117
|
def initialize(id:, name:, attributes: [], return_reasons: [], parent: nil)
|
|
99
118
|
@id = id
|
|
@@ -101,7 +120,9 @@ module ProductTaxonomy
|
|
|
101
120
|
@children = []
|
|
102
121
|
@secondary_children = []
|
|
103
122
|
@attributes = attributes
|
|
104
|
-
@
|
|
123
|
+
@inherits_return_reasons = return_reasons == :inherit
|
|
124
|
+
@defined_return_reasons = @inherits_return_reasons ? [] : return_reasons.dup
|
|
125
|
+
@return_reasons = @defined_return_reasons.dup
|
|
105
126
|
@parent = parent
|
|
106
127
|
@secondary_parents = []
|
|
107
128
|
end
|
|
@@ -139,11 +160,29 @@ module ProductTaxonomy
|
|
|
139
160
|
@attributes << attribute
|
|
140
161
|
end
|
|
141
162
|
|
|
142
|
-
# Add a return reason to the category
|
|
163
|
+
# Add a return reason to the category. Explicitly adding a reason means the category defines its own reasons
|
|
164
|
+
# rather than inheriting them, so the first add on an inheriting category drops the inherited reasons.
|
|
143
165
|
#
|
|
144
166
|
# @param [ReturnReason] return_reason
|
|
145
167
|
def add_return_reason(return_reason)
|
|
146
|
-
@
|
|
168
|
+
if @inherits_return_reasons
|
|
169
|
+
@inherits_return_reasons = false
|
|
170
|
+
@defined_return_reasons = []
|
|
171
|
+
end
|
|
172
|
+
@defined_return_reasons << return_reason
|
|
173
|
+
resolve_return_reasons
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Copy return reasons from the closest ancestor that defines its own, when this category inherits. No-op for
|
|
177
|
+
# categories that define their own reasons or have no defining ancestor.
|
|
178
|
+
def resolve_return_reasons
|
|
179
|
+
@return_reasons = if inherits_return_reasons
|
|
180
|
+
ancestors.find { |ancestor| !ancestor.inherits_return_reasons }&.defined_return_reasons&.dup || []
|
|
181
|
+
else
|
|
182
|
+
defined_return_reasons.dup
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@return_reasons = ReturnReason.global.dup if @return_reasons.empty?
|
|
147
186
|
end
|
|
148
187
|
|
|
149
188
|
#
|
|
@@ -310,6 +349,13 @@ module ProductTaxonomy
|
|
|
310
349
|
end
|
|
311
350
|
end
|
|
312
351
|
|
|
352
|
+
def root_does_not_inherit_return_reasons
|
|
353
|
+
return unless inherits_return_reasons
|
|
354
|
+
|
|
355
|
+
# A root category has no ancestor to inherit from, so `inherit` cannot be resolved.
|
|
356
|
+
errors.add(:return_reasons, :root_cannot_inherit, message: "cannot be inherited by a root category")
|
|
357
|
+
end
|
|
358
|
+
|
|
313
359
|
def children_found?
|
|
314
360
|
children&.each do |child|
|
|
315
361
|
next if child.is_a?(Category)
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTaxonomy
|
|
4
|
+
# A legally-required product disclosure (e.g. a safety warning or chemical
|
|
5
|
+
# exposure notice). Disclosures form a shallow hierarchy: grouping/root nodes
|
|
6
|
+
# (no `parent_public_id`) organize the axis, and leaf nodes carry the
|
|
7
|
+
# jurisdiction information, plus the optional copy that merchants surface.
|
|
8
|
+
class Disclosure
|
|
9
|
+
include ActiveModel::Validations
|
|
10
|
+
include FormattedValidationErrors
|
|
11
|
+
extend Localized
|
|
12
|
+
extend Indexed
|
|
13
|
+
|
|
14
|
+
# Surfaces on which a disclosure may be displayed.
|
|
15
|
+
SURFACES = ["product_page", "cart", "checkout"].freeze
|
|
16
|
+
|
|
17
|
+
# `public_id` must be a lowercase slug: alphanumeric segments joined by `-` or `_`.
|
|
18
|
+
PUBLIC_ID_FORMAT = /\A[a-z0-9]+(?:[_-][a-z0-9]+)*\z/
|
|
19
|
+
|
|
20
|
+
# String-typed fields validated for type on load.
|
|
21
|
+
STRING_FIELDS = [
|
|
22
|
+
:name,
|
|
23
|
+
:internal_label,
|
|
24
|
+
:description,
|
|
25
|
+
:legal_citation,
|
|
26
|
+
:symbol,
|
|
27
|
+
:display_requirements,
|
|
28
|
+
:title,
|
|
29
|
+
:content,
|
|
30
|
+
:source,
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# Fields required on every leaf disclosure, in addition to jurisdictions.
|
|
34
|
+
LEAF_REQUIRED_FIELDS = [:legal_citation, :source].freeze
|
|
35
|
+
|
|
36
|
+
# Fields required on a leaf disclosure that names a display surface.
|
|
37
|
+
DISPLAY_REQUIRED_FIELDS = [:title, :content].freeze
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# Load disclosures from source data. By default this is deserialized from
|
|
41
|
+
# `data/disclosures.yml`.
|
|
42
|
+
#
|
|
43
|
+
# @param source_data [Array<Hash>] The source data to load disclosures from.
|
|
44
|
+
# @return [void]
|
|
45
|
+
def load_from_source(source_data)
|
|
46
|
+
raise ArgumentError, "source_data must be an array" unless source_data.is_a?(Array)
|
|
47
|
+
|
|
48
|
+
source_data.each do |disclosure_data|
|
|
49
|
+
raise ArgumentError, "source_data must contain hashes" unless disclosure_data.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
disclosure = disclosure_from(disclosure_data)
|
|
52
|
+
Disclosure.add(disclosure)
|
|
53
|
+
disclosure.validate!(:create)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Reset all class-level state.
|
|
58
|
+
def reset
|
|
59
|
+
@localizations = nil
|
|
60
|
+
@hashed_models = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get the next ID for a newly created disclosure.
|
|
64
|
+
#
|
|
65
|
+
# @return [Integer] The next ID.
|
|
66
|
+
def next_id = (all.max_by(&:id)&.id || 0) + 1
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def disclosure_from(data)
|
|
71
|
+
Disclosure.new(
|
|
72
|
+
id: data["id"],
|
|
73
|
+
public_id: data["public_id"],
|
|
74
|
+
parent_public_id: data["parent_public_id"],
|
|
75
|
+
name: data["name"],
|
|
76
|
+
internal_label: data["internal_label"],
|
|
77
|
+
description: data["description"],
|
|
78
|
+
jurisdictions: data["jurisdictions"],
|
|
79
|
+
legal_citation: data["legal_citation"],
|
|
80
|
+
symbol: data["symbol"],
|
|
81
|
+
display_requirements: data["display_requirements"],
|
|
82
|
+
display_preferences: data["display_preferences"],
|
|
83
|
+
title: data["title"],
|
|
84
|
+
content: data["content"],
|
|
85
|
+
source: data["source"],
|
|
86
|
+
disclosure_attributes: data["disclosure_attributes"],
|
|
87
|
+
disclosure_attribute_values: data["disclosure_attribute_values"],
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
validates :id, presence: true, numericality: { only_integer: true }, on: :create
|
|
93
|
+
validates :public_id, presence: true, format: { with: PUBLIC_ID_FORMAT, allow_blank: true }, on: :create
|
|
94
|
+
validates :name, presence: true, on: :create
|
|
95
|
+
validates :internal_label, presence: true, on: :create
|
|
96
|
+
validates_with ProductTaxonomy::Indexed::UniquenessValidator, attributes: [:public_id, :id], on: :create
|
|
97
|
+
validate :field_types_are_valid, on: :create
|
|
98
|
+
validate :not_self_parenting, on: :create
|
|
99
|
+
|
|
100
|
+
# Validations that can only run once the whole axis is loaded.
|
|
101
|
+
validate :parent_reference_exists, on: :taxonomy_loaded
|
|
102
|
+
validate :hierarchy_is_shallow_and_acyclic, on: :taxonomy_loaded
|
|
103
|
+
validate :jurisdiction_and_display_only_on_leaves, on: :taxonomy_loaded
|
|
104
|
+
validate :leaf_required_fields_present, on: :taxonomy_loaded
|
|
105
|
+
validate :display_fields_are_consistent, on: :taxonomy_loaded
|
|
106
|
+
validate :display_preferences_surfaces_are_valid, on: :taxonomy_loaded
|
|
107
|
+
|
|
108
|
+
localized_attr_reader :name, :description, :title, :content, keyed_by: :public_id
|
|
109
|
+
|
|
110
|
+
attr_reader :id,
|
|
111
|
+
:public_id,
|
|
112
|
+
:parent_public_id,
|
|
113
|
+
:internal_label,
|
|
114
|
+
:jurisdictions,
|
|
115
|
+
:legal_citation,
|
|
116
|
+
:symbol,
|
|
117
|
+
:display_requirements,
|
|
118
|
+
:display_preferences,
|
|
119
|
+
:source,
|
|
120
|
+
:disclosure_attributes,
|
|
121
|
+
:disclosure_attribute_values
|
|
122
|
+
|
|
123
|
+
def initialize(
|
|
124
|
+
id:,
|
|
125
|
+
public_id:,
|
|
126
|
+
name:,
|
|
127
|
+
internal_label:,
|
|
128
|
+
parent_public_id: nil,
|
|
129
|
+
description: nil,
|
|
130
|
+
jurisdictions: nil,
|
|
131
|
+
legal_citation: nil,
|
|
132
|
+
symbol: nil,
|
|
133
|
+
display_requirements: nil,
|
|
134
|
+
display_preferences: nil,
|
|
135
|
+
title: nil,
|
|
136
|
+
content: nil,
|
|
137
|
+
source: nil,
|
|
138
|
+
disclosure_attributes: nil,
|
|
139
|
+
disclosure_attribute_values: nil
|
|
140
|
+
)
|
|
141
|
+
@id = id
|
|
142
|
+
@public_id = public_id
|
|
143
|
+
@parent_public_id = parent_public_id
|
|
144
|
+
@name = name
|
|
145
|
+
@internal_label = internal_label
|
|
146
|
+
@description = description
|
|
147
|
+
@jurisdictions = jurisdictions
|
|
148
|
+
@legal_citation = legal_citation
|
|
149
|
+
@symbol = symbol
|
|
150
|
+
@display_requirements = display_requirements
|
|
151
|
+
@display_preferences = display_preferences
|
|
152
|
+
@title = title
|
|
153
|
+
@content = content
|
|
154
|
+
@source = source
|
|
155
|
+
@disclosure_attributes = disclosure_attributes
|
|
156
|
+
@disclosure_attribute_values = disclosure_attribute_values
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The global ID of the disclosure.
|
|
160
|
+
#
|
|
161
|
+
# @return [String]
|
|
162
|
+
def gid
|
|
163
|
+
"gid://shopify/TaxonomyDisclosure/#{id}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether this is a grouping/root node (has no parent).
|
|
167
|
+
#
|
|
168
|
+
# @return [Boolean]
|
|
169
|
+
def root?
|
|
170
|
+
parent_public_id.nil?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The parent disclosure, or nil for root nodes.
|
|
174
|
+
#
|
|
175
|
+
# @return [Disclosure, nil]
|
|
176
|
+
def parent
|
|
177
|
+
parent_public_id && Disclosure.find_by(public_id: parent_public_id)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The direct children of this disclosure.
|
|
181
|
+
#
|
|
182
|
+
# @return [Array<Disclosure>]
|
|
183
|
+
def children
|
|
184
|
+
Disclosure.all.select { |disclosure| disclosure.parent_public_id == public_id }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Whether this is a leaf node (no other disclosure points at it). Leaf nodes
|
|
188
|
+
# are the ones that carry jurisdiction and display information.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean]
|
|
191
|
+
def leaf?
|
|
192
|
+
children.empty?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def field_types_are_valid
|
|
198
|
+
errors.add(:jurisdictions, :invalid, message: "must be an array") if jurisdictions && !jurisdictions.is_a?(Array)
|
|
199
|
+
errors.add(:display_preferences, :invalid, message: "must be a hash") if display_preferences && !display_preferences.is_a?(Hash)
|
|
200
|
+
unless disclosure_attributes.nil? || disclosure_attributes.is_a?(Array)
|
|
201
|
+
errors.add(:disclosure_attributes, :invalid, message: "must be an array")
|
|
202
|
+
end
|
|
203
|
+
unless disclosure_attribute_values.nil? || disclosure_attribute_values.is_a?(Array)
|
|
204
|
+
errors.add(:disclosure_attribute_values, :invalid, message: "must be an array")
|
|
205
|
+
end
|
|
206
|
+
STRING_FIELDS.each do |field|
|
|
207
|
+
value = send(field)
|
|
208
|
+
errors.add(field, :invalid, message: "must be a string") if value && !value.is_a?(String)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def not_self_parenting
|
|
213
|
+
return if parent_public_id.nil?
|
|
214
|
+
|
|
215
|
+
errors.add(:parent_public_id, :invalid, message: "cannot be its own parent") if parent_public_id == public_id
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def parent_reference_exists
|
|
219
|
+
return if parent_public_id.nil?
|
|
220
|
+
return if Disclosure.find_by(public_id: parent_public_id)
|
|
221
|
+
|
|
222
|
+
errors.add(:parent_public_id, :not_found, message: "must reference an existing disclosure")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Enforces a two-level axis (grouping/root → leaf) and guards against cycles.
|
|
226
|
+
def hierarchy_is_shallow_and_acyclic
|
|
227
|
+
return if parent_public_id.nil?
|
|
228
|
+
|
|
229
|
+
seen = [public_id]
|
|
230
|
+
current = Disclosure.find_by(public_id: parent_public_id)
|
|
231
|
+
while current
|
|
232
|
+
if seen.include?(current.public_id)
|
|
233
|
+
errors.add(:parent_public_id, :invalid, message: "introduces a cycle in the hierarchy")
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
seen << current.public_id
|
|
237
|
+
current = current.parent_public_id && Disclosure.find_by(public_id: current.parent_public_id)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
parent = Disclosure.find_by(public_id: parent_public_id)
|
|
241
|
+
unless parent.nil? || parent.root?
|
|
242
|
+
errors.add(:parent_public_id, :invalid, message: "must reference a top-level grouping (max depth is two levels)")
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def jurisdiction_and_display_only_on_leaves
|
|
247
|
+
if leaf?
|
|
248
|
+
errors.add(:jurisdictions, :blank, message: "must be present on leaf disclosures") if jurisdictions.blank?
|
|
249
|
+
else
|
|
250
|
+
errors.add(:jurisdictions, :present, message: "must not be set on grouping disclosures") if jurisdictions.present?
|
|
251
|
+
if display_preferences.present?
|
|
252
|
+
errors.add(:display_preferences, :present, message: "must not be set on grouping disclosures")
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def leaf_required_fields_present
|
|
258
|
+
return unless leaf?
|
|
259
|
+
|
|
260
|
+
LEAF_REQUIRED_FIELDS.each do |field|
|
|
261
|
+
errors.add(field, :blank, message: "must be present on leaf disclosures") if send(field).blank?
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# A leaf that names a display surface must carry the copy that buyers read.
|
|
266
|
+
def display_fields_are_consistent
|
|
267
|
+
return unless leaf?
|
|
268
|
+
return if display_preferences.blank?
|
|
269
|
+
|
|
270
|
+
DISPLAY_REQUIRED_FIELDS.each do |field|
|
|
271
|
+
errors.add(field, :blank, message: "must be present when display_preferences is set") if send(field).blank?
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def display_preferences_surfaces_are_valid
|
|
276
|
+
return if display_preferences.blank?
|
|
277
|
+
|
|
278
|
+
surfaces = display_preferences.is_a?(Hash) ? display_preferences["surfaces"] : nil
|
|
279
|
+
unless surfaces.is_a?(Array) && surfaces.any?
|
|
280
|
+
errors.add(:display_preferences, :invalid, message: "must define a non-empty surfaces array")
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
invalid = surfaces - SURFACES
|
|
285
|
+
errors.add(:display_preferences, :invalid, message: "has invalid surfaces: #{invalid.join(", ")}") if invalid.any?
|
|
286
|
+
errors.add(:display_preferences, :invalid, message: "has duplicate surfaces") if surfaces.uniq.size != surfaces.size
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -5,7 +5,13 @@ module ProductTaxonomy
|
|
|
5
5
|
def validate!(...)
|
|
6
6
|
super # Calls original ActiveModel::Validations#validate!
|
|
7
7
|
rescue ActiveModel::ValidationError
|
|
8
|
-
id_field_name = self.is_a?(Category)
|
|
8
|
+
id_field_name = if self.is_a?(Category)
|
|
9
|
+
:id
|
|
10
|
+
elsif self.respond_to?(:friendly_id)
|
|
11
|
+
:friendly_id
|
|
12
|
+
else
|
|
13
|
+
:public_id
|
|
14
|
+
end
|
|
9
15
|
id_value = self.send(id_field_name)
|
|
10
16
|
|
|
11
17
|
formatted_error_details = self.errors.map do |error|
|
|
@@ -7,6 +7,17 @@ module ProductTaxonomy
|
|
|
7
7
|
extend Localized
|
|
8
8
|
extend Indexed
|
|
9
9
|
|
|
10
|
+
# Reasons that apply to every category, in the order they are always listed in. They sit at the end of a
|
|
11
|
+
# category's list, and stand in for categories that define no reasons of their own.
|
|
12
|
+
GLOBAL_FRIENDLY_IDS = [
|
|
13
|
+
"changed_my_mind",
|
|
14
|
+
"item_not_as_described",
|
|
15
|
+
"received_the_wrong_item",
|
|
16
|
+
"damaged_or_defective",
|
|
17
|
+
"unknown",
|
|
18
|
+
"other_reason",
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
10
21
|
class << self
|
|
11
22
|
# Override to match folder name convention (return_reasons vs returnreasons)
|
|
12
23
|
def localizations_humanized_model_name
|
|
@@ -33,6 +44,15 @@ module ProductTaxonomy
|
|
|
33
44
|
def reset
|
|
34
45
|
@localizations = nil
|
|
35
46
|
@hashed_models = nil
|
|
47
|
+
@global = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The global return reasons, in their defined order. Reasons that are not loaded are skipped, so callers get
|
|
51
|
+
# whatever subset the current source data defines. Frozen because the memoized array is shared by all callers.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<ReturnReason>]
|
|
54
|
+
def global
|
|
55
|
+
@global ||= GLOBAL_FRIENDLY_IDS.filter_map { |friendly_id| find_by(friendly_id:) }.freeze
|
|
36
56
|
end
|
|
37
57
|
|
|
38
58
|
# Get the next ID for a newly created return reason.
|