relaton-cli 3.0.7 → 3.1.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton-cli.rb +4 -16
  3. metadata +11 -123
  4. data/.gitignore +0 -22
  5. data/.hound.yml +0 -3
  6. data/.rspec +0 -1
  7. data/CLAUDE.md +0 -524
  8. data/Gemfile +0 -42
  9. data/Rakefile +0 -21
  10. data/bin/console +0 -11
  11. data/bin/rspec +0 -29
  12. data/bin/setup +0 -8
  13. data/docs/README.adoc +0 -589
  14. data/exe/relaton +0 -20
  15. data/frontend/dist/404.html +0 -18
  16. data/frontend/dist/app.iife.js +0 -1
  17. data/frontend/dist/style.css +0 -3
  18. data/i18n.yaml +0 -1
  19. data/lib/relaton/bibcollection.rb +0 -146
  20. data/lib/relaton/bibdata.rb +0 -92
  21. data/lib/relaton/cli/base_convertor.rb +0 -134
  22. data/lib/relaton/cli/command.rb +0 -390
  23. data/lib/relaton/cli/data_fetcher.rb +0 -20
  24. data/lib/relaton/cli/frontend_assets.rb +0 -58
  25. data/lib/relaton/cli/full_text_search.rb +0 -98
  26. data/lib/relaton/cli/index_item_normalizer.rb +0 -380
  27. data/lib/relaton/cli/index_site_generator.rb +0 -1008
  28. data/lib/relaton/cli/relaton_file.rb +0 -290
  29. data/lib/relaton/cli/subcommand_collection.rb +0 -263
  30. data/lib/relaton/cli/subcommand_db.rb +0 -81
  31. data/lib/relaton/cli/util.rb +0 -8
  32. data/lib/relaton/cli/version.rb +0 -11
  33. data/lib/relaton/cli/xml_convertor.rb +0 -47
  34. data/lib/relaton/cli/xml_to_html_renderer.rb +0 -117
  35. data/lib/relaton/cli/yaml_convertor.rb +0 -102
  36. data/lib/relaton/cli.rb +0 -113
  37. data/lib/relaton/element_finder.rb +0 -29
  38. data/relaton-cli.gemspec +0 -49
  39. data/templates/_document.liquid +0 -111
  40. data/templates/_index.liquid +0 -61
  41. data/templates/index/page.liquid +0 -20
  42. data/templates/index-style.css +0 -1017
@@ -1,98 +0,0 @@
1
- require "set"
2
-
3
- module Relaton
4
- class FullTextSeatch
5
- # Instance variables added by lutaml-model that form back-references
6
- # (cycles) when a model is serialized. Skipping them keeps recursive
7
- # traversal of a bibitem's object graph finite.
8
- INTERNAL_IVARS = %i[
9
- @using_default @lutaml_register @lutaml_parent @lutaml_root @register_records
10
- ].freeze
11
-
12
- # @return Regexp
13
- attr_reader :regex
14
-
15
- # @param collection [Relaton::Bibcollection]
16
- def initialize(collection)
17
- @collection = collection
18
- end
19
-
20
- # @param text [String]
21
- # @return [Array<Hash>]
22
- def search(text)
23
- @regex = %{(.*?)(.{0,20})(#{text})(.{0,20})(.*)}
24
- @matches = @collection.items.reduce({}) do |m, item|
25
- res = result item, Set.new
26
- m[item.id] = res if res.any?
27
- m
28
- end
29
- end
30
-
31
- def print_results
32
- @matches.each do |docid, attrs|
33
- puts " Document ID: #{docid}"
34
- print_attrs attrs, 4
35
- end
36
- end
37
-
38
- # @return [Boolean]
39
- def any?
40
- @matches.any?
41
- end
42
-
43
- private
44
-
45
- # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
46
- def print_attrs(attrs, indent)
47
- ind = " " * indent
48
- if attrs.is_a? String then puts ind + attrs
49
- elsif attrs.is_a? Hash
50
- attrs.each do |key, val|
51
- pref = "#{ind}#{key}:"
52
- if val.is_a? String then puts pref + " " + val
53
- else
54
- puts pref
55
- print_attrs val, indent + 2
56
- end
57
- end
58
- elsif attrs.is_a? Array then attrs.each { |v| print_attrs v, indent + 2 }
59
- end
60
- end
61
-
62
- # @param item [Relaton::Bibdata]
63
- # @param seen [Set<Integer>] object_ids already visited on this path
64
- # @return [Hash]
65
- def result(item, seen)
66
- if item.is_a? String
67
- message $~ if item.match regex
68
- elsif item.respond_to? :reduce
69
- item.reduce([]) do |m, i|
70
- res = result i, seen
71
- m << res if res && !res.empty?
72
- m
73
- end
74
- else
75
- return {} unless seen.add?(item.object_id)
76
-
77
- item.instance_variables.reduce({}) do |m, var|
78
- next m if INTERNAL_IVARS.include?(var)
79
-
80
- res = result item.instance_variable_get(var), seen
81
- m[var.to_s.tr(":@", "")] = res if res && !res.empty?
82
- m
83
- end
84
- end
85
- end
86
- # rubocop:enable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
87
-
88
- # @param match [MatchData]
89
- # @return [String]
90
- def message(match)
91
- msg = ""
92
- msg += "..." unless match[1].empty?
93
- msg += "#{match[2]}\e[4m#{match[3]}\e[24m#{match[4]}"
94
- msg += "..." unless match[5].empty?
95
- msg
96
- end
97
- end
98
- end
@@ -1,380 +0,0 @@
1
- module Relaton
2
- module Cli
3
- # Turns a Relaton bibliographic YAML document (already parsed to a Hash) into
4
- # the compact record the index frontend consumes. Works directly off the
5
- # document's own rendered fields (docidentifier/title/date/ext.doctype), so no
6
- # per-flavor pubid reconstruction is needed — the data files carry string
7
- # DocIDs.
8
- #
9
- # Output record keys (shared contract with frontend/src/lib/types.ts):
10
- # id, title, doctype, stage, date, link, yaml (always present) plus the
11
- # optional detail-page fields (only when non-empty): abstract, edition,
12
- # languages, keywords, publisher, contributors, docids, dates, relations.
13
- module IndexItemNormalizer
14
- module_function
15
-
16
- # @param doc [Hash] parsed Relaton YAML (string keys)
17
- # @param lang [String] preferred language for title/docid
18
- # @param yaml_ref [String, nil] URL/path to the raw YAML source
19
- # @return [Hash]
20
- def normalize(doc, lang: "en", yaml_ref: nil)
21
- {
22
- "id" => docidentifier(doc, lang),
23
- "title" => title(doc, lang),
24
- "doctype" => doctype(doc),
25
- "stage" => stage(doc),
26
- "date" => date(doc),
27
- "link" => link(doc, lang),
28
- "yaml" => yaml_ref,
29
- }.merge(details(doc, lang))
30
- end
31
-
32
- # Richer, structured fields the detail page renders. Empty/nil values are
33
- # dropped so a summary-only doc keeps the compact core shape and the
34
- # embedded JSON payload stays lean. These ride only in the embedded
35
- # payload; search.json and the crawler DOM stay summary-only.
36
- def details(doc, lang)
37
- {
38
- "abstract" => abstract(doc, lang),
39
- "edition" => edition(doc),
40
- "languages" => languages(doc),
41
- "keywords" => keywords(doc),
42
- "publisher" => publisher(doc, lang),
43
- "contributors" => contributors(doc, lang),
44
- "docids" => docids(doc),
45
- "dates" => dates(doc),
46
- "relations" => relations(doc, lang),
47
- }.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
48
- end
49
-
50
- # Pick the primary, preferred-language rendered DocID; fall back to
51
- # docnumber / id. Values can be plain strings or {content, ...} hashes.
52
- def docidentifier(doc, lang)
53
- primary_docidentifier(doc, lang) || strip(doc["docnumber"]) || doc["id"].to_s
54
- end
55
-
56
- # Just the rendered primary DocID, with no docnumber/id fallback — nil when
57
- # the item carries no docidentifier. Split out because a relation's target
58
- # needs the DocID *only*: its `id` is an internal XML anchor, which must not
59
- # outrank the formattedref (see `relation_id`).
60
- def primary_docidentifier(doc, lang)
61
- ids = wrap(doc["docidentifier"])
62
- primary = ids.select { |d| d.is_a?(Hash) && truthy(d["primary"]) }
63
- primary = ids if primary.empty?
64
-
65
- chosen =
66
- pick_lang(primary, lang) ||
67
- primary.find { |d| d.is_a?(Hash) && d["language"].nil? } ||
68
- primary.first
69
- strip(content_of(chosen))
70
- end
71
-
72
- def title(doc, lang)
73
- titles = wrap(doc["title"])
74
- # A title entry may be {type: "main", ...} and/or language-tagged.
75
- main = titles.select { |t| t.is_a?(Hash) && t["type"] == "main" }
76
- pool = main.empty? ? titles : main
77
- chosen = pick_lang(pool, lang) || pool.first
78
- strip(content_of(chosen))
79
- end
80
-
81
- def doctype(doc)
82
- dt = doc.dig("ext", "doctype") || doc["doctype"] || doc["type"]
83
- strip(content_of(dt))
84
- end
85
-
86
- def stage(doc)
87
- st = doc.dig("status", "stage") || doc["stage"]
88
- strip(content_of(st))
89
- end
90
-
91
- # date: array of {type, at|on} or a plain string. Prefer "published".
92
- def date(doc)
93
- dates = doc["date"]
94
- return normalize_date(dates) if dates.is_a?(String)
95
-
96
- arr = wrap(dates)
97
- chosen = arr.find { |d| d.is_a?(Hash) && d["type"] == "published" } ||
98
- arr.find { |d| d.is_a?(Hash) } || arr.first
99
- val = chosen.is_a?(Hash) ? (chosen["at"] || chosen["on"] || chosen["from"]) : chosen
100
- normalize_date(val)
101
- end
102
-
103
- # Human-facing link: prefer a citation source in the preferred language.
104
- def link(doc, lang)
105
- sources = Array(doc["source"]) + Array(doc["link"])
106
- return nil if sources.empty?
107
-
108
- citation = sources.select do |s|
109
- s.is_a?(Hash) && %w[citation web src].include?(s["type"])
110
- end
111
- pool = citation.empty? ? sources : citation
112
- chosen = pick_lang(pool, lang) || pool.first
113
- strip(content_of(chosen))
114
- end
115
-
116
- # --- detail-page fields --------------------------------------------------
117
-
118
- # Preferred-language abstract (String or [{language, content}]), HTML-stripped.
119
- def abstract(doc, lang)
120
- value = doc["abstract"]
121
- return strip(value) if value.is_a?(String)
122
-
123
- entries = Array(value)
124
- strip(content_of(pick_lang(entries, lang) || entries.first))
125
- end
126
-
127
- def edition(doc)
128
- strip(content_of(doc["edition"]))
129
- end
130
-
131
- # The document's language codes, e.g. ["en", "fr"].
132
- def languages(doc)
133
- wrap(doc["language"]).map { |l| strip(content_of(l)) }.compact
134
- end
135
-
136
- def keywords(doc)
137
- wrap(doc["keyword"]).map { |k| strip(content_of(k)) }.compact
138
- end
139
-
140
- # Name of the first publisher-role contributor, if any.
141
- def publisher(doc, lang)
142
- entry = wrap(doc["contributor"]).find { |c| role?(c, "publisher") }
143
- entry && contributor_name(entry, lang)
144
- end
145
-
146
- # All contributors as {name, role}. Entries without a resolvable name are
147
- # skipped so the detail page never shows a blank author line.
148
- def contributors(doc, lang)
149
- wrap(doc["contributor"]).filter_map { |c| contributor(c, lang) }
150
- end
151
-
152
- def contributor(entry, lang)
153
- return nil unless entry.is_a?(Hash)
154
-
155
- name = contributor_name(entry, lang)
156
- return nil unless name
157
-
158
- role = strip(content_of(roles_of(entry).first))
159
- { "name" => name, "role" => role }.reject { |_, v| v.nil? }
160
- end
161
-
162
- # Prefer an organization name, then a person name, then a bare "name".
163
- def contributor_name(entry, lang)
164
- org = entry["organization"]
165
- return org_name(org, lang) if org.is_a?(Hash)
166
-
167
- person = entry["person"]
168
- return person_name(person, lang) if person.is_a?(Hash)
169
-
170
- strip(content_of(entry["name"]))
171
- end
172
-
173
- def org_name(org, lang)
174
- names = wrap(org["name"])
175
- strip(content_of(pick_lang(names, lang) || names.first))
176
- end
177
-
178
- def person_name(person, lang)
179
- name = person["name"]
180
- return strip(content_of(name)) unless name.is_a?(Hash)
181
-
182
- complete = name["completename"] || name["completeName"]
183
- return strip(content_of(pick_lang(wrap(complete), lang) || complete)) if complete
184
-
185
- given = strip(content_of(name["forename"] || name["given"]))
186
- surname = strip(content_of(name["surname"]))
187
- joined = [given, surname].compact.join(" ")
188
- joined.empty? ? nil : joined
189
- end
190
-
191
- # role[].type for a contributor, as an array of raw values.
192
- def roles_of(entry)
193
- roles = entry["role"]
194
- roles = [roles] unless roles.is_a?(Array)
195
- roles.map { |r| r.is_a?(Hash) ? r["type"] : r }
196
- end
197
-
198
- def role?(entry, type)
199
- return false unless entry.is_a?(Hash)
200
-
201
- roles_of(entry).any? { |r| strip(content_of(r)) == type }
202
- end
203
-
204
- # Every docidentifier as {id, type?, language?} (blank ids dropped).
205
- def docids(doc)
206
- wrap(doc["docidentifier"]).filter_map do |d|
207
- id = strip(content_of(d))
208
- next nil unless id
209
-
210
- hash = d.is_a?(Hash) ? d : {}
211
- { "id" => id,
212
- "type" => strip(content_of(hash["type"])),
213
- "language" => strip(content_of(hash["language"])) }
214
- .reject { |_, v| v.nil? }
215
- end
216
- end
217
-
218
- # Every date as {type?, value}; a plain string date becomes {value}.
219
- def dates(doc)
220
- value = doc["date"]
221
- entries = value.is_a?(String) ? [value] : wrap(value)
222
- entries.filter_map { |d| date_entry(d) }
223
- end
224
-
225
- def date_entry(entry)
226
- unless entry.is_a?(Hash)
227
- val = normalize_date(entry)
228
- return val && { "value" => val }
229
- end
230
-
231
- val = normalize_date(entry["at"] || entry["on"] || entry["from"])
232
- return nil unless val
233
-
234
- { "type" => strip(content_of(entry["type"])), "value" => val }
235
- .reject { |_, v| v.nil? }
236
- end
237
-
238
- # Related documents as {type, id}, e.g. {"type" => "obsoletedBy",
239
- # "id" => "ISO 29862:2024"}. The id is what the detail page matches against
240
- # the rest of the corpus to decide whether the relation can be rendered as
241
- # an intra-dataset link, so it must be rendered exactly as a document's own
242
- # `id` is — hence the shared `docidentifier` helper below.
243
- #
244
- # Self-references are dropped (BIPM's Metrologia articles relate to
245
- # themselves once per carrier, which would render as a link to the page you
246
- # are already on) and identical {type, id} pairs are collapsed.
247
- def relations(doc, lang)
248
- own = docidentifier(doc, lang)
249
- wrap(doc["relation"])
250
- .filter_map { |r| relation(r, lang) }
251
- .reject { |r| r["id"] == own }
252
- .uniq
253
- end
254
-
255
- def relation(entry, lang)
256
- return nil unless entry.is_a?(Hash)
257
-
258
- id = relation_id(entry["bibitem"], lang)
259
- return nil unless id
260
-
261
- { "type" => strip(content_of(entry["type"])), "id" => id }.reject { |_, v| v.nil? }
262
- end
263
-
264
- # A relation's bibitem carries the same docidentifier shape as a top-level
265
- # document, so the primary-DocID logic is shared — that is what makes the
266
- # emitted id string-identical to the dataset id it must match.
267
- #
268
- # Order matters. `docidentifier` is NOT reused whole: its last fallback is
269
- # the item's `id`, which on a relation bibitem is an internal XML anchor
270
- # ("CENISO/TS21003-7-2008/A1-2010", as CEN records carry). That anchor can
271
- # never match a document in the corpus, so letting it outrank the rendered
272
- # formattedref would strand a target that IS present as plain text under a
273
- # mangled label. The anchor stays only as the last resort.
274
- def relation_id(bibitem, lang)
275
- return strip(content_of(bibitem)) unless bibitem.is_a?(Hash)
276
-
277
- primary_docidentifier(bibitem, lang) ||
278
- strip(content_of(bibitem["formattedref"])) ||
279
- strip(bibitem["docnumber"]) ||
280
- strip(bibitem["id"])
281
- end
282
-
283
- # --- helpers -------------------------------------------------------------
284
-
285
- # Wrap a value as an array WITHOUT `Array()`'s Hash-to-pairs explosion: a
286
- # bare Hash (a single unwrapped entry) becomes a one-element array, not a
287
- # list of [key, value] pairs. RelatonBib normally emits arrays for these
288
- # repeatable fields, but a hand-written YAML may carry a lone Hash.
289
- def wrap(value)
290
- case value
291
- when nil then []
292
- when Array then value
293
- else [value]
294
- end
295
- end
296
-
297
- def pick_lang(entries, lang)
298
- entries.find do |e|
299
- e.is_a?(Hash) && (e["language"] == lang || Array(e["language"]).include?(lang))
300
- end
301
- end
302
-
303
- # Extract a display string from a value that may be a String or a Hash with
304
- # a "content"/"name" key (recursing into arrays/hashes as needed).
305
- def content_of(value)
306
- case value
307
- when String then value
308
- when Numeric then value.to_s
309
- when Hash then content_of(value["content"] || value["name"])
310
- when Array then content_of(value.first)
311
- end
312
- end
313
-
314
- def normalize_date(value)
315
- return nil if value.nil?
316
-
317
- s = value.to_s.strip
318
- return nil if s.empty?
319
-
320
- # Keep YYYY, YYYY-MM, or YYYY-MM-DD as-is (leading 10 chars).
321
- s[0, 10]
322
- end
323
-
324
- # Strip inline HTML (e.g. <sup>) so records are clean for JSON/data-attrs.
325
- #
326
- # Counts angle brackets in one left-to-right pass instead of using a
327
- # regex, because both regex spellings are wrong here:
328
- #
329
- # * `gsub(/<[^>]+>/, "")` matches ACROSS a nested `<`, so on
330
- # "<sc<x>ript>" it removes the outer span "<sc<x>" and lets the
331
- # fragments either side of the tag it walked over close back up into
332
- # "ript>" (CodeQL rb/incomplete-multi-character-sanitization);
333
- # * narrowing to `/<[^<>]*>/` matches innermost-first and fixes that,
334
- # but then one pass no longer suffices and re-running to a fixed point
335
- # is quadratic — each pass peels a single nesting level off the whole
336
- # string. Measured at 0.7s for 8k chars of "<"*n + ">"*n, i.e. a real
337
- # ReDoS traded for the theoretical one the scanner flagged.
338
- #
339
- # This is O(n) with no backtracking and nothing to re-scan. `pending`
340
- # holds the span opened by the outermost unclosed `<` so that a bare "<"
341
- # that never closes survives as literal text ("5 < 6") rather than
342
- # swallowing the rest of the value; a bare ">" is likewise kept.
343
- def strip(value)
344
- return nil if value.nil?
345
-
346
- s = strip_tags(value.to_s).strip
347
- s.empty? ? nil : s
348
- end
349
-
350
- def strip_tags(str)
351
- return str unless str.include?("<")
352
-
353
- out = +""
354
- pending = +""
355
- depth = 0
356
-
357
- str.each_char do |char|
358
- if char == "<"
359
- depth += 1
360
- elsif depth.zero?
361
- # Outside any tag — including a stray ">", which is not markup.
362
- out << char
363
- next
364
- elsif char == ">"
365
- depth -= 1
366
- end
367
-
368
- pending << char
369
- pending.clear if depth.zero? # the span closed: drop it wholesale
370
- end
371
-
372
- depth.zero? ? out : out << pending
373
- end
374
-
375
- def truthy(value)
376
- value == true || value == "true"
377
- end
378
- end
379
- end
380
- end