relaton 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c749882e825411654761a8fa93aafe143cbcc9ff3f1ab2df213af541b423274b
4
- data.tar.gz: 18424b0050827481d74048b45397610181e4b6b77f07b718bf9ab1fa589e6d6a
3
+ metadata.gz: f81699e1df94f1c570f01c07b4114492b7cc5ae1e45715a4ced3ef51c1ba3fb6
4
+ data.tar.gz: 1ea38d455d16b512807a505bd558757ea0509a9392cf41c120f3fc10801beb87
5
5
  SHA512:
6
- metadata.gz: e37f88eb3fd9f0c20ae2db7bbb08fbcd54ae7b6784ab249494c39d6d56fa452940883ecb1ad5225ec27115ea6fe5a05c518ab1d143e28566a87c111d83ce2361
7
- data.tar.gz: 79d5434ca258b521c473e88cd8afbd763bff52e10e46ce20ed8f3743679967bcf9cb51444eef9fd4cd21ceb11b32a830664e105eb92747378336aa91c2d63aa1
6
+ metadata.gz: 4d27294461f66031023053e1ec70a4c26b0c8fa068e6ed9d1224d1ab9c6cb366a2b2d8f85cd50305c4826733ab028817f70b96aebc267346d224b9be1fdf29be
7
+ data.tar.gz: a3c00ef5a93352f08a492624b531955494c8530a6b0d68f01dc748d58d8599304fa9ac743d157cfd2a6cc37b63664c781da75de5e199de70035ed133e70d0305
@@ -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
@@ -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
@@ -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
  #
@@ -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"
@@ -36,6 +36,17 @@ module Relaton
36
36
  # @return [Boolean] whether this run prunes the existing output tree
37
37
  attr_accessor :prune
38
38
 
39
+ # Record a file this run wrote through a flavor's own writer, so the
40
+ # ledger's save (which keeps only files this run owned) and
41
+ # #prune_unwritten account for it. Core's write_unique records its
42
+ # own writes; a flavor writing directly calls this after File.write.
43
+ #
44
+ # @param file [String]
45
+ # @return [void]
46
+ def record_written(file)
47
+ @written << file
48
+ end
49
+
39
50
  # @return [Boolean] whether this run consults and maintains the
40
51
  # content-digest ledger (see #unchanged?). Off by default: a flavor
41
52
  # turns it on only where its enumeration entry determines the record
@@ -91,6 +102,16 @@ module Relaton
91
102
  ledger_new[key] = { "digest" => entry_digest(entry), "file" => file }
92
103
  end
93
104
 
105
+ # The file a previous run recorded for `key`, for a caller that
106
+ # skipped the record via #unchanged? and still needs to know where it
107
+ # lives (an alias pointing at another record's file).
108
+ #
109
+ # @param key [String]
110
+ # @return [String, nil]
111
+ def ledger_file(key)
112
+ ledger.dig(key, "file")
113
+ end
114
+
94
115
  # Persist the ledger: this run's noted entries over the loaded ones,
95
116
  # keeping only entries whose file this run recorded as written.
96
117
  #
@@ -13,12 +13,14 @@ module Relaton
13
13
  end
14
14
 
15
15
  def fetch(_source = nil)
16
+ docs = []
16
17
  Dir["iana-registries/**/*.xml"].each do |file|
17
18
  content = File.read file, encoding: "UTF-8"
18
- parse(content) if content.include? "<registry"
19
+ collect content, docs if content.include? "<registry"
19
20
  rescue StandardError => e
20
21
  Util.error "Error: #{e.message}. File: #{file}"
21
22
  end
23
+ write_docs docs
22
24
  index.save
23
25
  report_errors
24
26
  end
@@ -50,12 +52,57 @@ module Relaton
50
52
  Util.warn "Skipping index entry for `#{docnumber}` (#{file}): #{e.message}"
51
53
  end
52
54
 
53
- def parse(content)
55
+ # A registry element with no <record> children carries no registry data
56
+ # of its own: IANA wraps some registries in a same-titled <hide/> page
57
+ # (the language-tag-extensions wrapper around its "-1" child), and
58
+ # republishes child registries as standalone pages whose XML is empty
59
+ # (rpki-signed-objects). The saved record for such an element is a
60
+ # slug-titled, dateless duplicate of the registry that holds the
61
+ # records (relaton-iana#8, #9), so it never becomes a document: when a
62
+ # record-bearing document of the same registry id exists, it becomes an
63
+ # index alias to that document's file; otherwise it is dropped.
64
+ RegistryDoc = Struct.new(:bib, :empty, :source)
65
+
66
+ def collect(content, docs)
54
67
  xml = Moxml.parse(content)
55
68
  registry = xml.at_xpath("/xmlns:registry", NS)
56
69
  doc = Parser.parse registry, nil, @errors
57
- save_doc doc
58
- registry.xpath("./xmlns:registry", NS).each { |r| save_doc Parser.parse(r, doc, @errors) }
70
+ docs << RegistryDoc.new(doc, recordless?(registry), content) if doc
71
+ registry.xpath("./xmlns:registry", NS).each do |r|
72
+ child = Parser.parse r, doc, @errors
73
+ docs << RegistryDoc.new(child, recordless?(r), content) if child
74
+ end
75
+ end
76
+
77
+ def recordless?(registry_el)
78
+ registry_el.xpath("./xmlns:record", NS).empty?
79
+ end
80
+
81
+ def write_docs(docs)
82
+ saved = {}
83
+ docs.each do |d|
84
+ if d.empty
85
+ Util.warn "Registry #{d.bib.docnumber} has no records; skipping"
86
+ next
87
+ end
88
+ # The fetched XML content bounds the registry's record, so an
89
+ # unchanged source skips the parse and the write (the digest
90
+ # ledger); the aliased file still has to resolve for the alias
91
+ # phase. The content is only digested when the ledger is on.
92
+ if @digest_ledger && d.source && unchanged?(d.bib.docnumber, d.source)
93
+ saved[d.bib.id] = ledger_file(d.bib.docnumber)
94
+ next
95
+ end
96
+ file = save_doc d.bib
97
+ note_digest d.bib.docnumber, d.source, file: file if @digest_ledger && d.source
98
+ saved[d.bib.id] = file if file
99
+ end
100
+ docs.select(&:empty).each do |d|
101
+ next unless (file = saved[d.bib.id])
102
+
103
+ Util.warn "Registry #{d.bib.docnumber} has no records; aliasing #{file}"
104
+ add_to_index d.bib.docnumber, file
105
+ end
59
106
  end
60
107
 
61
108
  #
@@ -83,6 +130,8 @@ module Relaton
83
130
  @files << file
84
131
  add_to_index bib.docnumber, file
85
132
  File.write file, serialize(bib), encoding: "UTF-8"
133
+ record_written file
134
+ file
86
135
  end
87
136
 
88
137
  def to_xml(bib)
@@ -24,10 +24,36 @@ serialized to the `_type: pubid:ieee:*` (lutaml) form, keyed on the index via
24
24
  loss is never silent. `DataFetcher.fetch` (doc generation) is unchanged — the
25
25
  data repo (`relaton-data-ieee`) calls `build_index` after obtaining `data/`.
26
26
  - **Read side.** `bibliography.rb#search` passes `pubid_class:` to `find_or_create`,
27
- parses the query with `parse_pubid` (falling back to the raw String on failure),
28
- passes the pubid to `index.search` for number-narrowing, and picks the row by
29
- `row[:id].to_s`. `processor.rb#remove_index_file` passes `url: true` and `file:`
30
- only: the delete never reads the index (see `lib/relaton/index/CLAUDE.md`).
27
+ parses the query with `parse_pubid` (falling back to the raw String on failure)
28
+ and passes the pubid to `index.search` for number-narrowing. A parsed query
29
+ selects its rows by **pubid equality with the year aside**
30
+ (`id.exclude(:year) == pubid.exclude(:year)`), so a redline, a draft, a
31
+ corrigendum and another number (`1003.10` for `1003.1`) never match. A dated
32
+ query (its own year, or the `year` argument) takes that year; an **undated**
33
+ query takes the **latest** edition, and `get` returns it as
34
+ `to_most_recent_reference` (undated IEEE docids, no date, an `instanceOf`
35
+ relation to the edition; `opts[:keep_year]` keeps it dated) — relaton-ieee#29.
36
+ Before this, a substring match + `min_by(to_s)` answered `IEEE 528` with the
37
+ oldest edition, `528-1994`. When equality finds nothing (an unparseable query,
38
+ or one of the ~4% of forms whose year pubid keeps elsewhere, e.g.
39
+ `IEEE Std 1003.1-1988/INT-1992`, the corrigendum's legacy base year), the old
40
+ substring match is the fallback. `processor.rb#remove_index_file` passes
41
+ `url: true` and `file:` only: the delete never reads the index (see
42
+ `lib/relaton/index/CLAUDE.md`).
43
+ - **Why not `pubid === row`.** pubid declares no `subset_strict` attribute for
44
+ IEEE, so a nil `parts`, `prefix`, `draft` or `draft_status` in the query is a
45
+ wildcard: over the published index (12,616 rows) `===` let `IEEE 802.3` match
46
+ `802.3.1-2011` and `IEEE 754` match `IEEE Unapproved P754/D9.0`. Switch to
47
+ `index.search(pubid)` once pubid makes those strict, and re-measure `===`
48
+ against the equality over the full index first.
49
+ - **`Relaton::Ieee::Docidentifier`.** `Item` holds this class (the producer
50
+ creates it too: `IdamsParser#docidentifier`, `DataFetcher`), because
51
+ `Bib::Docidentifier#remove_date!` is a no-op. Its `remove_date!` re-renders an
52
+ `IEEE` docid through pubid without the year (`to_s(trademark: true)` for the
53
+ `scope: trademark` one) and leaves an id **with no year** untouched: a
54
+ re-render would change a draft's spelling (`P802.5t™/D-2.5` →
55
+ `P802.5t®/D2.5`). `ItemData#to_most_recent_reference` drops the ISBN and
56
+ DOI, which name one edition.
31
57
 
32
58
  ## Identifier resolution (`RawbibIdParser`, pubid-first)
33
59
 
@@ -9,33 +9,12 @@ module Relaton
9
9
  #
10
10
  # @param ref [String, Pubid::Ieee::Identifier] the reference, or its
11
11
  # parse from Relaton::Db (relaton#205)
12
+ # @param year [String, nil] the year, when the reference has none
12
13
  #
13
14
  # @return [Relaton::Ieee::ItemData, nil]
14
15
  #
15
- def search(ref) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
16
- # The machine index on the Pages site (relaton#206, the W3C
17
- # pilot's contract): a parsed query reads only its own shard. A
18
- # Pages failure raises `Relaton::RequestError`; the whole-index
19
- # zip on the same site serves only the retry on a miss.
20
- index = Relaton::Index.find_or_create :ieee, pages_url: PAGES_URL,
21
- pubid_class: ::Pubid::Ieee::Identifier
22
- # Pass the parsed pubid (not the raw String) so index-v2 narrows
23
- # candidates by number via binary search before the block runs; the
24
- # block keeps the broad substring match the string index gave, and an
25
- # unparseable/partial ref falls back to the full-scan String search.
26
- # Rows are Pubid::Ieee::Identifier objects (not Comparable), so pick by
27
- # the string form.
28
- pubid = parse_pubid ref
29
- needle = pubid.to_s
30
- # pubid's normalization moves a legacy base year into its own
31
- # comma segment ("IEEE P802.16/D5, 2004/Cor. 1-2005"); the
32
- # index rows carry the canonical form without it, so the
33
- # block also matches with the moved year stripped
34
- row = index.search(pubid) do |r|
35
- id = r[:id].to_s
36
- id.include?(needle) ||
37
- id.include?(needle.gsub(/, (19|20)\d{2}\//, "/"))
38
- end.min_by { |r| r[:id].to_s }
16
+ def search(ref, year = nil)
17
+ row = find_row parse_pubid(ref), year
39
18
  return unless row
40
19
 
41
20
  resp = Faraday.get "#{GH_URL}#{row[:file]}"
@@ -47,30 +26,106 @@ module Relaton
47
26
  end
48
27
 
49
28
  #
50
- # Get IEEE bibliography item by reference.
29
+ # Get IEEE bibliography item by reference. An undated reference
30
+ # answers with the latest edition as an undated item that has an
31
+ # `instanceOf` relation to that edition (relaton-ieee#29).
51
32
  #
52
33
  # @param ref [String, Pubid::Ieee::Identifier] the IEEE standard
53
34
  # reference to look up (e.g. "IEEE 528-2019"), or its parse from
54
35
  # Relaton::Db (relaton#205)
55
36
  # @param year [String] the year the standard was published (optional)
56
37
  # @param opts [Hash] options
38
+ # @option opts [Boolean] :keep_year keep the edition's date on an
39
+ # undated reference
57
40
  #
58
41
  # @return [Relaton::Ieee::ItemData, nil]
59
42
  #
60
- def get(ref, _year = nil, _opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
43
+ def get(ref, year = nil, opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
61
44
  Util.info "Fetching from Relaton repository ...", key: ref.to_s
62
- item = search(ref)
63
- if item
64
- Util.info "Found: `#{item.docidentifier.first.content}`", key: ref.to_s
65
- item
66
- else
45
+ item = search(ref, year)
46
+ unless item
67
47
  Util.info "Not found.", key: ref.to_s
68
- nil
48
+ return
69
49
  end
50
+
51
+ Util.info "Found: `#{item.docidentifier.first.content}`", key: ref.to_s
52
+ return item if opts[:keep_year] || dated?(parse_pubid(ref), year)
53
+
54
+ item.to_most_recent_reference
70
55
  end
71
56
 
72
57
  private
73
58
 
59
+ # The index row of the reference. A parsed reference matches the rows
60
+ # of its own document by pubid equality, the year aside; an undated
61
+ # one takes the latest edition. When that finds nothing (an
62
+ # unparseable reference, or a form whose year pubid keeps elsewhere)
63
+ # the broad substring match is the fallback.
64
+ #
65
+ # @param pubid [Pubid::Ieee::Identifier, String]
66
+ # @param year [String, nil]
67
+ # @return [Hash, nil] the `{ id:, file: }` row
68
+ def find_row(pubid, year)
69
+ index = Relaton::Index.find_or_create :ieee, pages_url: PAGES_URL,
70
+ pubid_class: ::Pubid::Ieee::Identifier
71
+ row = pubid_row(index, pubid, year) unless pubid.is_a?(String)
72
+ row || substring_row(index, pubid)
73
+ end
74
+
75
+ # Not `pubid === row`: IEEE `parts`, `prefix`, `draft` and
76
+ # `draft_status` are not `subset_strict` in pubid, so a nil there is a
77
+ # wildcard and `IEEE 802.3` would match `IEEE Std 802.3.1-2011` and
78
+ # `IEEE Unapproved P802.3/D2.0`.
79
+ def pubid_row(index, pubid, year)
80
+ target = (pubid.year || year)&.to_s
81
+ base = pubid.exclude(:year)
82
+ rows = index.search(pubid) do |r|
83
+ id = r[:id]
84
+ id.is_a?(::Pubid::Ieee::Identifier) && id.exclude(:year) == base &&
85
+ (target.nil? || id.year.to_s == target)
86
+ end
87
+ return rows.min_by { |r| r[:id].to_s } if target
88
+
89
+ rows.max_by { |r| [r[:id].year.to_s, r[:id].to_s] }
90
+ end
91
+
92
+ # pubid's normalization moves a legacy base year into its own comma
93
+ # segment ("IEEE P802.16/D5, 2004/Cor. 1-2005"); the index rows carry
94
+ # the canonical form without it, so the block also matches with the
95
+ # moved year stripped. Rows are Pubid::Ieee::Identifier objects (not
96
+ # Comparable), so pick by the string form.
97
+ #
98
+ # The project prefix moves too: "IEEE P802.21/D14" parses as a
99
+ # ProjectDraft, but the pre-publication crawl records spell the same
100
+ # document "IEEE Unapproved 802.21/D14, Sept 2008" — the Unapproved
101
+ # draft status occupies the slot the publisher and P would render in,
102
+ # so the row string contains neither (relaton-ieee#25). The draft
103
+ # number "/D14" still has to match, so the bare form cannot reach the
104
+ # published standard or a different draft — the variant exists only
105
+ # for a reference that names its draft.
106
+ def substring_row(index, pubid)
107
+ needle = pubid.to_s
108
+ # The publisher-and-prefix-stripped form; nil when there is no
109
+ # project-prefixed draft number.
110
+ stripped = needle.sub("IEEE P", "") if needle.match?(/\AIEEE P\S*\/D\d/i)
111
+ index.search(pubid) do |r|
112
+ id = r[:id].to_s
113
+ id.include?(needle) ||
114
+ id.include?(needle.gsub(/, (19|20)\d{2}\//, "/")) ||
115
+ (stripped && id.include?(stripped))
116
+ end.min_by { |r| r[:id].to_s }
117
+ end
118
+
119
+ # @param pubid [Pubid::Ieee::Identifier, String]
120
+ # @param year [String, nil]
121
+ def dated?(pubid, year)
122
+ return true if year
123
+ return pubid.year if pubid.respond_to?(:year)
124
+
125
+ # An unparseable reference was found by substring; leave it dated.
126
+ true
127
+ end
128
+
74
129
  # Parse a reference into a Pubid::Ieee::Identifier for index narrowing, or
75
130
  # return the raw String when pubid can't parse it (e.g. a partial ref) so
76
131
  # the search falls back to the substring scan.
@@ -171,7 +171,7 @@ module Relaton
171
171
  end
172
172
  return if RELATION_TYPES[type] == false
173
173
 
174
- docid = Bib::Docidentifier.new(type: "IEEE", content: fref, primary: true)
174
+ docid = Docidentifier.new(type: "IEEE", content: fref, primary: true)
175
175
  bib = ItemData.new formattedref: Bib::Formattedref.new(content: fref), docidentifier: [docid]
176
176
  description = create_relation_description type
177
177
  Bib::Relation.new(type: RELATION_TYPES[type][:type], description: description, bibitem: bib)
@@ -0,0 +1,37 @@
1
+ module Relaton
2
+ module Ieee
3
+ # IEEE document identifier. An `IEEE` docid is backed by a
4
+ # Pubid::Ieee::Identifier so `remove_date!` can strip the year when an
5
+ # undated reference answers with its latest edition (relaton-ieee#29).
6
+ # ISBN and DOI docids carry no pubid and are never changed.
7
+ class Docidentifier < Bib::Docidentifier
8
+ # Strip the year: `IEEE Std 528-2019` -> `IEEE Std 528`, and the
9
+ # trademark docid `IEEE Std 528™-2019` -> `IEEE Std 528™`. An id with no
10
+ # year is left as it is: a re-render would change its spelling
11
+ # (`IEEE P802.5t™/D-2.5` -> `IEEE P802.5t®/D2.5`).
12
+ def remove_date!
13
+ pubid = parse_pubid
14
+ return unless pubid&.year
15
+
16
+ undated = pubid.exclude(:year)
17
+ self.content = scope == "trademark" ? undated.to_s(trademark: true) : undated.to_s
18
+ end
19
+
20
+ # IEEE has no all-parts form: both are no-ops, so
21
+ # `Bib::ItemData#to_all_parts` never raises.
22
+ def remove_part!; end
23
+
24
+ def to_all_parts!; end
25
+
26
+ private
27
+
28
+ def parse_pubid
29
+ return unless type == "IEEE" && content
30
+
31
+ ::Pubid::Ieee::Identifier.parse content
32
+ rescue StandardError
33
+ nil
34
+ end
35
+ end
36
+ end
37
+ end
@@ -92,7 +92,7 @@ module Relaton
92
92
  #
93
93
  # Parse identifiers
94
94
  #
95
- # @return [Array<Relaton::Bib::Docidentifier>]
95
+ # @return [Array<Relaton::Ieee::Docidentifier>]
96
96
  #
97
97
  def parse_docidentifier # rubocop:disable Metrics/MethodLength
98
98
  ids = @doc.isbn_doi.map { |id| id[:content] = id.delete(:id); id }
@@ -102,7 +102,7 @@ module Relaton
102
102
  ids.unshift(content: pubid.to_s, type: "IEEE", primary: true)
103
103
  end
104
104
 
105
- result = ids.map { |dcid| Bib::Docidentifier.new(**dcid) }
105
+ result = ids.map { |dcid| Docidentifier.new(**dcid) }
106
106
  @errors[:docidentifier] &&= result.empty?
107
107
  result
108
108
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "ext"
2
+ require_relative "docidentifier"
2
3
 
3
4
  module Relaton
4
5
  module Ieee
@@ -6,6 +7,8 @@ module Relaton
6
7
  model ItemData
7
8
 
8
9
  attribute :ext, Ext
10
+ attribute :docidentifier, Docidentifier, collection: true,
11
+ initialize_empty: true
9
12
  end
10
13
  end
11
14
  end
@@ -1,7 +1,13 @@
1
1
  module Relaton
2
2
  module Ieee
3
3
  class ItemData < Bib::ItemData
4
-
4
+ # The ISBN and DOI name one edition, so the undated item keeps only the
5
+ # IEEE docids; its `instanceOf` relation still carries them.
6
+ def to_most_recent_reference
7
+ me = super
8
+ me.docidentifier = me.docidentifier.select { |d| d.type == "IEEE" }
9
+ me
10
+ end
5
11
  end
6
12
  end
7
13
  end
@@ -95,8 +95,7 @@ module Relaton
95
95
  def index
96
96
  @index ||= Relaton::Index.find_or_create(
97
97
  :jis,
98
- url: "#{GH_URL}#{INDEXFILE}.zip",
99
- file: "#{INDEXFILE}.yaml",
98
+ pages_url: PAGES_URL,
100
99
  pubid_class: ::Pubid::Jis::Identifier,
101
100
  )
102
101
  end
data/lib/relaton/jis.rb CHANGED
@@ -17,6 +17,7 @@ module Relaton
17
17
  module Jis
18
18
  # The pubid index. relaton-data-jis rebuilds the legacy index-v1 from data/.
19
19
  INDEXFILE = "index-v2"
20
+ PAGES_URL = "https://relaton.github.io/relaton-data-jis/"
20
21
 
21
22
  class Error < StandardError; end
22
23
 
@@ -356,7 +356,7 @@ module Relaton
356
356
  end
357
357
 
358
358
  index = Relaton::Index.find_or_create(
359
- :nist, url: "#{GHNISTDATA}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
359
+ :nist, pages_url: PAGES_URL,
360
360
  pubid_class: ::Pubid::Nist::Identifier
361
361
  )
362
362
  # search(r) narrows candidates by number via binary search; the block
data/lib/relaton/nist.rb CHANGED
@@ -22,6 +22,7 @@ module Relaton
22
22
  # (for older gem versions) is rebuilt separately by the relaton-data-nist
23
23
  # crawler, not by this gem's DataFetcher.
24
24
  INDEXFILE = "index-v2"
25
+ PAGES_URL = "https://relaton.github.io/relaton-data-nist/"
25
26
 
26
27
  class Error < StandardError; end
27
28
 
@@ -30,7 +30,7 @@ module Relaton
30
30
 
31
31
  def index
32
32
  @index ||= Relaton::Index.find_or_create(
33
- :plateau, url: "#{ENDPOINT}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
33
+ :plateau, pages_url: PAGES_URL,
34
34
  pubid_class: ::Pubid::Plateau::Identifier
35
35
  )
36
36
  end
@@ -19,6 +19,7 @@ require_relative "plateau/processor"
19
19
  module Relaton
20
20
  module Plateau
21
21
  INDEXFILE = "index-v2"
22
+ PAGES_URL = "https://relaton.github.io/relaton-data-plateau/"
22
23
 
23
24
  class Error < StandardError; end
24
25
 
@@ -1,3 +1,3 @@
1
1
  module Relaton
2
- VERSION = "3.0.0".freeze
2
+ VERSION = "3.0.1".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.0.0
4
+ version: 3.0.1
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-10-27 00:00:00.000000000 Z
11
+ date: 2026-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -422,6 +422,26 @@ dependencies:
422
422
  - - "~>"
423
423
  - !ruby/object:Gem::Version
424
424
  version: '1.26'
425
+ - !ruby/object:Gem::Dependency
426
+ name: parsanol
427
+ requirement: !ruby/object:Gem::Requirement
428
+ requirements:
429
+ - - ">="
430
+ - !ruby/object:Gem::Version
431
+ version: 1.3.112
432
+ - - "<"
433
+ - !ruby/object:Gem::Version
434
+ version: '1.4'
435
+ type: :runtime
436
+ prerelease: false
437
+ version_requirements: !ruby/object:Gem::Requirement
438
+ requirements:
439
+ - - ">="
440
+ - !ruby/object:Gem::Version
441
+ version: 1.3.112
442
+ - - "<"
443
+ - !ruby/object:Gem::Version
444
+ version: '1.4'
425
445
  - !ruby/object:Gem::Dependency
426
446
  name: parslet
427
447
  requirement: !ruby/object:Gem::Requirement
@@ -556,6 +576,7 @@ files:
556
576
  - lib/relaton/3gpp/bibitem.rb
557
577
  - lib/relaton/3gpp/bibliography.rb
558
578
  - lib/relaton/3gpp/data_fetcher.rb
579
+ - lib/relaton/3gpp/date.rb
559
580
  - lib/relaton/3gpp/docidentifier.rb
560
581
  - lib/relaton/3gpp/doctype.rb
561
582
  - lib/relaton/3gpp/ext.rb
@@ -749,6 +770,7 @@ files:
749
770
  - lib/relaton/cie.rb
750
771
  - lib/relaton/cie/CLAUDE.md
751
772
  - lib/relaton/cie/README.adoc
773
+ - lib/relaton/cie/authors_parser.rb
752
774
  - lib/relaton/cie/bibdata.rb
753
775
  - lib/relaton/cie/bibitem.rb
754
776
  - lib/relaton/cie/bibliography.rb
@@ -953,6 +975,7 @@ files:
953
975
  - lib/relaton/ieee/converter/bibxml/from_rfcxml.rb
954
976
  - lib/relaton/ieee/converter/bibxml/from_rfcxml_referencegroup.rb
955
977
  - lib/relaton/ieee/data_fetcher.rb
978
+ - lib/relaton/ieee/docidentifier.rb
956
979
  - lib/relaton/ieee/doctype.rb
957
980
  - lib/relaton/ieee/editorial_group.rb
958
981
  - lib/relaton/ieee/ext.rb