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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/scheduled_tasks.lock +1 -0
  3. data/.rubocop_todo.yml +5 -47
  4. data/CLAUDE.md +11 -1
  5. data/Gemfile +4 -2
  6. data/TODO.reconcile/01-design.md +57 -0
  7. data/TODO.reconcile/02-change-models.md +44 -0
  8. data/TODO.reconcile/02-termbase-loader.md +48 -0
  9. data/TODO.reconcile/03-live-loader.md +28 -0
  10. data/TODO.reconcile/03-status-mapper.md +24 -0
  11. data/TODO.reconcile/04-concept-merger.md +45 -0
  12. data/TODO.reconcile/04-termbase-loader.md +28 -0
  13. data/TODO.reconcile/05-content-diff.md +45 -0
  14. data/TODO.reconcile/05-live-loader.md +20 -0
  15. data/TODO.reconcile/06-content-differ.md +32 -0
  16. data/TODO.reconcile/06-v3-serializer.md +42 -0
  17. data/TODO.reconcile/07-cli-and-run.md +27 -0
  18. data/TODO.reconcile/07-concept-merger.md +32 -0
  19. data/TODO.reconcile/08-report.md +53 -0
  20. data/TODO.reconcile/09-pipeline.md +23 -0
  21. data/TODO.reconcile/10-script-and-run.md +15 -0
  22. data/data/locales/glossarist_enums.yml +705 -0
  23. data/iev.gemspec +4 -2
  24. data/lib/iev/bibliography_builder.rb +87 -0
  25. data/lib/iev/cli/command.rb +29 -0
  26. data/lib/iev/cli/command_helper.rb +1 -6
  27. data/lib/iev/config.rb +10 -1
  28. data/lib/iev/exporter.rb +37 -4
  29. data/lib/iev/figure_builder.rb +186 -0
  30. data/lib/iev/multi_doc_yaml.rb +60 -0
  31. data/lib/iev/reconciler/change.rb +29 -0
  32. data/lib/iev/reconciler/change_set.rb +48 -0
  33. data/lib/iev/reconciler/concept_merger.rb +130 -0
  34. data/lib/iev/reconciler/content_differ.rb +157 -0
  35. data/lib/iev/reconciler/live_loader.rb +163 -0
  36. data/lib/iev/reconciler/pipeline.rb +104 -0
  37. data/lib/iev/reconciler/reconciled_concept.rb +14 -0
  38. data/lib/iev/reconciler/report.rb +118 -0
  39. data/lib/iev/reconciler/status_mapper.rb +29 -0
  40. data/lib/iev/reconciler/term_marker_parser.rb +196 -0
  41. data/lib/iev/reconciler/termbase_loader.rb +141 -0
  42. data/lib/iev/reconciler.rb +17 -0
  43. data/lib/iev/scraper/browser.rb +155 -50
  44. data/lib/iev/scraper/page_parser.rb +116 -24
  45. data/lib/iev/source_parser.rb +12 -1
  46. data/lib/iev/subject_area_concepts.rb +4 -2
  47. data/lib/iev/utilities.rb +7 -5
  48. data/lib/iev/version.rb +1 -1
  49. data/lib/iev.rb +4 -0
  50. data/scripts/find_unparseable.rb +22 -0
  51. 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
@@ -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
- content = []
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
- attr_accessor :data_path, :cache_dir, :remote_base_url
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
- FileUtils.mkdir_p(concepts_dir)
175
- collection.save_grouped_concepts_to_files(concepts_dir.to_s)
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
- content: code.section_uri,
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