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,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