pubid 1.15.19 → 2.0.0.pre.alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (604) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/README.adoc +2041 -53
  4. data/archived-gems/pubid-ccsds/update_codes.yaml +1 -0
  5. data/archived-gems/pubid-iec/stages.yaml +129 -0
  6. data/archived-gems/pubid-iec/update_codes.yaml +67 -0
  7. data/archived-gems/pubid-ieee/update_codes.yaml +104 -0
  8. data/archived-gems/pubid-iso/stages.yaml +106 -0
  9. data/archived-gems/pubid-iso/update_codes.yaml +4 -0
  10. data/archived-gems/pubid-itu/i18n.yaml +13 -0
  11. data/archived-gems/pubid-itu/series.yaml +42 -0
  12. data/archived-gems/pubid-nist/publishers.yaml +6 -0
  13. data/archived-gems/pubid-nist/series.yaml +121 -0
  14. data/archived-gems/pubid-nist/stages.yaml +16 -0
  15. data/archived-gems/pubid-nist/update_codes.yaml +93 -0
  16. data/archived-gems/pubid-plateau/update_codes.yaml +6 -0
  17. data/data/ccsds/update_codes.yaml +1 -0
  18. data/data/iec/update_codes.yaml +67 -0
  19. data/data/ieee/update_codes.yaml +104 -0
  20. data/data/iso/update_codes.yaml +21 -0
  21. data/data/nist/update_codes.yaml +89 -0
  22. data/data/plateau/update_codes.yaml +6 -0
  23. data/lib/pubid/amca/builder.rb +176 -0
  24. data/lib/pubid/amca/identifier.rb +57 -0
  25. data/lib/pubid/amca/identifiers/base.rb +64 -0
  26. data/lib/pubid/amca/identifiers/interpretation.rb +51 -0
  27. data/lib/pubid/amca/identifiers/publication.rb +47 -0
  28. data/lib/pubid/amca/identifiers/standard.rb +22 -0
  29. data/lib/pubid/amca/identifiers.rb +12 -0
  30. data/lib/pubid/amca/parser.rb +153 -0
  31. data/lib/pubid/amca/scheme.rb +16 -0
  32. data/lib/pubid/amca/single_identifier.rb +33 -0
  33. data/lib/pubid/amca/urn_generator.rb +50 -0
  34. data/lib/pubid/amca.rb +26 -0
  35. data/lib/pubid/ansi/builder.rb +52 -0
  36. data/lib/pubid/ansi/identifier.rb +55 -0
  37. data/lib/pubid/ansi/identifiers/american_national_standard.rb +12 -0
  38. data/lib/pubid/ansi/identifiers/standard.rb +16 -0
  39. data/lib/pubid/ansi/identifiers.rb +11 -0
  40. data/lib/pubid/ansi/parser.rb +91 -0
  41. data/lib/pubid/ansi/scheme.rb +15 -0
  42. data/lib/pubid/ansi/single_identifier.rb +45 -0
  43. data/lib/pubid/ansi/urn_generator.rb +76 -0
  44. data/lib/pubid/ansi.rb +27 -0
  45. data/lib/pubid/api/builder.rb +85 -0
  46. data/lib/pubid/api/components/code.rb +9 -0
  47. data/lib/pubid/api/identifier.rb +68 -0
  48. data/lib/pubid/api/identifiers/base.rb +24 -0
  49. data/lib/pubid/api/identifiers/bulletin.rb +15 -0
  50. data/lib/pubid/api/identifiers/continuous_operations_standard.rb +15 -0
  51. data/lib/pubid/api/identifiers/mpms.rb +44 -0
  52. data/lib/pubid/api/identifiers/publication.rb +15 -0
  53. data/lib/pubid/api/identifiers/recommended_practice.rb +15 -0
  54. data/lib/pubid/api/identifiers/specification.rb +15 -0
  55. data/lib/pubid/api/identifiers/standard.rb +15 -0
  56. data/lib/pubid/api/identifiers/technical_report.rb +15 -0
  57. data/lib/pubid/api/identifiers/typeless_standard.rb +27 -0
  58. data/lib/pubid/api/parser.rb +140 -0
  59. data/lib/pubid/api/scheme.rb +66 -0
  60. data/lib/pubid/api/single_identifier.rb +46 -0
  61. data/lib/pubid/api/urn_generator.rb +41 -0
  62. data/lib/pubid/api.rb +17 -0
  63. data/lib/pubid/ashrae/builder.rb +498 -0
  64. data/lib/pubid/ashrae/identifier.rb +57 -0
  65. data/lib/pubid/ashrae/identifiers/addenda_package.rb +46 -0
  66. data/lib/pubid/ashrae/identifiers/addendum.rb +55 -0
  67. data/lib/pubid/ashrae/identifiers/base.rb +23 -0
  68. data/lib/pubid/ashrae/identifiers/combined_addenda.rb +51 -0
  69. data/lib/pubid/ashrae/identifiers/errata.rb +40 -0
  70. data/lib/pubid/ashrae/identifiers/guideline.rb +38 -0
  71. data/lib/pubid/ashrae/identifiers/interpretation.rb +39 -0
  72. data/lib/pubid/ashrae/identifiers/standard.rb +38 -0
  73. data/lib/pubid/ashrae/identifiers.rb +16 -0
  74. data/lib/pubid/ashrae/parser.rb +724 -0
  75. data/lib/pubid/ashrae/scheme.rb +53 -0
  76. data/lib/pubid/ashrae/single_identifier.rb +23 -0
  77. data/lib/pubid/ashrae/supplement_identifier.rb +23 -0
  78. data/lib/pubid/ashrae/urn_generator.rb +59 -0
  79. data/lib/pubid/ashrae.rb +21 -0
  80. data/lib/pubid/asme/builder.rb +153 -0
  81. data/lib/pubid/asme/components/code.rb +18 -0
  82. data/lib/pubid/asme/identifier.rb +61 -0
  83. data/lib/pubid/asme/identifiers/base.rb +70 -0
  84. data/lib/pubid/asme/identifiers/standard.rb +12 -0
  85. data/lib/pubid/asme/identifiers.rb +10 -0
  86. data/lib/pubid/asme/parser.rb +308 -0
  87. data/lib/pubid/asme/scheme.rb +37 -0
  88. data/lib/pubid/asme/single_identifier.rb +29 -0
  89. data/lib/pubid/asme/urn_generator.rb +133 -0
  90. data/lib/pubid/asme.rb +21 -0
  91. data/lib/pubid/astm/builder.rb +159 -0
  92. data/lib/pubid/astm/components/code.rb +33 -0
  93. data/lib/pubid/astm/identifier.rb +92 -0
  94. data/lib/pubid/astm/identifiers/adjunct.rb +21 -0
  95. data/lib/pubid/astm/identifiers/base.rb +13 -0
  96. data/lib/pubid/astm/identifiers/data_series.rb +25 -0
  97. data/lib/pubid/astm/identifiers/iso_dual_published.rb +74 -0
  98. data/lib/pubid/astm/identifiers/manual.rb +40 -0
  99. data/lib/pubid/astm/identifiers/monograph.rb +25 -0
  100. data/lib/pubid/astm/identifiers/research_report.rb +18 -0
  101. data/lib/pubid/astm/identifiers/standard.rb +52 -0
  102. data/lib/pubid/astm/identifiers/technical_report.rb +23 -0
  103. data/lib/pubid/astm/identifiers/work_in_progress.rb +21 -0
  104. data/lib/pubid/astm/parser.rb +244 -0
  105. data/lib/pubid/astm/scheme.rb +55 -0
  106. data/lib/pubid/astm/single_identifier.rb +25 -0
  107. data/lib/pubid/astm/urn_generator.rb +99 -0
  108. data/lib/pubid/astm.rb +38 -0
  109. data/lib/pubid/bsi/builder.rb +1483 -0
  110. data/lib/pubid/bsi/components/code.rb +11 -0
  111. data/lib/pubid/bsi/components/date.rb +11 -0
  112. data/lib/pubid/bsi/components/publisher.rb +11 -0
  113. data/lib/pubid/bsi/components/type.rb +11 -0
  114. data/lib/pubid/bsi/identifier.rb +87 -0
  115. data/lib/pubid/bsi/identifiers/addendum_document.rb +64 -0
  116. data/lib/pubid/bsi/identifiers/adopted_european_norm.rb +95 -0
  117. data/lib/pubid/bsi/identifiers/adopted_international_standard.rb +82 -0
  118. data/lib/pubid/bsi/identifiers/aerospace_standard.rb +118 -0
  119. data/lib/pubid/bsi/identifiers/amendment.rb +40 -0
  120. data/lib/pubid/bsi/identifiers/base.rb +11 -0
  121. data/lib/pubid/bsi/identifiers/british_industrial_practice.rb +27 -0
  122. data/lib/pubid/bsi/identifiers/british_standard.rb +33 -0
  123. data/lib/pubid/bsi/identifiers/bundled_identifier.rb +114 -0
  124. data/lib/pubid/bsi/identifiers/committee_document.rb +51 -0
  125. data/lib/pubid/bsi/identifiers/consolidated_identifier.rb +152 -0
  126. data/lib/pubid/bsi/identifiers/corrigendum.rb +28 -0
  127. data/lib/pubid/bsi/identifiers/detailed_specification.rb +69 -0
  128. data/lib/pubid/bsi/identifiers/disc.rb +56 -0
  129. data/lib/pubid/bsi/identifiers/draft_document.rb +71 -0
  130. data/lib/pubid/bsi/identifiers/electronic_book.rb +52 -0
  131. data/lib/pubid/bsi/identifiers/expert_commentary.rb +47 -0
  132. data/lib/pubid/bsi/identifiers/explanatory_supplement.rb +82 -0
  133. data/lib/pubid/bsi/identifiers/flex.rb +61 -0
  134. data/lib/pubid/bsi/identifiers/handbook.rb +39 -0
  135. data/lib/pubid/bsi/identifiers/index.rb +62 -0
  136. data/lib/pubid/bsi/identifiers/method.rb +76 -0
  137. data/lib/pubid/bsi/identifiers/national_annex.rb +73 -0
  138. data/lib/pubid/bsi/identifiers/practice_guide.rb +27 -0
  139. data/lib/pubid/bsi/identifiers/publicly_available_specification.rb +79 -0
  140. data/lib/pubid/bsi/identifiers/published_document.rb +79 -0
  141. data/lib/pubid/bsi/identifiers/section.rb +62 -0
  142. data/lib/pubid/bsi/identifiers/set.rb +46 -0
  143. data/lib/pubid/bsi/identifiers/standalone_amendment.rb +40 -0
  144. data/lib/pubid/bsi/identifiers/supplement_document.rb +51 -0
  145. data/lib/pubid/bsi/identifiers/supplementary_index.rb +81 -0
  146. data/lib/pubid/bsi/identifiers/technical_specification.rb +79 -0
  147. data/lib/pubid/bsi/identifiers/test_method.rb +67 -0
  148. data/lib/pubid/bsi/identifiers/value_added_publication.rb +52 -0
  149. data/lib/pubid/bsi/identifiers.rb +52 -0
  150. data/lib/pubid/bsi/model.rb +196 -0
  151. data/lib/pubid/bsi/parser.rb +659 -0
  152. data/lib/pubid/bsi/scheme.rb +243 -0
  153. data/lib/pubid/bsi/single_identifier.rb +129 -0
  154. data/lib/pubid/bsi/urn_generator.rb +84 -0
  155. data/lib/pubid/bsi.rb +32 -0
  156. data/lib/pubid/builder/base.rb +138 -0
  157. data/lib/pubid/bundled_identifier.rb +126 -0
  158. data/lib/pubid/ccsds/builder.rb +56 -0
  159. data/lib/pubid/ccsds/identifier.rb +84 -0
  160. data/lib/pubid/ccsds/identifiers/base.rb +89 -0
  161. data/lib/pubid/ccsds/identifiers/base_BASE_88929.rb +70 -0
  162. data/lib/pubid/ccsds/identifiers/corrigendum.rb +39 -0
  163. data/lib/pubid/ccsds/identifiers.rb +10 -0
  164. data/lib/pubid/ccsds/parser.rb +71 -0
  165. data/lib/pubid/ccsds/scheme.rb +57 -0
  166. data/lib/pubid/ccsds/single_identifier.rb +77 -0
  167. data/lib/pubid/ccsds/supplement_identifier.rb +33 -0
  168. data/lib/pubid/ccsds/urn_generator.rb +115 -0
  169. data/lib/pubid/ccsds.rb +21 -0
  170. data/lib/pubid/cen_cenelec/builder.rb +330 -0
  171. data/lib/pubid/cen_cenelec/identifier.rb +52 -0
  172. data/lib/pubid/cen_cenelec/identifiers/adopted_european_norm.rb +40 -0
  173. data/lib/pubid/cen_cenelec/identifiers/amendment.rb +29 -0
  174. data/lib/pubid/cen_cenelec/identifiers/base.rb +75 -0
  175. data/lib/pubid/cen_cenelec/identifiers/cen_report.rb +28 -0
  176. data/lib/pubid/cen_cenelec/identifiers/cen_workshop_agreement.rb +27 -0
  177. data/lib/pubid/cen_cenelec/identifiers/cenelec_harmonization_document.rb +28 -0
  178. data/lib/pubid/cen_cenelec/identifiers/consolidated_identifier.rb +61 -0
  179. data/lib/pubid/cen_cenelec/identifiers/corrigendum.rb +35 -0
  180. data/lib/pubid/cen_cenelec/identifiers/european_norm.rb +41 -0
  181. data/lib/pubid/cen_cenelec/identifiers/european_prestandard.rb +37 -0
  182. data/lib/pubid/cen_cenelec/identifiers/european_specification.rb +28 -0
  183. data/lib/pubid/cen_cenelec/identifiers/fragment.rb +22 -0
  184. data/lib/pubid/cen_cenelec/identifiers/guide.rb +27 -0
  185. data/lib/pubid/cen_cenelec/identifiers/harmonization_document.rb +27 -0
  186. data/lib/pubid/cen_cenelec/identifiers/technical_report.rb +27 -0
  187. data/lib/pubid/cen_cenelec/identifiers/technical_specification.rb +35 -0
  188. data/lib/pubid/cen_cenelec/identifiers.rb +32 -0
  189. data/lib/pubid/cen_cenelec/parser.rb +144 -0
  190. data/lib/pubid/cen_cenelec/scheme.rb +164 -0
  191. data/lib/pubid/cen_cenelec/single_identifier.rb +130 -0
  192. data/lib/pubid/cen_cenelec/supplement_identifier.rb +48 -0
  193. data/lib/pubid/cen_cenelec/urn_generator.rb +129 -0
  194. data/lib/pubid/cen_cenelec.rb +21 -0
  195. data/lib/pubid/cie/builder.rb +399 -0
  196. data/lib/pubid/cie/components/code.rb +72 -0
  197. data/lib/pubid/cie/components/language.rb +58 -0
  198. data/lib/pubid/cie/identifier.rb +71 -0
  199. data/lib/pubid/cie/identifiers/bundle.rb +20 -0
  200. data/lib/pubid/cie/identifiers/conference.rb +32 -0
  201. data/lib/pubid/cie/identifiers/corrigendum.rb +40 -0
  202. data/lib/pubid/cie/identifiers/dual_published.rb +41 -0
  203. data/lib/pubid/cie/identifiers/identical.rb +64 -0
  204. data/lib/pubid/cie/identifiers/joint_published.rb +52 -0
  205. data/lib/pubid/cie/identifiers/standard.rb +58 -0
  206. data/lib/pubid/cie/identifiers/supplement.rb +45 -0
  207. data/lib/pubid/cie/identifiers/tutorial_bundle.rb +20 -0
  208. data/lib/pubid/cie/identifiers.rb +17 -0
  209. data/lib/pubid/cie/parser.rb +347 -0
  210. data/lib/pubid/cie/scheme.rb +64 -0
  211. data/lib/pubid/cie/single_identifier.rb +30 -0
  212. data/lib/pubid/cie/supplement_identifier.rb +26 -0
  213. data/lib/pubid/cie/urn_generator.rb +123 -0
  214. data/lib/pubid/cie.rb +28 -0
  215. data/lib/pubid/components/code.rb +33 -0
  216. data/lib/pubid/components/date.rb +49 -0
  217. data/lib/pubid/components/edition.rb +32 -0
  218. data/lib/pubid/components/factory.rb +50 -0
  219. data/lib/pubid/components/language.rb +37 -0
  220. data/lib/pubid/components/locality.rb +10 -0
  221. data/lib/pubid/components/publisher.rb +36 -0
  222. data/lib/pubid/components/stage.rb +54 -0
  223. data/lib/pubid/components/type.rb +58 -0
  224. data/lib/pubid/components/typed_stage.rb +59 -0
  225. data/lib/pubid/components.rb +16 -0
  226. data/lib/pubid/core/pattern_doc_generator.rb +272 -0
  227. data/lib/pubid/core/update_codes.rb +77 -0
  228. data/lib/pubid/core.rb +8 -0
  229. data/lib/pubid/csa/builder.rb +671 -0
  230. data/lib/pubid/csa/components/code.rb +9 -0
  231. data/lib/pubid/csa/components.rb +9 -0
  232. data/lib/pubid/csa/composite_identifier.rb +27 -0
  233. data/lib/pubid/csa/identifier.rb +513 -0
  234. data/lib/pubid/csa/identifiers/base.rb +133 -0
  235. data/lib/pubid/csa/identifiers/bundled.rb +125 -0
  236. data/lib/pubid/csa/identifiers/canadian_adopted.rb +82 -0
  237. data/lib/pubid/csa/identifiers/cec.rb +129 -0
  238. data/lib/pubid/csa/identifiers/combined.rb +130 -0
  239. data/lib/pubid/csa/identifiers/csa_adopted.rb +78 -0
  240. data/lib/pubid/csa/identifiers/package.rb +65 -0
  241. data/lib/pubid/csa/identifiers/series.rb +127 -0
  242. data/lib/pubid/csa/identifiers/standard.rb +10 -0
  243. data/lib/pubid/csa/identifiers.rb +17 -0
  244. data/lib/pubid/csa/parser.rb +445 -0
  245. data/lib/pubid/csa/scheme.rb +44 -0
  246. data/lib/pubid/csa/single_identifier.rb +30 -0
  247. data/lib/pubid/csa/urn_generator.rb +80 -0
  248. data/lib/pubid/csa/wrapper_identifier.rb +31 -0
  249. data/lib/pubid/csa.rb +25 -0
  250. data/lib/pubid/etsi/builder.rb +133 -0
  251. data/lib/pubid/etsi/components/code.rb +42 -0
  252. data/lib/pubid/etsi/components/version.rb +37 -0
  253. data/lib/pubid/etsi/components.rb +10 -0
  254. data/lib/pubid/etsi/identifier.rb +57 -0
  255. data/lib/pubid/etsi/identifiers/amendment.rb +15 -0
  256. data/lib/pubid/etsi/identifiers/base.rb +38 -0
  257. data/lib/pubid/etsi/identifiers/corrigendum.rb +15 -0
  258. data/lib/pubid/etsi/identifiers/etsi_standard.rb +19 -0
  259. data/lib/pubid/etsi/identifiers/supplement_identifier.rb +91 -0
  260. data/lib/pubid/etsi/identifiers.rb +14 -0
  261. data/lib/pubid/etsi/parser.rb +133 -0
  262. data/lib/pubid/etsi/scheme.rb +42 -0
  263. data/lib/pubid/etsi/urn_generator.rb +76 -0
  264. data/lib/pubid/etsi.rb +21 -0
  265. data/lib/pubid/export/auditor.rb +89 -0
  266. data/lib/pubid/export/data_class_exporter.rb +59 -0
  267. data/lib/pubid/export/exporter.rb +74 -0
  268. data/lib/pubid/export/flavor_exporter.rb +402 -0
  269. data/lib/pubid/export/ieee_exporter.rb +78 -0
  270. data/lib/pubid/export/itu_exporter.rb +66 -0
  271. data/lib/pubid/export/nist_exporter.rb +64 -0
  272. data/lib/pubid/export/registry_exporter.rb +90 -0
  273. data/lib/pubid/export/result.rb +97 -0
  274. data/lib/pubid/export/scheme_exporter.rb +70 -0
  275. data/lib/pubid/export.rb +18 -0
  276. data/lib/pubid/format_detector.rb +16 -0
  277. data/lib/pubid/format_registry.rb +42 -0
  278. data/lib/pubid/identifier.rb +242 -0
  279. data/lib/pubid/identifier_metadata.rb +148 -0
  280. data/lib/pubid/identifier_registry.rb +198 -0
  281. data/lib/pubid/idf/builder.rb +82 -0
  282. data/lib/pubid/idf/identifier.rb +129 -0
  283. data/lib/pubid/idf/identifiers/amendment.rb +27 -0
  284. data/lib/pubid/idf/identifiers/corrigendum.rb +27 -0
  285. data/lib/pubid/idf/identifiers/international_standard.rb +123 -0
  286. data/lib/pubid/idf/identifiers/reviewed_method.rb +100 -0
  287. data/lib/pubid/idf/identifiers.rb +13 -0
  288. data/lib/pubid/idf/parser.rb +143 -0
  289. data/lib/pubid/idf/scheme.rb +61 -0
  290. data/lib/pubid/idf/single_identifier.rb +19 -0
  291. data/lib/pubid/idf/supplement_identifier.rb +43 -0
  292. data/lib/pubid/idf/urn_generator.rb +84 -0
  293. data/lib/pubid/idf.rb +25 -0
  294. data/lib/pubid/iec/builder.rb +458 -0
  295. data/lib/pubid/iec/components/code.rb +60 -0
  296. data/lib/pubid/iec/components/consolidated_amendment.rb +59 -0
  297. data/lib/pubid/iec/components/publisher.rb +36 -0
  298. data/lib/pubid/iec/components/sheet.rb +32 -0
  299. data/lib/pubid/iec/components/trf_info.rb +38 -0
  300. data/lib/pubid/iec/components/vap_suffix.rb +41 -0
  301. data/lib/pubid/iec/identifier.rb +256 -0
  302. data/lib/pubid/iec/identifiers/amendment.rb +94 -0
  303. data/lib/pubid/iec/identifiers/base.rb +82 -0
  304. data/lib/pubid/iec/identifiers/component_specification.rb +39 -0
  305. data/lib/pubid/iec/identifiers/conformity_assessment.rb +39 -0
  306. data/lib/pubid/iec/identifiers/consolidated_identifier.rb +82 -0
  307. data/lib/pubid/iec/identifiers/corrigendum.rb +94 -0
  308. data/lib/pubid/iec/identifiers/fragment_identifier.rb +137 -0
  309. data/lib/pubid/iec/identifiers/guide.rb +104 -0
  310. data/lib/pubid/iec/identifiers/international_standard.rb +147 -0
  311. data/lib/pubid/iec/identifiers/interpretation_sheet.rb +104 -0
  312. data/lib/pubid/iec/identifiers/operational_document.rb +39 -0
  313. data/lib/pubid/iec/identifiers/publicly_available_specification.rb +101 -0
  314. data/lib/pubid/iec/identifiers/sheet_identifier.rb +62 -0
  315. data/lib/pubid/iec/identifiers/societal_technology_trend_report.rb +40 -0
  316. data/lib/pubid/iec/identifiers/systems_reference_document.rb +40 -0
  317. data/lib/pubid/iec/identifiers/technical_report.rb +132 -0
  318. data/lib/pubid/iec/identifiers/technical_specification.rb +132 -0
  319. data/lib/pubid/iec/identifiers/technology_report.rb +39 -0
  320. data/lib/pubid/iec/identifiers/test_report_form.rb +78 -0
  321. data/lib/pubid/iec/identifiers/vap_identifier.rb +73 -0
  322. data/lib/pubid/iec/identifiers/white_paper.rb +39 -0
  323. data/lib/pubid/iec/identifiers/working_document.rb +96 -0
  324. data/lib/pubid/iec/parser.rb +417 -0
  325. data/lib/pubid/iec/rendering_style.rb +113 -0
  326. data/lib/pubid/iec/scheme.rb +71 -0
  327. data/lib/pubid/iec/single_identifier.rb +80 -0
  328. data/lib/pubid/iec/supplement_identifier.rb +161 -0
  329. data/lib/pubid/iec/urn_generator.rb +79 -0
  330. data/lib/pubid/iec/urn_parser.rb +90 -0
  331. data/lib/pubid/iec.rb +85 -0
  332. data/lib/pubid/ieee/aiee/builder.rb +71 -0
  333. data/lib/pubid/ieee/aiee/identifier.rb +105 -0
  334. data/lib/pubid/ieee/aiee/parser.rb +130 -0
  335. data/lib/pubid/ieee/aiee.rb +11 -0
  336. data/lib/pubid/ieee/builder.rb +1237 -0
  337. data/lib/pubid/ieee/components/code.rb +102 -0
  338. data/lib/pubid/ieee/components/draft.rb +93 -0
  339. data/lib/pubid/ieee/components/relationship.rb +157 -0
  340. data/lib/pubid/ieee/components/typed_stage.rb +100 -0
  341. data/lib/pubid/ieee/identifier.rb +54 -0
  342. data/lib/pubid/ieee/identifiers/adopted_standard.rb +33 -0
  343. data/lib/pubid/ieee/identifiers/base.rb +591 -0
  344. data/lib/pubid/ieee/identifiers/conformance_identifier.rb +35 -0
  345. data/lib/pubid/ieee/identifiers/corrigendum.rb +37 -0
  346. data/lib/pubid/ieee/identifiers/csa_dual_published.rb +51 -0
  347. data/lib/pubid/ieee/identifiers/dual_identifier.rb +18 -0
  348. data/lib/pubid/ieee/identifiers/dual_published.rb +28 -0
  349. data/lib/pubid/ieee/identifiers/iec_ieee_copublished.rb +27 -0
  350. data/lib/pubid/ieee/identifiers/interpretation_identifier.rb +34 -0
  351. data/lib/pubid/ieee/identifiers/joint_development.rb +172 -0
  352. data/lib/pubid/ieee/identifiers/multi_numbered_identifier.rb +51 -0
  353. data/lib/pubid/ieee/identifiers/nesc/base.rb +56 -0
  354. data/lib/pubid/ieee/identifiers/nesc/draft.rb +28 -0
  355. data/lib/pubid/ieee/identifiers/nesc/handbook.rb +32 -0
  356. data/lib/pubid/ieee/identifiers/nesc/redline.rb +26 -0
  357. data/lib/pubid/ieee/identifiers/nesc/standard.rb +26 -0
  358. data/lib/pubid/ieee/identifiers/nesc.rb +15 -0
  359. data/lib/pubid/ieee/identifiers/parenthetical_identifier.rb +20 -0
  360. data/lib/pubid/ieee/identifiers/project_draft_identifier.rb +26 -0
  361. data/lib/pubid/ieee/identifiers/redlined_standard.rb +33 -0
  362. data/lib/pubid/ieee/identifiers/si_standard.rb +73 -0
  363. data/lib/pubid/ieee/identifiers/standard.rb +41 -0
  364. data/lib/pubid/ieee/identifiers/supplement_identifier.rb +23 -0
  365. data/lib/pubid/ieee/identifiers.rb +33 -0
  366. data/lib/pubid/ieee/ire/builder.rb +61 -0
  367. data/lib/pubid/ieee/ire/identifier.rb +58 -0
  368. data/lib/pubid/ieee/ire/parser.rb +91 -0
  369. data/lib/pubid/ieee/ire.rb +11 -0
  370. data/lib/pubid/ieee/nesc/builder.rb +101 -0
  371. data/lib/pubid/ieee/nesc/parser.rb +154 -0
  372. data/lib/pubid/ieee/nesc.rb +10 -0
  373. data/lib/pubid/ieee/parser.rb +1226 -0
  374. data/lib/pubid/ieee/scheme.rb +90 -0
  375. data/lib/pubid/ieee/typed_stages.rb +172 -0
  376. data/lib/pubid/ieee/urn_generator.rb +188 -0
  377. data/lib/pubid/ieee.rb +32 -0
  378. data/lib/pubid/ieee_debug.rb +31 -0
  379. data/lib/pubid/iho/builder.rb +37 -0
  380. data/lib/pubid/iho/identifier.rb +61 -0
  381. data/lib/pubid/iho/identifiers/base.rb +41 -0
  382. data/lib/pubid/iho/identifiers/bibliographic.rb +16 -0
  383. data/lib/pubid/iho/identifiers/circular_letter.rb +15 -0
  384. data/lib/pubid/iho/identifiers/miscellaneous.rb +16 -0
  385. data/lib/pubid/iho/identifiers/publication.rb +15 -0
  386. data/lib/pubid/iho/identifiers/standard.rb +15 -0
  387. data/lib/pubid/iho/identifiers.rb +14 -0
  388. data/lib/pubid/iho/parser.rb +68 -0
  389. data/lib/pubid/iho/scheme.rb +29 -0
  390. data/lib/pubid/iho/urn_generator.rb +29 -0
  391. data/lib/pubid/iho.rb +21 -0
  392. data/lib/pubid/iso/builder.rb +309 -0
  393. data/lib/pubid/iso/bundled_identifier.rb +85 -0
  394. data/lib/pubid/iso/combined_identifier.rb +22 -0
  395. data/lib/pubid/iso/components/code.rb +36 -0
  396. data/lib/pubid/iso/components/publisher.rb +60 -0
  397. data/lib/pubid/iso/components.rb +12 -0
  398. data/lib/pubid/iso/format_resolver.rb +45 -0
  399. data/lib/pubid/iso/identifier.rb +330 -0
  400. data/lib/pubid/iso/identifiers/addendum.rb +104 -0
  401. data/lib/pubid/iso/identifiers/amendment.rb +128 -0
  402. data/lib/pubid/iso/identifiers/base.rb +115 -0
  403. data/lib/pubid/iso/identifiers/corrigendum.rb +108 -0
  404. data/lib/pubid/iso/identifiers/data.rb +76 -0
  405. data/lib/pubid/iso/identifiers/directives.rb +59 -0
  406. data/lib/pubid/iso/identifiers/directives_supplement.rb +119 -0
  407. data/lib/pubid/iso/identifiers/extract.rb +30 -0
  408. data/lib/pubid/iso/identifiers/guide.rb +100 -0
  409. data/lib/pubid/iso/identifiers/international_standard.rb +168 -0
  410. data/lib/pubid/iso/identifiers/international_standardized_profile.rb +94 -0
  411. data/lib/pubid/iso/identifiers/international_workshop_agreement.rb +89 -0
  412. data/lib/pubid/iso/identifiers/pas.rb +93 -0
  413. data/lib/pubid/iso/identifiers/recommendation.rb +45 -0
  414. data/lib/pubid/iso/identifiers/supplement.rb +87 -0
  415. data/lib/pubid/iso/identifiers/tc_document.rb +108 -0
  416. data/lib/pubid/iso/identifiers/technical_report.rb +103 -0
  417. data/lib/pubid/iso/identifiers/technical_specification.rb +102 -0
  418. data/lib/pubid/iso/identifiers/technology_trends_assessments.rb +95 -0
  419. data/lib/pubid/iso/identifiers.rb +33 -0
  420. data/lib/pubid/iso/parser.rb +512 -0
  421. data/lib/pubid/iso/rendering_style.rb +120 -0
  422. data/lib/pubid/iso/scheme.rb +193 -0
  423. data/lib/pubid/iso/single_identifier.rb +64 -0
  424. data/lib/pubid/iso/supplement_identifier.rb +27 -0
  425. data/lib/pubid/iso/urn_generator.rb +426 -0
  426. data/lib/pubid/iso/urn_parser.rb +437 -0
  427. data/lib/pubid/iso/utilities.rb +86 -0
  428. data/lib/pubid/iso.rb +50 -0
  429. data/lib/pubid/itu/builder.rb +171 -0
  430. data/lib/pubid/itu/components/code.rb +39 -0
  431. data/lib/pubid/itu/components/sector.rb +35 -0
  432. data/lib/pubid/itu/components/series.rb +29 -0
  433. data/lib/pubid/itu/i18n.rb +9 -0
  434. data/lib/pubid/itu/i18n.yaml +30 -0
  435. data/lib/pubid/itu/identifier.rb +118 -0
  436. data/lib/pubid/itu/identifiers/amendment.rb +43 -0
  437. data/lib/pubid/itu/identifiers/annex.rb +74 -0
  438. data/lib/pubid/itu/identifiers/base.rb +154 -0
  439. data/lib/pubid/itu/identifiers/combined_identifier.rb +47 -0
  440. data/lib/pubid/itu/identifiers/corrigendum.rb +44 -0
  441. data/lib/pubid/itu/identifiers/recommendation.rb +16 -0
  442. data/lib/pubid/itu/identifiers/special_publication.rb +31 -0
  443. data/lib/pubid/itu/identifiers/supplement.rb +46 -0
  444. data/lib/pubid/itu/identifiers.rb +16 -0
  445. data/lib/pubid/itu/model.rb +111 -0
  446. data/lib/pubid/itu/parser.rb +225 -0
  447. data/lib/pubid/itu/scheme.rb +174 -0
  448. data/lib/pubid/itu/urn_generator.rb +105 -0
  449. data/lib/pubid/itu.rb +22 -0
  450. data/lib/pubid/jcgm/builder.rb +88 -0
  451. data/lib/pubid/jcgm/components/publisher.rb +20 -0
  452. data/lib/pubid/jcgm/components.rb +9 -0
  453. data/lib/pubid/jcgm/identifier.rb +54 -0
  454. data/lib/pubid/jcgm/identifiers/amendment.rb +35 -0
  455. data/lib/pubid/jcgm/identifiers/guide.rb +21 -0
  456. data/lib/pubid/jcgm/identifiers/gum_guide.rb +51 -0
  457. data/lib/pubid/jcgm/identifiers.rb +11 -0
  458. data/lib/pubid/jcgm/parser.rb +84 -0
  459. data/lib/pubid/jcgm/scheme.rb +60 -0
  460. data/lib/pubid/jcgm/single_identifier.rb +48 -0
  461. data/lib/pubid/jcgm/supplement_identifier.rb +16 -0
  462. data/lib/pubid/jcgm/urn_generator.rb +110 -0
  463. data/lib/pubid/jcgm.rb +31 -0
  464. data/lib/pubid/jis/builder.rb +124 -0
  465. data/lib/pubid/jis/components/code.rb +59 -0
  466. data/lib/pubid/jis/components.rb +9 -0
  467. data/lib/pubid/jis/identifier.rb +61 -0
  468. data/lib/pubid/jis/identifiers/amendment.rb +16 -0
  469. data/lib/pubid/jis/identifiers/base.rb +72 -0
  470. data/lib/pubid/jis/identifiers/explanation.rb +22 -0
  471. data/lib/pubid/jis/identifiers/japanese_industrial_standard.rb +16 -0
  472. data/lib/pubid/jis/identifiers/standard.rb +27 -0
  473. data/lib/pubid/jis/identifiers/technical_report.rb +31 -0
  474. data/lib/pubid/jis/identifiers/technical_specification.rb +31 -0
  475. data/lib/pubid/jis/identifiers.rb +17 -0
  476. data/lib/pubid/jis/parser.rb +109 -0
  477. data/lib/pubid/jis/scheme.rb +49 -0
  478. data/lib/pubid/jis/single_identifier.rb +37 -0
  479. data/lib/pubid/jis/supplement_identifier.rb +47 -0
  480. data/lib/pubid/jis/urn_generator.rb +25 -0
  481. data/lib/pubid/jis.rb +23 -0
  482. data/lib/pubid/lutaml/no_store_registration.rb +30 -0
  483. data/lib/pubid/nist/builder.rb +2269 -0
  484. data/lib/pubid/nist/components/code.rb +38 -0
  485. data/lib/pubid/nist/components/edition.rb +134 -0
  486. data/lib/pubid/nist/components/issue_number.rb +28 -0
  487. data/lib/pubid/nist/components/part.rb +77 -0
  488. data/lib/pubid/nist/components/publisher.rb +24 -0
  489. data/lib/pubid/nist/components/stage.rb +53 -0
  490. data/lib/pubid/nist/components/supplement.rb +188 -0
  491. data/lib/pubid/nist/components/translation.rb +42 -0
  492. data/lib/pubid/nist/components/update.rb +103 -0
  493. data/lib/pubid/nist/components/version.rb +35 -0
  494. data/lib/pubid/nist/components/volume.rb +32 -0
  495. data/lib/pubid/nist/components.rb +19 -0
  496. data/lib/pubid/nist/configuration.rb +77 -0
  497. data/lib/pubid/nist/identifier.rb +62 -0
  498. data/lib/pubid/nist/identifiers/base.rb +578 -0
  499. data/lib/pubid/nist/identifiers/circular.rb +68 -0
  500. data/lib/pubid/nist/identifiers/circular_supplement.rb +50 -0
  501. data/lib/pubid/nist/identifiers/commercial_standard.rb +41 -0
  502. data/lib/pubid/nist/identifiers/commercial_standard_emergency.rb +56 -0
  503. data/lib/pubid/nist/identifiers/commercial_standards_monthly.rb +56 -0
  504. data/lib/pubid/nist/identifiers/crpl_report.rb +132 -0
  505. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +104 -0
  506. data/lib/pubid/nist/identifiers/grant_contractor_report.rb +35 -0
  507. data/lib/pubid/nist/identifiers/handbook.rb +50 -0
  508. data/lib/pubid/nist/identifiers/internal_report.rb +56 -0
  509. data/lib/pubid/nist/identifiers/letter_circular.rb +45 -0
  510. data/lib/pubid/nist/identifiers/miscellaneous_publication.rb +65 -0
  511. data/lib/pubid/nist/identifiers/monograph.rb +69 -0
  512. data/lib/pubid/nist/identifiers/ncstar.rb +41 -0
  513. data/lib/pubid/nist/identifiers/nsrds.rb +41 -0
  514. data/lib/pubid/nist/identifiers/owmwp.rb +35 -0
  515. data/lib/pubid/nist/identifiers/report.rb +67 -0
  516. data/lib/pubid/nist/identifiers/special_publication.rb +36 -0
  517. data/lib/pubid/nist/identifiers/technical_note.rb +90 -0
  518. data/lib/pubid/nist/identifiers.rb +33 -0
  519. data/lib/pubid/nist/parser.rb +1117 -0
  520. data/lib/pubid/nist/scheme.rb +199 -0
  521. data/lib/pubid/nist/supplement_identifier.rb +67 -0
  522. data/lib/pubid/nist/urn_generator.rb +133 -0
  523. data/lib/pubid/nist.rb +37 -0
  524. data/lib/pubid/oiml/builder.rb +189 -0
  525. data/lib/pubid/oiml/components/code.rb +20 -0
  526. data/lib/pubid/oiml/components.rb +9 -0
  527. data/lib/pubid/oiml/identifier.rb +61 -0
  528. data/lib/pubid/oiml/identifiers/amendment.rb +13 -0
  529. data/lib/pubid/oiml/identifiers/annex.rb +62 -0
  530. data/lib/pubid/oiml/identifiers/base.rb +36 -0
  531. data/lib/pubid/oiml/identifiers/basic_publication.rb +13 -0
  532. data/lib/pubid/oiml/identifiers/document.rb +13 -0
  533. data/lib/pubid/oiml/identifiers/expert_report.rb +13 -0
  534. data/lib/pubid/oiml/identifiers/guide.rb +13 -0
  535. data/lib/pubid/oiml/identifiers/recommendation.rb +13 -0
  536. data/lib/pubid/oiml/identifiers/seminar_report.rb +13 -0
  537. data/lib/pubid/oiml/identifiers/vocabulary.rb +13 -0
  538. data/lib/pubid/oiml/identifiers.rb +18 -0
  539. data/lib/pubid/oiml/parser.rb +173 -0
  540. data/lib/pubid/oiml/scheme.rb +46 -0
  541. data/lib/pubid/oiml/single_identifier.rb +90 -0
  542. data/lib/pubid/oiml/supplement_identifier.rb +43 -0
  543. data/lib/pubid/oiml/urn_generator.rb +64 -0
  544. data/lib/pubid/oiml.rb +26 -0
  545. data/lib/pubid/parser/common_parse_methods.rb +13 -0
  546. data/lib/pubid/parser/common_parse_rules.rb +56 -0
  547. data/lib/pubid/parser.rb +8 -0
  548. data/lib/pubid/parsers/base.rb +11 -0
  549. data/lib/pubid/parsers/mr_string.rb +93 -0
  550. data/lib/pubid/plateau/builder.rb +50 -0
  551. data/lib/pubid/plateau/identifier.rb +57 -0
  552. data/lib/pubid/plateau/identifiers/annex.rb +16 -0
  553. data/lib/pubid/plateau/identifiers/base.rb +51 -0
  554. data/lib/pubid/plateau/identifiers/handbook.rb +34 -0
  555. data/lib/pubid/plateau/identifiers/technical_report.rb +20 -0
  556. data/lib/pubid/plateau/identifiers.rb +12 -0
  557. data/lib/pubid/plateau/parser.rb +63 -0
  558. data/lib/pubid/plateau/scheme.rb +45 -0
  559. data/lib/pubid/plateau/supplement_identifier.rb +72 -0
  560. data/lib/pubid/plateau/urn_generator.rb +29 -0
  561. data/lib/pubid/plateau.rb +26 -0
  562. data/lib/pubid/renderers/base.rb +53 -0
  563. data/lib/pubid/renderers/directives_renderer.rb +61 -0
  564. data/lib/pubid/renderers/guide_renderer.rb +24 -0
  565. data/lib/pubid/renderers/human_readable.rb +70 -0
  566. data/lib/pubid/renderers/iwa_renderer.rb +20 -0
  567. data/lib/pubid/renderers/mr_string.rb +16 -0
  568. data/lib/pubid/renderers/supplement_renderer.rb +36 -0
  569. data/lib/pubid/renderers/urn.rb +11 -0
  570. data/lib/pubid/renderers.rb +14 -0
  571. data/lib/pubid/rendering/base.rb +73 -0
  572. data/lib/pubid/rendering/common.rb +211 -0
  573. data/lib/pubid/rendering/context.rb +159 -0
  574. data/lib/pubid/rendering/date.rb +27 -0
  575. data/lib/pubid/rendering/format.rb +25 -0
  576. data/lib/pubid/rendering/language.rb +21 -0
  577. data/lib/pubid/rendering/numbering.rb +24 -0
  578. data/lib/pubid/rendering/publisher.rb +25 -0
  579. data/lib/pubid/rendering/stage.rb +38 -0
  580. data/lib/pubid/rendering/supplement.rb +46 -0
  581. data/lib/pubid/rendering.rb +16 -0
  582. data/lib/pubid/sae/builder.rb +32 -0
  583. data/lib/pubid/sae/components/code.rb +9 -0
  584. data/lib/pubid/sae/components/date.rb +19 -0
  585. data/lib/pubid/sae/components/type.rb +19 -0
  586. data/lib/pubid/sae/components.rb +11 -0
  587. data/lib/pubid/sae/identifier.rb +37 -0
  588. data/lib/pubid/sae/identifiers/base.rb +42 -0
  589. data/lib/pubid/sae/identifiers.rb +9 -0
  590. data/lib/pubid/sae/parser.rb +55 -0
  591. data/lib/pubid/sae/scheme.rb +47 -0
  592. data/lib/pubid/sae/urn_generator.rb +38 -0
  593. data/lib/pubid/sae.rb +19 -0
  594. data/lib/pubid/scheme.rb +219 -0
  595. data/lib/pubid/urn_generator/base.rb +110 -0
  596. data/lib/pubid/utils/string_normalizer.rb +196 -0
  597. data/lib/pubid/utils.rb +7 -0
  598. data/lib/pubid/version.rb +3 -1
  599. data/lib/pubid.rb +137 -13
  600. data/lib/tasks/docs.rake +37 -0
  601. data/lib/tasks/export.rake +38 -0
  602. data/lib/tasks/website-data.json +7488 -0
  603. metadata +616 -171
  604. data/lib/pubid/registry.rb +0 -30
@@ -0,0 +1,2269 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Nist
5
+ # Builder class for constructing NIST identifier objects from parsed data
6
+ # Single Responsibility: Transform parsed data into identifier objects
7
+ #
8
+ # CRITICAL ARCHITECTURE PRINCIPLE:
9
+ # Builder NEVER makes business logic decisions.
10
+ # Builder ONLY casts parsed data to domain objects.
11
+ 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
26
+ end
27
+
28
+ # Build an identifier object from parsed data
29
+ # @param parsed [Hash, Array] the parsed identifier data
30
+ # @return [Identifiers::Base] the constructed identifier object
31
+ def build(parsed)
32
+ # Parslet can return array of hashes - merge them
33
+ parsed_hash = parsed.is_a?(Array) ? flatten_array(parsed) : parsed
34
+
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
45
+
46
+ # NEW: Fix for letter suffix in number with edition_dash_year
47
+ # Pattern: "304a-2017" where parser returns first_number="304a" and edition_dash_year="2017"
48
+ # Expected: number="304", part="A", edition with type="e" and id="2017"
49
+ # We'll handle this by extracting the info and NOT adding :part to parsed_hash
50
+ # to avoid it being processed by cast(:part, ...) which would set type="pt"
51
+ letter_suffix_part = nil
52
+ edition_from_dash_year = nil
53
+ if parsed_hash[:first_number]&.to_s&.match?(/^[0-9]+[a-zA-Z]$/) && parsed_hash[:edition_dash_year]
54
+ number_str = parsed_hash[:first_number].to_s
55
+ # Extract letter suffix from number
56
+ if match_data = number_str.match(/^([0-9]+)([a-zA-Z])$/)
57
+ base_number = match_data[1]
58
+ letter_suffix = match_data[2].upcase
59
+
60
+ # Update first_number to exclude letter suffix
61
+ parsed_hash[:first_number] =
62
+ Components::Code.new(number: base_number)
63
+
64
+ # Store Part component for later (after identifier is initialized)
65
+ letter_suffix_part = Components::Part.new(type: "",
66
+ value: letter_suffix)
67
+
68
+ # Convert edition_dash_year to Edition component with type="e"
69
+ dash_year = parsed_hash[:edition_dash_year][:dash_year]
70
+ edition_from_dash_year = Components::Edition.new(type: "e",
71
+ id: dash_year)
72
+ parsed_hash.delete(:edition_dash_year) # Remove the old key
73
+ end
74
+ end
75
+
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
+ # NEW: Fix for edition embedded in second_number
297
+ # Pattern: "53e5" where second_number="53e5" with edition "e5" embedded
298
+ # Expected: second_number="53", edition with type="e" and id="5"
299
+ if parsed_hash[:second_number]&.to_s&.match?(/^\d+[a-zA-Z]\d+$/)
300
+ second_str = parsed_hash[:second_number].to_s
301
+ # Extract edition from second_number (e.g., "53e5" → "53" + edition "e5")
302
+ if match_data = second_str.match(/^(\d+)([a-zA-Z])(\d+)$/)
303
+ base_number = match_data[1]
304
+ edition_letter = match_data[2]
305
+ edition_id = match_data[3]
306
+
307
+ # Update second_number and create Edition component
308
+ parsed_hash[:second_number] =
309
+ Components::Code.new(number: base_number)
310
+ # Store Edition component for later (after identifier is initialized)
311
+ edition_from_embedded = Components::Edition.new(
312
+ type: edition_letter, id: edition_id,
313
+ )
314
+ end
315
+ end
316
+
317
+ # NEW: Check for CIRC supplement pattern
318
+ # Note: :base_portion is lost during parser merge, so check for supplement indicators
319
+ if parsed_hash[:supplement_date_range] || parsed_hash[:supplement_slash_year] ||
320
+ parsed_hash[:supplement_month_year] || parsed_hash[:supplement_year] ||
321
+ parsed_hash[:supplement] || parsed_hash[:base_portion]
322
+ return build_circular_supplement(parsed_hash)
323
+ end
324
+
325
+ # Locate the appropriate identifier class via Scheme
326
+ identifier = @scheme.locate_identifier_klass(parsed_hash).new
327
+
328
+ # NEW: If we extracted a letter suffix Part, assign it now (after identifier initialization)
329
+ if letter_suffix_part
330
+ identifier.part = letter_suffix_part
331
+ end
332
+
333
+ # NEW: If we extracted an Edition from edition_dash_year, assign it now
334
+ if edition_from_dash_year
335
+ identifier.edition = edition_from_dash_year
336
+ end
337
+
338
+ # NEW: If we extracted an Edition from embedded second_number, assign it now
339
+ if edition_from_embedded
340
+ identifier.edition = edition_from_embedded
341
+ end
342
+
343
+ # NEW: If we extracted an Edition from edition_dash_year with embedded edition in first_number, assign it now
344
+ if parsed_hash[:edition_embedded_with_year]
345
+ identifier.edition = parsed_hash[:edition_embedded_with_year]
346
+ end
347
+
348
+ # NEW: If we extracted an Edition from edition_dash_year as year-only edition, assign it now
349
+ if parsed_hash[:edition_from_year]
350
+ identifier.edition = parsed_hash[:edition_from_year]
351
+ end
352
+
353
+ # NEW: If we extracted an Edition from edition_dash_year with embedded edition, assign it now
354
+ if parsed_hash[:edition_with_year]
355
+ identifier.edition = parsed_hash[:edition_with_year]
356
+ end
357
+
358
+ # NEW: If we have a direct Edition from parsed_hash, assign it now
359
+ # (Used for IR patterns where large dash_year is treated as edition)
360
+ if parsed_hash[:edition]
361
+ identifier.edition = parsed_hash[:edition]
362
+ end
363
+
364
+ # Track first_number, second_number, decimal_number, and letter_number for building compound number
365
+ first_num = nil
366
+ second_num = nil
367
+ decimal_num = nil
368
+ letter_num = nil
369
+ part_num = nil
370
+ extracted_revision = nil
371
+
372
+ # Accumulate supplement signals from the casts (a flat value string,
373
+ # the has_revision flag, and date-range start/end) and fold them into a
374
+ # single Components::Supplement at the end. They are intercepted (not
375
+ # assigned) because :supplement is now a component attribute, so a raw
376
+ # string must never be written to it directly.
377
+ supp = { value: nil, has_revision: false, range_start: nil,
378
+ range_end: nil, present: false }
379
+ capture_supplement = lambda do |k, v|
380
+ case k
381
+ when :supplement then supp[:value] = v
382
+ when :supplement_has_revision then supp[:has_revision] = !!v
383
+ when :supplement_date_range_start then supp[:range_start] = v
384
+ when :supplement_date_range_end then supp[:range_end] = v
385
+ else return false
386
+ end
387
+ supp[:present] = true
388
+ true
389
+ end
390
+
391
+ # Cast and assign all attributes
392
+ parsed_hash.each_pair do |key, value|
393
+ realized_components = cast(key.to_sym, value, parsed_hash) # Pass parsed_hash for context
394
+ next if realized_components.nil?
395
+ next if !realized_components.is_a?(Hash) && capture_supplement.call(key.to_sym, realized_components)
396
+
397
+ # Track number components
398
+ if key == :first_number && realized_components.is_a?(Components::Code)
399
+ first_num = realized_components
400
+ elsif key == :second_number && realized_components.is_a?(Components::Code)
401
+ second_num = realized_components
402
+ elsif key == :crpl_range && realized_components.is_a?(Components::Code)
403
+ # crpl_range is treated as second_number for compound number construction
404
+ second_num = realized_components
405
+ elsif key == :part_number
406
+ part_num = value.to_s
407
+ # NEW: Track decimal_number for IR identifiers (e.g., 80-2073.3)
408
+ # decimal_number is stored as hash with :decimal_base and :decimal_suffix
409
+ elsif key == :decimal_number && realized_components.is_a?(Hash)
410
+ # Store the raw hash for processing during compound number construction
411
+ decimal_num = realized_components
412
+ # NEW: Track letter_number for NCSTAR identifiers (e.g., 1-1A, 1-3B)
413
+ # letter_number is stored as hash with :letter_base and :letter_suffix
414
+ # For SpecialPublication (e.g., 800-56A), we need to:
415
+ # 1. Store the original hash for compound number construction (letter_base)
416
+ # 2. Create a Part component from letter_suffix
417
+ elsif key == :letter_number
418
+ # Store the original hash for compound number construction
419
+ letter_num = value
420
+ # If cast returned a hash with a part component, it will be assigned below
421
+ end
422
+
423
+ # Handle composite hash returns (multiple related values)
424
+ case realized_components
425
+ when Hash
426
+ realized_components.each_pair do |sub_key, sub_value|
427
+ # Track first_number from hash returns
428
+ if sub_key == :first_number && sub_value.is_a?(Components::Code)
429
+ first_num = sub_value
430
+ # Track second_number from hash returns
431
+ elsif sub_key == :second_number && sub_value.is_a?(Components::Code)
432
+ second_num = sub_value
433
+ # NEW: Handle second_number with edition (hash with :number_only and :edition_id)
434
+ # For "126r2013": parser returns {:number_only=>"126", :edition_id=>"2013"}
435
+ # We DON'T convert to Components::Code here; we process it during compound number construction
436
+ elsif sub_key == :second_number && sub_value.is_a?(Hash)
437
+ if sub_value[:number_only] && sub_value[:edition_id]
438
+ # Store the raw hash for processing during compound number construction
439
+ # This prevents the hash from being assigned directly to identifier.number
440
+ second_num = sub_value
441
+ extracted_revision = "r" # Mark as revision format
442
+ end
443
+ # Track revision extraction
444
+ elsif sub_key == :revision
445
+ extracted_revision = sub_value
446
+ end
447
+ # Skip assignment for second_number hashes - they'll be processed during compound number construction
448
+ next if sub_key == :second_number && sub_value.is_a?(Hash) && sub_value[:number_only]
449
+
450
+ # Intercept supplement signals into the accumulator instead of
451
+ # assigning them (supplement is now a component built at the end).
452
+ next if capture_supplement.call(sub_key, sub_value)
453
+
454
+ attrs = identifier.class.attributes
455
+ setter = "#{sub_key}="
456
+ if attrs.key?(sub_key.to_sym)
457
+ identifier.public_send(setter,
458
+ sub_value)
459
+ end
460
+ end
461
+ else
462
+ attrs = identifier.class.attributes
463
+ setter = "#{key}="
464
+ if attrs.key?(key.to_sym)
465
+ identifier.public_send(setter,
466
+ realized_components)
467
+ end
468
+ end
469
+ end
470
+
471
+ # Build compound number from first_number and second_number
472
+ if first_num && !identifier.number
473
+ # Skip if this is a v#n# pattern - now handled as Part component
474
+ if identifier.volume && identifier.issue_number
475
+ # V#n# pattern handled as Part in first_number cast
476
+ # NEW: Handle decimal number pattern (e.g., 80-2073.3 for IR identifiers)
477
+ # decimal_num is {:decimal_base => "2073", :decimal_suffix => "3"}
478
+ elsif decimal_num
479
+ decimal_base = decimal_num[:decimal_base].to_s
480
+ decimal_suffix = decimal_num[:decimal_suffix].to_s
481
+ identifier.number = Components::Code.new(number: "#{first_num.value}-#{decimal_base}.#{decimal_suffix}")
482
+ # NEW: Handle letter number pattern (e.g., 1-1A, 1-3B for NCSTAR identifiers)
483
+ # letter_num is {:letter_base => "1", :letter_suffix => "A"}
484
+ # Also handles IR series "R" suffix: "79-1786R" → "79-1786r1"
485
+ elsif letter_num
486
+ letter_base = letter_num[:letter_base].to_s
487
+ letter_suffix = letter_num[:letter_suffix].to_s
488
+
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")
502
+ elsif identifier.part
503
+ # SpecialPublication pattern: letter_suffix is separate Part component
504
+ identifier.number = Components::Code.new(number: "#{first_num.value}-#{letter_base}")
505
+ else
506
+ # NCSTAR pattern: letter_suffix is part of the number
507
+ identifier.number = Components::Code.new(number: "#{first_num.value}-#{letter_base}#{letter_suffix}")
508
+ end
509
+ elsif second_num
510
+ # Check for special patterns first
511
+ # NEW: Handle second_number with edition (hash from parser pattern)
512
+ # For "126r2013": second_num is {:number_only=>"126", :edition_id=>"2013"}
513
+ if second_num.is_a?(Hash) && second_num[:number_only] && second_num[:edition_id]
514
+ # Extract components from hash
515
+ number_part = second_num[:number_only].to_s
516
+ edition_id = second_num[:edition_id].to_s
517
+
518
+ # Create Edition component
519
+ edition_obj = Components::Edition.new(type: "r", id: edition_id)
520
+
521
+ identifier.number = Components::Code.new(number: "#{first_num.value}-#{number_part}")
522
+ identifier.edition = edition_obj
523
+ identifier.edition_component = edition_obj
524
+ identifier.revision = "r#{edition_id}"
525
+ # CS Emergency pattern: e104-43 → number=104, edition_year=1943
526
+ # Logic: e104-43 means "emergency 104 from 1943" (43 = 1943)
527
+ elsif first_num.value.to_s.match?(/^e(\d{3})$/) &&
528
+ second_num.value.to_s.match?(/^\d{2}$/)
529
+ match_data = first_num.value.to_s.match(/^e(\d{3})$/)
530
+ number_part = match_data[1] # 104
531
+ year_suffix = second_num.value.to_s # 43
532
+ # Edition year: 19 + 43 = 1943 (1900s + year suffix)
533
+ edition_year = "19#{year_suffix}"
534
+
535
+ # Create Edition component
536
+ edition_obj = Components::Edition.new(type: "e", id: edition_year)
537
+
538
+ identifier.number = Components::Code.new(number: number_part)
539
+ identifier.edition = edition_obj
540
+ identifier.edition_component = edition_obj
541
+ elsif first_num.value.to_s.match?(/^(\d+)e(\d+)$/) &&
542
+ second_num.value.to_s.match?(/^\d{2,4}$/)
543
+ # Pattern: "11e2-1915" OR "123e2-50" parsed as first="11e2"|"123e2", second="1915"|"50"
544
+ # Extract number and edition from first_num
545
+ match_data = first_num.value.to_s.match(/^(\d+)e(\d+)$/)
546
+ number_part = match_data[1]
547
+ edition_id = match_data[2]
548
+ year_part = second_num.value.to_s
549
+
550
+ # Expand 2-digit year to 4-digit (50 → 1950)
551
+ year_part = "19#{year_part}" if year_part.length == 2
552
+
553
+ identifier.number = Components::Code.new(number: number_part)
554
+
555
+ # For edition+year patterns, handling depends on identifier type:
556
+ # - CIRC: edition number + year as additional_text, rendered with dot ("11e2-1915" → "11e2.1915")
557
+ # - HB, others: edition number + year as additional_text, rendered with dash ("44e2-1955")
558
+ # Both use the same Edition component structure, only rendering differs
559
+ edition_obj = Components::Edition.new(type: "e",
560
+ id: edition_id, additional_text: year_part)
561
+ identifier.edition = edition_obj
562
+ identifier.edition_component = edition_obj
563
+ elsif first_num.value.to_s.match?(/^(\d+)supp?$/) &&
564
+ second_num.value.to_s.match?(/^\d{4}$/)
565
+ # Pattern: "25supp-1924" parsed as first="25supp", second="1924"
566
+ number_part = first_num.value.to_s.match(/^(\d+)supp?$/)[1]
567
+ year_part = second_num.value.to_s
568
+
569
+ identifier.number = Components::Code.new(number: number_part)
570
+ supp[:value] = year_part
571
+ supp[:present] = true
572
+ elsif second_num.value.to_s.match?(/^(\d+)supp?$/)
573
+ # Pattern: "800-53sup"/"800-53supp" - bare marker on the compound
574
+ # second number. Strip it and isolate as supplement="" (single-p).
575
+ second_part = second_num.value.to_s.match(/^(\d+)supp?$/)[1]
576
+ compound = "#{first_num.value}-#{second_part}"
577
+ identifier.number = Components::Code.new(number: compound)
578
+ supp[:value] = ""
579
+ supp[:present] = true
580
+ elsif identifier.is_a?(Identifiers::TechnicalNote) &&
581
+ second_num.value.to_s.match?(/^(19|20)\d{2}$/)
582
+ # SPECIAL CASE FOR TN: second_num is edition year
583
+ # Following "date IS edition" rule: -1993 becomes Edition(type: "e", id: "1993")
584
+ identifier.number = first_num
585
+ edition_obj = Components::Edition.new(type: "e",
586
+ id: second_num.value.to_s)
587
+ identifier.edition_component = edition_obj
588
+ identifier.edition = edition_obj
589
+ 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
593
+ identifier.part = Components::Part.new(type: "pt",
594
+ 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"
597
+ else
598
+ # For GCR and others, include part number in compound number
599
+ compound_value = "#{first_num.value}-#{second_num.value}"
600
+ compound_value += "-#{part_num}" if part_num
601
+ identifier.number = Components::Code.new(number: compound_value)
602
+ end
603
+ else
604
+ # No second_num, use first_num directly
605
+ identifier.number = first_num
606
+ end
607
+ end
608
+
609
+ # Apply extracted revision if not already set
610
+ if extracted_revision && !identifier.edition
611
+ # Convert extracted revision to Edition component
612
+ identifier.edition = Components::Edition.new(type: "r",
613
+ id: extracted_revision.to_s)
614
+ end
615
+
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
637
+
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
641
+
642
+ # NEW: Convert revision with month+year to update component (V1 compatibility)
643
+ # Patterns like "NIST IR 4743rJun1992" should be rendered as "NIST IR 4743/Upd1-199206"
644
+ if parsed_hash[:revision_month] && parsed_hash[:revision_year]
645
+ # rJun1992 pattern: revision_month is "Jun", revision_year is "1992"
646
+ month_str = parsed_hash[:revision_month].to_s
647
+ year_str = parsed_hash[:revision_year].to_s
648
+
649
+ # Convert month name to number (Jun → 06, Nov → 11, etc.)
650
+ month_num = month_name_to_number(month_str)
651
+
652
+ # Create update component with default number=1, converted year and month
653
+ update_obj = Components::Update.new(
654
+ number: "1",
655
+ year: year_str,
656
+ month: sprintf("%02d", month_num),
657
+ prefix: "slash", # V1 uses /Upd format
658
+ )
659
+
660
+ # Set both V2 component and legacy attribute for backward compatibility
661
+ identifier.update_component = update_obj
662
+ identifier.update = update_obj
663
+
664
+ # Clear the legacy revision_year/revision_month attributes
665
+ identifier.revision_year = nil
666
+ identifier.revision_month = nil
667
+ end
668
+
669
+ # Fold the accumulated supplement signals into the single structured
670
+ # supplement component (the source of truth).
671
+ if (supp[:present] || supp[:has_revision]) &&
672
+ identifier.respond_to?(:supplement=)
673
+ identifier.supplement = supplement_from(
674
+ value: supp[:value], has_revision: supp[:has_revision],
675
+ range_start: supp[:range_start], range_end: supp[:range_end]
676
+ )
677
+ end
678
+
679
+ identifier
680
+ end
681
+
682
+ # Build a Components::Supplement from the builder's accumulated raw signals
683
+ # (flat value string, has_revision flag, fused date-range start/end). This
684
+ # is the one place raw supplement text becomes the structured component.
685
+ def supplement_from(value:, has_revision:, range_start:, range_end:)
686
+ if range_start || range_end
687
+ component = Components::Supplement.new
688
+ # Split the fused "Jun1925"/"Jun1926" strings into isolated start/end
689
+ # month+year nodes (start reuses :month/:year, end uses *_end).
690
+ if range_start && (m = range_start.match(/\A([A-Za-z]{3,9})(\d{4})\z/))
691
+ component.month = m[1]
692
+ component.year = m[2]
693
+ end
694
+ if range_end && (m = range_end.match(/\A([A-Za-z]{3,9})(\d{4})\z/))
695
+ component.month_end = m[1]
696
+ component.year_end = m[2]
697
+ end
698
+ component
699
+ else
700
+ Components::Supplement.from_raw(value, has_revision: has_revision)
701
+ end
702
+ end
703
+
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.
734
+ 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
2266
+ end
2267
+ end
2268
+ end
2269
+ end