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 +4 -4
- data/CLAUDE.md +12 -0
- data/lib/opencdd/cddal/serializer.rb +12 -0
- data/lib/opencdd/entity_diff.rb +108 -0
- data/lib/opencdd/exporters/json.rb +30 -1
- data/lib/opencdd/parcel/versioned_reader.rb +99 -0
- data/lib/opencdd/parcel.rb +1 -0
- data/lib/opencdd/validator/runner.rb +1 -1
- data/lib/opencdd/version.rb +1 -1
- data/lib/opencdd.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fef2d0d21f84980a8ecd470525ad52e687f511de1ca0c0567b08d55ca63d138
|
|
4
|
+
data.tar.gz: b5497fc0a22a1ccedabc62ed3424a99164e2c6b964cd049f5b139a2060316fbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
data/lib/opencdd/parcel.rb
CHANGED
|
@@ -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&.
|
|
80
|
+
column_schema = schema&.find_by_property_id(base)
|
|
81
81
|
RuleContext.new(
|
|
82
82
|
database: database,
|
|
83
83
|
entity: entity,
|
data/lib/opencdd/version.rb
CHANGED
data/lib/opencdd.rb
CHANGED
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.
|
|
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-
|
|
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
|