snails 0.0.8 → 0.1.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/.gitignore +1 -0
- data/lib/snails.rb +9 -483
- data/lib/snails/app.rb +481 -0
- data/lib/snails/mailer.rb +3 -5
- data/snails.gemspec +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 88f932ce0b002068d85f97f21f87fd7250473f46
|
4
|
+
data.tar.gz: 746a134507fb00de2b222388b8124b5837a2b474
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ab90d572eeaf226d8fd25b3e604ec47afa2e3a54638d827d753900c74a87a75a7facd5ccdab7864258e23e8e62c7a10d4c8b70ecd7eaeb71e7d73687fde81a5a
|
7
|
+
data.tar.gz: 50f360665268d2787a3b2df14750425228bf29e7b1a46ab398212ee93fe334908550ca616b067bcbb564ba4da265d4781ee9d3d195b09271da39e07776e726d8
|
data/.gitignore
CHANGED
data/lib/snails.rb
CHANGED
@@ -1,13 +1,11 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
active_support/string_inquirer
|
4
|
-
active_support/core_ext/hash
|
5
|
-
sinatra/base
|
6
|
-
sinatra/content_for
|
7
|
-
sinatra/flash
|
8
|
-
).each { |lib| require lib }
|
1
|
+
require 'logger'
|
2
|
+
require 'snails/app'
|
9
3
|
|
10
4
|
module Snails
|
5
|
+
def self.root
|
6
|
+
@root ||= Pathname.new(Dir.pwd)
|
7
|
+
end
|
8
|
+
|
11
9
|
def self.env
|
12
10
|
@env ||= ActiveSupport::StringInquirer.new(ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'development')
|
13
11
|
end
|
@@ -20,480 +18,8 @@ module Snails
|
|
20
18
|
puts "Warning: There's more than one Snail app defined!" if @apps.count > 1
|
21
19
|
@apps.first
|
22
20
|
end
|
23
|
-
end
|
24
|
-
|
25
|
-
module Snails
|
26
|
-
|
27
|
-
module RequiredParams
|
28
|
-
def requires!(req, hash = params)
|
29
|
-
if req.is_a?(Hash)
|
30
|
-
req.each do |k, vals|
|
31
|
-
if vals.is_a?(Array) or vals.is_a?(Hash)
|
32
|
-
halt(400, "Missing: #{k} in #{hash}") if hash[k].nil?
|
33
|
-
requires!(vals, hash[k])
|
34
|
-
else
|
35
|
-
requires!(k, hash)
|
36
|
-
end
|
37
|
-
end
|
38
|
-
elsif req.nil? or (req.is_a?(Symbol) and hash[req].nil?) \
|
39
|
-
or (req.is_a?(Array) and req.any? { |p| hash[p].nil? })
|
40
|
-
halt(400, "Required parameters: #{req} (in #{hash})")
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
class App < Sinatra::Base
|
46
|
-
|
47
|
-
def self.inherited(base)
|
48
|
-
Snails.apps << base
|
49
|
-
super
|
50
|
-
end
|
51
|
-
|
52
|
-
cwd = Pathname.new(Dir.pwd) # settings.root
|
53
|
-
LOGGER = Logger.new(File.exist?(cwd.join('log')) ? cwd.join('log', "#{Snails.env}.log") : nil)
|
54
|
-
|
55
|
-
set :protection, except: :frame_options
|
56
|
-
set :views, cwd.join('lib', 'views')
|
57
|
-
set :session_secret, ENV.fetch('SESSION_SECRET') { SecureRandom.hex }
|
58
|
-
set :static_paths, %w(/css /img /js /files /fonts favicon.ico)
|
59
|
-
|
60
|
-
enable :sessions
|
61
|
-
enable :method_override
|
62
|
-
enable :logging
|
63
|
-
|
64
|
-
register Sinatra::Flash
|
65
|
-
use Rack::CommonLogger, LOGGER
|
66
|
-
use Rack::Static, urls: static_paths, root: 'public'
|
67
|
-
|
68
|
-
configure :production, :staging do
|
69
|
-
set :raise_errors, true
|
70
|
-
set :dump_errors, false
|
71
|
-
end
|
72
|
-
|
73
|
-
configure :development do
|
74
|
-
set :raise_errors, true
|
75
|
-
set :show_exceptions, true
|
76
|
-
set :log_level, Logger::DEBUG
|
77
|
-
end
|
78
|
-
|
79
|
-
helpers do
|
80
|
-
include RequiredParams
|
81
|
-
include Sinatra::ContentFor
|
82
|
-
def logger; LOGGER; end
|
83
|
-
end
|
84
|
-
|
85
|
-
error do
|
86
|
-
err = request.env['sinatra.error']
|
87
|
-
logger.error err.message
|
88
|
-
logger.error err.backtrace.first(3).join("\n")
|
89
|
-
halt(500, err.message)
|
90
|
-
end
|
91
|
-
|
92
|
-
not_found do
|
93
|
-
show_error(404)
|
94
|
-
end
|
95
|
-
|
96
|
-
protected
|
97
|
-
|
98
|
-
def deliver(data, code = 200, format = :json)
|
99
|
-
status(code)
|
100
|
-
content_type(format)
|
101
|
-
data.public_send("to_#{format}")
|
102
|
-
end
|
103
|
-
|
104
|
-
def show_error(code)
|
105
|
-
erb :"errors/#{code}", layout: false
|
106
|
-
end
|
107
|
-
|
108
|
-
end
|
109
|
-
|
110
|
-
module All
|
111
|
-
|
112
|
-
def self.registered(app)
|
113
|
-
app.register Snails::Database
|
114
|
-
app.register Snails::Locales
|
115
|
-
app.register Snails::Assets
|
116
|
-
end
|
117
|
-
|
118
|
-
end
|
119
|
-
|
120
|
-
module Database
|
121
|
-
|
122
|
-
def self.registered(app)
|
123
|
-
require 'sinatra/activerecord'
|
124
|
-
|
125
|
-
app.register Sinatra::ActiveRecordExtension
|
126
|
-
|
127
|
-
# app.configure :development do
|
128
|
-
# ActiveRecord::Base.logger.level = Logger::DEBUG
|
129
|
-
# end
|
130
|
-
end
|
131
|
-
|
132
|
-
end
|
133
|
-
|
134
|
-
module Locales
|
135
|
-
|
136
|
-
def self.registered(app)
|
137
|
-
require 'i18n'
|
138
|
-
require 'i18n/backend/fallbacks'
|
139
|
-
|
140
|
-
cwd = Pathname.new(Dir.pwd)
|
141
|
-
app.set :locale, :es
|
142
|
-
app.set :locales_path, cwd.join('config', 'locales')
|
143
|
-
|
144
|
-
app.helpers do
|
145
|
-
def t(key); I18n.t(key); end
|
146
|
-
end
|
147
|
-
|
148
|
-
app.configure do
|
149
|
-
I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks)
|
150
|
-
I18n.load_path = Dir[File.join(app.settings.locales_path, '*.yml')]
|
151
|
-
I18n.enforce_available_locales = false
|
152
|
-
I18n.backend.load_translations
|
153
|
-
end
|
154
|
-
|
155
|
-
app.before do
|
156
|
-
I18n.locale = app.settings.locale
|
157
|
-
end
|
158
|
-
end
|
159
|
-
|
160
|
-
end
|
161
|
-
|
162
|
-
# usage:
|
163
|
-
|
164
|
-
# class App < Snails::App
|
165
|
-
# register Snails::Assets
|
166
|
-
#
|
167
|
-
# # optional:
|
168
|
-
# set :assets_precompile, %w(js/app.js css/styles.css)
|
169
|
-
# # also optional, set compressor
|
170
|
-
# sprockets.css_compressor = :csso
|
171
|
-
# sprockets.js_compressor = :uglifier
|
172
|
-
# end
|
173
|
-
|
174
|
-
# Then, in your view:
|
175
|
-
#
|
176
|
-
# <script src="/assets/js/app.js"></script>
|
177
|
-
# <link rel="stylesheet" href="/assets/css/styles.css" />
|
178
|
-
#
|
179
|
-
|
180
|
-
module Assets
|
181
|
-
|
182
|
-
def self.registered(app)
|
183
|
-
require 'sprockets-helpers'
|
184
|
-
|
185
|
-
cwd = Pathname.new(Dir.pwd)
|
186
|
-
app.set :sprockets, Sprockets::Environment.new(cwd)
|
187
|
-
app.set :assets_prefix, '/assets' # URL
|
188
|
-
app.set :digest_assets, false
|
189
|
-
app.set :assets_public_path, -> { cwd.join('public', 'assets') } # output dir
|
190
|
-
app.set :assets_paths, %w(assets) # source files
|
191
|
-
app.set :assets_precompile, %w(js/main.js css/main.css)
|
192
|
-
app.set :assets_remove_digests, false
|
193
|
-
|
194
|
-
app.configure do
|
195
|
-
app.assets_paths.each do |path|
|
196
|
-
app.sprockets.append_path cwd.join(path)
|
197
|
-
end
|
198
|
-
end
|
199
|
-
|
200
|
-
app.configure :production, :staging do
|
201
|
-
# app.sprockets.css_compressor = :sass
|
202
|
-
# app.sprockets.js_compressor = :uglifier
|
203
|
-
end
|
204
|
-
|
205
|
-
app.configure :development do
|
206
|
-
# allow asset requests to pass
|
207
|
-
app.allow_paths.push /^#{app.assets_prefix}(\/\w+)?\/([\w\.-]+)/
|
208
|
-
|
209
|
-
# and serve them
|
210
|
-
app.get "#{app.assets_prefix}/*" do |path|
|
211
|
-
env_sprockets = request.env.dup
|
212
|
-
env_sprockets['PATH_INFO'] = path
|
213
|
-
app.sprockets.call(env_sprockets)
|
214
|
-
end
|
215
|
-
end
|
216
|
-
|
217
|
-
app.helpers do
|
218
|
-
def asset_path(filename)
|
219
|
-
file = manifest[filename] or raise "Not found in manifest: #{filename}"
|
220
|
-
[settings.assets_prefix, file].join('/')
|
221
|
-
end
|
222
|
-
|
223
|
-
if Snails.env.production?
|
224
|
-
def manifest
|
225
|
-
@manifest ||= read_manifest
|
226
|
-
end
|
227
|
-
else
|
228
|
-
def manifest
|
229
|
-
read_manifest
|
230
|
-
end
|
231
|
-
end
|
232
|
-
|
233
|
-
def read_manifest
|
234
|
-
file = Dir[settings.assets_public_path + '/.*.json'].first or raise "No manifest found at #{path}"
|
235
|
-
JSON.parse(IO.read(file))['assets']
|
236
|
-
end
|
237
|
-
end
|
238
|
-
end
|
239
|
-
|
240
|
-
module Tasks
|
241
|
-
|
242
|
-
def self.precompile_for(app)
|
243
|
-
unless app.respond_to?(:assets_public_path)
|
244
|
-
return puts "#{app.name} doesn't have the Asset module included."
|
245
|
-
end
|
246
|
-
|
247
|
-
puts "Precompiling #{app.name} assets to #{app.assets_public_path}..."
|
248
|
-
FileUtils.remove_dir(app.assets_public_path.to_s, true)
|
249
|
-
|
250
|
-
environment = app.sprockets
|
251
|
-
manifest = ::Sprockets::Manifest.new(environment.index, app.assets_public_path)
|
252
|
-
manifest.compile(app.assets_precompile)
|
253
|
-
|
254
|
-
if app.assets_remove_digests?
|
255
|
-
# files = Dir[app.assets_public_path.to_s + '/*/*']
|
256
|
-
files = `find #{app.assets_public_path}`.split("\n").select { |f| f[/\.(js|css)/] }
|
257
|
-
remove_digests(files)
|
258
|
-
end
|
259
|
-
end
|
260
|
-
|
261
|
-
private
|
262
|
-
|
263
|
-
def self.remove_digests(files)
|
264
|
-
puts "Removing digests from #{files.length} files..."
|
265
|
-
files.each do |file|
|
266
|
-
dir = File.dirname(file)
|
267
|
-
parts = File.basename(file).split(/-|\./)
|
268
|
-
if !parts[1] or parts[1].length < 10
|
269
|
-
# puts "This doesn't look like a digested file: #{file}. Skipping..."
|
270
|
-
next
|
271
|
-
end
|
272
|
-
|
273
|
-
dest = File.join(dir, "#{parts.first}.#{parts.last}").sub('.gz', '.' + parts.last(2).join('.'))
|
274
|
-
FileUtils.mv(file, dest)
|
275
|
-
puts " --> #{dest}"
|
276
|
-
end
|
277
|
-
end
|
278
|
-
end
|
279
|
-
|
280
|
-
end
|
281
|
-
|
282
|
-
module FormHelpers
|
283
|
-
|
284
|
-
def form_input(object, field, options = {})
|
285
|
-
id, name, index, label = input_base(object, field, options)
|
286
|
-
type = (options[:type] || :text).to_sym
|
287
|
-
value = options[:value] ? "value='#{options[:value]}'" : (type == :password ? '' : "value='#{object.send(field)}'")
|
288
|
-
|
289
|
-
classes = object.errors[field].any? ? 'has-errors' : ''
|
290
|
-
label + raw_input(index, type, id, name, value, options[:placeholder], classes, options[:required])
|
291
|
-
end
|
292
|
-
|
293
|
-
def form_password(object, field, options = {})
|
294
|
-
form_input(object, field, {:type => 'password'}.merge(options))
|
295
|
-
end
|
296
|
-
|
297
|
-
def form_checkbox(object, field, options = {})
|
298
|
-
id, name, index, label = input_base(object, field, options)
|
299
|
-
|
300
|
-
type = options[:type] || :checkbox
|
301
|
-
value = options[:value] ? "value='#{options[:value]}'" : ''
|
302
|
-
|
303
|
-
if type.to_sym == :radio
|
304
|
-
checked = object.send(field) == options[:value]
|
305
|
-
value += checked ? " selected='selected'" : ''
|
306
|
-
else
|
307
|
-
checked = object.send(field)
|
308
|
-
value += checked ? " checked='true'" : ''
|
309
|
-
end
|
310
|
-
|
311
|
-
label + raw_input(index, type, id, name, options[:placeholder], value)
|
312
|
-
end
|
313
|
-
|
314
|
-
def form_textarea(object, field, options = {})
|
315
|
-
id, name, index, label = input_base(object, field, options)
|
316
|
-
style = options[:style] ? "style='#{options[:style]}'" : ''
|
317
|
-
label + "<textarea #{style} tabindex='#{index}' id='#{id}' name='#{name}'>#{object.send(field)}</textarea>"
|
318
|
-
end
|
319
|
-
|
320
|
-
def form_select_options(list, selected = nil)
|
321
|
-
list.map do |name, val|
|
322
|
-
val = name if val.nil?
|
323
|
-
sel = val == selected ? 'selected="selected"' : ''
|
324
|
-
"<option #{sel} value='#{val}'>#{name}</option>"
|
325
|
-
end.join("\n")
|
326
|
-
end
|
327
|
-
|
328
|
-
def form_put
|
329
|
-
'<input type="hidden" name="_method" value="put" />'
|
330
|
-
end
|
331
|
-
|
332
|
-
def form_submit(text = 'Actualizar', classes = '')
|
333
|
-
@tabindex = @tabindex ? @tabindex + 1 : 1
|
334
|
-
"<button tabindex='#{@tabindex}' class='primary #{classes}' type='submit'>#{text}</button>"
|
335
|
-
end
|
336
|
-
|
337
|
-
def post_button(text, path, opts = {})
|
338
|
-
form_id = opts.delete(:form_id) || path.gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
|
339
|
-
css_class = opts.delete(:css_class) || ''
|
340
|
-
input_html = opts.delete(:input_html) || ''
|
341
|
-
confirm_text = opts.delete(:confirm_text) || 'Seguro?'
|
342
|
-
submit_val = opts.delete(:value) || text
|
343
|
-
'<form id="' + form_id + '" style="display:inline" method="post" action="' + url(path) + '" onsubmit="return confirm(\'' + confirm_text + '\');">
|
344
|
-
' + input_html + '
|
345
|
-
<button name="submit" type="submit" class="' + css_class + ' button" value="' + submit_val + '">' + text + '</button>
|
346
|
-
</form>'
|
347
|
-
end
|
348
|
-
|
349
|
-
def delete_link(options = {})
|
350
|
-
post_button(options[:text], options[:path], options.merge({
|
351
|
-
input_html: "<input type='hidden' name='_method' value='delete' />",
|
352
|
-
css_class: 'danger'
|
353
|
-
}))
|
354
|
-
end
|
355
|
-
|
356
|
-
protected
|
357
|
-
|
358
|
-
def input_base(object, field, options)
|
359
|
-
label = options[:label] || field.to_s.gsub('_', ' ').capitalize
|
360
|
-
example = options[:example] ? "<small class='example'>#{options[:example]}</small>" : ''
|
361
|
-
|
362
|
-
id = "#{get_model_name(object).downcase}_#{options[:key] || field}"
|
363
|
-
name = "#{get_model_name(object).downcase}[#{field}]"
|
364
|
-
label = options[:label] == false ? '' : "<label for='#{id}'>#{label}#{example}</label>\n"
|
365
|
-
|
366
|
-
@tabindex = @tabindex ? @tabindex + 1 : 1
|
367
|
-
return id, name, @tabindex, label
|
368
|
-
end
|
369
|
-
|
370
|
-
def raw_input(index, type, id, name, value, placeholder = '', classes = '', required = false)
|
371
|
-
req = required ? "required" : ''
|
372
|
-
"<input #{req} class='#{classes}' tabindex='#{index}' type='#{type}' id='#{id}' name='#{name}' placeholder='#{placeholder}' #{value} />"
|
373
|
-
end
|
374
|
-
|
375
|
-
def get_model_name(obj)
|
376
|
-
obj.respond_to?(:field_name) ? obj.field_name : get_class_name(obj.class)
|
377
|
-
end
|
378
|
-
|
379
|
-
def get_class_name(klass)
|
380
|
-
klass.model_name.to_s.split("::").last
|
381
|
-
end
|
382
|
-
|
383
|
-
end
|
384
|
-
|
385
|
-
module SimpleFormat
|
386
|
-
|
387
|
-
def tag(name, options = nil, open = false)
|
388
|
-
attributes = tag_attributes(options)
|
389
|
-
"<#{name}#{attributes}#{open ? '>' : ' />'}"
|
390
|
-
end
|
391
|
-
|
392
|
-
def tag_attributes(options)
|
393
|
-
return '' unless options
|
394
|
-
options.inject('') do |all,(key,value)|
|
395
|
-
next all unless value
|
396
|
-
all << ' ' if all.empty?
|
397
|
-
all << %(#{key}="#{value}" )
|
398
|
-
end.chomp!(' ')
|
399
|
-
end
|
400
|
-
|
401
|
-
def simple_format(text, options = {})
|
402
|
-
t = options.delete(:tag) || :p
|
403
|
-
start_tag = tag(t, options, true)
|
404
|
-
text = text.to_s.dup
|
405
|
-
text.gsub!(/\r\n?/, "\n") # \r\n and \r -> \n
|
406
|
-
text.gsub!(/\n\n+/, "</#{t}>\n\n#{start_tag}") # 2+ newline -> paragraph
|
407
|
-
text.gsub!(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
|
408
|
-
text.insert 0, start_tag
|
409
|
-
text << "</#{t}>"
|
410
|
-
text
|
411
|
-
end
|
412
21
|
|
22
|
+
def self.logger
|
23
|
+
@logged ||= Logger.new(File.exist?(root.join('log')) ? root.join('log', "#{Snails.env}.log") : nil)
|
413
24
|
end
|
414
|
-
|
415
|
-
module ViewHelpers
|
416
|
-
|
417
|
-
def self.included(base)
|
418
|
-
Time.include(RelativeTime) unless Time.instance_methods.include?(:relative)
|
419
|
-
base.include(FormHelpers)
|
420
|
-
base.include(SimpleFormat)
|
421
|
-
end
|
422
|
-
|
423
|
-
def action
|
424
|
-
request.path_info.gsub('/','').blank? ? 'home' : request.path_info.gsub('/',' ')
|
425
|
-
end
|
426
|
-
|
427
|
-
def partial(name, opts = {})
|
428
|
-
partial_name = name.to_s["/"] ? name.to_s.reverse.sub("/", "_/").reverse : "_#{name}"
|
429
|
-
erb(partial_name.to_sym, { layout: false }.merge(opts))
|
430
|
-
end
|
431
|
-
|
432
|
-
def view(view_name, opts = {})
|
433
|
-
layout = request.xhr? ? false : true
|
434
|
-
erb(view_name.to_sym, { layout: layout }.merge(opts))
|
435
|
-
end
|
436
|
-
|
437
|
-
#########################################
|
438
|
-
# pagination
|
439
|
-
|
440
|
-
def get_page(counter)
|
441
|
-
curr = params[:page].to_i
|
442
|
-
i = (curr == 0 && counter == 1) ? 2
|
443
|
-
: (curr == 2 && counter == -1) ? 0
|
444
|
-
: curr + counter
|
445
|
-
i == 0 ? "" : "/page/#{i}"
|
446
|
-
end
|
447
|
-
|
448
|
-
def show_pager(array, path)
|
449
|
-
# remove page from path
|
450
|
-
path = (env['SCRIPT_NAME'] + path.gsub(/[?|&|\/]page[=|\/]\d+/,''))
|
451
|
-
|
452
|
-
prevlink = '<li>' + link_to("#{path}#{get_page(-1)}", '← Prev').sub('//', '/') + '</li>'
|
453
|
-
nextlink = array.count != Routes::PER_PAGE ? ""
|
454
|
-
: '<li>' + link_to("#{path}#{get_page(1)}", 'Next →').sub('//', '/') + '</li>'
|
455
|
-
|
456
|
-
str = params[:page] ? prevlink + nextlink : nextlink
|
457
|
-
str != "" ? "<ul class='pager'>" + str + "</ul>" : ''
|
458
|
-
end
|
459
|
-
|
460
|
-
end
|
461
|
-
|
462
|
-
module RelativeTime
|
463
|
-
|
464
|
-
def in_words
|
465
|
-
minutes = (((Time.now - self).abs)/60).round
|
466
|
-
return nil if minutes < 0
|
467
|
-
|
468
|
-
case minutes
|
469
|
-
when 0..1 then 'menos de un min'
|
470
|
-
when 2..4 then 'menos de 5 min'
|
471
|
-
when 5..14 then 'menos de 15 min'
|
472
|
-
when 15..29 then "media hora"
|
473
|
-
when 30..59 then "#{minutes} minutos"
|
474
|
-
when 60..119 then '1 hora'
|
475
|
-
when 120..239 then '2 horas'
|
476
|
-
when 240..479 then '4 horas'
|
477
|
-
when 480..719 then '8 horas'
|
478
|
-
when 720..1439 then '12 horas'
|
479
|
-
when 1440..11519 then "#{(minutes/1440).floor} días"
|
480
|
-
when 11520..43199 then "#{(minutes/11520).floor} semanas"
|
481
|
-
when 43200..525599 then "#{(minutes/43200).floor} meses"
|
482
|
-
else "#{(minutes/525600).floor} años"
|
483
|
-
end
|
484
|
-
end
|
485
|
-
|
486
|
-
def relative
|
487
|
-
if str = in_words
|
488
|
-
if Time.now < self
|
489
|
-
# "#{str} más"
|
490
|
-
"en #{str}"
|
491
|
-
else
|
492
|
-
"hace #{str}"
|
493
|
-
end
|
494
|
-
end
|
495
|
-
end
|
496
|
-
|
497
|
-
end
|
498
|
-
|
499
|
-
end
|
25
|
+
end
|
data/lib/snails/app.rb
ADDED
@@ -0,0 +1,481 @@
|
|
1
|
+
%w(
|
2
|
+
snails
|
3
|
+
active_support/string_inquirer
|
4
|
+
active_support/core_ext/hash
|
5
|
+
sinatra/base
|
6
|
+
sinatra/content_for
|
7
|
+
sinatra/flash
|
8
|
+
).each { |lib| require lib }
|
9
|
+
|
10
|
+
module Snails
|
11
|
+
|
12
|
+
module RequiredParams
|
13
|
+
def requires!(req, hash = params)
|
14
|
+
if req.is_a?(Hash)
|
15
|
+
req.each do |k, vals|
|
16
|
+
if vals.is_a?(Array) or vals.is_a?(Hash)
|
17
|
+
halt(400, "Missing: #{k} in #{hash}") if hash[k].nil?
|
18
|
+
requires!(vals, hash[k])
|
19
|
+
else
|
20
|
+
requires!(k, hash)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
elsif req.nil? or (req.is_a?(Symbol) and hash[req].nil?) \
|
24
|
+
or (req.is_a?(Array) and req.any? { |p| hash[p].nil? })
|
25
|
+
halt(400, "Required parameters: #{req} (in #{hash})")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class App < Sinatra::Base
|
31
|
+
|
32
|
+
def self.inherited(base)
|
33
|
+
Snails.apps << base
|
34
|
+
super
|
35
|
+
end
|
36
|
+
|
37
|
+
set :protection, except: :frame_options
|
38
|
+
set :views, Snails.root.join('lib', 'views')
|
39
|
+
set :session_secret, ENV.fetch('SESSION_SECRET') { SecureRandom.hex }
|
40
|
+
set :static_paths, %w(/css /img /js /files /fonts favicon.ico)
|
41
|
+
|
42
|
+
enable :sessions
|
43
|
+
enable :method_override
|
44
|
+
enable :logging
|
45
|
+
|
46
|
+
register Sinatra::Flash
|
47
|
+
use Rack::CommonLogger, Snails.logger
|
48
|
+
use Rack::Static, urls: static_paths, root: 'public'
|
49
|
+
|
50
|
+
configure :production, :staging do
|
51
|
+
set :raise_errors, true
|
52
|
+
set :dump_errors, false
|
53
|
+
end
|
54
|
+
|
55
|
+
configure :development do
|
56
|
+
set :raise_errors, true
|
57
|
+
set :show_exceptions, true
|
58
|
+
set :log_level, Logger::DEBUG
|
59
|
+
end
|
60
|
+
|
61
|
+
helpers do
|
62
|
+
include RequiredParams
|
63
|
+
include Sinatra::ContentFor
|
64
|
+
def logger; Snails.logger; end
|
65
|
+
end
|
66
|
+
|
67
|
+
error do
|
68
|
+
err = request.env['sinatra.error']
|
69
|
+
logger.error err.message
|
70
|
+
logger.error err.backtrace.first(3).join("\n")
|
71
|
+
halt(500, err.message)
|
72
|
+
end
|
73
|
+
|
74
|
+
not_found do
|
75
|
+
show_error(404)
|
76
|
+
end
|
77
|
+
|
78
|
+
protected
|
79
|
+
|
80
|
+
def deliver(data, code = 200, format = :json)
|
81
|
+
status(code)
|
82
|
+
content_type(format)
|
83
|
+
data.public_send("to_#{format}")
|
84
|
+
end
|
85
|
+
|
86
|
+
def show_error(code)
|
87
|
+
erb :"errors/#{code}", layout: false
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
module All
|
93
|
+
|
94
|
+
def self.registered(app)
|
95
|
+
app.register Snails::Database
|
96
|
+
app.register Snails::Locales
|
97
|
+
app.register Snails::Assets
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
101
|
+
|
102
|
+
module Database
|
103
|
+
|
104
|
+
def self.registered(app)
|
105
|
+
require 'sinatra/activerecord'
|
106
|
+
|
107
|
+
app.register Sinatra::ActiveRecordExtension
|
108
|
+
|
109
|
+
# app.configure :development do
|
110
|
+
# ActiveRecord::Base.logger.level = Logger::DEBUG
|
111
|
+
# end
|
112
|
+
end
|
113
|
+
|
114
|
+
end
|
115
|
+
|
116
|
+
module Locales
|
117
|
+
|
118
|
+
def self.registered(app)
|
119
|
+
require 'i18n'
|
120
|
+
require 'i18n/backend/fallbacks'
|
121
|
+
|
122
|
+
cwd = Pathname.new(Dir.pwd)
|
123
|
+
app.set :locale, :es
|
124
|
+
app.set :locales_path, cwd.join('config', 'locales')
|
125
|
+
|
126
|
+
app.helpers do
|
127
|
+
def t(key); I18n.t(key); end
|
128
|
+
end
|
129
|
+
|
130
|
+
app.configure do
|
131
|
+
I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks)
|
132
|
+
I18n.load_path = Dir[File.join(app.settings.locales_path, '*.yml')]
|
133
|
+
I18n.enforce_available_locales = false
|
134
|
+
I18n.backend.load_translations
|
135
|
+
end
|
136
|
+
|
137
|
+
app.before do
|
138
|
+
I18n.locale = app.settings.locale
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
end
|
143
|
+
|
144
|
+
# usage:
|
145
|
+
|
146
|
+
# class App < Snails::App
|
147
|
+
# register Snails::Assets
|
148
|
+
#
|
149
|
+
# # optional:
|
150
|
+
# set :assets_precompile, %w(js/app.js css/styles.css)
|
151
|
+
# # also optional, set compressor
|
152
|
+
# sprockets.css_compressor = :csso
|
153
|
+
# sprockets.js_compressor = :uglifier
|
154
|
+
# end
|
155
|
+
|
156
|
+
# Then, in your view:
|
157
|
+
#
|
158
|
+
# <script src="/assets/js/app.js"></script>
|
159
|
+
# <link rel="stylesheet" href="/assets/css/styles.css" />
|
160
|
+
#
|
161
|
+
|
162
|
+
module Assets
|
163
|
+
|
164
|
+
def self.registered(app)
|
165
|
+
require 'sprockets-helpers'
|
166
|
+
|
167
|
+
cwd = Pathname.new(Dir.pwd)
|
168
|
+
app.set :sprockets, Sprockets::Environment.new(cwd)
|
169
|
+
app.set :assets_prefix, '/assets' # URL
|
170
|
+
app.set :digest_assets, false
|
171
|
+
app.set :assets_public_path, -> { cwd.join('public', 'assets') } # output dir
|
172
|
+
app.set :assets_paths, %w(assets) # source files
|
173
|
+
app.set :assets_precompile, %w(js/main.js css/main.css)
|
174
|
+
app.set :assets_remove_digests, false
|
175
|
+
|
176
|
+
app.configure do
|
177
|
+
app.assets_paths.each do |path|
|
178
|
+
app.sprockets.append_path cwd.join(path)
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
app.configure :production, :staging do
|
183
|
+
# app.sprockets.css_compressor = :sass
|
184
|
+
# app.sprockets.js_compressor = :uglifier
|
185
|
+
end
|
186
|
+
|
187
|
+
app.configure :development do
|
188
|
+
# allow asset requests to pass
|
189
|
+
app.allow_paths.push /^#{app.assets_prefix}(\/\w+)?\/([\w\.-]+)/
|
190
|
+
|
191
|
+
# and serve them
|
192
|
+
app.get "#{app.assets_prefix}/*" do |path|
|
193
|
+
env_sprockets = request.env.dup
|
194
|
+
env_sprockets['PATH_INFO'] = path
|
195
|
+
app.sprockets.call(env_sprockets)
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
app.helpers do
|
200
|
+
def asset_path(filename)
|
201
|
+
file = manifest[filename] or raise "Not found in manifest: #{filename}"
|
202
|
+
[settings.assets_prefix, file].join('/')
|
203
|
+
end
|
204
|
+
|
205
|
+
if Snails.env.production?
|
206
|
+
def manifest
|
207
|
+
@manifest ||= read_manifest
|
208
|
+
end
|
209
|
+
else
|
210
|
+
def manifest
|
211
|
+
read_manifest
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def read_manifest
|
216
|
+
file = Dir[settings.assets_public_path + '/.*.json'].first or raise "No manifest found at #{path}"
|
217
|
+
JSON.parse(IO.read(file))['assets']
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
module Tasks
|
223
|
+
|
224
|
+
def self.precompile_for(app)
|
225
|
+
unless app.respond_to?(:assets_public_path)
|
226
|
+
return puts "#{app.name} doesn't have the Asset module included."
|
227
|
+
end
|
228
|
+
|
229
|
+
puts "Precompiling #{app.name} assets to #{app.assets_public_path}..."
|
230
|
+
FileUtils.remove_dir(app.assets_public_path.to_s, true)
|
231
|
+
|
232
|
+
environment = app.sprockets
|
233
|
+
manifest = ::Sprockets::Manifest.new(environment.index, app.assets_public_path)
|
234
|
+
manifest.compile(app.assets_precompile)
|
235
|
+
|
236
|
+
if app.assets_remove_digests?
|
237
|
+
# files = Dir[app.assets_public_path.to_s + '/*/*']
|
238
|
+
files = `find #{app.assets_public_path}`.split("\n").select { |f| f[/\.(js|css)/] }
|
239
|
+
remove_digests(files)
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
private
|
244
|
+
|
245
|
+
def self.remove_digests(files)
|
246
|
+
puts "Removing digests from #{files.length} files..."
|
247
|
+
files.each do |file|
|
248
|
+
dir = File.dirname(file)
|
249
|
+
parts = File.basename(file).split(/-|\./)
|
250
|
+
if !parts[1] or parts[1].length < 10
|
251
|
+
# puts "This doesn't look like a digested file: #{file}. Skipping..."
|
252
|
+
next
|
253
|
+
end
|
254
|
+
|
255
|
+
dest = File.join(dir, "#{parts.first}.#{parts.last}").sub('.gz', '.' + parts.last(2).join('.'))
|
256
|
+
FileUtils.mv(file, dest)
|
257
|
+
puts " --> #{dest}"
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
end
|
263
|
+
|
264
|
+
module FormHelpers
|
265
|
+
|
266
|
+
def form_input(object, field, options = {})
|
267
|
+
id, name, index, label = input_base(object, field, options)
|
268
|
+
type = (options[:type] || :text).to_sym
|
269
|
+
value = options[:value] ? "value='#{options[:value]}'" : (type == :password ? '' : "value='#{object.send(field)}'")
|
270
|
+
|
271
|
+
classes = object.errors[field].any? ? 'has-errors' : ''
|
272
|
+
label + raw_input(index, type, id, name, value, options[:placeholder], classes, options[:required])
|
273
|
+
end
|
274
|
+
|
275
|
+
def form_password(object, field, options = {})
|
276
|
+
form_input(object, field, {:type => 'password'}.merge(options))
|
277
|
+
end
|
278
|
+
|
279
|
+
def form_checkbox(object, field, options = {})
|
280
|
+
id, name, index, label = input_base(object, field, options)
|
281
|
+
|
282
|
+
type = options[:type] || :checkbox
|
283
|
+
value = options[:value] ? "value='#{options[:value]}'" : ''
|
284
|
+
|
285
|
+
if type.to_sym == :radio
|
286
|
+
checked = object.send(field) == options[:value]
|
287
|
+
value += checked ? " selected='selected'" : ''
|
288
|
+
else
|
289
|
+
checked = object.send(field)
|
290
|
+
value += checked ? " checked='true'" : ''
|
291
|
+
end
|
292
|
+
|
293
|
+
label + raw_input(index, type, id, name, options[:placeholder], value)
|
294
|
+
end
|
295
|
+
|
296
|
+
def form_textarea(object, field, options = {})
|
297
|
+
id, name, index, label = input_base(object, field, options)
|
298
|
+
style = options[:style] ? "style='#{options[:style]}'" : ''
|
299
|
+
label + "<textarea #{style} tabindex='#{index}' id='#{id}' name='#{name}'>#{object.send(field)}</textarea>"
|
300
|
+
end
|
301
|
+
|
302
|
+
def form_select_options(list, selected = nil)
|
303
|
+
list.map do |name, val|
|
304
|
+
val = name if val.nil?
|
305
|
+
sel = val == selected ? 'selected="selected"' : ''
|
306
|
+
"<option #{sel} value='#{val}'>#{name}</option>"
|
307
|
+
end.join("\n")
|
308
|
+
end
|
309
|
+
|
310
|
+
def form_put
|
311
|
+
'<input type="hidden" name="_method" value="put" />'
|
312
|
+
end
|
313
|
+
|
314
|
+
def form_submit(text = 'Actualizar', classes = '')
|
315
|
+
@tabindex = @tabindex ? @tabindex + 1 : 1
|
316
|
+
"<button tabindex='#{@tabindex}' class='primary #{classes}' type='submit'>#{text}</button>"
|
317
|
+
end
|
318
|
+
|
319
|
+
def post_button(text, path, opts = {})
|
320
|
+
form_id = opts.delete(:form_id) || path.gsub(/[^a-z0-9]/, '_').gsub(/_+/, '_')
|
321
|
+
css_class = opts.delete(:css_class) || ''
|
322
|
+
input_html = opts.delete(:input_html) || ''
|
323
|
+
confirm_text = opts.delete(:confirm_text) || 'Seguro?'
|
324
|
+
submit_val = opts.delete(:value) || text
|
325
|
+
'<form id="' + form_id + '" style="display:inline" method="post" action="' + url(path) + '" onsubmit="return confirm(\'' + confirm_text + '\');">
|
326
|
+
' + input_html + '
|
327
|
+
<button name="submit" type="submit" class="' + css_class + ' button" value="' + submit_val + '">' + text + '</button>
|
328
|
+
</form>'
|
329
|
+
end
|
330
|
+
|
331
|
+
def delete_link(options = {})
|
332
|
+
post_button(options[:text], options[:path], options.merge({
|
333
|
+
input_html: "<input type='hidden' name='_method' value='delete' />",
|
334
|
+
css_class: 'danger'
|
335
|
+
}))
|
336
|
+
end
|
337
|
+
|
338
|
+
protected
|
339
|
+
|
340
|
+
def input_base(object, field, options)
|
341
|
+
label = options[:label] || field.to_s.gsub('_', ' ').capitalize
|
342
|
+
example = options[:example] ? "<small class='example'>#{options[:example]}</small>" : ''
|
343
|
+
|
344
|
+
id = "#{get_model_name(object).downcase}_#{options[:key] || field}"
|
345
|
+
name = "#{get_model_name(object).downcase}[#{field}]"
|
346
|
+
label = options[:label] == false ? '' : "<label for='#{id}'>#{label}#{example}</label>\n"
|
347
|
+
|
348
|
+
@tabindex = @tabindex ? @tabindex + 1 : 1
|
349
|
+
return id, name, @tabindex, label
|
350
|
+
end
|
351
|
+
|
352
|
+
def raw_input(index, type, id, name, value, placeholder = '', classes = '', required = false)
|
353
|
+
req = required ? "required" : ''
|
354
|
+
"<input #{req} class='#{classes}' tabindex='#{index}' type='#{type}' id='#{id}' name='#{name}' placeholder='#{placeholder}' #{value} />"
|
355
|
+
end
|
356
|
+
|
357
|
+
def get_model_name(obj)
|
358
|
+
obj.respond_to?(:field_name) ? obj.field_name : get_class_name(obj.class)
|
359
|
+
end
|
360
|
+
|
361
|
+
def get_class_name(klass)
|
362
|
+
klass.model_name.to_s.split("::").last
|
363
|
+
end
|
364
|
+
|
365
|
+
end
|
366
|
+
|
367
|
+
module SimpleFormat
|
368
|
+
|
369
|
+
def tag(name, options = nil, open = false)
|
370
|
+
attributes = tag_attributes(options)
|
371
|
+
"<#{name}#{attributes}#{open ? '>' : ' />'}"
|
372
|
+
end
|
373
|
+
|
374
|
+
def tag_attributes(options)
|
375
|
+
return '' unless options
|
376
|
+
options.inject('') do |all,(key,value)|
|
377
|
+
next all unless value
|
378
|
+
all << ' ' if all.empty?
|
379
|
+
all << %(#{key}="#{value}" )
|
380
|
+
end.chomp!(' ')
|
381
|
+
end
|
382
|
+
|
383
|
+
def simple_format(text, options = {})
|
384
|
+
t = options.delete(:tag) || :p
|
385
|
+
start_tag = tag(t, options, true)
|
386
|
+
text = text.to_s.dup
|
387
|
+
text.gsub!(/\r\n?/, "\n") # \r\n and \r -> \n
|
388
|
+
text.gsub!(/\n\n+/, "</#{t}>\n\n#{start_tag}") # 2+ newline -> paragraph
|
389
|
+
text.gsub!(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
|
390
|
+
text.insert 0, start_tag
|
391
|
+
text << "</#{t}>"
|
392
|
+
text
|
393
|
+
end
|
394
|
+
|
395
|
+
end
|
396
|
+
|
397
|
+
module ViewHelpers
|
398
|
+
|
399
|
+
def self.included(base)
|
400
|
+
Time.include(RelativeTime) unless Time.instance_methods.include?(:relative)
|
401
|
+
base.include(FormHelpers)
|
402
|
+
base.include(SimpleFormat)
|
403
|
+
end
|
404
|
+
|
405
|
+
def action
|
406
|
+
request.path_info.gsub('/','').blank? ? 'home' : request.path_info.gsub('/',' ')
|
407
|
+
end
|
408
|
+
|
409
|
+
def partial(name, opts = {})
|
410
|
+
partial_name = name.to_s["/"] ? name.to_s.reverse.sub("/", "_/").reverse : "_#{name}"
|
411
|
+
erb(partial_name.to_sym, { layout: false }.merge(opts))
|
412
|
+
end
|
413
|
+
|
414
|
+
def view(view_name, opts = {})
|
415
|
+
layout = request.xhr? ? false : true
|
416
|
+
erb(view_name.to_sym, { layout: layout }.merge(opts))
|
417
|
+
end
|
418
|
+
|
419
|
+
#########################################
|
420
|
+
# pagination
|
421
|
+
|
422
|
+
def get_page(counter)
|
423
|
+
curr = params[:page].to_i
|
424
|
+
i = (curr == 0 && counter == 1) ? 2
|
425
|
+
: (curr == 2 && counter == -1) ? 0
|
426
|
+
: curr + counter
|
427
|
+
i == 0 ? "" : "/page/#{i}"
|
428
|
+
end
|
429
|
+
|
430
|
+
def show_pager(array, path)
|
431
|
+
# remove page from path
|
432
|
+
path = (env['SCRIPT_NAME'] + path.gsub(/[?|&|\/]page[=|\/]\d+/,''))
|
433
|
+
|
434
|
+
prevlink = '<li>' + link_to("#{path}#{get_page(-1)}", '← Prev').sub('//', '/') + '</li>'
|
435
|
+
nextlink = array.count != Routes::PER_PAGE ? ""
|
436
|
+
: '<li>' + link_to("#{path}#{get_page(1)}", 'Next →').sub('//', '/') + '</li>'
|
437
|
+
|
438
|
+
str = params[:page] ? prevlink + nextlink : nextlink
|
439
|
+
str != "" ? "<ul class='pager'>" + str + "</ul>" : ''
|
440
|
+
end
|
441
|
+
|
442
|
+
end
|
443
|
+
|
444
|
+
module RelativeTime
|
445
|
+
|
446
|
+
def in_words
|
447
|
+
minutes = (((Time.now - self).abs)/60).round
|
448
|
+
return nil if minutes < 0
|
449
|
+
|
450
|
+
case minutes
|
451
|
+
when 0..1 then 'menos de un min'
|
452
|
+
when 2..4 then 'menos de 5 min'
|
453
|
+
when 5..14 then 'menos de 15 min'
|
454
|
+
when 15..29 then "media hora"
|
455
|
+
when 30..59 then "#{minutes} minutos"
|
456
|
+
when 60..119 then '1 hora'
|
457
|
+
when 120..239 then '2 horas'
|
458
|
+
when 240..479 then '4 horas'
|
459
|
+
when 480..719 then '8 horas'
|
460
|
+
when 720..1439 then '12 horas'
|
461
|
+
when 1440..11519 then "#{(minutes/1440).floor} días"
|
462
|
+
when 11520..43199 then "#{(minutes/11520).floor} semanas"
|
463
|
+
when 43200..525599 then "#{(minutes/43200).floor} meses"
|
464
|
+
else "#{(minutes/525600).floor} años"
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
def relative
|
469
|
+
if str = in_words
|
470
|
+
if Time.now < self
|
471
|
+
# "#{str} más"
|
472
|
+
"en #{str}"
|
473
|
+
else
|
474
|
+
"hace #{str}"
|
475
|
+
end
|
476
|
+
end
|
477
|
+
end
|
478
|
+
|
479
|
+
end
|
480
|
+
|
481
|
+
end
|
data/lib/snails/mailer.rb
CHANGED
@@ -12,7 +12,6 @@ module Snails
|
|
12
12
|
@queue = :emails
|
13
13
|
|
14
14
|
def initialize(opts)
|
15
|
-
cwd = Pathname.new(Dir.pwd)
|
16
15
|
mail_config = (opts[:smtp] || opts[:mail]) or raise ":smtp options missing"
|
17
16
|
|
18
17
|
if key = mail_config.dig(:dkim, :private_key) and File.exist?(key)
|
@@ -23,11 +22,10 @@ module Snails
|
|
23
22
|
end
|
24
23
|
|
25
24
|
Tuktuk.options = mail_config
|
26
|
-
|
27
25
|
@from_email = opts[:from] or raise ":from required"
|
28
26
|
@base_subject = opts[:base_subject] || ''
|
29
|
-
@views = opts[:views] ||
|
30
|
-
@logfile = opts[:logfile] ||
|
27
|
+
@views = opts[:views] || Snails.root.join('lib', 'views')
|
28
|
+
@logfile = opts[:logfile] # || Snails.root.join('log', 'mailer.log')
|
31
29
|
end
|
32
30
|
|
33
31
|
def email(name, &block)
|
@@ -92,7 +90,7 @@ A <%= @exception.class %> occurred in <%= @url %>:
|
|
92
90
|
end
|
93
91
|
|
94
92
|
def logger
|
95
|
-
@logger ||= Logger.new(@logfile)
|
93
|
+
@logger ||= @logfile ? Logger.new(@logfile) : Snails.logger
|
96
94
|
end
|
97
95
|
|
98
96
|
def send_email(from: nil, to:, subject:, body: nil, template: nil, html_body: nil, html_template: nil)
|
data/snails.gemspec
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: snails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tomás Pollak
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-11-
|
11
|
+
date: 2018-11-09 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -141,6 +141,7 @@ files:
|
|
141
141
|
- example/Rakefile
|
142
142
|
- example/config.ru
|
143
143
|
- lib/snails.rb
|
144
|
+
- lib/snails/app.rb
|
144
145
|
- lib/snails/mailer.rb
|
145
146
|
- lib/snails/tasks.rb
|
146
147
|
- snails.gemspec
|