iev 0.4.6 → 0.4.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.
- checksums.yaml +4 -4
- data/.claude/scheduled_tasks.lock +1 -0
- data/.rubocop_todo.yml +5 -47
- data/CLAUDE.md +11 -1
- data/Gemfile +4 -2
- data/TODO.reconcile/01-design.md +57 -0
- data/TODO.reconcile/02-change-models.md +44 -0
- data/TODO.reconcile/02-termbase-loader.md +48 -0
- data/TODO.reconcile/03-live-loader.md +28 -0
- data/TODO.reconcile/03-status-mapper.md +24 -0
- data/TODO.reconcile/04-concept-merger.md +45 -0
- data/TODO.reconcile/04-termbase-loader.md +28 -0
- data/TODO.reconcile/05-content-diff.md +45 -0
- data/TODO.reconcile/05-live-loader.md +20 -0
- data/TODO.reconcile/06-content-differ.md +32 -0
- data/TODO.reconcile/06-v3-serializer.md +42 -0
- data/TODO.reconcile/07-cli-and-run.md +27 -0
- data/TODO.reconcile/07-concept-merger.md +32 -0
- data/TODO.reconcile/08-report.md +53 -0
- data/TODO.reconcile/09-pipeline.md +23 -0
- data/TODO.reconcile/10-script-and-run.md +15 -0
- data/data/locales/glossarist_enums.yml +705 -0
- data/iev.gemspec +4 -2
- data/lib/iev/bibliography_builder.rb +87 -0
- data/lib/iev/cli/command.rb +29 -0
- data/lib/iev/cli/command_helper.rb +1 -6
- data/lib/iev/config.rb +10 -1
- data/lib/iev/exporter.rb +37 -4
- data/lib/iev/figure_builder.rb +186 -0
- data/lib/iev/multi_doc_yaml.rb +60 -0
- data/lib/iev/reconciler/change.rb +29 -0
- data/lib/iev/reconciler/change_set.rb +48 -0
- data/lib/iev/reconciler/concept_merger.rb +130 -0
- data/lib/iev/reconciler/content_differ.rb +157 -0
- data/lib/iev/reconciler/live_loader.rb +163 -0
- data/lib/iev/reconciler/pipeline.rb +104 -0
- data/lib/iev/reconciler/reconciled_concept.rb +14 -0
- data/lib/iev/reconciler/report.rb +118 -0
- data/lib/iev/reconciler/status_mapper.rb +29 -0
- data/lib/iev/reconciler/term_marker_parser.rb +196 -0
- data/lib/iev/reconciler/termbase_loader.rb +141 -0
- data/lib/iev/reconciler.rb +17 -0
- data/lib/iev/scraper/browser.rb +155 -50
- data/lib/iev/scraper/page_parser.rb +116 -24
- data/lib/iev/source_parser.rb +12 -1
- data/lib/iev/subject_area_concepts.rb +4 -2
- data/lib/iev/utilities.rb +7 -5
- data/lib/iev/version.rb +1 -1
- data/lib/iev.rb +4 -0
- data/scripts/find_unparseable.rb +22 -0
- metadata +54 -6
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iev
|
|
4
|
+
# Builds a `Glossarist::BibliographyData` from the sources cited across a
|
|
5
|
+
# concept collection.
|
|
6
|
+
#
|
|
7
|
+
# Each unique `(source, id)` pair from a concept's `ConceptSource#origin`
|
|
8
|
+
# becomes one `BibliographyEntry`. The entry's `id` is the normalized
|
|
9
|
+
# anchor that `Glossarist::Validation::BibliographyIndex` uses for
|
|
10
|
+
# resolution — so the same normalization rules are applied here.
|
|
11
|
+
module BibliographyBuilder
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param concepts [Enumerable<Glossarist::ManagedConcept>]
|
|
15
|
+
# @return [Glossarist::BibliographyData]
|
|
16
|
+
def build(concepts)
|
|
17
|
+
entries = collect_entries(concepts)
|
|
18
|
+
Glossarist::BibliographyData.new(entries: entries)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def collect_entries(concepts)
|
|
22
|
+
seen = {}
|
|
23
|
+
concepts.each do |concept|
|
|
24
|
+
concept.localizations.each do |l10n|
|
|
25
|
+
collect_from_l10n(l10n, seen)
|
|
26
|
+
end
|
|
27
|
+
Array(concept.sources).each { |src| add_source_entry(src, seen) }
|
|
28
|
+
end
|
|
29
|
+
seen.values.sort_by(&:id)
|
|
30
|
+
end
|
|
31
|
+
private_class_method :collect_entries
|
|
32
|
+
|
|
33
|
+
def collect_from_l10n(l10n, seen)
|
|
34
|
+
Array(l10n.all_sources).each { |src| add_source_entry(src, seen) }
|
|
35
|
+
end
|
|
36
|
+
private_class_method :collect_from_l10n
|
|
37
|
+
|
|
38
|
+
def add_source_entry(source, seen)
|
|
39
|
+
ref = source_origin_ref(source)
|
|
40
|
+
return unless ref
|
|
41
|
+
|
|
42
|
+
seen[entry_label(ref)] ||= build_entry(ref, source&.origin)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :add_source_entry
|
|
45
|
+
|
|
46
|
+
def source_origin_ref(source)
|
|
47
|
+
ref = source&.origin&.ref
|
|
48
|
+
return unless ref&.source && !ref.source.strip.empty?
|
|
49
|
+
|
|
50
|
+
ref
|
|
51
|
+
end
|
|
52
|
+
private_class_method :source_origin_ref
|
|
53
|
+
|
|
54
|
+
def build_entry(ref, origin)
|
|
55
|
+
label = entry_label(ref)
|
|
56
|
+
Glossarist::BibliographyEntry.new(
|
|
57
|
+
id: normalize_anchor(label),
|
|
58
|
+
reference: label,
|
|
59
|
+
link: origin&.link,
|
|
60
|
+
type: type_for(ref.source),
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :build_entry
|
|
64
|
+
|
|
65
|
+
def entry_label(ref)
|
|
66
|
+
[ref.source, ref.id].compact.join(" ").strip
|
|
67
|
+
end
|
|
68
|
+
private_class_method :entry_label
|
|
69
|
+
|
|
70
|
+
# Mirrors `Glossarist::Validation::BibliographyIndex#normalize_anchor`
|
|
71
|
+
# so the id we write matches what the validator will look up.
|
|
72
|
+
def normalize_anchor(anchor)
|
|
73
|
+
anchor.to_s.gsub(/[ \/:]/, "_").gsub(/__+/, "_").downcase
|
|
74
|
+
end
|
|
75
|
+
private_class_method :normalize_anchor
|
|
76
|
+
|
|
77
|
+
def type_for(source)
|
|
78
|
+
case source.to_s
|
|
79
|
+
when /\A(IEV|VIM|JCGM)/ then "vocabulary"
|
|
80
|
+
when /\AITU/ then "recommendation"
|
|
81
|
+
when /\A(BIPM|BBIPM)/ then "brochure"
|
|
82
|
+
else "standard"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
private_class_method :type_for
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/iev/cli/command.rb
CHANGED
|
@@ -187,9 +187,38 @@ module Iev
|
|
|
187
187
|
exit 1
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
+
desc "reparse", "Parse cached HTML into Glossarist YAML concept files"
|
|
191
|
+
option :output, aliases: :o,
|
|
192
|
+
default: File.join(Dir.pwd, "concepts"),
|
|
193
|
+
desc: "Output directory"
|
|
194
|
+
option :pages_dir, desc: "Directory containing cached HTML pages"
|
|
195
|
+
def reparse
|
|
196
|
+
pages_dir = options[:pages_dir] || Iev.config.pages_dir
|
|
197
|
+
concepts_dir = build_reparse_dir(options[:output])
|
|
198
|
+
collection = Glossarist::ManagedConceptCollection.new
|
|
199
|
+
Dir.glob(File.join(pages_dir, "pages", "*.html")).sort.each do |path|
|
|
200
|
+
code = File.basename(path, ".html")
|
|
201
|
+
html = File.read(path, encoding: "utf-8")
|
|
202
|
+
doc = Nokogiri::HTML(html)
|
|
203
|
+
raw = Iev::Scraper::PageParser.new(doc, code).parse
|
|
204
|
+
next unless raw
|
|
205
|
+
collection.store(build_concept_from_raw(code, raw))
|
|
206
|
+
end
|
|
207
|
+
Iev::MultiDocYaml.save_grouped_concepts(collection, concepts_dir.to_s)
|
|
208
|
+
info "Reparsed #{collection.count} concepts into #{concepts_dir}."
|
|
209
|
+
end
|
|
210
|
+
|
|
190
211
|
def self.exit_on_failure?
|
|
191
212
|
true
|
|
192
213
|
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def build_reparse_dir(output)
|
|
218
|
+
concepts_dir = Pathname.new(output).expand_path.join("concepts")
|
|
219
|
+
FileUtils.mkdir_p(concepts_dir)
|
|
220
|
+
concepts_dir
|
|
221
|
+
end
|
|
193
222
|
end
|
|
194
223
|
end
|
|
195
224
|
end
|
|
@@ -121,12 +121,7 @@ module Iev
|
|
|
121
121
|
end
|
|
122
122
|
|
|
123
123
|
def print_concept_grouped_yaml(concept)
|
|
124
|
-
|
|
125
|
-
content << concept.to_yaml
|
|
126
|
-
concept.localized_concepts.each_key do |lang|
|
|
127
|
-
content << concept.localization(lang).to_yaml
|
|
128
|
-
end
|
|
129
|
-
puts content.join("\n")
|
|
124
|
+
puts Iev::MultiDocYaml.join(Iev::MultiDocYaml.parts_for(concept))
|
|
130
125
|
end
|
|
131
126
|
end
|
|
132
127
|
end
|
data/lib/iev/config.rb
CHANGED
|
@@ -6,12 +6,21 @@ module Iev
|
|
|
6
6
|
class Config
|
|
7
7
|
DEFAULT_REMOTE_BASE_URL = "https://raw.githubusercontent.com/glossarist/glossarist-data-iev/main/concepts"
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# Default mirror cache lives OUTSIDE the repo, at <repo-parent>/iev-data-latest,
|
|
10
|
+
# which is itself a private git repo (glossarist/iev-data-latest) so the
|
|
11
|
+
# raw HTML snapshots are versioned and shareable. This keeps the cache
|
|
12
|
+
# outside repo operations (rm -rf, branch switches), persists across
|
|
13
|
+
# reboots (so Dir.tmpdir is wrong — macOS purges it), and survives
|
|
14
|
+
# accidental deletion of the iev working tree.
|
|
15
|
+
DEFAULT_PAGES_DIR = File.expand_path("../../../iev-data-latest", __dir__)
|
|
16
|
+
|
|
17
|
+
attr_accessor :data_path, :cache_dir, :remote_base_url, :pages_dir
|
|
10
18
|
|
|
11
19
|
def initialize
|
|
12
20
|
@data_path = ENV.fetch("IEV_DATA_PATH", nil)
|
|
13
21
|
@cache_dir = ENV["IEV_CACHE_DIR"] || File.join(Dir.tmpdir, "iev-cache")
|
|
14
22
|
@remote_base_url = DEFAULT_REMOTE_BASE_URL
|
|
23
|
+
@pages_dir = ENV["IEV_PAGES_DIR"] || DEFAULT_PAGES_DIR
|
|
15
24
|
end
|
|
16
25
|
end
|
|
17
26
|
end
|
data/lib/iev/exporter.rb
CHANGED
|
@@ -56,13 +56,18 @@ module Iev
|
|
|
56
56
|
collection = build_collection(dataset)
|
|
57
57
|
add_subject_area_concepts(collection) if @include_areas
|
|
58
58
|
build_section_narrower_relations(collection) if @include_areas
|
|
59
|
+
figures = FigureBuilder.extract!(collection)
|
|
60
|
+
enrich_references(collection)
|
|
59
61
|
save_collection(collection)
|
|
62
|
+
save_figures(figures)
|
|
63
|
+
save_bibliography(BibliographyBuilder.build(collection))
|
|
60
64
|
save_register
|
|
61
65
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
62
66
|
|
|
63
67
|
@stats = {
|
|
64
68
|
concept_count: collection.count,
|
|
65
69
|
localized_count: localized_count(collection),
|
|
70
|
+
figure_count: figures.length,
|
|
66
71
|
elapsed_seconds: elapsed,
|
|
67
72
|
}
|
|
68
73
|
collection
|
|
@@ -171,8 +176,35 @@ module Iev
|
|
|
171
176
|
|
|
172
177
|
def save_collection(collection)
|
|
173
178
|
concepts_dir = output_dir.expand_path.join("concepts")
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
MultiDocYaml.save_grouped_concepts(collection, concepts_dir.to_s)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def save_figures(figures)
|
|
183
|
+
return if figures.empty?
|
|
184
|
+
|
|
185
|
+
figures_dir = output_dir.expand_path.join("figures")
|
|
186
|
+
FileUtils.mkdir_p(figures_dir)
|
|
187
|
+
figures.each do |figure|
|
|
188
|
+
path = figures_dir.join("#{figure.id}.yaml")
|
|
189
|
+
File.write(path, figure.to_yaml, encoding: "utf-8")
|
|
190
|
+
end
|
|
191
|
+
puts "Written #{figures.length} figures to figures/" if $stdout.tty?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def save_bibliography(bibliography)
|
|
195
|
+
return if bibliography.entries.empty?
|
|
196
|
+
|
|
197
|
+
path = output_dir.expand_path.join("bibliography.yaml")
|
|
198
|
+
FileUtils.mkdir_p(path.dirname)
|
|
199
|
+
File.write(path, bibliography.to_yaml, encoding: "utf-8")
|
|
200
|
+
count = bibliography.entries.length
|
|
201
|
+
puts "Written bibliography.yaml with #{count} entries" if $stdout.tty?
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def enrich_references(collection)
|
|
205
|
+
return if collection.none?
|
|
206
|
+
|
|
207
|
+
Glossarist::ConceptEnricher.new.inject_references(collection.to_a)
|
|
176
208
|
end
|
|
177
209
|
|
|
178
210
|
def save_register
|
|
@@ -275,7 +307,8 @@ module Iev
|
|
|
275
307
|
|
|
276
308
|
concept.related << Glossarist::RelatedConcept.new(
|
|
277
309
|
type: "broader",
|
|
278
|
-
|
|
310
|
+
# lang-keyed hash per glossarist 2.13 RelatedConcept L10N invariant
|
|
311
|
+
content: { "eng" => code.section_uri },
|
|
279
312
|
ref: Glossarist::ConceptRef.new(source: "IEV", id: code.section_uri),
|
|
280
313
|
)
|
|
281
314
|
end
|
|
@@ -300,7 +333,7 @@ module Iev
|
|
|
300
333
|
narrower = child_ids.sort.map do |child_id|
|
|
301
334
|
Glossarist::RelatedConcept.new(
|
|
302
335
|
type: "narrower",
|
|
303
|
-
content: child_id,
|
|
336
|
+
content: { "eng" => child_id },
|
|
304
337
|
ref: Glossarist::ConceptRef.new(source: "IEV", id: child_id),
|
|
305
338
|
)
|
|
306
339
|
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iev
|
|
4
|
+
# Hoists IEV figure references into dataset-shared Figure entities.
|
|
5
|
+
#
|
|
6
|
+
# IEV source data carries figures as inline SIMG tags, which Utilities
|
|
7
|
+
# rewrites to AsciiDoc image macros (+image::/assets/images/parts/{area}/
|
|
8
|
+
# FILE[Figure N - caption]+). This builder walks every concept's
|
|
9
|
+
# localizations, finds those image macros, promotes each to a
|
|
10
|
+
# dataset-shared Glossarist::Figure entity, and rewrites the inline text
|
|
11
|
+
# to a V3 figure mention (+{{fig:id, display}}+).
|
|
12
|
+
#
|
|
13
|
+
# The Figure entity is shared across concepts and languages — captions
|
|
14
|
+
# from different localizations merge into the same {lang => text} hash.
|
|
15
|
+
# The structural link from concept to figure is a FigureReference entry
|
|
16
|
+
# on ManagedConceptData#figures.
|
|
17
|
+
#
|
|
18
|
+
# Extraction is destructive: it mutates DetailedDefinition#content and
|
|
19
|
+
# appends FigureReference entries. Returns the unique Figure entities so
|
|
20
|
+
# the exporter can persist them to figures/{id}.yaml.
|
|
21
|
+
module FigureBuilder
|
|
22
|
+
# URL path prefix emitted by Utilities when converting SIMG tags.
|
|
23
|
+
# Kept in sync with Utilities::IMAGE_PATH_PREFIX (without the macro).
|
|
24
|
+
PATH_PREFIX = "/assets/images/parts"
|
|
25
|
+
private_constant :PATH_PREFIX
|
|
26
|
+
|
|
27
|
+
# Matches AsciiDoc image macros emitted by Utilities#process_simg_figures.
|
|
28
|
+
IMAGE_MACRO_REGEX = /
|
|
29
|
+
image::#{Regexp.escape(PATH_PREFIX)}
|
|
30
|
+
\/(?<area>\d+)\/(?<file>[\w.-]+)\[(?<caption>[^\]]*)\]
|
|
31
|
+
/x
|
|
32
|
+
|
|
33
|
+
# Captures "Figure N" label and the trailing caption text.
|
|
34
|
+
CAPTION_REGEX = /\A(?<label>Figure\s+\d+)\s*[–-]\s*(?<text>.+)\z/m
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param collection [Glossarist::ManagedConceptCollection]
|
|
39
|
+
# @return [Array<Glossarist::Figure>] unique figures, sorted by id
|
|
40
|
+
def extract!(collection)
|
|
41
|
+
figures_by_id = {}
|
|
42
|
+
|
|
43
|
+
collection.each do |concept|
|
|
44
|
+
concept.localizations.each do |l10n|
|
|
45
|
+
process_localization(l10n, concept, figures_by_id)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
figures_by_id.values.sort_by(&:id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def process_localization(l10n, concept, figures_by_id)
|
|
53
|
+
lang = l10n.data&.language_code
|
|
54
|
+
return unless lang && lang.length == 3
|
|
55
|
+
|
|
56
|
+
Glossarist::ConceptData.detailed_definition_fields.each do |field|
|
|
57
|
+
process_field(l10n, field, lang, concept, figures_by_id)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
private_class_method :process_localization
|
|
61
|
+
|
|
62
|
+
def process_field(l10n, field, lang, concept, figures_by_id)
|
|
63
|
+
l10n.data.public_send(field).each do |dd|
|
|
64
|
+
next unless dd.content&.include?("image::")
|
|
65
|
+
|
|
66
|
+
rewritten, hits = extract_from_text(dd.content, lang)
|
|
67
|
+
next if hits.empty?
|
|
68
|
+
|
|
69
|
+
dd.content = rewritten
|
|
70
|
+
hits.each { |hit| register_figure(hit, concept, figures_by_id) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
private_class_method :process_field
|
|
74
|
+
|
|
75
|
+
# @return [Array<(String, Array<Hash>)>] rewritten text and per-match
|
|
76
|
+
# figure descriptors ({ id:, identifier:, caption:, lang:, image: })
|
|
77
|
+
def extract_from_text(text, lang)
|
|
78
|
+
hits = []
|
|
79
|
+
rewritten = text.gsub(IMAGE_MACRO_REGEX) do
|
|
80
|
+
hit = build_hit(Regexp.last_match, lang)
|
|
81
|
+
hits << hit
|
|
82
|
+
mention_for(hit)
|
|
83
|
+
end
|
|
84
|
+
[rewritten, hits]
|
|
85
|
+
end
|
|
86
|
+
private_class_method :extract_from_text
|
|
87
|
+
|
|
88
|
+
def build_hit(match, lang)
|
|
89
|
+
identifier, caption = parse_caption(match[:caption])
|
|
90
|
+
{
|
|
91
|
+
id: figure_id_for(match[:file]),
|
|
92
|
+
identifier: identifier,
|
|
93
|
+
caption: caption,
|
|
94
|
+
lang: lang,
|
|
95
|
+
image: build_image(match[:area], match[:file]),
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
private_class_method :build_hit
|
|
99
|
+
|
|
100
|
+
def build_image(area, file)
|
|
101
|
+
Glossarist::FigureImage.new(
|
|
102
|
+
src: "#{PATH_PREFIX}/#{area}/#{file}",
|
|
103
|
+
format: format_for(file),
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
private_class_method :build_image
|
|
107
|
+
|
|
108
|
+
def parse_caption(bracket)
|
|
109
|
+
stripped = bracket.to_s.strip
|
|
110
|
+
return [nil, nil] if stripped.empty?
|
|
111
|
+
|
|
112
|
+
if (m = stripped.match(CAPTION_REGEX))
|
|
113
|
+
label = m[:label].gsub(/\s+/, " ")
|
|
114
|
+
[label, m[:text].strip]
|
|
115
|
+
elsif stripped.match?(/\AFigure\s+\d+\z/)
|
|
116
|
+
[stripped, nil]
|
|
117
|
+
else
|
|
118
|
+
[nil, stripped]
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
private_class_method :parse_caption
|
|
122
|
+
|
|
123
|
+
def figure_id_for(file)
|
|
124
|
+
"fig-#{file.sub(/\.[^.]+\z/, '')}"
|
|
125
|
+
end
|
|
126
|
+
private_class_method :figure_id_for
|
|
127
|
+
|
|
128
|
+
def format_for(file)
|
|
129
|
+
File.extname(file).delete_prefix(".").downcase
|
|
130
|
+
end
|
|
131
|
+
private_class_method :format_for
|
|
132
|
+
|
|
133
|
+
def mention_for(hit)
|
|
134
|
+
parts = [hit[:identifier], hit[:caption]].compact
|
|
135
|
+
return "{{fig:#{hit[:id]}}}" if parts.empty?
|
|
136
|
+
|
|
137
|
+
"{{fig:#{hit[:id]}, #{parts.join(' - ')}}}"
|
|
138
|
+
end
|
|
139
|
+
private_class_method :mention_for
|
|
140
|
+
|
|
141
|
+
# Add or merge a figure descriptor into the shared index, and ensure the
|
|
142
|
+
# concept carries a FigureReference to it.
|
|
143
|
+
def register_figure(hit, concept, figures_by_id)
|
|
144
|
+
figure = figures_by_id[hit[:id]] ||= build_figure(hit)
|
|
145
|
+
merge_caption!(figure, hit)
|
|
146
|
+
add_image_if_missing(figure, hit[:image])
|
|
147
|
+
add_figure_reference(concept, hit[:id], hit[:identifier])
|
|
148
|
+
end
|
|
149
|
+
private_class_method :register_figure
|
|
150
|
+
|
|
151
|
+
def build_figure(hit)
|
|
152
|
+
Glossarist::Figure.new(
|
|
153
|
+
id: hit[:id],
|
|
154
|
+
identifier: hit[:identifier],
|
|
155
|
+
images: [],
|
|
156
|
+
caption: {},
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
private_class_method :build_figure
|
|
160
|
+
|
|
161
|
+
def merge_caption!(figure, hit)
|
|
162
|
+
return unless hit[:caption]
|
|
163
|
+
|
|
164
|
+
figure.caption ||= {}
|
|
165
|
+
figure.caption[hit[:lang]] ||= hit[:caption]
|
|
166
|
+
end
|
|
167
|
+
private_class_method :merge_caption!
|
|
168
|
+
|
|
169
|
+
def add_image_if_missing(figure, image)
|
|
170
|
+
return if figure.images.any? { |i| i.src == image.src }
|
|
171
|
+
|
|
172
|
+
figure.images << image
|
|
173
|
+
end
|
|
174
|
+
private_class_method :add_image_if_missing
|
|
175
|
+
|
|
176
|
+
def add_figure_reference(concept, figure_id, display)
|
|
177
|
+
refs = Array(concept.data.figures)
|
|
178
|
+
return if refs.any? { |r| r.entity_id == figure_id }
|
|
179
|
+
|
|
180
|
+
concept.data.figures = refs + [
|
|
181
|
+
Glossarist::FigureReference.new(entity_id: figure_id, display: display),
|
|
182
|
+
]
|
|
183
|
+
end
|
|
184
|
+
private_class_method :add_figure_reference
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Iev
|
|
6
|
+
# Assembles serialized glossarist models into valid multi-document YAML.
|
|
7
|
+
#
|
|
8
|
+
# Lutaml::Model versions below 0.8.30 emit `to_yaml` without the leading
|
|
9
|
+
# "---" document start marker. Concatenating such outputs naively (as
|
|
10
|
+
# glossarist's `save_grouped_concepts_to_files` does with `join("\n")`)
|
|
11
|
+
# merges all documents into one YAML mapping, with later keys overriding
|
|
12
|
+
# earlier ones — the last localized concept would silently replace the
|
|
13
|
+
# managed concept's data. `join` guarantees each part is a self-contained
|
|
14
|
+
# YAML document and is a no-op for outputs that already carry the marker.
|
|
15
|
+
module MultiDocYaml
|
|
16
|
+
DOCUMENT_START = "---"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Serialized YAML documents for a managed concept: the concept first,
|
|
21
|
+
# then each of its localizations (glossarist's grouped file layout).
|
|
22
|
+
def parts_for(concept)
|
|
23
|
+
[concept.to_yaml].tap do |parts|
|
|
24
|
+
(concept.localized_concepts || {}).each_key do |lang|
|
|
25
|
+
l10n = concept.localization(lang)
|
|
26
|
+
parts << l10n.to_yaml if l10n
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Concatenates serialized YAML parts into a parseable document stream.
|
|
32
|
+
def join(parts)
|
|
33
|
+
parts.map { |part| with_document_start(part) }.join("\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Writes joined parts to +path+ as UTF-8.
|
|
37
|
+
def write(path, parts)
|
|
38
|
+
File.write(path, join(parts), encoding: "utf-8")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Writes one grouped YAML file per managed concept (concept followed by
|
|
42
|
+
# its localizations) into +dir+, named by the concept's uuid — the same
|
|
43
|
+
# layout and naming as glossarist's `save_grouped_concepts_to_files`,
|
|
44
|
+
# but document-marker-safe.
|
|
45
|
+
def save_grouped_concepts(collection, dir)
|
|
46
|
+
FileUtils.mkdir_p(dir)
|
|
47
|
+
|
|
48
|
+
collection.each do |concept|
|
|
49
|
+
write(File.join(dir.to_s, "#{concept.uuid}.yaml"), parts_for(concept))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Prepends the YAML document start marker unless already present.
|
|
54
|
+
def with_document_start(yaml)
|
|
55
|
+
return yaml if yaml.start_with?(DOCUMENT_START)
|
|
56
|
+
|
|
57
|
+
"#{DOCUMENT_START}\n#{yaml}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iev
|
|
4
|
+
module Reconciler
|
|
5
|
+
# A single field-level difference between the termbase snapshot and
|
|
6
|
+
# the live snapshot of one concept. Collected into a ChangeSet.
|
|
7
|
+
#
|
|
8
|
+
# @attr code [String] IEV code (e.g. "102-01-01")
|
|
9
|
+
# @attr field [Symbol] what changed: :designation, :definition, :notes,
|
|
10
|
+
# :examples, :status, :language_added, :language_removed
|
|
11
|
+
# @attr language [String, nil] ISO 639-2 code, nil for concept-level
|
|
12
|
+
# @attr old_value [String, nil] termbase value (nil for additions)
|
|
13
|
+
# @attr new_value [String, nil] live value (nil for removals)
|
|
14
|
+
# @attr detected_at [String] ISO 8601 date the change was observed
|
|
15
|
+
Change = Struct.new(:code, :field, :language, :old_value, :new_value,
|
|
16
|
+
:detected_at, keyword_init: true) do
|
|
17
|
+
def to_h
|
|
18
|
+
{
|
|
19
|
+
code: code,
|
|
20
|
+
field: field.to_s,
|
|
21
|
+
language: language,
|
|
22
|
+
detected_at: detected_at,
|
|
23
|
+
old_value: old_value,
|
|
24
|
+
new_value: new_value,
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Iev
|
|
4
|
+
module Reconciler
|
|
5
|
+
# A collection of Change records for a single concept. Enumerable,
|
|
6
|
+
# with query helpers for filtering by field or language.
|
|
7
|
+
class ChangeSet
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
attr_reader :code
|
|
11
|
+
|
|
12
|
+
def initialize(code)
|
|
13
|
+
@code = code
|
|
14
|
+
@changes = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(change)
|
|
18
|
+
@changes << change
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each(&block)
|
|
23
|
+
@changes.each(&block)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def size
|
|
27
|
+
@changes.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def empty?
|
|
31
|
+
@changes.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def by_field(field)
|
|
35
|
+
@changes.select { |c| c.field == field }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def by_language(lang)
|
|
39
|
+
@changes.select { |c| c.language == lang }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Hash of field => count, for summary statistics.
|
|
43
|
+
def summary
|
|
44
|
+
@changes.group_by(&:field).transform_values(&:size)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Iev
|
|
6
|
+
module Reconciler
|
|
7
|
+
# Merges a termbase concept and a live concept into a single
|
|
8
|
+
# ReconciledConcept. The merge preserves lifecycle dates from the
|
|
9
|
+
# termbase, uses live content for current values, and records any
|
|
10
|
+
# detected changes as ConceptDate amendments.
|
|
11
|
+
#
|
|
12
|
+
# The termbase concept is mutated in place — the pipeline streams
|
|
13
|
+
# through codes one at a time, so each concept object is used once
|
|
14
|
+
# and discarded. This avoids the cost of cloning 20k+ model objects.
|
|
15
|
+
class ConceptMerger
|
|
16
|
+
# @param differ [ContentDiffer] injectable for testing
|
|
17
|
+
def initialize(differ: ContentDiffer.new)
|
|
18
|
+
@differ = differ
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param code [String] IEV code
|
|
22
|
+
# @param termbase_concept [Glossarist::ManagedConcept, nil]
|
|
23
|
+
# @param live_concept [Glossarist::ManagedConcept, nil]
|
|
24
|
+
# @param detected_at [String] ISO 8601 date
|
|
25
|
+
# @return [ReconciledConcept]
|
|
26
|
+
def merge(code:, termbase_concept:, live_concept:, detected_at:)
|
|
27
|
+
if termbase_concept && live_concept
|
|
28
|
+
merge_both(code, termbase_concept, live_concept, detected_at)
|
|
29
|
+
elsif termbase_concept
|
|
30
|
+
retire(code, termbase_concept, detected_at)
|
|
31
|
+
else
|
|
32
|
+
ReconciledConcept.new(
|
|
33
|
+
managed_concept: live_concept,
|
|
34
|
+
change_set: ChangeSet.new(code),
|
|
35
|
+
source: :live_only,
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def merge_both(code, tb_concept, live_concept, detected_at)
|
|
43
|
+
change_set = @differ.diff(tb_concept, live_concept, detected_at: detected_at)
|
|
44
|
+
|
|
45
|
+
merge_localized_content(tb_concept, live_concept)
|
|
46
|
+
|
|
47
|
+
unless change_set.empty?
|
|
48
|
+
add_date(tb_concept, "amended", detected_at)
|
|
49
|
+
flag_stale_translations(tb_concept, change_set, detected_at)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
ReconciledConcept.new(
|
|
53
|
+
managed_concept: tb_concept,
|
|
54
|
+
change_set: change_set,
|
|
55
|
+
source: :both,
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
SOURCE_LANGUAGES = %w[eng fra].freeze
|
|
60
|
+
|
|
61
|
+
# When source-language (English/French) notes or definitions change,
|
|
62
|
+
# the translations in other languages are potentially stale because
|
|
63
|
+
# they haven't been updated to match. Flag them with an annotation.
|
|
64
|
+
def flag_stale_translations(concept, change_set, detected_at)
|
|
65
|
+
source_changes = change_set.select do |c|
|
|
66
|
+
SOURCE_LANGUAGES.include?(c.language) &&
|
|
67
|
+
%i[definition notes].include?(c.field)
|
|
68
|
+
end
|
|
69
|
+
return if source_changes.empty?
|
|
70
|
+
|
|
71
|
+
stale_langs = (concept.localized_concepts&.keys || []) - SOURCE_LANGUAGES
|
|
72
|
+
return if stale_langs.empty?
|
|
73
|
+
|
|
74
|
+
source_fields = source_changes.map { |c| "#{c.language}:#{c.field}" }.uniq.join(", ")
|
|
75
|
+
stale_langs.each do |lang|
|
|
76
|
+
lc = concept.localization(lang)
|
|
77
|
+
next unless lc&.data
|
|
78
|
+
lc.data.examples ||= []
|
|
79
|
+
lc.data.examples << Glossarist::DetailedDefinition.new(
|
|
80
|
+
content: "[Reconciler] Translation may be stale — source language content changed (#{source_fields}) on #{detected_at}",
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def retire(code, tb_concept, detected_at)
|
|
86
|
+
tb_concept.status = "retired"
|
|
87
|
+
add_date(tb_concept, "retired", detected_at)
|
|
88
|
+
|
|
89
|
+
cs = ChangeSet.new(code)
|
|
90
|
+
cs.add(Change.new(
|
|
91
|
+
code: code, field: :status, language: nil,
|
|
92
|
+
old_value: tb_concept.status, new_value: "retired",
|
|
93
|
+
detected_at: detected_at,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
ReconciledConcept.new(
|
|
97
|
+
managed_concept: tb_concept,
|
|
98
|
+
change_set: cs,
|
|
99
|
+
source: :termbase_only,
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def merge_localized_content(merged, live_concept)
|
|
104
|
+
live_concept.localized_concepts&.each_key do |lang|
|
|
105
|
+
live_lc = live_concept.localization(lang)
|
|
106
|
+
next unless live_lc&.data
|
|
107
|
+
|
|
108
|
+
existing = merged.localization(lang)
|
|
109
|
+
if existing&.data
|
|
110
|
+
update_from_live(existing.data, live_lc.data)
|
|
111
|
+
else
|
|
112
|
+
merged.add_l10n(live_lc)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def update_from_live(target_data, live_data)
|
|
118
|
+
target_data.definition = live_data.definition if live_data.definition&.any?
|
|
119
|
+
target_data.notes = live_data.notes if live_data.notes&.any?
|
|
120
|
+
target_data.examples = live_data.examples if live_data.examples&.any?
|
|
121
|
+
target_data.terms = live_data.terms if live_data.terms&.any?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def add_date(concept, type, date)
|
|
125
|
+
concept.dates ||= []
|
|
126
|
+
concept.dates << Glossarist::ConceptDate.new(type: type, date: date)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|