relaton 3.0.0.pre.alpha.5 → 3.0.0.pre.alpha.6

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton/3gpp/bibliography.rb +19 -6
  3. data/lib/relaton/3gpp/processor.rb +6 -0
  4. data/lib/relaton/bib/item_data.rb +14 -0
  5. data/lib/relaton/bipm/bibliography.rb +24 -21
  6. data/lib/relaton/bsi/bibliography.rb +26 -23
  7. data/lib/relaton/calconnect/bibliography.rb +6 -6
  8. data/lib/relaton/calconnect/hit_collection.rb +4 -1
  9. data/lib/relaton/ccsds/bibliography.rb +15 -10
  10. data/lib/relaton/ccsds/hit_collection.rb +1 -1
  11. data/lib/relaton/ccsds/processor.rb +3 -2
  12. data/lib/relaton/cen/bibliography.rb +27 -24
  13. data/lib/relaton/cie/bibliography.rb +11 -9
  14. data/lib/relaton/cie/scrapper.rb +15 -8
  15. data/lib/relaton/core/processor.rb +61 -8
  16. data/lib/relaton/core/request_error.rb +21 -3
  17. data/lib/relaton/db/cache.rb +49 -19
  18. data/lib/relaton/db/cache_entry.rb +9 -0
  19. data/lib/relaton/db/registry.rb +128 -28
  20. data/lib/relaton/db.rb +162 -48
  21. data/lib/relaton/ecma/bibliography.rb +18 -13
  22. data/lib/relaton/etsi/bibliography.rb +8 -7
  23. data/lib/relaton/gb/bibliography.rb +20 -12
  24. data/lib/relaton/iala/bibliography.rb +15 -12
  25. data/lib/relaton/iana/bibliography.rb +12 -12
  26. data/lib/relaton/iana/parser.rb +5 -2
  27. data/lib/relaton/iec/bibliography.rb +18 -7
  28. data/lib/relaton/iec/processor.rb +5 -0
  29. data/lib/relaton/ieee/bibliography.rb +19 -13
  30. data/lib/relaton/ieee/processor.rb +16 -0
  31. data/lib/relaton/ieee/rawbib_id_parser.rb +2 -2
  32. data/lib/relaton/ietf/bibliography.rb +11 -9
  33. data/lib/relaton/ietf/scraper.rb +5 -4
  34. data/lib/relaton/iso/bibliography.rb +11 -9
  35. data/lib/relaton/itu/bibliography.rb +9 -4
  36. data/lib/relaton/jis/bibliography.rb +22 -11
  37. data/lib/relaton/nist/bibliography.rb +25 -21
  38. data/lib/relaton/oasis/bibliography.rb +16 -13
  39. data/lib/relaton/ogc/bibliography.rb +15 -14
  40. data/lib/relaton/ogc/hit_collection.rb +9 -7
  41. data/lib/relaton/ogc/processor.rb +7 -1
  42. data/lib/relaton/omg/bibliography.rb +9 -9
  43. data/lib/relaton/omg/scraper.rb +3 -2
  44. data/lib/relaton/plateau/bibliography.rb +9 -7
  45. data/lib/relaton/plateau/hit_collection.rb +2 -1
  46. data/lib/relaton/version.rb +1 -1
  47. data/lib/relaton/xsf/bibliography.rb +11 -6
  48. metadata +18 -4
@@ -47,14 +47,67 @@ module Relaton
47
47
  @pubid_class ||= ::Pubid.const_get(flavor)::Identifier
48
48
  end
49
49
 
50
+ # Whether the flavor's identifiers print without a publisher token
51
+ # (OGC `19-025r1`, 3GPP `TS 23.207:REL-18/18.0.0`). Routing then takes
52
+ # an exact parse by the flavor although no prefix of it claims the
53
+ # reference (Registry#route).
54
+ #
55
+ # @return [Boolean]
56
+ def bare_identifiers?
57
+ false
58
+ end
59
+
50
60
  # Parse a query reference into a pubid. A flavor that normalizes a
51
- # reference before it parses overrides this. A parse error propagates:
52
- # an unrecognized reference is not "not found".
61
+ # reference before it parses overrides this. A reference the flavor's
62
+ # grammar cannot read gives no pubid (relaton#235): `get` then
63
+ # receives the String and normalizes it itself, and the query is not
64
+ # keyed - the same reading as a flavor's own miss.
53
65
  #
54
66
  # @param ref [String]
55
67
  # @return [Pubid::Identifier, nil]
56
68
  def cache_pubid(ref)
57
69
  pubid_class&.parse(ref)
70
+ rescue ArgumentError, ::Pubid::Errors::Error, Parslet::ParseFailed
71
+ # relaton-bib carries the log facade; core specs load without it.
72
+ require "relaton/bib"
73
+ Relaton::Bib::Util.info "does not parse as this flavor's pubid; " \
74
+ "passing the reference to the flavor as " \
75
+ "written", "relaton-db", key: ref
76
+ nil
77
+ end
78
+
79
+ # The reference as `get` receives it and the cache keys it (relaton#205):
80
+ # the parse routing already made, else the flavor's own. Nil when the
81
+ # flavor gives no pubid: `get` then receives the String, and the query
82
+ # is not cached. A flavor overrides this when an option keeps a query
83
+ # out of the cache and away from the pubid (CCSDS format).
84
+ #
85
+ # @param ref [String]
86
+ # @param opts [Hash]
87
+ # @param parsed [Pubid::Identifier, nil] routing's parse, of this
88
+ # flavor's class
89
+ # @return [Pubid::Identifier, nil]
90
+ def query_pubid(ref, _opts = {}, parsed = nil)
91
+ pubid = parsed || cache_pubid(ref)
92
+ # A flavor that reads an unparseable reference as a miss (IANA,
93
+ # IEEE) gives nil or the raw String: no pubid then. The nil test comes
94
+ # first: without a pubid class, pubid may not be loaded.
95
+ pubid if !pubid.nil? && pubid.is_a?(::Pubid::Identifier)
96
+ end
97
+
98
+ # The publishers of a co-published identifier after the lead, in the
99
+ # order the parsed pubid holds them: the printed order (pubid#472).
100
+ # Relaton::Db asks their flavors when the lead's catalog has no record
101
+ # (relaton#205). A flavor whose pubid keeps them elsewhere overrides
102
+ # this (IEEE).
103
+ #
104
+ # @param pubid [Pubid::Identifier] the query pubid (#query_pubid)
105
+ # @return [Array<String>] publisher abbreviations, e.g. `["IEC"]`
106
+ def copublishers(pubid)
107
+ pubid = pubid.identifiers.first if pubid.all_parts?
108
+ return [] unless pubid.respond_to?(:copublishers)
109
+
110
+ Array(pubid.copublishers).map(&:to_s)
58
111
  end
59
112
 
60
113
  # Set the year on a parsed pubid, where the flavor's `get` applies it. The
@@ -88,12 +141,12 @@ module Relaton
88
141
  # @param ref [String]
89
142
  # @param year [String, Integer, nil]
90
143
  # @param opts [Hash]
91
- # @return [Pubid::Identifier, nil]
92
- def cache_key(ref, year, opts)
93
- pubid = cache_pubid(ref)
94
- # A flavor that reads an unparseable reference as a miss (IANA, IEEE)
95
- # gives nil or the raw String: no pubid key then.
96
- return if pubid.nil? || !pubid.is_a?(::Pubid::Identifier)
144
+ # @param parsed [Pubid::Identifier, nil] the reference as already parsed
145
+ # with this flavor's class (#query_pubid); parsed here otherwise
146
+ # @return [Pubid::Identifier, nil] never the given object itself when a
147
+ # year or all-parts is folded in: those fold on copies
148
+ def cache_key(ref, year, opts, parsed = nil)
149
+ pubid = query_pubid(ref, opts, parsed) or return
97
150
 
98
151
  pubid = fold_year(pubid, year) if year
99
152
  pubid = pubid.to_all_parts if opts[:all_parts] && !pubid.all_parts?
@@ -1,12 +1,30 @@
1
1
  module Relaton
2
+ # The base of relaton's own errors. It depends on nothing, so
3
+ # `relaton/index` requires this file alone and does not load the rest of
4
+ # `relaton/core`.
5
+ class Error < StandardError; end
6
+
2
7
  # Transport failure while fetching remote data; `Relaton::Db#net_retry`
3
- # retries it. It depends on nothing, so `relaton/index` requires this file
4
- # alone and does not load the rest of `relaton/core`.
8
+ # retries it.
5
9
  #
6
10
  # The class is named `Relaton::RequestError`, not `Relaton::Core::...`: an
7
11
  # error raised without a message uses the class name as its message, and
8
12
  # relaton-cli prints that message.
9
- class RequestError < StandardError; end
13
+ class RequestError < Error; end
14
+
15
+ # `Relaton::Db` found no flavor for a reference: no flavor's pubid grammar
16
+ # reads it exactly, and no flavor's prefix matches it. relaton-cli rescues
17
+ # it, logs the message and returns no document.
18
+ class UnknownReferenceError < Error
19
+ # @return [String] the reference no flavor recognizes
20
+ attr_reader :reference
21
+
22
+ # @param reference [String]
23
+ def initialize(reference)
24
+ @reference = reference
25
+ super("`#{reference}` is not a recognized standards identifier")
26
+ end
27
+ end
10
28
 
11
29
  module Core
12
30
  RequestError = Relaton::RequestError
@@ -27,7 +27,9 @@ module Relaton
27
27
  VERSIONS_KEY = "_versions".freeze
28
28
  STRING_FLAVOR = "_key".freeze
29
29
  PUBID_FLAVOR = "_pubid".freeze
30
+ # The legacy string form of a not-found entry (`not_found <date>`).
30
31
  NOT_FOUND = /\Anot_found/
32
+ # Days an undated entry, or any not_found entry, stays valid.
31
33
  UNDATED_TTL = 60
32
34
  WRAPPED_KEY = /\A(?<prefix>[^(\s]+)\((?<code>.+)\)\z/m
33
35
  # What a row may add to a dated key and still answer it: `===` also
@@ -76,20 +78,29 @@ module Relaton
76
78
  @versions = {}
77
79
  end
78
80
 
79
- # Read the document (or `not_found <date>`) for a key: the row with
80
- # this key, else, for a dated pubid, a row the key is a subset of.
81
+ # Read the entry for a key: the row with this key, else, for a dated
82
+ # pubid, a row the key is a subset of.
81
83
  #
82
84
  # @param key [Pubid::Identifier, String]
83
- # @return [String, nil]
84
- def [](key)
85
+ # @return [String, Relaton::Db::NotFound, nil] document XML or not found
86
+ def read(key)
85
87
  found = lookup(key)
86
88
  found && read_entry(found.last)
87
89
  end
90
+
91
+ # The entry for a key as a string, a not-found entry as
92
+ # `not_found <date>`.
93
+ #
94
+ # @param key [Pubid::Identifier, String]
95
+ # @return [String, nil]
96
+ def [](key)
97
+ read(key)&.to_s
98
+ end
88
99
  alias get []
89
100
 
90
101
  # @param key [Pubid::Identifier, String]
91
- # @param value [String, nil] document XML or `not_found <date>`; nil
92
- # deletes the row
102
+ # @param value [String, Relaton::Db::NotFound, nil] document XML, a
103
+ # not-found entry (also as `not_found <date>`); nil deletes the row
93
104
  def []=(key, value)
94
105
  store key, value
95
106
  end
@@ -100,18 +111,20 @@ module Relaton
100
111
  # point to one document file.
101
112
  #
102
113
  # @param key [Pubid::Identifier, String] query key
103
- # @param value [String, nil] document XML or `not_found <date>`
114
+ # @param value [String, Relaton::Db::NotFound, nil] document XML, a
115
+ # not-found entry (also as `not_found <date>`); nil deletes the row
104
116
  # @param item_key [Pubid::Identifier, String, nil] the document's key
105
- # @return [String, nil] value
117
+ # @return [String, Relaton::Db::NotFound, nil] value
106
118
  #
107
119
  def store(key, value, item_key: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
108
120
  return delete(key) if value.nil?
109
121
 
110
122
  bucket, key = resolve key
123
+ value = coerce value
111
124
  fetched = fetched_of value
112
125
  record_version bucket
113
- if value.match? NOT_FOUND
114
- upsert bucket, entry(key, CacheEntry::NOT_FOUND, nil, fetched)
126
+ if value.is_a? NotFound
127
+ upsert bucket, new_row(key, CacheEntry::NOT_FOUND, nil, fetched)
115
128
  return value
116
129
  end
117
130
 
@@ -120,9 +133,9 @@ module Relaton
120
133
  record_version item_bucket
121
134
  @rows.adapter.transaction do
122
135
  @docs.set file, value
123
- upsert item_bucket, entry(item_key, CacheEntry::DOC, file, fetched)
136
+ upsert item_bucket, new_row(item_key, CacheEntry::DOC, file, fetched)
124
137
  unless canonical(key) == canonical(item_key)
125
- upsert bucket, entry(key, CacheEntry::DOC, file, fetched)
138
+ upsert bucket, new_row(key, CacheEntry::DOC, file, fetched)
126
139
  end
127
140
  end
128
141
  value
@@ -154,12 +167,16 @@ module Relaton
154
167
  end
155
168
 
156
169
  #
157
- # Save the row of a key, and its document, from another cache.
170
+ # Save the row of a key, and its document, from another cache. A row
171
+ # the cache already holds is kept: a local cache keeps its own value
172
+ # for a key the global cache also holds (local over global).
158
173
  #
159
174
  # @param key [Pubid::Identifier, String]
160
175
  # @param other [Relaton::Db::Cache]
161
176
  #
162
177
  def clone_entry(key, other)
178
+ return if lookup(key)
179
+
163
180
  found = other.lookup(key) or return
164
181
  row_id, row = found
165
182
  store row_id, other.read_entry(row)
@@ -171,7 +188,9 @@ module Relaton
171
188
  lookup(key)&.last&.dig("fetched")
172
189
  end
173
190
 
174
- # An undated entry expires after 60 days, a dated one never.
191
+ # An undated entry expires after 60 days, a dated document never. A
192
+ # not_found entry expires after 60 days even for a dated query: the
193
+ # document can be published later.
175
194
  # @param key [Pubid::Identifier, String]
176
195
  # @param year [String, nil]
177
196
  def valid_entry?(key, year)
@@ -243,9 +262,11 @@ module Relaton
243
262
  end
244
263
 
245
264
  # @param row [Hash]
246
- # @return [String, nil] document XML or `not_found <date>`
265
+ # @return [String, Relaton::Db::NotFound, nil] document XML or not found
247
266
  def read_entry(row)
248
- return "not_found #{row['fetched']}" if row["status"] == CacheEntry::NOT_FOUND
267
+ if row["status"] == CacheEntry::NOT_FOUND
268
+ return NotFound.new(fetched: row["fetched"])
269
+ end
249
270
 
250
271
  @docs.get row["file"]
251
272
  end
@@ -405,7 +426,9 @@ module Relaton
405
426
  def valid_row?(row, year)
406
427
  return false unless read_entry(row)
407
428
 
408
- year || Date.today - Date.parse(row["fetched"]) < UNDATED_TTL
429
+ return year if year && row["status"] == CacheEntry::DOC
430
+
431
+ Date.today - Date.parse(row["fetched"]) < UNDATED_TTL
409
432
  end
410
433
 
411
434
  #
@@ -442,7 +465,7 @@ module Relaton
442
465
  end
443
466
  end
444
467
 
445
- def entry(key, status, file, fetched)
468
+ def new_row(key, status, file, fetched)
446
469
  row = CacheEntry.new(status: status, file: file, fetched: fetched)
447
470
  if key.is_a?(String) then row.key = key
448
471
  else row.id = canonical(key)
@@ -517,8 +540,15 @@ module Relaton
517
540
  @docs.delete file unless used
518
541
  end
519
542
 
543
+ # A legacy `not_found <date>` string becomes a NotFound value.
544
+ def coerce(value)
545
+ return value unless value.is_a?(String) && value.match?(NOT_FOUND)
546
+
547
+ NotFound.new(fetched: value[/\d{4}-\d{2}-\d{2}/] || Date.today.to_s)
548
+ end
549
+
520
550
  def fetched_of(value)
521
- date = if value.match? NOT_FOUND then value[/\d{4}-\d{2}-\d{2}/]
551
+ date = if value.is_a? NotFound then value.fetched
522
552
  else value[%r{<fetched>\s*([^<\s]+)\s*</fetched>}, 1]
523
553
  end
524
554
  date || Date.today.to_s
@@ -2,6 +2,15 @@ require "lutaml/model"
2
2
 
3
3
  module Relaton
4
4
  class Db
5
+ # The cached answer "the flavor has no such document", with the date it
6
+ # was fetched. Db and Cache pass it in place of the document XML.
7
+ NotFound = Data.define(:fetched) do
8
+ # @return [String] the legacy string form, `not_found <date>`
9
+ def to_s
10
+ "not_found #{fetched}"
11
+ end
12
+ end
13
+
5
14
  # One row of the cache index. A row is keyed either by a pubid (`id`, its
6
15
  # `to_hash`) or, for a processor with no pubid class, by a string (`key`).
7
16
  # `file` names the document in the doc store; several rows can share one
@@ -1,4 +1,5 @@
1
1
  require "singleton"
2
+ require_relative "../core/request_error"
2
3
 
3
4
  module Relaton
4
5
  class Db
@@ -111,37 +112,42 @@ module Relaton
111
112
  end
112
113
 
113
114
  #
114
- # Pubid namespaces whose spelling differs from the processor short
115
- # name.
116
- PUBID_FLAVOR_ALIASES = { "cencenelec" => "cen", "tgpp" => "3gpp" }.freeze
117
-
118
- # Flavors routed by the parsed Pubid class (relaton#205, pilot). Only
119
- # processors on this list answer parse-first routing: an allowlist,
120
- # not a guess. Pubid::Un is deliberately absent — its Document
121
- # identifier parses DOI-shaped strings ("10.17487/RFC3986") that must
122
- # not be hijacked away from their current handling. DOI and ISBN are
123
- # present: their canonical forms carry their own token (`doi:…`,
124
- # `ISBN …`), which pubid detects exactly as the prefix regex routes it.
125
- PARSE_ROUTED_FLAVORS = %w[
126
- bipm bs cencenelec calconnect cc ccsds cen cie csa doi
127
- ecma ecs etsi gost iala iana iec ieee ietf isbn iso itu
128
- jcgm jis nist oasis ogc oiml plateau w3c xsf 3gpp
129
- ].freeze
130
-
131
- # Find the processor that owns the parsed Pubid's flavor. The
132
- # namespace is matched against the processor short name, with the
133
- # alias table for the spellings that differ.
115
+ # Route a reference to its flavor (relaton#205).
134
116
  #
135
- # @param pubid [Pubid::Core::Identifier] parsed query
136
- # @return [Symbol, nil] standard class name
117
+ # 1. The reference is parsed with pubid. Only the parsing flavor's own
118
+ # **exact** parse counts (#exact_parse): `Pubid.parse` falls back to a
119
+ # partial parse by any flavor (`ATN5014` as `IEC ATN5014`), and a
120
+ # permissive grammar reads another publisher's string exactly
121
+ # (`ISO REF` as IEC).
122
+ # 2. The parse routes by class ancestry (#processor_by_pubid), so the
123
+ # registration order does not matter.
124
+ # 3. A co-published identifier routes to the co-publisher its printed
125
+ # form names first (`ISO/IEC …` → ISO, `IEC/ISO …` → IEC). This is
126
+ # relaton's routing policy (#205): there is no canonical form of the
127
+ # identifier, pubid does not route records (pubid#469), and
128
+ # `Pubid.parse` tries the owners of a joint prefix in alphabetical
129
+ # order.
130
+ # 4. Otherwise the prefix regex (#class_by_ref) routes: a combined
131
+ # reference, the `PREFIX(code)` wrapper, a spelling a flavor
132
+ # normalizes, a flavor's free text.
133
+ #
134
+ # @param reference [String]
135
+ # @return [Array(Symbol, Pubid::Identifier)] the standard class, and the
136
+ # parsed pubid when it is that flavor's own exact parse (else nil)
137
+ # @raise [Relaton::UnknownReferenceError] no flavor recognizes it
137
138
  #
138
- def class_by_pubid(pubid)
139
- ns = pubid.class.name.split("::")[1]&.downcase
140
- flavor = PUBID_FLAVOR_ALIASES.fetch(ns, ns)
141
- return nil unless PARSE_ROUTED_FLAVORS.include?(flavor)
139
+ def route(reference) # rubocop:disable Metrics/CyclomaticComplexity
140
+ parsed = exact_parse reference
141
+ stdclass = parsed && class_by_parsed(parsed, reference)
142
+ parsed = nil unless stdclass
143
+ lead = joint_lead reference
144
+ return [lead, stdclass == lead ? parsed : nil] if lead
145
+ return [stdclass, parsed] if stdclass
142
146
 
143
- key = "relaton_#{flavor}".to_sym
144
- processors.key?(key) ? key : nil
147
+ stdclass = class_by_ref(reference)
148
+ raise UnknownReferenceError, reference unless stdclass
149
+
150
+ [stdclass, nil]
145
151
  end
146
152
 
147
153
  # Find processor by refernce or prefix
@@ -189,8 +195,102 @@ module Relaton
189
195
  processors_by_prefix(prefix).map { |processor| flavor_module(processor) }
190
196
  end
191
197
 
198
+ #
199
+ # The flavor that serves a publisher's catalog: the processor whose own
200
+ # prefix is the publisher (`IEC` → `:relaton_iec`). Nil for a publisher
201
+ # no flavor serves (`ASTM`). Used for a co-publisher (relaton#205).
202
+ #
203
+ # @param publisher [String]
204
+ # @return [Symbol, nil] the standard class
205
+ #
206
+ def class_by_publisher(publisher)
207
+ processors_by_prefix(publisher)
208
+ .detect { |processor| processor.prefix.casecmp?(publisher) }&.short
209
+ end
210
+
192
211
  private
193
212
 
213
+ # The reference parsed by pubid, when the parse renders the reference
214
+ # back (pubid's round-trip test). pubid itself is loaded here, not at
215
+ # registration. `Pubid.parse` raises a plain ArgumentError for a URN no
216
+ # flavor owns (`urn:foo:bar`): that is no parse either.
217
+ #
218
+ # @param reference [String]
219
+ # @return [Pubid::Identifier, nil]
220
+ def exact_parse(reference)
221
+ require "pubid"
222
+ parsed = ::Pubid.parse reference
223
+ parsed if parsed.to_s == reference
224
+ rescue ArgumentError, ::Pubid::Errors::Error, Parslet::ParseFailed
225
+ nil
226
+ end
227
+
228
+ # Whether the flavor of +pubid+ owns a prefix +reference+ starts with.
229
+ def claims?(pubid, reference)
230
+ flavor = ::Pubid.const_get pubid.class.name.split("::")[1]
231
+ return false unless flavor.respond_to?(:prefixes)
232
+
233
+ flavor.prefixes.any? { |pref| prefix_of?(pref, reference) }
234
+ end
235
+
236
+ # A prefix matches when the reference ends there or goes on with a
237
+ # separator, or when the prefix itself ends with one (`doi:10.…`).
238
+ def prefix_of?(prefix, reference)
239
+ return false unless reference.start_with?(prefix)
240
+
241
+ rest = reference[prefix.length]
242
+ rest.nil? || !alnum?(rest) || !alnum?(prefix[-1])
243
+ end
244
+
245
+ def alnum?(char)
246
+ char.match?(/[[:alnum:]]/)
247
+ end
248
+
249
+ # The flavor of an exact parse, when the parse is that flavor's own: the
250
+ # flavor claims the reference by one of its prefixes, or its identifiers
251
+ # print without one (Core::Processor#bare_identifiers?). A permissive
252
+ # grammar reads other publishers' strings exactly (`ISO REF` as IEC,
253
+ # `ABC 123456` as GB, a bare DOI as UN), but it does not claim them.
254
+ #
255
+ # @param pubid [Pubid::Identifier]
256
+ # @param reference [String]
257
+ # @return [Symbol, nil] the standard class
258
+ def class_by_parsed(pubid, reference)
259
+ processor = processor_by_pubid(pubid) or return
260
+ return unless processor.bare_identifiers? || claims?(pubid, reference)
261
+
262
+ processor.short
263
+ end
264
+
265
+ # The co-publisher that a joint prefix names first (`ISO/IEC 27001` →
266
+ # ISO). Nil unless the reference starts with a prefix several flavors
267
+ # own.
268
+ #
269
+ # @param reference [String]
270
+ # @return [Symbol, nil] the standard class
271
+ def joint_lead(reference)
272
+ prefix = joint_prefixes.detect do |pref|
273
+ reference == pref || reference.start_with?("#{pref} ")
274
+ end
275
+ return unless prefix
276
+
277
+ class_by_publisher prefix.split("/").first
278
+ end
279
+
280
+ # Prefixes that more than one flavor owns (`ISO/IEC`, `ISO/IEC/IEEE`),
281
+ # longest first.
282
+ #
283
+ # @return [Array<String>]
284
+ def joint_prefixes
285
+ @joint_prefixes ||= processors.values.flat_map(&:prefixes).uniq
286
+ .select { |pref| pref.include?("/") && joint?(pref) }
287
+ .sort_by { |pref| -pref.size }
288
+ end
289
+
290
+ def joint?(prefix)
291
+ processors_by_prefix(prefix).size > 1
292
+ end
293
+
194
294
  # The flavor namespace for a processor: the module enclosing its class.
195
295
  # Relaton::Iso::Processor -> Relaton::Iso. Derived from the class name so
196
296
  # there's no @short string-munging and no 3gpp special case.