relaton 3.0.0.pre.alpha.1 → 3.0.0.pre.alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +57 -1
  3. data/bin/console +0 -1
  4. data/lib/relaton/3gpp/bibliography.rb +82 -7
  5. data/lib/relaton/3gpp/data_fetcher.rb +51 -3
  6. data/lib/relaton/3gpp/docidentifier.rb +114 -0
  7. data/lib/relaton/3gpp/item.rb +6 -0
  8. data/lib/relaton/3gpp/parser.rb +1 -1
  9. data/lib/relaton/3gpp/processor.rb +4 -1
  10. data/lib/relaton/3gpp.rb +5 -1
  11. data/lib/relaton/adobe/bibdata.rb +8 -0
  12. data/lib/relaton/adobe/bibitem.rb +8 -0
  13. data/lib/relaton/adobe/bibliography.rb +92 -0
  14. data/lib/relaton/adobe/docidentifier.rb +49 -0
  15. data/lib/relaton/adobe/doctype.rb +14 -0
  16. data/lib/relaton/adobe/ext.rb +32 -0
  17. data/lib/relaton/adobe/item.rb +15 -0
  18. data/lib/relaton/adobe/item_base.rb +18 -0
  19. data/lib/relaton/adobe/item_data.rb +6 -0
  20. data/lib/relaton/adobe/processor.rb +45 -0
  21. data/lib/relaton/adobe/util.rb +8 -0
  22. data/lib/relaton/adobe.rb +37 -0
  23. data/lib/relaton/bib/model/address.rb +2 -2
  24. data/lib/relaton/bib/model/docidentifier.rb +24 -9
  25. data/lib/relaton/bib/model/localized_string.rb +1 -1
  26. data/lib/relaton/bib/model/structured_identifier.rb +10 -9
  27. data/lib/relaton/bib/sanitizer.rb +202 -6
  28. data/lib/relaton/bib.rb +0 -2
  29. data/lib/relaton/bipm/bibliography.rb +159 -10
  30. data/lib/relaton/bipm/data_fetcher.rb +26 -2
  31. data/lib/relaton/bipm/data_outcomes_parser.rb +3 -4
  32. data/lib/relaton/bipm/id_parser.rb +5 -4
  33. data/lib/relaton/bipm/model/structured_identifier.rb +21 -0
  34. data/lib/relaton/bipm/processor.rb +2 -2
  35. data/lib/relaton/bipm/rawdata_bipm_metrologia/fetcher.rb +2 -4
  36. data/lib/relaton/bipm/si_brochure_parser.rb +5 -3
  37. data/lib/relaton/bipm.rb +7 -1
  38. data/lib/relaton/bsi/bibliography.rb +115 -43
  39. data/lib/relaton/bsi/hit.rb +14 -0
  40. data/lib/relaton/bsi/hit_collection.rb +15 -16
  41. data/lib/relaton/bsi/model/docidentifier.rb +99 -1
  42. data/lib/relaton/bsi/processor.rb +1 -0
  43. data/lib/relaton/calconnect/bibliography.rb +12 -14
  44. data/lib/relaton/calconnect/data_fetcher.rb +77 -9
  45. data/lib/relaton/calconnect/docidentifier.rb +80 -0
  46. data/lib/relaton/calconnect/hit_collection.rb +65 -57
  47. data/lib/relaton/calconnect/model/item.rb +7 -0
  48. data/lib/relaton/calconnect/processor.rb +7 -1
  49. data/lib/relaton/calconnect.rb +11 -1
  50. data/lib/relaton/ccsds/data/fetcher.rb +17 -12
  51. data/lib/relaton/ccsds/data/parser.rb +1 -1
  52. data/lib/relaton/ccsds/hit_collection.rb +6 -1
  53. data/lib/relaton/ccsds/model/docidentifier.rb +121 -0
  54. data/lib/relaton/ccsds/model/item.rb +2 -0
  55. data/lib/relaton/cen/bibliography.rb +75 -47
  56. data/lib/relaton/cen/hit.rb +16 -1
  57. data/lib/relaton/cen/hit_collection.rb +59 -11
  58. data/lib/relaton/cen/model/docidentifier.rb +92 -1
  59. data/lib/relaton/cen/processor.rb +13 -8
  60. data/lib/relaton/cen/scraper.rb +13 -5
  61. data/lib/relaton/cen.rb +1 -0
  62. data/lib/relaton/cie/data_fetcher.rb +215 -30
  63. data/lib/relaton/cie/processor.rb +3 -1
  64. data/lib/relaton/cie/scrapper.rb +15 -2
  65. data/lib/relaton/cie.rb +2 -1
  66. data/lib/relaton/core/data_fetcher.rb +150 -3
  67. data/lib/relaton/core/governor.rb +320 -0
  68. data/lib/relaton/core/pacer.rb +134 -0
  69. data/lib/relaton/core/processor.rb +19 -0
  70. data/lib/relaton/core/request_error.rb +14 -0
  71. data/lib/relaton/core.rb +3 -0
  72. data/lib/relaton/db/registry.rb +41 -1
  73. data/lib/relaton/doi/crossref.rb +19 -2
  74. data/lib/relaton/doi/parser.rb +109 -15
  75. data/lib/relaton/easc/bibdata.rb +8 -0
  76. data/lib/relaton/easc/bibitem.rb +8 -0
  77. data/lib/relaton/easc/bibliography.rb +95 -0
  78. data/lib/relaton/easc/docidentifier.rb +100 -0
  79. data/lib/relaton/easc/doctype.rb +14 -0
  80. data/lib/relaton/easc/ext.rb +44 -0
  81. data/lib/relaton/easc/item.rb +13 -0
  82. data/lib/relaton/easc/item_base.rb +18 -0
  83. data/lib/relaton/easc/item_data.rb +6 -0
  84. data/lib/relaton/easc/processor.rb +46 -0
  85. data/lib/relaton/easc/util.rb +8 -0
  86. data/lib/relaton/easc.rb +35 -0
  87. data/lib/relaton/ecma/bibliography.rb +93 -25
  88. data/lib/relaton/ecma/data_fetcher.rb +71 -12
  89. data/lib/relaton/ecma/docidentifier.rb +124 -0
  90. data/lib/relaton/ecma/item.rb +2 -0
  91. data/lib/relaton/ecma/memento_parser.rb +1 -1
  92. data/lib/relaton/ecma/page_fetcher.rb +15 -3
  93. data/lib/relaton/ecma/parser_common.rb +2 -2
  94. data/lib/relaton/ecma/processor.rb +4 -1
  95. data/lib/relaton/ecma/standard_parser.rb +2 -2
  96. data/lib/relaton/ecma.rb +10 -1
  97. data/lib/relaton/etsi/bibliography.rb +67 -2
  98. data/lib/relaton/etsi/data_fetcher.rb +43 -4
  99. data/lib/relaton/etsi/processor.rb +3 -1
  100. data/lib/relaton/etsi.rb +2 -1
  101. data/lib/relaton/gb/bibliography.rb +55 -29
  102. data/lib/relaton/gb/docidentifier.rb +58 -9
  103. data/lib/relaton/gb/processor.rb +3 -0
  104. data/lib/relaton/gb/scraper.rb +27 -10
  105. data/lib/relaton/gost/bibdata.rb +8 -0
  106. data/lib/relaton/gost/bibitem.rb +8 -0
  107. data/lib/relaton/gost/bibliography.rb +107 -0
  108. data/lib/relaton/gost/docidentifier.rb +80 -0
  109. data/lib/relaton/gost/doctype.rb +16 -0
  110. data/lib/relaton/gost/ext.rb +46 -0
  111. data/lib/relaton/gost/item.rb +15 -0
  112. data/lib/relaton/gost/item_base.rb +18 -0
  113. data/lib/relaton/gost/item_data.rb +6 -0
  114. data/lib/relaton/gost/processor.rb +49 -0
  115. data/lib/relaton/gost/util.rb +8 -0
  116. data/lib/relaton/gost.rb +36 -0
  117. data/lib/relaton/iala/bibdata.rb +8 -0
  118. data/lib/relaton/iala/bibitem.rb +8 -0
  119. data/lib/relaton/iala/bibliography.rb +146 -0
  120. data/lib/relaton/iala/docidentifier.rb +89 -0
  121. data/lib/relaton/iala/doctype.rb +18 -0
  122. data/lib/relaton/iala/ext.rb +32 -0
  123. data/lib/relaton/iala/item.rb +21 -0
  124. data/lib/relaton/iala/item_base.rb +18 -0
  125. data/lib/relaton/iala/item_data.rb +6 -0
  126. data/lib/relaton/iala/processor.rb +43 -0
  127. data/lib/relaton/iala/relation.rb +7 -0
  128. data/lib/relaton/iala/util.rb +8 -0
  129. data/lib/relaton/iala.rb +35 -0
  130. data/lib/relaton/iana/bibliography.rb +67 -14
  131. data/lib/relaton/iana/data_fetcher.rb +35 -5
  132. data/lib/relaton/iana/processor.rb +3 -1
  133. data/lib/relaton/iana.rb +12 -1
  134. data/lib/relaton/iec/data_fetcher.rb +7 -1
  135. data/lib/relaton/iec/hit_collection.rb +1 -1
  136. data/lib/relaton/iec/model/docidentifier.rb +9 -5
  137. data/lib/relaton/iec/model/ext.rb +2 -2
  138. data/lib/relaton/iec/processor.rb +1 -0
  139. data/lib/relaton/ieee/bibliography.rb +25 -3
  140. data/lib/relaton/ieee/data_fetcher.rb +158 -17
  141. data/lib/relaton/ieee/idams_parser.rb +18 -11
  142. data/lib/relaton/ieee/processor.rb +4 -1
  143. data/lib/relaton/ieee/rawbib_id_parser.rb +291 -86
  144. data/lib/relaton/ieee.rb +2 -1
  145. data/lib/relaton/ietf/data_fetcher.rb +295 -12
  146. data/lib/relaton/ietf/processor.rb +7 -3
  147. data/lib/relaton/ietf/rfc/entry.rb +39 -3
  148. data/lib/relaton/ietf/scraper.rb +69 -36
  149. data/lib/relaton/ietf.rb +4 -1
  150. data/lib/relaton/iho/bibliography.rb +1 -1
  151. data/lib/relaton/iho/docidentifier.rb +1 -1
  152. data/lib/relaton/index/file_io.rb +11 -11
  153. data/lib/relaton/index/file_storage.rb +6 -1
  154. data/lib/relaton/index/pool.rb +6 -1
  155. data/lib/relaton/index/shard_source.rb +201 -0
  156. data/lib/relaton/index/type.rb +63 -12
  157. data/lib/relaton/index.rb +2 -1
  158. data/lib/relaton/iso/bibliography.rb +20 -15
  159. data/lib/relaton/iso/data_fetcher.rb +3 -3
  160. data/lib/relaton/iso/data_parser.rb +17 -3
  161. data/lib/relaton/iso/hit_collection.rb +27 -15
  162. data/lib/relaton/iso/item_data.rb +22 -0
  163. data/lib/relaton/iso/model/docidentifier.rb +24 -12
  164. data/lib/relaton/iso/processor.rb +1 -0
  165. data/lib/relaton/iso/scraper.rb +19 -3
  166. data/lib/relaton/itu/bibliography.rb +9 -4
  167. data/lib/relaton/itu/data_crawler_r.rb +664 -0
  168. data/lib/relaton/itu/data_fetcher.rb +496 -50
  169. data/lib/relaton/itu/data_merge_r.rb +149 -0
  170. data/lib/relaton/itu/data_parser_r.rb +163 -89
  171. data/lib/relaton/itu/data_parser_t.rb +228 -0
  172. data/lib/relaton/itu/family_cache.rb +177 -0
  173. data/lib/relaton/itu/governor.rb +56 -0
  174. data/lib/relaton/itu/hit.rb +9 -3
  175. data/lib/relaton/itu/hit_collection.rb +258 -86
  176. data/lib/relaton/itu/model/docidentifier.rb +67 -1
  177. data/lib/relaton/itu/model/structured_identifier.rb +19 -0
  178. data/lib/relaton/itu/processor.rb +10 -4
  179. data/lib/relaton/itu/pubid.rb +27 -5
  180. data/lib/relaton/itu/recommendation_fields.rb +334 -0
  181. data/lib/relaton/itu/recommendation_parser.rb +18 -149
  182. data/lib/relaton/itu/scraper.rb +13 -3
  183. data/lib/relaton/itu.rb +2 -1
  184. data/lib/relaton/jcgm/bibdata.rb +8 -0
  185. data/lib/relaton/jcgm/bibitem.rb +8 -0
  186. data/lib/relaton/jcgm/bibliography.rb +97 -0
  187. data/lib/relaton/jcgm/data_fetcher.rb +81 -0
  188. data/lib/relaton/jcgm/docidentifier.rb +102 -0
  189. data/lib/relaton/jcgm/doctype.rb +12 -0
  190. data/lib/relaton/jcgm/ext.rb +23 -0
  191. data/lib/relaton/jcgm/item.rb +20 -0
  192. data/lib/relaton/jcgm/item_base.rb +18 -0
  193. data/lib/relaton/jcgm/item_data.rb +6 -0
  194. data/lib/relaton/jcgm/meetings_parser.rb +175 -0
  195. data/lib/relaton/jcgm/processor.rb +71 -0
  196. data/lib/relaton/jcgm/relation.rb +9 -0
  197. data/lib/relaton/jcgm/structured_identifier.rb +40 -0
  198. data/lib/relaton/jcgm/util.rb +8 -0
  199. data/lib/relaton/jcgm.rb +24 -0
  200. data/lib/relaton/jis/bibliography.rb +8 -10
  201. data/lib/relaton/jis/data_fetcher.rb +21 -19
  202. data/lib/relaton/jis/docidentifier.rb +104 -5
  203. data/lib/relaton/jis/hit.rb +18 -23
  204. data/lib/relaton/jis/hit_collection.rb +19 -18
  205. data/lib/relaton/jis/processor.rb +1 -1
  206. data/lib/relaton/jis.rb +2 -3
  207. data/lib/relaton/logger/channels/gh_issue.rb +78 -13
  208. data/lib/relaton/nist/data_fetcher.rb +63 -13
  209. data/lib/relaton/nist/docidentifier.rb +165 -0
  210. data/lib/relaton/nist/item.rb +2 -0
  211. data/lib/relaton/nist/item_base.rb +16 -0
  212. data/lib/relaton/nist/mods_parser.rb +38 -12
  213. data/lib/relaton/nist/processor.rb +2 -1
  214. data/lib/relaton/nist/relation.rb +3 -0
  215. data/lib/relaton/nist/scraper.rb +6 -3
  216. data/lib/relaton/oasis/bibliography.rb +147 -6
  217. data/lib/relaton/oasis/data_fetcher.rb +41 -5
  218. data/lib/relaton/oasis/data_parser_utils.rb +37 -3
  219. data/lib/relaton/oasis/docidentifier.rb +54 -0
  220. data/lib/relaton/oasis/item.rb +3 -0
  221. data/lib/relaton/oasis/processor.rb +7 -1
  222. data/lib/relaton/oasis.rb +14 -1
  223. data/lib/relaton/ogc/data_fetcher.rb +23 -2
  224. data/lib/relaton/ogc/docidentifier.rb +105 -0
  225. data/lib/relaton/ogc/hit_collection.rb +78 -3
  226. data/lib/relaton/ogc/processor.rb +2 -1
  227. data/lib/relaton/ogc.rb +5 -1
  228. data/lib/relaton/oiml/bibliography.rb +90 -15
  229. data/lib/relaton/oiml/docidentifier.rb +18 -3
  230. data/lib/relaton/omg/docidentifier.rb +67 -0
  231. data/lib/relaton/omg/item.rb +1 -0
  232. data/lib/relaton/omg/processor.rb +1 -0
  233. data/lib/relaton/omg/scraper.rb +61 -16
  234. data/lib/relaton/omg.rb +1 -0
  235. data/lib/relaton/plateau/bibliography.rb +10 -3
  236. data/lib/relaton/plateau/data_fetcher.rb +25 -2
  237. data/lib/relaton/plateau/handbook_parser.rb +8 -1
  238. data/lib/relaton/plateau/hit.rb +10 -2
  239. data/lib/relaton/plateau/hit_collection.rb +31 -11
  240. data/lib/relaton/plateau/processor.rb +3 -1
  241. data/lib/relaton/plateau/technical_report_parser.rb +8 -1
  242. data/lib/relaton/plateau.rb +2 -1
  243. data/lib/relaton/sdo/config.rb +34 -0
  244. data/lib/relaton/sdo/fetcher.rb +52 -0
  245. data/lib/relaton/sdo/logo.rb +95 -0
  246. data/lib/relaton/sdo/name.rb +26 -0
  247. data/lib/relaton/sdo/organization.rb +71 -0
  248. data/lib/relaton/sdo/store.rb +49 -0
  249. data/lib/relaton/sdo.rb +29 -0
  250. data/lib/relaton/version.rb +1 -1
  251. data/lib/relaton/w3c/bibliography.rb +132 -12
  252. data/lib/relaton/w3c/data_fetcher.rb +194 -16
  253. data/lib/relaton/w3c/data_parser.rb +3 -3
  254. data/lib/relaton/w3c/docidentifier.rb +48 -0
  255. data/lib/relaton/w3c/governor.rb +32 -0
  256. data/lib/relaton/w3c/item.rb +3 -0
  257. data/lib/relaton/w3c/pubid.rb +12 -0
  258. data/lib/relaton/w3c/safe_realize.rb +110 -21
  259. data/lib/relaton/w3c.rb +12 -1
  260. data/lib/relaton/xsf/bibliography.rb +61 -1
  261. data/lib/relaton/xsf/data_fetcher.rb +55 -5
  262. data/lib/relaton/xsf/docidentifier.rb +46 -0
  263. data/lib/relaton/xsf/hit_collection.rb +31 -3
  264. data/lib/relaton/xsf/item.rb +6 -0
  265. data/lib/relaton/xsf/processor.rb +1 -0
  266. data/lib/relaton/xsf.rb +5 -1
  267. data/lib/relaton.rb +42 -0
  268. metadata +135 -24
  269. data/lib/relaton/ieee/pub_id.rb +0 -161
  270. data/lib/relaton/index/id_number.rb +0 -30
@@ -4,38 +4,76 @@ require "mechanize"
4
4
 
5
5
  module Relaton
6
6
  module Ecma
7
- # IETF bibliography module
7
+ # ECMA bibliography module
8
8
  module Bibliography
9
9
  ENDPOINT = "https://raw.githubusercontent.com/relaton/relaton-data-ecma/refs/heads/v2/"
10
10
 
11
11
  class << self
12
+ # @return [Relaton::Index::Type]
13
+ def index
14
+ Relaton::Index.find_or_create(
15
+ :ECMA, url: "#{ENDPOINT}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
16
+ pubid_class: ::Pubid::Ecma::Identifier
17
+ )
18
+ end
19
+
20
+ #
21
+ # Search the index for a reference.
12
22
  #
13
- # Search for a reference on the IETF website.
23
+ # **The pubid is passed to `Index::Type#search`, not the string.**
24
+ # `search_candidates` narrows only when the argument is not a `String`,
25
+ # and a block alone never narrows — so the plain string this used to
26
+ # pass disabled the binary search however the index was built.
27
+ # `pubid_class:` on the index alone fixes nothing; both had to change
28
+ # together.
14
29
  #
15
- # @param ref [String] the ECMA standard reference to look up (e..g "ECMA-6")
30
+ # A reference pubid cannot parse finds nothing. There is deliberately no
31
+ # substring-scan fallback here (the OGC idiom): an ECMA id renders as
32
+ # `ECMA-262 ed17`, so a substring scan would answer a truncated
33
+ # reference like `ECMA-26` with every ECMA-26x document — an ambiguous
34
+ # answer is worse than none. By the same trade-off, a reference must now
35
+ # parse WHOLE: the old regex was unanchored at the end, so
36
+ # `ECMA-6 (draft)` silently resolved to ECMA-6 and now does not. See
37
+ # lib/relaton/ecma/CLAUDE.md for the measured table.
16
38
  #
17
- # @return [Array<Hash>]
39
+ # The rows are selected with pubid's subset match `pubid === row`, the
40
+ # default of `Index::Type#search`. An `edition` or a `volume` that the
41
+ # reference omits matches any value, so a bare `ECMA-269` reaches the
42
+ # edition rows. pubid declares `part` strict for ECMA, so a bare
43
+ # `ECMA-418` does not match `ECMA-418-1`. The class must be identical,
44
+ # which keeps `ECMA-100` and `ECMA TR/100` apart.
45
+ #
46
+ # @param ref [String] the ECMA reference (e.g. "ECMA-6", "ECMA-269 ed3 vol2")
47
+ #
48
+ # @return [Array<Hash>] matching index rows
18
49
  #
19
50
  def search(ref)
20
- refparts = parse_ref ref
21
- return [] unless refparts
51
+ pubid = parse_ref ref
52
+ return [] unless pubid
22
53
 
23
- index = Relaton::Index.find_or_create :ECMA, url: "#{ENDPOINT}#{INDEXFILE}.zip", id_keys: %i[id ed vol]
24
- index.search { |row| match_ref refparts, row }
54
+ index.search(pubid)
25
55
  end
26
56
 
57
+ #
58
+ # Parse a user reference into a `Pubid::Ecma::Identifier`, or nil.
59
+ #
60
+ # `Pubid::Ecma` takes the space form (`ECMA 6`) as well as the hyphen
61
+ # one, and parses the ` ed<N>` / ` vol<N>` suffixes the old bespoke
62
+ # regex accepted, so every reference shape the flavor has to handle
63
+ # parses without normalization here.
64
+ #
65
+ # @param ref [String]
66
+ # @return [Pubid::Ecma::Identifier, nil]
67
+ #
68
+ # An unrecognized reference **raises**; like ISO, ETSI and 3GPP we let it
69
+ # propagate. relaton-cli rescues `Pubid::Errors::Error` and renders
70
+ # `"..." is not a recognized standards identifier`
71
+ # (`gems/relaton-cli/lib/relaton/cli/command.rb:324`), and `Db#fetch`
72
+ # logs it through the `StandardError` arm at `lib/relaton/db.rb:122`.
73
+ # Rescuing here would collapse "this identifier is malformed" into "no
74
+ # such document", leaving a caller unable to tell them apart.
27
75
  def parse_ref(ref)
28
- %r{^ECMA[-\s]
29
- (?<id>(?:\d[\d-]*|\w+/\d+))
30
- (?:\sed(?<ed>[\d.]+))?
31
- (?:\svol(?<vol>\d+))?
32
- }x.match ref
33
- end
34
-
35
- def match_ref(refparts, row) # rubocop:disable Metrics/AbcSize
36
- row[:id][:id].match?(/^ECMA[-\s]#{refparts[:id]}/) &&
37
- (refparts[:ed].nil? || row[:id][:ed] == refparts[:ed]) &&
38
- (refparts[:vol].nil? || row[:id][:vol] == refparts[:vol])
76
+ ::Pubid::Ecma::Identifier.parse ref.to_s.strip
39
77
  end
40
78
 
41
79
  # @param code [String] the ECMA standard Code to look up (e..g "ECMA-6")
@@ -47,20 +85,50 @@ module Relaton
47
85
  result = fetch_doc(code)
48
86
  if result
49
87
  Util.info "Found: `#{result.docidentifier.first.content}`", key: code
50
- # item
51
88
  else
52
89
  Util.info "Not found.", key: code
53
90
  end
54
91
  result
55
92
  end
56
93
 
57
- def compare_edition_volume(aaa, bbb)
58
- comp = bbb[:id][:ed] <=> aaa[:id][:ed]
59
- comp.zero? ? aaa[:id][:vol] <=> bbb[:id][:vol] : comp
94
+ private
95
+
96
+ #
97
+ # The index row for a reference: latest edition, then lowest volume.
98
+ #
99
+ # That is the selection order the bespoke `compare_edition_volume` +
100
+ # `min` implemented, kept deliberately — but the edition is now compared
101
+ # segment-wise as INTEGERS. Comparing the rendered strings made "9" beat
102
+ # "17" and "5" beat "5.1", so 5 of the 421 document families returned an
103
+ # older document than the reference asked for: ECMA-262 answered ed9
104
+ # (2018-06) where ed17 (2026-06) exists, ECMA-74 ed9 (2005-12) where
105
+ # ed22 (2025-12) exists, and likewise 402, 328 and 109.
106
+ #
107
+ # `r[:file]` breaks the tie, because the index sort is not stable.
108
+ #
109
+ # @param ref [String]
110
+ # @return [Hash, nil]
111
+ #
112
+ def best_match(ref)
113
+ search(ref).max_by { |r| [edition_key(r[:id].edition), -r[:id].volume.to_i, r[:file]] }
114
+ end
115
+
116
+ #
117
+ # Order key for an ECMA edition: its dot-separated segments as integers.
118
+ #
119
+ # `[]` for an absent edition, which therefore sorts below every present
120
+ # one — right for the 64 edition-less rows (mementos, a few reports),
121
+ # none of which shares a document with an edition-bearing row.
122
+ #
123
+ # @param edition [String, nil]
124
+ # @return [Array<Integer>]
125
+ #
126
+ def edition_key(edition)
127
+ edition.to_s.split(".").map(&:to_i)
60
128
  end
61
129
 
62
- def fetch_doc(code) # rubocop:disable Metrics/AbcSize
63
- row = search(code).min { |a, b| compare_edition_volume a, b }
130
+ def fetch_doc(code)
131
+ row = best_match code
64
132
  return unless row
65
133
 
66
134
  url = "#{ENDPOINT}#{row[:file]}"
@@ -18,7 +18,12 @@ module Relaton
18
18
  SOURCES = %w[standards technical-reports mementos].freeze
19
19
 
20
20
  def index
21
- @index ||= Relaton::Index.find_or_create :ecma, file: "#{INDEXFILE}.yaml"
21
+ # `pubid_class:` on the producer too: FileIO#save only calls `to_hash`
22
+ # for instances of it, so without it the crawl writes v1-shaped rows
23
+ # under a v2 name, silently.
24
+ @index ||= Relaton::Index.find_or_create(
25
+ :ecma, file: "#{INDEXFILE}.yaml", pubid_class: ::Pubid::Ecma::Identifier
26
+ )
22
27
  end
23
28
 
24
29
  def log_error(msg)
@@ -31,30 +36,84 @@ module Relaton
31
36
 
32
37
  # @param bib [Relaton::Ecma::ItemData]
33
38
  def write_file(bib)
34
- file = filename bib
39
+ # Two distinct ids can sanitize to one filename, in which case the
40
+ # second document used to be dropped outright. Give it a path of its
41
+ # own; a genuine duplicate (same id) still resolves to one path and is
42
+ # still skipped.
43
+ file = unique_output_file filename_id(bib)
44
+ # A reserved path only ever belongs to one id, so a hit here is the same
45
+ # document again. Checked FIRST: a disambiguated path stays != filename
46
+ # forever, so gating this on that comparison would make a repeat of a
47
+ # disambiguated id skip the skip and overwrite its own file.
35
48
  if @files.include? file
36
49
  Util.warn "Duplicate file #{file}"
37
- else
38
- @files << file
39
- File.write file, serialize(bib), encoding: "UTF-8"
40
- index.add_or_update index_id(bib), file
50
+ return
41
51
  end
52
+
53
+ Util.warn "Duplicate file #{filename bib}; writing #{file} instead" if file != filename(bib)
54
+ @files << file
55
+ File.write file, serialize(bib), encoding: "UTF-8"
56
+ add_to_index bib, file
57
+ end
58
+
59
+ #
60
+ # Index the document, or record why it could not be indexed.
61
+ #
62
+ # An id pubid cannot rebuild is recorded in `@errors` — the inherited
63
+ # `report_errors` logs a String value as the message, and its GhIssue
64
+ # channel opens a GitHub issue at the end of the crawl — and the row is
65
+ # skipped rather than indexed unparsed: `Relaton::Index` rejects the WHOLE
66
+ # index if a single row fails to deserialize, and its sort calls
67
+ # `.root.number` on every id. The data file is already written by the
68
+ # caller, so the document is unindexed, never lost. (The 3GPP/W3C shape.)
69
+ #
70
+ # @param bib [Relaton::Ecma::ItemData]
71
+ # @param file [String] path the document was written to
72
+ #
73
+ def add_to_index(bib, file)
74
+ id = index_id bib
75
+ return index.add_or_update(id, file) if id
76
+
77
+ docid = bib.docidentifier[0]&.content || file
78
+ @errors[docid.to_s] = "Unparseable primary id `#{docid}` was not indexed (#{file})"
42
79
  end
43
80
 
44
81
  def filename(bib)
82
+ output_file filename_id(bib)
83
+ end
84
+
85
+ # The docid the filename is derived from.
86
+ def filename_id(bib)
45
87
  id = bib.docidentifier[0].content
46
88
  id += " #{bib.edition.content}" if bib.edition
47
89
  locality = locality_with_volume bib
48
90
  id += " #{locality.reference_from}" if locality
49
- output_file id
91
+ id
50
92
  end
51
93
 
94
+ #
95
+ # The index key: a `Pubid::Ecma::Identifier` carrying the number, the
96
+ # edition and the volume.
97
+ #
98
+ # Built from the MODEL, never from a rendered string — the same three
99
+ # fields `#filename_id` reads — so the crawl cannot lose a component to a
100
+ # parse or to a render default. The base identifier is the docidentifier's
101
+ # own pubid, **duplicated** first: `edition` and `volume` are index
102
+ # metadata, and every `ECMA-269` volume file carries the bare
103
+ # `docidentifier: ECMA-269`, so setting them on the shared object would
104
+ # promote the document's own printed id to the index form.
105
+ #
106
+ # @param bib [Relaton::Ecma::ItemData]
107
+ # @return [Pubid::Ecma::Identifier, nil] nil if pubid rejects the docid
108
+ #
52
109
  def index_id(bib)
53
- { id: bib.docidentifier[0].content }.tap do |i|
54
- i[:ed] = bib.edition.content if bib.edition
55
- locality = locality_with_volume bib
56
- i[:vol] = locality.reference_from if locality
57
- end
110
+ pubid = bib.docidentifier[0]&.pubid&.dup
111
+ return unless pubid
112
+
113
+ pubid.edition = bib.edition.content if bib.edition
114
+ locality = locality_with_volume bib
115
+ pubid.volume = locality.reference_from if locality
116
+ pubid
58
117
  end
59
118
 
60
119
  def locality_with_volume(bib)
@@ -0,0 +1,124 @@
1
+ module Relaton
2
+ module Ecma
3
+ # Structured ECMA document identifier. The docid string is the BARE printed
4
+ # form ("ECMA-269", "ECMA TR/101", "ECMA MEM/2021"), and it is parsed into a
5
+ # `Pubid::Ecma::Identifier` kept in `@pubid` while the lutaml `content`
6
+ # attribute stays a plain string for serialization.
7
+ #
8
+ # Parsing is **soft**: `content=` lazily requires pubid and rescues
9
+ # `LoadError`/`StandardError`, so a missing pubid gem or non-ECMA content
10
+ # leaves `@pubid` nil rather than raising.
11
+ #
12
+ # ## Why this class exists, and the one rule that is ECMA-specific
13
+ #
14
+ # `Pubid::Ecma::Identifier#to_s` renders the edition and the volume **by
15
+ # default** — `"ECMA-269 ed3 vol2"`. That default is deliberate on the pubid
16
+ # side: `Relaton::Index::Type#add_or_update` keys on a bare `id.to_s`, and
17
+ # 740 of the 804 published rows carry an edition, so without it 383 rows
18
+ # collapse onto another row's key and vanish from the index.
19
+ #
20
+ # A **document's own** docidentifier is the opposite. Every `ECMA-269`
21
+ # volume file in `relaton-data-ecma` carries `docidentifier: ECMA-269` and
22
+ # the same title — the edition and the volume are index metadata, not part
23
+ # of the printed id. So `refresh_content!` must opt OUT of both, or a
24
+ # mutation would silently promote the stored content to the index form.
25
+ #
26
+ # This is the mirror image of `Relaton::ThreeGpp::Docidentifier`, whose
27
+ # `refresh_content!` must pass `with_publisher: true` because ITS pubid
28
+ # defaults to the index rendering and its stored content is the fuller one.
29
+ # The two look contradictory only until you notice each one re-renders what
30
+ # its own stored `content` already holds.
31
+ class Docidentifier < Bib::Docidentifier
32
+ attr_reader :pubid
33
+
34
+ # Capture the inherited (LocalizedMarkedUpString) content setter before
35
+ # overriding #content=, so #refresh_content! can write the re-rendered
36
+ # string back WITHOUT re-parsing (a re-parse would rebuild @pubid from the
37
+ # string and discard in-place mutations).
38
+ alias_method :store_content, :content=
39
+
40
+ def initialize(attrs = {}, options = {})
41
+ pubid = attrs.is_a?(Hash) ? attrs.delete(:pubid) : nil
42
+ attrs[:content] ||= render(pubid) if pubid
43
+ super
44
+ @pubid = pubid if pubid
45
+ end
46
+
47
+ def content=(value)
48
+ super
49
+ return unless value
50
+
51
+ @pubid = begin
52
+ require "pubid"
53
+ ::Pubid::Ecma::Identifier.parse(value.to_s)
54
+ rescue LoadError, StandardError
55
+ nil
56
+ end
57
+ end
58
+
59
+ # `Pubid::Ecma::Identifier` models number/part/subpart/edition/volume, so
60
+ # the mapping is:
61
+ #
62
+ # - **`remove_date!` → clears `edition`.** ECMA has no date component;
63
+ # `edition` is its version discriminator, so clearing it yields the
64
+ # version-agnostic ("most recent") reference. It is invisible in the
65
+ # rendered bare form, which never carried the edition to begin with —
66
+ # but the identifier really does change, and a consumer reading
67
+ # `#pubid` sees it.
68
+ # - **`remove_part!` → clears `part`/`subpart`.** Real here:
69
+ # "ECMA-418-1" -> "ECMA-418".
70
+ # - **`to_all_parts!` → both, then wraps `@pubid` in pubid's `AllParts`.**
71
+ # `content` stays the plain stripped id — ECMA's own renderer has no
72
+ # "(all parts)" marker, and `content` is cached from the pre-wrap
73
+ # pubid on purpose. `#pubid` itself, read directly, now answers
74
+ # `all_parts? == true` and renders WITH pubid's generic marker
75
+ # (`#to_s`), since it's the wrapper.
76
+ #
77
+ # All three no-op safely when `@pubid` is nil, so `Bib::ItemData`'s
78
+ # `#to_all_parts` / `#to_most_recent_reference` never raise on ECMA items.
79
+
80
+ def remove_part!
81
+ clear_attrs! :part
82
+ end
83
+
84
+ def remove_date!
85
+ clear_attrs! :edition
86
+ end
87
+
88
+ def to_all_parts!
89
+ return if !@pubid || @pubid.all_parts?
90
+
91
+ # `#exclude` (no args) rebuilds a full independent copy — `remove_part!`
92
+ # / `remove_date!` mutate `@pubid` in place, so a bare reference here
93
+ # would lose the original part/edition to that mutation too.
94
+ original = @pubid.exclude
95
+ remove_part!
96
+ remove_date!
97
+ @pubid = original.to_all_parts
98
+ end
99
+
100
+ private
101
+
102
+ def clear_attrs!(*attrs)
103
+ return unless @pubid
104
+
105
+ cleared = attrs.select { |attr| @pubid.respond_to?("#{attr}=") }
106
+ return if cleared.empty?
107
+
108
+ cleared.each { |attr| @pubid.public_send("#{attr}=", nil) }
109
+ @pubid.subpart = nil if attrs.include?(:part) && @pubid.respond_to?(:subpart=)
110
+ refresh_content!
111
+ end
112
+
113
+ def refresh_content!
114
+ store_content(render(@pubid)) if @pubid
115
+ end
116
+
117
+ # The bare document form. See the class comment for why both flags are
118
+ # opted out of, and why 3GPP's twin does the opposite.
119
+ def render(pubid)
120
+ pubid.to_s(with_edition: false, with_volume: false)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,3 +1,4 @@
1
+ require_relative "docidentifier"
1
2
  require_relative "ext"
2
3
 
3
4
  module Relaton
@@ -5,6 +6,7 @@ module Relaton
5
6
  class Item < Bib::Item
6
7
  model ItemData
7
8
 
9
+ attribute :docidentifier, Docidentifier, collection: true, initialize_empty: true
8
10
  attribute :ext, Ext
9
11
  end
10
12
  end
@@ -21,7 +21,7 @@ module Relaton
21
21
 
22
22
  private
23
23
 
24
- # @return [Array<Relaton::Bib::Docidentifier>]
24
+ # @return [Array<Relaton::Ecma::Docidentifier>]
25
25
  def fetch_docidentifier
26
26
  code = "ECMA MEM/#{@hit.at('div[1]//p').text}"
27
27
  docid = super(code)
@@ -1,6 +1,8 @@
1
1
  module Relaton
2
2
  module Ecma
3
3
  class PageFetcher
4
+ RETRIES = 3
5
+
4
6
  def initialize
5
7
  @agent = Mechanize.new
6
8
  @agent.user_agent_alias = Mechanize::AGENT_ALIASES.keys[rand(21)]
@@ -9,18 +11,28 @@ module Relaton
9
11
  #
10
12
  # Get page with retries
11
13
  #
14
+ # A failed fetch must raise, not fall out of the loop. `RETRIES.times`
15
+ # returns its receiver, so the exhausted loop used to hand the caller the
16
+ # Integer 3 as if it were a page, and `StandardParser#fetch_title`
17
+ # reported the transport error as `undefined method 'xpath' for an
18
+ # instance of Integer`. `DataFetcher#html_index` already rescues per hit,
19
+ # so one unreachable page is skipped with a readable message.
20
+ #
12
21
  # @param [String] url url to fetch
13
22
  #
14
23
  # @return [Mechanize::Page] document
24
+ # @raise [Relaton::RequestError] after RETRIES attempts
15
25
  #
16
26
  def get(url)
17
- 3.times do |n|
27
+ error = nil
28
+ RETRIES.times do |n|
18
29
  sleep n
19
- doc = @agent.get url
20
- return doc
30
+ return @agent.get url
21
31
  rescue StandardError => e
32
+ error = e
22
33
  Util.error e.message
23
34
  end
35
+ raise Relaton::RequestError, "Could not access #{url}: #{error.message}"
24
36
  end
25
37
  end
26
38
  end
@@ -14,11 +14,11 @@ module Relaton
14
14
  [Bib::Contributor.new(organization: org, role: [role])]
15
15
  end
16
16
 
17
- # @return [Array<Relaton::Bib::Docidentifier>]
17
+ # @return [Array<Relaton::Ecma::Docidentifier>]
18
18
  def fetch_docidentifier(id = nil)
19
19
  return [] if id.nil? || id.empty?
20
20
 
21
- [Bib::Docidentifier.new(type: "ECMA", content: id, primary: true)]
21
+ [Docidentifier.new(type: "ECMA", content: id, primary: true)]
22
22
  end
23
23
 
24
24
  def fetch_ext
@@ -9,6 +9,7 @@ module Relaton
9
9
  @defaultprefix = /^ECMA(-|\s)/
10
10
  @idtype = "ECMA"
11
11
  @datasets = %w[ecma-standards]
12
+ @pubid_flavor = :Ecma
12
13
  end
13
14
 
14
15
  # @param code [String]
@@ -59,7 +60,9 @@ module Relaton
59
60
  #
60
61
  def remove_index_file
61
62
  require_relative "../ecma"
62
- Relaton::Index.find_or_create(:ECMA, url: true, file: "#{INDEXFILE}.yaml").remove_file
63
+ Relaton::Index.find_or_create(
64
+ :ECMA, url: true, file: "#{INDEXFILE}.yaml"
65
+ ).remove_file
63
66
  end
64
67
  end
65
68
  end
@@ -26,7 +26,7 @@ module Relaton
26
26
  @translation_source ||= parse_translation_source
27
27
  end
28
28
 
29
- # @return [Array<Relaton::Bib::Docidentifier>]
29
+ # @return [Array<Relaton::Ecma::Docidentifier>]
30
30
  def fetch_docidentifier
31
31
  result = super(@hit.text)
32
32
  @errors[:standard_docidentifier] &&= result.empty?
@@ -83,7 +83,7 @@ module Relaton
83
83
  ref, ed, date, vol = edition_parser.edition_id_parts rel.at("span").text
84
84
  next if ed.nil? || ed.empty?
85
85
 
86
- docid = Bib::Docidentifier.new(type: "ECMA", content: ref, primary: true)
86
+ docid = Docidentifier.new(type: "ECMA", content: ref, primary: true)
87
87
  source = rel.xpath("span/a").map { |l| Bib::Uri.new type: "pdf", content: l[:href] }
88
88
  edition = Bib::Edition.new content: ed
89
89
  extent = edition_parser.create_extent(vol)
data/lib/relaton/ecma.rb CHANGED
@@ -1,3 +1,8 @@
1
+ # Not lazy: DataFetcher names ::Pubid::Ecma::Identifier as the index
2
+ # `pubid_class:`, and Docidentifier parses every docid through it.
3
+ # (The IANA/IHO/IALA/OGC form; spec/relaton/lazy_loading_spec.rb guards that
4
+ # this file is not itself loaded when a Db is built.)
5
+ require "pubid"
1
6
  require "relaton/index"
2
7
  require "relaton/bib"
3
8
  require_relative "version"
@@ -10,7 +15,11 @@ require_relative "ecma/bibliography"
10
15
 
11
16
  module Relaton
12
17
  module Ecma
13
- INDEXFILE = "index-v1"
18
+ # The one index this flavor builds and reads: pubid-keyed rows
19
+ # (`_type: pubid:ecma:*`), via `pubid_class: ::Pubid::Ecma::Identifier`.
20
+ # `relaton-data-ecma`'s crawler derives the legacy `index-v1` from these
21
+ # rows for released consumers, so it is not produced or read here.
22
+ INDEXFILE = "index-v2".freeze
14
23
 
15
24
  # Returns hash of XML reammar
16
25
  # @return [String]
@@ -9,8 +9,15 @@ module Relaton
9
9
  # @param text [String]
10
10
  # @return [Relaton::Etsi::ItemData, nil]
11
11
  def search(text) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
12
- index = Relaton::Index.find_or_create :etsi, url: "#{SOURCE}index-v1.zip", file: INDEX_FILE
13
- row = index.search(text).min_by { |r| r[:id] }
12
+ # An unrecognized reference raises Pubid::Errors::ParseError; like
13
+ # ISO we let it propagate — the CLI turns it into a friendly message
14
+ # and API callers rescue it themselves. Valid partial refs parse with
15
+ # the omitted refinements (version/date/part) left blank.
16
+ pubid = ::Pubid::Etsi.parse text
17
+
18
+ index = Relaton::Index.find_or_create :etsi, url: "#{SOURCE}#{INDEXFILE}.zip", file: "#{INDEXFILE}.yaml",
19
+ pubid_class: ::Pubid::Etsi::Identifier
20
+ row = best_match(index, pubid)
14
21
  return unless row
15
22
 
16
23
  url = "#{SOURCE}#{row[:file]}"
@@ -24,6 +31,64 @@ module Relaton
24
31
  raise Relaton::RequestError, e.message
25
32
  end
26
33
 
34
+ # Match the reference against the index and return the most recent edition.
35
+ #
36
+ # The rows are selected with pubid's subset match `pubid === row`. A
37
+ # version or a date that the reference omits matches any value, so a
38
+ # bare `ETSI GS ZSM 012` matches every edition, while a fully-qualified
39
+ # ref matches only that edition. The class must be identical, so a base
40
+ # reference such as `ETSI ETR 310` does not reach its amendment or
41
+ # corrigendum (`ETR 310/C1`).
42
+ # The pubid — not a String — is passed to `index.search` so the index
43
+ # narrows candidates by number via binary search; each row's `:id` is
44
+ # already a Pubid::Etsi identifier (deserialized via `pubid_class`).
45
+ # `max_by` on `edition_key` picks the latest edition among the matches.
46
+ #
47
+ # pubid declares `parts` strict for ETSI, so a part-less reference asks
48
+ # for every part by matching each row **without its parts** (see
49
+ # #comparable). `#to_all_parts` cannot serve here: a
50
+ # `Pubid::AllPartsIdentifier` compares the document alone, so it also
51
+ # drops the version and the date, and
52
+ # `ETSI GR ZSM 011 V1.1.1 (2023-02)` would answer with V2.1.1.
53
+ #
54
+ # @param index [Relaton::Index::Type]
55
+ # @param pubid [::Pubid::Etsi::Identifier]
56
+ # @return [Hash, nil] the winning index row (`{ id:, file: }`)
57
+ def best_match(index, pubid)
58
+ all_parts = pubid.code&.parts.to_a.empty?
59
+ index.search(pubid) { |row| pubid === comparable(row[:id], all_parts) }
60
+ .max_by { |row| edition_key(row[:id]) }
61
+ end
62
+
63
+ # The row as the reference sees it: without its parts when the reference
64
+ # names none, unchanged otherwise. `#exclude` returns a copy, so the
65
+ # cached index id is untouched.
66
+ #
67
+ # @param id [::Pubid::Etsi::Identifier]
68
+ # @param all_parts [Boolean] the reference names no part
69
+ # @return [::Pubid::Etsi::Identifier]
70
+ def comparable(id, all_parts)
71
+ all_parts ? id.exclude(:part, :subpart, :parts) : id
72
+ end
73
+
74
+ # Sort key for one edition: the version numbers, then the publication date.
75
+ #
76
+ # ETSI versions are not zero-padded, so a comparison of the rendered id
77
+ # orders `V9.0.0` above `V19.0.0` and `ed.9` above `ed.11`, and a bare
78
+ # reference then resolves to an old edition. `Pubid::Etsi::Identifier` is
79
+ # not `Comparable` and its `<=>` returns nil, so the key comes from the
80
+ # parsed components: `version.version` holds the bare numbers (`"19.0.0"`,
81
+ # or `"9"` for the `ed.9` form) and `date` renders as `yyyy-mm`. Both
82
+ # delegate to `base` on a corrigendum/amendment id, so every row shape
83
+ # keys the same way. A missing version or date gives `[]` / `""`, which
84
+ # sort below any real value — hence the `.to_s` outside each `&.` chain.
85
+ #
86
+ # @param id [::Pubid::Etsi::Identifier]
87
+ # @return [Array(Array<Integer>, String)]
88
+ def edition_key(id)
89
+ [(id.version&.version).to_s.split(".").map(&:to_i), id.date.to_s]
90
+ end
91
+
27
92
  # @param ref [String] the ETSI standard Code to look up
28
93
  # @param year [String, nil] year
29
94
  # @param opts [Hash] options