pubid 2.0.4 → 2.0.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.
@@ -6,10 +6,17 @@ module Pubid
6
6
  # every concrete IHO identifier (Identifiers::*) descends from it.
7
7
  #
8
8
  # IHO identifiers have the form:
9
- # IHO {S|P|M|B|C}-{number}[ Ap. {appendix}][ Part {part}][ Annex {annex}][ Suppl {supplement}][ {version|year|Vol. N [issue]}]
9
+ # IHO {S|P|M|B|C}-{number}[ {edition}| Vol. N [(issue)]]
10
+ # [ Sup N][ Part X][ Annex A][ Appendix B[-2]] [(2024)]
10
11
  #
11
- # The leading IHO publisher prefix is optional on input but always
12
- # emitted on output.
12
+ # Every tail component is a first-class semantic component: a typed
13
+ # attribute, independently addressable, URN-encodable and matchable.
14
+ # The canonical faces order the edition BEFORE the part
15
+ # (`IHO S-100 1.0.0 Part 2`), print the supplement as "Sup" and the
16
+ # appendix as "Appendix" (with the subnumber after a dash), the
17
+ # volume issue parenthesized (`IHO P-1 Vol. 30 (2)`), and the
18
+ # with-year qualifier parenthesized (`IHO S-100 5.0.0 Part 2 (2024)`
19
+ # — the only year face: a periodical year IS a volume).
13
20
  class Identifier < ::Pubid::Identifier
14
21
  # Parse an IHO identifier string into an identifier object
15
22
  # @param identifier [String] The IHO identifier string to parse
@@ -25,32 +32,55 @@ module Pubid
25
32
  raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
26
33
  end
27
34
 
28
- parsed = Pubid::Parg::Backend.parse(:iho, identifier.strip)
35
+ parsed = Pubid::Parg::Model.bound(:iho, identifier.strip)
29
36
  Builder.build(parsed)
30
37
  end
31
38
 
32
39
  # `number` overrides the generic Components::Code attribute with a plain
33
- # string (same pattern as `part` below); it holds the IHO document
34
- # number (e.g. "100", "1/21"). Kept as the generic `number` so the
35
- # relaton index sorts/binary-searches on it natively via `id.number`.
40
+ # string (same pattern as the other string components); it holds the IHO
41
+ # document number (e.g. "100", "1/21" until the slash-chain volume
42
+ # parse, "158:100"). Kept as the generic `number` so the relaton index
43
+ # sorts/binary-searches on it natively via `id.number`.
36
44
  attribute :number, :string
37
45
  attribute :publisher, :string, default: "IHO"
38
- attribute :appendix, :string
39
- attribute :part, :string
40
- attribute :annex, :string
41
- attribute :supplement, :string
42
- attribute :version, :string
43
- # Series identity components (relaton#407): the year-identified
44
- # series (B-7, P-5, P-6) and the volume/issue-identified ones
45
- # (P-1 and the IHR) carry no a.b.c edition — their trailing
46
- # component is a year or a "Vol. N [issue]" pair instead. Plain
47
- # scalars, like every other IHO attribute; the grammar's tail
48
- # picks exactly one discriminator, so only one of
49
- # version/year/volume is ever present, with issue only
50
- # alongside volume.
46
+ # The official IHO edition is the dotted x.y.z (a plain string — the
47
+ # ~16 string-edition flavors precede this). `IHO S-100 1.0.0` is
48
+ # edition-specific, `IHO S-100` edition-non-specific; a year beside an
49
+ # edition is the with-year qualifier, never a year-keyed docidentifier.
50
+ attribute :edition, :string
51
+ # The with-year qualifier on standards (`(2024)` beside an edition) —
52
+ # the only year component. A periodical year IS a volume: the bare
53
+ # legacy year input ("IHO P-5 2024") binds into `volume`, so this
54
+ # attribute never carries a serial locator.
51
55
  attribute :year, :string
56
+ # Supplement, part, annex, appendix and volume are independently
57
+ # publishable (owner ruling: "this can happen with any independently
58
+ # publishable artifacts"), so each carries an optional edition (+ year
59
+ # qualifier) of its own, mutually exclusive with the base edition
60
+ # context: `IHO P-6 1.0.0 Vol. 2` (within-edition) vs
61
+ # `IHO P-6 Vol. 2 1.0.1` (separately-published). The URN attaches
62
+ # ed- to the node it follows, so one positional slot records both.
63
+ attribute :supplement, :string
64
+ attribute :supplement_edition, :string
65
+ attribute :supplement_year, :string
66
+ attribute :part, :string
67
+ attribute :part_edition, :string
68
+ attribute :part_year, :string
69
+ attribute :annex, :string
70
+ attribute :annex_edition, :string
71
+ attribute :annex_year, :string
72
+ # The appendix is a letter or a number; a subnumbered appendix
73
+ # ("Appendix D-2") keeps the letter in `appendix` and the "2" in
74
+ # `appendix_subnumber` — two independently addressable components,
75
+ # not one string.
76
+ attribute :appendix, :string
77
+ attribute :appendix_subnumber, :string
78
+ attribute :appendix_edition, :string
79
+ attribute :appendix_year, :string
52
80
  attribute :volume, :string
53
81
  attribute :issue, :string
82
+ attribute :volume_edition, :string
83
+ attribute :volume_year, :string
54
84
 
55
85
  # Polymorphic type map for lutaml::Model key_value serialization: maps
56
86
  # each subclass's polymorphic_name to its class name so `from_hash` can
@@ -71,31 +101,98 @@ module Pubid
71
101
  key_value do
72
102
  map "_type", to: :_type, polymorphic_map: IHO_TYPE_MAP
73
103
  map "number", to: :number
74
- map "appendix", to: :appendix
75
- map "part", to: :part
76
- map "annex", to: :annex
77
- map "supplement", to: :supplement
78
- map "version", to: :version
104
+ map "edition", to: :edition
79
105
  map "year", to: :year
80
- map "volume", to: :volume
81
- map "issue", to: :issue
106
+ map "supplement", to: :supplement
107
+ map "supplement_edition", to: :supplement_edition
108
+ map "supplement_year", to: :supplement_year
109
+ map "part", to: :part
110
+ map "part_edition", to: :part_edition
111
+ map "part_year", to: :part_year
112
+ map "annex", to: :annex
113
+ map "annex_edition", to: :annex_edition
114
+ map "annex_year", to: :annex_year
115
+ map "appendix", to: :appendix
116
+ map "appendix_subnumber", to: :appendix_subnumber
117
+ map "appendix_edition", to: :appendix_edition
118
+ map "appendix_year", to: :appendix_year
119
+ map "volume", to: :volume
120
+ map "volume_edition", to: :volume_edition
121
+ map "volume_year", to: :volume_year
122
+ map "issue", to: :issue
82
123
  end
83
124
 
84
125
  # from_hash is the shared polymorphic dispatch on Pubid::Identifier.
85
- # IHO_TYPE_MAP remains as the key_value polymorphic_map.
86
-
87
- # The volume/issue pair is a scalar cluster: a reference that omits
88
- # the issue must still match a hit that carries one ("P-1 Vol. 30"
89
- # vs "P-1 Vol. 30 (2)"), so excluding the volume nils the issue
90
- # with it — the same additive-alias shape the base applies to
91
- # :year -> :date. A lone scalar :year needs no override here: the
92
- # base nils a declared :year directly (the additive :year -> :date
93
- # alias nils whichever name the flavor declares).
126
+ # IHO_TYPE_MAP remains the key_value polymorphic_map.
127
+
128
+ # The volume/issue and appendix/subnumber pairs are scalar clusters:
129
+ # a reference that omits the finer component must still match a hit
130
+ # that carries one ("P-1 Vol. 30" vs "P-1 Vol. 30 (2)"), so excluding
131
+ # the coarser component nils the finer one with it — the same
132
+ # additive-alias shape the base applies to :year -> :date. A lone
133
+ # scalar :year needs no override here: the base nils a declared :year
134
+ # directly (the additive :year -> :date alias nils whichever name the
135
+ # flavor declares).
94
136
  def exclude(*args)
137
+ args = args.dup
95
138
  args << :issue if args.include?(:volume)
139
+ args << :appendix_subnumber if args.include?(:appendix)
96
140
  super
97
141
  end
98
- end
99
142
 
143
+ # Render the compact face: the code-adjacent components folded onto
144
+ # the code with separators — the supplement and the part hyphenated
145
+ # (`IHO M-2-2` for `IHO M-2 Sup 2`, `IHO S-5-B` for `IHO S-5 Part B`,
146
+ # `IHO S-100-1` for `IHO S-100 Part 1`) and the volume as the slash
147
+ # chain (`IHO P-5/2024` for `IHO P-5 Vol. 2024`). The canonical
148
+ # render stays expanded; compact is opt-in. The legacy faces (glued
149
+ # lettered bases, Suppl, bare years) parse but are never rendered in
150
+ # any form.
151
+ def render(format: :human, **opts)
152
+ compact = opts.delete(:compact)
153
+ result = super
154
+ compact && format == :human ? compact_face(result) : result
155
+ end
156
+
157
+ # The shared mr_string path reads `edition&.number` (the base
158
+ # declares edition as Components::Edition); IHO's edition is a plain
159
+ # string, so the segment is built from it directly.
160
+ def mr_edition
161
+ return nil unless edition
162
+
163
+ "ed#{edition}"
164
+ end
165
+
166
+ private
167
+
168
+ # Fold " Sup N" / " Part X" / " Vol. N" into the code as "-N" /
169
+ # "-X" / "/N", keeping everything else in place (the edition
170
+ # follows the compact code, as in the legacy "S-5A 1.0.2" print
171
+ # order). The issue keeps its parenthesized print.
172
+ def compact_face(rendered)
173
+ result = rendered.sub(/\A#{publisher} #{base_code}(?= |$)/,
174
+ "#{publisher} #{compact_code}")
175
+ folded_segments.each { |segment| result = result.sub(segment, "") }
176
+ result
177
+ end
178
+
179
+ def base_code
180
+ "#{self.class.type[:short]}-#{number}"
181
+ end
182
+
183
+ def compact_code
184
+ code = base_code.dup
185
+ code << "-#{supplement}" if supplement
186
+ code << "-#{part}" if part
187
+ code << "/#{volume}" if volume
188
+ code
189
+ end
190
+
191
+ def folded_segments
192
+ [supplement && " Sup #{supplement}",
193
+ part && " Part #{part}",
194
+ volume && " Vol. #{volume}"].compact
195
+ end
196
+ end
100
197
  end
101
198
  end
@@ -4,33 +4,55 @@ module Pubid
4
4
  module Iho
5
5
  # Human-readable renderer for IHO identifiers.
6
6
  #
7
- # Produces strings like:
8
- # "IHO S-44 5.0.0"
9
- # "IHO P-1/21 1.0.0"
10
- # "IHO S-100 Part 4a 1.0.0"
11
- # "IHO S-65 Ap. A 1.0.0"
12
- # "IHO P-5 2024" (year-identified series)
13
- # "IHO P-1 Vol. 30 (2)" (volume/issue-identified series)
7
+ # Canonical faces (owner rulings, 2026-11-03):
8
+ # "IHO S-100" undated (edition-non-specific)
9
+ # "IHO S-100 1.0.0" dated (edition-specific)
10
+ # "IHO S-100 5.0.0 Part 2" edition BEFORE the part
11
+ # "IHO S-98 1.0.0 Part A Annex B"
12
+ # "IHO S-122 1.0.0 Appendix D-2" canonical Appendix, subnumber
13
+ # joined by a dash
14
+ # "IHO M-2 Sup 1" canonical Sup (Suppl is an input
15
+ # alias)
16
+ # "IHO P-1 Vol. 30 (2)" volume-keyed series (P-1, P-6)
17
+ # "IHO P-5 Vol. 2024" the yearbook year IS a volume
18
+ # "IHO S-100 5.0.0 Part 2 (2024)" the with-year qualifier: a year
19
+ # beside an edition is the date
20
+ # qualifier on standards,
21
+ # parenthesized — the only year face
14
22
  #
15
23
  # The renderer is registered as the `:human` format in the IHO format
16
- # registry and invoked via `render(format: :human)`.
24
+ # registry and invoked via `render(format: :human)`; the compact face
25
+ # (`IHO S-100-1 5.0.0`, `IHO M-2-2`, `IHO P-5/2024`) is the opt-in
26
+ # `compact: true` flag, and `with_publisher: false` drops the leading
27
+ # publisher as everywhere else. The legacy faces (glued lettered
28
+ # bases, Suppl, bare years) parse but are never rendered.
17
29
  class Renderer < ::Pubid::Renderers::Base
30
+ # The canonical component faces in print order: edition BEFORE the
31
+ # part; each independently-publishable component carries its own
32
+ # edition (+ parenthesized year) right after it; the appendix joins
33
+ # its subnumber; the issue rides parenthesized after its volume;
34
+ # the base year prints only as the parenthesized with-year
35
+ # qualifier.
36
+ FACES = [
37
+ [:edition, " %s"],
38
+ [:supplement, " Sup %s"], [:supplement_edition, " %s"],
39
+ [:supplement_year, " (%s)"],
40
+ [:part, " Part %s"], [:part_edition, " %s"], [:part_year, " (%s)"],
41
+ [:annex, " Annex %s"], [:annex_edition, " %s"], [:annex_year, " (%s)"],
42
+ [:appendix, " Appendix %s"], [:appendix_subnumber, "-%s"],
43
+ [:appendix_edition, " %s"], [:appendix_year, " (%s)"],
44
+ [:volume, " Vol. %s"], [:issue, " (%s)"],
45
+ [:volume_edition, " %s"], [:volume_year, " (%s)"],
46
+ [:year, " (%s)"]
47
+ ].freeze
48
+
18
49
  def render(context: nil, **opts)
19
50
  id = @id
20
-
21
- letter = id.class.type[:short]
22
- rendered = "#{id.publisher} #{letter}-#{id.number}"
23
- rendered << " Ap. #{id.appendix}" if id.appendix
24
- rendered << " Part #{id.part}" if id.part
25
- rendered << " Annex #{id.annex}" if id.annex
26
- rendered << " Suppl #{id.supplement}" if id.supplement
27
- # The grammar's tail picks exactly one discriminator: a dotted
28
- # version, a year, or a volume (with an optional issue). The
29
- # issue prints parenthesized; "Issue N" is an input alias only.
30
- rendered << " #{id.version}" if id.version
31
- rendered << " #{id.year}" if id.year
32
- rendered << " Vol. #{id.volume}" if id.volume
33
- rendered << " (#{id.issue})" if id.issue
51
+ rendered = "#{id.publisher} #{id.class.type[:short]}-#{id.number}"
52
+ FACES.each do |component, face|
53
+ value = id.public_send(component)
54
+ rendered << (face % value) if value
55
+ end
34
56
  rendered
35
57
  end
36
58
  end
@@ -4,16 +4,33 @@ module Pubid
4
4
  module Iho
5
5
  # Generates URN strings for IHO identifiers.
6
6
  #
7
- # Format: urn:iho:{type-letter-lowercase}:{number}[:ap.{appendix}][:part.{part}][:annex.{annex}][:suppl.{supplement}][:vol.{volume}[:issue.{issue}]][:{version}][:y.{year}]
8
- # Example: urn:iho:s:44:5.0.0 for "IHO S-44 5.0.0".
9
- # Only the dotted version is a bare segment. A year is tagged
10
- # ("urn:iho:p:5:y.2024" for "IHO P-5 2024"), and so is a volume with
11
- # its optional issue ("urn:iho:p:1:vol.30:issue.2" for
12
- # "IHO P-1 Vol. 30 (2)").
7
+ # The URN is the documented positional segment grammar (see the
8
+ # grammar header in pubid-grammar's grammars/iho.parg and the flavor
9
+ # notes): containment-ordered, one semantic atom per segment, every
10
+ # non-number component a marker-prefixed segment whose marker is the
11
+ # lowercased human-face token (edition follows ISO's ed- precedent):
13
12
  #
14
- # The number is not escaped, so "IHO S-158:100" gives
15
- # "urn:iho:s:158:100". A bare year would then make "IHO S-158:2024"
16
- # and "IHO S-158 2024" share one URN; the y. tag keeps them apart.
13
+ # urn:iho:<type>:<number>[:ed-<x.y.z>][:sup-<n>][:part-<X>]
14
+ # [:annex-<A>][:appendix-<B>][:<sub>][:vol-<n>][:issue-<n>]
15
+ # [:year-<yyyy>]
16
+ #
17
+ # Containment: part contains annexes, annex contains appendices, the
18
+ # subnumber is a child atom of its appendix, the issue a child of its
19
+ # volume. Letters are case-preserved. The volume carries every serial
20
+ # locator — the yearbook year IS a volume (P-5 Vol. 2024 gives
21
+ # vol-2024), so a periodical identifier has no year segment; the
22
+ # year- segment exists only for the standards with-year qualifier.
23
+ # Both are marker-tagged so neither can collide with a colon-number
24
+ # segment (the number is not escaped and may span bare segments:
25
+ # "IHO S-158:100" gives "urn:iho:s:158:100").
26
+ #
27
+ # Examples:
28
+ # - urn:iho:s:44:ed-5.0.0 IHO S-44 5.0.0
29
+ # - urn:iho:s:100:ed-5.0.0:part-2:year-2024 IHO S-100 5.0.0 Part 2 (2024)
30
+ # - urn:iho:s:122:ed-1.0.0:appendix-D:2 IHO S-122 1.0.0 Appendix D-2
31
+ # - urn:iho:m:2:sup-1 IHO M-2 Sup 1
32
+ # - urn:iho:p:5:vol-2024 IHO P-5 Vol. 2024
33
+ # - urn:iho:p:1:vol-30:issue-2 IHO P-1 Vol. 30 (2)
17
34
  class UrnGenerator
18
35
  attr_reader :identifier
19
36
 
@@ -21,22 +38,42 @@ module Pubid
21
38
  @identifier = identifier
22
39
  end
23
40
 
41
+ # [component, marker] pairs in the containment/canonical-face
42
+ # order. A component edition/year emits right after its component
43
+ # (the ed- segment attaches to the node it follows, recording the
44
+ # separately-published mode). nil markers emit their component as
45
+ # a BARE atom (the appendix subnumber directly after its
46
+ # appendix).
47
+ SEGMENTS = [
48
+ [:edition, "ed"], [:year, "year"],
49
+ [:supplement, "sup"], [:supplement_edition, "ed"],
50
+ [:supplement_year, "year"],
51
+ [:part, "part"], [:part_edition, "ed"], [:part_year, "year"],
52
+ [:annex, "annex"], [:annex_edition, "ed"], [:annex_year, "year"],
53
+ [:appendix, "appendix"], [:appendix_subnumber, nil],
54
+ [:appendix_edition, "ed"], [:appendix_year, "year"],
55
+ [:volume, "vol"], [:issue, "issue"], [:volume_edition, "ed"],
56
+ [:volume_year, "year"]
57
+ ].freeze
58
+
24
59
  def generate
25
60
  parts = ["urn", "iho"]
26
61
  parts << identifier.class.type[:short].to_s.downcase
27
62
  parts << identifier.number.to_s
28
- parts << "ap.#{identifier.appendix}" if identifier.appendix
29
- parts << "part.#{identifier.part}" if identifier.part
30
- parts << "annex.#{identifier.annex}" if identifier.annex
31
- parts << "suppl.#{identifier.supplement}" if identifier.supplement
32
- # The grammar's tail picks exactly one discriminator; a volume
33
- # may carry an issue beside it.
34
- parts << "vol.#{identifier.volume}" if identifier.volume
35
- parts << "issue.#{identifier.issue}" if identifier.issue
36
- parts << identifier.version.to_s if identifier.version
37
- parts << "y.#{identifier.year}" if identifier.year
63
+ parts.concat(component_segments)
38
64
  parts.join(":")
39
65
  end
66
+
67
+ private
68
+
69
+ def component_segments
70
+ SEGMENTS.filter_map do |component, marker|
71
+ value = identifier.public_send(component)
72
+ next unless value
73
+
74
+ marker ? "#{marker}-#{value}" : value.to_s
75
+ end
76
+ end
40
77
  end
41
78
  end
42
79
  end