pubid 2.0.0.pre.alpha.5 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70b24e82967e02c1a0a641c5adc3ad9a77e563f0b7e6f69a59aa4f02971db3d2
4
- data.tar.gz: c206002602026012406fa8ebe4339ff6ca0f02db7aa0b00ba49796a034cb697c
3
+ metadata.gz: 1b1fa5f38da07bc9a507c0c507ba9ddfc8f6541ab9f4b6793b09abe302ab8849
4
+ data.tar.gz: 63b7eba9c9942c177e95b9c78a09462679a11acde4fd6a2c2e486848fb43ca93
5
5
  SHA512:
6
- metadata.gz: f74a26e175164470260014a2d492eadfc2f396b0b56bc9f5848fc79168c1ec1d627380bb64c83126bd0eeb2841da40828e336cf263b785b4c2c1959a712b95c5
7
- data.tar.gz: b450a0f2343231dff1174c01cec84fdacda74279134fed4c913a552fcd3c7a82e662e1fba5e52018462c295de1df9ba5c78af5a62a1ebde09974fbcd8270f7d6
6
+ metadata.gz: 915034be3882a43991ce3c0f934c4097f216f1fdac35dcb59ed6a0decb639dd4a19882de4f28940d4f29e6e9bb351de3383b07bd95cc0dc2a53bb4ad875f072d
7
+ data.tar.gz: 1cb7e935accc6e66bed8b599d25605b26cd415547c1ddec9ba2ecf1b7183883102c99078b830b2de9da436bc17613db042b5fc05e0d7d5515786fae71592aeb8
@@ -114,19 +114,11 @@ module Pubid
114
114
  if data[:na_prefix]
115
115
  return build_national_annex(data, supplements_data)
116
116
  elsif data[:adopted_string]
117
- # Check for multi-level adoptions
118
- identifier = build_adopted_identifier(data)
119
-
120
- # Wrap with consolidated if supplements present
121
- if supplements_data.any?
122
- identifier = wrap_with_consolidated(identifier,
123
- supplements_data)
124
- end
125
-
126
- # Wrap with ExpertCommentary if needed
127
- identifier = wrap_with_expert_commentary(identifier) if data[:expert_commentary]
128
-
129
- return identifier
117
+ # Adopted identifiers carry their supplements and Expert-commentary
118
+ # suffix *inside* the adopted string, so build_adopted_identifier does
119
+ # all the wrapping. Wrapping again here would double-wrap (a second
120
+ # ExpertCommentary around the first) and break #base_document.
121
+ return build_adopted_identifier(data)
130
122
  end
131
123
 
132
124
  # Determine identifier class using the module's lookup helpers
@@ -139,8 +131,11 @@ module Pubid
139
131
  supplements_data)
140
132
  end
141
133
 
142
- # Wrap with ExpertCommentary if needed
143
- identifier = wrap_with_expert_commentary(identifier) if data[:expert_commentary]
134
+ # Wrap with ExpertCommentary if needed (full "Expert Commentary" form
135
+ # arrives as the standalone :expert_commentary_full token)
136
+ if data[:expert_commentary] || data[:expert_commentary_full]
137
+ identifier = wrap_with_expert_commentary(identifier)
138
+ end
144
139
 
145
140
  identifier
146
141
  end
@@ -922,6 +917,7 @@ module Pubid
922
917
  number: (supp_data[:amd_number] || supp_data[:cor_number])&.to_s,
923
918
  year: (supp_data[:amd_year] || supp_data[:cor_year])&.to_s,
924
919
  separator: separator,
920
+ amd_suffix_form: !(supp_data[:amd_sep_plus] || supp_data[:amd_sep_slash]),
925
921
  }
926
922
  end
927
923
  end
@@ -1157,8 +1153,10 @@ module Pubid
1157
1153
  # Extract ExComm suffix before parsing (it should be preserved in data[:expert_commentary])
1158
1154
  # Remove it from adopted_string so ISO/IEC parsers don't choke on it
1159
1155
  # Handle all three formats: "Expert Commentary", "ExComm", "ExComm (Fire)"
1160
- if adopted_str.end_with?("Expert Commentary")
1161
- adopted_str = adopted_str.sub(/Expert Commentary$/, "")
1156
+ # Expert-commentary suffix matching is case-insensitive; output is always
1157
+ # canonicalised to "Expert Commentary" / "ExComm".
1158
+ if adopted_str.match?(/expert commentary$/i)
1159
+ adopted_str = adopted_str.sub(/expert commentary$/i, "")
1162
1160
  data[:expert_commentary_full] = "Expert Commentary"
1163
1161
  data[:expert_commentary] = true unless data.key?(:expert_commentary)
1164
1162
  # Update @original_data so wrap_with_expert_commentary can access it
@@ -1167,8 +1165,8 @@ module Pubid
1167
1165
  @original_data[:expert_commentary] =
1168
1166
  true
1169
1167
  end
1170
- elsif adopted_str.end_with?("ExComm (")
1171
- adopted_str = adopted_str.sub(/ExComm \(.*\)$/, "")
1168
+ elsif adopted_str.match?(/excomm \($/i)
1169
+ adopted_str = adopted_str.sub(/excomm \(.*\)$/i, "")
1172
1170
  data[:expert_commentary_full] = "Expert Commentary"
1173
1171
  data[:expert_commentary] = true unless data.key?(:expert_commentary)
1174
1172
  @original_data[:expert_commentary_full] = "Expert Commentary"
@@ -1176,10 +1174,10 @@ module Pubid
1176
1174
  @original_data[:expert_commentary] =
1177
1175
  true
1178
1176
  end
1179
- elsif adopted_str.include?("ExComm (")
1177
+ elsif adopted_str.match?(/excomm \(/i)
1180
1178
  # Extract topic from "ExComm (Fire)"
1181
- topic_match = adopted_str.match(/ExComm\s*\(([^)]+)\)/)
1182
- adopted_str = adopted_str.sub(/ExComm\s*\(.*\)$/, "")
1179
+ topic_match = adopted_str.match(/excomm\s*\(([^)]+)\)/i)
1180
+ adopted_str = adopted_str.sub(/excomm\s*\(.*\)$/i, "")
1183
1181
  data[:expert_commentary_topic] = topic_match[1] if topic_match
1184
1182
  data[:expert_commentary] = true unless data.key?(:expert_commentary)
1185
1183
  if topic_match
@@ -1190,9 +1188,9 @@ module Pubid
1190
1188
  @original_data[:expert_commentary] =
1191
1189
  true
1192
1190
  end
1193
- elsif adopted_str.match?(/ExComm\s*$/)
1191
+ elsif adopted_str.match?(/excomm\s*$/i)
1194
1192
  # Abbreviated form "ExComm" at the end
1195
- adopted_str = adopted_str.sub(/ExComm\s*$/, "")
1193
+ adopted_str = adopted_str.sub(/excomm\s*$/i, "")
1196
1194
  data[:expert_commentary] = true unless data.key?(:expert_commentary)
1197
1195
  unless @original_data.key?(:expert_commentary)
1198
1196
  @original_data[:expert_commentary] =
@@ -1258,9 +1256,10 @@ module Pubid
1258
1256
  # BSI uses +A1:2020 format, but ISO/IEC parsers can't handle this
1259
1257
  # Patterns: +A1:2020, +A11:2021, +AMD1:2001, +C1:2020
1260
1258
  extracted_supplements = []
1261
- # Match patterns like: +A1:2020, +A11:2021, +AMD1:2001, +C1:2020, +COR1:2020
1259
+ # Match patterns like: +A1:2020, +A11:2021, +AMD1:2001, +C1:2020, +COR1:2020,
1260
+ # and the year-less compact forms +A2 / +C1 (the ":YYYY" is optional).
1262
1261
  # Must be at end of string or followed by space (not colon, which is part of date)
1263
- adopted_str_clean = adopted_str_clean.gsub(/([+])(A(\d+)|AMD(\d+)|C(\d+)|COR(\d+)):(\d{4})(?:\s|$)/) do |_match|
1262
+ adopted_str_clean = adopted_str_clean.gsub(/([+])(A(\d+)|AMD(\d+)|C(\d+)|COR(\d+))(?::(\d{4}))?(?:\s|$)/) do |_match|
1264
1263
  separator = $1 # "+" or "/"
1265
1264
  type_code = $2 || $3 || $4 || $5 || $6
1266
1265
  $2 || $3 || $4 || $5 || $6
@@ -1285,6 +1284,8 @@ module Pubid
1285
1284
  number: supp_number,
1286
1285
  year: year,
1287
1286
  separator: separator,
1287
+ # compact "+A"/"/A" join form (not the trailing " AMD5" suffix form)
1288
+ amd_suffix_form: false,
1288
1289
  }
1289
1290
 
1290
1291
  "" # Remove from adopted_string
@@ -1300,6 +1301,7 @@ module Pubid
1300
1301
  number: amd_number,
1301
1302
  year: nil,
1302
1303
  separator: nil, # No separator for AMD without year
1304
+ amd_suffix_form: true, # trailing " AMD5" suffix form
1303
1305
  }
1304
1306
  "" # Remove from adopted_string
1305
1307
  end.strip
@@ -1365,6 +1367,9 @@ module Pubid
1365
1367
  # Return appropriate wrapper based on adoption type
1366
1368
  if adopted_id
1367
1369
  # If adopted_id is a CEN identifier (in Cen module), use AdoptedEuropeanNorm
1370
+ # The Expert-commentary suffix is represented uniformly by an outer
1371
+ # ExpertCommentary wrapper (below), never a boolean on the inner norm,
1372
+ # so that #base_document peels cleanly to the bare standard.
1368
1373
  identifier = if adopted_id.class.name.start_with?("Pubid::CenCenelec::")
1369
1374
  Identifiers::AdoptedEuropeanNorm.new(
1370
1375
  publisher: Components::Publisher.new(body: bsi_prefix),
@@ -1372,10 +1377,6 @@ module Pubid
1372
1377
  edition: final_edition&.to_s,
1373
1378
  translation_lang: data[:translation_lang]&.to_s,
1374
1379
  translation_upper: data[:translation_upper]&.to_s,
1375
- # Pass expert_commentary data so ConsolidatedIdentifier can render it
1376
- expert_commentary: data[:expert_commentary],
1377
- # Pass expert_commentary_topic if present
1378
- expert_commentary_topic: data[:expert_commentary_topic],
1379
1380
  # Pass translation_suffix_type for rendering
1380
1381
  translation_suffix_type: data[:translation_suffix_type]&.to_s,
1381
1382
  # Pass reaffirmation_year for rendering
@@ -1389,11 +1390,6 @@ module Pubid
1389
1390
  edition: final_edition&.to_s,
1390
1391
  translation_lang: data[:translation_lang]&.to_s,
1391
1392
  translation_upper: data[:translation_upper]&.to_s,
1392
- # Pass expert_commentary data ONLY if no supplements
1393
- # When supplements are present, ConsolidatedIdentifier will add ExComm later
1394
- expert_commentary: data[:expert_commentary] && extracted_supplements.none?,
1395
- # Pass expert_commentary_topic if present and no supplements
1396
- expert_commentary_topic: (data[:expert_commentary_topic] if extracted_supplements.none?),
1397
1393
  # Pass translation_suffix_type for rendering
1398
1394
  translation_suffix_type: data[:translation_suffix_type]&.to_s,
1399
1395
  # Pass reaffirmation_year for rendering
@@ -1401,20 +1397,13 @@ module Pubid
1401
1397
  )
1402
1398
  end
1403
1399
 
1404
- # NEW: Wrap with supplements if any were extracted from adopted_string
1405
- # This must happen BEFORE wrapping with ExpertCommentary so that supplements
1406
- # render before ExComm suffix
1400
+ # Wrap supplements first (they render before the ExComm suffix), then
1401
+ # wrap the whole thing in a single ExpertCommentary same shape as the
1402
+ # pure-BSI path, keeping #base_document uniform.
1407
1403
  if extracted_supplements.any?
1408
- # When supplements are present, wrap with ConsolidatedIdentifier
1409
- # Pass expert_commentary data so ConsolidatedIdentifier can render it later
1410
- identifier = wrap_with_consolidated(
1411
- identifier,
1412
- extracted_supplements,
1413
- expert_commentary: data[:expert_commentary],
1414
- expert_commentary_topic: data[:expert_commentary_topic],
1415
- )
1416
- elsif data[:expert_commentary]
1417
- # When no supplements but ExComm present, wrap with ExpertCommentary
1404
+ identifier = wrap_with_consolidated(identifier, extracted_supplements)
1405
+ end
1406
+ if data[:expert_commentary] || data[:expert_commentary_full]
1418
1407
  identifier = wrap_with_expert_commentary(identifier)
1419
1408
  end
1420
1409
 
@@ -1451,6 +1440,7 @@ expert_commentary: nil, expert_commentary_topic: nil)
1451
1440
  amendment_number: supp[:number],
1452
1441
  amendment_year: year_val&.to_i,
1453
1442
  separator: supp[:separator] || "+",
1443
+ amd_suffix_form: supp[:amd_suffix_form] ? true : false,
1454
1444
  )
1455
1445
  else
1456
1446
  Identifiers::Corrigendum.new(
@@ -1484,6 +1474,9 @@ expert_commentary: nil, expert_commentary_topic: nil)
1484
1474
  number: (supp_data[:amd_number] || supp_data[:cor_number])&.to_s,
1485
1475
  year: (supp_data[:amd_year] || supp_data[:cor_year])&.to_s,
1486
1476
  separator: separator,
1477
+ # The compact "+A"/"/A" amendment rule captures an explicit separator;
1478
+ # the trailing " AMD5" suffix form does not.
1479
+ amd_suffix_form: !(supp_data[:amd_sep_plus] || supp_data[:amd_sep_slash]),
1487
1480
  }
1488
1481
  end
1489
1482
  end
@@ -10,10 +10,38 @@ module Pubid
10
10
  attribute :amendment_number, :string
11
11
  attribute :amendment_year, :integer
12
12
  attribute :separator, :string, default: -> { "+" }
13
+ # true for the trailing " AMD5" / " AMD AA" suffix form, false for the
14
+ # compact "+A5" / "/A5" join form. Distinguishes the two when no year is
15
+ # present (previously the presence of a year was a reliable proxy).
16
+ attribute :amd_suffix_form, :boolean, default: -> { false }
13
17
 
14
18
  def publisher
15
19
  base_identifier&.publisher
16
20
  end
21
+
22
+ # Base document = the standard this amendment applies to, fully peeled.
23
+ def base_document
24
+ base_identifier&.base_document || self
25
+ end
26
+
27
+ # Dropping the supplement layer yields the base standard.
28
+ def drop_supplements
29
+ base_identifier || self
30
+ end
31
+
32
+ # Uniform supplement interface (shared with Corrigendum) so callers need
33
+ # not special-case the class.
34
+ def supplement_type
35
+ :amendment
36
+ end
37
+
38
+ def supplement_number
39
+ amendment_number
40
+ end
41
+
42
+ def supplement_year
43
+ amendment_year
44
+ end
17
45
  end
18
46
  end
19
47
  end
@@ -6,7 +6,7 @@ module Pubid
6
6
  # Consolidated Identifier - contains base document plus supplements
7
7
  # Example: "BS 4592-0:2006+A1:2012" = [BS 4592-0:2006, Amendment 1:2012]
8
8
  class ConsolidatedIdentifier < SingleIdentifier
9
- attribute :identifiers, ::Pubid::Identifier, polymorphic: true,
9
+ attribute :identifiers, ::Pubid::Identifier, polymorphic: true,
10
10
  collection: true
11
11
 
12
12
  def to_urn
@@ -62,6 +62,16 @@ module Pubid
62
62
  base = identifiers&.first
63
63
  base.type if base&.class&.attributes&.key?(:type)
64
64
  end
65
+
66
+ # Base document = the wrapped standard (first entry), fully peeled.
67
+ def base_document
68
+ identifiers&.first&.base_document || self
69
+ end
70
+
71
+ # Dropping the supplement layer yields the base standard alone.
72
+ def drop_supplements
73
+ identifiers&.first || self
74
+ end
65
75
  end
66
76
  end
67
77
  end
@@ -14,6 +14,30 @@ module Pubid
14
14
  def publisher
15
15
  base_identifier&.publisher
16
16
  end
17
+
18
+ # Base document = the standard this corrigendum applies to, fully peeled.
19
+ def base_document
20
+ base_identifier&.base_document || self
21
+ end
22
+
23
+ # Dropping the supplement layer yields the base standard.
24
+ def drop_supplements
25
+ base_identifier || self
26
+ end
27
+
28
+ # Uniform supplement interface (shared with Amendment) so callers need
29
+ # not special-case the class.
30
+ def supplement_type
31
+ :corrigendum
32
+ end
33
+
34
+ def supplement_number
35
+ corrigendum_number
36
+ end
37
+
38
+ def supplement_year
39
+ corrigendum_year
40
+ end
17
41
  end
18
42
  end
19
43
  end
@@ -25,6 +25,11 @@ module Pubid
25
25
  def year
26
26
  base_identifier&.year
27
27
  end
28
+
29
+ # Base document = the commented standard, fully peeled.
30
+ def base_document
31
+ base_identifier&.base_document || self
32
+ end
28
33
  end
29
34
  end
30
35
  end
@@ -20,6 +20,10 @@ module Pubid
20
20
  { key: :flex, title: "BSI Flex", short: "BSI Flex" }
21
21
  end
22
22
 
23
+ # Base document = the Flex standard without its version (edition).
24
+ def base_document
25
+ exclude(:edition)
26
+ end
23
27
  end
24
28
  end
25
29
  end
@@ -5,6 +5,19 @@ require "parslet"
5
5
  module Pubid
6
6
  module Bsi
7
7
  class Parser < Parslet::Parser
8
+ # Case-insensitive string match (Parslet 2.0 has no built-in `stri`).
9
+ # Each character becomes a two-case character class; other characters
10
+ # (spaces, punctuation) match verbatim.
11
+ def stri(string)
12
+ string.each_char.map do |char|
13
+ if char =~ /[a-z]/i
14
+ match["#{char.upcase}#{char.downcase}"]
15
+ else
16
+ str(char)
17
+ end
18
+ end.reduce(:>>)
19
+ end
20
+
8
21
  rule(:digit) { match["0-9"] }
9
22
  rule(:digits) { digit.repeat(1) }
10
23
  rule(:space) { str(" ") }
@@ -296,14 +309,15 @@ module Pubid
296
309
  # Single letter (a-z or A-Z) that comes immediately after number or part
297
310
  rule(:letter_edition) { match["a-zA-Z"].as(:letter_edition) }
298
311
 
299
- # Amendment (+A1:2008 or /A2:2019 or /Amd 1:2020 or +A1:15 for short year)
312
+ # Amendment (+A1:2008 or /A2:2019 or /Amd 1:2020 or +A1:15 for short year,
313
+ # or +A2 / /A2 without a year — mirrors the optional-year corrigendum rule)
300
314
  rule(:amendment) do
301
315
  ((plus.as(:amd_sep_plus) | slash.as(:amd_sep_slash)) >>
302
316
  str("A") >> digits.as(:amd_number) >>
303
- colon >> digit.repeat(2, 4).as(:amd_year)) |
317
+ (colon >> digit.repeat(2, 4).as(:amd_year)).maybe) |
304
318
  ((plus.as(:amd_sep_plus) | slash.as(:amd_sep_slash)) >>
305
319
  str("Amd") >> space >> digits.as(:amd_number) >>
306
- colon >> digit.repeat(4, 4).as(:amd_year))
320
+ (colon >> digit.repeat(4, 4).as(:amd_year)).maybe)
307
321
  end
308
322
 
309
323
  # Corrigendum (+AC:2009 or +AC1:2008 or /AC1:2005 or +C1:2018 or +C1 without year)
@@ -518,8 +532,8 @@ module Pubid
518
532
  # 3. "ExComm (Fire)" (with optional topic suffix)
519
533
  rule(:expert_commentary) do
520
534
  space >> (
521
- str("Expert Commentary").as(:expert_commentary_full) |
522
- (str("ExComm") >> (space >> str("(") >> match["A-Za-z"].repeat(1).as(:expert_commentary_topic) >> str(")")).maybe).as(:expert_commentary)
535
+ stri("Expert Commentary").as(:expert_commentary_full) |
536
+ (stri("ExComm") >> (space >> str("(") >> match["A-Za-z"].repeat(1).as(:expert_commentary_topic) >> str(")")).maybe).as(:expert_commentary)
523
537
  )
524
538
  end
525
539
 
@@ -289,20 +289,21 @@ module Pubid
289
289
  end
290
290
 
291
291
  def render_amendment(id)
292
- if id.base_identifier
293
- if id.amendment_year
294
- "#{id.base_identifier}#{id.separator}A#{id.amendment_number}:#{id.amendment_year}"
295
- elsif id.amendment_number&.match?(/^[A-Z]+$/)
296
- "#{id.base_identifier} AMD #{id.amendment_number}"
297
- else
298
- "#{id.base_identifier} AMD#{id.amendment_number}"
299
- end
300
- elsif id.amendment_year
301
- "#{id.separator}A#{id.amendment_number}:#{id.amendment_year}"
302
- elsif id.amendment_number&.match?(/^[A-Z]+$/)
303
- "AMD #{id.amendment_number}"
292
+ base = id.base_identifier ? id.base_identifier.to_s : ""
293
+
294
+ if id.amd_suffix_form
295
+ # Trailing " AMD5" / " AMD AA" suffix form
296
+ suffix = if id.amendment_number&.match?(/^[A-Z]+$/)
297
+ " AMD #{id.amendment_number}"
298
+ else
299
+ " AMD#{id.amendment_number}"
300
+ end
301
+ base.empty? ? suffix.lstrip : "#{base}#{suffix}"
304
302
  else
305
- "AMD#{id.amendment_number}"
303
+ # Compact "+A5" / "/A5" join form, with an optional ":year"
304
+ compact = "#{id.separator}A#{id.amendment_number}"
305
+ compact += ":#{id.amendment_year}" if id.amendment_year
306
+ "#{base}#{compact}"
306
307
  end
307
308
  end
308
309
 
@@ -408,15 +409,16 @@ module Pubid
408
409
 
409
410
  id.identifiers[1..].each do |idd|
410
411
  if idd.is_a?(Identifiers::Amendment)
411
- if idd.amendment_year
412
- sep = idd.separator || "+"
413
- result += "#{sep}A#{idd.amendment_number}:#{idd.amendment_year}"
414
- else
412
+ if idd.amd_suffix_form
415
413
  result += if idd.amendment_number&.match?(/^[A-Z]+$/)
416
414
  " AMD #{idd.amendment_number}"
417
415
  else
418
416
  " AMD#{idd.amendment_number}"
419
417
  end
418
+ else
419
+ sep = idd.separator || "+"
420
+ result += "#{sep}A#{idd.amendment_number}"
421
+ result += ":#{idd.amendment_year}" if idd.amendment_year
420
422
  end
421
423
  elsif idd.is_a?(Identifiers::Corrigendum)
422
424
  sep = idd.separator || "+"
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Builds an IALA identifier object from the Parslet parse tree.
6
+ class Builder
7
+ def build(parsed)
8
+ hash = parsed
9
+ type_letter = stringify(hash[:type_letter])&.upcase
10
+
11
+ attrs = {
12
+ number: build_number(hash),
13
+ edition: stringify(hash.dig(:edition, :edition_value)),
14
+ language: stringify(hash.dig(:language_group, :language))&.upcase,
15
+ }.compact
16
+
17
+ Iala.identifier_klass_for_type_letter(type_letter).new(**attrs)
18
+ end
19
+
20
+ def self.build(parsed)
21
+ new.build(parsed)
22
+ end
23
+
24
+ private
25
+
26
+ # Compose "1070" or "0126-1" from doc_number + optional subpart.
27
+ def build_number(hash)
28
+ base = stringify(hash[:doc_number])
29
+ return base unless hash[:subpart]
30
+
31
+ subpart_str = subpart_to_s(hash[:subpart])
32
+ subpart_str.empty? ? base : "#{base}-#{subpart_str}"
33
+ end
34
+
35
+ # The subpart capture may be an array of {subpart_number: "1"},
36
+ # {subpart_number: "9"}, … (repeated matches). Join with "-".
37
+ def subpart_to_s(subpart)
38
+ return stringify(subpart).to_s unless subpart.is_a?(Array)
39
+
40
+ subpart.filter_map { |h| h.is_a?(Hash) ? stringify(h[:subpart_number]) : nil }.join("-")
41
+ end
42
+
43
+ def stringify(value)
44
+ return nil if value.nil?
45
+
46
+ str = value.to_s
47
+ str.empty? ? nil : str
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Base class for all IALA identifiers. Canonical name
6
+ # Pubid::Iala::Identifier; every concrete IALA identifier
7
+ # (Identifiers::*) descends from it, and Identifiers::Base — aliased
8
+ # at the foot of identifiers/base.rb — points back to it.
9
+ #
10
+ # IALA identifiers have the form:
11
+ # [IALA ]{S|R|G|M|C|X|P}<4-digit number>[-<subpart>][ Ed <edition>][ (<LangLetter>)]
12
+ #
13
+ # Examples:
14
+ # S1070
15
+ # IALA S1070 Ed 2.0
16
+ # R1016:ed2.0(F)
17
+ # C0103-1 Ed 3.0
18
+ class Identifier < ::Pubid::Identifier
19
+ # Parse an IALA identifier string into an identifier object.
20
+ # @param identifier [String]
21
+ # @return [Pubid::Iala::Identifier]
22
+ # @raise [Parslet::ParseFailed] If parsing fails
23
+ def self.parse(identifier)
24
+ parsed = Parser.parse(identifier)
25
+ Builder.build(parsed)
26
+ rescue Parslet::ParseFailed => e
27
+ raise "Failed to parse IALA identifier '#{identifier}': #{e.message}"
28
+ end
29
+
30
+ attribute :publisher, :string, default: "IALA"
31
+ attribute :number, :string # "1070", "0126", "0103-1"
32
+ attribute :edition, :string # "2.0", "1.3", nil
33
+ attribute :language, :string # "E", "F", "S", "C", "A", "R", nil
34
+
35
+ IALA_TYPE_MAP = {
36
+ "pubid:iala:standard" => "Pubid::Iala::Identifiers::Standard",
37
+ "pubid:iala:recommendation" => "Pubid::Iala::Identifiers::Recommendation",
38
+ "pubid:iala:guideline" => "Pubid::Iala::Identifiers::Guideline",
39
+ "pubid:iala:manual" => "Pubid::Iala::Identifiers::Manual",
40
+ "pubid:iala:model-course" => "Pubid::Iala::Identifiers::ModelCourse",
41
+ "pubid:iala:report" => "Pubid::Iala::Identifiers::Report",
42
+ "pubid:iala:resolution" => "Pubid::Iala::Identifiers::Resolution",
43
+ }.freeze
44
+
45
+ key_value do
46
+ map "_type", to: :_type, polymorphic_map: IALA_TYPE_MAP
47
+ map "number", to: :number
48
+ map "edition", to: :edition
49
+ map "language", to: :language
50
+ end
51
+
52
+ def to_urn
53
+ UrnGenerator.new(self).generate
54
+ end
55
+
56
+ # Single-letter type code (S, R, G, …) — convenience accessor used
57
+ # by Renderer and UrnGenerator.
58
+ def type_letter
59
+ self.class.type[:short]
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Backward-compatible alias: IALA's base class used to be
4
+ # Pubid::Iala::Identifiers::Base. It is now Pubid::Iala::Identifier
5
+ # (defined in identifier.rb), which is loaded by the parent autoload.
6
+ require_relative "../identifier" unless defined?(Pubid::Iala::Identifier)
7
+
8
+ module Pubid
9
+ module Iala
10
+ module Identifiers
11
+ Base = Pubid::Iala::Identifier
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Guidelines (G series).
7
+ # Examples: G1015 Ed 2.2, G1078 Ed 2.1.
8
+ class Guideline < Base
9
+ def self.type
10
+ { key: :guideline, title: "Guideline", short: "G" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Manuals (M prefix, planned).
7
+ # Today manuals carry no code (NAVGUIDE, VTS Manual, …); M is
8
+ # reserved for future numbered manuals.
9
+ class Manual < Base
10
+ def self.type
11
+ { key: :manual, title: "Manual", short: "M" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Model Courses (C series).
7
+ # Examples: C0103-1 Ed 3.0, C2001-1 Ed 1.0.
8
+ class ModelCourse < Base
9
+ def self.type
10
+ { key: :"model-course", title: "Model Course", short: "C" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Recommendations (R series).
7
+ # Examples: R0103 Ed 3.1, R0126 Ed 2.0, R1024 Ed 1.0.
8
+ class Recommendation < Base
9
+ def self.type
10
+ { key: :recommendation, title: "Recommendation", short: "R" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Reports & Proceedings (X prefix, planned).
7
+ # Today reports carry no code; X is reserved for future numbered
8
+ # reports.
9
+ class Report < Base
10
+ def self.type
11
+ { key: :report, title: "Report", short: "X" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Resolutions and other Council publications (P prefix,
7
+ # planned). Today resolutions carry no code; P is reserved for
8
+ # future numbered resolutions.
9
+ class Resolution < Base
10
+ def self.type
11
+ { key: :resolution, title: "Resolution", short: "P" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ # IALA Standards (S series).
7
+ # Examples: S1010, S1020 Ed 2.0, S1070 Ed 2.0.
8
+ class Standard < Base
9
+ def self.type
10
+ { key: :standard, title: "Standard", short: "S" }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ module Identifiers
6
+ autoload :Base, "#{__dir__}/identifiers/base"
7
+ autoload :Standard, "#{__dir__}/identifiers/standard"
8
+ autoload :Recommendation, "#{__dir__}/identifiers/recommendation"
9
+ autoload :Guideline, "#{__dir__}/identifiers/guideline"
10
+ autoload :Manual, "#{__dir__}/identifiers/manual"
11
+ autoload :ModelCourse, "#{__dir__}/identifiers/model_course"
12
+ autoload :Report, "#{__dir__}/identifiers/report"
13
+ autoload :Resolution, "#{__dir__}/identifiers/resolution"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module Pubid
6
+ module Iala
7
+ # Parslet grammar for IALA identifiers.
8
+ #
9
+ # Accepts every shape that appears on the IALA website or cover page:
10
+ #
11
+ # S1070
12
+ # S1070 Ed 2.0
13
+ # IALA S1070 Ed 2.0
14
+ # S1070 Ed 2.0 (F)
15
+ # R1016:ed2.0(F)
16
+ # C0103-1
17
+ # C0103-1 Ed 3.0
18
+ class Parser < Parslet::Parser
19
+ include ::Pubid::Parser::CommonParseRules
20
+
21
+ root :identifier
22
+
23
+ rule(:space) { str(" ") }
24
+ rule(:space?) { space.maybe }
25
+ rule(:dash) { str("-") }
26
+ rule(:colon) { str(":") }
27
+ rule(:dot) { str(".") }
28
+ rule(:lparen) { str("(") }
29
+ rule(:rparen) { str(")") }
30
+
31
+ # Type letter — one of IALA's seven document classes. Captured to
32
+ # route to the right Identifiers::* subclass.
33
+ rule(:type_letter) do
34
+ (str("S") | str("R") | str("G") | str("M") |
35
+ str("C") | str("X") | str("P")).as(:type_letter)
36
+ end
37
+
38
+ # 4-digit document number, e.g. "1070", "0126".
39
+ rule(:doc_number) do
40
+ match("[0-9]").repeat(4, 4).as(:doc_number)
41
+ end
42
+
43
+ # Numeric sub-part suffix(es): "-1", "-9-10", "-11". The catalogue
44
+ # uses ranges like R0124-9-10 & 11 — that compound is captured
45
+ # verbatim and resolved by the caller, not here.
46
+ rule(:subpart) do
47
+ (dash >> digits.as(:subpart_number)).repeat(1).as(:subpart)
48
+ end
49
+
50
+ rule(:code) do
51
+ type_letter >> doc_number >> subpart.maybe
52
+ end
53
+
54
+ # Edition forms observed on covers and listing pages:
55
+ # " Ed 2.0" — cover-page human form
56
+ # ":ed2.0" — listing-page compact form
57
+ rule(:edition_human) do
58
+ (space >> str("Ed") >> space >> edition_value).as(:edition)
59
+ end
60
+
61
+ rule(:edition_compact) do
62
+ (colon >> str("ed") >> edition_value).as(:edition)
63
+ end
64
+
65
+ rule(:edition_value) do
66
+ (digits >> (dot >> digits).repeat).as(:edition_value)
67
+ end
68
+
69
+ rule(:edition) do
70
+ edition_human | edition_compact
71
+ end
72
+
73
+ # Language is a single uppercase letter inside parens: (E), (F), …
74
+ rule(:language) do
75
+ (space? >> lparen >>
76
+ (str("E") | str("F") | str("S") | str("C") | str("A") | str("R")).as(:language) >>
77
+ rparen).as(:language_group)
78
+ end
79
+
80
+ # Optional "IALA " prefix.
81
+ rule(:publisher) do
82
+ (str("IALA") >> space).maybe
83
+ end
84
+
85
+ rule(:identifier) do
86
+ publisher >> code >> edition.maybe >> language.maybe
87
+ end
88
+
89
+ # Parse a string and return the raw parslet tree.
90
+ # @param string [String]
91
+ # @return [Hash]
92
+ def self.parse(string)
93
+ raise ArgumentError, "IALA identifier string exceeds maximum length" if string.length > ::Pubid::MAX_INPUT_LENGTH
94
+
95
+ new.parse(string.strip)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Human-readable renderer for IALA identifiers.
6
+ #
7
+ # Produces strings like:
8
+ # "IALA S1070"
9
+ # "IALA S1070 Ed 2.0"
10
+ # "IALA R1016 Ed 2.0 (F)"
11
+ class Renderer < ::Pubid::Renderers::Base
12
+ def render(context: nil, **_opts)
13
+ id = @id
14
+
15
+ rendered = "#{id.publisher} #{id.type_letter}#{id.number}"
16
+ rendered << " Ed #{id.edition}" if id.edition
17
+ rendered << " (#{id.language})" if id.language
18
+ rendered
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Generates URN strings for IALA identifiers using the Maritime Resource
6
+ # Name (MRN) namespace that IALA prints on its cover pages.
7
+ #
8
+ # Format: urn:mrn:iala:pub:<type-lower><number>[:ed<edition>][:<lang-lower>]
9
+ # Example: urn:mrn:iala:pub:s1070:ed2.0
10
+ # urn:mrn:iala:pub:r1016:ed2.0:f
11
+ class UrnGenerator
12
+ attr_reader :identifier
13
+
14
+ def initialize(identifier)
15
+ @identifier = identifier
16
+ end
17
+
18
+ def generate
19
+ parts = ["urn:mrn:iala:pub", code_segment]
20
+ parts << "ed#{identifier.edition}" if identifier.edition
21
+ parts << identifier.language.downcase if identifier.language
22
+ parts.join(":")
23
+ end
24
+
25
+ private
26
+
27
+ def code_segment
28
+ "#{identifier.type_letter.downcase}#{identifier.number}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ # Parses IALA MRN URNs back into identifiers.
6
+ #
7
+ # UrnGenerator emits:
8
+ # urn:mrn:iala:pub:<type-lower><number>[:ed<edition>][:<lang-lower>]
9
+ #
10
+ # Examples:
11
+ # urn:mrn:iala:pub:s1070:ed2.0 → IALA S1070 Ed 2.0
12
+ # urn:mrn:iala:pub:r1016:ed2.0:f → IALA R1016 Ed 2.0 (F)
13
+ # urn:mrn:iala:pub:c0103-1 → IALA C0103-1
14
+ class UrnParser < Pubid::UrnParser::Base
15
+ PREFIX = "urn:mrn:iala:pub:".freeze
16
+
17
+ def parse_urn(urn)
18
+ body = strip_namespace(urn)
19
+ parts = split_parts(body)
20
+ code = parts.fetch(0)
21
+
22
+ edition = nil
23
+ language = nil
24
+ parts.drop(1).each do |seg|
25
+ if seg.start_with?("ed")
26
+ edition = seg.sub(/\Aed/, "")
27
+ elsif seg.length == 1 && seg.match?(/\A[a-z]\z/i)
28
+ language = seg.upcase
29
+ end
30
+ end
31
+
32
+ text = "IALA #{code.upcase}"
33
+ text += " Ed #{edition}" if edition
34
+ text += " (#{language})" if language
35
+ flavor_parse(text)
36
+ end
37
+
38
+ private
39
+
40
+ def strip_namespace(urn)
41
+ unless urn.downcase.start_with?(PREFIX)
42
+ raise Pubid::UrnParser::Errors::ParseError,
43
+ "Invalid IALA URN: #{urn.inspect}"
44
+ end
45
+
46
+ urn[PREFIX.length..]
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/pubid/iala.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Iala
5
+ autoload :Builder, "#{__dir__}/iala/builder"
6
+ autoload :Identifier, "#{__dir__}/iala/identifier"
7
+ autoload :Identifiers, "#{__dir__}/iala/identifiers"
8
+ autoload :Parser, "#{__dir__}/iala/parser"
9
+ autoload :Renderer, "#{__dir__}/iala/renderer"
10
+ autoload :UrnGenerator, "#{__dir__}/iala/urn_generator"
11
+ autoload :UrnParser, "#{__dir__}/iala/urn_parser"
12
+
13
+ # Parse an IALA identifier string into an identifier object.
14
+ # @param identifier [String] The IALA identifier string to parse
15
+ # @return [Pubid::Iala::Identifier]
16
+ def self.parse(identifier)
17
+ Identifier.parse(identifier)
18
+ end
19
+
20
+ # Per-flavor format registry: inherits global formats, overrides :human
21
+ Identifiers::Base.format_registry = FormatRegistry.new(parent: ::Pubid::Identifier.format_registry)
22
+ Identifiers::Base.format_registry.register(:human, renderer: Iala::Renderer)
23
+
24
+ # Auto-discover all identifier types from the Identifiers namespace.
25
+ # @return [Array<Class>] identifier classes (Pubid::Identifier subclasses)
26
+ def self.identifier_types
27
+ @identifier_types ||= Identifiers.constants
28
+ .filter_map { |c| begin; Identifiers.const_get(c); rescue NameError; nil; end }
29
+ .select { |c| c.is_a?(Class) && c < Pubid::Identifier }
30
+ .reject { |c| c == Identifiers::Base }
31
+ end
32
+
33
+ # Build typed stage index from identifier types
34
+ # @return [Array<Pubid::Components::TypedStage>]
35
+ def self.all_typed_stages
36
+ @all_typed_stages ||= identifier_types.flat_map do |klass|
37
+ klass.const_defined?(:TYPED_STAGES) ? klass.const_get(:TYPED_STAGES) : []
38
+ end
39
+ end
40
+
41
+ # Lookup: type code -> identifier class
42
+ # @param code [String, Symbol]
43
+ # @return [Class, nil]
44
+ def self.locate_type(code)
45
+ identifier_types.find { |t| t.type[:key].to_s == code.to_s }
46
+ end
47
+
48
+ # Lookup: abbreviation -> typed stage
49
+ # @param abbr [String, Symbol]
50
+ # @return [Pubid::Components::TypedStage, nil]
51
+ def self.locate_stage(abbr)
52
+ abbr_str = abbr.to_s.upcase
53
+ all_typed_stages.find { |s| s.abbr.any? { |a| a.to_s.upcase == abbr_str } }
54
+ end
55
+
56
+ # Look up an identifier class by its IALA type letter (S, R, G, M, C, X, P).
57
+ # @param letter [String]
58
+ # @return [Class<Identifiers::Base>]
59
+ def self.identifier_klass_for_type_letter(letter)
60
+ @by_letter ||= identifier_types.to_h { |klass| [klass.type[:short], klass] }
61
+ @by_letter.fetch(letter.to_s.upcase)
62
+ end
63
+ end
64
+ end
65
+
66
+ Pubid::Registry.register(:iala, Pubid::Iala)
@@ -120,6 +120,13 @@ module Pubid
120
120
  nil
121
121
  end
122
122
 
123
+ # The underlying standard, with amendment / corrigendum / Expert-commentary
124
+ # / Flex-version wrappers peeled recursively. Wrapper subclasses override
125
+ # this; a plain identifier is its own base document.
126
+ def base_document
127
+ self
128
+ end
129
+
123
130
  # @return [String, nil] publication year from the date component
124
131
  def year
125
132
  date&.year&.to_s
@@ -311,17 +318,48 @@ module Pubid
311
318
  Pubid::UrnGenerator::Base
312
319
  end
313
320
 
321
+ # Excluded attributes are nilled; every other value is passed through
322
+ # #exclude_from_nested so the exclusion also propagates into nested
323
+ # identifiers — wrapper types (adopted standards, consolidated amendments,
324
+ # expert-commentary wrappers) delegate their date to an inner identifier
325
+ # rather than storing it in their own attribute.
314
326
  def exclude(*args)
327
+ # :amendment / :supplement are structural, not attributes — they reduce
328
+ # a supplemented identifier to the standard it wraps (#drop_supplements).
329
+ supplement_keys = args & %i[amendment supplement]
330
+ unless supplement_keys.empty?
331
+ return drop_supplements.exclude(*(args - supplement_keys))
332
+ end
333
+
315
334
  excluded_args = args.dup
316
335
  # Map :year to :date since identifiers store years inside date
317
336
  excluded_args << :date if excluded_args.delete(:year)
318
337
 
319
338
  attrs = self.class.attributes.each_with_object({}) do |(name, _), h|
320
- h[name] = excluded_args.include?(name) ? nil : public_send(name)
339
+ value = excluded_args.include?(name) ? nil : public_send(name)
340
+ h[name] = exclude_from_nested(value, args)
321
341
  end
322
342
  self.class.new(attrs)
323
343
  end
324
344
 
345
+ # The standard this identifier supplements, dropping its own supplement
346
+ # layer (one level). Overridden by ConsolidatedIdentifier / Amendment /
347
+ # Corrigendum; a non-supplement identifier supplements nothing, so it is
348
+ # returned unchanged.
349
+ def drop_supplements
350
+ self
351
+ end
352
+
353
+ # Fuzz-level equality: two identifiers match when they are equal after
354
+ # excluding the given aspects. `ignore` accepts the same symbols as #exclude
355
+ # (e.g. :date, :edition, :amendment). This is the primitive relaton uses to
356
+ # match a reference against catalogue hits at varying strictness.
357
+ def matches?(other, ignore: [])
358
+ return false unless other.is_a?(::Pubid::Identifier)
359
+
360
+ exclude(*ignore) == other.exclude(*ignore)
361
+ end
362
+
325
363
  def new_edition_of?(other)
326
364
  unless publisher == other.publisher
327
365
  raise ArgumentError,
@@ -362,6 +400,21 @@ module Pubid
362
400
 
363
401
  private
364
402
 
403
+ # Propagate an #exclude into a nested attribute value: recurse when it is
404
+ # (or contains) another identifier, otherwise return it unchanged.
405
+ # Components and scalars are copied as-is. Passes the original args so
406
+ # nested identifiers re-apply the same :year->:date mapping themselves.
407
+ def exclude_from_nested(value, args)
408
+ case value
409
+ when ::Pubid::Identifier
410
+ value.exclude(*args)
411
+ when Array
412
+ value.map { |item| exclude_from_nested(item, args) }
413
+ else
414
+ value
415
+ end
416
+ end
417
+
365
418
  def build_rendering_context(_renderer, format:, with_edition: false,
366
419
  lang: :en, lang_single: false,
367
420
  stage_format_long: nil, with_date: nil,
data/lib/pubid/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pubid
4
- VERSION = "2.0.0.pre.alpha.5"
4
+ VERSION = "2.0.0.pre.alpha.6"
5
5
  end
data/lib/pubid.rb CHANGED
@@ -95,6 +95,7 @@ module Pubid
95
95
  autoload :Idf, "pubid/idf"
96
96
  autoload :Iec, "pubid/iec"
97
97
  autoload :Ieee, "pubid/ieee"
98
+ autoload :Iala, "pubid/iala"
98
99
  autoload :Iho, "pubid/iho"
99
100
  autoload :Iso, "pubid/iso"
100
101
  autoload :Itu, "pubid/itu"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pubid
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0.pre.alpha.5
4
+ version: 2.0.0.pre.alpha.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-06 00:00:00.000000000 Z
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -335,6 +335,22 @@ files:
335
335
  - lib/pubid/export/result.rb
336
336
  - lib/pubid/format_detector.rb
337
337
  - lib/pubid/format_registry.rb
338
+ - lib/pubid/iala.rb
339
+ - lib/pubid/iala/builder.rb
340
+ - lib/pubid/iala/identifier.rb
341
+ - lib/pubid/iala/identifiers.rb
342
+ - lib/pubid/iala/identifiers/base.rb
343
+ - lib/pubid/iala/identifiers/guideline.rb
344
+ - lib/pubid/iala/identifiers/manual.rb
345
+ - lib/pubid/iala/identifiers/model_course.rb
346
+ - lib/pubid/iala/identifiers/recommendation.rb
347
+ - lib/pubid/iala/identifiers/report.rb
348
+ - lib/pubid/iala/identifiers/resolution.rb
349
+ - lib/pubid/iala/identifiers/standard.rb
350
+ - lib/pubid/iala/parser.rb
351
+ - lib/pubid/iala/renderer.rb
352
+ - lib/pubid/iala/urn_generator.rb
353
+ - lib/pubid/iala/urn_parser.rb
338
354
  - lib/pubid/identifier.rb
339
355
  - lib/pubid/identifier_metadata.rb
340
356
  - lib/pubid/idf.rb