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.
- checksums.yaml +4 -4
- data/lib/relaton-cli.rb +4 -16
- metadata +11 -123
- data/.gitignore +0 -22
- data/.hound.yml +0 -3
- data/.rspec +0 -1
- data/CLAUDE.md +0 -524
- data/Gemfile +0 -42
- data/Rakefile +0 -21
- data/bin/console +0 -11
- data/bin/rspec +0 -29
- data/bin/setup +0 -8
- data/docs/README.adoc +0 -589
- data/exe/relaton +0 -20
- data/frontend/dist/404.html +0 -18
- data/frontend/dist/app.iife.js +0 -1
- data/frontend/dist/style.css +0 -3
- data/i18n.yaml +0 -1
- data/lib/relaton/bibcollection.rb +0 -146
- data/lib/relaton/bibdata.rb +0 -92
- data/lib/relaton/cli/base_convertor.rb +0 -134
- data/lib/relaton/cli/command.rb +0 -390
- data/lib/relaton/cli/data_fetcher.rb +0 -20
- data/lib/relaton/cli/frontend_assets.rb +0 -58
- data/lib/relaton/cli/full_text_search.rb +0 -98
- data/lib/relaton/cli/index_item_normalizer.rb +0 -380
- data/lib/relaton/cli/index_site_generator.rb +0 -1008
- data/lib/relaton/cli/relaton_file.rb +0 -290
- data/lib/relaton/cli/subcommand_collection.rb +0 -263
- data/lib/relaton/cli/subcommand_db.rb +0 -81
- data/lib/relaton/cli/util.rb +0 -8
- data/lib/relaton/cli/version.rb +0 -11
- data/lib/relaton/cli/xml_convertor.rb +0 -47
- data/lib/relaton/cli/xml_to_html_renderer.rb +0 -117
- data/lib/relaton/cli/yaml_convertor.rb +0 -102
- data/lib/relaton/cli.rb +0 -113
- data/lib/relaton/element_finder.rb +0 -29
- data/relaton-cli.gemspec +0 -49
- data/templates/_document.liquid +0 -111
- data/templates/_index.liquid +0 -61
- data/templates/index/page.liquid +0 -20
- 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
|