twb 0.5.3 → 1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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