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,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Opencdd
6
+ class EffectiveProperties
7
+ Result = Struct.new(:properties, :sources, keyword_init: true) do
8
+ include Enumerable
9
+
10
+ def each
11
+ return enum_for(:each) unless block_given?
12
+ properties.each { |p| yield p }
13
+ end
14
+
15
+ def each_property
16
+ return enum_for(:each_property) unless block_given?
17
+ properties.each { |p| yield p }
18
+ end
19
+
20
+ def size
21
+ properties.size
22
+ end
23
+
24
+ def include?(property)
25
+ irdi = property.is_a?(Opencdd::IRDI) ? property : property&.irdi
26
+ properties.any? { |p| p.irdi == irdi }
27
+ end
28
+
29
+ def codes
30
+ properties.map(&:code)
31
+ end
32
+
33
+ def to_a
34
+ properties.dup
35
+ end
36
+ end
37
+
38
+ attr_reader :database
39
+
40
+ def initialize(database)
41
+ @database = database
42
+ end
43
+
44
+ def for(klass)
45
+ k = resolve_klass(klass)
46
+ return Result.new(properties: [], sources: {}) unless k
47
+ acc = []
48
+ sources = Hash.new { |h, kk| h[kk] = [] }
49
+ accumulate(k, Set.new, acc, sources)
50
+ Result.new(properties: acc.uniq { |p| p.irdi }, sources: sources)
51
+ end
52
+
53
+ def codes_for(klass)
54
+ result = self.for(klass)
55
+ result.properties.map(&:code)
56
+ end
57
+
58
+ private
59
+
60
+ def resolve_klass(value)
61
+ return value if value.is_a?(Opencdd::Klass)
62
+ @database.resolve_reference(value)
63
+ end
64
+
65
+ def accumulate(klass, seen, acc, sources)
66
+ return if seen.include?(klass.irdi)
67
+ seen << klass.irdi
68
+
69
+ add_declared_properties(klass, acc, sources)
70
+ add_applicable_properties(klass, acc, sources)
71
+ add_imported_properties(klass, acc, sources)
72
+ walk_superclass(klass, seen, acc, sources)
73
+ walk_is_case_of(klass, seen, acc, sources)
74
+ end
75
+
76
+ def add_declared_properties(klass, acc, sources)
77
+ klass.declared_property_irdis.each do |irdi|
78
+ prop = @database.find(irdi)
79
+ next unless prop
80
+ acc << prop
81
+ sources[prop.irdi.to_s] << klass unless sources[prop.irdi.to_s].include?(klass)
82
+ end
83
+ end
84
+
85
+ def add_applicable_properties(klass, acc, sources)
86
+ klass.applicable_property_irdis.each do |irdi|
87
+ prop = @database.find(irdi)
88
+ next unless prop
89
+ acc << prop
90
+ sources[prop.irdi.to_s] << klass unless sources[prop.irdi.to_s].include?(klass)
91
+ end
92
+ end
93
+
94
+ def add_imported_properties(klass, acc, sources)
95
+ klass.imported_property_irdis.each do |irdi|
96
+ prop = @database.find(irdi)
97
+ next unless prop
98
+ acc << prop
99
+ sources[prop.irdi.to_s] << klass unless sources[prop.irdi.to_s].include?(klass)
100
+ end
101
+ end
102
+
103
+ def walk_superclass(klass, seen, acc, sources)
104
+ ref = klass.parent_irdi || klass.superclass_irdi
105
+ return unless ref
106
+ parent = @database.find(ref)
107
+ return unless parent
108
+ accumulate(parent, seen, acc, sources)
109
+ end
110
+
111
+ def walk_is_case_of(klass, seen, acc, sources)
112
+ klass.is_case_of_irdis.each do |ref|
113
+ target = @database.find(ref)
114
+ next unless target
115
+ accumulate(target, seen, acc, sources)
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ class Entity
5
+ # Typed reader for field declarations. Knows how to extract a
6
+ # value of a given kind from an entity's properties hash. The
7
+ # single source of truth for "what does MDC_P004.en look like"
8
+ # so the Json exporter, validators, and TS codegen all agree.
9
+ module FieldReader
10
+ class << self
11
+ # Read the +name+ field from +entity+. Multilingual fields
12
+ # accept a +lang:+ keyword and fall back to source-language
13
+ # (stored under the bare `MDC_P###` key) when the requested
14
+ # language is missing.
15
+ def read(entity, name, lang: nil)
16
+ entry = FieldRegistry.field_for(entity.class, name)
17
+ return nil unless entry
18
+
19
+ # Synthetic fields defined with a block: evaluate the block
20
+ # in the entity's context via instance_exec. This preserves
21
+ # access to private helper methods without using send to
22
+ # bypass privacy (encapsulation rule from CLAUDE.md).
23
+ return entity.instance_exec(&entry.block) if entry.block?
24
+
25
+ # Legacy form: synthetic fields name a public reader method.
26
+ # public_send is safe here because readers are public API.
27
+ return entity.public_send(entry.reader) if entry.synthetic? && entry.reader
28
+
29
+ raw =
30
+ if entry.multilingual?
31
+ raw_multilingual(entity, entry, lang)
32
+ else
33
+ entity.properties[entry.property_id]
34
+ end
35
+
36
+ coerce(raw, entry.value_kind)
37
+ end
38
+
39
+ private
40
+
41
+ def raw_multilingual(entity, entry, lang)
42
+ props = entity.properties
43
+ # The scrape stores multilingual values under "<id>.<lang>"
44
+ # (e.g. "MDC_P004.en", "MDC_P004.de"). Some legacy data
45
+ # omits the suffix entirely ("MDC_P004" with no .en). When
46
+ # no lang is requested, default to "en" — the historical
47
+ # source language for all IEC CDD dictionaries.
48
+ effective_lang = lang || "en"
49
+ props["#{entry.property_id}.#{effective_lang}"] ||
50
+ props[entry.property_id] ||
51
+ props["#{entry.property_id}.en"]
52
+ end
53
+
54
+ # Convert +raw+ (a String from the .xls) into the typed
55
+ # Ruby value the model expects. Collection kinds (:set_of_refs,
56
+ # :synonym_pairs) return empty arrays for nil/blank input so
57
+ # callers can safely iterate without nil checks — mirrors the
58
+ # legacy hand-written accessors.
59
+ def coerce(raw, value_kind)
60
+ case value_kind
61
+ when :string, :date, :date_time, :condition
62
+ raw.nil? ? nil : raw.to_s
63
+ when :irdi, :identifier_ref, :class_ref
64
+ parse_irdi(raw)
65
+ when :set_of_refs
66
+ parse_irdi_list_via_helpers(raw)
67
+ when :string_list
68
+ parse_string_list_via_helpers(raw)
69
+ when :synonym_pairs
70
+ parse_pair_list_via_helpers(raw)
71
+ when :integer
72
+ raw.nil? ? nil : (Integer(raw) rescue raw)
73
+ when :boolean
74
+ raw.nil? ? nil : parse_boolean(raw)
75
+ else
76
+ raw
77
+ end
78
+ end
79
+
80
+ def parse_irdi(raw)
81
+ return nil if raw.to_s.strip.empty?
82
+ Opencdd::IRDI.parse(raw)
83
+ rescue Opencdd::IRDI::ParseError
84
+ nil
85
+ end
86
+
87
+ # Parcel's set_of_refs and synonym_pairs wire shapes are
88
+ # delimited with braces/parens and commas. Parsed inline
89
+ # rather than via Opencdd::ParseHelpers because ParseHelpers
90
+ # methods are private (intended for mixin within Entity).
91
+ def parse_irdi_list_via_helpers(raw)
92
+ return [] if raw.to_s.strip.empty?
93
+ stripped = raw.to_s.strip.sub(/\A[\{\(\[]+/, "").sub(/[\}\)\]]+\z/, "")
94
+ stripped
95
+ .split(/[,;\s]+/)
96
+ .reject(&:empty?)
97
+ .filter_map { |s| parse_irdi(s) }
98
+ end
99
+
100
+ def parse_pair_list_via_helpers(raw)
101
+ return [] if raw.to_s.strip.empty?
102
+ stripped = raw.to_s.strip.sub(/\A[\{\(\[]+/, "").sub(/[\}\)\]]+\z/, "")
103
+ tokens = stripped.split(/[,;]+/).map(&:strip).reject(&:empty?)
104
+ # Parcel synonym wire shape alternates lang, name, lang, name:
105
+ # "(en, Foo, fr, Bidon)" → [["en", "Foo"], ["fr", "Bidon"]].
106
+ # If a token contains ":", treat as explicit "lang:name" pair.
107
+ # Otherwise group consecutive tokens into pairs.
108
+ if tokens.any? { |t| t.include?(":") }
109
+ tokens.map { |pair| parse_synonym_pair(pair) }.compact
110
+ else
111
+ tokens.each_slice(2).map { |a, b| b ? [a, b] : [nil, a] }
112
+ end
113
+ end
114
+
115
+ def parse_string_list_via_helpers(raw)
116
+ return [] if raw.to_s.strip.empty?
117
+ stripped = raw.to_s.strip.sub(/\A[\{\(\[]+/, "").sub(/[\}\)\]]+\z/, "")
118
+ stripped.split(/[,;]+/).map(&:strip).reject(&:empty?)
119
+ end
120
+
121
+ def parse_synonym_pair(pair)
122
+ if pair.include?(":")
123
+ lang, name = pair.split(":", 2).map(&:strip)
124
+ [lang.empty? ? nil : lang, name]
125
+ else
126
+ [nil, pair]
127
+ end
128
+ end
129
+
130
+ def parse_boolean(raw)
131
+ return raw unless raw.is_a?(String)
132
+ case raw.strip.downcase
133
+ when "true", "1", "yes", "y" then true
134
+ when "false", "0", "no", "n", "" then false
135
+ else raw
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ class Entity
5
+ # Registry of declared fields on Entity subclasses. Open/closed:
6
+ # adding a field is a single `field` declaration in the entity
7
+ # class body — no edits to switch statements elsewhere.
8
+ module FieldRegistry
9
+ Entry = Struct.new(
10
+ :entity_class, :name, :property_id, :value_kind,
11
+ :multilingual, :synthetic, :reader, :block, :json_key,
12
+ keyword_init: true,
13
+ ) do
14
+ def synthetic? = !!synthetic
15
+ def multilingual? = !!multilingual
16
+
17
+ # True when this field's value is computed by a block rather
18
+ # than read directly from +properties+. FieldReader dispatches
19
+ # block-defined fields via +instance_exec+, which preserves
20
+ # access to private helpers — no +send+ bypass required.
21
+ def block?
22
+ !block.nil?
23
+ end
24
+
25
+ # The key to use when serializing this field to JSON. Defaults
26
+ # to the field's ruby name. Override via the `as:` DSL option
27
+ # when the wire-format name should differ from the method
28
+ # name (e.g. is_case_of_irdis → is_case_of).
29
+ def wire_name
30
+ (json_key || name).to_s
31
+ end
32
+ end
33
+
34
+ @by_class = Hash.new { |h, k| h[k] = {} }
35
+
36
+ class << self
37
+ # Register a field on +entity_class+. The DSL in Entity.field
38
+ # is the only intended caller. Re-registering the same name on
39
+ # the same class overwrites the prior declaration (useful for
40
+ # subclasses overriding the value_kind).
41
+ #
42
+ # Synthetic fields prefer the +block:+ form: the block is
43
+ # evaluated via +instance_exec+ on the entity when the field
44
+ # is read, so it has access to private helpers without
45
+ # requiring +send+ dispatch. The legacy +reader:+ form
46
+ # (a Symbol naming a public method on the entity) is accepted
47
+ # for back-compat but should not be used for new fields.
48
+ def register(entity_class:, name:, property_id:, value_kind:,
49
+ multilingual: false, synthetic: false, reader: nil,
50
+ block: nil, json_key: nil)
51
+ entry = Entry.new(
52
+ entity_class: entity_class,
53
+ name: name.to_sym,
54
+ property_id: property_id,
55
+ value_kind: value_kind,
56
+ multilingual: multilingual,
57
+ synthetic: synthetic,
58
+ reader: reader,
59
+ block: block,
60
+ json_key: json_key,
61
+ )
62
+ @by_class[entity_class][entry.name] = entry
63
+ entry
64
+ end
65
+
66
+ # Lookup a single field by name on +entity_class+. Walks up
67
+ # the ancestor chain so subclasses see inherited fields.
68
+ def field_for(entity_class, name)
69
+ each_entry(entity_class) { |e| return e if e.name == name.to_sym }
70
+ nil
71
+ end
72
+
73
+ # Every field visible on +entity_class+, including inherited
74
+ # fields. Order: base-class declarations first, then subclass
75
+ # overrides (last wins on duplicate names).
76
+ def fields_for(entity_class)
77
+ seen = {}
78
+ each_entry(entity_class) { |e| seen[e.name] = e }
79
+ seen.values
80
+ end
81
+
82
+ private
83
+
84
+ # Walk ancestors from most-generic (Entity) to most-specific,
85
+ # yielding each declared entry along the way. Subclass
86
+ # declarations come last so they overwrite base-class entries
87
+ # with the same name (visible to #fields_for via the `seen`
88
+ # hash in callers).
89
+ def each_entry(entity_class)
90
+ entity_class.ancestors.reverse_each do |klass|
91
+ next unless klass.is_a?(Class)
92
+ entries = @by_class[klass]
93
+ entries.each_value { |e| yield e } if entries
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ class Entity
5
+ # Model object for the per-version provenance captured in
6
+ # +downloads/<dict>/<CODE>/_entity.json#versions+. Each entry
7
+ # represents one historical version of the entity: which version
8
+ # and revision it was, its status at that point, who committed it,
9
+ # and the change-request ID.
10
+ #
11
+ # The ShardedDirReader parses +_entity.json+ for each class subdir
12
+ # and attaches a VersionHistory to every entity it creates. The
13
+ # Json exporter then emits it as an array of version entries,
14
+ # matching the IEC CDD "Version history" section on detail pages.
15
+ class VersionHistory
16
+ Entry = Struct.new(
17
+ :version, :revision, :status, :timestamp, :user,
18
+ :change_request_id, :unid, :is_current,
19
+ keyword_init: true,
20
+ ) do
21
+ def current? = !!is_current
22
+ end
23
+
24
+ include Enumerable
25
+
26
+ def initialize(entries = [])
27
+ @entries = Array(entries)
28
+ end
29
+
30
+ attr_reader :entries
31
+
32
+ def each(&block)
33
+ @entries.each(&block)
34
+ end
35
+
36
+ def size
37
+ @entries.size
38
+ end
39
+
40
+ def empty?
41
+ @entries.empty?
42
+ end
43
+
44
+ def current
45
+ @entries.find(&:current?) || @entries.first
46
+ end
47
+
48
+ def previous
49
+ @entries.reject { |e| e == current }
50
+ end
51
+
52
+ def to_a
53
+ @entries.dup
54
+ end
55
+
56
+ def attach(entry)
57
+ @entries << entry
58
+ self
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ class Entity
5
+ include Opencdd::ParseHelpers
6
+
7
+ # Open/closed field declaration. Each `field :foo, "MDC_P###",
8
+ # :kind` call registers a typed accessor on the entity class and
9
+ # adds an entry to FieldRegistry. Adding a field is one line
10
+ # here — no edits to switch statements in the exporter,
11
+ # validators, or TS codegen (all read from FieldRegistry).
12
+ autoload :FieldRegistry, "opencdd/entity/field_registry"
13
+ autoload :FieldReader, "opencdd/entity/field_reader"
14
+ autoload :VersionHistory, "opencdd/entity/version_history"
15
+
16
+ class << self
17
+ # Declare a typed field on this entity class. Defaults for
18
+ # +value_kind+ and +multilingual+ are resolved from
19
+ # +Opencdd::PropertyIds::REGISTRY+ when the +property_id+ is known
20
+ # there — the REGISTRY is the SSOT for wire-format metadata,
21
+ # and re-declaring it here would violate DRY.
22
+ #
23
+ # Pass an explicit +value_kind+ to override (used when REGISTRY's
24
+ # kind doesn't match how the field is consumed — e.g. MDC_P023
25
+ # is :identifier_ref in REGISTRY but Unit#structure uses it as
26
+ # a raw string).
27
+ #
28
+ # +synthetic: true+ for computed fields with no MDC_P### —
29
+ # requires a block (preferred) that returns the value. The
30
+ # block is evaluated via +instance_exec+ on the entity, so it
31
+ # has access to private helpers without +send+ dispatch.
32
+ def field(name, property_id = nil, value_kind = nil,
33
+ multilingual: nil, synthetic: false, reader: nil,
34
+ as: nil, &block)
35
+ resolved_kind, resolved_ml = resolve_from_registry(property_id)
36
+ Opencdd::Entity::FieldRegistry.register(
37
+ entity_class: self,
38
+ name: name,
39
+ property_id: property_id,
40
+ value_kind: value_kind || resolved_kind || :string,
41
+ multilingual: multilingual.nil? ? resolved_ml : multilingual,
42
+ synthetic: synthetic,
43
+ reader: reader,
44
+ block: block,
45
+ json_key: as,
46
+ )
47
+ define_method(name) do |lang = nil|
48
+ Opencdd::Entity::FieldReader.read(self, name, lang: lang)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_from_registry(property_id)
55
+ return [nil, false] unless property_id
56
+ entry = Opencdd::PropertyIds::REGISTRY[property_id.to_s]
57
+ return [nil, false] unless entry
58
+ [entry.value_kind, entry.multilingual]
59
+ end
60
+ end
61
+
62
+ META_CLASS_CODE = nil
63
+
64
+ attr_reader :irdi, :properties, :schema, :meta_class_irdi, :source_location
65
+
66
+ def self.from_row(row, schema:, meta_class_irdi:, code_property_id: nil)
67
+ code_property_id ||= default_code_property_id(meta_class_irdi)
68
+ raw_code = code_property_id && row[code_property_id]
69
+ irdi = raw_code && Opencdd::IRDI.parse(raw_code)
70
+
71
+ props = row.each_with_object({}) do |(k, v), h|
72
+ next if k == "__row_index__"
73
+ h[k] = v if v
74
+ end
75
+
76
+ new(irdi: irdi, properties: props, schema: schema, meta_class_irdi: meta_class_irdi)
77
+ end
78
+
79
+ def self.default_code_property_id(meta_class_irdi)
80
+ Opencdd::MetaClasses.code_property_id_for(meta_class_irdi&.code)
81
+ end
82
+
83
+ def initialize(irdi:, properties:, schema: nil, meta_class_irdi: nil)
84
+ @irdi = irdi
85
+ @properties = properties
86
+ @schema = schema
87
+ @meta_class_irdi = meta_class_irdi
88
+ @version_history = Opencdd::Entity::VersionHistory.new
89
+ end
90
+
91
+ def type
92
+ Opencdd::Parcel::META_CLASS_TYPES[meta_class_irdi&.code]
93
+ end
94
+
95
+ def code
96
+ @irdi&.code
97
+ end
98
+
99
+ alias_method :short, :code
100
+
101
+ # ── Pure field reads (value_kind and multilingual auto-resolved
102
+ # from PropertyIds::REGISTRY). `as:` aliases preserve the
103
+ # existing JSON wire shape — ruby method names differ from
104
+ # historical JSON keys for backward compatibility. ─────────
105
+ # Note: irdi and code are NOT declared as DSL fields — they're
106
+ # emitted explicitly by Exporters::Json#entity_payload because
107
+ # the model's `irdi` accessor must return the Opencdd::IRDI object
108
+ # (not a String); DSL emission would override it.
109
+ field :version, "MDC_P002_1"
110
+ field :revision, "MDC_P002_2"
111
+ field :preferred_name, "MDC_P004" # multilingual via REGISTRY
112
+ field :short_name, "MDC_P005" # multilingual via REGISTRY
113
+ field :definition, "MDC_P006" # multilingual via REGISTRY
114
+ # source_document wins the MDC_P006_1 dedup (declared before the
115
+ # _of_definition alias); JSON key is "source_document".
116
+ field :source_document, "MDC_P006_1"
117
+ field :source_document_of_definition, "MDC_P006_1"
118
+ field :synonyms, "MDC_P007", :synonym_pairs, multilingual: true, as: "synonyms"
119
+ field :synonymous_names, "MDC_P007", :synonym_pairs, multilingual: true
120
+ field :note, "MDC_P008" # multilingual via REGISTRY
121
+ field :simplified_drawing, "MDC_P008_1", :string
122
+ field :remark, "MDC_P009" # multilingual via REGISTRY
123
+ field :description, "MDC_P112" # multilingual via REGISTRY
124
+ field :example, "MDC_P113"
125
+ field :guid, "MDC_P066", as: "guid"
126
+ field :data_object_identifier, "MDC_P066"
127
+ field :time_stamp, "MDC_P067"
128
+
129
+ # ── C### workbook-specific codes. These are IEC CDD export column
130
+ # identifiers that don't have MDC_P### codes in PropertyIds::REGISTRY.
131
+ # They carry publisher, status, committee, and change-request data.
132
+ # Declared as synthetic (no MDC_P###) with block-form readers
133
+ # evaluated via instance_exec (no send-to-private bypass).
134
+ field(:status_level, synthetic: true) { @properties["C016"] }
135
+ field(:publisher, synthetic: true) { @properties["C011"] }
136
+ field(:published_in, synthetic: true) { @properties["C012"] }
137
+ field(:responsible_committee, synthetic: true) { @properties["MDC_P012"] || @properties["C019.en"] }
138
+ field(:change_request_id, synthetic: true) { @properties["C002"] }
139
+
140
+ # ── Full raw properties dump. Every key in @properties appears
141
+ # in the JSON output. This guarantees "full import" — every
142
+ # column from the .xls is preserved, including multilingual
143
+ # variants (MDC_P004.en/de/fr/zh), C### workbook-specific
144
+ # codes (status, publisher, etc.), and sub-IDs the DSL
145
+ # hasn't named yet. The browser can render typed fields
146
+ # nicely and fall back to raw_properties for completeness.
147
+ field(:raw_properties, synthetic: true) { @properties }
148
+
149
+ # ── Per-version provenance from _entity.json#versions. Set by
150
+ # Opencdd::Parcel::ShardedDirReader after entity creation. The
151
+ # DSL serializer converts VersionHistory → array of entry
152
+ # hashes for JSON emission.
153
+ field(:version_history, synthetic: true) { @version_history }
154
+
155
+ # ── Computed field with custom block ────────────────────────
156
+ Dates = Struct.new(:original_definition, :current_version, :current_revision, keyword_init: true)
157
+
158
+ field(:dates, synthetic: true) do
159
+ Dates.new(
160
+ original_definition: @properties[Opencdd::PropertyIds::MDC_P003_1],
161
+ current_version: @properties[Opencdd::PropertyIds::MDC_P003_2],
162
+ current_revision: @properties[Opencdd::PropertyIds::MDC_P003_3],
163
+ )
164
+ end
165
+
166
+ def [](key)
167
+ @properties[key.to_s]
168
+ end
169
+
170
+ def key?(key)
171
+ @properties.key?(key.to_s)
172
+ end
173
+
174
+ def keys
175
+ @properties.keys
176
+ end
177
+
178
+ def each_property
179
+ return enum_for(:each_property) unless block_given?
180
+ @properties.each { |k, v| yield k, v }
181
+ end
182
+
183
+ def eql?(other)
184
+ other.is_a?(Opencdd::Entity) &&
185
+ irdi == other.irdi &&
186
+ type == other.type
187
+ end
188
+
189
+ def hash
190
+ [irdi, type].hash
191
+ end
192
+
193
+ alias_method :==, :eql?
194
+
195
+ def inspect
196
+ "#<#{self.class.name} #{irdi}>"
197
+ end
198
+
199
+ def replace_irdi!(new_irdi)
200
+ @irdi = Opencdd::IRDI === new_irdi ? new_irdi : Opencdd::IRDI.parse(new_irdi.to_s)
201
+ self
202
+ end
203
+
204
+ def replace_code_value!(code_property_id, new_code)
205
+ return self unless code_property_id
206
+ @properties[code_property_id.to_s] = new_code.to_s
207
+ self
208
+ end
209
+
210
+ # ── Field DSL access seam ───────────────────────────────────
211
+ #
212
+ # The canonical way to read or write a typed field on an entity.
213
+ # Goes through FieldRegistry so multilingual, value-kind, and
214
+ # synthetic-field semantics are consistent across all callers
215
+ # (SheetEmitter, Json exporter, validator, GUID setter, etc.).
216
+ # Callers that index +properties+ directly bypass these and
217
+ # risk round-trip drift — see TODO.impl/26.
218
+
219
+ # Read a declared field by name. Accepts an optional +lang:+ for
220
+ # multilingual fields. Returns the typed value (parsed IRDI,
221
+ # Array, etc.) per the field's declared value_kind.
222
+ def read_field(name, lang: nil)
223
+ Opencdd::Entity::FieldReader.read(self, name, lang: lang)
224
+ end
225
+
226
+ # Write a value to a property ID, routing through canonical-id
227
+ # alias resolution so writes are consistent with reads. Use
228
+ # this in preference to +entity.properties[id] = value+ from
229
+ # non-infrastructure callers.
230
+ def write_property!(id, value)
231
+ canonical = Opencdd::PropertyIds.canonical_id(id.to_s) || id.to_s
232
+ base = canonical.to_s.split(".").first
233
+ @properties[base] = value.to_s
234
+ self
235
+ end
236
+
237
+ # Attach per-version provenance captured in _entity.json. Called
238
+ # by Opencdd::Parcel::ShardedDirReader after entity creation — the
239
+ # constructor doesn't take it because FlatDirReader creates
240
+ # entities from .xls rows without version context.
241
+ def attach_version_history(version_history)
242
+ @version_history = version_history
243
+ self
244
+ end
245
+
246
+ # Attach source location (file:line) where this entity was
247
+ # declared. Set by Opencdd::Cddal::Builder from import/instantiation
248
+ # context. Nil for entities constructed from Parcel readers
249
+ # (which don't have a single source file).
250
+ def attach_source_location(loc)
251
+ @source_location = loc
252
+ self
253
+ end
254
+ end
255
+ end