mendix-ruby-bridge 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.mxcli-version +2 -0
- data/LICENSE +21 -0
- data/README.md +77 -0
- data/bin/git-mendix +4 -0
- data/bin/mendix-apply +145 -0
- data/bin/mendix-desktop +26 -0
- data/bin/mendix-git +121 -0
- data/bin/mendix-ruby +580 -0
- data/bin/mxcli +20 -0
- data/bin/setup-tools +25 -0
- data/lib/mendix_bridge/backend_server.rb +1404 -0
- data/lib/mendix_bridge/change_planner.rb +594 -0
- data/lib/mendix_bridge/config.rb +89 -0
- data/lib/mendix_bridge/dependency_index.rb +188 -0
- data/lib/mendix_bridge/desktop_app.rb +121 -0
- data/lib/mendix_bridge/document_parser.rb +120 -0
- data/lib/mendix_bridge/domain_parser.rb +103 -0
- data/lib/mendix_bridge/dsl.rb +540 -0
- data/lib/mendix_bridge/enumeration_parser.rb +32 -0
- data/lib/mendix_bridge/git_workflow.rb +321 -0
- data/lib/mendix_bridge/html_viewer.rb +672 -0
- data/lib/mendix_bridge/importer.rb +415 -0
- data/lib/mendix_bridge/inventory.rb +93 -0
- data/lib/mendix_bridge/mdl_generator.rb +343 -0
- data/lib/mendix_bridge/microflow_parser.rb +131 -0
- data/lib/mendix_bridge/migration.rb +290 -0
- data/lib/mendix_bridge/migration_executor.rb +234 -0
- data/lib/mendix_bridge/model.rb +204 -0
- data/lib/mendix_bridge/page_parser.rb +95 -0
- data/lib/mendix_bridge/presenter.rb +35 -0
- data/lib/mendix_bridge/project_creator.rb +181 -0
- data/lib/mendix_bridge/ruby_inventory_generator.rb +63 -0
- data/lib/mendix_bridge/security_parser.rb +72 -0
- data/lib/mendix_bridge/snapshot_diff.rb +58 -0
- data/lib/mendix_bridge/validator.rb +46 -0
- data/lib/mendix_bridge/version.rb +5 -0
- data/lib/mendix_bridge/visual_entity_plan.rb +176 -0
- data/lib/mendix_bridge.rb +127 -0
- data/share/applications/mendix-ruby-bridge.desktop +12 -0
- data/share/icons/hicolor/128x128/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/256x256/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/32x32/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/48x48/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/512x512/apps/mendix-ruby-bridge.png +0 -0
- data/share/icons/hicolor/64x64/apps/mendix-ruby-bridge.png +0 -0
- data/web/dist/apple-touch-icon.png +0 -0
- data/web/dist/assets/index-BO6iPT1P.css +1 -0
- data/web/dist/assets/index-JGODw81W.js +27 -0
- data/web/dist/brand/mendix-ruby-bridge.png +0 -0
- data/web/dist/favicon.png +0 -0
- data/web/dist/icons.svg +24 -0
- data/web/dist/index.html +17 -0
- metadata +179 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module MendixBridge
|
|
8
|
+
class ImportError < StandardError; end
|
|
9
|
+
|
|
10
|
+
class Importer
|
|
11
|
+
SNAPSHOT_FILE = "inventory/project-tree.json"
|
|
12
|
+
ELEMENT_DETAILS_FILE = "inventory/element-details.json"
|
|
13
|
+
METADATA_FILE = "mendix-project.json"
|
|
14
|
+
DEPENDENCIES_FILE = "inventory/dependencies.json"
|
|
15
|
+
|
|
16
|
+
def initialize(mxcli:)
|
|
17
|
+
@mxcli = File.expand_path(mxcli)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def import(project_file, output_dir)
|
|
21
|
+
project_file = File.expand_path(project_file)
|
|
22
|
+
output_dir = File.expand_path(output_dir)
|
|
23
|
+
|
|
24
|
+
raise ImportError, "Mendix project does not exist: #{project_file}" unless File.file?(project_file)
|
|
25
|
+
raise ImportError, "mxcli is not executable: #{@mxcli}" unless File.executable?(@mxcli)
|
|
26
|
+
|
|
27
|
+
stdout, stderr, status = Open3.capture3(
|
|
28
|
+
@mxcli, "--json", "-p", project_file, "project-tree"
|
|
29
|
+
)
|
|
30
|
+
unless status.success?
|
|
31
|
+
raise ImportError, "mxcli could not read the project: #{stderr.strip}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
inventory = Inventory.from_json(stdout)
|
|
35
|
+
element_details, warnings = describe_elements(project_file, inventory)
|
|
36
|
+
inventory = Inventory.from_json(stdout, details: element_details)
|
|
37
|
+
write_project(output_dir, project_file, inventory)
|
|
38
|
+
[inventory, warnings]
|
|
39
|
+
rescue JSON::ParserError => error
|
|
40
|
+
raise ImportError, "mxcli returned invalid project-tree JSON: #{error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def write_project(output_dir, project_file, inventory)
|
|
46
|
+
FileUtils.mkdir_p(File.join(output_dir, "inventory"))
|
|
47
|
+
FileUtils.rm_f(File.join(output_dir, "inventory", "domain-details.json"))
|
|
48
|
+
|
|
49
|
+
File.write(
|
|
50
|
+
File.join(output_dir, SNAPSHOT_FILE),
|
|
51
|
+
"#{JSON.pretty_generate(inventory.tree)}\n"
|
|
52
|
+
)
|
|
53
|
+
File.write(
|
|
54
|
+
File.join(output_dir, ELEMENT_DETAILS_FILE),
|
|
55
|
+
"#{JSON.pretty_generate(element_details(inventory))}\n"
|
|
56
|
+
)
|
|
57
|
+
File.write(
|
|
58
|
+
File.join(output_dir, METADATA_FILE),
|
|
59
|
+
"#{JSON.pretty_generate(metadata(project_file, inventory))}\n"
|
|
60
|
+
)
|
|
61
|
+
File.write(
|
|
62
|
+
File.join(output_dir, DEPENDENCIES_FILE),
|
|
63
|
+
"#{JSON.pretty_generate(DependencyIndex.new(inventory).to_h)}\n"
|
|
64
|
+
)
|
|
65
|
+
write_once(File.join(output_dir, "Gemfile"), gemfile)
|
|
66
|
+
write_once(File.join(output_dir, "Mendixfile.rb"), mendixfile)
|
|
67
|
+
write_once(File.join(output_dir, "README.md"), readme(project_file))
|
|
68
|
+
write_generated_modules(output_dir, inventory)
|
|
69
|
+
write_generated_microflows(output_dir, inventory)
|
|
70
|
+
write_generated_nanoflows(output_dir, inventory)
|
|
71
|
+
write_generated_enumerations(output_dir, inventory)
|
|
72
|
+
write_generated_pages(output_dir, inventory)
|
|
73
|
+
write_generated_project_documents(output_dir, inventory)
|
|
74
|
+
write_generated_security(output_dir, inventory)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def metadata(project_file, inventory)
|
|
78
|
+
{
|
|
79
|
+
schema_version: 1,
|
|
80
|
+
source_project: project_file,
|
|
81
|
+
imported_at: Time.now.iso8601,
|
|
82
|
+
snapshot: SNAPSHOT_FILE,
|
|
83
|
+
element_count: inventory.elements.length,
|
|
84
|
+
types: inventory.types
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_once(path, content)
|
|
89
|
+
File.write(path, content) unless File.exist?(path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# One `mxcli describe` process per element, so a full refresh on a real
|
|
93
|
+
# project spawns hundreds of them — run a small worker pool. Open3 blocks
|
|
94
|
+
# in IO/waitpid, which releases the GVL, so threads give near-linear
|
|
95
|
+
# speedup here.
|
|
96
|
+
DESCRIBE_WORKERS = 8
|
|
97
|
+
|
|
98
|
+
def describe_elements(project_file, inventory)
|
|
99
|
+
details = {}
|
|
100
|
+
warnings = []
|
|
101
|
+
mutex = Mutex.new
|
|
102
|
+
|
|
103
|
+
queue = Queue.new
|
|
104
|
+
inventory.elements
|
|
105
|
+
.select do |element|
|
|
106
|
+
%w[
|
|
107
|
+
entity association enumeration microflow nanoflow page
|
|
108
|
+
constant javaaction layout snippet importmapping exportmapping navprofile
|
|
109
|
+
projectsecurity modulerole userrole
|
|
110
|
+
].include?(element.type)
|
|
111
|
+
end
|
|
112
|
+
.each { |element| queue << element }
|
|
113
|
+
queue.close
|
|
114
|
+
|
|
115
|
+
workers = Array.new(DESCRIBE_WORKERS) do
|
|
116
|
+
Thread.new do
|
|
117
|
+
while (element = queue.pop)
|
|
118
|
+
describe_type, describe_name = describe_target(element)
|
|
119
|
+
begin
|
|
120
|
+
stdout, stderr, status = Open3.capture3(
|
|
121
|
+
@mxcli,
|
|
122
|
+
"--json",
|
|
123
|
+
"-p", project_file,
|
|
124
|
+
"describe", describe_type, describe_name
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
unless status.success?
|
|
128
|
+
mutex.synchronize { warnings << "#{element.qualified_name}: #{stderr.strip}" }
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parsed = parse_description(element, JSON.parse(stdout))
|
|
133
|
+
mutex.synchronize { details[element.qualified_name] = parsed }
|
|
134
|
+
rescue JSON::ParserError => error
|
|
135
|
+
mutex.synchronize { warnings << "#{element.qualified_name}: invalid JSON (#{error.message})" }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
workers.each(&:join)
|
|
141
|
+
|
|
142
|
+
[details, warnings]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_description(element, description)
|
|
146
|
+
if %w[microflow nanoflow].include?(element.type)
|
|
147
|
+
MicroflowParser.parse(description)
|
|
148
|
+
elsif element.type == "enumeration"
|
|
149
|
+
EnumerationParser.parse(description)
|
|
150
|
+
elsif %w[
|
|
151
|
+
constant javaaction layout snippet importmapping exportmapping navprofile
|
|
152
|
+
].include?(element.type)
|
|
153
|
+
DocumentParser.parse(element.type, description)
|
|
154
|
+
elsif element.type == "page"
|
|
155
|
+
PageParser.parse(description)
|
|
156
|
+
elsif %w[projectsecurity modulerole userrole].include?(element.type)
|
|
157
|
+
SecurityParser.parse(element.type, description)
|
|
158
|
+
else
|
|
159
|
+
DomainParser.parse(description)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def describe_target(element)
|
|
164
|
+
case element.type
|
|
165
|
+
when "importmapping", "exportmapping"
|
|
166
|
+
[element.type, element.label]
|
|
167
|
+
when "navprofile"
|
|
168
|
+
["navigation", element.qualified_name]
|
|
169
|
+
else
|
|
170
|
+
[element.type, element.qualified_name]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def element_details(inventory)
|
|
175
|
+
inventory.elements.each_with_object({}) do |element, result|
|
|
176
|
+
if element.qualified_name && element.details
|
|
177
|
+
result[element.qualified_name] = element.details
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def write_generated_modules(output_dir, inventory)
|
|
183
|
+
generated_dir = File.join(output_dir, "generated", "modules")
|
|
184
|
+
FileUtils.mkdir_p(generated_dir)
|
|
185
|
+
FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
|
|
186
|
+
|
|
187
|
+
RubyInventoryGenerator.generate(inventory).each do |module_name, domain|
|
|
188
|
+
path = File.join(
|
|
189
|
+
generated_dir,
|
|
190
|
+
"#{RubyInventoryGenerator.file_name(module_name)}.rb"
|
|
191
|
+
)
|
|
192
|
+
File.write(path, RubyInventoryGenerator.ruby_source(module_name, domain))
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def write_generated_microflows(output_dir, inventory)
|
|
197
|
+
generated_dir = File.join(output_dir, "generated", "microflows")
|
|
198
|
+
FileUtils.mkdir_p(generated_dir)
|
|
199
|
+
FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
|
|
200
|
+
|
|
201
|
+
inventory.modules.each do |app_module|
|
|
202
|
+
module_name = app_module.qualified_name
|
|
203
|
+
microflows = inventory.elements
|
|
204
|
+
.select do |element|
|
|
205
|
+
element.type == "microflow" &&
|
|
206
|
+
element.qualified_name&.start_with?("#{module_name}.")
|
|
207
|
+
end
|
|
208
|
+
.map do |element|
|
|
209
|
+
{
|
|
210
|
+
"name" => element.label,
|
|
211
|
+
"qualified_name" => element.qualified_name,
|
|
212
|
+
"details" => element.details
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
next if microflows.empty?
|
|
216
|
+
|
|
217
|
+
path = File.join(
|
|
218
|
+
generated_dir,
|
|
219
|
+
"#{RubyInventoryGenerator.file_name(module_name)}.rb"
|
|
220
|
+
)
|
|
221
|
+
File.write(
|
|
222
|
+
path,
|
|
223
|
+
RubyInventoryGenerator.ruby_source(
|
|
224
|
+
"#{module_name}Microflows",
|
|
225
|
+
"microflows" => microflows
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def write_generated_nanoflows(output_dir, inventory)
|
|
232
|
+
write_generated_documents(
|
|
233
|
+
output_dir,
|
|
234
|
+
inventory,
|
|
235
|
+
type: "nanoflow",
|
|
236
|
+
directory: "nanoflows"
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def write_generated_enumerations(output_dir, inventory)
|
|
241
|
+
write_generated_documents(
|
|
242
|
+
output_dir,
|
|
243
|
+
inventory,
|
|
244
|
+
type: "enumeration",
|
|
245
|
+
directory: "enumerations"
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def write_generated_pages(output_dir, inventory)
|
|
250
|
+
write_generated_documents(output_dir, inventory, type: "page", directory: "pages")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def write_generated_project_documents(output_dir, inventory)
|
|
254
|
+
{
|
|
255
|
+
"constant" => "constants",
|
|
256
|
+
"javaaction" => "java_actions",
|
|
257
|
+
"layout" => "layouts",
|
|
258
|
+
"snippet" => "snippets",
|
|
259
|
+
"importmapping" => "import_mappings",
|
|
260
|
+
"exportmapping" => "export_mappings"
|
|
261
|
+
}.each do |type, directory|
|
|
262
|
+
write_generated_documents(output_dir, inventory, type:, directory:)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
write_generated_navigation(output_dir, inventory)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def write_generated_navigation(output_dir, inventory)
|
|
269
|
+
generated_dir = File.join(output_dir, "generated", "navigation")
|
|
270
|
+
FileUtils.mkdir_p(generated_dir)
|
|
271
|
+
FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
|
|
272
|
+
|
|
273
|
+
profiles = inventory.of_type("navprofile").map do |element|
|
|
274
|
+
{
|
|
275
|
+
"name" => element.label,
|
|
276
|
+
"qualified_name" => element.qualified_name,
|
|
277
|
+
"details" => element.details
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
return if profiles.empty?
|
|
281
|
+
|
|
282
|
+
File.write(
|
|
283
|
+
File.join(generated_dir, "project.rb"),
|
|
284
|
+
RubyInventoryGenerator.ruby_source(
|
|
285
|
+
"Navigation",
|
|
286
|
+
"navigation_profiles" => profiles
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def write_generated_documents(output_dir, inventory, type:, directory:)
|
|
292
|
+
generated_dir = File.join(output_dir, "generated", directory)
|
|
293
|
+
FileUtils.mkdir_p(generated_dir)
|
|
294
|
+
FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
|
|
295
|
+
|
|
296
|
+
inventory.modules.each do |app_module|
|
|
297
|
+
module_name = app_module.qualified_name
|
|
298
|
+
documents = inventory.elements
|
|
299
|
+
.select do |element|
|
|
300
|
+
element.type == type &&
|
|
301
|
+
element.qualified_name&.start_with?("#{module_name}.")
|
|
302
|
+
end
|
|
303
|
+
.map do |element|
|
|
304
|
+
{
|
|
305
|
+
"name" => element.label,
|
|
306
|
+
"qualified_name" => element.qualified_name,
|
|
307
|
+
"details" => element.details
|
|
308
|
+
}
|
|
309
|
+
end
|
|
310
|
+
next if documents.empty?
|
|
311
|
+
|
|
312
|
+
path = File.join(
|
|
313
|
+
generated_dir,
|
|
314
|
+
"#{RubyInventoryGenerator.file_name(module_name)}.rb"
|
|
315
|
+
)
|
|
316
|
+
File.write(
|
|
317
|
+
path,
|
|
318
|
+
RubyInventoryGenerator.ruby_source(
|
|
319
|
+
"#{module_name}#{type.capitalize}s",
|
|
320
|
+
"#{type}s" => documents
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def write_generated_security(output_dir, inventory)
|
|
327
|
+
generated_dir = File.join(output_dir, "generated", "security")
|
|
328
|
+
FileUtils.mkdir_p(generated_dir)
|
|
329
|
+
FileUtils.rm_f(Dir[File.join(generated_dir, "*.rb")])
|
|
330
|
+
|
|
331
|
+
security_elements = inventory.elements
|
|
332
|
+
.select { |element| %w[projectsecurity modulerole userrole].include?(element.type) }
|
|
333
|
+
.map do |element|
|
|
334
|
+
{
|
|
335
|
+
"name" => element.qualified_name || element.label,
|
|
336
|
+
"type" => element.type,
|
|
337
|
+
"details" => element.details
|
|
338
|
+
}
|
|
339
|
+
end
|
|
340
|
+
entity_access = inventory.of_type("entity").filter_map do |entity|
|
|
341
|
+
rules = entity.details&.fetch("access_rules", [])
|
|
342
|
+
next if rules.empty?
|
|
343
|
+
|
|
344
|
+
{ "entity" => entity.qualified_name, "rules" => rules }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
source = RubyInventoryGenerator.ruby_source(
|
|
348
|
+
"Security",
|
|
349
|
+
"security_elements" => security_elements,
|
|
350
|
+
"entity_access" => entity_access
|
|
351
|
+
)
|
|
352
|
+
File.write(File.join(generated_dir, "project.rb"), source)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def mendixfile
|
|
356
|
+
<<~RUBY
|
|
357
|
+
# frozen_string_literal: true
|
|
358
|
+
|
|
359
|
+
require "mendix_bridge"
|
|
360
|
+
|
|
361
|
+
MENDIX_PROJECT = MendixBridge::Inventory.load(
|
|
362
|
+
File.expand_path("inventory/project-tree.json", __dir__)
|
|
363
|
+
)
|
|
364
|
+
RUBY
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def gemfile
|
|
368
|
+
bridge_dir = File.dirname(File.dirname(@mxcli))
|
|
369
|
+
|
|
370
|
+
<<~RUBY
|
|
371
|
+
source "https://rubygems.org"
|
|
372
|
+
|
|
373
|
+
gem "mendix-ruby-bridge", path: #{bridge_dir.inspect}
|
|
374
|
+
RUBY
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def readme(project_file)
|
|
378
|
+
<<~MARKDOWN
|
|
379
|
+
# Imported Mendix project
|
|
380
|
+
|
|
381
|
+
Read-only Ruby inventory imported from:
|
|
382
|
+
|
|
383
|
+
`#{project_file}`
|
|
384
|
+
|
|
385
|
+
Load it from Ruby:
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
require_relative "Mendixfile"
|
|
389
|
+
|
|
390
|
+
project = MENDIX_PROJECT
|
|
391
|
+
project.modules
|
|
392
|
+
project.of_type("microflow")
|
|
393
|
+
project.search("Customer")
|
|
394
|
+
project.find("MyFirstModule.Customer")
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Generated domain-model files are under `generated/modules/`; generated
|
|
398
|
+
microflow files are under `generated/microflows/`; nanoflows are under
|
|
399
|
+
`generated/nanoflows/`; enumerations are under
|
|
400
|
+
`generated/enumerations/`; generated pages are under `generated/pages/`;
|
|
401
|
+
the consolidated security view is under `generated/security/`. Every
|
|
402
|
+
interpreted element retains its original MDL plus normalized details.
|
|
403
|
+
|
|
404
|
+
Explore interactively:
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
bundle exec irb -r ./Mendixfile
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Refresh the snapshot by running `mendix-ruby import` again with
|
|
411
|
+
`--force`. Importing and querying do not modify the `.mpr` project.
|
|
412
|
+
MARKDOWN
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MendixBridge
|
|
4
|
+
class Inventory
|
|
5
|
+
Element = Data.define(:label, :type, :qualified_name, :path, :raw, :details) do
|
|
6
|
+
def module_name
|
|
7
|
+
qualified_name&.split(".", 2)&.first
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_h
|
|
11
|
+
{
|
|
12
|
+
label:,
|
|
13
|
+
type:,
|
|
14
|
+
qualified_name:,
|
|
15
|
+
path:,
|
|
16
|
+
raw:,
|
|
17
|
+
details:
|
|
18
|
+
}.compact
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :tree, :elements
|
|
23
|
+
|
|
24
|
+
def self.from_json(json, details: {})
|
|
25
|
+
new(JSON.parse(json), details:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.load(path, details_path: nil)
|
|
29
|
+
details_path ||= begin
|
|
30
|
+
directory = File.dirname(path)
|
|
31
|
+
current = File.join(directory, "element-details.json")
|
|
32
|
+
File.file?(current) ? current : File.join(directory, "domain-details.json")
|
|
33
|
+
end
|
|
34
|
+
details = File.file?(details_path) ? JSON.parse(File.read(details_path)) : {}
|
|
35
|
+
from_json(File.read(path), details:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(tree, details: {})
|
|
39
|
+
raise ArgumentError, "inventory root must be an array" unless tree.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
@tree = tree
|
|
42
|
+
@details = details
|
|
43
|
+
@elements = flatten(tree).freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def search(query)
|
|
47
|
+
needle = query.to_s.downcase
|
|
48
|
+
elements.select do |element|
|
|
49
|
+
[element.label, element.qualified_name, element.type]
|
|
50
|
+
.compact
|
|
51
|
+
.any? { |value| value.downcase.include?(needle) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def of_type(type)
|
|
56
|
+
expected = type.to_s.downcase
|
|
57
|
+
elements.select { |element| element.type&.downcase == expected }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def modules
|
|
61
|
+
of_type("module")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find(qualified_name)
|
|
65
|
+
elements.find { |element| element.qualified_name == qualified_name }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def types
|
|
69
|
+
elements.each_with_object(Hash.new(0)) do |element, counts|
|
|
70
|
+
counts[element.type] += 1 if element.type
|
|
71
|
+
end.sort.to_h
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def flatten(nodes, parents = [])
|
|
77
|
+
nodes.flat_map do |node|
|
|
78
|
+
label = node["label"]
|
|
79
|
+
path = [*parents, label].compact
|
|
80
|
+
element = Element.new(
|
|
81
|
+
label:,
|
|
82
|
+
type: node["type"],
|
|
83
|
+
qualified_name: node["qualifiedName"],
|
|
84
|
+
path:,
|
|
85
|
+
raw: node,
|
|
86
|
+
details: @details[node["qualifiedName"]]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
[element, *flatten(node.fetch("children", []), path)]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|