metanorma-release 0.2.24 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +21 -319
  3. data/README.adoc +319 -92
  4. data/lib/metanorma/release/aggregation_pipeline.rb +128 -45
  5. data/lib/metanorma/release/asset_processor.rb +3 -3
  6. data/lib/metanorma/release/cache_store.rb +1 -0
  7. data/lib/metanorma/release/change_detector.rb +3 -2
  8. data/lib/metanorma/release/cli.rb +28 -4
  9. data/lib/metanorma/release/commands/aggregate.rb +1 -17
  10. data/lib/metanorma/release/commands/package.rb +9 -17
  11. data/lib/metanorma/release/commands/release_command.rb +19 -14
  12. data/lib/metanorma/release/config.rb +16 -7
  13. data/lib/metanorma/release/delta_state.rb +15 -1
  14. data/lib/metanorma/release/dependency_validation.rb +19 -0
  15. data/lib/metanorma/release/document_flattener.rb +173 -0
  16. data/lib/metanorma/release/index.rb +1 -1
  17. data/lib/metanorma/release/interfaces.rb +12 -0
  18. data/lib/metanorma/release/platform/github/manifest_reader.rb +3 -1
  19. data/lib/metanorma/release/platform/github/release_fetcher.rb +10 -14
  20. data/lib/metanorma/release/platform/github/topic_discoverer.rb +1 -1
  21. data/lib/metanorma/release/platform/github.rb +0 -4
  22. data/lib/metanorma/release/platform/local/fetcher.rb +5 -17
  23. data/lib/metanorma/release/platform/null/manifest_reader.rb +15 -0
  24. data/lib/metanorma/release/platform/null/publisher.rb +1 -1
  25. data/lib/metanorma/release/platform/null.rb +2 -0
  26. data/lib/metanorma/release/platform/static_discoverer.rb +19 -0
  27. data/lib/metanorma/release/platform.rb +1 -0
  28. data/lib/metanorma/release/platform_factory.rb +6 -21
  29. data/lib/metanorma/release/publication.rb +23 -161
  30. data/lib/metanorma/release/publication_serializer.rb +59 -0
  31. data/lib/metanorma/release/release_pipeline.rb +7 -15
  32. data/lib/metanorma/release/rxl_extractor.rb +106 -0
  33. data/lib/metanorma/release/site.rb +5 -165
  34. data/lib/metanorma/release/slug_strategy.rb +30 -15
  35. data/lib/metanorma/release/version.rb +1 -1
  36. data/lib/metanorma/release/zip_packager.rb +1 -1
  37. data/lib/metanorma/release.rb +36 -19
  38. metadata +11 -5
@@ -1,12 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require "relaton/bib"
5
- rescue LoadError
6
- raise LoadError,
7
- "The relaton-bib gem is required. Add `gem 'relaton-bib'` to your Gemfile."
8
- end
9
-
10
3
  require "json"
11
4
 
12
5
  module Metanorma
@@ -67,6 +60,11 @@ module Metanorma
67
60
  class Publication
68
61
  METADATA_VERSION = 1
69
62
 
63
+ DRAFT_STAGES = %w[
64
+ 20 30 40 50
65
+ working-draft committee-draft draft-standard final-draft
66
+ ].freeze
67
+
70
68
  attr_reader :identifier, :slug, :title, :edition, :stage, :doctype,
71
69
  :revdate, :files, :channels, :source
72
70
 
@@ -94,14 +92,23 @@ module Metanorma
94
92
  files.any? ? File.dirname(files.first.path) : "."
95
93
  end
96
94
 
97
- def content_hash
98
- ContentHash.of_directory(base_dir, base: slug)
95
+ def content_hash(from_directory: nil)
96
+ dir = if from_directory && base_dir == "."
97
+ from_directory
98
+ else
99
+ base_dir
100
+ end
101
+ ContentHash.of_directory(dir, base: slug)
99
102
  end
100
103
 
101
104
  def file?(format)
102
105
  formats.include?(format)
103
106
  end
104
107
 
108
+ def draft?
109
+ DRAFT_STAGES.include?(stage.to_s)
110
+ end
111
+
105
112
  def eql?(other)
106
113
  other.is_a?(self.class) && identifier == other.identifier &&
107
114
  edition == other.edition && stage == other.stage
@@ -154,40 +161,26 @@ module Metanorma
154
161
  end
155
162
 
156
163
  def to_release_body
157
- "<!-- mn-release-metadata\n#{JSON.generate(metadata_hash)}\n-->"
164
+ PublicationSerializer.to_release_body(self)
158
165
  end
159
166
 
160
167
  def to_json(*_args)
161
- JSON.generate(metadata_hash)
162
- end
163
-
164
- def self.from_json(json_string)
165
- data = JSON.parse(json_string)
166
- raise ArgumentError, "Missing required field: id" unless data["id"]
167
- unless data["title"]
168
- raise ArgumentError,
169
- "Missing required field: title"
170
- end
171
-
172
- from_metadata_hash(data)
168
+ PublicationSerializer.to_json(self)
173
169
  end
174
170
 
175
171
  def self.from_release_body(body)
176
- return nil if body.nil? || body.empty?
177
-
178
- match = body.match(/<!--\s*mn-release-metadata\s*\n(.*?)\n-->/m)
179
- return nil unless match
172
+ PublicationSerializer.from_release_body(body)
173
+ end
180
174
 
181
- from_json(match[1])
182
- rescue JSON::ParserError
183
- nil
175
+ def self.from_json(json_string)
176
+ PublicationSerializer.from_json(json_string)
184
177
  end
185
178
 
186
179
  def self.from_metadata_hash(data)
187
180
  ident = data["identifier"] || data["id"]
188
181
  new(
189
182
  identifier: ident,
190
- slug: slug_from_identifier(ident),
183
+ slug: SlugStrategy.slug_from_identifier(ident),
191
184
  title: data["title"],
192
185
  edition: data["edition"],
193
186
  stage: data["stage"],
@@ -199,137 +192,6 @@ module Metanorma
199
192
  metadata_formats: data["formats"],
200
193
  )
201
194
  end
202
-
203
- private
204
-
205
- def metadata_hash
206
- {
207
- "version" => METADATA_VERSION,
208
- "id" => slug,
209
- "identifier" => identifier,
210
- "title" => title,
211
- "edition" => edition,
212
- "stage" => stage,
213
- "doctype" => doctype,
214
- "revdate" => revdate,
215
- "formats" => formats,
216
- "channels" => channels,
217
- "publisher" => Publication.publisher_from_identifier(identifier),
218
- }
219
- end
220
-
221
- def self.slug_from_identifier(identifier)
222
- identifier.to_s.strip
223
- .gsub(/\s+/, "-")
224
- .gsub(/:+/, "-")
225
- .downcase
226
- .gsub(/--+/, "-")
227
- .gsub(/[-.]+$/, "")
228
- end
229
-
230
- def self.publisher_from_identifier(identifier)
231
- return nil if identifier.nil? || identifier.strip.empty?
232
-
233
- identifier.strip.split(/[\s-]/).first&.downcase
234
- end
235
-
236
- # -- RXL extraction --
237
-
238
- def self.discover(output_dir)
239
- Dir.glob(File.join(output_dir, "**", "*.rxl")).filter_map do |path|
240
- from_rxl(path)
241
- rescue StandardError => e
242
- warn "Warning: Skipping #{path}: #{e.message}"
243
- nil
244
- end
245
- end
246
-
247
- def self.from_rxl(rxl_path)
248
- unless File.exist?(rxl_path)
249
- raise ArgumentError,
250
- "RXL file not found: #{rxl_path}"
251
- end
252
-
253
- content = File.read(rxl_path)
254
- bib = Relaton::Bib::Item.from_xml(content)
255
- build_from_bib(bib, rxl_path)
256
- rescue StandardError => e
257
- warn "Warning: Failed to parse RXL #{rxl_path}: #{e.message}"
258
- fallback_from_rxl(rxl_path)
259
- end
260
-
261
- class << self
262
- private
263
-
264
- def build_from_bib(bib, rxl_path)
265
- identifier = bib.docidentifier&.first&.content || ""
266
- slug = slug_from_identifier(identifier)
267
- output_dir = File.dirname(rxl_path)
268
- base_name = File.basename(rxl_path, ".rxl")
269
-
270
- new(
271
- identifier: identifier, slug: slug,
272
- title: bib.title&.first&.content || "",
273
- edition: extract_edition(bib),
274
- stage: extract_stage(bib),
275
- doctype: extract_doctype(bib),
276
- revdate: extract_revdate(bib),
277
- files: discover_files(output_dir, base_name),
278
- channels: [], source: nil
279
- )
280
- end
281
-
282
- def extract_edition(bib)
283
- ed = bib.edition
284
- return "1" unless ed
285
-
286
- ed.respond_to?(:content) ? ed.content.to_s : ed.to_s
287
- end
288
-
289
- def extract_stage(bib)
290
- stage = bib.status&.stage
291
- return "" unless stage
292
-
293
- stage.respond_to?(:content) ? stage.content.to_s : stage.to_s
294
- end
295
-
296
- def extract_doctype(bib)
297
- doctype = bib.ext&.doctype
298
- return "" unless doctype
299
-
300
- doctype.respond_to?(:content) ? doctype.content.to_s : doctype.to_s
301
- end
302
-
303
- def extract_revdate(bib)
304
- date = bib.date&.find { |d| d.type == "published" } || bib.date&.first
305
- return nil unless date
306
-
307
- val = date.at
308
- val ? val.to_s : nil
309
- rescue StandardError
310
- nil
311
- end
312
-
313
- def discover_files(output_dir, base_name)
314
- Dir.glob(File.join(output_dir, "#{base_name}.*")).filter_map do |path|
315
- next if File.directory?(path)
316
-
317
- name = File.basename(path)
318
- ext = File.extname(name).delete_prefix(".")
319
- PublicationFile.new(format: ext, name: name, path: name)
320
- end
321
- end
322
-
323
- def fallback_from_rxl(rxl_path)
324
- base_name = File.basename(rxl_path, ".rxl")
325
- slug = slug_from_identifier(base_name)
326
- new(
327
- identifier: base_name, slug: slug, title: "",
328
- edition: "0", stage: "", doctype: "",
329
- revdate: nil, files: [], channels: [], source: nil
330
- )
331
- end
332
- end
333
195
  end
334
196
  end
335
197
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module PublicationSerializer
6
+ METADATA_COMMENT_PATTERN = /<!--\s*mn-release-metadata\s*\n(.*?)\n-->/m
7
+
8
+ def self.to_release_body(publication)
9
+ "<!-- mn-release-metadata\n#{JSON.generate(metadata_hash(publication))}\n-->"
10
+ end
11
+
12
+ def self.from_release_body(body)
13
+ return nil if body.nil? || body.empty?
14
+
15
+ match = body.match(METADATA_COMMENT_PATTERN)
16
+ return nil unless match
17
+
18
+ from_json(match[1])
19
+ rescue JSON::ParserError
20
+ nil
21
+ end
22
+
23
+ def self.to_json(publication)
24
+ JSON.generate(metadata_hash(publication))
25
+ end
26
+
27
+ def self.from_json(json_string)
28
+ data = JSON.parse(json_string)
29
+ raise ArgumentError, "Missing required field: id" unless data["id"]
30
+ unless data["title"]
31
+ raise ArgumentError,
32
+ "Missing required field: title"
33
+ end
34
+
35
+ Publication.from_metadata_hash(data)
36
+ end
37
+
38
+ class << self
39
+ private
40
+
41
+ def metadata_hash(publication)
42
+ {
43
+ "version" => Publication::METADATA_VERSION,
44
+ "id" => publication.slug,
45
+ "identifier" => publication.identifier,
46
+ "title" => publication.title,
47
+ "edition" => publication.edition,
48
+ "stage" => publication.stage,
49
+ "doctype" => publication.doctype,
50
+ "revdate" => publication.revdate,
51
+ "formats" => publication.formats,
52
+ "channels" => publication.channels,
53
+ "publisher" => SlugStrategy.publisher_from_identifier(publication.identifier),
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -15,6 +15,8 @@ module Metanorma
15
15
  :manifest, :channel_override, :config,
16
16
  keyword_init: true
17
17
  ) do
18
+ include DependencyValidation
19
+
18
20
  def initialize(**kwargs)
19
21
  super
20
22
  validate_types!
@@ -23,9 +25,9 @@ module Metanorma
23
25
  private
24
26
 
25
27
  def validate_types!
26
- unless extractor.respond_to?(:discover)
28
+ unless extractor.is_a?(Class) && extractor.singleton_class.ancestors.include?(Extractor)
27
29
  raise ArgumentError,
28
- "extractor must respond to #discover, got #{extractor.class}"
30
+ "extractor must extend Extractor, got #{extractor}"
29
31
  end
30
32
 
31
33
  validate_interface!(change_detector, ChangeDetector,
@@ -33,21 +35,11 @@ module Metanorma
33
35
  validate_interface!(packager, Packager, "packager")
34
36
  validate_interface!(publisher, Publisher, "publisher")
35
37
  end
36
-
37
- def validate_interface!(obj, mod, name)
38
- return if obj.is_a?(mod) || begin
39
- obj.class.ancestors.include?(mod)
40
- rescue StandardError
41
- false
42
- end
43
-
44
- raise ArgumentError, "#{name} must include #{mod}, got #{obj.class}"
45
- end
46
38
  end
47
39
 
48
40
  Config = Struct.new(
49
- :output_dir, :manifest_path, :force, :force_replace_patterns,
50
- :concurrency, :default_visibility,
41
+ :output_dir, :force, :force_replace_patterns,
42
+ :concurrency,
51
43
  keyword_init: true
52
44
  )
53
45
 
@@ -72,7 +64,7 @@ module Metanorma
72
64
 
73
65
  def phase_one(publications, config)
74
66
  publications.map do |pub|
75
- publisher = Publication.publisher_from_identifier(pub.identifier)
67
+ publisher = SlugStrategy.publisher_from_identifier(pub.identifier)
76
68
  strategy = @deps.slug_registry.resolve(publisher)
77
69
  tag_info = strategy.compute_tag(pub)
78
70
  canonical_base = strategy.compute_asset_name(pub).sub(/\.zip$/, "")
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class RxlExtractor
6
+ extend Extractor
7
+
8
+ def self.discover(output_dir)
9
+ require "relaton/bib"
10
+ Dir.glob(File.join(output_dir, "**", "*.rxl")).filter_map do |path|
11
+ from_rxl(path)
12
+ rescue StandardError => e
13
+ Metanorma::Release.logger.warn "Skipping #{path}: #{e.message}"
14
+ nil
15
+ end
16
+ end
17
+
18
+ def self.from_rxl(rxl_path)
19
+ unless File.exist?(rxl_path)
20
+ raise ArgumentError,
21
+ "RXL file not found: #{rxl_path}"
22
+ end
23
+
24
+ content = File.read(rxl_path)
25
+ bib = Relaton::Bib::Item.from_xml(content)
26
+ build_from_bib(bib, rxl_path)
27
+ rescue StandardError => e
28
+ Metanorma::Release.logger.warn "Failed to parse RXL #{rxl_path}: #{e.message}"
29
+ fallback_from_rxl(rxl_path)
30
+ end
31
+
32
+ class << self
33
+ private
34
+
35
+ def build_from_bib(bib, rxl_path)
36
+ identifier = bib.docidentifier&.first&.content || ""
37
+ slug = SlugStrategy.slug_from_identifier(identifier)
38
+ output_dir = File.dirname(rxl_path)
39
+ base_name = File.basename(rxl_path, ".rxl")
40
+
41
+ Publication.new(
42
+ identifier: identifier, slug: slug,
43
+ title: bib.title&.first&.content || "",
44
+ edition: extract_edition(bib),
45
+ stage: extract_stage(bib),
46
+ doctype: extract_doctype(bib),
47
+ revdate: extract_revdate(bib),
48
+ files: discover_files(output_dir, base_name),
49
+ channels: [], source: nil
50
+ )
51
+ end
52
+
53
+ def extract_edition(bib)
54
+ ed = bib.edition
55
+ return "1" unless ed
56
+
57
+ ed.is_a?(String) ? ed : ed.content.to_s
58
+ end
59
+
60
+ def extract_stage(bib)
61
+ stage = bib.status&.stage
62
+ return "" unless stage
63
+
64
+ stage.is_a?(String) ? stage : stage.content.to_s
65
+ end
66
+
67
+ def extract_doctype(bib)
68
+ doctype = bib.ext&.doctype
69
+ return "" unless doctype
70
+
71
+ doctype.is_a?(String) ? doctype : doctype.content.to_s
72
+ end
73
+
74
+ def extract_revdate(bib)
75
+ date = bib.date&.find { |d| d.type == "published" } || bib.date&.first
76
+ return nil unless date
77
+
78
+ val = date.at
79
+ val&.to_s
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def discover_files(output_dir, base_name)
85
+ Dir.glob(File.join(output_dir, "#{base_name}.*")).filter_map do |path|
86
+ next if File.directory?(path)
87
+
88
+ name = File.basename(path)
89
+ ext = File.extname(name).delete_prefix(".")
90
+ PublicationFile.new(format: ext, name: name, path: name)
91
+ end
92
+ end
93
+
94
+ def fallback_from_rxl(rxl_path)
95
+ base_name = File.basename(rxl_path, ".rxl")
96
+ slug = SlugStrategy.slug_from_identifier(base_name)
97
+ Publication.new(
98
+ identifier: base_name, slug: slug, title: "",
99
+ edition: "0", stage: "", doctype: "",
100
+ revdate: nil, files: [], channels: [], source: nil
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -13,7 +13,7 @@ module Metanorma
13
13
  @index = index
14
14
  @output_dir = output_dir
15
15
  @data_dir = data_dir
16
- @display_categories = display_categories || []
16
+ @flattener = DocumentFlattener.new(display_categories: display_categories)
17
17
  end
18
18
 
19
19
  def write!
@@ -33,7 +33,7 @@ module Metanorma
33
33
  require "zip"
34
34
 
35
35
  path = zip_path || "#{output_dir}.zip"
36
- Zip::File.open(path, Zip::File::CREATE) do |zipfile|
36
+ Zip::File.open(path, create: true) do |zipfile|
37
37
  Dir.glob("#{output_dir}/**/*").each do |file|
38
38
  next if File.directory?(file)
39
39
 
@@ -50,12 +50,13 @@ module Metanorma
50
50
  index.publications.map do |pub|
51
51
  enrich_publication(pub)
52
52
  rescue StandardError => e
53
- warn " Skip #{pub.identifier}: #{e.message}"
53
+ Metanorma::Release.logger.warn "Skip #{pub.identifier}: #{e.message}"
54
54
  pub.to_h
55
55
  end
56
56
  end
57
57
 
58
58
  def enrich_publication(pub)
59
+ require "relaton/bib"
59
60
  rxl_file = pub.files.find { |f| f.format == "rxl" }
60
61
  return pub.to_h unless rxl_file
61
62
 
@@ -78,171 +79,10 @@ module Metanorma
78
79
 
79
80
  def write_data_file(documents)
80
81
  FileUtils.mkdir_p(@data_dir)
81
- items = documents.compact.map { |doc| flatten_for_site(doc) }
82
+ items = documents.compact.map { |doc| @flattener.flatten(doc) }
82
83
  File.write(File.join(@data_dir, "documents.json"),
83
84
  JSON.pretty_generate({ "items" => items }))
84
85
  end
85
-
86
- def flatten_for_site(doc)
87
- bib = doc["bibliographic"] || {}
88
- doctype = extract_doctype(bib) || doc.fetch("doctype", "")
89
- formats = doc["formats"] || []
90
- base = {
91
- "slug" => doc["id"],
92
- "id" => resolve_doc_id(bib, doc),
93
- "title" => doc["title"].to_s,
94
- "abstract" => extract_abstract(bib),
95
- "stage" => (doc["stage"] || "published").to_s.downcase,
96
- "doctype" => doctype,
97
- "edition" => doc["edition"],
98
- "date" => extract_date(doc),
99
- "channels" => doc["channels"] || [],
100
- "formats" => formats,
101
- "files" => doc["files"] || [],
102
- "bibliographic" => bib,
103
- }
104
- add_format_flags(base, formats)
105
- add_display_category(base, doctype)
106
- add_contributors(base, bib)
107
- base
108
- end
109
-
110
- def add_format_flags(hash, formats)
111
- hash["has_html"] = formats.include?("html")
112
- hash["has_pdf"] = formats.include?("pdf")
113
- hash["has_xml"] = formats.include?("xml")
114
- hash["has_rxl"] = formats.include?("rxl")
115
-
116
- files = hash["files"]
117
- html_file = files.find { |f| f["format"] == "html" }
118
- hash["html_path"] = html_file["path"] if html_file
119
- pdf_file = files.find { |f| f["format"] == "pdf" }
120
- hash["pdf_path"] = pdf_file["path"] if pdf_file
121
- xml_file = files.find { |f| f["format"] == "xml" }
122
- hash["xml_path"] = xml_file["path"] if xml_file
123
- rxl_file = files.find { |f| f["format"] == "rxl" }
124
- hash["rxl_path"] = rxl_file["path"] if rxl_file
125
- end
126
-
127
- def add_display_category(hash, doctype)
128
- cat = resolve_display_category(doctype)
129
- hash["stage_css"] = hash["stage"].gsub(/\s+/, "-")
130
- hash["doctype_class"] = "type-#{doctype.downcase}"
131
- hash["display_category"] = cat&.fetch("name", nil)
132
- hash["display_category_slug"] = cat&.fetch("slug", nil)
133
- end
134
-
135
- def resolve_doc_id(bib, doc)
136
- extract_primary_id(bib) || doc["identifier"] || doc["id"]
137
- end
138
-
139
- def extract_primary_id(bib)
140
- ids = bib["docidentifier"]
141
- return nil unless ids&.any?
142
-
143
- primary = ids.find { |di| di["primary"] == true } || ids.first
144
- primary["content"]
145
- end
146
-
147
- def extract_doctype(bib)
148
- bib.dig("ext", "doctype", "content")
149
- end
150
-
151
- def extract_abstract(bib)
152
- abstracts = bib["abstract"]
153
- return nil unless abstracts&.any?
154
-
155
- abstracts.first["content"]
156
- end
157
-
158
- def extract_date(doc)
159
- bib_date = extract_bib_date(doc["bibliographic"])
160
- return bib_date if bib_date
161
-
162
- release_date = doc.dig("source", "releaseDate")
163
- return nil unless release_date
164
-
165
- release_date.to_s.split(/[T ]/).first
166
- end
167
-
168
- def extract_bib_date(bib)
169
- return nil unless bib
170
-
171
- dates = bib["date"]
172
- return nil if dates.nil? || dates.empty?
173
-
174
- published = dates.find { |d| d["type"] == "published" }
175
- entry = published || dates.first
176
- at = entry["at"]
177
- at ? at.to_s.split(/[T ]/).first : nil
178
- end
179
-
180
- def resolve_display_category(doctype)
181
- return nil if doctype.nil? || doctype.empty?
182
-
183
- @display_categories.each do |cat|
184
- doctypes = cat["doctypes"] || []
185
- return { "name" => cat["name"], "slug" => cat["slug"] } if doctypes.include?(doctype)
186
- end
187
- nil
188
- end
189
-
190
- def add_contributors(hash, bib)
191
- contribs = bib["contributor"] || []
192
- persons, committees = partition_contributors(contribs)
193
- hash["authors"] = persons
194
- hash["committee"] = committees.first
195
- end
196
-
197
- def partition_contributors(contribs)
198
- persons = contribs.filter_map { |c| parse_person(c) }
199
- committees = contribs.filter_map { |c| parse_committee(c) }
200
- [persons, committees]
201
- end
202
-
203
- def parse_person(contrib)
204
- return nil unless contrib["person"]
205
-
206
- name = extract_person_name(contrib["person"])
207
- return nil unless name
208
-
209
- role = (contrib["role"] || []).first&.fetch("type", nil)
210
- { "name" => name, "role" => role }
211
- end
212
-
213
- def parse_committee(contrib)
214
- return nil unless contrib["organization"]
215
-
216
- extract_org_subdivision(contrib["organization"])
217
- end
218
-
219
- def extract_person_name(person)
220
- n = person["name"] || {}
221
- complete = n["completename"]
222
- return complete["content"] if complete.is_a?(Hash) && complete["content"]
223
- return complete if complete.is_a?(String)
224
-
225
- surname = n["surname"]
226
- given = n["given"]
227
- given_str = given.is_a?(Hash) ? given["content"].to_s : given.to_s
228
- parts = [given_str, surname].compact
229
- parts.empty? ? nil : parts.join(" ")
230
- end
231
-
232
- def extract_org_subdivision(org)
233
- subs = org["subdivision"]
234
- return nil unless subs&.any?
235
-
236
- sd = subs.first
237
- sd_name = sd["name"]
238
- if sd_name.is_a?(Array)
239
- sd_name.first&.dig("content")
240
- elsif sd_name.is_a?(Hash)
241
- sd_name["content"]
242
- else
243
- sd_name.to_s
244
- end
245
- end
246
86
  end
247
87
  end
248
88
  end