relaton 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/bib/converter/csl.rb +3 -2
  3. data/lib/relaton/bib/model/docidentifier.rb +61 -0
  4. data/lib/relaton/bib.rb +2 -1
  5. data/lib/relaton/bipm/CLAUDE.md +14 -0
  6. data/lib/relaton/bipm.rb +0 -1
  7. data/lib/relaton/bsi/model/docidentifier.rb +0 -4
  8. data/lib/relaton/calconnect/data_fetcher.rb +2 -2
  9. data/lib/relaton/ccsds/data/fetcher.rb +2 -1
  10. data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
  11. data/lib/relaton/core/data_fetcher.rb +2 -1
  12. data/lib/relaton/core/processor.rb +1 -1
  13. data/lib/relaton/core.rb +7 -0
  14. data/lib/relaton/db/cache.rb +3 -3
  15. data/lib/relaton/db/registry.rb +1 -1
  16. data/lib/relaton/db.rb +22 -3
  17. data/lib/relaton/doi/crossref.rb +2 -1
  18. data/lib/relaton/doi/parser.rb +3 -2
  19. data/lib/relaton/etsi/data_fetcher.rb +3 -3
  20. data/lib/relaton/gb/sec_scraper.rb +3 -3
  21. data/lib/relaton/iec/data_fetcher.rb +3 -2
  22. data/lib/relaton/iec/model/docidentifier.rb +0 -4
  23. data/lib/relaton/ietf/CLAUDE.md +27 -2
  24. data/lib/relaton/ietf/bibxml_parser.rb +38 -0
  25. data/lib/relaton/ietf/data_fetcher.rb +3 -30
  26. data/lib/relaton/ietf/scraper.rb +60 -3
  27. data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
  28. data/lib/relaton/iho/CLAUDE.md +4 -0
  29. data/lib/relaton/iho/bibliography.rb +7 -1
  30. data/lib/relaton/index/dataset_lock.rb +4 -3
  31. data/lib/relaton/index/shard_source.rb +3 -2
  32. data/lib/relaton/index/sqlite_backend.rb +5 -3
  33. data/lib/relaton/isbn/open_library.rb +2 -1
  34. data/lib/relaton/iso/data_fetcher.rb +6 -6
  35. data/lib/relaton/iso/model/docidentifier.rb +0 -4
  36. data/lib/relaton/itu/data_fetcher.rb +2 -2
  37. data/lib/relaton/itu/hit_collection.rb +3 -3
  38. data/lib/relaton/itu/recommendation_fields.rb +2 -2
  39. data/lib/relaton/jis/data_fetcher.rb +2 -1
  40. data/lib/relaton/json.rb +21 -0
  41. data/lib/relaton/logger/channels/gh_issue.rb +3 -3
  42. data/lib/relaton/logger/formatter_json.rb +2 -1
  43. data/lib/relaton/nist/CLAUDE.md +7 -0
  44. data/lib/relaton/nist/bibliography.rb +31 -0
  45. data/lib/relaton/nist/cswp.rb +137 -0
  46. data/lib/relaton/nist/cswp.yaml +155 -0
  47. data/lib/relaton/nist/docidentifier.rb +0 -4
  48. data/lib/relaton/nist/processor.rb +20 -0
  49. data/lib/relaton/nist/pubs_export.rb +2 -1
  50. data/lib/relaton/nist.rb +1 -0
  51. data/lib/relaton/ogc/data_fetcher.rb +2 -2
  52. data/lib/relaton/omg/scraper.rb +3 -3
  53. data/lib/relaton/plateau/data_fetcher.rb +2 -1
  54. data/lib/relaton/un/hit_collection.rb +4 -3
  55. data/lib/relaton/version.rb +1 -1
  56. data/lib/relaton.rb +1 -0
  57. metadata +45 -88
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f81699e1df94f1c570f01c07b4114492b7cc5ae1e45715a4ced3ef51c1ba3fb6
4
- data.tar.gz: 1ea38d455d16b512807a505bd558757ea0509a9392cf41c120f3fc10801beb87
3
+ metadata.gz: a774b326943089257e3aef6e5402fc1e2d53d4ff557e8b19f845a62d64b9a99b
4
+ data.tar.gz: 665fa378d2380f79a4e2952df1ff3d0f7e1de6d91ead262d75261555a4170e5c
5
5
  SHA512:
6
- metadata.gz: 4d27294461f66031023053e1ec70a4c26b0c8fa068e6ed9d1224d1ab9c6cb366a2b2d8f85cd50305c4826733ab028817f70b96aebc267346d224b9be1fdf29be
7
- data.tar.gz: a3c00ef5a93352f08a492624b531955494c8530a6b0d68f01dc748d58d8599304fa9ac743d157cfd2a6cc37b63664c781da75de5e199de70035ed133e70d0305
6
+ metadata.gz: bfa2af357a4e288f0ce97fb82ff78ac58b39bc8f8fc8659584ebb29a4568a50175137b8b8cd32b6710aff65b8413cb4ab0d1ea674918187edd9a74592e331a6c
7
+ data.tar.gz: e2ad6a9822a6ffa5f9a625bb4095260b81b3906fcff8ba0cf4fbdcea0d543f9e2b14133c7dd29dad0f165c26713b16fc0f06ebfc06d134384d4d0f280ee873b1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require "yeptris"
4
+
4
5
 
5
6
  module Relaton
6
7
  module Bib
@@ -43,7 +44,7 @@ module Relaton
43
44
  keywords = Array(@item.keyword).map { |k| k.content.to_s }.reject(&:empty?)
44
45
  out[:keyword] = keywords unless keywords.empty?
45
46
  out[:URL] = source_uri if source_uri
46
- [out].to_json + "\n"
47
+ Yeptris::JSON.dump([out]) + "\n"
47
48
  end
48
49
 
49
50
  private
@@ -44,6 +44,67 @@ module Relaton
44
44
  def to_all_parts!; end
45
45
 
46
46
  def remove_date!; end
47
+
48
+ # Docidentifier types with an SDO-specific :external and :internal form
49
+ # (relaton#49), mapped to the pubid flavor that renders both. The
50
+ # :external form carries the publisher ("IETF RFC 8341",
51
+ # "BSI PAS 2031:2019"); the :internal form is the one the SDO uses for its
52
+ # own documents, without it ("SP 800-113" for "NIST SP 800-113"). pubid
53
+ # decides per identifier, so "BS EN ISO 8848:2021" and "CGPM RES (1889)"
54
+ # are the same in both forms. Every other type answers its content for
55
+ # both forms: pubid would drop "ISO" from an ISO identifier.
56
+ #
57
+ # The table keys on `type`, not on a flavor subclass, so it also covers a
58
+ # relation's nested `Bib::ItemData` and the `Bib::Docidentifier` objects
59
+ # BIPM and IETF build (a subclass-typed collection would make lutaml's
60
+ # YAML serializer reject those).
61
+ FORM_PUBID = {
62
+ "NIST" => :Nist,
63
+ "BIPM" => :Bipm,
64
+ "IETF" => :Ietf,
65
+ "Internet-Draft" => :Ietf,
66
+ "BSI" => :Bsi,
67
+ }.freeze
68
+
69
+ FORMS = %i[external internal].freeze
70
+
71
+ # The parsed identifier. A pubid-backed flavor subclass overrides it.
72
+ def pubid; end
73
+
74
+ # @param form [Symbol, nil] nil for the stored content, :external for the
75
+ # identifier with its publisher, :internal for the form the SDO uses
76
+ # for its own documents
77
+ # @raise [ArgumentError] for an unknown form
78
+ def to_s(form: nil)
79
+ return content.to_s unless form
80
+
81
+ form = form.to_sym
82
+ unless FORMS.include?(form)
83
+ raise ArgumentError, "unknown identifier form: #{form.inspect}"
84
+ end
85
+
86
+ form_content(form).to_s
87
+ end
88
+
89
+ private
90
+
91
+ # pubid renders the form (`to_s(with_publisher:)`, pubid#620). A
92
+ # docidentifier with no parsed pubid (a plain `Bib::Docidentifier`) is
93
+ # parsed here; content that pubid cannot parse is returned unchanged.
94
+ def form_content(form)
95
+ flavor = FORM_PUBID[type]
96
+ id = flavor && (pubid || parse_form_pubid(flavor))
97
+ id ? id.to_s(with_publisher: form == :external) : content
98
+ end
99
+
100
+ def parse_form_pubid(flavor)
101
+ return if content.to_s.empty?
102
+
103
+ require "pubid"
104
+ ::Pubid.const_get(flavor).parse(content)
105
+ rescue StandardError
106
+ nil
107
+ end
47
108
  end
48
109
  end
49
110
  end
data/lib/relaton/bib.rb CHANGED
@@ -5,6 +5,7 @@ require "bibtex"
5
5
  require "iso639"
6
6
  require "rfcxml"
7
7
  require "relaton/core"
8
+ require "yeptris"
8
9
  require_relative "version"
9
10
  require_relative "bib/util"
10
11
  require_relative "bib/sanitizer"
@@ -41,7 +42,7 @@ module Relaton
41
42
  # @return [Hash{String=>String}] schema versions
42
43
  #
43
44
  def schema_versions
44
- @@schema_versions ||= JSON.parse File.read(File.join(__dir__, "bib/versions.json"))
45
+ @@schema_versions ||= Yeptris::JSON.load File.read(File.join(__dir__, "bib/versions.json"))
45
46
  end
46
47
  end
47
48
 
@@ -144,6 +144,20 @@ demonstrated rather than argued.
144
144
  builds `index-v1` with them, and `id_parser_spec.rb` still covers them. Only
145
145
  `bibliography.rb` stopped requiring the file.
146
146
 
147
+ ### Internal identifier
148
+
149
+ `docid.to_s(form: :internal)` drops the
150
+ `BIPM` publisher (relaton#49): `BIPM SI Brochure Part 1` →
151
+ `SI Brochure Part 1`. Committee ids (`CGPM RES (1889)`, `CCTF REC 2 (2009)`)
152
+ have no publisher token and are the same in both forms. The rule is the `"BIPM"` row of
153
+ `Bib::Docidentifier::FORM_PUBID`: BIPM docidentifiers carry no pubid,
154
+ so the content is parsed with `Pubid::Bipm` at call time and rendered with
155
+ `to_s(with_publisher: false)`; unparseable content is returned unchanged. It
156
+ also holds for a relation's nested item. Do not move it into a `Bipm::Docidentifier`
157
+ subclass: typing the item's `docidentifier` collection with a subclass makes
158
+ YAML serialization reject the `Bib::Docidentifier` objects the parsers build
159
+ (`IncorrectModelError`).
160
+
147
161
  ### Data Sources
148
162
 
149
163
  The gem fetches from three external datasets:
data/lib/relaton/bipm.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  require "zip"
2
2
  require "fileutils"
3
- require "parslet"
4
3
  require "relaton/bib"
5
4
  require "relaton/index"
6
5
  require "relaton/core"
@@ -44,10 +44,6 @@ module Relaton
44
44
  c if c.is_a?(::Pubid::Bsi::Identifier) || c.is_a?(::Pubid::AllParts)
45
45
  end
46
46
 
47
- def to_s
48
- content.to_s
49
- end
50
-
51
47
  # Strip the publication date to build a most-recent (undated) reference.
52
48
  # pubid's `exclude` propagates into nested identifiers (so a consolidated
53
49
  # base date is dropped while the amendment is kept); `:month` is excluded
@@ -1,6 +1,6 @@
1
+ require "yeptris"
1
2
  # frozen_string_literal:true
2
3
 
3
- require "json"
4
4
  require "mechanize"
5
5
  require "relaton/core"
6
6
  require "relaton/index"
@@ -54,7 +54,7 @@ module Relaton::Calconnect
54
54
  # 304 Not Modified — nothing changed since the last fetch
55
55
  return if resp.code == "304"
56
56
 
57
- data = JSON.parse resp.body
57
+ data = Relaton::Json.parse_external resp.body
58
58
  all_success = true
59
59
  Array(data["documents"]).each { |doc| all_success &&= parse_page doc }
60
60
  self.etag = resp.response["etag"] if all_success
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "json"
2
3
  require "mechanize"
3
4
  require "relaton/index"
@@ -43,7 +44,7 @@ module Relaton
43
44
  #
44
45
  def fetch_docs(url)
45
46
  resp = agent.get(url)
46
- json = JSON.parse resp.body.match(/const config = (.*);/)[1]
47
+ json = Relaton::Json.parse_external resp.body.match(/const config = (.*);/)[1]
47
48
  @array = json["data"].map { |doc| parse_and_save doc, json["data"] }
48
49
  end
49
50
 
@@ -60,10 +60,6 @@ module Relaton
60
60
  original_content
61
61
  end
62
62
 
63
- def to_s
64
- content.to_s
65
- end
66
-
67
63
  def to_all_parts!
68
64
  return if !@pubid || @pubid.all_parts?
69
65
 
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
3
 
3
4
  module Relaton
@@ -361,7 +362,7 @@ module Relaton
361
362
  def entry_digest(entry)
362
363
  require "digest"
363
364
  require "json"
364
- Digest::SHA256.hexdigest JSON.generate(entry)
365
+ Digest::SHA256.hexdigest Yeptris::JSON.dump(entry)
365
366
  end
366
367
 
367
368
  # Write, clobbering whatever is there. The caller has already established
@@ -67,7 +67,7 @@ module Relaton
67
67
  # @return [Pubid::Identifier, nil]
68
68
  def cache_pubid(ref)
69
69
  pubid_class&.parse(ref)
70
- rescue ArgumentError, ::Pubid::Errors::Error, Parslet::ParseFailed
70
+ rescue ArgumentError, ::Pubid::Errors::Error
71
71
  # relaton-bib carries the log facade; core specs load without it.
72
72
  require "relaton/bib"
73
73
  Relaton::Bib::Util.info "does not parse as this flavor's pubid; " \
data/lib/relaton/core.rb CHANGED
@@ -1,4 +1,11 @@
1
1
  require "relaton/logger"
2
+
3
+ module Relaton
4
+ # Registered here as well as in the root entry: flavor files and their
5
+ # specs load directly via require "relaton/<flavor>", never through
6
+ # relaton.rb, and the boundary parsers need this constant either way.
7
+ autoload :Json, "relaton/json"
8
+ end
2
9
  require_relative "version"
3
10
  require_relative "core/request_error"
4
11
  require_relative "core/array_wrapper"
@@ -1,6 +1,6 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
3
  require "digest"
3
- require "json"
4
4
  require "date"
5
5
  require "lutaml/store"
6
6
  require_relative "cache_entry"
@@ -520,7 +520,7 @@ module Relaton
520
520
  def canonical(key)
521
521
  return key if key.is_a? String
522
522
 
523
- JSON.parse JSON.generate(key.to_hash)
523
+ Yeptris::JSON.load Yeptris::JSON.dump(key.to_hash)
524
524
  end
525
525
 
526
526
  def row_key(row)
@@ -556,7 +556,7 @@ module Relaton
556
556
 
557
557
  # A digest of the canonical key, independent of the hash order.
558
558
  def digest(key)
559
- Digest::SHA256.hexdigest JSON.generate(canonical_sorted(key))
559
+ Digest::SHA256.hexdigest Yeptris::JSON.dump(canonical_sorted(key))
560
560
  end
561
561
 
562
562
  def canonical_sorted(key)
@@ -221,7 +221,7 @@ module Relaton
221
221
  require "pubid"
222
222
  parsed = ::Pubid.parse reference
223
223
  parsed if parsed.to_s == reference
224
- rescue ArgumentError, ::Pubid::Errors::Error, Parslet::ParseFailed
224
+ rescue ArgumentError, ::Pubid::Errors::Error
225
225
  nil
226
226
  end
227
227
 
data/lib/relaton/db.rb CHANGED
@@ -127,7 +127,15 @@ module Relaton
127
127
  rescue Relaton::RequestError => e
128
128
  args[3].call e
129
129
  rescue StandardError => e
130
- Util.error "`#{args[0]}` -- #{e.message}"
130
+ # An unparseable reference ("ISO Standards Tag Set" in a
131
+ # vocabulary corpus) is a not-found input, not a failure: the
132
+ # citation keeps its literal text and the build continues
133
+ # (relaton#412). Real errors keep the ERROR level.
134
+ if unreadable_reference?(e)
135
+ Util.info "`#{args[0]}` -- #{e.message}"
136
+ else
137
+ Util.error "`#{args[0]}` -- #{e.message}"
138
+ end
131
139
  args[3].call nil
132
140
  end
133
141
  @queues[stdclass] =
@@ -213,6 +221,14 @@ module Relaton
213
221
 
214
222
  private
215
223
 
224
+ # True when the error is a flavor's grammar refusing to read the
225
+ # reference. Both are raised by the flavors' pubid/parslet parses; a
226
+ # reference they cannot read is not-found, not a failure (relaton#412).
227
+ def unreadable_reference?(error)
228
+ (defined?(Pubid::Errors::Error) && error.is_a?(Pubid::Errors::Error)) ||
229
+ false
230
+ end
231
+
216
232
  # The flavor routing picks for `fetch_async`'s per-flavor queue; nil
217
233
  # (logged) when no flavor recognizes the reference.
218
234
  def async_route(ref)
@@ -309,6 +325,9 @@ module Relaton
309
325
  )
310
326
  end
311
327
  end
328
+ # Nothing resolved: answer not-found, not a stub that holds only the
329
+ # reference (relaton-un#14, metanorma-ietf#205).
330
+ doc.relation.empty? ? nil : doc
312
331
  end
313
332
 
314
333
  # The legacy string cache key, for a processor with no pubid class. The
@@ -370,7 +389,7 @@ module Relaton
370
389
  return unless docid&.content
371
390
 
372
391
  cache_key docid.content, nil, {}, stdclass
373
- rescue ::Pubid::Errors::Error, Parslet::ParseFailed
392
+ rescue ::Pubid::Errors::Error
374
393
  nil
375
394
  end
376
395
 
@@ -437,7 +456,7 @@ module Relaton
437
456
  def copublisher_query(code, opts, stdclass, publisher)
438
457
  query = begin
439
458
  @registry[stdclass].query_pubid(code, opts)
440
- rescue ::Pubid::Errors::Error, Parslet::ParseFailed
459
+ rescue ::Pubid::Errors::Error
441
460
  nil
442
461
  end
443
462
  return query if query
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "mechanize"
2
3
 
3
4
  module Relaton
@@ -58,7 +59,7 @@ module Relaton
58
59
  url = "https://api.crossref.org/works/#{CGI.escape(id)}"
59
60
  loop do
60
61
  resp = agent.get url
61
- work = JSON.parse resp.body
62
+ work = Relaton::Json.parse_external resp.body
62
63
  return work["message"] if work["status"] == "ok"
63
64
 
64
65
  if n > 1
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  module Relaton
2
3
  module Doi
3
4
  class Parser
@@ -856,7 +857,7 @@ module Relaton
856
857
  throttled = 0
857
858
  begin
858
859
  resp = Crossref.agent.get url
859
- JSON.parse(resp.body).dig("message", "items")
860
+ Relaton::Json.parse_external(resp.body).dig("message", "items")
860
861
  rescue Mechanize::ResponseCodeError => e
861
862
  # 429 must be handled before the 4xx nil return: a throttle is not
862
863
  # "no such record". Returning nil here made a rate-limited lookup look
@@ -881,7 +882,7 @@ module Relaton
881
882
  retries += 1
882
883
  retry if retries <= MAX_RETRIES
883
884
  raise Relaton::RequestError, "Crossref network error after #{MAX_RETRIES} retries: #{e.message}"
884
- rescue JSON::ParserError => e
885
+ rescue ::Yeptris::JSON::ParseError => e
885
886
  raise Relaton::RequestError, "Crossref JSON parsing error: #{e.message}"
886
887
  end
887
888
  end
@@ -1,5 +1,5 @@
1
+ require "yeptris"
1
2
  require "date"
2
- require "json"
3
3
  require "mechanize"
4
4
  require "relaton/core"
5
5
  require "relaton/index"
@@ -146,11 +146,11 @@ module Relaton
146
146
  # @raise [BadPage] when the body is not a JSON array
147
147
  #
148
148
  def parse_page(page, body)
149
- records = JSON.parse(body)
149
+ records = Relaton::Json.parse_external(body)
150
150
  return records if records.is_a?(Array)
151
151
 
152
152
  raise BadPage, bad_page_message(page, body)
153
- rescue JSON::ParserError
153
+ rescue ::Yeptris::JSON::ParseError
154
154
  raise BadPage, bad_page_message(page, body)
155
155
  end
156
156
 
@@ -1,8 +1,8 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+
4
5
  require "net/http"
5
- require "json"
6
6
  require "moxml"
7
7
  require_relative "scraper"
8
8
  require_relative "item"
@@ -24,8 +24,8 @@ module Relaton
24
24
  uri = URI "https://hbba.sacinfo.org.cn/stdQueryList"
25
25
  resp = Net::HTTP.post uri, URI.encode_www_form({ key: text }),
26
26
  "Content-Type" => "application/x-www-form-urlencoded"
27
- # res = JSON.parse Net::HTTP.get(uri)
28
- json = JSON.parse resp.body
27
+ # res = Relaton::Json.parse_external Net::HTTP.get(uri)
28
+ json = Relaton::Json.parse_external resp.body
29
29
  hits = json["records"].map do |h|
30
30
  Hit.new pid: h["pk"], docref: h["code"], status: h["status"], scraper: self
31
31
  end
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "relaton/core"
2
3
  require_relative "../iec"
3
4
  require_relative "data_parser"
@@ -135,7 +136,7 @@ module Relaton
135
136
  Util.warn "#{res.body}"
136
137
  break
137
138
  end
138
- json = JSON.parse res.body
139
+ json = Relaton::Json.parse_external res.body
139
140
  json[list_key].each { |pub| fetch_pub pub }
140
141
  page += 1
141
142
  next_page = res["link"]&.include? "rel=\"last\""
@@ -191,7 +192,7 @@ module Relaton
191
192
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
192
193
  http.request req
193
194
  end
194
- JSON.parse(res.body)["access_token"]
195
+ Relaton::Json.parse_external(res.body)["access_token"]
195
196
  end
196
197
  end
197
198
 
@@ -46,10 +46,6 @@ module Relaton
46
46
  original_content
47
47
  end
48
48
 
49
- def to_s
50
- content.to_s
51
- end
52
-
53
49
  # `remove_part!`/`remove_date!` are unnecessary here: `to_all_parts`'s
54
50
  # own identity computation already excludes part/date (down the whole
55
51
  # base chain, so a supplement's own year is dropped too — a disclosed
@@ -36,7 +36,7 @@ Converter classes use `Bib::NamespaceHelper` which resolves `namespace` by takin
36
36
 
37
37
  ### Key Flows
38
38
 
39
- 1. **Single document lookup**: `Processor#get` → `Bibliography.get(code)` → `Scraper.scrape_page` → parses the reference with `Pubid::Ietf` → searches the one combined `index-v2` from `relaton-data-ietf` → fetches that row's YAML
39
+ 1. **Single document lookup**: `Processor#get` → `Bibliography.get(code)` → `Scraper.scrape_page` → parses the reference with `Pubid::Ietf` → searches the one combined `index-v2` from `relaton-data-ietf` → fetches that row's YAML; a versioned draft the index lacks is read from bib.ietf.org (see below)
40
40
 
41
41
  2. **Bulk data fetching**: `DataFetcher` extends `Relaton::Core::DataFetcher` with three datasets:
42
42
  - `ietf-rfcsubseries` / `ietf-rfc-entries`: parse `rfc-index.xml` via `Rfc::Index` / `Rfc::Entry#to_item`
@@ -119,6 +119,30 @@ The old plain-string index matched that by substring; matching is exact now.
119
119
  A reference pubid cannot parse returns nil, so `Bibliography.get "CN 8341"` logs
120
120
  "Not found." rather than raising.
121
121
 
122
+ ### An index miss on a versioned draft reads bib.ietf.org (relaton-ietf#125)
123
+
124
+ The data repo crawls the rsync `bibxml-ids` snapshot, and that snapshot keeps
125
+ only some versions of each draft: `draft-ietf-vcarddav-vcardrev` has only `-22`,
126
+ so `-06` was "Not found." When `fetch_doc` misses and the query is a
127
+ `Pubid::Ietf::Identifiers::InternetDraft` **with a version**, `fetch_bibxml` reads
128
+ `https://bib.ietf.org/public/rfc/bibxml-ids/reference.I-D.<draft>-NN.xml`
129
+ (`BIBXML_IDS`). That service resolves any version the datatracker knows and
130
+ answers 404 otherwise; the datatracker's own `doc/bibxml3/...` URL answers 404
131
+ for these old versions. The parse is the crawler's: `BibXMLParser.to_utf8`
132
+ (the CP1252 repair, shared with `DataFetcher#read_bibxml`), `BibXMLParser.parse`,
133
+ and the version from the identifier, as `process_singleton` sets it.
134
+
135
+ Only a 404 is "not found". Any other non-200 (429, 5xx) raises
136
+ `Relaton::RequestError`, so `Db#net_retry` retries it and `Db::Cache` does not
137
+ store an outage as a not-found row. A 200 whose body does not parse logs a
138
+ warning and returns nil; that rescue names the parse errors and is not
139
+ `StandardError`, so a transport error still becomes `RequestError`.
140
+
141
+ RFCs, sub-series and unversioned drafts do **not** fall back: the index holds
142
+ all of them, so a miss is a real "not found". A record read this way has no
143
+ `updates`/`updatedBy` neighbours — the crawl links those, and one fetch cannot.
144
+ Old bibxml carries no `target`, so such a record has a `TXT` URI and no `src`.
145
+
122
146
  ### The producer: `DataFetcher` writes `index-v2`
123
147
 
124
148
  `DataFetcher` builds the index during the crawl, from the identifier it already
@@ -294,7 +318,8 @@ smart quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
294
318
  *tags* a string; it validates nothing, so those reached lutaml as invalid UTF-8
295
319
  and `Rfcxml::V3::Reference.from_xml` raised `Lutaml::Model::InvalidFormatError`.
296
320
 
297
- `read_bibxml` transcodes them (`data_fetcher.rb`). Three things make that the
321
+ `read_bibxml` transcodes them through `BibXMLParser.to_utf8`
322
+ (`bibxml_parser.rb`, shared with the scraper's bib.ietf.org fallback). Three things make that the
298
323
  right call rather than `scrub`:
299
324
 
300
325
  - **All of them decode losslessly as CP1252** — 125 of the ~122k published files
@@ -13,6 +13,44 @@ module Relaton
13
13
  FromRfc.new(rfc).transform
14
14
  end
15
15
 
16
+ #
17
+ # Decode bibxml bytes as UTF-8, recovering Windows-1252 bytes.
18
+ #
19
+ # These files declare `encoding='UTF-8'` but some carry CP1252 — smart
20
+ # quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
21
+ # *tags* the string, so those reach lutaml as invalid UTF-8 and it raises.
22
+ #
23
+ # `scrub` with a block transcodes each invalid *run* as CP1252 while
24
+ # leaving valid UTF-8 untouched. Both halves matter:
25
+ #
26
+ # * Not plain `scrub`, which substitutes U+FFFD: `client’s` would become
27
+ # `client�s` and `Muñoz` `Mu�oz` — author surnames included.
28
+ # The damage is length-preserving, so a length check will not catch it.
29
+ # * Not a whole-file CP1252 re-decode, which mangles a file that is
30
+ # genuinely UTF-8 apart from one stray byte: `Muñoz café ’` would come
31
+ # back as `Muñoz café ’`, silently, since the result is valid UTF-8 and
32
+ # nothing raises. No such file exists in today's corpus (0 of the 125
33
+ # affected contain valid multi-byte UTF-8) but it grows daily, and
34
+ # "decodes losslessly as CP1252" is weak evidence of correctness —
35
+ # CP1252 maps 251 of 256 byte values.
36
+ #
37
+ # `undef: :replace` covers the five bytes CP1252 leaves undefined
38
+ # (0x81 0x8D 0x8F 0x90 0x9D), so this returns valid UTF-8 rather than
39
+ # raising and costing the whole file.
40
+ #
41
+ # @param bytes [String] raw bytes, any encoding tag
42
+ # @return [String] UTF-8, valid encoding
43
+ #
44
+ def to_utf8(bytes)
45
+ utf8 = bytes.dup.force_encoding(Encoding::UTF_8)
46
+ return utf8 if utf8.valid_encoding?
47
+
48
+ utf8.scrub do |bad|
49
+ bad.force_encoding(Encoding::WINDOWS_1252)
50
+ .encode(Encoding::UTF_8, undef: :replace)
51
+ end
52
+ end
53
+
16
54
  def pubid_type(id)
17
55
  pref = id.match(/^(\S+)/)[1]
18
56
  case pref
@@ -216,41 +216,14 @@ module Relaton
216
216
  end
217
217
 
218
218
  #
219
- # Read a bibxml file as UTF-8, recovering Windows-1252 bytes.
220
- #
221
- # These files declare `encoding='UTF-8'` but some carry CP1252 — smart
222
- # quotes and accented Latin letters. `File.read(encoding: "UTF-8")` only
223
- # *tags* the string, so those reach lutaml as invalid UTF-8 and it raises.
224
- #
225
- # `scrub` with a block transcodes each invalid *run* as CP1252 while
226
- # leaving valid UTF-8 untouched. Both halves matter:
227
- #
228
- # * Not plain `scrub`, which substitutes U+FFFD: `client’s` would become
229
- # `client\uFFFDs` and `Muñoz` `Mu\uFFFDoz` — author surnames included.
230
- # The damage is length-preserving, so a length check will not catch it.
231
- # * Not a whole-file CP1252 re-decode, which mangles a file that is
232
- # genuinely UTF-8 apart from one stray byte: `Muñoz café ’` would come
233
- # back as `Muñoz café ’`, silently, since the result is valid UTF-8 and
234
- # nothing raises. No such file exists in today's corpus (0 of the 125
235
- # affected contain valid multi-byte UTF-8) but it grows daily, and
236
- # "decodes losslessly as CP1252" is weak evidence of correctness —
237
- # CP1252 maps 251 of 256 byte values.
238
- #
239
- # `undef: :replace` covers the five bytes CP1252 leaves undefined
240
- # (0x81 0x8D 0x8F 0x90 0x9D), so this returns valid UTF-8 rather than
241
- # raising and costing the whole file.
219
+ # Read a bibxml file as UTF-8, recovering Windows-1252 bytes. The repair
220
+ # is `BibXMLParser.to_utf8`, shared with `Scraper`'s bib.ietf.org fallback.
242
221
  #
243
222
  # @param path [String]
244
223
  # @return [String] UTF-8, valid encoding
245
224
  #
246
225
  def read_bibxml(path)
247
- utf8 = File.binread(path).force_encoding(Encoding::UTF_8)
248
- return utf8 if utf8.valid_encoding?
249
-
250
- utf8.scrub do |bad|
251
- bad.force_encoding(Encoding::WINDOWS_1252)
252
- .encode(Encoding::UTF_8, undef: :replace)
253
- end
226
+ BibXMLParser.to_utf8 File.binread(path)
254
227
  end
255
228
 
256
229
  #