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,196 @@
|
|
|
1
|
+
# frozen_string: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Opencdd
|
|
6
|
+
module Model
|
|
7
|
+
# CDD-native YAML entity. Every attribute uses the semantic name
|
|
8
|
+
# from the CDD ontology (preferred_name, superclass, class_type)
|
|
9
|
+
# rather than the IEC 62656-1 wire-format key (MDC_P004, MDC_P010,
|
|
10
|
+
# MDC_P011). Multilingual fields are Hash<String, String>;
|
|
11
|
+
# collection fields are Array<String>.
|
|
12
|
+
#
|
|
13
|
+
# This is the canonical YAML shape for a CDD entity:
|
|
14
|
+
#
|
|
15
|
+
# ---
|
|
16
|
+
# irdi: 0112/2///61360_4#AAA001
|
|
17
|
+
# type: class
|
|
18
|
+
# code: AAA001
|
|
19
|
+
# preferred_name:
|
|
20
|
+
# en: Vehicle
|
|
21
|
+
# fr: Véhicule
|
|
22
|
+
# class_type: ITEM_CLASS
|
|
23
|
+
# superclass: UNIVERSE
|
|
24
|
+
# applicable_properties:
|
|
25
|
+
# - vehicle_length
|
|
26
|
+
# - vehicle_weight
|
|
27
|
+
#
|
|
28
|
+
# Conversion between YamlEntity and the existing Entity model
|
|
29
|
+
# is via #from_entity and #to_entity, which bridge the semantic
|
|
30
|
+
# attributes to the raw properties Hash.
|
|
31
|
+
class YamlEntity < Lutaml::Model::Serializable
|
|
32
|
+
# ── Identity ────────────────────────────────────────────
|
|
33
|
+
attribute :irdi, :string
|
|
34
|
+
attribute :type, :string # :class, :property, :unit, ...
|
|
35
|
+
attribute :code, :string
|
|
36
|
+
attribute :guid, :string
|
|
37
|
+
|
|
38
|
+
# ── Multilingual text (Hash<lang, text>) ───────────────
|
|
39
|
+
attribute :preferred_name, :hash
|
|
40
|
+
attribute :short_name, :hash
|
|
41
|
+
attribute :definition, :hash
|
|
42
|
+
attribute :note, :hash
|
|
43
|
+
attribute :remark, :hash
|
|
44
|
+
|
|
45
|
+
# ── Version ─────────────────────────────────────────────
|
|
46
|
+
attribute :version, :string
|
|
47
|
+
attribute :revision, :string
|
|
48
|
+
|
|
49
|
+
# ── Class-specific ──────────────────────────────────────
|
|
50
|
+
attribute :class_type, :string # ITEM_CLASS, CATEGORICAL_CLASS
|
|
51
|
+
attribute :superclass, :string # IRDI or symbolic name
|
|
52
|
+
attribute :is_case_of, :string, collection: true
|
|
53
|
+
attribute :applicable_properties, :string, collection: true
|
|
54
|
+
attribute :imported_properties, :string, collection: true
|
|
55
|
+
attribute :sub_class_selection, :string, collection: true
|
|
56
|
+
|
|
57
|
+
# ── Property-specific ───────────────────────────────────
|
|
58
|
+
attribute :data_type, :string # REAL_TYPE, CLASS_REFERENCE(X)
|
|
59
|
+
attribute :value_format, :string
|
|
60
|
+
attribute :definition_class, :string # IRDI or symbolic name
|
|
61
|
+
attribute :unit, :string
|
|
62
|
+
attribute :condition, :string
|
|
63
|
+
attribute :property_data_element_type, :string
|
|
64
|
+
|
|
65
|
+
# ── Unit-specific ───────────────────────────────────────
|
|
66
|
+
attribute :unit_structure, :string
|
|
67
|
+
attribute :unit_text, :string
|
|
68
|
+
|
|
69
|
+
# ── Value-list-specific ─────────────────────────────────
|
|
70
|
+
attribute :enumerated_values, :string, collection: true
|
|
71
|
+
attribute :list_type, :string
|
|
72
|
+
|
|
73
|
+
# ── Relation-specific ───────────────────────────────────
|
|
74
|
+
attribute :relation_type, :string
|
|
75
|
+
attribute :domain, :string, collection: true
|
|
76
|
+
attribute :codomain, :string
|
|
77
|
+
|
|
78
|
+
# ── Catch-all for unknown properties (lossless) ────────
|
|
79
|
+
attribute :extra, :hash
|
|
80
|
+
|
|
81
|
+
# ── Conversion: Entity → YamlEntity ────────────────────
|
|
82
|
+
def self.from_entity(entity)
|
|
83
|
+
attrs = {
|
|
84
|
+
irdi: entity.irdi&.to_s,
|
|
85
|
+
type: entity.type&.to_s,
|
|
86
|
+
code: entity.code,
|
|
87
|
+
guid: entity[Opencdd::PropertyIds::MDC_P066],
|
|
88
|
+
version: entity[Opencdd::PropertyIds::MDC_P002_1],
|
|
89
|
+
revision: entity[Opencdd::PropertyIds::MDC_P002_2],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Multilingual fields
|
|
93
|
+
attrs[:preferred_name] = extract_ml(entity, Opencdd::PropertyIds::MDC_P004)
|
|
94
|
+
attrs[:short_name] = extract_ml(entity, Opencdd::PropertyIds::MDC_P005)
|
|
95
|
+
attrs[:definition] = extract_ml(entity, Opencdd::PropertyIds::MDC_P006)
|
|
96
|
+
attrs[:note] = extract_ml(entity, Opencdd::PropertyIds::MDC_P008)
|
|
97
|
+
attrs[:remark] = extract_ml(entity, Opencdd::PropertyIds::MDC_P009)
|
|
98
|
+
|
|
99
|
+
# Class-specific
|
|
100
|
+
if entity.type == :class
|
|
101
|
+
ct = entity.read_field(:class_type)
|
|
102
|
+
attrs[:class_type] = ct&.to_s
|
|
103
|
+
attrs[:superclass] = entity.read_field(:superclass_irdi)&.to_s
|
|
104
|
+
attrs[:is_case_of] = entity.read_field(:is_case_of_irdis)&.map(&:to_s) || []
|
|
105
|
+
attrs[:applicable_properties] = entity.read_field(:applicable_property_irdis)&.map(&:to_s) || []
|
|
106
|
+
attrs[:imported_properties] = entity.read_field(:imported_property_irdis)&.map(&:to_s) || []
|
|
107
|
+
attrs[:sub_class_selection] = entity.read_field(:sub_class_selection_irdis)&.map(&:to_s) || []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Property-specific
|
|
111
|
+
if entity.type == :property
|
|
112
|
+
dt = entity.read_field(:parsed_data_type)
|
|
113
|
+
attrs[:data_type] = dt&.to_s
|
|
114
|
+
attrs[:value_format] = entity.read_field(:parsed_value_format)&.to_s
|
|
115
|
+
dc = entity.read_field(:definition_class_irdi)
|
|
116
|
+
attrs[:definition_class] = dc&.to_s
|
|
117
|
+
ui = entity.read_field(:unit_irdi)
|
|
118
|
+
attrs[:unit] = ui&.to_s
|
|
119
|
+
attrs[:condition] = entity.read_field(:condition)&.to_s
|
|
120
|
+
attrs[:property_data_element_type] = entity.read_field(:property_data_element_type)&.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
new(**attrs.compact)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ── Conversion: YamlEntity → Entity ────────────────────
|
|
127
|
+
def to_entity(database = nil)
|
|
128
|
+
props = {}
|
|
129
|
+
|
|
130
|
+
props[Opencdd::PropertyIds::MDC_P066] = guid if guid
|
|
131
|
+
props[Opencdd::PropertyIds::MDC_P002_1] = version if version
|
|
132
|
+
props[Opencdd::PropertyIds::MDC_P002_2] = revision if revision
|
|
133
|
+
|
|
134
|
+
# Multilingual
|
|
135
|
+
merge_ml_into(props, Opencdd::PropertyIds::MDC_P004, preferred_name)
|
|
136
|
+
merge_ml_into(props, Opencdd::PropertyIds::MDC_P005, short_name)
|
|
137
|
+
merge_ml_into(props, Opencdd::PropertyIds::MDC_P006, definition)
|
|
138
|
+
merge_ml_into(props, Opencdd::PropertyIds::MDC_P008, note)
|
|
139
|
+
merge_ml_into(props, Opencdd::PropertyIds::MDC_P009, remark)
|
|
140
|
+
|
|
141
|
+
# Class-specific
|
|
142
|
+
props[Opencdd::PropertyIds::MDC_P011] = class_type if class_type
|
|
143
|
+
props[Opencdd::PropertyIds::MDC_P010] = superclass if superclass
|
|
144
|
+
props[Opencdd::PropertyIds::MDC_P013] = "{#{is_case_of.join(",")}}" if is_case_of&.any?
|
|
145
|
+
props[Opencdd::PropertyIds::MDC_P014] = "{#{applicable_properties.join(",")}}" if applicable_properties&.any?
|
|
146
|
+
props[Opencdd::PropertyIds::MDC_P090] = "{#{imported_properties.join(",")}}" if imported_properties&.any?
|
|
147
|
+
props[Opencdd::PropertyIds::MDC_P016] = "{#{sub_class_selection.join(",")}}" if sub_class_selection&.any?
|
|
148
|
+
|
|
149
|
+
# Property-specific
|
|
150
|
+
props[Opencdd::PropertyIds::MDC_P022] = data_type if data_type
|
|
151
|
+
props[Opencdd::PropertyIds::MDC_P024] = value_format if value_format
|
|
152
|
+
props[Opencdd::PropertyIds::MDC_P021] = definition_class if definition_class
|
|
153
|
+
props[Opencdd::PropertyIds::MDC_P041] = unit if unit
|
|
154
|
+
props[Opencdd::PropertyIds::MDC_P028] = condition if condition
|
|
155
|
+
props[Opencdd::PropertyIds::MDC_P020] = property_data_element_type if property_data_element_type
|
|
156
|
+
|
|
157
|
+
# Determine entity class from type
|
|
158
|
+
entity_class = Opencdd::MetaClasses.entity_class_for_type(type&.to_sym) || Opencdd::Klass
|
|
159
|
+
meta_code = Opencdd::MetaClasses.meta_class_for_type(type&.to_sym) || "MDC_C002"
|
|
160
|
+
|
|
161
|
+
parsed_irdi = irdi ? Opencdd::IRDI.parse(irdi) : nil
|
|
162
|
+
parsed_meta = Opencdd::IRDI.parse(meta_code)
|
|
163
|
+
|
|
164
|
+
entity_class.new(
|
|
165
|
+
irdi: parsed_irdi,
|
|
166
|
+
properties: props,
|
|
167
|
+
meta_class_irdi: parsed_meta,
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ── Helpers ─────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def self.extract_ml(entity, pid)
|
|
174
|
+
result = {}
|
|
175
|
+
entity.properties.each do |key, val|
|
|
176
|
+
next unless key.to_s.start_with?("#{pid}.")
|
|
177
|
+
lang = key.to_s.split(".", 2).last
|
|
178
|
+
result[lang] = val if val
|
|
179
|
+
end
|
|
180
|
+
result.empty? ? nil : result
|
|
181
|
+
end
|
|
182
|
+
private_class_method :extract_ml
|
|
183
|
+
|
|
184
|
+
def self.merge_ml(props, pid, hash)
|
|
185
|
+
return unless hash&.any?
|
|
186
|
+
hash.each { |lang, val| props["#{pid}.#{lang}"] = val }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def merge_ml_into(props, pid, hash)
|
|
192
|
+
self.class.merge_ml(props, pid, hash)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
# CDD-native model layer backed by Lutaml::Model. Provides typed
|
|
5
|
+
# attributes with semantic CDD names (preferred_name, superclass,
|
|
6
|
+
# class_type) — not wire-format keys (MDC_P004, MDC_P010). YAML
|
|
7
|
+
# is the canonical persistence format per user decision (2026-07-13).
|
|
8
|
+
module Model
|
|
9
|
+
autoload :YamlEntity, "opencdd/model/yaml_entity"
|
|
10
|
+
autoload :YamlDatabase, "opencdd/model/yaml_database"
|
|
11
|
+
autoload :EntityStore, "opencdd/model/entity_store"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Opencdd
|
|
6
|
+
module Parcel
|
|
7
|
+
module CsvReader
|
|
8
|
+
DEFAULT_ENCODING = "UTF-8".freeze
|
|
9
|
+
|
|
10
|
+
def self.read(path, meta_class_irdi:, name: nil, col_sep: ",", encoding: DEFAULT_ENCODING)
|
|
11
|
+
rows = ::CSV.read(path, col_sep: col_sep, encoding: encoding)
|
|
12
|
+
rows = rows.reject { |r| r.nil? || r.all? { |c| c.nil? || c.to_s.strip.empty? } }
|
|
13
|
+
meta_code = extract_meta_code(meta_class_irdi)
|
|
14
|
+
scaffold = Opencdd::Parcel::Sheet.scaffold(
|
|
15
|
+
meta_class_irdi: meta_code,
|
|
16
|
+
parcel_id: "IMPORT",
|
|
17
|
+
)
|
|
18
|
+
raw_rows = rows.map { |r| [nil, *r] }
|
|
19
|
+
Opencdd::Parcel::Sheet.new(
|
|
20
|
+
name: name || File.basename(path.to_s, ".*"),
|
|
21
|
+
metadata: scaffold.metadata,
|
|
22
|
+
schema: scaffold.schema,
|
|
23
|
+
raw_rows: raw_rows,
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.extract_meta_code(value)
|
|
28
|
+
return value if value.nil?
|
|
29
|
+
s = value.to_s
|
|
30
|
+
s.include?("#") ? s.split("#").last : s
|
|
31
|
+
end
|
|
32
|
+
private_class_method :extract_meta_code
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Opencdd
|
|
7
|
+
module Parcel
|
|
8
|
+
module CsvWriter
|
|
9
|
+
DEFAULT_ENCODING = "UTF-8".freeze
|
|
10
|
+
|
|
11
|
+
def self.write_sheet(sheet, entities, target, source_language: SheetEmitter::DEFAULT_SOURCE_LANGUAGE, encoding: DEFAULT_ENCODING, write_bom: false)
|
|
12
|
+
emitter = SheetEmitter.new(source_language: source_language)
|
|
13
|
+
rows = emitter.emit_data_rows(sheet, entities)
|
|
14
|
+
data_rows = rows.map { |row| row.drop(1) }
|
|
15
|
+
write_csv(target, data_rows, encoding: encoding, write_bom: write_bom)
|
|
16
|
+
target
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.write_workbook(database, dir, parcel_id:, source_language: SheetEmitter::DEFAULT_SOURCE_LANGUAGE, encoding: DEFAULT_ENCODING, write_bom: false)
|
|
20
|
+
require "fileutils"
|
|
21
|
+
FileUtils.mkdir_p(dir)
|
|
22
|
+
|
|
23
|
+
built_sheets = build_sheets(database, parcel_id, source_language)
|
|
24
|
+
paths = []
|
|
25
|
+
built_sheets.each do |built|
|
|
26
|
+
filename = "#{parcel_id}_#{type_label(built.type)}.csv"
|
|
27
|
+
path = File.join(dir, filename)
|
|
28
|
+
write_sheet(built.sheet, built.entities, path,
|
|
29
|
+
source_language: source_language, encoding: encoding, write_bom: write_bom)
|
|
30
|
+
paths << path
|
|
31
|
+
end
|
|
32
|
+
paths
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class BuiltSheet < Struct.new(:sheet, :entities, :type, keyword_init: true); end
|
|
36
|
+
|
|
37
|
+
def self.build_sheets(database, parcel_id, source_language)
|
|
38
|
+
if database.workbooks.any?
|
|
39
|
+
source_workbook = database.workbooks.first
|
|
40
|
+
sheets_by_type = source_workbook.sheets.select { |s| s.type }.group_by(&:type)
|
|
41
|
+
sheets_by_type.map do |type, sheets|
|
|
42
|
+
primary = sheets.first
|
|
43
|
+
entities = database.entities_of_type(primary.type)
|
|
44
|
+
BuiltSheet.new(sheet: primary, entities: entities, type: type)
|
|
45
|
+
end
|
|
46
|
+
else
|
|
47
|
+
types = %i[class property value_list value_term unit relation view_control]
|
|
48
|
+
types.filter_map do |type|
|
|
49
|
+
meta_code = Opencdd::MetaClasses.meta_class_for_type(type)
|
|
50
|
+
next nil unless meta_code
|
|
51
|
+
sheet = Opencdd::Parcel::Sheet.scaffold(
|
|
52
|
+
meta_class_irdi: meta_code,
|
|
53
|
+
parcel_id: parcel_id,
|
|
54
|
+
source_language: source_language,
|
|
55
|
+
)
|
|
56
|
+
entities = database.entities_of_type(type)
|
|
57
|
+
BuiltSheet.new(sheet: sheet, entities: entities, type: type)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_class_method :build_sheets
|
|
62
|
+
|
|
63
|
+
def self.type_label(type)
|
|
64
|
+
{
|
|
65
|
+
class: "CLASS",
|
|
66
|
+
property: "PROPERTY",
|
|
67
|
+
value_list: "ENUM",
|
|
68
|
+
value_term: "TERMINOLOGY",
|
|
69
|
+
unit: "UoM",
|
|
70
|
+
relation: "RELATION",
|
|
71
|
+
view_control: "VIEWCONTROL",
|
|
72
|
+
}.fetch(type, type.to_s.upcase)
|
|
73
|
+
end
|
|
74
|
+
private_class_method :type_label
|
|
75
|
+
|
|
76
|
+
def self.write_csv(target, rows, encoding:, write_bom:)
|
|
77
|
+
case target
|
|
78
|
+
when String then write_csv_to_path(target, rows, encoding, write_bom)
|
|
79
|
+
when Pathname then write_csv_to_path(target.to_s, rows, encoding, write_bom)
|
|
80
|
+
when IO, StringIO then write_csv_to_io(target, rows, encoding, write_bom)
|
|
81
|
+
else
|
|
82
|
+
raise ArgumentError, "unsupported CSV target: #{target.inspect} (expected String, Pathname, IO, or StringIO)"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
private_class_method :write_csv
|
|
86
|
+
|
|
87
|
+
def self.write_csv_to_path(path, rows, encoding, write_bom)
|
|
88
|
+
File.open(path, "w:#{encoding}") do |f|
|
|
89
|
+
write_bom_prefix(f, encoding) if write_bom
|
|
90
|
+
write_csv_to_io(f, rows, encoding, write_bom)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
private_class_method :write_csv_to_path
|
|
94
|
+
|
|
95
|
+
def self.write_csv_to_io(io, rows, _encoding, _write_bom)
|
|
96
|
+
rows.each do |row|
|
|
97
|
+
line = CSV.generate_line(row || [])
|
|
98
|
+
io.write(line)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
private_class_method :write_csv_to_io
|
|
102
|
+
|
|
103
|
+
def self.write_bom_prefix(io, encoding)
|
|
104
|
+
bom = case encoding.upcase
|
|
105
|
+
when "UTF-8", "UTF8" then "\xEF\xBB\xBF"
|
|
106
|
+
when "UTF-16LE" then "\xFF\xFE"
|
|
107
|
+
when "UTF-16BE" then "\xFE\xFF"
|
|
108
|
+
end
|
|
109
|
+
io.write(bom) if bom
|
|
110
|
+
end
|
|
111
|
+
private_class_method :write_bom_prefix
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Opencdd
|
|
6
|
+
module Parcel
|
|
7
|
+
# Reads the harvester's <code>_entity.json</code> sidecar format
|
|
8
|
+
# and converts it into Opencdd::Entity::VersionHistory entries
|
|
9
|
+
# and stub entities for cross-reference resolution.
|
|
10
|
+
#
|
|
11
|
+
# Extracted from ShardedDirReader so the JSON-shape concern has
|
|
12
|
+
# its own home and can be tested with a hash fixture instead of
|
|
13
|
+
# requiring a full directory tree on disk.
|
|
14
|
+
#
|
|
15
|
+
# The sidecar schema (produced by harvest/download_versions.py):
|
|
16
|
+
#
|
|
17
|
+
# {
|
|
18
|
+
# "irdi": "0112/2///62656_1#AAA001",
|
|
19
|
+
# "entity_type": "class", # Symbol
|
|
20
|
+
# "current_version_dir": "ABC123...", # UNID subfolder name
|
|
21
|
+
# "versions": [
|
|
22
|
+
# { "version": 1, "revision": 0, "status": "Released",
|
|
23
|
+
# "timestamp": "2024-01-15T10:23:00Z", "user": "...",
|
|
24
|
+
# "change_request_id": "...", "unid": "...",
|
|
25
|
+
# "is_current": true }
|
|
26
|
+
# ]
|
|
27
|
+
# }
|
|
28
|
+
class EntityManifest
|
|
29
|
+
attr_reader :data, :path
|
|
30
|
+
|
|
31
|
+
# Returns a manifest for +entity_dir+, or +nil+ when no
|
|
32
|
+
# +_entity.json+ lives there or the file is unparseable.
|
|
33
|
+
def self.read(entity_dir)
|
|
34
|
+
path = File.join(entity_dir, "_entity.json")
|
|
35
|
+
return nil unless File.file?(path)
|
|
36
|
+
data = JSON.parse(File.read(path))
|
|
37
|
+
new(data, path)
|
|
38
|
+
rescue JSON::ParserError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(data, path = nil)
|
|
43
|
+
@data = data
|
|
44
|
+
@path = path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# IRDI of the entity this manifest describes, or +nil+.
|
|
48
|
+
def irdi
|
|
49
|
+
raw = @data["irdi"]
|
|
50
|
+
return nil if raw.nil?
|
|
51
|
+
Opencdd::IRDI.parse(raw.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Symbol type (:class, :property, ...) or +nil+.
|
|
55
|
+
def entity_type
|
|
56
|
+
@data["entity_type"]&.to_sym
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Meta-class IRDI code (e.g. "MDC_C002"), or +nil+ if the
|
|
60
|
+
# type isn't registered.
|
|
61
|
+
def meta_class_code
|
|
62
|
+
Opencdd::MetaClasses.meta_class_for_type(entity_type)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Name of the per-version subfolder that holds the current
|
|
66
|
+
# version's XLS exports. +nil+ for flat-layout manifests.
|
|
67
|
+
def current_version_dir
|
|
68
|
+
@data["current_version_dir"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Version history compiled from the +versions+ array.
|
|
72
|
+
# Empty when no versions are listed.
|
|
73
|
+
def version_history
|
|
74
|
+
vh = Opencdd::Entity::VersionHistory.new
|
|
75
|
+
Array(@data["versions"]).each do |v|
|
|
76
|
+
vh.attach(build_entry(v))
|
|
77
|
+
end
|
|
78
|
+
vh
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def versions_empty?
|
|
82
|
+
Array(@data["versions"]).empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Constructs a minimal stub entity from this manifest, so
|
|
86
|
+
# cross-references to entities that weren't loaded from a
|
|
87
|
+
# +.xls+ still resolve. Returns +nil+ if the manifest lacks
|
|
88
|
+
# an IRDI, type, or known meta-class.
|
|
89
|
+
def to_stub_entity
|
|
90
|
+
return nil unless irdi && meta_class_code
|
|
91
|
+
meta_class = Opencdd::MetaClasses.for(meta_class_code)
|
|
92
|
+
return nil unless meta_class&.entity_class
|
|
93
|
+
|
|
94
|
+
code_property_id = Opencdd::MetaClasses.code_property_id_for(meta_class_code)
|
|
95
|
+
props = code_property_id ? { code_property_id => @data["irdi"].to_s } : {}
|
|
96
|
+
meta_class.entity_class.new(
|
|
97
|
+
irdi: irdi,
|
|
98
|
+
properties: props,
|
|
99
|
+
meta_class_irdi: Opencdd::IRDI.parse(meta_class_code),
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def build_entry(raw)
|
|
106
|
+
Opencdd::Entity::VersionHistory::Entry.new(
|
|
107
|
+
version: raw["version"],
|
|
108
|
+
revision: raw["revision"],
|
|
109
|
+
status: raw["status"],
|
|
110
|
+
timestamp: raw["timestamp"],
|
|
111
|
+
user: raw["user"],
|
|
112
|
+
change_request_id: raw["change_request_id"],
|
|
113
|
+
unid: raw["unid"],
|
|
114
|
+
is_current: raw["is_current"],
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Opencdd
|
|
4
|
+
module Parcel
|
|
5
|
+
# Reads the legacy "flat directory" Parcel layout: one directory
|
|
6
|
+
# containing 1-6 +export_(CLASS|PROPERTY|RELATION|UNIT|VALUELIST|
|
|
7
|
+
# VALUETERMS)_*.xls+ files at the top level. This is the format
|
|
8
|
+
# cdd.iec.ch's "EXCEL format" export produces when downloaded
|
|
9
|
+
# per-type without sharding by class.
|
|
10
|
+
#
|
|
11
|
+
# For per-class sharded subdirs see +Opencdd::Parcel::ShardedDirReader+.
|
|
12
|
+
# For single .xlsx workbooks see +Opencdd::Parcel::WorkbookReader+.
|
|
13
|
+
class FlatDirReader
|
|
14
|
+
# FILE_PATTERN moved to Opencdd::Parcel::LayoutDetector (SSOT).
|
|
15
|
+
# Kept here as an alias for back-compat with callers that
|
|
16
|
+
# reference FlatDirReader::FILE_PATTERN directly.
|
|
17
|
+
FILE_PATTERN = Opencdd::Parcel::LayoutDetector::FILE_PATTERN
|
|
18
|
+
|
|
19
|
+
TYPE_BY_PREFIX = {
|
|
20
|
+
"CLASS" => :class,
|
|
21
|
+
"PROPERTY" => :property,
|
|
22
|
+
"RELATION" => :relation,
|
|
23
|
+
"UNIT" => :unit,
|
|
24
|
+
"VALUELIST" => :value_list,
|
|
25
|
+
"VALUETERMS" => :value_term,
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :path
|
|
29
|
+
|
|
30
|
+
def initialize(path)
|
|
31
|
+
@path = path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read_workbook
|
|
35
|
+
files = legacy_files
|
|
36
|
+
raise "No legacy export_*.xls files found in #{@path.inspect}" if files.empty?
|
|
37
|
+
|
|
38
|
+
sheets = files.map { |f| read_one(f) }.compact
|
|
39
|
+
sheetmap = files.map { |f| sheetmap_entry_for(f) }.compact
|
|
40
|
+
|
|
41
|
+
parcel_id = files.first && File.basename(files.first).split("_").last&.sub(/\.\w+\z/, "")
|
|
42
|
+
project = Opencdd::Parcel::Workbook::ProjectInfo.new(
|
|
43
|
+
project_id: "LOCAL",
|
|
44
|
+
parcel_id: parcel_id,
|
|
45
|
+
multi_language: "",
|
|
46
|
+
base_language: "en",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
Opencdd::Parcel::Workbook.new(
|
|
50
|
+
sheets: sheets,
|
|
51
|
+
sheetmap: sheetmap,
|
|
52
|
+
project: project,
|
|
53
|
+
source_path: @path.to_s,
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_into(database)
|
|
58
|
+
workbook = read_workbook
|
|
59
|
+
database.add_workbook(workbook)
|
|
60
|
+
database.finalize!
|
|
61
|
+
database
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def legacy_files
|
|
67
|
+
if File.directory?(@path)
|
|
68
|
+
Dir.children(@path).sort.map { |f| File.join(@path, f) }
|
|
69
|
+
.select { |f| File.file?(f) && File.basename(f) =~ FILE_PATTERN }
|
|
70
|
+
elsif File.file?(@path) && File.basename(@path) =~ FILE_PATTERN
|
|
71
|
+
[@path.to_s]
|
|
72
|
+
else
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_one(file)
|
|
78
|
+
prefix = File.basename(file).sub(/\Aexport_/, "").split("_", 2).first
|
|
79
|
+
type = TYPE_BY_PREFIX[prefix]
|
|
80
|
+
warn "Unknown legacy file prefix: #{file}" unless type
|
|
81
|
+
return nil unless type
|
|
82
|
+
|
|
83
|
+
sheet_name = File.basename(file, ".*")
|
|
84
|
+
rows = read_rows(file)
|
|
85
|
+
Opencdd::Parcel::Sheet.from_rows(rows.each, name: sheet_name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_rows(file)
|
|
89
|
+
ext = File.extname(file).downcase
|
|
90
|
+
if ext == ".xls"
|
|
91
|
+
read_xls_rows(file)
|
|
92
|
+
else
|
|
93
|
+
read_xlsx_rows(file)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_xls_rows(file)
|
|
98
|
+
require "spreadsheet"
|
|
99
|
+
book = Spreadsheet.open(file)
|
|
100
|
+
sheet = book.worksheet(0) || book.worksheets.first
|
|
101
|
+
sheet.rows.map do |row|
|
|
102
|
+
row.to_a
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def read_xlsx_rows(file)
|
|
107
|
+
require "roo"
|
|
108
|
+
roo = Roo::Spreadsheet.open(file, extension: :xlsx)
|
|
109
|
+
target = roo.sheets.first || "Export"
|
|
110
|
+
sheet = roo.sheet(target)
|
|
111
|
+
(1..sheet.last_row.to_i).map { |i| sheet.row(i) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def sheetmap_entry_for(file)
|
|
115
|
+
prefix = File.basename(file).sub(/\Aexport_/, "").split("_", 2).first
|
|
116
|
+
type = TYPE_BY_PREFIX[prefix]
|
|
117
|
+
return nil unless type
|
|
118
|
+
meta_class_code = Opencdd::Parcel::TYPE_TO_META_CLASS[type]
|
|
119
|
+
return nil unless meta_class_code
|
|
120
|
+
|
|
121
|
+
Opencdd::Parcel::Workbook::SheetMapEntry.new(
|
|
122
|
+
project_id: "LOCAL",
|
|
123
|
+
parcel_id: nil,
|
|
124
|
+
class_irdi: Opencdd::IRDI.parse("0112/2///62656_1##{meta_class_code}"),
|
|
125
|
+
content_no: 0,
|
|
126
|
+
sheet_no: nil,
|
|
127
|
+
sheet_name: File.basename(file, ".*"),
|
|
128
|
+
type: prefix,
|
|
129
|
+
target: "",
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|