suma 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 304c4c7a2ec03c83604a127370259b022e0102d303c4951c2d730d3681d3ad39
4
- data.tar.gz: b8d9bcd807ea0aadb4f038dd58823d829d495827e4785316cb7c715e79b512cb
3
+ metadata.gz: 165792c4ce4a7a5a23cf0c8fdc649882f68235e65fcc6890a0d11ee5997c063a
4
+ data.tar.gz: 05fdff4f378207ba636169838b4d3631fcfd30009f254ab5eed85a191c12ec5a
5
5
  SHA512:
6
- metadata.gz: 21977b339cd0a16936940c50de766ba2130f3dc46df7965db1de04360a65c1d685e1b59786e9a49548d0e79f1d20befcf732a66482b6e8b8793a547bd557d28e
7
- data.tar.gz: bd1122e6d73a9a22b3a3830c7cf654c909a541e7c332808349b15bb469f772338ad7a84a25590f1c8d13b46b84d8399673fa2f43f16f5d943e0e1f8e2469a08a
6
+ metadata.gz: 227dd455539080a5b257f1d5cbb563c5a2fe78856d7eaac4035402060f9415dcbb80ec87928a971a77eccf8585e23c0f7d9e9fd866a8a2cea4471bb098f40ed5
7
+ data.tar.gz: 47b7edf861d91dc264acd794d6ffdeb1c952aa83da822da3816e450429f81104088d3af68322245a9c70ecf920bcda1762c916e049f0c16c67208f6035960b8f
data/lib/suma/cli/core.rb CHANGED
@@ -40,14 +40,42 @@ module Suma
40
40
  desc "extract-terms SCHEMA_MANIFEST_FILE GLOSSARIST_OUTPUT_PATH",
41
41
  "Extract terms from SCHEMA_MANIFEST_FILE into " \
42
42
  "Glossarist v3 format"
43
- def extract_terms(_schema_manifest_file, _glossarist_output_path)
44
- Cli::ExtractTerms.start
43
+ option :urn, type: :string, required: true, aliases: "-u",
44
+ desc: "URN for the dataset source " \
45
+ "(used for section references)"
46
+ option :language_code, type: :string, default: "eng", aliases: "-l",
47
+ desc: "Language code for the Glossarist"
48
+ def extract_terms(schema_manifest_file, glossarist_output_path)
49
+ TermExtractor.new(
50
+ schema_manifest_file,
51
+ glossarist_output_path,
52
+ urn: options[:urn],
53
+ language_code: options[:language_code],
54
+ ).call
45
55
  end
46
56
 
47
57
  desc "generate-register SCHEMA_MANIFEST_FILE OUTPUT_PATH",
48
58
  "Generate a Glossarist register.yaml with hierarchical sections"
49
- def generate_register(_schema_manifest_file, _output_path)
50
- Cli::GenerateRegister.start
59
+ option :urn, type: :string, required: true, aliases: "-u",
60
+ desc: "URN prefix for the dataset"
61
+ option :id, type: :string, required: true,
62
+ desc: "Dataset identifier (e.g. iso10303-2-express)"
63
+ option :ref, type: :string, required: true,
64
+ desc: "Human-readable reference label"
65
+ option :language_code, type: :string, default: "eng", aliases: "-l",
66
+ desc: "Language code for section names"
67
+ option :owner, type: :string, default: Suma::RegisterManifestGenerator::DEFAULT_OWNER,
68
+ desc: "Owner of the dataset (e.g. 'ISO/TC 184/SC 4')"
69
+ def generate_register(schema_manifest_file, output_path)
70
+ RegisterManifestGenerator.new(
71
+ schema_manifest_file,
72
+ output_path,
73
+ urn: options[:urn],
74
+ id: options[:id],
75
+ ref: options[:ref],
76
+ language_code: options[:language_code],
77
+ owner: options[:owner],
78
+ ).generate
51
79
  end
52
80
 
53
81
  desc "convert-jsdai XML_FILE IMAGE_FILE OUTPUT_DIR",
@@ -3,24 +3,25 @@
3
3
  require "expressir/commands/changes_import_eengine"
4
4
 
5
5
  module Suma
6
- # Converts eengine comparison XML to Expressir::Changes::SchemaChange
7
- # This is a thin wrapper around Expressir's ChangesImportEengine command
6
+ # Converts eengine comparison XML to Expressir::Changes::SchemaChange.
7
+ #
8
+ # Pure converter: takes XML content as a string (no I/O). The caller
9
+ # is responsible for reading the file. This keeps the object cheap
10
+ # to construct and easy to test in isolation — no fixture file
11
+ # required just to instantiate.
8
12
  class EengineConverter
9
- def initialize(xml_path, schema_name)
10
- @xml_path = xml_path
13
+ def initialize(schema_name, xml_content)
11
14
  @schema_name = schema_name
12
- @xml_content = File.read(xml_path)
15
+ @xml_content = xml_content
13
16
  end
14
17
 
15
- # Convert the eengine XML to a ChangeSchema
18
+ # Convert the eengine XML to a ChangeSchema.
16
19
  #
17
20
  # @param version [String] Version number for this change version
18
21
  # @param existing_change_schema [Expressir::Changes::SchemaChange, nil]
19
22
  # Existing schema to append to, or nil to create new
20
23
  # @return [Expressir::Changes::SchemaChange] The updated change schema
21
24
  def convert(version:, existing_change_schema: nil)
22
- # Use Expressir's built-in conversion which properly handles
23
- # HTML elements in descriptions
24
25
  Expressir::Commands::ChangesImportEengine.from_xml(
25
26
  @xml_content,
26
27
  @schema_name,
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+
5
+ module Suma
6
+ class LinkValidator
7
+ # Per-node-type navigation strategies for +validate_deep_path+.
8
+ #
9
+ # Each Step handles one EXPRESS construct (entity, type, ...) and
10
+ # knows how to walk one path segment from it. The dispatcher in
11
+ # LinkValidator picks the right Step by +handles?+.
12
+ #
13
+ # Adding a new navigable construct means adding a new Step class
14
+ # and registering it in REGISTRY — no edits to existing Steps or
15
+ # to validate_deep_path (open/closed principle).
16
+ module Step
17
+ Context = Struct.new(:file, :line, :link, :schema, :path,
18
+ keyword_init: true)
19
+
20
+ # Common failure-result builder. Step subclasses call this to
21
+ # surface an unresolved link.
22
+ module Failure
23
+ def failure(reason, context)
24
+ LinkValidationResult.new(
25
+ file: context.file,
26
+ line: context.line + 1,
27
+ link: context.link,
28
+ reason: reason,
29
+ )
30
+ end
31
+ end
32
+
33
+ # Navigates one path segment from an Entity by attribute name.
34
+ class Entity
35
+ include Failure
36
+
37
+ def self.handles?(node)
38
+ node.is_a?(Expressir::Model::Declarations::Entity)
39
+ end
40
+
41
+ def navigate(node, part, context)
42
+ attribute = node.attributes&.find { |a| a.id.downcase == part.downcase }
43
+ unless attribute
44
+ return failure("Attribute '#{part}' not found in entity '#{context.path}'",
45
+ context)
46
+ end
47
+
48
+ attribute
49
+ end
50
+ end
51
+
52
+ # Navigates one path segment from a Type by either enumeration
53
+ # value (for enum types) or by re-targeting to the underlying
54
+ # named type (for non-enum types).
55
+ class Type
56
+ include Failure
57
+
58
+ PRIMITIVE_TYPE_NAMES = %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
59
+ LOGICAL].freeze
60
+
61
+ def self.handles?(node)
62
+ node.is_a?(Expressir::Model::Declarations::Type)
63
+ end
64
+
65
+ def navigate(node, part, context)
66
+ underlying = node.underlying_type
67
+ unless underlying
68
+ return failure("Cannot navigate deeper from type '#{context.path}'",
69
+ context)
70
+ end
71
+
72
+ if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
73
+ navigate_enum(underlying, part, context)
74
+ else
75
+ navigate_underlying(underlying, context)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def navigate_enum(underlying, part, context)
82
+ enum_value = underlying.items.find { |e| e.id.downcase == part.downcase }
83
+ unless enum_value
84
+ return failure("Enumeration value '#{part}' not found in type '#{context.path}'",
85
+ context)
86
+ end
87
+
88
+ enum_value
89
+ end
90
+
91
+ def navigate_underlying(underlying, context)
92
+ base_type = resolve_base_type(context.schema, underlying)
93
+ unless base_type
94
+ return failure("Base type not found for '#{context.path}'",
95
+ context)
96
+ end
97
+
98
+ base_type
99
+ end
100
+
101
+ def resolve_base_type(schema, type_ref)
102
+ return nil if PRIMITIVE_TYPE_NAMES.include?(type_ref.to_s.upcase)
103
+ return find_schema_element(schema, type_ref) if type_ref.is_a?(String)
104
+
105
+ type_ref if type_ref.is_a?(Expressir::Model::ModelElement)
106
+ end
107
+
108
+ def find_schema_element(schema, name)
109
+ SchemaElementLookup.find(schema, name)
110
+ end
111
+ end
112
+
113
+ # Lookup helper shared across Step classes that need to resolve
114
+ # a named schema element (used by Type for underlying-type
115
+ # resolution). Lives behind its own module so Steps don't reach
116
+ # into LinkValidator's private methods.
117
+ module SchemaElementLookup
118
+ def self.find(schema, name)
119
+ collections(schema).each do |collection|
120
+ element = collection&.find { |e| e.id.downcase == name.downcase }
121
+ return element if element
122
+ end
123
+ nil
124
+ end
125
+
126
+ def self.collections(schema)
127
+ [
128
+ schema.entities,
129
+ schema.types,
130
+ schema.constants,
131
+ schema.functions,
132
+ schema.rules,
133
+ schema.procedures,
134
+ schema.subtype_constraints,
135
+ ]
136
+ end
137
+ private_class_method :collections
138
+ end
139
+
140
+ REGISTRY = [Entity, Type].freeze
141
+
142
+ # Returns the Step class that handles +node+, or +nil+ if no
143
+ # registered Step matches.
144
+ def self.for(node)
145
+ REGISTRY.find { |step| step.handles?(node) }
146
+ end
147
+ end
148
+ end
149
+ end
@@ -8,6 +8,8 @@ module Suma
8
8
  keyword_init: true)
9
9
 
10
10
  class LinkValidator
11
+ autoload :Step, "suma/link_validator/step"
12
+
11
13
  def initialize(index)
12
14
  @index = index
13
15
  end
@@ -100,113 +102,34 @@ module Suma
100
102
  end
101
103
 
102
104
  def validate_deep_path(schema, element, path_parts, file, line_idx,
103
- full_link)
105
+ full_link)
104
106
  current = element
105
107
  current_path = "#{schema.id}.#{element.id}"
108
+ context = Step::Context.new(file: file, line: line_idx, link: full_link,
109
+ schema: schema, path: current_path)
106
110
 
107
111
  path_parts.each do |part|
108
- case current
109
- when Expressir::Model::Declarations::Entity
110
- attribute = current.attributes&.find do |a|
111
- a.id.downcase == part.downcase
112
- end
113
-
114
- unless attribute
115
- return LinkValidationResult.new(
116
- file: file,
117
- line: line_idx + 1,
118
- link: full_link,
119
- reason: "Attribute '#{part}' not found in entity '#{current_path}'",
120
- )
121
- end
122
-
123
- current = attribute
124
- current_path += ".#{part}"
125
-
126
- when Expressir::Model::Declarations::Type
127
- underlying = current.underlying_type
128
-
129
- if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
130
- enum_value = underlying.items.find do |e|
131
- e.id.downcase == part.downcase
132
- end
133
-
134
- unless enum_value
135
- return LinkValidationResult.new(
136
- file: file,
137
- line: line_idx + 1,
138
- link: full_link,
139
- reason: "Enumeration value '#{part}' not found in type '#{current_path}'",
140
- )
141
- end
142
-
143
- current = enum_value
144
- current_path += ".#{part}"
145
-
146
- elsif underlying
147
- base_type = find_base_type(schema, underlying)
148
-
149
- unless base_type
150
- return LinkValidationResult.new(
151
- file: file,
152
- line: line_idx + 1,
153
- link: full_link,
154
- reason: "Base type not found for '#{current_path}'",
155
- )
156
- end
157
-
158
- current = base_type
159
-
160
- else
161
- return LinkValidationResult.new(
162
- file: file,
163
- line: line_idx + 1,
164
- link: full_link,
165
- reason: "Cannot navigate deeper from type '#{current_path}'",
166
- )
167
- end
112
+ step = Step.for(current)
113
+ return unsupported_step_failure(context) unless step
168
114
 
169
- else
170
- return LinkValidationResult.new(
171
- file: file,
172
- line: line_idx + 1,
173
- link: full_link,
174
- reason: "Cannot navigate deeper from '#{current_path}'",
175
- )
176
- end
115
+ outcome = step.new.navigate(current, part, context)
116
+ return outcome if outcome.is_a?(LinkValidationResult)
117
+
118
+ current = outcome
119
+ current_path += ".#{part}"
120
+ context.path = current_path
177
121
  end
178
122
 
179
123
  nil
180
124
  end
181
125
 
182
- def find_base_type(schema, type_ref)
183
- return nil if %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
184
- LOGICAL].include?(type_ref.to_s.upcase)
185
-
186
- if type_ref.is_a?(String)
187
- find_schema_element(schema, type_ref)
188
- elsif type_ref.is_a?(Expressir::Model::ModelElement)
189
- type_ref
190
- end
191
- end
192
-
193
- def find_schema_element(schema, element_name)
194
- [
195
- schema.entities,
196
- schema.types,
197
- schema.constants,
198
- schema.functions,
199
- schema.rules,
200
- schema.procedures,
201
- schema.subtype_constraints,
202
- ].each do |collection|
203
- element = collection&.find do |e|
204
- e.id.downcase == element_name.downcase
205
- end
206
- return element if element
207
- end
208
-
209
- nil
126
+ def unsupported_step_failure(context)
127
+ LinkValidationResult.new(
128
+ file: context.file,
129
+ line: context.line + 1,
130
+ link: context.link,
131
+ reason: "Cannot navigate deeper from '#{context.path}'",
132
+ )
210
133
  end
211
134
  end
212
135
  end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "glossarist"
4
+
5
+ module Suma
6
+ # Cleans raw EXPRESS entity remarks into a list of
7
+ # +Glossarist::V3::DetailedDefinition+ notes.
8
+ #
9
+ # Pipeline:
10
+ # 1. trim to first sentence / first complete list
11
+ # 2. convert express xrefs to URN mentions
12
+ # 3. drop redundant "X is a type of {{...}}" opening notes
13
+ # 4. drop notes containing image references or <<...>> blocks
14
+ # 5. drop notes that duplicate the first definition
15
+ #
16
+ # Pure: no I/O, no schema knowledge. The schema domain label and
17
+ # URN composition are injected via +xref_to_mention+ so this module
18
+ # stays independent of +Suma::Urn+ and the surrounding extractor.
19
+ class NoteProcessor
20
+ REDUNDANT_NOTE_REGEX =
21
+ %r{
22
+ ^An? # Starts with "A" or "An"
23
+ \s.*?\sis\sa\stype\sof # Text followed by "is a type of"
24
+ (\sa|\san)? # Optional " a" or " an"
25
+ \s\{\{[^\}]*\}\} # Text in double curly braces
26
+ \s*?\.?$ # Optional whitespace and period at the end
27
+ }x
28
+
29
+ IMAGE_REF_SUBSTRING = "image::"
30
+ DOUBLE_ANGLE_PATTERN = /<<(.*?){1,999}>>/
31
+ SEE_TAIL_PATTERN = /\s+\(see(.*?){1,999}\)/
32
+ XREF_WITH_DISPLAY = /<<express:([\w.]+),([\w. ][\w. ]*)>>/
33
+ XREF_WITHOUT_DISPLAY = /<<express:([\w.]+)>>/
34
+ INLINE_COMMENT_PATTERN = /\n\/\/.*?\n/
35
+ BULLET_LIST_PATTERN = /^\s*[*\-+]\s+/
36
+ NUMBERED_LIST_PATTERN = /^\s*\d+\.\s+/
37
+ CONTINUATION_PLUS = /^\+\s*$/
38
+ CONTINUATION_DASHES = /^--\s*$/
39
+ INDENTED_PATTERN = /^\s{2,}/
40
+ SENTENCE_BREAK_NEWLINE = /\.\n/
41
+ SENTENCE_BREAK_SPACE = /\. /
42
+
43
+ attr_reader :remarks, :definitions, :xref_to_mention
44
+
45
+ def initialize(remarks, definitions:, xref_to_mention:)
46
+ @remarks = remarks
47
+ @definitions = definitions
48
+ @xref_to_mention = xref_to_mention
49
+ end
50
+
51
+ def self.call(remarks, definitions:, xref_to_mention:)
52
+ new(remarks, definitions: definitions, xref_to_mention: xref_to_mention).call
53
+ end
54
+
55
+ def call
56
+ notes = build_initial_notes
57
+ notes = only_keep_first_sentence(notes)
58
+ notes = remove_see_content(notes)
59
+ notes = remove_redundant_note(notes)
60
+ notes = remove_invalid_references(notes)
61
+ compare_with_definitions(notes, definitions)
62
+ end
63
+
64
+ private
65
+
66
+ def build_initial_notes
67
+ trimmed = trim_definition(remarks)
68
+ return [] unless trimmed && !trimmed.empty?
69
+
70
+ [Glossarist::V3::DetailedDefinition.new(content: trimmed)]
71
+ end
72
+
73
+ def trim_definition(definition)
74
+ return nil if definition.nil? || definition.empty?
75
+
76
+ definition_str = array_to_paragraphs(definition)
77
+ return nil if definition_str.empty?
78
+
79
+ paragraphs = definition_str.split("\n\n")
80
+ combine_paragraphs(paragraphs)
81
+ end
82
+
83
+ def array_to_paragraphs(definition)
84
+ definition.is_a?(Array) ? definition.join("\n\n") : definition.to_s
85
+ end
86
+
87
+ def combine_paragraphs(paragraphs)
88
+ combined = first_block(paragraphs)
89
+ combined = "#{combined}\n"
90
+ combined = combined.gsub(INLINE_COMMENT_PATTERN, "\n")
91
+ combined.strip!
92
+ convert_xref(combined)
93
+ end
94
+
95
+ def first_block(paragraphs)
96
+ first_paragraph = paragraphs.first
97
+ return apply_first_sentence_logic(first_paragraph) unless list_header?(paragraphs)
98
+
99
+ complete_list = extract_complete_list(paragraphs, 1)
100
+ "#{first_paragraph}\n\n#{complete_list}"
101
+ end
102
+
103
+ def list_header?(paragraphs)
104
+ paragraphs.length > 1 &&
105
+ paragraphs.first.end_with?(":") &&
106
+ starts_with_list?(paragraphs[1])
107
+ end
108
+
109
+ def apply_first_sentence_logic(paragraph)
110
+ new_content = paragraph
111
+ .split(SENTENCE_BREAK_NEWLINE).first.strip
112
+ .split(SENTENCE_BREAK_SPACE).first.strip
113
+
114
+ new_content.end_with?(".") ? new_content : "#{new_content}."
115
+ end
116
+
117
+ def extract_complete_list(paragraphs, start_index)
118
+ return paragraphs[start_index] if start_index >= paragraphs.length
119
+
120
+ reduce_list_paragraphs(paragraphs, start_index)
121
+ end
122
+
123
+ def reduce_list_paragraphs(paragraphs, start_index)
124
+ combined = paragraphs[start_index].dup
125
+ state = ContinuationState.new(combined.include?("--") && !combined.match?(/--.*--/m))
126
+
127
+ ((start_index + 1)...paragraphs.length).each do |i|
128
+ break unless apply_paragraph_step?(combined, paragraphs[i], state)
129
+ end
130
+
131
+ combined
132
+ end
133
+
134
+ # Returns true if iteration should continue, false to stop.
135
+ def apply_paragraph_step?(combined, para, state)
136
+ case classify_paragraph(para, state)
137
+ when :continuation_toggle
138
+ state.toggle!
139
+ append!(combined, para)
140
+ when :continuation
141
+ append!(combined, para)
142
+ when :list_member
143
+ append!(combined, para)
144
+ state.toggle! if opens_continuation_block?(para)
145
+ else
146
+ return false
147
+ end
148
+ true
149
+ end
150
+
151
+ def append!(combined, para)
152
+ combined << "\n\n#{para}"
153
+ end
154
+
155
+ def opens_continuation_block?(para)
156
+ para.include?("--") && !para.match?(/--.*--/m)
157
+ end
158
+
159
+ def classify_paragraph(para, state)
160
+ return :continuation_toggle if para.match?(CONTINUATION_DASHES) || para.end_with?("--")
161
+ return :continuation if state.in_block?
162
+ return :list_member if starts_with_list?(para) || list_continuation?(para)
163
+
164
+ :stop
165
+ end
166
+
167
+ # Mutable flag tracking whether we are inside a "-- ... --"
168
+ # continuation block while walking paragraphs.
169
+ class ContinuationState
170
+ def initialize(in_block)
171
+ @in_block = in_block
172
+ end
173
+
174
+ def in_block?
175
+ @in_block
176
+ end
177
+
178
+ def toggle!
179
+ @in_block = !@in_block
180
+ end
181
+ end
182
+ private_constant :ContinuationState
183
+
184
+ def list_continuation?(content)
185
+ return false if content.nil? || content.empty?
186
+
187
+ content.match?(CONTINUATION_PLUS) ||
188
+ content.match?(CONTINUATION_DASHES) ||
189
+ content.match?(INDENTED_PATTERN) ||
190
+ content.start_with?("which", "where", "that")
191
+ end
192
+
193
+ def starts_with_list?(content)
194
+ return false if content.nil? || content.empty?
195
+
196
+ content.match?(BULLET_LIST_PATTERN) || content.match?(NUMBERED_LIST_PATTERN)
197
+ end
198
+
199
+ def only_keep_first_sentence(notes)
200
+ notes.each do |note|
201
+ next unless note&.content
202
+ next if preserve_complete_structure?(note.content)
203
+
204
+ new_content = note.content
205
+ .split(SENTENCE_BREAK_NEWLINE).first.strip
206
+ .split(SENTENCE_BREAK_SPACE).first.strip
207
+ note.content = new_content.end_with?(".") ? new_content : "#{new_content}."
208
+ end
209
+ end
210
+
211
+ def preserve_complete_structure?(content)
212
+ return false if content.nil? || content.empty?
213
+
214
+ lines = content.split("\n")
215
+ return false unless first_line_is_header?(lines)
216
+ return false if first_line_has_period?(lines.first.strip)
217
+
218
+ remaining_content = lines[1..].join("\n")
219
+ starts_with_list?(remaining_content.strip)
220
+ end
221
+
222
+ def first_line_is_header?(lines)
223
+ lines.first&.strip&.end_with?(":") && lines.length > 1
224
+ end
225
+
226
+ def first_line_has_period?(first_line)
227
+ first_line.count(".").positive?
228
+ end
229
+
230
+ def remove_see_content(notes)
231
+ notes.each do |note|
232
+ note.content = note.content.gsub(SEE_TAIL_PATTERN, "")
233
+ end
234
+ end
235
+
236
+ def remove_redundant_note(notes)
237
+ notes.reject do |note|
238
+ note.content.match?(REDUNDANT_NOTE_REGEX) &&
239
+ !note.content.include?("\n")
240
+ end
241
+ end
242
+
243
+ def remove_invalid_references(notes)
244
+ notes.reject do |note|
245
+ note.content.include?(IMAGE_REF_SUBSTRING) ||
246
+ note.content.match?(DOUBLE_ANGLE_PATTERN)
247
+ end
248
+ end
249
+
250
+ def compare_with_definitions(notes, definitions)
251
+ return [] if notes&.first&.content == definitions&.first&.content
252
+
253
+ notes
254
+ end
255
+
256
+ def convert_xref(content)
257
+ content
258
+ .gsub(XREF_WITHOUT_DISPLAY) do |_match|
259
+ full_ref = Regexp.last_match(1)
260
+ display = full_ref.split(".").last
261
+ xref_to_mention.call(full_ref, display)
262
+ end
263
+ .gsub(XREF_WITH_DISPLAY) do |_match|
264
+ full_ref = Regexp.last_match(1)
265
+ display = Regexp.last_match(2)
266
+ xref_to_mention.call(full_ref, display)
267
+ end
268
+ end
269
+ end
270
+ end
@@ -91,7 +91,7 @@ module Suma
91
91
  existing_schema = Expressir::Changes::SchemaChange.from_file(output_path)
92
92
  end
93
93
 
94
- converter = EengineConverter.new(xml_path, schema_name)
94
+ converter = EengineConverter.new(schema_name, File.read(xml_path))
95
95
  change_schema = converter.convert(
96
96
  version: options[:version],
97
97
  existing_change_schema: existing_schema,
@@ -6,15 +6,6 @@ require "glossarist"
6
6
 
7
7
  module Suma
8
8
  class TermExtractor
9
- REDUNDANT_NOTE_REGEX =
10
- %r{
11
- ^An? # Starts with "A" or "An"
12
- \s.*?\sis\sa\stype\sof # Text followed by "is a type of"
13
- (\sa|\san)? # Optional " a" or " an"
14
- \s\{\{[^\}]*\}\} # Text in double curly braces
15
- \s*?\.?$ # Optional whitespace and period at the end
16
- }x
17
-
18
9
  def initialize(schema_manifest_file, output_path, urn:,
19
10
  language_code: "eng")
20
11
  @schema_manifest_file = File.expand_path(schema_manifest_file)
@@ -140,7 +131,11 @@ module Suma
140
131
  data.domain = schema_domain
141
132
  data.sources = [source_ref] if source_ref
142
133
 
143
- notes = get_entity_notes(entity, schema_domain, data.definition)
134
+ notes = NoteProcessor.call(
135
+ entity.remarks,
136
+ definitions: data.definition,
137
+ xref_to_mention: method(:xref_to_mention),
138
+ )
144
139
  data.notes = notes if notes && !notes.empty?
145
140
  data.examples = []
146
141
  end
@@ -166,6 +161,10 @@ module Suma
166
161
  "{{#{urn},#{display}}}"
167
162
  end
168
163
 
164
+ def xref_to_mention(full_ref, display)
165
+ urn_mention(express_entity_urn(full_ref), display)
166
+ end
167
+
169
168
  def get_section_ref(schema)
170
169
  return nil unless @urn
171
170
 
@@ -219,183 +218,6 @@ module Suma
219
218
  [Glossarist::V3::DetailedDefinition.new(content: definition)]
220
219
  end
221
220
 
222
- def get_entity_notes(entity, schema_domain, definitions)
223
- notes = []
224
-
225
- if entity.remarks && !entity.remarks.empty?
226
- trimmed_def = trim_definition(entity.remarks)
227
- if trimmed_def && !trimmed_def.empty?
228
- notes << Glossarist::V3::DetailedDefinition.new(
229
- content: convert_express_xref(trimmed_def, schema_domain),
230
- )
231
- end
232
- end
233
-
234
- notes = only_keep_first_sentence(notes)
235
- notes = remove_see_content(notes)
236
- notes = remove_redundant_note(notes)
237
- notes = remove_invalid_references(notes)
238
- compare_with_definitions(notes, definitions)
239
- end
240
-
241
- def only_keep_first_sentence(notes)
242
- notes.each do |note|
243
- if note&.content && should_preserve_complete_structure?(note.content)
244
- next
245
- end
246
-
247
- if note&.content
248
- new_content = note.content
249
- .split(".\n").first.strip
250
- .split(". ").first.strip
251
- note.content = new_content.end_with?(".") ? new_content : "#{new_content}."
252
- end
253
- end
254
- end
255
-
256
- def should_preserve_complete_structure?(content)
257
- return false if content.nil? || content.empty?
258
-
259
- lines = content.split("\n")
260
- first_paragraph = lines.first&.strip
261
-
262
- if first_paragraph&.end_with?(":") && lines.length > 1
263
- if first_paragraph.count(".").positive?
264
- return false
265
- end
266
-
267
- remaining_content = lines[1..].join("\n")
268
- return starts_with_list?(remaining_content.strip)
269
- end
270
-
271
- false
272
- end
273
-
274
- def compare_with_definitions(notes, definitions)
275
- if notes&.first&.content == definitions&.first&.content
276
- return []
277
- end
278
-
279
- notes
280
- end
281
-
282
- def remove_invalid_references(notes)
283
- notes.reject do |note|
284
- note.content.include?("image::") ||
285
- note.content.match?(/<<(.*?){1,999}>>/)
286
- end
287
- end
288
-
289
- def remove_redundant_note(notes)
290
- notes.reject do |note|
291
- note.content.match?(REDUNDANT_NOTE_REGEX) &&
292
- !note.content.include?("\n")
293
- end
294
- end
295
-
296
- def remove_see_content(notes)
297
- notes.each do |note|
298
- note.content = note.content.gsub(/\s+\(see(.*?){1,999}\)/, "")
299
- end
300
- end
301
-
302
- def starts_with_list?(content)
303
- return false if content.nil? || content.empty?
304
-
305
- content.match?(/^\s*[*\-+]\s+/) || content.match?(/^\s*\d+\.\s+/)
306
- end
307
-
308
- def trim_definition(definition)
309
- return nil if definition.nil? || definition.empty?
310
-
311
- definition_str = definition.is_a?(Array) ? definition.join("\n\n") : definition.to_s
312
-
313
- return nil if definition_str.empty?
314
-
315
- paragraphs = definition_str.split("\n\n")
316
- first_paragraph = paragraphs.first
317
-
318
- combined = if paragraphs.length == 1
319
- apply_first_sentence_logic(first_paragraph)
320
- elsif first_paragraph.end_with?(":") && paragraphs.length > 1 && starts_with_list?(paragraphs[1])
321
- complete_list = extract_complete_list(paragraphs, 1)
322
- "#{first_paragraph}\n\n#{complete_list}"
323
- else
324
- apply_first_sentence_logic(first_paragraph)
325
- end
326
-
327
- combined = "#{combined}\n"
328
- combined.gsub!(/\n\/\/.*?\n/, "\n")
329
- combined.strip!
330
-
331
- express_reference_to_mention(combined)
332
- end
333
-
334
- def apply_first_sentence_logic(paragraph)
335
- new_content = paragraph
336
- .split(".\n").first.strip
337
- .split(". ").first.strip
338
-
339
- new_content.end_with?(".") ? new_content : "#{new_content}."
340
- end
341
-
342
- def extract_complete_list(paragraphs, start_index)
343
- return paragraphs[start_index] if start_index >= paragraphs.length
344
-
345
- combined = paragraphs[start_index].dup
346
- current_index = start_index + 1
347
- in_continuation_block = combined.include?("--") && !combined.match?(/--.*--/m)
348
-
349
- while current_index < paragraphs.length
350
- next_para = paragraphs[current_index]
351
-
352
- if next_para.match?(/^--\s*$/) || next_para.end_with?("--")
353
- in_continuation_block = !in_continuation_block
354
- combined += "\n\n#{next_para}"
355
- current_index += 1
356
- next
357
- end
358
-
359
- if in_continuation_block
360
- combined += "\n\n#{next_para}"
361
- current_index += 1
362
- next
363
- end
364
-
365
- if starts_with_list?(next_para) || is_list_continuation?(next_para)
366
- combined += "\n\n#{next_para}"
367
- current_index += 1
368
- in_continuation_block = true if next_para.include?("--") && !next_para.match?(/--.*--/m)
369
- else
370
- break
371
- end
372
- end
373
-
374
- combined
375
- end
376
-
377
- def is_list_continuation?(content)
378
- return false if content.nil? || content.empty?
379
-
380
- content.match?(/^\+\s*$/) ||
381
- content.match?(/^--\s*$/) ||
382
- content.match?(/^\s{2,}/) ||
383
- content.start_with?("which", "where", "that")
384
- end
385
-
386
- def express_reference_to_mention(description)
387
- description
388
- .gsub(/<<express:([\w.]+)>>/) do |_match|
389
- full_ref = Regexp.last_match[1]
390
- entity_id = full_ref.split(".").last
391
- urn_mention(express_entity_urn(full_ref), entity_id)
392
- end.gsub(/<<express:([\w.]+),([\w. ][\w. ]*)>>/) do |_match|
393
- full_ref = Regexp.last_match[1]
394
- display = Regexp.last_match(2)
395
- urn_mention(express_entity_urn(full_ref), display)
396
- end
397
- end
398
-
399
221
  def entity_name_to_text(entity_id)
400
222
  entity_id.downcase.gsub("_", " ")
401
223
  end
@@ -425,13 +247,5 @@ module Suma
425
247
  "#{entity_name_to_text(entity.id)} #{entity_ref}"
426
248
  end
427
249
  end
428
-
429
- def convert_express_xref(content, _schema_domain)
430
- content.gsub(/<<express:([\w.]+),([\w. ][\w. ]*)>>/) do
431
- full_ref = Regexp.last_match(1)
432
- display = Regexp.last_match(2)
433
- urn_mention(express_entity_urn(full_ref), display)
434
- end
435
- end
436
250
  end
437
251
  end
data/lib/suma/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Suma
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/suma.rb CHANGED
@@ -16,6 +16,7 @@ module Suma
16
16
  autoload :LinkValidation, "suma/link_validation"
17
17
  autoload :LinkValidator, "suma/link_validator"
18
18
  autoload :ManifestTraverser, "suma/manifest_traverser"
19
+ autoload :NoteProcessor, "suma/note_processor"
19
20
  autoload :RegisterManifestGenerator, "suma/register_manifest_generator"
20
21
  autoload :SchemaCategory, "suma/schema_category"
21
22
  autoload :SchemaCollection, "suma/schema_collection"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -214,7 +214,9 @@ files:
214
214
  - lib/suma/jsdai/figure_xml.rb
215
215
  - lib/suma/link_validation.rb
216
216
  - lib/suma/link_validator.rb
217
+ - lib/suma/link_validator/step.rb
217
218
  - lib/suma/manifest_traverser.rb
219
+ - lib/suma/note_processor.rb
218
220
  - lib/suma/processor.rb
219
221
  - lib/suma/register_manifest_generator.rb
220
222
  - lib/suma/schema_category.rb