mxrb 0.1.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 (237) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.de-DE.md +108 -0
  4. data/README.md +172 -0
  5. data/README.pt-BR.md +109 -0
  6. data/bin/mxrb +1613 -0
  7. data/docs/README.md +10 -0
  8. data/docs/de-DE/README.md +24 -0
  9. data/docs/de-DE/architectural-patterns.md +26 -0
  10. data/docs/de-DE/architectural-standard.md +48 -0
  11. data/docs/de-DE/architecture.md +76 -0
  12. data/docs/de-DE/compiler.md +162 -0
  13. data/docs/de-DE/conventions.md +19 -0
  14. data/docs/de-DE/design-system.md +52 -0
  15. data/docs/de-DE/entity-dsl.md +53 -0
  16. data/docs/de-DE/native-runtime-quality-report.md +74 -0
  17. data/docs/de-DE/oql-sql.md +211 -0
  18. data/docs/de-DE/platform-api-integration.md +36 -0
  19. data/docs/de-DE/platform-operations.md +243 -0
  20. data/docs/de-DE/project-structure.md +32 -0
  21. data/docs/de-DE/ruby-first-roadmap.md +116 -0
  22. data/docs/de-DE/scaffolds.md +59 -0
  23. data/docs/de-DE/semantic-refactoring.md +56 -0
  24. data/docs/de-DE/team-server.md +33 -0
  25. data/docs/de-DE/validation-matrix.md +64 -0
  26. data/docs/de-DE/vetclinic-acceptance.md +24 -0
  27. data/docs/de-DE/writing.md +138 -0
  28. data/docs/en-US/README.md +24 -0
  29. data/docs/en-US/architectural-patterns.md +151 -0
  30. data/docs/en-US/architectural-standard.md +89 -0
  31. data/docs/en-US/architecture.md +170 -0
  32. data/docs/en-US/compiler.md +157 -0
  33. data/docs/en-US/conventions.md +82 -0
  34. data/docs/en-US/design-system.md +163 -0
  35. data/docs/en-US/entity-dsl.md +53 -0
  36. data/docs/en-US/native-runtime-quality-report.md +73 -0
  37. data/docs/en-US/oql-sql.md +201 -0
  38. data/docs/en-US/platform-api-integration.md +36 -0
  39. data/docs/en-US/platform-operations.md +244 -0
  40. data/docs/en-US/project-structure.md +49 -0
  41. data/docs/en-US/ruby-first-roadmap.md +186 -0
  42. data/docs/en-US/scaffolds.md +57 -0
  43. data/docs/en-US/semantic-refactoring.md +198 -0
  44. data/docs/en-US/team-server.md +32 -0
  45. data/docs/en-US/validation-matrix.md +257 -0
  46. data/docs/en-US/vetclinic-acceptance.md +23 -0
  47. data/docs/en-US/writing.md +655 -0
  48. data/docs/pt-BR/README.md +24 -0
  49. data/docs/pt-BR/architectural-patterns.md +25 -0
  50. data/docs/pt-BR/architectural-standard.md +533 -0
  51. data/docs/pt-BR/architecture.md +300 -0
  52. data/docs/pt-BR/compiler.md +162 -0
  53. data/docs/pt-BR/conventions.md +19 -0
  54. data/docs/pt-BR/design-system.md +65 -0
  55. data/docs/pt-BR/entity-dsl.md +116 -0
  56. data/docs/pt-BR/native-runtime-quality-report.md +73 -0
  57. data/docs/pt-BR/oql-sql.md +208 -0
  58. data/docs/pt-BR/platform-api-integration.md +36 -0
  59. data/docs/pt-BR/platform-operations.md +260 -0
  60. data/docs/pt-BR/project-structure.md +32 -0
  61. data/docs/pt-BR/ruby-first-roadmap.md +239 -0
  62. data/docs/pt-BR/scaffolds.md +104 -0
  63. data/docs/pt-BR/semantic-refactoring.md +55 -0
  64. data/docs/pt-BR/team-server.md +59 -0
  65. data/docs/pt-BR/validation-matrix.md +86 -0
  66. data/docs/pt-BR/vetclinic-acceptance.md +50 -0
  67. data/docs/pt-BR/writing.md +177 -0
  68. data/examples/sudoku_evaluation.rb +14 -0
  69. data/examples/sudoku_functional_test.rb +28 -0
  70. data/lib/mxrb/architecture/graph.rb +124 -0
  71. data/lib/mxrb/architecture/validator.rb +98 -0
  72. data/lib/mxrb/benchmark.rb +33 -0
  73. data/lib/mxrb/compare.rb +390 -0
  74. data/lib/mxrb/compiler/adapter.rb +96 -0
  75. data/lib/mxrb/compiler/artifact_document_compiler.rb +102 -0
  76. data/lib/mxrb/compiler/artifact_materializer.rb +53 -0
  77. data/lib/mxrb/compiler/client_model_materializer.rb +42 -0
  78. data/lib/mxrb/compiler/code_action_document_compiler.rb +64 -0
  79. data/lib/mxrb/compiler/code_action_materializer.rb +35 -0
  80. data/lib/mxrb/compiler/code_action_type_compiler.rb +32 -0
  81. data/lib/mxrb/compiler/combo_box_bundle_compiler.rb +314 -0
  82. data/lib/mxrb/compiler/compatibility_analyzer.rb +169 -0
  83. data/lib/mxrb/compiler/constants_materializer.rb +111 -0
  84. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +290 -0
  85. data/lib/mxrb/compiler/database_connector_action_compiler.rb +280 -0
  86. data/lib/mxrb/compiler/deployment_asset_copier.rb +60 -0
  87. data/lib/mxrb/compiler/deployment_bootstrapper.rb +208 -0
  88. data/lib/mxrb/compiler/deployment_materializer.rb +43 -0
  89. data/lib/mxrb/compiler/deployment_metadata.rb +64 -0
  90. data/lib/mxrb/compiler/domain_document_compiler.rb +153 -0
  91. data/lib/mxrb/compiler/domain_model_materializer.rb +35 -0
  92. data/lib/mxrb/compiler/domain_security_compiler.rb +82 -0
  93. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +190 -0
  94. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +281 -0
  95. data/lib/mxrb/compiler/image_bundle_compiler.rb +216 -0
  96. data/lib/mxrb/compiler/java_proxy_generator.rb +509 -0
  97. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +267 -0
  98. data/lib/mxrb/compiler/legacy_page_builder.rb +287 -0
  99. data/lib/mxrb/compiler/mda.rb +99 -0
  100. data/lib/mxrb/compiler/microflow_document_compiler.rb +85 -0
  101. data/lib/mxrb/compiler/microflow_materializer.rb +48 -0
  102. data/lib/mxrb/compiler/microflow_node_compiler.rb +250 -0
  103. data/lib/mxrb/compiler/model_package.rb +116 -0
  104. data/lib/mxrb/compiler/model_values.rb +67 -0
  105. data/lib/mxrb/compiler/nanoflow_program_compiler.rb +483 -0
  106. data/lib/mxrb/compiler/navigation_document_compiler.rb +93 -0
  107. data/lib/mxrb/compiler/packager.rb +100 -0
  108. data/lib/mxrb/compiler/page_bundle_builder.rb +56 -0
  109. data/lib/mxrb/compiler/page_bundle_compiler.rb +2004 -0
  110. data/lib/mxrb/compiler/page_document_compiler.rb +77 -0
  111. data/lib/mxrb/compiler/portable_packager.rb +356 -0
  112. data/lib/mxrb/compiler/project_jar_archive.rb +84 -0
  113. data/lib/mxrb/compiler/project_jar_builder.rb +121 -0
  114. data/lib/mxrb/compiler/project_materializer.rb +139 -0
  115. data/lib/mxrb/compiler/project_model_orderer.rb +52 -0
  116. data/lib/mxrb/compiler/runtime_data_types.rb +31 -0
  117. data/lib/mxrb/compiler/runtime_model_schema.rb +90 -0
  118. data/lib/mxrb/compiler/schemas/runtime-10.24.0.73019.json +1052 -0
  119. data/lib/mxrb/compiler/schemas/runtime-11.json +1215 -0
  120. data/lib/mxrb/compiler/schemas/runtime-6.10.8.json +1148 -0
  121. data/lib/mxrb/compiler/schemas/runtime-7.17.0.json +1144 -0
  122. data/lib/mxrb/compiler/schemas/runtime-7.5.0.json +1215 -0
  123. data/lib/mxrb/compiler/schemas/runtime-9.6.1.29396.json +868 -0
  124. data/lib/mxrb/compiler/schemas/system-model-10.24.0.73019.b64 +1135 -0
  125. data/lib/mxrb/compiler/schemas/system-model-11.12.1.b64 +815 -0
  126. data/lib/mxrb/compiler/schemas/system-model-6.10.8.b64 +497 -0
  127. data/lib/mxrb/compiler/schemas/system-model-7.17.0.b64 +519 -0
  128. data/lib/mxrb/compiler/schemas/system-model-7.5.0.b64 +504 -0
  129. data/lib/mxrb/compiler/schemas/system-model-9.6.1.29396.b64 +816 -0
  130. data/lib/mxrb/compiler/security_materializer.rb +104 -0
  131. data/lib/mxrb/compiler/settings_document_compiler.rb +45 -0
  132. data/lib/mxrb/compiler/settings_materializer.rb +25 -0
  133. data/lib/mxrb/compiler/source_model.rb +128 -0
  134. data/lib/mxrb/compiler/system_model_seed.rb +88 -0
  135. data/lib/mxrb/compiler/system_queue_materializer.rb +54 -0
  136. data/lib/mxrb/compiler/system_text_materializer.rb +49 -0
  137. data/lib/mxrb/compiler/translation_materializer.rb +95 -0
  138. data/lib/mxrb/compiler/web_bundle_builder.rb +155 -0
  139. data/lib/mxrb/compiler/web_list_data_source.rb +139 -0
  140. data/lib/mxrb/compiler/web_operation_compiler.rb +614 -0
  141. data/lib/mxrb/compiler/web_shell_materializer.rb +194 -0
  142. data/lib/mxrb/compiler/widget_package_extractor.rb +63 -0
  143. data/lib/mxrb/doctor.rb +116 -0
  144. data/lib/mxrb/dsl/builder.rb +1636 -0
  145. data/lib/mxrb/errors.rb +18 -0
  146. data/lib/mxrb/evaluation.rb +131 -0
  147. data/lib/mxrb/exporter.rb +1716 -0
  148. data/lib/mxrb/frontend/migrator.rb +775 -0
  149. data/lib/mxrb/functional.rb +307 -0
  150. data/lib/mxrb/github/annotator.rb +161 -0
  151. data/lib/mxrb/initializer.rb +257 -0
  152. data/lib/mxrb/integrity/validator.rb +136 -0
  153. data/lib/mxrb/io/bson_codec.rb +145 -0
  154. data/lib/mxrb/io/mpr_file.rb +558 -0
  155. data/lib/mxrb/io/mxunit_codec.rb +42 -0
  156. data/lib/mxrb/marketplace.rb +380 -0
  157. data/lib/mxrb/model/association.rb +98 -0
  158. data/lib/mxrb/model/attribute.rb +100 -0
  159. data/lib/mxrb/model/connector.rb +17 -0
  160. data/lib/mxrb/model/design_materializer.rb +133 -0
  161. data/lib/mxrb/model/design_migration.rb +126 -0
  162. data/lib/mxrb/model/design_system.rb +111 -0
  163. data/lib/mxrb/model/domain_model.rb +51 -0
  164. data/lib/mxrb/model/entity.rb +180 -0
  165. data/lib/mxrb/model/menu.rb +38 -0
  166. data/lib/mxrb/model/microflow.rb +100 -0
  167. data/lib/mxrb/model/module.rb +142 -0
  168. data/lib/mxrb/model/navigation.rb +127 -0
  169. data/lib/mxrb/model/page.rb +410 -0
  170. data/lib/mxrb/model/project.rb +187 -0
  171. data/lib/mxrb/model/unit.rb +48 -0
  172. data/lib/mxrb/module_initializer.rb +75 -0
  173. data/lib/mxrb/official_marketplace/content_api.rb +292 -0
  174. data/lib/mxrb/official_marketplace/dependency_resolver.rb +329 -0
  175. data/lib/mxrb/official_marketplace/lifecycle.rb +367 -0
  176. data/lib/mxrb/official_marketplace/module_package_importer.rb +318 -0
  177. data/lib/mxrb/official_marketplace/widget_package_installer.rb +414 -0
  178. data/lib/mxrb/official_marketplace.rb +720 -0
  179. data/lib/mxrb/oql/analyzer.rb +155 -0
  180. data/lib/mxrb/oql/index_advisor.rb +85 -0
  181. data/lib/mxrb/oql/plan_analyzer.rb +185 -0
  182. data/lib/mxrb/oql/server.rb +132 -0
  183. data/lib/mxrb/oql/sql_server_plan_analyzer.rb +190 -0
  184. data/lib/mxrb/oql/sql_server_workload_analyzer.rb +80 -0
  185. data/lib/mxrb/oql/workload_analyzer.rb +175 -0
  186. data/lib/mxrb/oql/workload_baseline.rb +60 -0
  187. data/lib/mxrb/oql.rb +424 -0
  188. data/lib/mxrb/project_lifecycle.rb +76 -0
  189. data/lib/mxrb/protocols/plan.rb +86 -0
  190. data/lib/mxrb/protocols.rb +123 -0
  191. data/lib/mxrb/runtime/database_workspace.rb +677 -0
  192. data/lib/mxrb/runtime/docker_executor.rb +41 -0
  193. data/lib/mxrb/runtime/docker_workspace.rb +29 -0
  194. data/lib/mxrb/runtime/executor.rb +169 -0
  195. data/lib/mxrb/runtime/java_locator.rb +26 -0
  196. data/lib/mxrb/runtime/native.rb +474 -0
  197. data/lib/mxrb/runtime/sql_server_database.rb +146 -0
  198. data/lib/mxrb/runtime/toolchain.rb +82 -0
  199. data/lib/mxrb/scaffold/cli.rb +113 -0
  200. data/lib/mxrb/scaffold/generator.rb +246 -0
  201. data/lib/mxrb/scaffold/help.rb +80 -0
  202. data/lib/mxrb/scaffold/page_templates.rb +54 -0
  203. data/lib/mxrb/scaffold/recipes.rb +198 -0
  204. data/lib/mxrb/scaffold/registry.rb +82 -0
  205. data/lib/mxrb/scaffold/templates.rb +723 -0
  206. data/lib/mxrb/scaffold/transaction.rb +84 -0
  207. data/lib/mxrb/schema/tables.rb +55 -0
  208. data/lib/mxrb/semantic/analyzer.rb +473 -0
  209. data/lib/mxrb/semantic/batch_plan.rb +68 -0
  210. data/lib/mxrb/semantic/domain_mutator.rb +419 -0
  211. data/lib/mxrb/semantic/embedder.rb +51 -0
  212. data/lib/mxrb/semantic/extractor.rb +290 -0
  213. data/lib/mxrb/semantic/index.rb +623 -0
  214. data/lib/mxrb/semantic/inliner.rb +216 -0
  215. data/lib/mxrb/semantic/mover.rb +153 -0
  216. data/lib/mxrb/semantic/onnx_embedder.rb +34 -0
  217. data/lib/mxrb/semantic/remover.rb +70 -0
  218. data/lib/mxrb/semantic/renamer.rb +211 -0
  219. data/lib/mxrb/semantic/tfidf_embedder.rb +46 -0
  220. data/lib/mxrb/semantic/vec_store.rb +75 -0
  221. data/lib/mxrb/team_server.rb +390 -0
  222. data/lib/mxrb/templates/project/10.24.0.73019.json +32 -0
  223. data/lib/mxrb/templates/project/11.12.1.json +32 -0
  224. data/lib/mxrb/templates/project/6.10.8.json +50 -0
  225. data/lib/mxrb/templates/project/7.17.0.json +50 -0
  226. data/lib/mxrb/templates/project/7.5.0.json +50 -0
  227. data/lib/mxrb/templates/project/9.6.1.29396.json +50 -0
  228. data/lib/mxrb/version.rb +5 -0
  229. data/lib/mxrb/widget_package.rb +338 -0
  230. data/lib/mxrb/widget_synchronizer.rb +31 -0
  231. data/lib/mxrb/writer.rb +3775 -0
  232. data/lib/mxrb.rb +188 -0
  233. data/marketplace/catalog.json +10 -0
  234. data/marketplace/modules/shared-kernel/domain/README.md +4 -0
  235. data/marketplace/modules/shared-kernel/module.rb +5 -0
  236. data/marketplace/modules/shared-kernel/mxrb-module.json +9 -0
  237. metadata +447 -0
@@ -0,0 +1,775 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module Mxrb
7
+ module Frontend
8
+ MigrationIssue = Data.define(:unit_id, :path, :kind, :message)
9
+ MigrationChange = Data.define(
10
+ :unit_id, :before_hash, :before, :after, :widgets, :layout_rows, :design_properties
11
+ )
12
+
13
+ # Immutable, fail-closed preview of frontend model migrations.
14
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
15
+ class MigrationPlan
16
+ attr_reader :path, :version, :changes, :issues
17
+
18
+ def initialize(path:, version:, changes:, issues:)
19
+ @path = path
20
+ @version = version
21
+ @changes = changes.freeze
22
+ @issues = issues.freeze
23
+ @applied = false
24
+ end
25
+
26
+ def safe? = issues.empty?
27
+ def applied? = @applied
28
+ def widgets = changes.sum(&:widgets)
29
+ def layout_rows = changes.sum(&:layout_rows)
30
+ def design_properties = changes.sum(&:design_properties)
31
+
32
+ def apply!
33
+ raise SerializationError, blocked_message unless safe?
34
+ raise SerializationError, 'frontend migration plan was already applied' if applied?
35
+
36
+ mpr = IO::MprFile.open(path)
37
+ mpr.transaction do
38
+ changes.each do |change|
39
+ current = mpr.unit(change.unit_id)
40
+ raise SerializationError, "frontend unit disappeared: #{change.unit_id}" unless current
41
+ raise SerializationError, "frontend unit changed after preview: #{change.unit_id}" unless
42
+ current['ContentsHash'] == change.before_hash
43
+
44
+ mpr.update_unit(change.unit_id, change.after)
45
+ end
46
+ end
47
+ @applied = true
48
+ self
49
+ ensure
50
+ mpr&.close
51
+ end
52
+
53
+ private
54
+
55
+ def blocked_message
56
+ details = issues.map { "#{_1.kind} at #{_1.unit_id}#{_1.path}: #{_1.message}" }
57
+ "frontend migration is blocked: #{details.join('; ')}"
58
+ end
59
+ end
60
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
61
+
62
+ # Updates installed pluggable-widget schemas and legacy layout-grid weights
63
+ # directly in an MPR. Package XML is the source of truth; no Studio Pro or
64
+ # mx invocation is involved.
65
+ # rubocop:disable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength
66
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
67
+ # rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity
68
+ # rubocop:disable Lint/NonLocalExitFromIterator
69
+ class Migrator
70
+ SUPPORTED_MAJORS = [10, 11].freeze
71
+ STANDARD_TYPE_KEYS = %w[
72
+ $ID $Type HelpUrl ObjectType OfflineCapable StudioCategory StudioProCategory
73
+ SupportedPlatform WidgetDescription WidgetId WidgetName WidgetNeedsEntityContext
74
+ WidgetPluginWidget
75
+ ].freeze
76
+ STANDARD_OBJECT_KEYS = %w[$ID $Type Properties TypePointer].freeze
77
+ # TabletWeight/PhoneWeight use -1 as a valid responsive inheritance
78
+ # sentinel. CE0535 concerns only the desktop Weight total.
79
+ WEIGHT_KEYS = %w[Weight].freeze
80
+ AUDITED_DATA_GRID_PACKAGE_SHA256 =
81
+ '8eabd99ef927b918dbb2bd5f05cf12dc14026b37e496bc1b0569c34369a0b713'
82
+ ACTIVE_VALUE_FIELDS = {
83
+ 'Action' => %w[Action Microflow Nanoflow],
84
+ 'Association' => %w[EntityRef SourceVariable],
85
+ 'Attribute' => %w[AttributeRef SourceVariable],
86
+ 'DataSource' => %w[DataSource XPathConstraint],
87
+ 'Expression' => %w[Expression SourceVariable], 'Icon' => %w[Icon],
88
+ 'Image' => %w[Image], 'Object' => %w[Objects], 'Selection' => %w[Selection],
89
+ 'TextTemplate' => %w[TextTemplate TranslatableValue], 'Widgets' => %w[Widgets]
90
+ }.freeze
91
+
92
+ def self.plan(path) = new(path).plan
93
+
94
+ def initialize(path)
95
+ @path = File.expand_path(path)
96
+ @root = File.dirname(@path)
97
+ @definitions = {}
98
+ end
99
+
100
+ def plan
101
+ mpr = IO::MprFile.open(@path, readonly: true)
102
+ version = mpr.mendix_version.to_s
103
+ issues = []
104
+ unless SUPPORTED_MAJORS.include?(version.to_i)
105
+ issues << MigrationIssue.new('', '', :unsupported_version,
106
+ "Mendix #{version.inspect}; supported majors are 10 and 11")
107
+ return MigrationPlan.new(path: @path, version:, changes: [], issues:)
108
+ end
109
+ validate_widget_packages!(issues)
110
+
111
+ changes = mpr.all_units.filter_map do |unit|
112
+ before = mpr.parse_contents(unit)
113
+ after = deep_copy(before)
114
+ counts = { widgets: 0, layout_rows: 0, design_properties: 0 }
115
+ migrate_value!(after, unit.fetch('UnitID'), '$', issues, counts)
116
+ next if after == before
117
+
118
+ MigrationChange.new(
119
+ unit.fetch('UnitID'), unit.fetch('ContentsHash'), before, after,
120
+ counts.fetch(:widgets), counts.fetch(:layout_rows), counts.fetch(:design_properties)
121
+ )
122
+ end
123
+ MigrationPlan.new(path: @path, version:, changes:, issues:)
124
+ ensure
125
+ mpr&.close
126
+ end
127
+
128
+ private
129
+
130
+ def migrate_value!(value, unit_id, path, issues, counts)
131
+ case value
132
+ when Hash
133
+ migrate_design_properties!(value, unit_id, path, issues, counts) if value.key?('DesignProperties')
134
+ migrate_layout_row!(value, unit_id, path, issues, counts) if layout_row?(value)
135
+ migrate_widget!(value, unit_id, path, issues, counts) if custom_widget?(value)
136
+ value.each { |key, child| migrate_value!(child, unit_id, "#{path}.#{key}", issues, counts) }
137
+ when Array
138
+ value.each_with_index do |child, index|
139
+ migrate_value!(child, unit_id, "#{path}[#{index}]", issues, counts)
140
+ end
141
+ end
142
+ end
143
+
144
+ def migrate_widget!(widget, unit_id, path, issues, counts)
145
+ widget_id = widget.dig('Type', 'WidgetId').to_s
146
+ return if widget_id.empty?
147
+
148
+ definition = definition(widget_id)
149
+ unless definition
150
+ kind, message = definition_failure(widget_id)
151
+ return issue(issues, unit_id, path, kind, message)
152
+ end
153
+
154
+ new_type, new_object = WidgetPackage.template(definition)
155
+ return unless safe_envelope?(widget, new_type, new_object, unit_id, path, issues)
156
+
157
+ migrated = rebind_object(
158
+ widget['Object'], widget.dig('Type', 'ObjectType'),
159
+ new_object, new_type['ObjectType'], unit_id, path, issues
160
+ )
161
+ return unless migrated
162
+
163
+ apply_audited_package_migration!(migrated, new_type, package_digest(widget_id))
164
+ schema_changed = canonical_schema(widget['Type']) != canonical_schema(new_type)
165
+ object_changed = canonical_schema(widget['Object']) != canonical_schema(migrated)
166
+ return unless schema_changed || object_changed
167
+
168
+ widget['Type'] = new_type
169
+ widget['Object'] = migrated
170
+ counts[:widgets] += 1
171
+ end
172
+
173
+ def safe_envelope?(widget, new_type, new_object, unit_id, path, issues)
174
+ old_type = widget['Type']
175
+ old_object = widget['Object']
176
+ return issue(issues, unit_id, path, :malformed_widget, 'missing Type or Object') unless
177
+ old_type.is_a?(Hash) && old_object.is_a?(Hash)
178
+
179
+ extra_type = old_type.keys - STANDARD_TYPE_KEYS - new_type.keys
180
+ extra_object = old_object.keys - STANDARD_OBJECT_KEYS - new_object.keys
181
+ if extra_type.any?
182
+ return issue(issues, unit_id, path, :unknown_widget_schema,
183
+ "unrecognized Type fields: #{extra_type.sort.join(', ')}")
184
+ end
185
+ if extra_object.any?
186
+ return issue(issues, unit_id, path, :unknown_widget_object,
187
+ "unrecognized Object fields: #{extra_object.sort.join(', ')}")
188
+ end
189
+
190
+ true
191
+ end
192
+
193
+ def rebind_object(old_object, old_type, new_object, new_type, unit_id, path, issues)
194
+ old_types = property_types(old_type)
195
+ new_types = property_types(new_type)
196
+ old_by_pointer = old_types.to_h { [id(_1['$ID']), _1] }
197
+ new_by_key = new_types.to_h { [_1['PropertyKey'].to_s, _1] }
198
+ old_values = property_values(old_object)
199
+ rebound = {}
200
+
201
+ old_values.each do |property|
202
+ old_property_type = old_by_pointer[id(property['TypePointer'])]
203
+ unless old_property_type
204
+ return issue(issues, unit_id, path, :unknown_property_pointer,
205
+ "property pointer #{id(property['TypePointer']).inspect} is not in WidgetType")
206
+ end
207
+
208
+ key = old_property_type['PropertyKey'].to_s
209
+ new_property_type = new_by_key[key]
210
+ old_value_type = old_property_type['ValueType'] || {}
211
+ unless new_property_type
212
+ next if default_widget_value?(property['Value'], old_value_type)
213
+
214
+ return issue(issues, unit_id, path, :removed_configured_widget_property,
215
+ "installed schema removed configured property #{key.inspect}")
216
+ end
217
+
218
+ new_value_type = new_property_type['ValueType'] || {}
219
+ value = if old_value_type['Type'] == new_value_type['Type']
220
+ rebind_value(
221
+ property['Value'], old_value_type, new_value_type,
222
+ unit_id, "#{path}.#{key}", issues
223
+ )
224
+ else
225
+ convert_value(
226
+ property['Value'], old_value_type, new_value_type,
227
+ unit_id, "#{path}.#{key}", issues
228
+ )
229
+ end
230
+ return unless value
231
+
232
+ rebound[key] = deep_copy(property).merge(
233
+ 'TypePointer' => new_property_type['$ID'], 'Value' => value
234
+ )
235
+ end
236
+
237
+ result = deep_copy(new_object)
238
+ result['$ID'] = old_object['$ID'] if old_object.key?('$ID')
239
+ defaults = property_values(new_object).to_h do |property|
240
+ type = new_types.find { id(_1['$ID']) == id(property['TypePointer']) }
241
+ property = neutral_new_property(property, type)
242
+ [type.fetch('PropertyKey').to_s, property]
243
+ end
244
+ clear_inactive_default_captions!(rebound, new_by_key)
245
+ added = new_types.filter_map do |type|
246
+ key = type['PropertyKey'].to_s
247
+ defaults[key] unless rebound.key?(key)
248
+ end
249
+ # Studio Pro preserves the previous object-property order and appends
250
+ # newly introduced properties, even when their schema declaration is
251
+ # located in the middle of the XML. CE0463 checks that storage order.
252
+ values = rebound.values + added
253
+ result['Properties'] = IO::BsonCodec.build_array(values, marker: array_marker(new_object['Properties']))
254
+ result
255
+ end
256
+
257
+ def neutral_new_property(property, _type) = deep_copy(property)
258
+
259
+ def clear_inactive_default_captions!(properties, types)
260
+ properties.each do |key, property|
261
+ next unless key.end_with?('Caption') && property.dig('Value', 'TextTemplate')
262
+
263
+ controller = properties[key.delete_suffix('Caption')]
264
+ next unless controller&.dig('Value', 'PrimitiveValue') == 'false'
265
+
266
+ value_type = types.dig(key, 'ValueType')
267
+ next unless canonical_schema(property['Value']) == canonical_schema(default_value(value_type))
268
+
269
+ replacement = default_value(value_type)
270
+ replacement['$ID'] = property.dig('Value', '$ID')
271
+ replacement['TypePointer'] = value_type['$ID']
272
+ replacement['TextTemplate'] = nil
273
+ property['Value'] = replacement
274
+ end
275
+ end
276
+
277
+ def rebind_value(old_value, old_type, new_type, unit_id, path, issues)
278
+ return issue(issues, unit_id, path, :malformed_widget_value, 'property Value is not an object') unless
279
+ old_value.is_a?(Hash)
280
+
281
+ result = normalized_value(old_value, new_type)
282
+ result['TypePointer'] = new_type['$ID']
283
+ return result unless old_type['Type'] == 'Object'
284
+
285
+ objects = array_items(old_value['Objects'])
286
+ if objects.empty?
287
+ result['Objects'] = IO::BsonCodec.build_array([], marker: array_marker(old_value['Objects']))
288
+ return result
289
+ end
290
+
291
+ old_object_type = old_type['ObjectType']
292
+ new_object_type = new_type['ObjectType']
293
+ unless old_object_type.is_a?(Hash) && new_object_type.is_a?(Hash)
294
+ return issue(issues, unit_id, path, :changed_widget_object,
295
+ 'nested Object schema is unavailable')
296
+ end
297
+
298
+ objects = objects.map.with_index do |object, index|
299
+ rebound = rebind_object(
300
+ object, old_object_type, object_template(new_object_type), new_object_type,
301
+ unit_id, "#{path}[#{index}]", issues
302
+ )
303
+ return unless rebound
304
+
305
+ rebound
306
+ end
307
+ result['Objects'] = IO::BsonCodec.build_array(objects, marker: array_marker(old_value['Objects']))
308
+ result
309
+ end
310
+
311
+ def normalized_value(old_value, value_type)
312
+ result = default_value(value_type)
313
+ fields = ACTIVE_VALUE_FIELDS.fetch(value_type['Type'], %w[PrimitiveValue])
314
+ fields.each do |field|
315
+ result[field] = normalize_source_variable(deep_copy(old_value[field])) if old_value.key?(field)
316
+ end
317
+ preserve_ids!(result, old_value)
318
+ result
319
+ end
320
+
321
+ def convert_value(old_value, old_type, new_type, unit_id, path, issues)
322
+ if old_type['Type'] == 'Boolean' && new_type['Type'] == 'Expression'
323
+ primitive = old_value&.fetch('PrimitiveValue', nil)
324
+ if %w[true false].include?(primitive) && boolean_value_only?(old_value, old_type)
325
+ result = default_value(new_type)
326
+ result['Expression'] = primitive
327
+ result['TypePointer'] = new_type['$ID']
328
+ preserve_ids!(result, old_value)
329
+ return result
330
+ end
331
+ end
332
+
333
+ issue(
334
+ issues, unit_id, path, :changed_widget_property,
335
+ "property changed from #{old_type['Type']} to #{new_type['Type']} without a lossless conversion"
336
+ )
337
+ end
338
+
339
+ def boolean_value_only?(value, value_type)
340
+ expected = default_value(value_type)
341
+ actual = deep_copy(value)
342
+ actual['PrimitiveValue'] = expected['PrimitiveValue']
343
+ canonical_schema(actual) == canonical_schema(expected)
344
+ end
345
+
346
+ def object_template(object_type)
347
+ properties = property_types(object_type).reject { _1.dig('ValueType', 'Type') == 'System' }.map do |type|
348
+ value_type = type.fetch('ValueType')
349
+ property = {
350
+ type: value_type.fetch('Type'), default: value_type.fetch('DefaultValue', '').to_s,
351
+ translations: [], required: value_type.fetch('Required', false),
352
+ selection_types: array_items(value_type['SelectionTypes'])
353
+ }
354
+ {
355
+ '$ID' => SecureRandom.uuid, '$Type' => 'CustomWidgets$WidgetProperty',
356
+ 'TypePointer' => type['$ID'],
357
+ 'Value' => WidgetPackage.allocate.send(:widget_value, value_type, property)
358
+ }
359
+ end
360
+ {
361
+ '$ID' => SecureRandom.uuid, '$Type' => 'CustomWidgets$WidgetObject',
362
+ 'Properties' => IO::BsonCodec.build_array(properties, marker: 2),
363
+ 'TypePointer' => object_type['$ID']
364
+ }
365
+ end
366
+
367
+ def default_widget_value?(value, value_type)
368
+ canonical_schema(value) == canonical_schema(default_value(value_type))
369
+ end
370
+
371
+ def default_value(value_type)
372
+ translations = array_items(value_type['Translations']).map do |translation|
373
+ [translation['LanguageCode'].to_s, translation['Text'].to_s]
374
+ end
375
+ property = {
376
+ type: value_type.fetch('Type'), default: value_type.fetch('DefaultValue', '').to_s,
377
+ translations:, required: value_type.fetch('Required', false),
378
+ selection_types: array_items(value_type['SelectionTypes'])
379
+ }
380
+ WidgetPackage.allocate.send(:widget_value, value_type, property)
381
+ end
382
+
383
+ def migrate_layout_row!(row, unit_id, path, issues, counts)
384
+ columns = array_items(row['Columns'])
385
+ return if columns.empty?
386
+
387
+ replacements = {}
388
+ WEIGHT_KEYS.each do |key|
389
+ values = columns.map { _1[key] }
390
+ next if values.all? { _1.is_a?(Integer) && _1.positive? } && values.sum == 12
391
+
392
+ normalized = normalize_weights(values)
393
+ unless normalized
394
+ return issue(issues, unit_id, path, :unsafe_layout_weights,
395
+ "#{key} values cannot be normalized safely: #{values.inspect}")
396
+ end
397
+
398
+ replacements[key] = normalized
399
+ end
400
+ return if replacements.empty?
401
+
402
+ replacements.each do |key, values|
403
+ columns.each_with_index { |column, index| column[key] = values.fetch(index) }
404
+ end
405
+ counts[:layout_rows] += 1
406
+ end
407
+
408
+ def migrate_design_properties!(owner, unit_id, path, issues, counts)
409
+ raw = owner['DesignProperties']
410
+ items = array_items(raw)
411
+ mapping = spacing_mappings
412
+ return if items.empty?
413
+
414
+ changed = migrate_simple_design_aliases!(items)
415
+ retained = []
416
+ compounds = items.select { compound_design_property?(_1) }.to_h { [_1['Key'], _1] }
417
+ items.each do |item|
418
+ old_name = design_property_name(item)
419
+ target = mapping[old_name]
420
+ unless target
421
+ retained << item
422
+ next
423
+ end
424
+
425
+ compound = compounds[target.fetch(:key)] ||= compound_design_property(target.fetch(:key))
426
+ properties = array_items(compound.dig('Value', 'Properties'))
427
+ existing = properties.find { _1['Key'] == target.fetch(:child) }
428
+ if existing && existing.dig('Value', 'Option') != target.fetch(:option)
429
+ issue(issues, unit_id, path, :conflicting_design_property,
430
+ "#{old_name.inspect} conflicts with #{target.fetch(:child).inspect}")
431
+ retained << item
432
+ next
433
+ end
434
+ unless existing
435
+ properties << option_design_property(target.fetch(:child), target.fetch(:option))
436
+ compound['Value']['Properties'] = IO::BsonCodec.build_array(properties, marker: 2)
437
+ end
438
+ retained << compound unless retained.include?(compound)
439
+ changed += 1
440
+ end
441
+ return if changed.zero?
442
+
443
+ owner['DesignProperties'] = IO::BsonCodec.build_array(retained, marker: array_marker(raw))
444
+ counts[:design_properties] += changed
445
+ end
446
+
447
+ def spacing_mappings
448
+ return @spacing_mappings if defined?(@spacing_mappings)
449
+
450
+ mappings = {}
451
+ Dir.glob(File.join(@root, 'themesource', '*', '{web,native}', 'design-properties.json')).sort.each do |path|
452
+ document = JSON.parse(File.read(path))
453
+ document.each_value do |properties|
454
+ Array(properties).each { collect_spacing_mappings(_1, mappings) }
455
+ end
456
+ rescue JSON::ParserError
457
+ next
458
+ end
459
+ @spacing_mappings = mappings
460
+ end
461
+
462
+ def migrate_simple_design_aliases!(items)
463
+ mappings = design_alias_mappings
464
+ items.count do |item|
465
+ option = item.dig('Value', 'Option')
466
+ target = mappings[[item['Key'], option]]
467
+ next false unless target
468
+
469
+ item['Key'] = target.fetch(:key)
470
+ item['Value']['Option'] = target.fetch(:option) if target.key?(:option)
471
+ true
472
+ end
473
+ end
474
+
475
+ def design_alias_mappings
476
+ return @design_alias_mappings if defined?(@design_alias_mappings)
477
+
478
+ mappings = {}
479
+ design_property_documents.each do |document|
480
+ document.each_value do |properties|
481
+ Array(properties).each { collect_design_aliases(_1, mappings) }
482
+ end
483
+ end
484
+ @design_alias_mappings = mappings.reject { |_key, value| value == :conflict }
485
+ end
486
+
487
+ def design_property_documents
488
+ paths = Dir.glob(File.join(@root, 'themesource', '*', '{web,native}', 'design-properties.json')).sort
489
+ paths.filter_map do |path|
490
+ JSON.parse(File.read(path))
491
+ rescue JSON::ParserError
492
+ nil
493
+ end
494
+ end
495
+
496
+ def collect_design_aliases(property, mappings)
497
+ return unless property.is_a?(Hash) && property['name'] && property['type'] != 'Spacing'
498
+
499
+ new_key = property.fetch('name')
500
+ old_keys = Array(property['oldNames'])
501
+ options = Array(property['options'])
502
+ old_keys.each { add_design_alias(mappings, [_1, nil], key: new_key) }
503
+ options.each do |option|
504
+ new_option = option['name']
505
+ old_options = Array(option['oldNames'])
506
+ old_options.each { add_design_alias(mappings, [new_key, _1], key: new_key, option: new_option) }
507
+ old_keys.each { add_design_alias(mappings, [_1, new_option], key: new_key, option: new_option) }
508
+ old_keys.product(old_options).each do |old_key, old_option|
509
+ add_design_alias(mappings, [old_key, old_option], key: new_key, option: new_option)
510
+ end
511
+ end
512
+ end
513
+
514
+ def add_design_alias(mappings, source, target)
515
+ mappings[source] = :conflict if mappings.key?(source) && mappings[source] != target
516
+ mappings[source] ||= target.freeze
517
+ end
518
+
519
+ def collect_spacing_mappings(property, mappings)
520
+ return unless property.is_a?(Hash) && property['type'] == 'Spacing'
521
+
522
+ %w[margin padding].each do |mode|
523
+ Array(property[mode]).each do |size|
524
+ %w[top right bottom left].each do |direction|
525
+ Array(size.dig(direction, 'oldNames')).each do |old_name|
526
+ target = {
527
+ key: property.fetch('name'), child: "#{mode}-#{direction}",
528
+ option: size.fetch('name')
529
+ }.freeze
530
+ mappings[old_name] = target if !mappings.key?(old_name) || mappings[old_name] == target
531
+ end
532
+ end
533
+ end
534
+ end
535
+ end
536
+
537
+ def design_property_name(item)
538
+ return unless item.is_a?(Hash) && item.dig('Value', '$Type') == 'Forms$OptionDesignPropertyValue'
539
+
540
+ "#{item['Key']}::#{item.dig('Value', 'Option')}"
541
+ end
542
+
543
+ def compound_design_property?(item)
544
+ item.is_a?(Hash) && item.dig('Value', '$Type') == 'Forms$CompoundDesignPropertyValue'
545
+ end
546
+
547
+ def compound_design_property(key)
548
+ {
549
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$DesignPropertyValue', 'Key' => key,
550
+ 'Value' => {
551
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$CompoundDesignPropertyValue',
552
+ 'Properties' => IO::BsonCodec.build_array([], marker: 2)
553
+ }
554
+ }
555
+ end
556
+
557
+ def option_design_property(key, option)
558
+ {
559
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$DesignPropertyValue', 'Key' => key,
560
+ 'Value' => {
561
+ '$ID' => SecureRandom.uuid, '$Type' => 'Forms$OptionDesignPropertyValue', 'Option' => option
562
+ }
563
+ }
564
+ end
565
+
566
+ def normalize_weights(values)
567
+ return unless values.all? { _1.is_a?(Integer) && !_1.zero? }
568
+
569
+ positive_total = values.select(&:positive?).sum
570
+ return if positive_total >= 12 && values.any?(&:negative?)
571
+ return if positive_total > 12
572
+
573
+ negative = values.each_index.select { values[_1].negative? }
574
+ return if negative.empty?
575
+
576
+ available = 12 - positive_total
577
+ weights = negative.map { values[_1].abs }
578
+ allocated = apportion(available, weights)
579
+ return if allocated.any?(&:zero?)
580
+
581
+ values.map.with_index { |value, index| value.positive? ? value : allocated.fetch(negative.index(index)) }
582
+ end
583
+
584
+ def apportion(total, weights)
585
+ denominator = weights.sum
586
+ exact = weights.map { Rational(total * _1, denominator) }
587
+ result = exact.map(&:floor)
588
+ remaining = total - result.sum
589
+ order = exact.each_index.sort_by { |index| [-(exact[index] - result[index]), index] }
590
+ order.first(remaining).each { result[_1] += 1 }
591
+ result
592
+ end
593
+
594
+ def definition(widget_id)
595
+ return @definitions[widget_id] if @definitions.key?(widget_id)
596
+
597
+ @package_digests ||= {}
598
+ matches = valid_widget_package_paths.filter_map do |package_path|
599
+ found = WidgetPackage.new(package_path).definition(widget_id)
600
+ [package_path, found] if found
601
+ rescue Zip::Error, REXML::ParseException => e
602
+ @invalid_widget_packages ||= {}
603
+ @invalid_widget_packages[package_path] = e
604
+ nil
605
+ end
606
+ if matches.one?
607
+ package_path, found = matches.first
608
+ @package_digests[widget_id] = Digest::SHA256.file(package_path).hexdigest
609
+ return @definitions[widget_id] = found
610
+ end
611
+
612
+ @definition_failures ||= {}
613
+ @definition_failures[widget_id] = if matches.empty?
614
+ [:missing_widget_definition,
615
+ "no installed MPK defines #{widget_id.inspect}"]
616
+ else
617
+ paths = matches.map { relative_package_path(_1.first) }
618
+ [:ambiguous_widget_definition,
619
+ "multiple installed MPKs define #{widget_id.inspect}: " \
620
+ "#{paths.join(', ')}"]
621
+ end
622
+ @definitions[widget_id] = nil
623
+ end
624
+
625
+ def definition_failure(widget_id)
626
+ @definition_failures&.fetch(
627
+ widget_id, [:missing_widget_definition, "no installed MPK defines #{widget_id.inspect}"]
628
+ )
629
+ end
630
+
631
+ def validate_widget_packages!(issues)
632
+ @invalid_widget_packages = {}
633
+ widget_package_paths.each do |package_path|
634
+ WidgetPackage.new(package_path).definition('__mxrb_package_validation__')
635
+ rescue Zip::Error, REXML::ParseException => e
636
+ @invalid_widget_packages[package_path] = e
637
+ issue(
638
+ issues, '', ".widgets.#{File.basename(package_path)}", :invalid_widget_package,
639
+ "cannot read #{relative_package_path(package_path)}: #{e.class}: #{e.message}"
640
+ )
641
+ end
642
+ end
643
+
644
+ def widget_package_paths
645
+ @widget_package_paths ||= Dir.glob(File.join(@root, 'widgets', '*.mpk')).sort
646
+ end
647
+
648
+ def valid_widget_package_paths
649
+ invalid = @invalid_widget_packages || {}
650
+ widget_package_paths.reject { invalid.key?(_1) }
651
+ end
652
+
653
+ def relative_package_path(path)
654
+ Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
655
+ end
656
+
657
+ def package_digest(widget_id)
658
+ @package_digests&.fetch(widget_id, nil)
659
+ end
660
+
661
+ def apply_audited_package_migration!(object, type, digest)
662
+ return unless digest == AUDITED_DATA_GRID_PACKAGE_SHA256
663
+
664
+ types = property_types(type['ObjectType']).to_h { [_1['PropertyKey'], _1] }
665
+ properties = property_values(object).each_with_object({}) do |property, result|
666
+ property_type = types.values.find { id(_1['$ID']) == id(property['TypePointer']) }
667
+ result[property_type['PropertyKey']] = property if property_type
668
+ end
669
+ pagination = properties.dig('pagination', 'Value', 'PrimitiveValue')
670
+ selection = properties.dig('itemSelection', 'Value', 'Selection')
671
+ clear_text_template!(properties['loadMoreButtonCaption']) unless pagination == 'loadMore'
672
+ clear_text_template!(properties['clearSelectionButtonLabel']) unless selection == 'Multi'
673
+ clear_text_template!(properties['singleSelectionColumnLabel']) unless selection == 'Single'
674
+ clear_audited_data_grid_column_texts!(properties['columns'], types['columns'])
675
+ end
676
+
677
+ def clear_text_template!(property)
678
+ property['Value']['TextTemplate'] = nil if property
679
+ end
680
+
681
+ def clear_audited_data_grid_column_texts!(columns_property, columns_type)
682
+ return unless columns_property && columns_type
683
+
684
+ object_type = columns_type.dig('ValueType', 'ObjectType')
685
+ types = property_types(object_type).to_h { [id(_1['$ID']), _1] }
686
+ array_items(columns_property.dig('Value', 'Objects')).each do |column|
687
+ values = property_values(column).each_with_object({}) do |property, result|
688
+ type = types[id(property['TypePointer'])]
689
+ result[type['PropertyKey']] = property if type
690
+ end
691
+ content = values.dig('showContentAs', 'Value', 'PrimitiveValue')
692
+ clear_text_template!(values['dynamicText']) unless content == 'dynamicText'
693
+ clear_text_template!(values['exportValue']) unless content == 'customContent'
694
+ clear_text_template!(values['tooltip']) if content == 'customContent'
695
+ end
696
+ end
697
+
698
+ def property_types(type) = array_items(type&.fetch('PropertyTypes', nil))
699
+ def property_values(object) = array_items(object&.fetch('Properties', nil))
700
+ def array_items(value) = IO::BsonCodec.parse_array(value)[:items]
701
+ def array_marker(value) = IO::BsonCodec.parse_array(value)[:marker]
702
+ def id(value) = IO::BsonCodec.extract_id(value)
703
+ def custom_widget?(value) = value['$Type'] == 'CustomWidgets$CustomWidget'
704
+ def layout_row?(value) = value['$Type'] == 'Forms$LayoutGridRow'
705
+
706
+ def canonical_schema(value)
707
+ case value
708
+ when Hash
709
+ value.each_with_object({}) do |(key, child), result|
710
+ next if ['$ID', 'TypePointer'].include?(key)
711
+
712
+ result[key] = canonical_schema(child)
713
+ end
714
+ when Array
715
+ value.map { canonical_schema(_1) }
716
+ else value
717
+ end
718
+ end
719
+
720
+ def issue(issues, unit_id, path, kind, message)
721
+ issues << MigrationIssue.new(unit_id, path, kind, message)
722
+ false
723
+ end
724
+
725
+ def deep_copy(value)
726
+ case value
727
+ when Hash then value.to_h { |key, child| [key, deep_copy(child)] }
728
+ when Array then value.map { deep_copy(_1) }
729
+ else value
730
+ end
731
+ end
732
+
733
+ def preserve_ids!(target, source)
734
+ case target
735
+ when Hash
736
+ return target unless source.is_a?(Hash)
737
+
738
+ target['$ID'] = source['$ID'] if target.key?('$ID') && source.key?('$ID')
739
+ target.each do |key, child|
740
+ preserve_ids!(child, source[key]) if source.key?(key) && key != 'TypePointer'
741
+ end
742
+ when Array
743
+ return target unless source.is_a?(Array)
744
+
745
+ target.zip(source).each { |child, old_child| preserve_ids!(child, old_child) }
746
+ end
747
+ target
748
+ end
749
+
750
+ def normalize_source_variable(value)
751
+ case value
752
+ when Hash
753
+ value.each_with_object({}) do |(key, child), result|
754
+ result[key] = normalize_source_variable(child)
755
+ if value['$Type'] == 'Forms$PageVariable' && !value.key?('SubKey') && key == 'SnippetParameter'
756
+ result['SubKey'] = ''
757
+ elsif value['$Type'] == 'Forms$CallNanoflowClientAction' && !value.key?('OutputMappings') &&
758
+ key == 'Nanoflow'
759
+ result['OutputMappings'] = IO::BsonCodec.build_array([], marker: 3)
760
+ elsif value['$Type'] == 'Forms$MicroflowSettings' && !value.key?('OutputMappings') &&
761
+ key == 'Microflow'
762
+ result['OutputMappings'] = IO::BsonCodec.build_array([], marker: 3)
763
+ end
764
+ end
765
+ when Array then value.map { normalize_source_variable(_1) }
766
+ else value
767
+ end
768
+ end
769
+ end
770
+ # rubocop:enable Metrics/AbcSize, Metrics/BlockLength, Metrics/ClassLength
771
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
772
+ # rubocop:enable Metrics/ParameterLists, Metrics/PerceivedComplexity
773
+ # rubocop:enable Lint/NonLocalExitFromIterator
774
+ end
775
+ end