adrift 0.0.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.
@@ -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