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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4243253e77d9ff955bee5534bb5dd4dc8ace300c68b208d9ad04b0b0243468ff
4
- data.tar.gz: a6737982c68329a90623a75dc407b78a8c6684373c8fe0b44e0b8a7a8bceeca9
3
+ metadata.gz: cf214dd5828ef52d54652cd3c01d5406527e7dd0f6be2c52447a72889e7ed3c8
4
+ data.tar.gz: 48c41d919e828654ae0ddfd8fc0c6dcf2dafc912b65b58b493964145eb936ca6
5
5
  SHA512:
6
- metadata.gz: e3a28aa82a3b155f8222eb981cf814018539dd5b6ea4f33eb308ea034970b2670749cb778a7540767c6df7b526ffe61ed2aeb1d0f8186dfd8b121dd297d25a80
7
- data.tar.gz: 4836365cff31ecc94cb9464d6c321fa8f7770d72bb40adfe963a163cd23b260a77297700596eab3c54d2eb54fbd1d76774786d4bda3f07b6765b461d4cf38f91
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
@@ -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 => e
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.
@@ -11,11 +11,19 @@ module Relaton
11
11
  end
12
12
 
13
13
  class Configuration
14
- attr_accessor :use_api, :api_host
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
- while item = @queue.pop; yield(item) end
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
- stdclass, pubid = @registry.route(reference)
72
- processor = @registry[stdclass]
73
- ref = if processor.respond_to?(:urn_to_code)
74
- processor.urn_to_code(reference)&.first
75
- else reference
76
- end
77
- ref ||= reference
78
- # The routed pubid is the parse of `reference`; a URN rewritten to a
79
- # code is parsed again by the flavor.
80
- pubid = nil unless ref == reference
81
- result = combine_doc ref, year, opts, stdclass
82
- result || fetch_routed(ref, year, opts, stdclass, pubid)
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
- std = named_class(stdclass)
160
- # A flavor the caller names is the one asked: no co-publisher.
161
- return check_bibliocache(code, year, opts, std) if std
162
-
163
- std, pubid = @registry.route(code)
164
- fetch_routed(code, year, opts, std, pubid) ||
165
- fetch_equivalent(code, year, opts)
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
- @semaphore.synchronize { db.expire id, year }
493
- if altdb
494
- return bib_retval(altdb.read(id), stdclass) if opts[:fetch_db]
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
- @semaphore.synchronize do
497
- db.clone_entry id, altdb if altdb.valid_entry? id, year
498
- end
499
- new_bib_entry(searchcode, year, opts, stdclass, db: db, id: id,
500
- query: query)
501
- @semaphore.synchronize do
502
- altdb.clone_entry(id, db) if !altdb.valid_entry?(id, year)
503
- end
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
- new_bib_entry(searchcode, year, opts, stdclass, db: db, id: id,
508
- query: query)
509
- end
510
- bib_retval(db.read(id), stdclass)
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 { args[:db]&.read(args[:id]) }
515
- if !entry || opts[:no_cache]
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
- def fetch_entry(code, year, opts, stdclass, **args) # rubocop:disable Metrics/AbcSize
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 = net_retry(code, year, opts, processor, opts.fetch(:retries, 1),
530
- args[:query])
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
- if refresh || !args[:db].read(args[:id])
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
 
@@ -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
- return unless initial_post
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
- disp = Relaton::Json.parse_external resp.body
97
- @initial_time = Time.now
98
- disp["status"] || Util.warn("No results found for JIS")
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
- if initial_post
142
- url = "#{URL}W11M0070/getAddList"
143
- agent.post url, search_type: "JIS", offset: offset
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
- def save_doc(bib, url) # rubocop:disable Metrics/MethodLength
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
- docid = Docidentifier.new content: id, type: "JIS", primary: true
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],
@@ -11,7 +11,7 @@ module Relaton
11
11
  @short = :relaton_jis
12
12
  @prefix = "JIS"
13
13
  @pubid_identifier = :Jis # Db cache key
14
- @defaultprefix = %r{^(JIS|TR)\s}
14
+ @defaultprefix = %r{^(JIS|TR|WES)\s} # WES: relaton-jis#18
15
15
  @idtype = "JIS"
16
16
  @datasets = %w[jis-webdesk]
17
17
  end
@@ -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: "JIS", primary: true,
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
- docid = document_id
79
- match = docid&.match(/^\w+\s(\w)\s?(\d+)/)
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
- @errors[:editorialgroup] &&= node.nil?
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: "JIS",
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
- name = id.gsub(/\s+/, "-").gsub(/[^\w-]+/, "").downcase
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
- EDITION_SUFFIX = / (?:第[\d.]+版|\d+\.\d+)$/
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
- @array = index.search(pubid_ref, exact: !all_editions?)
21
- .map { |row| Hit.new(row, self) }
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?
@@ -1,3 +1,3 @@
1
1
  module Relaton
2
- VERSION = "3.1.2".freeze
2
+ VERSION = "3.1.3".freeze
3
3
  end
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.2
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 00:00:00.000000000 Z
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.9
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.9
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