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
@@ -0,0 +1,149 @@
1
+ require_relative "../itu"
2
+
3
+ module Relaton
4
+ module Itu
5
+ # Incremental write path for harvested ITU-R records (issue #75).
6
+ #
7
+ # The ITU-R dataset cannot be rebuilt from scratch — DataCrawlerR reads the
8
+ # pages ITU still serves, and those expose **less** than the decommissioned
9
+ # RunSearch feed did. Two consequences drive this module:
10
+ #
11
+ # 1. **A published date must never be rewritten.** A Recommendation page
12
+ # carries only its approval date, while the preserved record holds the
13
+ # publication date RunSearch served — measured on the whole BO series,
14
+ # those differ for every record and the *year* differs for 59% of them.
15
+ # Overwriting would change which edition answers a dated reference. (A
16
+ # Report page does carry the publication date, so reports reproduce it;
17
+ # the rule costs nothing there.)
18
+ # 2. **A harvest is a partial view.** Only the families the crawler
19
+ # implements are seen, so a run must add and backfill, never delete, and
20
+ # must leave untouched records byte-identical so the data repo's diff
21
+ # shows only what actually changed.
22
+ #
23
+ # Merge rules, applied per record: `date` is kept as published, always;
24
+ # `title` and `source` are filled in only when the published record has
25
+ # none; everything else is left alone. A record whose published counterpart
26
+ # has a **different doctype** is a collision — an ITU-R report and
27
+ # recommendation in the same series can share a docidentifier, and therefore
28
+ # a filename — so it is reported and skipped rather than silently
29
+ # overwriting the other document.
30
+ module DataMergeR
31
+ extend self
32
+
33
+ #
34
+ # Merge harvested records into the dataset the fetcher writes to.
35
+ #
36
+ # @param items [Array<Relaton::Itu::ItemData>] as DataCrawlerR#harvest returns
37
+ # @param fetcher [Relaton::Itu::DataFetcher] supplies output_file/write_file
38
+ # and the index, so harvested records get the same pubid guard and
39
+ # unparseable-id reporting as the ITU-T harvest
40
+ #
41
+ # @return [Hash] { added:, backfilled:, unchanged:, skipped:,
42
+ # collisions: [[file, published_doctype, harvested_doctype], …] }
43
+ #
44
+ def write_all(items, fetcher)
45
+ stats = { added: 0, backfilled: 0, unchanged: 0, skipped: 0, collisions: [] }
46
+ seen = {}
47
+ items.each do |bib|
48
+ id = primary_id(bib)
49
+ unless id
50
+ stats[:skipped] += 1
51
+ Util.error "ITU-R merge: record with no primary docidentifier skipped"
52
+ next
53
+ end
54
+
55
+ file = fetcher.output_file id
56
+ # First writer of a filename keeps it; a later claimant is reported and
57
+ # dropped, never merged into a document it isn't.
58
+ if (other = seen[file])
59
+ record_collision stats, file, other, "#{id} (#{doctype bib})", "this harvest"
60
+ next
61
+ end
62
+ seen[file] = "#{id} (#{doctype bib})"
63
+ merge_one bib, id, file, fetcher, stats
64
+ rescue => e # rubocop:disable Style/RescueStandardError
65
+ # One unreadable published file must not discard an hour of crawling.
66
+ stats[:skipped] += 1
67
+ Util.error "ITU-R merge: #{id || '(no id)'} skipped: #{e.message}"
68
+ end
69
+ stats
70
+ end
71
+
72
+ private
73
+
74
+ # @return [void]
75
+ def merge_one(bib, id, file, fetcher, stats)
76
+ unless File.exist? file
77
+ fetcher.write_file bib
78
+ stats[:added] += 1
79
+ return
80
+ end
81
+
82
+ published = Item.from_yaml File.read(file, encoding: "UTF-8")
83
+ published_id = primary_id published
84
+ # Both halves matter: a different doctype means a report and a
85
+ # recommendation share a filename, and a different id means two docids
86
+ # sanitize to one filename (`output_file` collapses `.`, `/` and spaces
87
+ # alike, so `ITU-R BO.4/BL/4` and `ITU-R BO.4-BL-4` would meet here).
88
+ if doctype(published) != doctype(bib) || published_id != id
89
+ record_collision stats, file, "#{published_id} (#{doctype published})",
90
+ "#{id} (#{doctype bib})", "the published dataset"
91
+ return
92
+ end
93
+
94
+ if backfill published, bib
95
+ fetcher.write_file published
96
+ stats[:backfilled] += 1
97
+ else
98
+ # Not rewritten: an identical re-serialization would churn the data
99
+ # repo's diff. Index it anyway — the index is rebuilt from scratch on
100
+ # every run, so a record that is not written still has to be in it.
101
+ # Indexed by the id the *file* carries, as #index_files does.
102
+ fetcher.index_primary published_id, file
103
+ stats[:unchanged] += 1
104
+ end
105
+ end
106
+
107
+ # Fill only what the published record is missing. `date` is deliberately
108
+ # absent from this list — see the module comment.
109
+ #
110
+ # @param published [Relaton::Itu::Item] mutated in place
111
+ # @param bib [Relaton::Itu::ItemData] the harvested record
112
+ # @return [Boolean] whether anything was filled in
113
+ def backfill(published, bib)
114
+ filled = false
115
+ if published.source.nil? || published.source.empty?
116
+ published.source = bib.source
117
+ filled ||= !(bib.source.nil? || bib.source.empty?)
118
+ end
119
+ if published.title.nil? || published.title.empty?
120
+ published.title = bib.title
121
+ filled ||= !(bib.title.nil? || bib.title.empty?)
122
+ end
123
+ filled
124
+ end
125
+
126
+ # @return [void]
127
+ def record_collision(stats, file, kept, dropped, where)
128
+ stats[:skipped] += 1
129
+ stats[:collisions] << [file, kept, dropped]
130
+ Util.error "ITU-R merge: filename collision on #{file} — #{kept} in #{where} vs " \
131
+ "harvested #{dropped}; not overwritten"
132
+ end
133
+
134
+ # Strictly the primary docid: DataFetcher#write_file reads
135
+ # `docidentifier.find(&:primary).content` with no fallback, so a record
136
+ # without one has nowhere to be written and is skipped here instead.
137
+ #
138
+ # @return [String, nil]
139
+ def primary_id(bib)
140
+ bib.docidentifier.find(&:primary)&.content
141
+ end
142
+
143
+ # @return [String, nil]
144
+ def doctype(bib)
145
+ bib.ext&.doctype&.content
146
+ end
147
+ end
148
+ end
149
+ end
@@ -1,140 +1,214 @@
1
1
  module Relaton
2
2
  module Itu
3
+ # Map one normalized ITU-R row to an ItemData.
4
+ #
5
+ # The row comes from DataCrawlerR's three-level `/pub` + `/rec` crawl (issue
6
+ # #75), which replaced the decommissioned RunSearch result hash this module
7
+ # used to consume. The split mirrors DataParserT: the crawler owns the HTTP
8
+ # and the page scraping, this module owns the mapping, so it stays a pure
9
+ # unit with no network in its specs. Row keys:
10
+ #
11
+ # :id "R-REC-BO.1130-5-202602-I" page id
12
+ # :code "BO.1130-5 (02/2026)" displayed code -> primary docid
13
+ # :title English title
14
+ # :status "In force (Main)" | "Superseded" | … (scraped, not modelled)
15
+ # :date "2026-02-18" | "2026-02" approval date for a Recommendation,
16
+ # publication date for a Report
17
+ # :url the record's landing page
18
+ # :pdf absolute dms_pubrec/dms_pub URL
19
+ # :family "R-REC" | "R-REP" | "R-QUE" | "R-RES" | "R-HDB"
3
20
  module DataParserR
4
21
  extend self
5
22
 
6
- TYPE_MAP = {
7
- "ITU-R Recommendations" => "recommendation",
8
- "ITU-R Questions" => "question",
9
- "ITU-R Reports" => "technical-report",
10
- "Handbooks" => "handbook",
11
- "ITU-R Resolutions" => "resolution",
23
+ FAMILY_DOCTYPE = {
24
+ "R-REC" => "recommendation",
25
+ "R-REP" => "technical-report",
26
+ "R-QUE" => "question",
27
+ "R-RES" => "resolution",
28
+ "R-HDB" => "handbook",
12
29
  }.freeze
13
30
 
14
31
  #
15
- # Parse ITU-R document from search API result.
32
+ # Parse an ITU-R document from a normalized crawler row.
16
33
  #
17
- # @param result [Hash] single search result from the API
34
+ # @param row [Hash] see the key list above
35
+ # @param errors [Hash] shared error tally, `&&=`-narrowed per field so a
36
+ # field that succeeded once is never reported as missing
18
37
  #
19
- # @return [Relaton::Itu::ItemData] bibliographic item
38
+ # @return [Relaton::Itu::ItemData, nil] nil when the family is unknown
20
39
  #
21
- def parse(result, errors = {})
22
- @errors = errors
23
- doctype = fetch_doctype(result)
40
+ def parse(row, errors = {})
41
+ doctype = fetch_doctype(row, errors)
24
42
  return unless doctype
25
43
 
44
+ docid = fetch_docid(row, errors)
45
+ # A record with no primary docid can't be written or indexed —
46
+ # DataFetcher#write_file reads `docidentifier.find(&:primary).content` —
47
+ # so drop it here, as DataParserT does.
48
+ return if docid.empty?
49
+
26
50
  Relaton::Itu::ItemData.new(
27
- docidentifier: fetch_docid(result), title: fetch_title(result),
28
- date: fetch_date(result), language: ["en"],
29
- source: fetch_source(result), script: ["Latn"],
51
+ docidentifier: docid, title: fetch_title(row, errors),
52
+ date: fetch_date(row, errors), language: ["en"],
53
+ source: fetch_source(row, errors), script: ["Latn"],
30
54
  type: "standard", ext: Relaton::Itu::Ext.new(doctype: doctype, flavor: "itu"),
31
55
  )
32
56
  end
33
57
 
34
- # @param result [Hash]
58
+ # Each ITU-R family spells its identifier differently, and the published
59
+ # dataset is the authority on which spelling. Verified against
60
+ # relaton-data-itu:
61
+ #
62
+ # R-REC the displayed code "BO.1130-5 (02/2026)" -> ITU-R BO.1130-5
63
+ # R-REP the same, prefixed "BO.1227-2 (1998)" -> Report ITU-R BO.1227-2
64
+ # R-QUE the same, + a colon "202-2/1" -> ITU-R 202-2/1:
65
+ # R-RES the **page id** R-RES-R.1-9-2023 -> ITU-R R.1-9
66
+ # R-HDB the **page id** R-HDB-43-2013 -> ITU-R 43.HDB (2013)
67
+ #
68
+ # Three of those need saying out loud. A **Report** is prefixed because
69
+ # Recommendations and Reports number independently, so `ITU-R BT.2020-1`
70
+ # alone names two different documents (pubid #327 types the prefixed form
71
+ # `pubid:itu:report`, and `output_file` sends it to `report-itu-r-*.yaml`
72
+ # instead of colliding). A **Resolution** cannot use its displayed code at
73
+ # all: the page renders "Res.1-9 (2023)" while the published record is
74
+ # `ITU-R R.1-9`, which only the id carries. A **Handbook** likewise: the
75
+ # index lists its *title* and the leaf shows only a year — see
76
+ # #handbook_docid.
77
+ #
78
+ # The code is NBSP-folded first — the cells are padded with `&nbsp;`, which
79
+ # neither `\s` nor String#strip match, and an NBSP left in a docid
80
+ # sanitizes into the filename and defeats `Pubid::Itu`.
81
+ #
82
+ # @param row [Hash]
83
+ # @return [String, nil] nil when the row carries nothing to build from
84
+ def family_docid(row)
85
+ # " " spelled as an escape on purpose: a literal NBSP here is invisible
86
+ # and has been silently lost by tooling before.
87
+ #
88
+ # The date suffix is cut at the bare `(`, leaving the space before it to
89
+ # #strip. A leading `\s*` would make this polynomial-time (CodeQL
90
+ # rb/polynomial-redos): unanchored, it can start matching anywhere in a
91
+ # run of spaces, so a code that never reaches a `(` costs one pass per
92
+ # space. Same result, no ambiguity.
93
+ code = row[:code].to_s.tr(" ", " ").sub(/\(.*\z/m, "").strip
94
+ # The two id-derived families, before `code` gets a say.
95
+ return "ITU-R #{resolution_number row}" if row[:family] == "R-RES"
96
+ return handbook_docid(row) if row[:family] == "R-HDB"
97
+ return nil if code.empty?
98
+
99
+ case row[:family]
100
+ when "R-REP" then "Report ITU-R #{code}"
101
+ when "R-QUE" then "ITU-R #{code}:"
102
+ else "ITU-R #{code}"
103
+ end
104
+ end
105
+
106
+ # "R-HDB-43-2013" -> "ITU-R 43.HDB (2013)".
107
+ #
108
+ # The displayed code is unusable twice over: the index cell holds the
109
+ # handbook's *title*, and the leaf holds a year that can disagree with its
110
+ # own id (`R-HDB-43-2013` displays "2014"). The id is the only consistent
111
+ # source, so the year comes from there.
112
+ #
113
+ # Keyed **per edition** because ITU publishes several per handbook (43 has
114
+ # 2002, 2013 and 2026) and `Pubid::Itu` models the year, so they stay
115
+ # distinct records. The bare `ITU-R 43` that four published records carry
116
+ # parses as a *recommendation*, claiming a number that is not its own —
117
+ # this is what replaces it.
118
+ #
119
+ # @param row [Hash]
120
+ # @return [String, nil]
121
+ def handbook_docid(row)
122
+ m = row[:id].to_s.match(/\AR-HDB-(?<number>\d+)(?:-(?<year>(?:19|20)\d{2}))?/)
123
+ return nil unless m
124
+
125
+ m[:year] ? "ITU-R #{m[:number]}.HDB (#{m[:year]})" : "ITU-R #{m[:number]}.HDB"
126
+ end
127
+
128
+ # "R-RES-R.1-9-2023" -> "R.1-9"
129
+ #
130
+ # @param row [Hash]
131
+ # @return [String, nil]
132
+ def resolution_number(row)
133
+ n = row[:id].to_s.sub(/\AR-RES-/, "").sub(/-(?:19|20)\d{2}\z/, "")
134
+ n.empty? ? nil : n
135
+ end
136
+
137
+ # @param row [Hash]
35
138
  # @return [Array<Relaton::Bib::Docidentifier>]
36
- def fetch_docid(result)
37
- title = result["Title"].to_s
38
- id = title.match(/^(ITU-R\s+\S+)/)&.captures&.first
39
- return(@errors[:docid] &&= true; []) unless id
40
-
41
- id = id.sub(/\s*\(.*/, "")
42
- result_ids = [Docidentifier.new(type: "ITU", content: id, primary: true)]
43
- @errors[:docid] &&= result_ids.empty?
44
- result_ids
139
+ def fetch_docid(row, errors = {})
140
+ content = family_docid row
141
+ if content.nil? || content == "ITU-R "
142
+ errors[:docid] &&= true
143
+ return []
144
+ end
145
+
146
+ r = [Docidentifier.new(type: "ITU", content: content, primary: true)]
147
+ errors[:docid] &&= r.empty?
148
+ r
45
149
  end
46
150
 
47
- # @param result [Hash]
151
+ # @param row [Hash]
48
152
  # @return [Array<Relaton::Bib::Title>]
49
- def fetch_title(result)
50
- title = result["Title"].to_s
51
- content = title.sub(/^[^:]+:\s*/, "").strip
52
- content = title unless content.length > 0
153
+ def fetch_title(row, errors = {})
154
+ content = row[:title].to_s.strip
155
+ if content.empty?
156
+ errors[:title] &&= true
157
+ return []
158
+ end
159
+
53
160
  r = [Relaton::Bib::Title.new(type: "main", content: content, language: "en", script: "Latn")]
54
- @errors[:title] &&= r.empty?
161
+ errors[:title] &&= r.empty?
55
162
  r
56
163
  end
57
164
 
58
- # @param result [Hash]
165
+ # Whatever date the crawler could see: for a **Recommendation** that is the
166
+ # approval date — *not* the publication date the preserved records carry,
167
+ # which died with RunSearch (issue #75), which is why DataMergeR never
168
+ # rewrites one — and for a **Report** it is the publication date itself,
169
+ # read off the edition page's posted files. Day precision when it comes
170
+ # from the page, month or year precision when derived from the page id.
171
+ #
172
+ # @param row [Hash]
59
173
  # @return [Array<Relaton::Bib::Date>]
60
- def fetch_date(result)
61
- prop = property(result, "Publication date")
62
- unless prop
63
- @errors[:date] &&= true
64
- return []
65
- end
66
-
67
- date = parse_pub_date(prop)
68
- unless date
69
- @errors[:date] &&= true
174
+ def fetch_date(row, errors = {})
175
+ date = row[:date].to_s.strip
176
+ if date.empty?
177
+ errors[:date] &&= true
70
178
  return []
71
179
  end
72
180
 
73
181
  r = [Relaton::Bib::Date.new(type: "published", at: date)]
74
- @errors[:date] &&= r.empty?
182
+ errors[:date] &&= r.empty?
75
183
  r
76
184
  end
77
185
 
78
- # @param result [Hash]
186
+ # @param row [Hash]
79
187
  # @return [Array<Relaton::Bib::Uri>]
80
- def fetch_source(result)
81
- locations = result["Locations"]
82
- unless locations.is_a?(Array)
83
- @errors[:source] &&= true
188
+ def fetch_source(row, errors = {})
189
+ pdf = row[:pdf].to_s.strip
190
+ if pdf.empty?
191
+ errors[:source] &&= true
84
192
  return []
85
193
  end
86
194
 
87
- pdf = locations.find { |l| l["Type"] == "pdf" }
88
- unless pdf && pdf["RawHref"]
89
- @errors[:source] &&= true
90
- return []
91
- end
92
-
93
- r = [Relaton::Bib::Uri.new(type: "pdf", content: pdf["RawHref"])]
94
- @errors[:source] &&= r.empty?
195
+ r = [Relaton::Bib::Uri.new(type: "pdf", content: pdf)]
196
+ errors[:source] &&= r.empty?
95
197
  r
96
198
  end
97
199
 
98
- # @param result [Hash]
200
+ # @param row [Hash]
99
201
  # @return [Relaton::Itu::Doctype, nil]
100
- def fetch_doctype(result)
101
- type_value = property(result, "Type")
102
- mapped = TYPE_MAP[type_value]
202
+ def fetch_doctype(row, errors = {})
203
+ mapped = FAMILY_DOCTYPE[row[:family]]
103
204
  unless mapped
104
- @errors[:doctype] &&= true
205
+ errors[:doctype] &&= true
105
206
  return
106
207
  end
107
208
 
108
- @errors[:doctype] &&= false
209
+ errors[:doctype] &&= false
109
210
  Doctype.new(content: mapped)
110
211
  end
111
-
112
- private
113
-
114
- # Find a property value from the result's Properties array.
115
- # @param result [Hash]
116
- # @param name [String]
117
- # @return [String, nil]
118
- def property(result, name)
119
- props = result["Properties"]
120
- return unless props.is_a?(Array)
121
-
122
- entry = props.find { |p| p["Title"] == name }
123
- entry&.[]("Value")
124
- end
125
-
126
- # Parse publication date string like "January, 2024" or "2024".
127
- # @param value [String]
128
- # @return [String, nil]
129
- def parse_pub_date(value)
130
- case value
131
- when /(\w+),?\s+(\d{4})/
132
- month = Date::MONTHNAMES.index($1)
133
- month ? "#{$2}-#{format('%02d', month)}" : $2
134
- when /(\d{4})/
135
- $1
136
- end
137
- end
138
212
  end
139
213
  end
140
214
  end
@@ -0,0 +1,228 @@
1
+ require_relative "../itu"
2
+ require_relative "recommendation_parser"
3
+
4
+ module Relaton
5
+ module Itu
6
+ # Parse a single row of the ITU-T recommendation index returned by the
7
+ # mws/api/recommendations/searchRecs endpoint (see DataFetcher#search_recs)
8
+ # into an ItemData. Recommendations and supplements share one row schema and
9
+ # are distinguished by "Suppl" in rec_name; each edition is its own row, so
10
+ # the docid keeps the (MM/YYYY) edition date (e.g. "ITU-T A.1 (10/2000)") —
11
+ # which Pubid::Itu parses per edition, keeping index rows and filenames
12
+ # unique across editions. Mirrors DataParserR (the ITU-R row parser).
13
+ #
14
+ # The searchRecs row is metadata-thin (docid/title/date/source/doctype). When
15
+ # an `agent` is given, each record is **enriched** with the same
16
+ # `getRecHdrDetail`-sourced fields the live runtime path emits — abstract,
17
+ # ISO/IEC co-identifier, editorial-group contributors, status — via the shared
18
+ # `RecommendationFields` (through `RecommendationParser`), so the harvested
19
+ # record matches a live `Bibliography.get`. Enrichment is best-effort: a
20
+ # detail-fetch failure degrades to the thin record rather than losing it.
21
+ module DataParserT
22
+ extend self
23
+
24
+ # rec_name markers → Doctype. Ordered; first match wins. A plain edition
25
+ # (no marker) is a "recommendation". Values are members of Doctype::TYPES.
26
+ DOCTYPE_MARKERS = {
27
+ /\bSuppl\b/ => "recommendation-supplement",
28
+ /\bAmd\b/ => "recommendation-amendment",
29
+ /\bCor\b/ => "recommendation-corrigendum",
30
+ /\bAnnex\b/ => "recommendation-annex",
31
+ }.freeze
32
+
33
+ # The `errors` hash is threaded through the fetch_* helpers rather than
34
+ # held in an ivar: this module is `extend self`, so an ivar would be state
35
+ # shared by every caller — and DataFetcher#fetch_recommendations parses
36
+ # rows from a pool of threads.
37
+ #
38
+ # @param row [Hash] single row from searchRecs Data
39
+ # @param agent [Mechanize, nil] when present, enrich via getRecHdrDetail
40
+ # @param errors [Hash]
41
+ # @return [Relaton::Itu::ItemData, nil]
42
+ def parse(row, agent = nil, errors = {}, cache: NullCache.instance)
43
+ docid = fetch_docid(row, errors)
44
+ return if docid.empty?
45
+
46
+ enr = enrichment(row, agent, cache)
47
+ date = fetch_date(row, errors)
48
+ row_source = fetch_source(row, errors)
49
+ Relaton::Itu::ItemData.new(
50
+ docidentifier: docid + enr.fetch(:iso, []),
51
+ title: fetch_title(row, errors),
52
+ edition: enr[:edition],
53
+ abstract: enr.fetch(:abstract, []),
54
+ date: date, language: ["en"],
55
+ status: enr[:status],
56
+ relation: enr.fetch(:relation, []),
57
+ contributor: enr.fetch(:contributor, []),
58
+ copyright: fetch_copyright(date),
59
+ place: [Relaton::Bib::Place.new(city: "Geneva")],
60
+ source: enr[:source] || row_source, script: ["Latn"],
61
+ type: "standard",
62
+ ext: Relaton::Itu::Ext.new(doctype: fetch_doctype(row, errors), flavor: "itu"),
63
+ )
64
+ end
65
+
66
+ # Best-effort getRecHdrDetail enrichment for one record. Returns {} when
67
+ # there is no agent or the detail fetch fails, so the record still gets its
68
+ # thin fields. ITU-T records are always sector T → abbreviation "ITU",
69
+ # bureau "T".
70
+ #
71
+ # @param row [Hash]
72
+ # @param agent [Mechanize, nil]
73
+ # @param cache [#fetch, #warm] shared across the worker pool, so the
74
+ # family-invariant endpoints are fetched once per recommendation rather
75
+ # than once per edition. Like `errors`, it is threaded through as a
76
+ # parameter rather than held as state — DataParserT is `extend self`.
77
+ # The source is the handle URI and the PDF URI, as on the live path. It
78
+ # is nil when the header has no handle_id, so the row source is used.
79
+ # @return [Hash] { iso:, abstract:, status:, edition:, relation:,
80
+ # contributor:, source: }
81
+ def enrichment(row, agent, cache = NullCache.instance)
82
+ return {} unless agent && row["idrec"]
83
+
84
+ f = RecommendationParser.new(agent, row["idrec"], false, cache: cache)
85
+ ed = f.fetch_edition
86
+ {
87
+ iso: Array(f.iso_docid),
88
+ abstract: f.fetch_abstract,
89
+ status: f.fetch_status,
90
+ edition: (Relaton::Bib::Edition.new(content: ed) if ed),
91
+ relation: f.fetch_relations,
92
+ contributor: [f.publisher("ITU"), f.editorial_group("T")].compact,
93
+ source: (f.fetch_source if f.doc["handle_id"]),
94
+ }
95
+ rescue StandardError => e
96
+ Util.warn "ITU-T enrichment failed for idrec=#{row['idrec']}: #{e.message}"
97
+ {}
98
+ end
99
+
100
+ # The ITU copyright, from the record's own publication year — no detail
101
+ # fetch needed, so a thin (un-enriched) record carries it too. Mirrors
102
+ # `Scraper#fetch_copyright` on the live path.
103
+ #
104
+ # @param date [Array<Relaton::Bib::Date>] the record's dates
105
+ # @return [Array<Relaton::Bib::Copyright>]
106
+ def fetch_copyright(date)
107
+ year = date.first&.at.to_s[/\d{4}/]
108
+ return [] unless year
109
+
110
+ org = Relaton::Bib::Organization.new(
111
+ name: [Relaton::Bib::TypedLocalizedString.new(content: "International Telecommunication Union")],
112
+ abbreviation: Relaton::Bib::LocalizedString.new(content: "ITU"),
113
+ uri: [Relaton::Bib::Uri.new(content: "www.itu.int")],
114
+ )
115
+ owner = [Relaton::Bib::ContributionInfo.new(organization: org)]
116
+ [Relaton::Bib::Copyright.new(from: year, owner: owner)]
117
+ end
118
+
119
+ # The primary docid is the rec_name prefixed with "ITU-T", keeping the
120
+ # trailing " (MM/YYYY)" edition date so Pubid::Itu identifies each edition
121
+ # distinctly (matches the existing "ITU-T L.163 (11/2018)" convention).
122
+ #
123
+ # @param row [Hash]
124
+ # @return [Array<Relaton::Itu::Docidentifier>]
125
+ def fetch_docid(row, errors = {})
126
+ name = normalize_rec_name row["rec_name"]
127
+ if name.empty?
128
+ errors[:docid] &&= true
129
+ return []
130
+ end
131
+
132
+ r = [Docidentifier.new(type: "ITU", content: "ITU-T #{name}", primary: true)]
133
+ errors[:docid] &&= r.empty?
134
+ r
135
+ end
136
+
137
+ # One rule, for the one spelling `::Pubid::Itu` deliberately will not take:
138
+ # a space where the series dot belongs ("G 231 (10/1976)"). pubid rejects it
139
+ # on purpose, because a space there is ambiguous against the series-only
140
+ # supplement form ("G Suppl. 1"), so relaton canonicalises it rather than
141
+ # leaving 4 records unindexed. `#index_primary` drops ids pubid rejects, and
142
+ # a dropped record is written but never indexed — reachable only through the
143
+ # live `rec.aspx` fallback.
144
+ #
145
+ # This changes **no filename**: `Core::DataFetcher#output_file` collapses
146
+ # runs of whitespace and punctuation alike, so `"G 231 (10/1976)"` and
147
+ # `"G.231 (10/1976)"` both yield `itu-t-g-231-10-1976.yaml`. The affected
148
+ # records simply gain an index row.
149
+ #
150
+ # Everything else searchRecs spells oddly — the doubled space, `Suppl.1` run
151
+ # into its number, and bare `v10`/`V2`/`v.1` versions — **parses natively**
152
+ # as of pubid #325, so the rules that used to rewrite those are gone: pubid
153
+ # now canonicalises on `to_s` itself and ITU's own spelling is kept in the
154
+ # docid. Forms pubid cannot parse are still left alone; guessing a spelling
155
+ # would invent identifiers rather than canonicalise them.
156
+ #
157
+ # @param rec_name [String, nil]
158
+ # @return [String]
159
+ def normalize_rec_name(rec_name)
160
+ rec_name.to_s.strip.sub(/\A([A-Z]+) (\d)/, '\1.\2')
161
+ end
162
+
163
+ # @param row [Hash]
164
+ # @return [Array<Relaton::Bib::Title>]
165
+ def fetch_title(row, errors = {})
166
+ content = row["title"].to_s.strip
167
+ r = content.empty? ? [] : [Relaton::Bib::Title.new(type: "main", content: content, language: "en", script: "Latn")]
168
+ errors[:title] &&= r.empty?
169
+ r
170
+ end
171
+
172
+ # Prefer a full day-precision approval_date (YYYY-MM-DD, matching the live
173
+ # runtime path); fall back to the (MM/YYYY) edition date in rec_name; and
174
+ # only then to a coarser (year / year-month) approval_date. Ordering this way
175
+ # means a coarse or malformed approval_date never downgrades precision below
176
+ # the always-well-formed rec_name edition date.
177
+ #
178
+ # @param row [Hash]
179
+ # @return [Array<Relaton::Bib::Date>]
180
+ def fetch_date(row, errors = {})
181
+ approval = row["approval_date"].to_s
182
+ date = approval[/\d{4}-\d{2}-\d{2}/] || rec_name_date(row["rec_name"]) || approval[/\d{4}(-\d{2})?/]
183
+ if date.nil? || date.empty?
184
+ errors[:date] &&= true
185
+ return []
186
+ end
187
+
188
+ r = [Relaton::Bib::Date.new(type: "published", at: date)]
189
+ errors[:date] &&= r.empty?
190
+ r
191
+ end
192
+
193
+ # @param row [Hash]
194
+ # @return [Array<Relaton::Bib::Uri>]
195
+ def fetch_source(row, errors = {})
196
+ link = row["dms_link"].to_s.strip
197
+ if link.empty? || link == "-"
198
+ errors[:source] &&= true
199
+ return []
200
+ end
201
+
202
+ r = [Relaton::Bib::Uri.new(type: "src", content: link)]
203
+ errors[:source] &&= r.empty?
204
+ r
205
+ end
206
+
207
+ # @param row [Hash]
208
+ # @return [Relaton::Itu::Doctype]
209
+ def fetch_doctype(row, errors = {})
210
+ name = row["rec_name"].to_s
211
+ content = DOCTYPE_MARKERS.find { |re, _| name.match?(re) }&.last || "recommendation"
212
+ errors[:doctype] &&= false
213
+ Doctype.new(content: content)
214
+ end
215
+
216
+ private
217
+
218
+ # "A.1 (10/2000)" -> "2000-10"
219
+ # @param name [String, nil]
220
+ # @return [String, nil]
221
+ def rec_name_date(name)
222
+ return unless name =~ %r{\((\d{2})/(\d{4})\)}
223
+
224
+ "#{$2}-#{$1}"
225
+ end
226
+ end
227
+ end
228
+ end