relaton 3.0.4 → 3.0.5

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: f6cef1665e29f9d4a6e8ab48528253675e5b901399666138306c2be69c0d1b6e
4
- data.tar.gz: e4eef10cab8373db1d911a4b68342b35c1bb335c15f986d24697f286e83d9a96
3
+ metadata.gz: 6e8ce7b77ba7b71f2bc7232319b926e70116ca3cdadf53395ef39e1dc4437a64
4
+ data.tar.gz: d0f29e54fa2129e444819703dfbd9b20ced132b34613053b6944b560370f9447
5
5
  SHA512:
6
- metadata.gz: 41185be7ff82e3b45667eb33380fb3eee8e548e3ea24039d8743c5ed50b2fcc1b1c252536f87a1d6b0c4821e41e17b08a45e861af1d6886a8abe9df287a800a6
7
- data.tar.gz: 3235473497b828e0f5d8890a59b6548994b2ab113d665f010d24e1970c6b24eaa398b298d7f83ba3b770153c558c712059ff5147b5f39d86b61ad5e35312d930
6
+ metadata.gz: 158c8d47b5a66ddd647f568aa35327ec4f1ee51e5d4ece559c77a610d3ac9474bcd90aa9bcedd544dd0cdaacf7dcac74f72560f810e014c2caa86477d7e33954
7
+ data.tar.gz: bf49242892015cae4f95d8b63120aa494bb936db2dd98eac34ac8f90ca1f94b889f7d99fd79cc933777f5a04796cb48dc3b933a110f79e29ce6d1f283bb5b744
@@ -61,7 +61,15 @@ Each bibliographic attribute has its own class in `lib/relaton/bib/model/`:
61
61
  **`Relaton::Bib::Sanitizer`** ([lib/relaton/bib/sanitizer.rb](lib/relaton/bib/sanitizer.rb))
62
62
  strips markup outside the basicdoc set. `LocalizedMarkedUpString#content=` calls it on
63
63
  assignment, so every `Title`, `Abstract`, `Note`, `Formattedref` and `Docidentifier` in
64
- every flavor passes through it. Two properties are load-bearing:
64
+ every flavor passes through it. The set it keeps depends on the field, through
65
+ `#sanitizer_allowed`: every field keeps `ALLOWED` (basicdoc inline markup), and `Abstract`
66
+ keeps `ALLOWED_WITH_BLOCKS`, which adds `BLOCK` — the basicdoc lists, notes, tables, figures
67
+ and admonitions, with the children their grammar requires (`name`, `thead`/`tbody`/`tfoot`,
68
+ `image`). That split follows `biblio.rng`: `<abstract>` takes `BasicBlockNoId`, and a title,
69
+ note, docidentifier or formattedref takes only `PureTextElement`, so do not widen the
70
+ default. Before relaton#421 an abstract's list or note unwrapped to bare `<p>`s with no
71
+ error. A consumer must therefore not assume that an abstract holds only `<p>` and inline
72
+ markup. Two properties are load-bearing:
65
73
 
66
74
  - **It does not give up on an undeclared namespace prefix.** Third-party markup prefixes
67
75
  its elements — Crossref returns JATS as `<jats:p><jats:italic>x</jats:italic></jats:p>`,
@@ -99,7 +107,17 @@ every flavor passes through it. Two properties are load-bearing:
99
107
  ### Rendering
100
108
 
101
109
  - **`Renderer::BibtexBuilder`** - Converts `ItemData` to BibTeX format
102
- - **`Converter::BibXml`** - Converts `ItemData` to RFC XML format via `Relaton::Bib::Converter::BibXml.to_xml`
110
+ - **`Converter::BibXml`** - Converts `ItemData` to an `Rfcxml::V3` model object via
111
+ `Relaton::Bib::Converter::BibXml.from_item`; call `to_xml` on the result (this is what
112
+ `ItemData#to_rfcxml` does). Two output shapes share one class hierarchy:
113
+ - `ToRfcxml` / `ToRfcxmlReferencegroup` (default) emit **BibXML**, the shape the Relaton
114
+ data repositories publish. It keeps the RFC 7991-deprecated `<format>` element, so it
115
+ must stay byte-compatible — every relaton data fetcher's `"bibxml"` format is
116
+ `entry.to_rfcxml`. Do not change its output.
117
+ - `ToRfcxmlV3` / `ToRfcxmlReferencegroupV3` (`v3: true`) subclass those and emit strict
118
+ **RFC 7991**: no `<format>`, plus `<stream>`, `ascii*` attributes and `<refcontent>`.
119
+ New v3-only behaviour belongs in the subclass, so the default path stays untouched by
120
+ construction and `spec/bib/relaton/bib/converter/bibxml_spec.rb` keeps passing unedited.
103
121
  - **lutaml-model native** - `to_xml`, `to_yaml`, `to_json` via the serialization classes
104
122
 
105
123
  ### Usage Pattern
@@ -120,11 +138,13 @@ item.to_xml # as <bibitem>
120
138
  item.to_xml(bibdata: true) # as <bibdata>
121
139
  item.to_yaml
122
140
  item.to_bibtex
123
- item.to_rfcxml
141
+ item.to_rfcxml # BibXML (what the data repos publish)
142
+ item.to_rfcxml(v3: true) # strict RFC 7991 / xml2rfc v3
143
+ item.to_rfcxml(anchor: "ISO712", include_keywords: false)
124
144
  ```
125
145
 
126
146
  ## Code Style
127
147
 
128
148
  - Follows Ribose OSS Ruby style guide (inherited via `.rubocop.yml`)
129
- - Target Ruby version: 3.1+
149
+ - Target Ruby version: 3.3+ (`required_ruby_version` in the gemspec, `TargetRubyVersion` in `.rubocop.yml`)
130
150
  - Uses YARD documentation comments
@@ -311,6 +311,70 @@ item.to_rfcxml
311
311
  </reference>
312
312
  ----
313
313
 
314
+ === Export bibliographic item to strict RFC XML v3
315
+
316
+ `to_rfcxml` emits the BibXML shape published by the Relaton data repositories,
317
+ which still carries the `<format>` element that RFC 7991 deprecates. Pass
318
+ `v3: true` for output that follows the RFC 7991 (xml2rfc v3) vocabulary
319
+ instead: no `<format>`, a `<stream>` element, `ascii*` attributes, and
320
+ `<refcontent>` for identifiers that cannot be expressed as `<seriesInfo>`.
321
+
322
+ [source,ruby]
323
+ ----
324
+ item.to_rfcxml v3: true
325
+ <reference anchor="ISO20483">
326
+ <front>
327
+ <title>Cereals and pulses</title>
328
+ <author initials="Ö." asciiInitials="O." surname="Nürk" asciiSurname="Nurk"/>
329
+ <author>
330
+ <organization abbrev="ISO" ascii="International Organization for Standardization">International Organization for Standardization</organization>
331
+ </author>
332
+ <date year="2013"/>
333
+ </front>
334
+ <refcontent>ISO 20483:2013-2014</refcontent>
335
+ </reference>
336
+ ----
337
+
338
+ Documents published by the IETF (identified by a contributing organization
339
+ named `IETF`, `Internet Engineering Task Force` or `RFC Publisher`) express
340
+ their identifiers as `<seriesInfo>` rather than `<refcontent>`:
341
+
342
+ [source,ruby]
343
+ ----
344
+ <reference anchor="RFC2119" target="https://www.rfc-editor.org/info/rfc2119">
345
+ <stream>IETF</stream>
346
+ <front>
347
+ <title>Key words for use in RFCs to Indicate Requirement Levels</title>
348
+ <seriesInfo name="BCP" value="14"/>
349
+ <seriesInfo name="RFC" value="2119"/>
350
+ ...
351
+ </front>
352
+ </reference>
353
+ ----
354
+
355
+ The v3 mode also applies the citation rules the IETF toolchain expects, which
356
+ the BibXML mode leaves alone:
357
+
358
+ * Contributors with role `author` or `editor` become `<author>`; the wider set
359
+ (`publisher`, `authorizer`, `translator`, …) is used only when the item names
360
+ no author or editor, so an RFC's publisher is not listed as its author.
361
+ * `<title>` takes the composite `title[@type="main"]`, else joins
362
+ title-intro / title-main / title-part, so multipart standards keep their full
363
+ title. Inline markup is flattened, since v3 `<title>` is text-only.
364
+ * Identifiers that restate the reference label, `URN` identifiers and the
365
+ `I-D.` anchor form are left out of `<refcontent>`; Internet-Draft
366
+ `<seriesInfo>` carries the full `draft-…` name, which is what xml2rfc
367
+ resolves against.
368
+ * Where folding a name leaves no letters (a non-Latin script), the `ascii*`
369
+ attribute is omitted rather than emitted blank.
370
+
371
+ Two further options are accepted by both modes:
372
+
373
+ `anchor:`:: overrides the anchor otherwise derived from `docnumber`, for
374
+ callers that carry their own identifier (for example a Metanorma Semantic XML
375
+ `bibitem/@id`).
376
+ `include_keywords:`:: set to `false` to suppress `<keyword>` elements.
377
+
314
378
  === Logging
315
379
 
316
380
  RelatonBib uses the relaton-logger gem for logging. By default, it logs to STDOUT. To change the log levels and add other loggers, read the https://github.com/relaton/relaton-logger#usage[relaton-logger] documentation.
@@ -326,7 +326,18 @@ module Relaton
326
326
 
327
327
  t = Title.new(content: si.name, language: "en", script: "Latn")
328
328
  Series.new(title: [t], number: si.value, type: "main")
329
- end.compact
329
+ end.compact + stream_series
330
+ end
331
+
332
+ # RFC XML v3 carries the publication stream in a <stream> element.
333
+ # ItemData has no dedicated slot for it, so it is kept the way
334
+ # relaton-ietf does: as a series marked `type="stream"`.
335
+ def stream_series
336
+ return [] unless @reference.stream
337
+
338
+ t = Title.new(content: @reference.stream, language: "en",
339
+ script: "Latn")
340
+ [Series.new(title: [t], type: "stream")]
330
341
  end
331
342
 
332
343
  # --- Keyword ---
@@ -5,14 +5,15 @@ module Relaton
5
5
  module Converter
6
6
  module BibXml
7
7
  class ToRfcxml
8
- def initialize(item, include_keywords: true)
8
+ def initialize(item, include_keywords: true, anchor: nil)
9
9
  @item = item
10
10
  @include_keywords = include_keywords
11
+ @anchor = anchor
11
12
  end
12
13
 
13
14
  def transform
14
15
  model = ::Rfcxml::V3::Reference.new
15
- model.anchor = @item.docnumber || derive_anchor
16
+ model.anchor = @anchor || @item.docnumber || derive_anchor
16
17
  model.target = create_target
17
18
  model.front = create_front
18
19
  model.format = create_format
@@ -26,14 +27,22 @@ module Relaton
26
27
  di&.content&.to_s&.gsub(" ", ".")
27
28
  end
28
29
 
30
+ # A <uri> may legitimately carry no type -- biblio.rng makes it
31
+ # optional, and hand-authored bibitems often omit it -- so never call
32
+ # String methods on it unguarded.
33
+ def target_types = %w[src doi]
34
+
29
35
  def create_target
30
- target = @item.source.detect { |l| l.type.casecmp("src").zero? } ||
31
- @item.source.detect { |l| l.type.casecmp("doi").zero? }
36
+ target = target_types.filter_map { |t| source_of_type(t) }.first
32
37
  return unless target
33
38
 
34
39
  target.content.to_s
35
40
  end
36
41
 
42
+ def source_of_type(type)
43
+ @item.source.detect { |l| l.type&.casecmp(type)&.zero? }
44
+ end
45
+
37
46
  def create_front # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
38
47
  front = ::Rfcxml::V3::Front.new
39
48
  front.title = Rfcxml::V3::Title.new(content: @item.title[0].content) if @item.title.any?
@@ -59,9 +68,16 @@ module Relaton
59
68
  end
60
69
  end
61
70
 
71
+ # A `type="stream"` series is the publication stream, not a series:
72
+ # it has no number, so it would yield a valueless <seriesInfo>.
73
+ def seriesinfo_series?(ser)
74
+ ser.type != "stream" &&
75
+ ser.title.reject { |t| t.content == "DOI" }.any?
76
+ end
77
+
62
78
  def series_to_seriesinfo # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
63
79
  @item.series.select do |s|
64
- s.title.reject { |t| t.content == "DOI" }.any?
80
+ seriesinfo_series?(s)
65
81
  end.uniq do |s|
66
82
  s.title.find { |t| t.content != "DOI" }.content
67
83
  end.each_with_object([]) do |s, si|
@@ -71,8 +87,13 @@ module Relaton
71
87
  end
72
88
  end
73
89
 
90
+ # Which contributors become <author>; overridden by the v3 emitter.
91
+ def contributors_for_authors
92
+ @item.contributor.reject { |c| committee_contributor?(c) }
93
+ end
94
+
74
95
  def create_authors # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
75
- @item.contributor.reject { |c| committee_contributor?(c) }.map do |contrib|
96
+ contributors_for_authors.map do |contrib|
76
97
  role = "editor" if contrib.role.detect { |r| r.type == "editor" }
77
98
  Rfcxml::V3::Author.new(
78
99
  role: role,
@@ -284,7 +305,10 @@ module Relaton
284
305
  return unless @item.abstract.any?
285
306
 
286
307
  content = @item.abstract[0].content
287
- paragraphs = content.scan(%r{<p>(.*?)</p>}m).flatten
308
+ # Semantic XML paragraphs carry attributes (`<p id="_349ea…">`);
309
+ # match those too, or the whole abstract falls through to the
310
+ # fallback below and the markup is escaped into the <t>.
311
+ paragraphs = content.scan(%r{<p\b[^>]*>(.*?)</p>}m).flatten
288
312
  paragraphs = [content] if paragraphs.empty?
289
313
  ts = paragraphs.map { |p| Rfcxml::V3::Text.new(content: CGI.unescapeHTML(p)) }
290
314
  Rfcxml::V3::Abstract.new(t: ts)
@@ -294,7 +318,7 @@ module Relaton
294
318
 
295
319
  def create_format # rubocop:disable Metrics/AbcSize
296
320
  @item.source.each_with_object([]) do |l, a|
297
- next unless FORMAT_TYPES.any? { |ft| l.type.casecmp(ft).zero? }
321
+ next unless FORMAT_TYPES.any? { |ft| l.type&.casecmp(ft)&.zero? }
298
322
 
299
323
  a << Rfcxml::V3::Format.new(
300
324
  type: l.type, target: l.content,
@@ -3,9 +3,10 @@ module Relaton
3
3
  module Converter
4
4
  module BibXml
5
5
  class ToRfcxmlReferencegroup
6
- def initialize(item, include_keywords: true)
6
+ def initialize(item, include_keywords: true, anchor: nil)
7
7
  @item = item
8
8
  @include_keywords = include_keywords
9
+ @anchor = anchor
9
10
  end
10
11
 
11
12
  def transform
@@ -30,6 +31,8 @@ module Relaton
30
31
  end
31
32
 
32
33
  def create_anchor
34
+ return @anchor if @anchor
35
+
33
36
  docid = @item.docidentifier.detect(&:primary) ||
34
37
  @item.docidentifier[0]
35
38
  return unless docid
@@ -38,9 +41,13 @@ module Relaton
38
41
  id.sub(/^(RFC|BCP|FYI|STD) /, '\1').sub(/^\w+\./, "")
39
42
  end
40
43
 
44
+ # An untyped <uri> is legal, so guard before comparing.
45
+ def target_types = %w[src doi]
46
+
41
47
  def create_target
42
- target = @item.source.detect { |l| l.type.casecmp("src").zero? } ||
43
- @item.source.detect { |l| l.type.casecmp("doi").zero? }
48
+ target = target_types.filter_map do |type|
49
+ @item.source.detect { |l| l.type&.casecmp(type)&.zero? }
50
+ end.first
44
51
  return unless target
45
52
 
46
53
  target.content.to_s
@@ -0,0 +1,43 @@
1
+ module Relaton
2
+ module Bib
3
+ module Converter
4
+ module BibXml
5
+ #
6
+ # Strict RFC 7991 flavour of {ToRfcxmlReferencegroup}: members are
7
+ # rendered by {ToRfcxmlV3}, and every one of them gets an anchor,
8
+ # which the v3 grammar makes mandatory on `<reference>`.
9
+ #
10
+ class ToRfcxmlReferencegroupV3 < ToRfcxmlReferencegroup
11
+ private
12
+
13
+ def target_types = %w[src HTML doi]
14
+
15
+ # <referencegroup> needs an anchor just as much as its members do,
16
+ # so fall back past the docidentifier the parent relies on.
17
+ def create_anchor
18
+ anchor = super || @item.docnumber || @item.id
19
+ anchor && anchor.to_s.strip.gsub(/\s+/, ".")
20
+ end
21
+
22
+ def build_references
23
+ included_items.each_with_index.map do |item, idx|
24
+ ref = ToRfcxmlV3.new(item, include_keywords: @include_keywords)
25
+ .transform
26
+ ref.anchor = member_anchor(idx) if ref.anchor.to_s.empty?
27
+ ref
28
+ end
29
+ end
30
+
31
+ def included_items
32
+ @item.relation.select { |rel| rel.type == "includes" }
33
+ .map(&:bibitem)
34
+ end
35
+
36
+ def member_anchor(idx)
37
+ "#{create_anchor || 'reference'}_#{idx + 1}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,416 @@
1
+ module Relaton
2
+ module Bib
3
+ module Converter
4
+ module BibXml
5
+ #
6
+ # Strict RFC 7991 (xml2rfc v3) flavour of {ToRfcxml}.
7
+ #
8
+ # It differs from the BibXML output its parent produces in four ways:
9
+ #
10
+ # * the deprecated `<format>` element is never emitted;
11
+ # * identifiers of documents published outside the IETF go into
12
+ # `<refcontent>` rather than being forced into `<seriesInfo>`
13
+ # (RFC 7991 2.39), as do series that carry no number;
14
+ # * `<stream>` and the `ascii*` attributes are populated;
15
+ # * `<front>` is filled out enough to satisfy the v3 grammar, which
16
+ # requires a title and at least one author.
17
+ #
18
+ class ToRfcxmlV3 < ToRfcxml
19
+ # Organizations whose documents are "home" standards: their
20
+ # identifiers are expressible as <seriesInfo>.
21
+ HOME_ORGS = [
22
+ "IETF", "Internet Engineering Task Force", "RFC Publisher"
23
+ ].freeze
24
+
25
+ # Identifier types that are already emitted as <seriesInfo> by the
26
+ # parent, or that never belong in one.
27
+ NON_AUTHORITATIVE_TYPES = %w[DOI URI].freeze
28
+
29
+ # Identifiers metanorma-ietf drops as internal bookkeeping.
30
+ IGNORED_ID_RE = /\A(rfc-anchor|Internet-Draft)/
31
+
32
+ # Series that name the document itself; handled as identifiers.
33
+ SUBSERIES = %w[BCP STD].freeze
34
+
35
+ HOME_ID_RE = /\A(?<name>RFC|BCP|STD|FYI)\s*(?<value>\d+)\z/i
36
+ # xml2rfc only resolves a draft reference when seriesInfo carries the
37
+ # full draft name, so keep `draft-…` intact. The `I-D.` anchor form
38
+ # is a citation label, not the identifier (metanorma-ietf#283).
39
+ DRAFT_ID_RE = /\A(?<value>draft-\S+)\z/i
40
+ DRAFT_ANCHOR_RE = /\AI-D[.\s]\s*\S+\z/i
41
+
42
+ # Identifier types that duplicate a human-readable identifier or are
43
+ # bookkeeping rather than citation content (metanorma-ietf#301).
44
+ EXCLUDED_ID_TYPES = %w[URN].freeze
45
+
46
+ # Title parts joined when relaton supplies no composite title.
47
+ TITLE_PARTS = %w[title-intro title-main title-part].freeze
48
+
49
+ # Roles that may stand in for an author when the item names none,
50
+ # mirroring the released renderer's `creatornames_roles_allowed`.
51
+ FALLBACK_ROLES = %w[performer adapter translator publisher
52
+ distributor authorizer].freeze
53
+
54
+ # The values RFC XML v3 allows on <stream>; "editorial" was added by
55
+ # RFC 9280. Keyed by the spellings the RFC Editor and relaton-ietf
56
+ # use, so "Independent Submission" is not silently thrown away.
57
+ STREAMS = {
58
+ "ietf" => "IETF", "iab" => "IAB", "irtf" => "IRTF",
59
+ "independent" => "independent",
60
+ "independent submission" => "independent",
61
+ "independent submission stream" => "independent",
62
+ "editorial" => "editorial", "rfc editor" => "editorial",
63
+ "rfc-editor" => "editorial"
64
+ }.freeze
65
+
66
+ # Latin letters with no NFKD decomposition, which would otherwise be
67
+ # dropped outright ("Sørensen" -> "Srensen").
68
+ TRANSLITERATIONS = {
69
+ "æ" => "ae", "Æ" => "AE", "œ" => "oe", "Œ" => "OE",
70
+ "ø" => "o", "Ø" => "O", "ß" => "ss", "ẞ" => "SS",
71
+ "đ" => "d", "Đ" => "D", "ð" => "d", "Ð" => "D",
72
+ "þ" => "th", "Þ" => "Th", "ł" => "l", "Ł" => "L",
73
+ "ħ" => "h", "Ħ" => "H", "ŋ" => "ng", "Ŋ" => "NG",
74
+ "ŧ" => "t", "Ŧ" => "T", "ı" => "i", "ĸ" => "k"
75
+ }.freeze
76
+
77
+ def transform
78
+ model = ::Rfcxml::V3::Reference.new
79
+ model.anchor = create_anchor
80
+ model.target = create_target
81
+ model.stream = create_stream
82
+ model.front = create_front
83
+ model.refcontent = create_refcontent
84
+ model
85
+ end
86
+
87
+ private
88
+
89
+ # The released IETF renderer takes an HTML-typed uri as the reference
90
+ # target when there is no src (isodoc/ietf/references.rb), and the
91
+ # transformer does the same; match it here.
92
+ def target_types = %w[src HTML doi]
93
+
94
+ # anchor is mandatory in v3 and is an XML identifier, so fall back
95
+ # through every source the item has and never leave whitespace in it.
96
+ def create_anchor
97
+ sanitize_anchor(@anchor || @item.docnumber || derive_anchor ||
98
+ @item.id)
99
+ end
100
+
101
+ def sanitize_anchor(anchor)
102
+ return nil if anchor.nil?
103
+
104
+ anchor.to_s.strip.gsub(/\s+/, ".")
105
+ end
106
+
107
+ # <front> needs a title and at least one author to be valid v3.
108
+ def create_front
109
+ front = super
110
+ front.title = compound_title || formattedref_title
111
+ front.author = [unknown_author] if Array(front.author).empty?
112
+ front
113
+ end
114
+
115
+ # The base emitter takes title[0], which on a multipart standard is
116
+ # the intro alone ("IT Security techniques"). A citation needs the
117
+ # whole compound, so prefer the composite title relaton builds, then
118
+ # the intro-main-part join, then whatever is first.
119
+ def compound_title
120
+ content = composite_title || joined_title ||
121
+ @item.title.first&.content
122
+ return nil if content.nil?
123
+
124
+ ::Rfcxml::V3::Title.new(content: plain_text(content))
125
+ end
126
+
127
+ def composite_title
128
+ @item.title.find { |t| t.type == "main" }&.content
129
+ end
130
+
131
+ def joined_title
132
+ parts = TITLE_PARTS.filter_map do |type|
133
+ @item.title.find { |t| t.type == type }&.content
134
+ end
135
+ parts.empty? ? nil : parts.join(" - ")
136
+ end
137
+
138
+ def formattedref_title
139
+ content = @item.formattedref&.content or return nil
140
+
141
+ ::Rfcxml::V3::Title.new(content: plain_text(content))
142
+ end
143
+
144
+ # v3 <title> is text-only, so inline markup a formattedref or title
145
+ # carries has to be flattened rather than escaped into the output.
146
+ def plain_text(content)
147
+ text = content.to_s
148
+ return text unless text.include?("<")
149
+
150
+ Nokogiri::XML.fragment(text).text.squeeze(" ").strip
151
+ end
152
+
153
+ def unknown_author
154
+ ::Rfcxml::V3::Author.new(surname: "Unknown")
155
+ end
156
+
157
+ # One <seriesInfo> per (name, value): the same identifier can arrive
158
+ # from a docidentifier and a series, or from two docidentifiers that
159
+ # spell it differently ("RFC 2119" and "RFC2119").
160
+ def create_seriesinfo
161
+ (docidentifier_to_seriesinfo + series_to_seriesinfo +
162
+ identifier_to_seriesinfo).uniq { |si| [si.name, si.value] }
163
+ end
164
+
165
+ # Only series that carry a number can become <seriesInfo>; the rest
166
+ # are rendered as <refcontent>.
167
+ def series_to_seriesinfo
168
+ numbered_series.map do |ser|
169
+ ::Rfcxml::V3::SeriesInfo.new(name: series_title(ser).content,
170
+ value: ser.number)
171
+ end
172
+ end
173
+
174
+ def numbered_series
175
+ renderable_series.select(&:number)
176
+ end
177
+
178
+ # Series that are neither the stream marker nor a restatement of
179
+ # something already rendered from the document's identifiers. Only
180
+ # names actually emitted elsewhere are dropped: a series such as
181
+ # Internet-Draft still carries its number when no docidentifier
182
+ # supplies one.
183
+ def renderable_series
184
+ @item.series.reject { |ser| skip_series?(ser) }
185
+ .uniq { |ser| series_title(ser).content }
186
+ end
187
+
188
+ def skip_series?(ser)
189
+ return true if ser.type == "stream"
190
+
191
+ title = series_title(ser)
192
+ title.nil? || title.content.to_s == "DOI" ||
193
+ identifier_series_names.include?(title.content.to_s)
194
+ end
195
+
196
+ # Series names already covered by <seriesInfo> or <refcontent> built
197
+ # from the item's identifiers.
198
+ def identifier_series_names
199
+ @identifier_series_names ||=
200
+ identifier_to_seriesinfo.map(&:name) +
201
+ subseries_identifiers.map { |id| id.split.first }
202
+ end
203
+
204
+ def series_title(ser)
205
+ ser.title.find { |title| title.content.to_s != "DOI" }
206
+ end
207
+
208
+ # --- Identifiers ---
209
+
210
+ def identifier_to_seriesinfo
211
+ return [] unless home_standard?
212
+
213
+ authoritative_identifiers.filter_map { |id| id_to_seriesinfo(id) }
214
+ end
215
+
216
+ def id_to_seriesinfo(id)
217
+ name, value = split_identifier(id)
218
+ return nil unless name
219
+
220
+ # No `stream` attribute here: in v3 the stream is carried by the
221
+ # <stream> element, and duplicating it only adds noise.
222
+ ::Rfcxml::V3::SeriesInfo.new(name: name, value: value,
223
+ status: seriesinfo_status)
224
+ end
225
+
226
+ def split_identifier(id)
227
+ if (match = DRAFT_ID_RE.match(id))
228
+ ["Internet-Draft", match[:value]]
229
+ elsif (match = HOME_ID_RE.match(id))
230
+ [match[:name].upcase, match[:value]]
231
+ end
232
+ end
233
+
234
+ # An untyped docidentifier that merely restates the reference's own
235
+ # label ("ZELLER", "Grail") is a citation label, not a citation: keep
236
+ # it only when the reference would otherwise show nothing at all.
237
+ def label_echo?(docid)
238
+ return false unless docid.type.nil?
239
+
240
+ docid.content.to_s.casecmp(label_candidates.to_s).zero? &&
241
+ reference_has_visible_text?
242
+ end
243
+
244
+ def label_candidates
245
+ @item.docnumber || @item.id
246
+ end
247
+
248
+ def reference_has_visible_text?
249
+ @item.title.any? || @item.formattedref ||
250
+ @item.contributor.any? || @item.date.any?
251
+ end
252
+
253
+ def seriesinfo_status
254
+ @item.status&.stage&.content
255
+ end
256
+
257
+ def authoritative_identifiers
258
+ ids = subseries_identifiers + docidentifier_identifiers
259
+ ids.reject { |id| id.empty? || IGNORED_ID_RE.match?(id) }.uniq
260
+ end
261
+
262
+ def docidentifier_identifiers
263
+ @item.docidentifier.reject { |di| non_authoritative?(di) }
264
+ .map { |di| di.content.to_s.strip }
265
+ end
266
+
267
+ def non_authoritative?(docid)
268
+ NON_AUTHORITATIVE_TYPES.include?(docid.type) ||
269
+ EXCLUDED_ID_TYPES.include?(docid.type) ||
270
+ docid.scope == "trademark" ||
271
+ DRAFT_ANCHOR_RE.match?(docid.content.to_s) ||
272
+ label_echo?(docid)
273
+ end
274
+
275
+ def subseries_identifiers
276
+ @item.series.filter_map do |ser|
277
+ title = ser.title.find { |t| SUBSERIES.include?(t.content.to_s) }
278
+ "#{title.content} #{ser.number}" if title && ser.number
279
+ end
280
+ end
281
+
282
+ def home_standard?
283
+ @item.contributor.any? do |contrib|
284
+ org = contrib.organization or next false
285
+
286
+ ([org.abbreviation&.content] + org.name.map(&:content))
287
+ .compact.any? { |name| HOME_ORGS.include?(name.to_s) }
288
+ end
289
+ end
290
+
291
+ # --- refcontent ---
292
+
293
+ def create_refcontent
294
+ (identifier_refcontent + series_refcontent)
295
+ .map { |text| ::Rfcxml::V3::Refcontent.new(content: text) }
296
+ end
297
+
298
+ # Identifiers that cannot be expressed as <seriesInfo> — either
299
+ # because the document is not an IETF one, or because the identifier
300
+ # does not split into a series name and number.
301
+ # Whatever <seriesInfo> already states must not be repeated here,
302
+ # whichever route put it there.
303
+ def identifier_refcontent
304
+ ids = authoritative_identifiers.reject { |id| in_seriesinfo?(id) }
305
+ ids.empty? ? [] : [ids.join(", ")]
306
+ end
307
+
308
+ def in_seriesinfo?(id)
309
+ name, value = split_identifier(id)
310
+ return false if name.nil?
311
+
312
+ create_seriesinfo.any? { |si| si.name == name && si.value == value }
313
+ end
314
+
315
+ def series_refcontent
316
+ (renderable_series - numbered_series).filter_map do |ser|
317
+ text = ser.formattedref&.content&.to_s || series_description(ser)
318
+ text unless text.strip.empty?
319
+ end
320
+ end
321
+
322
+ def series_description(ser)
323
+ [series_title(ser)&.content, ser.run, ser.organization,
324
+ ser.place&.city, series_dates(ser)]
325
+ .map(&:to_s).reject(&:empty?).join(", ")
326
+ end
327
+
328
+ def series_dates(ser)
329
+ [ser.from, ser.to].compact.map(&:to_s).reject(&:empty?).join("-")
330
+ end
331
+
332
+ # --- stream ---
333
+
334
+ # Values outside the grammar (relaton-ietf also has "Legacy") have no
335
+ # valid representation, so they are dropped with a warning rather
336
+ # than emitted as invalid XML.
337
+ def create_stream
338
+ value = ext_stream || series_stream
339
+ return nil if value.nil?
340
+
341
+ stream = STREAMS[value.to_s.strip.downcase]
342
+ unless stream
343
+ Util.warn "Dropping stream `#{value}`: not an RFC XML v3 stream"
344
+ end
345
+ stream
346
+ end
347
+
348
+ def ext_stream
349
+ @item.ext.respond_to?(:stream) ? @item.ext.stream : nil
350
+ end
351
+
352
+ def series_stream
353
+ ser = @item.series.find { |s| s.type == "stream" }
354
+ ser && ser.title.first&.content
355
+ end
356
+
357
+ # --- Contributors ---
358
+
359
+ # Every relaton-ietf RFC record carries publisher and authorizer
360
+ # contributors, and published RFC XML never lists those as authors.
361
+ # So: authors and editors if there are any, and only otherwise fall
362
+ # back to the wider set, which keeps a translator-only monograph its
363
+ # translator (metanorma-ietf#301).
364
+ def contributors_for_authors
365
+ contribs = super
366
+ primary = contribs.select { |c| role?(c, %w[author editor]) }
367
+ primary.any? ? primary : contribs.select { |c| fallback_author?(c) }
368
+ end
369
+
370
+ def role?(contrib, types)
371
+ contrib.role.any? { |r| types.include?(r.type) }
372
+ end
373
+
374
+ # A contributor with no role at all is an author by default; that is
375
+ # how most non-IETF bibitems are written.
376
+ def fallback_author?(contrib)
377
+ contrib.role.empty? || role?(contrib, FALLBACK_ROLES)
378
+ end
379
+
380
+ # --- ascii folding ---
381
+
382
+ def create_authors
383
+ super.each do |author|
384
+ author.ascii_fullname = ascii(author.fullname)
385
+ author.ascii_surname = ascii(author.surname)
386
+ author.ascii_initials = ascii(author.initials)
387
+ end
388
+ end
389
+
390
+ def create_organization(contrib)
391
+ org = super or return nil
392
+
393
+ org.ascii = ascii(Array(org.content).join)
394
+ org
395
+ end
396
+
397
+ # Transliterate to bare ASCII: expand the letters above, then strip
398
+ # the combining marks NFKD leaves behind. "Nürk" -> "Nurk",
399
+ # "Ö." -> "O.", "Sørensen" -> "Sorensen".
400
+ def ascii(str)
401
+ return nil if str.nil?
402
+
403
+ folded = str.to_s.gsub(/[#{TRANSLITERATIONS.keys.join}]/,
404
+ TRANSLITERATIONS)
405
+ .unicode_normalize(:nfkd)
406
+ .encode("ASCII", invalid: :replace, undef: :replace, replace: "")
407
+ # Folding a non-Latin script leaves only the punctuation and
408
+ # spacing behind ("Νίκος Παπαδόπουλος" -> " "), which is worse than
409
+ # no attribute at all. Real transliteration is the caller's policy.
410
+ folded.match?(/[[:alnum:]]/) ? folded : nil
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
@@ -1,5 +1,7 @@
1
1
  require_relative "bibxml/to_rfcxml"
2
2
  require_relative "bibxml/to_rfcxml_referencegroup"
3
+ require_relative "bibxml/to_rfcxml_v3"
4
+ require_relative "bibxml/to_rfcxml_referencegroup_v3"
3
5
  require_relative "bibxml/from_rfcxml"
4
6
  require_relative "bibxml/from_rfcxml_referencegroup"
5
7
 
@@ -16,17 +18,27 @@ module Relaton
16
18
  RFCPREFIXES = %w[RFC BCP FYI STD].freeze
17
19
 
18
20
  # Forward: ItemData -> Rfcxml model
19
- def self.from_item(item, include_keywords: true) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
20
- if bcp?(item)
21
- ToRfcxmlReferencegroup.new(
22
- item, include_keywords: include_keywords
23
- ).transform
24
- else
25
- ToRfcxml.new(
26
- item, include_keywords: include_keywords
27
- ).transform
21
+ #
22
+ # @param v3 [Boolean] emit strict RFC 7991 (xml2rfc v3) instead of the
23
+ # BibXML shape the relaton data fetchers publish
24
+ # @param anchor [String, nil] anchor to use instead of the one derived
25
+ # from the item's docnumber/docidentifier
26
+ def self.from_item(item, include_keywords: true, v3: false, anchor: nil) # rubocop:disable Naming/MethodParameterName
27
+ klass = if v3 then v3_converter(item)
28
+ elsif bcp?(item) then ToRfcxmlReferencegroup
29
+ else ToRfcxml
30
+ end
31
+ klass.new(item, include_keywords: include_keywords,
32
+ anchor: anchor).transform
33
+ end
34
+
35
+ def self.v3_converter(item)
36
+ if item.relation.any? { |rel| rel.type == "includes" }
37
+ ToRfcxmlReferencegroupV3
38
+ else ToRfcxmlV3
28
39
  end
29
40
  end
41
+ private_class_method :v3_converter
30
42
 
31
43
  def self.bcp?(item) # rubocop:disable Metrics/CyclomaticComplexity
32
44
  item.docnumber&.match(/^BCP/) ||
@@ -19,8 +19,18 @@ module Relaton
19
19
  COLLECTION_WRITE_ONLY_ATTRIBUTES = %i[title abstract source relation].freeze
20
20
 
21
21
  ATTRIBUTES.each { |attr| attr_accessor attr }
22
- COLLECTION_ATTRBUTES.each { |attr| attr_accessor attr }
23
- COLLECTION_WRITE_ONLY_ATTRIBUTES.each { |attr| attr_writer attr }
22
+ COLLECTION_ATTRBUTES.each { |attr| attr_reader attr }
23
+
24
+ # Collections are always arrays. lutaml-model assigns every mapped
25
+ # attribute after building the instance, so an element absent from the
26
+ # XML would otherwise overwrite the [] set in #initialize with nil --
27
+ # `Item.from_xml` items then blew up in any consumer that iterated them,
28
+ # while `from_yaml` ones did not.
29
+ (COLLECTION_ATTRBUTES + COLLECTION_WRITE_ONLY_ATTRIBUTES).each do |attr|
30
+ define_method("#{attr}=") do |value|
31
+ instance_variable_set("@#{attr}", array(value))
32
+ end
33
+ end
24
34
 
25
35
  attr_reader :id, :docidentifier
26
36
 
@@ -28,10 +38,10 @@ module Relaton
28
38
  ATTRIBUTES.each { |attr| instance_variable_set("@#{attr}", args[attr]) }
29
39
 
30
40
  (COLLECTION_ATTRBUTES + COLLECTION_WRITE_ONLY_ATTRIBUTES).each do |attr|
31
- instance_variable_set("@#{attr}", args[attr] || [])
41
+ instance_variable_set("@#{attr}", array(args[attr]))
32
42
  end
33
43
 
34
- @docidentifier = args[:docidentifier] || []
44
+ @docidentifier = array(args[:docidentifier])
35
45
  self.schema_version = schema
36
46
  @id = args[:id]
37
47
  create_id
@@ -42,7 +52,7 @@ module Relaton
42
52
  end
43
53
 
44
54
  def docidentifier=(value)
45
- @docidentifier = value || []
55
+ @docidentifier = array(value)
46
56
  create_id
47
57
  end
48
58
 
@@ -179,8 +189,10 @@ module Relaton
179
189
  Converter::Asciibib.from_item(self)
180
190
  end
181
191
 
182
- def to_rfcxml
183
- Converter::BibXml.from_item(self).to_xml
192
+ # @param opts [Hash] passed to {Converter::BibXml.from_item}; notably
193
+ # `v3: true` for strict RFC 7991 output instead of BibXML.
194
+ def to_rfcxml(**)
195
+ Converter::BibXml.from_item(self, **).to_xml
184
196
  end
185
197
 
186
198
  def deep_clone
@@ -11,6 +11,11 @@ module Relaton
11
11
  key_value do
12
12
  map "format", to: :format
13
13
  end
14
+
15
+ # biblio.rng admits basicdoc blocks in <abstract> only.
16
+ def sanitizer_allowed
17
+ Relaton::Bib::Sanitizer::ALLOWED_WITH_BLOCKS
18
+ end
14
19
  end
15
20
  end
16
21
  end
@@ -28,10 +28,15 @@ module Relaton
28
28
  class LocalizedMarkedUpString < LocalizedStringAttrs
29
29
  module ContentSanitization
30
30
  def content=(value)
31
- super(Relaton::Bib::Sanitizer.sanitize(value))
31
+ super(Sanitizer.sanitize(value, allowed: sanitizer_allowed))
32
32
  end
33
33
  end
34
34
 
35
+ # The elements that content keeps; Abstract widens it to blocks.
36
+ def sanitizer_allowed
37
+ Relaton::Bib::Sanitizer::ALLOWED
38
+ end
39
+
35
40
  # Cast at the assignment layer (lutaml-model#972): a bare scalar
36
41
  # assigned to a singular custom-typed attribute (e.g. a relation's
37
42
  # description) is wrapped here, so the serializer's type validation
@@ -24,12 +24,27 @@ module Relaton
24
24
  # markup, and must be preserved verbatim. Without the opaque-skip,
25
25
  # the recursive walk would unwrap MathML / AsciiMath elements down to
26
26
  # bare text nodes — see #116 for the round-trip-loss symptom.
27
+ #
28
+ # ALLOWED_WITH_BLOCKS adds the basicdoc block elements: lists, notes,
29
+ # tables, figures and admonitions, with the children that their grammar
30
+ # requires (a caption <name>, <thead>/<tbody>/<tfoot>, a figure's
31
+ # <image>). Only Abstract asks for it, because <abstract> is the one
32
+ # field whose grammar admits blocks; a title or a note admits only
33
+ # PureTextElement. Before relaton#421 an abstract's list or note
34
+ # unwrapped in place and came back as bare paragraphs, with no error.
27
35
  module Sanitizer
28
36
  ALLOWED = %w[
29
37
  em strong sub sup tt underline strike smallcap br stem
30
38
  p eref xref fn link
31
39
  ].freeze
32
40
 
41
+ BLOCK = %w[
42
+ ul ol li dl dt dd note admonition
43
+ table thead tbody tfoot tr td th figure name image
44
+ ].freeze
45
+
46
+ ALLOWED_WITH_BLOCKS = (ALLOWED + BLOCK).freeze
47
+
33
48
  # Elements whose children are non-basicdoc inline notation
34
49
  # (MathML, AsciiMath, LaTeX, …) and must be preserved verbatim
35
50
  # rather than sanitised against ALLOWED.
@@ -63,13 +78,13 @@ module Relaton
63
78
  # Reserved prefixes. XML declares both, so the content must not.
64
79
  NS_RESERVED = %w[xml xmlns].freeze
65
80
 
66
- def self.sanitize(content)
81
+ def self.sanitize(content, allowed: ALLOWED)
67
82
  return content unless sanitizable?(content)
68
83
 
69
84
  node = parse(content)
70
85
  return content if node.nil?
71
86
 
72
- sanitize_children(node)
87
+ sanitize_children(node, allowed)
73
88
  node.children.map { |c| c.to_xml(encoding: "UTF-8", indent: 0, expand_empty: false) }.join
74
89
  end
75
90
 
@@ -273,15 +288,15 @@ module Relaton
273
288
  end
274
289
  private_class_method :sanitizable?
275
290
 
276
- def self.sanitize_children(node)
291
+ def self.sanitize_children(node, allowed)
277
292
  node.children.to_a.each do |child|
278
293
  next unless child.element?
279
294
 
280
295
  child.name = RENAME[child.name] if RENAME.key?(child.name)
281
296
  next if OPAQUE.include?(child.name)
282
297
 
283
- sanitize_children(child)
284
- unwrap(child) unless ALLOWED.include?(child.name)
298
+ sanitize_children(child, allowed)
299
+ unwrap(child) unless allowed.include?(child.name)
285
300
  end
286
301
  end
287
302
  private_class_method :sanitize_children
@@ -131,6 +131,12 @@ module Relaton
131
131
  BibXMLParser.pubid_type(id)
132
132
  end
133
133
 
134
+ # The base parser keeps <stream> as a `type="stream"` series, for items
135
+ # with no other slot. This flavor carries the stream in `ext.stream`.
136
+ def stream_series
137
+ []
138
+ end
139
+
134
140
  def contributor # rubocop:disable Metrics/MethodLength
135
141
  contribs = []
136
142
  unless @reference.anchor&.match?(/^I-D/)
@@ -55,9 +55,11 @@ In `item.rb`, `require_relative "relation"` is placed **after** the `Item` class
55
55
 
56
56
  `Bibliography.search`/`.get` fetches YAML from the relaton-data-iho GitHub repo via `Relaton::Index`, then deserializes into model objects.
57
57
 
58
- **Editions.** The pubid `version` is the edition (`IHO B-12 3.0.0`; the grammar takes `a.b.c` only). A reference with no version answers with the **latest** edition: `version_key` compares the segments as integers (`2.10.0` after `2.9.0`). It used to be `min_by` on the string, which returned the oldest.
58
+ **Editions and series.** The pubid `version` is the edition (`IHO B-12 3.0.0`; the grammar takes `a.b.c` only). A reference with no version answers with the **latest** edition: `recency_key` compares the segments as integers (`2.10.0` after `2.9.0`). It used to be `min_by` on the string, which returned the oldest.
59
59
 
60
- **The edition reaches the index only through the committed row.** A record prints `B-12` and keeps its edition in `edition:`, so `relaton index` cannot read a version from the docid. `MachineIndex#refine` (relaton-cli) takes the committed `index-v3.yaml` row when it adds only a `version` to the parse. Before that, the Pages index (generated 2026-10-29) had no `version` in any of its 325 rows, so every versioned reference answered not-found (relaton-data-iho#20). The suite did not see it: `spec/fixtures/index-v3.zip` is the committed index, which has versions.
60
+ **Series are identified by year or volume, not an edition (relaton#407).** B-7, P-5 and P-6 carry a `year` (`IHO P-5 2024`), the IHR (P-1) a `volume` with an optional `issue` (`IHO P-1 Vol. 30 (2)`) — plain scalars added by pubid#635, mutually exclusive with `version` (the grammar's tail picks exactly one). The same `recency_key` orders each kind numerically, so a bare series query answers with the latest year/volume/issue. Matching (`pubid_match?`) treats a component the query omits as a wildcard: `IHO P-1 Vol. 30` matches any issue of volume 30. The comparison runs through pubid's `#exclude` — excluding `:volume` nils the `issue` with it (the pair is one scalar cluster) — the way BIPM's `stem_keys` does. The subdivisions (part/appendix/annex/supplement) stay strict: this flavor cannot use the `===` subset match, because pubid does not declare `subset_strict` for IHO's subdivisions and a nil `part` would read as "any part" (`IHO S-100` would answer with a part row).
61
+
62
+ **The edition reaches the index only through the committed row.** A record prints `B-12` and keeps its edition in `edition:`, so `relaton index` cannot read a version from the docid. `MachineIndex#refine` (relaton-cli) takes the committed `index-v3.yaml` row when it adds only a key in `REFINABLE_KEYS` to the parse — `version`/`edition`/`volume`/`year`/`issue` (relaton#407 added the year/issue pair for the series rows). Before that, the Pages index (generated 2026-10-29) had no `version` in any of its 325 rows, so every versioned reference answered not-found (relaton-data-iho#20). The suite did not see it: `spec/fixtures/index-v3.zip` is the committed index, which has versions. The suite's fixture currently carries the **re-keyed** series rows (year/volume) ahead of the data repo: the placeholder `1.0.0` rows for p-1/p-1-2x/p-5/p-6/b-7 are replaced by `year`/`volume` rows — refresh the fixture from the live published zip once relaton-data-iho re-keys, and reconcile any file-name drift then.
61
63
 
62
64
  ### Test Infrastructure
63
65
 
@@ -21,7 +21,7 @@ module Relaton
21
21
  # Pubid::Iho::Identifiers::* (Relaton::Index deserialized it via the
22
22
  # `pubid_class` passed in `#index`), so `pubid_match?` compares pubids.
23
23
  row = index.search(pubid) { |r| pubid_match?(r[:id], pubid) }
24
- .max_by { |r| version_key(r[:id]) }
24
+ .max_by { |r| recency_key(r[:id]) }
25
25
  unless row
26
26
  Util.info "Not found.", key: pubid.to_s
27
27
  return
@@ -96,26 +96,57 @@ module Relaton
96
96
  )
97
97
  end
98
98
 
99
- # The sort key for the latest edition: segments compare as integers,
100
- # so 2.10.0 is later than 2.9.0. A row with no version is the oldest.
101
- def version_key(id)
102
- id.version.to_s.split(".").map(&:to_i)
99
+ # The sort key for the latest record in a bucket (relaton#407). Each
100
+ # IHO identity carries exactly one trailing discriminator — a dotted
101
+ # `version` (an edition, `B-12 3.0.0`), a series `year` (`P-5 2024`)
102
+ # or a series `volume` with its `issue` (`P-1 Vol. 30 (2)`) — and the
103
+ # key orders by that component, numerically: version segments compare
104
+ # as integers (2.10.0 is later than 2.9.0), as do year, volume and
105
+ # issue. A row with no discriminator at all is the oldest. Rows in
106
+ # one bucket share the discriminator kind once the data is keyed;
107
+ # while kinds coexist the array compare is deterministic, and a
108
+ # year's bare [2024] beats a placeholder edition's [1, 0, 0].
109
+ def recency_key(id)
110
+ if id.year
111
+ [id.year.to_i]
112
+ elsif id.volume
113
+ [id.volume.to_i, id.issue.to_i]
114
+ else
115
+ id.version.to_s.split(".").map(&:to_i)
116
+ end
103
117
  end
104
118
 
119
+ # The components that make an IHO document the one it is beyond the
120
+ # series and subdivisions: the edition (`version`), or — for the
121
+ # series identified by year or volume instead (B-7, P-1, P-5, P-6;
122
+ # relaton#407) — the `year` or the `volume`/`issue` pair.
123
+ DISCRIMINATOR_KEYS = %i[version year volume issue].freeze
124
+
105
125
  # Both `row_id` and `query` are Pubid::Iho::Identifiers::* instances.
106
126
  # Class identity pins the series (S/P/M/B/C). Subdivision keys
107
127
  # (part/appendix/annex/supplement) use strict equality — a nil query
108
128
  # must match a nil row (the umbrella), not an arbitrary subdivision
109
- # under the same (number, version). Only :version stays nil-tolerant: an
110
- # unqualified `IHO B-11` query is expected to find the latest edition.
129
+ # under the same number. That is why this flavor does not use the
130
+ # `===` subset match: pubid reads a nil `part` there as "any part"
131
+ # (pubid's `subset_strict` is not declared for the IHO subdivisions),
132
+ # so `IHO S-100` would answer with a part row.
133
+ #
134
+ # The discriminator keys stay nil-tolerant: an unqualified `IHO B-11`
135
+ # finds the latest edition, an unqualified `IHO P-5` the latest year,
136
+ # and `IHO P-1 Vol. 30` any issue of that volume — while a stated
137
+ # component must match exactly. The comparison runs through pubid's
138
+ # `#exclude` (the primitive `matches?` is built on), so the rules the
139
+ # flavor declares there ride along: excluding `:volume` nils the
140
+ # `issue` with it. `stem_keys` in the BIPM flavor is the same shape.
111
141
  def pubid_match?(row_id, query)
112
142
  row_id.class == query.class &&
113
- row_id.number == query.number &&
114
- row_id.part.to_s == query.part.to_s &&
115
- row_id.appendix.to_s == query.appendix.to_s &&
116
- row_id.annex.to_s == query.annex.to_s &&
117
- row_id.supplement.to_s == query.supplement.to_s &&
118
- (query.version.nil? || row_id.version.to_s == query.version.to_s)
143
+ row_id.exclude(*wildcards(query)) == query.exclude(*wildcards(query))
144
+ end
145
+
146
+ # The discriminators the query leaves unset: wildcards for the
147
+ # search, significant whenever the query states one.
148
+ def wildcards(query)
149
+ DISCRIMINATOR_KEYS.select { |key| query.public_send(key).nil? }
119
150
  end
120
151
  end
121
152
  end
@@ -1,3 +1,3 @@
1
1
  module Relaton
2
- VERSION = "3.0.4".freeze
2
+ VERSION = "3.0.5".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.4
4
+ version: 3.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-01 00:00:00.000000000 Z
11
+ date: 2026-11-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -408,28 +408,28 @@ dependencies:
408
408
  requirements:
409
409
  - - "~>"
410
410
  - !ruby/object:Gem::Version
411
- version: 1.3.114
411
+ version: 1.3.121
412
412
  type: :runtime
413
413
  prerelease: false
414
414
  version_requirements: !ruby/object:Gem::Requirement
415
415
  requirements:
416
416
  - - "~>"
417
417
  - !ruby/object:Gem::Version
418
- version: 1.3.114
418
+ version: 1.3.121
419
419
  - !ruby/object:Gem::Dependency
420
420
  name: pubid
421
421
  requirement: !ruby/object:Gem::Requirement
422
422
  requirements:
423
423
  - - "~>"
424
424
  - !ruby/object:Gem::Version
425
- version: 2.0.1
425
+ version: 2.0.3
426
426
  type: :runtime
427
427
  prerelease: false
428
428
  version_requirements: !ruby/object:Gem::Requirement
429
429
  requirements:
430
430
  - - "~>"
431
431
  - !ruby/object:Gem::Version
432
- version: 2.0.1
432
+ version: 2.0.3
433
433
  - !ruby/object:Gem::Dependency
434
434
  name: yeptris
435
435
  requirement: !ruby/object:Gem::Requirement
@@ -566,6 +566,8 @@ files:
566
566
  - lib/relaton/bib/converter/bibxml/from_rfcxml_referencegroup.rb
567
567
  - lib/relaton/bib/converter/bibxml/to_rfcxml.rb
568
568
  - lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup.rb
569
+ - lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup_v3.rb
570
+ - lib/relaton/bib/converter/bibxml/to_rfcxml_v3.rb
569
571
  - lib/relaton/bib/converter/csl.rb
570
572
  - lib/relaton/bib/converter/ris.rb
571
573
  - lib/relaton/bib/converter/titles.rb