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.
- checksums.yaml +4 -4
- data/.claude/scheduled_tasks.lock +1 -0
- data/.rubocop_todo.yml +5 -5
- data/Gemfile +1 -0
- 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 +3 -1
- 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 +4 -4
- 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 +2 -0
- data/scripts/find_unparseable.rb +22 -0
- metadata +50 -4
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|