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,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