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 +4 -4
- data/lib/relaton/bib/CLAUDE.md +24 -4
- data/lib/relaton/bib/README.adoc +64 -0
- data/lib/relaton/bib/converter/bibxml/from_rfcxml.rb +12 -1
- data/lib/relaton/bib/converter/bibxml/to_rfcxml.rb +32 -8
- data/lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup.rb +10 -3
- data/lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup_v3.rb +43 -0
- data/lib/relaton/bib/converter/bibxml/to_rfcxml_v3.rb +416 -0
- data/lib/relaton/bib/converter/bibxml.rb +21 -9
- data/lib/relaton/bib/item_data.rb +19 -7
- data/lib/relaton/bib/model/abstract.rb +5 -0
- data/lib/relaton/bib/model/localized_string.rb +6 -1
- data/lib/relaton/bib/sanitizer.rb +20 -5
- data/lib/relaton/ietf/bibxml_parser.rb +6 -0
- data/lib/relaton/iho/CLAUDE.md +4 -2
- data/lib/relaton/iho/bibliography.rb +44 -13
- data/lib/relaton/version.rb +1 -1
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e8ce7b77ba7b71f2bc7232319b926e70116ca3cdadf53395ef39e1dc4437a64
|
|
4
|
+
data.tar.gz: d0f29e54fa2129e444819703dfbd9b20ced132b34613053b6944b560370f9447
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 158c8d47b5a66ddd647f568aa35327ec4f1ee51e5d4ece559c77a610d3ac9474bcd90aa9bcedd544dd0cdaacf7dcac74f72560f810e014c2caa86477d7e33954
|
|
7
|
+
data.tar.gz: bf49242892015cae4f95d8b63120aa494bb936db2dd98eac34ac8f90ca1f94b889f7d99fd79cc933777f5a04796cb48dc3b933a110f79e29ce6d1f283bb5b744
|
data/lib/relaton/bib/CLAUDE.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
149
|
+
- Target Ruby version: 3.3+ (`required_ruby_version` in the gemspec, `TargetRubyVersion` in `.rubocop.yml`)
|
|
130
150
|
- Uses YARD documentation comments
|
data/lib/relaton/bib/README.adoc
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
43
|
-
@item.source.detect { |l| l.type
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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|
|
|
23
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
|
@@ -28,10 +28,15 @@ module Relaton
|
|
|
28
28
|
class LocalizedMarkedUpString < LocalizedStringAttrs
|
|
29
29
|
module ContentSanitization
|
|
30
30
|
def content=(value)
|
|
31
|
-
super(
|
|
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
|
|
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/)
|
data/lib/relaton/iho/CLAUDE.md
CHANGED
|
@@ -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: `
|
|
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
|
-
**
|
|
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|
|
|
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
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
110
|
-
#
|
|
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.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
data/lib/relaton/version.rb
CHANGED
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
|
+
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|