t2-web 0.0.5
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +18 -0
- data/LICENSE +23 -0
- data/README +41 -0
- data/Rakefile +54 -0
- data/bin/t2_webapp.rb +295 -0
- data/public/css/form.css +70 -0
- data/public/css/results_header.css +23 -0
- data/public/css/results_navigation.css +17 -0
- data/public/css/tipsy.css +7 -0
- data/public/images/info.png +0 -0
- data/public/images/lumc_logo2.png +0 -0
- data/public/images/nbic_logo.gif +0 -0
- data/public/images/snake_transparent.gif +0 -0
- data/public/images/tipsy.gif +0 -0
- data/public/scripts/form.js +98 -0
- data/public/scripts/jquery-1.6.1.js +8936 -0
- data/public/scripts/jquery.tipsy.js +104 -0
- data/public/scripts/results.js +25 -0
- data/views/form.haml +173 -0
- data/views/results.haml +28 -0
- metadata +165 -0
data/CHANGES
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
= Changes log for the Taverna via the Web Gem
|
2
|
+
|
3
|
+
== Version 0.0.5
|
4
|
+
* Updated to new myexperiment-rest API
|
5
|
+
|
6
|
+
== Version 0.0.4
|
7
|
+
* Improved the UI (added logos, tooltips, changed colours).
|
8
|
+
* Made some code abstractions
|
9
|
+
|
10
|
+
== Version 0.0.3
|
11
|
+
* Improved the UI (changed divs with frames).
|
12
|
+
|
13
|
+
== Version 0.0.2
|
14
|
+
* Added upload functionality option for each input.
|
15
|
+
|
16
|
+
== Version 0.0.1
|
17
|
+
* Works. Very basic functionality and UI.
|
18
|
+
|
data/LICENSE
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
== t2-web
|
2
|
+
|
3
|
+
The MIT License
|
4
|
+
|
5
|
+
Copyright (c) 2010 - Netherlands Bioinformatics Centre
|
6
|
+
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
9
|
+
in the Software without restriction, including without limitation the rights
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
12
|
+
furnished to do so, subject to the following conditions:
|
13
|
+
|
14
|
+
The above copyright notice and this permission notice shall be included in
|
15
|
+
all copies or substantial portions of the Software.
|
16
|
+
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
23
|
+
THE SOFTWARE.
|
data/README
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
= t2-web interface generator
|
2
|
+
|
3
|
+
|
4
|
+
Authors:: Konstantinos Karasavvas
|
5
|
+
Gem Version:: 0.0.5
|
6
|
+
Contact:: mailto:kostas.karasavvas@nbic.nl
|
7
|
+
Licence:: MIT (See LICENCE or http://www.opensource.org/licenses/mit-license)
|
8
|
+
Copyright:: (c) 2010 Netherlands Bioinformatics Centre, The Netherlands
|
9
|
+
|
10
|
+
|
11
|
+
== Synopsis
|
12
|
+
|
13
|
+
This web application accepts a myExperiment workflow identifier and constructs
|
14
|
+
a web form to allow end-users to configure and execute that workflow via a web
|
15
|
+
browser with no other dependencies.. The web form interface is as good as the
|
16
|
+
description provided in the workflow itself in myExperiment.
|
17
|
+
|
18
|
+
|
19
|
+
== Installation
|
20
|
+
|
21
|
+
[sudo] gem install t2-web
|
22
|
+
|
23
|
+
|
24
|
+
== Usage
|
25
|
+
|
26
|
+
t2_webapp
|
27
|
+
|
28
|
+
Runs the web application at port 9494 and waits for incoming requests.
|
29
|
+
|
30
|
+
|
31
|
+
|
32
|
+
== References
|
33
|
+
|
34
|
+
Taverna to Web Form Generator:: https://trac.nbic.nl/elabfactory/wiki/t2web
|
35
|
+
Taverna2:: http://www.taverna.org.uk
|
36
|
+
myExperiment:: http://www.myexperiment.org
|
37
|
+
|
38
|
+
|
39
|
+
== Semantic Versioning
|
40
|
+
|
41
|
+
This module uses semantic versioning concepts from http://semver.org/.
|
data/Rakefile
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
#
|
2
|
+
# To change this template, choose Tools | Templates
|
3
|
+
# and open the template in the editor.
|
4
|
+
|
5
|
+
|
6
|
+
require 'rubygems'
|
7
|
+
require 'rake'
|
8
|
+
require 'rake/clean'
|
9
|
+
require 'rake/testtask'
|
10
|
+
require 'rubygems/package_task'
|
11
|
+
require 'rdoc/task'
|
12
|
+
|
13
|
+
spec = Gem::Specification.new do |s|
|
14
|
+
s.name = 't2-web'
|
15
|
+
s.version = '0.0.5'
|
16
|
+
s.extra_rdoc_files = ['README', 'LICENSE', 'CHANGES']
|
17
|
+
s.summary = 'WS (with libs) that generates a Web UI form for a Taverna2 workflow and then enacts it to a T2 server.'
|
18
|
+
s.description = s.summary
|
19
|
+
s.author = 'Kostas Karasavvas'
|
20
|
+
s.email = 'kostas.karasavvas@nbic.nl'
|
21
|
+
s.executables = ['t2_webapp.rb']
|
22
|
+
s.files = %w(LICENSE README CHANGES Rakefile) + Dir.glob("{bin,lib,spec,public,views}/**/*")
|
23
|
+
s.require_path = "lib"
|
24
|
+
s.bindir = "bin"
|
25
|
+
s.add_dependency 'sinatra', '~> 1.2.6'
|
26
|
+
s.add_dependency 'haml', '~> 3.1.2'
|
27
|
+
s.add_dependency 'rest-client', '~> 1.6.3'
|
28
|
+
s.add_dependency 't2-server', '~> 0.6.1'
|
29
|
+
s.add_dependency 'myexperiment-rest', '~> 0.3.0'
|
30
|
+
end
|
31
|
+
|
32
|
+
Gem::PackageTask.new(spec) do |p|
|
33
|
+
p.gem_spec = spec
|
34
|
+
p.need_tar = true
|
35
|
+
p.need_zip = true
|
36
|
+
end
|
37
|
+
|
38
|
+
RDoc::Task.new do |rdoc|
|
39
|
+
files =['README', 'LICENSE', 'CHANGES', 'lib/**/*.rb']
|
40
|
+
rdoc.rdoc_files.add(files)
|
41
|
+
rdoc.main = "README" # page to start on
|
42
|
+
rdoc.title = "web-t2 Docs"
|
43
|
+
rdoc.rdoc_dir = 'doc/rdoc' # rdoc output folder
|
44
|
+
rdoc.options << '--line-numbers'
|
45
|
+
end
|
46
|
+
|
47
|
+
Rake::TestTask.new do |t|
|
48
|
+
t.test_files = FileList['test/**/*.rb']
|
49
|
+
end
|
50
|
+
|
51
|
+
# make gem and copy to test deployment server
|
52
|
+
task :copy_to_server => :gem do
|
53
|
+
`scp pkg/#{spec.name}-#{spec.version}.gem kostas@mybiobank.org:/home/kostas`
|
54
|
+
end
|
data/bin/t2_webapp.rb
ADDED
@@ -0,0 +1,295 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'rubygems' if RUBY_VERSION < '1.9'
|
4
|
+
require 'sinatra'
|
5
|
+
require 'haml'
|
6
|
+
require 'open-uri'
|
7
|
+
require 'rest-client'
|
8
|
+
require 't2-server'
|
9
|
+
require 'myexperiment-rest'
|
10
|
+
require 'cgi'
|
11
|
+
#require "web-t2" TODO: move whole of WebT2App in lib
|
12
|
+
|
13
|
+
|
14
|
+
class WebT2App < Sinatra::Base
|
15
|
+
|
16
|
+
WEB_APP_NAME = "t2web"
|
17
|
+
# TODO: const for TMP_UPLOAD_PATH ? - need to read from config file?
|
18
|
+
|
19
|
+
set :port, 9494
|
20
|
+
set :views, File.dirname(__FILE__) + '/../views'
|
21
|
+
set :public, File.dirname(__FILE__) + '/../public'
|
22
|
+
|
23
|
+
# can be used from routes and views (haml)
|
24
|
+
helpers do
|
25
|
+
|
26
|
+
# Used before we sent the workflow inputs to the taverna server. Just converts
|
27
|
+
# double quotations to single ones. That is because the t2-server library
|
28
|
+
# is confused with double quotes and apparently it is not easy to fix.
|
29
|
+
def t2_library_sanitize(string)
|
30
|
+
ex = string.gsub('"', "'")
|
31
|
+
end
|
32
|
+
|
33
|
+
#
|
34
|
+
# Deletes last new line of file if it exists! It is needed for t2 workflows that
|
35
|
+
# do not sanitize properly, i.e. via a user-provided beanshell script
|
36
|
+
#
|
37
|
+
def chomp_last_newline(file)
|
38
|
+
|
39
|
+
if File.file?(file) and File.size(file) > 1
|
40
|
+
f = open(file, "rb+")
|
41
|
+
f.seek(-1, File::SEEK_END)
|
42
|
+
f.truncate(File.size(file) - 1) if f.read(1) == "\n"
|
43
|
+
f.close
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
48
|
+
# Construct the complete or partial URL as MyExperimentRest lib expects it
|
49
|
+
# TODO: Maybe update myExperimentREST lib to accept only wid and wkf_version
|
50
|
+
# Minor for now
|
51
|
+
def get_url_from_wid(wid, wkf_version)
|
52
|
+
if wkf_version == "default"
|
53
|
+
"/workflows/#{wid}"
|
54
|
+
else
|
55
|
+
"/workflows/#{wid}?version=#{wkf_version}"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
# Generates the contents of the header frame
|
61
|
+
def generate_header_frame(my_exp_wkf, my_exp_usr)
|
62
|
+
<<END
|
63
|
+
<html>
|
64
|
+
<head><link href='/css/form.css' rel='stylesheet'/></head>
|
65
|
+
<body>
|
66
|
+
#{generate_header_table(my_exp_wkf, my_exp_usr)}
|
67
|
+
</body>
|
68
|
+
</html>
|
69
|
+
END
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
# Generates the contents of the header frame
|
74
|
+
def generate_header_table(my_exp_wkf, my_exp_usr)
|
75
|
+
<<END
|
76
|
+
<div id='header'>
|
77
|
+
<table class='header'>
|
78
|
+
<tr>
|
79
|
+
<td>
|
80
|
+
<img alt='NBIC logo' src='/images/nbic_logo.gif' />
|
81
|
+
</td>
|
82
|
+
<td>
|
83
|
+
<table class='header-title'>
|
84
|
+
<tr>
|
85
|
+
<td class='header-title'>Workflow: #{@my_exp_wkf.title}</td>
|
86
|
+
</tr>
|
87
|
+
<tr>
|
88
|
+
<td class='right'>workflow by #{@my_exp_usr.name}</td>
|
89
|
+
|
90
|
+
</tr>
|
91
|
+
</table>
|
92
|
+
</td>
|
93
|
+
<td>
|
94
|
+
<img alt='LUMC logo' class='right' src='/images/lumc_logo2.png' />
|
95
|
+
</td>
|
96
|
+
</tr>
|
97
|
+
</table>
|
98
|
+
</div>
|
99
|
+
END
|
100
|
+
end
|
101
|
+
|
102
|
+
|
103
|
+
# Generates tooltip html text for descriptions
|
104
|
+
def generate_label_tooltip(input)
|
105
|
+
"Description: " << CGI::unescapeHTML(input.descriptions[0])
|
106
|
+
end
|
107
|
+
|
108
|
+
# Generates tooltip html text for textarea example
|
109
|
+
def generate_textarea_tooltip(input)
|
110
|
+
"Example: " << CGI::unescapeHTML(input.examples[0])
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
# Generates the contents of the data-navigation frame
|
115
|
+
def generate_data_navigation_frame(my_exp_wkf, uuid, wid, wkf_version, t2_server, finished_execution)
|
116
|
+
|
117
|
+
data_navigation_frame = "<html><head><link href='/css/results_navigation.css' rel='stylesheet'/></head><body><table>"
|
118
|
+
if my_exp_wkf.outputs.size >=1
|
119
|
+
my_exp_wkf.outputs.each do |output|
|
120
|
+
data_navigation_frame << "<tr>"
|
121
|
+
if finished_execution
|
122
|
+
data_navigation_frame << "<td><a href='javascript:void(0);' onclick='parent.getSampleOutput(\\\"#{t2_server}\\\", \\\"#{uuid}\\\", " <<
|
123
|
+
"\\\"#{output.name}\\\", \\\"#{wid}\\\", \\\"#{wkf_version}\\\");'>#{output.name}</a></td><td></td>"
|
124
|
+
else
|
125
|
+
data_navigation_frame << "<td>#{output.name}</td><td><img src='/images/snake_transparent.gif' alt='Loading Content...'></td>"
|
126
|
+
end
|
127
|
+
data_navigation_frame << "</tr>"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
data_navigation_frame << "</table></body></html>"
|
131
|
+
end
|
132
|
+
|
133
|
+
|
134
|
+
# Check type of data and generate appropriate html for that result!
|
135
|
+
def generate_html_result(data)
|
136
|
+
html = ""
|
137
|
+
#p data
|
138
|
+
if data.instance_of? Array
|
139
|
+
html = data.flatten.join('<br>')
|
140
|
+
elsif data.instance_of? String
|
141
|
+
html = data.gsub(/[\n]/, '<br>')
|
142
|
+
else
|
143
|
+
html = data
|
144
|
+
end
|
145
|
+
html
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
|
150
|
+
|
151
|
+
|
152
|
+
# Create the Web UI for that workflow
|
153
|
+
get "/#{WEB_APP_NAME}/workflow/:wid" do
|
154
|
+
|
155
|
+
# Get http request's parameters
|
156
|
+
# TODO: consider adding the following to a session
|
157
|
+
@wid = params[:wid]
|
158
|
+
@wkf_version = params[:wkf_version] || nil
|
159
|
+
@t2_server = params[:server] || "http://test.mybiobank.org/taverna-server"
|
160
|
+
|
161
|
+
# Get myExperiment workflow object
|
162
|
+
# TODO: catch exception -- make custom exceptions?
|
163
|
+
@my_exp_wkf = MyExperimentREST::Workflow.from_id_and_version(@wid, @wkf_version)
|
164
|
+
|
165
|
+
# Get myExperiment user object
|
166
|
+
# TODO: catch exception -- make custom exceptions?
|
167
|
+
@my_exp_usr = MyExperimentREST::User.from_uri(@my_exp_wkf.uploader_uri)
|
168
|
+
|
169
|
+
haml :form
|
170
|
+
|
171
|
+
end
|
172
|
+
|
173
|
+
|
174
|
+
# Enact the workflow with the submitted parameters and returns
|
175
|
+
# result display (involves checking run's status and getting results)
|
176
|
+
post "/#{WEB_APP_NAME}/enact" do
|
177
|
+
#p params
|
178
|
+
|
179
|
+
# Get http POST request's parameters
|
180
|
+
# TODO: consider adding the following to a session
|
181
|
+
@wid = params[:wid]
|
182
|
+
@wkf_version = params[:wkf_version]
|
183
|
+
@t2_server = params[:server]
|
184
|
+
|
185
|
+
@my_exp_wkf = MyExperimentREST::Workflow.from_id_and_version(@wid, @wkf_version)
|
186
|
+
|
187
|
+
# Get myExperiment user object (TODO: session!, exceptions)
|
188
|
+
@my_exp_usr = MyExperimentREST::User.from_uri(@my_exp_wkf.uploader_uri)
|
189
|
+
|
190
|
+
|
191
|
+
# use the uri reference to download the workflow locally
|
192
|
+
#wkf_file = URI.parse(@my_exp_wkf.content_uri)
|
193
|
+
wkf = open(@my_exp_wkf.content_uri).read
|
194
|
+
|
195
|
+
# create run
|
196
|
+
begin
|
197
|
+
run = T2Server::Run.create(@t2_server, wkf)
|
198
|
+
rescue T2Server::T2ServerError => e
|
199
|
+
return "404 Not Found: run could not be instantiated!"
|
200
|
+
end
|
201
|
+
|
202
|
+
# Get the run as instance member to make visible in views
|
203
|
+
@run_uuid = run.uuid
|
204
|
+
|
205
|
+
# Set workflow inputs
|
206
|
+
@my_exp_wkf.inputs.each do |input|
|
207
|
+
if params[:"upload-checkbox-#{input.name}"] == "yes"
|
208
|
+
filename = params[:"#{input.name}-file"]
|
209
|
+
tmp_filename = "/tmp/#{WEB_APP_NAME}/#{filename}"
|
210
|
+
|
211
|
+
# make sure that the file is uploaded completely, i.e. all it's
|
212
|
+
# connections are closed
|
213
|
+
#while `lsof -c cp | grep /tmp/#{WEB_APP_NAME}/#{filename}` != ""
|
214
|
+
# sleep 1
|
215
|
+
#end
|
216
|
+
|
217
|
+
chomp_last_newline(tmp_filename)
|
218
|
+
run.upload_input_file(input.name, tmp_filename)
|
219
|
+
else
|
220
|
+
run.set_input(input.name, t2_library_sanitize(params["#{input.name}-input".to_sym]) )
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# start run and wait until it is finished
|
225
|
+
run.start
|
226
|
+
|
227
|
+
haml :results
|
228
|
+
end
|
229
|
+
|
230
|
+
|
231
|
+
|
232
|
+
# Proxy operation (to bypass cross-domain AJAX) to get T2 run's status
|
233
|
+
get '/runs/:uuid/status' do
|
234
|
+
uuid = params[:uuid]
|
235
|
+
t2_server = params[:server]
|
236
|
+
|
237
|
+
response = RestClient.get t2_server + "/rest/runs/" + uuid + "/status", :content_type => "text/plain"
|
238
|
+
|
239
|
+
response.to_str
|
240
|
+
end
|
241
|
+
|
242
|
+
|
243
|
+
|
244
|
+
# Get results for specified run and output - display it in data-display div
|
245
|
+
# Cross-domain AJAX to get result from T2
|
246
|
+
get "/#{WEB_APP_NAME}/run/:uuid/output/:out" do
|
247
|
+
|
248
|
+
# Get http request's parameters
|
249
|
+
# TODO: consider adding the following to a session
|
250
|
+
@wid = params[:wid]
|
251
|
+
@wkf_version = params[:wkf_version]
|
252
|
+
|
253
|
+
# Get myExperiment workflow object
|
254
|
+
# TODO: with sessions that would be remembered
|
255
|
+
@my_exp_wkf = MyExperimentREST::Workflow.from_id_and_version(@wid, @wkf_version)
|
256
|
+
|
257
|
+
@run_uuid = params[:uuid]
|
258
|
+
@t2_server = params[:server]
|
259
|
+
t2_output = params[:out]
|
260
|
+
|
261
|
+
# Get t2 server and then the run using uuid
|
262
|
+
server = T2Server::Server.connect(@t2_server)
|
263
|
+
run = server.run(@run_uuid)
|
264
|
+
|
265
|
+
# Get output from t2 server
|
266
|
+
# TODO: that gets the whole output.. request client lib to support partial
|
267
|
+
# download! -- refs could then be used to download the full results!
|
268
|
+
begin
|
269
|
+
data_lists = run.get_output(t2_output, false)
|
270
|
+
rescue Exception => e
|
271
|
+
data_lists = run.get_output("#{t2_output}.error", false)
|
272
|
+
end
|
273
|
+
|
274
|
+
# that should be a sample of the results -- not the complete results!
|
275
|
+
data = generate_html_result(data_lists)
|
276
|
+
|
277
|
+
data
|
278
|
+
end
|
279
|
+
|
280
|
+
|
281
|
+
# Used from input's upload-form to upload files without refresh (actually
|
282
|
+
# a hidden iframe is refreshed).
|
283
|
+
post "/#{WEB_APP_NAME}/upload" do
|
284
|
+
filename = params[:file][:filename]
|
285
|
+
tempfile = params[:file][:tempfile]
|
286
|
+
FileUtils.mkdir("/tmp/#{WEB_APP_NAME}") unless File.exist?("/tmp/#{WEB_APP_NAME}")
|
287
|
+
FileUtils.copy(tempfile.path, "/tmp/#{WEB_APP_NAME}/#{filename}")
|
288
|
+
"Successfully copied #{filename}!!"
|
289
|
+
end
|
290
|
+
|
291
|
+
end
|
292
|
+
|
293
|
+
|
294
|
+
# Start Web App
|
295
|
+
WebT2App.run!
|
data/public/css/form.css
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
body{
|
2
|
+
background-color: #fdfdfd;
|
3
|
+
}
|
4
|
+
|
5
|
+
table.header {
|
6
|
+
width: 100%;
|
7
|
+
}
|
8
|
+
|
9
|
+
table.header-title {
|
10
|
+
border-collapse: collapse;
|
11
|
+
/* With equal left-right margins table will be aligned in the centre */
|
12
|
+
margin-left: auto;
|
13
|
+
margin-right: auto;
|
14
|
+
}
|
15
|
+
|
16
|
+
td.header-title {
|
17
|
+
font-size: 200%;
|
18
|
+
background-color: #f0f0f0;
|
19
|
+
padding: 12px;
|
20
|
+
border-bottom: 4px solid #909090;
|
21
|
+
border-top: 4px solid #909090;
|
22
|
+
}
|
23
|
+
|
24
|
+
#inputs {
|
25
|
+
}
|
26
|
+
|
27
|
+
table.inputs {
|
28
|
+
width: 800px;
|
29
|
+
border-collapse: collapse;
|
30
|
+
/* With equal left-right margins table will be aligned in the centre */
|
31
|
+
margin-left: auto;
|
32
|
+
margin-right: auto;
|
33
|
+
}
|
34
|
+
|
35
|
+
th.inputs {
|
36
|
+
font-size: 120%;
|
37
|
+
background-color: #f0f0f0;
|
38
|
+
padding: 10px;
|
39
|
+
border-bottom: 1px solid #909090;
|
40
|
+
border-top: 4px solid #909090;
|
41
|
+
}
|
42
|
+
|
43
|
+
td.inputs {
|
44
|
+
padding: 5px;
|
45
|
+
vertical-align: top;
|
46
|
+
}
|
47
|
+
|
48
|
+
td.right {
|
49
|
+
text-align: right;
|
50
|
+
}
|
51
|
+
|
52
|
+
img.right {
|
53
|
+
float: right;
|
54
|
+
}
|
55
|
+
|
56
|
+
#description {
|
57
|
+
}
|
58
|
+
|
59
|
+
#inputs {
|
60
|
+
}
|
61
|
+
|
62
|
+
#outputs {
|
63
|
+
}
|
64
|
+
|
65
|
+
#note {
|
66
|
+
}
|
67
|
+
|
68
|
+
label {
|
69
|
+
text-decoration: underline;
|
70
|
+
}
|