relaton 3.0.8 → 3.1.0
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/processor.rb +1 -1
- data/lib/relaton/3gpp.rb +3 -3
- data/lib/relaton/adobe/processor.rb +1 -1
- data/lib/relaton/adobe.rb +3 -3
- data/lib/relaton/bib/item_data.rb +2 -2
- data/lib/relaton/bib.rb +2 -2
- data/lib/relaton/bibcollection.rb +1 -1
- data/lib/relaton/bipm/processor.rb +1 -1
- data/lib/relaton/bipm.rb +3 -3
- data/lib/relaton/bsi/processor.rb +1 -1
- data/lib/relaton/bsi.rb +1 -1
- data/lib/relaton/calconnect/data_fetcher.rb +2 -2
- data/lib/relaton/calconnect/model/item.rb +1 -1
- data/lib/relaton/calconnect/processor.rb +1 -1
- data/lib/relaton/calconnect.rb +2 -2
- data/lib/relaton/ccsds/data/fetcher.rb +1 -1
- data/lib/relaton/ccsds/model/item.rb +1 -1
- data/lib/relaton/ccsds/processor.rb +1 -1
- data/lib/relaton/ccsds/util.rb +1 -1
- data/lib/relaton/ccsds.rb +1 -1
- data/lib/relaton/cen/processor.rb +1 -1
- data/lib/relaton/cen.rb +2 -2
- data/lib/relaton/cie/data_fetcher.rb +3 -3
- data/lib/relaton/cie/processor.rb +1 -1
- data/lib/relaton/cie.rb +2 -2
- data/lib/relaton/cli/base_convertor.rb +3 -3
- data/lib/relaton/cli/command.rb +15 -9
- data/lib/relaton/cli/index_site_generator.rb +34 -6
- data/lib/relaton/cli/relaton_file.rb +4 -4
- data/lib/relaton/cli/subcommand_collection.rb +1 -1
- data/lib/relaton/cli/xml_convertor.rb +1 -1
- data/lib/relaton/cli/yaml_convertor.rb +2 -2
- data/lib/relaton/cli.rb +1 -11
- data/lib/relaton/core/processor.rb +1 -1
- data/lib/relaton/core.rb +1 -1
- data/lib/relaton/db/registry.rb +1 -1
- data/lib/relaton/db.rb +1 -1
- data/lib/relaton/doi/parser.rb +2 -1
- data/lib/relaton/doi/processor.rb +1 -1
- data/lib/relaton/doi.rb +4 -4
- data/lib/relaton/easc/processor.rb +1 -1
- data/lib/relaton/easc.rb +3 -3
- data/lib/relaton/ecma/data_fetcher.rb +1 -1
- data/lib/relaton/ecma/processor.rb +1 -1
- data/lib/relaton/ecma.rb +2 -2
- data/lib/relaton/etsi/data_fetcher.rb +2 -2
- data/lib/relaton/etsi/item.rb +1 -1
- data/lib/relaton/etsi/processor.rb +2 -2
- data/lib/relaton/etsi.rb +1 -1
- data/lib/relaton/evs/processor.rb +1 -1
- data/lib/relaton/evs.rb +2 -2
- data/lib/relaton/gb/bibliography.rb +1 -1
- data/lib/relaton/gb/processor.rb +1 -1
- data/lib/relaton/gb.rb +2 -2
- data/lib/relaton/gost/processor.rb +1 -1
- data/lib/relaton/gost.rb +3 -3
- data/lib/relaton/iala/processor.rb +1 -1
- data/lib/relaton/iala.rb +3 -3
- data/lib/relaton/iana/data_fetcher.rb +1 -1
- data/lib/relaton/iana/processor.rb +1 -1
- data/lib/relaton/iana.rb +2 -2
- data/lib/relaton/iec/data_fetcher.rb +1 -1
- data/lib/relaton/iec/processor.rb +1 -1
- data/lib/relaton/iec.rb +2 -2
- data/lib/relaton/ieee/processor.rb +1 -1
- data/lib/relaton/ieee.rb +2 -2
- data/lib/relaton/ietf/data_fetcher.rb +1 -1
- data/lib/relaton/ietf/processor.rb +1 -1
- data/lib/relaton/ietf.rb +2 -2
- data/lib/relaton/iho/hash_parser_v1.rb +1 -1
- data/lib/relaton/iho/processor.rb +1 -1
- data/lib/relaton/iho.rb +2 -2
- data/lib/relaton/index.rb +1 -1
- data/lib/relaton/isbn/processor.rb +1 -1
- data/lib/relaton/isbn.rb +1 -1
- data/lib/relaton/iso/hash_parser_v1.rb +1 -1
- data/lib/relaton/iso/processor.rb +1 -1
- data/lib/relaton/iso.rb +3 -3
- data/lib/relaton/itu/processor.rb +1 -1
- data/lib/relaton/itu.rb +3 -3
- data/lib/relaton/jcgm/processor.rb +1 -1
- data/lib/relaton/jcgm.rb +2 -2
- data/lib/relaton/jis/processor.rb +1 -1
- data/lib/relaton/jis.rb +2 -2
- data/lib/relaton/nist/processor.rb +1 -1
- data/lib/relaton/nist.rb +2 -2
- data/lib/relaton/oasis/processor.rb +1 -1
- data/lib/relaton/oasis.rb +2 -2
- data/lib/relaton/ogc/processor.rb +1 -1
- data/lib/relaton/ogc.rb +2 -2
- data/lib/relaton/oiml/processor.rb +1 -1
- data/lib/relaton/oiml.rb +2 -2
- data/lib/relaton/omg/processor.rb +1 -1
- data/lib/relaton/omg.rb +1 -1
- data/lib/relaton/plateau/data_fetcher.rb +1 -1
- data/lib/relaton/plateau/processor.rb +1 -1
- data/lib/relaton/plateau.rb +2 -2
- data/lib/relaton/render/iso690/elements/extent.rb +1 -1
- data/lib/relaton/sae/CLAUDE.md +89 -0
- data/lib/relaton/sae/bibdata.rb +8 -0
- data/lib/relaton/sae/bibitem.rb +8 -0
- data/lib/relaton/sae/bibliography.rb +128 -0
- data/lib/relaton/sae/data_fetcher.rb +219 -0
- data/lib/relaton/sae/doi_code.rb +83 -0
- data/lib/relaton/sae/item.rb +10 -0
- data/lib/relaton/sae/item_data.rb +6 -0
- data/lib/relaton/sae/processor.rb +62 -0
- data/lib/relaton/sae/util.rb +9 -0
- data/lib/relaton/sae.rb +37 -0
- data/lib/relaton/un/processor.rb +1 -1
- data/lib/relaton/un.rb +1 -1
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton/w3c/bibliography.rb +1 -1
- data/lib/relaton/w3c/data_fetcher.rb +1 -1
- data/lib/relaton/w3c/processor.rb +1 -1
- data/lib/relaton/w3c.rb +2 -2
- data/lib/relaton/xsf/data_fetcher.rb +1 -1
- data/lib/relaton/xsf/processor.rb +1 -1
- data/lib/relaton/xsf.rb +2 -2
- data/lib/relaton-cli.rb +2 -2
- data/lib/relaton-render.rb +1 -2
- data/lib/relaton.rb +3 -2
- metadata +16 -7
- data/lib/relaton/cli/version.rb +0 -11
- data/lib/relaton/render/version.rb +0 -5
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
module Relaton
|
|
4
|
+
module Sae
|
|
5
|
+
module Bibliography
|
|
6
|
+
# Transport failures of the document fetch. They become
|
|
7
|
+
# `Relaton::RequestError`, the one class `Relaton::Db#net_retry` retries.
|
|
8
|
+
NET_ERRORS = [
|
|
9
|
+
SocketError, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
10
|
+
Errno::ETIMEDOUT, EOFError, Net::HTTPBadResponse,
|
|
11
|
+
Net::HTTPHeaderSyntaxError, Net::ProtocolError, Net::OpenTimeout,
|
|
12
|
+
Net::ReadTimeout, OpenSSL::SSL::SSLError
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
#
|
|
17
|
+
# Fetch an SAE standard.
|
|
18
|
+
#
|
|
19
|
+
# @param ref [String, Pubid::Sae::Identifier] `SAE J3016`,
|
|
20
|
+
# `SAE J3016:2021`, or SAE's native edition code `SAE J3016_202104`
|
|
21
|
+
# @param year [String, nil]
|
|
22
|
+
# @param _opts [Hash]
|
|
23
|
+
# @return [Relaton::Sae::ItemData, nil]
|
|
24
|
+
#
|
|
25
|
+
def get(ref, year = nil, _opts = {})
|
|
26
|
+
Util.info "Fetching from Relaton repository ...", key: ref.to_s
|
|
27
|
+
pubid = parse_ref ref, year
|
|
28
|
+
return not_found(ref) unless pubid
|
|
29
|
+
|
|
30
|
+
row = best_match pubid
|
|
31
|
+
item = row && fetch_doc(row[:file])
|
|
32
|
+
return not_found(ref) unless item
|
|
33
|
+
|
|
34
|
+
Util.info "Found: `#{primary_id item}`", key: ref.to_s
|
|
35
|
+
item
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def not_found(ref)
|
|
41
|
+
Util.info "Not found.", key: ref.to_s
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#
|
|
46
|
+
# The query as a `Pubid::Sae::Identifier`.
|
|
47
|
+
#
|
|
48
|
+
# A parsed pubid comes from `Relaton::Db` (relaton#205): it is not
|
|
49
|
+
# mutated, so a year argument goes on a copy. A reference pubid cannot
|
|
50
|
+
# read answers nil, so `get` answers not-found with a log line instead
|
|
51
|
+
# of raising (relaton#235): `Db` passes such a reference as a String.
|
|
52
|
+
#
|
|
53
|
+
# @return [Pubid::Sae::Identifier, nil]
|
|
54
|
+
#
|
|
55
|
+
def parse_ref(ref, year)
|
|
56
|
+
ref = parse_text(ref.to_s.strip) unless ref.is_a?(::Pubid::Sae::Identifier)
|
|
57
|
+
return ref unless ref && year
|
|
58
|
+
|
|
59
|
+
copy = ::Pubid::Sae::Identifier.from_hash ref.to_hash
|
|
60
|
+
copy.date = ::Pubid::Sae::Components::Date.new(year: year.to_i)
|
|
61
|
+
copy
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_text(text)
|
|
65
|
+
::Pubid::Sae::Identifier.parse text
|
|
66
|
+
rescue ::Pubid::Errors::Error, ArgumentError => e
|
|
67
|
+
Util.info "Not an SAE identifier pubid reads: #{e.message}", key: text
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#
|
|
72
|
+
# The index row for a query: the newest edition among its matches.
|
|
73
|
+
#
|
|
74
|
+
# Passing the pubid (not a String) is what makes `Index::Type#search`
|
|
75
|
+
# narrow to the `number` bucket, and with no block it selects with
|
|
76
|
+
# pubid's subset match `query === row`: a revision, a year or a month
|
|
77
|
+
# the query omits matches any value, and the type (`J`, `AMS`, …), a
|
|
78
|
+
# part and an amendment must agree.
|
|
79
|
+
#
|
|
80
|
+
# Of the matches, the highest revision wins (aerospace standards), then
|
|
81
|
+
# the latest date (J-series editions). `r[:file]` breaks a tie, because
|
|
82
|
+
# the index sort is not stable.
|
|
83
|
+
#
|
|
84
|
+
# @param pubid [Pubid::Sae::Identifier]
|
|
85
|
+
# @return [Hash, nil]
|
|
86
|
+
#
|
|
87
|
+
def best_match(pubid)
|
|
88
|
+
index.search(pubid).max_by do |r|
|
|
89
|
+
id = r[:id]
|
|
90
|
+
[revision_rank(id.revision), id.year.to_i, id.date&.month.to_i,
|
|
91
|
+
r[:file]]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `B` follows `A`, and a two-letter revision (`AA`) follows `Z`.
|
|
96
|
+
def revision_rank(revision)
|
|
97
|
+
[revision.to_s.size, revision.to_s]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @param file [String] the row's path in the data repo
|
|
101
|
+
# @return [Relaton::Sae::ItemData, nil] nil when the file is missing
|
|
102
|
+
def fetch_doc(file)
|
|
103
|
+
uri = URI("#{ENDPOINT}#{file}")
|
|
104
|
+
resp = Net::HTTP.get_response uri
|
|
105
|
+
return if resp.code == "404"
|
|
106
|
+
unless resp.code == "200"
|
|
107
|
+
raise Relaton::RequestError,
|
|
108
|
+
"Could not access #{uri}: HTTP #{resp.code}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Item.from_yaml(resp.body).tap { |i| i.fetched = Date.today.to_s }
|
|
112
|
+
rescue *NET_ERRORS => e
|
|
113
|
+
raise Relaton::RequestError, "Could not access #{uri}: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def primary_id(item)
|
|
117
|
+
(item.docidentifier.detect(&:primary) || item.docidentifier.first)&.content
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def index
|
|
121
|
+
Relaton::Index.find_or_create(
|
|
122
|
+
:sae, pages_url: PAGES_URL, pubid_class: ::Pubid::Sae::Identifier
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require_relative "../core"
|
|
3
|
+
require_relative "../json"
|
|
4
|
+
require_relative "../sae"
|
|
5
|
+
require_relative "../doi"
|
|
6
|
+
|
|
7
|
+
module Relaton
|
|
8
|
+
module Sae
|
|
9
|
+
#
|
|
10
|
+
# Crawl every SAE standard from Crossref into a data repo.
|
|
11
|
+
#
|
|
12
|
+
# sae.org answers a script with a CAPTCHA, and Crossref's text search does
|
|
13
|
+
# not find a standard by its identifier, but SAE deposits each edition
|
|
14
|
+
# with a DOI under prefix `10.4271` and type `standard`. This walks that
|
|
15
|
+
# set with Crossref's deep-paging cursor (1000 records a request, about 48
|
|
16
|
+
# requests), maps each record with `Relaton::Doi::Parser`, and writes one
|
|
17
|
+
# document per edition and the pubid `index-v2`.
|
|
18
|
+
#
|
|
19
|
+
class DataFetcher < Core::DataFetcher
|
|
20
|
+
SOURCES = %w[sae-crossref].freeze
|
|
21
|
+
URL = "https://api.crossref.org/prefixes/10.4271/works".freeze
|
|
22
|
+
ROWS = 1000
|
|
23
|
+
# Crossref's public pool allows far more; one request a second is polite
|
|
24
|
+
# and still finishes the crawl in about a minute of waits.
|
|
25
|
+
GAP = 1
|
|
26
|
+
# Attempts for one page before the crawl gives up.
|
|
27
|
+
ATTEMPTS = 5
|
|
28
|
+
RETRY_CODES = %w[429 500 502 503 504].freeze
|
|
29
|
+
|
|
30
|
+
def fetch(source = nil)
|
|
31
|
+
unless SOURCES.include? source
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"Unknown source `#{source}`, expected one of #{SOURCES}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@newest = {}
|
|
37
|
+
each_page { |items| items.each { |work| parse_work work } }
|
|
38
|
+
index.save
|
|
39
|
+
report_errors
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def index
|
|
43
|
+
# `pubid_class:` on the producer too: FileIO#save calls `to_hash` only
|
|
44
|
+
# for instances of it, so without it the crawl writes v1-shaped rows
|
|
45
|
+
# under a v2 name.
|
|
46
|
+
@index ||= Relaton::Index.find_or_create(
|
|
47
|
+
:sae, file: "#{INDEXFILE}.yaml", pubid_class: ::Pubid::Sae::Identifier
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log_error(msg)
|
|
52
|
+
Util.error msg
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def each_page
|
|
58
|
+
cursor = "*"
|
|
59
|
+
loop do
|
|
60
|
+
message = get_page(cursor)
|
|
61
|
+
items = message["items"]
|
|
62
|
+
break if items.nil? || items.empty?
|
|
63
|
+
|
|
64
|
+
yield items
|
|
65
|
+
cursor = message["next-cursor"]
|
|
66
|
+
break unless cursor
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# One page of the cursor walk. A throttle or a server error is retried
|
|
72
|
+
# with a growing wait; anything else, or a page that keeps failing,
|
|
73
|
+
# stops the crawl, because a partial crawl would publish a partial index.
|
|
74
|
+
#
|
|
75
|
+
# @param cursor [String]
|
|
76
|
+
# @return [Hash] the `message` of the response
|
|
77
|
+
#
|
|
78
|
+
def get_page(cursor) # rubocop:disable Metrics/MethodLength
|
|
79
|
+
uri = page_uri cursor
|
|
80
|
+
ATTEMPTS.times do |n|
|
|
81
|
+
pacer.wait
|
|
82
|
+
resp, failure = attempt uri
|
|
83
|
+
return Relaton::Json.parse_external(resp.body)["message"] if resp&.code == "200"
|
|
84
|
+
if resp && !RETRY_CODES.include?(resp.code)
|
|
85
|
+
raise Relaton::RequestError, "Crossref HTTP #{resp.code} for #{uri}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
break if n == ATTEMPTS - 1
|
|
89
|
+
|
|
90
|
+
Util.warn "Crossref #{failure || "HTTP #{resp.code}"}, " \
|
|
91
|
+
"retry #{n + 1} of #{ATTEMPTS - 1}"
|
|
92
|
+
sleep backoff(resp, n + 1)
|
|
93
|
+
end
|
|
94
|
+
raise Relaton::RequestError,
|
|
95
|
+
"Crossref keeps failing for #{uri} after #{ATTEMPTS} attempts"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A transport error is retried like a 5xx: one timeout in ~48 requests
|
|
99
|
+
# must not fail the crawl.
|
|
100
|
+
#
|
|
101
|
+
# @return [Array(Net::HTTPResponse|nil, String|nil)] the response, or nil
|
|
102
|
+
# and the error message
|
|
103
|
+
def attempt(uri)
|
|
104
|
+
[http_get(uri), nil]
|
|
105
|
+
rescue *Bibliography::NET_ERRORS => e
|
|
106
|
+
[nil, "#{e.class}: #{e.message}"]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def page_uri(cursor)
|
|
110
|
+
uri = URI URL
|
|
111
|
+
uri.query = URI.encode_www_form(filter: "type:standard", rows: ROWS,
|
|
112
|
+
cursor: cursor)
|
|
113
|
+
uri
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def http_get(uri)
|
|
117
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
118
|
+
open_timeout: 30, read_timeout: 120) do |http|
|
|
119
|
+
http.get uri.request_uri, "User-Agent" => Relaton::Doi::Crossref::USER_AGENT
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Crossref sends `X-Rate-Limit-Interval: 1s`; a throttled response can
|
|
124
|
+
# carry no such header, so the wait is never less than a second.
|
|
125
|
+
def backoff(resp, num)
|
|
126
|
+
interval = resp&.[]("x-rate-limit-interval").to_s[/\d+/].to_i
|
|
127
|
+
[interval, 1].max * num * 2
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pacer
|
|
131
|
+
@pacer ||= Core::Pacer.new(gap: GAP)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#
|
|
135
|
+
# Write one Crossref record and index it.
|
|
136
|
+
#
|
|
137
|
+
# @param work [Hash] a Crossref work
|
|
138
|
+
#
|
|
139
|
+
def parse_work(work)
|
|
140
|
+
code = DoiCode.parse work["DOI"], approved_year: approved_year(work)
|
|
141
|
+
return Util.warn("Not an SAE DOI: `#{work['DOI']}`") unless code
|
|
142
|
+
|
|
143
|
+
item = to_item work, code
|
|
144
|
+
file = write_unique code.native, serialize(item)
|
|
145
|
+
add_to_index code, work, file
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def approved_year(work)
|
|
149
|
+
(work["approved"] || work["issued"])&.dig("date-parts", 0, 0)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
#
|
|
153
|
+
# The `Relaton::Doi::Parser` item, with SAE's edition code (as SAE
|
|
154
|
+
# prints it) as its primary identifier and the DOI kept beside it.
|
|
155
|
+
#
|
|
156
|
+
# @return [Relaton::Sae::ItemData]
|
|
157
|
+
#
|
|
158
|
+
def to_item(work, code)
|
|
159
|
+
bib = Relaton::Doi::Parser.parse work
|
|
160
|
+
bib.id = code.native.delete(" /").gsub(/\W/, "")
|
|
161
|
+
bib.docidentifier = [
|
|
162
|
+
Bib::Docidentifier.new(type: "SAE", content: code.printed,
|
|
163
|
+
primary: true),
|
|
164
|
+
*bib.docidentifier.map do |d|
|
|
165
|
+
Bib::Docidentifier.new(type: d.type, content: d.content)
|
|
166
|
+
end,
|
|
167
|
+
]
|
|
168
|
+
bib.ext = Bib::Ext.new(doctype: Bib::Doctype.new(content: "standard"),
|
|
169
|
+
flavor: "sae")
|
|
170
|
+
Item.from_yaml Bib::Item.to_yaml(bib)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
#
|
|
174
|
+
# Index the edition under its pubid, or record why it cannot be.
|
|
175
|
+
#
|
|
176
|
+
# An id pubid cannot read is recorded in `@errors` (the inherited
|
|
177
|
+
# `report_errors` logs it and opens the crawl's GitHub issue) and the row
|
|
178
|
+
# is skipped: `Relaton::Index` rejects the WHOLE index when one row fails
|
|
179
|
+
# to deserialize. The data file is already written, so the document is
|
|
180
|
+
# unindexed, never lost.
|
|
181
|
+
#
|
|
182
|
+
# The key carries the month, so two editions of one year are two rows.
|
|
183
|
+
# Two DOIs that spell one code (`ams-4121j`, `ams4121j`) share a key;
|
|
184
|
+
# the one with the later DOI date, then approval date, keeps the row.
|
|
185
|
+
#
|
|
186
|
+
def add_to_index(code, work, file)
|
|
187
|
+
pubid = parse_pubid code
|
|
188
|
+
unless pubid
|
|
189
|
+
@errors[code.native] =
|
|
190
|
+
"Unparseable SAE id `#{code.native}` was not indexed (#{file})"
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
key = pubid.to_s
|
|
195
|
+
stamp = [code.date.to_s, approved_stamp(work)]
|
|
196
|
+
return if @newest[key] && (@newest[key] <=> stamp) >= 0
|
|
197
|
+
|
|
198
|
+
@newest[key] = stamp
|
|
199
|
+
index.add_or_update pubid, file
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def parse_pubid(code)
|
|
203
|
+
::Pubid::Sae::Identifier.parse code.reference
|
|
204
|
+
rescue ::Pubid::Errors::Error, ArgumentError
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def approved_stamp(work)
|
|
209
|
+
Array(work.dig("approved", "date-parts", 0)).map do |d|
|
|
210
|
+
d.to_i.to_s.rjust(2, "0")
|
|
211
|
+
end.join("-")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def to_yaml(bib) = bib.to_yaml
|
|
215
|
+
def to_xml(bib) = Bibdata.to_xml(bib)
|
|
216
|
+
def to_bibxml(bib) = bib.to_rfcxml
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Relaton
|
|
2
|
+
module Sae
|
|
3
|
+
#
|
|
4
|
+
# The SAE codes a Crossref DOI names.
|
|
5
|
+
#
|
|
6
|
+
# SAE deposits one DOI per edition, and its suffix is SAE's own edition
|
|
7
|
+
# code in lowercase: `10.4271/j3016_202104` is `SAE J3016_202104`, the
|
|
8
|
+
# April 2021 edition of J3016. Aerospace standards are told apart by a
|
|
9
|
+
# revision letter instead and carry no date (`10.4271/ams4121j`).
|
|
10
|
+
#
|
|
11
|
+
# Three strings come out of it:
|
|
12
|
+
#
|
|
13
|
+
# * `#native` — the code in the DOI's spelling (`SAE AMSQQA250/4B`). The
|
|
14
|
+
# output file is named after it, so a file name never depends on pubid.
|
|
15
|
+
# * `#printed` — the code as SAE prints it (`SAE AMS-QQ-A-250/4B`), the
|
|
16
|
+
# document's primary identifier.
|
|
17
|
+
# * `#reference` — the form the index row is parsed from. It is the
|
|
18
|
+
# native code when the DOI has a date; an undated code gets the year of
|
|
19
|
+
# the record's approval date, so a dated reference also reaches an
|
|
20
|
+
# aerospace revision (`SAE AMS4121J:2017`).
|
|
21
|
+
#
|
|
22
|
+
class DoiCode
|
|
23
|
+
PREFIX = "10.4271/".freeze
|
|
24
|
+
# A code, then an optional `_YYYYMM` edition date. The code takes letters,
|
|
25
|
+
# digits, the `/` of a part (`j2337/2`) and any `_` that is not that date
|
|
26
|
+
# (`as5259/7a_a1`, `j1939da_4q2012`: 1,163 of the 2026-11 deposits); what
|
|
27
|
+
# it means is pubid's business, so nothing else is decided here.
|
|
28
|
+
SUFFIX = %r{\A(?<code>[a-z0-9][a-z0-9/._-]*?)(?:_(?<date>\d{6}))?\z}i
|
|
29
|
+
|
|
30
|
+
attr_reader :code, :date, :year
|
|
31
|
+
|
|
32
|
+
#
|
|
33
|
+
# @param doi [String, nil] e.g. `10.4271/j3016_202104`
|
|
34
|
+
# @param approved_year [String, Integer, nil] the record's approval year
|
|
35
|
+
# @return [Relaton::Sae::DoiCode, nil] nil for a DOI that is not SAE's
|
|
36
|
+
#
|
|
37
|
+
def self.parse(doi, approved_year: nil)
|
|
38
|
+
suffix = doi.to_s.strip
|
|
39
|
+
return unless suffix.downcase.start_with? PREFIX
|
|
40
|
+
|
|
41
|
+
match = SUFFIX.match suffix[PREFIX.size..]
|
|
42
|
+
return unless match
|
|
43
|
+
|
|
44
|
+
new(match[:code].upcase, match[:date], approved_year)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(code, date, approved_year)
|
|
48
|
+
@code = code
|
|
49
|
+
@date = date
|
|
50
|
+
@year = date ? date[0, 4] : approved_year&.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String] SAE's edition code, e.g. `SAE J3016_202104`
|
|
54
|
+
def native
|
|
55
|
+
date ? "SAE #{code}_#{date}" : "SAE #{code}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [String] the code SAE prints, or the native code when pubid
|
|
59
|
+
# cannot read it
|
|
60
|
+
def printed
|
|
61
|
+
pubid&.to_s || native
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [String] the reference the index row is parsed from
|
|
65
|
+
def reference
|
|
66
|
+
return native if date || !year
|
|
67
|
+
|
|
68
|
+
"SAE #{code}:#{year}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Pubid::Sae::Identifier, nil] the native code, parsed
|
|
72
|
+
def pubid
|
|
73
|
+
return @pubid if defined? @pubid
|
|
74
|
+
|
|
75
|
+
@pubid = begin
|
|
76
|
+
::Pubid::Sae::Identifier.parse native
|
|
77
|
+
rescue ::Pubid::Errors::Error, ArgumentError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Relaton
|
|
2
|
+
module Sae
|
|
3
|
+
# An SAE bibliographic item. SAE adds no element to the shared model: the
|
|
4
|
+
# Crossref record maps onto `Bib` as it is, so the only flavor-specific
|
|
5
|
+
# value is `ext/flavor`.
|
|
6
|
+
class Item < Bib::Item
|
|
7
|
+
model ItemData
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "../core/processor"
|
|
2
|
+
|
|
3
|
+
module Relaton
|
|
4
|
+
module Sae
|
|
5
|
+
class Processor < Relaton::Core::Processor
|
|
6
|
+
attr_reader :idtype
|
|
7
|
+
|
|
8
|
+
def initialize # rubocop:disable Lint/MissingSuper
|
|
9
|
+
@short = :relaton_sae
|
|
10
|
+
@prefix = "SAE"
|
|
11
|
+
@defaultprefix = /^SAE\s/
|
|
12
|
+
@idtype = "SAE"
|
|
13
|
+
@datasets = %w[sae-crossref]
|
|
14
|
+
# Db cache key: `Pubid::Sae` reads the reference and SAE's native
|
|
15
|
+
# edition code (`SAE J3016_202104`) alike.
|
|
16
|
+
@pubid_flavor = :Sae
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param code [String, Pubid::Sae::Identifier]
|
|
20
|
+
# @param date [String, nil] year
|
|
21
|
+
# @param opts [Hash]
|
|
22
|
+
# @return [Relaton::Sae::ItemData, nil]
|
|
23
|
+
def get(code, date, opts)
|
|
24
|
+
require_relative "../sae"
|
|
25
|
+
Bibliography.get(code, date, opts)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param source [String] `sae-crossref`
|
|
29
|
+
# @param opts [Hash] `output:`, `format:`
|
|
30
|
+
def fetch_data(source, opts)
|
|
31
|
+
require_relative "data_fetcher"
|
|
32
|
+
DataFetcher.fetch(source, **opts)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param xml [String]
|
|
36
|
+
# @return [Relaton::Sae::ItemData]
|
|
37
|
+
def from_xml(xml)
|
|
38
|
+
require_relative "../sae"
|
|
39
|
+
Item.from_xml xml
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param yaml [String]
|
|
43
|
+
# @return [Relaton::Sae::ItemData]
|
|
44
|
+
def from_yaml(yaml)
|
|
45
|
+
require_relative "../sae"
|
|
46
|
+
Item.from_yaml yaml
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String]
|
|
50
|
+
def grammar_hash
|
|
51
|
+
require_relative "../sae"
|
|
52
|
+
@grammar_hash ||= Sae.grammar_hash
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def remove_index_file
|
|
56
|
+
require_relative "../sae"
|
|
57
|
+
Relaton::Index.find_or_create(:sae, url: true,
|
|
58
|
+
file: "#{INDEXFILE}.yaml").remove_file
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/relaton/sae.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "digest/md5"
|
|
2
|
+
require "pubid"
|
|
3
|
+
require_relative "bib"
|
|
4
|
+
require_relative "index"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
|
|
7
|
+
# Relaton::Sae is the SAE International flavor (relaton/relaton-sae#1).
|
|
8
|
+
#
|
|
9
|
+
# sae.org answers a script with a CAPTCHA, so the flavor reads SAE's Crossref
|
|
10
|
+
# deposits instead: every SAE standard edition has its own DOI
|
|
11
|
+
# (`10.4271/j3016_202104`). `DataFetcher` crawls them into the
|
|
12
|
+
# `relaton-data-sae` data repo with a pubid `index-v2`, and `Bibliography`
|
|
13
|
+
# reads that index. See lib/relaton/sae/CLAUDE.md.
|
|
14
|
+
module Relaton
|
|
15
|
+
module Sae
|
|
16
|
+
INDEXFILE = "index-v2".freeze
|
|
17
|
+
# The machine index on the data repo's Pages site (relaton#206).
|
|
18
|
+
PAGES_URL = "https://relaton.github.io/relaton-data-sae/".freeze
|
|
19
|
+
# Where the documents the index names are read from.
|
|
20
|
+
ENDPOINT = "https://raw.githubusercontent.com/relaton/relaton-data-sae/main/".freeze
|
|
21
|
+
|
|
22
|
+
autoload :Util, "relaton/sae/util"
|
|
23
|
+
autoload :DoiCode, "relaton/sae/doi_code"
|
|
24
|
+
autoload :ItemData, "relaton/sae/item_data"
|
|
25
|
+
autoload :Item, "relaton/sae/item"
|
|
26
|
+
autoload :Bibitem, "relaton/sae/bibitem"
|
|
27
|
+
autoload :Bibdata, "relaton/sae/bibdata"
|
|
28
|
+
autoload :Bibliography, "relaton/sae/bibliography"
|
|
29
|
+
autoload :Processor, "relaton/sae/processor"
|
|
30
|
+
|
|
31
|
+
# Returns hash of XML grammar
|
|
32
|
+
# @return [String]
|
|
33
|
+
def self.grammar_hash
|
|
34
|
+
Digest::MD5.hexdigest Relaton::VERSION
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/relaton/un/processor.rb
CHANGED
data/lib/relaton/un.rb
CHANGED
data/lib/relaton/version.rb
CHANGED
data/lib/relaton/w3c.rb
CHANGED
data/lib/relaton/xsf.rb
CHANGED
|
@@ -5,8 +5,8 @@ require "mechanize"
|
|
|
5
5
|
# index code names. Processor#remove_index_file names no pubid class: the
|
|
6
6
|
# delete never reads the index (see lib/relaton/index/CLAUDE.md).
|
|
7
7
|
require "pubid"
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
require_relative "index"
|
|
9
|
+
require_relative "bib"
|
|
10
10
|
require_relative "version"
|
|
11
11
|
require_relative "xsf/util"
|
|
12
12
|
require_relative "xsf/item_data"
|
data/lib/relaton-cli.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# `index_name_for`/`pubid_class_for` resolve `Relaton::<Flavor>::INDEXFILE`, so
|
|
3
3
|
# without the umbrella every `--pubid-flavor` fails with "no relaton flavor".
|
|
4
4
|
# Declaring autoloads is cheap: no flavor is loaded until it is referenced.
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
require_relative "relaton"
|
|
6
|
+
require_relative "relaton/db"
|
|
7
7
|
|
|
8
8
|
module Relaton
|
|
9
9
|
module Cli
|