adrift 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ doc/*
2
+ pkg/*
3
+ *.gem
4
+ .bundle
5
+ .rvmrc
6
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2011 Gabriel Andretta
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,10 @@
1
+ = Adrift
2
+
3
+ Adrift is a DIY library to ease attaching files to a model.
4
+
5
+ It currently works only with ActiveRecord and DataMapper within Rails
6
+ or Sinatra, but it should be pretty adaptable to another environment.
7
+
8
+ == License
9
+
10
+ Adrift is released under MIT license. See LICENSE.
@@ -0,0 +1,22 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new
6
+
7
+ require 'cucumber'
8
+ require 'cucumber/rake/task'
9
+ Cucumber::Rake::Task.new(:features)
10
+
11
+ require 'rdoc/task'
12
+ RDoc::Task.new do |rdoc|
13
+ rdoc.rdoc_dir = 'doc'
14
+ rdoc.title = 'Adrift'
15
+ rdoc.main = 'README.rdoc'
16
+
17
+ rdoc.rdoc_files.include('README.rdoc')
18
+ rdoc.rdoc_files.include('LICENSE')
19
+ rdoc.rdoc_files.include('lib/**/*.rb')
20
+ end
21
+
22
+ task :default => :spec
@@ -0,0 +1,38 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "adrift/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "adrift"
7
+ s.version = Adrift::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Gabriel Andretta"]
10
+ s.email = ["ohhgabriel@gmail.com"]
11
+ s.homepage = ""
12
+ s.summary = "Simplistic attachment management"
13
+ s.description = "Simplistic attachment management"
14
+
15
+ s.rubyforge_project = "adrift"
16
+
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {spec,features}/*`.split("\n")
19
+
20
+ s.require_paths = ["lib"]
21
+
22
+ s.has_rdoc = true
23
+
24
+ s.add_dependency 'activesupport', '~>3.0'
25
+ s.add_dependency 'i18n'
26
+
27
+ s.add_development_dependency 'rspec', '~>2.4'
28
+ s.add_development_dependency 'cucumber', '~>0.10'
29
+ s.add_development_dependency 'activerecord', '~>3.0'
30
+ s.add_development_dependency 'dm-core', '~>1.0'
31
+ s.add_development_dependency 'dm-migrations'
32
+ s.add_development_dependency 'dm-validations'
33
+ s.add_development_dependency 'dm-sqlite-adapter'
34
+ s.add_development_dependency 'sqlite3'
35
+ s.add_development_dependency 'rake'
36
+ s.add_development_dependency 'ZenTest'
37
+ s.add_development_dependency 'rdoc', '~>3.5'
38
+ end
@@ -0,0 +1 @@
1
+ Autotest.add_discovery { "rspec2" }
@@ -0,0 +1,42 @@
1
+ Feature: ActiveRecord integration
2
+
3
+ In order to handle file attachments easily
4
+ As a Ruby developer using ActiveRecord
5
+ I want to let Adrift do the dirty work
6
+
7
+ Scenario: A file is attached
8
+ Given I instantiate an active record model
9
+ When I attach a file to it
10
+ And I save it
11
+ Then the file should be stored
12
+
13
+ Scenario: A file is attached to an invalid model
14
+ Given I instantiate an invalid active record model
15
+ When I attach a file to it
16
+ And I try to save it
17
+ Then it should not be saved
18
+ And the file should not be stored
19
+
20
+ Scenario: Two files are attached
21
+ Given I instantiate an active record model
22
+ When I attach a file to it
23
+ And I save it
24
+ And I attach another file to it
25
+ And I save it again
26
+ Then the first file should not still be stored
27
+ And the second file should be stored
28
+
29
+ Scenario: A file is detached
30
+ Given I instantiate an active record model
31
+ When I attach a file to it
32
+ And I save it
33
+ And I detach the file from it
34
+ And I save it
35
+ Then the file should not still be stored
36
+
37
+ Scenario: A model with an attached file is destroyed
38
+ Given I instantiate an active record model
39
+ When I attach a file to it
40
+ And I save it
41
+ And I destroy it
42
+ Then the file should not still be stored
@@ -0,0 +1,42 @@
1
+ Feature: DataMapper integration
2
+
3
+ In order to handle file attachments easily
4
+ As a Ruby developer using DataMapper
5
+ I want to let Adrift do the dirty work
6
+
7
+ Scenario: A file is attached
8
+ Given I instantiate a data mapper model
9
+ When I attach a file to it
10
+ And I save it
11
+ Then the file should be stored
12
+
13
+ Scenario: A file is attached to an invalid model
14
+ Given I instantiate an invalid data mapper model
15
+ When I attach a file to it
16
+ And I try to save it
17
+ Then it should not be saved
18
+ And the file should not be stored
19
+
20
+ Scenario: Two files are attached
21
+ Given I instantiate a data mapper model
22
+ When I attach a file to it
23
+ And I save it
24
+ And I attach another file to it
25
+ And I save it again
26
+ Then the first file should not still be stored
27
+ And the second file should be stored
28
+
29
+ Scenario: A file is detached
30
+ Given I instantiate a data mapper model
31
+ When I attach a file to it
32
+ And I save it
33
+ And I detach the file from it
34
+ And I save it
35
+ Then the file should not still be stored
36
+
37
+ Scenario: A model with an attached file is destroyed
38
+ Given I instantiate a data mapper model
39
+ When I attach a file to it
40
+ And I save it
41
+ And I destroy it
42
+ Then the file should not still be stored
@@ -0,0 +1,54 @@
1
+ Given /^I instantiate an active record model$/ do
2
+ instantiate :active_record
3
+ end
4
+
5
+ Given /^I instantiate an invalid active record model$/ do
6
+ instantiate :active_record, :valid => false
7
+ end
8
+
9
+ Given /^I instantiate a data mapper model$/ do
10
+ instantiate :data_mapper
11
+ end
12
+
13
+ Given /^I instantiate an invalid data mapper model$/ do
14
+ instantiate :data_mapper, :valid => false
15
+ end
16
+
17
+ When /^I attach a(?:nother)? file to it$/ do
18
+ attach(attached? ? other_original_file : original_file)
19
+ end
20
+
21
+ When /^I(?: try to)? save it(?: again)?$/ do
22
+ instance.save
23
+ end
24
+
25
+ When /^I destroy it$/ do
26
+ instance.destroy
27
+ end
28
+
29
+ When /^I detach the file from it$/ do
30
+ detach
31
+ end
32
+
33
+ Then /^it should not be saved$/ do
34
+ if instance.respond_to?(:persisted?)
35
+ instance.should_not be_persisted
36
+ else
37
+ instance.should_not be_saved
38
+ end
39
+ end
40
+
41
+ Then /^the(?: second)? file should be stored$/ do
42
+ File.exist?(last_attachment).should be_true
43
+ read_file(last_attachment).should == read_file(last_file)
44
+ end
45
+
46
+ Then /^the file should not(?: still)? be stored$/ do
47
+ last_attachment.should_not be_nil
48
+ File.exist?(last_attachment).should be_false
49
+ end
50
+
51
+ Then /^the first file should not still be stored$/ do
52
+ first_attachment.should_not be_nil
53
+ File.exist?(first_attachment).should be_false
54
+ end
@@ -0,0 +1,55 @@
1
+ $:.unshift File.expand_path('../../../lib', __FILE__)
2
+ require 'cucumber'
3
+ require 'adrift'
4
+ require 'adrift/integration'
5
+
6
+ require 'active_record'
7
+ Adrift::Integration::ActiveRecord.install
8
+
9
+ ActiveRecord::Base.establish_connection(
10
+ :adapter => 'sqlite3',
11
+ :database => '/tmp/adrift-activerecord.sqlite3'
12
+ )
13
+
14
+ ActiveRecord::Migration.verbose = false
15
+
16
+ ActiveRecord::Schema.define(:version => 1) do
17
+ create_table 'ar_users', :force => true do |t|
18
+ t.string 'name'
19
+ t.string 'avatar_filename'
20
+ end
21
+ end
22
+
23
+ class ARUser < ActiveRecord::Base
24
+ validates :name, :presence => true
25
+ attachment :avatar
26
+ end
27
+
28
+ require 'dm-core'
29
+ require 'dm-validations'
30
+ require 'dm-migrations'
31
+ Adrift::Integration::DataMapper.install
32
+
33
+ DataMapper.setup(:default, 'sqlite::memory:')
34
+
35
+ class DMUser
36
+ include DataMapper::Resource
37
+
38
+ property :id, Serial
39
+ property :name, String
40
+ property :avatar_filename, String
41
+
42
+ validates_presence_of :name
43
+
44
+ attachment :avatar
45
+ end
46
+
47
+ DataMapper.finalize
48
+ DataMapper.auto_migrate!
49
+
50
+ Before do
51
+ ARUser.delete_all
52
+ DMUser.destroy
53
+ end
54
+
55
+ After { system 'rm -rf public' }
@@ -0,0 +1,58 @@
1
+ module Adrift
2
+ module Cucumber
3
+ module Attachment
4
+ def read_file(path)
5
+ File.open(path, 'rb') { |io| io.read }
6
+ end
7
+
8
+ def original_file
9
+ 'spec/fixtures/me.png'
10
+ end
11
+
12
+ def other_original_file
13
+ 'spec/fixtures/me_no_colors.png'
14
+ end
15
+ end
16
+
17
+ module Model
18
+ attr_accessor :instance, :last_file, :last_attachment, :first_file,
19
+ :first_attachment
20
+
21
+ def instantiate(orm, opts={ :valid => true })
22
+ @last_id ||= 0
23
+ self.instance = class_for(orm).new.tap do |user|
24
+ user.id = @last_id += 1
25
+ user.name = 'ohhgabriel' if opts[:valid]
26
+ end
27
+ end
28
+
29
+ def attached?
30
+ !last_file.nil?
31
+ end
32
+
33
+ def attach(path)
34
+ instance.avatar = File.new(path)
35
+ if first_file.nil?
36
+ self.first_file = last_file
37
+ self.first_attachment = last_attachment
38
+ end
39
+ self.last_file = path
40
+ self.last_attachment = instance.avatar.path
41
+ end
42
+
43
+ def detach
44
+ instance.avatar.destroy
45
+ end
46
+
47
+ def class_for(orm)
48
+ case orm
49
+ when :active_record then ARUser
50
+ when :data_mapper then DMUser
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ World(Adrift::Cucumber::Attachment)
58
+ World(Adrift::Cucumber::Model)
@@ -0,0 +1,10 @@
1
+ require 'adrift/attachment'
2
+ require 'adrift/file_to_attach'
3
+ require 'adrift/pattern'
4
+ require 'adrift/processor'
5
+ require 'adrift/storage'
6
+
7
+ require 'adrift/railtie' if defined?(Rails::Railtie)
8
+
9
+ module Adrift
10
+ end
@@ -0,0 +1,246 @@
1
+ module Adrift
2
+ # Handles attaching files to a model, allowing to automatically
3
+ # create different stlyes (versions) of them.
4
+ #
5
+ # Actually, this class's responsibility consist in just directing
6
+ # this process: it relies on a #storage object, for saving and
7
+ # removing the attached files, and on a #processor object, for the
8
+ # task of generating the different versions of a file from the given
9
+ # style definitions.
10
+ #
11
+ # Also, it provides a naive pattern mechanism to express the
12
+ # attachment's #path and #url.
13
+ class Attachment
14
+ attr_accessor :default_style, :styles, :storage, :processor, :pattern_class
15
+ attr_writer :default_url, :url, :path
16
+ attr_reader :name, :model
17
+
18
+ # Allows to change the options used for every new attachment. For
19
+ # instance, to change the +:default_style+ and +:path+ options:
20
+ #
21
+ # Adrift::Attachment.config do
22
+ # default_style :default
23
+ # path '/custom/storage/path/:url'
24
+ # end
25
+ #
26
+ # See ::default_options for a list of the supported options.
27
+ def self.config(&block)
28
+ config = BasicObject.new
29
+ def config.method_missing(m, *args)
30
+ options = Attachment.default_options
31
+ options[m] = args.first if options.has_key?(m)
32
+ end
33
+ config.instance_eval(&block)
34
+ end
35
+
36
+ # Default options for every new Attachment. These are:
37
+ #
38
+ # [+:default_style+]
39
+ # Style assumed by #url and #path when no one has been provided.
40
+ #
41
+ # [+:styles+]
42
+ # Hash with the style definitions, they keys are the style
43
+ # names, and the values whatever makes sense to the processor to
44
+ # generate the alternate versions of the attached file. By
45
+ # default, they indicate the thumbnails dimmensions, for
46
+ # instance:
47
+ #
48
+ # styles: { small: '50x50', medium: '100x100' }
49
+ #
50
+ # See Processor::Thumbnail for the details.
51
+ #
52
+ # [+:default_url+]
53
+ # String pattern used to build the returned value of #url when
54
+ # the attachment is empty.
55
+ #
56
+ # [+:url+]
57
+ # String pattern used to build the returned value of #url when
58
+ # the attachment is not empty.
59
+ #
60
+ # [+:path+]
61
+ # String pattern used to build the path where the attachment
62
+ # will be stored (and will be returned by #path).
63
+ #
64
+ # *Note*: when having an attachment with more than one style,
65
+ # the path must be unique for each one, otherwise the stored
66
+ # files will be overwritten. In the most common case, what this
67
+ # means is just that the path option must have a +:style+ tag.
68
+ #
69
+ # [+:storage+]
70
+ # Object delegated with the task of saving and removing files.
71
+ # See Storage for details and Storage::Filesystem for an
72
+ # implementation.
73
+ #
74
+ # [+:processor+]
75
+ # Object delegated with the task of generating the alternate
76
+ # versions of the attached file from the :styles. See Processor
77
+ # for details and Processor::Thumbnail for an implementation.
78
+ #
79
+ # [+:pattern_class+]
80
+ # The class used to build the #url and #path of the Attachment
81
+ # from the string patterns provided.
82
+ #
83
+ # See the source of this method to know the values of this
84
+ # options. Note that these values can be changed for every new
85
+ # attachment with ::config, or in a per-attachment basis, when
86
+ # they are constructed with ::new, or after, just calling the
87
+ # attachment's writer method named after the option.
88
+ def self.default_options
89
+ @default_options ||= {
90
+ :default_style => :original,
91
+ :styles => {},
92
+ :default_url => '/:attachment/:style/missing.png',
93
+ :url => '/system/:attachment/:id/:style/:filename',
94
+ :path => ':root/public:url',
95
+ :storage => Proc.new { Storage::Filesystem.new },
96
+ :processor => Proc.new { Processor::Thumbnail.new },
97
+ :pattern_class => Pattern
98
+ }
99
+ end
100
+
101
+ # Restores the attachment's options to their default values. See
102
+ # the source of ::default_options to know what these default
103
+ # values are.
104
+ def self.reset_default_options
105
+ @default_options = nil
106
+ end
107
+
108
+ # Creates a new Attachment object. +name+ is the name of the
109
+ # Attachment, +model+ is the model object it's attached to, and
110
+ # +options+ is a Hash that lets customize the Attachment's
111
+ # behaviour.
112
+ #
113
+ # The +model+ object must allow reading and writing to an
114
+ # attribute called after the Attachment's +name+ which stores the
115
+ # attached file's name. For instance, for an attachment named
116
+ # +avatar+, the +model+ needs to respond to the methods
117
+ # +avatar_filename+ and +avatar_filename=+.
118
+ #
119
+ # See ::default_options for a list of the supported +options+.
120
+ # The options passed here will overwrite the default ones.
121
+ def initialize(name, model, options={})
122
+ self.class.default_options.merge(options).each do |name, value|
123
+ writer_name = "#{name}="
124
+ if respond_to?(writer_name)
125
+ send writer_name, value.is_a?(Proc) ? value.call : value
126
+ end
127
+ end
128
+ @name, @model = name, model
129
+ end
130
+
131
+ # Indicates whether or not there are changes that need to be
132
+ # saved, that is, files that need to be processed and stored
133
+ # and/or removed.
134
+ def dirty?
135
+ !@file_to_attach.nil? || storage.dirty?
136
+ end
137
+
138
+ # Returns the attachment's url for the given +style+. If no
139
+ # +style+ is given it assumes the +:default_style+ option. Also,
140
+ # it uses the +:url+ or the +:default_url+ option, depending
141
+ # whether or not the attachment is empty.
142
+ def url(style=default_style)
143
+ specialize(empty? ? @default_url : @url, style)
144
+ end
145
+
146
+ # Returns the attachment's path for the given +style+. If no
147
+ # +style+ is given it assumes +:default_style+ option. When the
148
+ # attachment is empty it returns +nil+.
149
+ def path(style=default_style)
150
+ specialize(@path, style) unless empty?
151
+ end
152
+
153
+ # Makes +file_to_attach+ the new attached file, but it won't be
154
+ # stored nor processed until the attachment receives #save. It
155
+ # also updates the model's attachment file name attribute.
156
+ #
157
+ # See FileToAttach::Adapters for the expected interface of
158
+ # +file_to_attach+.
159
+ def assign(file_to_attach)
160
+ enqueue_files_for_removal
161
+ model_send(
162
+ :filename=,
163
+ file_to_attach.original_filename.to_s.tr('^a-zA-Z0-9.', '_')
164
+ )
165
+ @file_to_attach = file_to_attach
166
+ end
167
+
168
+ # Throws away the current attached file, but it won't actually be
169
+ # removed until the attachment receives #save. It also sets the
170
+ # model's attachment file name attribute to +nil+.
171
+ def clear
172
+ enqueue_files_for_removal
173
+ @file_to_attach = nil
174
+ model_send(:filename=, nil)
175
+ end
176
+
177
+ # When there is a new attached file will store and process it, and
178
+ # if there was a previous attached file it will also remove it.
179
+ # On the other hand it will remove the current attached file if it
180
+ # was thrown away (in other words, the attachment has received
181
+ # #clear).
182
+ #
183
+ # Generally, this will get called when the model is saved.
184
+ def save
185
+ unless @file_to_attach.nil?
186
+ processor.process(@file_to_attach.path, styles)
187
+ enqueue_files_for_storage
188
+ end
189
+ storage.flush
190
+ @file_to_attach = nil
191
+ end
192
+
193
+ # Removes the current attached file, setting the model's
194
+ # attachment file name attribute to +nil+.
195
+ def destroy
196
+ clear
197
+ save
198
+ end
199
+
200
+ # Indicates whether or not there is a file attached. Note that a
201
+ # file could be attached without being stored or processed.
202
+ def empty?
203
+ filename.nil?
204
+ end
205
+
206
+ # Returns the attachment's file name.
207
+ def filename
208
+ model_send(:filename)
209
+ end
210
+
211
+ private
212
+
213
+ # Sends the message +message_without_prefix+ to the model,
214
+ # prefixed with the attachment's name, and passing the given
215
+ # +args+.
216
+ def model_send(message_without_prefix, *args)
217
+ model.public_send("#{name}_#{message_without_prefix}", *args)
218
+ end
219
+
220
+ # Specializes the string pattern +str+, for the attachment which
221
+ # receives this message and the given +style+.
222
+ def specialize(str, style)
223
+ pattern_class.new(str).specialize(:attachment => self, :style => style)
224
+ end
225
+
226
+ # Adds all the current attached files to the storage's removal
227
+ # queue, unless they don't exist or are already there.
228
+ def enqueue_files_for_removal
229
+ return if empty? || dirty?
230
+ [:original, *styles.keys].uniq.each { |style| storage.remove path(style) }
231
+ end
232
+
233
+ # Adds all the current attached files to the storage's queue.
234
+ def enqueue_files_for_storage
235
+ files_for_storage.each { |style, file| storage.store(file, path(style)) }
236
+ end
237
+
238
+ # Returns a hash containing the files that need to be stored as
239
+ # values and their styles as keys.
240
+ def files_for_storage
241
+ processor.processed_files.dup.tap do |files|
242
+ files[:original] ||= @file_to_attach.path
243
+ end
244
+ end
245
+ end
246
+ end