relaton 3.0.0 → 3.0.2

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/3gpp/CLAUDE.md +31 -0
  3. data/lib/relaton/3gpp/bibliography.rb +1 -1
  4. data/lib/relaton/3gpp/data_fetcher.rb +32 -9
  5. data/lib/relaton/3gpp/date.rb +13 -0
  6. data/lib/relaton/3gpp/item.rb +3 -0
  7. data/lib/relaton/3gpp/parser.rb +9 -6
  8. data/lib/relaton/bib/converter/csl.rb +3 -2
  9. data/lib/relaton/bib/model/docidentifier.rb +61 -0
  10. data/lib/relaton/bib/model/item_shared.rb +12 -1
  11. data/lib/relaton/bib.rb +2 -1
  12. data/lib/relaton/bipm/CLAUDE.md +14 -0
  13. data/lib/relaton/bsi/model/docidentifier.rb +0 -4
  14. data/lib/relaton/calconnect/data_fetcher.rb +11 -3
  15. data/lib/relaton/ccsds/data/fetcher.rb +2 -1
  16. data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
  17. data/lib/relaton/cie/authors_parser.rb +78 -0
  18. data/lib/relaton/cie/data_fetcher.rb +23 -18
  19. data/lib/relaton/cie.rb +1 -0
  20. data/lib/relaton/core/data_fetcher.rb +23 -1
  21. data/lib/relaton/core.rb +7 -0
  22. data/lib/relaton/db/cache.rb +3 -3
  23. data/lib/relaton/db.rb +20 -1
  24. data/lib/relaton/doi/crossref.rb +2 -1
  25. data/lib/relaton/doi/parser.rb +3 -2
  26. data/lib/relaton/etsi/data_fetcher.rb +3 -3
  27. data/lib/relaton/gb/sec_scraper.rb +3 -3
  28. data/lib/relaton/iana/data_fetcher.rb +53 -4
  29. data/lib/relaton/iec/data_fetcher.rb +3 -2
  30. data/lib/relaton/iec/model/docidentifier.rb +0 -4
  31. data/lib/relaton/ieee/CLAUDE.md +30 -4
  32. data/lib/relaton/ieee/bibliography.rb +87 -32
  33. data/lib/relaton/ieee/data_fetcher.rb +1 -1
  34. data/lib/relaton/ieee/docidentifier.rb +37 -0
  35. data/lib/relaton/ieee/idams_parser.rb +2 -2
  36. data/lib/relaton/ieee/item.rb +3 -0
  37. data/lib/relaton/ieee/item_data.rb +7 -1
  38. data/lib/relaton/ietf/CLAUDE.md +27 -2
  39. data/lib/relaton/ietf/bibxml_parser.rb +38 -0
  40. data/lib/relaton/ietf/data_fetcher.rb +3 -30
  41. data/lib/relaton/ietf/scraper.rb +60 -3
  42. data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
  43. data/lib/relaton/iho/CLAUDE.md +4 -0
  44. data/lib/relaton/iho/bibliography.rb +7 -1
  45. data/lib/relaton/index/dataset_lock.rb +4 -3
  46. data/lib/relaton/index/shard_source.rb +3 -2
  47. data/lib/relaton/index/sqlite_backend.rb +5 -3
  48. data/lib/relaton/isbn/open_library.rb +2 -1
  49. data/lib/relaton/iso/data_fetcher.rb +6 -6
  50. data/lib/relaton/iso/model/docidentifier.rb +0 -4
  51. data/lib/relaton/itu/data_fetcher.rb +2 -2
  52. data/lib/relaton/itu/hit_collection.rb +3 -3
  53. data/lib/relaton/itu/recommendation_fields.rb +2 -2
  54. data/lib/relaton/jis/data_fetcher.rb +2 -1
  55. data/lib/relaton/jis/hit_collection.rb +1 -2
  56. data/lib/relaton/jis.rb +1 -0
  57. data/lib/relaton/json.rb +21 -0
  58. data/lib/relaton/logger/channels/gh_issue.rb +3 -3
  59. data/lib/relaton/logger/formatter_json.rb +2 -1
  60. data/lib/relaton/nist/CLAUDE.md +6 -0
  61. data/lib/relaton/nist/docidentifier.rb +0 -4
  62. data/lib/relaton/nist/hit_collection.rb +1 -1
  63. data/lib/relaton/nist/pubs_export.rb +2 -1
  64. data/lib/relaton/nist.rb +1 -0
  65. data/lib/relaton/ogc/data_fetcher.rb +2 -2
  66. data/lib/relaton/omg/scraper.rb +3 -3
  67. data/lib/relaton/plateau/data_fetcher.rb +2 -1
  68. data/lib/relaton/plateau/hit_collection.rb +1 -1
  69. data/lib/relaton/plateau.rb +1 -0
  70. data/lib/relaton/un/hit_collection.rb +4 -3
  71. data/lib/relaton/version.rb +1 -1
  72. data/lib/relaton.rb +1 -0
  73. metadata +40 -24
@@ -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)
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
+ (defined?(Parslet::ParseFailed) && error.is_a?(Parslet::ParseFailed))
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
@@ -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
@@ -13,12 +13,14 @@ module Relaton
13
13
  end
14
14
 
15
15
  def fetch(_source = nil)
16
+ docs = []
16
17
  Dir["iana-registries/**/*.xml"].each do |file|
17
18
  content = File.read file, encoding: "UTF-8"
18
- parse(content) if content.include? "<registry"
19
+ collect content, docs if content.include? "<registry"
19
20
  rescue StandardError => e
20
21
  Util.error "Error: #{e.message}. File: #{file}"
21
22
  end
23
+ write_docs docs
22
24
  index.save
23
25
  report_errors
24
26
  end
@@ -50,12 +52,57 @@ module Relaton
50
52
  Util.warn "Skipping index entry for `#{docnumber}` (#{file}): #{e.message}"
51
53
  end
52
54
 
53
- def parse(content)
55
+ # A registry element with no <record> children carries no registry data
56
+ # of its own: IANA wraps some registries in a same-titled <hide/> page
57
+ # (the language-tag-extensions wrapper around its "-1" child), and
58
+ # republishes child registries as standalone pages whose XML is empty
59
+ # (rpki-signed-objects). The saved record for such an element is a
60
+ # slug-titled, dateless duplicate of the registry that holds the
61
+ # records (relaton-iana#8, #9), so it never becomes a document: when a
62
+ # record-bearing document of the same registry id exists, it becomes an
63
+ # index alias to that document's file; otherwise it is dropped.
64
+ RegistryDoc = Struct.new(:bib, :empty, :source)
65
+
66
+ def collect(content, docs)
54
67
  xml = Moxml.parse(content)
55
68
  registry = xml.at_xpath("/xmlns:registry", NS)
56
69
  doc = Parser.parse registry, nil, @errors
57
- save_doc doc
58
- registry.xpath("./xmlns:registry", NS).each { |r| save_doc Parser.parse(r, doc, @errors) }
70
+ docs << RegistryDoc.new(doc, recordless?(registry), content) if doc
71
+ registry.xpath("./xmlns:registry", NS).each do |r|
72
+ child = Parser.parse r, doc, @errors
73
+ docs << RegistryDoc.new(child, recordless?(r), content) if child
74
+ end
75
+ end
76
+
77
+ def recordless?(registry_el)
78
+ registry_el.xpath("./xmlns:record", NS).empty?
79
+ end
80
+
81
+ def write_docs(docs)
82
+ saved = {}
83
+ docs.each do |d|
84
+ if d.empty
85
+ Util.warn "Registry #{d.bib.docnumber} has no records; skipping"
86
+ next
87
+ end
88
+ # The fetched XML content bounds the registry's record, so an
89
+ # unchanged source skips the parse and the write (the digest
90
+ # ledger); the aliased file still has to resolve for the alias
91
+ # phase. The content is only digested when the ledger is on.
92
+ if @digest_ledger && d.source && unchanged?(d.bib.docnumber, d.source)
93
+ saved[d.bib.id] = ledger_file(d.bib.docnumber)
94
+ next
95
+ end
96
+ file = save_doc d.bib
97
+ note_digest d.bib.docnumber, d.source, file: file if @digest_ledger && d.source
98
+ saved[d.bib.id] = file if file
99
+ end
100
+ docs.select(&:empty).each do |d|
101
+ next unless (file = saved[d.bib.id])
102
+
103
+ Util.warn "Registry #{d.bib.docnumber} has no records; aliasing #{file}"
104
+ add_to_index d.bib.docnumber, file
105
+ end
59
106
  end
60
107
 
61
108
  #
@@ -83,6 +130,8 @@ module Relaton
83
130
  @files << file
84
131
  add_to_index bib.docnumber, file
85
132
  File.write file, serialize(bib), encoding: "UTF-8"
133
+ record_written file
134
+ file
86
135
  end
87
136
 
88
137
  def to_xml(bib)
@@ -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
@@ -24,10 +24,36 @@ serialized to the `_type: pubid:ieee:*` (lutaml) form, keyed on the index via
24
24
  loss is never silent. `DataFetcher.fetch` (doc generation) is unchanged — the
25
25
  data repo (`relaton-data-ieee`) calls `build_index` after obtaining `data/`.
26
26
  - **Read side.** `bibliography.rb#search` passes `pubid_class:` to `find_or_create`,
27
- parses the query with `parse_pubid` (falling back to the raw String on failure),
28
- passes the pubid to `index.search` for number-narrowing, and picks the row by
29
- `row[:id].to_s`. `processor.rb#remove_index_file` passes `url: true` and `file:`
30
- only: the delete never reads the index (see `lib/relaton/index/CLAUDE.md`).
27
+ parses the query with `parse_pubid` (falling back to the raw String on failure)
28
+ and passes the pubid to `index.search` for number-narrowing. A parsed query
29
+ selects its rows by **pubid equality with the year aside**
30
+ (`id.exclude(:year) == pubid.exclude(:year)`), so a redline, a draft, a
31
+ corrigendum and another number (`1003.10` for `1003.1`) never match. A dated
32
+ query (its own year, or the `year` argument) takes that year; an **undated**
33
+ query takes the **latest** edition, and `get` returns it as
34
+ `to_most_recent_reference` (undated IEEE docids, no date, an `instanceOf`
35
+ relation to the edition; `opts[:keep_year]` keeps it dated) — relaton-ieee#29.
36
+ Before this, a substring match + `min_by(to_s)` answered `IEEE 528` with the
37
+ oldest edition, `528-1994`. When equality finds nothing (an unparseable query,
38
+ or one of the ~4% of forms whose year pubid keeps elsewhere, e.g.
39
+ `IEEE Std 1003.1-1988/INT-1992`, the corrigendum's legacy base year), the old
40
+ substring match is the fallback. `processor.rb#remove_index_file` passes
41
+ `url: true` and `file:` only: the delete never reads the index (see
42
+ `lib/relaton/index/CLAUDE.md`).
43
+ - **Why not `pubid === row`.** pubid declares no `subset_strict` attribute for
44
+ IEEE, so a nil `parts`, `prefix`, `draft` or `draft_status` in the query is a
45
+ wildcard: over the published index (12,616 rows) `===` let `IEEE 802.3` match
46
+ `802.3.1-2011` and `IEEE 754` match `IEEE Unapproved P754/D9.0`. Switch to
47
+ `index.search(pubid)` once pubid makes those strict, and re-measure `===`
48
+ against the equality over the full index first.
49
+ - **`Relaton::Ieee::Docidentifier`.** `Item` holds this class (the producer
50
+ creates it too: `IdamsParser#docidentifier`, `DataFetcher`), because
51
+ `Bib::Docidentifier#remove_date!` is a no-op. Its `remove_date!` re-renders an
52
+ `IEEE` docid through pubid without the year (`to_s(trademark: true)` for the
53
+ `scope: trademark` one) and leaves an id **with no year** untouched: a
54
+ re-render would change a draft's spelling (`P802.5t™/D-2.5` →
55
+ `P802.5t®/D2.5`). `ItemData#to_most_recent_reference` drops the ISBN and
56
+ DOI, which name one edition.
31
57
 
32
58
  ## Identifier resolution (`RawbibIdParser`, pubid-first)
33
59
 
@@ -9,33 +9,12 @@ module Relaton
9
9
  #
10
10
  # @param ref [String, Pubid::Ieee::Identifier] the reference, or its
11
11
  # parse from Relaton::Db (relaton#205)
12
+ # @param year [String, nil] the year, when the reference has none
12
13
  #
13
14
  # @return [Relaton::Ieee::ItemData, nil]
14
15
  #
15
- def search(ref) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
16
- # The machine index on the Pages site (relaton#206, the W3C
17
- # pilot's contract): a parsed query reads only its own shard. A
18
- # Pages failure raises `Relaton::RequestError`; the whole-index
19
- # zip on the same site serves only the retry on a miss.
20
- index = Relaton::Index.find_or_create :ieee, pages_url: PAGES_URL,
21
- pubid_class: ::Pubid::Ieee::Identifier
22
- # Pass the parsed pubid (not the raw String) so index-v2 narrows
23
- # candidates by number via binary search before the block runs; the
24
- # block keeps the broad substring match the string index gave, and an
25
- # unparseable/partial ref falls back to the full-scan String search.
26
- # Rows are Pubid::Ieee::Identifier objects (not Comparable), so pick by
27
- # the string form.
28
- pubid = parse_pubid ref
29
- needle = pubid.to_s
30
- # pubid's normalization moves a legacy base year into its own
31
- # comma segment ("IEEE P802.16/D5, 2004/Cor. 1-2005"); the
32
- # index rows carry the canonical form without it, so the
33
- # block also matches with the moved year stripped
34
- row = index.search(pubid) do |r|
35
- id = r[:id].to_s
36
- id.include?(needle) ||
37
- id.include?(needle.gsub(/, (19|20)\d{2}\//, "/"))
38
- end.min_by { |r| r[:id].to_s }
16
+ def search(ref, year = nil)
17
+ row = find_row parse_pubid(ref), year
39
18
  return unless row
40
19
 
41
20
  resp = Faraday.get "#{GH_URL}#{row[:file]}"
@@ -47,30 +26,106 @@ module Relaton
47
26
  end
48
27
 
49
28
  #
50
- # Get IEEE bibliography item by reference.
29
+ # Get IEEE bibliography item by reference. An undated reference
30
+ # answers with the latest edition as an undated item that has an
31
+ # `instanceOf` relation to that edition (relaton-ieee#29).
51
32
  #
52
33
  # @param ref [String, Pubid::Ieee::Identifier] the IEEE standard
53
34
  # reference to look up (e.g. "IEEE 528-2019"), or its parse from
54
35
  # Relaton::Db (relaton#205)
55
36
  # @param year [String] the year the standard was published (optional)
56
37
  # @param opts [Hash] options
38
+ # @option opts [Boolean] :keep_year keep the edition's date on an
39
+ # undated reference
57
40
  #
58
41
  # @return [Relaton::Ieee::ItemData, nil]
59
42
  #
60
- def get(ref, _year = nil, _opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
43
+ def get(ref, year = nil, opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
61
44
  Util.info "Fetching from Relaton repository ...", key: ref.to_s
62
- item = search(ref)
63
- if item
64
- Util.info "Found: `#{item.docidentifier.first.content}`", key: ref.to_s
65
- item
66
- else
45
+ item = search(ref, year)
46
+ unless item
67
47
  Util.info "Not found.", key: ref.to_s
68
- nil
48
+ return
69
49
  end
50
+
51
+ Util.info "Found: `#{item.docidentifier.first.content}`", key: ref.to_s
52
+ return item if opts[:keep_year] || dated?(parse_pubid(ref), year)
53
+
54
+ item.to_most_recent_reference
70
55
  end
71
56
 
72
57
  private
73
58
 
59
+ # The index row of the reference. A parsed reference matches the rows
60
+ # of its own document by pubid equality, the year aside; an undated
61
+ # one takes the latest edition. When that finds nothing (an
62
+ # unparseable reference, or a form whose year pubid keeps elsewhere)
63
+ # the broad substring match is the fallback.
64
+ #
65
+ # @param pubid [Pubid::Ieee::Identifier, String]
66
+ # @param year [String, nil]
67
+ # @return [Hash, nil] the `{ id:, file: }` row
68
+ def find_row(pubid, year)
69
+ index = Relaton::Index.find_or_create :ieee, pages_url: PAGES_URL,
70
+ pubid_class: ::Pubid::Ieee::Identifier
71
+ row = pubid_row(index, pubid, year) unless pubid.is_a?(String)
72
+ row || substring_row(index, pubid)
73
+ end
74
+
75
+ # Not `pubid === row`: IEEE `parts`, `prefix`, `draft` and
76
+ # `draft_status` are not `subset_strict` in pubid, so a nil there is a
77
+ # wildcard and `IEEE 802.3` would match `IEEE Std 802.3.1-2011` and
78
+ # `IEEE Unapproved P802.3/D2.0`.
79
+ def pubid_row(index, pubid, year)
80
+ target = (pubid.year || year)&.to_s
81
+ base = pubid.exclude(:year)
82
+ rows = index.search(pubid) do |r|
83
+ id = r[:id]
84
+ id.is_a?(::Pubid::Ieee::Identifier) && id.exclude(:year) == base &&
85
+ (target.nil? || id.year.to_s == target)
86
+ end
87
+ return rows.min_by { |r| r[:id].to_s } if target
88
+
89
+ rows.max_by { |r| [r[:id].year.to_s, r[:id].to_s] }
90
+ end
91
+
92
+ # pubid's normalization moves a legacy base year into its own comma
93
+ # segment ("IEEE P802.16/D5, 2004/Cor. 1-2005"); the index rows carry
94
+ # the canonical form without it, so the block also matches with the
95
+ # moved year stripped. Rows are Pubid::Ieee::Identifier objects (not
96
+ # Comparable), so pick by the string form.
97
+ #
98
+ # The project prefix moves too: "IEEE P802.21/D14" parses as a
99
+ # ProjectDraft, but the pre-publication crawl records spell the same
100
+ # document "IEEE Unapproved 802.21/D14, Sept 2008" — the Unapproved
101
+ # draft status occupies the slot the publisher and P would render in,
102
+ # so the row string contains neither (relaton-ieee#25). The draft
103
+ # number "/D14" still has to match, so the bare form cannot reach the
104
+ # published standard or a different draft — the variant exists only
105
+ # for a reference that names its draft.
106
+ def substring_row(index, pubid)
107
+ needle = pubid.to_s
108
+ # The publisher-and-prefix-stripped form; nil when there is no
109
+ # project-prefixed draft number.
110
+ stripped = needle.sub("IEEE P", "") if needle.match?(/\AIEEE P\S*\/D\d/i)
111
+ index.search(pubid) do |r|
112
+ id = r[:id].to_s
113
+ id.include?(needle) ||
114
+ id.include?(needle.gsub(/, (19|20)\d{2}\//, "/")) ||
115
+ (stripped && id.include?(stripped))
116
+ end.min_by { |r| r[:id].to_s }
117
+ end
118
+
119
+ # @param pubid [Pubid::Ieee::Identifier, String]
120
+ # @param year [String, nil]
121
+ def dated?(pubid, year)
122
+ return true if year
123
+ return pubid.year if pubid.respond_to?(:year)
124
+
125
+ # An unparseable reference was found by substring; leave it dated.
126
+ true
127
+ end
128
+
74
129
  # Parse a reference into a Pubid::Ieee::Identifier for index narrowing, or
75
130
  # return the raw String when pubid can't parse it (e.g. a partial ref) so
76
131
  # the search falls back to the substring scan.
@@ -171,7 +171,7 @@ module Relaton
171
171
  end
172
172
  return if RELATION_TYPES[type] == false
173
173
 
174
- docid = Bib::Docidentifier.new(type: "IEEE", content: fref, primary: true)
174
+ docid = Docidentifier.new(type: "IEEE", content: fref, primary: true)
175
175
  bib = ItemData.new formattedref: Bib::Formattedref.new(content: fref), docidentifier: [docid]
176
176
  description = create_relation_description type
177
177
  Bib::Relation.new(type: RELATION_TYPES[type][:type], description: description, bibitem: bib)
@@ -0,0 +1,37 @@
1
+ module Relaton
2
+ module Ieee
3
+ # IEEE document identifier. An `IEEE` docid is backed by a
4
+ # Pubid::Ieee::Identifier so `remove_date!` can strip the year when an
5
+ # undated reference answers with its latest edition (relaton-ieee#29).
6
+ # ISBN and DOI docids carry no pubid and are never changed.
7
+ class Docidentifier < Bib::Docidentifier
8
+ # Strip the year: `IEEE Std 528-2019` -> `IEEE Std 528`, and the
9
+ # trademark docid `IEEE Std 528™-2019` -> `IEEE Std 528™`. An id with no
10
+ # year is left as it is: a re-render would change its spelling
11
+ # (`IEEE P802.5t™/D-2.5` -> `IEEE P802.5t®/D2.5`).
12
+ def remove_date!
13
+ pubid = parse_pubid
14
+ return unless pubid&.year
15
+
16
+ undated = pubid.exclude(:year)
17
+ self.content = scope == "trademark" ? undated.to_s(trademark: true) : undated.to_s
18
+ end
19
+
20
+ # IEEE has no all-parts form: both are no-ops, so
21
+ # `Bib::ItemData#to_all_parts` never raises.
22
+ def remove_part!; end
23
+
24
+ def to_all_parts!; end
25
+
26
+ private
27
+
28
+ def parse_pubid
29
+ return unless type == "IEEE" && content
30
+
31
+ ::Pubid::Ieee::Identifier.parse content
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -92,7 +92,7 @@ module Relaton
92
92
  #
93
93
  # Parse identifiers
94
94
  #
95
- # @return [Array<Relaton::Bib::Docidentifier>]
95
+ # @return [Array<Relaton::Ieee::Docidentifier>]
96
96
  #
97
97
  def parse_docidentifier # rubocop:disable Metrics/MethodLength
98
98
  ids = @doc.isbn_doi.map { |id| id[:content] = id.delete(:id); id }
@@ -102,7 +102,7 @@ module Relaton
102
102
  ids.unshift(content: pubid.to_s, type: "IEEE", primary: true)
103
103
  end
104
104
 
105
- result = ids.map { |dcid| Bib::Docidentifier.new(**dcid) }
105
+ result = ids.map { |dcid| Docidentifier.new(**dcid) }
106
106
  @errors[:docidentifier] &&= result.empty?
107
107
  result
108
108
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "ext"
2
+ require_relative "docidentifier"
2
3
 
3
4
  module Relaton
4
5
  module Ieee
@@ -6,6 +7,8 @@ module Relaton
6
7
  model ItemData
7
8
 
8
9
  attribute :ext, Ext
10
+ attribute :docidentifier, Docidentifier, collection: true,
11
+ initialize_empty: true
9
12
  end
10
13
  end
11
14
  end
@@ -1,7 +1,13 @@
1
1
  module Relaton
2
2
  module Ieee
3
3
  class ItemData < Bib::ItemData
4
-
4
+ # The ISBN and DOI name one edition, so the undated item keeps only the
5
+ # IEEE docids; its `instanceOf` relation still carries them.
6
+ def to_most_recent_reference
7
+ me = super
8
+ me.docidentifier = me.docidentifier.select { |d| d.type == "IEEE" }
9
+ me
10
+ end
5
11
  end
6
12
  end
7
13
  end
@@ -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