pubid 2.0.25 → 2.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +115 -167
  3. data/data/parg/cen_pfs.json +1 -1
  4. data/data/parg/etsi_pfs.json +1 -1
  5. data/lib/pubid/cen_pfs/adopted_base.rb +114 -0
  6. data/lib/pubid/cen_pfs/builder.rb +157 -21
  7. data/lib/pubid/cen_pfs/identifier.rb +106 -48
  8. data/lib/pubid/cen_pfs/identifiers/adopted_european_norm.rb +55 -0
  9. data/lib/pubid/cen_pfs/identifiers/amendment.rb +27 -0
  10. data/lib/pubid/cen_pfs/identifiers/cen_report.rb +36 -0
  11. data/lib/pubid/cen_pfs/identifiers/cen_workshop_agreement.rb +36 -0
  12. data/lib/pubid/cen_pfs/identifiers/consolidated_identifier.rb +41 -0
  13. data/lib/pubid/cen_pfs/identifiers/corrigendum.rb +27 -0
  14. data/lib/pubid/cen_pfs/identifiers/european_norm.rb +43 -23
  15. data/lib/pubid/cen_pfs/identifiers/european_prestandard.rb +55 -0
  16. data/lib/pubid/cen_pfs/identifiers/european_specification.rb +36 -0
  17. data/lib/pubid/cen_pfs/identifiers/fragment.rb +28 -0
  18. data/lib/pubid/cen_pfs/identifiers/guide.rb +36 -0
  19. data/lib/pubid/cen_pfs/identifiers/harmonization_document.rb +36 -0
  20. data/lib/pubid/cen_pfs/identifiers/supplement.rb +39 -0
  21. data/lib/pubid/cen_pfs/identifiers/technical_report.rb +36 -0
  22. data/lib/pubid/cen_pfs/identifiers/technical_specification.rb +36 -0
  23. data/lib/pubid/cen_pfs/identifiers.rb +15 -1
  24. data/lib/pubid/cen_pfs/pubid_urn_schema.rb +20 -1
  25. data/lib/pubid/cen_pfs/renderer.rb +50 -1
  26. data/lib/pubid/cen_pfs/urn_generator.rb +75 -3
  27. data/lib/pubid/cen_pfs.rb +7 -16
  28. data/lib/pubid/doi_pfs/builder.rb +2 -2
  29. data/lib/pubid/etsi_pfs/CLAUDE.md +48 -40
  30. data/lib/pubid/etsi_pfs/builder.rb +45 -12
  31. data/lib/pubid/etsi_pfs/identifier.rb +23 -41
  32. data/lib/pubid/etsi_pfs/identifiers/amendment.rb +20 -0
  33. data/lib/pubid/etsi_pfs/identifiers/corrigendum.rb +20 -0
  34. data/lib/pubid/etsi_pfs/identifiers/standard.rb +64 -15
  35. data/lib/pubid/etsi_pfs/identifiers/supplement.rb +72 -0
  36. data/lib/pubid/etsi_pfs/identifiers.rb +4 -1
  37. data/lib/pubid/etsi_pfs/pubid_urn_schema.rb +4 -2
  38. data/lib/pubid/etsi_pfs/renderer.rb +29 -1
  39. data/lib/pubid/etsi_pfs/urn_generator.rb +40 -3
  40. data/lib/pubid/etsi_pfs.rb +2 -1
  41. data/lib/pubid/version.rb +1 -1
  42. metadata +20 -2
@@ -1,12 +1,84 @@
1
1
  module Pubid
2
2
  module CenPfs
3
- # The official URN generator, pinned to the scheme's declared
4
- # namespace ("cen") so to_urn stays
5
- # scheme-canonical under the compiled flavor key.
3
+ # The official URN generator: the scheme's declared namespace
4
+ # ("cen") and the ordered fused segments
5
+ # (feature 6) — the publisher group, the adopted document's
6
+ # fused MR string, the composed code, the year, the draft
7
+ # stage. A wrapper reads through to its subject and appends
8
+ # its declared chain; the consolidated bundle marks each
9
+ # member with the plus marker.
6
10
  class UrnGenerator < ::Pubid::UrnGenerator::Base
7
11
  def urn_namespace
8
12
  "cen"
9
13
  end
14
+
15
+ def generate
16
+ id = @identifier
17
+ members = nil
18
+ frag_segments = []
19
+ wrapper = []
20
+ if id.is_a?(Identifiers::Fragment)
21
+ frag_segments = wrapper_segments(id)
22
+ id = id.base
23
+ end
24
+ if id.is_a?(Identifiers::ConsolidatedIdentifier)
25
+ base, *consolidated = id.identifiers
26
+ members = consolidated
27
+ id = base
28
+ end
29
+ while id.is_a?(Identifiers::Supplement)
30
+ wrapper.prepend(*wrapper_segments(id))
31
+ id = id.base
32
+ end
33
+ @identifier = id
34
+ parts = ["urn", urn_namespace]
35
+ parts.concat(official_segments)
36
+ parts.concat(wrapper)
37
+ members&.each do |member|
38
+ parts << "plus"
39
+ parts.concat(wrapper_segments(member))
40
+ end
41
+ parts.concat(frag_segments)
42
+ parts.join(":")
43
+ end
44
+
45
+ private
46
+
47
+ def official_segments
48
+ segments = []
49
+ group = [@identifier.publisher&.respond_to?(:body) ? @identifier.publisher.body : @identifier.publisher,
50
+ *Array(@identifier.copublishers).map { |p| p.respond_to?(:body) ? p.body : p }].compact.map(&:to_s)
51
+ segments << group.map(&:downcase).join("-") unless group.empty?
52
+ adopted = @identifier.respond_to?(:adopted) ? @identifier.adopted : nil
53
+ mr = adopted&.to_mr_string
54
+ segments << mr.tr("._", "::") unless mr.nil? || mr.empty?
55
+ code = []
56
+ code << @identifier.number.to_s if @identifier.number
57
+ code << "-" + @identifier.part.to_s if @identifier.part
58
+ segments << code.join unless code.empty?
59
+ date = [@identifier.date&.year].compact
60
+ segments << date.join("-") unless date.empty?
61
+ stage = @identifier.typed_stage
62
+ code = stage&.stage_code
63
+ segments << "stage." + code.to_s if code && code.to_s != "published"
64
+ segments
65
+ end
66
+
67
+ # One wrapper's declared URN chain: the kind word, then the
68
+ # wrapper's columns (the ordinal, the year-month date).
69
+ def wrapper_segments(id)
70
+ segments = case id
71
+ when Identifiers::Amendment
72
+ ["amd", id.number, id.year]
73
+ when Identifiers::Corrigendum
74
+ ["cor", id.number, (id.respond_to?(:supplement_date) ? id.supplement_date : [id.year, id.month].compact.join("-") )]
75
+ when Identifiers::Fragment
76
+ ["frag", id.number]
77
+ else
78
+ []
79
+ end
80
+ segments.map(&:to_s).reject(&:empty?)
81
+ end
10
82
  end
11
83
  end
12
84
  end
data/lib/pubid/cen_pfs.rb CHANGED
@@ -1,34 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
  #
3
3
  # PFS-generated flavor — DO NOT EDIT BY HAND.
4
- # Source: definitions/cen.pfs (pfs:1) sha256:bc1edd5874f8938b5f946416a2b052d9d9b9df3d99f6c20a3d88fcab9ae1d70c
4
+ # Source: definitions/cen.pfs (pfs:1) sha256:b8f2c00e392dbc03c1fddedad141afa7c930c5430738721b7df0ac69a371d028
5
5
  # Compiler: pubid/grammar/pfs 0.1.0 (baked with parsanol 1.3.126)
6
6
  # Regenerate: ruby scripts/pfs compile definitions/cen.pfs --as cen_pfs --ruby-out <dir>
7
7
 
8
8
  require "pubid"
9
9
 
10
10
  module Pubid
11
- # CEN/CENELEC European Norms (module composition) flavor, compiled from its PFS definition
11
+ # CEN/CENELEC flavor, compiled from its PFS definition
12
12
  # (pubid/pubid#706). A compiled flavor is a peer of the coded
13
13
  # flavors: same registry, same input guards, same error
14
14
  # namespace, same URN contracts.
15
15
  module CenPfs
16
16
  extend Pubid::PrefixesSupport
17
17
 
18
- PREFIXES = ["EN", "CEN/", "CLC/"].freeze
19
- # The module composition the definition binds (issue #706 §4):
20
- # each entry names the library module, the version it was bound
21
- # at, and — where the module declares one — its IDENTITY
22
- # composition (wrapper-with-adopted: the adopted document is
23
- # the base). Carried so consumers and the wizard can
24
- # introspect what the flavor is composed of.
25
- MODULES = [
26
- { name: "iso.inner", version: 1, identity: { composition: "wrapper-with-adopted", base_module: "iso.inner", adopted_field: :adopted } },
27
- { name: "en.overlay", version: 1, identity: { composition: "wrapper-with-adopted", base_module: "iso.inner", adopted_field: :adopted } },
28
- { name: "national.prefix", version: 1, identity: { composition: "wrapper-with-adopted", base_module: "iso.inner", adopted_field: :adopted } },
29
- { name: "supplements.core", version: 1, identity: { composition: "wrapper-with-adopted", adopted_field: :base } },
30
- ].freeze
31
-
18
+ PREFIXES = ["CEN", "CLC", "CENELEC", "EN", "CWA", "HD", "ES", "CR", "ENV"].freeze
19
+ # The adopted-base dispatch hook (the §4 coded escape hatch):
20
+ # vendored beside the generated classes — a missing hook file
21
+ # fails loudly on the first adoption, never silently.
22
+ autoload :AdoptedBase, "#{__dir__}/cen_pfs/adopted_base"
32
23
  autoload :Builder, "#{__dir__}/cen_pfs/builder"
33
24
  autoload :Identifier, "#{__dir__}/cen_pfs/identifier"
34
25
  autoload :Identifiers, "#{__dir__}/cen_pfs/identifiers"
@@ -7,8 +7,8 @@
7
7
 
8
8
  def build(data)
9
9
  Identifiers::Resource.new(
10
- prefix: data[:prefix],
11
- suffix: data[:suffix],
10
+ prefix: data[:prefix],
11
+ suffix: data[:suffix],
12
12
  )
13
13
  end
14
14
  end
@@ -39,57 +39,65 @@ Measured surface (see the spec header for the numbers and their meaning):
39
39
  (all 15 type tokens; the four number shapes GSM/dotted/complex/simple;
40
40
  minor; the parts run; the /A//C supplement runs; `V`-versions and
41
41
  `ed.N`; the `(YYYY-MM)` date).
42
- - **to_hash and to_pubid_urn byte parity: 18,945/24,718** — exactly the
43
- rows carrying none of the three constructs pfs:1 cannot declare. Each
44
- gap is pinned per construct in the spec, so compiler progress turns the
45
- pin red:
46
- 1. **collection bindings** — the coded model stores `["1","0"]` (the
47
- `part -> parts[].part` binding); the compiled capture is the printed
48
- dash run `"-1-0"`.
49
- 2. **the edition face** — the coded model folds `ed.N` into
50
- `Version(version: "N", is_edition: true)`; the compiled hash carries
51
- `edition: "N"`.
52
- 3. **supplement wrappers** — the coded model wraps `/A1` `/C1` as
53
- Amendment/Corrigendum classes with an Integer ordinal delegating to
54
- their base; the compiled row stays a flat etsi-standard carrying the
55
- printed run `"/A1"`.
56
- - **to_s** renders the canonical CORE face (`ETSI <type> <number>`) —
57
- pfs:1 render faces cannot render optionals, so every corpus row (all of
58
- which carry version/ed + date) renders shorter; bare ids agree
59
- byte-for-byte.
60
- - **to_urn** is a compiler gap (the coded generator is hand-written Ruby
61
- composing `urn:etsi:{type}:{code}:{version}:{date}`; the generated
62
- generator pins the namespace only). Note the CODED generator raises
63
- `ArgumentError` on months 08/09 (`format('%02d', "08")` — the zero-padded
64
- string reads as an invalid octal); pre-existing on main, pinned by the
65
- parity spec.
66
- - **to_mr_string** is a compiler gap (the coded slug reads the flavor's
67
- `mr_*` hooks — type/parts/edition/date segments; the base hooks find
68
- none of them on the compiled columns).
42
+ - **to_hash and to_pubid_urn byte parity: 24,718/24,718** (phase 3
43
+ closed all three constructs; the former per-construct pins are now
44
+ parity rows):
45
+ 1. **collection bindings** (3,772 rows) — `parts` is a `kind: list`
46
+ field binding its `part` item per repetition; the compiled model
47
+ stores the coded `["1","0"]` array, dropped when empty.
48
+ 2. **the edition face** (1,833 rows) — the `ed.N` capture folds onto
49
+ `version` + `is_edition: true` (the coded Version component shape).
50
+ 3. **supplement wrappers** (168 rows) — the `supplements:` declaration
51
+ folds the printed run into SYNTHESIZED Amendment/Corrigendum classes
52
+ over the base standard, outermost last ("/A1/C1" is Corrigendum over
53
+ Amendment over the standard).
54
+ Trajectory: 18,945 → 22,717 (collections) → 24,718 (fold + wrappers).
55
+ - **to_s** renders the full printed face — the canonical template's
56
+ presence-guarded optionals and the V/ed alternated tail render what
57
+ the input carried (the conditional-render segment feature).
58
+ - **to_urn** is DECLARED (`urn.official`): type lowercased, the composed
59
+ code (number + minor + prefix-glued parts), the v/ed version face
60
+ (the edition affix under the fold flag), the year-month date group —
61
+ byte-identical with the coded generator everywhere the coded one
62
+ renders at all. The CODED generator raises `ArgumentError` on months
63
+ 08/09 (`format('%02d', "08")` — the zero-padded string reads as an
64
+ invalid octal); pre-existing on main, pinned by the parity spec — the
65
+ compiled generator renders the captured month verbatim and does not
66
+ share the defect.
67
+ - **to_mr_string** is DECLARED (`mr:`): the coded mr_type /
68
+ mr_number_with_part / mr_year / mr_edition hooks over the columns,
69
+ byte-identical slugs (`etsi.eg.200-053.2004-06.1-5-1`), with the
70
+ wrapper's supplement suffix (`…_a1.1`) carried by the synthesized
71
+ classes.
69
72
 
70
73
  ## Non-obvious facts, learned the hard way
71
74
 
72
75
  - The generated `--ruby-out` lands at `lib/etsi_pfs.rb`; the autoload and
73
76
  the `#{__dir__}` paths require it at `lib/pubid/etsi_pfs.rb` — move both
74
77
  the file and the directory (commands above).
75
- - `core: ["number"]` in a `.pfs` urn type row would generate
76
- `core: [:number]`, which crashes `PubidUrn.define` at load time (the
77
- generator emits `core.map(&:to_sym)`); only `core: []` round-trips.
78
- The instance's URNs resolve through the CODED schema anyway (the flavor
79
- segment of the canonical tag is `etsi`), so the alias rows are the
80
- future-cutover shape.
78
+ - The instance's URNs resolve through the CODED schema anyway (the
79
+ flavor segment of the canonical tag is `etsi`), so the alias's schema
80
+ rows are the future-cutover shape; the wrapper rows now carry the
81
+ coded `core: [["number", :int]]` ordinals (the phase-3 int-kind
82
+ cores).
81
83
  - `V1..1` parses in BOTH flavors (`.` is a version_char) and `GS` is a
82
84
  real type token — neither is a parity negative.
83
85
  - `Pubid::EtsiPfs.parse("ETSI …")` never hijacks `Pubid.parse` (registry
84
86
  keys sort `"etsi"` first), and `Pubid.from_hash` of an alias hash
85
87
  resolves the coded `EtsiStandard` (one scheme, one `_type`).
86
88
  - The corpus inventory (`spec/fixtures/etsi/identifiers/full/`) is the
87
- parity source; its bucket split is pinned: plain 18,945 / parts 3,772 /
88
- edition 1,833 / supplement 168.
89
+ parity source; its former bucket split (plain 18,945 / parts 3,772 /
90
+ edition 1,833 / supplement 168) is now the per-gap parity trajectory —
91
+ all 24,718 rows agree on to_hash, to_pubid_urn, to_s, to_urn and
92
+ to_mr_string.
93
+ - The run's internal captures (`supplement_kind` / `supplement_number`)
94
+ bind only through the run's item rows, and unbound captures arrive as
95
+ raw `Parsanol::Slice` leaves — the generated builder unwraps
96
+ `Slice#content` (the cen_cenelec precedent).
89
97
 
90
- ## v1 boundaries
98
+ ## v1 boundaries (still)
91
99
 
92
- Single-type schemes only, `kind: string` fields only, no free-standing
93
- `stages:`/`supplements:` sections, no semantic decode rules — see
94
- `pubid-grammar`'s `definitions/README.md` for the language reference and
95
- the fail-closed validation rules.
100
+ Single declared type per scheme (the `supplements:` wrappers are the one
101
+ multi-class shape), no free-standing `stages:` maps, no semantic decode
102
+ rules — see `pubid-grammar`'s `definitions/README.md` for the language
103
+ reference and the fail-closed validation rules.
@@ -1,25 +1,58 @@
1
- module Pubid
2
- module EtsiPfs
3
- class Builder
4
- def self.build(parsed_data)
5
- new.build(parsed_data)
6
- end
1
+ module Pubid
2
+ module EtsiPfs
3
+ class Builder
4
+ # The wrapper classes by kind face: the run's dispatch.
5
+ SUPPLEMENT_WRAPPERS = {
6
+ "A" => Identifiers::Amendment,
7
+ "C" => Identifiers::Corrigendum,
8
+ }.freeze
9
+
10
+ def self.build(parsed_data)
11
+ new.build(parsed_data)
12
+ end
7
13
 
8
- def build(data)
9
- Identifiers::Standard.new(
14
+ def build(data)
15
+ identifier = Identifiers::Standard.new(
10
16
  type: data[:type],
11
17
  number: data[:number],
12
18
  minor: data[:minor],
13
- parts: data[:parts],
14
- supplements: data[:supplements],
15
- version: data[:version],
16
- edition: data[:edition],
19
+ parts: build_parts(data),
20
+ version: data[:version] || data[:edition],
21
+ is_edition: !data[:edition].nil?,
17
22
  year: data[:year],
18
23
  month: data[:month],
24
+ )
25
+ run = Array(data[:supplements])
26
+ run.each do |row|
27
+ item = row[:supplement] || row["supplement"] || {}
28
+ kind = unwrap(item[:supplement_kind] || item["supplement_kind"])
29
+ identifier = SUPPLEMENT_WRAPPERS.fetch(kind).new(
30
+ number: unwrap(item[:supplement_number] || item["supplement_number"]).to_i,
31
+ base: identifier,
19
32
  )
20
33
  end
34
+ identifier
35
+ end
36
+
37
+ # The run's internal captures bind only through the item row
38
+ # (the cen_cenelec precedent), so unbound ones arrive as raw
39
+ # leaves — a Parsanol::Slice or a {value:, offset: ...} span.
40
+ # Unwrap the captured value.
41
+ def unwrap(capture)
42
+ return capture.content if capture.is_a?(Parsanol::Slice)
43
+ return capture[:value] if capture.is_a?(Hash) && capture.key?(:value)
44
+
45
+ capture
46
+ end
47
+
48
+ # The parts column from its bound item rows.
49
+ def build_parts(data)
50
+ Array(data[:parts]).map do |row|
51
+ row[:part] || row["part"]
52
+ end.compact
21
53
  end
22
54
  end
23
55
  end
24
56
 
25
57
  Pubid::EtsiPfs::Builder.prepend(Pubid::Builder::AllPartsWrap)
58
+ end
@@ -1,50 +1,32 @@
1
- module Pubid
2
- module EtsiPfs
3
- class Identifier < ::Pubid::Identifier
4
- attribute :type, :string
5
- attribute :number, :string
6
- attribute :minor, :string
7
- attribute :parts, :string
8
- attribute :supplements, :string
9
- attribute :version, :string
10
- attribute :edition, :string
11
- attribute :year, :string
12
- attribute :month, :string
1
+ module Pubid
2
+ module EtsiPfs
3
+ class Identifier < ::Pubid::Identifier
4
+ attribute :publisher, :string, default: -> { "ETSI" }
13
5
 
14
- ETSI_TYPE_MAP = {
15
- "pubid:etsi:etsi-standard" => "Pubid::EtsiPfs::Identifiers::Standard",
16
- }.freeze
6
+ PUBLISHER = "ETSI"
17
7
 
18
- key_value do
19
- map "_type", to: :_type, polymorphic_map: ETSI_TYPE_MAP
20
- map "type", to: :type
21
- map "number", to: :number
22
- map "minor", to: :minor
23
- map "parts", to: :parts
24
- map "supplements", to: :supplements
25
- map "version", to: :version
26
- map "edition", to: :edition
27
- map "year", to: :year
28
- map "month", to: :month
29
- end
30
-
31
- PUBLISHER = "ETSI"
8
+ # The scheme's declared publisher token: the internal-form
9
+ # cut (to_s(with_publisher: false)) strips it from the
10
+ # rendered face.
11
+ def publisher_prefixes
12
+ [PUBLISHER]
13
+ end
32
14
 
33
- def self.parse(identifier)
34
- unless identifier.is_a?(String)
35
- raise Pubid::Errors::InvalidInputError,
36
- Pubid::INPUT_NOT_A_STRING_MESSAGE
37
- end
15
+ def self.parse(identifier)
16
+ unless identifier.is_a?(String)
17
+ raise Pubid::Errors::InvalidInputError,
18
+ Pubid::INPUT_NOT_A_STRING_MESSAGE
19
+ end
38
20
 
39
- if identifier.length > Pubid::MAX_INPUT_LENGTH
40
- raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
41
- end
21
+ if identifier.length > Pubid::MAX_INPUT_LENGTH
22
+ raise Pubid::Errors::InvalidInputError, Pubid::INPUT_TOO_LONG_MESSAGE
23
+ end
42
24
 
43
- identifier = ::Pubid.utf8_input(identifier)
25
+ identifier = ::Pubid.utf8_input(identifier)
44
26
 
45
- parsed = Pubid::Parg::Model.bound(:etsi_pfs, identifier)
46
- Builder.build(parsed)
47
- end
27
+ parsed = Pubid::Parg::Model.bound(:etsi_pfs, identifier)
28
+ Builder.build(parsed)
48
29
  end
49
30
  end
50
31
  end
32
+ end
@@ -0,0 +1,20 @@
1
+ module Pubid
2
+ module EtsiPfs
3
+ module Identifiers
4
+ # The synthesized Amendment wrapper (PFS supplements
5
+ # declaration): the kind face "A" is the run's
6
+ # dispatch token and the printed notation's face.
7
+ class Amendment < Supplement
8
+ FACE = "A".freeze
9
+
10
+ def self.face
11
+ FACE
12
+ end
13
+
14
+ def self.polymorphic_name
15
+ "pubid:etsi:amendment"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ module Pubid
2
+ module EtsiPfs
3
+ module Identifiers
4
+ # The synthesized Corrigendum wrapper (PFS supplements
5
+ # declaration): the kind face "C" is the run's
6
+ # dispatch token and the printed notation's face.
7
+ class Corrigendum < Supplement
8
+ FACE = "C".freeze
9
+
10
+ def self.face
11
+ FACE
12
+ end
13
+
14
+ def self.polymorphic_name
15
+ "pubid:etsi:corrigendum"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,22 +1,71 @@
1
- module Pubid
2
- module EtsiPfs
3
- module Identifiers
4
- # Concrete ETSI Standard identifier type (PFS type `standard`).
5
- class Standard < Identifier
1
+ module Pubid
2
+ module EtsiPfs
3
+ module Identifiers
4
+ # Concrete ETSI Standard identifier type (PFS type `standard`).
5
+ class Standard < Identifier
6
6
  TYPED_STAGES = [].freeze
7
7
 
8
- def self.type
9
- { key: :etsi, web: :etsi, title: "ETSI Standard", short: "ETSI" }
10
- end
8
+ attribute :type, :string
9
+ attribute :number, :string
10
+ attribute :minor, :string
11
+ attribute :parts, :string, collection: true, initialize_empty: true
12
+ attribute :version, :string
13
+ attribute :year, :string
14
+ attribute :month, :string
15
+ attribute :is_edition, :boolean, default: -> { false }
11
16
 
12
- # The scheme-canonical wire tag declared in the definition:
13
- # to_hash stays byte-identical with the scheme's coded
14
- # flavor, and this flavor's own from_hash routes the tag
15
- # to this class.
16
- def self.polymorphic_name
17
- "pubid:etsi:etsi-standard"
18
- end
17
+ key_value do
18
+ map "_type", to: :_type
19
+ map "type", to: :type
20
+ map "number", to: :number
21
+ map "minor", to: :minor
22
+ map "parts", to: :parts
23
+ map "version", to: :version
24
+ map "year", to: :year
25
+ map "month", to: :month
26
+ map "is_edition", to: :is_edition
27
+ end
28
+
29
+ def self.type
30
+ { key: :etsi, web: :etsi, title: "ETSI Standard", short: "ETSI" }
31
+ end
32
+
33
+ # The scheme-canonical wire tag declared in the definition:
34
+ # to_hash stays byte-identical with the scheme's coded
35
+ # flavor, and this flavor's own from_hash routes the tag
36
+ # to this class.
37
+ def self.polymorphic_name
38
+ "pubid:etsi:etsi-standard"
39
+ end
40
+
41
+ def mr_type
42
+ mr_sanitize(type)
43
+ end
44
+
45
+ def mr_number_with_part
46
+ mr_sanitize([number, minor, *Array(parts)].compact.join("-"))
47
+ end
48
+
49
+ def mr_year
50
+ mr_sanitize([year, month].compact.join("-"))
51
+ end
52
+
53
+ def mr_edition
54
+ mr_sanitize(version)
55
+ end
56
+
57
+ # The MR charset rule (the coded flavors' mr_sanitize):
58
+ # lowercase, [a-z0-9-], non-charset runs collapsed to one
59
+ # dash; empty answers nil so the joined slug never gains a
60
+ # blank segment.
61
+ def mr_sanitize(value)
62
+ return nil if value.nil? || value.to_s.empty?
63
+
64
+ sanitized = value.to_s.downcase
65
+ .gsub(/[^a-z0-9-]+/, "-").gsub(/\A-+|-+\z/, "")
66
+ sanitized.empty? ? nil : sanitized
19
67
  end
20
68
  end
21
69
  end
22
70
  end
71
+ end
@@ -0,0 +1,72 @@
1
+ module Pubid
2
+ module EtsiPfs
3
+ module Identifiers
4
+ # The supplement wrapper base (pubid#706 phase 3): the
5
+ # wrapper owns its ordinal and the base identifier; every
6
+ # delegated attribute reads through.
7
+ class Supplement < ::Pubid::EtsiPfs::Identifier
8
+ attribute :base, ::Pubid::EtsiPfs::Identifier
9
+ attribute :number, :integer
10
+
11
+ key_value do
12
+ map "_type", to: :_type
13
+ map "number", to: :number
14
+ map "base", with: { to: :base_to_kv, from: :base_from_kv }
15
+ end
16
+
17
+ # Serialize the base via its own to_hash so it collapses
18
+ # to the leaf standard shape (the coded converters'
19
+ # rule).
20
+ def base_to_kv(model, doc)
21
+ return unless model.base
22
+
23
+ doc.add_child(
24
+ Lutaml::KeyValue::DataModel::Element.new("base", model.base.to_hash),
25
+ )
26
+ end
27
+
28
+ # Re-dispatch through the flavor base so _type resolves
29
+ # to the concrete Standard (the coded JCGM/ETSI rule).
30
+ def base_from_kv(model, value)
31
+ return unless value
32
+
33
+ model.base = ::Pubid::EtsiPfs::Identifier.from_hash(value)
34
+ end
35
+
36
+ # Delegations: the wrapper reads through to the wrapped
37
+ # standard (the coded SupplementIdentifier shape).
38
+ def type
39
+ base&.type
40
+ end
41
+
42
+ def version
43
+ base&.version
44
+ end
45
+
46
+ def year
47
+ base&.year
48
+ end
49
+
50
+ def month
51
+ base&.month
52
+ end
53
+
54
+ def is_edition
55
+ base&.is_edition
56
+ end
57
+
58
+ # The printed notation: the kind face + the ordinal.
59
+ def supplement_notation
60
+ "#{self.class.face}#{number}"
61
+ end
62
+
63
+ # The MR slug's supplement suffix: the shared MrString
64
+ # recurses into base and appends this (the coded
65
+ # mr_supplement_suffix rule).
66
+ def mr_supplement_suffix
67
+ [supplement_notation.to_s.downcase, number].compact.join(".")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,7 +1,10 @@
1
1
  module Pubid
2
2
  module EtsiPfs
3
3
  module Identifiers
4
- autoload :Standard, "#{__dir__}/identifiers/standard"
4
+ autoload :Standard, "#{__dir__}/identifiers/standard"
5
+ autoload :Supplement, "#{__dir__}/identifiers/supplement"
6
+ autoload :Amendment, "#{__dir__}/identifiers/amendment"
7
+ autoload :Corrigendum, "#{__dir__}/identifiers/corrigendum"
5
8
  end
6
9
  end
7
10
  end
@@ -12,8 +12,10 @@
12
12
  atom "year", "year"
13
13
  atom "month", "month"
14
14
  atom "version", "v"
15
- type "pubid:etsi_pfs:identifier", "id", core: []
16
- type "pubid:etsi_pfs:standard", "es", core: []
15
+ type "pubid:etsi:etsi-standard", "es", core: []
16
+ type "pubid:etsi:amendment", "amd", core: [["number", :int]]
17
+ type "pubid:etsi:corrigendum", "cor", core: [["number", :int]]
18
+ type "pubid:etsi:identifier", "id", core: []
17
19
  end
18
20
  end
19
21
  end