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 +4 -4
- data/lib/pubid/bsi/builder.rb +41 -48
- data/lib/pubid/bsi/identifiers/amendment.rb +28 -0
- data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +11 -1
- data/lib/pubid/bsi/identifiers/corrigendum.rb +24 -0
- data/lib/pubid/bsi/identifiers/expert_commentary.rb +5 -0
- data/lib/pubid/bsi/identifiers/flex.rb +4 -0
- data/lib/pubid/bsi/parser.rb +19 -5
- data/lib/pubid/bsi/renderer.rb +19 -17
- data/lib/pubid/iala/builder.rb +51 -0
- data/lib/pubid/iala/identifier.rb +63 -0
- data/lib/pubid/iala/identifiers/base.rb +14 -0
- data/lib/pubid/iala/identifiers/guideline.rb +15 -0
- data/lib/pubid/iala/identifiers/manual.rb +16 -0
- data/lib/pubid/iala/identifiers/model_course.rb +15 -0
- data/lib/pubid/iala/identifiers/recommendation.rb +15 -0
- data/lib/pubid/iala/identifiers/report.rb +16 -0
- data/lib/pubid/iala/identifiers/resolution.rb +16 -0
- data/lib/pubid/iala/identifiers/standard.rb +15 -0
- data/lib/pubid/iala/identifiers.rb +16 -0
- data/lib/pubid/iala/parser.rb +99 -0
- data/lib/pubid/iala/renderer.rb +22 -0
- data/lib/pubid/iala/urn_generator.rb +32 -0
- data/lib/pubid/iala/urn_parser.rb +50 -0
- data/lib/pubid/iala.rb +66 -0
- data/lib/pubid/identifier.rb +54 -1
- data/lib/pubid/version.rb +1 -1
- data/lib/pubid.rb +1 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b1fa5f38da07bc9a507c0c507ba9ddfc8f6541ab9f4b6793b09abe302ab8849
|
|
4
|
+
data.tar.gz: 63b7eba9c9942c177e95b9c78a09462679a11acde4fd6a2c2e486848fb43ca93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 915034be3882a43991ce3c0f934c4097f216f1fdac35dcb59ed6a0decb639dd4a19882de4f28940d4f29e6e9bb351de3383b07bd95cc0dc2a53bb4ad875f072d
|
|
7
|
+
data.tar.gz: 1cb7e935accc6e66bed8b599d25605b26cd415547c1ddec9ba2ecf1b7183883102c99078b830b2de9da436bc17613db042b5fc05e0d7d5515786fae71592aeb8
|
data/lib/pubid/bsi/builder.rb
CHANGED
|
@@ -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
|
-
#
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
#
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
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.
|
|
1171
|
-
adopted_str = adopted_str.sub(/
|
|
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.
|
|
1177
|
+
elsif adopted_str.match?(/excomm \(/i)
|
|
1180
1178
|
# Extract topic from "ExComm (Fire)"
|
|
1181
|
-
topic_match = adopted_str.match(/
|
|
1182
|
-
adopted_str = adopted_str.sub(/
|
|
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?(/
|
|
1191
|
+
elsif adopted_str.match?(/excomm\s*$/i)
|
|
1194
1192
|
# Abbreviated form "ExComm" at the end
|
|
1195
|
-
adopted_str = adopted_str.sub(/
|
|
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+))
|
|
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
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
#
|
|
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
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
data/lib/pubid/bsi/parser.rb
CHANGED
|
@@ -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
|
-
|
|
522
|
-
(
|
|
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
|
|
data/lib/pubid/bsi/renderer.rb
CHANGED
|
@@ -289,20 +289,21 @@ module Pubid
|
|
|
289
289
|
end
|
|
290
290
|
|
|
291
291
|
def render_amendment(id)
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
"#{
|
|
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
|
-
|
|
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.
|
|
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)
|
data/lib/pubid/identifier.rb
CHANGED
|
@@ -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
|
-
|
|
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
data/lib/pubid.rb
CHANGED
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.
|
|
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-
|
|
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
|