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.
- checksums.yaml +4 -4
- data/lib/relaton/3gpp/CLAUDE.md +31 -0
- data/lib/relaton/3gpp/bibliography.rb +1 -1
- data/lib/relaton/3gpp/data_fetcher.rb +32 -9
- data/lib/relaton/3gpp/date.rb +13 -0
- data/lib/relaton/3gpp/item.rb +3 -0
- data/lib/relaton/3gpp/parser.rb +9 -6
- data/lib/relaton/bib/converter/csl.rb +3 -2
- data/lib/relaton/bib/model/docidentifier.rb +61 -0
- data/lib/relaton/bib/model/item_shared.rb +12 -1
- data/lib/relaton/bib.rb +2 -1
- data/lib/relaton/bipm/CLAUDE.md +14 -0
- data/lib/relaton/bsi/model/docidentifier.rb +0 -4
- data/lib/relaton/calconnect/data_fetcher.rb +11 -3
- data/lib/relaton/ccsds/data/fetcher.rb +2 -1
- data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
- data/lib/relaton/cie/authors_parser.rb +78 -0
- data/lib/relaton/cie/data_fetcher.rb +23 -18
- data/lib/relaton/cie.rb +1 -0
- data/lib/relaton/core/data_fetcher.rb +23 -1
- data/lib/relaton/core.rb +7 -0
- data/lib/relaton/db/cache.rb +3 -3
- data/lib/relaton/db.rb +20 -1
- data/lib/relaton/doi/crossref.rb +2 -1
- data/lib/relaton/doi/parser.rb +3 -2
- data/lib/relaton/etsi/data_fetcher.rb +3 -3
- data/lib/relaton/gb/sec_scraper.rb +3 -3
- data/lib/relaton/iana/data_fetcher.rb +53 -4
- data/lib/relaton/iec/data_fetcher.rb +3 -2
- data/lib/relaton/iec/model/docidentifier.rb +0 -4
- data/lib/relaton/ieee/CLAUDE.md +30 -4
- data/lib/relaton/ieee/bibliography.rb +87 -32
- data/lib/relaton/ieee/data_fetcher.rb +1 -1
- data/lib/relaton/ieee/docidentifier.rb +37 -0
- data/lib/relaton/ieee/idams_parser.rb +2 -2
- data/lib/relaton/ieee/item.rb +3 -0
- data/lib/relaton/ieee/item_data.rb +7 -1
- data/lib/relaton/ietf/CLAUDE.md +27 -2
- data/lib/relaton/ietf/bibxml_parser.rb +38 -0
- data/lib/relaton/ietf/data_fetcher.rb +3 -30
- data/lib/relaton/ietf/scraper.rb +60 -3
- data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
- data/lib/relaton/iho/CLAUDE.md +4 -0
- data/lib/relaton/iho/bibliography.rb +7 -1
- data/lib/relaton/index/dataset_lock.rb +4 -3
- data/lib/relaton/index/shard_source.rb +3 -2
- data/lib/relaton/index/sqlite_backend.rb +5 -3
- data/lib/relaton/isbn/open_library.rb +2 -1
- data/lib/relaton/iso/data_fetcher.rb +6 -6
- data/lib/relaton/iso/model/docidentifier.rb +0 -4
- data/lib/relaton/itu/data_fetcher.rb +2 -2
- data/lib/relaton/itu/hit_collection.rb +3 -3
- data/lib/relaton/itu/recommendation_fields.rb +2 -2
- data/lib/relaton/jis/data_fetcher.rb +2 -1
- data/lib/relaton/jis/hit_collection.rb +1 -2
- data/lib/relaton/jis.rb +1 -0
- data/lib/relaton/json.rb +21 -0
- data/lib/relaton/logger/channels/gh_issue.rb +3 -3
- data/lib/relaton/logger/formatter_json.rb +2 -1
- data/lib/relaton/nist/CLAUDE.md +6 -0
- data/lib/relaton/nist/docidentifier.rb +0 -4
- data/lib/relaton/nist/hit_collection.rb +1 -1
- data/lib/relaton/nist/pubs_export.rb +2 -1
- data/lib/relaton/nist.rb +1 -0
- data/lib/relaton/ogc/data_fetcher.rb +2 -2
- data/lib/relaton/omg/scraper.rb +3 -3
- data/lib/relaton/plateau/data_fetcher.rb +2 -1
- data/lib/relaton/plateau/hit_collection.rb +1 -1
- data/lib/relaton/plateau.rb +1 -0
- data/lib/relaton/un/hit_collection.rb +4 -3
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton.rb +1 -0
- metadata +40 -24
data/lib/relaton/db/cache.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/relaton/doi/crossref.rb
CHANGED
|
@@ -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 =
|
|
62
|
+
work = Relaton::Json.parse_external resp.body
|
|
62
63
|
return work["message"] if work["status"] == "ok"
|
|
63
64
|
|
|
64
65
|
if n > 1
|
data/lib/relaton/doi/parser.rb
CHANGED
|
@@ -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
|
-
|
|
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::
|
|
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 =
|
|
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::
|
|
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 =
|
|
28
|
-
json =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
registry.xpath("./xmlns:registry", NS).each
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/relaton/ieee/CLAUDE.md
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
16
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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::
|
|
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|
|
|
105
|
+
result = ids.map { |dcid| Docidentifier.new(**dcid) }
|
|
106
106
|
@errors[:docidentifier] &&= result.empty?
|
|
107
107
|
result
|
|
108
108
|
end
|
data/lib/relaton/ieee/item.rb
CHANGED
|
@@ -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
|
data/lib/relaton/ietf/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|