sequenceserver-beta 0.8.7.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -0
  3. data/LICENSE.Apache.txt +176 -0
  4. data/LICENSE.txt +69 -0
  5. data/README.txt +5 -0
  6. data/bin/sequenceserver +82 -0
  7. data/config.ru +6 -0
  8. data/example.config.yml +39 -0
  9. data/lib/profile_code.rb +217 -0
  10. data/lib/sequenceserver.rb +527 -0
  11. data/lib/sequenceserver/blast.rb +92 -0
  12. data/lib/sequenceserver/customisation.rb +60 -0
  13. data/lib/sequenceserver/database.rb +29 -0
  14. data/lib/sequenceserver/database_formatter.rb +190 -0
  15. data/lib/sequenceserver/helpers.rb +136 -0
  16. data/lib/sequenceserver/sequencehelpers.rb +93 -0
  17. data/lib/sequenceserver/sinatralikeloggerformatter.rb +12 -0
  18. data/lib/sequenceserver/version.rb +9 -0
  19. data/public/css/beige.css.css +254 -0
  20. data/public/css/bootstrap.dropdown.css +29 -0
  21. data/public/css/bootstrap.icons.css +155 -0
  22. data/public/css/bootstrap.min.css +415 -0
  23. data/public/css/bootstrap.modal.css +28 -0
  24. data/public/css/custom.css +232 -0
  25. data/public/img/glyphicons-halflings-white.png +0 -0
  26. data/public/img/glyphicons-halflings.png +0 -0
  27. data/public/js/bootstrap.dropdown.js +92 -0
  28. data/public/js/bootstrap.modal.js +7 -0
  29. data/public/js/bootstrap.transition.js +7 -0
  30. data/public/js/jquery-scrollspy.js +98 -0
  31. data/public/js/jquery-ui.js +14987 -0
  32. data/public/js/jquery.activity.js +10 -0
  33. data/public/js/jquery.enablePlaceholder.min.js +10 -0
  34. data/public/js/jquery.js +5 -0
  35. data/public/js/sequenceserver.blast.js +208 -0
  36. data/public/js/sequenceserver.js +304 -0
  37. data/public/js/store.min.js +2 -0
  38. data/sequenceserver.gemspec +49 -0
  39. data/tests/database/nucleotide/Sinvicta2-2-3.cdna.subset.fasta +5486 -0
  40. data/tests/database/nucleotide/Sinvicta2-2-3.cdna.subset.fasta.nhr +0 -0
  41. data/tests/database/nucleotide/Sinvicta2-2-3.cdna.subset.fasta.nin +0 -0
  42. data/tests/database/nucleotide/Sinvicta2-2-3.cdna.subset.fasta.nsq +0 -0
  43. data/tests/database/protein/Sinvicta2-2-3.prot.subset.fasta +6449 -0
  44. data/tests/database/protein/Sinvicta2-2-3.prot.subset.fasta.phr +0 -0
  45. data/tests/database/protein/Sinvicta2-2-3.prot.subset.fasta.pin +0 -0
  46. data/tests/database/protein/Sinvicta2-2-3.prot.subset.fasta.psq +0 -0
  47. data/tests/run +26 -0
  48. data/tests/test_sequencehelpers.rb +77 -0
  49. data/tests/test_sequenceserver_blast.rb +60 -0
  50. data/tests/test_ui.rb +104 -0
  51. data/tests/test_ui.rb~ +104 -0
  52. data/tests/ui.specs.todo +10 -0
  53. data/views/500.erb +22 -0
  54. data/views/_options.erb +144 -0
  55. data/views/search.erb +220 -0
  56. metadata +226 -0
@@ -0,0 +1,527 @@
1
+ # sequenceserver.rb
2
+
3
+ require 'sinatra/base'
4
+ require 'yaml'
5
+ require 'logger'
6
+ require 'fileutils'
7
+ require 'sequenceserver/helpers'
8
+ require 'sequenceserver/blast'
9
+ require 'sequenceserver/sequencehelpers'
10
+ require 'sequenceserver/sinatralikeloggerformatter'
11
+ require 'sequenceserver/customisation'
12
+ require 'sequenceserver/version'
13
+
14
+ # Helper module - initialize the blast server.
15
+ module SequenceServer
16
+ class App < Sinatra::Base
17
+ include Helpers
18
+ include SequenceHelpers
19
+ include SequenceServer::Customisation
20
+
21
+ # Basic configuration settings for app.
22
+ configure do
23
+ # enable some builtin goodies
24
+ enable :session, :logging
25
+
26
+ # main application file
27
+ set :app_file, File.expand_path(__FILE__)
28
+
29
+ # app root is SequenceServer's installation directory
30
+ #
31
+ # SequenceServer figures out different settings, location of static
32
+ # assets or templates for example, based on app root.
33
+ set :root, File.dirname(File.dirname(app_file))
34
+
35
+ # path to test database
36
+ #
37
+ # SequenceServer ships with test database (fire ant genome) so users can
38
+ # launch and preview SequenceServer without any configuration, and/or run
39
+ # test suite.
40
+ set :test_database, File.join(root, 'tests', 'database')
41
+
42
+ # path to example configuration file
43
+ #
44
+ # SequenceServer ships with a dummy configuration file. Users can simply
45
+ # copy it and make necessary changes to get started.
46
+ set :example_config_file, File.join(root, 'example.config.yml')
47
+
48
+ # path to SequenceServer's configuration file
49
+ #
50
+ # The configuration file is a simple, YAML data store.
51
+ set :config_file, Proc.new{ File.expand_path('~/.sequenceserver.conf') }
52
+
53
+ set :log, Proc.new { Logger.new(STDERR) }
54
+ log.formatter = SinatraLikeLogFormatter.new()
55
+ end
56
+
57
+ # Local, app configuration settings derived from config.yml.
58
+ #
59
+ # A config.yml should contain the settings described in the following
60
+ # configure block as key, value pairs. See example.config.yml in the
61
+ # installation directory.
62
+ configure do
63
+ # store the settings hash from config.yml; further configuration values
64
+ # are derived from it
65
+ set :config, {}
66
+
67
+ # absolute path to the blast binaries
68
+ #
69
+ # A default of 'nil' is indicative of blast binaries being present in
70
+ # system PATH.
71
+ set :bin, Proc.new{ File.expand_path(config['bin']) rescue nil }
72
+
73
+ # absolute path to the database directory
74
+ #
75
+ # As a default use 'database' directory relative to current working
76
+ # directory of the running app.
77
+ set :database, Proc.new{ File.expand_path(config['database']) rescue test_database }
78
+
79
+ # the port number to run Sequence Server standalone
80
+ set :port, Proc.new{ (config['port'] or 4567).to_i }
81
+
82
+ # number of threads to be used during blasting
83
+ #
84
+ # This option is passed directly to BLAST+. We use a default value of 1
85
+ # as a higher value may cause BLAST+ to crash if it was not compiled with
86
+ # threading support.
87
+ set :num_threads, Proc.new{ (config['num_threads'] or 1).to_i }
88
+ end
89
+
90
+ # Lookup tables used by Sequence Server to pick up the right blast binary,
91
+ # or database. These tables should be populated during app initialization
92
+ # by scanning bin, and database directories.
93
+ configure do
94
+ # blast methods (executables) and their corresponding absolute path
95
+ set :binaries, {}
96
+
97
+ # list of blast databases indexed by their hash value
98
+ set :databases, {}
99
+ end
100
+
101
+ configure :development do
102
+ log.level = Logger::DEBUG
103
+ end
104
+
105
+ configure(:production) do
106
+ log.level = Logger::INFO
107
+ error do
108
+ erb :'500'
109
+ end
110
+ not_found do
111
+ erb :'500'
112
+ end
113
+ end
114
+
115
+ class << self
116
+ # Run SequenceServer as a self-hosted server.
117
+ #
118
+ # By default SequenceServer uses Thin, Mongrel or WEBrick (in that
119
+ # order). This can be configured by setting the 'server' option.
120
+ def run!(options={})
121
+ set options
122
+
123
+ # perform SequenceServer initializations
124
+ puts "\n== Initializing SequenceServer..."
125
+ init
126
+
127
+ # find out the what server to host SequenceServer with
128
+ handler = detect_rack_handler
129
+ handler_name = handler.name.gsub(/.*::/, '')
130
+
131
+ puts
132
+ log.info("Using #{handler_name} web server.")
133
+
134
+ if handler_name == 'WEBrick'
135
+ puts "\n== We recommend using Thin web server for better performance."
136
+ puts "== To install Thin: [sudo] gem install thin"
137
+ end
138
+
139
+ url = "http://#{bind}:#{port}"
140
+ puts "\n== Launched SequenceServer at: #{url}"
141
+ puts "== Press CTRL + C to quit."
142
+ handler.run(self, :Host => bind, :Port => port, :Logger => Logger.new('/dev/null')) do |server|
143
+ [:INT, :TERM].each { |sig| trap(sig) { quit!(server, handler) } }
144
+ set :running, true
145
+
146
+ # for Thin
147
+ server.silent = true if handler_name == 'Thin'
148
+ end
149
+ rescue Errno::EADDRINUSE, RuntimeError => e
150
+ puts "\n== Failed to start SequenceServer."
151
+ puts "== Is SequenceServer already running at: #{url}"
152
+ end
153
+
154
+ # Stop SequenceServer.
155
+ def quit!(server, handler_name)
156
+ # Use Thin's hard #stop! if available, otherwise just #stop.
157
+ server.respond_to?(:stop!) ? server.stop! : server.stop
158
+ puts "\n== Thank you for using SequenceServer :)." +
159
+ "\n== Please cite: " +
160
+ "\n== Priyam A., Woodcroft B.J., Wurm Y (in prep)." +
161
+ "\n== Sequenceserver: BLAST searching made easy." unless handler_name =~/cgi/i
162
+ end
163
+
164
+ # Initializes the blast server : executables, database. Exit if blast
165
+ # executables, and databses can not be found. Logs the result if logging
166
+ # has been enabled.
167
+ def init
168
+ # first read the user supplied configuration options
169
+ self.config = parse_config
170
+
171
+ # empty config file
172
+ unless config
173
+ log.warn("Empty configuration file: #{config_file} - will assume default settings")
174
+ self.config = {}
175
+ end
176
+
177
+ # scan for blast binaries
178
+ self.binaries = scan_blast_executables(bin).freeze
179
+
180
+ # Log the discovery of binaries.
181
+ binaries.each do |command, path|
182
+ log.info("Found #{command} at #{path}")
183
+ end
184
+
185
+ # scan for blast database
186
+ self.databases = scan_blast_db(database, binaries['blastdbcmd']).freeze
187
+
188
+ # Log the discovery of databases.
189
+ databases.each do |id, database|
190
+ log.info("Found #{database.type} database: #{database.title} at #{database.name}")
191
+ end
192
+ rescue IOError => error
193
+ log.fatal("Fail: #{error}")
194
+ exit
195
+ rescue ArgumentError => error
196
+ log.fatal("Error in config.yml: #{error}")
197
+ puts "YAML is white space sensitive. Is your config.yml properly indented?"
198
+ exit
199
+ rescue Errno::ENOENT # config file not found
200
+ log.info('Configuration file not found')
201
+ FileUtils.cp(example_config_file, config_file)
202
+ log.info("Generated a dummy configuration file: #{config_file}")
203
+ puts "\nPlease edit #{config_file} to indicate the location of your BLAST databases and run SequenceServer again."
204
+ exit
205
+ end
206
+
207
+ # Parse config.yml, and return the resulting hash.
208
+ #
209
+ # This method uses YAML.load_file to read config.yml. Absence of a
210
+ # config.yml is safely ignored as the app should then fall back on
211
+ # default configuration values. Any other error raised by YAML.load_file
212
+ # is not rescued.
213
+ def parse_config
214
+ YAML.load_file( config_file )
215
+ end
216
+ end
217
+
218
+ get '/' do
219
+ erb :search
220
+ end
221
+
222
+ before '/' do
223
+ pass if params.empty?
224
+
225
+ # ensure required params present
226
+ #
227
+ # If a required parameter is missing, SequnceServer returns 'Bad Request
228
+ # (400)' error.
229
+ #
230
+ # See Twitter's [Error Codes & Responses][1] page for reference.
231
+ #
232
+ # [1]: https://dev.twitter.com/docs/error-codes-responses
233
+
234
+ if params[:method].nil? or params[:method].empty?
235
+ halt 400, "No BLAST method provided."
236
+ end
237
+
238
+ if params[:sequence].nil? or params[:sequence].empty?
239
+ halt 400, "No input sequence provided."
240
+ end
241
+
242
+ if params[:databases].nil?
243
+ halt 400, "No BLAST database provided."
244
+ end
245
+
246
+ # ensure params are valid #
247
+
248
+ # only allowed blast methods should be used
249
+ blast_methods = %w|blastn blastp blastx tblastn tblastx|
250
+ unless blast_methods.include?(params[:method])
251
+ halt 400, "Unknown BLAST method: #{params[:method]}."
252
+ end
253
+
254
+ # check the advanced options are sensible
255
+ begin #FIXME
256
+ validate_advanced_parameters(params[:advanced])
257
+ rescue ArgumentError => error
258
+ halt 400, "Advanced parameters invalid: #{error}"
259
+ end
260
+
261
+ # log params
262
+ settings.log.debug('method: ' + params[:method])
263
+ settings.log.debug('sequence: ' + params[:sequence])
264
+ settings.log.debug('database: ' + params[:databases].inspect)
265
+ settings.log.debug('advanced: ' + params[:advanced])
266
+ end
267
+
268
+ post '/' do
269
+ method = params['method']
270
+ databases = params[:databases]
271
+ sequence = params[:sequence]
272
+ advanced_opts = params['advanced']
273
+
274
+ # evaluate empty sequence as nil, otherwise as fasta
275
+ sequence = sequence.empty? ? nil : to_fasta(sequence)
276
+
277
+ # blastn implies blastn, not megablast; but let's not interfere if a user
278
+ # specifies `task` herself
279
+ if method == 'blastn' and not advanced_opts =~ /task/
280
+ advanced_opts << ' -task blastn '
281
+ end
282
+
283
+ method = settings.binaries[ method ]
284
+ databases = params[:databases].map{|index|
285
+ settings.databases[index].name
286
+ }
287
+ advanced_opts << " -num_threads #{settings.num_threads}"
288
+
289
+ # run blast and log
290
+ blast = Blast.new(method, sequence, databases.join(' '), advanced_opts)
291
+ blast.run!
292
+ settings.log.info('Ran: ' + blast.command)
293
+
294
+ unless blast.success?
295
+ halt *blast.error
296
+ end
297
+
298
+ format_blast_results(blast.result, databases)
299
+ end
300
+
301
+ # get '/get_sequence/?id=sequence_ids&db=retreival_databases'
302
+ #
303
+ # Use whitespace to separate entries in sequence_ids (all other chars exist
304
+ # in identifiers) and retreival_databases (we don't allow whitespace in a
305
+ # database's name, so it's safe).
306
+ get '/get_sequence/' do
307
+ sequenceids = params[:id].split(/\s/).uniq # in a multi-blast
308
+ # query some may have been found multiply
309
+ retrieval_databases = params[:db].split(/\s/)
310
+
311
+ settings.log.info("Looking for: '#{sequenceids.join(', ')}' in '#{retrieval_databases.join(', ')}'")
312
+
313
+ # the results do not indicate which database a hit is from.
314
+ # Thus if several databases were used for blasting, we must check them all
315
+ # if it works, refactor with "inject" or "collect"?
316
+ found_sequences = ''
317
+
318
+ retrieval_databases.each do |database| # we need to populate this session variable from the erb.
319
+ sequence = sequence_from_blastdb(sequenceids, database)
320
+ if sequence.empty?
321
+ settings.log.debug("'#{sequenceids.join(', ')}' not found in #{database}")
322
+ else
323
+ found_sequences += sequence
324
+ end
325
+ end
326
+
327
+ found_sequences_count = found_sequences.count('>')
328
+
329
+ out = ''
330
+ # just in case, checking we found right number of sequences
331
+ if found_sequences_count != sequenceids.length
332
+ out <<<<HEADER
333
+ <h1>ERROR: incorrect number of sequences found.</h1>
334
+ <p>Dear user,</p>
335
+
336
+ <p><strong>We have found
337
+ <em>#{found_sequences_count > sequenceids.length ? 'more' : 'less'}</em>
338
+ sequence than expected.</strong></p>
339
+
340
+ <p>This is likely due to a problem with how databases are formatted.
341
+ <strong>Please share this text with the person managing this website so
342
+ they can resolve the issue.</strong></p>
343
+
344
+ <p> You requested #{sequenceids.length} sequence#{sequenceids.length > 1 ? 's' : ''}
345
+ with the following identifiers: <code>#{sequenceids.join(', ')}</code>,
346
+ from the following databases: <code>#{retrieval_databases.join(', ')}</code>.
347
+ But we found #{found_sequences_count} sequence#{found_sequences_count> 1 ? 's' : ''}.
348
+ </p>
349
+
350
+ <p>If sequences were retrieved, you can find them below (but some may be incorrect, so be careful!).</p>
351
+ <hr/>
352
+ HEADER
353
+ end
354
+
355
+ out << "<pre><code>#{found_sequences}</pre></code>"
356
+ out
357
+ end
358
+
359
+ # Ensure a '>sequence_identifier\n' at the start of a sequence.
360
+ #
361
+ # An empty query line appears in the blast report if the leading
362
+ # '>sequence_identifier\n' in the sequence is missing. We prepend
363
+ # the input sequence with user info in such case.
364
+ #
365
+ # > to_fasta("acgt")
366
+ # => 'Submitted_By_127.0.0.1_at_110214-15:33:34\nacgt'
367
+ def to_fasta(sequence)
368
+ sequence.lstrip!
369
+ if sequence[0,1] != '>'
370
+ ip = request.ip.to_s
371
+ time = Time.now.strftime("%y%m%d-%H:%M:%S")
372
+ sequence.insert(0, ">Submitted_By_#{ip}_at_#{time}\n")
373
+ end
374
+ return sequence
375
+ end
376
+
377
+ def format_blast_results(result, databases)
378
+ # Constructing the result in an Array and then calling Array#join is much faster than
379
+ # building up a String and using +=, as older versions of SeqServ did.
380
+ formatted_results = []
381
+
382
+ @all_retrievable_ids = []
383
+ string_of_used_databases = databases.join(' ')
384
+ blast_database_number = 0
385
+ line_number = 0
386
+ started_query = false
387
+ finished_database_summary = false
388
+ finished_alignments = false
389
+ reference_string = ''
390
+ database_summary_string = ''
391
+ result.each do |line|
392
+ line_number += 1
393
+ next if line_number <= 5 #skip the first 5 lines
394
+
395
+ # Add the reference to the end, not the start, of the blast result
396
+ if line_number >= 7 and line_number <= 15
397
+ reference_string += line
398
+ next
399
+ end
400
+
401
+ if !finished_database_summary and line_number > 15
402
+ database_summary_string += line
403
+ finished_database_summary = true if line.match(/total letters/)
404
+ next
405
+ end
406
+
407
+ # Remove certain lines from the output
408
+ skipped_lines = [/^<\/BODY>/,/^<\/HTML>/,/^<\/PRE>/]
409
+ skip = false
410
+ skipped_lines.each do |skippy|
411
+ # $stderr.puts "`#{line}' matches #{skippy}?"
412
+ if skippy.match(line)
413
+ skip = true
414
+ # $stderr.puts 'yes'
415
+ else
416
+ # $stderr.puts 'no'
417
+ end
418
+ end
419
+ next if skip
420
+
421
+ # Remove the javascript inclusion
422
+ line.gsub!(/^<script src=\"blastResult.js\"><\/script>/, '')
423
+
424
+ if line.match(/^>/) # If line to possibly replace
425
+ # Reposition the anchor to the end of the line, so that it both still works and
426
+ # doesn't interfere with the diagnostic space at the beginning of the line.
427
+ #
428
+ # There are two cases:
429
+ #
430
+ # database formatted _with_ -parse_seqids
431
+ line.gsub!(/^>(.+)(<a.*><\/a>)(.*)/, '>\1\3\2')
432
+ #
433
+ # database formatted _without_ -parse_seqids
434
+ line.gsub!(/^>(<a.*><\/a>)(.*)/, '>\2\1')
435
+
436
+ # get hit coordinates -- useful for linking to genome browsers
437
+ hit_length = result[line_number..-1].index{|l| l =~ />lcl|Lambda/}
438
+ hit_coordinates = result[line_number, hit_length].grep(/Sbjct/).
439
+ map(&:split).map{|l| [l[1], l[-1]]}.flatten.map(&:to_i).minmax
440
+
441
+ # Create the hyperlink (if required)
442
+ formatted_results << construct_sequence_hyperlink_line(line, databases, hit_coordinates)
443
+ else
444
+ # Surround each query's result in <div> tags so they can be coloured by CSS
445
+ if matches = line.match(/^<b>Query=<\/b> (.*)/) # If starting a new query, then surround in new <div> tag, and finish the last one off
446
+ line = "<div class=\"resultn\" id=\"#{matches[1]}\">\n<h3>Query= #{matches[1]}</h3><pre>"
447
+ unless blast_database_number == 0
448
+ line = "</pre></div>\n#{line}"
449
+ end
450
+ blast_database_number += 1
451
+ elsif line.match(/^ Database: /) and !finished_alignments
452
+ formatted_results << "</div>\n<pre>#{database_summary_string}\n\n"
453
+ finished_alignments = true
454
+ end
455
+ formatted_results << line
456
+ end
457
+ end
458
+ formatted_results << "</pre>"
459
+
460
+ link_to_fasta_of_all = "/get_sequence/?id=#{@all_retrievable_ids.join(' ')}&db=#{string_of_used_databases}"
461
+ # #dbs must be sep by ' '
462
+ retrieval_text = @all_retrievable_ids.empty? ? '' : "<a href='#{url(link_to_fasta_of_all)}'>FASTA of #{@all_retrievable_ids.length} retrievable hit(s)</a>"
463
+
464
+ "<h2>Results</h2>"+
465
+ retrieval_text +
466
+ "<br/><br/>" +
467
+ formatted_results.join +
468
+ "<br/>" +
469
+ "<pre>#{reference_string.strip}</pre>"
470
+ end
471
+
472
+ def construct_sequence_hyperlink_line(line, databases, hit_coordinates)
473
+ matches = line.match(/^>(.+)/)
474
+ sequence_id = matches[1]
475
+
476
+ link = nil
477
+
478
+ # If a custom sequence hyperlink method has been defined,
479
+ # use that.
480
+ options = {
481
+ :sequence_id => sequence_id,
482
+ :databases => databases,
483
+ :hit_coordinates => hit_coordinates
484
+ }
485
+
486
+ # First precedence: construct the whole line to be customised
487
+ if self.respond_to?(:construct_custom_sequence_hyperlinking_line)
488
+ settings.log.debug("Using custom hyperlinking line creator with sequence #{options.inspect}")
489
+ link_line = construct_custom_sequence_hyperlinking_line(options)
490
+ unless link_line.nil?
491
+ return link_line
492
+ end
493
+ end
494
+
495
+ # If we have reached here, custom construction of the
496
+ # whole line either wasn't defined, or returned nil
497
+ # (indicating failure)
498
+ if self.respond_to?(:construct_custom_sequence_hyperlink)
499
+ settings.log.debug("Using custom hyperlink creator with sequence #{options.inspect}")
500
+ link = construct_custom_sequence_hyperlink(options)
501
+ else
502
+ settings.log.debug("Using standard hyperlink creator with sequence `#{options.inspect}'")
503
+ link = construct_standard_sequence_hyperlink(options)
504
+ end
505
+
506
+ # Return the BLAST output line with the link in it
507
+ if link.nil?
508
+ settings.log.debug('No link added link for: `'+ sequence_id +'\'')
509
+ return line
510
+ else
511
+ settings.log.debug('Added link for: `'+ sequence_id +'\''+ link)
512
+ return "><a href='#{url(link)}' target='_blank'>#{sequence_id}</a> \n"
513
+ end
514
+
515
+ end
516
+
517
+ # Advanced options are specified by the user. Here they are checked for interference with SequenceServer operations.
518
+ # raise ArgumentError if an error has occurred, otherwise return without value
519
+ def validate_advanced_parameters(advanced_options)
520
+ raise ArgumentError, "Invalid characters detected in the advanced options" unless advanced_options =~ /\A[a-z0-9\-_\. ']*\Z/i
521
+ disallowed_options = %w(-out -html -outfmt -db -query)
522
+ disallowed_options.each do |o|
523
+ raise ArgumentError, "The advanced BLAST option \"#{o}\" is used internally by SequenceServer and so cannot be specified by the you" if advanced_options =~ /#{o}/i
524
+ end
525
+ end
526
+ end
527
+ end