pubid 2.0.0.pre.alpha.4 → 2.0.0.pre.alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/pubid/bsi/builder.rb +41 -48
- data/lib/pubid/bsi/identifiers/amendment.rb +28 -0
- data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +11 -1
- data/lib/pubid/bsi/identifiers/corrigendum.rb +24 -0
- data/lib/pubid/bsi/identifiers/expert_commentary.rb +5 -0
- data/lib/pubid/bsi/identifiers/flex.rb +4 -0
- data/lib/pubid/bsi/parser.rb +19 -5
- data/lib/pubid/bsi/renderer.rb +19 -17
- data/lib/pubid/iala/builder.rb +51 -0
- data/lib/pubid/iala/identifier.rb +63 -0
- data/lib/pubid/iala/identifiers/base.rb +14 -0
- data/lib/pubid/iala/identifiers/guideline.rb +15 -0
- data/lib/pubid/iala/identifiers/manual.rb +16 -0
- data/lib/pubid/iala/identifiers/model_course.rb +15 -0
- data/lib/pubid/iala/identifiers/recommendation.rb +15 -0
- data/lib/pubid/iala/identifiers/report.rb +16 -0
- data/lib/pubid/iala/identifiers/resolution.rb +16 -0
- data/lib/pubid/iala/identifiers/standard.rb +15 -0
- data/lib/pubid/iala/identifiers.rb +16 -0
- data/lib/pubid/iala/parser.rb +99 -0
- data/lib/pubid/iala/renderer.rb +22 -0
- data/lib/pubid/iala/urn_generator.rb +32 -0
- data/lib/pubid/iala/urn_parser.rb +50 -0
- data/lib/pubid/iala.rb +66 -0
- data/lib/pubid/identifier.rb +132 -1
- data/lib/pubid/oiml/builder.rb +3 -1
- data/lib/pubid/oiml/parser.rb +14 -1
- data/lib/pubid/oiml/renderer.rb +10 -0
- data/lib/pubid/oiml/supplement_identifier.rb +4 -0
- data/lib/pubid/version.rb +1 -1
- data/lib/pubid.rb +1 -0
- metadata +18 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
module Identifiers
|
|
6
|
+
# IALA Resolutions and other Council publications (P prefix,
|
|
7
|
+
# planned). Today resolutions carry no code; P is reserved for
|
|
8
|
+
# future numbered resolutions.
|
|
9
|
+
class Resolution < Base
|
|
10
|
+
def self.type
|
|
11
|
+
{ key: :resolution, title: "Resolution", short: "P" }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
module Identifiers
|
|
6
|
+
# IALA Standards (S series).
|
|
7
|
+
# Examples: S1010, S1020 Ed 2.0, S1070 Ed 2.0.
|
|
8
|
+
class Standard < Base
|
|
9
|
+
def self.type
|
|
10
|
+
{ key: :standard, title: "Standard", short: "S" }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
module Identifiers
|
|
6
|
+
autoload :Base, "#{__dir__}/identifiers/base"
|
|
7
|
+
autoload :Standard, "#{__dir__}/identifiers/standard"
|
|
8
|
+
autoload :Recommendation, "#{__dir__}/identifiers/recommendation"
|
|
9
|
+
autoload :Guideline, "#{__dir__}/identifiers/guideline"
|
|
10
|
+
autoload :Manual, "#{__dir__}/identifiers/manual"
|
|
11
|
+
autoload :ModelCourse, "#{__dir__}/identifiers/model_course"
|
|
12
|
+
autoload :Report, "#{__dir__}/identifiers/report"
|
|
13
|
+
autoload :Resolution, "#{__dir__}/identifiers/resolution"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parslet"
|
|
4
|
+
|
|
5
|
+
module Pubid
|
|
6
|
+
module Iala
|
|
7
|
+
# Parslet grammar for IALA identifiers.
|
|
8
|
+
#
|
|
9
|
+
# Accepts every shape that appears on the IALA website or cover page:
|
|
10
|
+
#
|
|
11
|
+
# S1070
|
|
12
|
+
# S1070 Ed 2.0
|
|
13
|
+
# IALA S1070 Ed 2.0
|
|
14
|
+
# S1070 Ed 2.0 (F)
|
|
15
|
+
# R1016:ed2.0(F)
|
|
16
|
+
# C0103-1
|
|
17
|
+
# C0103-1 Ed 3.0
|
|
18
|
+
class Parser < Parslet::Parser
|
|
19
|
+
include ::Pubid::Parser::CommonParseRules
|
|
20
|
+
|
|
21
|
+
root :identifier
|
|
22
|
+
|
|
23
|
+
rule(:space) { str(" ") }
|
|
24
|
+
rule(:space?) { space.maybe }
|
|
25
|
+
rule(:dash) { str("-") }
|
|
26
|
+
rule(:colon) { str(":") }
|
|
27
|
+
rule(:dot) { str(".") }
|
|
28
|
+
rule(:lparen) { str("(") }
|
|
29
|
+
rule(:rparen) { str(")") }
|
|
30
|
+
|
|
31
|
+
# Type letter — one of IALA's seven document classes. Captured to
|
|
32
|
+
# route to the right Identifiers::* subclass.
|
|
33
|
+
rule(:type_letter) do
|
|
34
|
+
(str("S") | str("R") | str("G") | str("M") |
|
|
35
|
+
str("C") | str("X") | str("P")).as(:type_letter)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 4-digit document number, e.g. "1070", "0126".
|
|
39
|
+
rule(:doc_number) do
|
|
40
|
+
match("[0-9]").repeat(4, 4).as(:doc_number)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Numeric sub-part suffix(es): "-1", "-9-10", "-11". The catalogue
|
|
44
|
+
# uses ranges like R0124-9-10 & 11 — that compound is captured
|
|
45
|
+
# verbatim and resolved by the caller, not here.
|
|
46
|
+
rule(:subpart) do
|
|
47
|
+
(dash >> digits.as(:subpart_number)).repeat(1).as(:subpart)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
rule(:code) do
|
|
51
|
+
type_letter >> doc_number >> subpart.maybe
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Edition forms observed on covers and listing pages:
|
|
55
|
+
# " Ed 2.0" — cover-page human form
|
|
56
|
+
# ":ed2.0" — listing-page compact form
|
|
57
|
+
rule(:edition_human) do
|
|
58
|
+
(space >> str("Ed") >> space >> edition_value).as(:edition)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
rule(:edition_compact) do
|
|
62
|
+
(colon >> str("ed") >> edition_value).as(:edition)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
rule(:edition_value) do
|
|
66
|
+
(digits >> (dot >> digits).repeat).as(:edition_value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
rule(:edition) do
|
|
70
|
+
edition_human | edition_compact
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Language is a single uppercase letter inside parens: (E), (F), …
|
|
74
|
+
rule(:language) do
|
|
75
|
+
(space? >> lparen >>
|
|
76
|
+
(str("E") | str("F") | str("S") | str("C") | str("A") | str("R")).as(:language) >>
|
|
77
|
+
rparen).as(:language_group)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Optional "IALA " prefix.
|
|
81
|
+
rule(:publisher) do
|
|
82
|
+
(str("IALA") >> space).maybe
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
rule(:identifier) do
|
|
86
|
+
publisher >> code >> edition.maybe >> language.maybe
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parse a string and return the raw parslet tree.
|
|
90
|
+
# @param string [String]
|
|
91
|
+
# @return [Hash]
|
|
92
|
+
def self.parse(string)
|
|
93
|
+
raise ArgumentError, "IALA identifier string exceeds maximum length" if string.length > ::Pubid::MAX_INPUT_LENGTH
|
|
94
|
+
|
|
95
|
+
new.parse(string.strip)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
# Human-readable renderer for IALA identifiers.
|
|
6
|
+
#
|
|
7
|
+
# Produces strings like:
|
|
8
|
+
# "IALA S1070"
|
|
9
|
+
# "IALA S1070 Ed 2.0"
|
|
10
|
+
# "IALA R1016 Ed 2.0 (F)"
|
|
11
|
+
class Renderer < ::Pubid::Renderers::Base
|
|
12
|
+
def render(context: nil, **_opts)
|
|
13
|
+
id = @id
|
|
14
|
+
|
|
15
|
+
rendered = "#{id.publisher} #{id.type_letter}#{id.number}"
|
|
16
|
+
rendered << " Ed #{id.edition}" if id.edition
|
|
17
|
+
rendered << " (#{id.language})" if id.language
|
|
18
|
+
rendered
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
# Generates URN strings for IALA identifiers using the Maritime Resource
|
|
6
|
+
# Name (MRN) namespace that IALA prints on its cover pages.
|
|
7
|
+
#
|
|
8
|
+
# Format: urn:mrn:iala:pub:<type-lower><number>[:ed<edition>][:<lang-lower>]
|
|
9
|
+
# Example: urn:mrn:iala:pub:s1070:ed2.0
|
|
10
|
+
# urn:mrn:iala:pub:r1016:ed2.0:f
|
|
11
|
+
class UrnGenerator
|
|
12
|
+
attr_reader :identifier
|
|
13
|
+
|
|
14
|
+
def initialize(identifier)
|
|
15
|
+
@identifier = identifier
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate
|
|
19
|
+
parts = ["urn:mrn:iala:pub", code_segment]
|
|
20
|
+
parts << "ed#{identifier.edition}" if identifier.edition
|
|
21
|
+
parts << identifier.language.downcase if identifier.language
|
|
22
|
+
parts.join(":")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def code_segment
|
|
28
|
+
"#{identifier.type_letter.downcase}#{identifier.number}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
# Parses IALA MRN URNs back into identifiers.
|
|
6
|
+
#
|
|
7
|
+
# UrnGenerator emits:
|
|
8
|
+
# urn:mrn:iala:pub:<type-lower><number>[:ed<edition>][:<lang-lower>]
|
|
9
|
+
#
|
|
10
|
+
# Examples:
|
|
11
|
+
# urn:mrn:iala:pub:s1070:ed2.0 → IALA S1070 Ed 2.0
|
|
12
|
+
# urn:mrn:iala:pub:r1016:ed2.0:f → IALA R1016 Ed 2.0 (F)
|
|
13
|
+
# urn:mrn:iala:pub:c0103-1 → IALA C0103-1
|
|
14
|
+
class UrnParser < Pubid::UrnParser::Base
|
|
15
|
+
PREFIX = "urn:mrn:iala:pub:".freeze
|
|
16
|
+
|
|
17
|
+
def parse_urn(urn)
|
|
18
|
+
body = strip_namespace(urn)
|
|
19
|
+
parts = split_parts(body)
|
|
20
|
+
code = parts.fetch(0)
|
|
21
|
+
|
|
22
|
+
edition = nil
|
|
23
|
+
language = nil
|
|
24
|
+
parts.drop(1).each do |seg|
|
|
25
|
+
if seg.start_with?("ed")
|
|
26
|
+
edition = seg.sub(/\Aed/, "")
|
|
27
|
+
elsif seg.length == 1 && seg.match?(/\A[a-z]\z/i)
|
|
28
|
+
language = seg.upcase
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
text = "IALA #{code.upcase}"
|
|
33
|
+
text += " Ed #{edition}" if edition
|
|
34
|
+
text += " (#{language})" if language
|
|
35
|
+
flavor_parse(text)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def strip_namespace(urn)
|
|
41
|
+
unless urn.downcase.start_with?(PREFIX)
|
|
42
|
+
raise Pubid::UrnParser::Errors::ParseError,
|
|
43
|
+
"Invalid IALA URN: #{urn.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
urn[PREFIX.length..]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/pubid/iala.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Iala
|
|
5
|
+
autoload :Builder, "#{__dir__}/iala/builder"
|
|
6
|
+
autoload :Identifier, "#{__dir__}/iala/identifier"
|
|
7
|
+
autoload :Identifiers, "#{__dir__}/iala/identifiers"
|
|
8
|
+
autoload :Parser, "#{__dir__}/iala/parser"
|
|
9
|
+
autoload :Renderer, "#{__dir__}/iala/renderer"
|
|
10
|
+
autoload :UrnGenerator, "#{__dir__}/iala/urn_generator"
|
|
11
|
+
autoload :UrnParser, "#{__dir__}/iala/urn_parser"
|
|
12
|
+
|
|
13
|
+
# Parse an IALA identifier string into an identifier object.
|
|
14
|
+
# @param identifier [String] The IALA identifier string to parse
|
|
15
|
+
# @return [Pubid::Iala::Identifier]
|
|
16
|
+
def self.parse(identifier)
|
|
17
|
+
Identifier.parse(identifier)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Per-flavor format registry: inherits global formats, overrides :human
|
|
21
|
+
Identifiers::Base.format_registry = FormatRegistry.new(parent: ::Pubid::Identifier.format_registry)
|
|
22
|
+
Identifiers::Base.format_registry.register(:human, renderer: Iala::Renderer)
|
|
23
|
+
|
|
24
|
+
# Auto-discover all identifier types from the Identifiers namespace.
|
|
25
|
+
# @return [Array<Class>] identifier classes (Pubid::Identifier subclasses)
|
|
26
|
+
def self.identifier_types
|
|
27
|
+
@identifier_types ||= Identifiers.constants
|
|
28
|
+
.filter_map { |c| begin; Identifiers.const_get(c); rescue NameError; nil; end }
|
|
29
|
+
.select { |c| c.is_a?(Class) && c < Pubid::Identifier }
|
|
30
|
+
.reject { |c| c == Identifiers::Base }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Build typed stage index from identifier types
|
|
34
|
+
# @return [Array<Pubid::Components::TypedStage>]
|
|
35
|
+
def self.all_typed_stages
|
|
36
|
+
@all_typed_stages ||= identifier_types.flat_map do |klass|
|
|
37
|
+
klass.const_defined?(:TYPED_STAGES) ? klass.const_get(:TYPED_STAGES) : []
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Lookup: type code -> identifier class
|
|
42
|
+
# @param code [String, Symbol]
|
|
43
|
+
# @return [Class, nil]
|
|
44
|
+
def self.locate_type(code)
|
|
45
|
+
identifier_types.find { |t| t.type[:key].to_s == code.to_s }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Lookup: abbreviation -> typed stage
|
|
49
|
+
# @param abbr [String, Symbol]
|
|
50
|
+
# @return [Pubid::Components::TypedStage, nil]
|
|
51
|
+
def self.locate_stage(abbr)
|
|
52
|
+
abbr_str = abbr.to_s.upcase
|
|
53
|
+
all_typed_stages.find { |s| s.abbr.any? { |a| a.to_s.upcase == abbr_str } }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Look up an identifier class by its IALA type letter (S, R, G, M, C, X, P).
|
|
57
|
+
# @param letter [String]
|
|
58
|
+
# @return [Class<Identifiers::Base>]
|
|
59
|
+
def self.identifier_klass_for_type_letter(letter)
|
|
60
|
+
@by_letter ||= identifier_types.to_h { |klass| [klass.type[:short], klass] }
|
|
61
|
+
@by_letter.fetch(letter.to_s.upcase)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Pubid::Registry.register(:iala, Pubid::Iala)
|
data/lib/pubid/identifier.rb
CHANGED
|
@@ -120,11 +120,96 @@ module Pubid
|
|
|
120
120
|
nil
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
# The underlying standard, with amendment / corrigendum / Expert-commentary
|
|
124
|
+
# / Flex-version wrappers peeled recursively. Wrapper subclasses override
|
|
125
|
+
# this; a plain identifier is its own base document.
|
|
126
|
+
def base_document
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
123
130
|
# @return [String, nil] publication year from the date component
|
|
124
131
|
def year
|
|
125
132
|
date&.year&.to_s
|
|
126
133
|
end
|
|
127
134
|
|
|
135
|
+
# Canonicalize the serialized hash so it never carries a defaulted attribute
|
|
136
|
+
# still at its default (or empty) value. This makes to_hash a pure function
|
|
137
|
+
# of the identifier's values, independent of how the object was built.
|
|
138
|
+
#
|
|
139
|
+
# Motivation: lutaml materializes each attribute's default as an explicit
|
|
140
|
+
# assignment during deserialization (flipping using_default? to false), so a
|
|
141
|
+
# naive from_hash(x).to_hash re-emits defaults that parse(x).to_hash omits —
|
|
142
|
+
# breaking the exact-equality round-trip relaton-index relies on
|
|
143
|
+
# (from_hash(raw).to_hash == raw). pubid never consults lutaml's unset
|
|
144
|
+
# tracking and defines no render_default: true, so a defaulted attribute at
|
|
145
|
+
# its default/empty value carries no meaning and does not belong in the
|
|
146
|
+
# canonical hash. Dropping it here (rather than repairing from_hash) fixes
|
|
147
|
+
# the round-trip through every construction path — parse, from_hash, manual.
|
|
148
|
+
def to_hash(*args)
|
|
149
|
+
hash = super
|
|
150
|
+
canonicalize_hash(self, hash) if hash.is_a?(::Hash)
|
|
151
|
+
hash
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Recursively drop attributes holding only their default (or empty) value
|
|
155
|
+
# from +hash+, the serialization of +model+. Recurses into nested component
|
|
156
|
+
# / identifier values because lutaml serializes those via its own transform
|
|
157
|
+
# — bypassing their public to_hash — so a nested defaulted attribute (e.g.
|
|
158
|
+
# Components::Supplement#has_revision) would otherwise leak into the parent
|
|
159
|
+
# hash and break the idempotent round-trip.
|
|
160
|
+
def canonicalize_hash(model, hash)
|
|
161
|
+
register = model.lutaml_register
|
|
162
|
+
model.class.attributes(register).each do |name, attr|
|
|
163
|
+
canonicalize_attr(model, hash, name, attr) unless name == :_type
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Canonicalize a single attribute against its serialized value in +hash+:
|
|
168
|
+
# drop it when it holds only its default/empty value, else recurse into it.
|
|
169
|
+
def canonicalize_attr(model, hash, name, attr)
|
|
170
|
+
key = hash.key?(name.to_s) ? name.to_s : name
|
|
171
|
+
return unless hash.key?(key)
|
|
172
|
+
|
|
173
|
+
value = model.public_send(name)
|
|
174
|
+
if default_valued?(value, attr, model)
|
|
175
|
+
hash.delete(key)
|
|
176
|
+
else
|
|
177
|
+
canonicalize_nested(value, hash[key])
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# True when +value+ is +attr+'s default (or empty), so it can be dropped.
|
|
182
|
+
def default_valued?(value, attr, model)
|
|
183
|
+
register = model.lutaml_register
|
|
184
|
+
attr.default_set?(register, model) &&
|
|
185
|
+
(Lutaml::Model::Utils.empty?(value) ||
|
|
186
|
+
value == attr.default(register, model))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Recurse into a nested component / identifier (or a collection of them) so
|
|
190
|
+
# its own defaulted attributes are canonicalized against its sub-hash.
|
|
191
|
+
def canonicalize_nested(value, sub)
|
|
192
|
+
if value.is_a?(Lutaml::Model::Serialize)
|
|
193
|
+
canonicalize_hash(value, sub) if sub.is_a?(::Hash)
|
|
194
|
+
elsif value.is_a?(::Array)
|
|
195
|
+
canonicalize_collection(value, sub)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Canonicalize each element of a collection against its serialized sub-hash.
|
|
200
|
+
# Only zip when object and serialized sizes match, so a (never-observed)
|
|
201
|
+
# length mismatch can't pair an element with the wrong sub-hash — worst case
|
|
202
|
+
# it leaves that collection untouched.
|
|
203
|
+
def canonicalize_collection(models, subs)
|
|
204
|
+
return unless subs.is_a?(::Array) && models.size == subs.size
|
|
205
|
+
|
|
206
|
+
models.each_with_index do |model, i|
|
|
207
|
+
canonicalize_nested(model, subs[i])
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
private :canonicalize_hash, :canonicalize_attr, :default_valued?,
|
|
211
|
+
:canonicalize_nested, :canonicalize_collection
|
|
212
|
+
|
|
128
213
|
def initialize(attrs = {}, options = {})
|
|
129
214
|
attrs = attrs.dup
|
|
130
215
|
attrs[:_type] ||= self.class.polymorphic_name
|
|
@@ -233,17 +318,48 @@ module Pubid
|
|
|
233
318
|
Pubid::UrnGenerator::Base
|
|
234
319
|
end
|
|
235
320
|
|
|
321
|
+
# Excluded attributes are nilled; every other value is passed through
|
|
322
|
+
# #exclude_from_nested so the exclusion also propagates into nested
|
|
323
|
+
# identifiers — wrapper types (adopted standards, consolidated amendments,
|
|
324
|
+
# expert-commentary wrappers) delegate their date to an inner identifier
|
|
325
|
+
# rather than storing it in their own attribute.
|
|
236
326
|
def exclude(*args)
|
|
327
|
+
# :amendment / :supplement are structural, not attributes — they reduce
|
|
328
|
+
# a supplemented identifier to the standard it wraps (#drop_supplements).
|
|
329
|
+
supplement_keys = args & %i[amendment supplement]
|
|
330
|
+
unless supplement_keys.empty?
|
|
331
|
+
return drop_supplements.exclude(*(args - supplement_keys))
|
|
332
|
+
end
|
|
333
|
+
|
|
237
334
|
excluded_args = args.dup
|
|
238
335
|
# Map :year to :date since identifiers store years inside date
|
|
239
336
|
excluded_args << :date if excluded_args.delete(:year)
|
|
240
337
|
|
|
241
338
|
attrs = self.class.attributes.each_with_object({}) do |(name, _), h|
|
|
242
|
-
|
|
339
|
+
value = excluded_args.include?(name) ? nil : public_send(name)
|
|
340
|
+
h[name] = exclude_from_nested(value, args)
|
|
243
341
|
end
|
|
244
342
|
self.class.new(attrs)
|
|
245
343
|
end
|
|
246
344
|
|
|
345
|
+
# The standard this identifier supplements, dropping its own supplement
|
|
346
|
+
# layer (one level). Overridden by ConsolidatedIdentifier / Amendment /
|
|
347
|
+
# Corrigendum; a non-supplement identifier supplements nothing, so it is
|
|
348
|
+
# returned unchanged.
|
|
349
|
+
def drop_supplements
|
|
350
|
+
self
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Fuzz-level equality: two identifiers match when they are equal after
|
|
354
|
+
# excluding the given aspects. `ignore` accepts the same symbols as #exclude
|
|
355
|
+
# (e.g. :date, :edition, :amendment). This is the primitive relaton uses to
|
|
356
|
+
# match a reference against catalogue hits at varying strictness.
|
|
357
|
+
def matches?(other, ignore: [])
|
|
358
|
+
return false unless other.is_a?(::Pubid::Identifier)
|
|
359
|
+
|
|
360
|
+
exclude(*ignore) == other.exclude(*ignore)
|
|
361
|
+
end
|
|
362
|
+
|
|
247
363
|
def new_edition_of?(other)
|
|
248
364
|
unless publisher == other.publisher
|
|
249
365
|
raise ArgumentError,
|
|
@@ -284,6 +400,21 @@ module Pubid
|
|
|
284
400
|
|
|
285
401
|
private
|
|
286
402
|
|
|
403
|
+
# Propagate an #exclude into a nested attribute value: recurse when it is
|
|
404
|
+
# (or contains) another identifier, otherwise return it unchanged.
|
|
405
|
+
# Components and scalars are copied as-is. Passes the original args so
|
|
406
|
+
# nested identifiers re-apply the same :year->:date mapping themselves.
|
|
407
|
+
def exclude_from_nested(value, args)
|
|
408
|
+
case value
|
|
409
|
+
when ::Pubid::Identifier
|
|
410
|
+
value.exclude(*args)
|
|
411
|
+
when Array
|
|
412
|
+
value.map { |item| exclude_from_nested(item, args) }
|
|
413
|
+
else
|
|
414
|
+
value
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
287
418
|
def build_rendering_context(_renderer, format:, with_edition: false,
|
|
288
419
|
lang: :en, lang_single: false,
|
|
289
420
|
stage_format_long: nil, with_date: nil,
|
data/lib/pubid/oiml/builder.rb
CHANGED
|
@@ -78,13 +78,14 @@ module Pubid
|
|
|
78
78
|
|
|
79
79
|
def build_supplement(parsed_hash)
|
|
80
80
|
marker = parsed_hash[:trailing_marker].to_s if parsed_hash[:trailing_marker]
|
|
81
|
+
plus_marker = parsed_hash[:plus_marker].to_s if parsed_hash[:plus_marker]
|
|
81
82
|
|
|
82
83
|
# Determine supplement type. The trailing word ("Amendment"/"Errata")
|
|
83
84
|
# selects the class; the concrete class then carries the word via
|
|
84
85
|
# #supplement_type, so only the `trailing` flag needs storing.
|
|
85
86
|
supplement_class = if parsed_hash[:annex_letter] || parsed_hash[:annex_marker]
|
|
86
87
|
Identifiers::Annex
|
|
87
|
-
elsif marker == "Errata"
|
|
88
|
+
elsif marker == "Errata" || plus_marker == "Errata"
|
|
88
89
|
Identifiers::Errata
|
|
89
90
|
else
|
|
90
91
|
Identifiers::Amendment
|
|
@@ -92,6 +93,7 @@ module Pubid
|
|
|
92
93
|
|
|
93
94
|
supplement = supplement_class.new
|
|
94
95
|
supplement.trailing = true if marker
|
|
96
|
+
supplement.joined = true if plus_marker
|
|
95
97
|
|
|
96
98
|
# Recursively parse base identifier
|
|
97
99
|
if parsed_hash[:base_identifier]
|
data/lib/pubid/oiml/parser.rb
CHANGED
|
@@ -19,7 +19,8 @@ module Pubid
|
|
|
19
19
|
# Main identifier pattern - check supplements first
|
|
20
20
|
rule(:identifier) do
|
|
21
21
|
amendment_identifier | amendment_short | annex_letter_identifier |
|
|
22
|
-
annex_identifier |
|
|
22
|
+
annex_identifier | plus_supplement_identifier |
|
|
23
|
+
trailing_supplement_identifier | base_identifier
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Publisher - always "OIML"
|
|
@@ -172,6 +173,18 @@ module Pubid
|
|
|
172
173
|
language_portion.maybe.as(:language)
|
|
173
174
|
end
|
|
174
175
|
|
|
176
|
+
# Plus-joined supplement - "BASE:YEAR+Supplement:YEAR" form where both
|
|
177
|
+
# the base and the supplement carry their own year. Used for amendments
|
|
178
|
+
# and errata to dated bases (e.g. "OIML B 10:2011+Amendment:2012").
|
|
179
|
+
# Annexes already encode the year-on-base intent via their own model.
|
|
180
|
+
rule(:plus_supplement_identifier) do
|
|
181
|
+
base_without_language.as(:base_identifier) >>
|
|
182
|
+
str("+") >>
|
|
183
|
+
(str("Amendment") | str("Errata")).as(:plus_marker) >>
|
|
184
|
+
(colon >> year_digits.as(:year)).maybe >>
|
|
185
|
+
language_portion.maybe.as(:language)
|
|
186
|
+
end
|
|
187
|
+
|
|
175
188
|
# Annex identifier - "BASE Annexes Edition YYYY" / "BASE Annexes:YYYY" /
|
|
176
189
|
# "BASE:YYYY Annexes" (year on the base, no annex year).
|
|
177
190
|
rule(:annex_identifier) do
|
data/lib/pubid/oiml/renderer.rb
CHANGED
|
@@ -84,6 +84,16 @@ module Pubid
|
|
|
84
84
|
def render_supplement(id)
|
|
85
85
|
format = effective_format(id)
|
|
86
86
|
|
|
87
|
+
# Plus-joined: "BASE+Amendment:YEAR" / "BASE+Errata:YEAR" with both
|
|
88
|
+
# the base and the supplement carrying their own year.
|
|
89
|
+
if id.joined
|
|
90
|
+
base_str = strip_language(id.base_identifier.to_s)
|
|
91
|
+
result = "#{base_str}+#{id.supplement_type}"
|
|
92
|
+
result += ":#{id.year}" if id.year
|
|
93
|
+
result += " (#{id.language})" if id.language
|
|
94
|
+
return result
|
|
95
|
+
end
|
|
96
|
+
|
|
87
97
|
# Trailing-word shorthand: "BASE Amendment" / "BASE Errata" with the
|
|
88
98
|
# publication year kept on the base identifier. The word comes from the
|
|
89
99
|
# concrete supplement class.
|
|
@@ -13,6 +13,9 @@ module Pubid
|
|
|
13
13
|
# "Amendment (YYYY) to BASE" prose form. The word itself comes from the
|
|
14
14
|
# concrete class (#supplement_type), so only this flag is stored.
|
|
15
15
|
attribute :trailing, :boolean, default: false
|
|
16
|
+
# True for the plus-joined form ("OIML B 10:2011+Amendment:2012") where
|
|
17
|
+
# both the base and the supplement carry their own year, joined by "+".
|
|
18
|
+
attribute :joined, :boolean, default: false
|
|
16
19
|
attribute :parsed_format, :string, default: -> {
|
|
17
20
|
"short"
|
|
18
21
|
} # Track supplement's parsed format
|
|
@@ -25,6 +28,7 @@ module Pubid
|
|
|
25
28
|
with: { to: :base_identifier_to_kv, from: :base_identifier_from_kv }
|
|
26
29
|
map "year", to: :year
|
|
27
30
|
map "trailing", to: :trailing
|
|
31
|
+
map "joined", to: :joined
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def base_identifier_to_kv(model, doc)
|
data/lib/pubid/version.rb
CHANGED
data/lib/pubid.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pubid
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.0.pre.alpha.
|
|
4
|
+
version: 2.0.0.pre.alpha.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: lutaml-model
|
|
@@ -335,6 +335,22 @@ files:
|
|
|
335
335
|
- lib/pubid/export/result.rb
|
|
336
336
|
- lib/pubid/format_detector.rb
|
|
337
337
|
- lib/pubid/format_registry.rb
|
|
338
|
+
- lib/pubid/iala.rb
|
|
339
|
+
- lib/pubid/iala/builder.rb
|
|
340
|
+
- lib/pubid/iala/identifier.rb
|
|
341
|
+
- lib/pubid/iala/identifiers.rb
|
|
342
|
+
- lib/pubid/iala/identifiers/base.rb
|
|
343
|
+
- lib/pubid/iala/identifiers/guideline.rb
|
|
344
|
+
- lib/pubid/iala/identifiers/manual.rb
|
|
345
|
+
- lib/pubid/iala/identifiers/model_course.rb
|
|
346
|
+
- lib/pubid/iala/identifiers/recommendation.rb
|
|
347
|
+
- lib/pubid/iala/identifiers/report.rb
|
|
348
|
+
- lib/pubid/iala/identifiers/resolution.rb
|
|
349
|
+
- lib/pubid/iala/identifiers/standard.rb
|
|
350
|
+
- lib/pubid/iala/parser.rb
|
|
351
|
+
- lib/pubid/iala/renderer.rb
|
|
352
|
+
- lib/pubid/iala/urn_generator.rb
|
|
353
|
+
- lib/pubid/iala/urn_parser.rb
|
|
338
354
|
- lib/pubid/identifier.rb
|
|
339
355
|
- lib/pubid/identifier_metadata.rb
|
|
340
356
|
- lib/pubid/idf.rb
|