suma 0.2.5 → 0.2.6
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 +4 -4
- data/.github/workflows/rake.yml +3 -0
- data/.github/workflows/release.yml +5 -1
- data/.rubocop_todo.yml +78 -26
- data/CLAUDE.md +76 -0
- data/Gemfile +3 -1
- data/README.adoc +131 -0
- data/lib/suma/cli/build.rb +2 -3
- data/lib/suma/cli/check_svg_quality.rb +178 -0
- data/lib/suma/cli/compare.rb +7 -158
- data/lib/suma/cli/export.rb +1 -7
- data/lib/suma/cli/extract_terms.rb +7 -648
- data/lib/suma/cli/generate_schemas.rb +9 -123
- data/lib/suma/cli/validate_links.rb +15 -290
- data/lib/suma/cli.rb +39 -0
- data/lib/suma/collection_manifest.rb +3 -4
- data/lib/suma/express_schema.rb +43 -30
- data/lib/suma/jsdai/figure_xml.rb +12 -9
- data/lib/suma/jsdai.rb +0 -6
- data/lib/suma/link_validator.rb +203 -0
- data/lib/suma/processor.rb +75 -101
- data/lib/suma/schema_attachment.rb +2 -29
- data/lib/suma/schema_collection.rb +1 -32
- data/lib/suma/schema_comparer.rb +116 -0
- data/lib/suma/schema_document.rb +0 -14
- data/lib/suma/schema_exporter.rb +16 -28
- data/lib/suma/schema_index.rb +53 -0
- data/lib/suma/schema_manifest_generator.rb +105 -0
- data/lib/suma/svg_quality/batch_report.rb +80 -0
- data/lib/suma/svg_quality/formatters/json_formatter.rb +30 -0
- data/lib/suma/svg_quality/formatters/terminal_formatter.rb +168 -0
- data/lib/suma/svg_quality/formatters/yaml_formatter.rb +32 -0
- data/lib/suma/svg_quality/report.rb +52 -0
- data/lib/suma/svg_quality.rb +28 -0
- data/lib/suma/term_extractor.rb +393 -0
- data/lib/suma/utils.rb +10 -2
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +3 -2
- data/suma.gemspec +3 -2
- metadata +33 -7
- data/lib/suma/export_standalone_schema.rb +0 -14
|
@@ -2,27 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require_relative "../thor_ext"
|
|
5
|
-
|
|
6
|
-
require "expressir"
|
|
7
|
-
require "securerandom"
|
|
8
|
-
require "glossarist"
|
|
5
|
+
require_relative "../term_extractor"
|
|
9
6
|
|
|
10
7
|
module Suma
|
|
11
8
|
module Cli
|
|
12
|
-
# ExtractTerms command using Expressir to extract terms into the
|
|
13
|
-
# Glossarist v2 format
|
|
14
9
|
class ExtractTerms < Thor
|
|
15
|
-
# Matches patterns like "A thing is a type of {{entity}}." or
|
|
16
|
-
# "An object is a type of a {{entity}}"
|
|
17
|
-
REDUNDANT_NOTE_REGEX =
|
|
18
|
-
%r{
|
|
19
|
-
^An? # Starts with "A" or "An"
|
|
20
|
-
\s.*?\sis\sa\stype\sof # Text followed by "is a type of"
|
|
21
|
-
(\sa|\san)? # Optional " a" or " an"
|
|
22
|
-
\s\{\{[^\}]*\}\} # Text in double curly braces
|
|
23
|
-
\s*?\.?$ # Optional whitespace and period at the end
|
|
24
|
-
}x
|
|
25
|
-
|
|
26
10
|
desc "extract_terms SCHEMA_MANIFEST_FILE GLOSSARIST_OUTPUT_PATH",
|
|
27
11
|
"Extract terms from SCHEMA_MANIFEST_FILE into " \
|
|
28
12
|
"Glossarist v2 format"
|
|
@@ -30,641 +14,16 @@ module Suma
|
|
|
30
14
|
desc: "Language code for the Glossarist"
|
|
31
15
|
|
|
32
16
|
def extract_terms(schema_manifest_file, output_path)
|
|
33
|
-
|
|
34
|
-
schema_manifest_file = File.expand_path(schema_manifest_file)
|
|
35
|
-
|
|
36
|
-
unless File.exist?(schema_manifest_file)
|
|
17
|
+
unless File.exist?(File.expand_path(schema_manifest_file))
|
|
37
18
|
raise Errno::ENOENT, "Specified SCHEMA_MANIFEST_FILE " \
|
|
38
19
|
"`#{schema_manifest_file}` not found."
|
|
39
20
|
end
|
|
40
21
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def run(schema_manifest_file, output_path, language_code = "eng")
|
|
47
|
-
get_exp_files(schema_manifest_file).map do |exp_file|
|
|
48
|
-
extract(exp_file, output_path, language_code)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def get_exp_files(schema_manifest_file)
|
|
53
|
-
config = Expressir::SchemaManifest.from_file(schema_manifest_file)
|
|
54
|
-
paths = config.schemas.map(&:path)
|
|
55
|
-
|
|
56
|
-
if paths.empty?
|
|
57
|
-
raise Errno::ENOENT, "No EXPRESS files found in " \
|
|
58
|
-
"`#{schema_manifest_file}`."
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
paths
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def extract(exp_file, output_path, language_code)
|
|
65
|
-
exp_path_rel = Pathname.new(exp_file).relative_path_from(Pathname.getwd)
|
|
66
|
-
puts "Building terms: #{exp_path_rel}"
|
|
67
|
-
|
|
68
|
-
repo = Expressir::Express::Parser.from_file(exp_file)
|
|
69
|
-
schema = get_default_schema(repo)
|
|
70
|
-
|
|
71
|
-
unless schema.file
|
|
72
|
-
raise Error.new("Schema must have an associated file")
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
collection = build_managed_concept_collection(
|
|
76
|
-
schema, language_code
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
output_data(collection, output_path)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def output_data(collection, output_path)
|
|
83
|
-
unless File.exist?(output_path)
|
|
84
|
-
FileUtils.mkdir_p(File.expand_path(output_path))
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
puts "Saving collection to files in: #{output_path}"
|
|
88
|
-
collection.save_to_files(File.expand_path(output_path))
|
|
89
|
-
|
|
90
|
-
collection
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def build_managed_concept_collection(schema, language_code)
|
|
94
|
-
Glossarist::ManagedConceptCollection.new.tap do |collection|
|
|
95
|
-
# Extract schema-level citation data once to reuse across all entities
|
|
96
|
-
source_ref = get_source_ref(schema)
|
|
97
|
-
|
|
98
|
-
# Create one concept per entity
|
|
99
|
-
schema.entities.each do |entity|
|
|
100
|
-
localized_concept = build_localized_concept(
|
|
101
|
-
schema: schema,
|
|
102
|
-
entity: entity,
|
|
103
|
-
language_code: language_code,
|
|
104
|
-
source_ref: source_ref,
|
|
105
|
-
)
|
|
106
|
-
localized_concept_id = get_localized_concept_identifier(
|
|
107
|
-
schema, entity, language_code
|
|
108
|
-
)
|
|
109
|
-
localized_concept.uuid = localized_concept_id
|
|
110
|
-
|
|
111
|
-
managed_data = Glossarist::ManagedConceptData.new.tap do |data|
|
|
112
|
-
data.id = get_entity_identifier(schema, entity)
|
|
113
|
-
|
|
114
|
-
# TODO: Why do we need both localizations and localized_concepts??
|
|
115
|
-
data.localizations[language_code] = localized_concept
|
|
116
|
-
# uuid is automatically set from the serialization of the object
|
|
117
|
-
data.localized_concepts = {
|
|
118
|
-
language_code => localized_concept_id,
|
|
119
|
-
}
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
managed_concept = Glossarist::ManagedConcept.new.tap do |concept|
|
|
123
|
-
# uuid is automatically set from the serialization of the object
|
|
124
|
-
concept.id = get_entity_identifier(schema, entity)
|
|
125
|
-
concept.uuid = concept.id
|
|
126
|
-
concept.data = managed_data
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
collection.store(managed_concept)
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def build_localized_concept(schema:, entity:, language_code:, source_ref:)
|
|
135
|
-
schema_domain = get_domain(schema)
|
|
136
|
-
|
|
137
|
-
localized_concept_data = Glossarist::ConceptData.new.tap do |data|
|
|
138
|
-
data.terms = get_entity_terms(entity)
|
|
139
|
-
data.definition = get_entity_definitions(entity, schema)
|
|
140
|
-
data.language_code = language_code
|
|
141
|
-
data.domain = schema_domain
|
|
142
|
-
data.sources = [source_ref] if source_ref
|
|
143
|
-
|
|
144
|
-
# Only assign optional fields if they have content
|
|
145
|
-
notes = get_entity_notes(entity, schema_domain, data.definition)
|
|
146
|
-
data.notes = notes if notes && !notes.empty?
|
|
147
|
-
|
|
148
|
-
# examples = get_entity_examples(entity, schema_domain)
|
|
149
|
-
# data.examples = examples if examples && !examples.empty?
|
|
150
|
-
data.examples = []
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
Glossarist::LocalizedConcept.new.tap do |concept|
|
|
154
|
-
concept.data = localized_concept_data
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# We only deal with 1 schema
|
|
159
|
-
def get_default_schema(repo)
|
|
160
|
-
repo.schemas.first
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def find_remark_value(schema, remark_id)
|
|
164
|
-
schema.remark_items.find { |s| s.id == remark_id }&.remarks&.first
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def get_entity_identifier(schema, entity)
|
|
168
|
-
"#{schema.id}.#{entity.id}"
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def get_localized_concept_identifier(schema, entity, lang)
|
|
172
|
-
"#{schema.id}.#{entity.id}-#{lang}"
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def get_source_ref(schema)
|
|
176
|
-
origin = Glossarist::Citation.new.tap do |citation|
|
|
177
|
-
citation.ref = "ISO 10303"
|
|
178
|
-
custom_locality = build_custom_locality(schema)
|
|
179
|
-
|
|
180
|
-
unless custom_locality.empty?
|
|
181
|
-
citation.custom_locality = custom_locality
|
|
182
|
-
end
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
Glossarist::ConceptSource.new(type: "authoritative", origin: origin)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# SCHEMA action_schema
|
|
189
|
-
# '{iso standard 10303 part(41) version(9) object(1) action-schema(1)}';
|
|
190
|
-
def build_custom_locality(schema)
|
|
191
|
-
[].tap do |localities|
|
|
192
|
-
# Add schema name
|
|
193
|
-
localities << Glossarist::CustomLocality.new(
|
|
194
|
-
name: "schema",
|
|
195
|
-
value: schema.id,
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
# Add version if available
|
|
199
|
-
version_item = schema.version.items.detect { |i| i.name == "version" }
|
|
200
|
-
if version_item
|
|
201
|
-
localities << Glossarist::CustomLocality.new(
|
|
202
|
-
name: "version",
|
|
203
|
-
value: version_item.value,
|
|
204
|
-
)
|
|
205
|
-
end
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
# TODO: What if this was a "bom"?
|
|
210
|
-
def get_domain(schema)
|
|
211
|
-
prefix = if mim?(schema.id) || arm?(schema.id)
|
|
212
|
-
"application module"
|
|
213
|
-
else
|
|
214
|
-
"resource"
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
"#{prefix}: #{schema.id}"
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def arm?(schema_id)
|
|
221
|
-
schema_id.end_with?("_arm")
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def mim?(schema_id)
|
|
225
|
-
schema_id.end_with?("_mim")
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
def get_terms(schema)
|
|
229
|
-
schema_title = get_title(schema)
|
|
230
|
-
if schema_title
|
|
231
|
-
[
|
|
232
|
-
Glossarist::Designation::Base.new(
|
|
233
|
-
designation: schema_title,
|
|
234
|
-
type: "expression",
|
|
235
|
-
normative_status: "preferred",
|
|
236
|
-
),
|
|
237
|
-
]
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def get_entity_terms(entity)
|
|
242
|
-
# For now, use the entity ID as the term
|
|
243
|
-
# This could be enhanced to look for entity-specific title remark items
|
|
244
|
-
[
|
|
245
|
-
Glossarist::Designation::Base.new(
|
|
246
|
-
designation: entity.id,
|
|
247
|
-
type: "expression",
|
|
248
|
-
normative_status: "preferred",
|
|
249
|
-
),
|
|
250
|
-
]
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def get_entity_definitions(entity, schema)
|
|
254
|
-
schema_type = extract_file_type(schema.file)
|
|
255
|
-
schema_domain = get_domain(schema)
|
|
256
|
-
|
|
257
|
-
definition = generate_entity_definition(entity, schema_domain,
|
|
258
|
-
schema_type)
|
|
259
|
-
[Glossarist::DetailedDefinition.new(content: definition)]
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def get_entity_notes(entity, schema_domain, definitions)
|
|
263
|
-
puts "Extracting notes for entity: #{entity.id}"
|
|
264
|
-
notes = []
|
|
265
|
-
|
|
266
|
-
notes = add_entity_notes(entity, schema_domain, notes)
|
|
267
|
-
# notes = add_other_notes(entity, schema_domain, notes)
|
|
268
|
-
notes = only_keep_first_sentence(notes)
|
|
269
|
-
notes = remove_see_content(notes)
|
|
270
|
-
notes = remove_redundant_note(notes)
|
|
271
|
-
notes = remove_invalid_references(notes)
|
|
272
|
-
compare_with_definitions(notes, definitions)
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def add_entity_notes(entity, schema_domain, notes)
|
|
276
|
-
# Add trimmed definition from entity description as first note
|
|
277
|
-
if entity.remarks && !entity.remarks.empty?
|
|
278
|
-
trimmed_def = trim_definition(entity.remarks)
|
|
279
|
-
if trimmed_def && !trimmed_def.empty?
|
|
280
|
-
notes << Glossarist::DetailedDefinition.new(
|
|
281
|
-
content: convert_express_xref(trimmed_def, schema_domain),
|
|
282
|
-
)
|
|
283
|
-
end
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
notes.compact
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def add_other_notes(entity, schema_domain, notes)
|
|
290
|
-
# Add other notes from entity remarks
|
|
291
|
-
other_notes = [
|
|
292
|
-
entity.remark_items&.select do |ri|
|
|
293
|
-
ri.id == "__note"
|
|
294
|
-
end&.map(&:remarks),
|
|
295
|
-
].flatten.compact
|
|
296
|
-
|
|
297
|
-
other_notes.each do |note|
|
|
298
|
-
notes << Glossarist::DetailedDefinition.new(
|
|
299
|
-
content: convert_express_xref(note, schema_domain),
|
|
300
|
-
)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
notes
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# https://github.com/metanorma/iso-10303/issues/621
|
|
307
|
-
# 1. First sentence in first paragraph of the entity description
|
|
308
|
-
# (in EXPRESS remark) becomes NOTE 1 in ISO 10303-2 of the entity.
|
|
309
|
-
def only_keep_first_sentence(notes)
|
|
310
|
-
notes.each do |note|
|
|
311
|
-
# Skip truncation only for content that starts with a paragraph ending in ":"
|
|
312
|
-
# followed by a list (complete list structures that should be preserved)
|
|
313
|
-
if note&.content && should_preserve_complete_structure?(note.content)
|
|
314
|
-
# For complete list structures, keep the content as-is
|
|
315
|
-
next
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
# Split by period and take the first sentence for all other content
|
|
319
|
-
# Avoid splitting by pattern like "abc.def"
|
|
320
|
-
if note&.content
|
|
321
|
-
new_content = note.content
|
|
322
|
-
.split(".\n").first.strip
|
|
323
|
-
.split(". ").first.strip
|
|
324
|
-
note.content = if new_content.end_with?(".")
|
|
325
|
-
new_content
|
|
326
|
-
else
|
|
327
|
-
"#{new_content}."
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def should_preserve_complete_structure?(content)
|
|
334
|
-
return false if content.nil? || content.empty?
|
|
335
|
-
|
|
336
|
-
# Check if content starts with a single introductory sentence ending in ":"
|
|
337
|
-
# followed by a list. This indicates a complete list structure that should be preserved.
|
|
338
|
-
lines = content.split("\n")
|
|
339
|
-
first_paragraph = lines.first&.strip
|
|
340
|
-
|
|
341
|
-
# Look for pattern: Single sentence ending with ":" (introductory pattern)
|
|
342
|
-
if first_paragraph&.end_with?(":") && lines.length > 1
|
|
343
|
-
# Check if the first paragraph contains multiple sentences (periods before the colon)
|
|
344
|
-
# If it does, this is NOT an introductory paragraph - extract first sentence only
|
|
345
|
-
if first_paragraph.count(".").positive?
|
|
346
|
-
return false
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
# Check if there's a list after the colon
|
|
350
|
-
remaining_content = lines[1..].join("\n")
|
|
351
|
-
return starts_with_list?(remaining_content.strip)
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
false
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
# https://github.com/metanorma/iso-10303/issues/621
|
|
358
|
-
# 2. If this first sentence matches the 7-word magic sentence
|
|
359
|
-
# (2-3 forms of that), it is discarded so there will not be a NOTE 1.
|
|
360
|
-
def compare_with_definitions(notes, definitions)
|
|
361
|
-
if notes&.first&.content == definitions&.first&.content
|
|
362
|
-
# Discarding first note as it matches the definition
|
|
363
|
-
return []
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
notes
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# https://github.com/metanorma/iso-10303/issues/621
|
|
370
|
-
# 3. No reference to any types or attribute or figures allowed in first
|
|
371
|
-
# sentence. Entity references “{{…}}” are allowed.
|
|
372
|
-
def remove_invalid_references(notes)
|
|
373
|
-
notes.reject do |note|
|
|
374
|
-
note.content.include?("image::") ||
|
|
375
|
-
note.content.match?(/<<(.*?){1,999}>>/)
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
# https://github.com/metanorma/iso-10303/issues/621
|
|
380
|
-
# 4. Entity notes and examples in EXPRESS remarks are NOT represented in
|
|
381
|
-
# part 2.
|
|
382
|
-
def remove_redundant_note(notes)
|
|
383
|
-
notes.reject do |note|
|
|
384
|
-
note.content.match?(REDUNDANT_NOTE_REGEX) &&
|
|
385
|
-
!note.content.include?("\n")
|
|
386
|
-
end
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
# https://github.com/metanorma/iso-10303/issues/621
|
|
390
|
-
# 5. If the sentence contains “\s+(see …)”, the contents including the
|
|
391
|
-
# parentheses are removed.
|
|
392
|
-
def remove_see_content(notes)
|
|
393
|
-
notes.each do |note|
|
|
394
|
-
note.content = note.content.gsub(/\s+\(see(.*?){1,999}\)/, "")
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
def get_entity_examples(entity, schema_domain)
|
|
399
|
-
examples = entity.remark_items&.select do |ri|
|
|
400
|
-
ri.id == "__example"
|
|
401
|
-
end&.map(&:remarks)&.flatten&.compact || []
|
|
402
|
-
|
|
403
|
-
examples.map do |example|
|
|
404
|
-
Glossarist::DetailedDefinition.new(
|
|
405
|
-
content: convert_express_xref(example, schema_domain),
|
|
406
|
-
)
|
|
407
|
-
end
|
|
408
|
-
end
|
|
409
|
-
|
|
410
|
-
def extract_file_type(filename)
|
|
411
|
-
match = filename.match(/(arm|mim|bom)_annotated\.exp$/)
|
|
412
|
-
return "resource" unless match
|
|
413
|
-
|
|
414
|
-
{
|
|
415
|
-
"arm" => "module_arm",
|
|
416
|
-
"mim" => "module_mim",
|
|
417
|
-
"bom" => "business_object_model",
|
|
418
|
-
}[match.captures[0]] || "resource"
|
|
419
|
-
end
|
|
420
|
-
|
|
421
|
-
def get_schema_type(schema)
|
|
422
|
-
return "mim" if mim?(schema.id)
|
|
423
|
-
return "arm" if arm?(schema.id)
|
|
424
|
-
return "bom" if bom?(schema.id)
|
|
425
|
-
|
|
426
|
-
"resource"
|
|
427
|
-
end
|
|
428
|
-
|
|
429
|
-
def bom?(schema_id)
|
|
430
|
-
schema_id.end_with?("_bom")
|
|
431
|
-
end
|
|
432
|
-
|
|
433
|
-
def contains_list?(content)
|
|
434
|
-
return false if content.nil? || content.empty?
|
|
435
|
-
|
|
436
|
-
# Check if content contains list markers
|
|
437
|
-
content.match?(/^\s*[\*\-\+]\s+/m) || content.match?(/^\s*\d+\.\s+/m)
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
def starts_with_list?(content)
|
|
441
|
-
return false if content.nil? || content.empty?
|
|
442
|
-
|
|
443
|
-
# Check if content starts with list markers
|
|
444
|
-
content.match?(/^\s*[\*\-\+]\s+/) || content.match?(/^\s*\d+\.\s+/)
|
|
445
|
-
end
|
|
446
|
-
|
|
447
|
-
def is_list_continuation?(content)
|
|
448
|
-
return false if content.nil? || content.empty?
|
|
449
|
-
|
|
450
|
-
# Check for AsciiDoc list continuation patterns
|
|
451
|
-
content.match?(/^\+\s*$/) ||
|
|
452
|
-
content.match?(/^--\s*$/) ||
|
|
453
|
-
content.match?(/^\s{2,}/) || # Indented content (continuation)
|
|
454
|
-
content.start_with?("which", "where", "that") # Logical continuation
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
def extract_complete_list(paragraphs, start_index)
|
|
458
|
-
return paragraphs[start_index] if start_index >= paragraphs.length
|
|
459
|
-
|
|
460
|
-
combined = paragraphs[start_index].dup
|
|
461
|
-
current_index = start_index + 1
|
|
462
|
-
|
|
463
|
-
# Check if the first paragraph already contains an opening continuation block
|
|
464
|
-
in_continuation_block = combined.include?("--") && !combined.match?(/--.*--/m)
|
|
465
|
-
|
|
466
|
-
# Continue collecting paragraphs while we're in a list context
|
|
467
|
-
while current_index < paragraphs.length
|
|
468
|
-
next_para = paragraphs[current_index]
|
|
469
|
-
|
|
470
|
-
# Check if we're entering or exiting a continuation block
|
|
471
|
-
if next_para.match?(/^--\s*$/) || next_para.end_with?("--")
|
|
472
|
-
in_continuation_block = !in_continuation_block
|
|
473
|
-
combined += "\n\n#{next_para}"
|
|
474
|
-
current_index += 1
|
|
475
|
-
next
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
# If we're in a continuation block, include all content until we hit the closing --
|
|
479
|
-
if in_continuation_block
|
|
480
|
-
combined += "\n\n#{next_para}"
|
|
481
|
-
current_index += 1
|
|
482
|
-
next
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# Check if this is a list item or list continuation
|
|
486
|
-
if starts_with_list?(next_para) || is_list_continuation?(next_para)
|
|
487
|
-
combined += "\n\n#{next_para}"
|
|
488
|
-
current_index += 1
|
|
489
|
-
|
|
490
|
-
# Check if this paragraph contains an opening continuation block
|
|
491
|
-
if next_para.include?("--") && !next_para.match?(/--.*--/m)
|
|
492
|
-
in_continuation_block = true
|
|
493
|
-
end
|
|
494
|
-
else
|
|
495
|
-
# This paragraph is not part of the list structure
|
|
496
|
-
break
|
|
497
|
-
end
|
|
498
|
-
end
|
|
499
|
-
|
|
500
|
-
combined
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
def ends_list_structure?(current_para, next_para)
|
|
504
|
-
return true if next_para.nil?
|
|
505
|
-
|
|
506
|
-
# List ends if:
|
|
507
|
-
# 1. Current paragraph doesn't end with continuation markers
|
|
508
|
-
# 2. Next paragraph starts a new section (not list or continuation)
|
|
509
|
-
!current_para.match?(/\+\s*$/) &&
|
|
510
|
-
!starts_with_list?(next_para) &&
|
|
511
|
-
!is_list_continuation?(next_para)
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
def apply_first_sentence_logic(paragraph)
|
|
515
|
-
# Apply the original first-sentence extraction logic
|
|
516
|
-
# Split by period and take the first sentence
|
|
517
|
-
# Avoid splitting by pattern like "abc.def"
|
|
518
|
-
new_content = paragraph
|
|
519
|
-
.split(".\n").first.strip
|
|
520
|
-
.split(". ").first.strip
|
|
521
|
-
|
|
522
|
-
if new_content.end_with?(".")
|
|
523
|
-
new_content
|
|
524
|
-
else
|
|
525
|
-
"#{new_content}."
|
|
526
|
-
end
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
# rubocop:disable Metrics/MethodLength
|
|
530
|
-
def combine_paragraphs(full_paragraph, next_paragraph)
|
|
531
|
-
# Check if we're dealing with a list structure
|
|
532
|
-
if contains_list?(full_paragraph) || starts_with_list?(next_paragraph)
|
|
533
|
-
return combine_list_content(full_paragraph, next_paragraph)
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
# For regular paragraphs, apply the original first-sentence logic
|
|
537
|
-
# If full_paragraph already contains a period, extract that.
|
|
538
|
-
if m = full_paragraph.match(/\A(?<inner_first>[^\n]*?\.)\s/)
|
|
539
|
-
if m[:inner_first]
|
|
540
|
-
return m[:inner_first]
|
|
541
|
-
else
|
|
542
|
-
return full_paragraph
|
|
543
|
-
end
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
# If full_paragraph ends with a period, this is the last.
|
|
547
|
-
if /\.\s*\Z/.match?(full_paragraph)
|
|
548
|
-
return full_paragraph
|
|
549
|
-
end
|
|
550
|
-
|
|
551
|
-
# If next_paragraph is a continuation of a paragraph
|
|
552
|
-
if next_paragraph&.start_with?("which", "where", "that")
|
|
553
|
-
return "#{full_paragraph}\n\n#{next_paragraph}"
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
full_paragraph
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
def combine_list_content(full_paragraph, next_paragraph)
|
|
560
|
-
combined = full_paragraph.dup
|
|
561
|
-
|
|
562
|
-
# If we have a next paragraph, add it
|
|
563
|
-
unless next_paragraph.nil? || next_paragraph.empty?
|
|
564
|
-
combined += "\n\n#{next_paragraph}"
|
|
565
|
-
end
|
|
566
|
-
|
|
567
|
-
combined
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
def trim_definition(definition)
|
|
571
|
-
return nil if definition.nil? || definition.empty?
|
|
572
|
-
|
|
573
|
-
# Handle case where definition is an array
|
|
574
|
-
definition_str = if definition.is_a?(Array)
|
|
575
|
-
definition.join("\n\n")
|
|
576
|
-
else
|
|
577
|
-
definition.to_s
|
|
578
|
-
end
|
|
579
|
-
|
|
580
|
-
return nil if definition_str.empty?
|
|
581
|
-
|
|
582
|
-
paragraphs = definition_str.split("\n\n")
|
|
583
|
-
first_paragraph = paragraphs.first
|
|
584
|
-
|
|
585
|
-
# If we only have one paragraph, apply the original logic
|
|
586
|
-
if paragraphs.length == 1
|
|
587
|
-
combined = apply_first_sentence_logic(first_paragraph)
|
|
588
|
-
elsif first_paragraph.end_with?(":") && paragraphs.length > 1 && starts_with_list?(paragraphs[1])
|
|
589
|
-
# Case 1: First paragraph ends with ":" and leads into a list
|
|
590
|
-
# Extract the complete list structure (this is an introductory paragraph)
|
|
591
|
-
complete_list = extract_complete_list(paragraphs, 1)
|
|
592
|
-
combined = "#{first_paragraph}\n\n#{complete_list}"
|
|
593
|
-
else
|
|
594
|
-
# Case 2: For all other cases (including sentences followed by lists)
|
|
595
|
-
# Extract only the first sentence from the first paragraph
|
|
596
|
-
combined = apply_first_sentence_logic(first_paragraph)
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
# Remove comments until end of line
|
|
600
|
-
combined = "#{combined}\n"
|
|
601
|
-
combined.gsub!(/\n\/\/.*?\n/, "\n")
|
|
602
|
-
combined.strip!
|
|
603
|
-
|
|
604
|
-
express_reference_to_mention(combined)
|
|
605
|
-
end
|
|
606
|
-
# rubocop:enable Metrics/MethodLength
|
|
607
|
-
|
|
608
|
-
# Replace `<<express:{schema}.{entity}>>` with {{entity}}
|
|
609
|
-
# and `<<express:{schema}.{entity},{render}>>` with {{entity,render}}
|
|
610
|
-
def express_reference_to_mention(description)
|
|
611
|
-
# TODO: Use Expressir to check whether the "entity" is really an
|
|
612
|
-
# EXPRESS ENTITY. If not, skip the mention.
|
|
613
|
-
description
|
|
614
|
-
.gsub(/<<express:([^,]+)>>/) do |_match|
|
|
615
|
-
"{{#{Regexp.last_match[1].split('.').last}}}"
|
|
616
|
-
end.gsub(/<<express:([^,]+),([^>]+)>>/) do |_match|
|
|
617
|
-
"{{#{Regexp.last_match[1].split('.').last}," \
|
|
618
|
-
"#{Regexp.last_match[2]}}}"
|
|
619
|
-
end
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
def entity_name_to_text(entity_id)
|
|
623
|
-
entity_id.downcase.gsub("_", " ")
|
|
624
|
-
end
|
|
625
|
-
|
|
626
|
-
# rubocop:disable Layout/LineLength
|
|
627
|
-
def generate_entity_definition(entity, _domain, schema_type)
|
|
628
|
-
return "" if entity.nil?
|
|
629
|
-
|
|
630
|
-
# See: metanorma/iso-10303-2#90
|
|
631
|
-
entity_type = case schema_type
|
|
632
|
-
when "module_arm"
|
|
633
|
-
"{{application object}}"
|
|
634
|
-
when "module_mim"
|
|
635
|
-
"{{entity data type}}"
|
|
636
|
-
when "resource", "business_object_model"
|
|
637
|
-
"{{entity data type}}"
|
|
638
|
-
else
|
|
639
|
-
raise Error.new("[suma] encountered unsupported schema_type")
|
|
640
|
-
end
|
|
641
|
-
|
|
642
|
-
if entity.subtype_of.empty?
|
|
643
|
-
"#{entity_type} " \
|
|
644
|
-
"that represents the " \
|
|
645
|
-
"#{entity_name_to_text(entity.id)} {{entity}}"
|
|
646
|
-
else
|
|
647
|
-
entity_subtypes = entity.subtype_of.map do |e|
|
|
648
|
-
"{{#{e.id}}}"
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
"#{entity_type} that is a type of " \
|
|
652
|
-
"#{entity_subtypes.join(' and ')} " \
|
|
653
|
-
"that represents the " \
|
|
654
|
-
"#{entity_name_to_text(entity.id)} {{entity}}"
|
|
655
|
-
end
|
|
656
|
-
end
|
|
657
|
-
# rubocop:enable Layout/LineLength
|
|
658
|
-
|
|
659
|
-
def convert_express_xref(content, schema_domain)
|
|
660
|
-
content.gsub(/<<express:(.*),(.*)>>/) do
|
|
661
|
-
"{{<#{schema_domain}>" \
|
|
662
|
-
"#{Regexp.last_match(1).split('.').last},#{Regexp.last_match(2)}}}"
|
|
663
|
-
end
|
|
664
|
-
end
|
|
665
|
-
|
|
666
|
-
def id_from_designation(designation)
|
|
667
|
-
designation.gsub(" ", "_").gsub("/", "_").gsub(":", "_")
|
|
22
|
+
TermExtractor.new(
|
|
23
|
+
schema_manifest_file,
|
|
24
|
+
output_path,
|
|
25
|
+
language_code: options[:language_code],
|
|
26
|
+
).call
|
|
668
27
|
end
|
|
669
28
|
end
|
|
670
29
|
end
|