glossarist 2.10.3 → 2.11.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: fb1aa6f911f51d2a58777a0b48615d94bdf84f46cf434f7850ffbd022b8ad0ff
4
- data.tar.gz: 9129cf0ef1e397c530795c33d702de64836f6728aa3825e5e255e3c64fb6dfa0
3
+ metadata.gz: b70c846aebbbbd0af25b5ba83b94ea880631518cf60b7a8ce39911275ab44ee4
4
+ data.tar.gz: bada2553c608f3816aa6532313cededc476680e52f5efdf6bf0285061340ade9
5
5
  SHA512:
6
- metadata.gz: c9c6fccba7097d7572c7715f499e8be577557fd936fd3f7bc6573427a1e498eb859533a1506a3c19e28196d3ba85381509fde73d9d0fb67a9d89f65bd24acb6e
7
- data.tar.gz: 7f42bfe0f602950647f08521a2e084480497d57e0d94e9b62cbfb07bd65a4fc7e9eda51a4f0640b5d37360d6f71db0d360160a0558cf3bf469092aceed4e1689
6
+ metadata.gz: 40bd8e71cbb34e419bb1ed4d82f1474666c82d29467afbc9b6e5537f7e706a61f8d53fb6682a25a65040b1e89d7b59ecacec2f9af77e9305ec2ce5f561c6f769
7
+ data.tar.gz: e39ba317e19833514190bcd693538b44f75241f07ba03ca25e261ac56efaa3671acb2802a505743cfc1bc4d0e52f0a3f11d8f7765be1a64ad44ecd0d8a01f106
data/CLAUDE.md CHANGED
@@ -61,7 +61,7 @@ Designation inheritance hierarchy (MECE):
61
61
 
62
62
  ### Dataset Loading
63
63
 
64
- - **`GlossaryStore`** (`glossary_store.rb`) — the dataset abstraction. Backed by `Lutaml::Store::PackageStore`, handles loading/saving from directories and ZIPs, concept CRUD, metadata, bibliography, images, and stats. Format detection is model-driven via `ConceptDocument.for_version`. **All callers that need to load concepts from a dataset should use GlossaryStore.**
64
+ - **`GlossaryStore`** (`glossary_store.rb`) — the dataset abstraction. Backed by `Lutaml::Store::PackageStore`, handles loading/saving from directories and ZIPs, concept CRUD, metadata, bibliography, images, dataset-level non-verbal entities (`#figures`, `#tables`, `#formulas` lazy-loaded from `figures/`/`tables/`/`formulas/` subdirectories), and stats. Format detection is model-driven via `ConceptDocument.for_version`. **All callers that need to load concepts from a dataset should use GlossaryStore.**
65
65
  - **`ConceptCollector`** (`concept_collector.rb`) — legacy scanner with hand-rolled format detection (file-system heuristics, not model-driven). Being replaced by GlossaryStore. Do not add new callers.
66
66
  - **`ConceptManager`** (`concept_manager.rb`) — legacy file I/O used by `ManagedConceptCollection`. Being replaced by GlossaryStore. Do not add new callers.
67
67
  - **`ManagedConceptCollection`** (`managed_concept_collection.rb`) — legacy collection that loads via ConceptManager. Being replaced by GlossaryStore.
@@ -132,7 +132,8 @@ The `exe/glossarist` executable uses Thor. Commands:
132
132
 
133
133
  - **`ConceptToTbxTransform`** (`transforms/concept_to_tbx_transform.rb`) — converts ManagedConcept to TBX-XML using the tbx gem (ISO 30042:2019). Produces `Tbx::ConceptEntry` per concept or `Tbx::Document` for full export.
134
134
  - **`ConceptToSkosTransform`** (`transforms/concept_to_skos_transform.rb`) — converts ManagedConcept to SKOS RDF using `Glossarist::Rdf::SkosConcept`. Has `transform` (single) and `transform_document` (batch, returns `SkosVocabulary`). Produces JSON-LD and Turtle via the unified `rdf` DSL.
135
- - **SKOS/RDF models** (`lib/glossarist/rdf/`) — `SkosConcept`, `SkosVocabulary` (ConceptScheme container), `LocalizedLiteral` (language-tagged value), namespace classes.
135
+ - **`ConceptToGlossTransform`** (`transforms/concept_to_gloss_transform.rb`) — converts ManagedConcept and dataset-level non-verbal entities to ontology-faithful RDF using `Glossarist::Rdf::Gloss*` view classes. `transform_document(concepts, figures:, tables:, formulas:)` and `to_turtle`/`to_jsonld` accept figures/tables/formulas kwargs (backward compatible — empty defaults). Uses a `DESIGNATION_BUILDERS` registry keyed by exact Designation subclass (OCP) instead of a case/when switch.
136
+ - **SKOS/RDF models** (`lib/glossarist/rdf/`) — `GlossConcept`, `GlossLocalizedConcept`, `GlossDesignation` and subclasses, `GlossFigure`/`GlossTable`/`GlossFormula`/`GlossFigureImage` (dataset-level non-verbal entities per concept-model v3.1.0 K1/K2 shapes), `SkosConcept`, `SkosVocabulary`, `LocalizedLiteral`, namespace classes. `EmitsExtraTriples` is autoloaded from `lutaml_ext.rb` via the immediate parent namespace file (`rdf.rb`); `LutamlTurtleTransformExt` is prepended into `Lutaml::Turtle::Transform` so the framework asks each instance for extra triples.
136
137
  - TBX, Turtle, JSON-LD, JSONL export all write a single document file; JSON writes per-concept files.
137
138
 
138
139
  ### Dependencies
@@ -142,14 +143,28 @@ The `exe/glossarist` executable uses Thor. Commands:
142
143
  - `relaton` (>= 2.0.0, < 3) — bibliography database integration
143
144
  - `thor` — CLI commands
144
145
 
145
- ## Gemfile Notes
146
+ ## Dependencies
146
147
 
147
- The Gemfile overrides relaton gems from git branches for lutaml-model 0.8 compatibility:
148
- - 5 repos use `fix/lutaml-model-0.8` branches (relaton-bib, relaton-iso, relaton-3gpp, relaton-bipm, relaton-bsi)
149
- - 5 repos use `lutaml-integration` branches (relaton-calconnect, relaton-ccsds, relaton-cen, relaton-iec, relaton-itu)
150
- - Released 2.0.0 gems have untyped lutaml-model attributes that fail with 0.8+
151
- - relaton-bib 2.1.0 is released but sub-gems pin `~> 2.0.0`, blocking 2.1.0 adoption until upstream updates constraints
152
- - Remove git overrides once relaton gems release versions with lutaml-model 0.8 support
148
+ - `relaton` (>= 2.0.0, < 3) bibliography database integration. Upstream
149
+ shipped 2.1.0 with lutaml-model 0.8 compatibility, so the historical
150
+ git-branch overrides are no longer needed and the Gemfile pins the
151
+ released gem directly.
152
+ - `lutaml-model` (~> 0.8.5), `lutaml-store` (~> 0.2.0) serialization
153
+ framework (YAML/XML/JSON-LD/Turtle) and package store.
154
+ - `tbx` — ISO 30042:2019 TBX model classes.
155
+ - `thor` — CLI commands.
156
+ - `rdf-turtle` (~> 3.3), `shacl` (~> 0.4) — Turtle emission + SHACL
157
+ validation against vendored concept-model shapes.
158
+
159
+ ## SchemaMigration module split
160
+
161
+ `SchemaMigration` is a thin facade over three single-concern modules:
162
+
163
+ - `SchemaMigration::V0ToV1` — hash-to-hash transform (IEV legacy YAML → V1 shape). Single `#migrate` entry point.
164
+ - `SchemaMigration::V2ToV3` — model-to-model transform (`V2::ManagedConcept` → `V3::ManagedConcept`). Class methods `migrate_concept` / `concept_version`.
165
+ - `SchemaMigration::CliPipeline` — file I/O + dispatch + output writing for `glossarist upgrade`. Single `#run` entry point; output can be a directory of YAML files or a `.gcr` ZIP package.
166
+
167
+ The facade preserves the historical class-method API (`SchemaMigration.new`, `.migrate_concept`, `.concept_version`, `.upgrade_directory`) so existing callers don't break. Adding V3→V4 later means adding a new module under `SchemaMigration::`, not editing these.
153
168
 
154
169
  ## Schema Version Subclasses Are NOT Duplication
155
170
 
@@ -170,8 +185,6 @@ See `TODO.improve/` for detailed plans.
170
185
 
171
186
  1. **Migrate callers to GlossaryStore** — ConceptCollector (8 call sites) and ConceptManager (1 call site) duplicate format detection with file-system heuristics. GlossaryStore is model-driven, framework-aligned, and handles both directory and ZIP. All callers should go through GlossaryStore.
172
187
 
173
- 2. **Split SchemaMigration into focused modules** — Currently handles V0→V1 hash migration, V2→V3 model migration, and a CLI pipeline at three different abstraction levels. Each should be its own module.
174
-
175
188
  ### Rejected candidates (do not re-suggest)
176
189
 
177
190
  - **Collapse V2/V3 ManagedConceptData** — Version subclasses are a real seam, not duplication. See "Schema Version Subclasses Are NOT Duplication" above.
@@ -52,13 +52,17 @@ module Glossarist
52
52
  private
53
53
 
54
54
  def load_concepts
55
- if @path.end_with?(".gcr")
56
- package = GcrPackage.load(@path)
57
- resolve_metadata_from_package(package)
58
- package.concepts
59
- else
60
- GlossaryStore.new.tap { |s| s.load(@path) }.concepts
61
- end
55
+ store.concepts
56
+ end
57
+
58
+ def store
59
+ @store ||= if @path.end_with?(".gcr")
60
+ pkg = GcrPackage.load(@path)
61
+ resolve_metadata_from_package(pkg)
62
+ pkg
63
+ else
64
+ GlossaryStore.new.tap { |s| s.load(@path) }
65
+ end
62
66
  end
63
67
 
64
68
  def resolve_metadata_from_package(package)
@@ -106,6 +110,22 @@ module Glossarist
106
110
  @options.fetch(:validate, false)
107
111
  end
108
112
 
113
+ # Dataset-level non-verbal entities come from the GlossaryStore (which
114
+ # lazy-loads figures/tables/formulas from the dataset directory). GCR
115
+ # packages don't yet expose these — empty arrays keep the call site
116
+ # uniform. Future: extend GcrPackage to surface them.
117
+ def dataset_figures
118
+ @store.is_a?(GlossaryStore) ? @store.figures : []
119
+ end
120
+
121
+ def dataset_tables
122
+ @store.is_a?(GlossaryStore) ? @store.tables : []
123
+ end
124
+
125
+ def dataset_formulas
126
+ @store.is_a?(GlossaryStore) ? @store.formulas : []
127
+ end
128
+
109
129
  def per_concept_supported?(format)
110
130
  PER_CONCEPT_FORMATS.include?(format)
111
131
  end
@@ -136,7 +156,10 @@ module Glossarist
136
156
  transform = Transforms::ConceptToGlossTransform.new(nil,
137
157
  transform_options)
138
158
  File.write(File.join(output_dir, "#{name}.jsonld"),
139
- transform.to_jsonld(concepts))
159
+ transform.to_jsonld(concepts,
160
+ figures: dataset_figures,
161
+ tables: dataset_tables,
162
+ formulas: dataset_formulas))
140
163
  end
141
164
 
142
165
  def export_turtle(concepts, name, output_dir)
@@ -144,7 +167,10 @@ module Glossarist
144
167
  transform = Transforms::ConceptToGlossTransform.new(nil,
145
168
  transform_options)
146
169
  File.write(File.join(output_dir, "#{name}.ttl"),
147
- transform.to_turtle(concepts))
170
+ transform.to_turtle(concepts,
171
+ figures: dataset_figures,
172
+ tables: dataset_tables,
173
+ formulas: dataset_formulas))
148
174
  end
149
175
 
150
176
  def export_tbx(concepts, name, output_dir)
@@ -9,11 +9,16 @@ module Glossarist
9
9
  @concept_document_class = V3::ConceptDocument
10
10
  @v1_concepts = nil
11
11
  @localized_concepts_dir_name = nil
12
+ @dataset_path = nil
13
+ @figures = nil
14
+ @tables = nil
15
+ @formulas = nil
12
16
  end
13
17
 
14
18
  # ── Load ──
15
19
 
16
20
  def load_directory(path, format: nil)
21
+ @dataset_path = File.expand_path(path)
17
22
  if v1_dataset?(path)
18
23
  load_v1_fallback(path)
19
24
  return self
@@ -44,6 +49,7 @@ module Glossarist
44
49
  end
45
50
 
46
51
  def load_zip(path, format: nil)
52
+ @dataset_path = File.expand_path(path)
47
53
  metadata = load_metadata_from_zip(path)
48
54
  @concept_document_class = resolve_concept_document_class(metadata)
49
55
 
@@ -174,6 +180,28 @@ module Glossarist
174
180
  @package.asset_paths.select { |p| p.start_with?("images/") }
175
181
  end
176
182
 
183
+ # ── Dataset-level non-verbal entities ──
184
+ #
185
+ # Loaded lazily from the dataset's figures/, tables/, formulas/
186
+ # subdirectories. Each YAML file is parsed as the corresponding model
187
+ # (Figure, Table, Formula). The entities are referenced from
188
+ # ManagedConceptData via FigureReference/TableReference/FormulaReference
189
+ # but live as standalone shared entities at the dataset level.
190
+ # Wired into ConceptToGlossTransform via the figures:/tables:/formulas:
191
+ # kwargs on transform_document.
192
+
193
+ def figures
194
+ @figures ||= load_dataset_entities("figures", Figure)
195
+ end
196
+
197
+ def tables
198
+ @tables ||= load_dataset_entities("tables", Table)
199
+ end
200
+
201
+ def formulas
202
+ @formulas ||= load_dataset_entities("formulas", Formula)
203
+ end
204
+
177
205
  # ── Stats ──
178
206
 
179
207
  def stats
@@ -201,6 +229,21 @@ module Glossarist
201
229
  @package = Lutaml::Store::PackageStore.new(definition)
202
230
  end
203
231
 
232
+ # Scans `{dataset_path}/{subdir}/*.{yaml,yml}` and parses each file as
233
+ # the given model class. Returns [] when the directory doesn't exist
234
+ # (e.g., datasets that don't carry this entity kind) — absent directory
235
+ # is not an error, just an empty collection.
236
+ def load_dataset_entities(subdir, klass)
237
+ return [] unless @dataset_path
238
+
239
+ dir = File.join(@dataset_path, subdir)
240
+ return [] unless File.directory?(dir)
241
+
242
+ Dir.glob(File.join(dir, "*.{yaml,yml}")).filter_map do |path|
243
+ klass.from_file(path)
244
+ end
245
+ end
246
+
204
247
  def resolve_concept_document_class(metadata)
205
248
  version = metadata&.schema_version.to_s
206
249
  ConceptDocument.for_version(version)
@@ -51,6 +51,9 @@ module Glossarist
51
51
 
52
52
  class GlossDocument < Lutaml::Model::Serializable
53
53
  attribute :concepts, GlossConcept, collection: true
54
+ attribute :figures, GlossFigure, collection: true
55
+ attribute :tables, GlossTable, collection: true
56
+ attribute :formulas, GlossFormula, collection: true
54
57
 
55
58
  rdf do
56
59
  namespace Namespaces::GlossaristNamespace,
@@ -61,6 +64,9 @@ module Glossarist
61
64
  Namespaces::RdfNamespace
62
65
 
63
66
  members :concepts
67
+ members :figures
68
+ members :tables
69
+ members :formulas
64
70
  end
65
71
  end
66
72
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for a dataset-level Figure entity (concept-model v3.1.0 K1+K2).
8
+ #
9
+ # Emits a `gloss:Figure` subject with:
10
+ # - gloss:image — xsd:anyURI (the first image variant's src, per K1
11
+ # FigureShape which constrains it to min 1)
12
+ # - gloss:caption — xsd:string (one language picked from the localized hash)
13
+ # - dcterms:description — xsd:string (one language picked)
14
+ #
15
+ # Per-variant foaf:Image subjects (K2 ImageShape) are emitted via the
16
+ # `images` collection — each variant becomes its own subject with
17
+ # dcterms:format and gloss:imageRole.
18
+ #
19
+ # The Figure domain model (Glossarist::Figure) stores caption/description
20
+ # as language-keyed hashes; the K1 FigureShape constrains them to single
21
+ # xsd:string values, so the transform picks one language at build time.
22
+ class GlossFigure < Lutaml::Model::Serializable
23
+ attribute :id, :string
24
+ attribute :identifier, :string
25
+ attribute :image, :string
26
+ attribute :caption, :string
27
+ attribute :description, :string
28
+ attribute :images, GlossFigureImage, collection: true
29
+
30
+ rdf do
31
+ namespace Namespaces::GlossaristNamespace,
32
+ Namespaces::DctermsNamespace
33
+
34
+ subject { |f| "figure/#{f.id}" }
35
+
36
+ types "gloss:Figure"
37
+
38
+ predicate :image, namespace: Namespaces::GlossaristNamespace,
39
+ to: :image
40
+ predicate :caption, namespace: Namespaces::GlossaristNamespace,
41
+ to: :caption
42
+ predicate :description, namespace: Namespaces::DctermsNamespace,
43
+ to: :description
44
+ members :images
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for one image variant within a Figure (concept-model v3.1.0 K2).
8
+ #
9
+ # Emits a `foaf:Image` subject with:
10
+ # - dcterms:format — required by K2 ImageShape (e.g. "svg", "png")
11
+ # - dcterms:language — optional (max 1) — when the variant is localized
12
+ # - dcat:byteSize — optional (max 1) — when the source model carries it
13
+ # - gloss:imageRole — the variant's role (vector/raster/dark/light/print)
14
+ #
15
+ # The FigureImage domain model carries src, format, role, width, height,
16
+ # scale. byte_size isn't on the model (it would require file-system
17
+ # inspection); we emit nil when absent, which the RDF layer skips.
18
+ class GlossFigureImage < Lutaml::Model::Serializable
19
+ attribute :src, :string
20
+ attribute :format, :string
21
+ attribute :role, :string
22
+ attribute :byte_size, :integer
23
+
24
+ rdf do
25
+ namespace Namespaces::DctermsNamespace,
26
+ Namespaces::FoafNamespace,
27
+ Namespaces::DcatNamespace,
28
+ Namespaces::GlossaristNamespace
29
+
30
+ subject { |i| "image/#{i.src}" }
31
+
32
+ types "foaf:Image"
33
+
34
+ predicate :format, namespace: Namespaces::DctermsNamespace,
35
+ to: :format
36
+ predicate :byteSize, namespace: Namespaces::DcatNamespace,
37
+ to: :byte_size
38
+ predicate :imageRole, namespace: Namespaces::GlossaristNamespace,
39
+ to: :role
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for a dataset-level Formula entity (concept-model v3.1.0 K1).
8
+ #
9
+ # Emits a `gloss:Formula` subject with:
10
+ # - gloss:expression — xsd:string (one language picked from the localized hash)
11
+ # - gloss:latexForm — xsd:string (the LaTeX form, when notation is latex)
12
+ # - dcterms:description — xsd:string (one language picked)
13
+ class GlossFormula < Lutaml::Model::Serializable
14
+ attribute :id, :string
15
+ attribute :identifier, :string
16
+ attribute :expression, :string
17
+ attribute :latex_form, :string
18
+ attribute :description, :string
19
+
20
+ rdf do
21
+ namespace Namespaces::GlossaristNamespace,
22
+ Namespaces::DctermsNamespace
23
+
24
+ subject { |f| "formula/#{f.id}" }
25
+
26
+ types "gloss:Formula"
27
+
28
+ predicate :expression, namespace: Namespaces::GlossaristNamespace,
29
+ to: :expression
30
+ predicate :latexForm, namespace: Namespaces::GlossaristNamespace,
31
+ to: :latex_form
32
+ predicate :description, namespace: Namespaces::DctermsNamespace,
33
+ to: :description
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Glossarist
6
+ module Rdf
7
+ # RDF view for a dataset-level Table entity (concept-model v3.1.0 K1).
8
+ #
9
+ # Emits a `gloss:Table` subject with:
10
+ # - gloss:content — xsd:string (serialized table content)
11
+ # - gloss:caption — xsd:string (one language picked)
12
+ # - dcterms:title — xsd:string (the table identifier, used as title)
13
+ class GlossTable < Lutaml::Model::Serializable
14
+ attribute :id, :string
15
+ attribute :identifier, :string
16
+ attribute :content, :string
17
+ attribute :caption, :string
18
+
19
+ rdf do
20
+ namespace Namespaces::GlossaristNamespace,
21
+ Namespaces::DctermsNamespace
22
+
23
+ subject { |t| "table/#{t.id}" }
24
+
25
+ types "gloss:Table"
26
+
27
+ predicate :content, namespace: Namespaces::GlossaristNamespace,
28
+ to: :content
29
+ predicate :caption, namespace: Namespaces::GlossaristNamespace,
30
+ to: :caption
31
+ predicate :title, namespace: Namespaces::DctermsNamespace,
32
+ to: :identifier
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module Rdf
5
+ module Namespaces
6
+ class DcatNamespace < Lutaml::Rdf::Namespace
7
+ uri "http://www.w3.org/ns/dcat#"
8
+ prefix "dcat"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Glossarist
4
+ module Rdf
5
+ module Namespaces
6
+ class FoafNamespace < Lutaml::Rdf::Namespace
7
+ uri "http://xmlns.com/foaf/0.1/"
8
+ prefix "foaf"
9
+ end
10
+ end
11
+ end
12
+ end
@@ -3,7 +3,9 @@
3
3
  module Glossarist
4
4
  module Rdf
5
5
  module Namespaces
6
- autoload :DctermsNamespace, "#{__dir__}/namespaces/dcterms_namespace"
6
+ autoload :DcatNamespace, "#{__dir__}/namespaces/dcat_namespace"
7
+ autoload :DctermsNamespace, "#{__dir__}/namespaces/dcterms_namespace"
8
+ autoload :FoafNamespace, "#{__dir__}/namespaces/foaf_namespace"
7
9
  autoload :GlossaristNamespace,
8
10
  "#{__dir__}/namespaces/glossarist_namespace"
9
11
  autoload :IsoThesNamespace, "#{__dir__}/namespaces/iso_thes_namespace"
@@ -25,6 +25,10 @@ module Glossarist
25
25
  GlossLocalizedConcept
26
26
  GlossConcept
27
27
  GlossDocument
28
+ GlossFigure
29
+ GlossFigureImage
30
+ GlossTable
31
+ GlossFormula
28
32
  ].freeze
29
33
 
30
34
  VIEW_CLASS_NAMES.each do |name|
@@ -4,10 +4,18 @@ require "lutaml/rdf"
4
4
  require "lutaml/turtle"
5
5
  require "lutaml/jsonld"
6
6
 
7
- require_relative "rdf/lutaml_ext"
8
-
9
7
  module Glossarist
10
8
  module Rdf
9
+ # lutaml_ext.rb defines EmitsExtraTriples (mixed into RDF model classes
10
+ # that want to emit extra triples) and LutamlTurtleTransformExt (prepended
11
+ # into Lutaml::Turtle::Transform so the framework asks each instance for
12
+ # its extra triples). The prepend must run before the first transform
13
+ # call; autoload here fires the first time EmitsExtraTriples is
14
+ # referenced (i.e., when GlossLocalizedConcept includes it during its
15
+ # own autoload), which is before any transform runs.
16
+ autoload :EmitsExtraTriples, "#{__dir__}/rdf/lutaml_ext"
17
+ autoload :LutamlTurtleTransformExt, "#{__dir__}/rdf/lutaml_ext"
18
+
11
19
  autoload :Namespaces, "#{__dir__}/rdf/namespaces"
12
20
  autoload :LocalizedLiteral, "#{__dir__}/rdf/localized_literal"
13
21
  autoload :RelationshipPredicates, "#{__dir__}/rdf/relationship_predicates"
@@ -30,6 +38,10 @@ module Glossarist
30
38
  autoload :GlossLocalizedConcept, "#{__dir__}/rdf/gloss_localized_concept"
31
39
  autoload :GlossConcept, "#{__dir__}/rdf/gloss_concept"
32
40
  autoload :GlossDocument, "#{__dir__}/rdf/gloss_concept"
41
+ autoload :GlossFigure, "#{__dir__}/rdf/gloss_figure"
42
+ autoload :GlossFigureImage, "#{__dir__}/rdf/gloss_figure_image"
43
+ autoload :GlossTable, "#{__dir__}/rdf/gloss_table"
44
+ autoload :GlossFormula, "#{__dir__}/rdf/gloss_formula"
33
45
  autoload :V3, "#{__dir__}/rdf/v3"
34
46
  end
35
47
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Glossarist
6
+ class SchemaMigration
7
+ # Orchestrator for the `glossarist upgrade` CLI command.
8
+ #
9
+ # Reads source concepts from a directory, dispatches each file to the
10
+ # right migrator (V0ToV1 for IEV-legacy hashes, V2ToV3 for model-level
11
+ # version bumps), reads register.yaml, and writes the output either as
12
+ # a directory of YAML files or as a `.gcr` ZIP package.
13
+ #
14
+ # This is the third concern that previously lived tangled in
15
+ # SchemaMigration itself (alongside V0ToV1 and V2ToV3). Splitting it
16
+ # out keeps each module at a single abstraction level:
17
+ # - V0ToV1: hash → hash transform
18
+ # - V2ToV3: model → model transform
19
+ # - CliPipeline (this file): file I/O + dispatch + output writing
20
+ class CliPipeline
21
+ attr_reader :source_dir, :output, :target_version, :cross_references,
22
+ :dry_run
23
+
24
+ def initialize(source_dir, output:, target_version:,
25
+ cross_references: nil, dry_run: false)
26
+ @source_dir = File.expand_path(source_dir)
27
+ @output = output
28
+ @target_version = target_version
29
+ @cross_references = cross_references
30
+ @dry_run = dry_run
31
+ end
32
+
33
+ def run
34
+ validate_source!
35
+ concepts = read_and_migrate_concepts
36
+ register_data = read_register_yaml
37
+ write_output(concepts, register_data)
38
+
39
+ {
40
+ concepts: concepts,
41
+ register_data: register_data,
42
+ source_version: source_version,
43
+ target_version: target_version,
44
+ output: File.expand_path(output),
45
+ count: concepts.length,
46
+ }
47
+ end
48
+
49
+ private
50
+
51
+ def validate_source!
52
+ unless File.directory?(source_dir)
53
+ raise ArgumentError, "#{source_dir} is not a directory"
54
+ end
55
+ return if concepts_dir
56
+
57
+ raise ArgumentError, "No concept YAML files found in #{source_dir}"
58
+ end
59
+
60
+ def concepts_dir
61
+ @concepts_dir ||= [
62
+ File.join(source_dir, "concepts"),
63
+ source_dir,
64
+ ].find { |dir| Dir.glob(File.join(dir, "*.yaml")).any? }
65
+ end
66
+
67
+ def source_version
68
+ @source_version ||= begin
69
+ register = V1::Register.from_file(File.join(source_dir,
70
+ "register.yaml"))
71
+ register&.schema_version || "0"
72
+ end
73
+ end
74
+
75
+ def ref_maps
76
+ @ref_maps ||= begin
77
+ xref = V1::CrossReferences.from_file(cross_references)
78
+ xref ? xref.to_ref_maps : {}
79
+ end
80
+ end
81
+
82
+ def read_and_migrate_concepts
83
+ files = Dir.glob(File.join(concepts_dir, "*.yaml"))
84
+ concepts = []
85
+ errors = 0
86
+
87
+ files.each do |file|
88
+ v1 = V1::Concept.from_file(file)
89
+ next unless v1
90
+
91
+ migration = V0ToV1.new(
92
+ v1.to_yaml_hash,
93
+ from_version: source_version,
94
+ to_version: target_version,
95
+ ref_maps: ref_maps,
96
+ )
97
+ concepts << migration.migrate
98
+ rescue Errors::Base, Psych::SyntaxError => e
99
+ errors += 1
100
+ warn " Error migrating #{File.basename(file)}: #{e.message}" if errors <= 5
101
+ end
102
+
103
+ warn " ... #{errors - 5} more errors" if errors > 5
104
+ concepts
105
+ end
106
+
107
+ def read_register_yaml
108
+ register = V1::Register.from_file(File.join(source_dir,
109
+ "register.yaml"))
110
+ return nil unless register
111
+
112
+ data = register.to_h
113
+ data["schema_version"] = target_version
114
+ data
115
+ end
116
+
117
+ def write_output(concepts, register_data)
118
+ output_path = File.expand_path(output)
119
+
120
+ if File.extname(output).downcase == ".gcr"
121
+ write_gcr(concepts, register_data, output_path)
122
+ else
123
+ write_directory(concepts, register_data, output_path)
124
+ end
125
+ end
126
+
127
+ def write_gcr(concepts, register_data, output_path)
128
+ if dry_run
129
+ puts "Would package #{concepts.length} concepts into #{output_path}"
130
+ return
131
+ end
132
+
133
+ v1_concepts = concepts.map { |h| V1::Concept.of_yaml(h).to_managed_concept }
134
+ rd = register_data ? RegisterData.of_yaml(register_data) : nil
135
+ metadata = GcrMetadata.from_concepts(v1_concepts, register_data: rd)
136
+ GcrPackage.create(
137
+ concepts: v1_concepts,
138
+ metadata: metadata,
139
+ register_data: rd,
140
+ output_path: output_path,
141
+ )
142
+ end
143
+
144
+ def write_directory(concepts, register_data, output_path)
145
+ if dry_run
146
+ puts "Would write #{concepts.length} concepts to #{File.join(
147
+ output_path, 'concepts/'
148
+ )}"
149
+ return
150
+ end
151
+
152
+ concepts_out = File.join(output_path, "concepts")
153
+ FileUtils.mkdir_p(concepts_out)
154
+
155
+ concepts.each do |concept|
156
+ termid = concept["termid"]
157
+ mc = V1::Concept.of_yaml(concept).to_managed_concept
158
+ File.write(File.join(concepts_out, "#{termid}.yaml"), mc.to_yaml)
159
+ end
160
+
161
+ return unless register_data
162
+
163
+ rd = RegisterData.of_yaml(register_data)
164
+ File.write(File.join(output_path, "register.yaml"), rd.to_yaml)
165
+ end
166
+ end
167
+ end
168
+ end
@@ -1,14 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
-
3
+ # Facade for schema migration. Each abstraction level lives in its own
4
+ # module under SchemaMigration:: — the facade preserves the historical
5
+ # class-method API so existing callers (CLI upgrade command, V2/V3
6
+ # namespace specs, downstream gems) don't break.
7
+ #
8
+ # Architecture (MECE):
9
+ # - V0ToV1: hash to hash transform (IEV legacy YAML to V1 shape).
10
+ # - V2ToV3: model to model transform (V2::ManagedConcept to V3::ManagedConcept).
11
+ # - CliPipeline: file I/O + dispatch + output writing for the
12
+ # `glossarist upgrade` CLI command.
13
+ #
14
+ # Each module has a single public entry point (V0ToV1#migrate,
15
+ # V2ToV3.migrate_concept, CliPipeline#run) and is independently testable.
16
+ # Adding V3ToV4 later = adding a new module, not editing these.
5
17
  module Glossarist
6
18
  class SchemaMigration
7
19
  CURRENT_SCHEMA_VERSION = "1"
8
20
 
9
- autoload :V0ToV1, "glossarist/schema_migration/v0_to_v1"
10
- autoload :V2ToV3, "glossarist/schema_migration/v2_to_v3"
21
+ autoload :V0ToV1, "glossarist/schema_migration/v0_to_v1"
22
+ autoload :V2ToV3, "glossarist/schema_migration/v2_to_v3"
23
+ autoload :CliPipeline, "glossarist/schema_migration/cli_pipeline"
11
24
 
25
+ # Convenience: `SchemaMigration.new(input, **opts)` is shorthand for
26
+ # `V0ToV1.new(input, **opts)`. Preserved for backward compatibility.
12
27
  def self.new(...)
13
28
  V0ToV1.new(...)
14
29
  end
@@ -21,142 +36,15 @@ module Glossarist
21
36
  V2ToV3.concept_version(concept)
22
37
  end
23
38
 
24
- def self.upgrade_directory(source_dir, output:, # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
25
- target_version: CURRENT_SCHEMA_VERSION,
26
- cross_references: nil,
27
- dry_run: false)
28
- source_dir = File.expand_path(source_dir)
29
-
30
- concepts_dir = find_concepts_dir(source_dir)
31
- unless File.directory?(source_dir)
32
- raise ArgumentError,
33
- "#{source_dir} is not a directory"
34
- end
35
- unless concepts_dir
36
- raise ArgumentError,
37
- "No concept YAML files found in #{source_dir}"
38
- end
39
-
40
- source_version = detect_schema_version(source_dir)
41
- ref_maps = load_ref_maps(cross_references)
42
- concepts = read_and_migrate_concepts(concepts_dir, source_version,
43
- target_version, ref_maps)
44
- register_data = read_register_yaml(source_dir, target_version)
45
-
46
- write_output(concepts, register_data, output, dry_run)
47
-
48
- {
49
- concepts: concepts,
50
- register_data: register_data,
51
- source_version: source_version,
39
+ def self.upgrade_directory(source_dir, output:, target_version: CURRENT_SCHEMA_VERSION,
40
+ cross_references: nil, dry_run: false)
41
+ CliPipeline.new(
42
+ source_dir,
43
+ output: output,
52
44
  target_version: target_version,
53
- output: File.expand_path(output),
54
- count: concepts.length,
55
- }
56
- end
57
-
58
- class << self
59
- private
60
-
61
- def find_concepts_dir(source_dir)
62
- candidates = [
63
- File.join(source_dir, "concepts"),
64
- source_dir,
65
- ]
66
- candidates.find { |dir| Dir.glob(File.join(dir, "*.yaml")).any? }
67
- end
68
-
69
- def detect_schema_version(source_dir)
70
- register = V1::Register.from_file(File.join(source_dir,
71
- "register.yaml"))
72
- register&.schema_version || "0"
73
- end
74
-
75
- def load_ref_maps(cross_references_path)
76
- xref = V1::CrossReferences.from_file(cross_references_path)
77
- xref ? xref.to_ref_maps : {}
78
- end
79
-
80
- def read_and_migrate_concepts(concepts_dir, source_version, # rubocop:disable Metrics/MethodLength
81
- target_version, ref_maps)
82
- files = Dir.glob(File.join(concepts_dir, "*.yaml"))
83
- concepts = []
84
- errors = 0
85
-
86
- files.each do |file|
87
- v1 = V1::Concept.from_file(file)
88
- next unless v1
89
-
90
- migration = V0ToV1.new(
91
- v1.to_yaml_hash,
92
- from_version: source_version,
93
- to_version: target_version,
94
- ref_maps: ref_maps,
95
- )
96
- concepts << migration.migrate
97
- rescue Errors::Base, Psych::SyntaxError => e
98
- errors += 1
99
- warn " Error migrating #{File.basename(file)}: #{e.message}" if errors <= 5
100
- end
101
-
102
- warn " ... #{errors - 5} more errors" if errors > 5
103
- concepts
104
- end
105
-
106
- def read_register_yaml(source_dir, target_version)
107
- register = V1::Register.from_file(File.join(source_dir,
108
- "register.yaml"))
109
- return nil unless register
110
-
111
- data = register.to_h
112
- data["schema_version"] = target_version
113
- data
114
- end
115
-
116
- def write_output(concepts, register_data, output, dry_run) # rubocop:disable Metrics/MethodLength
117
- output_path = File.expand_path(output)
118
-
119
- if File.extname(output).downcase == ".gcr"
120
- if dry_run
121
- puts "Would package #{concepts.length} concepts into #{output_path}"
122
- return
123
- end
124
-
125
- v1_concepts = concepts.map { |h| V1::Concept.of_yaml(h).to_managed_concept }
126
- rd = register_data ? RegisterData.of_yaml(register_data) : nil
127
- metadata = GcrMetadata.from_concepts(v1_concepts,
128
- register_data: rd)
129
- GcrPackage.create(
130
- concepts: v1_concepts,
131
- metadata: metadata,
132
- register_data: rd,
133
- output_path: output_path,
134
- )
135
- else
136
- if dry_run
137
- puts "Would write #{concepts.length} concepts to #{File.join(
138
- output_path, 'concepts/'
139
- )}"
140
- return
141
- end
142
-
143
- concepts_out = File.join(output_path, "concepts")
144
- FileUtils.mkdir_p(concepts_out)
145
-
146
- concepts.each do |concept|
147
- termid = concept["termid"]
148
- mc = V1::Concept.of_yaml(concept).to_managed_concept
149
- File.write(File.join(concepts_out, "#{termid}.yaml"),
150
- mc.to_yaml)
151
- end
152
-
153
- if register_data
154
- rd = RegisterData.of_yaml(register_data)
155
- File.write(File.join(output_path, "register.yaml"),
156
- rd.to_yaml)
157
- end
158
- end
159
- end
45
+ cross_references: cross_references,
46
+ dry_run: dry_run,
47
+ ).run
160
48
  end
161
49
  end
162
50
  end
@@ -37,8 +37,10 @@ module Glossarist
37
37
  new(managed_concept, options).build
38
38
  end
39
39
 
40
- def self.transform_document(concepts, options = {})
41
- new(nil, options).build_document(concepts)
40
+ def self.transform_document(concepts, figures: [], tables: [],
41
+ formulas: [], **options)
42
+ new(nil, options).build_document(concepts, figures: figures,
43
+ tables: tables, formulas: formulas)
42
44
  end
43
45
 
44
46
  def initialize(managed_concept, options = {})
@@ -50,15 +52,22 @@ module Glossarist
50
52
  build_gloss_concept(concept)
51
53
  end
52
54
 
53
- def build_document(concepts)
55
+ def build_document(concepts, figures: [], tables: [], formulas: [])
54
56
  gloss_concepts = concepts.map { |c| build_gloss_concept(c) }
55
- doc = Rdf::GlossDocument.new(concepts: gloss_concepts)
57
+ doc = Rdf::GlossDocument.new(
58
+ concepts: gloss_concepts,
59
+ figures: figures.map { |f| build_gloss_figure(f) },
60
+ tables: tables.map { |t| build_gloss_table(t) },
61
+ formulas: formulas.map { |f| build_gloss_formula(f) },
62
+ )
56
63
  Rdf::GlossDocument.to_turtle(doc)
57
64
  end
58
65
 
59
- def to_turtle(concepts_or_concept = nil)
66
+ def to_turtle(concepts_or_concept = nil, figures: [], tables: [],
67
+ formulas: [])
60
68
  if concepts_or_concept.is_a?(Array)
61
- build_document(concepts_or_concept)
69
+ build_document(concepts_or_concept, figures: figures,
70
+ tables: tables, formulas: formulas)
62
71
  else
63
72
  target = concepts_or_concept || @concept
64
73
  return "" unless target
@@ -68,12 +77,18 @@ module Glossarist
68
77
  end
69
78
  end
70
79
 
71
- def to_jsonld(concepts_or_concept = nil)
80
+ def to_jsonld(concepts_or_concept = nil, figures: [], tables: [],
81
+ formulas: [])
72
82
  if concepts_or_concept.is_a?(Array)
73
83
  gloss_concepts = concepts_or_concept.map do |c|
74
84
  build_gloss_concept(c)
75
85
  end
76
- doc = Rdf::GlossDocument.new(concepts: gloss_concepts)
86
+ doc = Rdf::GlossDocument.new(
87
+ concepts: gloss_concepts,
88
+ figures: figures.map { |f| build_gloss_figure(f) },
89
+ tables: tables.map { |t| build_gloss_table(t) },
90
+ formulas: formulas.map { |f| build_gloss_formula(f) },
91
+ )
77
92
  Rdf::GlossDocument.to_jsonld(doc)
78
93
  else
79
94
  target = concepts_or_concept || @concept
@@ -353,6 +368,73 @@ desig_index)
353
368
  def status_uri(status)
354
369
  status ? "gloss:status/#{status}" : nil
355
370
  end
371
+
372
+ # ── Dataset-level non-verbal entity builders (concept-model v3.1.0 K1) ──
373
+ #
374
+ # The K1 shapes (FigureShape, TableShape, FormulaShape) constrain
375
+ # caption/description to a single xsd:string per subject. The domain
376
+ # models store these as language-keyed hashes; we pick one language
377
+ # (defaulting to eng, falling back to the first available) at build
378
+ # time. Multi-language emission would require per-language subjects,
379
+ # which is beyond K1's scope.
380
+
381
+ DEFAULT_RDF_LANG = "eng"
382
+
383
+ def build_gloss_figure(figure)
384
+ Rdf::GlossFigure.new(
385
+ id: figure.id,
386
+ identifier: figure.identifier,
387
+ image: figure.images.first&.src,
388
+ caption: localized_pick(figure.caption),
389
+ description: localized_pick(figure.description),
390
+ images: Array(figure.images).map { |img| build_gloss_figure_image(img) },
391
+ )
392
+ end
393
+
394
+ def build_gloss_figure_image(image)
395
+ Rdf::GlossFigureImage.new(
396
+ src: image.src,
397
+ format: image.format,
398
+ role: image.role,
399
+ # FigureImage doesn't carry byte_size — emit nil so the RDF layer
400
+ # skips dcat:byteSize rather than emitting a spurious "0".
401
+ byte_size: nil,
402
+ )
403
+ end
404
+
405
+ def build_gloss_table(table)
406
+ Rdf::GlossTable.new(
407
+ id: table.id,
408
+ identifier: table.identifier,
409
+ content: serialize_table_content(table.content),
410
+ caption: localized_pick(table.caption),
411
+ )
412
+ end
413
+
414
+ def build_gloss_formula(formula)
415
+ expression_str = localized_pick(formula.expression)
416
+ Rdf::GlossFormula.new(
417
+ id: formula.id,
418
+ identifier: formula.identifier,
419
+ expression: expression_str,
420
+ latex_form: formula.notation == "latex" ? expression_str : nil,
421
+ description: localized_pick(formula.description),
422
+ )
423
+ end
424
+
425
+ def localized_pick(localized_hash)
426
+ return nil unless localized_hash.is_a?(Hash) && !localized_hash.empty?
427
+
428
+ localized_hash[DEFAULT_RDF_LANG] ||
429
+ localized_hash[DEFAULT_RDF_LANG.to_sym] ||
430
+ localized_hash.values.first
431
+ end
432
+
433
+ def serialize_table_content(content)
434
+ return nil if content.nil?
435
+
436
+ content.is_a?(Hash) ? content.to_yaml : content.to_s
437
+ end
356
438
  end
357
439
  end
358
440
  end
@@ -4,5 +4,5 @@
4
4
  #
5
5
 
6
6
  module Glossarist
7
- VERSION = "2.10.3"
7
+ VERSION = "2.11.0"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: glossarist
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.10.3
4
+ version: 2.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-06 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -303,16 +303,22 @@ files:
303
303
  - lib/glossarist/rdf/gloss_concept_source.rb
304
304
  - lib/glossarist/rdf/gloss_designation.rb
305
305
  - lib/glossarist/rdf/gloss_detailed_definition.rb
306
+ - lib/glossarist/rdf/gloss_figure.rb
307
+ - lib/glossarist/rdf/gloss_figure_image.rb
308
+ - lib/glossarist/rdf/gloss_formula.rb
306
309
  - lib/glossarist/rdf/gloss_grammar_info.rb
307
310
  - lib/glossarist/rdf/gloss_locality.rb
308
311
  - lib/glossarist/rdf/gloss_localized_concept.rb
309
312
  - lib/glossarist/rdf/gloss_non_verbal_rep.rb
310
313
  - lib/glossarist/rdf/gloss_pronunciation.rb
311
314
  - lib/glossarist/rdf/gloss_reference.rb
315
+ - lib/glossarist/rdf/gloss_table.rb
312
316
  - lib/glossarist/rdf/localized_literal.rb
313
317
  - lib/glossarist/rdf/lutaml_ext.rb
314
318
  - lib/glossarist/rdf/namespaces.rb
319
+ - lib/glossarist/rdf/namespaces/dcat_namespace.rb
315
320
  - lib/glossarist/rdf/namespaces/dcterms_namespace.rb
321
+ - lib/glossarist/rdf/namespaces/foaf_namespace.rb
316
322
  - lib/glossarist/rdf/namespaces/glossarist_namespace.rb
317
323
  - lib/glossarist/rdf/namespaces/iso_thes_namespace.rb
318
324
  - lib/glossarist/rdf/namespaces/owl_namespace.rb
@@ -335,6 +341,7 @@ files:
335
341
  - lib/glossarist/resolution_adapter/remote.rb
336
342
  - lib/glossarist/resolution_adapter/route.rb
337
343
  - lib/glossarist/schema_migration.rb
344
+ - lib/glossarist/schema_migration/cli_pipeline.rb
338
345
  - lib/glossarist/schema_migration/v0_to_v1.rb
339
346
  - lib/glossarist/schema_migration/v2_to_v3.rb
340
347
  - lib/glossarist/section.rb