relaton 3.0.0.pre.alpha.17 → 3.0.0.pre.alpha.19

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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/data/equivalence/adoption-chains.yaml +59 -0
  3. data/lib/relaton/3gpp/CLAUDE.md +187 -0
  4. data/lib/relaton/3gpp/README.adoc +149 -0
  5. data/lib/relaton/adobe/CLAUDE.md +104 -0
  6. data/lib/relaton/bib/CLAUDE.md +130 -0
  7. data/lib/relaton/bib/README.adoc +330 -0
  8. data/lib/relaton/bib/model/item_shared.rb +5 -1
  9. data/lib/relaton/bib/model/series.rb +1 -1
  10. data/lib/relaton/bipm/CLAUDE.md +173 -0
  11. data/lib/relaton/bipm/README.adoc +352 -0
  12. data/lib/relaton/bipm/data_fetcher.rb +3 -2
  13. data/lib/relaton/bsi/CLAUDE.md +35 -0
  14. data/lib/relaton/bsi/README.adoc +112 -0
  15. data/lib/relaton/calconnect/CLAUDE.md +177 -0
  16. data/lib/relaton/calconnect/README.adoc +140 -0
  17. data/lib/relaton/ccsds/CLAUDE.md +74 -0
  18. data/lib/relaton/ccsds/README.adoc +154 -0
  19. data/lib/relaton/cen/CLAUDE.md +219 -0
  20. data/lib/relaton/cen/README.adoc +155 -0
  21. data/lib/relaton/cie/CLAUDE.md +90 -0
  22. data/lib/relaton/cie/README.adoc +121 -0
  23. data/lib/relaton/core/CLAUDE.md +140 -0
  24. data/lib/relaton/core/README.adoc +64 -0
  25. data/lib/relaton/core/data_fetcher.rb +142 -3
  26. data/lib/relaton/db/CLAUDE.md +373 -0
  27. data/lib/relaton/db.rb +21 -1
  28. data/lib/relaton/doi/CLAUDE.md +105 -0
  29. data/lib/relaton/doi/README.adoc +89 -0
  30. data/lib/relaton/easc/CLAUDE.md +85 -0
  31. data/lib/relaton/ecma/CLAUDE.md +217 -0
  32. data/lib/relaton/ecma/README.adoc +178 -0
  33. data/lib/relaton/equivalence.rb +117 -0
  34. data/lib/relaton/etsi/CLAUDE.md +219 -0
  35. data/lib/relaton/etsi/README.adoc +137 -0
  36. data/lib/relaton/evs/CLAUDE.md +120 -0
  37. data/lib/relaton/evs/README.adoc +27 -0
  38. data/lib/relaton/gb/CLAUDE.md +47 -0
  39. data/lib/relaton/gb/README.adoc +169 -0
  40. data/lib/relaton/gost/CLAUDE.md +100 -0
  41. data/lib/relaton/iala/CLAUDE.md +97 -0
  42. data/lib/relaton/iana/CLAUDE.md +105 -0
  43. data/lib/relaton/iana/README.adoc +107 -0
  44. data/lib/relaton/iec/CLAUDE.md +13 -0
  45. data/lib/relaton/iec/README.adoc +280 -0
  46. data/lib/relaton/iec/data_fetcher.rb +24 -8
  47. data/lib/relaton/iec/project_fetcher.rb +48 -0
  48. data/lib/relaton/iec/project_parser.rb +111 -0
  49. data/lib/relaton/iec.rb +5 -0
  50. data/lib/relaton/ieee/CLAUDE.md +176 -0
  51. data/lib/relaton/ieee/README.adoc +131 -0
  52. data/lib/relaton/ietf/CLAUDE.md +357 -0
  53. data/lib/relaton/ietf/README.adoc +167 -0
  54. data/lib/relaton/iho/CLAUDE.md +70 -0
  55. data/lib/relaton/iho/README.adoc +119 -0
  56. data/lib/relaton/index/CLAUDE.md +207 -0
  57. data/lib/relaton/index/README.adoc +221 -0
  58. data/lib/relaton/index/dataset_lock.rb +122 -0
  59. data/lib/relaton/index/family.rb +63 -0
  60. data/lib/relaton/index/type.rb +32 -0
  61. data/lib/relaton/index.rb +3 -0
  62. data/lib/relaton/isbn/CLAUDE.md +47 -0
  63. data/lib/relaton/isbn/README.adoc +91 -0
  64. data/lib/relaton/iso/CLAUDE.md +38 -0
  65. data/lib/relaton/iso/README.adoc +370 -0
  66. data/lib/relaton/iso/data_fetcher.rb +16 -1
  67. data/lib/relaton/itu/CLAUDE.md +685 -0
  68. data/lib/relaton/itu/README.adoc +188 -0
  69. data/lib/relaton/jcgm/CLAUDE.md +107 -0
  70. data/lib/relaton/jis/CLAUDE.md +64 -0
  71. data/lib/relaton/jis/README.adoc +167 -0
  72. data/lib/relaton/logger/CLAUDE.md +37 -0
  73. data/lib/relaton/logger/README.adoc +120 -0
  74. data/lib/relaton/nist/CLAUDE.md +180 -0
  75. data/lib/relaton/nist/README.adoc +389 -0
  76. data/lib/relaton/nist/bibliography.rb +17 -1
  77. data/lib/relaton/nist/data_fetcher.rb +60 -4
  78. data/lib/relaton/nist/hit_collection.rb +121 -59
  79. data/lib/relaton/nist/scraper.rb +21 -7
  80. data/lib/relaton/oasis/CLAUDE.md +251 -0
  81. data/lib/relaton/oasis/README.adoc +132 -0
  82. data/lib/relaton/ogc/CLAUDE.md +242 -0
  83. data/lib/relaton/ogc/README.adoc +134 -0
  84. data/lib/relaton/ogc/data_fetcher.rb +9 -3
  85. data/lib/relaton/oiml/CLAUDE.md +47 -0
  86. data/lib/relaton/oiml/README.adoc +70 -0
  87. data/lib/relaton/omg/CLAUDE.md +137 -0
  88. data/lib/relaton/omg/README.adoc +101 -0
  89. data/lib/relaton/plateau/CLAUDE.md +126 -0
  90. data/lib/relaton/plateau/README.adoc +139 -0
  91. data/lib/relaton/sdo/CLAUDE.md +81 -0
  92. data/lib/relaton/un/CLAUDE.md +91 -0
  93. data/lib/relaton/un/README.adoc +125 -0
  94. data/lib/relaton/version.rb +1 -1
  95. data/lib/relaton/w3c/CLAUDE.md +369 -0
  96. data/lib/relaton/w3c/README.adoc +140 -0
  97. data/lib/relaton/w3c/data_fetcher.rb +1 -1
  98. data/lib/relaton/xsf/CLAUDE.md +189 -0
  99. data/lib/relaton/xsf/README.adoc +127 -0
  100. data/lib/relaton.rb +1 -0
  101. metadata +85 -4
@@ -0,0 +1,221 @@
1
+ = Relaton::Index
2
+
3
+ This gem is a part of [Relaton](https://github.com/relaton) project. It provides a way to index Relaton documents' files and search them by references.
4
+
5
+ For the data-repository layout, the index file specification (schema, `:id` v1/v2 shapes), and the GitHub Pages publishing contract, see link:../../../docs/data-repository-format.adoc[docs/data-repository-format.adoc] at the repo root.
6
+
7
+ == Usage
8
+
9
+ === Creating an index object
10
+
11
+ The gem provides the `Relaton::Index.find_or_create {type}, url: {url}, file: {filename}, pubid_class: {class}` method to create an index object. The first argument is the type of dataset (ISO, IEC, IHO, etc.). `url` is the URL to the zipped remote index file, `filename` is the local index file, and `pubid_class` is a class that implements `Pubid::Core::Identifier` for deserializing ID hashes into structured identifier objects. All three keywords are optional.
12
+
13
+ If the URL is specified and the local file in a `/{home}/.relaton/{type}` dir doesn't exist or is outdated, the index file will be downloaded from the URL saved as a local file and an index object will be created from the file. If the file in the `/{home}/.relaton/{type}` exists and is actual, the index object will be created from the local file.
14
+
15
+ If the URL isn't specified, the index object will be created from the local file in the current dir `./{filename}`. If the file doesn't exist, the empty index object will be created.
16
+
17
+ If the filename isn't specified, a default `index,yaml` filename will be used.
18
+
19
+ If the keys are specified, the local index file will be checked for the presence of the keys. If the keys are not found, the index file will be downloaded from the URL or an empty index object will be created if the URL isn't specified.
20
+
21
+ It's possible to create many index objects for different types of datasets. The index objects are saved in the pool. The pool is a hash with the type of dataset as a key and the index object as a value. The pool is a class variable of the `Relaton::Index` class. The pool is used to find an existing index object by the type of dataset. The `Relaton::Index.find_or_create` method returns an existing index object if it was created before.
22
+
23
+ If the URL or filename is specified and has a new value, different from what was used to create the index object before, the index object will be recreated.
24
+
25
+ [source,ruby]
26
+ ----
27
+ require 'relaton/index'
28
+
29
+ # Create a new index object. The first argument is the type of dataset (ISO, IEC, IHO, etc.) URL and filename are optional.
30
+ Relaton::Index.find_or_create :IHO, url: "https://raw.githubusercontent.com/relaton/relaton-data-iho/master/index.zip", file: "index-iho.yaml"
31
+
32
+ # Find an existing index object (created before).
33
+ Relaton::Index.find_or_create :IHO
34
+
35
+ # If the URL or filename is specified and has a new value (different from what it was before), the index object will be recreated.
36
+ Relaton::Index.find_or_create :IHO, url: nil, file: "index.yaml"
37
+
38
+ # Remove the index from the pool.
39
+ Relaton::Index.close :IHO
40
+ ----
41
+
42
+ The aim of this gem is to be used by Relaton libraries in two ways:
43
+ - indexing documents' files in GitHub repositories
44
+ - searching a document by a reference when the Relaton library fetches a document
45
+
46
+ === Indexing
47
+
48
+ In this case, the Relaton library creates an index object and adds documents' files to it. By default, the index object is saved to the `index.yaml` file in the root of the repository. The filename can be changed using the `filename` setting.
49
+
50
+ [source,ruby]
51
+ ----
52
+ # Create a new index object or fetch an existing one. The first argument is the type of dataset (ISO, IEC, IHO, etc.) URL should not be specified.
53
+ index = Relaton::Index.find_or_create :IHO
54
+
55
+ # Add a document to the index or update it if it already exists.
56
+ index.add_or_update "B-4 2.19.0", "data/b-4_2_19_0.xml"
57
+
58
+ # Save the index to the `index.yaml` file in the current directory.
59
+ index.save
60
+ ----
61
+
62
+ === Searching
63
+
64
+ In this case, the Relaton library should create an index object and search for a document by reference. The gem looks for the `.relaton/[TYPE]/index.yaml` file in the user's home directory. The `[TYPE]` is one of downcased ISO, IEC, IHO, etc. If the file is not found or is older than 24 hours then it will be downloaded from the URL specified in the `find_or_create` method. The URL can be specified as `true` if the index should not be fetched from the URL.
65
+
66
+ [source,ruby]
67
+ ----
68
+ # Create a new index object or fetch an existing one. URL should be specified. If the index file is not found or is older than 24 hours, it will be downloaded from the URL. By default, the index is saved as `index.yaml` file to the `/[HOME]/.relaton/iho/` folder. If the URL is specified as `true`, the index won't be fetched from the URL.
69
+ index = Relaton::Index.find_or_create :IHO, url: "https://raw.githubusercontent.com/relaton/relaton-data-iho/master/index.zip"
70
+
71
+ # Search for a document by reference
72
+ index.search "B-4"
73
+ # => [{ id: "B-4 2.19.0", file: "data/b-4_2_19_0.xml" }]
74
+
75
+ # Search for a document by reference using a block
76
+ index.search do |row|
77
+ # do something with the index row
78
+ row[:id] == "B-4"
79
+ end
80
+ # => [{ id: "B-4 2.19.0", file: "data/b-4_2_19_0.xml" }]
81
+ ----
82
+
83
+ A string reference matches a row as a substring, as above. An identifier
84
+ reference takes pubid's asymmetric subset match `reference === row[:id]`: a
85
+ component the reference leaves out matches any value, so `OGC 12-128` answers
86
+ with the `12-128r19` row. Pass a block for exact equality:
87
+
88
+ [source,ruby]
89
+ ----
90
+ index.search(pubid) { |row| row[:id] == pubid }
91
+ ----
92
+
93
+ === Using pubid_class for structured identifiers
94
+
95
+ The `pubid_class` option allows index entries to be deserialized into structured identifier objects instead of plain strings or hashes. The class must include `Pubid::Core::Identifier` and provide a `.create(**hash)` factory method.
96
+
97
+ When `pubid_class` is specified, each `:id` hash in the index is converted into a pubid object, enabling structured access to identifier components (e.g., publisher, number, part, year).
98
+
99
+ [source,ruby]
100
+ ----
101
+ require 'relaton/index'
102
+ require 'pubid-iso'
103
+
104
+ # Create an index with pubid_class to deserialize IDs into Pubid::Iso::Identifier objects
105
+ index = Relaton::Index.find_or_create :ISO,
106
+ url: "https://raw.githubusercontent.com/relaton/relaton-data-iso/main/index-v2.zip",
107
+ pubid_class: Pubid::Iso::Identifier
108
+
109
+ # Search returns entries with structured pubid objects as IDs
110
+ results = index.search "ISO 1"
111
+ results.first[:id]
112
+ # => #<Pubid::Iso::Identifier: ISO 1>
113
+ results.first[:id].publisher
114
+ # => "ISO"
115
+ ----
116
+
117
+ === Reading the machine index on a Pages site
118
+
119
+ A data repository's GitHub Pages site publishes a sharded copy of its index
120
+ (`index/manifest.json` and `index/shard-NNNNN.json`, see
121
+ `docs/data-repository-format.adoc`). Give its base URL as `pages_url:` in place
122
+ of `url:`, with a `pubid_class:`:
123
+
124
+ [source,ruby]
125
+ ----
126
+ index = Relaton::Index.find_or_create :W3C,
127
+ pages_url: "https://relaton.github.io/relaton-data-w3c/",
128
+ pubid_class: Pubid::W3c::Identifier
129
+ index.search(Pubid::W3c::Identifier.parse("W3C REC-xml-names"))
130
+ ----
131
+
132
+ A parsed query reads the manifest and the one shard
133
+ `crc32(id.root.number.to_s) % shards`. A String query and a search with only a
134
+ block read the whole index, which the manifest names. Nothing is written to
135
+ disk: the manifest, the shards and the whole index stay in memory for 24 hours.
136
+
137
+ * An absent shard (HTTP 404) is "not found", and the search returns `[]`.
138
+ * A network error, or an HTTP status other than 200 and 404, raises
139
+ `Relaton::RequestError`.
140
+ * A missing or unsupported manifest, or a row that the pubid class cannot read,
141
+ raises `Relaton::Index::Error`.
142
+
143
+ The manifest also carries `sha256` and `bytes` for the whole-index zip it
144
+ names, so a dataset can be pinned and the fetched artifact verified.
145
+ `Relaton::Index::DatasetLock.capture(pages_url)` writes that pin as a lockfile
146
+ and verifies the artifact later; see `docs/data-repository-format.adoc`.
147
+
148
+ There is no fallback to the `url:` zip.
149
+
150
+ === Family, edition and part views
151
+
152
+ `Type#family(reference)` returns the rows that share the reference's root
153
+ number — the base document, its editions, its parts, and any supplement
154
+ layer — as a `Relaton::Index::Family`:
155
+
156
+ [source,ruby]
157
+ ----
158
+ family = index.family("ISO 1")
159
+ family.members # every row in the family
160
+ family.editions # {"2015" => [...], "2020" => [...]}
161
+ family.parts # {"1" => [...]}
162
+ ----
163
+
164
+ A String reference is parsed by the index's `pubid_class`; a parsed identifier
165
+ is used as given. Narrowing uses the same number bucket as `search`, so a large
166
+ index is not scanned in full.
167
+
168
+ === Remove all index records
169
+
170
+ This method removes all records from the index object. The index file is not removed.
171
+
172
+ [source,ruby]
173
+ ----
174
+ index.remove_all
175
+ ----
176
+
177
+ === Remove index file
178
+
179
+ This method removes the index file. The index object is cleared and can be used for indexing or loading a file from the specified URL.
180
+
181
+ [source,ruby]
182
+ ----
183
+ index.remove_file
184
+ ----
185
+
186
+ === Configuration
187
+
188
+ The gem can be configured by using the `Relaton::Index.config` method. The following settings are available:
189
+
190
+ - `filename` - the name of the index file. By default, it is `index.yaml`.
191
+ - `storage` - the storage class or module. By default, it is `FileStorage` module. It can be any class or module that implements the `ctime`, `read`, and `write` class methods.
192
+ - `storage_dir` - the directory where the `.relaton/[TYPE]` folder is created. By default, it's a user's home dir. This setting works only when the gem is used for searching. When indexing, the index is always saved in a current folder.
193
+
194
+ [source,ruby]
195
+ ----
196
+ Relaton::Index.config do |config|
197
+ config.filename = "index-v1.yaml"
198
+ config.storage = S3Storage
199
+ config.storage_dir = "/"
200
+ end
201
+ ----
202
+
203
+ It's also possible to redefine file name for a specific type of index:
204
+ [source,ruby]
205
+ ----
206
+ index = Relaton::Index.find_or_create :IHO, file: "index-v2.yaml"
207
+ ----
208
+
209
+ == Development
210
+
211
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
212
+
213
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
214
+
215
+ == Contributing
216
+
217
+ Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton-index.
218
+
219
+ == License
220
+
221
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "net/http"
6
+ require "time"
7
+ require "uri"
8
+ require "lutaml/model"
9
+
10
+ module Relaton
11
+ module Index
12
+ # A pinned dataset: the machine-index manifest's content address written to
13
+ # a file, so a build can reproduce the exact index it consumed years later.
14
+ #
15
+ # The manifest (docs/data-repository-format.adoc) carries the SHA-256 and
16
+ # size of the published whole-index zip. Capture records them; #verify
17
+ # re-checks the bytes a consumer fetched.
18
+ #
19
+ # lock = Relaton::Index::DatasetLock.capture(
20
+ # "https://relaton.github.io/relaton-data-iso/",
21
+ # )
22
+ # File.write(".relaton-lock.yaml", lock.to_yaml)
23
+ #
24
+ # lock = Relaton::Index::DatasetLock.from_yaml(File.read(".relaton-lock.yaml"))
25
+ # lock.verify(File.binread(zip_path))
26
+ class DatasetLock < Lutaml::Model::Serializable
27
+ attribute :version, :integer
28
+ attribute :pages_url, :string
29
+ attribute :index, :string
30
+ attribute :count, :integer
31
+ attribute :shards, :integer
32
+ attribute :generated, :string
33
+ attribute :sha256, :string
34
+ attribute :bytes, :integer
35
+ attribute :captured_at, :string
36
+
37
+ SHA256_HEX = /\A\h{64}\z/
38
+
39
+ # The manifest names the artifact; these fields pin it. A manifest
40
+ # published before content addressing has no sha256 and cannot be
41
+ # locked: refusing is the point, a lock without a hash guarantees
42
+ # nothing.
43
+ def self.from_manifest(pages_url, manifest, captured_at: Time.now.utc.iso8601)
44
+ unless manifest.is_a?(Hash)
45
+ raise Error, "manifest is not a JSON object: #{manifest.class}"
46
+ end
47
+
48
+ sha = manifest["sha256"]
49
+ unless sha.is_a?(String) && SHA256_HEX.match?(sha)
50
+ raise Error,
51
+ "manifest for #{pages_url} carries no sha256 content address; " \
52
+ "republish the index with a generator that emits one"
53
+ end
54
+
55
+ index = manifest["index"]
56
+ unless index.is_a?(String) && !index.empty?
57
+ raise Error, "manifest for #{pages_url} names no index artifact"
58
+ end
59
+
60
+ new(
61
+ version: 1,
62
+ pages_url: pages_url,
63
+ index: index,
64
+ count: manifest["count"],
65
+ shards: manifest["shards"],
66
+ generated: manifest["generated"],
67
+ sha256: sha,
68
+ bytes: manifest["bytes"],
69
+ captured_at: captured_at,
70
+ )
71
+ end
72
+
73
+ # Fetch the site's manifest and return the lock for it. +fetcher+ is the
74
+ # injection point for tests and alternate transports; it must answer
75
+ # (pages_url, **timeouts) with the parsed manifest.
76
+ def self.capture(pages_url, fetcher: nil, open_timeout: 15, read_timeout: 30)
77
+ fetch = fetcher || method(:fetch_manifest)
78
+ from_manifest(
79
+ pages_url,
80
+ fetch.call(pages_url, open_timeout: open_timeout, read_timeout: read_timeout),
81
+ )
82
+ end
83
+
84
+ def self.fetch_manifest(pages_url, open_timeout: 15, read_timeout: 30)
85
+ base = pages_url.to_s
86
+ base = "#{base}/" unless base.end_with?("/")
87
+ uri = URI.join(base, "index/manifest.json")
88
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
89
+ open_timeout:, read_timeout:) do |http|
90
+ http.get(uri.request_uri)
91
+ end
92
+ raise Error, "cannot fetch #{uri}: HTTP #{res.code}" unless res.code == "200"
93
+
94
+ JSON.parse(res.body)
95
+ rescue JSON::ParserError => e
96
+ raise Error, "malformed manifest at #{uri}: #{e.message}"
97
+ end
98
+
99
+ # The published whole-index zip this lock addresses.
100
+ def zip_url
101
+ "#{pages_url.to_s.chomp('/')}/#{index}.zip"
102
+ end
103
+
104
+ # Raise unless +data+ is byte-for-byte the artifact this lock pins.
105
+ # Returns true so it reads as an assertion in build scripts.
106
+ def verify(data)
107
+ actual = Digest::SHA256.hexdigest(data)
108
+ if actual != sha256
109
+ raise Error,
110
+ "dataset hash mismatch for #{zip_url}: expected #{sha256}, got #{actual}"
111
+ end
112
+ if bytes && data.bytesize != bytes
113
+ raise Error,
114
+ "dataset size mismatch for #{zip_url}: expected #{bytes} bytes, " \
115
+ "got #{data.bytesize}"
116
+ end
117
+
118
+ true
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relaton
4
+ module Index
5
+ # A document's family: every index row whose root number matches the
6
+ # reference's — the base document, its editions, parts, and any supplement
7
+ # layer (root walks a wrapper's base chain). Rows are the same
8
+ # `{ id:, file: }` hashes #search returns.
9
+ #
10
+ # The views are driven by the components the identifiers actually state:
11
+ # #editions groups rows by year/edition/version, #parts by part/parts.
12
+ # An identifier that states none of those appears only in #members.
13
+ class Family
14
+ attr_reader :reference, :members
15
+
16
+ def initialize(reference, members)
17
+ @reference = reference
18
+ @members = members
19
+ end
20
+
21
+ # @return [String] the family's narrowing key
22
+ def root_number
23
+ reference.root.number.to_s
24
+ end
25
+
26
+ # Rows grouped by their stated edition, ascending.
27
+ # @return [Hash{String => Array<Hash>}]
28
+ def editions
29
+ group_by(%w[year edition version])
30
+ end
31
+
32
+ # Rows grouped by their stated part, ascending.
33
+ # @return [Hash{String => Array<Hash>}]
34
+ def parts
35
+ group_by(%w[part parts subpart])
36
+ end
37
+
38
+ private
39
+
40
+ def group_by(names)
41
+ grouped = Hash.new { |hash, key| hash[key] = [] }
42
+ members.each do |row|
43
+ value = component(row[:id], names)
44
+ grouped[value] << row unless value.empty?
45
+ end
46
+ grouped.sort_by { |key, _| key }.to_h
47
+ end
48
+
49
+ def component(id, names)
50
+ hash = id.root.to_hash
51
+ names.each do |name|
52
+ value = hash[name]
53
+ next if value.nil?
54
+
55
+ value = value.first if value.is_a?(Array)
56
+ text = value.to_s
57
+ return text unless text.strip.empty?
58
+ end
59
+ ""
60
+ end
61
+ end
62
+ end
63
+ end
@@ -130,6 +130,27 @@ module Relaton
130
130
  items.select { |i| match_item(i, id) }
131
131
  end
132
132
 
133
+ #
134
+ # The reference's family: rows sharing its root number — the base
135
+ # document, its editions and parts, and any supplement layer (root walks
136
+ # a wrapper's base chain). Narrowing uses the same number bucket as
137
+ # #search, so a large index is not scanned in full.
138
+ #
139
+ # @param [String, Pubid::Identifier] reference
140
+ # @return [Relaton::Index::Family]
141
+ #
142
+ def family(reference)
143
+ ref = coerce_reference(reference)
144
+ root = ref.root
145
+ members = search_candidates(ref).select do |row|
146
+ id = row[:id]
147
+ id.is_a?(::Pubid::Identifier) &&
148
+ id.root.class == root.class &&
149
+ id.root.number.to_s == root.number.to_s
150
+ end
151
+ Family.new(ref, members)
152
+ end
153
+
133
154
  #
134
155
  # Save index to storage
135
156
  #
@@ -223,6 +244,17 @@ module Relaton
223
244
  ShardSource.new(@dir, @pages_url, @pubid_class) if @pages_url
224
245
  end
225
246
 
247
+ # A String query is parsed by this index's flavor; a parsed identifier is
248
+ # used as given. A pubid-less index cannot answer a family query.
249
+ def coerce_reference(reference)
250
+ return reference unless reference.is_a?(String)
251
+
252
+ klass = @file_io.pubid_class
253
+ raise ArgumentError, "a String reference needs a pubid-backed index" unless klass
254
+
255
+ klass.parse(reference)
256
+ end
257
+
226
258
  def search_candidates(id)
227
259
  # A parsed query reads only its own shard. `@index`, once loaded, is
228
260
  # never refreshed, so the shards stay the fresher answer.
data/lib/relaton/index.rb CHANGED
@@ -18,6 +18,9 @@ require_relative "index/type"
18
18
 
19
19
  module Relaton
20
20
  module Index
21
+ autoload :DatasetLock, "relaton/index/dataset_lock"
22
+ autoload :Family, "relaton/index/family"
23
+
21
24
  class Error < StandardError; end
22
25
 
23
26
  class << self
@@ -0,0 +1,47 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this gem does
6
+
7
+ Retrieves bibliographic items from the OpenLibrary API by ISBN (10 or 13 digit) and returns `Relaton::Bib::ItemData` objects. Part of the Relaton family of gems.
8
+
9
+ ## Commands
10
+
11
+ ```sh
12
+ bundle exec rspec # run all tests
13
+ bundle exec rspec spec/relaton/isbn/parser_spec.rb # run single spec file
14
+ bundle exec rubocop # lint
15
+ bin/console # IRB with gem loaded
16
+ ```
17
+
18
+ ## Architecture
19
+
20
+ Request flow: `OpenLibrary.get(code)` → `Isbn.new(code).parse` (validates/normalizes ISBN) → `OpenLibrary.request_api(isbn)` (HTTP to openlibrary.org) → `Parser.parse(json)` (builds `ItemData`)
21
+
22
+ `get` also takes a parsed `Pubid::Isbn::Identifier`: its digits (`raw`, 10 or 13) go through the same `Isbn#parse`.
23
+
24
+ Key modules under `Relaton::Isbn`:
25
+ - **OpenLibrary** — API client, main entry point via `.get`
26
+ - **Isbn** — ISBN-10/13 validation and conversion (always normalizes to ISBN-13)
27
+ - **Parser** — Transforms OpenLibrary JSON into `Relaton::Bib::ItemData` with titles, contributors, dates, etc.
28
+ - **Processor** — Integration hook for relaton-core framework (`from_xml`, `from_yaml`, `get`)
29
+ - **Util** — Logging via `Relaton::Bib::Util`
30
+
31
+ ## Data model (relaton-bib 2.0)
32
+
33
+ Uses `Relaton::Bib::` namespace (not the old `RelatonBib::`). Key classes: `ItemData`, `Title`, `Docidentifier` (uses `.content` not `.id`), `Uri`, `Contributor`, `Person`, `Organization`, `Date` (uses `at:` not `on:`), `Place`.
34
+
35
+ `Parser#parse` returns `Bib::ItemData` (not `Bibitem`) — `ItemData` is the plain data class; `Bibitem`/`Item` are lutaml-model serializers that wrap it.
36
+
37
+ ## Testing
38
+
39
+ - **VCR** records HTTP interactions in `spec/vcr_cassettes/` (re-records every 7 days)
40
+ - **WebMock** disables real HTTP by default
41
+ - **SimpleCov** tracks coverage
42
+ - Fixtures in `spec/fixtures/`
43
+
44
+ ## Style
45
+
46
+ - RuboCop with Ribose OSS style guide, target Ruby 3.1
47
+ - `rubocop-rails` is required but Rails cops are disabled (not a Rails app)
@@ -0,0 +1,91 @@
1
+ = Relaton::ISBN: retrieves bibliographic items using OpenLibrary API
2
+
3
+ Relaton::Isbn is a Ruby gem that implements the
4
+ https://github.com/metanorma/metanorma-model-iso#iso-bibliographic-item[IsoBibliographicItem model].
5
+
6
+ You can use it to retrieve metadata of Standards from https://openlibrary.org, and
7
+ access such metadata through the `Bibitem` object.
8
+
9
+ == Usage
10
+
11
+ === Retrieving bibliographic items using OpenLibrary API
12
+
13
+ To retrieve bibliographic items, use `Relaton::Isbn::OpenLibrary.get` method with ISBN-10 or ISBN-13 as an argument. Allowed prefixes are `ISBN`, `isbn:`. Prefix and hyphens are optional. The method returns `Relaton::Bib::Bibitem` object.
14
+
15
+ [source,ruby]
16
+ ----
17
+ require 'relaton/isbn'
18
+ => true
19
+
20
+ # get document by ISBN-13
21
+ bibitem = Relaton::Isbn::OpenLibrary.get "ISBN 978-0-12-064481-0"
22
+ [relaton-isbn] INFO: (ISBN 978-0-12-064481-0) Fetching from OpenLibrary ...
23
+ [relaton-isbn] INFO: (ISBN 978-0-12-064481-0) Found: `9780120644810`
24
+ => #<Relaton::Bib::Bibitem:0x0000000127ed25d8
25
+ ...
26
+
27
+ # get document by ISBN-10
28
+ Relaton::Isbn::OpenLibrary.get "isbn:0120644819"
29
+ [relaton-isbn] INFO: (isbn:0120644819) Fetching from OpenLibrary ...
30
+ [relaton-isbn] INFO: (isbn:0120644819) Found: `9780120644810`
31
+ => #<Relaton::Bib::Bibitem:0x0000000127f11698
32
+ ...
33
+ ----
34
+
35
+ === Serializing bibliographic items
36
+ [source,ruby]
37
+ ----
38
+ # serialize to XML
39
+ bibitem.to_xml
40
+ => "<bibitem id="9780120644810" schema-version="v1.4.1">
41
+ <fetched>2026-03-04</fetched>
42
+ <title type="main">Graphics gems II</title>
43
+ <uri type="src">http://openlibrary.org/books/OL21119585M/Graphics_gems_II</uri>
44
+ <docidentifier type="ISBN" primary="true">9780120644810</docidentifier>
45
+ ...
46
+ </bibitem>"
47
+
48
+ # serialize to bibdata XML
49
+ bibitem.to_xml bibdata: true
50
+ => "<bibdata schema-version="v1.4.1">
51
+ <fetched>2026-03-04</fetched>
52
+ <title type="main">Graphics gems II</title>
53
+ <uri type="src">http://openlibrary.org/books/OL21119585M/Graphics_gems_II</uri>
54
+ <docidentifier type="ISBN" primary="true">9780120644810</docidentifier>
55
+ ...
56
+ <ext>
57
+ <flavor>isbn</flavor>
58
+ </ext>
59
+ </bibdata>"
60
+
61
+ # serialize to hash
62
+ puts bibitem.to_yaml
63
+ => "---
64
+ id: '9780120644810'
65
+ schema_version: v1.4.1
66
+ ..."
67
+ ----
68
+
69
+ === Logging
70
+
71
+ Relaton::Isbn uses the relaton-logger gem for logging. By default, it logs to STDOUT. To change the log levels and add other loggers, read the https://github.com/relaton/relaton-logger#usage[relaton-logger] documentation.
72
+
73
+ == Development
74
+
75
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
76
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
77
+ prompt that will allow you to experiment.
78
+
79
+ To install this gem onto your local machine, run `bundle exec rake install`. To
80
+ release a new version, update the version number in `version.rb`, and then run
81
+ `bundle exec rake release`, which will create a git tag for the version, push
82
+ git commits and the created tag, and push the `.gem` file to
83
+ https://rubygems.org[rubygems.org].
84
+
85
+ == Contributing
86
+
87
+ Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton-isbn.
88
+
89
+ == License
90
+
91
+ The gem is available as open source under the terms of the https://opensource.org/licenses/MIT[MIT License].
@@ -0,0 +1,38 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Development
6
+
7
+ - `bin/setup` — install dependencies
8
+ - `rake spec` — run tests
9
+ - `rspec spec/relaton/iso/bibliography_spec.rb` — run a single test file
10
+ - `rspec spec/relaton/iso/bibliography_spec.rb -e "some description"` — run a specific example
11
+ - `rake spec:update_index` — download latest ISO index fixture (`spec/fixtures/index-v1.zip`) from relaton-data-iso
12
+ - `bin/console` — interactive prompt with the gem loaded
13
+ - `rubocop` — lint (Ribose OSS style guide, Ruby 3.2 target)
14
+
15
+ ## Architecture
16
+
17
+ relaton-iso retrieves ISO standard bibliographic data. The core retrieval flow:
18
+
19
+ 1. **Bibliography** (`lib/relaton/iso/bibliography.rb`) — entry point via `search(pubid)` and `get(ref, year, opts)`. Handles year filtering, part matching, and type/stage validation. Both **propagate** `Pubid::Errors::ParseError` when the caller's reference can't be parsed by pubid (they do not swallow it — the CLI turns it into a friendly message; API consumers rescue it themselves). Note `look_up_with_any_types_stages` deliberately rescues `Pubid::Errors::ParseError` internally: it re-parses a *machine-derived* type/stage-stripped probe, so a failure there just means "no alternative-type/stage candidates" and must not abort a lookup whose original reference already parsed.
20
+ 2. **HitCollection** (`lib/relaton/iso/hit_collection.rb`) — searches a pre-built YAML index (`index-v1.zip` from relaton-data-iso) using `Relaton::Index`. Matches ids through the parsed pubid (`reference === row`, relaton#109). Returns sorted Hit array.
21
+ 3. **Hit** (`lib/relaton/iso/hit.rb`) — wraps an index result. The `item` attribute lazy-loads the full document from GitHub raw content (relaton-data-iso repo). `sort_weight` prioritizes published over withdrawn/deleted.
22
+ 4. **ItemData** / **Model::Item** — ISO-specific bibliographic item extending `Relaton::Bib::ItemData`.
23
+ 5. **Scraper** (`lib/relaton/iso/scraper.rb`) — parses individual ISO website pages. Used only by `Bibliography.get` as a fallback when an item is missing from the curated index; no longer drives bulk ingest.
24
+ 6. **DataFetcher** (`lib/relaton/iso/data_fetcher.rb`) — streams the ISO Open Data programme JSONL feeds (`iso_deliverables_metadata.jsonl` for documents, `iso_technical_committees.jsonl` for committee titles) and writes one YAML per primary docid into `@output`. Short-circuits on upstream `Last-Modified`; falls back to a full pass when `data/` or `index-v1.yaml` is missing. Two source modes:
25
+ - `iso-open-data` (default) — incremental, skip when upstream is unchanged.
26
+ - `iso-open-data-all` — wipe `@output` and re-emit every record.
27
+
28
+ File names come from `Core::DataFetcher#unique_output_file`, not the plain `output_file`: `output_file` is not injective (`ISO/DIS 15869.3` and `ISO/DIS 15869-3` both sanitize to `iso-dis-15869-3.yaml`), so a second distinct docid gets a digest-suffixed path. `rewrite_with_same_or_newer` (edition / substage-98) therefore only ever compares two records of the **same** docid.
29
+ 7. **DataParser** (`lib/relaton/iso/data_parser.rb`) — converts one Open Data record (`Hash`) into a `Relaton::Iso::ItemData`. Takes a `ref_index` (id → reference) for resolving `replaces`/`replacedBy` and a `tc_index` (reference → `{ "en"/"fr" => title }`) for resolving committee labels.
30
+
31
+ Key dependency: `pubid-iso` gem handles ISO publication identifier parsing and comparison.
32
+
33
+ ## Testing
34
+
35
+ - **Framework:** RSpec with VCR cassettes and WebMock
36
+ - **Network access:** fully blocked via `WebMock.disable_net_connect!`
37
+ - **Index fixture:** `spec/fixtures/index-v1.zip` is pre-loaded into `Relaton::Index` pool in `before(:suite)` (configured in `spec/support/webmock.rb`). Run `rake spec:update_index` to refresh from relaton-data-iso.
38
+ - **VCR:** cassettes in `spec/vcr_cassettes/`, record mode `:once`, re-record interval 7 days. Index download requests are ignored by VCR (handled by fixture instead).