eco-helpers 3.2.14 → 3.2.22

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/lib/eco/api/usecases/CLAUDE.md +78 -0
  4. data/lib/eco/api/usecases/default/pages.rb +30 -0
  5. data/lib/eco/api/usecases/graphql/CLAUDE.md +120 -0
  6. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
  7. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
  8. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
  9. data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +234 -0
  10. data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
  11. data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
  12. data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
  13. data/lib/eco/api/usecases/graphql/compat.rb +11 -0
  14. data/lib/eco/api/usecases/graphql/helpers/CLAUDE.md +79 -0
  15. data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
  16. data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
  17. data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
  18. data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
  19. data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
  20. data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
  21. data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
  22. data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
  23. data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
  24. data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
  25. data/lib/eco/api/usecases/graphql/samples/CLAUDE.md +76 -0
  26. data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
  27. data/lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md +59 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
  38. data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
  39. data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
  40. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
  41. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
  42. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
  43. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
  44. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
  45. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
  46. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
  47. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
  48. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
  49. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
  50. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
  51. data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
  52. data/lib/eco/api/usecases/graphql/samples/pages.rb +9 -0
  53. data/lib/eco/api/usecases/graphql/samples.rb +1 -0
  54. data/lib/eco/api/usecases/graphql.rb +1 -0
  55. data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
  56. data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +13 -3
  57. data/lib/eco/version.rb +1 -1
  58. metadata +52 -15
  59. data/.gitignore +0 -23
  60. data/.idea/.gitignore +0 -10
  61. data/.markdownlint.json +0 -4
  62. data/.rspec +0 -3
  63. data/.rubocop.yml +0 -103
  64. data/.ruby-version +0 -1
  65. data/.yardopts +0 -10
  66. data/Gemfile +0 -8
  67. data/Rakefile +0 -38
  68. data/eco-helpers.gemspec +0 -63
@@ -0,0 +1,112 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Native GraphQL re-expression of OozeSamples::Helpers::OozeHandlers.
4
+ #
5
+ # `merge_values` combines two values of a data field of the *same* type and
6
+ # label into one, matching the v2 semantics but dispatching on the GraphQL
7
+ # DataField TYPE STRING (e.g. 'PlainText', 'Select', 'Date') rather than on
8
+ # `Ecoportal::API::V2::Page::Component::*` classes. The type strings are the
9
+ # `__typename`-derived values in
10
+ # `Ecoportal::API::GraphQL::Base::Page::DataField::TYPE_MAP`.
11
+ #
12
+ # `merge_arrays` / `array_indexes` are pure array utilities — ported verbatim.
13
+ module OozeHandlers
14
+ # Per-type merge strategy.
15
+ # :join → concatenate origin + append with a delimiter (text, select,
16
+ # reference, people, checklist, action list, files, images, geo, law)
17
+ # :origin → keep the origin value untouched (date, number, gauge — no sensible
18
+ # textual concatenation; the v2 case returned `origin` for these)
19
+ #
20
+ # Text-ish types always merge with a NEWLINE delimiter in v2 (RichText /
21
+ # PlainText) regardless of the caller's `delimiter:`; the rest honour it.
22
+ MERGE_STRATEGY = {
23
+ 'PlainText' => %i[join newline],
24
+ 'RichText' => %i[join newline],
25
+ 'Select' => %i[join delimiter],
26
+ 'CrossReference' => %i[join delimiter],
27
+ 'People' => %i[join delimiter],
28
+ 'Checklist' => %i[join delimiter],
29
+ 'ActionsList' => %i[join delimiter],
30
+ 'FileField' => %i[join delimiter],
31
+ 'File' => %i[join delimiter],
32
+ 'ImageGallery' => %i[join delimiter],
33
+ 'Geo' => %i[join delimiter],
34
+ 'Law' => %i[join delimiter],
35
+ 'DateField' => %i[origin],
36
+ 'Date' => %i[origin],
37
+ 'Number' => %i[origin],
38
+ 'Gauge' => %i[origin]
39
+ }.freeze
40
+
41
+ # Merge values of fields of the same type and label name.
42
+ #
43
+ # @param origin [Object] the value to keep as the base.
44
+ # @param append [Object] the value to merge onto `origin`.
45
+ # @param type [String, Symbol, nil] the GraphQL DataField type string
46
+ # (e.g. 'PlainText', 'Select'). Nil / unknown → default join. Accepts a
47
+ # field object responding to #type too (convenience).
48
+ # @param delimiter [String] the delimiter for :delimiter-strategy types.
49
+ # @return [Object] the merged value.
50
+ def merge_values(origin, append, type: nil, delimiter: "\n")
51
+ return origin if !append || append.to_s.strip.empty?
52
+ return append if !origin || origin.to_s.strip.empty?
53
+
54
+ type_key = resolve_field_type(type)
55
+ strategy, delim_kind = MERGE_STRATEGY.fetch(type_key, %i[join delimiter])
56
+
57
+ return origin if strategy == :origin
58
+
59
+ used_delimiter = delim_kind == :newline ? "\n" : delimiter
60
+ [origin, append].join(used_delimiter)
61
+ end
62
+
63
+ # Merges arrays `ref` and `plus` by keeping the order of `ref`
64
+ # but inserting additional elements of `plus` in the correct position
65
+ # (i.e. when those elements are between two elements that exist in `ref`).
66
+ # @note this can be used to combine two different header sequences
67
+ # by using `ref` as a reference.
68
+ def merge_arrays(ref, plus) # rubocop:disable Metrics/AbcSize
69
+ return ref if ref == plus
70
+
71
+ href_idx = array_indexes(ref)
72
+ hplus_idx = array_indexes(plus)
73
+ shared = ref & plus
74
+ hshared_ref_idx = href_idx.slice(*shared)
75
+
76
+ shared_plus_idxs = hplus_idx.values_at(*shared).sort.reverse
77
+
78
+ only_plus = plus - ref
79
+ honly_plus_idx = hplus_idx.slice(*only_plus)
80
+
81
+ hplus_ref_idx = honly_plus_idx.each_with_object({}) do |(e, idx), href_idx_plus|
82
+ ps_idx = shared_plus_idxs.bsearch {|i| idx >= i}
83
+ rs_idx = ps_idx ? hshared_ref_idx[plus[ps_idx]] : -1
84
+ href_idx_plus[e] = rs_idx
85
+ end.group_by do |_e, idx|
86
+ idx
87
+ end.to_a.sort_by do |(idx, _pairs)|
88
+ idx
89
+ end.reverse.to_h.transform_values do |pairs|
90
+ pairs.map(&:first)
91
+ end
92
+
93
+ hplus_ref_idx.each_with_object(ref.dup) do |(idx, elements), rf|
94
+ rf.insert(idx + 1, *elements)
95
+ end
96
+ end
97
+
98
+ def array_indexes(ary)
99
+ ary.each_with_index.to_h
100
+ end
101
+
102
+ private
103
+
104
+ # Accept either a type string/symbol or a field object that responds to #type.
105
+ def resolve_field_type(type)
106
+ return type.type.to_s if type.respond_to?(:type)
107
+
108
+ type.to_s
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,52 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Prevents a script from stopping on a per-item error, prompting the operator to
4
+ # continue or abort. Ported from OozeSamples::Helpers::Rescuable; the only change is
5
+ # the base-type guard, which now requires a native GraphQL::Base case.
6
+ module Rescuable
7
+ module InstanceMethods
8
+ private
9
+
10
+ # Helper to prevent script from stopping
11
+ def with_rescue(reference, trace_count: 1)
12
+ raise ArgumentError, 'Expecting block, but not given' unless block_given?
13
+ yield
14
+ rescue StandardError => e
15
+ str_err = e.patch_full_message(trace_count: trace_count)
16
+ log(:error) { [reference, str_err].join(" => \n") }
17
+ lines = []
18
+ lines << "\nThere was an error. Choose one option:\n"
19
+ lines << ' (C) - Continue/resume. Just ignore this one.'
20
+ lines << ' (A) - Abort. Just break this run.'
21
+
22
+ session.prompt_user('Type one option (C/a):', explanation: lines.join("\n"), default: 'C') do |res|
23
+ res = res.upcase
24
+ raise if res.start_with?('A')
25
+
26
+ # 'C'
27
+ log(:warn) { "Script resumed after error...\n * #{reference}" }
28
+ nil
29
+ end
30
+ end
31
+ end
32
+
33
+ class << self
34
+ def included(base)
35
+ super
36
+ validate_base_type!(base)
37
+ base.include(InstanceMethods)
38
+ end
39
+
40
+ def validate_base_type!(base)
41
+ return super if defined?(super)
42
+
43
+ msg = "#{self} can only be included in Eco::API::UseCases::GraphQL::Base"
44
+ msg << "\nCan't be included in #{base}"
45
+ raise LoadError, msg unless base <= Eco::API::UseCases::GraphQL::Base
46
+
47
+ true
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,186 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # String simplification / comparison helpers, ported from
4
+ # OozeSamples::Helpers::Shortcuts. Everything here is pure except #object_reference,
5
+ # which is re-expressed with duck-typing (no Ecoportal::API::V2 constants) so it
6
+ # describes GraphQL page/stage/section/field/force models.
7
+ module Shortcuts
8
+ # Basic simplification pattern (matches all but a-z and blank space)
9
+ def non_letters_regex
10
+ @non_letters_regex ||= /[^a-z ]+/
11
+ end
12
+
13
+ # Matches anything between two consecutive (), inclusive
14
+ def bracked_regex
15
+ @bracked_regex ||= /(?<bracked>\([^\)]+?\))/ # rubocop:disable Style/RedundantRegexpEscape
16
+ end
17
+
18
+ # It always downcase, trim and remove double spaces.
19
+ # @param ignore [Boolean, Regexp, String, Array] ignored when `exact` is `true`
20
+ # * when `false`: it does not do anything additional
21
+ # * when `Regexp`: it removes all characters that match the expression.
22
+ # * when `String`: each character listed is removed.
23
+ # * when `Array`: reduces `str` processing `ignore` in order.
24
+ # * when `true` (or otherwise): it removes all non a-zA-Z characters but blank spaces.
25
+ def simplify_string(str, ignore: false)
26
+ str = str.to_s.strip.downcase.gsub(/\s+/, ' ')
27
+ return str unless ignore
28
+
29
+ sub = non_letters_regex
30
+ case ignore
31
+ when Regexp then sub = ignore
32
+ when String then sub = /[#{ignore}]+/
33
+ when Array
34
+ return ignore.reduce(str) do |out, sub_ignore|
35
+ simplify_string(out, ignore: sub_ignore)
36
+ end
37
+ end
38
+ str.gsub(sub, '').gsub(/\s+/, ' ').strip
39
+ end
40
+
41
+ # Offers multiple simplification methods to compare two strings
42
+ # @note only one of the values can be a Regexp
43
+ def same_string?(value_1, value_2, exact: false, mild: false, ignore: false) # rubocop:disable Metrics/AbcSize
44
+ return true if value_1.to_s.strip.empty? && value_2.to_s.strip.empty?
45
+ return false if value_1.to_s.strip.empty? || value_2.to_s.strip.empty?
46
+
47
+ val_1 = value_1
48
+ val_2 = value_2
49
+
50
+ unless exact
51
+ if val_1.is_a?(String)
52
+ val_1 = simplify_string(val_1, ignore: ignore) if ignore
53
+ val_1 = simplify_string(val_1, ignore: non_letters_regex) if mild
54
+ end
55
+ if val_2.is_a?(String)
56
+ val_2 = simplify_string(val_2, ignore: ignore) if ignore
57
+ val_2 = simplify_string(val_2, ignore: non_letters_regex) if mild
58
+ end
59
+ end
60
+
61
+ if val_1.is_a?(String) && val_2.is_a?(String)
62
+ val_1 == val_2
63
+ elsif val_1.is_a?(Regexp) && val_2.is_a?(String)
64
+ val_2 =~ val_1
65
+ elsif val_1.is_a?(String) && val_2.is_a?(Regexp)
66
+ val_1 =~ val_2
67
+ else
68
+ msg = 'Expected at least one String, and either a String or Regex. '
69
+ msg << "Given: (1: #{val_1.class}) and (2: #{val_2.class})"
70
+ raise ArgumentError, msg
71
+ end
72
+ end
73
+
74
+ def titleize(str)
75
+ return nil unless str
76
+ return str if str.strip.empty?
77
+
78
+ str.split(/\s+/).map do |part|
79
+ part[0] = part[0].upcase
80
+ part[1..] = part[1..].downcase
81
+ part
82
+ end.join(' ')
83
+ end
84
+
85
+ def normalize_string(str)
86
+ return nil unless str
87
+
88
+ str.gsub(/[^[:print:]]/, '').
89
+ gsub(/[[:space:]]+/, ' ').
90
+ gsub(/[[:space:]]$/, '').
91
+ gsub(/[-‑‒–]/, '-').then do |aux|
92
+ aux = yield(aux) if block_given?
93
+ aux
94
+ end
95
+ end
96
+
97
+ def clean_question(str)
98
+ return nil unless str
99
+
100
+ normalize_string(str) do |x|
101
+ x.gsub("\r\n", ' ').then do |aux|
102
+ aux = yield(aux) if block_given?
103
+ aux
104
+ end
105
+ end
106
+ end
107
+
108
+ # Human-readable reference for a GraphQL page-model object, for logs/errors.
109
+ #
110
+ # Re-expressed with duck-typing (was a V2 `case/when` on Page/Stage/Section/Component/
111
+ # Force/Binding). We inspect the object's shape rather than its class so this works
112
+ # across Base::/Model:: GraphQL types without coupling to constants that may move.
113
+ def object_reference(obj)
114
+ return 'No reference' unless obj
115
+
116
+ if binding_like?(obj)
117
+ "Binding '#{safe_name(obj)}' in #{object_reference(obj.force)}"
118
+ elsif force_like?(obj)
119
+ "Force '#{safe_name(obj)}'"
120
+ elsif component_like?(obj)
121
+ label = obj.respond_to?(:label) ? obj.label : nil
122
+ type = obj.respond_to?(:type) ? obj.type : nil
123
+ ref = "Component '#{label || "(unnamed of type '#{type}')"}'"
124
+ ref += " in #{object_reference(obj.section)}" if obj.respond_to?(:section) && obj.section
125
+ ref
126
+ elsif section_like?(obj)
127
+ heading = obj.respond_to?(:heading) ? obj.heading : nil
128
+ "Section '#{heading || '(unnamed)'}'"
129
+ elsif stage_like?(obj)
130
+ named_ref('Stage', obj)
131
+ elsif page_like?(obj)
132
+ named_ref("Page (#{obj.id})", obj)
133
+ else
134
+ named_ref('', obj).strip
135
+ end
136
+ end
137
+
138
+ def to_i(value)
139
+ Float(value).to_i
140
+ end
141
+
142
+ # https://stackoverflow.com/a/5661695/4352306
143
+ # Kept as `is_number?` for naming parity with OozeSamples::Helpers::Shortcuts.
144
+ def is_number?(value) # rubocop:disable Naming/PredicatePrefix
145
+ true if Float(value)
146
+ rescue ArgumentError, TypeError
147
+ false
148
+ end
149
+
150
+ private
151
+
152
+ def binding_like?(obj)
153
+ obj.respond_to?(:force) && obj.respond_to?(:name) && !obj.respond_to?(:bindings)
154
+ end
155
+
156
+ def force_like?(obj)
157
+ obj.respond_to?(:bindings) && obj.respond_to?(:name)
158
+ end
159
+
160
+ def component_like?(obj)
161
+ obj.respond_to?(:label) && (obj.respond_to?(:type) || obj.respond_to?(:section))
162
+ end
163
+
164
+ def section_like?(obj)
165
+ obj.respond_to?(:heading) && obj.respond_to?(:components)
166
+ end
167
+
168
+ def stage_like?(obj)
169
+ obj.respond_to?(:ordering) && obj.respond_to?(:state)
170
+ end
171
+
172
+ def page_like?(obj)
173
+ obj.respond_to?(:id) && obj.respond_to?(:stages)
174
+ end
175
+
176
+ def safe_name(obj)
177
+ obj.respond_to?(:name) ? obj.name : nil
178
+ end
179
+
180
+ def named_ref(prefix, obj)
181
+ name = safe_name(obj)
182
+ name ? "#{prefix} '#{name}'" : prefix
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,303 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ module Pages
3
+ # Native GraphQL re-expression of
4
+ # OozeSamples::HelpersMigration::TypedFieldsPairing.
5
+ #
6
+ # It pairs fields of the SAME type with the SAME label name across two page
7
+ # models (a source `src` and a destination `dst`). This is the cross-template
8
+ # field-pairing used by migration cases — DISTINCT from the value-merge
9
+ # `OozeHandlers#merge_values` (which combines two values of one already-paired
10
+ # field).
11
+ #
12
+ # It runs two screenings:
13
+ # (1) exact label match,
14
+ # (2) then the rest, case-insensitive a-z only, content between brackets removed.
15
+ #
16
+ # Other features (ported verbatim from the v2 "World Wonder of 2022"):
17
+ # * tracks src / dst fields that were NOT paired
18
+ # * lets you `resolve` fields paired by other means out of the unpaired sets
19
+ # * tracks src fields with MULTIPLE candidate dst fields (`src_dst_multi`)
20
+ #
21
+ # == What differs from the v2 class
22
+ #
23
+ # Fields are grouped and compared by the GraphQL DataField TYPE STRING (the
24
+ # `__typename`-derived value in `Ecoportal::API::GraphQL::Base::Page::DataField::TYPE_MAP`,
25
+ # e.g. 'PlainText', 'Select', 'Date') rather than the v2 snake_case component type
26
+ # (`'plain_text'`, `'select'`, `'date'`). Same-type grouping still guarantees a
27
+ # pair only forms between fields of identical type. `EXCLUDED_TYPES` therefore
28
+ # lists the GraphQL type strings for the never-pair types (charts / tag fields).
29
+ #
30
+ # `ximport?` detects an import-marker field via its `#tooltip` (unchanged), and
31
+ # `object_reference` / `same_string?` come from the native `Shortcuts`.
32
+ class TypedFieldsPairing
33
+ include Eco::API::UseCases::GraphQL::Helpers::Pages::Shortcuts
34
+
35
+ # GraphQL DataField type strings that must never be paired (no meaningful,
36
+ # copyable content). Mirrors the v2 EXCLUDED_TYPES
37
+ # (%w[tag_field chart frequency_rate_chart]) in GraphQL terms — GraphQL has no
38
+ # distinct chart data-field types, so TagField is the practical excluded type;
39
+ # any additional non-content types can be added here.
40
+ EXCLUDED_TYPES = %w[TagField Chart FrequencyRateChart].freeze
41
+
42
+ attr_reader :src, :dst
43
+ attr_reader :src_unpaired, :dst_unpaired
44
+ attr_reader :src_dst_pairs, :src_dst_multi
45
+
46
+ def initialize(source, destination, src_excluded: nil, dst_excluded: nil,
47
+ exclude_ximport: false)
48
+ @src = source
49
+ @dst = destination
50
+ @src_excluded = src_excluded || []
51
+ @dst_excluded = dst_excluded || []
52
+ @exclude_ximport = exclude_ximport
53
+
54
+ reset!
55
+ process(exact: true)
56
+ process
57
+ end
58
+
59
+ def each_pair
60
+ src_dst_pairs.each do |(src_field, dst_field)|
61
+ yield(src_field, dst_field) if block_given?
62
+ end
63
+ end
64
+
65
+ def report_src_unpaired(only_present: true, only_indexed: false)
66
+ return if src_unpaired.empty?
67
+
68
+ msg = "Source entry with unpaired fields (#{object_reference(src)}):\n"
69
+
70
+ src_filtered = src_unpaired.values.flatten.reject do |src_fld|
71
+ only_present && field_empty?(src_fld)
72
+ end.reject do |src_fld|
73
+ only_indexed && src_fld.deindex
74
+ end
75
+
76
+ return if src_filtered.empty?
77
+
78
+ msg << src_filtered.map do |src_fld|
79
+ " * #{object_reference(src_fld)}"
80
+ end.join("\n")
81
+ end
82
+
83
+ def report_dst_unpaired(only_required: false, exclude_ximport: true)
84
+ return if dst_unpaired.empty?
85
+
86
+ msg = "Destination entry with unpaired fields (#{object_reference(src)}):\n"
87
+
88
+ dst_filtered = dst_unpaired.values.flatten.reject do |dst_fld|
89
+ only_required && !dst_fld.required
90
+ end.reject do |dst_fld|
91
+ exclude_ximport && ximport?(dst_fld)
92
+ end
93
+
94
+ return if dst_filtered.empty?
95
+
96
+ msg << dst_filtered.map do |dst_fld|
97
+ " * #{object_reference(dst_fld)}"
98
+ end.join("\n")
99
+ end
100
+
101
+ def report_multi_pairs(only_present: true, only_indexed: false, dst_exclude_ximport: true)
102
+ return nil if src_dst_multi.empty?
103
+
104
+ ''.tap do |msg|
105
+ msg << "Source fields in (#{object_reference(src)}) paired with multiple destination fields:\n"
106
+ msg_flds = ''
107
+ src_dst_multi.each_value do |pairs|
108
+ pairs.each do |(src_fld, dst_flds)|
109
+ next if (only_present && field_empty?(src_fld)) || (only_indexed && src_fld.deindex)
110
+
111
+ dst_filtered = dst_flds.reject do |dst_fld|
112
+ dst_exclude_ximport && ximport?(dst_fld)
113
+ end
114
+ next if dst_filtered.empty?
115
+
116
+ msg_flds << " * #{object_reference(src_fld)}\n"
117
+ msg_flds << dst_filtered.map do |dst_fld|
118
+ " * #{object_reference(dst_fld)}"
119
+ end.compact.join("\n")
120
+ end
121
+ end
122
+
123
+ return nil if msg_flds.empty?
124
+
125
+ msg << msg_flds
126
+ end
127
+ end
128
+
129
+ # Remove from unpaired tracking those `flds`.
130
+ def resolve(*flds)
131
+ flds.each do |fld|
132
+ next if unpaired_delete?(fld, src_unpaired)
133
+
134
+ unpaired_delete?(fld, dst_unpaired)
135
+ end
136
+
137
+ multi_delete(*flds)
138
+ end
139
+
140
+ def some_multi?
141
+ !src_dst_multi.empty?
142
+ end
143
+
144
+ private
145
+
146
+ # When comparing, use only a-z letters.
147
+ def only_letters?
148
+ true
149
+ end
150
+
151
+ # When comparing, remove anything between brackets from the field label name.
152
+ def non_bracked?
153
+ true
154
+ end
155
+
156
+ # If `false`, it compares with all lower case.
157
+ def exact?
158
+ !(only_letters? || non_bracked?)
159
+ end
160
+
161
+ def process(exact: false)
162
+ src_unpaired.each do |type, src_pend|
163
+ next unless (dst_pend = dst_unpaired[type]) && !dst_pend.empty?
164
+
165
+ src_pend.dup.each do |src_fld|
166
+ dst_candidates = dst_pend.select do |dst_fld|
167
+ str_eq?(src_fld.label, dst_fld.label, exact: exact)
168
+ end
169
+ next if dst_candidates.empty?
170
+
171
+ if dst_candidates.one?
172
+ pair_add(src_fld, dst_candidates.first)
173
+ else
174
+ multi_add(type, src_fld, *dst_candidates)
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ def str_eq?(str_1, str_2, exact: false)
181
+ same_string?(str_1, str_2, **str_eq_params, exact: exact)
182
+ end
183
+
184
+ def str_eq_params
185
+ {
186
+ exact: exact?,
187
+ mild: only_letters?,
188
+ ignore: false
189
+ }.tap do |params|
190
+ params.merge!(ignore: bracked_regex) if non_bracked?
191
+ end
192
+ end
193
+
194
+ def pair_add(src_fld, dst_fld)
195
+ resolve(src_fld, dst_fld)
196
+ src_dst_pairs << [src_fld, dst_fld]
197
+ end
198
+
199
+ # Removes a field from a (src or dst) unpaired set.
200
+ # @return [Boolean] true when the field was present and removed.
201
+ def unpaired_delete?(fld, unpaired)
202
+ type = fld.type
203
+ return false unless unpaired.key?(type)
204
+ return false unless (flds = unpaired[type]).delete(fld)
205
+
206
+ unpaired.delete(type) if flds.empty?
207
+ true
208
+ end
209
+
210
+ def multi_add(type, src_fld, *dst_flds)
211
+ (src_dst_multi[type] ||= []) << (pair = [src_fld, dst_flds])
212
+ @src_multi[src_fld] = pair
213
+
214
+ dst_flds.each do |dst_fld|
215
+ (@dst_multi[dst_fld] ||= []) << pair
216
+ end
217
+ end
218
+
219
+ def multi_delete(*flds)
220
+ flds.each do |fld|
221
+ next unless (type_pairs = src_dst_multi[fld.type])
222
+
223
+ if (pairs = @dst_multi.delete(fld))
224
+ pairs.each do |pair|
225
+ src_fld = pair.first
226
+ dst_flds = pair.last
227
+ dst_flds.delete(fld)
228
+ if dst_flds.empty?
229
+ @src_multi.delete(src_fld)
230
+ type_pairs.delete(pair)
231
+ end
232
+ end
233
+ elsif @src_multi.key?(fld)
234
+ pair = @src_multi.delete(fld)
235
+ type_pairs.delete(pair)
236
+
237
+ pair.last.each do |dst_fld|
238
+ pairs = @dst_multi[dst_fld]
239
+ pairs.delete(pair)
240
+ @dst_multi.delete(dst_fld) if pairs.empty?
241
+ end
242
+ end
243
+
244
+ src_dst_multi.delete(fld.type) if type_pairs.empty?
245
+ end
246
+ end
247
+
248
+ def by_type(components)
249
+ components.to_a.group_by(&:type).tap do |out|
250
+ EXCLUDED_TYPES.each {|type| out.delete(type)}
251
+ end
252
+ end
253
+
254
+ def src_components
255
+ flds = components_of(src)
256
+ flds = flds.reject {|fld| ximport?(fld)} if @exclude_ximport
257
+ flds - @src_excluded
258
+ end
259
+
260
+ def dst_components
261
+ flds = components_of(dst)
262
+ flds = flds.reject {|fld| ximport?(fld)} if @exclude_ximport
263
+ flds - @dst_excluded
264
+ end
265
+
266
+ # Duck-typed access to a page model's data fields. Native GraphQL page
267
+ # models expose `#components` (a SectionCollection-backed collection); the
268
+ # v2 class relied on the same method name.
269
+ def components_of(entry)
270
+ entry.components.to_a
271
+ end
272
+
273
+ def ximport?(fld)
274
+ return false unless fld.respond_to?(:tooltip)
275
+
276
+ fld.tooltip.to_s.include?('ximport')
277
+ end
278
+
279
+ # A field is "empty" when it carries no content. Native DataField types do
280
+ # not universally expose `#empty?`; fall back to `#value` emptiness so the
281
+ # report filters behave like the v2 ones.
282
+ def field_empty?(fld)
283
+ return fld.empty? if fld.respond_to?(:empty?)
284
+ return fld.value.to_s.strip.empty? if fld.respond_to?(:value)
285
+
286
+ false
287
+ end
288
+
289
+ def reset!
290
+ @src_unpaired = by_type(src_components)
291
+ @dst_unpaired = by_type(dst_components)
292
+ @src_dst_pairs = []
293
+ reset_multi!
294
+ end
295
+
296
+ def reset_multi!
297
+ @src_dst_multi = {}
298
+ @src_multi = {}
299
+ @dst_multi = {}
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,21 @@
1
+ module Eco::API::UseCases::GraphQL::Helpers
2
+ # Native GraphQL page-processing helper substrate.
3
+ #
4
+ # These mirror the *pure* parts of Eco::API::UseCases::OozeSamples::Helpers::* but drop the
5
+ # Ecoportal::API::V2 coupling: string/date utilities are ported verbatim, while the few
6
+ # methods that inspected V2 model types (object_reference, field_key_name) are re-expressed
7
+ # with duck-typing so they work against the GraphQL page/section/field models.
8
+ #
9
+ # This is Phase 1 of the ooze -> native GraphQL migration (build the shared substrate before
10
+ # any case). See ecoportal-api-graphql/.ai-assistance/projects/ooze-graphql-native-migration/.
11
+ module Pages
12
+ end
13
+ end
14
+
15
+ require_relative 'pages/shortcuts'
16
+ require_relative 'pages/filters'
17
+ require_relative 'pages/ooze_handlers'
18
+ require_relative 'pages/rescuable'
19
+ require_relative 'pages/creatable'
20
+ require_relative 'pages/typed_fields_pairing'
21
+ require_relative 'pages/copying'
@@ -6,3 +6,4 @@ end
6
6
  require_relative 'helpers/base'
7
7
  require_relative 'helpers/location'
8
8
  require_relative 'helpers/contractors'
9
+ require_relative 'helpers/pages'