eco-helpers 3.2.16 → 3.2.17

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -0
  3. data/eco-helpers.gemspec +2 -2
  4. data/lib/eco/api/usecases/graphql/compat/parity/comparison.rb +70 -0
  5. data/lib/eco/api/usecases/graphql/compat/parity/harness.rb +102 -0
  6. data/lib/eco/api/usecases/graphql/compat/parity/run_result.rb +96 -0
  7. data/lib/eco/api/usecases/graphql/compat.rb +5 -0
  8. data/lib/eco/api/usecases/graphql/helpers/location/command/end_points/optimizations.rb +4 -4
  9. data/lib/eco/api/usecases/graphql/helpers/pages/copying.rb +71 -0
  10. data/lib/eco/api/usecases/graphql/helpers/pages/creatable.rb +78 -0
  11. data/lib/eco/api/usecases/graphql/helpers/pages/filters.rb +114 -0
  12. data/lib/eco/api/usecases/graphql/helpers/pages/ooze_handlers.rb +112 -0
  13. data/lib/eco/api/usecases/graphql/helpers/pages/rescuable.rb +52 -0
  14. data/lib/eco/api/usecases/graphql/helpers/pages/shortcuts.rb +186 -0
  15. data/lib/eco/api/usecases/graphql/helpers/pages/typed_fields_pairing.rb +303 -0
  16. data/lib/eco/api/usecases/graphql/helpers/pages.rb +21 -0
  17. data/lib/eco/api/usecases/graphql/helpers.rb +1 -0
  18. data/lib/eco/api/usecases/graphql/samples/location/command/service/tree_update.rb +1 -1
  19. data/lib/eco/api/usecases/graphql/samples/pages/register/base.rb +181 -0
  20. data/lib/eco/api/usecases/graphql/samples/pages/register/migration_case.rb +132 -0
  21. data/lib/eco/api/usecases/graphql/samples/pages/register/target_oozes_update_case.rb +163 -0
  22. data/lib/eco/api/usecases/graphql/samples/pages/register.rb +8 -0
  23. data/lib/eco/api/usecases/graphql/samples/pages/template/base.rb +70 -0
  24. data/lib/eco/api/usecases/graphql/samples/pages/template/command_emitter.rb +139 -0
  25. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/builder.rb +126 -0
  26. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/format_map.rb +108 -0
  27. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build/parser.rb +98 -0
  28. data/lib/eco/api/usecases/graphql/samples/pages/template/csv_build.rb +17 -0
  29. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/applier.rb +141 -0
  30. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/drift_report.rb +104 -0
  31. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/loop.rb +155 -0
  32. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/recording_executor.rb +58 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/sync_readiness.rb +178 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy/verifier.rb +141 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/template/deploy.rb +21 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/template.rb +11 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages.rb +2 -0
  38. data/lib/eco/version.rb +1 -1
  39. metadata +34 -5
@@ -0,0 +1,163 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Native GraphQL re-expression of OozeSamples::TargetOozesUpdateCase.
3
+ #
4
+ # Where Register::Base scopes work to a register and iterates its entries via a
5
+ # cursor search, this case takes an explicit list of target page ids from a CSV
6
+ # (`options[:source][:file]`, first column, header dropped) and processes each of
7
+ # those pages. It reproduces the SHAPE of the v2 TargetOozesUpdateCase:
8
+ # * id-batching (`batched_target_ids`)
9
+ # * a preview with duplicate detection + interactive proceed prompt
10
+ # * fetch-per-id (`graphql.pages.get`), dedup-by-id, retrieved/non-retrieved KPIs
11
+ # * hand each fetched page to `process_page` (a.k.a. `process_ooze`)
12
+ #
13
+ # As with Register::Base, the v2 batch_queue / enqueue / queue_shift machinery is
14
+ # intentionally dropped: the GraphQL update path is per-page. `update_page(page)`
15
+ # (from Page::Base) persists changes as the subclass makes them.
16
+ #
17
+ # == Subclass interface
18
+ #
19
+ # class Custom::UseCase::TOOCSCoding < Eco::API::UseCases::GraphQL::Samples::Pages::Register::TargetOozesUpdateCase
20
+ # name 'toocs-coding'
21
+ #
22
+ # def process_page(page)
23
+ # fld = page.components.get_by_name('TOOCS Code')
24
+ # return skip('no field') unless fld
25
+ # fld.value = merge_field_values(fld, options.dig(:source, :value))
26
+ # update_page(page)
27
+ # end
28
+ # end
29
+ #
30
+ # The CSV file is provided via `options[:source][:file]`.
31
+ class Register::TargetOozesUpdateCase < Register::Base
32
+ name 'graphql-target-oozes-update-case'
33
+ type :other
34
+
35
+ attr_reader :non_retrieved_pages
36
+
37
+ # Entry point — mirrors Register::Base#process but iterates target ids rather
38
+ # than a register search.
39
+ def process
40
+ init_kpis
41
+ target_ids_preview
42
+ each_target_page do |page|
43
+ process_page(page)
44
+ @processed_pages += 1
45
+ end
46
+ log_kpis
47
+ end
48
+
49
+ protected
50
+
51
+ # Fetch and iterate each target page by id, in batches. Counts retrieved /
52
+ # non-retrieved and dedups by id (v2 parity).
53
+ def each_target_page
54
+ batched_target_ids do |ids|
55
+ ids.each do |id|
56
+ if page_result_ids[id]
57
+ @dupped_search_pages += 1
58
+ else
59
+ page_result_ids[id] = true
60
+ @total_pages += 1
61
+ end
62
+
63
+ if (page = fetch_page(id))
64
+ @retrieved_pages += 1
65
+ yield page
66
+ else
67
+ @non_retrieved_pages += 1
68
+ log(:warn) { "Could not get page #{id}" }
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # Fetch one page by id via the GraphQL compat client. Wrapped so a bad id does
75
+ # not abort the whole run.
76
+ # @return [Object, nil] the page model, or nil when it could not be fetched.
77
+ def fetch_page(id)
78
+ with_rescue("page #{id}") do
79
+ graphql.pages.get(id)
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Yields the target ids in slices of `batch_size`.
86
+ def batched_target_ids
87
+ msg = "Missing block. It yields in slices of #{self.class.batch_size} ids"
88
+ raise ArgumentError, msg unless block_given?
89
+
90
+ pool = []
91
+ target_ids.each do |id|
92
+ pool << id
93
+ if pool.length >= self.class.batch_size
94
+ yield(pool)
95
+ pool = []
96
+ end
97
+ end
98
+ yield(pool) unless pool.empty?
99
+ end
100
+
101
+ # Preview: total count + duplicate detection + interactive proceed prompt
102
+ # (v2-faithful — matches TargetOozesUpdateCase#target_ids_preview).
103
+ def target_ids_preview
104
+ dups = target_ids.select {|id| target_ids.count(id) > 1}
105
+ @total_search_pages = target_ids.count
106
+ @dupped_search_pages = 0
107
+
108
+ dups_str = dups.any? ? "There are #{dups.count} duplicated ids" : 'No duplicates detected'
109
+ msg = "Total target entries: #{target_ids.count} (#{dups_str})"
110
+
111
+ default_answer = session.config.run_mode_remote? ? 'Y' : 'N'
112
+
113
+ session.prompt_user(
114
+ 'Do you want to proceed (y/N):',
115
+ explanation: msg,
116
+ default: default_answer,
117
+ timeout: 10
118
+ ) do |res|
119
+ unless res.upcase.start_with?('Y')
120
+ puts '...'
121
+ log(:info) { 'Aborting script...' }
122
+ exit(0)
123
+ end
124
+ end
125
+ end
126
+
127
+ # The list of target page ids (first CSV column, header dropped).
128
+ def target_ids
129
+ @target_ids ||= input_csv.columns.first[1..]
130
+ end
131
+
132
+ def input_csv
133
+ @input_csv ||= Eco::CSV.read(options.dig(:source, :file))
134
+ end
135
+
136
+ # #each_page is register-search based (inherited from Register::Base) and is NOT
137
+ # the iteration path here — target ids come from the CSV. Guard against accidental
138
+ # use so a subclass mixing the two gets a clear error rather than a silent org scan.
139
+ def each_page(*)
140
+ raise NotImplementedError,
141
+ "#{self.class} iterates target ids from a CSV — use #each_target_page, not #each_page"
142
+ end
143
+
144
+ def init_kpis
145
+ super
146
+ @non_retrieved_pages = 0
147
+ end
148
+
149
+ def kpis_message
150
+ [
151
+ 'Run end:',
152
+ " * Target entries: #{total_search_pages}",
153
+ " * Duplicated: #{dupped_search_pages}",
154
+ " * Retrieved: #{retrieved_pages}",
155
+ " * Could not get: #{non_retrieved_pages}",
156
+ " * Processed: #{processed_pages}",
157
+ " * Updated: #{updated_pages} (of #{total_pages} unique)",
158
+ " * Skipped: #{skipped_pages}",
159
+ " * Failed: #{failed_pages}"
160
+ ].join("\n")
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,8 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ module Register
3
+ end
4
+ end
5
+
6
+ require_relative 'register/base'
7
+ require_relative 'register/target_oozes_update_case'
8
+ require_relative 'register/migration_case'
@@ -0,0 +1,70 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ # Build-from-scratch template (workflow) construction use case.
3
+ #
4
+ # A subclass declares the desired template structure in #declare(emitter) using the
5
+ # CommandEmitter DSL; the base turns it into an ordered command batch and applies it via
6
+ # graphql.template.create / update (executeWorkflowCommands). `simulate?` previews the batch
7
+ # without writing. The emitted batch (#desired_commands) is unit-testable with no live client.
8
+ #
9
+ # class Custom::UseCase::BuildIncidentTemplate < Samples::Pages::Template::Base
10
+ # name 'build-incident-template'
11
+ #
12
+ # def declare(t)
13
+ # t.stage(name: 'Report', ordering: 0) do |stage|
14
+ # stage.section(layout: 'content') do |section|
15
+ # section.field(label: 'Description', field_type: 'plain_text')
16
+ # end
17
+ # end
18
+ # end
19
+ # end
20
+ class Template::Base < Eco::API::UseCases::GraphQL::Base
21
+ name 'graphql-template-base'
22
+ type :other
23
+
24
+ require_relative 'command_emitter'
25
+
26
+ def process
27
+ commands = desired_commands
28
+ if simulate?
29
+ log(:info) { preview_message(commands) }
30
+ return nil
31
+ end
32
+ apply(commands)
33
+ end
34
+
35
+ # == Subclass override point ================================================
36
+
37
+ # Declare the desired template structure onto the given CommandEmitter.
38
+ def declare(_emitter)
39
+ raise NotImplementedError, "Implement #declare(emitter) in #{self.class}"
40
+ end
41
+
42
+ # The ordered command batch this template would apply. Pure — no client needed.
43
+ def desired_commands
44
+ emitter = CommandEmitter.new
45
+ declare(emitter)
46
+ emitter.commands
47
+ end
48
+
49
+ protected
50
+
51
+ # Persist a fresh template from the command batch. Override to #update an existing one.
52
+ def apply(commands)
53
+ graphql.template.create(commands: commands)
54
+ end
55
+
56
+ # Update an existing template model with the command batch.
57
+ def apply_update(model, commands)
58
+ graphql.template.update(model, commands: commands)
59
+ end
60
+
61
+ private
62
+
63
+ def preview_message(commands)
64
+ [
65
+ "Simulate — would apply #{commands.size} workflow command(s):",
66
+ *commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }
67
+ ].join("\n")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,139 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages
2
+ module Template
3
+ # Declarative build-from-scratch emitter for template (workflow) structure.
4
+ #
5
+ # Mirrors the *shape* of the location diff->commands engine (samples/location/command), but the
6
+ # id-threading is **placeholder-based**, not post-hoc newId-remap: each new node is given a
7
+ # client-chosen `placeholderId`, and later commands in the SAME batch reference it. The gem's
8
+ # executeWorkflowCommands resolves placeholders intra-batch. See the gem's PHASE0-FINDINGS.md.
9
+ #
10
+ # Commands are emitted in dependency order via depth-first DSL evaluation:
11
+ # addStage -> (per section) addSection, addStageSection -> (per field) addField
12
+ # -> (per option) addSelectFieldOption ; then forces: addForce -> (per binding) addBinding.
13
+ #
14
+ # Usage:
15
+ # emitter = CommandEmitter.new
16
+ # emitter.stage(name: 'Report', ordering: 0) do |stage|
17
+ # stage.section(layout: 'content') do |section|
18
+ # section.field(label: 'Notes', field_type: 'plain_text')
19
+ # section.field(label: 'Risk', field_type: 'select') do |field|
20
+ # field.option(label: 'High', weight: 10)
21
+ # end
22
+ # end
23
+ # end
24
+ # emitter.commands # => ordered Array of FINAL built { commandKey => kwargs } commands
25
+ #
26
+ # The output is fed DIRECTLY to graphql.template.create(commands:) / update(model, commands:).
27
+ # It is already built via WorkflowCommand.build — do NOT re-run it through build_commands
28
+ # (that normaliser is for hand-authored raw specs and is non-idempotent for key-renaming
29
+ # commands such as addSelectFieldOption: data_field_id -> dataFieldId).
30
+ class CommandEmitter
31
+ def initialize
32
+ @commands = []
33
+ @counters = Hash.new(0)
34
+ end
35
+
36
+ # @return [Array<Hash>] ordered command specs, each { commandKey => kwargs }
37
+ attr_reader :commands
38
+
39
+ # Declare a stage. Yields a StageBuilder for nested sections.
40
+ def stage(name:, ordering: nil, &block)
41
+ placeholder = allocate(:stg)
42
+ emit(:addStage, placeholderId: placeholder, name: name, ordering: ordering)
43
+ StageBuilder.new(self, placeholder).tap { |b| block&.call(b) }
44
+ placeholder
45
+ end
46
+
47
+ # Declare a force at template level. Yields a ForceBuilder for bindings.
48
+ def force(name:, custom_script: nil, url: nil, content_b64: nil, &block)
49
+ placeholder = allocate(:frc)
50
+ emit(:addForce, placeholderId: placeholder, name: name, customScript: custom_script,
51
+ url: url, contentB64: content_b64)
52
+ ForceBuilder.new(self, placeholder).tap { |b| block&.call(b) }
53
+ placeholder
54
+ end
55
+
56
+ # --- internal API used by the nested builders ---------------------------
57
+
58
+ # Allocate the next placeholder id for a node kind (:stg, :sec, :fld, :frc, :bnd).
59
+ def allocate(kind)
60
+ "#{kind}#{@counters[kind] += 1}"
61
+ end
62
+
63
+ # Build + append one command via the gem's WorkflowCommand contract.
64
+ # The gem constant is resolved lazily (at call time, not load time) — the gem's GraphQL
65
+ # namespace is not yet loaded while this file is being required.
66
+ def emit(command_key, **kwargs)
67
+ @commands << Ecoportal::API::GraphQL::Input::WorkflowCommand.build(command_key, **kwargs)
68
+ end
69
+
70
+ # Nested builders — each holds its parent's placeholder and appends in-order.
71
+
72
+ class StageBuilder
73
+ def initialize(emitter, stage_id)
74
+ @emitter = emitter
75
+ @stage_id = stage_id
76
+ end
77
+
78
+ # A section is created independently, then linked into this stage.
79
+ def section(layout: 'content', &block)
80
+ section_id = @emitter.allocate(:sec)
81
+ @emitter.emit(:addSection, placeholderId: section_id, layout: layout)
82
+ @emitter.emit(:addStageSection, stageId: @stage_id, sectionId: section_id)
83
+ SectionBuilder.new(@emitter, @stage_id, section_id).tap { |b| block&.call(b) }
84
+ section_id
85
+ end
86
+ end
87
+
88
+ class SectionBuilder
89
+ def initialize(emitter, stage_id, section_id)
90
+ @emitter = emitter
91
+ @stage_id = stage_id
92
+ @section_id = section_id
93
+ end
94
+
95
+ # `description` / `required` are accepted for forward-compatibility with the CSV-build
96
+ # identity convention (hidden-field + description). NOTE: the RELEASED gem's addField input
97
+ # (`Input::WorkflowCommand::AddField::VALID_KEYS`) currently slices to
98
+ # placeholderId/fieldType/label/stageId/sectionId/column and compacts everything else away —
99
+ # so today these pass-throughs are DROPPED by the gem contract (harmless no-op). They are wired
100
+ # here so that, once the gem's addField gains a description key, no emitter change is needed.
101
+ def field(label:, field_type:, column: 0, description: nil, required: nil, &block)
102
+ field_id = @emitter.allocate(:fld)
103
+ @emitter.emit(:addField, placeholderId: field_id, fieldType: field_type, label: label,
104
+ stageId: @stage_id, sectionId: @section_id, column: column,
105
+ description: description, required: required)
106
+ FieldBuilder.new(@emitter, field_id).tap { |b| block&.call(b) }
107
+ field_id
108
+ end
109
+ end
110
+
111
+ class FieldBuilder
112
+ def initialize(emitter, field_id)
113
+ @emitter = emitter
114
+ @field_id = field_id
115
+ end
116
+
117
+ # Select-field option. dataFieldId threads the (placeholder) field id.
118
+ def option(label:, weight: nil)
119
+ @emitter.emit(:addSelectFieldOption, data_field_id: @field_id, label: label, weight: weight)
120
+ @field_id
121
+ end
122
+ end
123
+
124
+ class ForceBuilder
125
+ def initialize(emitter, force_id)
126
+ @emitter = emitter
127
+ @force_id = force_id
128
+ end
129
+
130
+ def binding(name:, reference_id: nil, type: nil)
131
+ binding_id = @emitter.allocate(:bnd)
132
+ @emitter.emit(:addBinding, placeholderId: binding_id, forceId: @force_id, name: name,
133
+ referenceId: reference_id, type: type)
134
+ binding_id
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,126 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module CsvBuild
3
+ # CSV → template BUILD pipeline.
4
+ #
5
+ # Ties the pieces together: a `Parser` turns a columnar CSV into a stage/section/field tree, this
6
+ # Builder replays that tree onto the EXISTING `Template::CommandEmitter` (reused verbatim — same
7
+ # placeholder-id threading the create path already relies on), and then hands the resulting ordered
8
+ # `WorkflowCommand` batch to the released gem's `Builder::Template#create(commands:)`.
9
+ #
10
+ # Offline / dry-run by DEFAULT — real creation needs credentials, so `build` only assembles and
11
+ # returns the command batch; `create!` is the explicit live path (requires a gem `Builder::Template`
12
+ # or `GraphQL#template` facade).
13
+ #
14
+ # builder = CsvBuild::Builder.from_csv(csv_string)
15
+ # commands = builder.commands # ordered { commandKey => kwargs } batch, offline
16
+ # # live (needs creds):
17
+ # builder.create!(graphql.template) # => gem create mutation response
18
+ #
19
+ # SECTION / FIELD IDENTITY: each field's `description` (from FormatMap) is passed to the emitter's
20
+ # `field(description:)`, and a hidden anchor per section carries the section identity — so a rebuilt
21
+ # template stays pair-able against a prior version by the diff engine. When the field type is
22
+ # `select`, the parsed options are emitted as addSelectFieldOption commands.
23
+ #
24
+ # ⚠ HONEST LIMITATION: the RELEASED gem's `addField` input drops any key outside
25
+ # placeholderId/fieldType/label/stageId/sectionId/column (see CommandEmitter#field note). So today
26
+ # the `description` identity token is NOT persisted through addField — the hidden ANCHOR FIELD's
27
+ # presence (its label) is the only identity signal that currently survives to the batch. The
28
+ # description wiring is in place so identity flows end-to-end the moment the gem's addField input
29
+ # gains a description key. Until then, section/field identity relies on the anchor + heading/label.
30
+ class Builder
31
+ # Hidden marker field type used to anchor SECTION identity across rebuilds (identity convention:
32
+ # hidden-field + description). Kept as a constant so it is trivial to retune when the real format
33
+ # / platform convention for the anchor is confirmed.
34
+ SECTION_ANCHOR_TYPE = 'hidden'.freeze
35
+ SECTION_ANCHOR_LABEL = '__section_id'.freeze
36
+
37
+ def self.from_csv(csv_string, **opts)
38
+ new(Parser.new(csv_string), **opts)
39
+ end
40
+
41
+ def self.from_file(path, **opts)
42
+ new(Parser.from_file(path), **opts)
43
+ end
44
+
45
+ # @param parser [Parser]
46
+ # @param section_anchors [Boolean] emit a hidden anchor field per section for stable identity
47
+ # (default true — the CSV-pipeline identity convention). Set false for a plain build.
48
+ def initialize(parser, section_anchors: true)
49
+ @parser = parser
50
+ @section_anchors = section_anchors
51
+ end
52
+
53
+ # The ordered command batch — PURE, no client needed. This is what specs assert against.
54
+ def commands
55
+ @commands ||= begin
56
+ emitter = CommandEmitter.new
57
+ emit_tree(emitter)
58
+ emitter.commands
59
+ end
60
+ end
61
+
62
+ # LIVE build (needs creds). `template_facade` is the gem `Builder::Template` (a.k.a.
63
+ # `graphql.template`) exposing `create(commands:)`.
64
+ def create!(template_facade, &block)
65
+ unless template_facade.respond_to?(:create)
66
+ raise ArgumentError,
67
+ 'template facade must respond to #create(commands:)'
68
+ end
69
+
70
+ template_facade.create(commands: commands, &block)
71
+ end
72
+
73
+ # Human preview of the batch (for a dry-run / simulate log).
74
+ def preview
75
+ ["CSV build — #{commands.size} workflow command(s):",
76
+ *commands.map { |c| " * #{c.keys.first}: #{c.values.first.inspect}" }].join("\n")
77
+ end
78
+
79
+ private
80
+
81
+ def emit_tree(emitter)
82
+ @parser.tree.each do |stage|
83
+ emitter.stage(name: stage[:name], ordering: stage[:ordering]) do |stage_builder|
84
+ stage[:sections].each { |section| emit_section(stage_builder, section) }
85
+ end
86
+ end
87
+ end
88
+
89
+ def emit_section(stage_builder, section)
90
+ stage_builder.section(layout: section[:layout]) do |section_builder|
91
+ emit_section_anchor(section_builder, section) if @section_anchors
92
+ section[:fields].each { |field| emit_field(section_builder, field) }
93
+ end
94
+ end
95
+
96
+ # Hidden anchor field carrying the section identity in its description (identity convention).
97
+ def emit_section_anchor(section_builder, section)
98
+ section_builder.field(
99
+ label: SECTION_ANCHOR_LABEL,
100
+ field_type: SECTION_ANCHOR_TYPE,
101
+ description: section_identity(section)
102
+ )
103
+ end
104
+
105
+ def emit_field(section_builder, field)
106
+ section_builder.field(
107
+ label: field.label,
108
+ field_type: field.type,
109
+ column: field.column,
110
+ required: field.required,
111
+ description: field.description
112
+ ) do |field_builder|
113
+ Array(field.options).each do |opt|
114
+ field_builder.option(label: opt[:label], weight: opt[:weight])
115
+ end
116
+ end
117
+ end
118
+
119
+ # Stable section identity token. Uses the section heading; a hidden anchor field carries it so
120
+ # the diff/pairing engine can re-home the section on a rebuild.
121
+ def section_identity(section)
122
+ "section:#{section[:heading]}"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,108 @@
1
+ module Eco::API::UseCases::GraphQL::Samples::Pages::Template
2
+ module CsvBuild
3
+ # ISOLATED column-format mapping for the CSV → template build pipeline.
4
+ #
5
+ # ============================ IMPORTANT / TODO ============================
6
+ # The EXACT columnar CSV format for the ~300-template bulk build is due ~mid-July 2026 and is NOT
7
+ # yet confirmed. This class is the SINGLE place the assumed format is encoded, precisely so that
8
+ # when the real format lands, only THIS file (and its spec) needs to change — the Parser and
9
+ # Builder are written against the neutral intermediate `RowSpec`, not against column names.
10
+ #
11
+ # Assumed format (documented here, one row per FIELD, hierarchy carried on each row):
12
+ #
13
+ # stage,stage_ordering,section,section_ordering,section_layout,
14
+ # field_label,field_type,field_required,field_column,field_description,field_options
15
+ #
16
+ # * `field_type` — a template field type accepted by addField (e.g. plain_text, select,
17
+ # date, number, gauge, rich_text, people, ...). Passed through verbatim.
18
+ # * `field_options` — for select-type fields: pipe-separated `label:weight` pairs,
19
+ # e.g. "High:10|Medium:5|Low:0". Weight optional ("High|Medium|Low").
20
+ # * `field_description` — carries the SECTION/FIELD IDENTITY convention (see below).
21
+ #
22
+ # SECTION / FIELD IDENTITY CONVENTION (per the CSV-pipeline project notes): stable identity for a
23
+ # section/field across rebuilds is expressed via a HIDDEN marker field + a DESCRIPTION token, not
24
+ # via the volatile Mongo id. The parser threads `field_description` through so the builder can
25
+ # stamp a deterministic identity token; sections inherit identity from their heading + a hidden
26
+ # anchor field. This keeps a rebuilt template pair-able against a prior version by the diff engine.
27
+ # ==========================================================================
28
+ #
29
+ # To adapt to the real format: change COLUMNS + the `#value`/`#options` readers here. Everything
30
+ # downstream consumes `RowSpec`, which is format-agnostic.
31
+ module FormatMap
32
+ # Logical field => CSV header name (assumed). CHANGE HERE when the real format arrives.
33
+ COLUMNS = {
34
+ stage: 'stage',
35
+ stage_ordering: 'stage_ordering',
36
+ section: 'section',
37
+ section_ordering: 'section_ordering',
38
+ section_layout: 'section_layout',
39
+ field_label: 'field_label',
40
+ field_type: 'field_type',
41
+ field_required: 'field_required',
42
+ field_column: 'field_column',
43
+ field_description: 'field_description',
44
+ field_options: 'field_options'
45
+ }.freeze
46
+
47
+ # Delimiters (assumed). CHANGE HERE if the real format differs.
48
+ OPTIONS_DELIMITER = '|'.freeze
49
+ OPTION_WEIGHT_SEPARATOR = ':'.freeze
50
+
51
+ # Neutral, format-agnostic row: what the Builder consumes. Only THIS is passed downstream.
52
+ RowSpec = Struct.new(:stage, :stage_ordering, :section, :section_ordering, :section_layout,
53
+ :field_label, :field_type, :field_required, :field_column,
54
+ :field_description, :field_options, keyword_init: true)
55
+
56
+ module_function
57
+
58
+ # Map one CSV row (a Hash keyed by header string) to a RowSpec.
59
+ def row_spec(row)
60
+ RowSpec.new(
61
+ stage: value(row, :stage),
62
+ stage_ordering: integer(row, :stage_ordering),
63
+ section: value(row, :section),
64
+ section_ordering: integer(row, :section_ordering),
65
+ section_layout: value(row, :section_layout) || 'content',
66
+ field_label: value(row, :field_label),
67
+ field_type: value(row, :field_type),
68
+ field_required: truthy?(row, :field_required),
69
+ field_column: integer(row, :field_column) || 0,
70
+ field_description: value(row, :field_description),
71
+ field_options: options(value(row, :field_options))
72
+ )
73
+ end
74
+
75
+ # Parse the options cell into [ { label:, weight: }, ... ]. Empty → [].
76
+ def options(cell)
77
+ return [] if cell.nil? || cell.to_s.strip.empty?
78
+
79
+ cell.to_s.split(OPTIONS_DELIMITER).map do |token|
80
+ label, weight = token.split(OPTION_WEIGHT_SEPARATOR, 2).map(&:strip)
81
+ { label: label, weight: (weight && !weight.empty? ? Integer(weight, exception: false) : nil) }
82
+ end
83
+ end
84
+
85
+ def value(row, logical)
86
+ header = COLUMNS.fetch(logical)
87
+ raw = row[header]
88
+ raw = row[header.to_sym] if raw.nil? && row.respond_to?(:key?)
89
+ return nil if raw.nil?
90
+
91
+ str = raw.to_s.strip
92
+ str.empty? ? nil : str
93
+ end
94
+
95
+ def integer(row, logical)
96
+ raw = value(row, logical)
97
+ raw && Integer(raw, exception: false)
98
+ end
99
+
100
+ def truthy?(row, logical)
101
+ raw = value(row, logical)
102
+ return false if raw.nil?
103
+
104
+ %w[1 true yes y required].include?(raw.downcase)
105
+ end
106
+ end
107
+ end
108
+ end