pubid 2.0.0.pre.alpha.1 → 2.0.0.pre.alpha.2

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/data/nist/update_codes.yaml +2 -0
  3. data/lib/pubid/amca/identifier.rb +39 -0
  4. data/lib/pubid/ansi/identifier.rb +42 -0
  5. data/lib/pubid/api/identifier.rb +47 -0
  6. data/lib/pubid/ashrae/identifier.rb +39 -0
  7. data/lib/pubid/asme/identifier.rb +46 -0
  8. data/lib/pubid/astm/identifier.rb +77 -0
  9. data/lib/pubid/bsi/identifier.rb +60 -0
  10. data/lib/pubid/ccsds/identifier.rb +68 -0
  11. data/lib/pubid/ccsds/identifiers/base.rb +11 -0
  12. data/lib/pubid/ccsds/single_identifier.rb +4 -1
  13. data/lib/pubid/cen_cenelec/identifier.rb +37 -0
  14. data/lib/pubid/cie/identifier.rb +53 -0
  15. data/lib/pubid/components/factory.rb +50 -0
  16. data/lib/pubid/components/typed_stage.rb +4 -0
  17. data/lib/pubid/components.rb +1 -0
  18. data/lib/pubid/csa/identifier.rb +56 -0
  19. data/lib/pubid/etsi/identifier.rb +43 -0
  20. data/lib/pubid/identifier.rb +8 -1
  21. data/lib/pubid/idf/identifier.rb +60 -0
  22. data/lib/pubid/iec/builder.rb +2 -1
  23. data/lib/pubid/iec/components/code.rb +2 -1
  24. data/lib/pubid/iec/components/publisher.rb +2 -1
  25. data/lib/pubid/iec/identifier.rb +235 -0
  26. data/lib/pubid/iec/identifiers/base.rb +4 -0
  27. data/lib/pubid/iec/identifiers/consolidated_identifier.rb +0 -4
  28. data/lib/pubid/iec/identifiers/fragment_identifier.rb +0 -4
  29. data/lib/pubid/iec/identifiers/sheet_identifier.rb +0 -4
  30. data/lib/pubid/iec/identifiers/vap_identifier.rb +0 -4
  31. data/lib/pubid/iec/parser.rb +7 -2
  32. data/lib/pubid/iec/urn_generator.rb +57 -171
  33. data/lib/pubid/iec/urn_parser.rb +53 -252
  34. data/lib/pubid/ieee/identifier.rb +41 -0
  35. data/lib/pubid/iho/identifier.rb +42 -0
  36. data/lib/pubid/iho/identifiers/base.rb +1 -1
  37. data/lib/pubid/iho/identifiers/bibliographic.rb +0 -4
  38. data/lib/pubid/iho/identifiers/circular_letter.rb +0 -4
  39. data/lib/pubid/iho/identifiers/miscellaneous.rb +0 -4
  40. data/lib/pubid/iho/identifiers/publication.rb +0 -4
  41. data/lib/pubid/iho/identifiers/standard.rb +0 -4
  42. data/lib/pubid/iho/urn_generator.rb +1 -1
  43. data/lib/pubid/iso/builder.rb +5 -1
  44. data/lib/pubid/iso/identifier.rb +261 -0
  45. data/lib/pubid/iso/parser.rb +4 -2
  46. data/lib/pubid/iso/scheme.rb +6 -0
  47. data/lib/pubid/iso/single_identifier.rb +6 -3
  48. data/lib/pubid/iso/urn_generator.rb +17 -3
  49. data/lib/pubid/iso/urn_parser.rb +16 -2
  50. data/lib/pubid/itu/identifier.rb +87 -22
  51. data/lib/pubid/jcgm/identifier.rb +43 -0
  52. data/lib/pubid/jis/identifier.rb +43 -0
  53. data/lib/pubid/nist/builder.rb +174 -5
  54. data/lib/pubid/nist/components/edition.rb +16 -0
  55. data/lib/pubid/nist/components/supplement.rb +88 -21
  56. data/lib/pubid/nist/identifier.rb +62 -0
  57. data/lib/pubid/nist/identifiers/base.rb +103 -24
  58. data/lib/pubid/nist/identifiers/circular_supplement.rb +1 -1
  59. data/lib/pubid/nist/identifiers/crpl_report.rb +1 -4
  60. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +10 -0
  61. data/lib/pubid/nist/identifiers/report.rb +1 -2
  62. data/lib/pubid/nist/parser.rb +36 -3
  63. data/lib/pubid/nist/supplement_identifier.rb +8 -24
  64. data/lib/pubid/nist/urn_generator.rb +14 -8
  65. data/lib/pubid/nist.rb +1 -0
  66. data/lib/pubid/oiml/identifier.rb +50 -0
  67. data/lib/pubid/plateau/identifier.rb +57 -0
  68. data/lib/pubid/plateau.rb +1 -0
  69. data/lib/pubid/renderers/base.rb +34 -0
  70. data/lib/pubid/renderers/directives_renderer.rb +13 -14
  71. data/lib/pubid/renderers/guide_renderer.rb +5 -1
  72. data/lib/pubid/renderers/human_readable.rb +20 -8
  73. data/lib/pubid/renderers/iwa_renderer.rb +5 -1
  74. data/lib/pubid/renderers/supplement_renderer.rb +4 -1
  75. data/lib/pubid/rendering/context.rb +5 -2
  76. data/lib/pubid/sae/identifier.rb +23 -0
  77. data/lib/pubid/scheme.rb +12 -0
  78. data/lib/pubid/version.rb +1 -1
  79. metadata +5 -2
@@ -6,6 +6,49 @@ module Pubid
6
6
  def self.parse(string)
7
7
  Pubid::Jcgm.parse(string)
8
8
  end
9
+
10
+ # Factory mirroring pubid 1.x's `Pubid::Jcgm::Identifier.create` API.
11
+ # Dispatches `:guide` (default) → Guide, `:gum_guide` → GumGuide.
12
+ TYPE_KEY_TO_KLASS = {
13
+ guide: "Guide",
14
+ gum_guide: "GumGuide",
15
+ }.freeze
16
+
17
+ def self.create(type: nil, **opts)
18
+ klass = resolve_create_class(type)
19
+ attrs = coerce_create_attrs(opts)
20
+ ts = klass.const_defined?(:TYPED_STAGES) &&
21
+ klass.const_get(:TYPED_STAGES).find do |t|
22
+ t.stage_code&.to_sym == :published
23
+ end
24
+ attrs[:typed_stage] = ts if ts
25
+ klass.new(**attrs)
26
+ end
27
+
28
+ def self.resolve_create_class(type)
29
+ return Identifiers::Guide if type.nil?
30
+
31
+ klass_name = TYPE_KEY_TO_KLASS[type.to_sym]
32
+ raise ArgumentError, "Unknown JCGM type: #{type.inspect}" unless klass_name
33
+
34
+ Identifiers.const_get(klass_name)
35
+ end
36
+
37
+ def self.coerce_create_attrs(opts)
38
+ attrs = {
39
+ publisher: Pubid::Jcgm::Components::Publisher.new(
40
+ publisher: (opts[:publisher] || "JCGM").to_s,
41
+ ),
42
+ }
43
+ if (v = opts[:number])
44
+ attrs[:number] = Pubid::Components::Code.new(value: v.to_s)
45
+ end
46
+ if (v = opts[:year])
47
+ attrs[:date] = Pubid::Components::Date.new(year: v.to_s)
48
+ end
49
+ attrs
50
+ end
51
+ private_class_method :resolve_create_class, :coerce_create_attrs
9
52
  end
10
53
  end
11
54
  end
@@ -13,6 +13,49 @@ module Pubid
13
13
  rescue Parslet::ParseFailed => e
14
14
  raise "Failed to parse JIS identifier '#{identifier}': #{e.message}"
15
15
  end
16
+
17
+ # Factory mirroring pubid 1.x's `Pubid::Jis::Identifier.create` API.
18
+ #
19
+ # JIS uses a different attribute schema from most flavors — `series`
20
+ # (single letter A–Z), `number` (integer), `parts` (integer collection),
21
+ # `year` (integer), `language`. Publisher is hardcoded "JIS" on the
22
+ # instance and not a constructor kwarg; supplying `:publisher` is
23
+ # silently ignored.
24
+ #
25
+ # Type dispatch: :jis → Standard, :tr → TechnicalReport,
26
+ # :ts → TechnicalSpecification. Default → Standard.
27
+ def self.create(type: nil, **opts)
28
+ klass = if type
29
+ safe_locate_klass(type) || Identifiers::Standard
30
+ else
31
+ Identifiers::Standard
32
+ end
33
+ klass.new(**coerce_create_attrs(opts))
34
+ end
35
+
36
+ # JIS Scheme raises ArgumentError on miss; wrap.
37
+ def self.safe_locate_klass(type)
38
+ Scheme.locate_identifier_klass_by_type_code(type)
39
+ rescue ArgumentError
40
+ nil
41
+ end
42
+
43
+ def self.coerce_create_attrs(opts)
44
+ attrs = {}
45
+ attrs[:series] = opts[:series].to_s if opts[:series]
46
+ attrs[:number] = opts[:number].to_i if opts[:number]
47
+ if opts[:parts]
48
+ attrs[:parts] = Array(opts[:parts]).map(&:to_i)
49
+ end
50
+ attrs[:code] = opts[:code] if opts[:code]
51
+ attrs[:year] = opts[:year].to_i if opts[:year]
52
+ attrs[:language] = opts[:language].to_s if opts[:language]
53
+ attrs[:all_parts] = opts[:all_parts] if opts.key?(:all_parts)
54
+ # TODO(create-shim): :publisher silently ignored (JIS hardcodes
55
+ # "JIS"); supplement subclasses not yet wired through.
56
+ attrs
57
+ end
58
+ private_class_method :safe_locate_klass, :coerce_create_attrs
16
59
  end
17
60
  end
18
61
  end
@@ -369,10 +369,30 @@ module Pubid
369
369
  part_num = nil
370
370
  extracted_revision = nil
371
371
 
372
+ # Accumulate supplement signals from the casts (a flat value string,
373
+ # the has_revision flag, and date-range start/end) and fold them into a
374
+ # single Components::Supplement at the end. They are intercepted (not
375
+ # assigned) because :supplement is now a component attribute, so a raw
376
+ # string must never be written to it directly.
377
+ supp = { value: nil, has_revision: false, range_start: nil,
378
+ range_end: nil, present: false }
379
+ capture_supplement = lambda do |k, v|
380
+ case k
381
+ when :supplement then supp[:value] = v
382
+ when :supplement_has_revision then supp[:has_revision] = !!v
383
+ when :supplement_date_range_start then supp[:range_start] = v
384
+ when :supplement_date_range_end then supp[:range_end] = v
385
+ else return false
386
+ end
387
+ supp[:present] = true
388
+ true
389
+ end
390
+
372
391
  # Cast and assign all attributes
373
392
  parsed_hash.each_pair do |key, value|
374
393
  realized_components = cast(key.to_sym, value, parsed_hash) # Pass parsed_hash for context
375
394
  next if realized_components.nil?
395
+ next if !realized_components.is_a?(Hash) && capture_supplement.call(key.to_sym, realized_components)
376
396
 
377
397
  # Track number components
378
398
  if key == :first_number && realized_components.is_a?(Components::Code)
@@ -427,6 +447,10 @@ module Pubid
427
447
  # Skip assignment for second_number hashes - they'll be processed during compound number construction
428
448
  next if sub_key == :second_number && sub_value.is_a?(Hash) && sub_value[:number_only]
429
449
 
450
+ # Intercept supplement signals into the accumulator instead of
451
+ # assigning them (supplement is now a component built at the end).
452
+ next if capture_supplement.call(sub_key, sub_value)
453
+
430
454
  attrs = identifier.class.attributes
431
455
  setter = "#{sub_key}="
432
456
  if attrs.key?(sub_key.to_sym)
@@ -543,7 +567,16 @@ module Pubid
543
567
  year_part = second_num.value.to_s
544
568
 
545
569
  identifier.number = Components::Code.new(number: number_part)
546
- identifier.supplement = year_part
570
+ supp[:value] = year_part
571
+ supp[:present] = true
572
+ elsif second_num.value.to_s.match?(/^(\d+)supp?$/)
573
+ # Pattern: "800-53sup"/"800-53supp" - bare marker on the compound
574
+ # second number. Strip it and isolate as supplement="" (single-p).
575
+ second_part = second_num.value.to_s.match(/^(\d+)supp?$/)[1]
576
+ compound = "#{first_num.value}-#{second_part}"
577
+ identifier.number = Components::Code.new(number: compound)
578
+ supp[:value] = ""
579
+ supp[:present] = true
547
580
  elsif identifier.is_a?(Identifiers::TechnicalNote) &&
548
581
  second_num.value.to_s.match?(/^(19|20)\d{2}$/)
549
582
  # SPECIAL CASE FOR TN: second_num is edition year
@@ -633,9 +666,41 @@ module Pubid
633
666
  identifier.revision_month = nil
634
667
  end
635
668
 
669
+ # Fold the accumulated supplement signals into the single structured
670
+ # supplement component (the source of truth).
671
+ if (supp[:present] || supp[:has_revision]) &&
672
+ identifier.respond_to?(:supplement=)
673
+ identifier.supplement = supplement_from(
674
+ value: supp[:value], has_revision: supp[:has_revision],
675
+ range_start: supp[:range_start], range_end: supp[:range_end]
676
+ )
677
+ end
678
+
636
679
  identifier
637
680
  end
638
681
 
682
+ # Build a Components::Supplement from the builder's accumulated raw signals
683
+ # (flat value string, has_revision flag, fused date-range start/end). This
684
+ # is the one place raw supplement text becomes the structured component.
685
+ def supplement_from(value:, has_revision:, range_start:, range_end:)
686
+ if range_start || range_end
687
+ component = Components::Supplement.new
688
+ # Split the fused "Jun1925"/"Jun1926" strings into isolated start/end
689
+ # month+year nodes (start reuses :month/:year, end uses *_end).
690
+ if range_start && (m = range_start.match(/\A([A-Za-z]{3,9})(\d{4})\z/))
691
+ component.month = m[1]
692
+ component.year = m[2]
693
+ end
694
+ if range_end && (m = range_end.match(/\A([A-Za-z]{3,9})(\d{4})\z/))
695
+ component.month_end = m[1]
696
+ component.year_end = m[2]
697
+ end
698
+ component
699
+ else
700
+ Components::Supplement.from_raw(value, has_revision: has_revision)
701
+ end
702
+ end
703
+
639
704
  # Convert month name to month number
640
705
  # @param month_name [String] month abbreviation (Jan, Feb, Mar, etc.)
641
706
  # @return [Integer] month number (1-12)
@@ -659,8 +724,109 @@ module Pubid
659
724
 
660
725
  # Build CircularSupplement with base_identifier wrapping
661
726
  # @param parsed_hash [Hash] the parsed supplement data
662
- # @return [Identifiers::CircularSupplement] the supplement identifier
727
+ # Build a CIRC/LCIRC supplement. Most forms collapse onto the base
728
+ # identifier's normal class (Circular / LetterCircular) with isolated
729
+ # supplement attributes, so they share one model with every other series
730
+ # and are queryable by part. The V1-compat update forms (slash-year
731
+ # "118supp3/1926" → ".../Upd1-192603"; implicit revision "145r11/1925")
732
+ # render through an Update component the flat supplement model can't
733
+ # express, so they stay on the CircularSupplement wrapper for now.
663
734
  def build_circular_supplement(parsed_hash)
735
+ if parsed_hash[:supplement_slash_year].is_a?(Hash) ||
736
+ parsed_hash[:implicit_supplement].is_a?(Hash)
737
+ return build_circular_supplement_wrapper(parsed_hash)
738
+ end
739
+
740
+ series_value = if parsed_hash[:circ_series].is_a?(Hash)
741
+ parsed_hash[:circ_series][:series]
742
+ else
743
+ parsed_hash[:series]
744
+ end
745
+
746
+ # Date-range supplement (no base document): a plain base identifier with
747
+ # no number, carrying the range. parsed_format is left at the default so
748
+ # a dotted MR input still normalizes to the spaced short form.
749
+ if parsed_hash[:supplement_date_range].is_a?(Hash)
750
+ range = parsed_hash[:supplement_date_range]
751
+ identifier = build({ series: series_value })
752
+ ms = range[:supp_month_start]&.to_s
753
+ ys = range[:supp_year_start]&.to_s
754
+ me = range[:supp_month_end]&.to_s
755
+ ye = range[:supp_year_end]&.to_s
756
+ identifier.supplement = supplement_from(
757
+ value: nil, has_revision: false,
758
+ range_start: (ms && ys ? "#{ms}#{ys}" : nil),
759
+ range_end: (me && ye ? "#{me}#{ye}" : nil)
760
+ )
761
+ return identifier
762
+ end
763
+
764
+ # Based supplement: build the base, then attach the supplement as an
765
+ # isolated component on that normal class.
766
+ identifier = build_circular_supplement_base(parsed_hash, series_value)
767
+ raw = if parsed_hash[:supplement_month_year]
768
+ parsed_hash[:supplement_month_year].to_s
769
+ elsif parsed_hash[:supplement_year]
770
+ parsed_hash[:supplement_year].to_s
771
+ else
772
+ "" # supplement_empty or bare marker
773
+ end
774
+ identifier.supplement = Components::Supplement.from_raw(raw)
775
+ identifier
776
+ end
777
+
778
+ # Build just the base identifier for a based CIRC/LCIRC supplement, from
779
+ # base_portion (number, optional edition "101e2" or letter suffix "378G")
780
+ # or the merged first_number fallback. Returns the normal class.
781
+ def build_circular_supplement_base(parsed_hash, series_value)
782
+ base_portion = parsed_hash[:base_portion]
783
+ unless base_portion
784
+ return build({
785
+ publisher: parsed_hash[:publisher],
786
+ series: series_value || parsed_hash[:series],
787
+ first_number: parsed_hash[:first_number],
788
+ parsed_format: parsed_hash[:parsed_format],
789
+ })
790
+ end
791
+
792
+ base_number = if base_portion.is_a?(Hash)
793
+ base_portion[:simple_number] || base_portion[:base_number]
794
+ else
795
+ base_portion
796
+ end
797
+
798
+ letter_suffix = nil
799
+ if base_portion.is_a?(Hash) && base_portion[:letter_suffix]
800
+ letter_suffix = base_portion[:letter_suffix].to_s.upcase
801
+ end
802
+
803
+ publisher_value = nil
804
+ if parsed_hash[:circ_series].is_a?(Hash) && parsed_hash[:circ_series][:series]
805
+ series_str = parsed_hash[:circ_series][:series].to_s
806
+ publisher_value = series_str.split.first if series_str.include?(" ")
807
+ end
808
+
809
+ has_edition = base_portion.is_a?(Hash) && base_portion[:edition_number]
810
+
811
+ base_number_with_suffix = base_number.to_s
812
+ base_number_with_suffix += letter_suffix if letter_suffix
813
+ if has_edition
814
+ base_number_with_suffix += "e#{base_portion[:edition_number]}"
815
+ end
816
+
817
+ base_hash = {
818
+ series: series_value,
819
+ first_number: base_number_with_suffix,
820
+ parsed_format: parsed_hash[:parsed_format],
821
+ }
822
+ base_hash[:publisher] = publisher_value if publisher_value
823
+ base_hash[:edition_e] = { edition_id: base_portion[:edition_number] } if has_edition
824
+
825
+ build(base_hash)
826
+ end
827
+
828
+ # @return [Identifiers::CircularSupplement] the supplement identifier
829
+ def build_circular_supplement_wrapper(parsed_hash)
664
830
  supplement = Identifiers::CircularSupplement.new
665
831
 
666
832
  # Extract series from circ_series if present (nested structure from parser)
@@ -1066,9 +1232,12 @@ module Pubid
1066
1232
  end
1067
1233
  end
1068
1234
 
1069
- # NEW: Pattern "9350sup" - number with "sup" suffix (no year)
1070
- # This handles RPT supplements like "NBS RPT 9350sup"
1071
- if str_value =~ /^(\d+)sup$/
1235
+ # Pattern "9350sup"/"5893supp" - number with bare supplement marker
1236
+ # (no trailing payload). Accept both single-p "sup" and double-p
1237
+ # "supp" so the marker is isolated as supplement="" and rendered as
1238
+ # canonical single-p "sup", instead of staying baked into the number
1239
+ # as an opaque suffix. E.g. "NBS RPT 5893supp", "NBS MONO 32supp".
1240
+ if str_value =~ /^(\d+)supp?$/
1072
1241
  return {
1073
1242
  first_number: Components::Code.new(number: $1),
1074
1243
  supplement: "",
@@ -49,6 +49,22 @@ module Pubid
49
49
  end
50
50
  end
51
51
 
52
+ # Identity ignores original_prefix: it records how the edition was
53
+ # spelled in the source ("Rev. " vs "r") for round-trip rendering
54
+ # only. "r1" and "Rev. 1" are the same edition.
55
+ def ==(other)
56
+ return false unless other.is_a?(self.class)
57
+
58
+ type == other.type && id == other.id &&
59
+ additional_text == other.additional_text
60
+ end
61
+
62
+ alias eql? ==
63
+
64
+ def hash
65
+ [self.class, type, id, additional_text].hash
66
+ end
67
+
52
68
  private
53
69
 
54
70
  # Build short format: "e2", "e2.June1908", "e2.1908", "e2.50", "r1963", "-April1909", "r1a"
@@ -9,17 +9,19 @@ module Pubid
9
9
  # Represents supplement notation with number, year, month, or revision
10
10
  #
11
11
  # Examples:
12
- # Supplement.new(number: "2").to_s(:short) # => "supp2"
13
- # Supplement.new(year: "1925").to_s(:short) # => "supp-1925"
14
- # Supplement.new(number: "3", year: "1926").to_s(:short) # => "supp3/1926"
15
- # Supplement.new(month: "Jan", year: "1924").to_s(:short) # => "suppJan1924"
16
- # Supplement.new(has_revision: true).to_s(:short) # => "supprev"
12
+ # Supplement.new(number: "2").to_s(:short) # => "sup2"
13
+ # Supplement.new(year: "1925").to_s(:short) # => "sup1925"
14
+ # Supplement.new(number: "3", year: "1926").to_s(:short) # => "sup3/1926"
15
+ # Supplement.new(month: "Jan", year: "1924").to_s(:short) # => "supJan1924"
16
+ # Supplement.new(has_revision: true).to_s(:short) # => "suprev"
17
17
  class Supplement < Lutaml::Model::Serializable
18
18
  attribute :number, :string # Supplement number (e.g., "2" in "supp2")
19
- attribute :year, :string # Year (4 digits)
20
- attribute :month, :string # Month abbreviation (Jan, Feb, etc.)
21
- attribute :month_start, :string # Date range start month
22
- attribute :year_start, :string # Date range start year
19
+ attribute :year, :string # Year (4 digits); range START year
20
+ attribute :month, :string # Month abbreviation; range START month
21
+ # Date ranges reuse :month/:year as the start and add the end below. A
22
+ # present :month_end/:year_end is what marks a value as a range, so a
23
+ # lone month-year supplement is just a range with no end — and :year is
24
+ # the single field to filter on for both.
23
25
  attribute :month_end, :string # Date range end month
24
26
  attribute :year_end, :string # Date range end year
25
27
  attribute :has_revision, :boolean, default: -> {
@@ -27,12 +29,36 @@ module Pubid
27
29
  } # "supprev" pattern
28
30
  attribute :suffix, :string # General suffix for other patterns
29
31
 
32
+ # Build a Supplement from the flat string the parser/builder produces
33
+ # ("1924" year, "Jan1924" month+year, "1" number, "A" suffix, "" bare).
34
+ # has_revision is supplied separately. Returns a present-but-empty
35
+ # Supplement for an empty/nil value so callers can distinguish a bare
36
+ # supplement ("sup") from no supplement (nil).
37
+ def self.from_raw(value, has_revision: false)
38
+ supp = new
39
+ if has_revision
40
+ supp.has_revision = true
41
+ elsif value.nil? || value.to_s.empty?
42
+ # bare marker: present but no isolated parts
43
+ elsif (m = value.to_s.match(/\A([A-Za-z]{3,9})(\d{4})\z/))
44
+ supp.month = m[1]
45
+ supp.year = m[2]
46
+ elsif value.to_s.match?(/\A(?:18|19|20)\d{2}\z/)
47
+ supp.year = value.to_s
48
+ elsif value.to_s.match?(/\A\d+\z/)
49
+ supp.number = value.to_s
50
+ else
51
+ supp.suffix = value.to_s
52
+ end
53
+ supp
54
+ end
55
+
30
56
  # Render supplement in specified format
31
57
  # @param format [:short, :mr, :long] The output format
32
58
  # @return [String] The formatted supplement representation
33
59
  def to_s(format = :short)
34
- return "" if number.nil? && year.nil? && !has_revision && suffix.nil? &&
35
- month_start.nil? && year_start.nil? && month_end.nil? && year_end.nil?
60
+ return "" if number.nil? && year.nil? && month.nil? && !has_revision &&
61
+ suffix.nil? && month_end.nil? && year_end.nil?
36
62
 
37
63
  case format
38
64
  when :short, :mr
@@ -44,12 +70,52 @@ module Pubid
44
70
  end
45
71
  end
46
72
 
73
+ # True when this supplement is a date range (start reuses month/year).
74
+ def range?
75
+ date_range?
76
+ end
77
+
78
+ # The text after the "sup" marker for the simple (non-range, non-rev)
79
+ # forms: "1924" / "Jan1924" / "1" / "A"; "" for a bare supplement. Used
80
+ # by URN rendering, which handles range/revision separately.
81
+ def value_string
82
+ return "" if has_revision || date_range?
83
+ return suffix.to_s if suffix
84
+ return "#{month}#{year}" if month && year
85
+ return "#{number}/#{year}" if number && year
86
+ return year.to_s if year
87
+ return number.to_s if number
88
+
89
+ ""
90
+ end
91
+
92
+ # Value equality over the isolated parts, so a structured supplement
93
+ # participates correctly in Identifier#== and #matches? (two supplements
94
+ # with the same number/year/month/range/revision are the same).
95
+ IDENTITY_FIELDS = %i[
96
+ number year month month_end year_end has_revision suffix
97
+ ].freeze
98
+
99
+ def ==(other)
100
+ return false unless other.is_a?(self.class)
101
+
102
+ IDENTITY_FIELDS.all? { |f| send(f) == other.send(f) }
103
+ end
104
+
105
+ alias eql? ==
106
+
107
+ def hash
108
+ [self.class, *IDENTITY_FIELDS.map { |f| send(f) }].hash
109
+ end
110
+
47
111
  private
48
112
 
49
- # Build short format: "supp2", "supp-1925", "supp3/1926", "suppJan1924", "supprev"
113
+ # Build short format: "sup2", "sup1925", "sup3/1926", "supJan1924", "suprev"
114
+ # NIST/NBS canonical short form is single-p "sup" with the suffix
115
+ # attached directly (relaton-data-nist uses "sup2", "sup1940", "supA").
50
116
  def build_short_format
51
- return "supprev" if has_revision
52
- return "supp#{suffix}" if suffix
117
+ return "suprev" if has_revision
118
+ return "sup#{suffix}" if suffix
53
119
  return build_date_range_format if date_range?
54
120
  return build_month_year_format if month && year
55
121
  return build_number_year_format if number && year
@@ -72,32 +138,33 @@ module Pubid
72
138
  ""
73
139
  end
74
140
 
141
+ # A range is marked by the presence of an END; START reuses month/year.
75
142
  def date_range?
76
- month_start && year_start && month_end && year_end
143
+ month && year && month_end && year_end
77
144
  end
78
145
 
79
146
  def build_date_range_format
80
- "supp#{month_start}#{year_start}-#{month_end}#{year_end}"
147
+ "sup#{month}#{year}-#{month_end}#{year_end}"
81
148
  end
82
149
 
83
150
  def build_month_year_format
84
- "supp#{month}#{year}"
151
+ "sup#{month}#{year}"
85
152
  end
86
153
 
87
154
  def build_number_year_format
88
- "supp#{number}/#{year}"
155
+ "sup#{number}/#{year}"
89
156
  end
90
157
 
91
158
  def build_year_format
92
- "supp-#{year}"
159
+ "sup#{year}"
93
160
  end
94
161
 
95
162
  def build_number_format
96
- "supp#{number}"
163
+ "sup#{number}"
97
164
  end
98
165
 
99
166
  def build_long_date_range_format
100
- "Supplement #{month_start} #{year_start}-#{month_end} #{year_end}"
167
+ "Supplement #{month} #{year}-#{month_end} #{year_end}"
101
168
  end
102
169
 
103
170
  def build_long_month_year_format
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Nist
5
+ # NIST factory entry point. `.parse` lives on `Pubid::Nist` itself for
6
+ # historical reasons; this module hosts `.create` for API consistency
7
+ # with the other pubid flavors.
8
+ module Identifier
9
+ # Delegate to the flavor module so callers can use
10
+ # `Pubid::Nist::Identifier.parse` consistently with other flavors.
11
+ def self.parse(identifier)
12
+ Pubid::Nist.parse(identifier)
13
+ end
14
+
15
+ # Factory mirroring pubid 1.x's `Pubid::Nist::Identifier.create` API.
16
+ #
17
+ # Dispatch is by `:series` (e.g. "SP", "FIPS", "IR", "HB", "TN")
18
+ # rather than a type code — NIST organises identifiers by document
19
+ # series. {Scheme.locate_identifier_klass} handles the mapping,
20
+ # including compound prefixes like "NBS CIRC" and special cases
21
+ # (CSM, CS-E, etc.).
22
+ def self.create(**opts)
23
+ klass = Scheme.locate_identifier_klass(
24
+ series: opts[:series]&.to_s,
25
+ ) || Identifiers::Base
26
+ klass.new(**coerce_create_attrs(opts))
27
+ end
28
+
29
+ def self.coerce_create_attrs(opts)
30
+ attrs = {}
31
+ if (v = opts[:publisher])
32
+ attrs[:publisher] = Pubid::Nist::Components::Publisher.new(
33
+ publisher: v.to_s,
34
+ )
35
+ # NIST renderer prints the publisher only when it was parsed
36
+ # (or explicitly flagged as such).
37
+ attrs[:publisher_was_parsed] = true
38
+ end
39
+
40
+ attrs[:series] = wrap_code(opts[:series]) if opts[:series]
41
+ attrs[:number] = wrap_code(opts[:number]) if opts[:number]
42
+
43
+ %i[edition volume part stage version_component update_component
44
+ translation_component issue_number].each do |k|
45
+ attrs[k] = opts[k] unless opts[k].nil?
46
+ end
47
+
48
+ # TODO(create-shim): expose primitive coercion for edition/volume/
49
+ # part etc. once a caller needs it; for now pass-through accepts
50
+ # already-built NIST Components.
51
+ attrs
52
+ end
53
+
54
+ def self.wrap_code(v)
55
+ return v if v.is_a?(Pubid::Nist::Components::Code)
56
+
57
+ Pubid::Nist::Components::Code.new(number: v.to_s)
58
+ end
59
+ private_class_method :coerce_create_attrs, :wrap_code
60
+ end
61
+ end
62
+ end