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,219 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ relaton-etsi is a Ruby gem that retrieves ETSI (European Telecommunications Standards Institute) standards metadata using the Relaton bibliographic item model. It fetches data from the ETSI website and the relaton-data-etsi GitHub repository.
8
+
9
+ ## Commands
10
+
11
+ ```bash
12
+ # Install dependencies
13
+ bin/setup
14
+
15
+ # Run all tests
16
+ rake spec
17
+
18
+ # Run a specific test file
19
+ rspec spec/relaton/etsi/bibliography_spec.rb
20
+
21
+ # Run a specific test by line number
22
+ rspec spec/relaton/etsi/bibliography_spec.rb:15
23
+
24
+ # Run linter
25
+ rake rubocop
26
+
27
+ # Interactive console
28
+ bin/console
29
+
30
+ # Install gem locally
31
+ bundle exec rake install
32
+ ```
33
+
34
+ ## Architecture
35
+
36
+ The codebase is transitioning from `RelatonEtsi` namespace to `Relaton::Etsi` namespace.
37
+
38
+ ### New Architecture (lib/relaton/etsi/)
39
+
40
+ Uses Lutaml for serialization:
41
+ - `Item` - base bibliographic item inheriting from `Bib::Item`
42
+ - `Bibitem` - for `<bibitem>` XML output (includes `Bib::BibitemShared`)
43
+ - `Bibdata` - for `<bibdata>` XML output (includes `Bib::BibdataShared`)
44
+ - `Ext` - ETSI-specific extension data (marker, frequency, mandate, custom_collection)
45
+ - `Doctype` - ETSI document type with abbreviations (EN, ES, GS, TS, TR, etc.)
46
+
47
+ ### Legacy Architecture (lib/relaton_etsi/)
48
+
49
+ - `BibliographicItem` - extends `RelatonBib::BibliographicItem`
50
+ - `XMLParser` - parses XML into bibliographic items
51
+ - `HashConverter` - converts hashes to bibliographic items
52
+ - `DocumentType` - document type with type/abbreviation mapping
53
+
54
+ ### Core Components
55
+
56
+ - `Bibliography` - searches and retrieves standards from relaton-data-etsi index
57
+ - `DataFetcher` - fetches all documents from ETSI website CSV export
58
+ - `DataParser` - parses CSV rows into bibliographic items
59
+ - `Processor` - Relaton processor for integration with the relaton ecosystem
60
+
61
+ ### Data Flow
62
+
63
+ 1. `Bibliography.get(ref)` searches the relaton-data-etsi index
64
+ 2. Fetches YAML from GitHub, converts to `Item` using `from_yaml`
65
+ 3. `DataFetcher.fetch` pulls CSV from etsi.org, parses with `DataParser`, saves to output folder
66
+
67
+ ### Pubid-backed index-v2
68
+
69
+ The ETSI index is **pubid-structured** (`index-v2.yaml`/`.zip`): each row's `:id`
70
+ is a `Pubid::Etsi::Identifier` serialized to its `_type: pubid:etsi:{etsi-standard,
71
+ amendment,corrigendum}` hash (the whole published ETSI corpus round-trips on the
72
+ pinned pubid — see the root `CLAUDE.md` pubid-pin note). The rows use the **flat,
73
+ compact** shape (`type`/`number`/`version`/`year`/`month` as scalars under
74
+ `_type`) that the published `relaton-data-etsi` index carries — the ETSI
75
+ `to_hash` flattening (merged from `refactor/flatten-etsi-to-hash`) ships in the
76
+ released pubid `2.0.0.pre.alpha.13` that the gemspec pins. The wiring mirrors NIST/JCGM:
77
+
78
+ - **Producer** (`DataFetcher`): `index` calls `find_or_create(:etsi, file:
79
+ "#{INDEXFILE}.yaml", pubid_class: ::Pubid::Etsi::Identifier)`; `#save` parses the
80
+ docid via `#pubid` and stores the **pubid object** (`index.add_or_update pid,
81
+ file`) so `Relaton::Index` sorts by id number and serializes each id to its
82
+ `_type:` hash on save. `#pubid` returns nil (skipping the whole record) for any
83
+ id it can't parse **or** `to_hash`-serialize, so one malformed record can never
84
+ abort the crawl or corrupt the index. (No current ETSI record is skipped.)
85
+ - **Consumer** (`Bibliography#search`): parses the reference with
86
+ `::Pubid::Etsi.parse` and lets a `Pubid::Errors::ParseError` on an unrecognized ref
87
+ **propagate** (ISO parity — the CLI renders a friendly message, API callers
88
+ rescue it), then `#best_match` selects with pubid's subset match
89
+ `pubid === row`. The `pubid` (not a String) lets `Relaton::Index` narrow
90
+ candidates by number via binary search. A `version` or `date` that the ref
91
+ omits matches any value, so a bare `ETSI GS ZSM 012` matches every edition
92
+ and a fully-qualified ref matches only its edition.
93
+ `max_by { edition_key(row[:id]) }` returns the most recent.
94
+ Requires pubid `main` (partial-ref parsing, `Pubid::Errors::ParseError` on
95
+ failure, `subset_strict`).
96
+ - **A part-less reference strips the ROW's parts, it does not ask pubid for
97
+ "all parts".** pubid declares `parts` strict for ETSI (pubid#408), so a
98
+ part-less ref matches no part row on its own. `#comparable` therefore
99
+ matches each row without its parts (`exclude(:part, :subpart, :parts)`, a
100
+ copy — the cached index id is untouched), and `ETSI EN 300 175` reaches its
101
+ 16 part rows. **Do not use `#to_all_parts` here.** Since pubid#433 "all
102
+ parts" is a class, and `Pubid::AllPartsIdentifier#===` compares the document
103
+ alone: it drops the version and the date as well, so
104
+ `ETSI GR ZSM 011 V1.1.1 (2023-02)` answered with V2.1.1 (2024-09) — 586,487
105
+ pairs over the fixture. `resolves a fully-qualified part-less reference to
106
+ that exact edition` in `bibliography_spec.rb` pins it.
107
+ - **A base reference does not reach its supplements.** `===` requires the
108
+ same class, so `ETSI ETR 310` does not match the corrigendum
109
+ `ETSI ETR 310/C1`, and a corrigendum of the base (`ETS 300 092-1/C1`) does
110
+ not match a corrigendum of its amendment (`092-1/A1/C1`). The
111
+ `matches?(…, ignore:)` it replaced did match them: over the 28,651-row
112
+ fixture, 298 pairs changed, all of them a supplement row that the old match
113
+ let in, and 218 of 94,079 references resolved to a newer amendment or
114
+ corrigendum instead of the document itself (`ETSI ETR 310` →
115
+ `ETR 310/C1 ed.1 (1996-10)`). `bibliography_spec.rb` pins that case.
116
+ - **`#edition_key` orders on the parsed version, never on the rendered id.** ETSI
117
+ versions are not zero-padded, so a String comparison of `row[:id].to_s` orders
118
+ `V9.0.0` above `V19.0.0` and `ed.9` above `ed.11` — it picked the wrong edition
119
+ for 983 of the 3007 multi-edition documents in the spec fixture, and a bare
120
+ `ETSI TR 155 919` resolved to the 2010 edition. `Pubid::Etsi::Identifier` is
121
+ **not** `Comparable` (its `<=>` returns nil), so the key is built from the
122
+ components: `id.version.version` holds the bare numbers (`"19.0.0"`, or `"9"`
123
+ for the `ed.N` form) and `id.date` renders as `yyyy-mm` for the tie-break.
124
+ Both delegate to `base` on a `pubid:etsi:corrigendum`/`amendment` row (162 of
125
+ them in the fixture), so every row shape keys the same way. The key ignores
126
+ `is_edition`, which puts `ed.11` above `V1.0.0` — safe because **no** document
127
+ mixes the two forms (measured: 0 of 28651 fixture rows). Do not put `.to_s`
128
+ back.
129
+ - **Processor** `#remove_index_file` passes `url: true` and `file:` only. It
130
+ needs no `pubid_class:`: the delete never reads the index (see
131
+ `lib/relaton/index/CLAUDE.md`).
132
+
133
+ ### The crawl query keeps superseded editions (`version=1`)
134
+
135
+ `DataFetcher::SOURCEURL` is the ETSI standards-search query, and
136
+ `relaton-data-etsi/data/` is **exactly** what it returns — `crawler.rb` rebuilds
137
+ `data/` on every run, so a record the query drops cannot be reinstated in the
138
+ data repo. The `version` flag selects editions, not statuses: `version=0`
139
+ returns only the current edition of each branch, `version=1` returns the
140
+ superseded ones as well. The status flags (`withdrawn`, `historical`,
141
+ `superseded`, `onApproval`, `isCurrent`) are a **separate axis** and are already
142
+ all `1` — that is why the dataset carried `Historical` and `Withdrawn` documents
143
+ while still missing older editions (metanorma/metanorma-pdfa#95, which wanted
144
+ `ETSI EN 319 142-1 V1.2.1 (2024-01)` back). `version=0` also still returns
145
+ parallel current branches, so the response's own `superseded`/`new_versions`
146
+ fields, not the major version, are what `version` keys on.
147
+
148
+ The flavor uses `version=1`. Measured against the live API on 2026-08-27:
149
+
150
+ | | `version=0` | `version=1` |
151
+ |---|---|---|
152
+ | records | 28625 | 67446 (2.36x) |
153
+ | pages at 50/page | 573 | 1349 |
154
+ | `data/` on disk | 112 MB | ~264 MB |
155
+ | `index-v2.yaml` | 5.0 MB | ~11.8 MB |
156
+
157
+ Nothing else in the producer needed changing: the version is part of the ETSI
158
+ docid, so `Core::DataFetcher#unique_output_file` gives each edition its own
159
+ filename and `Index::Type#add_or_update` (keyed on `id.to_s`) gives each its own
160
+ row. `spec/etsi/relaton/etsi/data_fetcher_spec.rb` pins the query flag, and pins
161
+ that machinery too — it was already correct, but `version=1` is what makes
162
+ several editions of one document a common case rather than a rare one.
163
+
164
+ **Ordering rule for the data repo.** This is producer-side only; nothing changes
165
+ for users until `relaton-data-etsi` re-crawls. Do **not** deploy a `version=1`
166
+ crawl until the released `relaton-etsi` gem picks the newest edition. Its
167
+ `Bibliography#search` uses `min_by` on the rendered id, which returns the
168
+ **oldest** match, so a bare `ETSI EN 319 401` would resolve to the 2013 edition
169
+ across the whole corpus. This flavor picks the newest edition (`#best_match`
170
+ orders on `#edition_key`). Also check the crawl against the 6-hour GitHub
171
+ Actions job cap — it roughly doubles — and confirm all three EN 319 142-1
172
+ editions land in `data/` before merging the re-crawl.
173
+
174
+ ### A bad page fails the crawl; it is never skipped
175
+
176
+ The relaton-data-etsi crawl of 2026-09-30 (run 36761804954) died at about page
177
+ 700 with `JSON::ParserError: unexpected character: 'Array'`: data.php answered
178
+ once with PHP's `Array` text in place of JSON. It is transient and server side
179
+ — the code had not changed since the good crawl of 2026-09-28, and a full
180
+ re-crawl on 2026-10-02 gave valid JSON on all 1,353 pages (67,602 records,
181
+ `RowNum` 1..67,602 with no gap). Note also that `curl` gets an HTTP 403 page from
182
+ ETSI's web application firewall; Mechanize's user agent gets JSON.
183
+
184
+ `DataFetcher#fetch_page` therefore parses the body **inside**
185
+ `fetch_with_retry`: a body that is not a JSON array raises `BadPage`, which is
186
+ retried like a network error. A page still bad after its retries is deferred
187
+ and fetched again after the last page (`DEFERRED_DELAY`); if it is still bad,
188
+ the crawl raises `BadPage` with the page number and the first 200 characters of
189
+ the body, and `index.save` is never reached. Page 1 is not deferred (it gives
190
+ `total_count`). **Do not change this to skip the page:** `crawler.rb` deletes
191
+ `data/` before the crawl and commits what the crawl writes, so a skipped page
192
+ unpublishes 50 documents, while a failed crawl commits nothing and the previous
193
+ data stays live.
194
+
195
+ The result set is sorted by deliverable number (`sort=1`), not by date, so a
196
+ document that ETSI publishes during the one-hour crawl shifts the later pages
197
+ by one. The page count from page 1's `total_count` would then miss the last
198
+ record, so `fetch_remaining_pages` reads on past it while the pages stay full.
199
+ A record read twice overwrites its own file. Two bounds keep the loop finite
200
+ (data.php answers `[]` past the end today, but the loop must not depend on it):
201
+ a deferred page extends the range by one page only when it lies inside page
202
+ 1's `total_count`, and a crawl still reading full pages `MAX_EXTRA_PAGES` past
203
+ that count raises. A deferred page that comes back `[]` inside the range also
204
+ fails the crawl. **Known limit:** a document *removed* during the crawl shifts
205
+ the later pages the other way, so one record can fall onto a page already read.
206
+ Nothing detects that; it is rare, because every status flag is on.
207
+
208
+ ## Testing
209
+
210
+ - **Index fixture:** `spec/fixtures/index-v2.zip` (pubid `_type:` rows) is loaded
211
+ into the `Relaton::Index` pool in `before(:suite)` (`spec/support/webmock.rb`):
212
+ the YAML is written to a temp file and read through
213
+ `Relaton::Index::Type.new(:etsi, file:, pubid_class: ::Pubid::Etsi::Identifier)`,
214
+ and `type.index` forces the offline `pubid_class` deserialize before the net is
215
+ blocked; `actual?` is overridden to match only the remote (`url:`) lookup so the
216
+ producer-side `find_or_create(:etsi, file:, pubid_class:)` still gets a fresh
217
+ instance. Regenerate by parsing `index-v1`'s ids through `Pubid::Etsi` into a
218
+ `pubid_class` `Type` and re-zipping (`_type: pubid:etsi:…` rows).
219
+ Uses RSpec with VCR for HTTP interaction recording. VCR cassettes are in `spec/vcr_cassettes/`. When tests make new HTTP requests, VCR will record them.
@@ -0,0 +1,137 @@
1
+ = Relaton::Etsi
2
+
3
+ Relaton::Etsi is a Ruby gem that implements the https://github.com/relaton/relaton-models#bibliographic-item[BibliographicItem model].
4
+
5
+ You can use it to retrieve metadata of ETSI Standards and access such metadata through the `Relaton::Etsi::Item` object.
6
+
7
+ == Usage
8
+
9
+ === Search for a standard using keywords
10
+
11
+ [source,ruby]
12
+ ----
13
+ require "relaton/etsi"
14
+ => true
15
+
16
+ item = Relaton::Etsi::Bibliography.get("ETSI GS ZSM 012 V1.1.1")
17
+ [relaton-etsi] INFO: (ETSI GS ZSM 012 V1.1.1) Fetching from Relaton repository ...
18
+ [relaton-etsi] INFO: (ETSI GS ZSM 012 V1.1.1) Found: `ETSI GS ZSM 012 V1.1.1 (2022-12)`
19
+ => #<Relaton::Etsi::ItemData:0x0000000125869dd8
20
+ ...
21
+ ----
22
+
23
+ === XML serialization
24
+
25
+ [source,ruby]
26
+ ----
27
+ item.to_xml
28
+ => "<bibitem id="ETSIGSZSM012V111202212" schema-version="v1.5.6">
29
+ <fetched>2026-01-24</fetched>
30
+ <title language="en" script="Latn">Zero-touch network and Service Management (ZSM); Enablers for Artificial Intelligence-based Network and Service Automation</title>
31
+ <uri type="src">http://webapp.etsi.org/workprogram/Report_WorkItem.asp?WKI_ID=62010</uri>
32
+ <uri type="pdf">http://www.etsi.org/deliver/etsi_gs/ZSM/001_099/012/01.01.01_60/gs_ZSM012v010101p.pdf</uri>
33
+ <docidentifier type="ETSI" primary="true">ETSI GS ZSM 012 V1.1.1 (2022-12)</docidentifier>
34
+ ...
35
+ </bibitem>"
36
+ ----
37
+ With argument `bibdata: true` it outputs XML wrapped by `bibdata` element and adds flavour `ext` element.
38
+ [source,ruby]
39
+ ----
40
+ item.to_xml bibdata: true
41
+ => "<bibdata schema-version="v1.5.6">
42
+ <fetched>2026-01-24</fetched>
43
+ <title language="en" script="Latn">Zero-touch network and Service Management (ZSM); Enablers for Artificial Intelligence-based Network and Service Automation</title>
44
+ <uri type="src">http://webapp.etsi.org/workprogram/Report_WorkItem.asp?WKI_ID=62010</uri>
45
+ <uri type="pdf">http://www.etsi.org/deliver/etsi_gs/ZSM/001_099/012/01.01.01_60/gs_ZSM012v010101p.pdf</uri>
46
+ <docidentifier type="ETSI" primary="true">ETSI GS ZSM 012 V1.1.1 (2022-12)</docidentifier>
47
+ ...
48
+ <ext schema-version="v0.1.2">
49
+ <doctype abbreviation="GS">Group Specification</doctype>
50
+ </ext>
51
+ </bibdata>"
52
+ ----
53
+
54
+ === Typed source links
55
+
56
+ All the ETSI documents have SRC and PDF links. The gem provides a way to access these links through the `RelatonBib::TypedUri` object.
57
+
58
+ [source,ruby]
59
+ ----
60
+ item.source
61
+ =>
62
+ [#<Relaton::Bib::Uri:0x0000000129ea7b38 @content="http://webapp.etsi.org/workprogram/Report_WorkItem.asp?WKI_ID=62010", @language=nil, @locale=nil, @script=nil, @type="src">,
63
+ #<Relaton::Bib::Uri:0x0000000129ea76d8 @content="http://www.etsi.org/deliver/etsi_gs/ZSM/001_099/012/01.01.01_60/gs_ZSM012v010101p.pdf", @language=nil, @locale=nil, @script=nil, @type="pdf">]
64
+
65
+ item.source.size
66
+ => 2
67
+
68
+ item.source[0].type
69
+ => "src"
70
+
71
+ item.source[0].content
72
+ => "http://webapp.etsi.org/workprogram/Report_WorkItem.asp?WKI_ID=62010"
73
+
74
+ item.source[1].type
75
+ => "pdf"
76
+
77
+ item.source[1].content
78
+ => "http://www.etsi.org/deliver/etsi_gs/ZSM/001_099/012/01.01.01_60/gs_ZSM012v010101p.pdf"
79
+ ----
80
+
81
+ === Create bibliographic item from XML
82
+ [source,ruby]
83
+ ----
84
+ Relaton::Etsi::Item.from_xml File.read('spec/fixtures/bibdata.xml')
85
+ => #<Relaton::Etsi::ItemData:0x000000012b68f660
86
+ ...
87
+ ----
88
+
89
+ === Create bibliographic item from YAML
90
+ [source,ruby]
91
+ ----
92
+ Relaton::Etsi::Item.from_yaml File.read('spec/fixtures/item.yaml')
93
+ => #<Relaton::Etsi::ItemData:0x0000000126e34c58
94
+ ...
95
+ ----
96
+
97
+ === Fetch data
98
+
99
+ This gem uses the https://www.etsi.org as a data source.
100
+
101
+ The method `Relaton::Etsi::DataFetcher.fetch(output: "data", format: "yaml")` fetches all the documents from the data source and saves them to the `./data` folder in YAML format.
102
+ Arguments:
103
+
104
+ - `output` - folder to save documents (default './data').
105
+ - `format` - the format in which the documents are saved. Possible formats are: `yaml`, `xml`, `bibxml` (default `yaml`).
106
+
107
+ [source,ruby]
108
+ ----
109
+ require 'relaton/etsi/data_fetcher'
110
+ => true
111
+
112
+ Relaton::Etsi::DataFetcher.fetch
113
+ Started at: 2023-12-05 22:44:32 -0500
114
+ Stopped at: 2023-12-05 22:47:55 -0500
115
+ Done in: 204 sec.
116
+ => nil
117
+ ----
118
+
119
+ === Logging
120
+
121
+ Relaton::Etsi 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.
122
+
123
+ == Development
124
+
125
+ 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.
126
+
127
+ To update the index test fixture (used by tests), run `rake spec:update_index`. This downloads the latest `index-v2.zip` (pubid-structured `_type: pubid:etsi:…` rows) from the https://github.com/relaton/relaton-data-etsi[relaton-data-etsi] repository.
128
+
129
+ 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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
130
+
131
+ == Contributing
132
+
133
+ Bug reports and pull requests are welcome on GitHub at https://github.com/relaton/relaton-etsi.
134
+
135
+ == License
136
+
137
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,120 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ relaton-evs retrieves Estonian standardisation (EVS, Eesti Standardimis- ja
8
+ Akrediteerimiskeskus) bibliographic data using the Relaton model. Like CEN/BSI,
9
+ it publishes **no index and has no data repo**: the flavor **scrapes** the EVS
10
+ e-shop (`www.evs.ee`) with Mechanize. Reference handling is entirely
11
+ `Pubid::Evs` (the `:evs` flavor, registered in pubid's registry). EVS records
12
+ are dominated by **adoptions**: `EVS-EN …` adopts a European document,
13
+ `EVS-EN ISO …` a European adoption of an international one, `EVS-ISO …` /
14
+ `EVS-IEC …` adopt international documents directly. The adoption is expressed
15
+ with `adoptedFrom` relations parsed from the page's "Base Documents" group.
16
+
17
+ ## Development
18
+
19
+ ```bash
20
+ bundle exec rake spec:evs # the EVS suite
21
+ cd spec/evs && bundle exec rspec -I . relaton/evs/docidentifier_spec.rb
22
+ ```
23
+
24
+ (No gem-local rubocop config; the monorepo root config applies.)
25
+
26
+ ## Architecture
27
+
28
+ Namespace: `Relaton::Evs`. This is the **BSI/CEN shape**: parse the query with
29
+ pubid, select the hit with pubid, back the `Docidentifier` with pubid. No
30
+ `INDEXFILE`, no `pubid_class:`, no data repo. **Don't add an index** (unless a
31
+ `relaton-data-evs` repo appears — relaton#164 tracks that possibility).
32
+
33
+ Retrieval flow:
34
+
35
+ 1. **Bibliography** (`lib/relaton/evs/bibliography.rb`) — `get(code, year,
36
+ opts)` / `search` / `parse`. `parse` wraps `Pubid::Evs.parse` and **raises**
37
+ on an unrecognized query (root CLAUDE.md law); only the data side rescues,
38
+ in `Hit#pubid`. `get` keeps an early `nil` for an **empty** reference and
39
+ accepts the flavor's own pubid without mutating it (`Relaton::Db` hands it
40
+ the parse it routed). Transport errors are rescued **by name**
41
+ (`Net::ReadTimeout`, `Net::OpenTimeout` → `Relaton::RequestError`, which
42
+ `Db#net_retry` retries).
43
+ 2. **HitCollection** (`hit_collection.rb`) — the search is a **direct slug
44
+ probe**, not a form submit: the catalogue URL is the printed reference
45
+ slugified (`EVS-EN 18216:2026` → `/en/evs-en-18216-2026`), so the product
46
+ page exists iff the reference denotes a published document. A miss
47
+ **redirects** to `/page-not-found` (HTTP 302, followed by Mechanize) — that
48
+ redirect target is the "no hit" signal; a hard 4xx/5xx raises
49
+ `Mechanize::ResponseCodeError` and is a miss (`hits` rescues to `[]`).
50
+ The `et`/`et-en` language codes probe the `/et/` locale, which serves the
51
+ Estonian-language product.
52
+ - **The hit's `code` keeps the language code** (only the slug derivation
53
+ strips it): the filter `query === hit.pubid` needs the language ON the
54
+ hit for a language-carrying query to match its own record.
55
+ - A reference **without a year** probes `…-<number>` and misses — the
56
+ catalogue URL always carries the year. "Not found." is honest there; do
57
+ not add a search-endpoint fallback without evidence the shop renders one
58
+ server-side (its search is client-rendered).
59
+ - Slug shapes verified against the live catalogue: plain `EVS 945:2023`,
60
+ `EVS-EN 16798-1:2019`, `EVS-EN ISO 14001:2026`, the ETSI face
61
+ `EVS-EN 301 406-1 V3.2.1:2026`, `EVS-EN IEC 61000-4-30:2026`.
62
+ 3. **Hit** (`hit.rb`) — `item` lazy-loads the detail page via the scraper;
63
+ `pubid` memoizes the hit code parsed with pubid and is **nil** when the
64
+ grammar cannot read it.
65
+ 4. **Scraper** (`scraper.rb`) — Mechanize/XPath over the product page:
66
+ `h1` → docidentifier, `h2.product-title` → title (`Bib::Title.from_string`
67
+ splits " - " into intro/main — it returns an Array, don't re-wrap), the
68
+ "Valid from dd.mm.yyyy" badge → `published` date, "Base Documents" →
69
+ `adoptedFrom` relations (a double adoption lists both bases in ONE text
70
+ node, `ISO 14001:2026; EN ISO 14001:2026` — split on the semicolon), the
71
+ scope tab (`div#tab-1`) → abstract, ICS group links → `Ext.ics` via
72
+ `Isoics`. Contributor/copyright: the EVS organization (publisher); place
73
+ Tallinn.
74
+ 5. **ItemData / models** (`item_data.rb`, `model/`) — `Relaton::Evs::ItemData`
75
+ extends `Relaton::Bib::ItemData`; `Ext` adds a `StructuredIdentifier`
76
+ (docnumber/partnumber from the pubid **root**, agency "EVS"). There is no
77
+ `relaton-model-evs` gem, so `Ext` does not override `get_schema_version`
78
+ (inherits the nil default, like the other ten model-less flavors). The
79
+ **shared ext grammar requires a `doctype`** — `fetch_doctype` always emits
80
+ one (`standard`; `technical-specification`/`technical-report` for the
81
+ `EVS-TS`/`EVS-TR` faces), or Jing fails on `relaton-evs-compile.rng`.
82
+
83
+ ### `#root` is the accessor that answers for every form
84
+
85
+ A `NationalAdoption` (`EVS-EN ISO 9001:2015/A1:2024`) holds the origin
86
+ standard's number and year behind its `base` chain (base = the CEN/CENELEC
87
+ document; `AdoptedEuropeanNorm#adopted` = the ISO document; `#root` recurses),
88
+ so the year, number and part are read from `#root`. An `InternationalAdoption`
89
+ (`EVS-ISO 9001:2015`) or `EstonianStandard` (`EVS 945:2023`) is its own root.
90
+
91
+ ### `Docidentifier` — the year rule
92
+
93
+ `Relaton::Evs::Docidentifier` (`model/docidentifier.rb`, `< Bib::Docidentifier`)
94
+ parses its `content` into `@pubid` (DATA side: lazy `require "pubid"`, rescues
95
+ `LoadError`/`StandardError`; a foreign value stays verbatim with `@pubid` nil).
96
+ `Pubid#exclude` returns a **copy**, so each mutator replaces `@pubid` and
97
+ re-renders through the aliased `store_content`. **`remove_date!` drops the
98
+ LAST year**: on a supplement (`EVS-EN ISO 9001:2015/A1:2024`) the supplement's
99
+ year goes first (`exclude(:supplement_year)` → `…/A1`), the base year only
100
+ when there is none. `to_all_parts!` wraps in pubid's `AllParts` — `content`
101
+ stays the part-and-date-stripped string on purpose; `#pubid` renders the
102
+ "(all parts)" marker.
103
+
104
+ ### Printed separator is identity
105
+
106
+ `EVS EN 18216:2026` (space) and `EVS-EN 18216:2026` (hyphen) parse to the same
107
+ class and root, but each renders back **as written** — pubid preserves the
108
+ input's separator. Never compare EVS identifiers by `to_s`; compare by parse
109
+ (`===`, `root.number`).
110
+
111
+ ### Registration
112
+
113
+ `Processor` (`processor.rb`) sets `@short = :relaton_evs`, `@prefix = "EVS"`,
114
+ `@pubid_flavor = :Evs` (prefixes sourced from `Pubid::Evs.prefixes`) and
115
+ `@defaultprefix = %r{^EVS[\s-]}`. It lazy-`require_relative`s `../evs` inside
116
+ each method, like every other flavor (`spec/relaton/lazy_loading_spec.rb`
117
+ guards that). `lib/relaton.rb` autoloads `:Evs`; `Db::Registry::SUPPORTED_GEMS`
118
+ lists `relaton/evs`; `grammar/relaton-evs(-compile).rng` mirrors the CEN pair
119
+ (the shared `biblio-standoc` ext already models doctype/flavor/ics/
120
+ structuredidentifier, and its `DocRelationType` carries `adoptedFrom`).
@@ -0,0 +1,27 @@
1
+ = relaton-evs
2
+
3
+ EVS is the Estonian national corpus of the https://github.com/relaton/relaton[Relaton] bibliographic model.
4
+
5
+ Retrieves Estonian standardisation records (EVS, Eesti Standardimis- ja Akrediteerimiskeskus) from the https://www.evs.ee[EVS e-shop].
6
+
7
+ - Plain Estonian standards: `EVS 945:2023`, the `EVS-TS`/`EVS-TR` faces
8
+ - National adoptions: `EVS-EN 18216:2026`, `EVS-EN ISO 9001:2015/A1:2024`, `EVS-EN ISO/IEC 27017:2026`
9
+ - Direct international adoptions: `EVS-ISO 9001:2015`, `EVS-IEC 62368-1:2020`
10
+ - Language codes: `EVS 811:2018 et`, `et-en`
11
+
12
+ Reference parsing is entirely https://github.com/pubid/pubid[`Pubid::Evs`]; adoptions are expressed as `adoptedFrom` relations to the base documents.
13
+
14
+ == Usage
15
+
16
+ [source,ruby]
17
+ ----
18
+ require "relaton/evs"
19
+
20
+ item = Relaton::Evs::Bibliography.get "EVS-EN ISO 14001:2026"
21
+ #=> #<Relaton::Evs::ItemData …> with adoptedFrom relations to
22
+ # "ISO 14001:2026" and "EN ISO 14001:2026"
23
+ ----
24
+
25
+ == Development
26
+
27
+ Run the suite from the repository root: `bundle exec rake spec:evs`.
@@ -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
+ ## Project Overview
6
+
7
+ relaton-gb retrieves Chinese National Standards (Guobiao — `GB`, `GB/T`, `GB/Z`, plus social-org and sector standards) using the Relaton model. It scrapes several Chinese government portals (`openstd.samr.gov.cn`, `ttbz.org.cn`, `std.gov.cn`) with Mechanize. Depends on relaton-iso for ISO-based item/extension models, and on Chinese-specific classifiers (`cnccs`, `gb-agencies`).
8
+
9
+ ## Development
10
+
11
+ ```bash
12
+ bundle exec rake # default task → rspec
13
+ bundle exec rspec spec/relaton/gb/bibliography_spec.rb # single file
14
+ ```
15
+
16
+ (RuboCop is configured via the shared `.hound.yml`; the monorepo root config applies otherwise.)
17
+
18
+ ## Architecture
19
+
20
+ Namespace: `Relaton::Gb`. Note: GB places its model classes at the top of `lib/relaton/gb/` (`item.rb`, `bibitem.rb`, `bibdata.rb`, `ext.rb`, …) rather than under a `model/` subdir.
21
+
22
+ Retrieval flow:
23
+
24
+ 1. **Bibliography** (`lib/relaton/gb/bibliography.rb`) — `get`/`search` dispatch by prefix: `GB*` → `GbScraper` (openstd.samr.gov.cn), `T/` → `TScraper` (ttbz.org.cn), others → `SecScraper` (std.gov.cn).
25
+ 2. **Scrapers** (`scraper.rb` + `gb_scraper.rb` / `t_scraper.rb` / `sec_scraper.rb`) — scrape the relevant portal for hits, then scrape the detail page into an `ItemData`.
26
+ 3. **HitCollection / Hit** (`hit_collection.rb`, `hit.rb`, extend the `Relaton::Core` bases) — `Hit#item` lazy-loads the document; a hit holds `pid`, `docref`, `release_date`, `status`.
27
+ 4. **Item / ItemData / Ext** (`item.rb`, `item_data.rb`, `ext.rb`) — `Item` extends `Bib::Item`; `Ext` carries GB-specific fields (`gbtype`, `ccs`, `stagename`, `plannumber`). Supporting models: `gb_type.rb`, `ccs.rb` (Chinese classification), `stage_name.rb`, `committee.rb`, `structured_identifier.rb`.
28
+ 5. **Processor** (`lib/relaton/gb/processor.rb`) — registry integration; `@prefix = "CN"`, `@defaultprefix = %r{^(GB|GB/T|GB/Z) }`, `@pubid_flavor = :Gb`. Lazy-`require_relative`s `../gb` in its methods.
29
+
30
+ ## Identifiers (pubid)
31
+
32
+ GB parses identifiers with `Pubid::Gb::Identifier`. It publishes no index, so it has no `INDEXFILE` and no `pubid_class:` (the BSI/CEN shape, see the root `CLAUDE.md`). The attributes it reads are `publisher`, `mandate`, `number`, `part`, `year` and `all_parts`.
33
+
34
+ - **Query side raises.** `Bibliography.get` parses the caller's code without a rescue, so a reference that is not a GB identifier raises `Pubid::Errors::ParseError`. `get` also takes the `Pubid::Gb::Identifier` that `Relaton::Db` parsed (relaton#205) and never mutates it: an `AllParts` pubid (or a String `(all parts)`) sets `all_parts` and unwraps its identifier, and every change after that is on the `exclude(:year)` copy. The year comes from the argument or from `pubid.year`; `all_parts` sets `part = "1"` on the copy to find part 1. `search` still routes on the raw string (`GB|GJ|GS` → `GbScraper`, `T/…` → `TScraper`, else `SecScraper`), so the portal for each reference and the cassettes are unchanged.
35
+ - **Hit filter.** `search_filter` parses each `hit.docref` (rescued: a docref is data; an unparseable one drops the hit) and keeps the hits that `query.matches?`. It ignores `:year` only when the query has no year. It **never** ignores `:part`: `GB/T 20223` and `GB/T 20223.1` are two documents, and `gb_spec.rb` asserts that `GB/T 20223-2006` does not return `GB/T 20223.1-2006`. This is a deliberate departure from the migration hand-off, which proposed to ignore a part the query omits. It also stops `GB/T 1.1` from matching `GB/T 1.10`, which the old substring compare allowed.
36
+ - **`Docidentifier`** (`docidentifier.rb`) follows the CEN/OMG shape: `content=` parses into `@pubid` (rescued, data side), and the mutators replace the pubid with an `exclude` copy and write back through `store_content`. `to_all_parts!` excludes part and year, sets `all_parts`, and pubid renders the ` (all parts)` suffix itself (and parses it back). A nil pubid makes every mutator a no-op.
37
+ - **Scrapers** (`scraper.rb`) read the docref through a rescued `docref_pubid`. `get_prefix` keys `yaml/prefixes.yaml` with the first segment of `publisher` (a `Pubid::Components::Publisher`, so read it as `publisher.to_s`; `T/GZAEPI` → `T`). An unknown key gives nil, and `get_gbtype` keeps a nil prefix rather than raising — a portal docref is data. `get_mandate` maps `mandate` `T` → recommended, `Z` → guidelines, nil → mandatory. `get_contributors` passes `publisher.to_s` to `gb_agencies`, which names the same organization for `GB` and `GB/T`. `parse_docref` returns `[id without part and year, part, year]`; the first value is `plannumber`.
38
+ - **Pubid traps.** `Pubid::Gb` accepts any upper-case publisher code (`ISO 123` parses), so use `foo` or `GB/T` for a parse-error example. `year` is a reader over `date` (no `year=`); set it with `date = Pubid::Components::Date.new(year:)`. Local (`DB11/T`) forms do not parse. pubid PR #388 (`dcea7380`) renamed `publisher_code` to the `publisher` component, added the year to `to_hash`, and made `GBn` parse; a pubid older than that breaks this flavor. pubid renders an em dash (`T/GZAEPI 001—2018`) as a hyphen; the fixture `tgzaepi_001_2018.xml` still records the em dash, and its spec is pending until pubid decides.
39
+ - **Prefix register.** `@pubid_flavor = :Gb` sources `Relaton.prefix_flavor` from `Pubid::Gb.prefixes`, so `GB`, `GB/T`, `JB/T`, `T/` … resolve to `Relaton::Gb`. `CN` is not a pubid prefix, so `prefix_flavor("CN")` is `[]`; no caller in this repo used it, and `Db` routing still reads `@prefix`. `HB` resolves to `[Gb, Nist, Bsi]`, pinned in `spec/relaton/prefix_flavor_spec.rb`.
40
+
41
+ ## External dependencies
42
+
43
+ `cnccs ~> 0.1.1`, `gb-agencies ~> 0.0.1`, `mechanize ~> 2.10`, `csv ~> 3.0`, `relaton-core`, `relaton-iso`.
44
+
45
+ ## Testing
46
+
47
+ RSpec with WebMock + VCR (cassettes in `spec/vcr_cassettes/`). Network is blocked via WebMock. SimpleCov and equivalent-xml are loaded via `spec/support/`. No index fixture — GB is scraped, not indexed; `spec/fixtures/` holds XML/YAML round-trip data.