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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ # Single source of truth for Parcel on-disk layout detection.
6
+ #
7
+ # Three layouts exist in the wild:
8
+ #
9
+ # [legacy single] one <code>export_*.xls</code> file per type
10
+ # (CLASS, PROPERTY, RELATION, UNIT, VALUELIST,
11
+ # VALUETERMS).
12
+ # [flat dir] a directory containing those six files at the
13
+ # top level.
14
+ # [sharded dir] a directory containing one subdir per class
15
+ # code, each subdir holding 1-6 +export_*.xls+
16
+ # files either directly (flat sharded) or under
17
+ # a UNID-named version subfolder (per-version
18
+ # sharded, identified by <code>_entity.json</code>).
19
+ #
20
+ # Previously <code>Reader</code>, <code>FlatDirReader</code>,
21
+ # <code>ShardedDirReader</code>, and <code>ScrapeVerifier</code>
22
+ # each held their own copies of the regexes and detection
23
+ # helpers. Layout drift meant patching N files. Now they all
24
+ # delegate here.
25
+ module LayoutDetector
26
+ # Class-code shape per IEC CDD conventions: 2-6 uppercase
27
+ # letters, 1-6 digits, optional alnum suffix.
28
+ CLASS_CODE_PATTERN = /\A[A-Z]{2,6}[0-9]{1,6}[A-Z0-9-]*\z/.freeze
29
+
30
+ # Harvester per-version subfolder name (Lotus Notes UNID).
31
+ UNID_PATTERN = /\A[0-9A-F]{32}\z/i.freeze
32
+
33
+ # Per-type export filename produced by cdd.iec.ch's download
34
+ # endpoint.
35
+ FILE_PATTERN = /\Aexport_(CLASS|PROPERTY|RELATION|UNIT|VALUELIST|VALUETERMS)_[^\.]+\.(xls|xlsx)\z/i.freeze
36
+
37
+ module_function
38
+
39
+ # True if +name+ looks like a class code (AAA001, UAC696, ...).
40
+ def class_code?(name)
41
+ return false if name.nil?
42
+ name.match?(CLASS_CODE_PATTERN)
43
+ end
44
+
45
+ # True if +filename+ is one of the harvester's export_* files.
46
+ def legacy_export?(filename)
47
+ return false if filename.nil?
48
+ filename.match?(FILE_PATTERN)
49
+ end
50
+
51
+ # True if +name+ is a UNID-named subfolder.
52
+ def unid?(name)
53
+ return false if name.nil?
54
+ name.match?(UNID_PATTERN)
55
+ end
56
+
57
+ # Returns the directory holding the active +export_*.xls+ files
58
+ # for a given class-code dir, or +nil+ when no XLS is present.
59
+ #
60
+ # Resolution order:
61
+ # 1. Per-version: +_entity.json+ names a UNID subfolder.
62
+ # 2. Per-version (no manifest): a single UNID subfolder with XLS.
63
+ # 3. Flat sharded: XLS files directly under +code_dir+.
64
+ def active_xls_dir(code_dir)
65
+ return nil unless File.directory?(code_dir)
66
+
67
+ # Per-version with manifest.
68
+ manifest = File.join(code_dir, "_entity.json")
69
+ if File.file?(manifest)
70
+ current = read_current_version_dir(manifest)
71
+ if current && unid?(current)
72
+ candidate = File.join(code_dir, current)
73
+ return candidate if File.directory?(candidate)
74
+ end
75
+ end
76
+
77
+ # Per-version without manifest: unique UNID subfolder with XLS.
78
+ unid_subdirs = Dir.children(code_dir)
79
+ .select { |n| unid?(n) }
80
+ .map { |n| File.join(code_dir, n) }
81
+ .select { |p| File.directory?(p) }
82
+ if unid_subdirs.size == 1
83
+ active = unid_subdirs.first
84
+ return active if Dir.children(active).any? { |f| legacy_export?(f) }
85
+ end
86
+
87
+ # Flat sharded.
88
+ return code_dir if Dir.children(code_dir).any? { |f| legacy_export?(f) }
89
+
90
+ nil
91
+ end
92
+
93
+ # True if +path+ is a sharded class-code subdir (contains XLS
94
+ # directly or via the per-version layout).
95
+ def sharded_class_subdir?(path)
96
+ return false unless File.directory?(path)
97
+ return false unless class_code?(File.basename(path))
98
+ !active_xls_dir(path).nil?
99
+ end
100
+
101
+ # ── Internal ─────────────────────────────────────────────
102
+
103
+ def read_current_version_dir(manifest_path)
104
+ require "json"
105
+ data = JSON.parse(File.read(manifest_path))
106
+ data["current_version_dir"]
107
+ rescue JSON::ParserError
108
+ nil
109
+ rescue Errno::ENOENT
110
+ nil
111
+ end
112
+ private_class_method :read_current_version_dir
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ class Metadata
6
+ DIRECTIVE_REGEX = /\A#([^:=\s]+)\s*:=\s*(.*)\z/
7
+
8
+ attr_reader :directives
9
+
10
+ def self.from_directive(cell)
11
+ return nil if cell.nil?
12
+ s = cell.to_s.strip
13
+ return nil if s.empty?
14
+ return nil unless DIRECTIVE_REGEX.match?(s)
15
+ new(s)
16
+ end
17
+
18
+ def initialize(source = nil)
19
+ @directives = {}
20
+ @meta_class_raw = nil
21
+ add(source) if source
22
+ end
23
+
24
+ def add(cell)
25
+ s = cell.to_s
26
+ m = DIRECTIVE_REGEX.match(s.strip)
27
+ return nil unless m
28
+ key = m[1]
29
+ val = m[2]
30
+ @directives[key] = val
31
+ if key == "CLASS_ID"
32
+ @meta_class_raw = val
33
+ end
34
+ val
35
+ end
36
+
37
+ def [](key)
38
+ @directives[key.to_s]
39
+ end
40
+
41
+ def key?(key)
42
+ @directives.key?(key.to_s)
43
+ end
44
+
45
+ def fetch(key, default = nil, &block)
46
+ if @directives.key?(key.to_s)
47
+ @directives[key.to_s]
48
+ elsif block
49
+ yield
50
+ else
51
+ default
52
+ end
53
+ end
54
+
55
+ def meta_class_irdi
56
+ return @meta_class_irdi if defined?(@meta_class_irdi)
57
+ @meta_class_irdi =
58
+ if @meta_class_raw.nil? || @meta_class_raw.empty?
59
+ nil
60
+ else
61
+ Opencdd::IRDI.parse(synthesize_meta_class_irdi)
62
+ end
63
+ end
64
+
65
+ def synthesize_meta_class_irdi
66
+ raw = @meta_class_raw
67
+ return raw if raw.include?("#")
68
+ supplier = (@directives["DEFAULT_SUPPLIER"] || "").strip
69
+ return raw if supplier.empty?
70
+ "#{supplier}##{raw}"
71
+ end
72
+
73
+ def meta_class_code
74
+ meta_class_irdi&.code
75
+ end
76
+
77
+ def type
78
+ return @type if defined?(@type)
79
+ code = meta_class_code
80
+ @type = code ? Opencdd::Parcel::META_CLASS_TYPES[code] : nil
81
+ end
82
+
83
+ def class_name(lang = :en)
84
+ @directives["CLASS_NAME.#{lang}"]
85
+ end
86
+
87
+ def class_definition(lang = :en)
88
+ @directives["CLASS_DEFINITION.#{lang}"]
89
+ end
90
+
91
+ def class_note(lang = :en)
92
+ @directives["CLASS_NOTE.#{lang}"]
93
+ end
94
+
95
+ def source_language
96
+ @directives["SOURCE_LANGUAGE"]
97
+ end
98
+
99
+ def default_supplier
100
+ @directives["DEFAULT_SUPPLIER"]
101
+ end
102
+
103
+ def default_version
104
+ @directives["DEFAULT_VERSION"]
105
+ end
106
+
107
+ def each(&block)
108
+ @directives.each(&block)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ # Walks a sharded downloads directory and collects every IRDI
6
+ # referenced by a class .xls that points to a non-class entity
7
+ # (property, unit, value-list, value-term). The output manifest
8
+ # drives the per-entity scraper Pass 2 — see
9
+ # +TODO.cdd-editor/43-scraper-pass-2-per-entity.md+.
10
+ #
11
+ # Source columns on the CLASS .xls (parsed by +Opencdd::Klass+):
12
+ # applicable_property_irdis (MDC_P014) → property
13
+ # imported_property_irdis (MDC_P090) → property (cross-dict)
14
+ # is_case_of_irdis (MDC_P013) → class (cross-dict)
15
+ # sub_class_selection_irdis (MDC_P016) → class
16
+ #
17
+ # Cross-dictionary IRDIs are partitioned by their source scheme so
18
+ # the scraper can pick the right +Dictionary+ config. Properties
19
+ # referenced in +applicable_property_irdis+ of a class in dict A
20
+ # that point at dict B's IRDI space are emitted with
21
+ # +source_scheme: B+.
22
+ class ReferencedIrdis
23
+ Entry = Struct.new(:irdi, :entity_type, :source_scheme, :referenced_by, keyword_init: true) do
24
+ def to_h
25
+ { irdi: irdi.to_s, entity_type: entity_type, source_scheme: source_scheme,
26
+ referenced_by: referenced_by.dup }
27
+ end
28
+ end
29
+
30
+ attr_reader :path
31
+
32
+ def initialize(path)
33
+ @path = path
34
+ end
35
+
36
+ # Returns +Hash{ source_scheme => Array<Entry> }+.
37
+ def collect
38
+ reader = Opencdd::Parcel::ShardedDirReader.new(@path)
39
+ database = Opencdd::Database.new
40
+ reader.load_into(database)
41
+
42
+ by_irdi = {}
43
+ database.classes.each do |klass|
44
+ record_each(by_irdi, klass, klass.applicable_property_irdis, :property)
45
+ record_each(by_irdi, klass, klass.imported_property_irdis, :property)
46
+ record_each(by_irdi, klass, klass.is_case_of_irdis, :class)
47
+ record_each(by_irdi, klass, klass.sub_class_selection_irdis, :class)
48
+ end
49
+
50
+ by_scheme = {}
51
+ by_irdi.each_value do |entry|
52
+ bucket = by_scheme[entry.source_scheme] ||= []
53
+ bucket << entry
54
+ end
55
+ by_scheme.transform_values { |entries| entries.sort_by { |e| e.irdi.to_s } }
56
+ end
57
+
58
+ def as_json
59
+ collect.transform_values { |entries| entries.map(&:to_h) }
60
+ end
61
+
62
+ def to_json(*args)
63
+ require "json"
64
+ JSON.pretty_generate(as_json, *args)
65
+ end
66
+
67
+ private
68
+
69
+ def record_each(acc, klass, irdis, entity_type)
70
+ Array(irdis).each do |raw|
71
+ irdi = Opencdd::IRDI.parse(raw.to_s)
72
+ next unless irdi
73
+ scheme = irdi.scheme
74
+ next unless scheme
75
+ full = irdi.to_s
76
+ entry = acc[full]
77
+ if entry
78
+ entry.referenced_by << klass.irdi.to_s unless entry.referenced_by.include?(klass.irdi.to_s)
79
+ else
80
+ acc[full] = Entry.new(
81
+ irdi: irdi,
82
+ entity_type: entity_type,
83
+ source_scheme: scheme,
84
+ referenced_by: [klass.irdi.to_s],
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ # Verifies that scrape output produced populated .xls files, not
6
+ # just schema stubs. Catches the silent failure mode discovered
7
+ # on 2026-07-08: iec61360/iec63213/iec61360-7 scrapes appeared
8
+ # complete (10,338 PROPERTY .xls files for iec61360) but every
9
+ # file had 0 data rows. The build pipeline exported 574 classes
10
+ # + 0 properties, and the browser showed "No declared properties"
11
+ # for every class. Nothing failed loudly.
12
+ #
13
+ # This verifier reads every export_*.xls under a directory and
14
+ # reports which ones are empty. Use via `rake verify_scrape[dict]`
15
+ # or directly:
16
+ #
17
+ # results = Opencdd::Parcel::ScrapeVerifier.verify("downloads/iec61360")
18
+ # empty = results.select(&:empty?)
19
+ # abort "#{empty.size} empty files" unless empty.empty?
20
+ class ScrapeVerifier
21
+ Result = Struct.new(:path, :entity_type, :data_row_count, :status,
22
+ keyword_init: true) do
23
+ def ok? = status == :ok
24
+ def empty? = status == :empty
25
+ end
26
+
27
+ FILE_PATTERN = /\Aexport_(CLASS|PROPERTY|RELATION|UNIT|VALUELIST|VALUETERMS)_[^\.]+\.(xls|xlsx)\z/i
28
+
29
+ class << self
30
+ def verify(directory)
31
+ new(directory).verify
32
+ end
33
+ end
34
+
35
+ attr_reader :directory
36
+
37
+ def initialize(directory)
38
+ @directory = directory
39
+ end
40
+
41
+ def verify
42
+ xls_files.map { |f| check_file(f) }
43
+ end
44
+
45
+ # Summary counts — useful for CLI output.
46
+ def summary
47
+ results = verify
48
+ {
49
+ total: results.size,
50
+ ok: results.count(&:ok?),
51
+ empty: results.count(&:empty?),
52
+ by_type: results.group_by(&:entity_type).transform_values(&:size),
53
+ empty_by_type: results.select(&:empty?).group_by(&:entity_type).transform_values(&:size),
54
+ }
55
+ end
56
+
57
+ # A "data row" in the Parcel wire format is a row with content
58
+ # in column 1+ whose first cell isn't a `#` directive or comment.
59
+ # Column 0 may be empty (the IEC CDD .xls layout leaves it blank;
60
+ # data starts in column 1). Public so tests can verify the rule
61
+ # without send.
62
+ def data_row?(row)
63
+ return false unless row.is_a?(Array) && row.size > 1
64
+ first = row.first.to_s.strip
65
+ return false if first.start_with?("#")
66
+ row[1..].any? { |c| !c.nil? && !c.to_s.strip.empty? }
67
+ end
68
+
69
+ private
70
+
71
+ def xls_files
72
+ return [] unless File.directory?(@directory)
73
+ Dir.glob(File.join(@directory, "**", "export_*.{xls,xlsx}")).sort
74
+ end
75
+
76
+ def check_file(path)
77
+ rows = read_rows(path)
78
+ data = rows.count { |r| data_row?(r) }
79
+ Result.new(
80
+ path: path,
81
+ entity_type: type_from_filename(path),
82
+ data_row_count: data,
83
+ status: data.positive? ? :ok : :empty,
84
+ )
85
+ end
86
+
87
+ def read_rows(path)
88
+ ext = File.extname(path).downcase
89
+ if ext == ".xls"
90
+ read_xls_rows(path)
91
+ else
92
+ read_xlsx_rows(path)
93
+ end
94
+ rescue StandardError
95
+ # Corrupt or unreadable files count as empty for verification
96
+ # purposes — the user can investigate via the path.
97
+ []
98
+ end
99
+
100
+ def read_xls_rows(path)
101
+ require "spreadsheet"
102
+ book = Spreadsheet.open(path)
103
+ sheet = book.worksheet(0) || book.worksheets.first
104
+ return [] unless sheet
105
+ sheet.rows.map { |row| row.to_a }
106
+ end
107
+
108
+ def read_xlsx_rows(path)
109
+ require "roo"
110
+ roo = Roo::Spreadsheet.open(path, extension: :xlsx)
111
+ target = roo.sheets.first || "Export"
112
+ sheet = roo.sheet(target)
113
+ (1..sheet.last_row.to_i).map { |i| sheet.row(i).to_a }
114
+ end
115
+
116
+ def type_from_filename(path)
117
+ match = File.basename(path).match(FILE_PATTERN)
118
+ match ? match[1] : "UNKNOWN"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ # Pure-data value object describing a parcel entity selection.
6
+ #
7
+ # The Selector captures the intent of an export:
8
+ # - +entities:+ — an Array of IRDIs (or entities) to include.
9
+ # +nil+ means "every entity in the database".
10
+ # - +closure:+ — whether to expand the entity set along the
11
+ # class hierarchy. One of +:none+, +:ancestors+,
12
+ # +:descendants+, or +:tree+ (both).
13
+ #
14
+ # Resolution against a +Opencdd::Database+ returns a flat
15
+ # +Array<Opencdd::Entity>+ ordered by type (class, property,
16
+ # value_list, value_term, unit, relation, view_control). The
17
+ # caller (Writer, split, etc.) iterates and groups by type.
18
+ #
19
+ # Cross-type lifting (declared properties, value_lists,
20
+ # relations, units) is +Database+'s responsibility; the selector
21
+ # only walks the class hierarchy and asks the database for
22
+ # cross-type dependencies.
23
+ #
24
+ # Example:
25
+ # sel = Opencdd::Parcel::Selector.new(entities: ["0112/2///61360_4#AAA001"], closure: :tree)
26
+ # entities = sel.resolve(database)
27
+ #
28
+ Selector = Struct.new(:entities, :closure, keyword_init: true) do
29
+ CLOSURES = %i[none ancestors descendants tree].freeze
30
+
31
+ def initialize(entities: nil, closure: :none)
32
+ raise ArgumentError, "invalid closure: #{closure.inspect}" unless CLOSURES.include?(closure)
33
+ super(entities: entities, closure: closure)
34
+ end
35
+
36
+ def resolve(database)
37
+ return database.entities.to_a if entities.nil? && closure == :none
38
+
39
+ seed = expand_seed(database)
40
+ expanded = apply_closure(database, seed)
41
+ lift_cross_type(database, expanded)
42
+ end
43
+
44
+ private
45
+
46
+ def expand_seed(database)
47
+ Array(entities).flat_map { |e| resolve_entity(database, e) }
48
+ end
49
+
50
+ def resolve_entity(database, candidate)
51
+ case candidate
52
+ when Opencdd::Entity then [candidate]
53
+ when Opencdd::IRDI, String then
54
+ entity = database.find(candidate)
55
+ entity ? [entity] : []
56
+ when nil then []
57
+ else
58
+ raise TypeError, "Selector entities must be Opencdd::Entity, Opencdd::IRDI, or String; got #{candidate.class}"
59
+ end
60
+ end
61
+
62
+ def apply_closure(database, seed)
63
+ return seed if closure == :none || seed.empty?
64
+ classes = seed.select { |e| e.is_a?(Opencdd::Klass) }
65
+ return seed if classes.empty?
66
+
67
+ out = seed.dup
68
+ if closure == :ancestors || closure == :tree
69
+ classes.each { |k| add_ancestors(out, k) }
70
+ end
71
+ if closure == :descendants || closure == :tree
72
+ classes.each { |k| add_descendants(out, k) }
73
+ end
74
+ out
75
+ end
76
+
77
+ def add_ancestors(out, klass)
78
+ klass.ancestors.each do |a|
79
+ out << a unless out.include?(a)
80
+ end
81
+ end
82
+
83
+ def add_descendants(out, klass)
84
+ klass.descendants.each do |d|
85
+ out << d unless out.include?(d)
86
+ end
87
+ end
88
+
89
+ # Lifts cross-type dependencies for the resolved classes: their
90
+ # declared properties, value_lists those properties reference,
91
+ # units those properties use, and relations where the class is
92
+ # in domain or codomain. This makes the selected parcel
93
+ # self-sufficient when written.
94
+ def lift_cross_type(database, entities)
95
+ out = entities.dup
96
+ classes = entities.select { |e| e.is_a?(Opencdd::Klass) }
97
+ classes.each do |klass|
98
+ database.properties_of(klass).each { |p| out << p unless out.include?(p) }
99
+ database.relations_for(domain: klass.irdi).each { |r| out << r unless out.include?(r) }
100
+ database.relations_for(codomain: klass.irdi).each { |r| out << r unless out.include?(r) }
101
+ database.properties_of(klass).each do |prop|
102
+ next unless prop.is_a?(Opencdd::Property)
103
+ if prop.unit_irdi
104
+ unit = database.find(prop.unit_irdi)
105
+ out << unit if unit.is_a?(Opencdd::Unit) && !out.include?(unit)
106
+ end
107
+ vl = database.value_list_of(prop)
108
+ out << vl if vl && !out.include?(vl)
109
+ end
110
+ end
111
+ out
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Opencdd
4
+ module Parcel
5
+ # Reads the per-class sharded Parcel layout emitted by cdd.iec.ch
6
+ # downloads: a top-level directory containing one subdir per
7
+ # class code, each subdir holding 1-6 +export_*_<code>.xls+
8
+ # files (CLASS, PROPERTY, RELATION, UNIT, VALUELIST, VALUETERMS).
9
+ #
10
+ # Two on-disk layouts are supported:
11
+ #
12
+ # [flat (legacy)] +<CODE>/export_*.xls+ directly under the code dir.
13
+ # [per-version] +<CODE>/<UNID>/export_*.xls+ nested under the
14
+ # current version's UNID, with +<CODE>/_entity.json+ identifying
15
+ # which +<UNID>+ folder is the current version. Emitted by
16
+ # +harvest/download_versions.py+.
17
+ #
18
+ # The reader is a thin orchestrator over three deep modules:
19
+ #
20
+ # - +Opencdd::Parcel::LayoutDetector+ — single source of truth
21
+ # for "is this a class-code dir? where do the XLS live?"
22
+ # - +Opencdd::Parcel::EntityManifest+ — parses the
23
+ # +_entity.json+ sidecar and produces VersionHistory /
24
+ # stub entities.
25
+ # - +Opencdd::Parcel::FlatDirReader+ — actually reads the
26
+ # +.xls+ files into a Workbook.
27
+ #
28
+ # Example:
29
+ # reader = Opencdd::Parcel::ShardedDirReader.new("downloads/iec63213")
30
+ # db = Opencdd::Database.new
31
+ # reader.load_into(db)
32
+ #
33
+ class ShardedDirReader
34
+ # Detection patterns owned by Opencdd::Parcel::LayoutDetector
35
+ # (SSOT). Aliased here for back-compat with external callers
36
+ # that reference these constants by class.
37
+ CLASS_CODE_PATTERN = Opencdd::Parcel::LayoutDetector::CLASS_CODE_PATTERN
38
+ UNID_PATTERN = Opencdd::Parcel::LayoutDetector::UNID_PATTERN
39
+
40
+ attr_reader :path
41
+
42
+ def initialize(path)
43
+ @path = path
44
+ end
45
+
46
+ def load_into(database)
47
+ class_subdirs.each do |subdir|
48
+ active = active_xls_dir(subdir)
49
+ next unless active
50
+ workbook = Opencdd::Parcel::FlatDirReader.new(active).read_workbook
51
+ database.add_workbook(workbook)
52
+ end
53
+ attach_unloaded_entities(database)
54
+ attach_version_histories(database)
55
+ database.finalize!
56
+ database
57
+ end
58
+
59
+ # Walks each class subdir's +_entity.json+ and attaches its
60
+ # +versions+ array to the corresponding entity.
61
+ def attach_version_histories(database)
62
+ class_subdirs.each do |subdir|
63
+ manifest = Opencdd::Parcel::EntityManifest.read(subdir)
64
+ next unless manifest
65
+ next if manifest.versions_empty?
66
+ target = database.find_by_code(File.basename(subdir))
67
+ target&.attach_version_history(manifest.version_history)
68
+ end
69
+ end
70
+
71
+ # Back-compat: external callers may reach the per-class version
72
+ # history directly. New code should use EntityManifest.
73
+ def parse_version_history(code_dir)
74
+ manifest = Opencdd::Parcel::EntityManifest.read(code_dir)
75
+ manifest&.version_history || Opencdd::Entity::VersionHistory.new
76
+ end
77
+
78
+ # Scans +_entities/+ for manifests whose entity wasn't loaded
79
+ # by the XLS round (e.g. a property whose .xls export only had
80
+ # CLASS rows). Creates minimal stub entities so cross-references
81
+ # resolve.
82
+ def attach_unloaded_entities(database)
83
+ entities_root = File.join(@path, "_entities")
84
+ return unless File.directory?(entities_root)
85
+
86
+ Dir.children(entities_root).sort.each do |code|
87
+ entity_dir = File.join(entities_root, code)
88
+ next unless File.directory?(entity_dir)
89
+
90
+ manifest = Opencdd::Parcel::EntityManifest.read(entity_dir)
91
+ next unless manifest
92
+
93
+ irdi = manifest.irdi
94
+ next unless irdi
95
+ next if database.find(irdi)
96
+
97
+ stub = manifest.to_stub_entity
98
+ database.add_entity(stub) if stub
99
+ end
100
+ end
101
+
102
+ def read_workbook
103
+ sub_workbooks = class_subdirs.filter_map do |subdir|
104
+ active = active_xls_dir(subdir)
105
+ next nil unless active
106
+ Opencdd::Parcel::FlatDirReader.new(active).read_workbook
107
+ end
108
+
109
+ Opencdd::Parcel::Workbook.new(
110
+ sheets: sub_workbooks.flat_map(&:sheets),
111
+ sheetmap: sub_workbooks.flat_map(&:sheetmap),
112
+ project: sub_workbooks.first&.project,
113
+ source_path: @path.to_s,
114
+ )
115
+ end
116
+
117
+ def class_subdirs
118
+ return [] unless File.directory?(@path)
119
+ dirs = list_class_subdirs(@path)
120
+
121
+ # Also scan _entities/ for manifest-driven entity dirs.
122
+ entities_root = File.join(@path, "_entities")
123
+ dirs.concat(list_class_subdirs(entities_root)) if File.directory?(entities_root)
124
+
125
+ dirs
126
+ end
127
+
128
+ # Delegates to LayoutDetector — single source of truth for
129
+ # "where do the active XLS files live for this class-code dir?"
130
+ def active_xls_dir(code_dir)
131
+ Opencdd::Parcel::LayoutDetector.active_xls_dir(code_dir)
132
+ end
133
+
134
+ private
135
+
136
+ def list_class_subdirs(root)
137
+ Dir.children(root).sort
138
+ .map { |n| File.join(root, n) }
139
+ .select { |p| File.directory?(p) && Opencdd::Parcel::LayoutDetector.class_code?(File.basename(p)) }
140
+ end
141
+
142
+ def looks_like_class_code?(name)
143
+ Opencdd::Parcel::LayoutDetector.class_code?(name)
144
+ end
145
+
146
+ def legacy?(filename)
147
+ Opencdd::Parcel::LayoutDetector.legacy_export?(filename)
148
+ end
149
+ end
150
+ end
151
+ end