opencdd 0.1.1

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 (97) hide show
  1. checksums.yaml +7 -0
  2. data/CLAUDE.md +486 -0
  3. data/README.md +304 -0
  4. data/bin/lint-no-raw-mdc +41 -0
  5. data/lib/cdd.rb +11 -0
  6. data/lib/opencdd/alias_table.rb +52 -0
  7. data/lib/opencdd/cddal/ast.rb +151 -0
  8. data/lib/opencdd/cddal/builder.rb +374 -0
  9. data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
  10. data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
  11. data/lib/opencdd/cddal/fetcher.rb +18 -0
  12. data/lib/opencdd/cddal/generated_parser.rb +805 -0
  13. data/lib/opencdd/cddal/lexer.rb +193 -0
  14. data/lib/opencdd/cddal/parser.rb +19 -0
  15. data/lib/opencdd/cddal/resolver.rb +100 -0
  16. data/lib/opencdd/cddal/serializer.rb +210 -0
  17. data/lib/opencdd/cddal/value_serializer.rb +60 -0
  18. data/lib/opencdd/cddal.rb +63 -0
  19. data/lib/opencdd/class_tree.rb +80 -0
  20. data/lib/opencdd/class_type.rb +33 -0
  21. data/lib/opencdd/codegen/ts.rb +185 -0
  22. data/lib/opencdd/codegen.rb +7 -0
  23. data/lib/opencdd/composition_tree.rb +119 -0
  24. data/lib/opencdd/condition.rb +120 -0
  25. data/lib/opencdd/data_type.rb +143 -0
  26. data/lib/opencdd/database.rb +719 -0
  27. data/lib/opencdd/effective_properties.rb +119 -0
  28. data/lib/opencdd/entity/field_reader.rb +141 -0
  29. data/lib/opencdd/entity/field_registry.rb +99 -0
  30. data/lib/opencdd/entity/version_history.rb +62 -0
  31. data/lib/opencdd/entity.rb +255 -0
  32. data/lib/opencdd/exporters/json.rb +235 -0
  33. data/lib/opencdd/exporters/mermaid.rb +62 -0
  34. data/lib/opencdd/exporters/yaml.rb +16 -0
  35. data/lib/opencdd/exporters.rb +9 -0
  36. data/lib/opencdd/guid.rb +27 -0
  37. data/lib/opencdd/instance_rule.rb +70 -0
  38. data/lib/opencdd/irdi.rb +128 -0
  39. data/lib/opencdd/klass.rb +230 -0
  40. data/lib/opencdd/languages.rb +70 -0
  41. data/lib/opencdd/meta_class.rb +274 -0
  42. data/lib/opencdd/model/entity_store.rb +85 -0
  43. data/lib/opencdd/model/yaml_database.rb +49 -0
  44. data/lib/opencdd/model/yaml_entity.rb +196 -0
  45. data/lib/opencdd/model.rb +13 -0
  46. data/lib/opencdd/parcel/csv_reader.rb +35 -0
  47. data/lib/opencdd/parcel/csv_writer.rb +114 -0
  48. data/lib/opencdd/parcel/entity_manifest.rb +119 -0
  49. data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
  50. data/lib/opencdd/parcel/layout_detector.rb +115 -0
  51. data/lib/opencdd/parcel/metadata.rb +112 -0
  52. data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
  53. data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
  54. data/lib/opencdd/parcel/selector.rb +115 -0
  55. data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
  56. data/lib/opencdd/parcel/sheet.rb +287 -0
  57. data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
  58. data/lib/opencdd/parcel/sheet_schema.rb +253 -0
  59. data/lib/opencdd/parcel/workbook.rb +172 -0
  60. data/lib/opencdd/parcel/workbook_reader.rb +200 -0
  61. data/lib/opencdd/parcel/writer.rb +185 -0
  62. data/lib/opencdd/parcel.rb +175 -0
  63. data/lib/opencdd/parse_helpers.rb +73 -0
  64. data/lib/opencdd/property.rb +120 -0
  65. data/lib/opencdd/property_data_element_type.rb +44 -0
  66. data/lib/opencdd/property_ids.rb +202 -0
  67. data/lib/opencdd/reader.rb +103 -0
  68. data/lib/opencdd/relation.rb +88 -0
  69. data/lib/opencdd/relation_tree.rb +74 -0
  70. data/lib/opencdd/relation_type.rb +58 -0
  71. data/lib/opencdd/structured_values.rb +183 -0
  72. data/lib/opencdd/unit.rb +16 -0
  73. data/lib/opencdd/validator/class_reference_rule.rb +77 -0
  74. data/lib/opencdd/validator/condition_rule.rb +27 -0
  75. data/lib/opencdd/validator/data_type_rule.rb +26 -0
  76. data/lib/opencdd/validator/enum_rule.rb +27 -0
  77. data/lib/opencdd/validator/format_rule.rb +54 -0
  78. data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
  79. data/lib/opencdd/validator/irdi_rule.rb +57 -0
  80. data/lib/opencdd/validator/mandatory_rule.rb +25 -0
  81. data/lib/opencdd/validator/pattern_rule.rb +26 -0
  82. data/lib/opencdd/validator/reference_rule.rb +36 -0
  83. data/lib/opencdd/validator/rule.rb +65 -0
  84. data/lib/opencdd/validator/runner.rb +101 -0
  85. data/lib/opencdd/validator/set_rule.rb +41 -0
  86. data/lib/opencdd/validator/synonym_rule.rb +30 -0
  87. data/lib/opencdd/validator/type_rule.rb +102 -0
  88. data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
  89. data/lib/opencdd/validator.rb +68 -0
  90. data/lib/opencdd/value_format.rb +71 -0
  91. data/lib/opencdd/value_list.rb +42 -0
  92. data/lib/opencdd/value_term.rb +24 -0
  93. data/lib/opencdd/version.rb +5 -0
  94. data/lib/opencdd/view_control.rb +10 -0
  95. data/lib/opencdd/visitor.rb +93 -0
  96. data/lib/opencdd.rb +57 -0
  97. metadata +325 -0
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ class Sheet
6
+ DEFAULT_REQUIREMENT_FOR_CODE = "KEY".freeze
7
+ DEFAULT_REQUIREMENT_OTHER = "OPT".freeze
8
+ DEFAULT_SOURCE_LANGUAGE = "en".freeze
9
+
10
+ attr_reader :name, :metadata, :schema, :rows, :raw_row_count
11
+
12
+ def self.scaffold(meta_class_irdi:, parcel_id:, source_language: DEFAULT_SOURCE_LANGUAGE,
13
+ translation_languages: [], sheet_name: nil)
14
+ meta = Opencdd::MetaClasses.for(meta_class_irdi.to_s) or
15
+ raise ArgumentError, "unknown meta-class #{meta_class_irdi}"
16
+
17
+ code_property_id = Opencdd::MetaClasses.code_property_id_for(meta.irdi)
18
+ languages = ([source_language] + translation_languages).map(&:to_s).uniq
19
+
20
+ columns = meta.allowed_property_ids.each_with_index.map do |property_id, idx|
21
+ SheetSchema::Column.new(
22
+ index: idx + 1,
23
+ property_id: property_id,
24
+ name_by_lang: languages.to_h { |l| [l, display_name_for(property_id, l)] },
25
+ datatype: default_datatype_for(property_id),
26
+ value_format: nil,
27
+ pattern: nil,
28
+ requirement: property_id == code_property_id ? DEFAULT_REQUIREMENT_FOR_CODE : DEFAULT_REQUIREMENT_OTHER,
29
+ )
30
+ end
31
+
32
+ schema = SheetSchema.new
33
+ columns.each do |col|
34
+ schema.add_column(col)
35
+ end
36
+ schema.finalize_for_scaffold!
37
+
38
+ metadata = Metadata.new
39
+ metadata.add("#CLASS_ID := #{meta.irdi}")
40
+ metadata.add("#CLASS_NAME.#{source_language} := #{meta.name}")
41
+ metadata.add("#SOURCE_LANGUAGE := #{source_language}")
42
+ unless translation_languages.empty?
43
+ metadata.add("#TRANSLATION_LANGUAGE := #{translation_languages.join(',')}")
44
+ end
45
+
46
+ name = sheet_name || "#{parcel_id}_#{meta.name.upcase}"
47
+ new(name: name, metadata: metadata, schema: schema, raw_rows: [])
48
+ end
49
+
50
+ def self.display_name_for(property_id, _lang)
51
+ entry = Opencdd::PropertyIds::REGISTRY[property_id.to_s]
52
+ return property_id.to_s unless entry
53
+ entry.aliases.first || property_id.to_s
54
+ end
55
+
56
+ def self.default_datatype_for(property_id)
57
+ entry = Opencdd::PropertyIds::REGISTRY[property_id.to_s]
58
+ return nil unless entry
59
+ case entry.value_kind
60
+ when :identifier_ref, :class_ref then "ICID_STRING"
61
+ when :set_of_refs then "ICID_STRING"
62
+ when :date then "DATE_TYPE"
63
+ when :date_time then "DATE_TIME_TYPE"
64
+ when :condition then "STRING_TYPE"
65
+ else "STRING_TYPE"
66
+ end
67
+ end
68
+
69
+ def self.from_rows(rows, name: nil)
70
+ metadata = Opencdd::Parcel::Metadata.new
71
+ header_rows = []
72
+ data_rows = []
73
+
74
+ rows.each do |raw_row|
75
+ row = Array(raw_row)
76
+ first_cell = row.first&.to_s&.strip.to_s
77
+
78
+ if first_cell.start_with?("#") && first_cell.include?(":=")
79
+ metadata.add(first_cell)
80
+ next
81
+ end
82
+
83
+ if first_cell.start_with?("#")
84
+ header_rows << row
85
+ next
86
+ end
87
+
88
+ if row_has_data?(row)
89
+ data_rows << row
90
+ end
91
+ end
92
+
93
+ schema = Opencdd::Parcel::SheetSchema.from_header_rows(header_rows)
94
+ new(name: name, metadata: metadata, schema: schema, raw_rows: data_rows)
95
+ end
96
+
97
+ def self.row_has_data?(row)
98
+ row.each_with_index do |v, i|
99
+ next if i.zero?
100
+ next if v.nil?
101
+ s = v.to_s.strip
102
+ return true unless s.empty?
103
+ end
104
+ false
105
+ end
106
+
107
+ def initialize(name:, metadata:, schema:, raw_rows:)
108
+ @name = name
109
+ @metadata = metadata
110
+ @schema = schema
111
+ @raw_row_count = raw_rows.size
112
+ @rows = build_rows(raw_rows)
113
+ freeze
114
+ end
115
+
116
+ def meta_class_irdi
117
+ @metadata.meta_class_irdi
118
+ end
119
+
120
+ def meta_class_code
121
+ @metadata.meta_class_code
122
+ end
123
+
124
+ def type
125
+ @metadata.type
126
+ end
127
+
128
+ def each(&block)
129
+ @rows.each(&block)
130
+ end
131
+
132
+ include Enumerable
133
+
134
+ def size
135
+ @rows.size
136
+ end
137
+
138
+ alias_method :count, :size
139
+ alias_method :length, :size
140
+
141
+ def first
142
+ @rows.first
143
+ end
144
+
145
+ def last
146
+ @rows.last
147
+ end
148
+
149
+ def entities(database = nil)
150
+ EntitiesProxy.new(self, database)
151
+ end
152
+
153
+ def apply_default_values!(property_id:)
154
+ col = @schema.find_by_property_id(property_id.to_s)
155
+ return if col.nil? || col.default_value.nil?
156
+ storage_key = storage_key_for(col)
157
+ @rows.each do |row|
158
+ current = row[storage_key]
159
+ next if !current.nil? && !current.to_s.empty?
160
+ row[storage_key] = col.default_value
161
+ end
162
+ self
163
+ end
164
+
165
+ def apply_all_default_values!
166
+ @schema.columns.each do |col|
167
+ next if col.default_value.nil?
168
+ apply_default_values!(property_id: col.property_id)
169
+ end
170
+ self
171
+ end
172
+
173
+ def merge_rows_from(other)
174
+ unless meta_codes_match?(meta_class_irdi, other.meta_class_irdi)
175
+ raise ArgumentError,
176
+ "meta-class mismatch: #{meta_class_irdi} vs #{other.meta_class_irdi}"
177
+ end
178
+ other.rows.each do |src|
179
+ new_row = src.reject { |k, _| k == "__row_index__" }
180
+ next if new_row.empty?
181
+ new_row["__row_index__"] = @rows.size
182
+ @rows << new_row
183
+ end
184
+ self
185
+ end
186
+
187
+ def dup_with(name:)
188
+ raw_rows = @rows.map { |row| row_to_array(row) }
189
+ self.class.new(name: name, metadata: @metadata, schema: @schema, raw_rows: raw_rows)
190
+ end
191
+
192
+ private
193
+
194
+ def meta_codes_match?(a, b)
195
+ return false if a.nil? || b.nil?
196
+ a.to_s.split("#").last == b.to_s.split("#").last
197
+ end
198
+
199
+ def row_to_array(row)
200
+ array = Array.new(@schema.columns.map(&:index).max.to_i + 1)
201
+ @schema.columns.each do |col|
202
+ key = storage_key_for(col)
203
+ val = row[key]
204
+ array[col.index] = val if val
205
+ end
206
+ array
207
+ end
208
+
209
+ def build_rows(raw_rows)
210
+ seen = {}
211
+ result = []
212
+ raw_rows.each do |row|
213
+ instance = build_instance(row)
214
+ next unless instance
215
+ key = instance["__row_index__"] = result.size
216
+ result << instance
217
+ seen[key] = true
218
+ end
219
+ result
220
+ end
221
+
222
+ def build_instance(row)
223
+ h = {}
224
+ any_value = false
225
+ @schema.columns.each do |col|
226
+ v = row[col.index]
227
+ next if v.nil?
228
+ s = value_to_string(v)
229
+ next if s.empty?
230
+ key = storage_key_for(col)
231
+ h[key] = s
232
+ any_value = true
233
+ end
234
+
235
+ return nil unless any_value
236
+ h["__row_index__"] = nil
237
+ h
238
+ end
239
+
240
+ def storage_key_for(col)
241
+ pid = col.property_id
242
+ return pid if pid.include?(".")
243
+ entry = Opencdd::PropertyIds::REGISTRY[pid]
244
+ return pid unless entry&.multilingual
245
+ langs = (col.name_by_lang || {}).keys
246
+ return pid if langs.size != 1
247
+ "#{pid}.#{langs.first}"
248
+ end
249
+
250
+ def value_to_string(v)
251
+ return "" if v.nil?
252
+ case v
253
+ when String then v.strip
254
+ when Float
255
+ if v == v.to_i
256
+ v.to_i.to_s
257
+ else
258
+ v.to_s
259
+ end
260
+ when Integer then v.to_s
261
+ when Date, Time then v.iso8601
262
+ when Symbol then v.to_s
263
+ else v.to_s
264
+ end
265
+ end
266
+
267
+ class EntitiesProxy
268
+ attr_reader :sheet, :database
269
+
270
+ def initialize(sheet, database)
271
+ @sheet = sheet
272
+ @database = database
273
+ end
274
+
275
+ def each
276
+ return enum_for(:each) unless block_given?
277
+
278
+ sheet.each do |row|
279
+ yield row
280
+ end
281
+ end
282
+
283
+ include Enumerable
284
+ end
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ class SheetEmitter
6
+ DEFAULT_SOURCE_LANGUAGE = "en".freeze
7
+
8
+ META_DIRECTIVE_ORDER = %w[
9
+ CLASS_ID ALTERNATE_CLASSID SUPER_ALT_CLASSID SUB_ALT_CLASSID
10
+ CLASS_NAME CLASS_DEFINITION CLASS_NOTE
11
+ SOURCE_LANGUAGE PARCEL_MODE PARCEL_ID PARCEL_CC
12
+ DEFAULT_SUPPLIER DEFAULT_VERSION
13
+ ID_ENCODE DELIMITER DECIMAL OBJECT_ID_NAME
14
+ TRANSLATION_LANGUAGE
15
+ ].freeze
16
+
17
+ HEADER_DIRECTIVE_ORDER = %w[
18
+ PROPERTY_ID ALTERNATE_ID SUPER_ALTERNATE_ID SUB_ALTERNATE_ID EQUIVALENT_ID
19
+ SUPER_PROPERTY
20
+ DATATYPE UNIT VARIABLE_PREFIX_UNIT UNIT_ID ALTERNATIVE_UNITS
21
+ VALUE_FORMAT PATTERN RELATION DEFAULT_VALUE
22
+ DEFAULT_DATA_SUPPLIER DEFAULT_DATA_VERSION REQUIREMENT
23
+ ].freeze
24
+
25
+ MULTILINGUAL_DIRECTIVES = %w[PROPERTY_NAME DEFINITION NOTE].freeze
26
+
27
+ attr_reader :source_language
28
+
29
+ def initialize(source_language: "en")
30
+ @source_language = source_language.to_s
31
+ end
32
+
33
+ def emit(sheet, entities)
34
+ rows = []
35
+ emit_metadata_rows(sheet, rows)
36
+ emit_header_rows(sheet, rows)
37
+ entities.each { |e| emit_data_row(sheet, e, rows) }
38
+ rows
39
+ end
40
+
41
+ def emit_data_rows(sheet, entities)
42
+ rows = []
43
+ entities.each { |e| emit_data_row(sheet, e, rows) }
44
+ rows
45
+ end
46
+
47
+ private
48
+
49
+ def emit_metadata_rows(sheet, rows)
50
+ present = sheet.metadata.directives.transform_keys(&:to_s)
51
+ META_DIRECTIVE_ORDER.each do |base|
52
+ emit_meta_base(present, base, rows)
53
+ end
54
+ end
55
+
56
+ def emit_meta_base(present, base, rows)
57
+ matched = present.select { |k, _| k == base || k.start_with?("#{base}.") }
58
+ if matched.empty?
59
+ rows << ["##{base}:="]
60
+ return
61
+ end
62
+ matched.each do |key, val|
63
+ rows << ["##{key}:=#{val}"]
64
+ end
65
+ end
66
+
67
+ def emit_header_rows(sheet, rows)
68
+ present = column_directives_from(sheet)
69
+ HEADER_DIRECTIVE_ORDER.each do |name|
70
+ emit_directive_row(present, name, rows)
71
+ end
72
+ MULTILINGUAL_DIRECTIVES.each do |base|
73
+ present.keys.select { |k| k == base || k.start_with?("#{base}.") }.sort.each do |key|
74
+ emit_directive_row(present, key, rows)
75
+ end
76
+ end
77
+ end
78
+
79
+ def column_directives_from(sheet)
80
+ cols = sheet.schema.columns
81
+ {}.tap do |h|
82
+ HEADER_DIRECTIVE_ORDER.each do |name|
83
+ vals = cols.map { |col| column_scalar_value(col, name) }
84
+ h[name] = vals
85
+ end
86
+ MULTILINGUAL_DIRECTIVES.each do |base|
87
+ langs = cols.flat_map { |col| langs_for(col, base) }.uniq
88
+ langs.each do |lang|
89
+ h["#{base}.#{lang}"] = cols.map { |col| localized_value(col, base, lang) }
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ def emit_directive_row(present, name, rows)
96
+ values = present[name]
97
+ rows << ["##{name}", *(values || [])]
98
+ end
99
+
100
+ def column_scalar_value(col, name)
101
+ case name
102
+ when "PROPERTY_ID" then col.raw_property_id || col.property_id
103
+ when "ALTERNATE_ID" then col.alternate_id
104
+ when "SUPER_ALTERNATE_ID" then col.super_alternate_id
105
+ when "SUB_ALTERNATE_ID" then col.sub_alternate_id
106
+ when "EQUIVALENT_ID" then col.equivalent_id
107
+ when "SUPER_PROPERTY" then col.super_property
108
+ when "DATATYPE" then col.datatype
109
+ when "UNIT" then col.unit
110
+ when "VARIABLE_PREFIX_UNIT" then col.variable_prefix_unit
111
+ when "UNIT_ID" then col.unit_id
112
+ when "ALTERNATIVE_UNITS" then col.alternative_units
113
+ when "VALUE_FORMAT" then col.value_format
114
+ when "PATTERN" then col.pattern
115
+ when "RELATION" then col.relation
116
+ when "DEFAULT_VALUE" then col.default_value
117
+ when "DEFAULT_DATA_SUPPLIER" then col.default_data_supplier
118
+ when "DEFAULT_DATA_VERSION" then col.default_data_version
119
+ when "REQUIREMENT" then col.requirement
120
+ end
121
+ end
122
+
123
+ def langs_for(col, base)
124
+ case base
125
+ when "PROPERTY_NAME" then (col.name_by_lang || {}).keys
126
+ when "DEFINITION" then (col.definition_by_lang || {}).keys
127
+ when "NOTE" then (col.note_by_lang || {}).keys
128
+ else []
129
+ end
130
+ end
131
+
132
+ def localized_value(col, base, lang)
133
+ case base
134
+ when "PROPERTY_NAME" then (col.name_by_lang || {})[lang]
135
+ when "DEFINITION" then (col.definition_by_lang || {})[lang]
136
+ when "NOTE" then (col.note_by_lang || {})[lang]
137
+ end
138
+ end
139
+
140
+ def emit_data_row(sheet, entity, rows)
141
+ values = sheet.schema.columns.map { |col| entity_value_for(entity, col) }
142
+ rows << [nil, *values]
143
+ end
144
+
145
+ def entity_value_for(entity, col)
146
+ pid = col.property_id
147
+ val = entity.properties[pid]
148
+ return val unless val.nil?
149
+ multilingual_lookup(entity, pid)
150
+ end
151
+
152
+ # Multilingual fallback. The raw properties hash is the
153
+ # canonical store; the lookup here mirrors FieldReader's
154
+ # raw_multilingual resolution (single source of truth for
155
+ # "where do I find the language-tagged variant of this field").
156
+ # Routed through Entity#read_field when the column maps to a
157
+ # declared field, so any future field-DTL changes apply here
158
+ # too — see TODO.impl/26.
159
+ def multilingual_lookup(entity, pid)
160
+ entry = Opencdd::PropertyIds::REGISTRY[pid]
161
+ return entity.properties["#{pid}.#{@source_language}"] if entry && entry.multilingual
162
+ if pid.include?(".")
163
+ base, = pid.split(".", 2)
164
+ return entity.properties[pid] if entity.properties.key?(pid)
165
+ return entity.properties[base]
166
+ end
167
+ nil
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ class SheetSchema
6
+ # Parcel-specific column-ID canonicalization. Maps the
7
+ # ParcelMaker variant headers (MDC_P004_1, MDC_P005, ...)
8
+ # to the canonical IEC 61360 IDs (MDC_P004, MDC_P006, ...).
9
+ #
10
+ # Owned by SheetSchema because this mapping only exists for
11
+ # the Parcel sheet layout — the PropertyIds registry is
12
+ # ontology-only and shouldn't carry format-specific details.
13
+ VARIANT_TO_CANONICAL = {
14
+ "MDC_P004_1" => "MDC_P004",
15
+ "MDC_P004_2" => "MDC_P007",
16
+ "MDC_P004_3" => "MDC_P005",
17
+ "MDC_P005" => "MDC_P006",
18
+ "MDC_P007_1" => "MDC_P008",
19
+ "MDC_P007_2" => "MDC_P009",
20
+ }.freeze
21
+
22
+ # Canonicalize a Parcel column ID. Splits language tags
23
+ # (<id>.<lang>) so they round-trip cleanly. Returns the
24
+ # canonical ID (or the input unchanged if no mapping exists).
25
+ def self.canonical_id(raw_id)
26
+ return nil if raw_id.nil?
27
+ s = raw_id.to_s.strip
28
+ return nil if s.empty?
29
+ match = s.match(/\.(?<lang>[A-Za-z0-9-]+)\z/)
30
+ base = match ? match.pre_match : s
31
+ lang = match && match[:lang]
32
+ canonical = VARIANT_TO_CANONICAL[base] || base
33
+ lang ? "#{canonical}.#{lang}" : canonical
34
+ end
35
+
36
+ DIRECTIVE_ROWS = %w[
37
+ PROPERTY_ID
38
+ ALTERNATE_ID
39
+ SUPER_ALTERNATE_ID
40
+ SUB_ALTERNATE_ID
41
+ EQUIVALENT_ID
42
+ SUPER_PROPERTY
43
+ PROPERTY_NAME
44
+ DEFINITION
45
+ NOTE
46
+ DATATYPE
47
+ UNIT
48
+ VARIABLE_PREFIX_UNIT
49
+ UNIT_ID
50
+ ALTERNATIVE_UNITS
51
+ VALUE_FORMAT
52
+ PATTERN
53
+ RELATION
54
+ DEFAULT_VALUE
55
+ DEFAULT_DATA_SUPPLIER
56
+ DEFAULT_DATA_VERSION
57
+ REQUIREMENT
58
+ ].freeze
59
+
60
+ class Column < Struct.new(
61
+ :index,
62
+ :property_id,
63
+ :raw_property_id,
64
+ :alternate_id,
65
+ :super_alternate_id,
66
+ :sub_alternate_id,
67
+ :equivalent_id,
68
+ :super_property,
69
+ :name_by_lang,
70
+ :definition_by_lang,
71
+ :note_by_lang,
72
+ :datatype,
73
+ :unit,
74
+ :variable_prefix_unit,
75
+ :unit_id,
76
+ :alternative_units,
77
+ :value_format,
78
+ :pattern,
79
+ :relation,
80
+ :default_value,
81
+ :default_data_supplier,
82
+ :default_data_version,
83
+ :requirement,
84
+ keyword_init: true,
85
+ )
86
+ def name(lang = :en)
87
+ name_by_lang[lang.to_s]
88
+ end
89
+
90
+ def definition(lang = :en)
91
+ definition_by_lang[lang.to_s]
92
+ end
93
+
94
+ def note(lang = :en)
95
+ note_by_lang[lang.to_s]
96
+ end
97
+
98
+ def required?
99
+ requirement == "MAND"
100
+ end
101
+
102
+ def key?
103
+ requirement == "KEY"
104
+ end
105
+
106
+ def obsolete?
107
+ requirement == "OBS"
108
+ end
109
+ end
110
+
111
+ DIRECTIVE_ROW_PREFIX = "#".freeze
112
+
113
+ attr_reader :columns, :columns_by_id, :column_directives
114
+
115
+ def initialize
116
+ @columns = []
117
+ @columns_by_id = {}
118
+ @column_directives = {}
119
+ end
120
+
121
+ def self.from_header_rows(rows)
122
+ schema = new
123
+ rows.each do |row|
124
+ schema.add_directive_row(row)
125
+ end
126
+ schema.finalize!
127
+ schema
128
+ end
129
+
130
+ def add_directive_row(row)
131
+ return self if row.nil? || row.empty?
132
+
133
+ label_cell = row[0].to_s
134
+ directive = parse_directive(label_cell)
135
+ return self unless directive
136
+
137
+ values = row[1..].to_a
138
+
139
+ unless @column_directives.key?(directive)
140
+ @column_directives[directive] = []
141
+ end
142
+
143
+ values.each_with_index do |val, idx|
144
+ @column_directives[directive][idx] = val
145
+ end
146
+
147
+ self
148
+ end
149
+
150
+ def add_column(column)
151
+ @columns << column
152
+ @columns_by_id[column.property_id] = column
153
+ self
154
+ end
155
+
156
+ def finalize_for_scaffold!
157
+ freeze
158
+ end
159
+
160
+ def finalize!
161
+ ids = @column_directives["PROPERTY_ID"] || []
162
+ ids.each_with_index do |id, idx|
163
+ next if id.nil? || id.to_s.strip.empty?
164
+
165
+ col = Column.new(
166
+ index: idx + 1,
167
+ property_id: Opencdd::Parcel::SheetSchema.canonical_id(id.to_s.strip),
168
+ raw_property_id: id.to_s.strip,
169
+ alternate_id: lookup("ALTERNATE_ID", idx),
170
+ super_alternate_id: lookup("SUPER_ALTERNATE_ID", idx),
171
+ sub_alternate_id: lookup("SUB_ALTERNATE_ID", idx),
172
+ equivalent_id: lookup("EQUIVALENT_ID", idx),
173
+ super_property: lookup("SUPER_PROPERTY", idx),
174
+ name_by_lang: lang_hash_for("PROPERTY_NAME", idx),
175
+ definition_by_lang: lang_hash_for("DEFINITION", idx),
176
+ note_by_lang: lang_hash_for("NOTE", idx),
177
+ datatype: lookup("DATATYPE", idx),
178
+ unit: lookup("UNIT", idx),
179
+ variable_prefix_unit: lookup("VARIABLE_PREFIX_UNIT", idx),
180
+ unit_id: lookup("UNIT_ID", idx),
181
+ alternative_units: lookup("ALTERNATIVE_UNITS", idx),
182
+ value_format: lookup("VALUE_FORMAT", idx),
183
+ pattern: lookup("PATTERN", idx),
184
+ relation: lookup("RELATION", idx),
185
+ default_value: lookup("DEFAULT_VALUE", idx),
186
+ default_data_supplier: lookup("DEFAULT_DATA_SUPPLIER", idx),
187
+ default_data_version: lookup("DEFAULT_DATA_VERSION", idx),
188
+ requirement: lookup("REQUIREMENT", idx),
189
+ )
190
+ @columns << col
191
+ @columns_by_id[col.property_id] = col
192
+ end
193
+
194
+ freeze
195
+ end
196
+
197
+ def [](property_id_or_name)
198
+ find_by_property_id(property_id_or_name) || find_by_name(property_id_or_name)
199
+ end
200
+
201
+ def find_by_property_id(id)
202
+ @columns_by_id[id.to_s]
203
+ end
204
+
205
+ def find_by_name(name, lang = :en)
206
+ @columns.find { |c| c.name(lang).to_s.casecmp(name.to_s).zero? }
207
+ end
208
+
209
+ def size
210
+ @columns.size
211
+ end
212
+
213
+ def each(&block)
214
+ @columns.each(&block)
215
+ end
216
+
217
+ include Enumerable
218
+
219
+ private
220
+
221
+ def parse_directive(cell)
222
+ return nil if cell.nil?
223
+ s = cell.to_s.strip
224
+ return nil unless s.start_with?("#")
225
+ s[1..]
226
+ end
227
+
228
+ def lookup(directive, col_idx)
229
+ vals = @column_directives[directive]
230
+ return nil unless vals
231
+ v = vals[col_idx]
232
+ return nil if v.nil?
233
+ s = v.to_s.strip
234
+ s.empty? ? nil : s
235
+ end
236
+
237
+ def lang_hash_for(directive, col_idx)
238
+ h = {}
239
+ @column_directives.each do |key, vals|
240
+ base, lang = key.split(".", 2)
241
+ next unless base == directive
242
+ v = vals[col_idx]
243
+ next if v.nil?
244
+ s = v.to_s.strip
245
+ next if s.empty?
246
+ lang = "en" if lang.nil? || lang.empty?
247
+ h[lang] = s
248
+ end
249
+ h
250
+ end
251
+ end
252
+ end
253
+ end