ea 0.2.5 → 0.3.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.refactor/01-ea-model-domain-layer.md +64 -0
  3. data/TODO.refactor/02-ea-model-qea-source-adapter.md +58 -0
  4. data/TODO.refactor/03-ea-model-xmi-source-adapter.md +33 -0
  5. data/TODO.refactor/04-ea-model-annotations-tagged-values.md +34 -0
  6. data/TODO.refactor/05-ea-model-diagrams-coordinates.md +37 -0
  7. data/TODO.refactor/06-spa-projection-layer.md +45 -0
  8. data/TODO.refactor/07-spa-sharded-output-strategy.md +28 -0
  9. data/TODO.refactor/08-spa-build-time-search-index.md +24 -0
  10. data/TODO.refactor/09-spa-frontend-data-contract.md +25 -0
  11. data/TODO.refactor/10-spa-cli-command-rework.md +34 -0
  12. data/TODO.refactor/11-spa-perf-benchmark.md +23 -0
  13. data/TODO.refactor/12-lutaml-uml-static-site-deprecation.md +28 -0
  14. data/lib/ea/cli/app.rb +13 -7
  15. data/lib/ea/cli/command/spa.rb +33 -48
  16. data/lib/ea/cli/command/svg.rb +68 -0
  17. data/lib/ea/cli/command.rb +1 -0
  18. data/lib/ea/model/annotation.rb +29 -0
  19. data/lib/ea/model/association.rb +40 -0
  20. data/lib/ea/model/base.rb +27 -0
  21. data/lib/ea/model/bounds.rb +22 -0
  22. data/lib/ea/model/classifier.rb +41 -0
  23. data/lib/ea/model/data_type.rb +9 -0
  24. data/lib/ea/model/dependency.rb +19 -0
  25. data/lib/ea/model/diagram.rb +28 -0
  26. data/lib/ea/model/diagram_connector.rb +30 -0
  27. data/lib/ea/model/diagram_element.rb +23 -0
  28. data/lib/ea/model/document.rb +107 -0
  29. data/lib/ea/model/enumeration.rb +15 -0
  30. data/lib/ea/model/enumeration_literal.rb +16 -0
  31. data/lib/ea/model/generalization.rb +19 -0
  32. data/lib/ea/model/interface.rb +9 -0
  33. data/lib/ea/model/klass.rb +16 -0
  34. data/lib/ea/model/metadata.rb +31 -0
  35. data/lib/ea/model/operation.rb +33 -0
  36. data/lib/ea/model/package.rb +36 -0
  37. data/lib/ea/model/parameter.rb +24 -0
  38. data/lib/ea/model/point.rb +16 -0
  39. data/lib/ea/model/polymorphic_maps.rb +30 -0
  40. data/lib/ea/model/primitive_type.rb +9 -0
  41. data/lib/ea/model/property.rb +49 -0
  42. data/lib/ea/model/realization.rb +19 -0
  43. data/lib/ea/model/relationship.rb +21 -0
  44. data/lib/ea/model/stereotype.rb +24 -0
  45. data/lib/ea/model/tagged_value.rb +20 -0
  46. data/lib/ea/model/waypoint.rb +19 -0
  47. data/lib/ea/model.rb +48 -0
  48. data/lib/ea/sources/qea/adapter.rb +63 -0
  49. data/lib/ea/sources/qea/annotation_builder.rb +36 -0
  50. data/lib/ea/sources/qea/classifier_builder.rb +119 -0
  51. data/lib/ea/sources/qea/connector_relationship_map.rb +42 -0
  52. data/lib/ea/sources/qea/diagram_builder.rb +130 -0
  53. data/lib/ea/sources/qea/diagram_style_parser.rb +43 -0
  54. data/lib/ea/sources/qea/id_normalizer.rb +24 -0
  55. data/lib/ea/sources/qea/metadata_builder.rb +42 -0
  56. data/lib/ea/sources/qea/object_classifier_map.rb +36 -0
  57. data/lib/ea/sources/qea/operation_builder.rb +79 -0
  58. data/lib/ea/sources/qea/package_builder.rb +44 -0
  59. data/lib/ea/sources/qea/property_builder.rb +86 -0
  60. data/lib/ea/sources/qea/relationship_builder.rb +113 -0
  61. data/lib/ea/sources/qea/stereotype_builder.rb +44 -0
  62. data/lib/ea/sources/qea/tagged_value_builder.rb +39 -0
  63. data/lib/ea/sources/qea.rb +31 -0
  64. data/lib/ea/sources/xmi/adapter.rb +62 -0
  65. data/lib/ea/sources/xmi/annotation_builder.rb +49 -0
  66. data/lib/ea/sources/xmi/classifier_builder.rb +89 -0
  67. data/lib/ea/sources/xmi/diagram_builder.rb +82 -0
  68. data/lib/ea/sources/xmi/id_normalizer.rb +27 -0
  69. data/lib/ea/sources/xmi/metadata_builder.rb +32 -0
  70. data/lib/ea/sources/xmi/operation_builder.rb +59 -0
  71. data/lib/ea/sources/xmi/package_builder.rb +73 -0
  72. data/lib/ea/sources/xmi/property_builder.rb +77 -0
  73. data/lib/ea/sources/xmi/relationship_builder.rb +142 -0
  74. data/lib/ea/sources/xmi/relationship_type_map.rb +34 -0
  75. data/lib/ea/sources/xmi/type_classifier_map.rb +33 -0
  76. data/lib/ea/sources/xmi.rb +29 -0
  77. data/lib/ea/sources.rb +15 -0
  78. data/lib/ea/spa/configuration.rb +96 -0
  79. data/lib/ea/spa/generator.rb +45 -0
  80. data/lib/ea/spa/lazy_ref.rb +18 -0
  81. data/lib/ea/spa/output/sharded_multi_file_strategy.rb +71 -0
  82. data/lib/ea/spa/output/single_file_strategy.rb +56 -0
  83. data/lib/ea/spa/output/strategy.rb +36 -0
  84. data/lib/ea/spa/output.rb +11 -0
  85. data/lib/ea/spa/package_tree.rb +15 -0
  86. data/lib/ea/spa/package_tree_node.rb +26 -0
  87. data/lib/ea/spa/projector.rb +194 -0
  88. data/lib/ea/spa/search_entry.rb +28 -0
  89. data/lib/ea/spa/search_index.rb +18 -0
  90. data/lib/ea/spa/shard.rb +19 -0
  91. data/lib/ea/spa/skeleton.rb +22 -0
  92. data/lib/ea/spa/skeleton_entry.rb +27 -0
  93. data/lib/ea/spa.rb +31 -0
  94. data/lib/ea/svg/bounds_calculator.rb +74 -0
  95. data/lib/ea/svg/connector_path.rb +46 -0
  96. data/lib/ea/svg/element_box.rb +71 -0
  97. data/lib/ea/svg/renderer.rb +70 -0
  98. data/lib/ea/svg/style_resolver.rb +72 -0
  99. data/lib/ea/svg.rb +21 -0
  100. data/lib/ea/version.rb +1 -1
  101. data/lib/ea.rb +4 -0
  102. metadata +97 -2
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Maps XMI's `xmi:type` discriminator on packaged elements to
7
+ # the concrete Ea::Model::Classifier subclass. New classifier
8
+ # kinds = one entry here, nothing else changes (OCP).
9
+ module TypeClassifierMap
10
+ module_function
11
+
12
+ LOOKUP = {
13
+ "uml:Class" => "Ea::Model::Klass",
14
+ "uml:DataType" => "Ea::Model::DataType",
15
+ "uml:PrimitiveType" => "Ea::Model::PrimitiveType",
16
+ "uml:Enumeration" => "Ea::Model::Enumeration",
17
+ "uml:Interface" => "Ea::Model::Interface",
18
+ }.freeze
19
+
20
+ def class_for(xmi_type)
21
+ name = LOOKUP[xmi_type]
22
+ return nil unless name
23
+
24
+ Object.const_get(name)
25
+ end
26
+
27
+ def classifier_type?(xmi_type)
28
+ LOOKUP.key?(xmi_type)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ # XMI source adapter. Mirrors Ea::Sources::Qea: produces an
6
+ # Ea::Model::Document from a parsed Xmi::Sparx::Root by walking
7
+ # the UML model tree once and translating each element into the
8
+ # canonical model.
9
+ #
10
+ # Both adapters emit structurally identical Ea::Model instances
11
+ # — source format becomes irrelevant once you're in the model.
12
+ # That's the "single way of doing things": one pipeline, two
13
+ # interchangeable sources.
14
+ module Xmi
15
+ autoload :IdNormalizer, "ea/sources/xmi/id_normalizer"
16
+ autoload :TypeClassifierMap, "ea/sources/xmi/type_classifier_map"
17
+ autoload :RelationshipTypeMap, "ea/sources/xmi/relationship_type_map"
18
+ autoload :MetadataBuilder, "ea/sources/xmi/metadata_builder"
19
+ autoload :PackageBuilder, "ea/sources/xmi/package_builder"
20
+ autoload :ClassifierBuilder, "ea/sources/xmi/classifier_builder"
21
+ autoload :PropertyBuilder, "ea/sources/xmi/property_builder"
22
+ autoload :OperationBuilder, "ea/sources/xmi/operation_builder"
23
+ autoload :RelationshipBuilder, "ea/sources/xmi/relationship_builder"
24
+ autoload :AnnotationBuilder, "ea/sources/xmi/annotation_builder"
25
+ autoload :DiagramBuilder, "ea/sources/xmi/diagram_builder"
26
+ autoload :Adapter, "ea/sources/xmi/adapter"
27
+ end
28
+ end
29
+ end
data/lib/ea/sources.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ # Source adapters: translate format-specific parses (QEA SQLite,
5
+ # Sparx XMI) into the canonical Ea::Model::Document.
6
+ #
7
+ # Each adapter is a driving port in the hexagonal sense — its
8
+ # only job is to take a parsed source and emit model instances.
9
+ # Adapters do not know about each other or about consumer
10
+ # adapters (Ea::Spa, etc.).
11
+ module Sources
12
+ autoload :Qea, "ea/sources/qea"
13
+ autoload :Xmi, "ea/sources/xmi"
14
+ end
15
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ea
6
+ module Spa
7
+ # SPA-side configuration. A YAML file with the shape:
8
+ #
9
+ # metadata:
10
+ # title: ...
11
+ # description: ...
12
+ # version: ...
13
+ # author: ...
14
+ # license: ...
15
+ # homepage: ...
16
+ # repository: ...
17
+ # ui:
18
+ # title: ...
19
+ # description: ...
20
+ #
21
+ # Values under `metadata` override fields on the model's
22
+ # Ea::Model::Metadata. Values under `ui` (and other sections
23
+ # like `appearance`) are surfaced to the SPA view via the
24
+ # skeleton's metadata hash so the frontend can use them.
25
+ class Configuration
26
+ attr_reader :path, :data
27
+
28
+ def self.load(path)
29
+ return nil if path.nil? || path.empty?
30
+ unless File.exist?(path)
31
+ raise ArgumentError, "SPA config not found: #{path}"
32
+ end
33
+
34
+ new(path, YAML.safe_load(File.read(path)) || {})
35
+ end
36
+
37
+ def initialize(path, data)
38
+ @path = path
39
+ @data = data.is_a?(Hash) ? data : {}
40
+ end
41
+
42
+ def metadata_override
43
+ (@data["metadata"] || {}).transform_keys(&:to_sym)
44
+ end
45
+
46
+ def ui
47
+ @data["ui"] || {}
48
+ end
49
+
50
+ def appearance
51
+ @data["appearance"] || {}
52
+ end
53
+
54
+ # Apply overrides to a model Metadata instance, returning a new
55
+ # one. The original is left untouched.
56
+ def apply_to_metadata(model_metadata)
57
+ return model_metadata unless model_metadata
58
+
59
+ merged = model_metadata_to_hash(model_metadata)
60
+ .merge(stringify_keys(metadata_override))
61
+ hash_to_metadata(merged)
62
+ end
63
+
64
+ # Surface the relevant config (ui, appearance) into a hash the
65
+ # SPA skeleton embeds in its metadata payload.
66
+ def view_extras
67
+ { "ui" => ui, "appearance" => appearance }.reject { |_, v| v.nil? || v.empty? }
68
+ end
69
+
70
+ private
71
+
72
+ def model_metadata_to_hash(meta)
73
+ JSON.parse(meta.to_json)
74
+ end
75
+
76
+ def hash_to_metadata(hash)
77
+ Ea::Model::Metadata.new(
78
+ id: hash["id"] || "metadata",
79
+ title: hash["title"],
80
+ version: hash["version"],
81
+ author: hash["author"],
82
+ company: hash["company"],
83
+ created_date: hash["createdDate"],
84
+ modified_date: hash["modifiedDate"],
85
+ source_format: hash["sourceFormat"],
86
+ source_tool: hash["sourceTool"],
87
+ source_path: hash["sourcePath"]
88
+ )
89
+ end
90
+
91
+ def stringify_keys(hash)
92
+ hash.transform_keys(&:to_s)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # Orchestrator: takes an Ea::Model::Document and an output mode,
6
+ # dispatches to the matching Output::Strategy. Single source of
7
+ # truth for which modes exist; new modes = one entry in
8
+ # OUTPUT_STRATEGIES.
9
+ class Generator
10
+ OUTPUT_STRATEGIES = {
11
+ single_file: Output::SingleFileStrategy,
12
+ sharded: Output::ShardedMultiFileStrategy
13
+ }.freeze
14
+
15
+ DEFAULT_MODE = :single_file
16
+
17
+ attr_reader :document, :mode, :output_path, :shard_url_for, :configuration
18
+
19
+ def initialize(document, output:, mode: nil, shard_url_for: nil,
20
+ configuration: nil)
21
+ @document = document
22
+ @output_path = output
23
+ @mode = (mode || DEFAULT_MODE).to_sym
24
+ @shard_url_for = shard_url_for
25
+ @configuration = configuration
26
+ end
27
+
28
+ def generate
29
+ strategy_class.new(output_path).render(projector)
30
+ end
31
+
32
+ def projector
33
+ @projector ||= Projector.new(document,
34
+ shard_url_for: shard_url_for,
35
+ configuration: configuration)
36
+ end
37
+
38
+ def strategy_class
39
+ OUTPUT_STRATEGIES[mode] ||
40
+ raise(ArgumentError, "Unknown SPA mode: #{mode.inspect}. " \
41
+ "Use :single_file or :sharded.")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # A lazy reference: id + URL to where the full shard lives. Used
6
+ # in the skeleton so the frontend can fetch detail on demand
7
+ # rather than upfront.
8
+ class LazyRef < Lutaml::Model::Serializable
9
+ attribute :id, :string
10
+ attribute :url, :string
11
+
12
+ json do
13
+ map "id", to: :id
14
+ map "url", to: :url
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ea
6
+ module Spa
7
+ module Output
8
+ # Sharded layout: directory with skeleton.json, search.json,
9
+ # and one JSON file per entity under data/. The frontend
10
+ # loads skeleton + search upfront and fetches shards on
11
+ # demand. This is the layout for large models.
12
+ class ShardedMultiFileStrategy < Strategy
13
+ def render(projector)
14
+ FileUtils.mkdir_p(output_path)
15
+ FileUtils.mkdir_p(File.join(output_path, "data"))
16
+
17
+ skeleton = projector.skeleton
18
+ write_json(File.join(output_path, "skeleton.json"), skeleton)
19
+ write_json(File.join(output_path, "search.json"),
20
+ projector.search_index)
21
+ write_shards(projector)
22
+ write_index_html(skeleton)
23
+
24
+ output_path
25
+ end
26
+
27
+ private
28
+
29
+ def write_shards(projector)
30
+ projector.each_shard.each do |shard|
31
+ path = File.join(output_path, "data", pluralize(shard.kind),
32
+ "#{shard.id}.json")
33
+ write_json(path, shard)
34
+ end
35
+ end
36
+
37
+ def pluralize(kind)
38
+ case kind
39
+ when "class" then "classes"
40
+ when "property" then "properties"
41
+ else "#{kind}s"
42
+ end
43
+ end
44
+
45
+ def write_index_html(skeleton)
46
+ title = skeleton.view_extras&.dig("ui", "title") ||
47
+ skeleton.metadata&.fetch("title", nil) ||
48
+ "Ea::Spa"
49
+ File.write(File.join(output_path, "index.html"), <<~HTML)
50
+ <!DOCTYPE html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="UTF-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
+ <title>#{title}</title>
56
+ </head>
57
+ <body>
58
+ <div id="app"></div>
59
+ <script>
60
+ window.__SPA_SKELETON_URL__ = 'skeleton.json';
61
+ window.__SPA_SEARCH_URL__ = 'search.json';
62
+ window.__SPA_SHARD_BASE__ = 'data/';
63
+ </script>
64
+ </body>
65
+ </html>
66
+ HTML
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ea
6
+ module Spa
7
+ module Output
8
+ # Embeds skeleton + search + every shard in one HTML file
9
+ # (data inlined as a JSON blob). Suitable for small models
10
+ # only; not appropriate when shard count or total payload is
11
+ # large.
12
+ class SingleFileStrategy < Strategy
13
+ def render(projector)
14
+ FileUtils.mkdir_p(File.dirname(output_path))
15
+
16
+ skeleton = projector.skeleton
17
+ payload = {
18
+ metadata: skeleton.metadata,
19
+ packageTree: skeleton.package_tree,
20
+ skeletonEntries: skeleton.entries,
21
+ searchIndex: projector.search_index,
22
+ shards: projector.each_shard.to_a
23
+ }
24
+ payload[:viewExtras] = skeleton.view_extras unless skeleton.view_extras.nil? || skeleton.view_extras.empty?
25
+
26
+ File.write(output_path, build_html(payload))
27
+ output_path
28
+ end
29
+
30
+ private
31
+
32
+ def build_html(payload)
33
+ title = payload.dig(:viewExtras, "ui", "title") ||
34
+ payload[:metadata]&.fetch("title", nil) ||
35
+ "Ea::Spa"
36
+ <<~HTML
37
+ <!DOCTYPE html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="UTF-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
+ <title>#{title}</title>
43
+ </head>
44
+ <body>
45
+ <div id="app"></div>
46
+ <script>
47
+ window.__SPA_DATA__ = #{payload.to_json};
48
+ </script>
49
+ </body>
50
+ </html>
51
+ HTML
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ module Output
6
+ # Abstract output strategy. Concrete subclasses project the
7
+ # same SPA artifacts (skeleton, search index, shards) to disk
8
+ # in different layouts.
9
+ #
10
+ # Adding a new layout = new subclass + registration in
11
+ # Generator::OUTPUT_STRATEGIES. No edit to Projector or
12
+ # existing strategies.
13
+ class Strategy
14
+ SIZE_THRESHOLD_BYTES = 2 * 1024 * 1024 # 2 MB
15
+
16
+ attr_reader :output_path
17
+
18
+ def initialize(output_path)
19
+ @output_path = output_path
20
+ end
21
+
22
+ def render(_projector)
23
+ raise NotImplementedError,
24
+ "#{self.class} must implement #render"
25
+ end
26
+
27
+ private
28
+
29
+ def write_json(path, data)
30
+ FileUtils.mkdir_p(File.dirname(path))
31
+ File.write(path, data.to_json)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ module Output
6
+ autoload :Strategy, "ea/spa/output/strategy"
7
+ autoload :SingleFileStrategy, "ea/spa/output/single_file_strategy"
8
+ autoload :ShardedMultiFileStrategy, "ea/spa/output/sharded_multi_file_strategy"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ class PackageTree < Lutaml::Model::Serializable
6
+ attribute :root_ids, :string, collection: true, initialize_empty: true
7
+ attribute :nodes, PackageTreeNode, collection: true, initialize_empty: true
8
+
9
+ json do
10
+ map "rootIds", to: :root_ids, render_empty: true
11
+ map "nodes", to: :nodes, render_empty: true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # One node in the SPA's package navigation tree. References
6
+ # sub-packages and classifiers by id — the frontend resolves
7
+ # them via the skeleton index.
8
+ class PackageTreeNode < Lutaml::Model::Serializable
9
+ attribute :id, :string
10
+ attribute :name, :string
11
+ attribute :parent_id, :string
12
+ attribute :child_ids, :string, collection: true, initialize_empty: true
13
+ attribute :classifier_ids, :string, collection: true, initialize_empty: true
14
+ attribute :diagram_ids, :string, collection: true, initialize_empty: true
15
+
16
+ json do
17
+ map "id", to: :id
18
+ map "name", to: :name
19
+ map "parentId", to: :parent_id
20
+ map "childIds", to: :child_ids, render_empty: true
21
+ map "classifierIds", to: :classifier_ids, render_empty: true
22
+ map "diagramIds", to: :diagram_ids, render_empty: true
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # One-way transform: Ea::Model::Document → SPA view artifacts.
6
+ #
7
+ # The projector walks the model once and emits:
8
+ # - Skeleton (with package tree + per-classifier entries)
9
+ # - SearchIndex (flat searchable rows)
10
+ # - Shards (one per entity, generated lazily via #shard_for)
11
+ #
12
+ # Sharding strategy is configurable via the `shard_url_for`
13
+ # proc — by default it produces "data/<kind>s/<id>.json" paths
14
+ # matching the sharded output strategy.
15
+ class Projector
16
+ attr_reader :document, :shard_url_for, :configuration
17
+
18
+ def initialize(document, shard_url_for: nil, configuration: nil)
19
+ @document = document
20
+ @shard_url_for = shard_url_for || default_shard_url
21
+ @configuration = configuration
22
+ end
23
+
24
+ def skeleton
25
+ Skeleton.new(
26
+ metadata: metadata_hash,
27
+ package_tree: build_package_tree,
28
+ entries: build_entries,
29
+ view_extras: view_extras
30
+ )
31
+ end
32
+
33
+ def search_index
34
+ SearchIndex.new(entries: build_search_entries)
35
+ end
36
+
37
+ def shard_for(model_element)
38
+ Shard.new(
39
+ id: model_element.id,
40
+ kind: kind_of(model_element),
41
+ payload: payload_for(model_element)
42
+ )
43
+ end
44
+
45
+ # Enumerate every (id, kind, shard) triple the SPA can address.
46
+ def each_shard
47
+ return enum_for(:each_shard) unless block_given?
48
+
49
+ document.classifiers.each { |c| yield shard_for(c) }
50
+ document.packages.each { |p| yield shard_for(p) }
51
+ document.diagrams.each { |d| yield shard_for(d) }
52
+ end
53
+
54
+ private
55
+
56
+ def default_shard_url
57
+ lambda do |element|
58
+ kind = kind_of(element)
59
+ "data/#{pluralize(kind)}/#{element.id}.json"
60
+ end
61
+ end
62
+
63
+ def pluralize(kind)
64
+ case kind
65
+ when "class" then "classes"
66
+ when "property" then "properties"
67
+ else "#{kind}s"
68
+ end
69
+ end
70
+
71
+ def metadata_hash
72
+ base = document.metadata
73
+ merged = configuration ? configuration.apply_to_metadata(base) : base
74
+ JSON.parse(merged.to_json)
75
+ end
76
+
77
+ def view_extras
78
+ configuration&.view_extras || {}
79
+ end
80
+
81
+ def build_package_tree
82
+ nodes = document.packages.map do |pkg|
83
+ PackageTreeNode.new(
84
+ id: pkg.id,
85
+ name: pkg.name,
86
+ parent_id: pkg.parent_id,
87
+ child_ids: pkg.sub_package_ids,
88
+ classifier_ids: classifiers_in_package_ids(pkg.id),
89
+ diagram_ids: pkg.diagram_ids
90
+ )
91
+ end
92
+ PackageTree.new(
93
+ root_ids: document.root_packages.map(&:id),
94
+ nodes: nodes
95
+ )
96
+ end
97
+
98
+ def classifiers_in_package_ids(package_id)
99
+ document.classifiers_in_package(package_id).map(&:id)
100
+ end
101
+
102
+ def build_entries
103
+ document.classifiers.map do |c|
104
+ SkeletonEntry.new(
105
+ id: c.id,
106
+ name: c.name,
107
+ kind: c.model_kind,
108
+ package_id: c.package_id,
109
+ qualified_name: c.qualified_name,
110
+ shard_url: shard_url_for.call(c)
111
+ )
112
+ end
113
+ end
114
+
115
+ def build_search_entries
116
+ entries = []
117
+ document.classifiers.each do |c|
118
+ entries << search_entry_for_classifier(c)
119
+ c.properties.each { |p| entries << search_entry_for_property(p, c) }
120
+ end
121
+ document.packages.each { |p| entries << search_entry_for_package(p) }
122
+ entries
123
+ end
124
+
125
+ def search_entry_for_classifier(c)
126
+ SearchEntry.new(
127
+ id: c.id,
128
+ kind: c.model_kind,
129
+ name: c.name,
130
+ qualified_name: c.qualified_name,
131
+ package: package_qualified_name(c.package_id),
132
+ content: classifier_search_content(c),
133
+ boost: 1.5
134
+ )
135
+ end
136
+
137
+ def search_entry_for_property(prop, owner)
138
+ SearchEntry.new(
139
+ id: prop.id,
140
+ kind: "property",
141
+ name: prop.name,
142
+ qualified_name: "#{owner.qualified_name}::#{prop.name}",
143
+ package: package_qualified_name(owner.package_id),
144
+ content: [prop.name, prop.type_name, prop.annotations.map(&:body)].flatten.compact.join(" "),
145
+ boost: 1.0
146
+ )
147
+ end
148
+
149
+ def search_entry_for_package(pkg)
150
+ SearchEntry.new(
151
+ id: pkg.id,
152
+ kind: "package",
153
+ name: pkg.name,
154
+ qualified_name: pkg.qualified_name,
155
+ package: pkg.parent_id ? package_qualified_name(pkg.parent_id) : "",
156
+ content: [pkg.name, pkg.annotations.map(&:body)].flatten.compact.join(" "),
157
+ boost: 1.2
158
+ )
159
+ end
160
+
161
+ def classifier_search_content(c)
162
+ parts = [c.name, c.qualified_name, c.stereotype_refs].compact
163
+ parts << c.properties.map(&:name)
164
+ parts << c.operations.map(&:name)
165
+ parts << c.annotations.map(&:body)
166
+ parts.flatten.compact.join(" ").gsub(/\s+/, " ").strip
167
+ end
168
+
169
+ def package_qualified_name(package_id)
170
+ return "" if package_id.nil?
171
+
172
+ pkg = document.index_by_id[package_id]
173
+ pkg&.qualified_name || ""
174
+ end
175
+
176
+ def kind_of(element)
177
+ case element
178
+ when Ea::Model::Klass then "class"
179
+ when Ea::Model::Enumeration then "enumeration"
180
+ when Ea::Model::DataType then "data_type"
181
+ when Ea::Model::PrimitiveType then "primitive_type"
182
+ when Ea::Model::Interface then "interface"
183
+ when Ea::Model::Package then "package"
184
+ when Ea::Model::Diagram then "diagram"
185
+ else "element"
186
+ end
187
+ end
188
+
189
+ def payload_for(element)
190
+ JSON.parse(element.to_json)
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # One searchable row. MiniSearch-compatible shape: id, name,
6
+ # qualifiedName, type, package, content — all searchable, with
7
+ # `boost` weighting source-type importance.
8
+ class SearchEntry < Lutaml::Model::Serializable
9
+ attribute :id, :string
10
+ attribute :kind, :string # class|enumeration|package|...
11
+ attribute :name, :string
12
+ attribute :qualified_name, :string
13
+ attribute :package, :string
14
+ attribute :content, :string # flattened searchable text
15
+ attribute :boost, :float, default: -> { 1.0 }
16
+
17
+ json do
18
+ map "id", to: :id
19
+ map "kind", to: :kind
20
+ map "name", to: :name
21
+ map "qualifiedName", to: :qualified_name
22
+ map "package", to: :package
23
+ map "content", to: :content
24
+ map "boost", to: :boost, render_default: true
25
+ end
26
+ end
27
+ end
28
+ end