iev 0.4.7 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/scheduled_tasks.lock +1 -0
  3. data/.rubocop_todo.yml +5 -5
  4. data/Gemfile +1 -0
  5. data/TODO.reconcile/01-design.md +57 -0
  6. data/TODO.reconcile/02-change-models.md +44 -0
  7. data/TODO.reconcile/02-termbase-loader.md +48 -0
  8. data/TODO.reconcile/03-live-loader.md +28 -0
  9. data/TODO.reconcile/03-status-mapper.md +24 -0
  10. data/TODO.reconcile/04-concept-merger.md +45 -0
  11. data/TODO.reconcile/04-termbase-loader.md +28 -0
  12. data/TODO.reconcile/05-content-diff.md +45 -0
  13. data/TODO.reconcile/05-live-loader.md +20 -0
  14. data/TODO.reconcile/06-content-differ.md +32 -0
  15. data/TODO.reconcile/06-v3-serializer.md +42 -0
  16. data/TODO.reconcile/07-cli-and-run.md +27 -0
  17. data/TODO.reconcile/07-concept-merger.md +32 -0
  18. data/TODO.reconcile/08-report.md +53 -0
  19. data/TODO.reconcile/09-pipeline.md +23 -0
  20. data/TODO.reconcile/10-script-and-run.md +15 -0
  21. data/data/locales/glossarist_enums.yml +705 -0
  22. data/iev.gemspec +3 -1
  23. data/lib/iev/cli/command.rb +29 -0
  24. data/lib/iev/cli/command_helper.rb +1 -6
  25. data/lib/iev/config.rb +10 -1
  26. data/lib/iev/exporter.rb +4 -4
  27. data/lib/iev/multi_doc_yaml.rb +60 -0
  28. data/lib/iev/reconciler/change.rb +29 -0
  29. data/lib/iev/reconciler/change_set.rb +48 -0
  30. data/lib/iev/reconciler/concept_merger.rb +130 -0
  31. data/lib/iev/reconciler/content_differ.rb +157 -0
  32. data/lib/iev/reconciler/live_loader.rb +163 -0
  33. data/lib/iev/reconciler/pipeline.rb +104 -0
  34. data/lib/iev/reconciler/reconciled_concept.rb +14 -0
  35. data/lib/iev/reconciler/report.rb +118 -0
  36. data/lib/iev/reconciler/status_mapper.rb +29 -0
  37. data/lib/iev/reconciler/term_marker_parser.rb +196 -0
  38. data/lib/iev/reconciler/termbase_loader.rb +141 -0
  39. data/lib/iev/reconciler.rb +17 -0
  40. data/lib/iev/scraper/browser.rb +155 -50
  41. data/lib/iev/scraper/page_parser.rb +116 -24
  42. data/lib/iev/source_parser.rb +12 -1
  43. data/lib/iev/subject_area_concepts.rb +4 -2
  44. data/lib/iev/utilities.rb +7 -5
  45. data/lib/iev/version.rb +1 -1
  46. data/lib/iev.rb +2 -0
  47. data/scripts/find_unparseable.rb +22 -0
  48. metadata +50 -4
@@ -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
@@ -176,8 +176,7 @@ module Iev
176
176
 
177
177
  def save_collection(collection)
178
178
  concepts_dir = output_dir.expand_path.join("concepts")
179
- FileUtils.mkdir_p(concepts_dir)
180
- collection.save_grouped_concepts_to_files(concepts_dir.to_s)
179
+ MultiDocYaml.save_grouped_concepts(collection, concepts_dir.to_s)
181
180
  end
182
181
 
183
182
  def save_figures(figures)
@@ -308,7 +307,8 @@ module Iev
308
307
 
309
308
  concept.related << Glossarist::RelatedConcept.new(
310
309
  type: "broader",
311
- content: code.section_uri,
310
+ # lang-keyed hash per glossarist 2.13 RelatedConcept L10N invariant
311
+ content: { "eng" => code.section_uri },
312
312
  ref: Glossarist::ConceptRef.new(source: "IEV", id: code.section_uri),
313
313
  )
314
314
  end
@@ -333,7 +333,7 @@ module Iev
333
333
  narrower = child_ids.sort.map do |child_id|
334
334
  Glossarist::RelatedConcept.new(
335
335
  type: "narrower",
336
- content: child_id,
336
+ content: { "eng" => child_id },
337
337
  ref: Glossarist::ConceptRef.new(source: "IEV", id: child_id),
338
338
  )
339
339
  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
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iev
4
+ module Reconciler
5
+ # Compares two Glossarist::ManagedConcept objects and produces a
6
+ # ChangeSet with field-level differences. This powers the change
7
+ # reporting that answers "what changed, from what to what?"
8
+ class ContentDiffer
9
+ # @param old_concept [Glossarist::ManagedConcept] the termbase snapshot
10
+ # @param new_concept [Glossarist::ManagedConcept] the live snapshot
11
+ # @param detected_at [String] ISO 8601 date the change was observed
12
+ # @return [ChangeSet]
13
+ def diff(old_concept, new_concept, detected_at:)
14
+ code = new_concept&.id || old_concept&.id
15
+ change_set = ChangeSet.new(code)
16
+
17
+ return change_set unless old_concept && new_concept
18
+
19
+ diff_status(change_set, old_concept, new_concept, detected_at)
20
+ diff_languages(change_set, old_concept, new_concept, detected_at)
21
+ diff_localized(change_set, old_concept, new_concept, detected_at)
22
+
23
+ change_set
24
+ end
25
+
26
+ private
27
+
28
+ def diff_status(cs, old_c, new_c, date)
29
+ old_status = old_c.status
30
+ new_status = new_c.status
31
+ return if old_status == new_status
32
+
33
+ cs.add(Change.new(
34
+ code: cs.code,
35
+ field: :status,
36
+ language: nil,
37
+ old_value: old_status,
38
+ new_value: new_status,
39
+ detected_at: date,
40
+ ))
41
+ end
42
+
43
+ def diff_languages(cs, old_c, new_c, date)
44
+ old_langs = language_keys(old_c)
45
+ new_langs = language_keys(new_c)
46
+
47
+ (new_langs - old_langs).each do |lang|
48
+ cs.add(Change.new(
49
+ code: cs.code, field: :language_added, language: lang,
50
+ old_value: nil, new_value: "present", detected_at: date,
51
+ ))
52
+ end
53
+ end
54
+
55
+ def diff_localized(cs, old_c, new_c, date)
56
+ common_langs = language_keys(old_c) & language_keys(new_c)
57
+ common_langs.each do |lang|
58
+ old_lc = localized_data(old_c, lang)
59
+ new_lc = localized_data(new_c, lang)
60
+ next unless old_lc && new_lc
61
+
62
+ diff_designation(cs, lang, old_lc, new_lc, date)
63
+ diff_definition(cs, lang, old_lc, new_lc, date)
64
+ diff_notes(cs, lang, old_lc, new_lc, date)
65
+ diff_examples(cs, lang, old_lc, new_lc, date)
66
+ end
67
+ end
68
+
69
+ def diff_designation(cs, lang, old_lc, new_lc, date)
70
+ old_term = extract_designation(old_lc)
71
+ new_term = extract_designation(new_lc)
72
+ return if normalize(old_term) == normalize(new_term)
73
+
74
+ cs.add(Change.new(
75
+ code: cs.code, field: :designation, language: lang,
76
+ old_value: old_term, new_value: new_term, detected_at: date,
77
+ ))
78
+ end
79
+
80
+ def diff_definition(cs, lang, old_lc, new_lc, date)
81
+ old_def = extract_definition(old_lc)
82
+ new_def = extract_definition(new_lc)
83
+ return if normalize(old_def) == normalize(new_def)
84
+
85
+ cs.add(Change.new(
86
+ code: cs.code, field: :definition, language: lang,
87
+ old_value: old_def, new_value: new_def, detected_at: date,
88
+ ))
89
+ end
90
+
91
+ def diff_notes(cs, lang, old_lc, new_lc, date)
92
+ old_notes = extract_notes(old_lc)
93
+ new_notes = extract_notes(new_lc)
94
+ return if old_notes == new_notes
95
+
96
+ cs.add(Change.new(
97
+ code: cs.code, field: :notes, language: lang,
98
+ old_value: old_notes.join(" | "), new_value: new_notes.join(" | "),
99
+ detected_at: date,
100
+ ))
101
+ end
102
+
103
+ def diff_examples(cs, lang, old_lc, new_lc, date)
104
+ old_ex = extract_examples(old_lc)
105
+ new_ex = extract_examples(new_lc)
106
+ return if old_ex == new_ex
107
+
108
+ cs.add(Change.new(
109
+ code: cs.code, field: :examples, language: lang,
110
+ old_value: old_ex.join(" | "), new_value: new_ex.join(" | "),
111
+ detected_at: date,
112
+ ))
113
+ end
114
+
115
+ # --- extraction helpers ---
116
+
117
+ def language_keys(concept)
118
+ concept.localized_concepts&.keys || []
119
+ end
120
+
121
+ def localized_data(concept, lang)
122
+ lc = concept.localization(lang)
123
+ lc&.data
124
+ end
125
+
126
+ def extract_designation(cdata)
127
+ cdata&.terms&.first&.designation.to_s
128
+ end
129
+
130
+ def extract_definition(cdata)
131
+ cdata&.definition&.first&.content.to_s
132
+ end
133
+
134
+ def extract_notes(cdata)
135
+ Array(cdata&.notes).map(&:content)
136
+ end
137
+
138
+ def extract_examples(cdata)
139
+ Array(cdata&.examples).map(&:content)
140
+ end
141
+
142
+ def normalize(str)
143
+ return "" unless str
144
+ str
145
+ .to_s
146
+ .unicode_normalize(:nfc)
147
+ .gsub(/<[^>]+>/, "")
148
+ .gsub(/[ ​­]/, "")
149
+ .gsub(/[""″‴„‟‚‛]/, '"')
150
+ .gsub(/[''‚‛]/, "'")
151
+ .gsub(/\s+/, " ")
152
+ .strip
153
+ .downcase
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Iev
6
+ module Reconciler
7
+ # Indexes live HTML pages and builds Glossarist::ManagedConcept
8
+ # objects on demand per code. Uses Iev::Scraper::PageParser which
9
+ # runs the full semantic enrichment pipeline (HTML → AsciiDoc with
10
+ # stem:[] for math, {{urn:...}} for cross-refs, etc.), so parsed
11
+ # output matches the format used in termbase.yaml.
12
+ class LiveLoader
13
+ # @param pages_dir [String, Pathname] directory containing *.html pages
14
+ def initialize(pages_dir)
15
+ @pages_dir = pages_dir.to_s
16
+ @index = nil
17
+ end
18
+
19
+ def codes
20
+ index.keys
21
+ end
22
+
23
+ def get(code)
24
+ path = index[code]
25
+ return nil unless path
26
+
27
+ html = File.read(path, encoding: "utf-8")
28
+ parse_concept(code, html)
29
+ end
30
+
31
+ private
32
+
33
+ def index
34
+ @index ||= Dir.glob(File.join(@pages_dir, "*.html")).each_with_object({}) do |path, h|
35
+ h[File.basename(path, ".html")] = path
36
+ end
37
+ end
38
+
39
+ def parse_concept(code, html)
40
+ doc = Nokogiri::HTML(html)
41
+ parsed = Iev::Scraper::PageParser.new(doc, code).parse
42
+ return nil unless parsed && parsed.dig("data", "localized_concepts")&.any?
43
+
44
+ build_concept(code, parsed["data"]["localized_concepts"])
45
+ end
46
+
47
+ def build_concept(code, localized_data)
48
+ concept = Glossarist::ManagedConcept.of_yaml(
49
+ "id" => code, "data" => { "id" => code },
50
+ )
51
+ concept.status = "valid"
52
+ concept.schema_version = "3"
53
+
54
+ localized_data.each do |lang, lc_data|
55
+ lc = build_localized_concept(code, lang, lc_data)
56
+ concept.add_l10n(lc)
57
+ end
58
+
59
+ concept
60
+ end
61
+
62
+ def build_localized_concept(code, lang, lc_data)
63
+ cdata = Glossarist::ConceptData.new
64
+ cdata.id = code
65
+ cdata.language_code = lang
66
+ cdata.entry_status = "valid"
67
+
68
+ term = lc_data["term"].to_s
69
+ areas = lc_data["term_areas"] || {}
70
+ cdata.terms = if term.empty?
71
+ []
72
+ else
73
+ TermMarkerParser.parse_multiple(term).map do |parsed|
74
+ build_expression(parsed, areas[parsed.designation])
75
+ end
76
+ end
77
+
78
+ definition = lc_data["definition"]
79
+ if definition && !definition.empty?
80
+ parts = split_notes_examples(definition)
81
+ if parts[:definition] && !parts[:definition].empty?
82
+ cdata.definition = [Glossarist::DetailedDefinition.new(content: parts[:definition])]
83
+ end
84
+ cdata.notes = parts[:notes].map { |n| Glossarist::DetailedDefinition.new(content: n) }
85
+ cdata.examples = parts[:examples].map { |e| Glossarist::DetailedDefinition.new(content: e) }
86
+ end
87
+
88
+ lc = Glossarist::LocalizedConcept.new
89
+ lc.id = code
90
+ lc.data = cdata
91
+ lc
92
+ end
93
+
94
+ def build_expression(parsed, area = nil)
95
+ expr = Glossarist::Designation::Expression.new(
96
+ designation: parsed.designation,
97
+ normative_status: "preferred",
98
+ )
99
+ expr.geographical_area = area if area
100
+ expr.field_of_application = parsed.domain if parsed.domain
101
+ expr.usage_info = parsed.usage_info if parsed.usage_info
102
+ expr.prefix = parsed.is_prefix if parsed.is_prefix
103
+
104
+ grammar = build_grammar_info(parsed)
105
+ expr.grammar_info = [grammar] if grammar
106
+ expr
107
+ end
108
+
109
+ def build_related_concepts(parsed)
110
+ return [] unless parsed.related_refs&.any?
111
+
112
+ parsed.related_refs.map do |code|
113
+ Glossarist::RelatedConcept.new(
114
+ type: "see",
115
+ ref: { "source" => "IEV", "id" => code },
116
+ )
117
+ end
118
+ end
119
+
120
+ def build_grammar_info(parsed)
121
+ has_gender = parsed.genders&.any?
122
+ has_number = parsed.numbers&.any?
123
+ has_pos = !parsed.part_of_speech.nil?
124
+ return nil unless has_gender || has_number || has_pos
125
+
126
+ grammar = Glossarist::Designation::GrammarInfo.new
127
+ grammar.gender = parsed.genders if has_gender
128
+ grammar.number = parsed.numbers if has_number
129
+ grammar.part_of_speech = parsed.part_of_speech if has_pos
130
+ grammar
131
+ end
132
+
133
+ NOTE_RE = /^(Note\s+\d+\s+to\s+entry:.*)$/i
134
+ EXAMPLE_RE = /^(Example\s*:.*)$/i
135
+ NOTE_PREFIX_RE = /^Note\s+\d+\s+to\s+entry:\s*/i
136
+ EXAMPLE_PREFIX_RE = /^Example\s*:\s*/i
137
+
138
+ def split_notes_examples(text)
139
+ notes = []
140
+ examples = []
141
+
142
+ lines = text.split("\n")
143
+ definition_lines = []
144
+
145
+ lines.each do |line|
146
+ stripped = line.strip
147
+ if stripped.match?(NOTE_RE)
148
+ notes << stripped.sub(NOTE_PREFIX_RE, "")
149
+ elsif stripped.match?(EXAMPLE_RE)
150
+ examples << stripped.sub(EXAMPLE_PREFIX_RE, "")
151
+ else
152
+ definition_lines << line
153
+ end
154
+ end
155
+
156
+ definition = definition_lines.join("\n").strip
157
+ definition = nil if definition.empty?
158
+
159
+ { definition: definition, notes: notes, examples: examples }
160
+ end
161
+ end
162
+ end
163
+ end