relaton 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/3gpp/CLAUDE.md +31 -0
  3. data/lib/relaton/3gpp/bibliography.rb +1 -1
  4. data/lib/relaton/3gpp/data_fetcher.rb +32 -9
  5. data/lib/relaton/3gpp/date.rb +13 -0
  6. data/lib/relaton/3gpp/item.rb +3 -0
  7. data/lib/relaton/3gpp/parser.rb +9 -6
  8. data/lib/relaton/bib/converter/csl.rb +3 -2
  9. data/lib/relaton/bib/model/docidentifier.rb +61 -0
  10. data/lib/relaton/bib/model/item_shared.rb +12 -1
  11. data/lib/relaton/bib.rb +2 -1
  12. data/lib/relaton/bipm/CLAUDE.md +14 -0
  13. data/lib/relaton/bsi/model/docidentifier.rb +0 -4
  14. data/lib/relaton/calconnect/data_fetcher.rb +11 -3
  15. data/lib/relaton/ccsds/data/fetcher.rb +2 -1
  16. data/lib/relaton/ccsds/model/docidentifier.rb +0 -4
  17. data/lib/relaton/cie/authors_parser.rb +78 -0
  18. data/lib/relaton/cie/data_fetcher.rb +23 -18
  19. data/lib/relaton/cie.rb +1 -0
  20. data/lib/relaton/core/data_fetcher.rb +23 -1
  21. data/lib/relaton/core.rb +7 -0
  22. data/lib/relaton/db/cache.rb +3 -3
  23. data/lib/relaton/db.rb +20 -1
  24. data/lib/relaton/doi/crossref.rb +2 -1
  25. data/lib/relaton/doi/parser.rb +3 -2
  26. data/lib/relaton/etsi/data_fetcher.rb +3 -3
  27. data/lib/relaton/gb/sec_scraper.rb +3 -3
  28. data/lib/relaton/iana/data_fetcher.rb +53 -4
  29. data/lib/relaton/iec/data_fetcher.rb +3 -2
  30. data/lib/relaton/iec/model/docidentifier.rb +0 -4
  31. data/lib/relaton/ieee/CLAUDE.md +30 -4
  32. data/lib/relaton/ieee/bibliography.rb +87 -32
  33. data/lib/relaton/ieee/data_fetcher.rb +1 -1
  34. data/lib/relaton/ieee/docidentifier.rb +37 -0
  35. data/lib/relaton/ieee/idams_parser.rb +2 -2
  36. data/lib/relaton/ieee/item.rb +3 -0
  37. data/lib/relaton/ieee/item_data.rb +7 -1
  38. data/lib/relaton/ietf/CLAUDE.md +27 -2
  39. data/lib/relaton/ietf/bibxml_parser.rb +38 -0
  40. data/lib/relaton/ietf/data_fetcher.rb +3 -30
  41. data/lib/relaton/ietf/scraper.rb +60 -3
  42. data/lib/relaton/ietf/wg_name_resolver.rb +2 -2
  43. data/lib/relaton/iho/CLAUDE.md +4 -0
  44. data/lib/relaton/iho/bibliography.rb +7 -1
  45. data/lib/relaton/index/dataset_lock.rb +4 -3
  46. data/lib/relaton/index/shard_source.rb +3 -2
  47. data/lib/relaton/index/sqlite_backend.rb +5 -3
  48. data/lib/relaton/isbn/open_library.rb +2 -1
  49. data/lib/relaton/iso/data_fetcher.rb +6 -6
  50. data/lib/relaton/iso/model/docidentifier.rb +0 -4
  51. data/lib/relaton/itu/data_fetcher.rb +2 -2
  52. data/lib/relaton/itu/hit_collection.rb +3 -3
  53. data/lib/relaton/itu/recommendation_fields.rb +2 -2
  54. data/lib/relaton/jis/data_fetcher.rb +2 -1
  55. data/lib/relaton/jis/hit_collection.rb +1 -2
  56. data/lib/relaton/jis.rb +1 -0
  57. data/lib/relaton/json.rb +21 -0
  58. data/lib/relaton/logger/channels/gh_issue.rb +3 -3
  59. data/lib/relaton/logger/formatter_json.rb +2 -1
  60. data/lib/relaton/nist/CLAUDE.md +6 -0
  61. data/lib/relaton/nist/docidentifier.rb +0 -4
  62. data/lib/relaton/nist/hit_collection.rb +1 -1
  63. data/lib/relaton/nist/pubs_export.rb +2 -1
  64. data/lib/relaton/nist.rb +1 -0
  65. data/lib/relaton/ogc/data_fetcher.rb +2 -2
  66. data/lib/relaton/omg/scraper.rb +3 -3
  67. data/lib/relaton/plateau/data_fetcher.rb +2 -1
  68. data/lib/relaton/plateau/hit_collection.rb +1 -1
  69. data/lib/relaton/plateau.rb +1 -0
  70. data/lib/relaton/un/hit_collection.rb +4 -3
  71. data/lib/relaton/version.rb +1 -1
  72. data/lib/relaton.rb +1 -0
  73. metadata +40 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c749882e825411654761a8fa93aafe143cbcc9ff3f1ab2df213af541b423274b
4
- data.tar.gz: 18424b0050827481d74048b45397610181e4b6b77f07b718bf9ab1fa589e6d6a
3
+ metadata.gz: f15745a534625b27b3d013d7b3d6edd7e0185f1bcc1c545f7b44dc6059d9d113
4
+ data.tar.gz: ac15b5259abb3c980cd6bee9d10c5f2f0ae12fea955478ca28f7da82027ab6f6
5
5
  SHA512:
6
- metadata.gz: e37f88eb3fd9f0c20ae2db7bbb08fbcd54ae7b6784ab249494c39d6d56fa452940883ecb1ad5225ec27115ea6fe5a05c518ab1d143e28566a87c111d83ce2361
7
- data.tar.gz: 79d5434ca258b521c473e88cd8afbd763bff52e10e46ce20ed8f3743679967bcf9cb51444eef9fd4cd21ceb11b32a830664e105eb92747378336aa91c2d63aa1
6
+ metadata.gz: de3f2c7182f6a2b1737a54e9168bcab856dbc81fe169f293b5b932f0224e8cb95187f506829230068ebc5308884e89a918515b03838f969c9c9870020442efff
7
+ data.tar.gz: a4bfe1d14667d8907eecd43fe880b02e7d15ceab20d2c6302c8f5c220d6c4398b3adefa056ff8815639686ad85674713c5c3a4759bd5ad57ffdd1e42b9bc39e1
@@ -154,6 +154,37 @@ guards does not depend on it. `Processor` also sets
154
154
  `@pubid_flavor = :Tgpp`, so `Core::Processor#prefixes` reads
155
155
  `Pubid::Tgpp.prefixes` (`["3GPP"]`).
156
156
 
157
+ ### Dates: published, then the Release start and end
158
+
159
+ `Parser#parse_date` emits the `published` date (CSV `Date`) first, then
160
+ `project-start` from the CSV `Project Start` column and `released` from
161
+ `Project End` (relaton-3gpp#13). #15 defines the Release end date as the date
162
+ the Release is released, and the issue thread asks for it as the release date
163
+ of the specification — hence `released`, not `project-end`. Both are the dates
164
+ of the document's **Release**, not of the document: every version in one
165
+ Release carries the same pair, so neither fits a standard type (`issued` would
166
+ date a 2004 `REL-98` version to 1999). They also stay in `ext/release`
167
+ (`project_start` / `project_end`). An empty column gives no date;
168
+ `@errors[:date]` tracks the published date only.
169
+
170
+ - **`Relaton::ThreeGpp::Date`** (`date.rb`) extends `Bib::Date`'s `type`
171
+ value list with the two types, and `Item` shadows `date` with it (as it does
172
+ `docidentifier`). Without it `validate!` rejects the types; the RelaxNG
173
+ grammar already accepts any text. The shadow covers the top-level item
174
+ only: an item nested in a `relation` (the transposition) reloads as
175
+ `Bib::ItemBase` with plain `Bib::Date`s. Nothing validates those today.
176
+ - **The constant hides Ruby's `::Date`** inside `Relaton::ThreeGpp`. Write
177
+ `::Date.parse` / `::Date.today` in this flavor; a bare `Date.today` now
178
+ raises `NoMethodError`.
179
+ - **Select the publication date by type, never by `date[0]`.**
180
+ `DataFetcher#published_date` does this for the transposition check: a row
181
+ with no `Date` still has the two Release dates, so `date.any?` no longer
182
+ means "has a publication date".
183
+ - `spec/3gpp/fixtures/bib.xml`, `bibdata.xml` and `bib.yaml` are rendered from
184
+ the `3gpp_get_document` cassette, i.e. from the published
185
+ `relaton-data-3gpp` record. They get the two dates only after that repo
186
+ re-crawls and the cassette is re-recorded — do not hand-add them.
187
+
157
188
  ## Testing
158
189
 
159
190
  - `bundle exec rake spec:3gpp` — run the 3GPP suite.
@@ -25,7 +25,7 @@ module Relaton
25
25
  return unless resp.code == "200"
26
26
 
27
27
  item = Item.from_yaml(resp.body)
28
- item.fetched = Date.today.to_s
28
+ item.fetched = ::Date.today.to_s
29
29
  item
30
30
  rescue SocketError, Timeout::Error, Errno::EINVAL, Errno::ECONNRESET,
31
31
  EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
@@ -42,7 +42,14 @@ module Relaton
42
42
  index.remove_all # if renewal
43
43
  end
44
44
  CSV.open(file, "r:bom|utf-8", headers: true, col_sep: ";").each do |row|
45
- save_doc Parser.parse(row, @errors)
45
+ # The row carries the whole record payload, so an unchanged row
46
+ # skips the parse and the write (the digest ledger). The row is
47
+ # only read when the ledger is on.
48
+ key = @digest_ledger ? "#{row[0]}/#{row['Version']}" : nil
49
+ next if key && unchanged?(key, row.to_h)
50
+
51
+ file = save_doc Parser.parse(row, @errors)
52
+ note_digest key, row.to_h, file: file if key
46
53
  end
47
54
  File.write CURRENT, @current.to_yaml, encoding: "UTF-8"
48
55
  index.save
@@ -132,7 +139,11 @@ module Relaton
132
139
  @files << file
133
140
  index_primary bib1.docidentifier&.detect(&:primary), file
134
141
  end
135
- File.write file, serialize(bib1), encoding: "UTF-8" unless bib1.nil?
142
+ return nil if bib1.nil?
143
+
144
+ File.write file, serialize(bib1), encoding: "UTF-8"
145
+ record_written file
146
+ file
136
147
  end
137
148
 
138
149
  #
@@ -210,11 +221,9 @@ module Relaton
210
221
  # @return [Array<Relaton3gpp::BibliographicItem, Boolean>] main bibliographic item,
211
222
  # related bibliographic item, true if relation has been added
212
223
  #
213
- def transposed_relation(bib, existed) # rubocop:disable Metrics/CyclomaticComplexity
214
- if (bib.date.none? && existed.date.none?) || (bib.date.any? && existed.date.none?)
215
- return [bib, existed, false]
216
- end
217
- return [existed, bib, true] if bib.date.none? && existed.date.any?
224
+ def transposed_relation(bib, existed)
225
+ return [bib, existed, false] unless published_date(existed)
226
+ return [existed, bib, true] unless published_date(bib)
218
227
 
219
228
  check_transposed_date bib, existed
220
229
  end
@@ -229,16 +238,30 @@ module Relaton
229
238
  # related bibliographic item, true if relation has been added
230
239
  #
231
240
  def check_transposed_date(bib, existed)
232
- if bib.date[0].at < existed.date[0].at
241
+ bib_date = published_date(bib).at
242
+ existed_date = published_date(existed).at
243
+ if bib_date < existed_date
233
244
  add_transposed_relation bib, existed
234
245
  [bib, existed, true]
235
- elsif bib.date[0].at > existed.date[0].at
246
+ elsif bib_date > existed_date
236
247
  add_transposed_relation existed, bib
237
248
  [existed, bib, true]
238
249
  else [bib, existed, false]
239
250
  end
240
251
  end
241
252
 
253
+ #
254
+ # The publication date. The date array also holds the Release start and
255
+ # end, which say nothing about which version came first.
256
+ #
257
+ # @param [Relaton::ThreeGpp::ItemData] bib bibliographic item
258
+ #
259
+ # @return [Relaton::ThreeGpp::Date, nil]
260
+ #
261
+ def published_date(bib)
262
+ bib.date.detect { |d| d.type == "published" }
263
+ end
264
+
242
265
  #
243
266
  # Add transposed relation
244
267
  #
@@ -0,0 +1,13 @@
1
+ module Relaton
2
+ module ThreeGpp
3
+ # A bibliographic date that also accepts the start of the document's 3GPP
4
+ # Release (`project-start`) and its end, which is the release date of the
5
+ # Release (`released`, relaton-3gpp#13). The RelaxNG grammar takes any text
6
+ # as a date type; only the Bib::Date value list needs the two extra types.
7
+ class Date < Bib::Date
8
+ RELEASE_TYPES = %w[project-start released].freeze
9
+
10
+ attribute :type, :string, values: Bib::Date.attributes[:type].options[:values] + RELEASE_TYPES
11
+ end
12
+ end
13
+ end
@@ -1,6 +1,7 @@
1
1
  require_relative "item_data"
2
2
  require_relative "ext"
3
3
  require_relative "docidentifier"
4
+ require_relative "date"
4
5
 
5
6
  module Relaton
6
7
  module ThreeGpp
@@ -13,6 +14,8 @@ module Relaton
13
14
  # Item, so this one declaration covers all three.
14
15
  attribute :docidentifier, Docidentifier, collection: true,
15
16
  initialize_empty: true
17
+ # Same shadow for the dates, which carry the Release start and end.
18
+ attribute :date, Date, collection: true, initialize_empty: true
16
19
  end
17
20
  end
18
21
  end
@@ -143,17 +143,20 @@ module Relaton
143
143
  # end
144
144
 
145
145
  #
146
- # Parse date
146
+ # Parse date. The published date comes first, then the start of the
147
+ # document's Release and its end as the release date (relaton-3gpp#13).
147
148
  #
148
- # @return [Array<RelatonBib::BibliographicDate>] date
149
+ # @return [Array<Relaton::ThreeGpp::Date>] date
149
150
  #
150
151
  def parse_date
151
152
  dates = []
152
153
  if @row["Date"]
153
- on = Date.parse(@row["Date"]).to_s
154
- dates << Bib::Date.new(type: "published", at: on)
154
+ on = ::Date.parse(@row["Date"]).to_s
155
+ dates << Date.new(type: "published", at: on)
155
156
  end
156
157
  @errors[:date] &&= dates.empty?
158
+ dates << Date.new(type: "project-start", at: project_start.to_s) if project_start
159
+ dates << Date.new(type: "released", at: project_end.to_s) if project_end
157
160
  dates
158
161
  end
159
162
 
@@ -388,12 +391,12 @@ module Relaton
388
391
 
389
392
  def project_start
390
393
  @errors[:project_start] &&= @row["Project Start"].nil? || @row["Project Start"].empty?
391
- Date.parse(@row["Project Start"]) if @row["Project Start"]
394
+ ::Date.parse(@row["Project Start"]) if @row["Project Start"]
392
395
  end
393
396
 
394
397
  def project_end
395
398
  @errors[:project_end] &&= @row["Project End"].nil? || @row["Project End"].empty?
396
- Date.parse(@row["Project End"]) if @row["Project End"]
399
+ ::Date.parse(@row["Project End"]) if @row["Project End"]
397
400
  end
398
401
  end
399
402
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require "yeptris"
4
+
4
5
 
5
6
  module Relaton
6
7
  module Bib
@@ -43,7 +44,7 @@ module Relaton
43
44
  keywords = Array(@item.keyword).map { |k| k.content.to_s }.reject(&:empty?)
44
45
  out[:keyword] = keywords unless keywords.empty?
45
46
  out[:URL] = source_uri if source_uri
46
- [out].to_json + "\n"
47
+ Yeptris::JSON.dump([out]) + "\n"
47
48
  end
48
49
 
49
50
  private
@@ -44,6 +44,67 @@ module Relaton
44
44
  def to_all_parts!; end
45
45
 
46
46
  def remove_date!; end
47
+
48
+ # Docidentifier types with an SDO-specific :external and :internal form
49
+ # (relaton#49), mapped to the pubid flavor that renders both. The
50
+ # :external form carries the publisher ("IETF RFC 8341",
51
+ # "BSI PAS 2031:2019"); the :internal form is the one the SDO uses for its
52
+ # own documents, without it ("SP 800-113" for "NIST SP 800-113"). pubid
53
+ # decides per identifier, so "BS EN ISO 8848:2021" and "CGPM RES (1889)"
54
+ # are the same in both forms. Every other type answers its content for
55
+ # both forms: pubid would drop "ISO" from an ISO identifier.
56
+ #
57
+ # The table keys on `type`, not on a flavor subclass, so it also covers a
58
+ # relation's nested `Bib::ItemData` and the `Bib::Docidentifier` objects
59
+ # BIPM and IETF build (a subclass-typed collection would make lutaml's
60
+ # YAML serializer reject those).
61
+ FORM_PUBID = {
62
+ "NIST" => :Nist,
63
+ "BIPM" => :Bipm,
64
+ "IETF" => :Ietf,
65
+ "Internet-Draft" => :Ietf,
66
+ "BSI" => :Bsi,
67
+ }.freeze
68
+
69
+ FORMS = %i[external internal].freeze
70
+
71
+ # The parsed identifier. A pubid-backed flavor subclass overrides it.
72
+ def pubid; end
73
+
74
+ # @param form [Symbol, nil] nil for the stored content, :external for the
75
+ # identifier with its publisher, :internal for the form the SDO uses
76
+ # for its own documents
77
+ # @raise [ArgumentError] for an unknown form
78
+ def to_s(form: nil)
79
+ return content.to_s unless form
80
+
81
+ form = form.to_sym
82
+ unless FORMS.include?(form)
83
+ raise ArgumentError, "unknown identifier form: #{form.inspect}"
84
+ end
85
+
86
+ form_content(form).to_s
87
+ end
88
+
89
+ private
90
+
91
+ # pubid renders the form (`to_s(with_publisher:)`, pubid#620). A
92
+ # docidentifier with no parsed pubid (a plain `Bib::Docidentifier`) is
93
+ # parsed here; content that pubid cannot parse is returned unchanged.
94
+ def form_content(form)
95
+ flavor = FORM_PUBID[type]
96
+ id = flavor && (pubid || parse_form_pubid(flavor))
97
+ id ? id.to_s(with_publisher: form == :external) : content
98
+ end
99
+
100
+ def parse_form_pubid(flavor)
101
+ return if content.to_s.empty?
102
+
103
+ require "pubid"
104
+ ::Pubid.const_get(flavor).parse(content)
105
+ rescue StandardError
106
+ nil
107
+ end
47
108
  end
48
109
  end
49
110
  end
@@ -59,10 +59,21 @@ module Relaton
59
59
  def self.prune_attribute(base, attr_name, xml_name)
60
60
  return unless base.attributes.key?(attr_name)
61
61
 
62
+ # lutaml-model 0.8.142 shares a subclass's mappings/attributes with
63
+ # its superclass until the class's own DSL forks them (copy-on-write).
64
+ # These deletes must stay private to `base`: without the fork, pruning
65
+ # Bibdata's :id erased it from Bibitem (and Bibitem's :ext from
66
+ # Bibdata), and every from_xml raised "Attribute ... not found".
67
+ # 0.8.141 and older give each class private copies and have no
68
+ # lutaml_cow_fork!, where mutating in place was already safe.
69
+ if base.respond_to?(:lutaml_cow_fork!)
70
+ base.lutaml_cow_fork!(:mappings)
71
+ base.lutaml_cow_fork!(:attributes)
72
+ end
62
73
  xml_mapping = base.mappings[:xml]
63
74
  xml_mapping.instance_variable_get(:@elements).delete(xml_name)
64
75
  xml_mapping.instance_variable_get(:@attributes).delete(xml_name)
65
- base.attributes.delete(attr_name)
76
+ base.class_attributes.delete(attr_name)
66
77
  end
67
78
 
68
79
  XML_BODY = lambda do # rubocop:disable Metrics/BlockLength
data/lib/relaton/bib.rb CHANGED
@@ -5,6 +5,7 @@ require "bibtex"
5
5
  require "iso639"
6
6
  require "rfcxml"
7
7
  require "relaton/core"
8
+ require "yeptris"
8
9
  require_relative "version"
9
10
  require_relative "bib/util"
10
11
  require_relative "bib/sanitizer"
@@ -41,7 +42,7 @@ module Relaton
41
42
  # @return [Hash{String=>String}] schema versions
42
43
  #
43
44
  def schema_versions
44
- @@schema_versions ||= JSON.parse File.read(File.join(__dir__, "bib/versions.json"))
45
+ @@schema_versions ||= Yeptris::JSON.load File.read(File.join(__dir__, "bib/versions.json"))
45
46
  end
46
47
  end
47
48
 
@@ -144,6 +144,20 @@ demonstrated rather than argued.
144
144
  builds `index-v1` with them, and `id_parser_spec.rb` still covers them. Only
145
145
  `bibliography.rb` stopped requiring the file.
146
146
 
147
+ ### Internal identifier
148
+
149
+ `docid.to_s(form: :internal)` drops the
150
+ `BIPM` publisher (relaton#49): `BIPM SI Brochure Part 1` →
151
+ `SI Brochure Part 1`. Committee ids (`CGPM RES (1889)`, `CCTF REC 2 (2009)`)
152
+ have no publisher token and are the same in both forms. The rule is the `"BIPM"` row of
153
+ `Bib::Docidentifier::FORM_PUBID`: BIPM docidentifiers carry no pubid,
154
+ so the content is parsed with `Pubid::Bipm` at call time and rendered with
155
+ `to_s(with_publisher: false)`; unparseable content is returned unchanged. It
156
+ also holds for a relation's nested item. Do not move it into a `Bipm::Docidentifier`
157
+ subclass: typing the item's `docidentifier` collection with a subclass makes
158
+ YAML serialization reject the `Bib::Docidentifier` objects the parsers build
159
+ (`IncorrectModelError`).
160
+
147
161
  ### Data Sources
148
162
 
149
163
  The gem fetches from three external datasets:
@@ -44,10 +44,6 @@ module Relaton
44
44
  c if c.is_a?(::Pubid::Bsi::Identifier) || c.is_a?(::Pubid::AllParts)
45
45
  end
46
46
 
47
- def to_s
48
- content.to_s
49
- end
50
-
51
47
  # Strip the publication date to build a most-recent (undated) reference.
52
48
  # pubid's `exclude` propagates into nested identifiers (so a consolidated
53
49
  # base date is dropped while the amendment is kept); `:month` is excluded
@@ -1,6 +1,6 @@
1
+ require "yeptris"
1
2
  # frozen_string_literal:true
2
3
 
3
- require "json"
4
4
  require "mechanize"
5
5
  require "relaton/core"
6
6
  require "relaton/index"
@@ -54,7 +54,7 @@ module Relaton::Calconnect
54
54
  # 304 Not Modified — nothing changed since the last fetch
55
55
  return if resp.code == "304"
56
56
 
57
- data = JSON.parse resp.body
57
+ data = Relaton::Json.parse_external resp.body
58
58
  all_success = true
59
59
  Array(data["documents"]).each { |doc| all_success &&= parse_page doc }
60
60
  self.etag = resp.response["etag"] if all_success
@@ -70,8 +70,14 @@ module Relaton::Calconnect
70
70
  # @param [Hash] doc
71
71
  #
72
72
  def parse_page(doc)
73
+ # The document payload carries the whole record, so an unchanged
74
+ # payload skips the scrape and the write (the digest ledger). The
75
+ # payload is only read when the ledger is on.
76
+ return true if @digest_ledger && unchanged?(doc["id"], doc)
77
+
73
78
  bib = Scraper.new(@errors).parse_page doc
74
- write_doc doc["id"], bib
79
+ file = write_doc doc["id"], bib
80
+ note_digest doc["id"], doc, file: file if @digest_ledger
75
81
  true
76
82
  rescue StandardError => e
77
83
  Util.warn "Document: #{doc['id']}"
@@ -95,7 +101,9 @@ module Relaton::Calconnect
95
101
  # Write first, index second: an id pubid rejects is skipped from the
96
102
  # index, and the document still has to reach disk.
97
103
  File.write file, serialize(bib), encoding: "UTF-8"
104
+ record_written file
98
105
  add_to_index bib, file
106
+ file
99
107
  end
100
108
 
101
109
  #
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "json"
2
3
  require "mechanize"
3
4
  require "relaton/index"
@@ -43,7 +44,7 @@ module Relaton
43
44
  #
44
45
  def fetch_docs(url)
45
46
  resp = agent.get(url)
46
- json = JSON.parse resp.body.match(/const config = (.*);/)[1]
47
+ json = Relaton::Json.parse_external resp.body.match(/const config = (.*);/)[1]
47
48
  @array = json["data"].map { |doc| parse_and_save doc, json["data"] }
48
49
  end
49
50
 
@@ -60,10 +60,6 @@ module Relaton
60
60
  original_content
61
61
  end
62
62
 
63
- def to_s
64
- content.to_s
65
- end
66
-
67
63
  def to_all_parts!
68
64
  return if !@pubid || @pubid.all_parts?
69
65
 
@@ -0,0 +1,78 @@
1
+ module Relaton
2
+ module Cie
3
+ # CIE publishes its author lists as one inconsistently formatted string:
4
+ # "Surname, I.X." pairs, bare "Forename Surname" runs, Chinese-style
5
+ # "Surname Given" without a comma, compound surnames ("Nilsson
6
+ # Tengelin"), broken hyphens ("Dam- Hansen"), " and " before the final
7
+ # author, and "et al." tails (relaton-cie#5). The comma is overloaded --
8
+ # it separates authors and marks the surname/forename boundary -- so the
9
+ # parse walks comma-separated segments with three rules:
10
+ #
11
+ # 1. a single-word segment binds the following segment as its forenames
12
+ # ("Hemphälä, Hillevi"; initials "A.Sh." bind the same way);
13
+ # 2. a multi-word segment followed by an initials-only segment binds
14
+ # them ("Nilsson Tengelin, M."), because initials never stand alone;
15
+ # 3. any other segment is one author kept whole (completename) --
16
+ # "Glimne Susanne", "Annika K. Jägerbrand", "Pan Jiangen": which word
17
+ # is the surname would be a guess, so none is made.
18
+ module AuthorsParser
19
+ Author = Struct.new(:surname, :forenames, :completename)
20
+
21
+ # Uppercase-led, dot/hyphen/space-separated short parts only:
22
+ # "A.Sh.", "H.T.B", "W-T. M.", "N." -- never "Li" (lowercase tail),
23
+ # "Guillaume" (long undotted part) or "Bierman".
24
+ INITIALS_RX = /\A\p{Lu}(?:[.\s-]+\p{Lu}\p{Ll}{0,2})*\.?\z/.freeze
25
+
26
+ class << self
27
+ # @param string [String] the CIE authors string
28
+ # @return [Array<Author>]
29
+ def parse(string)
30
+ authors = []
31
+ segments = segmentize(string)
32
+ i = 0
33
+ while i < segments.length
34
+ seg = segments[i]
35
+ nxt = segments[i + 1]
36
+ i += if initials?(seg)
37
+ # Defensive: a stray initials segment binds left.
38
+ authors.last&.forenames << seg
39
+ 1
40
+ elsif single_word?(seg) || nxt && initials?(nxt)
41
+ # "Surname, Forename": a single-word segment binds
42
+ # whatever follows; a multi-word one only binds trailing
43
+ # initials, which never stand alone.
44
+ authors << Author.new(seg, nxt ? [nxt] : [], nil)
45
+ nxt ? 2 : 1
46
+ else
47
+ authors << Author.new(nil, nil, seg)
48
+ 1
49
+ end
50
+ end
51
+ authors
52
+ end
53
+
54
+ # @param seg [String]
55
+ # @return [Boolean]
56
+ def single_word?(seg)
57
+ !seg.include?(" ")
58
+ end
59
+
60
+ # @param seg [String]
61
+ # @return [Boolean]
62
+ def initials?(seg)
63
+ INITIALS_RX.match? seg
64
+ end
65
+
66
+ private
67
+
68
+ def segmentize(string)
69
+ string
70
+ .gsub(/(\p{L})- (?=\p{Lu})/, '\1-')
71
+ .gsub(%r{\s*,?\s+and\s+}i, ", ")
72
+ .sub(/\s*,?\s*et\s+al\.?\s*\z/i, "")
73
+ .split(/\s*[,;]\s*/).map(&:strip).reject(&:empty?)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -329,28 +329,21 @@ module Relaton
329
329
  def fetch_contributor(doc) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/CyclomaticComplexity
330
330
  authors = doc.xpath('//hgroup/p[not(@class="pub_date")]').text.gsub "\"", ""
331
331
  contribs = []
332
- until authors.empty?
333
- /^(?<sname1>\S+(?:\sder?\s)?[^\s,]+)
334
- (?:,?\s(?<sname2>[\w-]{2,})(?=,\s+\w\.))?
335
- (?:,?\s(?<fname>W-T\.[\w-]{2,})(?!,\s+\w\.))?
336
- (?:(?:\s?,\s?|\s)(?<init>(?:\w(?:\s?\.|\s|,|$)[\s-]?)+))?
337
- (?:(?:[,;]\s*|\s+|\.|(?<=\s))(?:and\s)?)?/x =~ authors
338
- raise StandardError, "Author name not found in \"#{authors}\"" unless $LAST_MATCH_INFO
339
-
340
- authors.sub! $LAST_MATCH_INFO.to_s, ""
341
- sname = [sname1, sname2].compact.join " "
342
- surname = Bib::LocalizedString.new content: sname, language: "en", script: "Latn"
343
- forename = []
344
- forename << Bib::FullNameType::Forename.new(content: fname, language: "en", script: "Latn") if fname
345
- (init&.strip || "").split(/(?:,|\.)(?:-|\s)?/).each do |int|
346
- forename << Bib::FullNameType::Forename.new(content: "", initial: int.strip, language: "en", script: "Latn")
347
- end
348
- fullname = Bib::FullName.new surname: surname, forename: forename
332
+ AuthorsParser.parse(authors).each do |author|
333
+ fullname =
334
+ if author.completename
335
+ Bib::FullName.new completename: name_string(author.completename)
336
+ else
337
+ Bib::FullName.new(
338
+ surname: name_string(author.surname),
339
+ forename: author.forenames.map { |f| forename(f) },
340
+ )
341
+ end
349
342
  person = Bib::Person.new name: fullname
350
343
  role = Bib::Contributor::Role.new type: "author"
351
344
  contribs << Bib::Contributor.new(person: person, role: [role])
352
- @errors[:contributor_author] &&= contribs.empty?
353
345
  end
346
+ @errors[:contributor_author] &&= contribs.empty?
354
347
  org_name = Bib::TypedLocalizedString.new(content: "Commission Internationale de L'Eclairage")
355
348
  abbrev = Bib::LocalizedString.new content: "CIE"
356
349
  org_uri = Bib::Uri.new content: "cie.co.at"
@@ -359,6 +352,18 @@ module Relaton
359
352
  contribs << Bib::Contributor.new(organization: org, role: [org_role])
360
353
  end
361
354
 
355
+ def name_string(str)
356
+ Bib::LocalizedString.new content: str, language: "en", script: "Latn"
357
+ end
358
+
359
+ def forename(str)
360
+ if AuthorsParser.initials?(str)
361
+ Bib::FullNameType::Forename.new(initial: str.sub(/\.\z/, ""))
362
+ else
363
+ Bib::FullNameType::Forename.new(content: str)
364
+ end
365
+ end
366
+
362
367
  def fetch_ext
363
368
  Ext.new(doctype: fetch_doctype, flavor: "cie")
364
369
  end
data/lib/relaton/cie.rb CHANGED
@@ -5,6 +5,7 @@ require "relaton/index"
5
5
  require "relaton/bib"
6
6
  # require "relaton_bib/name_parser"
7
7
  require_relative "version"
8
+ require_relative "cie/authors_parser"
8
9
  require_relative "cie/util"
9
10
  require_relative "cie/item_data"
10
11
  require_relative "cie/item"
@@ -1,3 +1,4 @@
1
+ require "yeptris"
1
2
  require "fileutils"
2
3
 
3
4
  module Relaton
@@ -36,6 +37,17 @@ module Relaton
36
37
  # @return [Boolean] whether this run prunes the existing output tree
37
38
  attr_accessor :prune
38
39
 
40
+ # Record a file this run wrote through a flavor's own writer, so the
41
+ # ledger's save (which keeps only files this run owned) and
42
+ # #prune_unwritten account for it. Core's write_unique records its
43
+ # own writes; a flavor writing directly calls this after File.write.
44
+ #
45
+ # @param file [String]
46
+ # @return [void]
47
+ def record_written(file)
48
+ @written << file
49
+ end
50
+
39
51
  # @return [Boolean] whether this run consults and maintains the
40
52
  # content-digest ledger (see #unchanged?). Off by default: a flavor
41
53
  # turns it on only where its enumeration entry determines the record
@@ -91,6 +103,16 @@ module Relaton
91
103
  ledger_new[key] = { "digest" => entry_digest(entry), "file" => file }
92
104
  end
93
105
 
106
+ # The file a previous run recorded for `key`, for a caller that
107
+ # skipped the record via #unchanged? and still needs to know where it
108
+ # lives (an alias pointing at another record's file).
109
+ #
110
+ # @param key [String]
111
+ # @return [String, nil]
112
+ def ledger_file(key)
113
+ ledger.dig(key, "file")
114
+ end
115
+
94
116
  # Persist the ledger: this run's noted entries over the loaded ones,
95
117
  # keeping only entries whose file this run recorded as written.
96
118
  #
@@ -340,7 +362,7 @@ module Relaton
340
362
  def entry_digest(entry)
341
363
  require "digest"
342
364
  require "json"
343
- Digest::SHA256.hexdigest JSON.generate(entry)
365
+ Digest::SHA256.hexdigest Yeptris::JSON.dump(entry)
344
366
  end
345
367
 
346
368
  # Write, clobbering whatever is there. The caller has already established
data/lib/relaton/core.rb CHANGED
@@ -1,4 +1,11 @@
1
1
  require "relaton/logger"
2
+
3
+ module Relaton
4
+ # Registered here as well as in the root entry: flavor files and their
5
+ # specs load directly via require "relaton/<flavor>", never through
6
+ # relaton.rb, and the boundary parsers need this constant either way.
7
+ autoload :Json, "relaton/json"
8
+ end
2
9
  require_relative "version"
3
10
  require_relative "core/request_error"
4
11
  require_relative "core/array_wrapper"