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.
- checksums.yaml +7 -0
- data/CLAUDE.md +486 -0
- data/README.md +304 -0
- data/bin/lint-no-raw-mdc +41 -0
- data/lib/cdd.rb +11 -0
- data/lib/opencdd/alias_table.rb +52 -0
- data/lib/opencdd/cddal/ast.rb +151 -0
- data/lib/opencdd/cddal/builder.rb +374 -0
- data/lib/opencdd/cddal/fetcher/in_memory.rb +32 -0
- data/lib/opencdd/cddal/fetcher/net_http.rb +84 -0
- data/lib/opencdd/cddal/fetcher.rb +18 -0
- data/lib/opencdd/cddal/generated_parser.rb +805 -0
- data/lib/opencdd/cddal/lexer.rb +193 -0
- data/lib/opencdd/cddal/parser.rb +19 -0
- data/lib/opencdd/cddal/resolver.rb +100 -0
- data/lib/opencdd/cddal/serializer.rb +210 -0
- data/lib/opencdd/cddal/value_serializer.rb +60 -0
- data/lib/opencdd/cddal.rb +63 -0
- data/lib/opencdd/class_tree.rb +80 -0
- data/lib/opencdd/class_type.rb +33 -0
- data/lib/opencdd/codegen/ts.rb +185 -0
- data/lib/opencdd/codegen.rb +7 -0
- data/lib/opencdd/composition_tree.rb +119 -0
- data/lib/opencdd/condition.rb +120 -0
- data/lib/opencdd/data_type.rb +143 -0
- data/lib/opencdd/database.rb +719 -0
- data/lib/opencdd/effective_properties.rb +119 -0
- data/lib/opencdd/entity/field_reader.rb +141 -0
- data/lib/opencdd/entity/field_registry.rb +99 -0
- data/lib/opencdd/entity/version_history.rb +62 -0
- data/lib/opencdd/entity.rb +255 -0
- data/lib/opencdd/exporters/json.rb +235 -0
- data/lib/opencdd/exporters/mermaid.rb +62 -0
- data/lib/opencdd/exporters/yaml.rb +16 -0
- data/lib/opencdd/exporters.rb +9 -0
- data/lib/opencdd/guid.rb +27 -0
- data/lib/opencdd/instance_rule.rb +70 -0
- data/lib/opencdd/irdi.rb +128 -0
- data/lib/opencdd/klass.rb +230 -0
- data/lib/opencdd/languages.rb +70 -0
- data/lib/opencdd/meta_class.rb +274 -0
- data/lib/opencdd/model/entity_store.rb +85 -0
- data/lib/opencdd/model/yaml_database.rb +49 -0
- data/lib/opencdd/model/yaml_entity.rb +196 -0
- data/lib/opencdd/model.rb +13 -0
- data/lib/opencdd/parcel/csv_reader.rb +35 -0
- data/lib/opencdd/parcel/csv_writer.rb +114 -0
- data/lib/opencdd/parcel/entity_manifest.rb +119 -0
- data/lib/opencdd/parcel/flat_dir_reader.rb +134 -0
- data/lib/opencdd/parcel/layout_detector.rb +115 -0
- data/lib/opencdd/parcel/metadata.rb +112 -0
- data/lib/opencdd/parcel/referenced_irdis.rb +91 -0
- data/lib/opencdd/parcel/scrape_verifier.rb +122 -0
- data/lib/opencdd/parcel/selector.rb +115 -0
- data/lib/opencdd/parcel/sharded_dir_reader.rb +151 -0
- data/lib/opencdd/parcel/sheet.rb +287 -0
- data/lib/opencdd/parcel/sheet_emitter.rb +171 -0
- data/lib/opencdd/parcel/sheet_schema.rb +253 -0
- data/lib/opencdd/parcel/workbook.rb +172 -0
- data/lib/opencdd/parcel/workbook_reader.rb +200 -0
- data/lib/opencdd/parcel/writer.rb +185 -0
- data/lib/opencdd/parcel.rb +175 -0
- data/lib/opencdd/parse_helpers.rb +73 -0
- data/lib/opencdd/property.rb +120 -0
- data/lib/opencdd/property_data_element_type.rb +44 -0
- data/lib/opencdd/property_ids.rb +202 -0
- data/lib/opencdd/reader.rb +103 -0
- data/lib/opencdd/relation.rb +88 -0
- data/lib/opencdd/relation_tree.rb +74 -0
- data/lib/opencdd/relation_type.rb +58 -0
- data/lib/opencdd/structured_values.rb +183 -0
- data/lib/opencdd/unit.rb +16 -0
- data/lib/opencdd/validator/class_reference_rule.rb +77 -0
- data/lib/opencdd/validator/condition_rule.rb +27 -0
- data/lib/opencdd/validator/data_type_rule.rb +26 -0
- data/lib/opencdd/validator/enum_rule.rb +27 -0
- data/lib/opencdd/validator/format_rule.rb +54 -0
- data/lib/opencdd/validator/hierarchy_rule.rb +65 -0
- data/lib/opencdd/validator/irdi_rule.rb +57 -0
- data/lib/opencdd/validator/mandatory_rule.rb +25 -0
- data/lib/opencdd/validator/pattern_rule.rb +26 -0
- data/lib/opencdd/validator/reference_rule.rb +36 -0
- data/lib/opencdd/validator/rule.rb +65 -0
- data/lib/opencdd/validator/runner.rb +101 -0
- data/lib/opencdd/validator/set_rule.rb +41 -0
- data/lib/opencdd/validator/synonym_rule.rb +30 -0
- data/lib/opencdd/validator/type_rule.rb +102 -0
- data/lib/opencdd/validator/uniqueness_rule.rb +32 -0
- data/lib/opencdd/validator.rb +68 -0
- data/lib/opencdd/value_format.rb +71 -0
- data/lib/opencdd/value_list.rb +42 -0
- data/lib/opencdd/value_term.rb +24 -0
- data/lib/opencdd/version.rb +5 -0
- data/lib/opencdd/view_control.rb +10 -0
- data/lib/opencdd/visitor.rb +93 -0
- data/lib/opencdd.rb +57 -0
- 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
|