twb 0.5.3 → 1.0

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.
@@ -0,0 +1,153 @@
1
+ require 'nokogiri'
2
+ require 'csv'
3
+ require 'twb'
4
+
5
+ def init
6
+ system 'cls'
7
+ $pFile = File.open('identifyFields.txt','w')
8
+
9
+ sqiCSV = 'TWBFieldsByType.csv'
10
+ $fieldsCSV = CSV.open(sqiCSV, 'w')
11
+ $fieldsCSV << [
12
+ 'TWB',
13
+ 'Data Connection - UI',
14
+ 'Field Name',
15
+ 'TWB Field Type',
16
+ 'Data Type',
17
+ 'Calc Field?',
18
+ ]
19
+ $path = if ARGV.empty? then '**/*.twb' else ARGV[0] end
20
+ emit " "
21
+ emit " Identifying fields in Tableau Workbook Data Connections (TWDCs).\n "
22
+ emit " Looking for Workbooks matching: '#{$path}' - from: #{ARGV[0]}\n\n "
23
+ end
24
+
25
+ $localEmit = true
26
+ def emit stuff
27
+ $pFile.puts stuff
28
+ puts stuff if $localEmit
29
+ end
30
+
31
+ $paths = {
32
+ 'Relation Column' => { 'fieldXPath' => './connection/relation/columns/column',
33
+ 'nameXPath' => './@name'
34
+ },
35
+ 'Metadata Record' => { 'fieldXPath' => './connection/metadata-records/metadata-record[@class="column"]',
36
+ 'nameXPath' => './remote-name[text()]'
37
+ },
38
+ 'Columns' => { 'fieldXPath' => './column',
39
+ 'nameXPath' => './@name'
40
+ },
41
+ }
42
+
43
+ # different connection classes have
44
+ # different paths & attributes to the Relation fields
45
+ $relClassPaths = {
46
+ 'dataengine' => { 'fieldXPath' => './connection/relation/columns/map',
47
+ 'nameXPath' => './@name'
48
+ },
49
+ 'excel-direct' => { 'fieldXPath' => './connection/relation/columns/map',
50
+ 'nameXPath' => './@name'
51
+ },
52
+ 'textscan' => { 'fieldXPath' => './connection/relation/columns/map',
53
+ 'nameXPath' => './@name'
54
+ },
55
+ }
56
+
57
+ def relationFields dataSource, connClass
58
+ emit "\t relationFields connClass: '%s'" % [connClass]
59
+ xPaths = $relClassPaths[connClass]
60
+ emit "\t xPaths: #{xPaths}"
61
+ fnodesPath = xPaths['fieldXPath']
62
+ fnamePath = xPaths['nameXPath']
63
+ fieldNodes = dataSource.xpath(fnodesPath)
64
+
65
+ end
66
+
67
+ def metaDataRecords dataSource
68
+ emit "\t metaDataRecords " #connClass: '%s'" % [connClass]
69
+ mdNodes = dataSource.xpath('./connection/metadata-records/metadata-record[@class="column"]')
70
+ emit " mdNodes.size: #{mdNodes.size}"
71
+ fields = {}
72
+ mdNodes.each do |node|
73
+ name = node.xpath('./local-name').text.gsub(/^\[/,'').gsub(/\]$/,'')
74
+ type = node.xpath('./local-type').text
75
+ fields[name] = {'type' => type}
76
+ end
77
+ return fields
78
+ end
79
+
80
+ def localColumns dataSource
81
+ emit "\t localColumns " #connClass: '%s'" % [connClass]
82
+ colNodes = dataSource.xpath('./column')
83
+ emit " colNodes.size: #{colNodes.size}"
84
+ fields = {}
85
+ colNodes.each do |node|
86
+ calcField = !node.at_xpath('./calculation').nil?
87
+ caption = node.attribute('caption')
88
+ name = node.attribute('name').text.gsub(/^\[/,'').gsub(/\]$/,'')
89
+ type = node.attribute('datatype')
90
+ emit " \t name: %s\n \t type: %s\n \t caption: %s" % [name, type, caption]
91
+ caption = name if caption.nil?
92
+ emit " \t caption: %s \n " % [caption]
93
+ fields[caption] = {'type' => type, 'calcField' => calcField}
94
+ end
95
+ return fields
96
+ end
97
+
98
+ def process file
99
+ emit "\n == #{file}"
100
+ doc = Nokogiri::XML(open(file))
101
+ dataSourcesNodes = doc.xpath('//workbook/datasources/datasource')
102
+ dataSourcesNodes.each do |ds|
103
+ dsCaption = ds.attribute('caption')
104
+ dsName = ds.attribute('name')
105
+ emit "\n dsCapt: #{dsCaption}"
106
+ emit " dsName: '#{dsName}'\n ---"
107
+ connClass = ds.xpath('./connection/@class')
108
+ emit "dsCClass: '#{connClass}'\n ---"
109
+ if dsName != 'Parameters' then
110
+ # relationFields ds, connClass
111
+ mdFields = metaDataRecords ds
112
+ mdFields.each do |fname, payload|
113
+ emit "\t "
114
+ $fieldsCSV << [file, dsCaption, fname, 'metadata', payload['type'] ]
115
+ emit [file, dsCaption, fname, 'metadata', payload['type'] ]
116
+ end
117
+ colFields = localColumns ds
118
+ colFields.each do |fname, payload|
119
+ emit "\t "
120
+ $fieldsCSV << [file, dsCaption, fname, 'column', payload['type'], payload['calcField'] ]
121
+ emit [file, dsCaption, fname, 'column', payload['type'], payload['calcField'] ]
122
+ end
123
+ end
124
+
125
+ # $paths.each do |type, paths|
126
+ # emit " type: #{type}"
127
+ # emit paths['fieldXPath']
128
+ # nodes = ds.xpath(paths['fieldXPath']).to_a
129
+ # nodes.each do |node|
130
+ # name = node.xpath(paths['nameXPath']).text
131
+ # emit " fname: #{name}"
132
+ # end
133
+ # end
134
+ end
135
+ end
136
+
137
+ init
138
+
139
+ def showPaths
140
+ $paths.each do |type, paths|
141
+ emit " #{type}"
142
+ paths.each do |key, value|
143
+ emit " : %10s => %-s" % [key, value]
144
+ end
145
+ puts " "
146
+ end
147
+ end
148
+
149
+ emit "XXX \n#{$relClassPaths}"
150
+
151
+ Dir.glob($path) {|twb| process twb }
152
+
153
+ emit "\n\n== Done ==\n "
data/lib/twb/graph.rb ADDED
@@ -0,0 +1,53 @@
1
+ # Copyright (C) 2014, 2017 Chris Gerrard
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ module Twb
17
+
18
+ class Graph
19
+
20
+ # @root - the visible name
21
+ # @id - the technical identifier, used to distinquish the node from similarly named nodes
22
+ # @type - useful for categorizing the node
23
+ attr_reader :name, :id, :type
24
+ attr_accessor :properties
25
+
26
+
27
+ def initialize (name:, id:, type:, properties: {})
28
+ @name = name
29
+ @id = id
30
+ @type = type
31
+ @properties = properties
32
+ end
33
+
34
+ def dotLabel
35
+ # "JIRA 1::JIRA 1.csv" [label="JIRA 1.csv"]
36
+ "\"%s\" [label=\"%s\"]" % [id, name]
37
+ end
38
+
39
+ def eql? other
40
+ @name == other.name && @id == other.id && @type == other.type && @properties == other.properties
41
+ end
42
+
43
+ def hash
44
+ [@name, @id, @type, @properties].hash
45
+ end
46
+
47
+ def to_s
48
+ "name:'%s' id:'%s' t:'%s' p:'%s'" % [@name, @id, @type, @properties.to_s]
49
+ end
50
+
51
+ end
52
+
53
+ end
@@ -0,0 +1,36 @@
1
+ # Copyright (C) 2014, 2017 Chris Gerrard
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ module Twb
17
+
18
+ class Graphedges
19
+
20
+ # @root - the graph's root node
21
+ # @edges - the collection of the graph's edges
22
+ attr_reader :root
23
+ attr_accessor :edges
24
+
25
+ def initialize (root:)
26
+ @root = root if root.instance_of? Twb::Graphnode
27
+ @edges = []
28
+ end
29
+
30
+ def to_s
31
+ "Root:'%s' edges::%s::" % [@root.to_s, @edges.to_s]
32
+ end
33
+
34
+ end
35
+
36
+ end
@@ -0,0 +1,53 @@
1
+ # Copyright (C) 2014, 2017 Chris Gerrard
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ module Twb
17
+
18
+ class Graphnode
19
+
20
+ # @name - the visible name
21
+ # @id - the technical identifier, used to distinquish the node from similarly named nodes
22
+ # @type - useful for categorizing the node
23
+ attr_reader :name, :id, :type
24
+ attr_accessor :properties
25
+
26
+
27
+ def initialize (name:, id:, type:, properties: {})
28
+ @name = name
29
+ @id = id
30
+ @type = type
31
+ @properties = properties
32
+ end
33
+
34
+ def dotLabel
35
+ # "JIRA 1::JIRA 1.csv" [label="JIRA 1.csv"]
36
+ "\"%s\" [label=\"%s\"]" % [id, name]
37
+ end
38
+
39
+ def eql? other
40
+ @name == other.name && @id == other.id && @type == other.type && @properties == other.properties
41
+ end
42
+
43
+ def hash
44
+ [@name, @id, @type, @properties].hash
45
+ end
46
+
47
+ def to_s
48
+ "name:'%s' id:'%s' t:'%s' p:'%s'" % [@name, @id, @type, @properties.to_s]
49
+ end
50
+
51
+ end
52
+
53
+ end
@@ -0,0 +1,103 @@
1
+ require 'nokogiri'
2
+ require 'csv'
3
+ require 'twb'
4
+
5
+ def init
6
+ system 'cls'
7
+ $pFile = File.open('identifyFields.txt','w')
8
+
9
+ sqiCSV = 'TWBFieldsByType.csv'
10
+ $fieldsCSV = CSV.open(sqiCSV, 'w')
11
+ $fieldsCSV << [
12
+ 'TWB',
13
+ 'Data Connection - UI',
14
+ 'Field Name',
15
+ 'Field Type',
16
+ ]
17
+ $path = if ARGV.empty? then '**/*.twb' else ARGV[0] end
18
+ emit " "
19
+ emit " Identifying fields in Tableau Workbook Data Connections (TWDCs).\n "
20
+ emit " Looking for Workbooks matching: '#{$path}' - from: #{ARGV[0]}\n\n "
21
+ end
22
+
23
+ def method_name
24
+ datasources = {}
25
+ dataSourcesNode = doc.at_xpath('//workbook/datasources')
26
+ dataSourcesNodes = doc.xpath('//workbook/datasources/datasource').to_a
27
+ puts " dsn: #{dataSourcesNodes}"
28
+ datasourceNodes.each do |node|
29
+ datasource = Twb::DataSource.new(node)
30
+ datasources[datasource.name] = datasource
31
+ end
32
+ end
33
+
34
+ $localEmit = true
35
+ def emit stuff
36
+ $pFile.puts stuff
37
+ puts stuff if $localEmit
38
+ end
39
+
40
+ $paths = {
41
+ 'Relation Column' => { 'fieldXPath' => './connection/relation/columns/column',
42
+ 'nameXPath' => '@name'
43
+ },
44
+ 'Metadata Record' => { 'fieldXPath' => './connection/metadata-records/metadata-record[@class="column"]',
45
+ 'nameXPath' => './remote-name'
46
+ },
47
+ 'Columns' => { 'fieldXPath' => './column',
48
+ 'nameXPath' => '@name'
49
+ },
50
+ }
51
+
52
+ def proc file
53
+ emit "\n == #{file}"
54
+ end
55
+
56
+ def process file
57
+ emit "\n == #{file}"
58
+ doc = Nokogiri::XML(open(file))
59
+ dataSourcesNodes = doc.xpath('//workbook/datasources/datasource')
60
+ dataSourcesNodes.each do |ds|
61
+ emit "\n dc: #{ds.attribute('caption')}"
62
+ emit " dn: #{ds.attribute('name')}\n ---"
63
+ typeCounts = {}
64
+ $paths.each do |type, path|
65
+ nodes = ds.xpath(path).to_a
66
+ # emit " : %3i %-17s %-s" % [nodes.size, type, path]
67
+ typeCnt = nodes.size
68
+ nodes.each do |n|
69
+ # $fieldsCSV << [file,ds.attribute('name'),n.attribute('name'),type]
70
+ end
71
+ if type == 'Columns' then
72
+ calcCnt = 0
73
+ nodes.each do |n|
74
+ calc = n.xpath('./calculation')
75
+ # emit " c: #{calc.size} "
76
+ calcCnt += calc.size
77
+ end
78
+ # emit ' ---'
79
+ # emit " c#: %3i " % [calcCnt]
80
+ # emit " !c#: %3i " % [nodes.size - calcCnt]
81
+ typeCounts['Columns'] = nodes.size
82
+ typeCounts['Columns - calc'] = calcCnt
83
+ typeCounts['Columns - not calc'] = nodes.size - calcCnt
84
+ else
85
+ typeCounts[type] = typeCnt
86
+ end
87
+ end
88
+ # emit ' ---'
89
+ typeCounts.each do |t,c|
90
+ emit " tc: %3i %-s" % [c, t]
91
+ end
92
+ end
93
+ end
94
+
95
+ init
96
+
97
+ $paths.each do |path|
98
+ emit " p: #{path}"
99
+ end
100
+
101
+ Dir.glob($path) {|twb| process twb }
102
+
103
+ emit "\n\n== Done ==\n "
@@ -21,12 +21,13 @@ module Twb
21
21
 
22
22
  class LocalField
23
23
 
24
- attr_reader :type, :node, :name, :datatype, :role, :type, :hidden, :caption, :aggregation, :uiname, :calculation, :comments
24
+ attr_reader :type, :node, :tableauname, :dbname, :datatype, :role, :type, :hidden, :caption, :aggregation, :uiname, :calculation, :comments
25
25
 
26
26
  def initialize fieldNode
27
27
  @node = fieldNode
28
28
  @type = 'local'
29
- @name = @node.attr('name')
29
+ @tableauname = @node.attr('name')
30
+ @dbname = @node.attr('name').gsub(/^\[/,'').gsub(/\]$/,'')
30
31
  @datatype = @node.attr('datatype')
31
32
  @role = @node.attr('role')
32
33
  @type = @node.attr('type')
@@ -35,15 +36,15 @@ module Twb
35
36
  @aggregation = @node.attr('aggregation')
36
37
  @calculation = getCalculation
37
38
  @comments = getComments
38
- @uiname = if @caption.nil? || @caption == '' then @name.gsub(/^\[/,'').gsub(/\]$/,'') else @caption end
39
+ @uiname = if @caption.nil? || @caption == '' then @dbname else @caption end
39
40
  return self
40
41
  end
41
-
42
+
42
43
  def getCalculation
43
44
  calcNode = @node.at_xpath("./calculation")
44
45
  FieldCalculation.new(calcNode) unless calcNode.nil?
45
46
  end
46
-
47
+
47
48
  def getComments
48
49
  comments = ''
49
50
  runs = node.xpath('./desc/formatted-text/run')
@@ -54,7 +55,7 @@ module Twb
54
55
  end
55
56
  return comments
56
57
  end
57
-
58
+
58
59
  def remove_attribute attribute
59
60
  @node.remove_attribute(attribute)
60
61
  end
@@ -0,0 +1,69 @@
1
+ # Copyright (C) 2017 Chris Gerrard
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ module Twb
17
+ module Util
18
+
19
+ class Graphedge
20
+
21
+ # @from - the origin node
22
+ # @to - the destination node
23
+ # @relationship - useful for categorizing the edge
24
+ # @properties - useful for categorizing the edge
25
+ attr_reader :from, :to, :relationship
26
+ attr_accessor :properties
27
+ attr_reader :cypherCreate
28
+
29
+ # Neo4J cypher variable quote character: `
30
+
31
+ def initialize (from:, to:, relationship:, properties: {})
32
+ raise ArgumentError.new("from: parameter must be a Graphnode, is a '#{from.class}'") unless from.is_a? Twb::Util::Graphnode
33
+ raise ArgumentError.new( "to: parameter must be a Graphnode, is a '#{to.class}'" ) unless to.is_a? Twb::Util::Graphnode
34
+ @from = from
35
+ @to = to
36
+ @relationship = relationship
37
+ @properties = properties
38
+ @cypherCreate = "CREATE #{cypher_s}"
39
+ end
40
+
41
+ def eql? other
42
+ @from == other.from && @to == other.to && @relationship == other.relationship && @properties == other.properties
43
+ end
44
+
45
+ def hash
46
+ [@from.hash, @to.hash, @relationship, @properties].hash
47
+ end
48
+
49
+ def to_s
50
+ "'#{@from.name}//{@from.id}' --#{@relationship}--> '#{@to.name}//#{@to.id}'"
51
+ end
52
+
53
+ def dot
54
+ "%s -> %s" % [dotquote(from.id), dotquote(to.id)]
55
+ end
56
+
57
+ def dotquote str
58
+ ns = str.gsub(/(["])/,'\\"')
59
+ return "\"#{ns}\""
60
+ end
61
+
62
+ def cypher_s
63
+ "(%s)-[:`%s`]->(%s)" % [@from.cypherID,@relationship,@to.cypherID]
64
+ end
65
+
66
+ end
67
+
68
+ end # module Util
69
+ end # module Twb
@@ -0,0 +1,38 @@
1
+ # Copyright (C) 2014, 2017 Chris Gerrard
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ module Twb
17
+ module Util
18
+
19
+ class Graphedges
20
+
21
+ # @root - the graph's root node
22
+ # @edges - the collection of the graph's edges
23
+ attr_reader :root
24
+ attr_accessor :edges
25
+
26
+ def initialize (root = nil)
27
+ @root = root if root.instance_of? Twb::Util::Graphnode
28
+ @edges = []
29
+ end
30
+
31
+ def to_s
32
+ "Root:'%s' edges::%s::" % [@root.to_s, @edges.to_s]
33
+ end
34
+
35
+ end
36
+
37
+ end # module Util
38
+ end # module Twb
@@ -0,0 +1,94 @@
1
+ # Copyright (C) 2014, 2017 Chris Gerrard
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <http://www.gnu.org/licenses/>.
15
+
16
+ module Twb
17
+ module Util
18
+
19
+ class Graphnode
20
+
21
+ @@stripChars = /[.: %-\-\(\)=]/
22
+ @@replChar = '_'
23
+
24
+
25
+
26
+ # @name - the visible name
27
+ # @id - the technical identifier, used to distinquish the node from similarly named nodes
28
+ # @type - useful for categorizing the node
29
+ attr_reader :id, :type, :name
30
+ attr_reader :cypherID, :cypherCreate
31
+ # attr_reader :cypherName, :cypherID, :cypherNodeID, :cypherType, :cypherCreate
32
+ attr_accessor :properties
33
+
34
+ # Neo4J cypher variable quote character: `
35
+
36
+ def initialize (name:, id:, type:, properties: {})
37
+ @id = id
38
+ @type = type
39
+ @name = name
40
+ @properties = properties
41
+ # --
42
+ @cypherID = '`' + id + '`'
43
+ # @cypherType = type
44
+ # @cypherName = name
45
+ @properties['name'] = name unless @properties.key? 'name'
46
+ @cypherCreate = "CREATE (%s:`%s` {%s})" % [@cypherID,type,props_s]
47
+ end
48
+
49
+
50
+ def cypherize str
51
+ enquote str.gsub(@@stripChars,@@replChar)
52
+ end
53
+
54
+ def enquote str
55
+ str.gsub("'","\\\\'")
56
+ end
57
+
58
+
59
+ def dotLabel
60
+ # "JIRA 1::JIRA 1.csv" [label="JIRA 1.csv"]
61
+ filled = @type =~ /Data Source|DB Table|Database Field/ ? "style=filled" : ''
62
+ "\"%s\" [label=\"%s\" %s]" % [id, name, filled]
63
+ end
64
+
65
+
66
+ def eql? other
67
+ @name == other.name && @id == other.id && @type == other.type && @properties == other.properties
68
+ end
69
+
70
+
71
+ def hash
72
+ [@name, @id, @type, @properties].hash
73
+ end
74
+
75
+
76
+ def to_s
77
+ "name:'%s' id:'%s' t:'%s' p:%s" % [@name, @id, @type, @properties.to_s]
78
+ end
79
+
80
+
81
+ def cypher_s
82
+ "name:%s id:%s t:%s p:{%s}" % [name, @cypherID, @type, props_s]
83
+ end
84
+
85
+
86
+ def props_s
87
+ @properties.map{|k,v| "#{k}: #{v.inspect}"}.join(', ')
88
+ end
89
+
90
+
91
+ end
92
+
93
+ end # module Util
94
+ end # module Twb