pubid 2.0.20 → 2.0.21
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/data/iho/s4-reserved-specs.yaml +15 -0
- data/data/parg/bipm.json +1 -1
- data/data/parg/bipm.parg +46 -11
- data/data/parg/iho.json +1 -1
- data/data/parg/iho.parg +128 -11
- data/data/parg/plateau.json +1 -1
- data/lib/pubid/bipm/CLAUDE.md +6 -1
- data/lib/pubid/bipm/builder.rb +20 -1
- data/lib/pubid/bipm/renderer.rb +14 -2
- data/lib/pubid/bipm/urn_generator.rb +80 -14
- data/lib/pubid/bipm/urn_parser.rb +70 -6
- data/lib/pubid/ecma/CLAUDE.md +12 -0
- data/lib/pubid/ecma/identifier.rb +1 -2
- data/lib/pubid/ecma/parser.rb +49 -0
- data/lib/pubid/iec/CLAUDE.md +5 -1
- data/lib/pubid/iec/identifier.rb +19 -70
- data/lib/pubid/iec/identifiers/fragment_identifier.rb +3 -3
- data/lib/pubid/iec/single_identifier.rb +69 -4
- data/lib/pubid/iec/supplement_identifier.rb +52 -76
- data/lib/pubid/iho/CLAUDE.md +52 -0
- data/lib/pubid/iho/builder.rb +105 -2
- data/lib/pubid/iho/identifiers/base.rb +52 -1
- data/lib/pubid/iho/pubid_urn_schema.rb +11 -4
- data/lib/pubid/iho/renderer.rb +39 -2
- data/lib/pubid/iho/reserved_specs.rb +54 -0
- data/lib/pubid/iho/urn_generator.rb +6 -0
- data/lib/pubid/iho/urn_parser.rb +50 -9
- data/lib/pubid/iho.rb +1 -0
- data/lib/pubid/nist/CLAUDE.md +11 -0
- data/lib/pubid/nist/preprocessor.rb +16 -0
- data/lib/pubid/plateau/CLAUDE.md +35 -0
- data/lib/pubid/plateau/builder.rb +4 -0
- data/lib/pubid/plateau/identifiers/handbook.rb +10 -2
- data/lib/pubid/plateau/pubid_urn_schema.rb +1 -0
- data/lib/pubid/plateau/renderer.rb +6 -1
- data/lib/pubid/sae/CLAUDE.md +11 -2
- data/lib/pubid/version.rb +1 -1
- metadata +4 -2
|
@@ -22,15 +22,80 @@ module Pubid
|
|
|
22
22
|
# their own (Identifiers::Base and SupplementIdentifier).
|
|
23
23
|
#
|
|
24
24
|
# That is a structural exclusion, not a denylist of no-op converters that
|
|
25
|
-
# a later key can be forgotten from
|
|
26
|
-
#
|
|
25
|
+
# a later key can be forgotten from. The date keys live here for the
|
|
26
|
+
# same reason: the wrappers delegate `date` too, and when the date maps
|
|
27
|
+
# sat on Iec::Identifier the denylist no-opped `year` but not
|
|
28
|
+
# `month`/`day`/`undated`, so `IEC CA 01:2025-10 CSV` emitted a second,
|
|
29
|
+
# top-level "month" beside the one inside "base".
|
|
27
30
|
#
|
|
28
|
-
#
|
|
29
|
-
# the scalar, and ::Pubid::Identifier#to_hash drops a nil.
|
|
31
|
+
# number/part/subpart are plain strings, so no converter is needed:
|
|
32
|
+
# lutaml writes the scalar, and ::Pubid::Identifier#to_hash drops a nil.
|
|
33
|
+
# The date keys keep converters, because they split one `date`
|
|
34
|
+
# attribute across four keys.
|
|
30
35
|
key_value do
|
|
31
36
|
map "number", to: :number
|
|
32
37
|
map "part", to: :part
|
|
33
38
|
map "subpart", to: :subpart
|
|
39
|
+
# date serialized flat as year/month/day, nils omitted.
|
|
40
|
+
map "year", with: { to: :year_to_kv, from: :year_from_kv }
|
|
41
|
+
map "month", with: { to: :month_to_kv, from: :month_from_kv }
|
|
42
|
+
map "day", with: { to: :day_to_kv, from: :day_from_kv }
|
|
43
|
+
# IEC undated reference (e.g. IEC 60050:--). The `false` default is
|
|
44
|
+
# omitted; only the meaningful `true` round-trips via to_hash.
|
|
45
|
+
map "undated", with: { to: :undated_to_kv, from: :undated_from_kv }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- date serialized flat as year/month/day ---
|
|
49
|
+
def year_to_kv(model, doc)
|
|
50
|
+
y = model.date&.year
|
|
51
|
+
return if y.nil? || y.to_s.empty?
|
|
52
|
+
|
|
53
|
+
doc.add_child(Lutaml::KeyValue::DataModel::Element.new("year", y.to_s))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def year_from_kv(model, value)
|
|
57
|
+
return if value.nil? || value.to_s.empty?
|
|
58
|
+
|
|
59
|
+
(model.date ||= ::Pubid::Components::Date.new).year = value.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def month_to_kv(model, doc)
|
|
63
|
+
m = model.date&.month
|
|
64
|
+
return if m.nil? || m.to_s.empty?
|
|
65
|
+
|
|
66
|
+
doc.add_child(Lutaml::KeyValue::DataModel::Element.new("month", m.to_s))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def month_from_kv(model, value)
|
|
70
|
+
return if value.nil? || value.to_s.empty?
|
|
71
|
+
|
|
72
|
+
(model.date ||= ::Pubid::Components::Date.new).month = value.to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def day_to_kv(model, doc)
|
|
76
|
+
d = model.date&.day
|
|
77
|
+
return if d.nil? || d.to_s.empty?
|
|
78
|
+
|
|
79
|
+
doc.add_child(Lutaml::KeyValue::DataModel::Element.new("day", d.to_s))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def day_from_kv(model, value)
|
|
83
|
+
return if value.nil? || value.to_s.empty?
|
|
84
|
+
|
|
85
|
+
(model.date ||= ::Pubid::Components::Date.new).day = value.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# --- date undated flag (IEC undated reference, e.g. IEC 60050:--) ---
|
|
89
|
+
# The `false` default is omitted; only the meaningful `true` round-trips
|
|
90
|
+
# via to_hash / from_hash.
|
|
91
|
+
def undated_to_kv(model, doc)
|
|
92
|
+
return unless model.date&.undated?
|
|
93
|
+
|
|
94
|
+
doc.add_child(Lutaml::KeyValue::DataModel::Element.new("undated", true))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def undated_from_kv(model, value)
|
|
98
|
+
(model.date ||= ::Pubid::Components::Date.new).undated = value.to_s == "true"
|
|
34
99
|
end
|
|
35
100
|
|
|
36
101
|
# Generate URN for this identifier
|
|
@@ -33,26 +33,11 @@ module Pubid
|
|
|
33
33
|
def base_from_kv(model, value)
|
|
34
34
|
return unless value
|
|
35
35
|
|
|
36
|
-
base
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
# is always a concrete type (InternationalStandard, etc.). Re-engage the
|
|
42
|
-
# synthetic-base state so the supplement renders in standalone form
|
|
43
|
-
# (publisher/TYPE base-number-supp-number) instead of attached form.
|
|
44
|
-
if base.instance_of?(::Pubid::Iec::SingleIdentifier)
|
|
45
|
-
model.mark_synthetic_standalone!
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Re-engage the standalone synthetic-base state on a deserialized
|
|
50
|
-
# supplement. At this point `number` already holds the supplement number
|
|
51
|
-
# (set from the "number" key before "base"), so preserve it as the
|
|
52
|
-
# supplement number and flag the synthetic base.
|
|
53
|
-
def mark_synthetic_standalone!
|
|
54
|
-
@synthetic_base = true
|
|
55
|
-
@supplement_number = @number
|
|
36
|
+
# A bare SingleIdentifier here is the synthetic base of a standalone
|
|
37
|
+
# supplement; #synthetic_base? reads that class, so nothing else needs
|
|
38
|
+
# restoring. "number" already holds the supplement number, which is
|
|
39
|
+
# what ensure_base stores on the parse path too.
|
|
40
|
+
model.base = ::Pubid::Iec::Identifier.from_hash(value)
|
|
56
41
|
end
|
|
57
42
|
|
|
58
43
|
# Override base getter to ensure it's always created for standalone supplements
|
|
@@ -60,29 +45,21 @@ module Pubid
|
|
|
60
45
|
@base || ensure_base
|
|
61
46
|
end
|
|
62
47
|
|
|
63
|
-
#
|
|
48
|
+
# A standalone supplement holds the grammar's layout (base number in
|
|
49
|
+
# `number`, supplement number in `part`) until #ensure_base moves it.
|
|
50
|
+
# Not every construction path goes through one builder branch ("IEC FDAM
|
|
51
|
+
# 60038-1" does not, nor a hand-built Amendment), so these readers make
|
|
52
|
+
# the move before they answer: the result never depends on which reader
|
|
53
|
+
# ran first, and #exclude, which copies through the readers, copies the
|
|
54
|
+
# moved layout. They only TRIGGER the move; they return the stored value.
|
|
64
55
|
def number
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# For standalone supplements, trigger base creation first
|
|
69
|
-
ensure_base if @synthetic_base.nil? && publisher && part
|
|
70
|
-
|
|
71
|
-
if @synthetic_base
|
|
72
|
-
@supplement_number || @number
|
|
73
|
-
else
|
|
74
|
-
super
|
|
75
|
-
end
|
|
56
|
+
settle_standalone_layout
|
|
57
|
+
super
|
|
76
58
|
end
|
|
77
59
|
|
|
78
|
-
# Override part getter for standalone supplements (part becomes nil after swap)
|
|
79
60
|
def part
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
nil
|
|
83
|
-
else
|
|
84
|
-
super
|
|
85
|
-
end
|
|
61
|
+
settle_standalone_layout
|
|
62
|
+
super
|
|
86
63
|
end
|
|
87
64
|
|
|
88
65
|
# Delegate publisher and copublishers to base whenever there is
|
|
@@ -102,59 +79,52 @@ module Pubid
|
|
|
102
79
|
base ? base.copublishers : @copublishers
|
|
103
80
|
end
|
|
104
81
|
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
82
|
+
# Synthesise the base of a supplement that names none, and return it.
|
|
83
|
+
#
|
|
84
|
+
# A standalone supplement ("IEC/FDAM 60038-1") arrives from the grammar
|
|
85
|
+
# with the BASE number in `number` and the SUPPLEMENT number in `part`.
|
|
86
|
+
# This moves the base number onto a bare SingleIdentifier base and stores
|
|
87
|
+
# the supplement number in `number`, so the attributes hold what they
|
|
88
|
+
# mean — the layout `from_hash` rebuilds from the serialized hash. A
|
|
89
|
+
# supplement with no part (a bare "+AMD1:2008" in a consolidation,
|
|
90
|
+
# "IEC/AMD 1:2017") gets a self-base whose number equals its own; its
|
|
91
|
+
# `number` is left as it is.
|
|
92
|
+
#
|
|
93
|
+
# The base deliberately gets NO typed_stage. A stage without a code is
|
|
94
|
+
# not serialized, so `from_hash` cannot restore one, and a stage set
|
|
95
|
+
# here alone made `parse != from_hash(to_hash)` — `==` compares `base`
|
|
96
|
+
# (`lib/pubid/iec/CLAUDE.md`).
|
|
108
97
|
def ensure_base
|
|
109
98
|
return @base if @base
|
|
110
99
|
|
|
111
|
-
#
|
|
100
|
+
# publisher/copublishers read through `base`; this guard makes them
|
|
101
|
+
# read the supplement's own value while the base is being built.
|
|
112
102
|
@ensuring_base = true
|
|
103
|
+
return nil unless publisher && (@number || @part)
|
|
113
104
|
|
|
114
|
-
# For standalone supplements like "IEC/FDAM 60038-1"
|
|
115
|
-
# The supplement number is in the `part` attribute, while `number` is the base number
|
|
116
|
-
# We need to create a synthetic base and use part as the supplement number
|
|
117
|
-
return nil unless publisher && (number || part)
|
|
118
|
-
|
|
119
|
-
# Capture original values BEFORE marking as synthetic
|
|
120
|
-
# (otherwise our getters will return swapped values)
|
|
121
|
-
base_number = number
|
|
122
|
-
supplement_number = part
|
|
123
|
-
|
|
124
|
-
# NOW mark that this is a synthetic base (after capturing values)
|
|
125
|
-
@synthetic_base = true
|
|
126
|
-
|
|
127
|
-
# Use SingleIdentifier which has typed_stage attribute
|
|
128
105
|
base = SingleIdentifier.new
|
|
129
106
|
base.publisher = publisher
|
|
130
|
-
base.number =
|
|
131
|
-
base.
|
|
132
|
-
base.subpart = subpart if subpart
|
|
107
|
+
base.number = @number if @number
|
|
108
|
+
base.subpart = @subpart if @subpart
|
|
133
109
|
base.date = date if date
|
|
134
110
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
type_code: :is,
|
|
140
|
-
stage_code: :undated,
|
|
141
|
-
)
|
|
111
|
+
if @part
|
|
112
|
+
@number = @part
|
|
113
|
+
@part = nil
|
|
114
|
+
end
|
|
142
115
|
|
|
143
116
|
@base = base
|
|
144
|
-
|
|
145
|
-
# Store the supplement number for getter override
|
|
146
|
-
# The original number is the base number (moved to base)
|
|
147
|
-
# The original part is the supplement number (what we want to return)
|
|
148
|
-
@supplement_number = supplement_number
|
|
149
|
-
|
|
150
|
-
@base
|
|
151
117
|
ensure
|
|
152
118
|
@ensuring_base = false
|
|
153
119
|
end
|
|
154
120
|
|
|
155
|
-
#
|
|
121
|
+
# True when the base is the bare SingleIdentifier that #ensure_base (or
|
|
122
|
+
# from_hash) synthesised for a standalone supplement; a real attached
|
|
123
|
+
# base is always a concrete type (InternationalStandard, SheetIdentifier
|
|
124
|
+
# …). Read from the base itself, so it survives #exclude, which copies
|
|
125
|
+
# `base` into a new object.
|
|
156
126
|
def synthetic_base?
|
|
157
|
-
|
|
127
|
+
base.instance_of?(::Pubid::Iec::SingleIdentifier)
|
|
158
128
|
end
|
|
159
129
|
|
|
160
130
|
def to_s(**opts)
|
|
@@ -173,6 +143,12 @@ module Pubid
|
|
|
173
143
|
|
|
174
144
|
segments.join(".")
|
|
175
145
|
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
def settle_standalone_layout
|
|
150
|
+
ensure_base if @base.nil? && @part && !@ensuring_base
|
|
151
|
+
end
|
|
176
152
|
end
|
|
177
153
|
end
|
|
178
154
|
end
|
data/lib/pubid/iho/CLAUDE.md
CHANGED
|
@@ -96,6 +96,58 @@ The compact face (`compact: true`) folds the code-adjacent components
|
|
|
96
96
|
onto the code: supplement and part hyphenated, volume as the slash
|
|
97
97
|
chain — `IHO M-2-2`, `IHO S-100-2 5.0.0`, `IHO P-5/2024`.
|
|
98
98
|
|
|
99
|
+
## S-4 loose-leaf sections and specs (owner rulings, 2026-11-11)
|
|
100
|
+
|
|
101
|
+
The S-4 loose-leaf publication (Parts A/B/C → Sections 100-600, Part C
|
|
102
|
+
100-500 → Regulations/Specifications cited `<letter>-<hundreds><seq>`)
|
|
103
|
+
is modeled ONE COMPONENT DEEP, not two tiers: the spec number IS the
|
|
104
|
+
leaf. `B-401` binds the letter into `part` ("B") and the number into
|
|
105
|
+
`spec` ("401"); the SECTION (the hundreds digit) is DERIVED —
|
|
106
|
+
`#spec_section` (`B-401` → `"400"`) — never stored, never emitted, and
|
|
107
|
+
the `00` slot (B-400) IS the section-level citation, so `S-4 Part B,
|
|
108
|
+
Section 400` ≡ `IHO S-4 B-400` (one identity, two spellings).
|
|
109
|
+
|
|
110
|
+
| component | attribute | canonical face | parse-only forms |
|
|
111
|
+
|--------------|--------------|---------------------|-------------------------------------|
|
|
112
|
+
| spec | `spec` | `B-401` (with part) | the spelled section (", Section 400") |
|
|
113
|
+
| spec sub | `spec_sub` | `.1` | — |
|
|
114
|
+
| spec variant | `spec_variant` | `(I)` | — |
|
|
115
|
+
|
|
116
|
+
- **Faces**: canonical compact `IHO S-4 B-401` (dated:
|
|
117
|
+
`IHO S-4 4.10.0 B-401` — the edition precedes the citation, as it
|
|
118
|
+
precedes the part). The citation face REPLACES the Part face in the
|
|
119
|
+
renderer (`compact: true` is a no-op on it — it is already compact).
|
|
120
|
+
Subs and variants combine (`B-351.1(I)` is in the 4.9.0 text).
|
|
121
|
+
- **Three forms parse to one identity**: the anchored citation, the
|
|
122
|
+
LONG form `S-4 Part B, Section 400[, B-401]` (normalizing parse; the
|
|
123
|
+
spelled section becomes `spec` alone, and beside a citation it only
|
|
124
|
+
cross-checks the citation's derived section — "Section 500, B-401"
|
|
125
|
+
raises) and the BARE `B-401` (implicit S-4 anchor; B only — bare
|
|
126
|
+
C-x stays the Circular Letter series, and the 3-digit `[1-6]xx`
|
|
127
|
+
shape keeps the B Bibliographic series (B-12) on the main branch).
|
|
128
|
+
`IHO B-401` (publisher + bare) parses to the spec too.
|
|
129
|
+
- **The anchor is load-bearing**: a spec citation requires the S-4
|
|
130
|
+
anchor (series S number 4 — the builder raises otherwise), a
|
|
131
|
+
lettered Part (A/B/C, not a numbered part), and no component
|
|
132
|
+
editions or S-4-foreign components (supplement/annex/appendix/
|
|
133
|
+
volume/issue); the citation letter must agree with a Part-rule
|
|
134
|
+
capture.
|
|
135
|
+
- **Reserved numbers** (`B-170`, `B-230`, `B-305`, `B-323` — "NOT
|
|
136
|
+
CURRENTLY USED" in the Ed 4.9.0 and 4.10.0 contents) parse and
|
|
137
|
+
resolve AS RESERVED, never invalid: `#reserved?` answers from
|
|
138
|
+
`data/iho/s4-reserved-specs.yaml` (edition-keyed, provenance in the
|
|
139
|
+
file; an edition-less reference is reserved when ANY seeded edition
|
|
140
|
+
reserves the number). `lib/pubid/iho/reserved_specs.rb` is the
|
|
141
|
+
loader (`Pubid::Iho::ReservedSpecs`).
|
|
142
|
+
- **URN atoms**: the citation is a child of its part — the letter rides
|
|
143
|
+
the existing `part-` atom (case preserved: `part-B`, never the
|
|
144
|
+
lowercase `part-b` sketch), then `spec-`, `specsub-`, `specvar-`
|
|
145
|
+
(the `appsub-` naming convention; the `:sub-1`/`:variant-i` in the
|
|
146
|
+
ruling's sketch are these atoms). `urn:iho:s:4:ed-4.10.0:part-B:
|
|
147
|
+
spec-401`; the section is not emitted. No `ed-`/`year-` after a
|
|
148
|
+
spec node (UrnParseError — the spec rides its publication's
|
|
149
|
+
edition); `specsub-`/`specvar-` only after `spec-`.
|
|
150
|
+
|
|
99
151
|
## Identity semantics
|
|
100
152
|
|
|
101
153
|
- The components are identity-bearing in `==`/`matches?`; a bare
|
data/lib/pubid/iho/builder.rb
CHANGED
|
@@ -18,18 +18,34 @@ module Pubid
|
|
|
18
18
|
part_edition part_year supplement_edition
|
|
19
19
|
supplement_year annex_edition annex_year
|
|
20
20
|
appendix_edition appendix_year volume_edition
|
|
21
|
-
volume_year
|
|
21
|
+
volume_year
|
|
22
|
+
spec spec_sub spec_variant spec_part
|
|
23
|
+
spec_section].freeze
|
|
22
24
|
# The independently-publishable components' own editions; the
|
|
23
25
|
# base edition context is mutually exclusive with them (owner
|
|
24
26
|
# ruling on the two publication modes).
|
|
25
27
|
COMPONENT_EDITIONS = %i[part_edition supplement_edition annex_edition
|
|
26
28
|
appendix_edition volume_edition].freeze
|
|
29
|
+
# The S-4 anchor: the loose-leaf publication every spec citation
|
|
30
|
+
# resolves through (bare "B-401" carries it implicitly).
|
|
31
|
+
S4_TYPE = "S"
|
|
32
|
+
S4_NUMBER = "4"
|
|
33
|
+
# A citation letter is one of S-4's lettered Parts; the section
|
|
34
|
+
# vocabulary (100-600, Part C 100-500) is theirs.
|
|
35
|
+
SPEC_LETTERS = %w[A B C].freeze
|
|
36
|
+
# Components an S-4 spec citation cannot carry: the citation is a
|
|
37
|
+
# leaf of the Part, not a wrappable publication.
|
|
38
|
+
SPEC_FOREIGN_COMPONENTS = %i[supplement annex appendix
|
|
39
|
+
appendix_subnumber volume issue].freeze
|
|
27
40
|
|
|
28
41
|
def build(parsed)
|
|
29
42
|
hash = parsed.is_a?(Array) ? parsed.reduce({}, :merge) : parsed
|
|
30
43
|
attrs = stringified_attrs(hash)
|
|
44
|
+
resolve_spec_citation!(attrs)
|
|
45
|
+
type = type_letter_from(hash, attrs)
|
|
46
|
+
assert_spec_anchor!(attrs, type)
|
|
31
47
|
assert_edition_mode!(attrs)
|
|
32
|
-
klass = Pubid::Iho.identifier_klass_for_type_letter(
|
|
48
|
+
klass = Pubid::Iho.identifier_klass_for_type_letter(type)
|
|
33
49
|
klass.new(**attrs)
|
|
34
50
|
end
|
|
35
51
|
|
|
@@ -39,6 +55,93 @@ module Pubid
|
|
|
39
55
|
|
|
40
56
|
private
|
|
41
57
|
|
|
58
|
+
# The citation captures resolve onto the model (see the grammar
|
|
59
|
+
# header): the citation letter folds into `part` (a Part-rule
|
|
60
|
+
# capture must agree), and the long form's spelled section becomes
|
|
61
|
+
# the spec alone — beside a citation it only cross-checks the
|
|
62
|
+
# citation's derived section ("Part B, Section 400, B-401" stores
|
|
63
|
+
# spec 401; "Part B, Section 500, B-401" is rejected).
|
|
64
|
+
def resolve_spec_citation!(attrs)
|
|
65
|
+
letter = attrs.delete(:spec_part)
|
|
66
|
+
spelled = attrs.delete(:spec_section)
|
|
67
|
+
attrs[:part] = merge_citation_letter!(attrs[:part], letter) if letter
|
|
68
|
+
apply_spelled_section!(attrs, spelled)
|
|
69
|
+
assert_citation_shape!(attrs) if attrs[:spec]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_spelled_section!(attrs, spelled)
|
|
73
|
+
return if spelled.nil?
|
|
74
|
+
return attrs[:spec] = spelled unless attrs[:spec]
|
|
75
|
+
|
|
76
|
+
return if spelled == spec_section_of(attrs[:spec])
|
|
77
|
+
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"the spelled section #{spelled} disagrees with the " \
|
|
80
|
+
"citation #{attrs[:part]}-#{attrs[:spec]}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def spec_section_of(spec)
|
|
84
|
+
"#{spec[0]}00"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def merge_citation_letter!(part, letter)
|
|
88
|
+
return letter if part.nil? || part == letter
|
|
89
|
+
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"the citation letter #{letter} conflicts with part #{part}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def assert_citation_shape!(attrs)
|
|
95
|
+
assert_citation_letter!(attrs)
|
|
96
|
+
assert_citation_leaf!(attrs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def assert_citation_letter!(attrs)
|
|
100
|
+
return if SPEC_LETTERS.include?(attrs[:part])
|
|
101
|
+
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"an S-4 spec citation needs a lettered Part (A/B/C), " \
|
|
104
|
+
"not #{attrs[:part].inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The citation is a leaf of its Part: it carries no component
|
|
108
|
+
# edition and none of the wrappable components.
|
|
109
|
+
def assert_citation_leaf!(attrs)
|
|
110
|
+
if attrs[:part_edition] || attrs[:part_year]
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"a spec citation rides the S-4 edition, not a part edition"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
foreign = attrs.slice(*SPEC_FOREIGN_COMPONENTS).compact.keys
|
|
116
|
+
return if foreign.empty?
|
|
117
|
+
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"an S-4 spec citation is a leaf of its Part and cannot " \
|
|
120
|
+
"carry #{foreign.join(', ')}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The series letter: a bare citation branch parse has no series
|
|
124
|
+
# capture, and its anchor is implicitly the S-4 publication (the
|
|
125
|
+
# number binds with it).
|
|
126
|
+
def type_letter_from(hash, attrs)
|
|
127
|
+
type = stringify(hash[:type])
|
|
128
|
+
return type if type
|
|
129
|
+
|
|
130
|
+
raise ArgumentError, "unknown IHO series" unless attrs[:spec]
|
|
131
|
+
|
|
132
|
+
attrs[:number] = S4_NUMBER
|
|
133
|
+
S4_TYPE
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def assert_spec_anchor!(attrs, type)
|
|
137
|
+
return unless attrs[:spec]
|
|
138
|
+
return if type == S4_TYPE && attrs[:number] == S4_NUMBER
|
|
139
|
+
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"an S-4 spec citation requires the S-4 anchor: " \
|
|
142
|
+
"#{type}-#{attrs[:number]} cannot carry spec #{attrs[:spec]}"
|
|
143
|
+
end
|
|
144
|
+
|
|
42
145
|
# The two publication modes are mutually exclusive: an identifier
|
|
43
146
|
# is either within-edition (a base edition its components ride) or
|
|
44
147
|
# separately-published (a component carries its own edition and
|
|
@@ -8,6 +8,11 @@ module Pubid
|
|
|
8
8
|
# IHO identifiers have the form:
|
|
9
9
|
# IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
|
|
10
10
|
# [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
|
|
11
|
+
# The S-4 loose-leaf publication adds the spec citation
|
|
12
|
+
# IHO S-4 [edition] <letter>-<hundreds><seq>[.sub][(variant)]
|
|
13
|
+
# ("IHO S-4 B-401", the long form "S-4 Part B, Section 400" and the
|
|
14
|
+
# bare "B-401" all parse to it; `spec` stores the leaf number, the
|
|
15
|
+
# citation letter rides `part`, the section is a derived reader).
|
|
11
16
|
#
|
|
12
17
|
# Every tail component is a first-class semantic component: a typed
|
|
13
18
|
# attribute, independently addressable, URN-encodable and matchable.
|
|
@@ -83,6 +88,15 @@ module Pubid
|
|
|
83
88
|
attribute :issue, :string
|
|
84
89
|
attribute :volume_edition, :string
|
|
85
90
|
attribute :volume_year, :string
|
|
91
|
+
# The S-4 loose-leaf spec citation (issue #691): `spec` is the leaf
|
|
92
|
+
# number ("401" of B-401), the citation letter rides `part` (the
|
|
93
|
+
# builder folds it in), the dotted sub and the parenthesized
|
|
94
|
+
# variant keep their own components. The SECTION (the hundreds
|
|
95
|
+
# digit) is never stored — a derived reader — and the `00` slot
|
|
96
|
+
# (B-400) IS the section-level citation.
|
|
97
|
+
attribute :spec, :string
|
|
98
|
+
attribute :spec_sub, :string
|
|
99
|
+
attribute :spec_variant, :string
|
|
86
100
|
|
|
87
101
|
# Polymorphic type map for lutaml::Model key_value serialization: maps
|
|
88
102
|
# each subclass's polymorphic_name to its class name so `from_hash` can
|
|
@@ -122,6 +136,9 @@ module Pubid
|
|
|
122
136
|
map "volume_edition", to: :volume_edition
|
|
123
137
|
map "volume_year", to: :volume_year
|
|
124
138
|
map "issue", to: :issue
|
|
139
|
+
map "spec", to: :spec
|
|
140
|
+
map "spec_sub", to: :spec_sub
|
|
141
|
+
map "spec_variant", to: :spec_variant
|
|
125
142
|
end
|
|
126
143
|
|
|
127
144
|
# from_hash is the shared polymorphic dispatch on Pubid::Identifier.
|
|
@@ -139,9 +156,40 @@ module Pubid
|
|
|
139
156
|
args = args.dup
|
|
140
157
|
args << :issue if args.include?(:volume)
|
|
141
158
|
args << :appendix_subnumber if args.include?(:appendix)
|
|
159
|
+
# The spec citation is the leaf: a reference that omits the spec
|
|
160
|
+
# nils its sub and variant with it.
|
|
161
|
+
args.push(:spec_sub, :spec_variant) if args.include?(:spec)
|
|
142
162
|
super
|
|
143
163
|
end
|
|
144
164
|
|
|
165
|
+
# The citation letter of an S-4 spec (nil unless the identifier IS
|
|
166
|
+
# a spec citation): the part carries the letter, the same way the
|
|
167
|
+
# long form spells it ("Part B" of "B-401").
|
|
168
|
+
def spec_part
|
|
169
|
+
spec && part
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The section an S-4 spec belongs to: the hundreds digit of the
|
|
173
|
+
# spec number, zero-padded (B-401 -> "400"; B-400 derives its own
|
|
174
|
+
# section). DERIVED, never stored, never emitted — the owner
|
|
175
|
+
# ruling keeps the spec number the leaf.
|
|
176
|
+
def spec_section
|
|
177
|
+
return nil unless spec
|
|
178
|
+
|
|
179
|
+
"#{spec[0]}00"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# True when the spec number is reserved ("NOT CURRENTLY USED") in
|
|
183
|
+
# the edition's S-4 contents (data/iho/s4-reserved-specs.yaml). An
|
|
184
|
+
# edition-less reference is reserved when any seeded edition
|
|
185
|
+
# reserves the number. A reserved citation parses and resolves AS
|
|
186
|
+
# RESERVED, never invalid (issue #691).
|
|
187
|
+
def reserved?
|
|
188
|
+
return false unless spec
|
|
189
|
+
|
|
190
|
+
ReservedSpecs.reserved?(edition, part, spec)
|
|
191
|
+
end
|
|
192
|
+
|
|
145
193
|
# Render the compact face: the code-adjacent components folded onto
|
|
146
194
|
# the code with separators — the supplement and the part hyphenated
|
|
147
195
|
# (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
|
|
@@ -153,7 +201,10 @@ module Pubid
|
|
|
153
201
|
def render(format: :human, **opts)
|
|
154
202
|
compact = opts.delete(:compact)
|
|
155
203
|
result = super
|
|
156
|
-
|
|
204
|
+
# A spec citation's canonical face IS the compact form (the
|
|
205
|
+
# citation is already folded onto the code: "IHO S-4 B-401"), so
|
|
206
|
+
# the compact flag is a no-op for it.
|
|
207
|
+
compact && format == :human && !spec ? compact_face(result) : result
|
|
157
208
|
end
|
|
158
209
|
|
|
159
210
|
# The shared mr_string path reads `edition&.number` (the base
|
|
@@ -13,10 +13,11 @@ module Pubid
|
|
|
13
13
|
# edition/year right after it — supplement, part, annex, appendix (the
|
|
14
14
|
# two-level Appendix D-2 keeps the subnumber as its own atom), volume
|
|
15
15
|
# (the unified locator, yearbook volumes included: `vol-2024`), issue.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
16
|
+
# The S-4 spec citation sits inside its part (`spec-`, `specsub-`,
|
|
17
|
+
# `specvar-`). Letters stay case-preserved (`part-A`). The legacy
|
|
18
|
+
# `y.` yearbook marker is decodable in the official URN parser, never
|
|
19
|
+
# emitted here: a periodical has no `year-` atom value, its volume
|
|
20
|
+
# carries the year. The official urn:mrn:iho:… form stays on #to_urn.
|
|
20
21
|
PubidUrnSchema = Pubid::PubidUrn.define("iho") do
|
|
21
22
|
atom "edition", "ed"
|
|
22
23
|
atom "year", "year"
|
|
@@ -26,6 +27,12 @@ module Pubid
|
|
|
26
27
|
atom "part", "part"
|
|
27
28
|
atom "part_edition", "parted"
|
|
28
29
|
atom "part_year", "party"
|
|
30
|
+
# The S-4 loose-leaf spec citation (issue #691): a child of the
|
|
31
|
+
# part (the citation letter rides `part-`); the section is
|
|
32
|
+
# derivable from the spec's hundreds digit — never an atom.
|
|
33
|
+
atom "spec", "spec"
|
|
34
|
+
atom "spec_sub", "specsub"
|
|
35
|
+
atom "spec_variant", "specvar"
|
|
29
36
|
atom "annex", "annex"
|
|
30
37
|
atom "annex_edition", "anned"
|
|
31
38
|
atom "annex_year", "annyear"
|
data/lib/pubid/iho/renderer.rb
CHANGED
|
@@ -19,6 +19,13 @@ module Pubid
|
|
|
19
19
|
# beside an edition is the date
|
|
20
20
|
# qualifier on standards,
|
|
21
21
|
# parenthesized — the only year face
|
|
22
|
+
# "IHO S-4 B-401" the S-4 spec citation (the long
|
|
23
|
+
# form "S-4 Part B, Section 400"
|
|
24
|
+
# parses to the same identity and
|
|
25
|
+
# renders compact: the citation
|
|
26
|
+
# replaces the Part face; the
|
|
27
|
+
# section is derivable, never
|
|
28
|
+
# printed)
|
|
22
29
|
#
|
|
23
30
|
# The renderer is registered as the `:human` format in the IHO format
|
|
24
31
|
# registry and invoked via `render(format: :human)`; the compact face
|
|
@@ -32,12 +39,17 @@ module Pubid
|
|
|
32
39
|
# edition (+ parenthesized year) right after it; the appendix joins
|
|
33
40
|
# its subnumber; the issue rides parenthesized after its volume;
|
|
34
41
|
# the base year prints only as the parenthesized with-year
|
|
35
|
-
# qualifier.
|
|
42
|
+
# qualifier. The S-4 spec citation face (" B-401") sits where the
|
|
43
|
+
# Part face sits — it REPLACES the Part face when a spec is
|
|
44
|
+
# present (the citation letter is the part), printing
|
|
45
|
+
# "<letter>-<number>" with the dotted sub and the parenthesized
|
|
46
|
+
# variant folded on.
|
|
36
47
|
FACES = [
|
|
37
48
|
[:edition, " %s"],
|
|
38
49
|
[:supplement, " Sup %s"], [:supplement_edition, " %s"],
|
|
39
50
|
[:supplement_year, " (%s)"],
|
|
40
51
|
[:part, " Part %s"], [:part_edition, " %s"], [:part_year, " (%s)"],
|
|
52
|
+
[:spec, " %s"],
|
|
41
53
|
[:annex, " Annex %s"], [:annex_edition, " %s"], [:annex_year, " (%s)"],
|
|
42
54
|
[:appendix, " Appendix %s"], [:appendix_subnumber, "-%s"],
|
|
43
55
|
[:appendix_edition, " %s"], [:appendix_year, " (%s)"],
|
|
@@ -50,11 +62,36 @@ module Pubid
|
|
|
50
62
|
id = @id
|
|
51
63
|
rendered = "#{id.publisher} #{id.class.type[:short]}-#{id.number}"
|
|
52
64
|
FACES.each do |component, face|
|
|
53
|
-
value = id
|
|
65
|
+
value = face_value(id, component)
|
|
54
66
|
rendered << (face % value) if value
|
|
55
67
|
end
|
|
56
68
|
rendered
|
|
57
69
|
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# nil = the face does not print. The citation face replaces the
|
|
74
|
+
# Part face (same containment slot: the citation letter IS the
|
|
75
|
+
# part), and it prints only on a spec citation.
|
|
76
|
+
def face_value(id, component)
|
|
77
|
+
if component == :spec
|
|
78
|
+
return nil unless id.spec
|
|
79
|
+
|
|
80
|
+
return spec_citation(id)
|
|
81
|
+
end
|
|
82
|
+
return nil if component == :part && id.spec
|
|
83
|
+
|
|
84
|
+
id.public_send(component)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# "B-401", "B-122.1", "B-521(I)", "B-351.1(I)" — the citation the
|
|
88
|
+
# S-4 contents and cross-references print.
|
|
89
|
+
def spec_citation(id)
|
|
90
|
+
citation = "#{id.part}-#{id.spec}"
|
|
91
|
+
citation << ".#{id.spec_sub}" if id.spec_sub
|
|
92
|
+
citation << "(#{id.spec_variant})" if id.spec_variant
|
|
93
|
+
citation
|
|
94
|
+
end
|
|
58
95
|
end
|
|
59
96
|
end
|
|
60
97
|
end
|