ecoportal-api-graphql 2.2.1 → 3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +405 -9
  3. data/README.gem.md +53 -0
  4. data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
  5. data/lib/ecoportal/api/common/graphql/client.rb +38 -0
  6. data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
  7. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
  8. data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
  9. data/lib/ecoportal/api/graphql/base.rb +1 -0
  10. data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
  11. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
  12. data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
  13. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +6 -1
  14. data/lib/ecoportal/api/graphql/input/page/update.rb +110 -7
  15. data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
  16. data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
  17. data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
  18. data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
  19. data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
  20. data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
  21. data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
  22. data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
  23. data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
  24. data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
  25. data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
  26. data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
  27. data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
  28. data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
  29. data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
  30. data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
  31. data/lib/ecoportal/api/graphql/model/template.rb +33 -0
  32. data/lib/ecoportal/api/graphql/model.rb +1 -0
  33. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
  34. data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
  35. data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
  36. data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
  37. data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
  38. data/lib/ecoportal/api/graphql/mutation.rb +1 -0
  39. data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
  40. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  41. data/lib/ecoportal/api/graphql_version.rb +1 -1
  42. metadata +35 -2
  43. data/README.md +0 -24
@@ -0,0 +1,262 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # Diff engine: walks the in-memory tree and emits the ordered `[CommandInput]` batch
7
+ # `#as_commands` returns, plus the phase-scoped subsets `StagedExecutor` sends one at
8
+ # a time (each subset re-synthesised against the CURRENT tree, so a node resolved by
9
+ # an earlier phase — `Node#resolve!` — is addressed by its real id, not its stale
10
+ # placeholder, the next time a phase is built; see `Node#ref`).
11
+ #
12
+ # ORDER — stage -> section -> field -> option/config -> force -> helper -> binding,
13
+ # matching `Diff::CommandSynthesizer::KIND_ORDER`'s own dependency-safe order plus the
14
+ # two groups it does not cover (force, helper/binding — no existing gem emitter
15
+ # before this class). Within a stage: structural section commands first
16
+ # (add/remove/header), THEN a `reorderSection` TRAILER for every section with a
17
+ # pending move (see `Section#move`'s own doc for why last) — this mirrors the
18
+ # EPT-2078 "preserve reorderSection trailer protection" rule.
19
+ #
20
+ # SAFETY — never guesses. A field's `field_type` has no setter (see `Field`'s own
21
+ # doc), so a type "change" cannot even be expressed here; every other write goes
22
+ # through the same `WorkflowCommand.build` layer `Builder::TemplateBuilder` and
23
+ # `Diff::CommandSynthesizer` already use, so a missing required key (`value` on a
24
+ # new option, etc.) raises there — never invented here.
25
+ class CommandSynthesis
26
+ def initialize(instance)
27
+ @instance = instance
28
+ end
29
+
30
+ # The full ordered batch — the public `#as_commands` contract.
31
+ def commands
32
+ stage_phase + section_phase + field_phase + force_phase + helper_binding_phase
33
+ end
34
+
35
+ # --- phase-scoped subsets, used by StagedExecutor -------------------
36
+
37
+ def stage_phase
38
+ instance.stages.select(&:new?).flat_map { |stage| stage_commands(stage) }
39
+ end
40
+
41
+ def section_phase
42
+ instance.stages.flat_map { |stage| section_commands_for_stage(stage) }
43
+ end
44
+
45
+ def field_phase
46
+ instance.stages.flat_map { |stage| stage.sections.flat_map { |section| field_commands_for_section(section) } }
47
+ end
48
+
49
+ def force_phase
50
+ instance.stages.flat_map { |stage|
51
+ stage.forces.reject(&:new?).flat_map { |f| force_edit_commands(f) } +
52
+ stage.forces.select(&:new?).flat_map { |f| force_add_commands(f) }
53
+ }
54
+ end
55
+
56
+ def helper_binding_phase
57
+ instance.stages.flat_map { |stage| stage.forces.flat_map { |force| helper_and_binding_commands(force) } }
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :instance
63
+
64
+ def build(key, **kwargs)
65
+ Ecoportal::API::GraphQL::Input::WorkflowCommand.build(key, **kwargs)
66
+ end
67
+
68
+ # --- stage -----------------------------------------------------------
69
+
70
+ def stage_commands(stage)
71
+ [build(:addStage, name: stage.name, ordering: stage.ordering, placeholderId: stage.placeholder)]
72
+ end
73
+
74
+ # --- section -----------------------------------------------------------
75
+
76
+ def section_commands_for_stage(stage)
77
+ structural = stage.sections.flat_map { |section| section_structural_commands(stage, section) }
78
+ trailer = stage.sections.reject(&:removed?).filter_map { |section| reorder_command(stage, section) }
79
+ structural + trailer
80
+ end
81
+
82
+ def section_structural_commands(stage, section)
83
+ return removed_section_commands(section) if section.removed?
84
+ return new_section_commands(stage, section) if section.new?
85
+
86
+ edited_section_commands(section)
87
+ end
88
+
89
+ def removed_section_commands(section)
90
+ [build(:removeSection, sectionId: section.id)]
91
+ end
92
+
93
+ def new_section_commands(stage, section)
94
+ cmds = [build(:addSection, placeholderId: section.placeholder, layout: section.layout)]
95
+ cmds << build(:addStageSection, stageId: stage.ref, sectionId: section.ref)
96
+ cmds << header_command(section)
97
+ cmds.compact
98
+ end
99
+
100
+ def edited_section_commands(section)
101
+ return [] unless section.dirty?
102
+
103
+ [header_command(section)].compact
104
+ end
105
+
106
+ def header_command(section)
107
+ changed = section.new? ? section_header_kwargs(section) : section.changed_attributes
108
+ return nil if changed.empty? || changed.values.all?(&:nil?)
109
+
110
+ build(:editSectionHeader, sectionId: section.ref,
111
+ header: changed[:heading], leftHeader: changed[:left_heading],
112
+ rightHeader: changed[:right_heading])
113
+ end
114
+
115
+ def section_header_kwargs(section)
116
+ { heading: section.heading, left_heading: section.left_heading, right_heading: section.right_heading }
117
+ end
118
+
119
+ def reorder_command(stage, section)
120
+ move = section.pending_move
121
+ return nil if move.nil?
122
+
123
+ build(:reorderSection, sectionId: section.ref, stageId: stage.ref,
124
+ anchorId: move[:anchor].ref, anchorPosition: move[:position])
125
+ end
126
+
127
+ # --- field / option ----------------------------------------------------
128
+
129
+ def field_commands_for_section(section)
130
+ section.fields.flat_map { |field| field_commands(section, field) }
131
+ end
132
+
133
+ def field_commands(section, field)
134
+ return [build(:removeField, id: field.id)] if field.removed?
135
+
136
+ cmds = field.new? ? new_field_commands(section, field) : edited_field_commands(field)
137
+ cmds + option_commands(field)
138
+ end
139
+
140
+ def new_field_commands(section, field)
141
+ cmds = [build(:addField, placeholderId: field.placeholder, fieldType: field.field_type,
142
+ label: field.label, stageId: field.stage.ref, sectionId: section.ref,
143
+ column: field.column)]
144
+ cfg = field_config_kwargs(field)
145
+ cmds << build(:editFieldConfiguration, data_field_id: field.ref, **stage_id_kwarg(field), **cfg) unless cfg.empty?
146
+ cmds
147
+ end
148
+
149
+ def edited_field_commands(field)
150
+ cfg = wire_field_config_keys(field.changed_attributes)
151
+ return [] if cfg.empty?
152
+
153
+ [build(:editFieldConfiguration, data_field_id: field.ref, **stage_id_kwarg(field), **cfg)]
154
+ end
155
+
156
+ # `editFieldConfiguration.stageId` disambiguates WHICH stage's copy of the field is
157
+ # being edited -- REQUIRED on a phased page (`edit_field_configuration.rb`'s own
158
+ # schema-authority comment), a key the schema does not need (and this class never
159
+ # sends, not even as an explicit `null`) on a non-phased page. `Field#stage` is
160
+ # ALWAYS present either way (`Read`'s workflow-first read populates it for BOTH
161
+ # page kinds) -- `#phased` on the owning `Instance`, not "does the field know its
162
+ # stage", is the real gate the schema cares about (mirrors the DSL dialect's own
163
+ # `Bindings.write_field_property`/the downstream `FieldWriter.configure`, both of which only
164
+ # thread a stage id through when one is actually known/needed).
165
+ def stage_id_kwarg(field)
166
+ field.template.phased? ? { stageId: field.stage.ref } : {}
167
+ end
168
+
169
+ # Only the non-nil subset of a NEW field's attributes — `editFieldConfiguration`
170
+ # after a fresh `addField` should not send a literal `nil` for something the
171
+ # caller never set. `label` is dropped: it already went through `addField`.
172
+ def field_config_kwargs(field)
173
+ wire_field_config_keys({ tooltip: field.tooltip, description: field.description,
174
+ required: field.required, hidden: field.hidden }.compact)
175
+ end
176
+
177
+ # `Field#hidden` (this model's read/write attribute name, matching `Read`'s own
178
+ # `tf` fragment key) maps onto `EditFieldConfiguration`'s real top-level argument
179
+ # `hideView` (`input/workflow_command/edit_field_configuration.rb`'s own
180
+ # `BASE_KEYS` — there is no plain `hidden` key on that input) — the one place this
181
+ # read/write naming split is bridged, so every other layer can stay consistent.
182
+ def wire_field_config_keys(cfg)
183
+ return cfg unless cfg.key?(:hidden)
184
+
185
+ cfg.merge(hideView: cfg[:hidden]).except(:hidden)
186
+ end
187
+
188
+ def option_commands(field)
189
+ field.options.flat_map { |opt| option_command(field, opt) }
190
+ end
191
+
192
+ def option_command(field, opt)
193
+ return [build(:removeSelectFieldOption, data_field_id: field.ref, option_id: opt.id)] if opt.removed?
194
+
195
+ if opt.new?
196
+ # addSelectFieldOption REQUIRES `value` (backend: AddSelectFieldOptionInput,
197
+ # required: true — see `Diff::CommandSynthesizer#add_option`'s own identical
198
+ # rule). `WorkflowCommand::AddSelectFieldOption.build` accepts a literal `nil`
199
+ # without complaint (it is a required KEYWORD, not a required VALUE — Ruby
200
+ # only enforces the keyword's presence), so this class enforces the missing-
201
+ # value rule itself rather than letting a `nil` reach the server.
202
+ if opt.value.nil?
203
+ raise ArgumentError,
204
+ "CommandSynthesis: cannot build addSelectFieldOption for option " \
205
+ "'#{opt.label}' (field #{field.ref}) — value is nil. Set " \
206
+ '`option.value = ...` before calling #as_commands/#save!.'
207
+ end
208
+
209
+ return [build(:addSelectFieldOption, data_field_id: field.ref, placeholderId: opt.placeholder,
210
+ label: opt.label, value: opt.value, weight: opt.weight)]
211
+ end
212
+
213
+ changed = opt.changed_attributes
214
+ return [] if changed.empty?
215
+
216
+ [build(:editSelectFieldOption, data_field_id: field.ref, option_id: opt.id, **changed)]
217
+ end
218
+
219
+ # --- force / helper / binding ------------------------------------------
220
+
221
+ def force_add_commands(force)
222
+ [build(:addForce, placeholderId: force.placeholder, name: force.name,
223
+ customScript: force.custom_script, url: force.url, contentB64: force.content_b64)]
224
+ end
225
+
226
+ def force_edit_commands(force)
227
+ changed = force.changed_attributes
228
+ return [] if changed.empty?
229
+
230
+ [build(:editForce, id: force.id, name: changed[:name], customScript: changed[:custom_script],
231
+ url: changed[:url], contentB64: changed[:content_b64])]
232
+ end
233
+
234
+ def helper_and_binding_commands(force)
235
+ force.helpers.select(&:new?).map { |h| helper_command(force, h) } +
236
+ force.bindings.flat_map { |b| binding_command(force, b) }
237
+ end
238
+
239
+ def helper_command(force, helper)
240
+ build(:addLinkedHelper, placeholderId: helper.placeholder, forceId: force.ref, name: helper.name,
241
+ filePath: helper.file_path, script: helper.script, contentB64: helper.content_b64)
242
+ end
243
+
244
+ def binding_command(force, binding)
245
+ return [] if binding.removed?
246
+
247
+ if binding.new?
248
+ [build(:addBinding, placeholderId: binding.placeholder, forceId: force.ref, name: binding.name,
249
+ referenceId: binding.reference_id, type: binding.type)]
250
+ else
251
+ changed = binding.changed_attributes
252
+ return [] if changed.empty?
253
+
254
+ [build(:editBinding, id: binding.id, name: changed[:name], referenceId: changed[:reference_id])]
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,68 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One data field. `field_type` is immutable after creation on purpose: the platform
7
+ # has no `editFieldType` command (confirmed,
8
+ # the repo's internal docs section
9
+ # C.1) — giving this class no setter for it makes a destructive remove+add
10
+ # structurally unreachable rather than a rule `#as_commands` has to remember to
11
+ # enforce.
12
+ class Field
13
+ include Node
14
+
15
+ PLACEHOLDER_PREFIX = 'fld'.freeze
16
+
17
+ attr_reader :template, :section, :stage, :field_type, :column, :options
18
+ attr_accessor :label, :tooltip, :description, :required, :hidden
19
+
20
+ def initialize(template:, section:, stage:, field_type:, id: nil, label: nil, column: nil,
21
+ tooltip: nil, description: nil, required: nil, hidden: nil, options: [])
22
+ @template = template
23
+ @section = section
24
+ @stage = stage
25
+ @id = id
26
+ @field_type = field_type
27
+ @column = column
28
+ @label = label
29
+ @tooltip = tooltip
30
+ @description = description
31
+ @required = required
32
+ @hidden = hidden
33
+ @options = options
34
+ @snapshot = snapshot_attrs.freeze
35
+ end
36
+
37
+ # Appends a new (in-memory) option. Only meaningful for a Select-shaped field, but
38
+ # this class does not enforce `field_type` — the server does, on `addSelectFieldOption`.
39
+ def add_option(value:, label: nil, weight: nil)
40
+ opt = Option.new(template: template, field: self, label: label, value: value, weight: weight)
41
+ options << opt
42
+ opt
43
+ end
44
+
45
+ def dirty?
46
+ !removed? && !new? && snapshot_attrs != @snapshot
47
+ end
48
+
49
+ # The subset of `changed_attributes` that maps onto `editFieldConfiguration`'s
50
+ # top-level keys (label/tooltip/description/required/hidden) — empty when nothing
51
+ # changed, so callers can `commands << ... unless field_config_changes.empty?`.
52
+ def changed_attributes
53
+ return {} if new? || removed?
54
+
55
+ snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
56
+ end
57
+
58
+ private
59
+
60
+ def snapshot_attrs
61
+ { label: label, tooltip: tooltip, description: description, required: required, hidden: hidden }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,65 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # One force, owned by a Stage (`18_template_editor_save_path.md` section A.1:
7
+ # `TemplateStageFields.forces`). Editing an existing force is limited to
8
+ # name/custom_script/url/content_b64 (`editForce`'s own argument list,
9
+ # `input/workflow_command/edit_force.rb`) — `weight`/`lastSyncedAt` are
10
+ # server-managed and not exposed as writable attributes here.
11
+ class Force
12
+ include Node
13
+
14
+ PLACEHOLDER_PREFIX = 'frc'.freeze
15
+
16
+ attr_reader :template, :stage, :bindings, :helpers
17
+ attr_accessor :name, :custom_script, :url, :content_b64
18
+
19
+ def initialize(template:, stage:, id: nil, name: nil, custom_script: nil, url: nil, content_b64: nil)
20
+ @template = template
21
+ @stage = stage
22
+ @id = id
23
+ @name = name
24
+ @custom_script = custom_script
25
+ @url = url
26
+ @content_b64 = content_b64 # rubocop:disable Naming/VariableNumber
27
+ @bindings = []
28
+ @helpers = []
29
+ @snapshot = snapshot_attrs.freeze
30
+ end
31
+
32
+ def add_binding(reference_id:, name: nil, type: Binding::BINDING_TYPE)
33
+ binding = Binding.new(template: template, force: self, name: name, reference_id: reference_id, type: type)
34
+ bindings << binding
35
+ binding
36
+ end
37
+
38
+ def add_helper(name: nil, file_path: nil, script: nil, content_b64: nil)
39
+ helper = Helper.new(template: template, force: self, name: name, file_path: file_path,
40
+ script: script, content_b64: content_b64)
41
+ helpers << helper
42
+ helper
43
+ end
44
+
45
+ def dirty?
46
+ !removed? && !new? && snapshot_attrs != @snapshot
47
+ end
48
+
49
+ def changed_attributes
50
+ return {} if new? || removed?
51
+
52
+ snapshot_attrs.each_with_object({}) { |(k, v), h| h[k] = v if v != @snapshot[k] }
53
+ end
54
+
55
+ private
56
+
57
+ def snapshot_attrs
58
+ { name: name, custom_script: custom_script, url: url, content_b64: content_b64 }
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # A force's linked helper (`addLinkedHelper`). ADD-only in this design — no live
7
+ # call site or schema citation for `editLinkedHelper`'s read-side shape was available
8
+ # to this session (see `Read.call`'s header for the read-side field-name caveat), so
9
+ # editing an existing helper is left unimplemented rather than guessed.
10
+ class Helper
11
+ include Node
12
+
13
+ PLACEHOLDER_PREFIX = 'hlp'.freeze
14
+
15
+ attr_reader :template, :force
16
+ attr_accessor :name, :file_path, :script, :content_b64
17
+
18
+ def initialize(template:, force:, id: nil, name: nil, file_path: nil, script: nil, content_b64: nil)
19
+ @template = template
20
+ @force = force
21
+ @id = id
22
+ @name = name
23
+ @file_path = file_path
24
+ @script = script
25
+ @content_b64 = content_b64 # rubocop:disable Naming/VariableNumber
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,202 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Model
5
+ module Template
6
+ # The mutable, in-memory template: `Model::Template.load(client, id:)` returns one of
7
+ # these. Owns the placeholder counters every `Node` mints from (see `Node#placeholder`
8
+ # — keeping them on the Instance rather than globally means two `Instance`s loaded in
9
+ # the same process never share a counter, so specs get deterministic `ph_sec_1`-style
10
+ # ids per fixture without any manual reset).
11
+ #
12
+ # THE THREE LINES (see the internal design doc,
13
+ # section 2.4):
14
+ #
15
+ # template = Ecoportal::API::GraphQL::Model::Template.load(client, id: page_id)
16
+ # template.fields.each { |f| f.label = "#{f.label} (reviewed)" }
17
+ # template.save!(client)
18
+ #
19
+ # `#save!` defaults to `simulate: true` (see its own doc) — nothing is sent to the
20
+ # server until the caller explicitly asks for `simulate: false`.
21
+ class Instance
22
+ attr_reader :id, :patch_ver, :stages, :phased
23
+
24
+ # @param phased [Boolean] true for a `PhasedPage` (the server's own type name),
25
+ # false for a `BasicPage`/build-from-nothing. Drives whether field-level EDIT
26
+ # commands need a `stageId` disambiguator (see `CommandSynthesis#stage_id_kwarg`)
27
+ # -- a `Field` always carries a `#stage` reference either way (workflow-first
28
+ # reads populate it for BOTH page kinds, per `Read`'s own header), so `#phased`,
29
+ # not "does the field know its stage", is the real gate the schema cares about.
30
+ def initialize(id:, patch_ver:, stages: [], phased: false)
31
+ @id = id
32
+ @patch_ver = patch_ver
33
+ @stages = stages
34
+ @phased = phased
35
+ @counters = Hash.new(0)
36
+ end
37
+
38
+ alias_method :phased?, :phased
39
+
40
+ def next_placeholder(prefix)
41
+ @counters[prefix] += 1
42
+ "ph_#{prefix}_#{@counters[prefix]}"
43
+ end
44
+
45
+ class << self
46
+ # @param client [Ecoportal::API::GraphQL]
47
+ # @param id [String]
48
+ def load(client, id:)
49
+ page = Read.call(client, id)
50
+ from_doc(page)
51
+ end
52
+
53
+ # Builds an Instance from an already-fetched `page` doc (same shape `Read.call`
54
+ # returns) — used by both `.load` and `StagedExecutor`'s between-phase re-reads,
55
+ # so both paths build the SAME tree shape from the SAME doc reader.
56
+ def from_doc(page)
57
+ instance = new(id: page['id'], patch_ver: page['patchVer'], phased: page['__typename'] == 'PhasedPage')
58
+ Read.stages(page).each { |stage_doc| instance.stages << build_stage(instance, stage_doc) }
59
+ instance
60
+ end
61
+
62
+ private
63
+
64
+ def build_stage(instance, stage_doc)
65
+ stage = Stage.new(template: instance, id: stage_doc['id'], name: stage_doc['name'],
66
+ ordering: stage_doc['ordering'])
67
+ Array(stage_doc['sections']).each { |sec_doc| stage.sections << build_section(instance, stage, sec_doc) }
68
+ Array(stage_doc['forces']).each { |force_doc| stage.forces << build_force(instance, stage, force_doc) }
69
+ stage
70
+ end
71
+
72
+ def build_section(instance, stage, doc)
73
+ split = doc['__typename'] == 'SplitSection'
74
+ section = Section.new(template: instance, stage: stage, id: doc['id'],
75
+ kind: (split ? :split : :content), heading: doc['heading'],
76
+ left_heading: doc['leftHeading'], right_heading: doc['rightHeading'])
77
+ if split
78
+ Array(doc['leftDataFields']).each { |f| section.left_fields << build_field(instance, section, stage, f) }
79
+ Array(doc['rightDataFields']).each { |f| section.right_fields << build_field(instance, section, stage, f) }
80
+ else
81
+ Array(doc['dataFields']).each { |f| section.left_fields << build_field(instance, section, stage, f) }
82
+ end
83
+ section
84
+ end
85
+
86
+ def build_field(instance, section, stage, doc)
87
+ field = Field.new(template: instance, section: section, stage: stage, id: doc['id'],
88
+ field_type: doc['__typename'], label: doc['label'], tooltip: doc['tooltip'],
89
+ description: doc['description'], required: doc['required'], hidden: doc['hidden'])
90
+ Array(doc['options']).each do |opt_doc|
91
+ field.options << Option.new(template: instance, field: field, id: opt_doc['id'],
92
+ label: opt_doc['name'], value: opt_doc['value'])
93
+ end
94
+ field
95
+ end
96
+
97
+ def build_force(instance, stage, doc)
98
+ force = Force.new(template: instance, stage: stage, id: doc['id'], name: doc['name'],
99
+ custom_script: doc['customScript'], url: doc['url'], content_b64: doc['contentB64'])
100
+ Array(doc['bindings']).each do |b|
101
+ force.bindings << Binding.new(template: instance, force: force, name: b['name'], reference_id: b['referenceId'])
102
+ end
103
+ Array(doc['helpers']).each do |h|
104
+ force.helpers << Helper.new(template: instance, force: force, id: h['id'], name: h['name'],
105
+ file_path: h['filePath'], script: h['script'], content_b64: h['contentB64'])
106
+ end
107
+ force
108
+ end
109
+ end
110
+
111
+ # Every (non-removed) field across every stage, document order.
112
+ def fields
113
+ stages.flat_map(&:fields)
114
+ end
115
+
116
+ def sections
117
+ stages.flat_map(&:sections)
118
+ end
119
+
120
+ def forces
121
+ stages.flat_map(&:forces)
122
+ end
123
+
124
+ # The public diff engine — see `CommandSynthesis`'s own doc for ordering/safety.
125
+ def as_commands
126
+ CommandSynthesis.new(self).commands
127
+ end
128
+
129
+ def dirty?
130
+ !as_commands.empty?
131
+ end
132
+
133
+ # @param client [Ecoportal::API::GraphQL, nil] required unless `simulate: true`.
134
+ # @param simulate [Boolean] default true — returns the staged PLAN (stages, chunks,
135
+ # command counts, placeholders) WITHOUT calling the server at all. Pass
136
+ # `simulate: false` to actually execute it.
137
+ # @param chunk_size [Integer]
138
+ # @return [StagedExecutor::Plan, StagedExecutor::Result]
139
+ def save!(client = nil, simulate: true, chunk_size: StagedExecutor::DEFAULT_CHUNK_SIZE)
140
+ executor = StagedExecutor.new(self, client: client, chunk_size: chunk_size)
141
+ simulate ? executor.plan : executor.execute!
142
+ end
143
+
144
+ # Re-reads the template fresh and reports, per node this Instance currently holds
145
+ # (and is not flagged removed), whether the live server agrees with THIS model's
146
+ # own current in-memory state. Call after a `save!(simulate: false)` — compares
147
+ # against the model's post-save state, not the pre-save snapshot, so a call with
148
+ # zero pending changes never reports a false "verified" for something it never
149
+ # actually checked (every tracked node is still compared, `changed or not`).
150
+ #
151
+ # @param client [Ecoportal::API::GraphQL]
152
+ # @return [Hash] { verified: [...], mismatch: [...], missing: [...] } — each entry
153
+ # `{ kind:, id:, attribute:, expected:, actual: }`. `missing` covers both "no
154
+ # node with this id exists live" and "this node never resolved a real id at all"
155
+ # (a save that silently no-oped).
156
+ def verify(client)
157
+ live = Instance.load(client, id: id)
158
+ report = { verified: [], mismatch: [], missing: [] }
159
+ verify_fields(live, report)
160
+ verify_options(live, report)
161
+ verify_sections(live, report)
162
+ report
163
+ end
164
+
165
+ private
166
+
167
+ def verify_fields(live, report)
168
+ live_by_id = live.fields.to_h { |f| [f.id, f] }
169
+ fields.reject(&:removed?).each { |field| compare_node(field, live_by_id[field.id], :field, %i[label tooltip description required hidden], report) }
170
+ end
171
+
172
+ def verify_options(live, report)
173
+ live_by_id = live.fields.flat_map(&:options).to_h { |o| [o.id, o] }
174
+ fields.reject(&:removed?).flat_map(&:options).reject(&:removed?).each do |opt|
175
+ compare_node(opt, live_by_id[opt.id], :option, %i[label value weight], report)
176
+ end
177
+ end
178
+
179
+ def verify_sections(live, report)
180
+ live_by_id = live.sections.to_h { |s| [s.id, s] }
181
+ sections.reject(&:removed?).each { |section| compare_node(section, live_by_id[section.id], :section, %i[heading left_heading right_heading], report) }
182
+ end
183
+
184
+ def compare_node(node, live_node, kind, attrs, report)
185
+ if node.id.nil? || live_node.nil?
186
+ report[:missing] << { kind: kind, id: node.id, attribute: nil, expected: nil, actual: nil }
187
+ return
188
+ end
189
+
190
+ attrs.each do |attr|
191
+ expected = node.public_send(attr)
192
+ actual = live_node.public_send(attr)
193
+ entry = { kind: kind, id: node.id, attribute: attr, expected: expected, actual: actual }
194
+ (expected == actual ? report[:verified] : report[:mismatch]) << entry
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end