twb 1.0.5 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/twb.rb +10 -6
- data/lib/twb/analysis/CalculatedFields/CSVEmitter.rb +154 -0
- data/lib/twb/analysis/CalculatedFields/CalculatedFieldsAnalyzer.rb +527 -0
- data/lib/twb/analysis/CalculatedFields/MarkdownEmitter.rb +71 -0
- data/lib/twb/analysis/DataSources/DataSourceTableFieldsCSVEmitter.rb +117 -0
- data/lib/twb/analysis/Sheets/WorksheetDataStructureCSVEmitter.rb +192 -0
- data/lib/twb/calculatedfield.rb +47 -0
- data/lib/twb/columnfield.rb +94 -0
- data/lib/twb/dashboard.rb +1 -1
- data/lib/twb/datasource.rb +368 -121
- data/lib/twb/fieldcalculation.rb +157 -81
- data/lib/twb/localfield.rb +32 -29
- data/lib/twb/metadatafield.rb +57 -15
- data/lib/twb/util/ftpPublisher.rb +48 -0
- data/lib/twb/util/joinUtilities.rb +52 -0
- data/lib/twb/workbook.rb +27 -10
- data/lib/twb/worksheet.rb +146 -53
- metadata +11 -5
- data/lib/twb/analysis/calculatedfieldsanalyzer.rb +0 -508
- data/lib/twb/apps/X-Ray Dashboards.rb +0 -80
- data/lib/twb/countNodes.rb +0 -98
@@ -0,0 +1,48 @@
|
|
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
|
+
require 'net/ftp'
|
20
|
+
|
21
|
+
class FTPPublisher
|
22
|
+
|
23
|
+
attr_reader :fileName, :fileURL
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@ftp = Net::FTP.new('gerrard.net')
|
27
|
+
@ftp.login("tableautools", 'TableauT00ls!')
|
28
|
+
end
|
29
|
+
|
30
|
+
def publish fileName
|
31
|
+
@fileName = fileName
|
32
|
+
@ftp.passive = true
|
33
|
+
@ftp.puttextfile(fileName, fileName)
|
34
|
+
@fileURL = "http://gerrard.net/tableautools/#{fileName}"
|
35
|
+
end
|
36
|
+
|
37
|
+
def list
|
38
|
+
@ftp.list()
|
39
|
+
end
|
40
|
+
|
41
|
+
def close
|
42
|
+
@ftp.close unless @ftp.nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end # module Util
|
48
|
+
end # module Twb
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# Copyright (C) 2012, 2015 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 JoinTable
|
20
|
+
attr_reader :table, :datasource
|
21
|
+
def initialize(table, datasource=nil)
|
22
|
+
@table = table
|
23
|
+
@datasource = datasource
|
24
|
+
end
|
25
|
+
end # class JoinTable
|
26
|
+
|
27
|
+
|
28
|
+
class JoinTablePair
|
29
|
+
attr_reader :from, :to
|
30
|
+
def initialize(from, to)
|
31
|
+
raise ParamaterException.new("'From' cannot be nil.") if from.nil?
|
32
|
+
raise ParamaterException.new("'To' cannot be nil.") if to.nil?
|
33
|
+
@from = from
|
34
|
+
@to = to
|
35
|
+
end
|
36
|
+
end # class JoinTablePair
|
37
|
+
|
38
|
+
class JoinTree
|
39
|
+
attr_reader :root, :maxdepth
|
40
|
+
def initialize
|
41
|
+
@root = nil
|
42
|
+
@maxdepth = 0
|
43
|
+
end
|
44
|
+
def add from, to
|
45
|
+
puts "abc"
|
46
|
+
end
|
47
|
+
|
48
|
+
end # class JoinTree
|
49
|
+
|
50
|
+
|
51
|
+
end # module Util
|
52
|
+
end # module Twb
|
data/lib/twb/workbook.rb
CHANGED
@@ -25,11 +25,12 @@ module Twb
|
|
25
25
|
class Workbook
|
26
26
|
|
27
27
|
attr_reader :workbooknode
|
28
|
-
attr_reader :name,
|
29
|
-
attr_reader :modtime,
|
30
|
-
attr_reader :datasources,
|
31
|
-
attr_reader :
|
32
|
-
attr_reader :
|
28
|
+
attr_reader :name, :dir
|
29
|
+
attr_reader :modtime, :version, :build
|
30
|
+
attr_reader :datasources, :datasource
|
31
|
+
attr_reader :datasourceNames, :datasourceUINames, :dataSourceNamesMap
|
32
|
+
attr_reader :dashboards, :storyboards, :worksheets, :actions
|
33
|
+
attr_reader :valid, :ndoc
|
33
34
|
|
34
35
|
##
|
35
36
|
# Creates a Workbook from its file name.
|
@@ -39,6 +40,9 @@ module Twb
|
|
39
40
|
# The Workbook's file name, the Workbook can be a TWB or TWBX file.
|
40
41
|
#
|
41
42
|
def initialize twbWithDir
|
43
|
+
raise ArgumentError.new("ERROR in Workbok creation: '#{twbWithDir}' must be a String, is a #{twbWithDir.class} \n ") unless twbWithDir.is_a? String
|
44
|
+
raise ArgumentError.new("ERROR in Workbok creation: '#{twbWithDir}' must have an extension of .twb or .twbx \n ") unless twbWithDir.upcase.end_with?(".TWB", ".TWBX")
|
45
|
+
raise ArgumentError.new("ERROR in Workbok creation: '#{twbWithDir}' cannot be found, must be a Tableau Workbook file. \n ") unless File.file?(twbWithDir)
|
42
46
|
@valid = false
|
43
47
|
if File.file?(twbWithDir) then
|
44
48
|
@name = File.basename(twbWithDir)
|
@@ -66,8 +70,7 @@ module Twb
|
|
66
70
|
|
67
71
|
def processDoc
|
68
72
|
@workbooknode = @ndoc.at_xpath('//workbook')
|
69
|
-
@version = @ndoc.xpath('/workbook/@version')
|
70
|
-
@build = @ndoc.xpath('/workbook/comment()').text.gsub(/^[^0-9]+/,'').strip
|
73
|
+
@version = @ndoc.xpath('/workbook/@version').first.text
|
71
74
|
loaddatasources
|
72
75
|
loadWorksheets
|
73
76
|
loadDashboards
|
@@ -77,17 +80,31 @@ module Twb
|
|
77
80
|
@valid = true
|
78
81
|
end
|
79
82
|
|
83
|
+
def build
|
84
|
+
@build ||= loadBuild
|
85
|
+
end
|
86
|
+
|
87
|
+
def loadBuild
|
88
|
+
# - earlier Version, need to confirm when source-build began
|
89
|
+
# @build = @ndoc.xpath('/workbook/comment()').text.gsub(/^[^0-9]+/,'').strip
|
90
|
+
@build = if !@ndoc.xpath('/workbook/@source-build').nil?
|
91
|
+
@ndoc.xpath('/workbook/@source-build').first.text
|
92
|
+
else
|
93
|
+
@ndoc.xpath('/workbook/comment()').text.gsub(/^[^0-9]+/,'').strip
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
80
97
|
def loaddatasources
|
81
98
|
# puts "LOAD DATA SOURCES"
|
82
99
|
# @dataSourcesNode = @ndoc.at_xpath('//workbook/datasources')
|
83
100
|
@datasources = Set.new
|
84
|
-
@datasourceNames =
|
85
|
-
@datasourceUINames =
|
101
|
+
@datasourceNames = SortedSet.new
|
102
|
+
@datasourceUINames = SortedSet.new
|
86
103
|
@dataSourceNamesMap = {}
|
87
104
|
datasourceNodes = @ndoc.xpath('//workbook/datasources/datasource')
|
88
105
|
# puts "DATASOURCENODES : #{@datasourceNodes.length}"
|
89
106
|
datasourceNodes.each do |node|
|
90
|
-
datasource = Twb::DataSource.new(node)
|
107
|
+
datasource = Twb::DataSource.new(node,self)
|
91
108
|
@datasources << datasource
|
92
109
|
@datasourceNames << datasource.name
|
93
110
|
@datasourceNames << datasource.uiname
|
data/lib/twb/worksheet.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# Copyright (C) 2014,
|
1
|
+
# Copyright (C) 2014, 2018 Chris Gerrard
|
2
2
|
#
|
3
3
|
# This program is free software: you can redistribute it and/or modify
|
4
4
|
# it under the terms of the GNU General Public License as published by
|
@@ -18,22 +18,13 @@ require 'digest/md5'
|
|
18
18
|
|
19
19
|
module Twb
|
20
20
|
|
21
|
-
class WorksheetDataSource
|
22
|
-
attr_reader :node, :name, :caption, :uiname
|
23
|
-
def initialize node
|
24
|
-
@node = node
|
25
|
-
@caption = node.attr('caption')
|
26
|
-
@name = node.attr('name')
|
27
|
-
@uiname = if @caption.nil? || @caption == '' then @name else @caption end
|
28
|
-
end
|
29
|
-
end
|
30
|
-
|
31
21
|
class Worksheet
|
32
22
|
|
33
23
|
@@hasher = Digest::SHA256.new
|
34
24
|
|
35
25
|
attr_reader :node, :name, :datasourcenames, :datasources
|
36
|
-
attr_reader :
|
26
|
+
attr_reader :panesCount
|
27
|
+
attr_reader :fields, :rowFields, :colFields, :paneFields, :datasourceFields
|
37
28
|
|
38
29
|
def initialize sheetNode
|
39
30
|
@node = sheetNode
|
@@ -61,33 +52,34 @@ module Twb
|
|
61
52
|
end
|
62
53
|
|
63
54
|
def datasourceFields
|
64
|
-
@datasourceFields
|
55
|
+
@datasourceFields ||= loadFields
|
65
56
|
end
|
66
57
|
|
67
58
|
def fields
|
68
|
-
@fields
|
59
|
+
@fields ||= loadFields
|
60
|
+
# @fields.nil? ? loadFields : @fields
|
69
61
|
end
|
70
62
|
|
71
63
|
def loadFields
|
72
64
|
# puts "WORKSHEET loadFields"
|
65
|
+
@fields = {}
|
73
66
|
@datasourceFields = {}
|
74
67
|
dsNodes = @node.xpath('.//datasource-dependencies')
|
75
68
|
dsNodes.each do |dsn|
|
76
|
-
dsName
|
77
|
-
|
69
|
+
dsName = dsn.attribute('datasource').text
|
70
|
+
dsFields = @datasourceFields[dsName] = Set.new
|
78
71
|
fieldNodes = dsn.xpath('./column')
|
79
72
|
fieldNodes.each do |fn|
|
80
|
-
|
81
|
-
dsfs.push fname.text.gsub(/^\[/,'').gsub(/\]$/,'') unless fname.nil?
|
73
|
+
dsFields.add WorksheetField.new fn, dsName
|
82
74
|
end
|
83
75
|
end
|
76
|
+
@fields[:dsfields] = @datasourceFields
|
84
77
|
# <rows>([DATA Connection (copy 2)].[none:JIRA HARVEST Correspondence Name:nk] / [DATA Connection (copy 2)].[none:CreatedDate (Issue) (DAY):ok])</rows>
|
85
78
|
# <cols>[DATA Connection (copy 2)].[:Measure Names]</cols>
|
86
|
-
@
|
87
|
-
@
|
88
|
-
@
|
89
|
-
@
|
90
|
-
@fields[:cols] = @colFields
|
79
|
+
@rowFields ||= loadRowColFields(:rows) # returns map of data source => (Set of) field names
|
80
|
+
@fields[:rows] = @rowFields
|
81
|
+
@colFields ||= loadRowColFields(:cols) # returns map of data source => (Set of) field names
|
82
|
+
@fields[:cols] = @colFields
|
91
83
|
end
|
92
84
|
|
93
85
|
def addDSFields fields, usage
|
@@ -95,41 +87,142 @@ module Twb
|
|
95
87
|
end
|
96
88
|
end
|
97
89
|
|
98
|
-
def
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
f1 = parts[1].gsub(/^pcto:|^fVal:/,'').gsub(/^[a-z]+:/,'')
|
113
|
-
# /^pcto:|^fVal:/ - special handling based upon actual codings
|
114
|
-
|
115
|
-
# remove terminating )s
|
116
|
-
f2 = f1.gsub(/[\)]+$/,'')
|
117
|
-
|
118
|
-
# remove suffix(es), if any
|
119
|
-
f3 = f2.gsub(/:[0-9]+[\]]?$/,'').gsub(/:[a-z]+[\]]?$|[\]]?$/,'')
|
120
|
-
# /:[0-9]+[\]]?$/ - special handling based upon actual codings
|
121
|
-
|
122
|
-
dsFields[ds] = Set.new unless dsFields.include? ds
|
123
|
-
dsFields[ds].add(f3)
|
124
|
-
end
|
90
|
+
def panesCount
|
91
|
+
@panesCount ||= @panesCount = node.xpath('.//panes/pane').length
|
92
|
+
end
|
93
|
+
|
94
|
+
def loadRowColFields(type)
|
95
|
+
fields = []
|
96
|
+
xpath = case type
|
97
|
+
when :rows then './/rows'
|
98
|
+
when :cols then './/cols'
|
99
|
+
end
|
100
|
+
return fields if xpath.nil?
|
101
|
+
node = @node.at_xpath(xpath)
|
102
|
+
RowsColsSplitter.new(node).fields.each do |fcode|
|
103
|
+
fields << CodedField.new(fcode)
|
125
104
|
end
|
126
|
-
return
|
105
|
+
return fields
|
106
|
+
end
|
107
|
+
|
108
|
+
def paneFields
|
109
|
+
@paneFields ||= loadPaneFields
|
110
|
+
end
|
111
|
+
|
112
|
+
def loadPaneFields
|
113
|
+
@paneFields = Set.new
|
114
|
+
panes = @node.xpath('.//pane')
|
127
115
|
end
|
128
116
|
|
129
117
|
def datasourcenames
|
130
118
|
@datasources.keys
|
131
119
|
end
|
132
|
-
|
133
120
|
end
|
134
121
|
|
135
|
-
|
122
|
+
class WorksheetDataSource
|
123
|
+
# --
|
124
|
+
attr_reader :node, :name, :caption, :uiname
|
125
|
+
# --
|
126
|
+
def initialize node
|
127
|
+
@node = node
|
128
|
+
@caption = node.attr('caption')
|
129
|
+
@name = node.attr('name').gsub(/^\[/,'').gsub(/\]$/,'')
|
130
|
+
@uiname = if @caption.nil? || @caption == '' then @name else @caption end
|
131
|
+
end
|
132
|
+
end # class WorksheetDataSource
|
133
|
+
|
134
|
+
|
135
|
+
class WorksheetField
|
136
|
+
# --
|
137
|
+
attr_reader :datasource, :name, :caption, :uiname, :node
|
138
|
+
# --
|
139
|
+
def initialize node, dsName
|
140
|
+
@node = node
|
141
|
+
@name = node.attr('name').gsub(/^\[/,'').gsub(/\]$/,'')
|
142
|
+
@caption = node.attr('caption').nil? ? nil : node.attr('caption').gsub(/^\[/,'').gsub(/\]$/,'')
|
143
|
+
@uiname = if @caption.nil? || @caption == '' then @name else @caption end
|
144
|
+
@datasource = dsName
|
145
|
+
end
|
146
|
+
# --
|
147
|
+
def to_s
|
148
|
+
"name:#{@name}|caption:#{@caption}|uiname:#{@uiname}||ds:#{datasource}"
|
149
|
+
end
|
150
|
+
end # WorksheetField
|
151
|
+
|
152
|
+
|
153
|
+
class RowsColsSplitter
|
154
|
+
include Comparable
|
155
|
+
attr_reader :fields
|
156
|
+
def initialize node
|
157
|
+
@fields = []
|
158
|
+
return fields if node.nil?
|
159
|
+
unless node.text.nil?
|
160
|
+
codes = node.text.split(/[\])] [\/+*] [(]?\[/)
|
161
|
+
codes.each {|c| @fields << c.gsub(/^[(]*[\[]*|[)\]]*$/,'')}
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end # class RowsColsSplitter
|
165
|
+
|
166
|
+
|
167
|
+
|
168
|
+
class CodedField
|
169
|
+
|
170
|
+
include Comparable
|
171
|
+
|
172
|
+
attr_reader :code, :dataSource, :prefix, :name, :suffix
|
173
|
+
|
174
|
+
def initialize incode
|
175
|
+
# puts "CF: #{incode}"
|
176
|
+
@code = incode
|
177
|
+
trimCode = code.gsub(/^\[/,'').gsub(/\]$/,'')
|
178
|
+
# puts " : '#{trimCode}'"
|
179
|
+
@dataSource = nil
|
180
|
+
dsFldBits = trimCode.split('].[')
|
181
|
+
if dsFldBits.length == 2
|
182
|
+
@dataSource = dsFldBits[0]
|
183
|
+
fieldCode = dsFldBits[1]
|
184
|
+
else
|
185
|
+
fieldCode = dsFldBits[0]
|
186
|
+
end
|
187
|
+
# puts "fc: #{fieldCode}"
|
188
|
+
parts = fieldCode.split(':')
|
189
|
+
bits =
|
190
|
+
if ':Measure Names'.eql?(fieldCode)
|
191
|
+
{:prefix => nil, :name => fieldCode, :suffix => nil }
|
192
|
+
else
|
193
|
+
case parts.length
|
194
|
+
when 1
|
195
|
+
bits = {:prefix => nil, :name => parts[0], :suffix => nil}
|
196
|
+
when 3
|
197
|
+
bits = {:prefix => parts[0], :name => parts[1], :suffix => parts[2]}
|
198
|
+
when 4
|
199
|
+
if parts[-1].match(/^(\d)+$/)
|
200
|
+
bits = {:prefix => parts[0], :name => parts[1], :suffix => "#{parts[2]}:#{parts[3]}"}
|
201
|
+
else
|
202
|
+
bits = {:prefix => "#{parts[0]}:#{parts[1]}", :name => parts[2], :suffix => parts[3]}
|
203
|
+
end
|
204
|
+
when 5
|
205
|
+
bits = {:prefix => "#{parts[0]}:#{parts[1]}", :name => parts[2], :suffix => "#{parts[3]}:#{parts[4]}"}
|
206
|
+
else
|
207
|
+
bits = {:prefix => "OOPS #{parts.inspect}", :name => '', :suffix => '' }
|
208
|
+
end
|
209
|
+
end
|
210
|
+
@prefix = bits[:prefix]
|
211
|
+
@name = bits[:name]
|
212
|
+
@suffix = bits[:suffix]
|
213
|
+
# puts " l: #{parts.length} == #{bits}"
|
214
|
+
# bits.each { |k,v| puts " l: %-7s %s" % [k,v] }
|
215
|
+
end
|
216
|
+
# --
|
217
|
+
def to_s
|
218
|
+
@code
|
219
|
+
end
|
220
|
+
|
221
|
+
def <=>(other)
|
222
|
+
@code <=> other.code
|
223
|
+
end
|
224
|
+
|
225
|
+
end #class CodedField
|
226
|
+
|
227
|
+
|
228
|
+
end # module Twb
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: twb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.9.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Chris Gerrard
|
@@ -24,9 +24,13 @@ files:
|
|
24
24
|
- lib/twb.rb
|
25
25
|
- lib/twb/TwbTest.rb
|
26
26
|
- lib/twb/action.rb
|
27
|
-
- lib/twb/analysis/
|
28
|
-
- lib/twb/
|
29
|
-
- lib/twb/
|
27
|
+
- lib/twb/analysis/CalculatedFields/CSVEmitter.rb
|
28
|
+
- lib/twb/analysis/CalculatedFields/CalculatedFieldsAnalyzer.rb
|
29
|
+
- lib/twb/analysis/CalculatedFields/MarkdownEmitter.rb
|
30
|
+
- lib/twb/analysis/DataSources/DataSourceTableFieldsCSVEmitter.rb
|
31
|
+
- lib/twb/analysis/Sheets/WorksheetDataStructureCSVEmitter.rb
|
32
|
+
- lib/twb/calculatedfield.rb
|
33
|
+
- lib/twb/columnfield.rb
|
30
34
|
- lib/twb/dashboard.rb
|
31
35
|
- lib/twb/dashboard.txt
|
32
36
|
- lib/twb/datasource.rb
|
@@ -47,11 +51,13 @@ files:
|
|
47
51
|
- lib/twb/storyboard.rb
|
48
52
|
- lib/twb/there.rb
|
49
53
|
- lib/twb/util/dotFileRenderer.rb
|
54
|
+
- lib/twb/util/ftpPublisher.rb
|
50
55
|
- lib/twb/util/graphedge.rb
|
51
56
|
- lib/twb/util/graphedges.rb
|
52
57
|
- lib/twb/util/graphnode.rb
|
53
58
|
- lib/twb/util/hashtohtml.rb
|
54
59
|
- lib/twb/util/htmllistcollapsible.rb
|
60
|
+
- lib/twb/util/joinUtilities.rb
|
55
61
|
- lib/twb/util/twbDashSheetDataDotBuilder.rb
|
56
62
|
- lib/twb/util/twbDashSheetDataDotRenderer.rb
|
57
63
|
- lib/twb/util/twbDashesSheetDataDotBuilder.rb
|
@@ -90,7 +96,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
96
|
version: '0'
|
91
97
|
requirements: []
|
92
98
|
rubyforge_project:
|
93
|
-
rubygems_version: 2.6.
|
99
|
+
rubygems_version: 2.6.13
|
94
100
|
signing_key:
|
95
101
|
specification_version: 4
|
96
102
|
summary: Classes for accessing Tableau Workbooks and their contents.
|