relaton 3.1.2 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/relaton/bib/converter/csl/from_csl.rb +195 -0
- data/lib/relaton/bib/converter/csl.rb +13 -1
- data/lib/relaton/cli/command.rb +3 -1
- data/lib/relaton/cli/subcommand_collection.rb +5 -1
- data/lib/relaton/core/request_error.rb +18 -0
- data/lib/relaton/db/config.rb +9 -1
- data/lib/relaton/db/workers_pool.rb +11 -1
- data/lib/relaton/db.rb +129 -44
- data/lib/relaton/etsi/data_fetcher.rb +15 -5
- data/lib/relaton/jis/README.adoc +2 -0
- data/lib/relaton/jis/data_fetcher.rb +74 -13
- data/lib/relaton/jis/hit_collection.rb +2 -1
- data/lib/relaton/jis/processor.rb +1 -1
- data/lib/relaton/jis/scraper.rb +46 -5
- data/lib/relaton/plateau/data_fetcher.rb +10 -1
- data/lib/relaton/plateau/handbook_parser.rb +8 -1
- data/lib/relaton/plateau/hit_collection.rb +19 -4
- data/lib/relaton/version.rb +1 -1
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf214dd5828ef52d54652cd3c01d5406527e7dd0f6be2c52447a72889e7ed3c8
|
|
4
|
+
data.tar.gz: 48c41d919e828654ae0ddfd8fc0c6dcf2dafc912b65b58b493964145eb936ca6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 612e007cdc0a28b1eb0ac6ef9e0e563f067daa8532a25002d76835233e43b5e5051e2af133ad078a96be66d73c56c4ca58f5e3c675e51b8331cb58e3d842060c
|
|
7
|
+
data.tar.gz: 55ba69a723f91403724139457801a0fa92842208d5cc06be86fd7260a8507e85ccccbe3eb454f82553247130e1e072b1def59eee9a6c54c9b2086b8c6a29bd8d
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Relaton
|
|
4
|
+
module Bib
|
|
5
|
+
module Converter
|
|
6
|
+
module Csl
|
|
7
|
+
# CSL-JSON entry -> ItemData. The downgrade direction: a CSL record
|
|
8
|
+
# is a flat citation, so fields it does not express are left out
|
|
9
|
+
# rather than invented. The mirror of Converter::Bibtex::FromBibtex.
|
|
10
|
+
class FromCsl
|
|
11
|
+
# The reverse of ENTRY_TYPES for the types the export emits; any
|
|
12
|
+
# other CSL type is kept as written.
|
|
13
|
+
TYPES = {
|
|
14
|
+
"article-journal" => "article", "chapter" => "inbook",
|
|
15
|
+
"paper-conference" => "inproceedings", "webpage" => "webresource",
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# @param entry [Hash] one CSL-JSON entry
|
|
19
|
+
def initialize(entry)
|
|
20
|
+
@entry = entry
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Relaton::Bib::ItemData]
|
|
24
|
+
def transform # rubocop:disable Metrics/MethodLength
|
|
25
|
+
ItemData.new(
|
|
26
|
+
id: @entry["id"],
|
|
27
|
+
docidentifier: fetch_docid,
|
|
28
|
+
type: fetch_type,
|
|
29
|
+
title: fetch_title,
|
|
30
|
+
contributor: fetch_contributor,
|
|
31
|
+
date: fetch_date,
|
|
32
|
+
abstract: fetch_abstract,
|
|
33
|
+
extent: fetch_extent,
|
|
34
|
+
edition: fetch_edition,
|
|
35
|
+
series: fetch_series,
|
|
36
|
+
source: fetch_source,
|
|
37
|
+
language: fetch_language,
|
|
38
|
+
keyword: fetch_keyword,
|
|
39
|
+
relation: fetch_relation,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# @return [Array<Relaton::Bib::Docidentifier>]
|
|
46
|
+
def fetch_docid
|
|
47
|
+
docid = [Docidentifier.new(content: @entry["id"].to_s, primary: true)]
|
|
48
|
+
docid << Docidentifier.new(content: @entry["ISBN"].to_s, type: "isbn") if @entry["ISBN"]
|
|
49
|
+
docid << Docidentifier.new(content: @entry["ISSN"].to_s, type: "issn") if @entry["ISSN"]
|
|
50
|
+
docid
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String, nil]
|
|
54
|
+
def fetch_type
|
|
55
|
+
TYPES[@entry["type"]] || @entry["type"]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Array<Relaton::Bib::Title>]
|
|
59
|
+
def fetch_title
|
|
60
|
+
return [] unless @entry["title"]
|
|
61
|
+
|
|
62
|
+
[Title.new(type: "main", content: @entry["title"])]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<Relaton::Bib::Contributor>]
|
|
66
|
+
def fetch_contributor
|
|
67
|
+
contribs = []
|
|
68
|
+
fetch_person("author") { |c| contribs << c }
|
|
69
|
+
fetch_person("editor") { |c| contribs << c }
|
|
70
|
+
|
|
71
|
+
return contribs unless @entry["publisher"]
|
|
72
|
+
|
|
73
|
+
contribs << Contributor.new(
|
|
74
|
+
organization: Organization.new(name: [TypedLocalizedString.new(content: @entry["publisher"])]),
|
|
75
|
+
role: [Contributor::Role.new(type: "publisher")],
|
|
76
|
+
)
|
|
77
|
+
contribs
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# CSL names are {family:, given:} or a bare {literal:}.
|
|
81
|
+
#
|
|
82
|
+
# @param role [String] contributor role
|
|
83
|
+
def fetch_person(role, &_)
|
|
84
|
+
Array(@entry[role]).each do |name|
|
|
85
|
+
yield Contributor.new(person: Person.new(name: build_name(name)), role: [Contributor::Role.new(type: role)])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @param name [Hash] CSL name variable
|
|
90
|
+
# @return [Relaton::Bib::FullName]
|
|
91
|
+
def build_name(name)
|
|
92
|
+
return FullName.new(completename: LocalizedString.new(content: name["literal"])) if name["literal"]
|
|
93
|
+
|
|
94
|
+
fore = name["given"].to_s.split
|
|
95
|
+
FullName.new(
|
|
96
|
+
surname: LocalizedString.new(content: name["family"].to_s),
|
|
97
|
+
forename: fore.map { |fn| FullNameType::Forename.new(content: fn) },
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Array<Relaton::Bib::Date>]
|
|
102
|
+
def fetch_date
|
|
103
|
+
{ "issued" => "published", "accessed" => "accessed" }.filter_map do |field, type|
|
|
104
|
+
parts = @entry.dig(field, "date-parts")&.first
|
|
105
|
+
next unless parts
|
|
106
|
+
|
|
107
|
+
Bib::Date.new(type: type, at: iso_date(parts))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @param parts [Array<Integer>] [year], [year, month] or [year, month, day]
|
|
112
|
+
# @return [String]
|
|
113
|
+
def iso_date(parts)
|
|
114
|
+
format("%04d", parts[0]).tap do |iso|
|
|
115
|
+
iso << "-%02d" % parts[1] if parts[1]
|
|
116
|
+
iso << "-%02d" % parts[2] if parts[2]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [Array<Relaton::Bib::Abstract>]
|
|
121
|
+
def fetch_abstract
|
|
122
|
+
return [] unless @entry["abstract"]
|
|
123
|
+
|
|
124
|
+
[Abstract.new(content: @entry["abstract"])]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @return [Array<Relaton::Bib::Extent>]
|
|
128
|
+
def fetch_extent
|
|
129
|
+
locs = []
|
|
130
|
+
locs << Locality.new(type: "volume", reference_from: @entry["volume"].to_s) if @entry["volume"]
|
|
131
|
+
if @entry["page"]
|
|
132
|
+
from, to = @entry["page"].to_s.split("-", 2)
|
|
133
|
+
locs << Locality.new(type: "page", reference_from: from, reference_to: to)
|
|
134
|
+
end
|
|
135
|
+
locs.any? ? [Extent.new(locality: locs)] : []
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Relaton::Bib::Edition, nil]
|
|
139
|
+
def fetch_edition
|
|
140
|
+
Edition.new(content: @entry["edition"].to_s) if @entry["edition"]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Array<Relaton::Bib::Series>]
|
|
144
|
+
# container-title is the journal for article types and the book for
|
|
145
|
+
# chapters; the chapter shape maps to partOf, the journal shape to
|
|
146
|
+
# a series — the same split FromBibtex makes for journal/booktitle.
|
|
147
|
+
def fetch_series
|
|
148
|
+
return [] unless container_title && fetch_type != "inbook"
|
|
149
|
+
|
|
150
|
+
[Series.new(
|
|
151
|
+
type: "journal",
|
|
152
|
+
title: Title.new(content: container_title),
|
|
153
|
+
number: @entry["number"]&.to_s,
|
|
154
|
+
)]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @return [Array<Relaton::Bib::Uri>]
|
|
158
|
+
def fetch_source
|
|
159
|
+
source = []
|
|
160
|
+
source << Uri.new(type: "src", content: @entry["URL"]) if @entry["URL"]
|
|
161
|
+
source << Uri.new(type: "doi", content: @entry["DOI"]) if @entry["DOI"]
|
|
162
|
+
source
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @return [Array<String>]
|
|
166
|
+
def fetch_language
|
|
167
|
+
return [] unless @entry["language"]
|
|
168
|
+
|
|
169
|
+
sub = @entry["language"].split("-").first
|
|
170
|
+
[Iso639[sub]&.alpha2 || sub]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [Array<Relaton::Bib::Keyword>]
|
|
174
|
+
def fetch_keyword
|
|
175
|
+
Array(@entry["keyword"]).map do |kw|
|
|
176
|
+
Keyword.new(vocab: LocalizedString.new(content: kw))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @return [Array<Relaton::Bib::Relation>]
|
|
181
|
+
def fetch_relation
|
|
182
|
+
return [] unless container_title && fetch_type == "inbook"
|
|
183
|
+
|
|
184
|
+
ttl = Title.new(type: "main", content: container_title)
|
|
185
|
+
[Relation.new(type: "partOf", bibitem: ItemData.new(title: [ttl]))]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def container_title
|
|
189
|
+
@entry["container-title"]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "yeptris"
|
|
4
4
|
|
|
5
|
+
require_relative "csl/from_csl"
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
module Relaton
|
|
7
9
|
module Bib
|
|
@@ -21,6 +23,16 @@ module Relaton
|
|
|
21
23
|
Renderer.new(item).to_s
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# CSL-JSON string -> Hash{String=>ItemData}
|
|
27
|
+
def self.to_item(csl_str)
|
|
28
|
+
entries = ::Yeptris::JSON.load(csl_str)
|
|
29
|
+
entries.reduce({}) do |h, entry|
|
|
30
|
+
item = FromCsl.new(entry).transform
|
|
31
|
+
h[item.id.to_s] = item
|
|
32
|
+
h
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
24
36
|
class Renderer
|
|
25
37
|
def initialize(item)
|
|
26
38
|
@item = item
|
|
@@ -97,7 +109,7 @@ module Relaton
|
|
|
97
109
|
def published_year
|
|
98
110
|
date = Array(@item.date).find { |d| %w[published issued].include?(d.type.to_s) } || @item.date.first
|
|
99
111
|
value = date && (date.at || date.from || date.to)
|
|
100
|
-
value.to_s[/\d{4}/]
|
|
112
|
+
value.to_s[/\d{4}/] || ""
|
|
101
113
|
end
|
|
102
114
|
|
|
103
115
|
def source_uri
|
data/lib/relaton/cli/command.rb
CHANGED
|
@@ -371,7 +371,9 @@ module Relaton
|
|
|
371
371
|
nil
|
|
372
372
|
rescue Pubid::Errors::Error
|
|
373
373
|
%("#{code}" is not a recognized standards identifier)
|
|
374
|
-
rescue Relaton::RequestError => e
|
|
374
|
+
rescue Relaton::RequestError, Relaton::NotInLocalStoreError => e
|
|
375
|
+
# A transport failure, or offline mode with no stored entry
|
|
376
|
+
# (relaton#536): print the reason instead of a backtrace.
|
|
375
377
|
e.message
|
|
376
378
|
end
|
|
377
379
|
|
|
@@ -131,7 +131,11 @@ module Relaton
|
|
|
131
131
|
File.write colfile, coll.to_yaml, encoding: "UTF-8"
|
|
132
132
|
else Util.info "No matching bibliographic entry found"
|
|
133
133
|
end
|
|
134
|
-
rescue Relaton::UnknownReferenceError
|
|
134
|
+
rescue Relaton::UnknownReferenceError, Relaton::NotInLocalStoreError,
|
|
135
|
+
Relaton::RequestError => e
|
|
136
|
+
# An unknown reference, offline mode with no stored entry
|
|
137
|
+
# (relaton#536) or a transport failure: log the reason, and do not
|
|
138
|
+
# write the collection.
|
|
135
139
|
Util.warn e.message
|
|
136
140
|
rescue Pubid::Errors::Error
|
|
137
141
|
Util.error %("#{code}" is not a recognized standards identifier)
|
|
@@ -17,6 +17,24 @@ module Relaton
|
|
|
17
17
|
# declare only when absent — both forms rescue as StandardError
|
|
18
18
|
class RequestError < Error; end unless defined?(Relaton::RequestError)
|
|
19
19
|
|
|
20
|
+
# Offline mode (`Relaton::Db.configuration.offline`, RELATON_OFFLINE) and
|
|
21
|
+
# the local store has no entry for the reference (relaton#536). Not a
|
|
22
|
+
# RequestError: `Relaton::Db#net_retry` must not retry it, since no
|
|
23
|
+
# request was made.
|
|
24
|
+
class NotInLocalStoreError < Error
|
|
25
|
+
# @return [String] the reference the local store lacks
|
|
26
|
+
attr_reader :reference
|
|
27
|
+
|
|
28
|
+
# @param reference [String]
|
|
29
|
+
# @param dirs [Array<String>] the cache directories that were read
|
|
30
|
+
def initialize(reference, dirs = [])
|
|
31
|
+
@reference = reference
|
|
32
|
+
where = dirs.empty? ? "no cache directory" : dirs.join(", ")
|
|
33
|
+
super("`#{reference}` is not in the local store (#{where}); " \
|
|
34
|
+
"offline mode makes no network request")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
20
38
|
# `Relaton::Db` found no flavor for a reference: no flavor's pubid grammar
|
|
21
39
|
# reads it exactly, and no flavor's prefix matches it. relaton-cli rescues
|
|
22
40
|
# it, logs the message and returns no document.
|
data/lib/relaton/db/config.rb
CHANGED
|
@@ -11,11 +11,19 @@ module Relaton
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
class Configuration
|
|
14
|
-
|
|
14
|
+
# Values of RELATON_OFFLINE that turn offline mode on.
|
|
15
|
+
OFFLINE_ON = %w[1 true yes on].freeze
|
|
16
|
+
|
|
17
|
+
# @!attribute offline
|
|
18
|
+
# @return [Boolean] true: every fetch is answered from the local
|
|
19
|
+
# store only, and a miss raises Relaton::NotInLocalStoreError
|
|
20
|
+
# (relaton#536). Defaults to RELATON_OFFLINE.
|
|
21
|
+
attr_accessor :use_api, :api_host, :offline
|
|
15
22
|
|
|
16
23
|
def initialize
|
|
17
24
|
@use_api = false
|
|
18
25
|
@api_host = "https://api.relaton.org"
|
|
26
|
+
@offline = OFFLINE_ON.include?(ENV["RELATON_OFFLINE"].to_s.downcase)
|
|
19
27
|
end
|
|
20
28
|
end
|
|
21
29
|
|
|
@@ -8,7 +8,17 @@ module Relaton
|
|
|
8
8
|
@queue = SizedQueue.new(workers * 2)
|
|
9
9
|
@threads = Array.new workers do
|
|
10
10
|
Thread.new do
|
|
11
|
-
|
|
11
|
+
# A worker that dies silently wedges the pool: the item it was
|
|
12
|
+
# processing never completes, and every consumer blocked on that
|
|
13
|
+
# completion blocks forever (metanorma-standoc CI deadlock,
|
|
14
|
+
# 2026-11-13). The pool outlives any single item's failure.
|
|
15
|
+
while item = @queue.pop
|
|
16
|
+
begin
|
|
17
|
+
yield(item)
|
|
18
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
19
|
+
Util.error "fetch worker item failed: #{e.class}: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
12
22
|
end
|
|
13
23
|
end
|
|
14
24
|
end
|
data/lib/relaton/db.rb
CHANGED
|
@@ -65,21 +65,25 @@ module Relaton
|
|
|
65
65
|
# @return [nil, RelatonBib::BibliographicItem, ...]
|
|
66
66
|
# @raise [Relaton::UnknownReferenceError] no flavor recognizes the
|
|
67
67
|
# reference (see Registry#route)
|
|
68
|
+
# @raise [Relaton::NotInLocalStoreError] offline mode, and the local
|
|
69
|
+
# store has no entry for the reference (relaton#536)
|
|
68
70
|
##
|
|
69
71
|
def fetch(text, year = nil, opts = {}) # rubocop:disable Metrics/MethodLength
|
|
70
72
|
reference = text.strip
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
offline_answer(reference, opts) do |opts| # rubocop:disable Lint/ShadowingOuterLocalVariable
|
|
74
|
+
stdclass, pubid = @registry.route(reference)
|
|
75
|
+
processor = @registry[stdclass]
|
|
76
|
+
ref = if processor.respond_to?(:urn_to_code)
|
|
77
|
+
processor.urn_to_code(reference)&.first
|
|
78
|
+
else reference
|
|
79
|
+
end
|
|
80
|
+
ref ||= reference
|
|
81
|
+
# The routed pubid is the parse of `reference`; a URN rewritten to a
|
|
82
|
+
# code is parsed again by the flavor.
|
|
83
|
+
pubid = nil unless ref == reference
|
|
84
|
+
result = combine_doc ref, year, opts, stdclass
|
|
85
|
+
result || fetch_routed(ref, year, opts, stdclass, pubid)
|
|
86
|
+
end
|
|
83
87
|
end
|
|
84
88
|
|
|
85
89
|
# @see Relaton::Db#fetch
|
|
@@ -124,7 +128,7 @@ module Relaton
|
|
|
124
128
|
threads = ENV["RELATON_FETCH_PARALLEL"]&.to_i || processor.threads
|
|
125
129
|
wp = WorkersPool.new(threads) do |args|
|
|
126
130
|
args[3].call fetch(*args[0..2])
|
|
127
|
-
rescue Relaton::RequestError => e
|
|
131
|
+
rescue Relaton::RequestError, Relaton::NotInLocalStoreError => e
|
|
128
132
|
args[3].call e
|
|
129
133
|
rescue StandardError => e
|
|
130
134
|
# An unparseable reference ("ISO Standards Tag Set" in a
|
|
@@ -137,6 +141,14 @@ module Relaton
|
|
|
137
141
|
Util.error "`#{args[0]}` -- #{e.message}"
|
|
138
142
|
end
|
|
139
143
|
args[3].call nil
|
|
144
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
145
|
+
# The completion callback is a contract: the consumer counts
|
|
146
|
+
# exactly one delivery per fetch, and a missing one blocks it
|
|
147
|
+
# forever (metanorma-standoc CI deadlock, 2026-11-13: an
|
|
148
|
+
# RSpec::Mocks::MockExpectationError -- an Exception, not a
|
|
149
|
+
# StandardError -- escaped the rescue above).
|
|
150
|
+
Util.error "`#{args[0]}` -- #{e.class}: #{e.message}"
|
|
151
|
+
args[3].call nil
|
|
140
152
|
end
|
|
141
153
|
@queues[stdclass] =
|
|
142
154
|
{ queue: SizedQueue.new(threads * 2), workers_pool: wp }
|
|
@@ -155,14 +167,19 @@ module Relaton
|
|
|
155
167
|
# @raise [Relaton::UnknownReferenceError] no flavor is named and none
|
|
156
168
|
# recognizes the reference
|
|
157
169
|
#
|
|
170
|
+
# @raise [Relaton::NotInLocalStoreError] offline mode, and the local
|
|
171
|
+
# store has no entry for the reference (relaton#536)
|
|
172
|
+
#
|
|
158
173
|
def fetch_std(code, year = nil, stdclass = nil, opts = {})
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
offline_answer(code, opts) do |opts| # rubocop:disable Lint/ShadowingOuterLocalVariable
|
|
175
|
+
std = named_class(stdclass)
|
|
176
|
+
# A flavor the caller names is the one asked: no co-publisher.
|
|
177
|
+
next check_bibliocache(code, year, opts, std) if std
|
|
178
|
+
|
|
179
|
+
std, pubid = @registry.route(code)
|
|
180
|
+
fetch_routed(code, year, opts, std, pubid) ||
|
|
181
|
+
fetch_equivalent(code, year, opts)
|
|
182
|
+
end
|
|
166
183
|
end
|
|
167
184
|
|
|
168
185
|
# Cross-SDO equivalence as data: on a total miss, walk the equivalence
|
|
@@ -174,6 +191,9 @@ module Relaton
|
|
|
174
191
|
#
|
|
175
192
|
# @return [Relaton::Bib::ItemData, nil]
|
|
176
193
|
def fetch_equivalent(code, year, opts)
|
|
194
|
+
# `relaton/db` alone does not declare the `Relaton::Equivalence`
|
|
195
|
+
# autoload (`relaton.rb` does), so a fetch_std miss NameErrored there.
|
|
196
|
+
require_relative "equivalence"
|
|
177
197
|
Equivalence.related_for(code).each do |candidate|
|
|
178
198
|
Util.info "Not found; trying equivalent `#{candidate}`", key: code
|
|
179
199
|
std, pubid = @registry.route(candidate)
|
|
@@ -221,6 +241,24 @@ module Relaton
|
|
|
221
241
|
|
|
222
242
|
private
|
|
223
243
|
|
|
244
|
+
#
|
|
245
|
+
# Offline mode (Configuration#offline, relaton#536) answers every fetch
|
|
246
|
+
# from the local store only: the block runs with `fetch_db` set, so no
|
|
247
|
+
# path reaches a flavor's `get`, and a miss of the whole resolution
|
|
248
|
+
# (routed flavor, co-publishers, equivalents) raises. An explicit
|
|
249
|
+
# `fetch_db` keeps its nil answer.
|
|
250
|
+
#
|
|
251
|
+
# @yieldparam opts [Hash] the options to fetch with
|
|
252
|
+
# @raise [Relaton::NotInLocalStoreError]
|
|
253
|
+
#
|
|
254
|
+
def offline_answer(reference, opts)
|
|
255
|
+
return yield(opts) if opts[:fetch_db] || !Db.configuration.offline
|
|
256
|
+
|
|
257
|
+
yield(opts.merge(fetch_db: true)) or
|
|
258
|
+
raise NotInLocalStoreError.new(reference.to_s,
|
|
259
|
+
[@local_db, @db].compact.map(&:dir))
|
|
260
|
+
end
|
|
261
|
+
|
|
224
262
|
# True when the error is a flavor's grammar refusing to read the
|
|
225
263
|
# reference. Both are raised by the flavors' pubid/parslet parses; a
|
|
226
264
|
# reference they cannot read is not-found, not a failure (relaton#412).
|
|
@@ -489,30 +527,48 @@ module Relaton
|
|
|
489
527
|
return check_date_range(searchcode, id, year, opts, stdclass, query)
|
|
490
528
|
end
|
|
491
529
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
530
|
+
# The cache-only path reads, and never expires: a stale entry is
|
|
531
|
+
# still the local store's answer (relaton#536).
|
|
532
|
+
# A document in either cache answers over a not_found in the other.
|
|
533
|
+
if opts[:fetch_db]
|
|
534
|
+
entries = [db, altdb].compact.map { |c| c.read(id) }
|
|
535
|
+
return bib_retval(entries.find { |e| e.is_a?(String) }, stdclass)
|
|
536
|
+
end
|
|
495
537
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
else
|
|
505
|
-
return bib_retval(db.read(id), stdclass) if opts[:fetch_db]
|
|
538
|
+
@semaphore.synchronize { replace_stale(db, altdb, id, year) } if altdb
|
|
539
|
+
# The entry new_bib_entry answers, not a second read of `db`: a stale
|
|
540
|
+
# document served from `altdb` has no row in `db`.
|
|
541
|
+
entry = new_bib_entry(searchcode, year, opts, stdclass,
|
|
542
|
+
db: db, id: id, query: query, altdb: altdb)
|
|
543
|
+
@semaphore.synchronize { replace_stale(altdb, db, id, year) } if altdb
|
|
544
|
+
bib_retval(entry, stdclass)
|
|
545
|
+
end
|
|
506
546
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
547
|
+
# Replace the stale (or missing) row of `id` in `cache` with the valid
|
|
548
|
+
# row `other` holds. A valid row of `cache` is kept (local over global).
|
|
549
|
+
def replace_stale(cache, other, id, year)
|
|
550
|
+
return if cache.valid_entry?(id, year)
|
|
551
|
+
return unless other.valid_entry?(id, year)
|
|
552
|
+
|
|
553
|
+
cache.expire id, year
|
|
554
|
+
cache.clone_entry id, other
|
|
511
555
|
end
|
|
512
556
|
|
|
557
|
+
#
|
|
558
|
+
# A stale entry (Cache#valid_entry? is false) is refetched, but it is
|
|
559
|
+
# not deleted first: Db#fetch_entry serves it when the refetch fails
|
|
560
|
+
# with a transport error, and replaces it when the refetch answers.
|
|
561
|
+
# With no row of its own, the stale document of the other cache
|
|
562
|
+
# (`altdb:`) is the fallback. `no_cache` asks for a fresh answer, so it
|
|
563
|
+
# has no fallback.
|
|
564
|
+
#
|
|
513
565
|
def new_bib_entry(code, year, opts, stdclass, **args)
|
|
514
|
-
entry = @semaphore.synchronize
|
|
515
|
-
|
|
566
|
+
entry, valid = @semaphore.synchronize do
|
|
567
|
+
found = args[:db]&.read(args[:id])
|
|
568
|
+
[found, found && args[:db].valid_entry?(args[:id], year)]
|
|
569
|
+
end
|
|
570
|
+
if !entry || opts[:no_cache] || !valid
|
|
571
|
+
args = with_stale(args, entry) unless opts[:no_cache]
|
|
516
572
|
return fetch_entry(code, year, opts, stdclass, **args)
|
|
517
573
|
end
|
|
518
574
|
|
|
@@ -524,18 +580,47 @@ module Relaton
|
|
|
524
580
|
entry
|
|
525
581
|
end
|
|
526
582
|
|
|
527
|
-
|
|
583
|
+
# @return [Hash] args, with the `stale:` entry and the `stale_db:` cache
|
|
584
|
+
# that holds it, when there is one
|
|
585
|
+
def with_stale(args, entry)
|
|
586
|
+
stale_db = args[:db]
|
|
587
|
+
unless entry
|
|
588
|
+
stale_db = args[:altdb]
|
|
589
|
+
entry = @semaphore.synchronize { stale_db&.read(args[:id]) }
|
|
590
|
+
return args unless entry.is_a?(String)
|
|
591
|
+
end
|
|
592
|
+
args.merge(stale: entry, stale_db: stale_db)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
#
|
|
596
|
+
# @option args [String, Relaton::Db::NotFound, nil] :stale the stale
|
|
597
|
+
# entry of the key: a transport failure answers a stale document with
|
|
598
|
+
# a warning (relaton#536), and an answer replaces it
|
|
599
|
+
# @option args [Relaton::Db::Cache] :stale_db the cache that holds it
|
|
600
|
+
#
|
|
601
|
+
def fetch_entry(code, year, opts, stdclass, **args) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
528
602
|
processor = @registry[stdclass]
|
|
529
|
-
bib =
|
|
530
|
-
|
|
603
|
+
bib = begin
|
|
604
|
+
net_retry(code, year, opts, processor, opts.fetch(:retries, 1),
|
|
605
|
+
args[:query])
|
|
606
|
+
rescue Relaton::RequestError => e
|
|
607
|
+
raise unless args[:stale].is_a?(String)
|
|
608
|
+
|
|
609
|
+
Util.warn "#{e.message}; using the cached entry fetched " \
|
|
610
|
+
"#{args[:stale_db].fetched(args[:id])}", key: code
|
|
611
|
+
return args[:stale]
|
|
612
|
+
end
|
|
531
613
|
entry = bib_entry bib
|
|
532
614
|
return entry if args[:db].nil?
|
|
533
615
|
|
|
534
616
|
# `no_cache` refreshes a cached entry, but a failed fetch does not
|
|
535
|
-
# replace a cached document with a not-found entry.
|
|
617
|
+
# replace a valid cached document with a not-found entry. A stale
|
|
618
|
+
# entry is replaced by any answer; its row goes first, so a dated
|
|
619
|
+
# query's subset row does not outlive the refetch.
|
|
536
620
|
refresh = opts[:no_cache] && bib.respond_to?(:to_xml)
|
|
537
621
|
@semaphore.synchronize do
|
|
538
|
-
|
|
622
|
+
args[:db].expire args[:id], year if args[:stale]
|
|
623
|
+
if args[:stale] || refresh || !args[:db].read(args[:id])
|
|
539
624
|
save_bib args[:db], args[:id], bib, entry, stdclass
|
|
540
625
|
end
|
|
541
626
|
end
|
|
@@ -166,17 +166,27 @@ module Relaton
|
|
|
166
166
|
|
|
167
167
|
def normalize(record)
|
|
168
168
|
{
|
|
169
|
-
"ETSI deliverable" => record["ETSI_DELIVERABLE"],
|
|
170
|
-
"title" => record["TITLE"],
|
|
169
|
+
"ETSI deliverable" => sanitize(record["ETSI_DELIVERABLE"]),
|
|
170
|
+
"title" => sanitize(record["TITLE"]),
|
|
171
171
|
"Details link" => "https://webapp.etsi.org/workprogram/Report_WorkItem.asp?WKI_ID=#{record['wki_id']}",
|
|
172
172
|
"PDF link" => "https://www.etsi.org/deliver/#{record['EDSpathname']}#{record['EDSPDFfilename']}",
|
|
173
173
|
"Status" => derive_status(record),
|
|
174
|
-
"Keywords" => record["Keywords"].to_s,
|
|
175
|
-
"Technical body" => record["TB"],
|
|
176
|
-
"Scope" => record["Scope"],
|
|
174
|
+
"Keywords" => sanitize(record["Keywords"]).to_s,
|
|
175
|
+
"Technical body" => sanitize(record["TB"]),
|
|
176
|
+
"Scope" => sanitize(record["Scope"]),
|
|
177
177
|
}
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
# The upstream ETSI metadata carries stray control characters — one
|
|
181
|
+
# 1998-era record ships a literal NUL inside "sequences", which the YAML
|
|
182
|
+
# dump surfaces as a raw \0 escape and which silently truncates the
|
|
183
|
+
# api.relaton.org D1 import stream (relaton-api-js#155,
|
|
184
|
+
# relaton-data-etsi#7). Non-structural C0 controls are dropped; \t, \n
|
|
185
|
+
# and \r stay — the Scope column is hard-wrapped upstream text.
|
|
186
|
+
def sanitize(value)
|
|
187
|
+
value&.gsub(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/, "")
|
|
188
|
+
end
|
|
189
|
+
|
|
180
190
|
def derive_status(record)
|
|
181
191
|
return "Withdrawn" if record["ACTION_TYPE"] == "WD"
|
|
182
192
|
|
data/lib/relaton/jis/README.adoc
CHANGED
|
@@ -4,6 +4,8 @@ RelatonJis is a Ruby gem that implements the https://github.com/metanorma/metano
|
|
|
4
4
|
|
|
5
5
|
You can use it to retrieve metadata of JIS Standards from https://webdesk.jsa.or.jp, and access such metadata through the `Relaton::Jis::ItemData` object.
|
|
6
6
|
|
|
7
|
+
The flavor also retrieves WES standards of the Japan Welding Engineering Society (JWES), which webdesk.jsa.or.jp sells next to JIS: `Relaton::Jis::Bibliography.get "WES 7700-1:2019"`. A WES record has the docidentifier type `WES` and JWES as its author and publisher. Its doctype is the JIS value of its form (`japanese-industrial-standard`, `technical-report`, `technical-specification` or `amendment`), because the JIS grammar has no WES value.
|
|
8
|
+
|
|
7
9
|
== Usage
|
|
8
10
|
|
|
9
11
|
=== Search for standards using keywords
|
|
@@ -9,11 +9,20 @@ module Relaton
|
|
|
9
9
|
class DataFetcher < Core::DataFetcher
|
|
10
10
|
URL = "https://webdesk.jsa.or.jp/books/"
|
|
11
11
|
|
|
12
|
+
# The Webdesk lists a crawl reads, in this order: the JIS search, then
|
|
13
|
+
# the JWES group list (WES standards, relaton-jis#18). Webdesk keeps the
|
|
14
|
+
# open list in the session, and the list page and its paging
|
|
15
|
+
# (`W11M0070`) read that list. So the lists are read one after the
|
|
16
|
+
# other, and a session refresh opens the current list again.
|
|
17
|
+
LISTS = %w[JIS JWES].freeze
|
|
18
|
+
|
|
12
19
|
def initialize(output, format)
|
|
13
20
|
super
|
|
14
21
|
@queue = SizedQueue.new 10
|
|
15
22
|
@threads = create_thread_pool 5
|
|
16
23
|
@mutex = Mutex.new
|
|
24
|
+
# URLs of the documents that did not fetch; see #prune_unwritten.
|
|
25
|
+
@failed_docs = []
|
|
17
26
|
end
|
|
18
27
|
|
|
19
28
|
def log_error(msg)
|
|
@@ -72,30 +81,63 @@ module Relaton
|
|
|
72
81
|
else
|
|
73
82
|
Util.warn "URL: #{url}"
|
|
74
83
|
Util.warn "#{e.message}\n#{e.backtrace[0..6].join("\n")}"
|
|
84
|
+
@mutex.synchronize { @failed_docs << url }
|
|
75
85
|
end
|
|
76
86
|
else
|
|
77
87
|
save_doc bib, url
|
|
78
88
|
end
|
|
79
89
|
end
|
|
80
90
|
|
|
91
|
+
# relaton-data-jis crawls with `prune: true`: it keeps data/, and after
|
|
92
|
+
# #fetch, #prune_unwritten deletes each file the run did not write. So a
|
|
93
|
+
# list that does not open, or stops part way (#get_next_page), raises:
|
|
94
|
+
# the crawl job fails before the prune and publishes nothing.
|
|
81
95
|
def fetch(_source = nil)
|
|
82
|
-
|
|
96
|
+
failed = begin
|
|
97
|
+
LISTS.find { |list| !fetch_list(list) }
|
|
98
|
+
ensure
|
|
99
|
+
end_threads_and_wait
|
|
100
|
+
end
|
|
101
|
+
raise Error, "Webdesk list #{failed} did not open" if failed
|
|
83
102
|
|
|
84
|
-
resp = agent.get "#{URL}W11M0070/index"
|
|
85
|
-
parse_page resp
|
|
86
103
|
index.save
|
|
87
104
|
report_errors
|
|
88
105
|
end
|
|
89
106
|
|
|
107
|
+
# Queue every document of one list. False if the list does not open.
|
|
108
|
+
def fetch_list(list)
|
|
109
|
+
@list = list
|
|
110
|
+
@initial_time = nil
|
|
111
|
+
return false unless initial_post
|
|
112
|
+
|
|
113
|
+
parse_page agent.get("#{URL}W11M0070/index")
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Open the current list in the session, unless it was opened less than
|
|
118
|
+
# 10 minutes ago.
|
|
90
119
|
def initial_post
|
|
91
120
|
return true if @initial_time && Time.now - @initial_time < 600
|
|
92
121
|
|
|
122
|
+
status = @list == "JWES" ? open_group_list(@list) : open_jis_search
|
|
123
|
+
@initial_time = Time.now
|
|
124
|
+
status || Util.warn("No results found for #{@list}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def open_jis_search
|
|
93
128
|
body = { record: 0, dantai: "JIS", searchtype2: 1,
|
|
94
129
|
status_1: 1, status_2: 2 } # rubocop:disable Naming/VariableNumber
|
|
95
130
|
resp = agent.post "#{URL}W11M0270/index", body
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
131
|
+
Relaton::Json.parse_external(resp.body)["status"]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A group list from the "domestic group publications" page. The link
|
|
135
|
+
# redirects to the list page, which states the count of documents.
|
|
136
|
+
def open_group_list(dantai)
|
|
137
|
+
page = agent.get "#{URL}W11S0260/getSearchAllListByLink" \
|
|
138
|
+
"?dantai_cd=#{dantai}&display=KOKUNAI"
|
|
139
|
+
script = page.at_xpath('//script[contains(.,"var count =")]')
|
|
140
|
+
script&.text.to_s[/var count = (\d+);/, 1].to_i.positive?
|
|
99
141
|
end
|
|
100
142
|
|
|
101
143
|
def agent
|
|
@@ -111,7 +153,6 @@ module Relaton
|
|
|
111
153
|
|
|
112
154
|
resp = get_next_page(offset)
|
|
113
155
|
end
|
|
114
|
-
end_threads_and_wait
|
|
115
156
|
end
|
|
116
157
|
|
|
117
158
|
def parse_offset(resp) # rubocop:disable Metrics/AbcSize
|
|
@@ -135,25 +176,44 @@ module Relaton
|
|
|
135
176
|
@count.to_i
|
|
136
177
|
end
|
|
137
178
|
|
|
179
|
+
# The next page of the current list. Raises when the page does not
|
|
180
|
+
# come, because a list that stops part way leaves its unread documents
|
|
181
|
+
# unwritten, and the prune would delete them.
|
|
138
182
|
def get_next_page(offset) # rubocop:disable Metrics/MethodLength
|
|
139
183
|
attempts = 0
|
|
140
184
|
begin
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
185
|
+
unless initial_post
|
|
186
|
+
raise Error, "Webdesk list #{@list} did not open again " \
|
|
187
|
+
"at offset #{offset}"
|
|
144
188
|
end
|
|
189
|
+
|
|
190
|
+
agent.post "#{URL}W11M0070/getAddList",
|
|
191
|
+
search_type: "JIS", offset: offset
|
|
192
|
+
rescue Error
|
|
193
|
+
raise
|
|
145
194
|
rescue StandardError => e
|
|
146
195
|
attempts += 1
|
|
147
196
|
if attempts < 5
|
|
148
197
|
sleep 2
|
|
149
198
|
retry
|
|
150
|
-
else
|
|
151
|
-
Util.warn "#{e.message}\n#{e.backtrace[0..6].join("\n")}"
|
|
152
199
|
end
|
|
200
|
+
raise Error, "Webdesk list #{@list} stopped at offset " \
|
|
201
|
+
"#{offset}: #{e.message}"
|
|
153
202
|
end
|
|
154
203
|
end
|
|
155
204
|
|
|
156
|
-
|
|
205
|
+
# Prune only after a complete crawl: a document that did not fetch was
|
|
206
|
+
# not written, and the prune would delete its file.
|
|
207
|
+
def prune_unwritten(min_ratio: 0.9)
|
|
208
|
+
unless @failed_docs.empty?
|
|
209
|
+
warn "prune skipped: #{@failed_docs.size} document(s) did not fetch"
|
|
210
|
+
return []
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
super
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def save_doc(bib, url) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
157
217
|
return unless bib
|
|
158
218
|
|
|
159
219
|
id = bib.docidentifier.find(&:primary).content
|
|
@@ -175,6 +235,7 @@ module Relaton
|
|
|
175
235
|
Util.warn "File #{output_file id} already exists; writing #{file} instead" if file != output_file(id)
|
|
176
236
|
@files << file
|
|
177
237
|
File.write file, serialize(bib), encoding: "UTF-8"
|
|
238
|
+
record_written file
|
|
178
239
|
pid = pubid id
|
|
179
240
|
index.add_or_update pid, file if pid
|
|
180
241
|
end
|
|
@@ -83,7 +83,8 @@ module Relaton
|
|
|
83
83
|
|
|
84
84
|
def create_relation(hit)
|
|
85
85
|
id = hit.pubid.to_s
|
|
86
|
-
|
|
86
|
+
type = id.start_with?("WES ") ? "WES" : "JIS"
|
|
87
|
+
docid = Docidentifier.new content: id, type: type, primary: true
|
|
87
88
|
bibitem = ItemData.new(
|
|
88
89
|
formattedref: Bib::Formattedref.new(content: id),
|
|
89
90
|
docidentifier: [docid],
|
data/lib/relaton/jis/scraper.rb
CHANGED
|
@@ -15,6 +15,12 @@ module Relaton
|
|
|
15
15
|
DATETYPES = { "発行年月日" => "issued", "確認年月日" => "confirmed" }.freeze
|
|
16
16
|
STATUSES = { "有効" => "valid", "廃止" => "withdrawn" }.freeze
|
|
17
17
|
|
|
18
|
+
# WES (Japan Welding Engineering Society) standards are sold on the same
|
|
19
|
+
# site and kept in the JIS flavor (relaton-jis#18). Their pages name no
|
|
20
|
+
# drafting body, so JWES is the author and publisher.
|
|
21
|
+
JWES_NAMES = { "ja" => "一般社団法人日本溶接協会",
|
|
22
|
+
"en" => "Japan Welding Engineering Society" }.freeze
|
|
23
|
+
|
|
18
24
|
def initialize(url, errors = {})
|
|
19
25
|
@url = url
|
|
20
26
|
@agent = Mechanize.new
|
|
@@ -70,13 +76,17 @@ module Relaton
|
|
|
70
76
|
return [] if docid.nil? || docid.empty?
|
|
71
77
|
|
|
72
78
|
[Docidentifier.new(
|
|
73
|
-
content: docid, type:
|
|
79
|
+
content: docid, type: id_type, primary: true,
|
|
74
80
|
)]
|
|
75
81
|
end
|
|
76
82
|
|
|
83
|
+
# The series letter and the number: `JIS X 0208:1997` -> `X0208`,
|
|
84
|
+
# `TR Z 0010:2008` -> `Z0010`. A WES id has no series letter:
|
|
85
|
+
# `WES TR 2032:2024` -> `2032`.
|
|
77
86
|
def fetch_docnumber
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
match = document_id&.match(
|
|
88
|
+
/^(?:JIS\s)?(?:WES\s)?(?:T[RS]\s)?(?:([A-Z])\s?)?(\d+)/,
|
|
89
|
+
)
|
|
80
90
|
@errors[:docnumber] &&= match.nil?
|
|
81
91
|
return unless match
|
|
82
92
|
|
|
@@ -87,6 +97,15 @@ module Relaton
|
|
|
87
97
|
@document_id ||= @doc.at_xpath("./h2/text()[1]")&.text&.strip
|
|
88
98
|
end
|
|
89
99
|
|
|
100
|
+
def wes?
|
|
101
|
+
document_id.to_s.start_with? "WES "
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The docidentifier and structuredidentifier type.
|
|
105
|
+
def id_type
|
|
106
|
+
wes? ? "WES" : "JIS"
|
|
107
|
+
end
|
|
108
|
+
|
|
90
109
|
def fetch_date
|
|
91
110
|
result = DATETYPES.each_with_object([]) do |(key, type), a|
|
|
92
111
|
node = @doc.at_xpath("./div/div/div/p/text()[contains(.,'#{key}')]")
|
|
@@ -141,8 +160,13 @@ module Relaton
|
|
|
141
160
|
Bib::Status.new(stage: stage)
|
|
142
161
|
end
|
|
143
162
|
|
|
163
|
+
# The JIS grammar requires a doctype and has no WES value, so a WES
|
|
164
|
+
# record takes the JIS value of its form (`WES TR`/`WES TS` match the
|
|
165
|
+
# TR/TS rules).
|
|
144
166
|
def fetch_doctype # rubocop:disable Metrics/CyclomaticComplexity
|
|
145
167
|
type = case document_id
|
|
168
|
+
when %r{^WES\s\d[^/]*/AMENDMENT} then "amendment"
|
|
169
|
+
when /^WES\s\d/ then "japanese-industrial-standard"
|
|
146
170
|
when /JIS\s[A-Z]\s[\w-]+:\d{4}\/AMENDMENT/ then "amendment"
|
|
147
171
|
when /JIS\s[A-Z]\s[\w-]+/ then "japanese-industrial-standard"
|
|
148
172
|
when /TR[\s\/][\w-]+/ then "technical-report"
|
|
@@ -163,6 +187,8 @@ module Relaton
|
|
|
163
187
|
end
|
|
164
188
|
|
|
165
189
|
def fetch_contributor
|
|
190
|
+
return jwes_contributors if wes?
|
|
191
|
+
|
|
166
192
|
authorizer = create_contrib(
|
|
167
193
|
"一般財団法人 日本規格協会", "authorizer"
|
|
168
194
|
)
|
|
@@ -175,6 +201,20 @@ module Relaton
|
|
|
175
201
|
result
|
|
176
202
|
end
|
|
177
203
|
|
|
204
|
+
def jwes_contributors
|
|
205
|
+
tls = Bib::TypedLocalizedString
|
|
206
|
+
scripts = { "ja" => "Jpan", "en" => "Latn" }
|
|
207
|
+
%w[author publisher].map do |role|
|
|
208
|
+
name = JWES_NAMES.map do |lang, content|
|
|
209
|
+
tls.new content: content, language: lang, script: scripts[lang]
|
|
210
|
+
end
|
|
211
|
+
org = Bib::Organization.new name: name
|
|
212
|
+
Bib::Contributor.new(
|
|
213
|
+
organization: org, role: [Bib::Contributor::Role.new(type: role)],
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
178
218
|
def create_contrib(name, role)
|
|
179
219
|
org = Bib::Organization.new name: create_orgname(name)
|
|
180
220
|
role_obj = Bib::Contributor::Role.new(type: role)
|
|
@@ -195,7 +235,8 @@ module Relaton
|
|
|
195
235
|
|
|
196
236
|
def fetch_editorialgroup_contributor # rubocop:disable Metrics/MethodLength
|
|
197
237
|
node = @doc.at_xpath("./table/tr[th[.='原案作成団体']]/td")
|
|
198
|
-
|
|
238
|
+
# A WES page names no drafting body (see JWES_NAMES).
|
|
239
|
+
@errors[:editorialgroup] &&= node.nil? unless wes?
|
|
199
240
|
return unless node
|
|
200
241
|
|
|
201
242
|
subdivision = Bib::Subdivision.new(
|
|
@@ -215,7 +256,7 @@ module Relaton
|
|
|
215
256
|
def fetch_structuredidentifier
|
|
216
257
|
StructuredIdentifier.new(
|
|
217
258
|
project_number: Iso::ProjectNumber.new(content: fetch_docnumber),
|
|
218
|
-
type:
|
|
259
|
+
type: id_type,
|
|
219
260
|
)
|
|
220
261
|
end
|
|
221
262
|
|
|
@@ -12,6 +12,10 @@ module Relaton
|
|
|
12
12
|
class DataFetcher < Core::DataFetcher
|
|
13
13
|
HANDBOOKS_URL = "https://www.mlit.go.jp/plateau/_next/data/1.3.0/libraries/handbooks.json".freeze
|
|
14
14
|
TECHNICAL_REPORTS_URL = "https://www.mlit.go.jp/plateau/_next/data/1.3.0/libraries/technical-reports.json".freeze
|
|
15
|
+
# The Japanese volume label of a Handbook id gets an ASCII file-name
|
|
16
|
+
# suffix: #file_name strips non-word characters, so 本編 and 本編+付録
|
|
17
|
+
# of one edition would otherwise share a file.
|
|
18
|
+
VOLUME_SUFFIX = { "本編+付録" => "-with-appendix", "本編" => "-main" }.freeze
|
|
15
19
|
|
|
16
20
|
def index
|
|
17
21
|
@index ||= Relaton::Index.find_or_create(
|
|
@@ -125,6 +129,9 @@ module Relaton
|
|
|
125
129
|
Util.warn "File #{file} already exists, skipping.", key: id
|
|
126
130
|
else
|
|
127
131
|
File.write(file, serialize(item))
|
|
132
|
+
# Recorded for #prune_unwritten: the data repo crawls into a kept
|
|
133
|
+
# data/, and an unrecorded write makes every prune skip.
|
|
134
|
+
record_written file
|
|
128
135
|
@files << file
|
|
129
136
|
pid = pubid id
|
|
130
137
|
if pid
|
|
@@ -152,12 +159,14 @@ module Relaton
|
|
|
152
159
|
end
|
|
153
160
|
|
|
154
161
|
def file_name(id)
|
|
155
|
-
|
|
162
|
+
volume = id[/ (本編(?:+付録)?)\z/, 1]
|
|
163
|
+
name = id.delete_suffix(" #{volume}").gsub(/\s+/, "-").gsub(/[^\w-]+/, "").downcase
|
|
156
164
|
if id.match?(/民間活用編/)
|
|
157
165
|
name += "-private"
|
|
158
166
|
elsif id.match?(/公共活用編/)
|
|
159
167
|
name += "-public"
|
|
160
168
|
end
|
|
169
|
+
name += VOLUME_SUFFIX[volume] if volume
|
|
161
170
|
File.join(@output, "#{name}.#{@ext}")
|
|
162
171
|
end
|
|
163
172
|
|
|
@@ -23,6 +23,13 @@ module Relaton
|
|
|
23
23
|
@edition_label ||= @version["title"].to_s[/第[\d.]+版/].to_s
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Volume label of the version title, as Pubid::Plateau renders it:
|
|
27
|
+
# "本編" (main volume) or "本編+付録" (main volume and appendix). Handbook
|
|
28
|
+
# #00 edition 5.0 is published as both. ASCII "+" becomes "+".
|
|
29
|
+
def volume_label
|
|
30
|
+
@version["title"].to_s[/第[\d.]+版[ ]+(本編(?:[++]付録)?)/, 1]&.tr("+", "+")
|
|
31
|
+
end
|
|
32
|
+
|
|
26
33
|
def slug_number
|
|
27
34
|
@slug_number ||= mapped_docnumber&.[](/#(\S+)/, 1) ||
|
|
28
35
|
@entry["slug"]&.to_s&.split("_")&.first
|
|
@@ -31,7 +38,7 @@ module Relaton
|
|
|
31
38
|
def parse_docnumber
|
|
32
39
|
@errors[:hb_docnumber] &&= @entry["slug"].nil? || @entry["slug"].to_s.empty?
|
|
33
40
|
mapped_docnumber ||
|
|
34
|
-
["Handbook ##{slug_number}", edition_label].reject { |s| s.to_s.empty? }.join(" ")
|
|
41
|
+
["Handbook ##{slug_number}", edition_label, volume_label].reject { |s| s.to_s.empty? }.join(" ")
|
|
35
42
|
end
|
|
36
43
|
|
|
37
44
|
# Part label of a split edition, e.g. "(公共活用編)". Editions 1-3 of
|
|
@@ -9,16 +9,24 @@ module Relaton
|
|
|
9
9
|
# legacy Latin form (`1.0`), stripped in #to_all_editions to derive the
|
|
10
10
|
# edition-less family id from a relaton-bib docidentifier *string* (the
|
|
11
11
|
# fetched document is not a pubid). Matching itself is done on pubid objects.
|
|
12
|
-
|
|
12
|
+
# A Handbook volume label (`本編`, `本編+付録`) follows the edition, and
|
|
13
|
+
# goes with it.
|
|
14
|
+
EDITION_SUFFIX = / (?:第[\d.]+版|\d+\.\d+)(?: 本編(?:+付録)?)?$/
|
|
13
15
|
|
|
14
16
|
# An edition-less reference selects every edition of the document with
|
|
15
17
|
# pubid's subset match `pubid_ref === row`: the omitted edition matches
|
|
16
18
|
# any value, and pubid declares `annex` strict for PLATEAU, so
|
|
17
19
|
# `PLATEAU Handbook #03` does not reach `#03-1`. A reference with an
|
|
18
|
-
# edition selects that one row with `==`.
|
|
20
|
+
# edition selects that one row with `==`. pubid also declares `volume`
|
|
21
|
+
# strict, so the edition-less match compares each row without its
|
|
22
|
+
# volume (`本編`, `本編+付録`): every edition includes every volume.
|
|
19
23
|
def find
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
rows = if all_editions?
|
|
25
|
+
index.search(pubid_ref) { |row| pubid_ref === without_volume(row[:id]) }
|
|
26
|
+
else
|
|
27
|
+
index.search(pubid_ref, exact: true)
|
|
28
|
+
end
|
|
29
|
+
@array = rows.map { |row| Hit.new(row, self) }
|
|
22
30
|
self
|
|
23
31
|
end
|
|
24
32
|
|
|
@@ -49,6 +57,13 @@ module Relaton
|
|
|
49
57
|
@pubid_ref ||= ref.is_a?(String) ? ::Pubid::Plateau.parse(ref) : ref
|
|
50
58
|
end
|
|
51
59
|
|
|
60
|
+
# The row's identifier as an edition-less reference sees it. `#exclude`
|
|
61
|
+
# returns a copy, so the cached index row is untouched. Only a Handbook
|
|
62
|
+
# has a volume.
|
|
63
|
+
def without_volume(id)
|
|
64
|
+
id.respond_to?(:volume) && id.volume ? id.exclude(:volume) : id
|
|
65
|
+
end
|
|
66
|
+
|
|
52
67
|
# A reference with no edition (`PLATEAU Handbook #00`, or any Technical
|
|
53
68
|
# Report — TRs carry no edition) asks for all editions of the document.
|
|
54
69
|
def all_editions?
|
data/lib/relaton/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: relaton
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.1.
|
|
4
|
+
version: 3.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: addressable
|
|
@@ -520,14 +520,14 @@ dependencies:
|
|
|
520
520
|
requirements:
|
|
521
521
|
- - "~>"
|
|
522
522
|
- !ruby/object:Gem::Version
|
|
523
|
-
version: 2.0.
|
|
523
|
+
version: 2.0.22
|
|
524
524
|
type: :runtime
|
|
525
525
|
prerelease: false
|
|
526
526
|
version_requirements: !ruby/object:Gem::Requirement
|
|
527
527
|
requirements:
|
|
528
528
|
- - "~>"
|
|
529
529
|
- !ruby/object:Gem::Version
|
|
530
|
-
version: 2.0.
|
|
530
|
+
version: 2.0.22
|
|
531
531
|
- !ruby/object:Gem::Dependency
|
|
532
532
|
name: yeptris
|
|
533
533
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -654,6 +654,7 @@ files:
|
|
|
654
654
|
- lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup_v3.rb
|
|
655
655
|
- lib/relaton/bib/converter/bibxml/to_rfcxml_v3.rb
|
|
656
656
|
- lib/relaton/bib/converter/csl.rb
|
|
657
|
+
- lib/relaton/bib/converter/csl/from_csl.rb
|
|
657
658
|
- lib/relaton/bib/converter/ris.rb
|
|
658
659
|
- lib/relaton/bib/converter/titles.rb
|
|
659
660
|
- lib/relaton/bib/hash_parser_v1.rb
|