opencdd 0.2.0 → 0.2.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74a134259cf9d336b0e401139d10fcda58d1df883ec52312e04eadd2c6016bb9
4
- data.tar.gz: b2c6cd320d420f7258c4116c218e0de50850d9ebb1745f81e4b06c33113196ef
3
+ metadata.gz: 9fc81f8bcb07863fa5c69e681de61f2d198f39bc81a0fc59da37ec987c9b2a1f
4
+ data.tar.gz: 90e951f6174389ecab19ae8170b3e23ed8ff3e7d79602505f9ae2bd517369384
5
5
  SHA512:
6
- metadata.gz: 5147138e2581efbaf28bf224de9c56ed541040cdedec3569436c786419df35fc190a144982d78d53ca7d1ce4d4b7f73b7cd21847d5c3597e1987b0ade886db85
7
- data.tar.gz: 9bd7ee2d030cfb9792177763875b02114aca94534754986fe17ba6c7b24dcf5cd47ad9361fd9f9533c8d3fcbdc4bcc296d4610b335809358d5a6d3ae9844f0f0
6
+ metadata.gz: c20ebb47fc6253b09cc1dc083066aadb552af65606c77400c932a3f78ac1ee08e78b431eb69a7409a1c9ec9dd2059b7d6d3a6ce78c0143393b6fd80f8c6a89dc
7
+ data.tar.gz: 50c2e7547728041faa53a8acc143e545628e5d3750d61dd55b36e2cd7ec980ee0a7af4755e2fafd8024f0d67a9e4c9cdb9f2140274ae893d8aba29906d26f185
data/CLAUDE.md CHANGED
@@ -448,6 +448,18 @@ ParcelMaker's interactive flows in a browser. Plans are in `TODO.cdd-editor/`.
448
448
  + iec61360 (574) + iec62683 (1855) + iec61987 (11831) =
449
449
  **14,529 entities** across 7 dictionaries. Run `cd browser && npm run dev`
450
450
  to serve at `http://localhost:5173`.
451
+ - **2026-07-17 data pipeline additions** (see `TODO.final/`):
452
+ - `rake browser:build_parcel[<dict>]` — emit a Parcel .xlsx via
453
+ `Opencdd::Parcel::Writer`. Output at `data/<dict>/parcel/<parcel_id>.xlsx`.
454
+ - `rake browser:build_versions[<dict>]` — emit per-version JSON
455
+ via `Opencdd::Parcel::VersionedReader` + `Opencdd::Exporters::Json#payload_for`.
456
+ Output at `data/<dict>/versions/<code>/<unid>.json`.
457
+ - `rake browser:build_all_parcel` — batch Parcel emit across all dicts.
458
+ - **2026-07-17 public API additions**:
459
+ - `Opencdd::Cddal::Serializer#emit_entity(entity)` — single-entity CDDAL.
460
+ - `Opencdd::Exporters::Json#payload_for(entity, database: nil)` — registry-dispatched per-entity payload (no case/when).
461
+ - `Opencdd::Parcel::VersionedReader#versions_for(code)`, `#load_version(code, unid)`.
462
+ - `Opencdd::EntityDiff.between(a, b)` + `#added` / `#removed` / `#changed`.
451
463
  - **Phase 2 (lutaml-model migration) NOT started**: tracked in
452
464
  `TODO.full-cdd/16-lutaml-model-migration.md`. This is the major
453
465
  remaining architectural work — migrate entities to `Lutaml::Model`
@@ -40,6 +40,18 @@ module Opencdd
40
40
  lines.join("\n") + "\n"
41
41
  end
42
42
 
43
+ # Emit a single entity as a standalone CDDAL document.
44
+ # Returns the canonical text (with header) for one entity,
45
+ # suitable for download from the browser or extraction from
46
+ # a database by IRDI.
47
+ #
48
+ # Round-trips: <tt>Opencdd::Cddal.parse(emit_entity(e))</tt>
49
+ # yields a Database whose single entity is equivalent to +e+.
50
+ def emit_entity(entity)
51
+ body = emit_instance(entity).join("\n")
52
+ "#{HEADER.chomp}\n#{body}\n"
53
+ end
54
+
43
55
  private
44
56
 
45
57
  def emit_default_aliases(lines)
@@ -69,6 +69,17 @@ module Opencdd
69
69
  def self.from_row(row, schema:, meta_class_irdi:, code_property_id: nil)
70
70
  code_property_id ||= default_code_property_id(meta_class_irdi)
71
71
  raw_code = code_property_id && row[code_property_id]
72
+ # Fall back to other known code property IDs when the entity-
73
+ # specific one is absent (happens with name-only header sheets
74
+ # where "Code" synthesizes to MDC_P001_5 regardless of type).
75
+ if raw_code.nil? || raw_code.to_s.strip.empty?
76
+ Opencdd::MetaClasses::CODE_PROPERTY_IDS.each_value do |alt_id|
77
+ val = row[alt_id]
78
+ next if val.nil? || val.to_s.strip.empty?
79
+ raw_code = val
80
+ break
81
+ end
82
+ end
72
83
  irdi = raw_code && Opencdd::IRDI.parse(raw_code)
73
84
 
74
85
  props = row.each_with_object({}) do |(k, v), h|
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ # Compute a structured diff between two entities of the same IRDI.
5
+ #
6
+ # Pure value object: doesn't mutate its inputs, doesn't reach
7
+ # beyond +entity.properties+. Iterates the union of keys and
8
+ # categorizes each into +added+, +removed+, or +changed+.
9
+ #
10
+ # Multilingual fields (<tt>MDC_P001.en</tt>, <tt>MDC_P001.fr</tt>)
11
+ # are diffed as a group keyed by their base property ID. A change
12
+ # in any language counts as one change to the base field.
13
+ #
14
+ # Example:
15
+ # diff = Opencdd::EntityDiff.between(v001, v002)
16
+ # diff.added # => ["MDC_P999"]
17
+ # diff.removed # => []
18
+ # diff.changed # => [{ field: "preferred_name", from: "X", to: "Y" }]
19
+ # diff.empty? # => false
20
+ class EntityDiff
21
+ Change = Struct.new(:field, :from, :to, keyword_init: true)
22
+
23
+ attr_reader :from_entity, :to_entity, :added, :removed, :changed
24
+
25
+ def self.between(from_entity, to_entity)
26
+ new(from_entity, to_entity)
27
+ end
28
+
29
+ def initialize(from_entity, to_entity)
30
+ raise ArgumentError, "EntityDiff requires same IRDI (#{from_entity.irdi} vs #{to_entity.irdi})" unless from_entity.irdi == to_entity.irdi
31
+ @from_entity = from_entity
32
+ @to_entity = to_entity
33
+ compute_diff
34
+ end
35
+
36
+ def empty?
37
+ added.empty? && removed.empty? && changed.empty?
38
+ end
39
+
40
+ # Total change count (added + removed + changed).
41
+ def size
42
+ added.size + removed.size + changed.size
43
+ end
44
+
45
+ # Wire-format summary suitable for JSON emit or browser diff view.
46
+ def to_h
47
+ {
48
+ irdi: from_entity.irdi.to_s,
49
+ added: added,
50
+ removed: removed,
51
+ changed: changed.map(&:to_h),
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def compute_diff
58
+ @added = []
59
+ @removed = []
60
+ @changed = []
61
+ from_groups = grouped_properties(from_entity)
62
+ to_groups = grouped_properties(to_entity)
63
+ (from_groups.keys | to_groups.keys).sort.each do |field|
64
+ from_val = from_groups[field]
65
+ to_val = to_groups[field]
66
+ if from_val.nil? && !to_val.nil?
67
+ @added << field
68
+ elsif !from_val.nil? && to_val.nil?
69
+ @removed << field
70
+ elsif values_differ?(from_val, to_val)
71
+ @changed << Change.new(field: field, from: from_val, to: to_val)
72
+ end
73
+ end
74
+ end
75
+
76
+ # Group multilingual keys (<base>.<lang>) under their <base>.
77
+ # Returns +{ base_property_id => grouped_value }+ where
78
+ # +grouped_value+ is a string for monolingual fields or a
79
+ # sorted +{ lang => value }+ hash for multilingual fields.
80
+ def grouped_properties(entity)
81
+ groups = Hash.new { |h, k| h[k] = {} }
82
+ entity.properties.each do |key, value|
83
+ next if value.nil?
84
+ k = key.to_s
85
+ base, lang = split_language_suffix(k)
86
+ if lang
87
+ groups[base][lang] = value.to_s
88
+ else
89
+ groups[base] = value.to_s
90
+ end
91
+ end
92
+ # Collapse empty hashes (a group that had only nil values).
93
+ groups.transform_values { |v| v.is_a?(Hash) && v.empty? ? nil : v }
94
+ end
95
+
96
+ def split_language_suffix(key)
97
+ m = key.match(/\A(?<base>.+?)\.(?<lang>[a-z]{2,3}(?:-[a-z0-9]+)?)\z/i)
98
+ return [key, nil] unless m
99
+ [m[:base], m[:lang].downcase]
100
+ end
101
+
102
+ def values_differ?(from_val, to_val)
103
+ return from_val != to_val unless from_val.is_a?(Hash) && to_val.is_a?(Hash)
104
+ # For multilingual groups: compare as normalized hashes.
105
+ from_val == to_val ? false : true
106
+ end
107
+ end
108
+ end
@@ -53,7 +53,34 @@ module Opencdd
53
53
  @nodes << view_control_node(vc)
54
54
  end
55
55
 
56
- private
56
+ # ─────────────────────────────────────────────────────────────
57
+ # Public per-entity payload API
58
+ #
59
+ # `payload_for(entity, database:)` returns the wire-format Hash
60
+ # for a single entity. Dispatch is via the PAYLOAD_BUILDERS
61
+ # registry — adding a new entity type means adding one entry to
62
+ # the registry and one builder method, not editing a switch.
63
+ #
64
+ # The optional `database:` enables cross-entity resolution
65
+ # (e.g. property → value_list). Without it, cross-links are
66
+ # omitted (payload still has all the entity's own fields).
67
+ # ─────────────────────────────────────────────────────────────
68
+ PAYLOAD_BUILDERS = {
69
+ Opencdd::Klass => :class_node,
70
+ Opencdd::Property => :property_node,
71
+ Opencdd::Unit => :unit_node,
72
+ Opencdd::ValueList => :value_list_node,
73
+ Opencdd::ValueTerm => :value_term_node,
74
+ Opencdd::Relation => :relation_node,
75
+ Opencdd::ViewControl => :view_control_node,
76
+ }.freeze
77
+
78
+ def payload_for(entity, database: nil)
79
+ @database = database if database
80
+ builder = PAYLOAD_BUILDERS[entity.class]
81
+ raise ArgumentError, "No JSON payload builder for #{entity.class}" unless builder
82
+ public_send(builder, entity)
83
+ end
57
84
 
58
85
  # ─────────────────────────────────────────────────────────────
59
86
  # Open/closed payload builders
@@ -98,6 +125,8 @@ module Opencdd
98
125
  entity_payload(vc).merge(type: "view_control").compact
99
126
  end
100
127
 
128
+ private
129
+
101
130
  # Iterates every declared field on the entity's class (walking
102
131
  # the ancestor chain via FieldRegistry.fields_for). Each field's
103
132
  # value is read by name (synthetic fields call their custom
@@ -79,6 +79,7 @@ module Opencdd
79
79
  "MDC_C011" => "MDC_P001_13",
80
80
  "MDC_C009" => "MDC_P001_10",
81
81
  "MDC_C010" => "MDC_P001_11",
82
+ "MDC_C0100" => "C0101",
82
83
  "EXT_C001" => "EXT_P001",
83
84
  }.freeze
84
85
 
@@ -89,6 +90,7 @@ module Opencdd
89
90
  "MDC_C011" => :relation,
90
91
  "MDC_C009" => :unit,
91
92
  "MDC_C010" => :value_term,
93
+ "MDC_C0100" => :list_of_unit,
92
94
  "EXT_C001" => :view_control,
93
95
  }.freeze
94
96
 
@@ -252,6 +254,13 @@ module Opencdd
252
254
  type: :view_control,
253
255
  allowed_property_ids: common + %w[EXT_P002 EXT_P003],
254
256
  )
257
+ list_of_unit = MetaClass.new(
258
+ irdi: "MDC_C0100",
259
+ name: "ListOfUnit",
260
+ entity_class: Opencdd::Entity,
261
+ type: :list_of_unit,
262
+ allowed_property_ids: common,
263
+ )
255
264
  {
256
265
  klass_class.irdi => klass_class,
257
266
  property_class.irdi => property_class,
@@ -260,6 +269,7 @@ module Opencdd
260
269
  unit.irdi => unit,
261
270
  value_term.irdi => value_term,
262
271
  view_control.irdi => view_control,
272
+ list_of_unit.irdi => list_of_unit,
263
273
  }
264
274
  end
265
275
  end
@@ -23,6 +23,7 @@ module Opencdd
23
23
  "UNIT" => :unit,
24
24
  "VALUELIST" => :value_list,
25
25
  "VALUETERMS" => :value_term,
26
+ "LISTOFUNITS" => :list_of_unit,
26
27
  }.freeze
27
28
 
28
29
  attr_reader :path
@@ -32,7 +32,7 @@ module Opencdd
32
32
 
33
33
  # Per-type export filename produced by cdd.iec.ch's download
34
34
  # endpoint.
35
- FILE_PATTERN = /\Aexport_(CLASS|PROPERTY|RELATION|UNIT|VALUELIST|VALUETERMS)_[^\.]+\.(xls|xlsx)\z/i.freeze
35
+ FILE_PATTERN = /\Aexport_(CLASS|PROPERTY|RELATION|UNIT|VALUELIST|VALUETERMS|LISTOFUNITS)_[^\.]+\.(xls|xlsx)\z/i.freeze
36
36
 
37
37
  module_function
38
38
 
@@ -159,6 +159,9 @@ module Opencdd
159
159
 
160
160
  def finalize!
161
161
  ids = @column_directives["PROPERTY_ID"] || []
162
+ if ids.empty? || ids.all? { |id| id.nil? || id.to_s.strip.empty? }
163
+ ids = synthesize_ids_from_names
164
+ end
162
165
  ids.each_with_index do |id, idx|
163
166
  next if id.nil? || id.to_s.strip.empty?
164
167
 
@@ -194,6 +197,81 @@ module Opencdd
194
197
  freeze
195
198
  end
196
199
 
200
+ # When a sheet has no #PROPERTY_ID directive row (only
201
+ # #PROPERTY_NAME), synthesize property IDs from the names.
202
+ # This happens with some export formats (notably RELATION
203
+ # exports from cdd.iec.ch) that omit the PROPERTY_ID row.
204
+ NAME_TO_PROPERTY_ID = {
205
+ "Code" => "MDC_P001_5",
206
+ "Version" => "MDC_P002_1",
207
+ "Revision" => "MDC_P002_2",
208
+ "VersionInitiationDate" => "MDC_P003_1",
209
+ "VersionReleaseDate" => "MDC_P003_2",
210
+ "RevisionReleaseDate" => "MDC_P003_3",
211
+ "PreferredName" => "MDC_P004",
212
+ "SynonymousName" => "MDC_P007",
213
+ "ShortName" => "MDC_P005",
214
+ "Definition" => "MDC_P006",
215
+ "DefinitionSource" => "MDC_P006_1",
216
+ "Note" => "MDC_P008",
217
+ "Remark" => "MDC_P009",
218
+ "Drawing" => "MDC_P008_1",
219
+ "GUID" => "MDC_P066",
220
+ "TimeStamp" => "MDC_P067",
221
+ "ClassType" => "MDC_P011",
222
+ "Superclass" => "MDC_P010",
223
+ "IsCaseOf" => "MDC_P013",
224
+ "ApplicableProperties" => "MDC_P014",
225
+ "ImportedProperties" => "MDC_P090",
226
+ "SubClassSelection" => "MDC_P016",
227
+ "DataType" => "MDC_P022",
228
+ "ValueFormat" => "MDC_P024",
229
+ "DefinitionClass" => "MDC_P021",
230
+ "Unit" => "MDC_P041",
231
+ "Condition" => "MDC_P028",
232
+ "ListType" => "MDC_P046",
233
+ "CodeList" => "MDC_P044",
234
+ "TermList" => "MDC_P043",
235
+ "EnumerationCode" => "MDC_P044",
236
+ "RelationType" => "MDC_P200",
237
+ "RelationDomain" => "MDC_P201",
238
+ "FunctionDomain" => "MDC_P202",
239
+ "FunctionCodomain" => "MDC_P203",
240
+ "Formula" => "MDC_P204",
241
+ "FormulaLanguage" => "MDC_P205",
242
+ "FormulaExternalSolver" => "MDC_P206",
243
+ "TriggerEvent" => "MDC_P207",
244
+ "DomainElementType" => "MDC_P208",
245
+ "CodomainElementType" => "MDC_P209",
246
+ "Role" => "MDC_P210",
247
+ "Segment" => "MDC_P211",
248
+ "SuperRelation" => "MDC_P212",
249
+ }.freeze
250
+
251
+ def synthesize_ids_from_names
252
+ names = nil
253
+ @column_directives.each do |key, vals|
254
+ base, = key.split(".", 2)
255
+ next unless base == "PROPERTY_NAME"
256
+ names = vals
257
+ break
258
+ end
259
+ return [] unless names
260
+ Array.new(names.size) do |idx|
261
+ val = names[idx]
262
+ next nil if val.nil? || val.to_s.strip.empty?
263
+ raw = val.to_s.strip
264
+ if raw =~ /\A(.+)\.([A-Za-z]{2})\z/
265
+ base = $1
266
+ lang = $2
267
+ pid = NAME_TO_PROPERTY_ID[base]
268
+ pid ? "#{pid}.#{lang.downcase}" : nil
269
+ else
270
+ NAME_TO_PROPERTY_ID[raw]
271
+ end
272
+ end
273
+ end
274
+
197
275
  def [](property_id_or_name)
198
276
  find_by_property_id(property_id_or_name) || find_by_name(property_id_or_name)
199
277
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ # Reads historical versions of entities from a sharded
6
+ # per-version Parcel layout.
7
+ #
8
+ # The sharded layout stores every version of an entity in a
9
+ # per-UNID subfolder: +<entity_dir>/<UNID>/export_*.xls+. The
10
+ # +_entity.json+ sidecar lists every version and which UNID is
11
+ # current. ShardedDirReader reads only the current version;
12
+ # VersionedReader exposes the full history.
13
+ #
14
+ # Delegates to:
15
+ # - +Opencdd::Parcel::EntityManifest+ for version metadata.
16
+ # - +Opencdd::Parcel::FlatDirReader+ for xls parsing of one
17
+ # version's subfolder.
18
+ #
19
+ # Example:
20
+ # reader = Opencdd::Parcel::VersionedReader.new("downloads/iec63213")
21
+ # reader.versions_for("KEA012") # => 3 VersionHistory::Entry
22
+ # reader.load_version("KEA012", "ABC123...") # => Database with v002 content
23
+ class VersionedReader
24
+ attr_reader :path
25
+
26
+ def initialize(path)
27
+ @path = path
28
+ end
29
+
30
+ # All versions recorded for +code+, as
31
+ # +Opencdd::Entity::VersionHistory::Entry+ instances.
32
+ # Empty +VersionHistory+ if +code+ has no manifest.
33
+ def versions_for(code)
34
+ manifest = manifest_for(code)
35
+ return Opencdd::Entity::VersionHistory.new unless manifest
36
+ manifest.version_history
37
+ end
38
+
39
+ # Load a single historical version's content as a standalone
40
+ # Database. The +unid+ identifies which version's subfolder
41
+ # to read.
42
+ #
43
+ # Returns +nil+ if the version subfolder doesn't exist or has
44
+ # no +.xls+ files. The returned database is finalized but not
45
+ # cross-linked to other entities (historical context only).
46
+ def load_version(code, unid)
47
+ version_dir = version_dir_for(code, unid)
48
+ return nil unless version_dir && File.directory?(version_dir)
49
+ return nil unless Dir.children(version_dir).any? { |f| Opencdd::Parcel::LayoutDetector.legacy_export?(f) }
50
+
51
+ workbook = Opencdd::Parcel::FlatDirReader.new(version_dir).read_workbook
52
+ database = Opencdd::Database.new
53
+ database.add_workbook(workbook)
54
+ attach_version_history(database, code)
55
+ database.finalize!
56
+ database
57
+ end
58
+
59
+ # The entity subdirectory for +code+. Tries both the legacy
60
+ # +<path>/<code>+ layout and the manifest-driven
61
+ # +<path>/_entities/<code>+ layout.
62
+ def entity_dir_for(code)
63
+ flat = File.join(@path, code)
64
+ return flat if File.directory?(flat)
65
+ sharded = File.join(@path, "_entities", code)
66
+ return sharded if File.directory?(sharded)
67
+ nil
68
+ end
69
+
70
+ private
71
+
72
+ def manifest_for(code)
73
+ dir = entity_dir_for(code)
74
+ return nil unless dir
75
+ Opencdd::Parcel::EntityManifest.read(dir)
76
+ end
77
+
78
+ def version_dir_for(code, unid)
79
+ dir = entity_dir_for(code)
80
+ return nil unless dir
81
+ candidate = File.join(dir, unid)
82
+ return candidate if File.directory?(candidate)
83
+ nil
84
+ end
85
+
86
+ def attach_version_history(database, code)
87
+ manifest = manifest_for(code)
88
+ return unless manifest
89
+ return if manifest.versions_empty?
90
+ manifest.version_history.entries.each do |entry|
91
+ next unless entry.unid
92
+ target = database.find_by_code(code)
93
+ target&.attach_version_history(manifest.version_history)
94
+ break
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -12,6 +12,7 @@ module Opencdd
12
12
  autoload :WorkbookReader, "opencdd/parcel/workbook_reader"
13
13
  autoload :FlatDirReader, "opencdd/parcel/flat_dir_reader"
14
14
  autoload :ShardedDirReader, "opencdd/parcel/sharded_dir_reader"
15
+ autoload :VersionedReader, "opencdd/parcel/versioned_reader"
15
16
  autoload :LayoutDetector, "opencdd/parcel/layout_detector"
16
17
  autoload :EntityManifest, "opencdd/parcel/entity_manifest"
17
18
  autoload :ReferencedIrdis, "opencdd/parcel/referenced_irdis"
@@ -77,7 +77,7 @@ module Opencdd
77
77
  base = column_iri.to_s.split(".").first
78
78
  entry = Opencdd::PropertyIds::REGISTRY[base]
79
79
  kind = entry&.value_kind
80
- column_schema = schema&.column_for(base)
80
+ column_schema = schema&.find_by_property_id(base)
81
81
  RuleContext.new(
82
82
  database: database,
83
83
  entity: entity,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Opencdd
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/opencdd.rb CHANGED
@@ -36,6 +36,7 @@ module Opencdd
36
36
  autoload :Reader, "opencdd/reader"
37
37
  autoload :ClassTree, "opencdd/class_tree"
38
38
  autoload :Visitor, "opencdd/visitor"
39
+ autoload :EntityDiff, "opencdd/entity_diff"
39
40
 
40
41
  autoload :Parcel, "opencdd/parcel"
41
42
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opencdd
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - OpenCDD contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-16 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: roo
@@ -241,6 +241,7 @@ files:
241
241
  - lib/opencdd/entity/field_registry.rb
242
242
  - lib/opencdd/entity/version_history.rb
243
243
  - lib/opencdd/entity/yaml.rb
244
+ - lib/opencdd/entity_diff.rb
244
245
  - lib/opencdd/exporters.rb
245
246
  - lib/opencdd/exporters/json.rb
246
247
  - lib/opencdd/exporters/mermaid.rb
@@ -268,6 +269,7 @@ files:
268
269
  - lib/opencdd/parcel/sheet.rb
269
270
  - lib/opencdd/parcel/sheet_emitter.rb
270
271
  - lib/opencdd/parcel/sheet_schema.rb
272
+ - lib/opencdd/parcel/versioned_reader.rb
271
273
  - lib/opencdd/parcel/workbook.rb
272
274
  - lib/opencdd/parcel/workbook_reader.rb
273
275
  - lib/opencdd/parcel/writer.rb