relaton 3.0.6 → 3.0.8

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/bin/relaton +20 -0
  3. data/lib/relaton/bib/model/docidentifier.rb +17 -2
  4. data/lib/relaton/bibcollection.rb +146 -0
  5. data/lib/relaton/bibdata.rb +92 -0
  6. data/lib/relaton/bipm/data_outcomes_parser.rb +6 -1
  7. data/lib/relaton/cli/base_convertor.rb +134 -0
  8. data/lib/relaton/cli/command.rb +390 -0
  9. data/lib/relaton/cli/data_fetcher.rb +20 -0
  10. data/lib/relaton/cli/frontend/dist/404.html +18 -0
  11. data/lib/relaton/cli/frontend/dist/app.iife.js +1 -0
  12. data/lib/relaton/cli/frontend/dist/style.css +3 -0
  13. data/lib/relaton/cli/frontend/index.html +14 -0
  14. data/lib/relaton/cli/frontend/package-lock.json +2945 -0
  15. data/lib/relaton/cli/frontend/package.json +29 -0
  16. data/lib/relaton/cli/frontend/public/404.html +18 -0
  17. data/lib/relaton/cli/frontend/src/App.test.ts +501 -0
  18. data/lib/relaton/cli/frontend/src/App.vue +506 -0
  19. data/lib/relaton/cli/frontend/src/app.ts +90 -0
  20. data/lib/relaton/cli/frontend/src/components/DocumentDetail.test.ts +142 -0
  21. data/lib/relaton/cli/frontend/src/components/DocumentDetail.vue +228 -0
  22. data/lib/relaton/cli/frontend/src/components/DocumentRow.vue +88 -0
  23. data/lib/relaton/cli/frontend/src/components/Icon.test.ts +44 -0
  24. data/lib/relaton/cli/frontend/src/components/Icon.vue +55 -0
  25. data/lib/relaton/cli/frontend/src/components/Pagination.vue +26 -0
  26. data/lib/relaton/cli/frontend/src/dev/main.ts +100 -0
  27. data/lib/relaton/cli/frontend/src/dev/sample-data.json +71 -0
  28. data/lib/relaton/cli/frontend/src/lib/filter.test.ts +88 -0
  29. data/lib/relaton/cli/frontend/src/lib/filter.ts +95 -0
  30. data/lib/relaton/cli/frontend/src/lib/hydrate.test.ts +102 -0
  31. data/lib/relaton/cli/frontend/src/lib/hydrate.ts +55 -0
  32. data/lib/relaton/cli/frontend/src/lib/shards.test.ts +319 -0
  33. data/lib/relaton/cli/frontend/src/lib/shards.ts +183 -0
  34. data/lib/relaton/cli/frontend/src/lib/types.ts +148 -0
  35. data/lib/relaton/cli/frontend/src/lib/url.test.ts +151 -0
  36. data/lib/relaton/cli/frontend/src/lib/url.ts +111 -0
  37. data/lib/relaton/cli/frontend/src/shims-vue.d.ts +5 -0
  38. data/lib/relaton/cli/frontend/src/styles/index.css +27 -0
  39. data/lib/relaton/cli/frontend/src/test/fetch.ts +58 -0
  40. data/lib/relaton/cli/frontend/tsconfig.json +20 -0
  41. data/lib/relaton/cli/frontend/vite.config.ts +42 -0
  42. data/lib/relaton/cli/frontend/vitest.config.ts +11 -0
  43. data/lib/relaton/cli/frontend_assets.rb +58 -0
  44. data/lib/relaton/cli/full_text_search.rb +98 -0
  45. data/lib/relaton/cli/i18n.yaml +1 -0
  46. data/lib/relaton/cli/index_item_normalizer.rb +380 -0
  47. data/lib/relaton/cli/index_site_generator.rb +1015 -0
  48. data/lib/relaton/cli/relaton_file.rb +290 -0
  49. data/lib/relaton/cli/subcommand_collection.rb +263 -0
  50. data/lib/relaton/cli/subcommand_db.rb +81 -0
  51. data/lib/relaton/cli/templates/_document.liquid +111 -0
  52. data/lib/relaton/cli/templates/_index.liquid +61 -0
  53. data/lib/relaton/cli/templates/index/page.liquid +20 -0
  54. data/lib/relaton/cli/templates/index-style.css +1017 -0
  55. data/lib/relaton/cli/util.rb +8 -0
  56. data/lib/relaton/cli/version.rb +11 -0
  57. data/lib/relaton/cli/xml_convertor.rb +47 -0
  58. data/lib/relaton/cli/xml_to_html_renderer.rb +117 -0
  59. data/lib/relaton/cli/yaml_convertor.rb +102 -0
  60. data/lib/relaton/cli.rb +113 -0
  61. data/lib/relaton/doi/CLAUDE.md +1 -1
  62. data/lib/relaton/element_finder.rb +29 -0
  63. data/lib/relaton/gb/CLAUDE.md +1 -1
  64. data/lib/relaton/ietf/CLAUDE.md +1 -1
  65. data/lib/relaton/iho/CLAUDE.md +3 -3
  66. data/lib/relaton/iho/bibliography.rb +41 -27
  67. data/lib/relaton/iho/processor.rb +3 -2
  68. data/lib/relaton/nist/CLAUDE.md +11 -1
  69. data/lib/relaton/nist/README.adoc +7 -1
  70. data/lib/relaton/nist/hit_collection.rb +25 -1
  71. data/lib/relaton/nist/mods_parser.rb +32 -8
  72. data/lib/relaton/nist/pubs_export.rb +69 -11
  73. data/lib/relaton/ogc/CLAUDE.md +1 -1
  74. data/lib/relaton/omg/CLAUDE.md +1 -1
  75. data/lib/relaton/plateau/CLAUDE.md +1 -1
  76. data/lib/relaton/render/general.rb +319 -0
  77. data/lib/relaton/render/iso690/csl_import.rb +129 -0
  78. data/lib/relaton/render/iso690/element.rb +153 -0
  79. data/lib/relaton/render/iso690/elements/access.rb +44 -0
  80. data/lib/relaton/render/iso690/elements/authorizer.rb +45 -0
  81. data/lib/relaton/render/iso690/elements/citeid.rb +39 -0
  82. data/lib/relaton/render/iso690/elements/component_part.rb +97 -0
  83. data/lib/relaton/render/iso690/elements/creator.rb +267 -0
  84. data/lib/relaton/render/iso690/elements/edition.rb +97 -0
  85. data/lib/relaton/render/iso690/elements/extent.rb +71 -0
  86. data/lib/relaton/render/iso690/elements/identifier.rb +46 -0
  87. data/lib/relaton/render/iso690/elements/location.rb +73 -0
  88. data/lib/relaton/render/iso690/elements/medium.rb +32 -0
  89. data/lib/relaton/render/iso690/elements/numeration.rb +26 -0
  90. data/lib/relaton/render/iso690/elements/production.rb +111 -0
  91. data/lib/relaton/render/iso690/elements/pub_date.rb +63 -0
  92. data/lib/relaton/render/iso690/elements/series.rb +66 -0
  93. data/lib/relaton/render/iso690/elements/size.rb +45 -0
  94. data/lib/relaton/render/iso690/elements/status.rb +30 -0
  95. data/lib/relaton/render/iso690/elements/stddoc.rb +40 -0
  96. data/lib/relaton/render/iso690/elements/title.rb +56 -0
  97. data/lib/relaton/render/iso690/elements/updated.rb +38 -0
  98. data/lib/relaton/render/iso690/elements.rb +67 -0
  99. data/lib/relaton/render/iso690/fields.rb +142 -0
  100. data/lib/relaton/render/iso690/i18n/en.yml +48 -0
  101. data/lib/relaton/render/iso690/i18n/fr.yml +14 -0
  102. data/lib/relaton/render/iso690/i18n/ja.yml +38 -0
  103. data/lib/relaton/render/iso690/i18n.rb +83 -0
  104. data/lib/relaton/render/iso690/identifier_kinds.rb +29 -0
  105. data/lib/relaton/render/iso690/index_renderer.rb +57 -0
  106. data/lib/relaton/render/iso690/kinds.rb +53 -0
  107. data/lib/relaton/render/iso690/renderer.rb +150 -0
  108. data/lib/relaton/render/iso690/rules/bipm.rb +186 -0
  109. data/lib/relaton/render/iso690/rules/jis.rb +103 -0
  110. data/lib/relaton/render/iso690/rules/nist.rb +384 -0
  111. data/lib/relaton/render/iso690/rules.rb +227 -0
  112. data/lib/relaton/render/iso690/style/locale.rb +59 -0
  113. data/lib/relaton/render/iso690/style/name_form.rb +87 -0
  114. data/lib/relaton/render/iso690/style/scheme.rb +33 -0
  115. data/lib/relaton/render/iso690/style/sort_rule.rb +25 -0
  116. data/lib/relaton/render/iso690/style/template_map.rb +40 -0
  117. data/lib/relaton/render/iso690/style/type_template.rb +45 -0
  118. data/lib/relaton/render/iso690/style.rb +227 -0
  119. data/lib/relaton/render/iso690/styles/apa.yml +45 -0
  120. data/lib/relaton/render/iso690/styles/author-date.yml +31 -0
  121. data/lib/relaton/render/iso690/styles/chicago.yml +49 -0
  122. data/lib/relaton/render/iso690/styles/ieee-sa.yml +45 -0
  123. data/lib/relaton/render/iso690/styles/jis-en.yml +68 -0
  124. data/lib/relaton/render/iso690/styles/jis-ja.yml +67 -0
  125. data/lib/relaton/render/iso690/styles/lncs.yml +54 -0
  126. data/lib/relaton/render/iso690/styles/mla.yml +56 -0
  127. data/lib/relaton/render/iso690/template.rb +248 -0
  128. data/lib/relaton/render/iso690.rb +28 -0
  129. data/lib/relaton/render/version.rb +5 -0
  130. data/lib/relaton/un/CLAUDE.md +1 -1
  131. data/lib/relaton/version.rb +1 -1
  132. data/lib/relaton-cli.rb +17 -0
  133. data/lib/relaton-render.rb +13 -0
  134. metadata +192 -21
@@ -0,0 +1,8 @@
1
+ module Relaton
2
+ module Cli
3
+ module Util
4
+ extend Relaton::Bib::Util
5
+ PROGNAME = "relaton-cli".freeze
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ # relaton-cli ships in lockstep with the combined `relaton` gem, so its version
2
+ # is that gem's version — the single source of truth in Relaton::VERSION. relaton
3
+ # is a hard runtime dependency, so requiring its lightweight version file always
4
+ # resolves. Don't hardcode a string here; it would silently drift on a bump.
5
+ require "relaton/version"
6
+
7
+ module Relaton
8
+ module Cli
9
+ VERSION = Relaton::VERSION
10
+ end
11
+ end
@@ -0,0 +1,47 @@
1
+ require "nokogiri"
2
+ require "relaton/cli/base_convertor"
3
+
4
+ module Relaton
5
+ module Cli
6
+ class XMLConvertor < Relaton::Cli::BaseConvertor
7
+ def to_yaml
8
+ convert_and_write(file_content, :to_yaml)
9
+ end
10
+
11
+ # Convert to YAML
12
+ #
13
+ # This interface allow us to convert any XML file to YAML.
14
+ # It only require us to provide a valid XML file and it can
15
+ # do converstion using default attributes, but it also allow
16
+ # us to provide custom options to customize this converstion
17
+ # process.
18
+ #
19
+ # @param file [File] The complete path to a XML file
20
+ # @param options [Hash] Options as hash key, value pairs.
21
+ #
22
+ def self.to_yaml(file, options = {})
23
+ new(file, options).to_yaml
24
+ end
25
+
26
+ private
27
+
28
+ def default_ext
29
+ "yaml"
30
+ end
31
+
32
+ # @param content [Nokogiri::XML::Document]
33
+ # @return [Hash]
34
+ def convert_content(content)
35
+ if %w[bibitem bibdata].include? content.root.name
36
+ Bibdata.from_xml(content)
37
+ else
38
+ Bibcollection.from_xml(content)
39
+ end
40
+ end
41
+
42
+ def file_content
43
+ Nokogiri::XML(File.read(file, encoding: "utf-8"))
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,117 @@
1
+ require "nokogiri"
2
+ require "liquid"
3
+
4
+ module Relaton::Cli
5
+ class XmlToHtmlRenderer
6
+ def initialize(liquid_dir: nil, stylesheet: nil)
7
+ @liquid_dir = liquid_dir
8
+ @stylesheet = read_file(stylesheet)
9
+ init_liquid_template_and_filesystem
10
+ end
11
+
12
+ # @param index_xml [String] Relaton XML
13
+ # @return [String] HTML
14
+ def render(index_xml)
15
+ Liquid::Template
16
+ .parse(template)
17
+ .render(build_liquid_document(index_xml))
18
+ end
19
+
20
+ def uri_for_extension(uri, extension)
21
+ uri&.sub(/\.[^.]+$/, ".#{extension}")
22
+ end
23
+
24
+ # Render HTML
25
+ #
26
+ # This interface allow us to convert a Relaton XML to HTML
27
+ # using the specified liquid template and stylesheets. It
28
+ # also do some minor clean during this conversion.
29
+ #
30
+ # @param file [String] Relaton XML
31
+ # @param options [Hash]
32
+ # @return [String] HTML
33
+ def self.render(file, options)
34
+ new(**options).render(file)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :stylesheet, :liquid_dir, :template
40
+
41
+ def read_file(file)
42
+ File.read(file, encoding: "utf-8")
43
+ end
44
+
45
+ # rubocop:disable Metrics/MethodLength
46
+ # @param source [String] Relaton XML
47
+ def build_liquid_document(source)
48
+ bibcollection = build_bibcollection(source)
49
+ hash_to_liquid(
50
+ depth: 2,
51
+ css: stylesheet,
52
+ title: bibcollection.title,
53
+ date: DateTime.now.to_s,
54
+ author: bibcollection.author,
55
+ documents: document_items(bibcollection)
56
+ )
57
+ end
58
+
59
+ def init_liquid_template_and_filesystem
60
+ file_system = Liquid::LocalFileSystem.new(liquid_dir)
61
+ @template = read_file(file_system.full_path("index"))
62
+
63
+ Liquid::Template.file_system = file_system
64
+ end
65
+
66
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
67
+
68
+ # TODO: This should be recursive, but it's not
69
+ # @param hash [Hash]
70
+ # @option hash [Integer] :dept
71
+ # @option hash [String] :css path to stylesheet file
72
+ # @option hash [String] :title
73
+ # @option hash [String] :author
74
+ # @option hash [Array<Hash>] :documents
75
+ #
76
+ # @return [Hash]
77
+ def hash_to_liquid(hash)
78
+ hash.map do |key, value|
79
+ case key
80
+ when "title"
81
+ if value.is_a?(Array)
82
+ title = value.detect { |t| t["type"] == "main" } || value.first
83
+ v = title ? title["content"] : nil
84
+ elsif value.is_a?(Hash) then v = value["content"]
85
+ else v = value
86
+ end
87
+ when "docidentifier"
88
+ did = if value.is_a?(Array)
89
+ value.detect { |d| (d["id"] || d["content"]).to_s !~ /^(http|https):\/\// } ||
90
+ value.first
91
+ else value
92
+ end
93
+ v = did
94
+ else v = value
95
+ end
96
+ [key.to_s, empty2nil(v)]
97
+ end.to_h
98
+ end
99
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
100
+
101
+ def empty2nil(value)
102
+ value unless value.is_a?(String) && value.empty? && !value.nil?
103
+ end
104
+
105
+ # @param source [String] Relaton XML
106
+ # @return [Relaton::Bibcollection]
107
+ def build_bibcollection(source)
108
+ Relaton::Bibcollection.from_xml(Nokogiri::XML(source))
109
+ end
110
+
111
+ # @param bibcollection [Relaton::Bibcollection]
112
+ # @return [Array<Hash>]
113
+ def document_items(bibcollection)
114
+ bibcollection.to_h["root"]["items"].map { |item| hash_to_liquid(item) }
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,102 @@
1
+ require "yaml"
2
+ require "relaton/cli/base_convertor"
3
+ require "relaton/bib"
4
+
5
+ module Relaton
6
+ module Cli
7
+ class YAMLConvertor < Relaton::Cli::BaseConvertor
8
+ def to_xml
9
+ if writable
10
+ convert_and_write(file_content, :to_xml)
11
+ else
12
+ convert_content(file_content).to_xml date_format: :full, bibdata: true
13
+ end
14
+ end
15
+
16
+ class << self
17
+ # Convert to XML
18
+ #
19
+ # This interface allow us to convert any YAML file to XML.
20
+ # It only require us to provide a valid YAML file and it can
21
+ # do converstion using default attributes, but it also allow
22
+ # us to provide custom options to customize this converstion
23
+ # process.
24
+ #
25
+ # @param file [File] The complete path to a YAML file
26
+ # @param options [Hash] Options as hash key, value pairs.
27
+ #
28
+ def to_xml(file, options = {})
29
+ new(file, options).to_xml
30
+ end
31
+
32
+ # @param content [Hash] document in YAML format
33
+ # @return [RelatonBib::BibliographicItem,
34
+ # RelatonIso::IsoBiblioraphicItem]
35
+ def convert_single_file(content)
36
+ flavor = content.dig("ext", "flavor") || doctype(content["docidentifier"])
37
+ if (processor = Relaton::Db::Registry.instance.by_type(flavor))
38
+ begin
39
+ processor.from_yaml content.to_yaml
40
+ rescue RuntimeError
41
+ Relaton::Bib::Item.from_yaml(content.to_yaml)
42
+ end
43
+ else
44
+ Relaton::Bib::Item.from_yaml(content.to_yaml)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # @param content [Hash]
51
+ # @return [String]
52
+ def doctype(docid)
53
+ did = docid.is_a?(Array) ? docid.fetch(0) : docid
54
+ return unless did
55
+
56
+ did["type"] || did.fetch("content")&.match(/^\w+/)&.to_s
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def default_ext
63
+ "rxl"
64
+ end
65
+
66
+ def file_content
67
+ date_to_string(YAML.load_file(file))
68
+ end
69
+
70
+ def date_to_string(obj)
71
+ if obj.is_a? Hash
72
+ obj.reduce({}) do |memo, (k, v)|
73
+ memo[k] = date_to_string(v)
74
+ memo
75
+ end
76
+ elsif obj.is_a? Array
77
+ obj.reduce([]) { |memo, v| memo << date_to_string(v) }
78
+ else
79
+ obj.is_a?(Date) ? obj.to_s : obj
80
+ end
81
+ end
82
+
83
+ def convert_collection(content)
84
+ if content.has_key?("root")
85
+ content["root"]["items"] = content["root"]["items"].map do |i|
86
+ # RelatonBib::HashConverter::hash_to_bib(i)
87
+ self.class.convert_single_file(i)
88
+ end
89
+ Relaton::Bibcollection.new(content["root"])
90
+ end
91
+ end
92
+
93
+ def xml_content(_raw_file)
94
+ convert_content(file_content).to_xml(date_format: :full, bibdata: true)
95
+ end
96
+
97
+ def convert_content(content)
98
+ convert_collection(content) || self.class.convert_single_file(content)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,113 @@
1
+ require "fileutils"
2
+ require "thor"
3
+ require "thor/hollaback"
4
+ require_relative "cli/version"
5
+ require_relative "cli/util"
6
+ require_relative "cli/command"
7
+
8
+ module Relaton
9
+ def self.db(dir = nil)
10
+ Cli.relaton dir
11
+ end
12
+
13
+ module Cli
14
+ class RelatonDb
15
+ include Singleton
16
+
17
+ DBCONF = "#{Dir.home}/.relaton/dbpath".freeze
18
+
19
+ # @param dir [String, nil]
20
+ # @return [Relaton::Db]
21
+ def db(dir)
22
+ if dir
23
+ FileUtils.mkdir_p File.dirname(DBCONF)
24
+ File.write DBCONF, dir, encoding: "UTF-8"
25
+ @db = Relaton::Db.new dir, nil
26
+ else
27
+ @db ||= Relaton::Db.new dbpath, nil
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # @return [String] path to DB
34
+ def dbpath
35
+ if File.exist?(DBCONF)
36
+ File.read(DBCONF, encoding: "UTF-8")
37
+ else "#{Dir.home}/.relaton/cache"
38
+ end
39
+ end
40
+ end
41
+
42
+ class << self
43
+ def version
44
+ require "relaton/bib"
45
+ registry = Relaton::Db::Registry.instance
46
+ # Every flavor now ships inside the single `relaton` gem, so they all
47
+ # report Relaton::VERSION rather than a separate gem version.
48
+ puts "CLI => #{Relaton::Cli::VERSION}"
49
+ puts "relaton => #{Relaton::VERSION}"
50
+ puts "relaton-bib => #{Relaton::VERSION}"
51
+ registry.processors.each_key do |k|
52
+ name = k.to_s.sub("_", "-")
53
+ puts "#{name} => #{Relaton::VERSION}"
54
+ end
55
+ end
56
+
57
+ def start(arguments)
58
+ Relaton::Cli::Command.start(arguments)
59
+ end
60
+
61
+ # Relaton
62
+ #
63
+ # Based on current setup, we need to initiate a Db instance to
64
+ # register all of it's supported processor backends. To make it
65
+ # easier we have added it as a class method so we can use this
66
+ # whenever necessary.
67
+ #
68
+ # @param dir [String, nil]
69
+ # @return [Relaton::Db]
70
+ def relaton(dir)
71
+ RelatonDb.instance.db dir
72
+ end
73
+
74
+ # @param content [Nokogiri::XML::Document]
75
+ # @return [RelatonBib::BibliographicItem,
76
+ # RelatonIsoBib::IsoBibliongraphicItem]
77
+ def parse_xml(doc)
78
+ doc.remove_namespaces! if doc.respond_to?(:remove_namespaces!)
79
+ if (proc = Cli.processor(doc))
80
+ proc.from_xml(doc.to_s)
81
+ else
82
+ Relaton::Bib::Item.from_xml(doc.to_s) rescue nil
83
+ end
84
+ end
85
+
86
+ # @param doc [Nokogiri::XML::Element]
87
+ # @return [RelatonIso::Processor, RelatonIec::Processor,
88
+ # RelatonNist::Processor, RelatonIetf::Processot,
89
+ # RelatonItu::Processor, RelatonGb::Processor,
90
+ # RelatonOgc::Processor, RelatonCalconnect::Processor]
91
+ def processor(doc)
92
+ docid = doc.at "docidentifier"
93
+ proc = get_proc docid
94
+ return proc if proc
95
+
96
+ Relaton::Db::Registry.instance.by_type(docid&.text&.match(/^\w+/)&.to_s)
97
+ end
98
+
99
+ private
100
+
101
+ # @param doc [Nokogiri::XML::Element]
102
+ # @return [RelatonIso::Processor, RelatonIec::Processor,
103
+ # RelatonNist::Processor, RelatonIetf::Processot,
104
+ # RelatonItu::Processor, RelatonGb::Processor,
105
+ # RelatonOgc::Processor, RelatonCalconnect::Processor]
106
+ def get_proc(docid)
107
+ return unless docid && docid[:type]
108
+
109
+ Relaton::Db::Registry.instance.by_type(docid[:type])
110
+ end
111
+ end
112
+ end
113
+ end
@@ -38,7 +38,7 @@ Key classes in `lib/relaton/doi/`:
38
38
  - **RSpec** with `expect` syntax only (monkey patching disabled)
39
39
  - **VCR** cassettes in `spec/vcr_cassettes/` record Crossref HTTP responses and are never auto-rerecorded (see root `CLAUDE.md`). Because Crossref rate-limits, **re-record this suite alone** — under parallel `rake spec` it answers 429 and the empty body gets recorded as if it were data.
40
40
  - **XML fixtures** in `spec/fixtures/` — expected output XML files. The `read_fixture` helper auto-substitutes today's date into `<fetched>` tags.
41
- - **equivalent-xml** gem for XML comparison in integration tests
41
+ - **canon** gem for XML comparison in integration tests
42
42
  - Integration tests in `spec/relaton/doi_spec.rb` cover 40+ document types via VCR cassettes
43
43
  - Unit tests in `spec/relaton/doi/parser_spec.rb` test Parser methods directly with hash inputs
44
44
 
@@ -0,0 +1,29 @@
1
+ module Relaton
2
+ module ElementFinder
3
+ attr_reader :document
4
+
5
+ def find_text(xpath, element = nil)
6
+ find(xpath, element)&.text
7
+ end
8
+
9
+ def find_html(xpath, element = nil)
10
+ find(xpath, element)&.inner_html
11
+ end
12
+
13
+ def find(xpath, element = nil)
14
+ (element || document).at(apply_namespace(xpath))
15
+ end
16
+
17
+ def find_xpath(xpath, element = nil)
18
+ element&.xpath(apply_namespace(xpath))
19
+ end
20
+
21
+ def apply_namespace(xpath)
22
+ xpath
23
+ .gsub(%r{/([a-zA-Z])}, "/xmlns:\\1")
24
+ .gsub(%r{::([a-zA-Z])}, "::xmlns:\\1")
25
+ .gsub(%r{\[([a-zA-Z][a-z0-9A-Z@/]* ?=)}, "[xmlns:\\1")
26
+ .gsub(%r{\[([a-zA-Z][a-z0-9A-Z@/]*\])}, "[xmlns:\\1")
27
+ end
28
+ end
29
+ end
@@ -44,4 +44,4 @@ GB parses identifiers with `Pubid::Gb::Identifier`. It publishes no index, so it
44
44
 
45
45
  ## Testing
46
46
 
47
- RSpec with WebMock + VCR (cassettes in `spec/vcr_cassettes/`). Network is blocked via WebMock. SimpleCov and equivalent-xml are loaded via `spec/support/`. No index fixture — GB is scraped, not indexed; `spec/fixtures/` holds XML/YAML round-trip data.
47
+ RSpec with WebMock + VCR (cassettes in `spec/vcr_cassettes/`). Network is blocked via WebMock. SimpleCov and canon are loaded via `spec/support/`. No index fixture — GB is scraped, not indexed; `spec/fixtures/` holds XML/YAML round-trip data.
@@ -375,7 +375,7 @@ and the affected files come through with their real characters —
375
375
  - **An oracle for `read_bibxml` must read binary.** The method is byte-verbatim (`File.binread`), so comparing it against `File.read(path, encoding: "UTF-8")` compares two different strings **on Windows only**: Ruby opens a file in text mode there and collapses CRLF to LF. The repo declares no `.gitattributes` and the GitHub Windows runner image defaults to `core.autocrlf=true`, so a fixture stored with LF is checked out CRLF — which failed all three `windows-latest` jobs while every Linux and macOS job passed. Use `File.read(path, mode: "rb", encoding: "UTF-8")`. macOS cannot reproduce this by checking the fixture out as CRLF, because its text-mode read does not translate; emulate Windows explicitly with `File.read(path, encoding: "UTF-8", universal_newline: true)`. The two `keeps CRLF line endings verbatim` examples pin the contract with `binwrite` temp files, independent of the checkout — one per branch, since the CP1252 repair rebuilds the string run by run and is where a line ending is likeliest to be lost. Note that a byte-equality oracle alone cannot catch a dropped `force_encoding`: `rfc.xml` is pure ASCII, and Ruby compares two ASCII-only strings as equal across encodings, so the example asserts `encoding` separately.
376
376
  - **Schema validation** via `ruby-jing` against the shared RNG grammars in the repo-root `grammar/` (`Jing.new "../../grammar/relaton-ietf-compile.rng"`, relative to the suite's CWD `spec/ietf/`)
377
377
  - **Shared examples** for org/person parsing in `bibxml_parser_spec.rb` (`parse_org`, `parse_person`)
378
- - **`equivalent-xml`** gem used for XML comparison (`be_equivalent_to` matcher)
378
+ - **`canon`** gem used for XML comparison (`be_equivalent_to` matcher)
379
379
 
380
380
  ## RuboCop
381
381
 
@@ -55,11 +55,11 @@ In `item.rb`, `require_relative "relation"` is placed **after** the `Item` class
55
55
 
56
56
  `Bibliography.search`/`.get` fetches YAML from the relaton-data-iho GitHub repo via `Relaton::Index`, then deserializes into model objects.
57
57
 
58
- **Editions and series.** The pubid `version` is the edition (`IHO B-12 3.0.0`; the grammar takes `a.b.c` only). A reference with no version answers with the **latest** edition: `recency_key` compares the segments as integers (`2.10.0` after `2.9.0`). It used to be `min_by` on the string, which returned the oldest.
58
+ **Editions and series (the pubid 2.0.5 component model, relaton#440).** The pubid attribute is `edition` (dotted `a.b.c`, `IHO B-12 3.0.0`; the pre-440 name `version` is gone). A reference with no edition answers with the **latest**: `recency_key` compares the segments as integers (`2.10.0` after `2.9.0`). It used to be `min_by` on the string, which returned the oldest.
59
59
 
60
- **Series are identified by year or volume, not an edition (relaton#407).** B-7, P-5 and P-6 carry a `year` (`IHO P-5 2024`), the IHR (P-1) a `volume` with an optional `issue` (`IHO P-1 Vol. 30 (2)`) — plain scalars added by pubid#635, mutually exclusive with `version` (the grammar's tail picks exactly one). The same `recency_key` orders each kind numerically, so a bare series query answers with the latest year/volume/issue. Matching (`pubid_match?`) treats a component the query omits as a wildcard: `IHO P-1 Vol. 30` matches any issue of volume 30. The comparison runs through pubid's `#exclude` — excluding `:volume` nils the `issue` with it (the pair is one scalar cluster) — the way BIPM's `stem_keys` does. The subdivisions (part/appendix/annex/supplement) stay strict: this flavor cannot use the `===` subset match, because pubid does not declare `subset_strict` for IHO's subdivisions and a nil `part` would read as "any part" (`IHO S-100` would answer with a part row).
60
+ **Serial locators are volumes (relaton#440 unified the year locators of relaton#407).** The yearbook year IS a volume: `IHO P-5 Vol. 2020` is canonical, the bare-year faces (`P-5 2020`, `P-5/2020`) parse as legacy aliases into the same `volume`, and the IHR is `P-1 Vol. 30` with `Issue 1` (no parens) or the short slash form `P-1/30`. A bare series query answers with the latest volume/issue. **Components can publish separately and carry their own edition:** `IHO S-66 Suppl 1 2.0.0` gives `supplement_edition`, `S-100 Part S-102 1.0.0` gives `part_edition`, annexes likewise — and the publication years in parens (`S-52 Annex A 4.0.3 (2021)`) are `*_year` metadata, not identity: a stated year pins the record, an omitted one wildcards. Matching (`pubid_match?`) treats a discriminator the query omits as a wildcard: `IHO P-1 Vol. 30` matches any issue of volume 30. The comparison runs through pubid's `#exclude` — excluding `:volume` nils the `issue`, excluding an `:appendix` nils its `appendix_subnumber` (scalar clusters) — the way BIPM's `stem_keys` does. The subdivisions (part/appendix/annex/supplement) stay strict: this flavor cannot use the `===` subset match, because pubid does not declare `subset_strict` for IHO's subdivisions and a nil `part` would read as "any part" (`IHO S-100` would answer with a part row).
61
61
 
62
- **The edition reaches the index only through the committed row.** A record prints `B-12` and keeps its edition in `edition:`, so `relaton index` cannot read a version from the docid. `MachineIndex#refine` (relaton-cli) takes the committed `index-v3.yaml` row when it adds only a key in `REFINABLE_KEYS` to the parse — `version`/`edition`/`volume`/`year`/`issue` (relaton#407 added the year/issue pair for the series rows). Before that, the Pages index (generated 2026-10-29) had no `version` in any of its 325 rows, so every versioned reference answered not-found (relaton-data-iho#20). The suite did not see it: `spec/fixtures/index-v3.zip` is the committed index, which has versions. The suite's fixture currently carries the **re-keyed** series rows (year/volume) ahead of the data repo: the placeholder `1.0.0` rows for p-1/p-1-2x/p-5/p-6/b-7 are replaced by `year`/`volume` rows — refresh the fixture from the live published zip once relaton-data-iho re-keys, and reconcile any file-name drift then.
62
+ **The edition reaches the index only through the committed row.** A record prints `B-12` and keeps its edition in `edition:`, so `relaton index` cannot read a version from the docid. `MachineIndex#refine` (relaton-cli) takes the committed `index-v3.yaml` row when it adds only a key in `REFINABLE_KEYS` to the parse — `version`/`edition`/`volume`/`year`/`issue` (relaton#407 added the year/issue pair for the series rows). Before that, the Pages index (generated 2026-10-29) had no `version` in any of its 325 rows, so every versioned reference answered not-found (relaton-data-iho#20). The suite did not see it: `spec/fixtures/index-v3.zip` is the committed index, which has versions. The suite's fixture carries the #48-era re-keyed series rows migrated to the #440 keys (`version`→`edition`/`<component>_edition`, year locators→`volume`; `support/migrate_index_fixture.rb` did the one-shot migration) — refresh the fixture from the live published zip once relaton-data-iho round 2 republishes, and reconcile any file-name drift then.
63
63
 
64
64
  ### Test Infrastructure
65
65
 
@@ -96,31 +96,37 @@ module Relaton
96
96
  )
97
97
  end
98
98
 
99
- # The sort key for the latest record in a bucket (relaton#407). Each
100
- # IHO identity carries exactly one trailing discriminator — a dotted
101
- # `version` (an edition, `B-12 3.0.0`), a series `year` (`P-5 2024`)
102
- # or a series `volume` with its `issue` (`P-1 Vol. 30 (2)`) — and the
103
- # key orders by that component, numerically: version segments compare
104
- # as integers (2.10.0 is later than 2.9.0), as do year, volume and
105
- # issue. A row with no discriminator at all is the oldest. Rows in
106
- # one bucket share the discriminator kind once the data is keyed;
107
- # while kinds coexist the array compare is deterministic, and a
108
- # year's bare [2024] beats a placeholder edition's [1, 0, 0].
99
+ # The sort key for the latest record in a bucket (relaton#407,
100
+ # reshaped by relaton#440's component model). Volume-locator series
101
+ # (P-1, P-5) carry a `volume` with an optional `issue` — the
102
+ # yearbook year IS a volume (`P-5 Vol. 2020`; the bare-year faces
103
+ # of the #48 re-key parse as legacy aliases), so the year locators
104
+ # fold into the volume ordering. Edition-keyed products carry a
105
+ # dotted `edition` (`B-12 3.0.0`), and a component published
106
+ # separately carries its own edition (`S-66 Suppl 1 2.0.0` →
107
+ # `supplement_edition`). Every component compares numerically
108
+ # segment by segment (2.10.0 after 2.9.0); a row with no
109
+ # discriminator at all is the oldest.
109
110
  def recency_key(id)
110
- if id.year
111
- [id.year.to_i]
112
- elsif id.volume
111
+ if id.volume
113
112
  [id.volume.to_i, id.issue.to_i]
114
113
  else
115
- id.version.to_s.split(".").map(&:to_i)
114
+ ed = id.edition || id.supplement_edition || id.part_edition ||
115
+ id.annex_edition || id.appendix_edition || id.volume_edition
116
+ ed.to_s.split(".").map(&:to_i)
116
117
  end
117
118
  end
118
119
 
119
- # The components that make an IHO document the one it is beyond the
120
- # series and subdivisions: the edition (`version`), or — for the
121
- # series identified by year or volume instead (B-7, P-1, P-5, P-6;
122
- # relaton#407) — the `year` or the `volume`/`issue` pair.
123
- DISCRIMINATOR_KEYS = %i[version year volume issue].freeze
120
+ # The identity components beyond the series and subdivisions
121
+ # (relaton#440): the base `edition` — or the component-level
122
+ # edition when the component publishes separately — plus the
123
+ # volume/issue pair. The publication years (`year`, `*_year`) are
124
+ # metadata, not identity: a stated year pins the record, an
125
+ # omitted one wildcards it.
126
+ DISCRIMINATOR_KEYS = %i[
127
+ edition volume issue
128
+ supplement_edition part_edition annex_edition appendix_edition volume_edition
129
+ ].freeze
124
130
 
125
131
  # Both `row_id` and `query` are Pubid::Iho::Identifiers::* instances.
126
132
  # Class identity pins the series (S/P/M/B/C). Subdivision keys
@@ -132,21 +138,29 @@ module Relaton
132
138
  # so `IHO S-100` would answer with a part row.
133
139
  #
134
140
  # The discriminator keys stay nil-tolerant: an unqualified `IHO B-11`
135
- # finds the latest edition, an unqualified `IHO P-5` the latest year,
136
- # and `IHO P-1 Vol. 30` any issue of that volume — while a stated
137
- # component must match exactly. The comparison runs through pubid's
138
- # `#exclude` (the primitive `matches?` is built on), so the rules the
139
- # flavor declares there ride along: excluding `:volume` nils the
140
- # `issue` with it. `stem_keys` in the BIPM flavor is the same shape.
141
+ # finds the latest edition, an unqualified `IHO P-5` the latest
142
+ # volume (the yearbook year — relaton#440 folded the year
143
+ # locators in), and `IHO P-1 Vol. 30` any issue of that volume —
144
+ # while a stated component must match exactly. The comparison runs
145
+ # through pubid's `#exclude` (the primitive `matches?` is built
146
+ # on), so the rules the flavor declares there ride along: excluding
147
+ # `:volume` nils the `issue` with it, and excluding an appendix
148
+ # nils its subnumber — the scalar clusters are one unit. `stem_keys`
149
+ # in the BIPM flavor is the same shape.
141
150
  def pubid_match?(row_id, query)
142
151
  row_id.class == query.class &&
143
152
  row_id.exclude(*wildcards(query)) == query.exclude(*wildcards(query))
144
153
  end
145
154
 
146
155
  # The discriminators the query leaves unset: wildcards for the
147
- # search, significant whenever the query states one.
156
+ # search, significant whenever the query states one — plus the
157
+ # publication years, which are stated-metadata: pinned when the
158
+ # query carries them, wildcarded when it does not.
148
159
  def wildcards(query)
149
- DISCRIMINATOR_KEYS.select { |key| query.public_send(key).nil? }
160
+ keys = DISCRIMINATOR_KEYS + %i[
161
+ year supplement_year part_year annex_year appendix_year volume_year
162
+ ]
163
+ keys.select { |key| query.public_send(key).nil? }
150
164
  end
151
165
  end
152
166
  end
@@ -26,9 +26,10 @@ module Relaton
26
26
  end
27
27
 
28
28
  # An IHO record prints `B-12` and keeps its edition in `edition`, so
29
- # the edition goes on the key as the version (relaton#408).
29
+ # the edition goes on the key (relaton#408). The component model
30
+ # (relaton#440) renamed the pubid attribute `version` -> `edition`.
30
31
  def item_cache_key(pubid, item)
31
- with_components pubid, version: item.edition&.content
32
+ with_components pubid, edition: item.edition&.content
32
33
  end
33
34
 
34
35
  # @param xml [String]
@@ -69,7 +69,8 @@ All data models inherit from `Lutaml::Model::Serializable` and use declarative a
69
69
  - **`#write_file` serializes before it indexes**, so a record that fails to serialize leaves no index row naming a file that was never written. That is how a `Bib::Date` in `Scraper.fetch_dates` hid: `Nist::ItemData` takes `Nist::Date`, and YAML serialization rejects the parent class (the same strictness as the docidentifier note below).
70
70
  - `ModsParser` — maps MODS XML (via `loc_mods`) to `ItemData`
71
71
  - `Scraper` — fetches items from GitHub YAML or CSRC JSON
72
- - `PubsExport` — singleton; caches CSRC pubs-export zip with thread-safe daily updates
72
+ - `PubsExport` — singleton; caches CSRC pubs-export zip with thread-safe daily updates. The cache file is `#datafile`, under `Relaton::Index.config.storage_dir` (default `Dir.home`; the `DATAFILE` constant is only that default).
73
+ - **A read-only file system must not stop a search (relaton-nist#57).** When the cache write fails, `#fetch_data` keeps the downloaded zip in `@buffer`, and `#unzip` reads it with `Zip::File.open_buffer`. In rubyzip 3, `open_buffer` returns the buffer, not the block value, so collect the rows in a local. The cache write goes to a temp file and is renamed into place (`#write_datafile`), so a failed write never leaves a truncated zip that later processes read as fresh. `HitCollection#search` also rescues the local-cache errors in `LOCAL_DATA_ERRORS` from `from_json` (`#from_json_or_empty`), logs a warning, and continues with `from_ga`. Do not widen that list to `SystemCallError`: `Errno::ECONNREFUSED` is one, and a CSRC outage would then answer "not found" from the index, which `Relaton::Db` can cache. Specs assert the warning with `output(...).to_stderr_from_any_process`. A `receive(:warn)` stub on `Util` is never hit: `Kernel#warn` is private, the stub stays private, and the explicit-receiver call goes to `method_missing`.
73
74
 
74
75
  ### Pubid-backed docidentifier
75
76
 
@@ -115,6 +116,15 @@ NIST-specific gotchas (why this isn't a straight copy of IEC/CCSDS):
115
116
  numbered editions/revisions (`e2`, `r5`). Dates that ride the `update`
116
117
  component (some NBS supplements) are left intact — `update` also encodes
117
118
  non-date update codes (`/Upd2`), so stripping it would corrupt identity.
119
+ - **The item's `edition` comes from the DOI (relaton-nist#56).**
120
+ `ModsParser#parse_edition` sets `<edition number="N">N</edition>` only for a
121
+ numbered `e<N>` edition in the DOI (`NBS.CIRC.111e2` → 2, `11e2.1915` → 2):
122
+ 250 of the 20,392 live MODS records. An `e<year>` edition is a date, and an
123
+ `r<N>` revision is not an edition, so both stay without one. The MODS
124
+ `<edition>` element is prose (`New version`, `Erratum`), never a number. The
125
+ issue's "edition 0 → 1" rule came from the old Crossref feed; it is kept for
126
+ a DOI `e0` (none in the live data). A DOI pubid cannot parse gives no
127
+ edition, not an error.
118
128
  - **No `(all parts)` marker in `content`.** `content` is live-derived from
119
129
  `@pubid`, so `to_all_parts!` freezes the already-stripped rendering into
120
130
  `@raw_content` before wrapping `@pubid` in pubid's `AllParts` — matching
@@ -311,7 +311,13 @@ Relaton::Nist::Item.from_yaml File.read("spec/fixtures/item.yaml")
311
311
 
312
312
  === Fetching data
313
313
 
314
- On a search, the CSRC data sources are fetched and stored in the user's cache.
314
+ On a search, the CSRC data sources are fetched and stored in the user's cache,
315
+ `~/.relaton/nist/pubs-export.zip`. The cache follows
316
+ `Relaton::Index.config.storage_dir`, so one setting moves it together with the
317
+ index cache. When the cache cannot be written (for example on a read-only file
318
+ system such as AWS Lambda), the data stays in memory for the process. When the
319
+ CSRC data cannot be used at all, the search continues with the
320
+ `relaton-data-nist` index.
315
321
 
316
322
  This gem uses the https://github.com/usnistgov/NIST-Tech-Pubs/releases/download/May2024/allrecords-MODS.xml dataset as NIST-Tech-Pubs data source.
317
323