pubid 2.0.5 → 2.0.7
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/README.adoc +16 -0
- data/data/parg/bipm.json +1 -1
- data/data/parg/bipm.parg +290 -0
- data/lib/pubid/ansi/pubid_urn_schema.rb +44 -0
- data/lib/pubid/ansi.rb +1 -0
- data/lib/pubid/astm/CLAUDE.md +8 -0
- data/lib/pubid/astm/pubid_urn_schema.rb +47 -0
- data/lib/pubid/astm.rb +1 -0
- data/lib/pubid/bipm/CLAUDE.md +2 -0
- data/lib/pubid/bipm/builder.rb +26 -1
- data/lib/pubid/bipm/identifiers/committee_document.rb +13 -1
- data/lib/pubid/bipm/renderer.rb +18 -3
- data/lib/pubid/bipm/urn_generator.rb +14 -1
- data/lib/pubid/bipm/urn_parser.rb +14 -1
- data/lib/pubid/identifier.rb +15 -0
- data/lib/pubid/iec/pubid_urn_schema.rb +32 -0
- data/lib/pubid/iec.rb +1 -0
- data/lib/pubid/ieee/CLAUDE.md +2 -0
- data/lib/pubid/ieee/pubid_urn_schema.rb +150 -0
- data/lib/pubid/ieee.rb +1 -0
- data/lib/pubid/iso/pubid_urn_schema.rb +61 -0
- data/lib/pubid/iso.rb +1 -0
- data/lib/pubid/itu/CLAUDE.md +15 -0
- data/lib/pubid/itu/pubid_urn_schema.rb +82 -0
- data/lib/pubid/itu.rb +1 -0
- data/lib/pubid/nist/CLAUDE.md +13 -0
- data/lib/pubid/nist/pubid_urn_schema.rb +108 -0
- data/lib/pubid/nist.rb +1 -0
- data/lib/pubid/pubid_urn/decoder.rb +156 -0
- data/lib/pubid/pubid_urn/encoder.rb +175 -0
- data/lib/pubid/pubid_urn/escape.rb +60 -0
- data/lib/pubid/pubid_urn/paths.rb +87 -0
- data/lib/pubid/pubid_urn/schema.rb +184 -0
- data/lib/pubid/pubid_urn.rb +226 -0
- data/lib/pubid/type_resolver.rb +12 -0
- data/lib/pubid/version.rb +1 -1
- data/lib/pubid.rb +18 -0
- metadata +16 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Itu
|
|
5
|
+
# The pubid-scheme URN of ITU (issue #656; docs/URN-SCHEMES.adoc).
|
|
6
|
+
#
|
|
7
|
+
# ITU-T G.729 Annex B (1996) Cor. 3 (03/2001)
|
|
8
|
+
# urn:pubid:itu:rec:729:sector-T:series-G:anx:B:year-1996:
|
|
9
|
+
# cor:3:year-2001:month-03
|
|
10
|
+
#
|
|
11
|
+
# The nodes go root first along +base+, so every supplement, annex and
|
|
12
|
+
# appendix of a Recommendation shares its slug prefix. The official
|
|
13
|
+
# urn:itu: form (IANA-registered) stays on #to_urn.
|
|
14
|
+
#
|
|
15
|
+
# Facets are the flags the ITU classes call rendering-only: the same
|
|
16
|
+
# document printed two ways ("Suppl.1" and "Suppl. 1", "V.25 ter" and
|
|
17
|
+
# "V.25ter", "D.502R" and "D.502 R", "Amd. 1/Cor. 1" and "Amd. 1 Cor. 1").
|
|
18
|
+
# Every other flag is an atom, because it names a different document
|
|
19
|
+
# ("Technical Cor.", "attachment", the word "series").
|
|
20
|
+
#
|
|
21
|
+
# Not placed, so #to_pubid_urn raises rather than drop it: the +parts+ of
|
|
22
|
+
# a combined designation ("G.780-1/Y.1351-2"), a list inside a list. No
|
|
23
|
+
# pass fixture has one.
|
|
24
|
+
PubidUrnSchema = Pubid::PubidUrn.define("itu") do
|
|
25
|
+
atom "sector", "sector"
|
|
26
|
+
atom "series", "series"
|
|
27
|
+
atom "series_word", "seriesword", kind: :bool
|
|
28
|
+
atom "series_dash", "seriesdash", kind: :bool
|
|
29
|
+
atom "imp_marker", "imp"
|
|
30
|
+
atom "subseries", "subseries"
|
|
31
|
+
atom "parts", "part", kind: :list
|
|
32
|
+
atom "series_suffix", "suffix"
|
|
33
|
+
atom "qualifier", "qualifier"
|
|
34
|
+
atom "range_end", "rangeend"
|
|
35
|
+
atom "attachment", "attachment", kind: :bool
|
|
36
|
+
atom "combined.*.series", "combseries"
|
|
37
|
+
atom "combined.*.number", "combnumber"
|
|
38
|
+
atom "combined.*.subseries", "combsubseries"
|
|
39
|
+
atom "combined.*.series_suffix", "combsuffix"
|
|
40
|
+
atom "combined.*.qualifier", "combqualifier"
|
|
41
|
+
atom "study_group", "sg"
|
|
42
|
+
atom "has_bl", "bl", kind: :bool
|
|
43
|
+
atom "bracketed", "bracketed", kind: :bool
|
|
44
|
+
atom "has_colon", "colon", kind: :bool
|
|
45
|
+
atom "technical", "technical", kind: :bool
|
|
46
|
+
atom "material", "material"
|
|
47
|
+
atom "version", "v"
|
|
48
|
+
atom "year", "year"
|
|
49
|
+
atom "month", "month"
|
|
50
|
+
atom "day", "day"
|
|
51
|
+
atom "language", "lang"
|
|
52
|
+
atom "common_text_twin", "twin", kind: :id
|
|
53
|
+
|
|
54
|
+
facet "series_suffix_spaced", "suffixspaced", kind: :bool
|
|
55
|
+
facet "qualifier_glued", "qualifierglued", kind: :bool
|
|
56
|
+
facet "combined.*.series_suffix_spaced", "combsuffixspaced",
|
|
57
|
+
kind: :bools
|
|
58
|
+
facet "combined.*.qualifier_glued", "combqualifierglued", kind: :bools
|
|
59
|
+
facet "number_glued", "numberglued", kind: :bool
|
|
60
|
+
facet "slash_joined", "slashjoined", kind: :bool
|
|
61
|
+
|
|
62
|
+
type "pubid:itu:recommendation", "rec"
|
|
63
|
+
type "pubid:itu:report", "rpt"
|
|
64
|
+
type "pubid:itu:combined-identifier", "comb"
|
|
65
|
+
type "pubid:itu:question", "q"
|
|
66
|
+
type "pubid:itu:handbook", "hb"
|
|
67
|
+
type "pubid:itu:radio-regulations", "rr"
|
|
68
|
+
type "pubid:itu:special-publication", "sp"
|
|
69
|
+
type "pubid:itu:contribution", "c"
|
|
70
|
+
type "pubid:itu:identifier", "id"
|
|
71
|
+
|
|
72
|
+
type "pubid:itu:annex", "annex"
|
|
73
|
+
type "pubid:itu:annex-of-recommendation", "anx"
|
|
74
|
+
type "pubid:itu:appendix-of-recommendation", "app"
|
|
75
|
+
type "pubid:itu:supplement", "sup"
|
|
76
|
+
type "pubid:itu:amendment", "amd"
|
|
77
|
+
type "pubid:itu:corrigendum", "cor"
|
|
78
|
+
type "pubid:itu:addendum", "add"
|
|
79
|
+
type "pubid:itu:errata", "err"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/pubid/itu.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Pubid
|
|
|
23
23
|
autoload :Identifier, "#{__dir__}/itu/identifiers/base"
|
|
24
24
|
autoload :Identifiers, "#{__dir__}/itu/identifiers"
|
|
25
25
|
autoload :Parser, "#{__dir__}/itu/parser"
|
|
26
|
+
autoload :PubidUrnSchema, "#{__dir__}/itu/pubid_urn_schema"
|
|
26
27
|
autoload :UrnGenerator, "#{__dir__}/itu/urn_generator"
|
|
27
28
|
autoload :UrnParser, "#{__dir__}/itu/urn_parser"
|
|
28
29
|
autoload :Model, "#{__dir__}/itu/model"
|
data/lib/pubid/nist/CLAUDE.md
CHANGED
|
@@ -80,3 +80,16 @@ Read this before you change `lib/pubid/nist/` or `spec/pubid/nist/`. The root
|
|
|
80
80
|
`to_short_style`: the publisher prefixes only when the input carried one
|
|
81
81
|
(`NIST.`/`NBS.`), and a bare `FIPS 140-3` still renders `FIPS.140-3`.
|
|
82
82
|
Locked by `spec/pubid/nist/identifiers/fips_spec.rb`.
|
|
83
|
+
|
|
84
|
+
- **pubid-scheme URN (issue #656).** `lib/pubid/nist/pubid_urn_schema.rb`
|
|
85
|
+
covers all 22 types; rulings in `docs/URN-SCHEMES.adoc` (`[[urn-nist]]`).
|
|
86
|
+
`update` and `update_component` serialize every field, so each field is
|
|
87
|
+
its own atom (`upd-`, `updyear-`, `uc-` …); reducing them changes
|
|
88
|
+
`to_hash` and needs a `relaton-data-nist` re-crawl, so it was not done.
|
|
89
|
+
`publisher_was_parsed` (`false` for a bare `FIPS 140-3`),
|
|
90
|
+
`parsed_format`, `edition.original_prefix` and the bracketed
|
|
91
|
+
`translation` are facets. No pass fixture sets `publisher_was_parsed`,
|
|
92
|
+
`supplement.year_end` or the Integer `year`; the review found them by a
|
|
93
|
+
sweep of `full/` and the spec literals, so sweep those too when a schema
|
|
94
|
+
changes. Found in passing, not fixed:
|
|
95
|
+
`NIST.IR.8259Apt` parses as `NIST IR 8259(por)` and loses the `A`.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module Nist
|
|
5
|
+
# The pubid-scheme URN of NIST (issue #656; docs/URN-SCHEMES.adoc).
|
|
6
|
+
#
|
|
7
|
+
# NIST SP 800-53r5
|
|
8
|
+
# urn:pubid:nist:sp:800-53:pub-NIST:series-SP:subseries-800:edtype-r:ed-5
|
|
9
|
+
#
|
|
10
|
+
# The core is the document number. +publisher+ and +series+ are atoms,
|
|
11
|
+
# because a few series print no number ("NIST OWMWP 06-13-2018" keeps
|
|
12
|
+
# its date there) and the series spelling varies inside one type
|
|
13
|
+
# ("CIRC", "NBS.CIRC", "LCIRC").
|
|
14
|
+
#
|
|
15
|
+
# +update+ and +update_component+ serialize every field of their
|
|
16
|
+
# component, so each field is its own atom. Reducing them to one
|
|
17
|
+
# identifying value changes #to_hash and needs a relaton-data-nist
|
|
18
|
+
# re-crawl; that is a separate decision (docs/URN-SCHEMES.adoc).
|
|
19
|
+
#
|
|
20
|
+
# Facets: +parsed_format+ (the input was an MR string), the printed
|
|
21
|
+
# prefix of a revision (" Rev. " against "r") and the bracketed
|
|
22
|
+
# +translation+ ("(spa)"), whose code is the +lang+ atom.
|
|
23
|
+
PubidUrnSchema = Pubid::PubidUrn.define("nist") do
|
|
24
|
+
atom "publisher", "pub"
|
|
25
|
+
atom "series", "series"
|
|
26
|
+
atom "subseries", "subseries"
|
|
27
|
+
atom "part.type", "parttype"
|
|
28
|
+
atom "part.value", "part"
|
|
29
|
+
atom "section", "section"
|
|
30
|
+
atom "volume", "vol"
|
|
31
|
+
atom "edition.type", "edtype"
|
|
32
|
+
atom "edition.id", "ed"
|
|
33
|
+
atom "edition.additional_text", "edtext"
|
|
34
|
+
atom "edition_year", "edyear"
|
|
35
|
+
atom "supplement.number", "sup"
|
|
36
|
+
atom "supplement.suffix", "supsuffix"
|
|
37
|
+
atom "supplement.year", "supyear"
|
|
38
|
+
atom "supplement.month", "supmonth"
|
|
39
|
+
atom "supplement.year_end", "supyearend"
|
|
40
|
+
atom "supplement_date_range_start", "suprangestart"
|
|
41
|
+
atom "supplement_date_range_end", "suprangeend"
|
|
42
|
+
atom "supplement.has_revision", "suprev", kind: :bool
|
|
43
|
+
atom "implicit_supplement", "implicitsup", kind: :bool
|
|
44
|
+
atom "update.prefix", "updprefix"
|
|
45
|
+
atom "update.number", "upd"
|
|
46
|
+
atom "update.year", "updyear"
|
|
47
|
+
atom "update.month", "updmonth"
|
|
48
|
+
atom "update_component.prefix", "ucprefix"
|
|
49
|
+
atom "update_component.number", "uc"
|
|
50
|
+
atom "update_component.year", "ucyear"
|
|
51
|
+
atom "update_component.month", "ucmonth"
|
|
52
|
+
atom "revision_year", "revyear"
|
|
53
|
+
atom "revision_month", "revmonth"
|
|
54
|
+
atom "revised_date", "reviseddate"
|
|
55
|
+
atom "update_number", "updnumber"
|
|
56
|
+
atom "update_year", "updnumberyear"
|
|
57
|
+
atom "stage.type", "stagetype"
|
|
58
|
+
atom "stage.id", "stage"
|
|
59
|
+
atom "draft_number", "draftnumber"
|
|
60
|
+
atom "version_component.value", "v"
|
|
61
|
+
atom "version", "version"
|
|
62
|
+
atom "year", "year", kind: :int
|
|
63
|
+
atom "month", "month", kind: :int
|
|
64
|
+
atom "date_year", "dateyear"
|
|
65
|
+
atom "date_month", "datemonth"
|
|
66
|
+
atom "date_day", "dateday"
|
|
67
|
+
atom "dated_seq", "datedseq"
|
|
68
|
+
atom "prefix", "prefix"
|
|
69
|
+
atom "range_notation", "range"
|
|
70
|
+
atom "translation_component.code", "lang"
|
|
71
|
+
atom "draft", "draft"
|
|
72
|
+
atom "index", "index"
|
|
73
|
+
atom "insert", "insert"
|
|
74
|
+
atom "errata", "errata"
|
|
75
|
+
atom "appendix", "appendix"
|
|
76
|
+
atom "addendum", "addendum"
|
|
77
|
+
atom "addendum_number", "addendumnumber"
|
|
78
|
+
|
|
79
|
+
facet "publisher_was_parsed", "pubparsed", kind: :bool
|
|
80
|
+
facet "parsed_format", "format"
|
|
81
|
+
facet "edition.original_prefix", "edprefix"
|
|
82
|
+
facet "translation", "translationsrc"
|
|
83
|
+
|
|
84
|
+
type "pubid:nist:identifier", "id"
|
|
85
|
+
type "pubid:nist:circular", "circ"
|
|
86
|
+
type "pubid:nist:circular-supplement", "circsup"
|
|
87
|
+
type "pubid:nist:commercial-standard", "cs"
|
|
88
|
+
type "pubid:nist:commercial-standard-emergency", "cse"
|
|
89
|
+
type "pubid:nist:commercial-standards-monthly", "csm"
|
|
90
|
+
type "pubid:nist:crpl-report", "crpl"
|
|
91
|
+
type "pubid:nist:dated-document", "dd"
|
|
92
|
+
type "pubid:nist:federal-information-processing-standards", "fips"
|
|
93
|
+
type "pubid:nist:grant-contractor-report", "gcr"
|
|
94
|
+
type "pubid:nist:handbook", "hb"
|
|
95
|
+
type "pubid:nist:interagency-report", "ir"
|
|
96
|
+
type "pubid:nist:letter-circular", "lc"
|
|
97
|
+
type "pubid:nist:miscellaneous-publication", "mp"
|
|
98
|
+
type "pubid:nist:monograph", "mono"
|
|
99
|
+
type "pubid:nist:ncstar", "ncstar"
|
|
100
|
+
type "pubid:nist:nsrds", "nsrds"
|
|
101
|
+
type "pubid:nist:owmwp", "owmwp"
|
|
102
|
+
type "pubid:nist:report", "rpt"
|
|
103
|
+
type "pubid:nist:special-publication", "sp"
|
|
104
|
+
type "pubid:nist:technical-note", "tn"
|
|
105
|
+
type "pubid:nist:supplement-identifier", "sup"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/pubid/nist.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Pubid
|
|
|
21
21
|
autoload :Caster, "#{__dir__}/nist/caster"
|
|
22
22
|
autoload :CircularSupplementBuilder, "#{__dir__}/nist/circular_supplement_builder"
|
|
23
23
|
autoload :Components, "#{__dir__}/nist/components"
|
|
24
|
+
autoload :PubidUrnSchema, "#{__dir__}/nist/pubid_urn_schema"
|
|
24
25
|
autoload :Configuration, "#{__dir__}/nist/configuration"
|
|
25
26
|
autoload :Identifier, "#{__dir__}/nist/identifiers/base"
|
|
26
27
|
autoload :Identifiers, "#{__dir__}/nist/identifiers"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module PubidUrn
|
|
5
|
+
# Reads the body of a pubid-scheme URN back into an identifier hash.
|
|
6
|
+
# The inverse of Encoder; strict about structure. Canonical form (atom
|
|
7
|
+
# order, escapes, trailing "~") is checked by PubidUrn.parse, which
|
|
8
|
+
# re-encodes the result and compares.
|
|
9
|
+
class Decoder
|
|
10
|
+
FACET = /\A(\d+)\.([a-z0-9]+)-(.*)\z/
|
|
11
|
+
|
|
12
|
+
def initialize(body)
|
|
13
|
+
@body = body
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Hash] the identifier hash (+_type+ and +base+ included)
|
|
17
|
+
def to_identifier_hash
|
|
18
|
+
flavor, rest = @body.split(":", 2)
|
|
19
|
+
fail!("no type segment") if rest.nil? || rest.empty?
|
|
20
|
+
decode_identifier(rest, flavor)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def decode_identifier(body, flavor)
|
|
26
|
+
tokens = PubidUrn.split_top(body, ":")
|
|
27
|
+
nodes = []
|
|
28
|
+
schemas = []
|
|
29
|
+
i = 0
|
|
30
|
+
while i < tokens.size
|
|
31
|
+
token = tokens[i]
|
|
32
|
+
if token == "f"
|
|
33
|
+
decode_facets(tokens.drop(i + 1), nodes, schemas)
|
|
34
|
+
break
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
schema = node_schema(token, flavor)
|
|
38
|
+
node = { "_type" => schema.type }
|
|
39
|
+
i = decode_core(tokens, i + 1, node, schema)
|
|
40
|
+
i = decode_atoms(tokens, i, node, schema)
|
|
41
|
+
nodes << node
|
|
42
|
+
schemas << schema
|
|
43
|
+
end
|
|
44
|
+
fail!("no identifier") if nodes.empty?
|
|
45
|
+
|
|
46
|
+
nodes.each_cons(2) { |base, node| node["base"] = base }
|
|
47
|
+
nodes.last
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def node_schema(token, flavor)
|
|
51
|
+
if token == "type"
|
|
52
|
+
fail!("type-metadata URN names a type, not a document")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
schema = if PubidUrn.structural?(token)
|
|
56
|
+
owner, name = PubidUrn.split_token(token, flavor)
|
|
57
|
+
PubidUrn.flavor_schema(owner)&.for_token(name)
|
|
58
|
+
end
|
|
59
|
+
fail!("unknown type or segment #{token.inspect}") unless schema
|
|
60
|
+
|
|
61
|
+
schema
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def decode_core(tokens, index, node, schema)
|
|
65
|
+
schema.core.each do |parts|
|
|
66
|
+
break if index >= tokens.size
|
|
67
|
+
|
|
68
|
+
token = tokens[index]
|
|
69
|
+
break if PubidUrn.structural?(token)
|
|
70
|
+
|
|
71
|
+
Paths.put(node, parts, Escape.decode(token)) unless token == NULL
|
|
72
|
+
index += 1
|
|
73
|
+
end
|
|
74
|
+
index
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def decode_atoms(tokens, index, node, schema)
|
|
78
|
+
last = -1
|
|
79
|
+
while index < tokens.size &&
|
|
80
|
+
(position = schema.atom_position(tokens[index]))
|
|
81
|
+
if position <= last
|
|
82
|
+
fail!("atom #{tokens[index].inspect} is out of order or repeated")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
last = position
|
|
86
|
+
atom = schema.atoms[position]
|
|
87
|
+
raw = tokens[index].split("-", 2).last
|
|
88
|
+
Paths.put(node, atom.parts, atom_value(raw, atom))
|
|
89
|
+
index += 1
|
|
90
|
+
end
|
|
91
|
+
index
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def atom_value(raw, atom)
|
|
95
|
+
case atom.kind
|
|
96
|
+
when :int then integer(raw)
|
|
97
|
+
when :id then nested(raw)
|
|
98
|
+
when :ids then PubidUrn.split_top(raw, ",").map { |item| nested(item) }
|
|
99
|
+
else slot_value(raw, atom)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The value of a :string, :bool or list atom, or of a facet.
|
|
104
|
+
def slot_value(raw, slot)
|
|
105
|
+
return scalar(raw, slot) unless slot.list?
|
|
106
|
+
|
|
107
|
+
PubidUrn.split_top(raw, ",", allow_empty: true).map do |item|
|
|
108
|
+
item == NULL ? nil : scalar(item, slot)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def scalar(raw, slot)
|
|
113
|
+
slot.element == :bool ? boolean(raw) : Escape.decode(raw)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def boolean(raw)
|
|
117
|
+
return true if raw == "true"
|
|
118
|
+
return false if raw == "false"
|
|
119
|
+
|
|
120
|
+
fail!("#{raw.inspect} is not a boolean")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def integer(raw)
|
|
124
|
+
unless /\A-?[0-9]+\z/.match?(raw)
|
|
125
|
+
fail!("#{raw.inspect} is not an integer")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Integer(raw, 10)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def nested(raw)
|
|
132
|
+
unless raw.start_with?("(") && raw.end_with?(")")
|
|
133
|
+
fail!("nested identifier #{raw.inspect} needs brackets")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
Decoder.new(raw[1..-2]).to_identifier_hash
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def decode_facets(tokens, nodes, schemas)
|
|
140
|
+
fail!("empty facet group") if tokens.empty?
|
|
141
|
+
|
|
142
|
+
tokens.each do |token|
|
|
143
|
+
match = FACET.match(token) || fail!("bad facet #{token.inspect}")
|
|
144
|
+
index = Integer(match[1], 10)
|
|
145
|
+
schema = schemas[index] || fail!("facet #{token.inspect} has no node")
|
|
146
|
+
facet = schema.facet(match[2]) || fail!("bad facet #{token.inspect}")
|
|
147
|
+
Paths.put(nodes[index], facet.parts, slot_value(match[3], facet))
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def fail!(message)
|
|
152
|
+
raise Errors::UrnParseError, "pubid URN #{@body.inspect}: #{message}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module PubidUrn
|
|
5
|
+
# Writes the pubid-scheme URN of an identifier hash.
|
|
6
|
+
#
|
|
7
|
+
# urn:pubid:<flavor>:<node>[:<node>…][:f:<facet>…]
|
|
8
|
+
# <node> = <type>[:<core>…][:<prefix>-<value>…]
|
|
9
|
+
#
|
|
10
|
+
# The nodes go root first, along +base+: "ISO 9001:2015/Amd 1:2016" is
|
|
11
|
+
# "is:9001:year-2015:amd:1:year-2016", so every supplement of a document
|
|
12
|
+
# shares its slug prefix. A core value is positional; an absent one in
|
|
13
|
+
# the middle is "~", and absent ones at the end are dropped. An optional
|
|
14
|
+
# value is a prefixed atom, so it leaves no segment when it is absent.
|
|
15
|
+
class Encoder
|
|
16
|
+
def initialize(hash)
|
|
17
|
+
@hash = hash
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [String] the body after "urn:pubid:"
|
|
21
|
+
def body
|
|
22
|
+
flavor = PubidUrn.flavor_segment(@hash["_type"])
|
|
23
|
+
"#{flavor}:#{encode_identifier(@hash, flavor)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def encode_identifier(hash, flavor)
|
|
29
|
+
segments = []
|
|
30
|
+
facets = []
|
|
31
|
+
chain(hash).each_with_index do |node, index|
|
|
32
|
+
schema = PubidUrn.type_schema!(node["_type"],
|
|
33
|
+
error: Errors::InvalidInputError)
|
|
34
|
+
node = deep_dup(node)
|
|
35
|
+
node.delete("_type")
|
|
36
|
+
segments << token(schema, flavor)
|
|
37
|
+
segments.concat(core_segments(node, schema))
|
|
38
|
+
segments.concat(atom_segments(node, schema))
|
|
39
|
+
facets.concat(facet_segments(node, schema, index))
|
|
40
|
+
check_placed!(node, schema)
|
|
41
|
+
end
|
|
42
|
+
segments.push("f", *facets) unless facets.empty?
|
|
43
|
+
segments.join(":")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The nodes from the root document to +hash+ itself.
|
|
47
|
+
def chain(hash)
|
|
48
|
+
nodes = []
|
|
49
|
+
node = hash
|
|
50
|
+
while node
|
|
51
|
+
unless node.is_a?(Hash) && node["_type"].is_a?(String)
|
|
52
|
+
raise Errors::InvalidInputError,
|
|
53
|
+
"a pubid URN base must be an identifier hash, " \
|
|
54
|
+
"got #{node.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
nodes.unshift(node.except("base"))
|
|
58
|
+
node = node["base"]
|
|
59
|
+
end
|
|
60
|
+
nodes
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def token(schema, flavor)
|
|
64
|
+
return schema.token if schema.flavor == flavor
|
|
65
|
+
|
|
66
|
+
"#{schema.flavor}.#{schema.token}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def core_segments(node, schema)
|
|
70
|
+
values = schema.core.map { |parts| Paths.take(node, parts) }
|
|
71
|
+
values.pop while !values.empty? && values.last.nil?
|
|
72
|
+
values.map do |value|
|
|
73
|
+
value.nil? ? NULL : core_value(value, schema)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A core value that has the shape of structure (PubidUrn::STRUCTURE)
|
|
78
|
+
# gets its first character escaped.
|
|
79
|
+
def core_value(value, schema)
|
|
80
|
+
unless value.is_a?(String) && !value.empty?
|
|
81
|
+
raise Errors::InvalidInputError,
|
|
82
|
+
"core value of #{schema.type} must be a non-empty String, " \
|
|
83
|
+
"got #{value.inspect}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
encoded = Escape.encode(value)
|
|
87
|
+
return encoded unless PubidUrn.structural?(encoded)
|
|
88
|
+
|
|
89
|
+
Escape.force(value[0]) + Escape.encode(value[1..])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def atom_segments(node, schema)
|
|
93
|
+
schema.atoms.filter_map do |atom|
|
|
94
|
+
value = Paths.take(node, atom.parts)
|
|
95
|
+
next if value.nil?
|
|
96
|
+
|
|
97
|
+
"#{atom.prefix}-#{atom_value(value, atom, schema)}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def atom_value(value, atom, schema)
|
|
102
|
+
case atom.kind
|
|
103
|
+
when :int then typed(value, Integer, atom, schema).to_s
|
|
104
|
+
when :id then nested(typed(value, Hash, atom, schema))
|
|
105
|
+
when :ids then nested_list(typed(value, Array, atom, schema))
|
|
106
|
+
else slot_value(value, atom, schema)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The value of a :string, :bool or list atom, or of a facet.
|
|
111
|
+
def slot_value(value, slot, schema)
|
|
112
|
+
return scalar(value, slot, schema) unless slot.list?
|
|
113
|
+
|
|
114
|
+
typed(value, Array, slot, schema).map do |item|
|
|
115
|
+
item.nil? ? NULL : scalar(item, slot, schema)
|
|
116
|
+
end.join(",")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def scalar(value, slot, schema)
|
|
120
|
+
if slot.element == :bool
|
|
121
|
+
typed(value, [true, false], slot, schema).to_s
|
|
122
|
+
else
|
|
123
|
+
Escape.encode(typed(value, String, slot, schema))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def nested(hash)
|
|
128
|
+
"(#{Encoder.new(hash).body})"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def nested_list(hashes)
|
|
132
|
+
hashes.map { |hash| nested(hash) }.join(",")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def facet_segments(node, schema, index)
|
|
136
|
+
schema.facets.filter_map do |facet|
|
|
137
|
+
value = Paths.take(node, facet.parts)
|
|
138
|
+
next if value.nil?
|
|
139
|
+
|
|
140
|
+
"#{index}.#{facet.name}-#{slot_value(value, facet, schema)}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def typed(value, expected, slot, schema)
|
|
145
|
+
ok = if expected.is_a?(Array)
|
|
146
|
+
expected.include?(value)
|
|
147
|
+
else
|
|
148
|
+
value.is_a?(expected)
|
|
149
|
+
end
|
|
150
|
+
return value if ok
|
|
151
|
+
|
|
152
|
+
raise Errors::InvalidInputError,
|
|
153
|
+
"#{schema.type} #{slot.path}: #{value.inspect} " \
|
|
154
|
+
"is not #{slot.kind}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def check_placed!(node, schema)
|
|
158
|
+
left = node.reject { |_, value| Paths.blank?(value) }
|
|
159
|
+
return if left.empty?
|
|
160
|
+
|
|
161
|
+
raise Errors::InvalidInputError,
|
|
162
|
+
"the pubid URN schema of #{schema.type} " \
|
|
163
|
+
"does not place #{left.inspect}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def deep_dup(value)
|
|
167
|
+
case value
|
|
168
|
+
when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
|
|
169
|
+
when Array then value.map { |v| deep_dup(v) }
|
|
170
|
+
else value
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pubid
|
|
4
|
+
module PubidUrn
|
|
5
|
+
# The value escape of the pubid-scheme URN.
|
|
6
|
+
#
|
|
7
|
+
# The URN is also an HTTP path slug (relaton/relaton#441), so it cannot
|
|
8
|
+
# use "%": servers and proxies decode "%2F" before they route, or they
|
|
9
|
+
# refuse it. "~XX" (uppercase hex of one UTF-8 byte) has no meaning in a
|
|
10
|
+
# path, and "~" is an RFC 3986 unreserved character. Every byte outside
|
|
11
|
+
# SAFE is escaped, so ":" "," "(" ")" "~" never occur raw in a value and
|
|
12
|
+
# stay free for the structure of the URN.
|
|
13
|
+
module Escape
|
|
14
|
+
SAFE = /[A-Za-z0-9._-]/
|
|
15
|
+
RAW_TOKEN = /\A(?:[A-Za-z0-9._-]|~[0-9A-F]{2})*\z/
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param value [String]
|
|
20
|
+
# @return [String]
|
|
21
|
+
def encode(value)
|
|
22
|
+
value.to_s.each_char.map { |char| encode_char(char) }.join
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Escape every character of +char+, safe or not. The codec uses it on
|
|
26
|
+
# the first character of a core value that would otherwise read as
|
|
27
|
+
# structure ("amd" -> "~61md").
|
|
28
|
+
#
|
|
29
|
+
# @param char [String] one character
|
|
30
|
+
# @return [String]
|
|
31
|
+
def force(char)
|
|
32
|
+
char.b.each_byte.map { |byte| format("~%02X", byte) }.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param token [String]
|
|
36
|
+
# @return [String]
|
|
37
|
+
# @raise [Pubid::Errors::UrnParseError] for a raw character outside the
|
|
38
|
+
# safe set, or a malformed or lower-case escape
|
|
39
|
+
def decode(token)
|
|
40
|
+
unless RAW_TOKEN.match?(token)
|
|
41
|
+
raise Pubid::Errors::UrnParseError,
|
|
42
|
+
"bad character or escape in pubid URN value #{token.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
bytes = token.b.gsub(/~([0-9A-F]{2})/n) { Regexp.last_match(1).hex.chr }
|
|
46
|
+
decoded = bytes.force_encoding(Encoding::UTF_8)
|
|
47
|
+
unless decoded.valid_encoding?
|
|
48
|
+
raise Pubid::Errors::UrnParseError,
|
|
49
|
+
"escape in pubid URN value #{token.inspect} is not UTF-8"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
decoded
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def encode_char(char)
|
|
56
|
+
SAFE.match?(char) ? char : force(char)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|