scjson 0.3.3
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/bin/scjson +9 -0
- data/lib/scjson/cli.rb +219 -0
- data/lib/scjson/version.rb +11 -0
- data/lib/scjson.rb +477 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e5458d979377d6bb8afc2d6ba6290f228d0b85602ab0c00885fd5f3a0c8f7055
|
|
4
|
+
data.tar.gz: 0a5889b05002e6944b82ae94814aaefa33d008519164486d7d4db5a03f4a8ed3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 762b5feb6ce8d91f42b99fe7358528a02e1c8a462bc41cfcdf6721e17cb8a12be73f1d0ad60185005a312da607fbf49699ec5f272cf3b94cd9a631930609f945
|
|
7
|
+
data.tar.gz: 7e936f6fb3bf500d10e4618d2e19f62af93d124eec40642d162e6c2c939793718f561d6e329bae5c18bc363cf2d1db8f9c9f8bdc2d36e01668a16bf635eedd17
|
data/bin/scjson
ADDED
data/lib/scjson/cli.rb
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Agent Name: ruby-cli
|
|
4
|
+
#
|
|
5
|
+
# Part of the scjson project.
|
|
6
|
+
# Developed by Softoboros Technology Inc.
|
|
7
|
+
# Licensed under the BSD 1-Clause License.
|
|
8
|
+
|
|
9
|
+
require 'pathname'
|
|
10
|
+
require 'optparse'
|
|
11
|
+
require 'fileutils'
|
|
12
|
+
require_relative '../scjson'
|
|
13
|
+
|
|
14
|
+
module Scjson
|
|
15
|
+
##
|
|
16
|
+
# Display the CLI program header.
|
|
17
|
+
#
|
|
18
|
+
# @return [void]
|
|
19
|
+
def self.splash
|
|
20
|
+
puts "scjson #{VERSION} - SCXML <-> scjson converter"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# Command line interface for scjson conversions.
|
|
25
|
+
#
|
|
26
|
+
# @param [Array<String>] argv Command line arguments provided by the user.
|
|
27
|
+
# @return [void]
|
|
28
|
+
def self.main(argv = ARGV)
|
|
29
|
+
options = { recursive: false, verify: false, keep_empty: false }
|
|
30
|
+
cmd = argv.shift
|
|
31
|
+
if cmd.nil? || %w[-h --help].include?(cmd)
|
|
32
|
+
puts(help_text)
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
parser = OptionParser.new do |opts|
|
|
36
|
+
opts.banner = ''
|
|
37
|
+
opts.on('-o', '--output PATH', 'output file or directory') { |v| options[:output] = v }
|
|
38
|
+
opts.on('-r', '--recursive', 'recurse into directories') { options[:recursive] = true }
|
|
39
|
+
opts.on('-v', '--verify', 'verify conversion without writing output') { options[:verify] = true }
|
|
40
|
+
opts.on('--keep-empty', 'keep null or empty items when producing JSON') { options[:keep_empty] = true }
|
|
41
|
+
end
|
|
42
|
+
path = argv.shift
|
|
43
|
+
parser.parse!(argv)
|
|
44
|
+
unless path
|
|
45
|
+
puts(help_text)
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
splash
|
|
49
|
+
case cmd
|
|
50
|
+
when 'json'
|
|
51
|
+
handle_json(Pathname.new(path), options)
|
|
52
|
+
when 'xml'
|
|
53
|
+
handle_xml(Pathname.new(path), options)
|
|
54
|
+
when 'validate'
|
|
55
|
+
validate(Pathname.new(path), options[:recursive])
|
|
56
|
+
else
|
|
57
|
+
puts(help_text)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
##
|
|
62
|
+
# Render the help text describing CLI usage.
|
|
63
|
+
#
|
|
64
|
+
# @return [String] A one-line summary of the CLI purpose.
|
|
65
|
+
def self.help_text
|
|
66
|
+
'scjson - SCXML <-> scjson converter and validator'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Convert SCXML inputs to scjson outputs.
|
|
71
|
+
#
|
|
72
|
+
# Handles both file and directory inputs, preserving relative paths when
|
|
73
|
+
# writing to directories.
|
|
74
|
+
#
|
|
75
|
+
# @param [Pathname] path Source file or directory.
|
|
76
|
+
# @param [Hash] opt Options hash controlling output and recursion behaviour.
|
|
77
|
+
# @return [void]
|
|
78
|
+
def self.handle_json(path, opt)
|
|
79
|
+
if path.directory?
|
|
80
|
+
out_dir = opt[:output] ? Pathname.new(opt[:output]) : path
|
|
81
|
+
pattern = opt[:recursive] ? '**/*.scxml' : '*.scxml'
|
|
82
|
+
Dir.glob(path.join(pattern).to_s).each do |src|
|
|
83
|
+
rel = Pathname.new(src).relative_path_from(path)
|
|
84
|
+
dest = out_dir.join(rel).sub_ext('.scjson')
|
|
85
|
+
convert_scxml_file(src, dest, opt[:verify], opt[:keep_empty])
|
|
86
|
+
end
|
|
87
|
+
else
|
|
88
|
+
dest = if opt[:output]
|
|
89
|
+
p = Pathname.new(opt[:output])
|
|
90
|
+
p.directory? ? p.join(path.basename.sub_ext('.scjson')) : p
|
|
91
|
+
else
|
|
92
|
+
path.sub_ext('.scjson')
|
|
93
|
+
end
|
|
94
|
+
convert_scxml_file(path, dest, opt[:verify], opt[:keep_empty])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# Convert scjson inputs to SCXML outputs.
|
|
100
|
+
#
|
|
101
|
+
# Handles both file and directory inputs.
|
|
102
|
+
#
|
|
103
|
+
# @param [Pathname] path Source file or directory.
|
|
104
|
+
# @param [Hash] opt Options hash controlling output and recursion behaviour.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def self.handle_xml(path, opt)
|
|
107
|
+
if path.directory?
|
|
108
|
+
out_dir = opt[:output] ? Pathname.new(opt[:output]) : path
|
|
109
|
+
pattern = opt[:recursive] ? '**/*.scjson' : '*.scjson'
|
|
110
|
+
Dir.glob(path.join(pattern).to_s).each do |src|
|
|
111
|
+
rel = Pathname.new(src).relative_path_from(path)
|
|
112
|
+
dest = out_dir.join(rel).sub_ext('.scxml')
|
|
113
|
+
convert_scjson_file(src, dest, opt[:verify])
|
|
114
|
+
end
|
|
115
|
+
else
|
|
116
|
+
dest = if opt[:output]
|
|
117
|
+
p = Pathname.new(opt[:output])
|
|
118
|
+
p.directory? ? p.join(path.basename.sub_ext('.scxml')) : p
|
|
119
|
+
else
|
|
120
|
+
path.sub_ext('.scxml')
|
|
121
|
+
end
|
|
122
|
+
convert_scjson_file(path, dest, opt[:verify])
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
##
|
|
127
|
+
# Convert a single SCXML document to scjson.
|
|
128
|
+
#
|
|
129
|
+
# @param [String, Pathname] src Input SCXML file path.
|
|
130
|
+
# @param [Pathname] dest Target path for scjson output.
|
|
131
|
+
# @param [Boolean] verify When true, only validate round-tripping without writing.
|
|
132
|
+
# @param [Boolean] keep_empty When true, retain empty containers in JSON output.
|
|
133
|
+
# @return [void]
|
|
134
|
+
def self.convert_scxml_file(src, dest, verify, keep_empty)
|
|
135
|
+
xml_str = File.read(src)
|
|
136
|
+
begin
|
|
137
|
+
json_str = Scjson.xml_to_json(xml_str, !keep_empty)
|
|
138
|
+
if verify
|
|
139
|
+
Scjson.json_to_xml(json_str)
|
|
140
|
+
puts "Verified #{src}"
|
|
141
|
+
else
|
|
142
|
+
FileUtils.mkdir_p(dest.dirname)
|
|
143
|
+
File.write(dest, json_str)
|
|
144
|
+
puts "Wrote #{dest}"
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
warn "Failed to convert #{src}: #{e}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
##
|
|
152
|
+
# Convert a single scjson document to SCXML.
|
|
153
|
+
#
|
|
154
|
+
# @param [String, Pathname] src Input scjson file path.
|
|
155
|
+
# @param [Pathname] dest Target SCXML file path.
|
|
156
|
+
# @param [Boolean] verify When true, only validate round-tripping without writing.
|
|
157
|
+
# @return [void]
|
|
158
|
+
def self.convert_scjson_file(src, dest, verify)
|
|
159
|
+
json_str = File.read(src)
|
|
160
|
+
begin
|
|
161
|
+
xml_str = Scjson.json_to_xml(json_str)
|
|
162
|
+
if verify
|
|
163
|
+
Scjson.xml_to_json(xml_str)
|
|
164
|
+
puts "Verified #{src}"
|
|
165
|
+
else
|
|
166
|
+
FileUtils.mkdir_p(dest.dirname)
|
|
167
|
+
File.write(dest, xml_str)
|
|
168
|
+
puts "Wrote #{dest}"
|
|
169
|
+
end
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
warn "Failed to convert #{src}: #{e}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
##
|
|
176
|
+
# Validate a file or directory tree of SCXML and scjson documents.
|
|
177
|
+
#
|
|
178
|
+
# @param [Pathname] path File or directory to validate.
|
|
179
|
+
# @param [Boolean] recursive When true, traverse subdirectories.
|
|
180
|
+
# @return [void]
|
|
181
|
+
def self.validate(path, recursive)
|
|
182
|
+
success = true
|
|
183
|
+
if path.directory?
|
|
184
|
+
pattern = recursive ? '**/*' : '*'
|
|
185
|
+
Dir.glob(path.join(pattern).to_s).each do |f|
|
|
186
|
+
next unless File.file?(f)
|
|
187
|
+
next unless f.end_with?('.scxml', '.scjson')
|
|
188
|
+
success &= validate_file(f)
|
|
189
|
+
end
|
|
190
|
+
else
|
|
191
|
+
success &= validate_file(path.to_s)
|
|
192
|
+
end
|
|
193
|
+
exit(1) unless success
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
##
|
|
197
|
+
# Validate a single SCXML or scjson document by round-tripping.
|
|
198
|
+
#
|
|
199
|
+
# @param [String] src Path to the document to validate.
|
|
200
|
+
# @return [Boolean] True when the document validates successfully.
|
|
201
|
+
def self.validate_file(src)
|
|
202
|
+
begin
|
|
203
|
+
data = File.read(src)
|
|
204
|
+
if src.end_with?('.scxml')
|
|
205
|
+
json = Scjson.xml_to_json(data)
|
|
206
|
+
Scjson.json_to_xml(json)
|
|
207
|
+
elsif src.end_with?('.scjson')
|
|
208
|
+
xml = Scjson.json_to_xml(data)
|
|
209
|
+
Scjson.xml_to_json(xml)
|
|
210
|
+
else
|
|
211
|
+
return true
|
|
212
|
+
end
|
|
213
|
+
true
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
warn "Validation failed for #{src}: #{e}"
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
data/lib/scjson.rb
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Agent Name: ruby-scjson
|
|
4
|
+
#
|
|
5
|
+
# Part of the scjson project.
|
|
6
|
+
# Developed by Softoboros Technology Inc.
|
|
7
|
+
# Licensed under the BSD 1-Clause License.
|
|
8
|
+
|
|
9
|
+
require 'json'
|
|
10
|
+
require 'nokogiri'
|
|
11
|
+
|
|
12
|
+
require_relative 'scjson/version'
|
|
13
|
+
|
|
14
|
+
# Canonical SCXML <-> scjson conversion for the Ruby agent.
|
|
15
|
+
module Scjson
|
|
16
|
+
XMLNS = 'http://www.w3.org/2005/07/scxml'.freeze
|
|
17
|
+
|
|
18
|
+
ATTRIBUTE_MAP = {
|
|
19
|
+
'datamodel' => 'datamodel_attribute',
|
|
20
|
+
'initial' => 'initial_attribute',
|
|
21
|
+
'type' => 'type_value',
|
|
22
|
+
'raise' => 'raise_value'
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
COLLAPSE_ATTRS = %w[expr cond event target delay location name src id].freeze
|
|
26
|
+
|
|
27
|
+
SCXML_ELEMENTS = %w[
|
|
28
|
+
scxml state parallel final history transition invoke finalize datamodel data
|
|
29
|
+
onentry onexit log send cancel raise assign script foreach param if elseif
|
|
30
|
+
else content donedata initial
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
STRUCTURAL_FIELDS = %w[
|
|
34
|
+
state parallel final history transition invoke finalize datamodel data
|
|
35
|
+
onentry onexit log send cancel raise assign script foreach param if_value
|
|
36
|
+
elseif else_value raise_value content donedata initial
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Convert an SCXML document to its canonical scjson form.
|
|
43
|
+
#
|
|
44
|
+
# @param [String] xml_str SCXML source document.
|
|
45
|
+
# @param [Boolean] omit_empty Remove empty containers when true.
|
|
46
|
+
# @return [String] Canonical scjson output.
|
|
47
|
+
def xml_to_json(xml_str, omit_empty = true)
|
|
48
|
+
doc = Nokogiri::XML(xml_str) { |cfg| cfg.strict.nonet }
|
|
49
|
+
root = locate_root(doc)
|
|
50
|
+
raise ArgumentError, 'Document missing <scxml> root element' unless root
|
|
51
|
+
|
|
52
|
+
map = element_to_hash(root)
|
|
53
|
+
collapse_whitespace(map)
|
|
54
|
+
remove_empty(map) if omit_empty
|
|
55
|
+
JSON.pretty_generate(map)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
##
|
|
59
|
+
# Convert a canonical scjson document back to SCXML.
|
|
60
|
+
#
|
|
61
|
+
# @param [String] json_str Canonical scjson input.
|
|
62
|
+
# @return [String] XML document encoded as UTF-8.
|
|
63
|
+
def json_to_xml(json_str)
|
|
64
|
+
data = JSON.parse(json_str)
|
|
65
|
+
remove_empty(data)
|
|
66
|
+
doc = Nokogiri::XML::Document.new
|
|
67
|
+
doc.encoding = 'utf-8'
|
|
68
|
+
root = build_element(doc, 'scxml', data)
|
|
69
|
+
doc.root = root
|
|
70
|
+
doc.to_xml
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ----------------------------
|
|
74
|
+
# Conversion helpers
|
|
75
|
+
# ----------------------------
|
|
76
|
+
|
|
77
|
+
def locate_root(doc)
|
|
78
|
+
doc.at_xpath('/*[local-name()="scxml"]')
|
|
79
|
+
end
|
|
80
|
+
private_class_method :locate_root
|
|
81
|
+
|
|
82
|
+
def local_name(node)
|
|
83
|
+
(node.name || '').split(':').last
|
|
84
|
+
end
|
|
85
|
+
private_class_method :local_name
|
|
86
|
+
|
|
87
|
+
def append_child(hash, key, value)
|
|
88
|
+
if hash.key?(key)
|
|
89
|
+
existing = hash[key]
|
|
90
|
+
if existing.is_a?(Array)
|
|
91
|
+
existing << value
|
|
92
|
+
else
|
|
93
|
+
hash[key] = [existing, value]
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
hash[key] = [value]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_class_method :append_child
|
|
100
|
+
|
|
101
|
+
def wrap_list(value)
|
|
102
|
+
return [] if value.nil?
|
|
103
|
+
value.is_a?(Array) ? value : [value]
|
|
104
|
+
end
|
|
105
|
+
private_class_method :wrap_list
|
|
106
|
+
|
|
107
|
+
def any_element_to_hash(node)
|
|
108
|
+
result = { 'qname' => node.name }
|
|
109
|
+
text = node.text
|
|
110
|
+
result['text'] = text.to_s if text
|
|
111
|
+
unless node.attribute_nodes.empty?
|
|
112
|
+
attrs = {}
|
|
113
|
+
node.attribute_nodes.each do |attr|
|
|
114
|
+
attrs[attr.name] = attr.value
|
|
115
|
+
end
|
|
116
|
+
result['attributes'] = attrs unless attrs.empty?
|
|
117
|
+
end
|
|
118
|
+
unless node.element_children.empty?
|
|
119
|
+
children = node.element_children.map { |child| any_element_to_hash(child) }
|
|
120
|
+
result['children'] = children unless children.empty?
|
|
121
|
+
end
|
|
122
|
+
result
|
|
123
|
+
end
|
|
124
|
+
private_class_method :any_element_to_hash
|
|
125
|
+
|
|
126
|
+
def element_to_hash(node)
|
|
127
|
+
map = {}
|
|
128
|
+
local = local_name(node)
|
|
129
|
+
|
|
130
|
+
node.attribute_nodes.each do |attr|
|
|
131
|
+
name = local_name(attr)
|
|
132
|
+
value = attr.value
|
|
133
|
+
if local == 'transition' && name == 'target'
|
|
134
|
+
map['target'] = value.split(/\s+/)
|
|
135
|
+
elsif name == 'initial'
|
|
136
|
+
tokens = value.split(/\s+/)
|
|
137
|
+
key = local == 'scxml' ? 'initial' : 'initial_attribute'
|
|
138
|
+
map[key] = tokens
|
|
139
|
+
elsif name == 'version'
|
|
140
|
+
number = begin
|
|
141
|
+
Float(value)
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
map['version'] = number ? number : value
|
|
146
|
+
elsif name == 'datamodel'
|
|
147
|
+
map['datamodel_attribute'] = value
|
|
148
|
+
elsif name == 'type'
|
|
149
|
+
map['type_value'] = value
|
|
150
|
+
elsif name == 'raise'
|
|
151
|
+
map['raise_value'] = value
|
|
152
|
+
elsif local == 'send' && name == 'delay'
|
|
153
|
+
map['delay'] = value
|
|
154
|
+
elsif local == 'send' && name == 'event'
|
|
155
|
+
map['event'] = value
|
|
156
|
+
elsif name == 'xmlns'
|
|
157
|
+
next
|
|
158
|
+
else
|
|
159
|
+
map[name] = value
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if local == 'assign'
|
|
164
|
+
map['type_value'] ||= 'replacechildren'
|
|
165
|
+
end
|
|
166
|
+
if local == 'send'
|
|
167
|
+
map['type_value'] ||= 'scxml'
|
|
168
|
+
map['delay'] ||= '0s'
|
|
169
|
+
end
|
|
170
|
+
if local == 'invoke'
|
|
171
|
+
map['type_value'] ||= 'scxml'
|
|
172
|
+
map['autoforward'] ||= 'false'
|
|
173
|
+
end
|
|
174
|
+
if local == 'assign' && map.key?('id')
|
|
175
|
+
(map['other_attributes'] ||= {})['id'] = map.delete('id')
|
|
176
|
+
end
|
|
177
|
+
if map.key?('intial')
|
|
178
|
+
(map['other_attributes'] ||= {})['intial'] = map.delete('intial')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
text_items = []
|
|
182
|
+
node.children.each do |child|
|
|
183
|
+
if child.element?
|
|
184
|
+
child_local = local_name(child)
|
|
185
|
+
if SCXML_ELEMENTS.include?(child_local)
|
|
186
|
+
key = case child_local
|
|
187
|
+
when 'if' then 'if_value'
|
|
188
|
+
when 'else' then 'else_value'
|
|
189
|
+
when 'raise' then 'raise_value'
|
|
190
|
+
else child_local
|
|
191
|
+
end
|
|
192
|
+
child_map = element_to_hash(child)
|
|
193
|
+
target_key = if child_local == 'scxml' && local != 'scxml'
|
|
194
|
+
'content'
|
|
195
|
+
elsif local == 'content' && child_local == 'scxml'
|
|
196
|
+
'content'
|
|
197
|
+
else
|
|
198
|
+
key
|
|
199
|
+
end
|
|
200
|
+
if %w[initial history].include?(local) && child_local == 'transition'
|
|
201
|
+
map[target_key] = child_map
|
|
202
|
+
else
|
|
203
|
+
append_child(map, target_key, child_map)
|
|
204
|
+
end
|
|
205
|
+
else
|
|
206
|
+
append_child(map, 'content', any_element_to_hash(child))
|
|
207
|
+
end
|
|
208
|
+
elsif child.text?
|
|
209
|
+
value = child.text
|
|
210
|
+
text_items << value if value && !value.strip.empty?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
text_items.each { |text| append_child(map, 'content', text) }
|
|
215
|
+
|
|
216
|
+
if local == 'scxml'
|
|
217
|
+
map['version'] ||= 1.0
|
|
218
|
+
map['datamodel_attribute'] ||= 'null'
|
|
219
|
+
elsif local == 'donedata'
|
|
220
|
+
content = map['content']
|
|
221
|
+
if content.is_a?(Array) && content.length == 1
|
|
222
|
+
map['content'] = content.first
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
map
|
|
227
|
+
end
|
|
228
|
+
private_class_method :element_to_hash
|
|
229
|
+
|
|
230
|
+
def collapse_whitespace(value)
|
|
231
|
+
case value
|
|
232
|
+
when Array
|
|
233
|
+
value.each { |item| collapse_whitespace(item) }
|
|
234
|
+
when Hash
|
|
235
|
+
value.each do |key, val|
|
|
236
|
+
if (key.end_with?('_attribute') || COLLAPSE_ATTRS.include?(key)) && val.is_a?(String)
|
|
237
|
+
value[key] = val.tr("\n\r\t", ' ')
|
|
238
|
+
else
|
|
239
|
+
collapse_whitespace(val)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
private_class_method :collapse_whitespace
|
|
245
|
+
|
|
246
|
+
PRESERVE_EMPTY_KEYS = %w[expr cond event target id name label text].freeze
|
|
247
|
+
|
|
248
|
+
ALWAYS_KEEP_KEYS = %w[else_value else final onentry].freeze
|
|
249
|
+
|
|
250
|
+
def remove_empty(value, key = nil)
|
|
251
|
+
case value
|
|
252
|
+
when Hash
|
|
253
|
+
value.keys.each do |key|
|
|
254
|
+
remove = remove_empty(value[key], key)
|
|
255
|
+
value.delete(key) if remove
|
|
256
|
+
end
|
|
257
|
+
value.empty? && !ALWAYS_KEEP_KEYS.include?(key)
|
|
258
|
+
when Array
|
|
259
|
+
value.reject! { |item| remove_empty(item, key) }
|
|
260
|
+
value.empty? && !ALWAYS_KEEP_KEYS.include?(key)
|
|
261
|
+
when NilClass
|
|
262
|
+
true
|
|
263
|
+
when String
|
|
264
|
+
if value.empty?
|
|
265
|
+
preserve_empty_string?(key) ? false : true
|
|
266
|
+
else
|
|
267
|
+
false
|
|
268
|
+
end
|
|
269
|
+
else
|
|
270
|
+
false
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
private_class_method :remove_empty
|
|
274
|
+
|
|
275
|
+
def preserve_empty_string?(key)
|
|
276
|
+
return false if key.nil?
|
|
277
|
+
|
|
278
|
+
key.end_with?('_attribute') ||
|
|
279
|
+
key.end_with?('_value') ||
|
|
280
|
+
PRESERVE_EMPTY_KEYS.include?(key)
|
|
281
|
+
end
|
|
282
|
+
private_class_method :preserve_empty_string?
|
|
283
|
+
|
|
284
|
+
def join_tokens(value)
|
|
285
|
+
case value
|
|
286
|
+
when Array
|
|
287
|
+
return unless value.all? { |item| item.is_a?(String) || item.is_a?(Numeric) }
|
|
288
|
+
value.map(&:to_s).join(' ')
|
|
289
|
+
when String
|
|
290
|
+
value
|
|
291
|
+
when Numeric
|
|
292
|
+
value.to_s
|
|
293
|
+
else
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
private_class_method :join_tokens
|
|
298
|
+
|
|
299
|
+
def scxml_like?(hash)
|
|
300
|
+
return false unless hash.is_a?(Hash)
|
|
301
|
+
|
|
302
|
+
hash.key?('state') || hash.key?('parallel') || hash.key?('final') ||
|
|
303
|
+
hash.key?('datamodel') || hash.key?('datamodel_attribute') || hash.key?('version')
|
|
304
|
+
end
|
|
305
|
+
private_class_method :scxml_like?
|
|
306
|
+
|
|
307
|
+
def build_element(doc, name, map)
|
|
308
|
+
if map.is_a?(Array) && map.length == 1
|
|
309
|
+
return build_element(doc, name, map.first)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
if map.is_a?(String)
|
|
313
|
+
element = Nokogiri::XML::Element.new(name, doc)
|
|
314
|
+
element.content = map
|
|
315
|
+
return element
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
raise ArgumentError, 'Expected object for element construction' unless map.is_a?(Hash)
|
|
319
|
+
|
|
320
|
+
element_name = map['qname'] || name
|
|
321
|
+
element = Nokogiri::XML::Element.new(element_name, doc)
|
|
322
|
+
|
|
323
|
+
if name == 'scxml'
|
|
324
|
+
element['xmlns'] ||= XMLNS
|
|
325
|
+
elsif !element_name.include?(':') && !SCXML_ELEMENTS.include?(element_name)
|
|
326
|
+
element['xmlns'] ||= ''
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
if map['text']
|
|
330
|
+
element.add_child(Nokogiri::XML::Text.new(map['text'], doc))
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
if map['attributes'].is_a?(Hash)
|
|
334
|
+
map['attributes'].each do |attr_name, attr_value|
|
|
335
|
+
element[attr_name] = attr_value if attr_value
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
map.each do |key, value|
|
|
340
|
+
next if %w[qname text attributes].include?(key)
|
|
341
|
+
|
|
342
|
+
case key
|
|
343
|
+
when 'content'
|
|
344
|
+
handle_content_nodes(doc, element, value, element_name)
|
|
345
|
+
when 'children'
|
|
346
|
+
wrap_list(value).each do |child_map|
|
|
347
|
+
next unless child_map.is_a?(Hash)
|
|
348
|
+
child_name = child_map['qname'] || 'content'
|
|
349
|
+
element.add_child(build_element(doc, child_name, child_map))
|
|
350
|
+
end
|
|
351
|
+
when 'other_attributes'
|
|
352
|
+
next unless value.is_a?(Hash)
|
|
353
|
+
value.each do |attr_name, attr_value|
|
|
354
|
+
element[attr_name] = join_tokens(attr_value) || attr_value.to_s
|
|
355
|
+
end
|
|
356
|
+
when ->(k) { k.end_with?('_attribute') }
|
|
357
|
+
attr_name = key.sub(/_attribute\z/, '')
|
|
358
|
+
joined = join_tokens(value)
|
|
359
|
+
element[attr_name] = joined if joined
|
|
360
|
+
when 'datamodel_attribute'
|
|
361
|
+
joined = join_tokens(value)
|
|
362
|
+
element['datamodel'] = joined if joined
|
|
363
|
+
when 'type_value'
|
|
364
|
+
joined = join_tokens(value)
|
|
365
|
+
element['type'] = joined if joined
|
|
366
|
+
when 'target'
|
|
367
|
+
joined = join_tokens(value)
|
|
368
|
+
element['target'] = joined if joined
|
|
369
|
+
when 'delay', 'event'
|
|
370
|
+
joined = join_tokens(value)
|
|
371
|
+
element[key] = joined if joined
|
|
372
|
+
when 'initial'
|
|
373
|
+
joined = join_tokens(value)
|
|
374
|
+
if joined
|
|
375
|
+
element['initial'] = joined
|
|
376
|
+
else
|
|
377
|
+
wrap_list(value).each do |child|
|
|
378
|
+
element.add_child(build_element(doc, 'initial', child))
|
|
379
|
+
end
|
|
380
|
+
next
|
|
381
|
+
end
|
|
382
|
+
when 'version'
|
|
383
|
+
element['version'] = value.to_s
|
|
384
|
+
else
|
|
385
|
+
child_name = case key
|
|
386
|
+
when 'if_value' then 'if'
|
|
387
|
+
when 'else_value' then 'else'
|
|
388
|
+
when 'raise_value' then 'raise'
|
|
389
|
+
else key
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
if STRUCTURAL_FIELDS.include?(key) || %w[if_value else_value raise_value].include?(key)
|
|
393
|
+
wrap_list(value).each do |child|
|
|
394
|
+
element.add_child(build_element(doc, child_name, child))
|
|
395
|
+
end
|
|
396
|
+
elsif value.is_a?(Array) && value.all? { |item| !item.is_a?(Hash) }
|
|
397
|
+
element[key] = join_tokens(value)
|
|
398
|
+
elsif value.is_a?(Hash)
|
|
399
|
+
element.add_child(build_element(doc, child_name, value))
|
|
400
|
+
elsif !value.nil?
|
|
401
|
+
element[key] = value.to_s
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
element
|
|
407
|
+
end
|
|
408
|
+
private_class_method :build_element
|
|
409
|
+
|
|
410
|
+
def handle_content_nodes(doc, element, value, parent_name)
|
|
411
|
+
items = wrap_list(value)
|
|
412
|
+
items.each do |item|
|
|
413
|
+
if item.is_a?(String)
|
|
414
|
+
if parent_name == 'script'
|
|
415
|
+
element.add_child(Nokogiri::XML::Text.new(item, doc))
|
|
416
|
+
elsif parent_name == 'data'
|
|
417
|
+
element.add_child(Nokogiri::XML::Text.new(item, doc))
|
|
418
|
+
else
|
|
419
|
+
content_element = Nokogiri::XML::Element.new('content', doc)
|
|
420
|
+
content_element.add_child(Nokogiri::XML::Text.new(item, doc))
|
|
421
|
+
element.add_child(content_element)
|
|
422
|
+
end
|
|
423
|
+
next
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
next unless item.is_a?(Hash)
|
|
427
|
+
|
|
428
|
+
if parent_name == 'send' && item.keys == ['content']
|
|
429
|
+
wrap_list(item['content']).each do |inner|
|
|
430
|
+
content_element = Nokogiri::XML::Element.new('content', doc)
|
|
431
|
+
if inner.is_a?(String)
|
|
432
|
+
content_element.add_child(Nokogiri::XML::Text.new(inner, doc))
|
|
433
|
+
elsif inner.is_a?(Hash)
|
|
434
|
+
content_element.add_child(build_element(doc, 'content', inner))
|
|
435
|
+
end
|
|
436
|
+
element.add_child(content_element)
|
|
437
|
+
end
|
|
438
|
+
next
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
if parent_name == 'donedata' && item.keys == ['content']
|
|
442
|
+
content_element = Nokogiri::XML::Element.new('content', doc)
|
|
443
|
+
wrap_list(item['content']).each do |inner|
|
|
444
|
+
if inner.is_a?(String)
|
|
445
|
+
content_element.add_child(Nokogiri::XML::Text.new(inner, doc))
|
|
446
|
+
elsif inner.is_a?(Hash)
|
|
447
|
+
content_element.add_child(build_element(doc, 'content', inner))
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
element.add_child(content_element)
|
|
451
|
+
next
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
if item.key?('qname')
|
|
455
|
+
child = build_element(doc, item['qname'], item)
|
|
456
|
+
element.add_child(child)
|
|
457
|
+
next
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
child_name = if scxml_like?(item)
|
|
461
|
+
'scxml'
|
|
462
|
+
elsif parent_name == 'script'
|
|
463
|
+
'content'
|
|
464
|
+
else
|
|
465
|
+
'content'
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
if parent_name == 'data' && child_name == 'content'
|
|
469
|
+
element.add_child(Nokogiri::XML::Text.new(item['content'].to_s, doc))
|
|
470
|
+
else
|
|
471
|
+
child_element = build_element(doc, child_name, item)
|
|
472
|
+
element.add_child(child_element)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
private_class_method :handle_content_nodes
|
|
477
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: scjson
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Softoboros Technology Inc.
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-10-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: nokogiri
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
description:
|
|
28
|
+
email:
|
|
29
|
+
- info@softoboros.com
|
|
30
|
+
executables:
|
|
31
|
+
- scjson
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- bin/scjson
|
|
36
|
+
- lib/scjson.rb
|
|
37
|
+
- lib/scjson/cli.rb
|
|
38
|
+
- lib/scjson/version.rb
|
|
39
|
+
homepage:
|
|
40
|
+
licenses:
|
|
41
|
+
- BSD-1-Clause
|
|
42
|
+
metadata: {}
|
|
43
|
+
post_install_message:
|
|
44
|
+
rdoc_options: []
|
|
45
|
+
require_paths:
|
|
46
|
+
- lib
|
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
48
|
+
requirements:
|
|
49
|
+
- - ">="
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '0'
|
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0'
|
|
57
|
+
requirements: []
|
|
58
|
+
rubygems_version: 3.4.19
|
|
59
|
+
signing_key:
|
|
60
|
+
specification_version: 4
|
|
61
|
+
summary: SCXML <-> scjson converter and validator
|
|
62
|
+
test_files: []
|