opencdd 0.2.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74a134259cf9d336b0e401139d10fcda58d1df883ec52312e04eadd2c6016bb9
4
- data.tar.gz: b2c6cd320d420f7258c4116c218e0de50850d9ebb1745f81e4b06c33113196ef
3
+ metadata.gz: 2fef2d0d21f84980a8ecd470525ad52e687f511de1ca0c0567b08d55ca63d138
4
+ data.tar.gz: b5497fc0a22a1ccedabc62ed3424a99164e2c6b964cd049f5b139a2060316fbc
5
5
  SHA512:
6
- metadata.gz: 5147138e2581efbaf28bf224de9c56ed541040cdedec3569436c786419df35fc190a144982d78d53ca7d1ce4d4b7f73b7cd21847d5c3597e1987b0ade886db85
7
- data.tar.gz: 9bd7ee2d030cfb9792177763875b02114aca94534754986fe17ba6c7b24dcf5cd47ad9361fd9f9533c8d3fcbdc4bcc296d4610b335809358d5a6d3ae9844f0f0
6
+ metadata.gz: f4d40389beaf651c46c1acebf77d557ea6ee0b722e6048914bf5e9107937e82aadb65ed7cdce2ae421984cfabf722303145f9dd69c9715fdcabec6d94811da43
7
+ data.tar.gz: '083684e0064e581c101c3a906dca0e25e33d06c96c75d1170eef5b46c7a77a5380fb7ebd1d9782b7fac5602086ca0e44d74d7824cc39726c9a12f3f353686f00'
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)
@@ -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
@@ -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.1"
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.1
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