pubid 2.0.0.pre.alpha.2 → 2.0.0.pre.alpha.3

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 (458) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +5 -1
  3. data/data/nist/update_codes.yaml +25 -0
  4. data/lib/pubid/amca/builder.rb +2 -2
  5. data/lib/pubid/amca/identifier.rb +7 -39
  6. data/lib/pubid/amca/identifiers/base.rb +0 -26
  7. data/lib/pubid/amca/identifiers/interpretation.rb +0 -17
  8. data/lib/pubid/amca/identifiers/publication.rb +0 -13
  9. data/lib/pubid/amca/renderer.rb +82 -0
  10. data/lib/pubid/amca/single_identifier.rb +0 -23
  11. data/lib/pubid/amca/urn_parser.rb +28 -0
  12. data/lib/pubid/amca.rb +42 -1
  13. data/lib/pubid/ansi/builder.rb +5 -3
  14. data/lib/pubid/ansi/identifier.rb +1 -43
  15. data/lib/pubid/ansi/identifiers/american_national_standard.rb +2 -1
  16. data/lib/pubid/ansi/identifiers/standard.rb +2 -3
  17. data/lib/pubid/ansi/renderer.rb +53 -0
  18. data/lib/pubid/ansi/single_identifier.rb +2 -31
  19. data/lib/pubid/ansi/urn_generator.rb +3 -38
  20. data/lib/pubid/ansi/urn_parser.rb +23 -0
  21. data/lib/pubid/ansi.rb +38 -3
  22. data/lib/pubid/api/builder.rb +29 -74
  23. data/lib/pubid/api/identifier.rb +0 -51
  24. data/lib/pubid/api/identifiers/base.rb +0 -2
  25. data/lib/pubid/api/identifiers/bulletin.rb +0 -2
  26. data/lib/pubid/api/identifiers/continuous_operations_standard.rb +0 -2
  27. data/lib/pubid/api/identifiers/mpms.rb +1 -17
  28. data/lib/pubid/api/identifiers/publication.rb +0 -2
  29. data/lib/pubid/api/identifiers/recommended_practice.rb +0 -2
  30. data/lib/pubid/api/identifiers/specification.rb +0 -2
  31. data/lib/pubid/api/identifiers/standard.rb +0 -2
  32. data/lib/pubid/api/identifiers/technical_report.rb +0 -2
  33. data/lib/pubid/api/identifiers/typeless_standard.rb +1 -14
  34. data/lib/pubid/api/identifiers.rb +18 -0
  35. data/lib/pubid/api/renderer.rb +89 -0
  36. data/lib/pubid/api/single_identifier.rb +1 -13
  37. data/lib/pubid/api/urn_generator.rb +0 -18
  38. data/lib/pubid/api/urn_parser.rb +35 -0
  39. data/lib/pubid/api.rb +51 -5
  40. data/lib/pubid/ashrae/builder.rb +3 -3
  41. data/lib/pubid/ashrae/identifier.rb +6 -39
  42. data/lib/pubid/ashrae/identifiers/addenda_package.rb +0 -10
  43. data/lib/pubid/ashrae/identifiers/addendum.rb +0 -19
  44. data/lib/pubid/ashrae/identifiers/base.rb +3 -0
  45. data/lib/pubid/ashrae/identifiers/combined_addenda.rb +0 -15
  46. data/lib/pubid/ashrae/identifiers/errata.rb +0 -10
  47. data/lib/pubid/ashrae/identifiers/interpretation.rb +0 -10
  48. data/lib/pubid/ashrae/renderer.rb +117 -0
  49. data/lib/pubid/ashrae/single_identifier.rb +0 -13
  50. data/lib/pubid/ashrae/urn_generator.rb +0 -8
  51. data/lib/pubid/ashrae/urn_parser.rb +27 -0
  52. data/lib/pubid/ashrae.rb +42 -1
  53. data/lib/pubid/asme/components/code.rb +10 -2
  54. data/lib/pubid/asme/identifier.rb +0 -46
  55. data/lib/pubid/asme/identifiers/base.rb +0 -60
  56. data/lib/pubid/asme/renderer.rb +66 -0
  57. data/lib/pubid/asme/urn_parser.rb +31 -0
  58. data/lib/pubid/asme.rb +42 -1
  59. data/lib/pubid/astm/components/code.rb +9 -0
  60. data/lib/pubid/{jis → astm}/components.rb +1 -1
  61. data/lib/pubid/astm/identifier.rb +0 -77
  62. data/lib/pubid/astm/identifiers/adjunct.rb +0 -8
  63. data/lib/pubid/astm/identifiers/data_series.rb +0 -14
  64. data/lib/pubid/astm/identifiers/iso_dual_published.rb +9 -34
  65. data/lib/pubid/astm/identifiers/manual.rb +0 -27
  66. data/lib/pubid/astm/identifiers/monograph.rb +0 -14
  67. data/lib/pubid/astm/identifiers/research_report.rb +0 -7
  68. data/lib/pubid/astm/identifiers/standard.rb +0 -39
  69. data/lib/pubid/astm/identifiers/technical_report.rb +0 -13
  70. data/lib/pubid/astm/identifiers/work_in_progress.rb +0 -11
  71. data/lib/pubid/astm/identifiers.rb +18 -0
  72. data/lib/pubid/astm/renderer.rb +172 -0
  73. data/lib/pubid/astm/single_identifier.rb +0 -10
  74. data/lib/pubid/astm/urn_parser.rb +30 -0
  75. data/lib/pubid/astm.rb +39 -27
  76. data/lib/pubid/bsi/builder.rb +21 -12
  77. data/lib/pubid/bsi/identifier.rb +8 -62
  78. data/lib/pubid/bsi/identifiers/addendum_document.rb +3 -33
  79. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +11 -47
  80. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +11 -38
  81. data/lib/pubid/bsi/identifiers/aerospace_standard.rb +3 -53
  82. data/lib/pubid/bsi/identifiers/amendment.rb +3 -19
  83. data/lib/pubid/bsi/identifiers/british_industrial_practice.rb +2 -4
  84. data/lib/pubid/bsi/identifiers/british_standard.rb +2 -1
  85. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +3 -84
  86. data/lib/pubid/bsi/identifiers/committee_document.rb +1 -14
  87. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +3 -84
  88. data/lib/pubid/bsi/identifiers/corrigendum.rb +3 -7
  89. data/lib/pubid/bsi/identifiers/detailed_specification.rb +1 -34
  90. data/lib/pubid/bsi/identifiers/disc.rb +1 -27
  91. data/lib/pubid/bsi/identifiers/draft_document.rb +3 -44
  92. data/lib/pubid/bsi/identifiers/electronic_book.rb +3 -36
  93. data/lib/pubid/bsi/identifiers/expert_commentary.rb +3 -15
  94. data/lib/pubid/bsi/identifiers/explanatory_supplement.rb +1 -45
  95. data/lib/pubid/bsi/identifiers/flex.rb +1 -33
  96. data/lib/pubid/bsi/identifiers/handbook.rb +2 -13
  97. data/lib/pubid/bsi/identifiers/index.rb +1 -30
  98. data/lib/pubid/bsi/identifiers/method.rb +1 -39
  99. data/lib/pubid/bsi/identifiers/national_annex.rb +5 -27
  100. data/lib/pubid/bsi/identifiers/practice_guide.rb +2 -4
  101. data/lib/pubid/bsi/identifiers/publicly_available_specification.rb +3 -52
  102. data/lib/pubid/bsi/identifiers/published_document.rb +3 -52
  103. data/lib/pubid/bsi/identifiers/section.rb +1 -28
  104. data/lib/pubid/bsi/identifiers/set.rb +3 -17
  105. data/lib/pubid/bsi/identifiers/standalone_amendment.rb +1 -7
  106. data/lib/pubid/bsi/identifiers/supplement_document.rb +3 -21
  107. data/lib/pubid/bsi/identifiers/supplementary_index.rb +1 -44
  108. data/lib/pubid/bsi/identifiers/technical_specification.rb +3 -45
  109. data/lib/pubid/bsi/identifiers/test_method.rb +1 -30
  110. data/lib/pubid/bsi/identifiers/value_added_publication.rb +3 -14
  111. data/lib/pubid/bsi/identifiers.rb +0 -1
  112. data/lib/pubid/bsi/renderer.rb +1050 -0
  113. data/lib/pubid/bsi/single_identifier.rb +6 -70
  114. data/lib/pubid/bsi/urn_generator.rb +2 -3
  115. data/lib/pubid/bsi/urn_parser.rb +52 -0
  116. data/lib/pubid/bsi.rb +224 -1
  117. data/lib/pubid/builder/base.rb +57 -10
  118. data/lib/pubid/bundled_identifier.rb +0 -1
  119. data/lib/pubid/ccsds/builder.rb +4 -3
  120. data/lib/pubid/ccsds/identifier.rb +63 -66
  121. data/lib/pubid/ccsds/identifiers/base.rb +11 -61
  122. data/lib/pubid/ccsds/identifiers/corrigendum.rb +7 -6
  123. data/lib/pubid/ccsds/parser.rb +4 -2
  124. data/lib/pubid/ccsds/supplement_identifier.rb +15 -11
  125. data/lib/pubid/ccsds/urn_generator.rb +3 -3
  126. data/lib/pubid/ccsds/urn_parser.rb +20 -0
  127. data/lib/pubid/ccsds.rb +39 -1
  128. data/lib/pubid/cen_cenelec/builder.rb +12 -14
  129. data/lib/pubid/cen_cenelec/identifier.rb +7 -38
  130. data/lib/pubid/cen_cenelec/identifiers/adopted_european_norm.rb +13 -4
  131. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +2 -8
  132. data/lib/pubid/cen_cenelec/identifiers/base.rb +5 -41
  133. data/lib/pubid/cen_cenelec/identifiers/cen_report.rb +2 -1
  134. data/lib/pubid/cen_cenelec/identifiers/cen_workshop_agreement.rb +2 -1
  135. data/lib/pubid/cen_cenelec/identifiers/consolidated_identifier.rb +2 -25
  136. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +2 -13
  137. data/lib/pubid/cen_cenelec/identifiers/european_norm.rb +2 -1
  138. data/lib/pubid/cen_cenelec/identifiers/european_prestandard.rb +4 -7
  139. data/lib/pubid/cen_cenelec/identifiers/european_specification.rb +2 -1
  140. data/lib/pubid/cen_cenelec/identifiers/fragment.rb +2 -2
  141. data/lib/pubid/cen_cenelec/identifiers/harmonization_document.rb +2 -1
  142. data/lib/pubid/cen_cenelec/identifiers/technical_report.rb +2 -1
  143. data/lib/pubid/cen_cenelec/identifiers/technical_specification.rb +2 -1
  144. data/lib/pubid/cen_cenelec/renderer.rb +261 -0
  145. data/lib/pubid/cen_cenelec/single_identifier.rb +11 -89
  146. data/lib/pubid/cen_cenelec/urn_generator.rb +6 -6
  147. data/lib/pubid/cen_cenelec/urn_parser.rb +28 -0
  148. data/lib/pubid/cen_cenelec.rb +168 -1
  149. data/lib/pubid/cie/components/code.rb +8 -0
  150. data/lib/pubid/cie/identifier.rb +6 -57
  151. data/lib/pubid/cie/urn_parser.rb +28 -0
  152. data/lib/pubid/cie.rb +43 -1
  153. data/lib/pubid/components/adoption.rb +104 -0
  154. data/lib/pubid/components/code.rb +22 -8
  155. data/lib/pubid/components/date.rb +23 -16
  156. data/lib/pubid/components/edition.rb +9 -6
  157. data/lib/pubid/components/iteration.rb +32 -0
  158. data/lib/pubid/components/language.rb +6 -4
  159. data/lib/pubid/components/locality.rb +10 -1
  160. data/lib/pubid/components/publisher.rb +9 -6
  161. data/lib/pubid/components/relationship.rb +151 -0
  162. data/lib/pubid/components/stage.rb +5 -14
  163. data/lib/pubid/components/supplement.rb +184 -0
  164. data/lib/pubid/components/type.rb +5 -15
  165. data/lib/pubid/components/typed_stage.rb +10 -11
  166. data/lib/pubid/components.rb +4 -1
  167. data/lib/pubid/core/update_codes.rb +28 -7
  168. data/lib/pubid/csa/identifier.rb +0 -59
  169. data/lib/pubid/csa/identifiers/base.rb +2 -122
  170. data/lib/pubid/csa/identifiers/cec.rb +2 -101
  171. data/lib/pubid/csa/identifiers/series.rb +2 -102
  172. data/lib/pubid/csa/renderer.rb +292 -0
  173. data/lib/pubid/csa/urn_generator.rb +1 -1
  174. data/lib/pubid/csa/urn_parser.rb +33 -0
  175. data/lib/pubid/csa.rb +42 -1
  176. data/lib/pubid/etsi/components/code.rb +9 -2
  177. data/lib/pubid/etsi/identifier.rb +0 -43
  178. data/lib/pubid/etsi/identifiers/base.rb +1 -4
  179. data/lib/pubid/etsi/identifiers/supplement_identifier.rb +2 -9
  180. data/lib/pubid/etsi/renderer.rb +42 -0
  181. data/lib/pubid/etsi/urn_parser.rb +34 -0
  182. data/lib/pubid/etsi.rb +42 -1
  183. data/lib/pubid/export/exporter.rb +4 -46
  184. data/lib/pubid/export/flavor_exporter.rb +111 -278
  185. data/lib/pubid/export.rb +0 -6
  186. data/lib/pubid/identifier.rb +2 -17
  187. data/lib/pubid/identifier_facade.rb +114 -0
  188. data/lib/pubid/identifier_metadata.rb +1 -1
  189. data/lib/pubid/idf/builder.rb +3 -3
  190. data/lib/pubid/idf/identifier.rb +3 -66
  191. data/lib/pubid/idf/identifiers/amendment.rb +2 -1
  192. data/lib/pubid/idf/identifiers/corrigendum.rb +2 -1
  193. data/lib/pubid/idf/identifiers/international_standard.rb +2 -1
  194. data/lib/pubid/idf/identifiers/reviewed_method.rb +2 -1
  195. data/lib/pubid/idf/parser.rb +3 -2
  196. data/lib/pubid/idf/renderer.rb +84 -0
  197. data/lib/pubid/idf/supplement_identifier.rb +2 -10
  198. data/lib/pubid/idf/urn_generator.rb +4 -39
  199. data/lib/pubid/idf/urn_parser.rb +25 -0
  200. data/lib/pubid/idf.rb +51 -1
  201. data/lib/pubid/iec/builder.rb +46 -64
  202. data/lib/pubid/iec/components/code.rb +8 -32
  203. data/lib/pubid/iec/components/publisher.rb +0 -1
  204. data/lib/pubid/iec/components.rb +14 -0
  205. data/lib/pubid/iec/identifier.rb +251 -213
  206. data/lib/pubid/iec/identifiers/amendment.rb +2 -3
  207. data/lib/pubid/iec/identifiers/base.rb +8 -32
  208. data/lib/pubid/iec/identifiers/component_specification.rb +3 -3
  209. data/lib/pubid/iec/identifiers/conformity_assessment.rb +1 -2
  210. data/lib/pubid/iec/identifiers/consolidated_identifier.rb +27 -26
  211. data/lib/pubid/iec/identifiers/corrigendum.rb +2 -3
  212. data/lib/pubid/iec/identifiers/fragment_identifier.rb +37 -22
  213. data/lib/pubid/iec/identifiers/guide.rb +0 -2
  214. data/lib/pubid/iec/identifiers/international_standard.rb +2 -3
  215. data/lib/pubid/iec/identifiers/interpretation_sheet.rb +2 -3
  216. data/lib/pubid/iec/identifiers/operational_document.rb +3 -3
  217. data/lib/pubid/iec/identifiers/publicly_available_specification.rb +2 -3
  218. data/lib/pubid/iec/identifiers/sheet_identifier.rb +21 -11
  219. data/lib/pubid/iec/identifiers/societal_technology_trend_report.rb +3 -3
  220. data/lib/pubid/iec/identifiers/systems_reference_document.rb +2 -3
  221. data/lib/pubid/iec/identifiers/technical_report.rb +2 -3
  222. data/lib/pubid/iec/identifiers/technical_specification.rb +2 -3
  223. data/lib/pubid/iec/identifiers/technology_report.rb +1 -2
  224. data/lib/pubid/iec/identifiers/test_report_form.rb +5 -34
  225. data/lib/pubid/iec/identifiers/vap_identifier.rb +26 -19
  226. data/lib/pubid/iec/identifiers/white_paper.rb +3 -3
  227. data/lib/pubid/iec/identifiers/working_document.rb +4 -48
  228. data/lib/pubid/iec/identifiers.rb +30 -0
  229. data/lib/pubid/iec/parser.rb +13 -12
  230. data/lib/pubid/iec/renderer.rb +254 -0
  231. data/lib/pubid/iec/single_identifier.rb +6 -12
  232. data/lib/pubid/iec/supplement_identifier.rb +58 -54
  233. data/lib/pubid/iec/urn_generator.rb +3 -3
  234. data/lib/pubid/iec/urn_parser.rb +3 -3
  235. data/lib/pubid/iec.rb +40 -68
  236. data/lib/pubid/ieee/builder.rb +12 -12
  237. data/lib/pubid/ieee/components/code.rb +8 -0
  238. data/lib/pubid/ieee/components/draft.rb +14 -0
  239. data/lib/pubid/ieee/components/relationship.rb +5 -149
  240. data/lib/pubid/ieee/identifier.rb +6 -41
  241. data/lib/pubid/ieee/identifiers/adopted_standard.rb +1 -6
  242. data/lib/pubid/ieee/identifiers/base.rb +101 -458
  243. data/lib/pubid/ieee/identifiers/conformance_identifier.rb +1 -7
  244. data/lib/pubid/ieee/identifiers/corrigendum.rb +1 -9
  245. data/lib/pubid/ieee/identifiers/csa_dual_published.rb +1 -7
  246. data/lib/pubid/ieee/identifiers/dual_identifier.rb +1 -1
  247. data/lib/pubid/ieee/identifiers/dual_published.rb +1 -1
  248. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +1 -6
  249. data/lib/pubid/ieee/identifiers/interpretation_identifier.rb +1 -7
  250. data/lib/pubid/ieee/identifiers/joint_development.rb +2 -0
  251. data/lib/pubid/ieee/identifiers/multi_numbered_identifier.rb +1 -15
  252. data/lib/pubid/ieee/identifiers/parenthetical_identifier.rb +1 -3
  253. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +15 -0
  254. data/lib/pubid/ieee/identifiers/redlined_standard.rb +1 -4
  255. data/lib/pubid/ieee/identifiers/si_standard.rb +1 -35
  256. data/lib/pubid/ieee/identifiers/standard.rb +1 -1
  257. data/lib/pubid/ieee/pre_parser.rb +301 -0
  258. data/lib/pubid/ieee/renderer.rb +307 -0
  259. data/lib/pubid/ieee/urn_parser.rb +34 -0
  260. data/lib/pubid/ieee.rb +62 -1
  261. data/lib/pubid/ieee_debug.rb +0 -1
  262. data/lib/pubid/iho/builder.rb +2 -2
  263. data/lib/pubid/iho/identifier.rb +8 -42
  264. data/lib/pubid/iho/identifiers/base.rb +49 -10
  265. data/lib/pubid/iho/parser.rb +3 -3
  266. data/lib/pubid/iho/renderer.rb +30 -0
  267. data/lib/pubid/iho/urn_generator.rb +2 -2
  268. data/lib/pubid/iho/urn_parser.rb +58 -0
  269. data/lib/pubid/iho.rb +50 -1
  270. data/lib/pubid/iso/builder.rb +55 -53
  271. data/lib/pubid/iso/bundled_identifier.rb +51 -0
  272. data/lib/pubid/iso/components/code.rb +7 -19
  273. data/lib/pubid/iso/components/publisher.rb +10 -8
  274. data/lib/pubid/iso/components.rb +2 -4
  275. data/lib/pubid/iso/identifier.rb +218 -252
  276. data/lib/pubid/iso/identifiers/addendum.rb +9 -6
  277. data/lib/pubid/iso/identifiers/amendment.rb +8 -4
  278. data/lib/pubid/iso/identifiers/corrigendum.rb +4 -4
  279. data/lib/pubid/iso/identifiers/data.rb +0 -1
  280. data/lib/pubid/iso/identifiers/directives.rb +8 -2
  281. data/lib/pubid/iso/identifiers/directives_supplement.rb +43 -14
  282. data/lib/pubid/iso/identifiers/extract.rb +2 -2
  283. data/lib/pubid/iso/identifiers/guide.rb +0 -1
  284. data/lib/pubid/iso/identifiers/international_standard.rb +4 -4
  285. data/lib/pubid/iso/identifiers/international_standardized_profile.rb +4 -4
  286. data/lib/pubid/iso/identifiers/international_workshop_agreement.rb +10 -4
  287. data/lib/pubid/iso/identifiers/pas.rb +2 -2
  288. data/lib/pubid/iso/identifiers/recommendation.rb +2 -2
  289. data/lib/pubid/iso/identifiers/supplement.rb +11 -3
  290. data/lib/pubid/iso/identifiers/tc_document.rb +44 -15
  291. data/lib/pubid/iso/identifiers/technical_report.rb +4 -4
  292. data/lib/pubid/iso/identifiers/technical_specification.rb +2 -2
  293. data/lib/pubid/iso/identifiers/technology_trends_assessments.rb +2 -2
  294. data/lib/pubid/iso/identifiers.rb +0 -1
  295. data/lib/pubid/iso/normalizer.rb +89 -0
  296. data/lib/pubid/iso/parser.rb +22 -4
  297. data/lib/pubid/iso/supplement_identifier.rb +15 -2
  298. data/lib/pubid/iso/urn_generator.rb +66 -182
  299. data/lib/pubid/iso/urn_parser.rb +12 -7
  300. data/lib/pubid/iso.rb +173 -2
  301. data/lib/pubid/itu/builder.rb +0 -12
  302. data/lib/pubid/itu/components/code.rb +8 -0
  303. data/lib/pubid/itu/components.rb +11 -0
  304. data/lib/pubid/itu/identifier.rb +6 -104
  305. data/lib/pubid/itu/identifiers/amendment.rb +0 -2
  306. data/lib/pubid/itu/identifiers/annex.rb +0 -2
  307. data/lib/pubid/itu/identifiers/base.rb +0 -6
  308. data/lib/pubid/itu/identifiers/combined_identifier.rb +0 -2
  309. data/lib/pubid/itu/identifiers/corrigendum.rb +0 -2
  310. data/lib/pubid/itu/identifiers/recommendation.rb +0 -2
  311. data/lib/pubid/itu/identifiers/special_publication.rb +0 -2
  312. data/lib/pubid/itu/identifiers/supplement.rb +0 -2
  313. data/lib/pubid/itu/urn_parser.rb +23 -0
  314. data/lib/pubid/itu.rb +42 -1
  315. data/lib/pubid/jcgm/builder.rb +16 -8
  316. data/lib/pubid/jcgm/identifier.rb +0 -43
  317. data/lib/pubid/jcgm/identifiers/amendment.rb +2 -7
  318. data/lib/pubid/jcgm/identifiers/gum_guide.rb +2 -10
  319. data/lib/pubid/jcgm/renderer.rb +68 -0
  320. data/lib/pubid/jcgm/single_identifier.rb +1 -5
  321. data/lib/pubid/jcgm/urn_generator.rb +4 -6
  322. data/lib/pubid/jcgm/urn_parser.rb +23 -0
  323. data/lib/pubid/jcgm.rb +43 -2
  324. data/lib/pubid/jis/builder.rb +44 -52
  325. data/lib/pubid/jis/identifier.rb +132 -46
  326. data/lib/pubid/jis/identifiers/amendment.rb +1 -1
  327. data/lib/pubid/jis/identifiers/corrigendum.rb +16 -0
  328. data/lib/pubid/jis/identifiers/standard.rb +2 -1
  329. data/lib/pubid/jis/identifiers/technical_report.rb +2 -1
  330. data/lib/pubid/jis/identifiers/technical_specification.rb +2 -1
  331. data/lib/pubid/jis/identifiers.rb +1 -1
  332. data/lib/pubid/jis/parser.rb +31 -5
  333. data/lib/pubid/jis/renderer.rb +69 -0
  334. data/lib/pubid/jis/single_identifier.rb +6 -12
  335. data/lib/pubid/jis/supplement_identifier.rb +17 -14
  336. data/lib/pubid/jis/urn_parser.rb +23 -0
  337. data/lib/pubid/jis.rb +42 -2
  338. data/lib/pubid/nist/builder.rb +63 -1871
  339. data/lib/pubid/nist/caster.rb +1272 -0
  340. data/lib/pubid/nist/circular_supplement_builder.rb +291 -0
  341. data/lib/pubid/nist/components/code.rb +9 -20
  342. data/lib/pubid/nist/components/supplement.rb +2 -2
  343. data/lib/pubid/nist/components.rb +0 -1
  344. data/lib/pubid/nist/identifier.rb +11 -48
  345. data/lib/pubid/nist/identifiers/base.rb +110 -47
  346. data/lib/pubid/nist/identifiers/circular.rb +7 -2
  347. data/lib/pubid/nist/identifiers/circular_supplement.rb +2 -1
  348. data/lib/pubid/nist/identifiers/commercial_standard.rb +2 -1
  349. data/lib/pubid/nist/identifiers/commercial_standard_emergency.rb +6 -4
  350. data/lib/pubid/nist/identifiers/commercial_standards_monthly.rb +10 -3
  351. data/lib/pubid/nist/identifiers/crpl_report.rb +8 -8
  352. data/lib/pubid/nist/identifiers/dated_document.rb +49 -0
  353. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +15 -24
  354. data/lib/pubid/nist/identifiers/grant_contractor_report.rb +2 -1
  355. data/lib/pubid/nist/identifiers/handbook.rb +2 -1
  356. data/lib/pubid/nist/identifiers/internal_report.rb +2 -1
  357. data/lib/pubid/nist/identifiers/letter_circular.rb +2 -1
  358. data/lib/pubid/nist/identifiers/miscellaneous_publication.rb +5 -4
  359. data/lib/pubid/nist/identifiers/monograph.rb +7 -3
  360. data/lib/pubid/nist/identifiers/report.rb +4 -2
  361. data/lib/pubid/nist/identifiers/special_publication.rb +2 -1
  362. data/lib/pubid/nist/identifiers/technical_note.rb +3 -2
  363. data/lib/pubid/nist/identifiers.rb +1 -0
  364. data/lib/pubid/nist/parser.rb +62 -452
  365. data/lib/pubid/nist/parser_output_normalizer.rb +233 -0
  366. data/lib/pubid/nist/preprocessor.rb +416 -0
  367. data/lib/pubid/nist/renderer.rb +43 -0
  368. data/lib/pubid/nist/router.rb +148 -0
  369. data/lib/pubid/nist/series/base.rb +58 -0
  370. data/lib/pubid/nist/series/crpl.rb +13 -0
  371. data/lib/pubid/nist/series/fips.rb +14 -0
  372. data/lib/pubid/nist/series/ir.rb +60 -0
  373. data/lib/pubid/nist/series/letter_preserving.rb +15 -0
  374. data/lib/pubid/nist/series/mono.rb +19 -0
  375. data/lib/pubid/nist/series/ncstar.rb +20 -0
  376. data/lib/pubid/nist/series.rb +49 -0
  377. data/lib/pubid/nist/supplement_identifier.rb +3 -1
  378. data/lib/pubid/nist/urn_parser.rb +67 -0
  379. data/lib/pubid/nist.rb +82 -4
  380. data/lib/pubid/oiml/components/code.rb +10 -0
  381. data/lib/pubid/oiml/identifier.rb +0 -50
  382. data/lib/pubid/oiml/identifiers/annex.rb +3 -45
  383. data/lib/pubid/oiml/identifiers/base.rb +2 -17
  384. data/lib/pubid/oiml/renderer.rb +161 -0
  385. data/lib/pubid/oiml/single_identifier.rb +6 -45
  386. data/lib/pubid/oiml/supplement_identifier.rb +4 -19
  387. data/lib/pubid/oiml/urn_generator.rb +0 -8
  388. data/lib/pubid/oiml/urn_parser.rb +22 -0
  389. data/lib/pubid/oiml.rb +42 -1
  390. data/lib/pubid/plateau/identifier.rb +7 -41
  391. data/lib/pubid/plateau/identifiers/handbook.rb +1 -3
  392. data/lib/pubid/plateau/identifiers/technical_report.rb +1 -1
  393. data/lib/pubid/plateau/renderer.rb +51 -0
  394. data/lib/pubid/plateau/supplement_identifier.rb +1 -1
  395. data/lib/pubid/plateau/urn_parser.rb +43 -0
  396. data/lib/pubid/plateau.rb +43 -1
  397. data/lib/pubid/renderers/directives_renderer.rb +22 -8
  398. data/lib/pubid/renderers/guide_renderer.rb +4 -2
  399. data/lib/pubid/renderers/human_readable.rb +18 -7
  400. data/lib/pubid/rendering/context.rb +28 -19
  401. data/lib/pubid/rendering.rb +0 -3
  402. data/lib/pubid/sae/components/date.rb +8 -0
  403. data/lib/pubid/sae/components/type.rb +5 -1
  404. data/lib/pubid/sae/identifier.rb +0 -23
  405. data/lib/pubid/sae/identifiers/base.rb +2 -16
  406. data/lib/pubid/sae/renderer.rb +36 -0
  407. data/lib/pubid/sae/urn_generator.rb +2 -10
  408. data/lib/pubid/sae/urn_parser.rb +36 -0
  409. data/lib/pubid/sae.rb +42 -1
  410. data/lib/pubid/urn_generator/base.rb +12 -12
  411. data/lib/pubid/urn_parser/base.rb +81 -0
  412. data/lib/pubid/urn_parser/errors.rb +9 -0
  413. data/lib/pubid/urn_parser.rb +14 -0
  414. data/lib/pubid/version.rb +1 -1
  415. data/lib/pubid.rb +29 -7
  416. data/lib/tasks/website-data.json +1940 -1882
  417. metadata +75 -44
  418. data/lib/pubid/amca/scheme.rb +0 -16
  419. data/lib/pubid/ansi/scheme.rb +0 -15
  420. data/lib/pubid/api/scheme.rb +0 -66
  421. data/lib/pubid/ashrae/scheme.rb +0 -53
  422. data/lib/pubid/asme/scheme.rb +0 -37
  423. data/lib/pubid/astm/scheme.rb +0 -55
  424. data/lib/pubid/bsi/identifiers/base.rb +0 -11
  425. data/lib/pubid/bsi/scheme.rb +0 -243
  426. data/lib/pubid/ccsds/scheme.rb +0 -57
  427. data/lib/pubid/cen_cenelec/scheme.rb +0 -164
  428. data/lib/pubid/cie/scheme.rb +0 -64
  429. data/lib/pubid/components/factory.rb +0 -50
  430. data/lib/pubid/csa/scheme.rb +0 -44
  431. data/lib/pubid/etsi/scheme.rb +0 -42
  432. data/lib/pubid/export/data_class_exporter.rb +0 -59
  433. data/lib/pubid/export/ieee_exporter.rb +0 -78
  434. data/lib/pubid/export/itu_exporter.rb +0 -66
  435. data/lib/pubid/export/nist_exporter.rb +0 -64
  436. data/lib/pubid/export/registry_exporter.rb +0 -90
  437. data/lib/pubid/export/scheme_exporter.rb +0 -70
  438. data/lib/pubid/identifier_registry.rb +0 -198
  439. data/lib/pubid/idf/scheme.rb +0 -61
  440. data/lib/pubid/iec/scheme.rb +0 -71
  441. data/lib/pubid/ieee/scheme.rb +0 -90
  442. data/lib/pubid/iho/scheme.rb +0 -29
  443. data/lib/pubid/iso/identifiers/base.rb +0 -115
  444. data/lib/pubid/iso/scheme.rb +0 -193
  445. data/lib/pubid/itu/scheme.rb +0 -174
  446. data/lib/pubid/jcgm/scheme.rb +0 -60
  447. data/lib/pubid/jis/components/code.rb +0 -59
  448. data/lib/pubid/jis/identifiers/base.rb +0 -72
  449. data/lib/pubid/jis/scheme.rb +0 -49
  450. data/lib/pubid/nist/components/publisher.rb +0 -24
  451. data/lib/pubid/nist/scheme.rb +0 -199
  452. data/lib/pubid/oiml/scheme.rb +0 -46
  453. data/lib/pubid/plateau/scheme.rb +0 -45
  454. data/lib/pubid/rendering/base.rb +0 -73
  455. data/lib/pubid/rendering/common.rb +0 -211
  456. data/lib/pubid/rendering/format.rb +0 -25
  457. data/lib/pubid/sae/scheme.rb +0 -47
  458. data/lib/pubid/scheme.rb +0 -219
@@ -3,26 +3,21 @@
3
3
  module Pubid
4
4
  module Nist
5
5
  # Builder class for constructing NIST identifier objects from parsed data
6
- # Single Responsibility: Transform parsed data into identifier objects
6
+ # Single Responsibility: Orchestrate parsing pipeline (pre-processing -> routing -> casting -> construction)
7
7
  #
8
8
  # CRITICAL ARCHITECTURE PRINCIPLE:
9
9
  # Builder NEVER makes business logic decisions.
10
10
  # Builder ONLY casts parsed data to domain objects.
11
+ #
12
+ # Delegates:
13
+ # - Router: series-to-class mapping (which identifier class to instantiate)
14
+ # - Caster: type coercion (parsed values -> domain component objects)
11
15
  class Builder < Pubid::Builder::Base
12
- # Translation normalization map (V1 compatibility)
13
- TRANSLATION_MAP = {
14
- "es" => "spa",
15
- "sp" => "spa",
16
- "pt" => "por",
17
- "id" => "ind",
18
- "chi" => "zho",
19
- "viet" => "vie",
20
- "port" => "por",
21
- "esp" => "spa",
22
- }.freeze
23
-
24
- def initialize(scheme)
25
- @scheme = scheme
16
+ def initialize
17
+ @router = Router.new
18
+ @caster = Caster.new
19
+ @normalizer = ParserOutputNormalizer.new
20
+ @circular_supplement_builder = CircularSupplementBuilder.new(self)
26
21
  end
27
22
 
28
23
  # Build an identifier object from parsed data
@@ -32,16 +27,10 @@ module Pubid
32
27
  # Parslet can return array of hashes - merge them
33
28
  parsed_hash = parsed.is_a?(Array) ? flatten_array(parsed) : parsed
34
29
 
35
- # NEW: Fix for update year captured as edition_e
36
- # Pattern: "800-53r4/Upd3-2015" where parser captures "-2015" as :edition_e
37
- # but it should be :update_year as part of the update component
38
- if parsed_hash[:update_prefix] && parsed_hash[:update] && parsed_hash[:edition_e]
39
- # Move edition_e to update_year
40
- edition_id = parsed_hash[:edition_e][:edition_id]
41
- parsed_hash[:update] =
42
- parsed_hash[:update].merge(update_year: edition_id)
43
- parsed_hash.delete(:edition_e) # Remove the edition_e hash
44
- end
30
+ # Pure in-place shape corrections: parser-output normalization that
31
+ # only mutates parsed_hash. Extraction logic that needs to surface
32
+ # components to the construction phase stays below.
33
+ @normalizer.normalize(parsed_hash)
45
34
 
46
35
  # NEW: Fix for letter suffix in number with edition_dash_year
47
36
  # Pattern: "304a-2017" where parser returns first_number="304a" and edition_dash_year="2017"
@@ -59,7 +48,7 @@ module Pubid
59
48
 
60
49
  # Update first_number to exclude letter suffix
61
50
  parsed_hash[:first_number] =
62
- Components::Code.new(number: base_number)
51
+ Components::Code.new(value: base_number)
63
52
 
64
53
  # Store Part component for later (after identifier is initialized)
65
54
  letter_suffix_part = Components::Part.new(type: "",
@@ -73,232 +62,12 @@ module Pubid
73
62
  end
74
63
  end
75
64
 
76
- # NEW: Fix for edition_dash_year with embedded edition in first_number
77
- # Pattern: "44e2-1955" where first_number="44e2" and edition_dash_year="1955"
78
- # Expected: edition extracted from "44e2" (type: "e", id: "2") with additional_text="1955"
79
- #
80
- # We need to check for this pattern BEFORE the simple year-as-edition pattern below
81
- if parsed_hash[:first_number]&.to_s&.match?(/^[0-9]+[a-zA-Z]\d+$/) && parsed_hash[:edition_dash_year]
82
- # Extract edition from embedded pattern (e.g., "44e2" -> type="e", id="2")
83
- number_str = parsed_hash[:first_number].to_s
84
- if match_data = number_str.match(/^(\d+)([a-zA-Z])(\d+)$/)
85
- base_number = match_data[1]
86
- edition_type = match_data[2].downcase
87
- edition_id = match_data[3]
88
-
89
- # Update first_number to base number only
90
- parsed_hash[:first_number] =
91
- Components::Code.new(number: base_number)
92
-
93
- # Create Edition with additional_text from edition_dash_year
94
- dash_year = parsed_hash[:edition_dash_year][:dash_year]
95
- edition_obj = Components::Edition.new(
96
- type: edition_type,
97
- id: edition_id,
98
- additional_text: dash_year,
99
- )
100
- parsed_hash[:edition_with_year] = edition_obj
101
- parsed_hash.delete(:edition_dash_year)
102
- end
103
- end
104
-
105
- # NEW: Fix for edition embedded in first_number WITHOUT edition_dash_year
106
- # Pattern: "8115r1" where parser returns first_number="8115r1" with NO edition_dash_year
107
- # Expected: first_number="8115", edition with type="r", id="1"
108
- # This handles patterns like IR "8115r1/upd" where r1 is the edition
109
- # CRITICAL: Only process if NO second_number is present!
110
- # Otherwise, the compound number logic (lines 418-437) will handle edition+year patterns
111
- if !parsed_hash[:second_number] &&
112
- parsed_hash[:first_number]&.to_s&.match?(/^[0-9]+[a-zA-Z]\d+$/) &&
113
- !parsed_hash[:edition_dash_year]
114
- number_str = parsed_hash[:first_number].to_s
115
- if match_data = number_str.match(/^(\d+)([a-zA-Z])(\d+)$/)
116
- base_number = match_data[1]
117
- edition_type = match_data[2].downcase
118
- edition_id = match_data[3]
119
-
120
- # Update first_number to base number only
121
- parsed_hash[:first_number] =
122
- Components::Code.new(number: base_number)
123
-
124
- # Create Edition without additional_text (no year in this pattern)
125
- edition_obj = Components::Edition.new(
126
- type: edition_type,
127
- id: edition_id,
128
- )
129
- parsed_hash[:edition_with_year] = edition_obj
130
- end
131
- end
132
-
133
- # REMOVED: edition_r_with_space pre-processing
134
- # Now handled by cast_edition method with original_prefix preservation
135
-
136
- # NEW: Fix for second_number_edition_year pattern
137
- # Pattern: "105-1-1990" where parser returns second_number_edition_year={second_number="1", dash_year="1990"}
138
- # Expected: number="105-1", edition with type="e", id="1990", original_prefix="-"
139
- if parsed_hash[:second_number_edition_year]
140
- second_num_value = parsed_hash[:second_number_edition_year][:second_number]
141
- dash_year = parsed_hash[:second_number_edition_year][:dash_year]
142
-
143
- # Extract second_number and edition_dash_year from the combined hash
144
- parsed_hash[:second_number] = second_num_value
145
-
146
- # For HB (Handbook) series, create year-only edition with dash rendering
147
- is_handbook = begin
148
- parsed_hash[:series].to_s == "HB"
149
- rescue StandardError
150
- false
151
- end
152
-
153
- if is_handbook && dash_year.to_s.match?(/^\d{4}$/)
154
- # Create Edition with type="e" and id=dash_year for HB series
155
- edition_obj = Components::Edition.new(type: "e", id: dash_year)
156
- parsed_hash[:edition_from_year] = edition_obj
157
- else
158
- # For other series, treat dash_year as edition_dash_year for further processing
159
- parsed_hash[:edition_dash_year] = { dash_year: dash_year }
160
- end
161
-
162
- parsed_hash.delete(:second_number_edition_year)
163
- end
164
-
165
- # NEW: Fix for fips_month_year_after_part pattern
166
- # Pattern: "11-1-Sep1977" where parser returns fips_month_year_after_part={second_number="1", edition_month="Sep", edition_year="1977"}
167
- # Expected: number="11-1", edition with type="e", id="197709" (year + month number)
168
- if parsed_hash[:fips_month_year_after_part]
169
- second_num_value = parsed_hash[:fips_month_year_after_part][:second_number]
170
- month_str = parsed_hash[:fips_month_year_after_part][:edition_month]
171
- year_str = parsed_hash[:fips_month_year_after_part][:edition_year]
172
-
173
- # Extract second_number
174
- parsed_hash[:second_number] = second_num_value
175
-
176
- # Convert month abbreviation to month number and combine with year
177
- month_num = Date::ABBR_MONTHNAMES.index(month_str) ||
178
- Date::MONTHNAMES.index(month_str) ||
179
- month_str.to_i
180
-
181
- edition_id = if month_num&.positive?
182
- "#{year_str}#{format('%02d', month_num)}"
183
- else
184
- year_str
185
- end
186
-
187
- # Create Edition with type="e" and combined ID
188
- edition_obj = Components::Edition.new(type: "e", id: edition_id)
189
- parsed_hash[:edition_from_year] = edition_obj
190
-
191
- parsed_hash.delete(:fips_month_year_after_part)
192
- end
193
-
194
- # NEW: Fix for IR compound number vs edition pattern
195
- # Pattern: "84-2946" where parser returns first_number="84", edition_dash_year={dash_year="2946"}
196
- # For IR (InteragencyReport), 4-digit second numbers >= 1000 are typically editions
197
- # EXCEPT for:
198
- # - Numbers > 2699 which are clearly not valid years (like 2946)
199
- # - Patterns with embedded edition_e (like "76-1094e2") which should be compound
200
- if parsed_hash[:series]&.to_s == "IR" && parsed_hash[:first_number] && parsed_hash[:edition_dash_year]
201
- first_num = parsed_hash[:first_number].to_s
202
- dash_year = parsed_hash[:edition_dash_year][:dash_year].to_s
203
-
204
- # Check if first_number looks like a 2-digit year (00-99)
205
- if first_num.match?(/^\d{2}$/) && dash_year.match?(/^\d{4}$/)
206
- dash_year_num = dash_year.to_i
207
- # Valid year range for IR: 1901-2026 (NBS establishment to present)
208
- is_valid_year = dash_year_num.between?(1901, 2026)
209
- # If there's an embedded edition (e2, e3, etc.), treat as compound, not edition
210
- has_embedded_edition = parsed_hash[:edition_e]
211
- if is_valid_year && !has_embedded_edition
212
- # Edition format: "76e1000", "76e2013", "76e1100", "80e2100", "81e2300", "81e2400", "82e2500", "82e2600"
213
- parsed_hash[:first_number] =
214
- Components::Code.new(number: first_num)
215
- parsed_hash[:edition] =
216
- Components::Edition.new(type: "e", id: dash_year)
217
- else
218
- # Compound number: "84-2946" or "76-1094e2"
219
- parsed_hash[:first_number] =
220
- Components::Code.new(number: "#{first_num}-#{dash_year}")
221
- end
222
- parsed_hash.delete(:edition_dash_year)
223
- end
224
- end
225
-
226
- # NEW: Fix for edition_dash_year that's actually a second_number
227
- # Pattern: "250-1039" where parser returns edition_dash_year="1039"
228
- # Also handles RPT date ranges: "1946-1947" where edition_dash_year="1947"
229
- # but it should be second_number="1039" (not a year - years are 1900+ or 2000+)
230
- if parsed_hash[:first_number] && parsed_hash[:edition_dash_year] && !parsed_hash[:first_number].to_s.match?(/^[0-9]+[a-zA-Z]\d+$/)
231
- dash_year = parsed_hash[:edition_dash_year][:dash_year].to_s
232
- series = begin
233
- parsed_hash[:series].to_s
234
- rescue StandardError
235
- ""
236
- end
237
-
238
- # Check if dash_year is a valid year (1901-2026)
239
- dash_year_num = dash_year.to_i
240
- is_valid_year = dash_year_num.between?(1901, 2026)
241
-
242
- # Check if series uses dash-year as edition (HB, CS, FIPS)
243
- # These series convert dash-year to edition ONLY for valid years
244
- uses_dash_year_as_edition = ["HB", "CS", "FIPS"].include?(series)
245
-
246
- # GCR always converts dash-year to edition (e.g., "15-1000" → "15e1000")
247
- is_gcr = series == "GCR"
248
-
249
- # IR only converts dash-year to edition for valid years (e.g., "76-1100" → "76e1100")
250
- # but NOT for non-years like 2946 (e.g., "84-2946" stays as "84-2946")
251
- is_ir = series == "IR"
252
-
253
- # Check if series uses compound numbers with dash-year (RPT date ranges)
254
- is_rpt = series == "RPT"
255
-
256
- if is_rpt
257
- # For RPT (Report) series with date ranges, create compound number: "1946-1947"
258
- parsed_hash[:first_number] =
259
- Components::Code.new(number: "#{parsed_hash[:first_number]}-#{dash_year}")
260
- parsed_hash.delete(:edition_dash_year)
261
- elsif is_gcr
262
- # GCR always converts dash_year to edition regardless of whether it's a valid year
263
- # e.g., "15-1000" → "15e1000", "15-1001" → "15e1001"
264
- edition_obj = Components::Edition.new(type: "e", id: dash_year)
265
- parsed_hash[:edition_from_year] = edition_obj
266
- parsed_hash.delete(:edition_dash_year)
267
- elsif is_ir && is_valid_year
268
- # IR only converts valid years to edition
269
- # e.g., "76-1100" → "76e1100", but "84-2946" stays as "84-2946"
270
- edition_obj = Components::Edition.new(type: "e", id: dash_year)
271
- parsed_hash[:edition_from_year] = edition_obj
272
- parsed_hash.delete(:edition_dash_year)
273
- elsif uses_dash_year_as_edition && is_valid_year
274
- # For HB, CS, FIPS: convert dash_year to edition ONLY if it's a valid year
275
- edition_obj = Components::Edition.new(type: "e", id: dash_year)
276
- parsed_hash[:edition_from_year] = edition_obj
277
- parsed_hash.delete(:edition_dash_year)
278
- elsif dash_year.to_i < 1900
279
- # For other series with dash_year < 1900, treat as second_number
280
- parsed_hash[:second_number] = dash_year
281
- parsed_hash.delete(:edition_dash_year)
282
- elsif is_valid_year
283
- # For remaining cases with valid year (>= 1901), check if series uses it as edition
284
- is_handbook = series == "HB"
285
- is_commercial_standard = series == "CS"
286
- is_fips = series == "FIPS"
287
-
288
- if is_handbook || is_commercial_standard || is_fips
289
- edition_obj = Components::Edition.new(type: "e", id: dash_year)
290
- parsed_hash[:edition_from_year] = edition_obj
291
- parsed_hash.delete(:edition_dash_year)
292
- end
293
- end
294
- end
295
-
296
65
  # NEW: Fix for edition embedded in second_number
297
66
  # Pattern: "53e5" where second_number="53e5" with edition "e5" embedded
298
67
  # Expected: second_number="53", edition with type="e" and id="5"
299
68
  if parsed_hash[:second_number]&.to_s&.match?(/^\d+[a-zA-Z]\d+$/)
300
69
  second_str = parsed_hash[:second_number].to_s
301
- # Extract edition from second_number (e.g., "53e5" "53" + edition "e5")
70
+ # Extract edition from second_number (e.g., "53e5" -> "53" + edition "e5")
302
71
  if match_data = second_str.match(/^(\d+)([a-zA-Z])(\d+)$/)
303
72
  base_number = match_data[1]
304
73
  edition_letter = match_data[2]
@@ -306,7 +75,7 @@ module Pubid
306
75
 
307
76
  # Update second_number and create Edition component
308
77
  parsed_hash[:second_number] =
309
- Components::Code.new(number: base_number)
78
+ Components::Code.new(value: base_number)
310
79
  # Store Edition component for later (after identifier is initialized)
311
80
  edition_from_embedded = Components::Edition.new(
312
81
  type: edition_letter, id: edition_id,
@@ -322,8 +91,12 @@ module Pubid
322
91
  return build_circular_supplement(parsed_hash)
323
92
  end
324
93
 
325
- # Locate the appropriate identifier class via Scheme
326
- identifier = @scheme.locate_identifier_klass(parsed_hash).new
94
+ # Locate the appropriate identifier class via Router
95
+ identifier = @router.locate_identifier_klass(parsed_hash).new
96
+
97
+ # Resolve the series policy once — every series-specific decision
98
+ # flows through this object instead of branching on the parsed shape.
99
+ series = Series.for(parsed_hash)
327
100
 
328
101
  # NEW: If we extracted a letter suffix Part, assign it now (after identifier initialization)
329
102
  if letter_suffix_part
@@ -390,9 +163,11 @@ module Pubid
390
163
 
391
164
  # Cast and assign all attributes
392
165
  parsed_hash.each_pair do |key, value|
393
- realized_components = cast(key.to_sym, value, parsed_hash) # Pass parsed_hash for context
166
+ realized_components = @caster.cast(key.to_sym, value, parsed_hash) # Pass parsed_hash for context
394
167
  next if realized_components.nil?
395
- next if !realized_components.is_a?(Hash) && capture_supplement.call(key.to_sym, realized_components)
168
+ next if !realized_components.is_a?(Hash) && capture_supplement.call(
169
+ key.to_sym, realized_components
170
+ )
396
171
 
397
172
  # Track number components
398
173
  if key == :first_number && realized_components.is_a?(Components::Code)
@@ -478,33 +253,25 @@ module Pubid
478
253
  elsif decimal_num
479
254
  decimal_base = decimal_num[:decimal_base].to_s
480
255
  decimal_suffix = decimal_num[:decimal_suffix].to_s
481
- identifier.number = Components::Code.new(number: "#{first_num.value}-#{decimal_base}.#{decimal_suffix}")
256
+ identifier.number = Components::Code.new(value: "#{first_num.value}-#{decimal_base}.#{decimal_suffix}")
482
257
  # NEW: Handle letter number pattern (e.g., 1-1A, 1-3B for NCSTAR identifiers)
483
258
  # letter_num is {:letter_base => "1", :letter_suffix => "A"}
484
- # Also handles IR series "R" suffix: "79-1786R" "79-1786r1"
259
+ # Also handles IR series "R" suffix: "79-1786R" -> "79-1786r1"
485
260
  elsif letter_num
486
261
  letter_base = letter_num[:letter_base].to_s
487
262
  letter_suffix = letter_num[:letter_suffix].to_s
488
263
 
489
- # SPECIAL CASE: IR series with "R" suffix means "r1" (revision 1)
490
- # "79-1786R" → number="79-1786", edition="r1"
491
- is_ir = parsed_hash[:series]&.to_s == "IR"
492
- if is_ir && letter_suffix == "R"
493
- # IR "R" suffix converts to revision format "r1"
494
- identifier.number = Components::Code.new(number: "#{first_num.value}-#{letter_base}")
495
- edition_obj = Components::Edition.new(type: "r", id: "1")
496
- identifier.edition = edition_obj
497
- identifier.edition_component = edition_obj
498
- identifier.revision = "r1"
499
- # If a Part component was already set (from cast handler), the letter_suffix
500
- # is a separate Part component (e.g., SpecialPublication "800-56A" → number="800-56", part="A")
501
- # Otherwise, letter_suffix is part of the number (e.g., NCSTAR "1-1A" → number="1-1A")
264
+ if series.handle_letter_num_compound?(identifier,
265
+ first_num: first_num,
266
+ letter_base: letter_base,
267
+ letter_suffix: letter_suffix)
268
+ # Series-specific handler took ownership (e.g., IR "R" r1)
502
269
  elsif identifier.part
503
270
  # SpecialPublication pattern: letter_suffix is separate Part component
504
- identifier.number = Components::Code.new(number: "#{first_num.value}-#{letter_base}")
271
+ identifier.number = Components::Code.new(value: "#{first_num.value}-#{letter_base}")
505
272
  else
506
273
  # NCSTAR pattern: letter_suffix is part of the number
507
- identifier.number = Components::Code.new(number: "#{first_num.value}-#{letter_base}#{letter_suffix}")
274
+ identifier.number = Components::Code.new(value: "#{first_num.value}-#{letter_base}#{letter_suffix}")
508
275
  end
509
276
  elsif second_num
510
277
  # Check for special patterns first
@@ -518,11 +285,11 @@ module Pubid
518
285
  # Create Edition component
519
286
  edition_obj = Components::Edition.new(type: "r", id: edition_id)
520
287
 
521
- identifier.number = Components::Code.new(number: "#{first_num.value}-#{number_part}")
288
+ identifier.number = Components::Code.new(value: "#{first_num.value}-#{number_part}")
522
289
  identifier.edition = edition_obj
523
290
  identifier.edition_component = edition_obj
524
291
  identifier.revision = "r#{edition_id}"
525
- # CS Emergency pattern: e104-43 number=104, edition_year=1943
292
+ # CS Emergency pattern: e104-43 -> number=104, edition_year=1943
526
293
  # Logic: e104-43 means "emergency 104 from 1943" (43 = 1943)
527
294
  elsif first_num.value.to_s.match?(/^e(\d{3})$/) &&
528
295
  second_num.value.to_s.match?(/^\d{2}$/)
@@ -535,7 +302,7 @@ module Pubid
535
302
  # Create Edition component
536
303
  edition_obj = Components::Edition.new(type: "e", id: edition_year)
537
304
 
538
- identifier.number = Components::Code.new(number: number_part)
305
+ identifier.number = Components::Code.new(value: number_part)
539
306
  identifier.edition = edition_obj
540
307
  identifier.edition_component = edition_obj
541
308
  elsif first_num.value.to_s.match?(/^(\d+)e(\d+)$/) &&
@@ -547,13 +314,13 @@ module Pubid
547
314
  edition_id = match_data[2]
548
315
  year_part = second_num.value.to_s
549
316
 
550
- # Expand 2-digit year to 4-digit (50 1950)
317
+ # Expand 2-digit year to 4-digit (50 -> 1950)
551
318
  year_part = "19#{year_part}" if year_part.length == 2
552
319
 
553
- identifier.number = Components::Code.new(number: number_part)
320
+ identifier.number = Components::Code.new(value: number_part)
554
321
 
555
322
  # For edition+year patterns, handling depends on identifier type:
556
- # - CIRC: edition number + year as additional_text, rendered with dot ("11e2-1915" "11e2.1915")
323
+ # - CIRC: edition number + year as additional_text, rendered with dot ("11e2-1915" -> "11e2.1915")
557
324
  # - HB, others: edition number + year as additional_text, rendered with dash ("44e2-1955")
558
325
  # Both use the same Edition component structure, only rendering differs
559
326
  edition_obj = Components::Edition.new(type: "e",
@@ -566,7 +333,7 @@ module Pubid
566
333
  number_part = first_num.value.to_s.match(/^(\d+)supp?$/)[1]
567
334
  year_part = second_num.value.to_s
568
335
 
569
- identifier.number = Components::Code.new(number: number_part)
336
+ identifier.number = Components::Code.new(value: number_part)
570
337
  supp[:value] = year_part
571
338
  supp[:present] = true
572
339
  elsif second_num.value.to_s.match?(/^(\d+)supp?$/)
@@ -574,7 +341,7 @@ module Pubid
574
341
  # second number. Strip it and isolate as supplement="" (single-p).
575
342
  second_part = second_num.value.to_s.match(/^(\d+)supp?$/)[1]
576
343
  compound = "#{first_num.value}-#{second_part}"
577
- identifier.number = Components::Code.new(number: compound)
344
+ identifier.number = Components::Code.new(value: compound)
578
345
  supp[:value] = ""
579
346
  supp[:present] = true
580
347
  elsif identifier.is_a?(Identifiers::TechnicalNote) &&
@@ -587,18 +354,17 @@ module Pubid
587
354
  identifier.edition_component = edition_obj
588
355
  identifier.edition = edition_obj
589
356
  identifier.edition_year = second_num.value.to_s
590
- elsif part_num && parsed_hash[:series].to_s.include?("IR")
591
- # Normal compound number
592
- # For IR identifiers, part_number should be a Part component (type="pt"), not in compound number
357
+ elsif part_num && series.part_num_as_component?
358
+ # IR pattern: part_num becomes a Part component (type="pt"),
359
+ # not folded into the compound number.
593
360
  identifier.part = Components::Part.new(type: "pt",
594
361
  value: part_num)
595
- identifier.number = Components::Code.new(number: "#{first_num.value}-#{second_num.value}")
596
- # For IR, create Part component with type="pt"
362
+ identifier.number = Components::Code.new(value: "#{first_num.value}-#{second_num.value}")
597
363
  else
598
364
  # For GCR and others, include part number in compound number
599
365
  compound_value = "#{first_num.value}-#{second_num.value}"
600
366
  compound_value += "-#{part_num}" if part_num
601
- identifier.number = Components::Code.new(number: compound_value)
367
+ identifier.number = Components::Code.new(value: compound_value)
602
368
  end
603
369
  else
604
370
  # No second_num, use first_num directly
@@ -613,31 +379,15 @@ module Pubid
613
379
  id: extracted_revision.to_s)
614
380
  end
615
381
 
616
- # IR-SPECIFIC: Handle compound numbers that were converted to edition+year format
617
- # For IR identifiers, "84-2946" should remain as compound number, not become "84e2946"
618
- # The preprocessing converts "84-2946" to "84e2946", so we need to convert it back for IR
619
- is_ir = begin
620
- parsed_hash[:series].to_s.include?("IR")
621
- rescue StandardError
622
- false
623
- end
624
- if is_ir && identifier.number && identifier.number.value.to_s.match?(/^(\d+)e(\d{4})$/)
625
- # Extract the compound number parts from the edition+year format
626
- match_data = identifier.number.value.to_s.match(/^(\d+)e(\d{4})$/)
627
- number_part = match_data[1] # "84"
628
- year_part = match_data[2] # "2946"
629
-
630
- # Convert to compound number format
631
- identifier.number = Components::Code.new(number: "#{number_part}-#{year_part}")
632
-
633
- # Clear the edition that was incorrectly set from the year
634
- identifier.edition = nil
635
- identifier.edition_component = nil
636
- end
382
+ # Series-specific post-processing (e.g., IR reverses the "84e2946"
383
+ # form that preprocessing produced back to "84-2946").
384
+ series.finalize_identifier(identifier, parsed_hash)
637
385
 
638
- # Set publisher_was_parsed flag if publisher was set
639
- # This includes cases where publisher was explicitly parsed or extracted from series prefix
640
- identifier.publisher_was_parsed = true if identifier.publisher
386
+ # publisher_was_parsed defaults to true (see Identifiers::Base), so only
387
+ # the prefix-less case needs recording: assign false when no publisher
388
+ # was parsed/extracted, and leave it unset (default true, omitted from
389
+ # to_hash) when one was. Keeps the common publisher-bearing id lean.
390
+ identifier.publisher_was_parsed = false unless identifier.publisher
641
391
 
642
392
  # NEW: Convert revision with month+year to update component (V1 compatibility)
643
393
  # Patterns like "NIST IR 4743rJun1992" should be rendered as "NIST IR 4743/Upd1-199206"
@@ -646,8 +396,8 @@ module Pubid
646
396
  month_str = parsed_hash[:revision_month].to_s
647
397
  year_str = parsed_hash[:revision_year].to_s
648
398
 
649
- # Convert month name to number (Jun 06, Nov 11, etc.)
650
- month_num = month_name_to_number(month_str)
399
+ # Convert month name to number (Jun -> 06, Nov -> 11, etc.)
400
+ month_num = @caster.month_name_to_number(month_str)
651
401
 
652
402
  # Create update component with default number=1, converted year and month
653
403
  update_obj = Components::Update.new(
@@ -669,7 +419,7 @@ module Pubid
669
419
  # Fold the accumulated supplement signals into the single structured
670
420
  # supplement component (the source of truth).
671
421
  if (supp[:present] || supp[:has_revision]) &&
672
- identifier.respond_to?(:supplement=)
422
+ identifier.class.attributes.key?(:supplement)
673
423
  identifier.supplement = supplement_from(
674
424
  value: supp[:value], has_revision: supp[:has_revision],
675
425
  range_start: supp[:range_start], range_end: supp[:range_end]
@@ -701,1568 +451,10 @@ module Pubid
701
451
  end
702
452
  end
703
453
 
704
- # Convert month name to month number
705
- # @param month_name [String] month abbreviation (Jan, Feb, Mar, etc.)
706
- # @return [Integer] month number (1-12)
707
- def month_name_to_number(month_name)
708
- month_map = {
709
- "Jan" => 1, "January" => 1,
710
- "Feb" => 2, "February" => 2,
711
- "Mar" => 3, "March" => 3,
712
- "Apr" => 4, "April" => 4,
713
- "May" => 5,
714
- "Jun" => 6, "June" => 6,
715
- "Jul" => 7, "July" => 7,
716
- "Aug" => 8, "August" => 8,
717
- "Sep" => 9, "Sept" => 9, "September" => 9,
718
- "Oct" => 10, "October" => 10,
719
- "Nov" => 11, "November" => 11,
720
- "Dec" => 12, "December" => 12
721
- }
722
- month_map[month_name] || 1 # Default to January if not found
723
- end
724
-
725
- # Build CircularSupplement with base_identifier wrapping
726
- # @param parsed_hash [Hash] the parsed supplement data
727
- # Build a CIRC/LCIRC supplement. Most forms collapse onto the base
728
- # identifier's normal class (Circular / LetterCircular) with isolated
729
- # supplement attributes, so they share one model with every other series
730
- # and are queryable by part. The V1-compat update forms (slash-year
731
- # "118supp3/1926" → ".../Upd1-192603"; implicit revision "145r11/1925")
732
- # render through an Update component the flat supplement model can't
733
- # express, so they stay on the CircularSupplement wrapper for now.
454
+ # Delegate CIRC/LCIRC supplement construction to the dedicated builder.
455
+ # See CircularSupplementBuilder for the construction pipeline.
734
456
  def build_circular_supplement(parsed_hash)
735
- if parsed_hash[:supplement_slash_year].is_a?(Hash) ||
736
- parsed_hash[:implicit_supplement].is_a?(Hash)
737
- return build_circular_supplement_wrapper(parsed_hash)
738
- end
739
-
740
- series_value = if parsed_hash[:circ_series].is_a?(Hash)
741
- parsed_hash[:circ_series][:series]
742
- else
743
- parsed_hash[:series]
744
- end
745
-
746
- # Date-range supplement (no base document): a plain base identifier with
747
- # no number, carrying the range. parsed_format is left at the default so
748
- # a dotted MR input still normalizes to the spaced short form.
749
- if parsed_hash[:supplement_date_range].is_a?(Hash)
750
- range = parsed_hash[:supplement_date_range]
751
- identifier = build({ series: series_value })
752
- ms = range[:supp_month_start]&.to_s
753
- ys = range[:supp_year_start]&.to_s
754
- me = range[:supp_month_end]&.to_s
755
- ye = range[:supp_year_end]&.to_s
756
- identifier.supplement = supplement_from(
757
- value: nil, has_revision: false,
758
- range_start: (ms && ys ? "#{ms}#{ys}" : nil),
759
- range_end: (me && ye ? "#{me}#{ye}" : nil)
760
- )
761
- return identifier
762
- end
763
-
764
- # Based supplement: build the base, then attach the supplement as an
765
- # isolated component on that normal class.
766
- identifier = build_circular_supplement_base(parsed_hash, series_value)
767
- raw = if parsed_hash[:supplement_month_year]
768
- parsed_hash[:supplement_month_year].to_s
769
- elsif parsed_hash[:supplement_year]
770
- parsed_hash[:supplement_year].to_s
771
- else
772
- "" # supplement_empty or bare marker
773
- end
774
- identifier.supplement = Components::Supplement.from_raw(raw)
775
- identifier
776
- end
777
-
778
- # Build just the base identifier for a based CIRC/LCIRC supplement, from
779
- # base_portion (number, optional edition "101e2" or letter suffix "378G")
780
- # or the merged first_number fallback. Returns the normal class.
781
- def build_circular_supplement_base(parsed_hash, series_value)
782
- base_portion = parsed_hash[:base_portion]
783
- unless base_portion
784
- return build({
785
- publisher: parsed_hash[:publisher],
786
- series: series_value || parsed_hash[:series],
787
- first_number: parsed_hash[:first_number],
788
- parsed_format: parsed_hash[:parsed_format],
789
- })
790
- end
791
-
792
- base_number = if base_portion.is_a?(Hash)
793
- base_portion[:simple_number] || base_portion[:base_number]
794
- else
795
- base_portion
796
- end
797
-
798
- letter_suffix = nil
799
- if base_portion.is_a?(Hash) && base_portion[:letter_suffix]
800
- letter_suffix = base_portion[:letter_suffix].to_s.upcase
801
- end
802
-
803
- publisher_value = nil
804
- if parsed_hash[:circ_series].is_a?(Hash) && parsed_hash[:circ_series][:series]
805
- series_str = parsed_hash[:circ_series][:series].to_s
806
- publisher_value = series_str.split.first if series_str.include?(" ")
807
- end
808
-
809
- has_edition = base_portion.is_a?(Hash) && base_portion[:edition_number]
810
-
811
- base_number_with_suffix = base_number.to_s
812
- base_number_with_suffix += letter_suffix if letter_suffix
813
- if has_edition
814
- base_number_with_suffix += "e#{base_portion[:edition_number]}"
815
- end
816
-
817
- base_hash = {
818
- series: series_value,
819
- first_number: base_number_with_suffix,
820
- parsed_format: parsed_hash[:parsed_format],
821
- }
822
- base_hash[:publisher] = publisher_value if publisher_value
823
- base_hash[:edition_e] = { edition_id: base_portion[:edition_number] } if has_edition
824
-
825
- build(base_hash)
826
- end
827
-
828
- # @return [Identifiers::CircularSupplement] the supplement identifier
829
- def build_circular_supplement_wrapper(parsed_hash)
830
- supplement = Identifiers::CircularSupplement.new
831
-
832
- # Extract series from circ_series if present (nested structure from parser)
833
- series_value = nil
834
- if parsed_hash[:circ_series].is_a?(Hash)
835
- series_value = parsed_hash[:circ_series][:series]
836
- elsif parsed_hash[:series]
837
- series_value = parsed_hash[:series]
838
- end
839
-
840
- # Handle date range supplement (no base)
841
- if parsed_hash[:supplement_date_range].is_a?(Hash)
842
- range = parsed_hash[:supplement_date_range]
843
- month_start = range[:supp_month_start]&.to_s
844
- year_start = range[:supp_year_start]&.to_s
845
- month_end = range[:supp_month_end]&.to_s
846
- year_end = range[:supp_year_end]&.to_s
847
-
848
- supplement.supplement_date_range_start = "#{month_start}#{year_start}" if month_start && year_start
849
- supplement.supplement_date_range_end = "#{month_end}#{year_end}" if month_end && year_end
850
-
851
- return supplement
852
- end
853
-
854
- # Build base identifier from base_portion (if present)
855
- # If not present (because it was merged during parsing), use first_number
856
- if parsed_hash[:base_portion]
857
- # Extract the actual number value from base_portion hash
858
- # base_portion can be: {:simple_number=>"118"}, {:base_number=>"145", :revision_letter=>"r", :revision_number=>"11"}, etc.
859
- base_portion = parsed_hash[:base_portion]
860
- base_number = if base_portion.is_a?(Hash)
861
- # Extract the value from whichever key is present
862
- base_portion[:simple_number] || base_portion[:base_number]
863
- else
864
- base_portion
865
- end
866
-
867
- # Check for letter suffix in base_portion (e.g., "378G")
868
- letter_suffix = nil
869
- if base_portion.is_a?(Hash) && base_portion[:letter_suffix]
870
- letter_suffix = base_portion[:letter_suffix].to_s.upcase
871
- end
872
-
873
- # Extract publisher from circ_series if present
874
- publisher_value = nil
875
- if parsed_hash[:circ_series].is_a?(Hash) && parsed_hash[:circ_series][:series]
876
- series_str = parsed_hash[:circ_series][:series].to_s
877
- # Extract publisher from series (e.g., "NBS LCIRC" -> "NBS")
878
- publisher_value = series_str.split.first if series_str.include?(" ")
879
- end
880
-
881
- # Check if base_portion has revision (for patterns like "145r11/1925")
882
- has_revision = base_portion.is_a?(Hash) && base_portion[:revision_letter] && base_portion[:revision_number]
883
-
884
- # NEW: Check if base_portion has edition_number (for patterns like "101e2")
885
- has_edition = base_portion.is_a?(Hash) && base_portion[:edition_number]
886
-
887
- # Include letter suffix in base_number if present
888
- # Also include edition_number if present (for "101e2" pattern)
889
- base_number_with_suffix = base_number.to_s
890
- if letter_suffix
891
- base_number_with_suffix += letter_suffix
892
- end
893
- if has_edition
894
- base_number_with_suffix += "e#{base_portion[:edition_number]}"
895
- end
896
-
897
- # Reconstruct parse hash for base identifier
898
- base_hash = {
899
- series: series_value,
900
- first_number: base_number_with_suffix,
901
- parsed_format: parsed_hash[:parsed_format],
902
- }
903
- base_hash[:publisher] = publisher_value if publisher_value
904
-
905
- # NEW: Add edition_number to base_hash for patterns like "101e2"
906
- # This will be processed by the normal build() logic to create Edition component
907
- if has_edition
908
- # Create edition_e hash that will be converted to Edition with type="e"
909
- base_hash[:edition_e] =
910
- { edition_id: base_portion[:edition_number] }
911
- end
912
-
913
- # Recursively build base identifier
914
- # This will go through normal build() process which extracts edition from "101e2"
915
- supplement.base_identifier = build(base_hash)
916
-
917
- # NEW: Handle revision + implicit supplement pattern (e.g., "145r11/1925")
918
- # Create update format: "Upd1-{year}{revision_number}"
919
- if has_revision && parsed_hash[:implicit_supplement].is_a?(Hash)
920
- revision_number = base_portion[:revision_number].to_s
921
- supplement_year = parsed_hash[:implicit_supplement][:implicit_supplement_year].to_s
922
-
923
- # Create Update component for revision+supplement pattern
924
- # Format: Upd1-{year}{revision_number} (always use "1" and concatenate year+revision)
925
- update_value = "Upd1-#{supplement_year}#{revision_number}"
926
- supplement.update = update_value
927
- supplement.implicit_supplement = true # Mark as implicit supplement for rendering
928
- end
929
- elsif parsed_hash[:first_number]
930
- # base_portion was lost during merge, use first_number to build base identifier
931
- base_hash = {
932
- publisher: parsed_hash[:publisher],
933
- series: series_value || parsed_hash[:series],
934
- first_number: parsed_hash[:first_number],
935
- parsed_format: parsed_hash[:parsed_format],
936
- }
937
-
938
- # Recursively build base identifier
939
- supplement.base_identifier = build(base_hash)
940
- end
941
-
942
- # Build supplement edition from captured data
943
- if parsed_hash[:supplement_month_year]
944
- # Parse month+year format like "Jan1924"
945
- month_year = parsed_hash[:supplement_month_year].to_s
946
- supplement.edition = Components::Edition.new(type: "s",
947
- id: month_year)
948
- elsif parsed_hash[:supplement_year]
949
- # Just year: 1924
950
- supplement.edition = Components::Edition.new(type: "s",
951
- id: parsed_hash[:supplement_year].to_s)
952
- elsif parsed_hash[:supplement_slash_year].is_a?(Hash)
953
- # NEW: Handle supplement_slash_year pattern (e.g., "sup12/1926", "sup1/1927")
954
- # V1 format: "Upd1-192612" where "1" is fixed and "192612" is year+number concatenated
955
- # Single digit numbers are zero-padded: "sup1/1927" → "Upd1-192701"
956
- supp_hash = parsed_hash[:supplement_slash_year]
957
- supp_number = supp_hash[:supp_number]&.to_s
958
- supp_year = supp_hash[:supp_year]&.to_s
959
-
960
- # Pad supplement number to 2 digits for single-digit numbers
961
- supp_number_padded = supp_number.rjust(2, "0")
962
-
963
- # Create Update component for supplement (V1 compatibility uses Update for supplements)
964
- # Format: Upd1-{year}{padded_number} (always use "1" and concatenate year+padded_number)
965
- update_value = "Upd1-#{supp_year}#{supp_number_padded}"
966
- supplement.update = update_value
967
- elsif parsed_hash[:supplement_empty]
968
- # Empty supplement - no edition
969
- # supplement.edition remains nil
970
- end
971
-
972
- supplement
973
- end
974
-
975
- private
976
-
977
- # Cast parsed value to appropriate component type
978
- # ALL conversions happen in this single method
979
- # @param type [Symbol] the parameter type
980
- # @param value [Object] the parsed value
981
- # @param parsed_hash [Hash] the full parsed hash for context
982
- # @return [Object, Hash, nil] the cast component(s)
983
- def cast(type, value, parsed_hash = {})
984
- case type
985
- when :publisher
986
- return nil if value.nil? || value.to_s.strip.empty?
987
-
988
- Components::Publisher.new(publisher: value.to_s)
989
-
990
- when :series
991
- return nil if value.nil? || value.to_s.strip.empty?
992
-
993
- str_value = value.to_s
994
- publisher_extracted = nil
995
-
996
- # For compound series like "NBS CIRC", extract publisher and series separately
997
- if str_value.start_with?("NBS ")
998
- publisher_extracted = "NBS"
999
- str_value = str_value.sub("NBS ", "")
1000
- end
1001
-
1002
- # Return composite hash with both publisher and series if extracted
1003
- if publisher_extracted
1004
- {
1005
- publisher: Components::Publisher.new(publisher: publisher_extracted),
1006
- series: Components::Code.new(number: str_value),
1007
- }
1008
- else
1009
- Components::Code.new(number: str_value)
1010
- end
1011
-
1012
- when :volume_number
1013
- # Volume from v#n# pattern - return Volume component
1014
- return nil if value.nil? || value.to_s.strip.empty?
1015
-
1016
- { volume: Components::Volume.new(value: value.to_s) }
1017
-
1018
- when :issue_number
1019
- # Issue number from v#n# pattern - return Part component
1020
- return nil if value.nil? || value.to_s.strip.empty?
1021
-
1022
- { part: Components::Part.new(type: "n", value: value.to_s) }
1023
-
1024
- when :part_number
1025
- # Part number from GCR pattern (e.g., 85-3273-37)
1026
- # Return raw value for inclusion in compound number
1027
- return nil if value.nil? || value.to_s.strip.empty?
1028
-
1029
- value # Return raw value to be tracked in builder
1030
-
1031
- when :letter_number
1032
- # Letter number pattern (e.g., 800-56A, 1-1A for NCSTAR, 73-197Ur for IR)
1033
- # Parser returns: {:letter_base=>"56", :letter_suffix=>"A"} or
1034
- # {:letter_base=>"197", :letter_suffix=>"U", :letter_suffix_extra=>"r"}
1035
- # For SpecialPublication, create Part component with letter suffix as value
1036
- # For MONO and NCSTAR, preserve letter suffix as part of the number (return raw value)
1037
- return nil if value.nil? || !value.is_a?(Hash)
1038
-
1039
- letter_suffix = value[:letter_suffix]&.to_s&.strip
1040
- letter_suffix_extra = value[:letter_suffix_extra]&.to_s&.strip
1041
-
1042
- # Combine letter_suffix and letter_suffix_extra (e.g., "U" + "r" = "Ur")
1043
- full_suffix = if letter_suffix_extra && !letter_suffix_extra.empty?
1044
- letter_suffix + letter_suffix_extra
1045
- else
1046
- letter_suffix
1047
- end
1048
-
1049
- return nil if full_suffix.nil? || full_suffix.empty?
1050
-
1051
- # Check if this is a MONO or NCSTAR series
1052
- # For these series, the letter suffix should be part of the number, not a separate Part component
1053
- # For IR with "R" or "Ur" suffix, also return raw value so builder can convert to edition "r1"
1054
- is_mono = begin
1055
- parsed_hash[:series].to_s.include?("MONO")
1056
- rescue StandardError
1057
- false
1058
- end
1059
- is_ncstar = begin
1060
- parsed_hash[:series].to_s.include?("NCSTAR")
1061
- rescue StandardError
1062
- false
1063
- end
1064
- # IR with "R" suffix needs special handling (convert to edition "r1")
1065
- # Also handle "Ur" which combines uppercase U with lowercase r
1066
- is_ir_with_r = begin
1067
- parsed_hash[:series].to_s.include?("IR") && (letter_suffix == "R" || full_suffix == "Ur")
1068
- rescue StandardError
1069
- false
1070
- end
1071
-
1072
- if is_mono || is_ncstar || is_ir_with_r
1073
- # For MONO and NCSTAR, preserve letter suffix as part of the number
1074
- # For IR with "R" or "Ur", return raw value so builder can convert "79-1786R" to "79-1786r1"
1075
- # Return raw value so builder can construct proper format
1076
- value[:letter_suffix] = full_suffix
1077
- value
1078
- else
1079
- # For SpecialPublication and others, create Part component
1080
- { part: Components::Part.new(type: "", value: full_suffix.upcase) }
1081
- end
1082
-
1083
- when :fips_part
1084
- # Part number from FIPS date pattern (e.g., 11-1-Sep30/1977)
1085
- # Return Part component with pt type
1086
- return nil if value.nil? || value.to_s.strip.empty?
1087
-
1088
- { part: Components::Part.new(type: "pt", value: value.to_s) }
1089
-
1090
- when :owmwp_date_number
1091
- # OWMWP date-based number format (MM-DD-YYYY)
1092
- # Parser returns: {:owmwp_month=>"06", :owmwp_day=>"13", :owmwp_year=>"2018"}
1093
- # Convert to number + edition: "06-13" + edition "e2018"
1094
- return nil if value.nil?
1095
-
1096
- number_part = "#{value[:owmwp_month]}-#{value[:owmwp_day]}"
1097
- edition_part = Components::Edition.new(type: "e",
1098
- id: value[:owmwp_year])
1099
- { first_number: Components::Code.new(number: number_part), edition: edition_part }
1100
-
1101
- when :first_number, :second_number
1102
- return nil if value.nil? || value.to_s.strip.empty?
1103
-
1104
- # NEW: Handle OWMWP date-based number (nested hash structure)
1105
- # Parser returns: {:owmwp_date_number=>{:owmwp_month=>"06", :owmwp_day=>"13", :owmwp_year=>"2018"}}
1106
- # Convert to number + edition: "06-13" + edition "e2018"
1107
- if value.is_a?(Hash) && value[:owmwp_date_number]
1108
- owmwp_hash = value[:owmwp_date_number]
1109
- number_part = "#{owmwp_hash[:owmwp_month]}-#{owmwp_hash[:owmwp_day]}"
1110
- edition_part = Components::Edition.new(type: "e",
1111
- id: owmwp_hash[:owmwp_year])
1112
- return { type => Components::Code.new(number: number_part), edition: edition_part }
1113
- end
1114
-
1115
- # NEW: Handle second_number with edition (hash with :number_only and :edition_id)
1116
- # This handles "126r2013" pattern where parser returns {:number_only=>"126", :edition_id=>"2013"}
1117
- # CRITICAL: Wrap in a structure that builder loop can recognize
1118
- # The builder loop expects keys like :second_number to be present in the hash
1119
- if type == :second_number && value.is_a?(Hash) && value[:number_only] && value[:edition_id]
1120
- # Return wrapped hash so builder loop finds :second_number key
1121
- return { second_number: value }
1122
- end
1123
-
1124
- # NEW: Handle second_number with revision_letter (hash with :revision_letter containing :number_only and :letter)
1125
- # This handles "27ra" pattern where parser returns {revision_letter: {number_only: "27", letter: "a"}}
1126
- # Should be combined to "27rA" format
1127
- if type == :second_number && value.is_a?(Hash) && value[:revision_letter]
1128
- revision_data = value[:revision_letter]
1129
- number_only = revision_data[:number_only].to_s
1130
- letter = revision_data[:letter].to_s.upcase
1131
- # Return as second_number with combined format "27rA"
1132
- return { second_number: Components::Code.new(number: "#{number_only}r#{letter}") }
1133
- end
1134
-
1135
- # Handle v#n# pattern (CSM series) - comes as hash from parser
1136
- # Return Volume and Part components separately
1137
- if value.is_a?(Hash) && value[:volume_number] && value[:issue_number]
1138
- volume_num = value[:volume_number].to_s
1139
- issue_num = value[:issue_number].to_s
1140
- return {
1141
- volume: Components::Volume.new(value: volume_num),
1142
- part: Components::Part.new(type: "n", value: issue_num),
1143
- }
1144
- end
1145
-
1146
- str_value = value.to_s
1147
-
1148
- # Handle special patterns embedded in first_number
1149
- if type == :first_number
1150
-
1151
- # NEW: Handle first_number hash with number_with_rev_year (e.g., "1013rv1953")
1152
- # Parser returns: {:number_with_rev_year=>{:number=>"1013", :revision_year=>"1953"}}
1153
- if value.is_a?(Hash) && value[:number_with_rev_year]
1154
- number_part = value[:number_with_rev_year][:number].to_s
1155
- revision_year = value[:number_with_rev_year][:revision_year].to_s
1156
- return {
1157
- first_number: Components::Code.new(number: number_part),
1158
- edition: Components::Edition.new(type: "rv", id: revision_year),
1159
- }
1160
- end
1161
-
1162
- # NEW: Handle first_number hash with language_code (e.g., "1262es")
1163
- # Parser returns: {:number=>"1262", :language_code=>"es"}
1164
- if value.is_a?(Hash) && value[:number] && value[:language_code]
1165
- number_part = value[:number].to_s
1166
- language_code = value[:language_code].to_s.strip.downcase
1167
- # Apply normalization map (es → spa, pt → por, etc.)
1168
- normalized_code = TRANSLATION_MAP[language_code] || language_code
1169
- return {
1170
- first_number: Components::Code.new(number: number_part),
1171
- translation_component: Components::Translation.new(code: normalized_code),
1172
- }
1173
- end
1174
-
1175
- # NEW: Handle first_number hash with number, part_number, and edition_year (MR format)
1176
- # Parser returns: {:number=>"28", :part_number=>"1", :edition_year=>"1969"}
1177
- # For "NBS.HB.28pt1e1969" MR format input
1178
- if value.is_a?(Hash) && value[:number] && value[:part_number] && value[:edition_year]
1179
- number_part = value[:number].to_s
1180
- part_number = value[:part_number].to_s
1181
- edition_year = value[:edition_year].to_s
1182
- return {
1183
- first_number: Components::Code.new(number: number_part),
1184
- part: Components::Part.new(type: "pt", value: part_number),
1185
- edition: Components::Edition.new(type: "e", id: edition_year),
1186
- }
1187
- end
1188
-
1189
- # NEW: Check for edition_year_separate in parsed_hash context
1190
- # This handles "11e2-1915" where first_number="11e2" and edition_year_separate="1915"
1191
- if parsed_hash[:edition_year_separate] && str_value =~ /^(\d+)e(\d+)$/
1192
- number_part = $1
1193
- edition_id = $2
1194
- year_part = parsed_hash[:edition_year_separate].to_s
1195
- return {
1196
- first_number: Components::Code.new(number: number_part),
1197
- edition: Components::Edition.new(type: "e", id: edition_id,
1198
- additional_text: year_part),
1199
- }
1200
- end
1201
-
1202
- # NEW: Check for number_with_volume in value hash (for first_number)
1203
- # This handles "539v10" where parser captures :number and :volume_suffix separately
1204
- # Parse tree: value = {:number_with_volume => {:number => "539", :volume_suffix => "10"}}
1205
- if value.is_a?(Hash) && value[:number_with_volume] && value[:number_with_volume][:volume_suffix]
1206
- number_part = value[:number_with_volume][:number].to_s
1207
- volume_value = value[:number_with_volume][:volume_suffix].to_s
1208
- return {
1209
- first_number: Components::Code.new(number: number_part),
1210
- volume: Components::Volume.new(value: volume_value),
1211
- }
1212
- end
1213
-
1214
- # NEW: Check for historical_month and historical_year in parsed_hash context
1215
- # This handles "-April1909" where it's captured as separate month/year
1216
- if parsed_hash[:historical_month] && parsed_hash[:historical_year]
1217
- month_part = parsed_hash[:historical_month].to_s
1218
- year_part = parsed_hash[:historical_year].to_s
1219
- # Check if str_value is just a number (the part before dash)
1220
- if /^\d+$/.match?(str_value)
1221
- return {
1222
- first_number: Components::Code.new(number: str_value),
1223
- edition: Components::Edition.new(type: "-",
1224
- additional_text: "#{month_part}#{year_part}"),
1225
- }
1226
- else
1227
- # No number, just historical edition
1228
- return {
1229
- edition: Components::Edition.new(type: "-",
1230
- additional_text: "#{month_part}#{year_part}"),
1231
- }
1232
- end
1233
- end
1234
-
1235
- # Pattern "9350sup"/"5893supp" - number with bare supplement marker
1236
- # (no trailing payload). Accept both single-p "sup" and double-p
1237
- # "supp" so the marker is isolated as supplement="" and rendered as
1238
- # canonical single-p "sup", instead of staying baked into the number
1239
- # as an opaque suffix. E.g. "NBS RPT 5893supp", "NBS MONO 32supp".
1240
- if str_value =~ /^(\d+)supp?$/
1241
- return {
1242
- first_number: Components::Code.new(number: $1),
1243
- supplement: "",
1244
- }
1245
- end
1246
-
1247
- # NEW: Check for supplement_year in parsed_hash context
1248
- # This handles "25supp-1924" where first_number="25supp" and supplement_year="1924"
1249
- if parsed_hash[:supplement_year] && str_value =~ /^(\d+)supp?$/
1250
- number_part = $1
1251
- year_part = parsed_hash[:supplement_year].to_s
1252
- return {
1253
- first_number: Components::Code.new(number: number_part),
1254
- supplement: year_part,
1255
- }
1256
- end
1257
-
1258
- # Pattern: "154supprev" - supplement with revision
1259
- if str_value =~ /^(\d+)supprev$/
1260
- return {
1261
- first_number: Components::Code.new(number: $1),
1262
- supplement: "",
1263
- supplement_has_revision: true,
1264
- }
1265
- # NEW: Pattern "11e2-1915" - edition with separate year (inline match)
1266
- # Creates: number="11", Edition(type: "e", id: "2", additional_text: "1915")
1267
- # Renders: "NBS CIRC 11e2.1915"
1268
- elsif str_value =~ /^(\d+)e(\d+)-(\d{4})$/
1269
- number_part = $1
1270
- edition_id = $2
1271
- year_part = $3
1272
- return {
1273
- first_number: Components::Code.new(number: number_part),
1274
- edition: Components::Edition.new(type: "e", id: edition_id,
1275
- additional_text: year_part),
1276
- }
1277
- # NEW: Pattern "-April1909" - historical edition with month+year (inline match)
1278
- # Creates: Edition(type: "-", additional_text: "April1909")
1279
- # Renders: "NBS CIRC -April1909"
1280
- elsif str_value =~ /^-([A-Za-z]{3,9})(\d{4})$/
1281
- month_part = $1
1282
- year_part = $2
1283
- return {
1284
- edition: Components::Edition.new(type: "-",
1285
- additional_text: "#{month_part}#{year_part}"),
1286
- }
1287
- # NEW: CS Emergency pattern "e104" or "e104-43" → extract number
1288
- # This must come BEFORE bare edition check to avoid conflict
1289
- # CS emergency always has 3+ digit number (e104, not e2)
1290
- # NOTE: If second_number exists (e104-43 pattern), defer to compound number logic
1291
- elsif /^e(\d{3,})$/.match?(str_value) && !parsed_hash[:second_number]
1292
- # Extract emergency number: e104 → 104 (only when no second_number)
1293
- emergency_num = str_value.sub(/^e/, "")
1294
- return {
1295
- first_number: Components::Code.new(number: emergency_num),
1296
- }
1297
- # If e104-43 pattern (with second_number), keep e prefix for compound number logic
1298
- elsif /^e(\d{3,})$/.match?(str_value) && parsed_hash[:second_number]
1299
- # Keep e104 as-is, let compound number logic handle it
1300
- return {
1301
- first_number: Components::Code.new(number: str_value),
1302
- }
1303
- # NEW: Bare edition pattern like "100e1" (CS series without year)
1304
- # ONLY when NO second_number present (to avoid conflict with "123e2-50")
1305
- # Creates: number="100", Edition(type: "e", id: "1")
1306
- # Renders: "NBS CS 100e1"
1307
- # CRITICAL: Skip if edition_dash_year is present - let that handler create Edition with additional_text
1308
- elsif str_value =~ /^(\d+)e(\d+)$/ && !parsed_hash[:second_number] && !parsed_hash[:edition_dash_year]
1309
- number_part = $1
1310
- edition_id = $2
1311
-
1312
- return {
1313
- first_number: Components::Code.new(number: number_part),
1314
- edition: Components::Edition.new(type: "e", id: edition_id),
1315
- }
1316
- # NEW: Bare edition pattern "e2" - just edition without number prefix
1317
- # Creates: Edition(type: "e", id: "2")
1318
- # Renders: "NBS CIRC e2"
1319
- # Only matches single or double digit (e1, e2, not e104 which is emergency)
1320
- elsif str_value =~ /^e(\d{1,2})$/
1321
- edition_id = $1
1322
- return {
1323
- edition: Components::Edition.new(type: "e", id: edition_id),
1324
- }
1325
- # Pattern: "13e2rev1908" - edition with revision year-only (NO month)
1326
- # Creates: Edition(type: "e", id: "2", additional_text: "1908")
1327
- # Renders: "e2.1908" (DOT separator)
1328
- elsif str_value =~ /^(\d+)e(\d+)rev(\d{4})$/
1329
- # CRITICAL: Capture BEFORE any regex method calls!
1330
- number_part = $1
1331
- edition_id_part = $2
1332
- year_part = $3
1333
- return {
1334
- first_number: Components::Code.new(number: number_part),
1335
- edition: Components::Edition.new(type: "e",
1336
- id: edition_id_part, additional_text: year_part),
1337
- }
1338
- # Pattern: "13e2revJune1908" - edition with revision month+year
1339
- # Creates: Edition(type: "e", id: "2", additional_text: "June1908")
1340
- # Renders: "e2.June1908" (DOT separator)
1341
- elsif str_value =~ /^(\d+)e(\d+)(rev.+)$/
1342
- # CRITICAL: Capture $1, $2, $3 BEFORE calling .sub() which resets them!
1343
- number_part = $1
1344
- edition_id_part = $2
1345
- rev_part = $3
1346
- # Strip "rev" prefix from additional_text - store only "June1908" or "1908"
1347
- additional_text = rev_part.sub(/^rev/, "")
1348
- return {
1349
- first_number: Components::Code.new(number: number_part),
1350
- edition: Components::Edition.new(type: "e",
1351
- id: edition_id_part, additional_text: additional_text),
1352
- }
1353
- # NEW: Pattern "24suppJan1924" - supplement with month and year in first_number
1354
- # Creates: number="24", supplement="Jan1924"
1355
- elsif str_value =~ /^(\d+)supp([A-Za-z]{3,9})(\d{4})$/
1356
- number_part = $1
1357
- month_part = $2
1358
- year_part = $3
1359
- return {
1360
- first_number: Components::Code.new(number: number_part),
1361
- supplement: "#{month_part}#{year_part}",
1362
- }
1363
- # NEW: Pattern "25supp1924" - supplement with year (no dash, no month)
1364
- # Creates: number="25", supplement="1924"
1365
- # Renders: "NBS SP 25supp1924"
1366
- elsif str_value =~ /^(\d+)supp(\d{4})$/
1367
- number_part = $1
1368
- year_part = $2
1369
- return {
1370
- first_number: Components::Code.new(number: number_part),
1371
- supplement: year_part,
1372
- }
1373
- # NEW: Pattern "25supp-1924" - supplement with dash-year (inline match)
1374
- # Creates: number="25", supplement="1924"
1375
- # Renders: "NBS CIRC 25supp-1924"
1376
- elsif str_value =~ /^(\d+)supp-(\d{4})$/
1377
- number_part = $1
1378
- year_part = $2
1379
- return {
1380
- first_number: Components::Code.new(number: number_part),
1381
- supplement: year_part,
1382
- }
1383
- # NEW: Pattern "101e2supp" - edition + supplement
1384
- # Creates: number="101", Edition(type: "e", id: "2"), supplement=""
1385
- # Renders: "NBS CIRC 101e2supp"
1386
- elsif str_value =~ /^(\d+)e(\d+)supp$/
1387
- number_part = $1
1388
- edition_id = $2
1389
- return {
1390
- first_number: Components::Code.new(number: number_part),
1391
- edition: Components::Edition.new(type: "e", id: edition_id),
1392
- supplement: "",
1393
- }
1394
- end
1395
- elsif type == :second_number && value.is_a?(Hash) && value[:first_number]
1396
- # Handle second_number as a hash with first_number context
1397
- # e.g., for pattern 800-57pt1r4
1398
- number_part = value[:first_number].to_s
1399
- part_value = value[:part_value]&.to_s
1400
- revision_value = value[:revision_value]&.to_s
1401
- return {
1402
- first_number: Components::Code.new(number: number_part),
1403
- part: Components::Part.new(value: part_value),
1404
- edition: Components::Edition.new(type: "r", id: revision_value),
1405
- }
1406
- end
1407
-
1408
- # Extract revision suffix from number (e.g., "53r5" → "53" + Edition(r, 5))
1409
- # ENHANCED: Also extract revision with slash-year (e.g., "53r5/1917" → "53" + Edition)
1410
- # ENHANCED: Also extract revision with 4-digit year (e.g., "1019r1963" → "1019" + Edition)
1411
- # ENHANCED: Also extract revision with month+year (e.g., "4743rJun1992" → "4743" + Edition)
1412
-
1413
- # NEW: Extract part suffix from number (e.g., "800-57pt1" → "800-57" + Part(1))
1414
- # This handles SP series part notation
1415
- # IMPORTANT: Handle combined part+revision first (e.g., "800-57pt1r4")
1416
- if str_value =~ /^(.+?)pt(\d+)r(\d+[a-z]?)$/
1417
- number_part = $1
1418
- part_value = $2
1419
- revision_value = $3
1420
- return {
1421
- type => Components::Code.new(number: number_part),
1422
- part: Components::Part.new(type: "pt", value: part_value),
1423
- edition: Components::Edition.new(type: "r", id: revision_value),
1424
- }
1425
- elsif str_value =~ /^(.+?)pt(\d+)$/
1426
- number_part = $1
1427
- part_value = $2
1428
- return {
1429
- type => Components::Code.new(number: number_part),
1430
- part: Components::Part.new(type: "pt", value: part_value),
1431
- }
1432
- end
1433
-
1434
- # NEW: Extract volume suffix from number (e.g., "539v10" → "539" + volume="10")
1435
- # This handles CIRC volume notation
1436
- if str_value =~ /^(\d+)v(\d+)$/
1437
- number_part = $1
1438
- volume_part = $2
1439
- return {
1440
- type => Components::Code.new(number: number_part),
1441
- volume: volume_part,
1442
- }
1443
- end
1444
-
1445
- # REVISION PATTERNS - These must come BEFORE letter suffix to avoid conflicts
1446
- case str_value
1447
- when /^(.+?)(r\d+\/\d{4})$/i
1448
- # Pattern: r6/1925 (revision with slash-year)
1449
- number_part = $1
1450
- revision_with_year = $2 # e.g., "r6/1925"
1451
- # Extract revision and year
1452
- if revision_with_year =~ /^r(\d+)\/(\d{4})$/
1453
- revision_id = $1
1454
- year_part = $2
1455
- return {
1456
- type => Components::Code.new(number: number_part),
1457
- edition: Components::Edition.new(type: "r", id: revision_id,
1458
- additional_text: year_part),
1459
- }
1460
- end
1461
- when /^(.+?)(r\d{4})$/i
1462
- # Pattern: r1963 (revision as 4-digit year)
1463
- number_part = $1
1464
- year_value = $2.sub(/^r/, "") # Strip 'r' prefix
1465
- return {
1466
- type => Components::Code.new(number: number_part),
1467
- edition: Components::Edition.new(type: "r", id: year_value),
1468
- }
1469
- when /^(.+?)(r[A-Za-z]{3,9}\d{4})$/i
1470
- # Pattern: rJun1992 (revision with month and year)
1471
- number_part = $1
1472
- revision_with_date = $2 # e.g., "rJun1992"
1473
- # Extract month and year
1474
- if revision_with_date =~ /^r([A-Za-z]{3,9})(\d{4})$/
1475
- month_part = $1
1476
- year_part = $2
1477
- return {
1478
- type => Components::Code.new(number: number_part),
1479
- edition: Components::Edition.new(type: "r",
1480
- id: "#{month_part}#{year_part}"),
1481
- }
1482
- end
1483
- when /^(.+?)(r\d+[a-z]?)$/i
1484
- # Pattern: r5, r1a (simple revision)
1485
- number_part = $1
1486
- revision_value = $2.sub(/^r/, "") # Strip 'r' prefix
1487
- return {
1488
- type => Components::Code.new(number: number_part),
1489
- edition: Components::Edition.new(type: "r", id: revision_value),
1490
- }
1491
- when /^(.+?)(?<![a-zA-Z])(r)$/i
1492
- # Pattern: bare r with no digits (e.g., "800-90r")
1493
- # Negative lookbehind ensures r is NOT preceded by a letter (avoids matching Ur, Ua, etc.)
1494
- number_part = $1
1495
- return {
1496
- type => Components::Code.new(number: number_part),
1497
- edition: Components::Edition.new(type: "r", id: "1"),
1498
- }
1499
- end
1500
-
1501
- # NEW: Extract UPPERCASE letter suffix as Part component (e.g., "800-56A" → "800-56" + Part)
1502
- # IMPORTANT: These patterns come AFTER revision patterns to avoid conflicts
1503
- # Letter suffixes are UPPERCASE letters A-Z only (no lowercase to avoid revision markers)
1504
-
1505
- # Pattern: UPPERCASE letter + revision (e.g., "800-56Ar2" → number + Part("", "A") + Edition(r, 2))
1506
- # NO /i flag - only match uppercase letters!
1507
- if str_value =~ /^(.+?)([A-Z])(r\d+[a-z]?)$/
1508
- number_part = $1
1509
- letter_part = $2
1510
- revision_part = $3.sub(/^r/, "")
1511
- return {
1512
- type => Components::Code.new(number: number_part),
1513
- part: Components::Part.new(type: "", value: letter_part),
1514
- edition: Components::Edition.new(type: "r", id: revision_part),
1515
- }
1516
- # Pattern: bare UPPERCASE letter suffix (e.g., "800-56A" → number + Part("", "A"))
1517
- # Only matches uppercase letters - won't match revision markers
1518
- # IMPORTANT: For MR format preservation, keep letter suffix as part of number
1519
- # IMPORTANT: For Report, FIPS, IR, and LC series, preserve letter suffix as part of number
1520
- elsif str_value =~ /^(.+?)([A-Z])$/
1521
- number_part = $1
1522
- letter_part = $2
1523
- # Check if we should preserve letter suffix in number
1524
- # Check for specific series that need letter suffix preserved
1525
- is_report = begin
1526
- parsed_hash[:series].to_s.include?("RPT")
1527
- rescue StandardError
1528
- false
1529
- end
1530
- is_fips = begin
1531
- parsed_hash[:series].to_s.include?("FIPS")
1532
- rescue StandardError
1533
- false
1534
- end
1535
- is_ir = begin
1536
- parsed_hash[:series].to_s.include?("IR")
1537
- rescue StandardError
1538
- false
1539
- end
1540
- is_crpl = begin
1541
- parsed_hash[:series].to_s.include?("CRPL")
1542
- rescue StandardError
1543
- false
1544
- end
1545
- is_mono = begin
1546
- parsed_hash[:series].to_s.include?("MONO")
1547
- rescue StandardError
1548
- false
1549
- end
1550
- is_mp = begin
1551
- parsed_hash[:series].to_s.include?("MP")
1552
- rescue StandardError
1553
- false
1554
- end
1555
- # Check for LC but exclude LCIRC (Letter Circular uses LC, not LCIRC)
1556
- is_lc = begin
1557
- parsed_hash[:series].to_s.include?("LC") && !parsed_hash[:series].to_s.include?("LCIRC")
1558
- rescue StandardError
1559
- false
1560
- end
1561
-
1562
- if parsed_hash[:parsed_format] == :mr || is_report || is_fips || is_ir || is_crpl || is_lc || is_mono || is_mp
1563
- # For MR format, Report, FIPS, IR, CRPL, LC, MONO, and MP, preserve letter suffix as part of number
1564
- return { type => Components::Code.new(number: str_value) }
1565
- else
1566
- # For other formats, extract letter suffix as separate Part component
1567
- return {
1568
- type => Components::Code.new(number: number_part),
1569
- part: Components::Part.new(type: "", value: letter_part),
1570
- }
1571
- end
1572
- end
1573
-
1574
- Components::Code.new(number: str_value)
1575
-
1576
- when :crpl_range
1577
- return nil if value.nil? || value.to_s.strip.empty?
1578
-
1579
- # For CRPL range patterns like "2_3-1" or "2_3-1A" (with supplement)
1580
- # Format: X_Y-Z where X,Y,Z are digits, optional trailing letter is supplement
1581
- # This should split into:
1582
- # - X → second_number (to combine with first_number as "1-X")
1583
- # - Y-Z → Part component (with type "pt" for CRPL)
1584
- # - trailing letter (if present) → Supplement
1585
- str_value = value.to_s
1586
-
1587
- # Check for supplement letter suffix (e.g., "2_3-1A" → supplement="A")
1588
- if str_value =~ /^(\d+)_(\d+-\d+)([A-Z])$/
1589
- second_num_part = $1 # "2"
1590
- part_value = $2 # "3-1"
1591
- supplement_letter = $3 # "A"
1592
-
1593
- # Return second_number, Part, and Supplement
1594
- {
1595
- second_number: Components::Code.new(number: second_num_part),
1596
- part: Components::Part.new(type: "pt", value: part_value),
1597
- supplement: supplement_letter,
1598
- }
1599
- elsif str_value =~ /^(\d+)_(\d+-\d+)$/
1600
- # No supplement letter
1601
- second_num_part = $1 # "2"
1602
- part_value = $2 # "3-1"
1603
-
1604
- # Return second_number and Part
1605
- {
1606
- second_number: Components::Code.new(number: second_num_part),
1607
- part: Components::Part.new(type: "pt", value: part_value),
1608
- }
1609
- else
1610
- # Fallback: treat entire value as second_number (shouldn't happen with valid CRPL patterns)
1611
- Components::Code.new(number: str_value)
1612
- end
1613
-
1614
- # ========== V2 COMPONENT CASTING ==========
1615
-
1616
- when :stage
1617
- # Stage from nested hash with id and type
1618
- return nil unless value.is_a?(Hash)
1619
-
1620
- stage_id = value[:stage_id]&.to_s&.downcase
1621
- stage_type = value[:stage_type]&.to_s&.downcase
1622
- return nil if stage_id.nil? || stage_type.nil? || stage_id.empty? || stage_type.empty?
1623
-
1624
- # Return as hash to set the stage attribute
1625
- { stage: Components::Stage.new(id: stage_id, type: stage_type) }
1626
-
1627
- when :stage_id, :stage_type
1628
- # These are captured by :stage, so skip individual processing
1629
- nil
1630
-
1631
- when :parsed_format
1632
- # Format detection result from parser
1633
- value&.to_s
1634
-
1635
- when :translation
1636
- # V1 TRANSLATION NORMALIZATION
1637
- return nil if value.nil? || value.to_s.strip.empty?
1638
-
1639
- code = value.to_s.strip.downcase
1640
- # Apply normalization map (es → spa, pt → por, etc.)
1641
- normalized_code = TRANSLATION_MAP[code] || code
1642
-
1643
- # Return as hash to set translation_component attribute
1644
- { translation_component: Components::Translation.new(code: normalized_code) }
1645
-
1646
- when :version
1647
- # Version component with dotted notation
1648
- return nil if value.nil? || value.to_s.strip.empty?
1649
-
1650
- # Return as hash to set version_component attribute
1651
- { version_component: Components::Version.new(value: value.to_s) }
1652
-
1653
- when :update
1654
- # Update component with number, year, and optional month
1655
- if value.is_a?(Hash)
1656
- # Convert Parslet slice to regular Hash for reliable key access
1657
- value_hash = value.to_h
1658
-
1659
- number = value_hash[:update_number]&.to_s # Don't default to "1"
1660
- year = value_hash[:update_year]&.to_s # String not integer
1661
- month = value_hash[:update_month]&.to_s # String not integer
1662
-
1663
- # Determine prefix from update_prefix key (captured by parser)
1664
- # If not present, default to "slash" (/Upd format)
1665
- prefix_str = value_hash[:update_prefix]&.to_s
1666
- prefix_value = if prefix_str&.include?("-") || prefix_str == "-upd"
1667
- "dash"
1668
- else
1669
- "slash"
1670
- end
1671
-
1672
- # Create update with at least number
1673
- update_obj = Components::Update.new(number: number, year: year,
1674
- month: month, prefix: prefix_value)
1675
- {
1676
- update: update_obj, # Main attribute for tests
1677
- update_component: update_obj, # V2 component
1678
- }
1679
- elsif value.to_s.strip.empty?
1680
- # Empty update string means "-upd" or "/upd" with no details
1681
- # Create Update with default number="1" (no year/month)
1682
- # Check update_prefix key to determine correct prefix format
1683
- prefix_str = parsed_hash[:update_prefix]&.to_s
1684
- prefix_value = if prefix_str&.include?("-") || prefix_str == "-upd"
1685
- "dash"
1686
- else
1687
- "slash"
1688
- end
1689
- update_obj = Components::Update.new(number: "1", year: nil,
1690
- month: nil, prefix: prefix_value)
1691
- {
1692
- update: update_obj,
1693
- update_component: update_obj,
1694
- }
1695
- else
1696
- # Simple string value - shouldn't reach here
1697
- { update: value.to_s.strip } unless value.to_s.strip.empty?
1698
- end
1699
-
1700
- when :update_prefix, :update_number, :update_year, :update_month
1701
- # Captured as part of :update processing
1702
- nil
1703
-
1704
- # ========== END V2 COMPONENTS ==========
1705
-
1706
- when :volume, :section, :appendix, :translation,
1707
- :errata, :index, :insert, :version
1708
- return nil if value.nil?
1709
- return nil if value.is_a?(Array) && value.empty?
1710
-
1711
- str_value = value.to_s.strip
1712
- return nil if str_value.empty?
1713
-
1714
- # For volume, create Volume component from string value
1715
- # This handles patterns like "v1" that come from parser as simple strings
1716
- if type == :volume
1717
- { volume: Components::Volume.new(value: str_value) }
1718
- else
1719
- str_value
1720
- end
1721
-
1722
- when :revision
1723
- # Revision MUST be Edition component with type "r"
1724
- return nil if value.nil? || value.to_s.strip.empty?
1725
-
1726
- # Handle new structure with :revision_prefix and :revision_id (format preservation)
1727
- if value.is_a?(Hash) && value[:revision_prefix] && value[:revision_id]
1728
- prefix = value[:revision_prefix].to_s
1729
- id = value[:revision_id].to_s.strip
1730
-
1731
- # Normalize bare "r" → "r1"
1732
- revision_id = if id.empty? || id == "r" || id == "R"
1733
- "1"
1734
- # Handle "r4", "R5", "4" etc. (but prefix already has the r/rev/etc.)
1735
- elsif id =~ /^(\d+[a-z]?)$/
1736
- $1
1737
- else
1738
- id
1739
- end
1740
-
1741
- # Return Edition component with original_prefix for format preservation
1742
- {
1743
- edition: Components::Edition.new(type: "r", id: revision_id,
1744
- original_prefix: prefix),
1745
- }
1746
- else
1747
- # Legacy handling: revision as simple string value
1748
- str_value = value.to_s.strip
1749
-
1750
- # Handle bare "r" → normalize to "r1"
1751
- revision_id = if str_value.empty? || str_value == "r" || str_value == "R"
1752
- "1"
1753
- # Handle "r4", "R5", "4" etc.
1754
- elsif str_value =~ /^[rR]?(\d+[a-z]?)$/
1755
- $1
1756
- else
1757
- str_value
1758
- end
1759
-
1760
- # Return Edition component (no original_prefix available)
1761
- {
1762
- edition: Components::Edition.new(type: "r", id: revision_id),
1763
- }
1764
- end
1765
-
1766
- when :revision_year, :revision_month
1767
- # When revision_year comes from parser as separate element (e.g., "1019 r1963")
1768
- # Create Edition component
1769
- if type == :revision_year
1770
- year_value = value.to_s.strip
1771
- # Check if this should be an Edition component or legacy revision_year
1772
- # If revision_month is also present, use legacy attributes for "revJune1908" pattern
1773
- if parsed_hash[:revision_month]
1774
- # Legacy: revision with month - keep as revision_year/revision_month
1775
- year_value
1776
- else
1777
- # V2: revision with year only - create Edition component
1778
- {
1779
- edition: Components::Edition.new(type: "r", id: year_value),
1780
- }
1781
- end
1782
- else
1783
- # revision_month - preserve as string for legacy rendering
1784
- return nil if value.nil? || value.to_s.strip.empty?
1785
-
1786
- value.to_s.strip
1787
- end
1788
-
1789
- when :edition_year_separate
1790
- # NEW: Edition year from "e2-1915" pattern (captured separately by parser)
1791
- # This comes with first_number like "11e2" and separate year "1915"
1792
- # Already handled in first_number regex matching above, but if it reaches here
1793
- # as a separate capture, we need to process it
1794
- return nil if value.nil? || value.to_s.strip.empty?
1795
-
1796
- value.to_s # Return as string for potential use
1797
-
1798
- when :historical_month
1799
- # NEW: Historical month from "-April1909" pattern
1800
- # Handled in first_number pattern matching, but return as string if separate
1801
- return nil if value.nil? || value.to_s.strip.empty?
1802
-
1803
- value.to_s
1804
-
1805
- when :historical_year
1806
- # NEW: Historical year from "-April1909" pattern
1807
- # Handled in first_number pattern matching, but return as string if separate
1808
- return nil if value.nil? || value.to_s.strip.empty?
1809
-
1810
- value.to_s
1811
-
1812
- when :supplement_year
1813
- # NEW: Supplement year from "supp-1924" pattern (captured separately by parser)
1814
- # This comes with first_number like "25supp" and separate year "1924"
1815
- # Already handled in first_number regex matching above, but if it reaches here
1816
- # as a separate capture, return as supplement value
1817
- return nil if value.nil? || value.to_s.strip.empty?
1818
-
1819
- { supplement: value.to_s } # Return as supplement attribute
1820
-
1821
- when :supplement
1822
- handle_supplement_cast(value)
1823
-
1824
- when :supplement_date_range
1825
- return nil unless value.is_a?(Hash)
1826
-
1827
- month_start = value[:supp_month_start]&.to_s
1828
- year_start = value[:supp_year_start]&.to_s
1829
- month_end = value[:supp_month_end]&.to_s
1830
- year_end = value[:supp_year_end]&.to_s
1831
-
1832
- {
1833
- supplement_date_range_start: (month_start && year_start ? "#{month_start}#{year_start}" : nil),
1834
- supplement_date_range_end: (month_end && year_end ? "#{month_end}#{year_end}" : nil),
1835
- }
1836
-
1837
- when :supplement_date
1838
- return nil unless value.is_a?(Hash)
1839
-
1840
- month = value[:supp_month]&.to_s
1841
- year = value[:supp_year]&.to_s
1842
-
1843
- month && year ? "#{month}#{year}" : nil
1844
-
1845
- when :supplement_slash_year
1846
- return nil unless value.is_a?(Hash)
1847
-
1848
- number = value[:supp_number]&.to_s
1849
- year = value[:supp_year]&.to_s
1850
-
1851
- number && year ? "#{number}/#{year}" : nil
1852
-
1853
- when :supplement_with_rev
1854
- { supplement: "", supplement_has_revision: true }
1855
-
1856
- when :supp_year
1857
- # Parser extracts supplement year from patterns like "187supp1924"
1858
- # This should set the supplement attribute with the year value
1859
- { supplement: value.to_s }
1860
-
1861
- # ========== V2 EDITION COMPONENT ==========
1862
-
1863
- when :edition_e_date
1864
- # Edition with "e" prefix + 6-digit date (YYYYMM): e199206, e202103
1865
- # Used for IR revision+month patterns after preprocessing: "4743rJun1992" → "4743e199206"
1866
- return nil unless value.is_a?(Hash) && value[:edition_date]
1867
-
1868
- edition_date = value[:edition_date].to_s
1869
- # Parse 6-digit date as YYYYMM
1870
- # Store as id directly - renders as "e199206"
1871
- {
1872
- edition: Components::Edition.new(type: "e", id: edition_date),
1873
- edition_component: Components::Edition.new(type: "e",
1874
- id: edition_date),
1875
- }
1876
-
1877
- when :edition_e
1878
- # Edition with "e" prefix: e2, e2021
1879
- return nil unless value.is_a?(Hash) && value[:edition_id]
1880
-
1881
- edition_id = value[:edition_id].to_s
1882
-
1883
- {
1884
- edition: Components::Edition.new(type: "e", id: edition_id),
1885
- edition_component: Components::Edition.new(type: "e",
1886
- id: edition_id),
1887
- }
1888
-
1889
- when :edition_r
1890
- # Revision with "r" prefix: r5, r2021
1891
- return nil unless value.is_a?(Hash) && value[:edition_id]
1892
-
1893
- edition_id = value[:edition_id].to_s
1894
-
1895
- {
1896
- edition: Components::Edition.new(type: "r", id: edition_id),
1897
- edition_component: Components::Edition.new(type: "r",
1898
- id: edition_id),
1899
- revision: "r#{edition_id}", # Also set revision string attribute for compatibility
1900
- }
1901
-
1902
- when :edition_r_no_space
1903
- # Revision with "r" prefix (no space pattern): r2, r5
1904
- # Used for patterns like "800-56Ar2" where edition is "r2"
1905
- return nil unless value.is_a?(Hash) && value[:edition_id]
1906
-
1907
- edition_id = value[:edition_id].to_s
1908
-
1909
- {
1910
- edition: Components::Edition.new(type: "r", id: edition_id),
1911
- edition_component: Components::Edition.new(type: "r",
1912
- id: edition_id),
1913
- revision: "r#{edition_id}", # Also set revision string attribute for compatibility
1914
- }
1915
-
1916
- when :edition_rev
1917
- # Revision with "rev" prefix (verbose): rev2013, rev 2013
1918
- return nil unless value.is_a?(Hash) && value[:edition_id]
1919
-
1920
- edition_id = value[:edition_id].to_s
1921
-
1922
- {
1923
- edition: Components::Edition.new(type: "r", id: edition_id),
1924
- edition_component: Components::Edition.new(type: "r",
1925
- id: edition_id),
1926
- revision: "r#{edition_id}", # Also set revision string attribute for compatibility
1927
- }
1928
-
1929
- when :edition_r_letter
1930
- # Revision with "r" prefix and letter suffix: r1a, r2b (for SP patterns like 800-22r1a)
1931
- return nil unless value.is_a?(Hash) && value[:edition_id] && value[:edition_letter]
1932
-
1933
- edition_id = value[:edition_id].to_s
1934
- edition_letter = value[:edition_letter].to_s.downcase
1935
-
1936
- {
1937
- edition: Components::Edition.new(type: "r", id: edition_id,
1938
- additional_text: edition_letter),
1939
- edition_component: Components::Edition.new(type: "r",
1940
- id: edition_id,
1941
- additional_text: edition_letter),
1942
- revision: "r#{edition_id}#{edition_letter}", # Also set revision string attribute for compatibility
1943
- }
1944
-
1945
- when :edition_r_letter_only
1946
- # Revision with "r" prefix and only letter (no digit): ra, rb (for SP patterns like 800-27ra)
1947
- return nil unless value.is_a?(Hash) && value[:edition_letter]
1948
-
1949
- edition_letter = value[:edition_letter].to_s.downcase
1950
-
1951
- {
1952
- edition: Components::Edition.new(type: "r", id: edition_letter),
1953
- edition_component: Components::Edition.new(type: "r",
1954
- id: edition_letter),
1955
- revision: "r#{edition_letter}", # Also set revision string attribute for compatibility
1956
- }
1957
-
1958
- when :edition_historical
1959
- # Historical with "-" prefix: -3, -4
1960
- return nil unless value.is_a?(Hash) && value[:edition_id]
1961
-
1962
- edition_id = value[:edition_id].to_s
1963
-
1964
- {
1965
- edition: Components::Edition.new(type: "-", id: edition_id),
1966
- edition_component: Components::Edition.new(type: "-",
1967
- id: edition_id),
1968
- }
1969
-
1970
- when :edition_r_with_space_letter
1971
- # Revision with "r" prefix, space, and letter: r 5A (format preservation)
1972
- # Used for patterns like "NIST SP 800-53 r5A"
1973
- # NOTE: If there's an update component, the space was added by preprocessing
1974
- return nil unless value.is_a?(Hash) && value[:edition_id] && value[:edition_letter]
1975
-
1976
- edition_id = value[:edition_id].to_s
1977
- edition_letter = value[:edition_letter].to_s.upcase
1978
-
1979
- # Check if this is an embedded edition with update (space added by preprocessing)
1980
- has_update = parsed_hash[:update_prefix] || parsed_hash[:update]
1981
-
1982
- if has_update
1983
- # No original_prefix - space was added by preprocessing
1984
- {
1985
- edition: Components::Edition.new(type: "r", id: edition_id,
1986
- additional_text: edition_letter),
1987
- edition_component: Components::Edition.new(type: "r",
1988
- id: edition_id,
1989
- additional_text: edition_letter),
1990
- revision: "r#{edition_id}#{edition_letter}",
1991
- }
1992
- else
1993
- # Space was in original input - preserve format
1994
- {
1995
- edition: Components::Edition.new(type: "r", id: edition_id,
1996
- additional_text: edition_letter,
1997
- original_prefix: " r"),
1998
- edition_component: Components::Edition.new(type: "r",
1999
- id: edition_id,
2000
- additional_text: edition_letter,
2001
- original_prefix: " r"),
2002
- revision: "r#{edition_id}#{edition_letter}",
2003
- }
2004
- end
2005
-
2006
- when :edition_r_with_space
2007
- # Revision with "r" prefix and space: r 5 (format preservation)
2008
- # Used for patterns like "NIST SP 800-53 r5"
2009
- # NOTE: If there's an update component, the space was added by preprocessing
2010
- # for patterns like "8115r1/upd" → "8115 r1/upd", so don't set original_prefix
2011
- return nil unless value.is_a?(Hash) && value[:edition_id]
2012
-
2013
- edition_id = value[:edition_id].to_s
2014
-
2015
- # Check if this is an embedded edition with update (space added by preprocessing)
2016
- # Patterns like "8115r1/upd" become "8115 r1/upd" after preprocessing
2017
- has_update = parsed_hash[:update_prefix] || parsed_hash[:update]
2018
-
2019
- if has_update
2020
- # No original_prefix - space was added by preprocessing
2021
- {
2022
- edition: Components::Edition.new(type: "r", id: edition_id),
2023
- edition_component: Components::Edition.new(type: "r",
2024
- id: edition_id),
2025
- revision: "r#{edition_id}",
2026
- }
2027
- else
2028
- # Space was in original input - preserve format
2029
- {
2030
- edition: Components::Edition.new(type: "r", id: edition_id,
2031
- original_prefix: " r"),
2032
- edition_component: Components::Edition.new(type: "r",
2033
- id: edition_id,
2034
- original_prefix: " r"),
2035
- revision: "r#{edition_id}",
2036
- }
2037
- end
2038
-
2039
- when :edition_id
2040
- # Captured by edition_e, edition_r, edition_rev, edition_historical
2041
- nil
2042
-
2043
- when :edition_date
2044
- # Captured by edition_e_date
2045
- nil
2046
-
2047
- # ========== LEGACY EDITION (for migration) ==========
2048
-
2049
- when :legacy_edition
2050
- # Legacy edition patterns - will be phased out
2051
- # For now, map to old edition_year/edition_month attributes
2052
- nil # Handled by existing edition_year logic below
2053
-
2054
- when :edition_month, :edition_year, :edition_day, :edition_has_rev
2055
- # These work together: edition_month + edition_year → single edition ID
2056
- # Skip processing if this is edition_month alone (will be processed with edition_year)
2057
- return nil if type == :edition_month
2058
-
2059
- # Process edition_year, combining with edition_month if present
2060
- return nil if value.nil? || value.to_s.strip.empty?
2061
-
2062
- # Build the edition ID from year and optional month
2063
- edition_id = value.to_s # Start with year (e.g., "1985")
2064
-
2065
- # Add month if present (e.g., "Mar" → "03", so "1985" + "03" = "198503")
2066
- # For FIPS with day: "Sep30/1977" → "19770930" (year + month + day)
2067
- if parsed_hash[:edition_month]
2068
- month_str = parsed_hash[:edition_month].to_s
2069
- month_num = Date::ABBR_MONTHNAMES.index(month_str) ||
2070
- Date::MONTHNAMES.index(month_str) ||
2071
- month_str.to_i
2072
- if month_num&.positive?
2073
- # Check if this is FIPS series - FIPS uses number format (e198503), not month abbreviations
2074
- # For historical NBS documents, preserve month name: "April1909" not "190904"
2075
- is_fips = parsed_hash[:series]&.to_s == "FIPS"
2076
- if !is_fips && month_str.match?(/^[A-Z][a-z]+/) && edition_id.to_s.match?(/^\d{4}$/)
2077
- # Historical NBS month+year format: preserve month name, use "-" type for special rendering
2078
- edition_obj = Components::Edition.new(
2079
- type: "-",
2080
- id: "",
2081
- additional_text: "#{month_str}#{edition_id}",
2082
- )
2083
- return {
2084
- edition: edition_obj,
2085
- edition_component: edition_obj,
2086
- edition_year: edition_id.to_s,
2087
- }
2088
- else
2089
- # Modern format (and FIPS): combine year and month as single number: 1985 + 03 = 198503
2090
- edition_id = "#{edition_id}#{format('%02d', month_num)}"
2091
-
2092
- # For FIPS with day, append day as well: "Sep30/1977" → "19770930"
2093
- if is_fips && parsed_hash[:edition_day]
2094
- day_num = parsed_hash[:edition_day].to_s.to_i
2095
- if day_num.positive? && day_num <= 31
2096
- edition_id = "#{edition_id}#{format('%02d', day_num)}"
2097
- end
2098
- end
2099
- end
2100
- end
2101
- end
2102
-
2103
- # Create Edition component with type="e" (edition) and combined ID
2104
- edition_obj = Components::Edition.new(type: "e", id: edition_id)
2105
-
2106
- # Return as hash to set edition and edition_year
2107
- {
2108
- edition: edition_obj, # Main attribute for tests
2109
- edition_component: edition_obj, # V2 component
2110
- edition_year: value.to_s, # Keep string for render logic
2111
- }
2112
-
2113
- when :part
2114
- # Part component - handle part number with optional addendum
2115
- return nil if value.nil? || value.to_s.strip.empty?
2116
-
2117
- str_value = value.to_s.strip
2118
-
2119
- # Pattern: "1adde1" → Part(value: "1"), addendum=true
2120
- # Note: eN after add is discarded (not included in output per fixture)
2121
- if str_value =~ /^(\d+)add(e\d+)$/
2122
- {
2123
- part: Components::Part.new(type: "pt", value: $1),
2124
- addendum: "true",
2125
- }
2126
- elsif str_value =~ /^(\d+)add/
2127
- {
2128
- part: Components::Part.new(type: "pt", value: $1),
2129
- addendum: "true",
2130
- }
2131
- else
2132
- # Just a part number - return Part component with pt type
2133
- { part: Components::Part.new(type: "pt", value: str_value) }
2134
- end
2135
-
2136
- when :part_extracted
2137
- # Legacy - this is now handled by :part
2138
- nil
2139
-
2140
- when :edition_letter
2141
- return nil if value.nil? || value.to_s.strip.empty?
2142
-
2143
- value.to_s
2144
-
2145
- when :public_draft
2146
- return nil if value.nil?
2147
-
2148
- value.to_s
2149
-
2150
- when :draft
2151
- # Extract draft number from "-draft N" pattern for pd rendering
2152
- return nil if value.nil?
2153
-
2154
- str_value = value.to_s.strip
2155
- return nil if str_value.empty?
2156
-
2157
- # Pattern: " -draft 2" or "-draft 2" → extract "2" for pd rendering
2158
- if str_value =~ /^\s*-draft\s+(\d+)$/
2159
- { draft_number: $1 }
2160
- # Pattern: " 2pd" → already in pd format
2161
- elsif str_value =~ /^\s*(\d+)pd$/
2162
- { public_draft: $1 }
2163
- # Other patterns (parenthetical, simple -draft)
2164
- else
2165
- str_value
2166
- end
2167
-
2168
- when :update
2169
- handle_update_cast(value)
2170
-
2171
- when :update_number, :update_year
2172
- return nil if value.nil? || value.to_s.strip.empty?
2173
-
2174
- value.to_s
2175
-
2176
- when :addendum
2177
- handle_addendum_cast(value)
2178
-
2179
- when :addendum_number
2180
- return nil if value.nil? || value.to_s.strip.empty?
2181
-
2182
- value.to_s
2183
-
2184
- when :supplement_suffix
2185
- # Return as hash to set supplement attribute (not supplement_suffix)
2186
- { supplement: value.to_s }
2187
-
2188
- when :date
2189
- # Date component per NIST spec
2190
- return nil unless value.is_a?(Hash)
2191
-
2192
- # NEW: Check if this is historical edition pattern ("-April1909")
2193
- # Parser captures as date with month + year, but semantically it's an edition
2194
- if value[:date_month] && value[:date_year] && !value[:date_day]
2195
- month_str = value[:date_month].to_s
2196
- year_str = value[:date_year].to_s
2197
- # If month is a word like "April", this is historical edition format
2198
- if month_str.match?(/^[A-Za-z]+$/)
2199
- return {
2200
- edition: Components::Edition.new(type: "-",
2201
- additional_text: "#{month_str}#{year_str}"),
2202
- }
2203
- end
2204
- end
2205
-
2206
- # Regular date processing
2207
- value[:date_year]&.to_s
2208
- value[:date_month]&.to_s
2209
- value[:date_day]&.to_s
2210
-
2211
- else
2212
- # Unknown types: return the original value for default processing
2213
- # This allows hashes with arbitrary structures to be processed
2214
- # e.g., second_number hash with number_only and edition_id
2215
- value.is_a?(Hash) ? value : nil
2216
- end
2217
- end
2218
-
2219
- # Handle supplement casting with all its variants
2220
- def handle_supplement_cast(value)
2221
- return nil unless value
2222
-
2223
- if value.is_a?(Array) && value.empty?
2224
- # Empty array means "supp" was present but no suffix
2225
- ""
2226
- else
2227
- str_value = value.to_s.strip
2228
- str_value.empty? ? nil : str_value
2229
- end
2230
- end
2231
-
2232
- # Handle update casting (number and year)
2233
- def handle_update_cast(value)
2234
- if value.is_a?(Hash)
2235
- {
2236
- update_number: value[:update_number]&.to_s,
2237
- update_year: value[:update_year]&.to_s,
2238
- }.compact
2239
- elsif value.to_s.strip.empty?
2240
- # Empty update string (just "-upd" with no details)
2241
- # Don't create update component - not enough data
2242
- nil
2243
- else
2244
- str_value = value.to_s.strip
2245
- str_value.empty? ? nil : str_value
2246
- end
2247
- end
2248
-
2249
- # Handle addendum casting (number)
2250
- def handle_addendum_cast(value)
2251
- if value.is_a?(Hash)
2252
- addendum_num = value[:addendum_number]&.to_s&.strip
2253
- if addendum_num && !addendum_num.empty?
2254
- { addendum_number: addendum_num }
2255
- else
2256
- { addendum: "true" }
2257
- end
2258
- else
2259
- str_value = value.to_s.strip
2260
- if str_value.empty?
2261
- { addendum: "true" }
2262
- else
2263
- { addendum_number: str_value }
2264
- end
2265
- end
457
+ @circular_supplement_builder.build_circular_supplement(parsed_hash)
2266
458
  end
2267
459
  end
2268
460
  end