adrift 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,100 @@
1
+ module Adrift
2
+ # Namespace containing the procesor objects used by Attachment.
3
+ #
4
+ # They are used to do whatever it's needed with the attached file,
5
+ # and need to satisfy the following interface:
6
+ #
7
+ # [<tt>#process(attached_file_path, styles)</tt>]
8
+ # Do whatever it needs to do. Generally this means creating new
9
+ # files from the attached one, but it can also mean transforming
10
+ # the attached file.
11
+ #
12
+ # [<tt>#processed_files</tt>]
13
+ # Hash with the style names as keys and the paths of the processed
14
+ # files as values.
15
+ module Processor
16
+ # Creates a set of thumbnails of an image. To be fair, it just
17
+ # tells ImageMagick to do it.
18
+ class Thumbnail
19
+ # A wrapper around ImageMagick's convert command line tool.
20
+ class Cli
21
+ # Runs *convert* with the given +input+ and +options+, which
22
+ # are expressed in a Hash. The resulting image is stored in
23
+ # +output+.
24
+ def run(input, output, options={})
25
+ options = options.map { |name, value| %(-#{name} "#{value}") }
26
+ `convert #{input} #{options.join(' ')} #{output}`
27
+ end
28
+ end
29
+
30
+ # Hash with the style names as keys and the paths as values of
31
+ # the files generated in the last #process.
32
+ attr_reader :processed_files
33
+
34
+ # Creates a new Thumbnail object. +cli+ is a wrapper around
35
+ # convert (see Cli).
36
+ def initialize(cli=Cli.new)
37
+ @processed_files = {}
38
+ @cli = cli
39
+ end
40
+
41
+ # Creates a set of thumbnails for +image_path+ with the
42
+ # dimensions specified in +styles+, which has the following
43
+ # general form:
44
+ #
45
+ # { style_name: 'definition', ... }
46
+ #
47
+ # where 'definition' is an
48
+ # {ImageMagick's image geometry}[http://www.imagemagick.org/script/command-line-processing.php#geometry]
49
+ # or has the form 'widthxheight#'. For instance:
50
+ #
51
+ # {
52
+ # fixed_width: '100',
53
+ # fixed_height: 'x100',
54
+ # max: '100x100',
55
+ # fixed: '100x100#'
56
+ # }
57
+ #
58
+ # will create, respectively, a thumbnail with a 100px width and
59
+ # the corresponding height to preserve the ratio, a thumbnail
60
+ # with a 100px height and the corresponding width to preserve
61
+ # the ratio, a thumbnail with at most 100px width and at most
62
+ # 100px height preserving the ratio, and a thumbnail with 100px
63
+ # width and 100px height preserving the ratio (to do that, it
64
+ # will resize the image trying to make it fit the specified
65
+ # dimensions and then will crop its center).
66
+ #
67
+ # The thumbnail files are named after +image_path+ prefixed with
68
+ # the style name and a hypen for every style. The last created
69
+ # thumbnails are accesible through #processed_files.
70
+ def process(image_path, styles={})
71
+ @processed_files.clear
72
+ styles.each do |name, definition|
73
+ thumbnail_path = File.join(
74
+ File.dirname(image_path),
75
+ "#{name}-#{File.basename(image_path)}"
76
+ )
77
+ @cli.run(image_path, thumbnail_path, options_for(definition))
78
+ @processed_files[name] = thumbnail_path
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # Returns a Hash with the options needed by convert to build a
85
+ # thumbnail vgiven its +definition+.
86
+ def options_for(definition)
87
+ if definition.end_with?('#')
88
+ {
89
+ :resize => definition.tr('#', '^'),
90
+ :gravity => 'center',
91
+ :background => 'None',
92
+ :extent => definition.tr('#', '')
93
+ }
94
+ else
95
+ { :resize => definition }
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,12 @@
1
+ module Adrift
2
+ class Railtie < Rails::Railtie
3
+ initializer "adrift.setup" do
4
+ Pattern::Tags::Root.path = Rails.root
5
+ ActiveSupport.on_load :active_record do
6
+ require 'adrift/integration/active_record'
7
+ end
8
+ # TODO find out a better way to go (?)
9
+ require 'adrift/integration/data_mapper'
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,82 @@
1
+ module Adrift
2
+ # Namespace containing the storage objects used by Attachment.
3
+ #
4
+ # They are used to save and remove files, and need to satisfy the
5
+ # following interface:
6
+ #
7
+ # [<tt>#store(source_path, destination_path)</tt>]
8
+ # Adds a file to be stored.
9
+ #
10
+ # [<tt>#remove(path)</tt>]
11
+ # Indicates that a file will be removed.
12
+ #
13
+ # [<tt>#flush</tt>]
14
+ # Store and remove the previously specified files.
15
+ #
16
+ # [<tt>#stored</tt>]
17
+ # Array of stored files in the last flush.
18
+ #
19
+ # [<tt>#removed</tt>]
20
+ # Array of removed files in the last flush.
21
+ module Storage
22
+ # Stores and removes files to and from the filesystem using
23
+ # queues.
24
+ class Filesystem
25
+ attr_reader :stored, :removed
26
+
27
+ # Creates a new Filesystem object.
28
+ def initialize
29
+ @queue_for_storage = []
30
+ @queue_for_removal = []
31
+ @stored = []
32
+ @removed = []
33
+ end
34
+
35
+ # Indicates whether or not there are files that need to be
36
+ # stored or removed.
37
+ def dirty?
38
+ @queue_for_storage.any? || @queue_for_removal.any?
39
+ end
40
+
41
+ # Adds the file +source_path+ to the storage queue, that will be
42
+ # saved in +destination_path+. Note that in order to actually
43
+ # store the file you need to call #flush.
44
+ def store(source_path, destination_path)
45
+ @queue_for_storage << [source_path, destination_path]
46
+ end
47
+
48
+ # Stores the files placed in the storage queue.
49
+ def store!
50
+ @queue_for_storage.each do |source_path, destination_path|
51
+ FileUtils.mkdir_p(File.dirname(destination_path))
52
+ FileUtils.cp(source_path, destination_path)
53
+ FileUtils.chmod(0644, destination_path)
54
+ end
55
+ @stored = @queue_for_storage.dup
56
+ @queue_for_storage.clear
57
+ end
58
+
59
+ # Adds the file +path+ to the removal queue. Note that in order
60
+ # to actually remove the file you need to call #flush.
61
+ def remove(path)
62
+ @queue_for_removal << path
63
+ end
64
+
65
+ # Removes the files placed in the removal queue.
66
+ def remove!
67
+ @queue_for_removal.each do |path|
68
+ FileUtils.rm(path) if File.exist?(path)
69
+ end
70
+ @removed = @queue_for_removal.dup
71
+ @queue_for_removal.clear
72
+ end
73
+
74
+ # Removes and then stores the files placed in the removal and
75
+ # storage queues, repectively.
76
+ def flush
77
+ remove!
78
+ store!
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,3 @@
1
+ module Adrift
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,488 @@
1
+ require 'spec_helper'
2
+
3
+ module Adrift
4
+ shared_examples_for "any attachment" do
5
+ describe "#dirty?" do
6
+ context "for a newly instantiated attachment" do
7
+ it "returns false" do
8
+ attachment.should_not be_dirty
9
+ end
10
+ end
11
+
12
+ context "when a file has been assigned" do
13
+ before { attachment.assign(up_file_double) }
14
+
15
+ it "returns true" do
16
+ attachment.should be_dirty
17
+ end
18
+
19
+ context "and the attachment is saved" do
20
+ it "returns false" do
21
+ attachment.save
22
+ attachment.should_not be_dirty
23
+ end
24
+ end
25
+
26
+ context "and the attachment is cleared" do
27
+ it "returns true" do
28
+ attachment.should be_dirty
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ describe "#empty?" do
35
+ context "when a file has been assigned" do
36
+ it "returns true" do
37
+ attachment.assign(up_file_double)
38
+ attachment.should_not be_empty
39
+ end
40
+ end
41
+ end
42
+
43
+ describe "#assign" do
44
+ let(:up_file) { up_file_double }
45
+
46
+ it "updates the attachment's filename in the model" do
47
+ up_file.stub(:original_filename => 'new_me.png')
48
+ attachment.assign(up_file)
49
+ user.avatar_filename.should == 'new_me.png'
50
+ end
51
+
52
+ it "replaces the filename's non alphanumeric characters with '_' (except '.')" do
53
+ up_file.stub(:original_filename => 'my awesome-avatar!.png')
54
+ attachment.assign(up_file)
55
+ attachment.filename.should == 'my_awesome_avatar_.png'
56
+ end
57
+ end
58
+
59
+ describe "#save" do
60
+ context "when a file hasn't been assigned" do
61
+ it "doesn't store anything" do
62
+ attachment.save
63
+ attachment.storage.stored.should be_empty
64
+ end
65
+
66
+ it "doesn't remove anything" do
67
+ attachment.save
68
+ attachment.storage.removed.should be_empty
69
+ end
70
+
71
+ it "doesn't proccess anything" do
72
+ attachment.processor.should_not_receive(:process)
73
+ attachment.save
74
+ end
75
+ end
76
+
77
+ context "when a file has been assigned" do
78
+ before do
79
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
80
+ attachment.path = '/:class_name/:id/:style/:filename'
81
+ attachment.assign up_file_double(:original_filename => 'new_me.png', :path => '/tmp/123')
82
+ end
83
+
84
+ it "process the assigned file" do
85
+ attachment.processor.should_receive(:process).with('/tmp/123', attachment.styles)
86
+ attachment.save
87
+ end
88
+
89
+ it "stores the assigned file and the processed ones" do
90
+ attachment.save
91
+ attachment.storage.stored.should include(['/tmp/123', '/users/1/original/new_me.png'])
92
+ attachment.storage.stored.should include(['/tmp/normal-123', '/users/1/normal/new_me.png'])
93
+ attachment.storage.stored.should include(['/tmp/small-123', '/users/1/small/new_me.png'])
94
+ attachment.storage.stored.size.should == 3
95
+ end
96
+
97
+ context "when an ':original' style has been set" do
98
+ before do
99
+ attachment.styles[:original] = '500x500'
100
+ attachment.save
101
+ end
102
+
103
+ it "doesn't store the uploaded file" do
104
+ attachment.storage.stored.should_not include(['/tmp/123', '/users/1/original/new_me.png'])
105
+ end
106
+
107
+ it "stores the processed one" do
108
+ attachment.storage.stored.should include(['/tmp/original-123', '/users/1/original/new_me.png'])
109
+ end
110
+ end
111
+ end
112
+
113
+ context "when two files has been asigned without saving" do
114
+ before do
115
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
116
+ attachment.path = '/:class_name/:id/:style/:filename'
117
+ attachment.assign up_file_double(:original_filename => 'first_me.png', :path => '/tmp/123')
118
+ attachment.assign up_file_double(:original_filename => 'second_me.png', :path => '/tmp/456')
119
+ attachment.save
120
+ end
121
+
122
+ it "stores and process only the second assigned file" do
123
+ attachment.storage.stored.should include(['/tmp/456', '/users/1/original/second_me.png'])
124
+ attachment.storage.stored.should include(['/tmp/normal-456', '/users/1/normal/second_me.png'])
125
+ attachment.storage.stored.should include(['/tmp/small-456', '/users/1/small/second_me.png'])
126
+ attachment.storage.stored.size.should == 3
127
+ end
128
+
129
+ it "doesn't try to remove the first assigned file" do
130
+ attachment.storage.removed.should_not include('/users/1/original/first_me.png')
131
+ attachment.storage.removed.should_not include('/users/1/normal/first_me.png')
132
+ attachment.storage.removed.should_not include('/users/1/small/first_me.png')
133
+ end
134
+ end
135
+
136
+ describe "#destroy" do
137
+ context "when a file hasn't been assigned" do
138
+ before { attachment.destroy }
139
+
140
+ it "doesn't store anything" do
141
+ attachment.storage.stored.should be_empty
142
+ end
143
+
144
+ it "sets to nil the attachment filename in the model" do
145
+ user.avatar_filename.should be_nil
146
+ end
147
+ end
148
+
149
+ context "when a file has been assigned" do
150
+ before do
151
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
152
+ attachment.path = '/:class_name/:id/:style/:filename'
153
+ attachment.assign up_file_double(:original_filename => 'new_me.png', :path => '/tmp/123')
154
+ attachment.destroy
155
+ end
156
+
157
+ it "doesn't remove the assigned file nor its processed files" do
158
+ attachment.storage.removed.should_not include('/users/1/original/new_me.png')
159
+ attachment.storage.removed.should_not include('/users/1/normal/new_me.png')
160
+ attachment.storage.removed.should_not include('/users/1/small/new_me.png')
161
+ end
162
+
163
+ it "doesn't store anything" do
164
+ attachment.storage.stored.should be_empty
165
+ end
166
+
167
+ it "sets to nil the attachment filename in the model" do
168
+ user.avatar_filename.should be_nil
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ describe Attachment do
176
+ describe ".reset_default_options" do
177
+ it "revert changes made to the default options" do
178
+ original_value = Attachment.default_options[:url]
179
+ Attachment.default_options[:url] = '/:filename'
180
+ Attachment.default_options[:url].should_not == original_value
181
+ Attachment.reset_default_options
182
+ Attachment.default_options[:url].should == original_value
183
+ end
184
+ end
185
+
186
+ describe ".new" do
187
+ let(:user) { user_double }
188
+ let(:attachment) { Attachment.new(:avatar, user) }
189
+
190
+ it "sets the attachment's name" do
191
+ attachment.name.should == :avatar
192
+ end
193
+
194
+ it "sets the model to which the attachment belongs" do
195
+ attachment.model.should == user
196
+ end
197
+
198
+ context "when not providing custom options" do
199
+ it "uses the default options" do
200
+ default_style_option = Attachment.default_options[:default_style]
201
+ default_style_option.should_not be_nil
202
+ attachment.default_style.should == default_style_option
203
+ end
204
+ end
205
+
206
+ context "when providing custom options" do
207
+ let(:styles) { { :small => '50x50', :normal => '100x100' } }
208
+ let(:attachment) { Attachment.new(:avatar, user, :styles => styles) }
209
+
210
+ it "prefers the custom over the default options" do
211
+ Attachment.default_options[:styles].should_not == styles
212
+ attachment.styles.should == styles
213
+ end
214
+
215
+ it "uses the default options when there's not a custom one" do
216
+ Attachment.default_options[:default_style].should_not be_nil
217
+ attachment.default_style.should == Attachment.default_options[:default_style]
218
+ end
219
+
220
+ it "doesn't complain if it doesn't know the option" do
221
+ expect {
222
+ Attachment.new(:avatar, user, :imaginary => 'option')
223
+ }.to_not raise_error
224
+ end
225
+ end
226
+ end
227
+
228
+ describe ".config" do
229
+ after { Attachment.reset_default_options }
230
+
231
+ it "changes the default attachment options" do
232
+ default_url = '/missing.png'
233
+ Attachment.default_options[:default_url].should_not == default_url
234
+ Attachment.config { default_url default_url }
235
+ Attachment.default_options[:default_url].should == default_url
236
+ end
237
+ end
238
+ end
239
+
240
+ describe Attachment, "when is empty" do
241
+ let(:user) { user_double(:id => 1, :avatar_filename => nil) }
242
+ let(:attachment) { Attachment.new(:avatar, user) }
243
+
244
+ it_behaves_like "any attachment"
245
+
246
+ describe "#empty?" do
247
+ it "returns true" do
248
+ attachment.should be_empty
249
+ end
250
+ end
251
+
252
+ describe "#url" do
253
+ it "returns a default url" do
254
+ attachment.url.should == '/avatars/original/missing.png'
255
+ end
256
+
257
+ it "builds the default url from a pattern if there's one" do
258
+ attachment.default_url = '/images/:class_name/missing.png'
259
+ attachment.url.should == '/images/users/missing.png'
260
+ end
261
+
262
+ it "accepts a style" do
263
+ attachment.default_url = '/images/:class_name/missing_:style.png'
264
+ attachment.url(:small).should == '/images/users/missing_small.png'
265
+ end
266
+
267
+ it "uses a default style if there isn't one" do
268
+ attachment.default_style = :normal
269
+ attachment.default_url = '/images/:class_name/missing_:style.png'
270
+ attachment.url.should == '/images/users/missing_normal.png'
271
+ end
272
+
273
+ it "assumes an ':original' default style" do
274
+ attachment.default_url = '/images/:class_name/missing_:style.png'
275
+ attachment.url.should == '/images/users/missing_original.png'
276
+ end
277
+ end
278
+
279
+ describe "#path" do
280
+ it "returns nil" do
281
+ attachment.path.should be_nil
282
+ end
283
+
284
+ it "accepts a style" do
285
+ attachment.path(:small).should be_nil
286
+ end
287
+ end
288
+
289
+ describe "#save" do
290
+ context "when a file has been assigned" do
291
+ before do
292
+ attachment.assign(up_file_double)
293
+ attachment.save
294
+ end
295
+
296
+ it "doesn't remove anything" do
297
+ attachment.storage.removed.should be_empty
298
+ end
299
+ end
300
+
301
+ context "when a file has been assigned and then cleared" do
302
+ before do
303
+ attachment.assign(up_file_double)
304
+ attachment.clear
305
+ attachment.save
306
+ end
307
+
308
+ it "doesn't store anything" do
309
+ attachment.storage.stored.should be_empty
310
+ end
311
+
312
+ it "doesn't remove anything" do
313
+ attachment.storage.removed.should be_empty
314
+ end
315
+ end
316
+ end
317
+
318
+ describe "#destroy" do
319
+ it "doesn't remove anything" do
320
+ attachment.destroy
321
+ attachment.storage.removed.should be_empty
322
+ end
323
+ end
324
+ end
325
+
326
+ describe Attachment, "when isn't empty" do
327
+ let(:user) { user_double(:id => 1, :avatar_filename => 'me.png') }
328
+ let(:attachment) { Attachment.new(:avatar, user) }
329
+
330
+ it_behaves_like "any attachment"
331
+
332
+ describe "#dirty?" do
333
+ context "when the attachment is cleared" do
334
+ before { attachment.clear }
335
+
336
+ it "returns true" do
337
+ attachment.should be_dirty
338
+ end
339
+
340
+ context "and the attachment is saved" do
341
+ it "returns false" do
342
+ attachment.save
343
+ attachment.should_not be_dirty
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ describe "#empty?" do
350
+ it "returns false" do
351
+ attachment.should_not be_empty
352
+ end
353
+ end
354
+
355
+ describe "#url" do
356
+ it "builds its url from a default pattern" do
357
+ attachment.url.should == '/system/avatars/1/original/me.png'
358
+ end
359
+
360
+ it "builds its url from a pattern if there's one" do
361
+ attachment.url = '/:class_name/:id/:attachment/:filename'
362
+ attachment.url.should == '/users/1/avatars/me.png'
363
+ end
364
+
365
+ it "accepts a style" do
366
+ attachment.url = '/:class_name/:id/:attachment/:style/:filename'
367
+ attachment.url(:small).should == '/users/1/avatars/small/me.png'
368
+ end
369
+
370
+ it "uses a default style if there isn't one" do
371
+ attachment.default_style = :normal
372
+ attachment.url = '/:class_name/:id/:attachment/:style/:filename'
373
+ attachment.url.should == '/users/1/avatars/normal/me.png'
374
+ end
375
+
376
+ it "assumes an ':original' default style" do
377
+ attachment.url = '/:class_name/:id/:attachment/:style/:filename'
378
+ attachment.url.should == '/users/1/avatars/original/me.png'
379
+ end
380
+ end
381
+
382
+ describe "#path" do
383
+ it "builds its path from the url by default" do
384
+ attachment.stub(:url => '/users/1/avatars/me.png')
385
+ attachment.path.should == './public/users/1/avatars/me.png'
386
+ end
387
+
388
+ it "builds its path from a pattern if there's one" do
389
+ attachment.path = './:class_name/:id/:attachment/:filename'
390
+ attachment.path.should == './users/1/avatars/me.png'
391
+ end
392
+
393
+ it "accepts a style" do
394
+ attachment.path = './:class_name/:id/:attachment/:style/:filename'
395
+ attachment.path(:small).should == './users/1/avatars/small/me.png'
396
+ end
397
+
398
+ it "uses a default style if there isn't one" do
399
+ attachment.default_style = :normal
400
+ attachment.path = './:class_name/:id/:attachment/:style/:filename'
401
+ attachment.path.should == './users/1/avatars/normal/me.png'
402
+ end
403
+
404
+ it "assumes an ':original' default style" do
405
+ attachment.path = './:class_name/:id/:attachment/:style/:filename'
406
+ attachment.path.should == './users/1/avatars/original/me.png'
407
+ end
408
+ end
409
+
410
+ describe "#clear" do
411
+ it "sets to nil the attachment filename in the model" do
412
+ attachment.clear
413
+ attachment.filename.should be_nil
414
+ end
415
+ end
416
+
417
+ describe "#save" do
418
+ context "when a file has been assigned" do
419
+ before do
420
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
421
+ attachment.path = '/:class_name/:id/:style/:filename'
422
+ attachment.assign up_file_double(:original_filename => 'new_me.png', :path => '/tmp/123')
423
+ attachment.save
424
+ end
425
+
426
+ it "removes the previous files for each style" do
427
+ attachment.storage.removed.should include('/users/1/original/me.png')
428
+ attachment.storage.removed.should include('/users/1/normal/me.png')
429
+ attachment.storage.removed.should include('/users/1/small/me.png')
430
+ end
431
+ end
432
+
433
+ context "when the attachment has been cleared" do
434
+ let(:attachment) { Attachment.new(:avatar, user) }
435
+ before do
436
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
437
+ attachment.path = '/:class_name/:id/:style/:filename'
438
+ attachment.clear
439
+ attachment.save
440
+ end
441
+
442
+ it "doesn't store anything" do
443
+ attachment.storage.stored.should be_empty
444
+ end
445
+
446
+ it "removes the files for each style" do
447
+ attachment.storage.removed.should include('/users/1/original/me.png')
448
+ attachment.storage.removed.should include('/users/1/normal/me.png')
449
+ attachment.storage.removed.should include('/users/1/small/me.png')
450
+ attachment.storage.removed.size.should == 3
451
+ end
452
+ end
453
+ end
454
+
455
+ describe "#destroy" do
456
+ context "when a file hasn't been assigned" do
457
+ before do
458
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
459
+ attachment.path = '/:class_name/:id/:style/:filename'
460
+ attachment.destroy
461
+ end
462
+
463
+ it "removes the files for each style" do
464
+ attachment.storage.removed.should include('/users/1/original/me.png')
465
+ attachment.storage.removed.should include('/users/1/normal/me.png')
466
+ attachment.storage.removed.should include('/users/1/small/me.png')
467
+ attachment.storage.removed.size.should == 3
468
+ end
469
+ end
470
+
471
+ context "when a file has been assigned" do
472
+ before do
473
+ attachment.styles = { :normal => '100x100', :small => '50x50' }
474
+ attachment.path = '/:class_name/:id/:style/:filename'
475
+ attachment.assign up_file_double(:original_filename => 'new_me.png', :path => '/tmp/123')
476
+ attachment.destroy
477
+ end
478
+
479
+ it "removes the files for every style" do
480
+ attachment.storage.removed.should include('/users/1/original/me.png')
481
+ attachment.storage.removed.should include('/users/1/normal/me.png')
482
+ attachment.storage.removed.should include('/users/1/small/me.png')
483
+ attachment.storage.removed.size.should == 3
484
+ end
485
+ end
486
+ end
487
+ end
488
+ end