relaton 3.0.8 → 3.1.1

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/3gpp/processor.rb +1 -1
  3. data/lib/relaton/3gpp.rb +3 -3
  4. data/lib/relaton/adobe/processor.rb +1 -1
  5. data/lib/relaton/adobe.rb +3 -3
  6. data/lib/relaton/bib/item_data.rb +2 -2
  7. data/lib/relaton/bib.rb +2 -2
  8. data/lib/relaton/bibcollection.rb +1 -1
  9. data/lib/relaton/bipm/processor.rb +1 -1
  10. data/lib/relaton/bipm.rb +3 -3
  11. data/lib/relaton/bsi/processor.rb +1 -1
  12. data/lib/relaton/bsi.rb +1 -1
  13. data/lib/relaton/calconnect/data_fetcher.rb +2 -2
  14. data/lib/relaton/calconnect/model/item.rb +1 -1
  15. data/lib/relaton/calconnect/processor.rb +1 -1
  16. data/lib/relaton/calconnect.rb +2 -2
  17. data/lib/relaton/ccsds/data/fetcher.rb +1 -1
  18. data/lib/relaton/ccsds/model/item.rb +1 -1
  19. data/lib/relaton/ccsds/processor.rb +1 -1
  20. data/lib/relaton/ccsds/util.rb +1 -1
  21. data/lib/relaton/ccsds.rb +1 -1
  22. data/lib/relaton/cen/processor.rb +1 -1
  23. data/lib/relaton/cen.rb +2 -2
  24. data/lib/relaton/cie/data_fetcher.rb +3 -3
  25. data/lib/relaton/cie/processor.rb +1 -1
  26. data/lib/relaton/cie.rb +2 -2
  27. data/lib/relaton/cli/base_convertor.rb +3 -3
  28. data/lib/relaton/cli/command.rb +19 -11
  29. data/lib/relaton/cli/index_site_generator.rb +34 -6
  30. data/lib/relaton/cli/relaton_file.rb +4 -4
  31. data/lib/relaton/cli/subcommand_collection.rb +1 -1
  32. data/lib/relaton/cli/xml_convertor.rb +1 -1
  33. data/lib/relaton/cli/yaml_convertor.rb +2 -2
  34. data/lib/relaton/cli.rb +1 -11
  35. data/lib/relaton/core/processor.rb +1 -1
  36. data/lib/relaton/core.rb +1 -1
  37. data/lib/relaton/db/registry.rb +1 -1
  38. data/lib/relaton/db.rb +1 -1
  39. data/lib/relaton/doi/parser.rb +2 -1
  40. data/lib/relaton/doi/processor.rb +1 -1
  41. data/lib/relaton/doi.rb +4 -4
  42. data/lib/relaton/easc/processor.rb +1 -1
  43. data/lib/relaton/easc.rb +3 -3
  44. data/lib/relaton/ecma/data_fetcher.rb +1 -1
  45. data/lib/relaton/ecma/processor.rb +1 -1
  46. data/lib/relaton/ecma.rb +2 -2
  47. data/lib/relaton/etsi/data_fetcher.rb +2 -2
  48. data/lib/relaton/etsi/item.rb +1 -1
  49. data/lib/relaton/etsi/processor.rb +2 -2
  50. data/lib/relaton/etsi.rb +1 -1
  51. data/lib/relaton/evs/processor.rb +1 -1
  52. data/lib/relaton/evs.rb +2 -2
  53. data/lib/relaton/gb/bibliography.rb +1 -1
  54. data/lib/relaton/gb/processor.rb +1 -1
  55. data/lib/relaton/gb.rb +2 -2
  56. data/lib/relaton/gost/processor.rb +1 -1
  57. data/lib/relaton/gost.rb +3 -3
  58. data/lib/relaton/iala/processor.rb +1 -1
  59. data/lib/relaton/iala.rb +3 -3
  60. data/lib/relaton/iana/data_fetcher.rb +1 -1
  61. data/lib/relaton/iana/processor.rb +1 -1
  62. data/lib/relaton/iana.rb +2 -2
  63. data/lib/relaton/iec/bibliography.rb +32 -20
  64. data/lib/relaton/iec/data_fetcher.rb +1 -1
  65. data/lib/relaton/iec/processor.rb +1 -1
  66. data/lib/relaton/iec.rb +2 -2
  67. data/lib/relaton/ieee/processor.rb +1 -1
  68. data/lib/relaton/ieee.rb +2 -2
  69. data/lib/relaton/ietf/data_fetcher.rb +1 -1
  70. data/lib/relaton/ietf/processor.rb +1 -1
  71. data/lib/relaton/ietf.rb +2 -2
  72. data/lib/relaton/iho/hash_parser_v1.rb +1 -1
  73. data/lib/relaton/iho/processor.rb +1 -1
  74. data/lib/relaton/iho.rb +2 -2
  75. data/lib/relaton/index.rb +1 -1
  76. data/lib/relaton/isbn/processor.rb +1 -1
  77. data/lib/relaton/isbn.rb +1 -1
  78. data/lib/relaton/iso/hash_parser_v1.rb +1 -1
  79. data/lib/relaton/iso/processor.rb +1 -1
  80. data/lib/relaton/iso.rb +3 -3
  81. data/lib/relaton/itu/processor.rb +1 -1
  82. data/lib/relaton/itu.rb +3 -3
  83. data/lib/relaton/jcgm/processor.rb +1 -1
  84. data/lib/relaton/jcgm.rb +2 -2
  85. data/lib/relaton/jis/processor.rb +1 -1
  86. data/lib/relaton/jis.rb +2 -2
  87. data/lib/relaton/nist/processor.rb +1 -1
  88. data/lib/relaton/nist.rb +2 -2
  89. data/lib/relaton/oasis/processor.rb +1 -1
  90. data/lib/relaton/oasis.rb +2 -2
  91. data/lib/relaton/ogc/processor.rb +1 -1
  92. data/lib/relaton/ogc.rb +2 -2
  93. data/lib/relaton/oiml/processor.rb +1 -1
  94. data/lib/relaton/oiml.rb +2 -2
  95. data/lib/relaton/omg/processor.rb +1 -1
  96. data/lib/relaton/omg.rb +1 -1
  97. data/lib/relaton/plateau/data_fetcher.rb +1 -1
  98. data/lib/relaton/plateau/processor.rb +1 -1
  99. data/lib/relaton/plateau.rb +2 -2
  100. data/lib/relaton/render/iso690/elements/extent.rb +1 -1
  101. data/lib/relaton/render.rb +17 -0
  102. data/lib/relaton/sae/CLAUDE.md +91 -0
  103. data/lib/relaton/sae/bibdata.rb +8 -0
  104. data/lib/relaton/sae/bibitem.rb +8 -0
  105. data/lib/relaton/sae/bibliography.rb +128 -0
  106. data/lib/relaton/sae/data_fetcher.rb +224 -0
  107. data/lib/relaton/sae/doi_code.rb +83 -0
  108. data/lib/relaton/sae/item.rb +10 -0
  109. data/lib/relaton/sae/item_data.rb +6 -0
  110. data/lib/relaton/sae/processor.rb +62 -0
  111. data/lib/relaton/sae/util.rb +9 -0
  112. data/lib/relaton/sae.rb +37 -0
  113. data/lib/relaton/un/processor.rb +1 -1
  114. data/lib/relaton/un.rb +1 -1
  115. data/lib/relaton/version.rb +1 -1
  116. data/lib/relaton/w3c/bibliography.rb +1 -1
  117. data/lib/relaton/w3c/data_fetcher.rb +1 -1
  118. data/lib/relaton/w3c/processor.rb +1 -1
  119. data/lib/relaton/w3c.rb +2 -2
  120. data/lib/relaton/xsf/data_fetcher.rb +1 -1
  121. data/lib/relaton/xsf/processor.rb +1 -1
  122. data/lib/relaton/xsf.rb +2 -2
  123. data/lib/relaton-cli.rb +2 -2
  124. data/lib/relaton-render.rb +4 -11
  125. data/lib/relaton.rb +3 -2
  126. metadata +18 -8
  127. data/lib/relaton/cli/version.rb +0 -11
  128. data/lib/relaton/render/version.rb +0 -5
@@ -0,0 +1,91 @@
1
+ # CLAUDE.md — SAE flavor
2
+
3
+ `Relaton::Sae` fetches SAE International standards (relaton/relaton-sae#1).
4
+
5
+ ## Development
6
+
7
+ - `bundle exec rake spec:sae` — this flavor's suite (from the repo root).
8
+ - No VCR: the specs stub HTTP with WebMock. `spec/sae/fixtures/crossref/`
9
+ holds three Crossref cursor pages built from real records (11 works:
10
+ six J3016/J3194 editions, three AMS4121 revisions, a part and a
11
+ letter-pair number). `spec/sae/fixtures/index-v2.yaml` and `fixtures/data/` are the
12
+ **output of `DataFetcher` over those pages**: regenerate both together by
13
+ running the crawler over the page fixtures, never by hand.
14
+
15
+ ## Where the data comes from
16
+
17
+ **sae.org answers every script request with a CAPTCHA** (product pages and
18
+ search alike), so no scraper is possible. SAE deposits every standard edition
19
+ with Crossref instead: prefix `10.4271`, type `standard`, ~47,700 records. The
20
+ DOI suffix is SAE's own edition code in lowercase:
21
+
22
+ | DOI | SAE code | scheme |
23
+ |---|---|---|
24
+ | `10.4271/j3016_202104` | `SAE J3016_202104` | J-series: `_YYYYMM` edition date |
25
+ | `10.4271/ams4121j` | `SAE AMS4121J` | aerospace: revision letter, no date |
26
+ | `10.4271/j2337/2_201702` | `SAE J2337/2_201702` | a part |
27
+
28
+ Crossref's text search does **not** find a standard by its id (it matches
29
+ titles), so an undated reference cannot be answered live. The flavor is
30
+ therefore index-based, the ECMA/IALA shape:
31
+
32
+ - **Producer** — `DataFetcher` (`relaton fetch-data sae-crossref`) walks
33
+ `api.crossref.org/prefixes/10.4271/works?filter=type:standard` with the
34
+ deep-paging cursor (1000 a request, one request a second through
35
+ `Core::Pacer`; 429/5xx and transport errors retried with back-off, five
36
+ attempts, then `RequestError`, because
37
+ a partial crawl would publish a partial index). Each work goes through
38
+ `Relaton::Doi::Parser`, then gets SAE's code as its **primary**
39
+ docidentifier (type `SAE`), the DOI beside it, and `ext` doctype
40
+ `standard`, flavor `sae`. `fetched` is cleared: a data file carries no
41
+ fetch date, or every daily crawl would rewrite and commit all ~47,700
42
+ files. `Bibliography#fetch_doc` sets it when it reads a document.
43
+ - **Consumer** — `Bibliography.get` reads the `relaton-data-sae` Pages index
44
+ (`PAGES_URL`) and the document from `ENDPOINT` (raw GitHub).
45
+
46
+ ## Three identifier forms
47
+
48
+ `DoiCode` gives each record three strings:
49
+
50
+ - `#native` — the code in the DOI's spelling, `SAE J3016_202104`,
51
+ `SAE AMSQQA250/4B`. The output file is named after it
52
+ (`data/sae-amsqqa250-4b.yaml`), so a file name never depends on pubid.
53
+ - `#printed` — the code as SAE prints it (`SAE AMS-QQ-A-250/4B`): pubid's
54
+ render of the native code, or the native code when pubid cannot read it.
55
+ It is the primary docidentifier.
56
+ - `#reference` — what the index row is parsed from: the native code when the
57
+ DOI has a date, else the code with the year of the record's `approved` date
58
+ (`SAE AMS4121J:2017`), so a dated reference also reaches an aerospace
59
+ revision.
60
+
61
+ `Pubid::Sae` (pubid >= 2.0.9, pubid#665) reads all three, the month
62
+ included, so the `Db` keys the query and the fetched document with the
63
+ default `cache_pubid`. A reference pubid cannot read (`SAE J3016_2021`,
64
+ `SAE CPGM2016`) answers nil with a log line (relaton#235); it does not raise.
65
+
66
+ ## Selection
67
+
68
+ `index.search(pubid)` with no block, so the subset match `query === row`:
69
+ a revision, a year or a month the query omits matches any value; the type
70
+ (`J`, `AMS`, …) is compared, and a part and an amendment are `subset_strict`
71
+ (`SAE J2337` does not answer with `SAE J2337/2`). Of the matches the highest
72
+ revision wins (`[length, letters]`, so `AA` follows `Z`), then the latest
73
+ year and month, then the file name (the index sort is not stable). A year
74
+ argument goes on a **copy** of a pubid handed in by `Db`.
75
+
76
+ The index key carries the month, so two editions of one year are two rows.
77
+ Two DOIs that spell one code (`ams-4121j`, `ams4121j`) share a key, and
78
+ `DataFetcher#add_to_index` keeps the later one (DOI date, then approval
79
+ date).
80
+
81
+ ## What pubid still cannot read
82
+
83
+ About 960 of the ~47,700 deposits: 791 `CP*` collected-paper products (not
84
+ standards) and ~170 one-off shapes (`SUP1` supplements, language tails such
85
+ as `_PTBR`, `_4Q2012`, `_MMYYYY` dates). See pubid's `lib/pubid/sae/CLAUDE.md`.
86
+ Such a record is written, reported in `@errors` (the crawl's GitHub issue),
87
+ and not indexed: one row that fails to deserialize makes `Relaton::Index`
88
+ reject the whole index.
89
+
90
+ A multi-letter tail stays in `number` (`J1939DA`, and also `AMS4121AA`), so
91
+ a two-letter revision is its own bucket: `SAE AMS4121` does not reach it.
@@ -0,0 +1,8 @@
1
+ module Relaton
2
+ module Sae
3
+ class Bibdata < Item
4
+ model ItemData
5
+ include Bib::BibdataShared
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Relaton
2
+ module Sae
3
+ class Bibitem < Item
4
+ model ItemData
5
+ include Bib::BibitemShared
6
+ end
7
+ end
8
+ end
@@ -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,224 @@
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
+ # `Doi::Parser` stamps today as `fetched`. A data file must not carry
157
+ # it: the daily crawl would then rewrite every file and commit the
158
+ # whole corpus. `Bibliography#fetch_doc` sets `fetched` on reading.
159
+ #
160
+ # @return [Relaton::Sae::ItemData]
161
+ #
162
+ def to_item(work, code)
163
+ bib = Relaton::Doi::Parser.parse work
164
+ bib.fetched = nil
165
+ bib.id = code.native.delete(" /").gsub(/\W/, "")
166
+ bib.docidentifier = [
167
+ Bib::Docidentifier.new(type: "SAE", content: code.printed,
168
+ primary: true),
169
+ *bib.docidentifier.map do |d|
170
+ Bib::Docidentifier.new(type: d.type, content: d.content)
171
+ end,
172
+ ]
173
+ bib.ext = Bib::Ext.new(doctype: Bib::Doctype.new(content: "standard"),
174
+ flavor: "sae")
175
+ Item.from_yaml Bib::Item.to_yaml(bib)
176
+ end
177
+
178
+ #
179
+ # Index the edition under its pubid, or record why it cannot be.
180
+ #
181
+ # An id pubid cannot read is recorded in `@errors` (the inherited
182
+ # `report_errors` logs it and opens the crawl's GitHub issue) and the row
183
+ # is skipped: `Relaton::Index` rejects the WHOLE index when one row fails
184
+ # to deserialize. The data file is already written, so the document is
185
+ # unindexed, never lost.
186
+ #
187
+ # The key carries the month, so two editions of one year are two rows.
188
+ # Two DOIs that spell one code (`ams-4121j`, `ams4121j`) share a key;
189
+ # the one with the later DOI date, then approval date, keeps the row.
190
+ #
191
+ def add_to_index(code, work, file)
192
+ pubid = parse_pubid code
193
+ unless pubid
194
+ @errors[code.native] =
195
+ "Unparseable SAE id `#{code.native}` was not indexed (#{file})"
196
+ return
197
+ end
198
+
199
+ key = pubid.to_s
200
+ stamp = [code.date.to_s, approved_stamp(work)]
201
+ return if @newest[key] && (@newest[key] <=> stamp) >= 0
202
+
203
+ @newest[key] = stamp
204
+ index.add_or_update pubid, file
205
+ end
206
+
207
+ def parse_pubid(code)
208
+ ::Pubid::Sae::Identifier.parse code.reference
209
+ rescue ::Pubid::Errors::Error, ArgumentError
210
+ nil
211
+ end
212
+
213
+ def approved_stamp(work)
214
+ Array(work.dig("approved", "date-parts", 0)).map do |d|
215
+ d.to_i.to_s.rjust(2, "0")
216
+ end.join("-")
217
+ end
218
+
219
+ def to_yaml(bib) = bib.to_yaml
220
+ def to_xml(bib) = Bibdata.to_xml(bib)
221
+ def to_bibxml(bib) = bib.to_rfcxml
222
+ end
223
+ end
224
+ 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,6 @@
1
+ module Relaton
2
+ module Sae
3
+ class ItemData < Bib::ItemData
4
+ end
5
+ end
6
+ 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
@@ -0,0 +1,9 @@
1
+ module Relaton
2
+ module Sae
3
+ module Util
4
+ extend Relaton::Bib::Util
5
+
6
+ PROGNAME = "relaton-sae".freeze
7
+ end
8
+ end
9
+ end
@@ -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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "relaton/core/processor"
3
+ require_relative "../core/processor"
4
4
 
5
5
  module Relaton
6
6
  module Un