jeremydurham-merb_paperclip 0.9.12
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +23 -0
- data/README +4 -0
- data/Rakefile +51 -0
- data/TODO +1 -0
- data/lib/generators/paperclip_generator.rb +53 -0
- data/lib/generators/templates/%file_name%.rb +17 -0
- data/lib/merb_paperclip/merbtasks.rb +38 -0
- data/lib/merb_paperclip.rb +19 -0
- data/lib/paperclip/attachment.rb +287 -0
- data/lib/paperclip/geometry.rb +109 -0
- data/lib/paperclip/iostream.rb +47 -0
- data/lib/paperclip/storage.rb +204 -0
- data/lib/paperclip/thumbnail.rb +80 -0
- data/lib/paperclip/upfile.rb +37 -0
- data/lib/paperclip.rb +246 -0
- data/spec/merb_paperclip_spec.rb +7 -0
- data/spec/spec_helper.rb +1 -0
- metadata +83 -0
data/LICENSE
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
LICENSE
|
2
|
+
|
3
|
+
The MIT License
|
4
|
+
|
5
|
+
Copyright (c) 2008 Jon Yurek and thoughtbot, inc.
|
6
|
+
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
9
|
+
in the Software without restriction, including without limitation the rights
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
12
|
+
furnished to do so, subject to the following conditions:
|
13
|
+
|
14
|
+
The above copyright notice and this permission notice shall be included in
|
15
|
+
all copies or substantial portions of the Software.
|
16
|
+
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
23
|
+
THE SOFTWARE.
|
data/README
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/gempackagetask'
|
3
|
+
|
4
|
+
require 'merb-core'
|
5
|
+
require 'merb-core/tasks/merb'
|
6
|
+
|
7
|
+
GEM_NAME = "merb_paperclip"
|
8
|
+
GEM_VERSION = "0.9.12"
|
9
|
+
AUTHOR = "Jeremy Durham"
|
10
|
+
EMAIL = "jeremydurham@gmail.com"
|
11
|
+
HOMEPAGE = "http://www.thoughtbot.com/projects/paperclip/"
|
12
|
+
SUMMARY = "A Merb plugin that is essentially a port of Jon Yurek's paperclip"
|
13
|
+
|
14
|
+
spec = Gem::Specification.new do |s|
|
15
|
+
s.rubyforge_project = 'merb'
|
16
|
+
s.name = GEM_NAME
|
17
|
+
s.version = GEM_VERSION
|
18
|
+
s.platform = Gem::Platform::RUBY
|
19
|
+
s.has_rdoc = true
|
20
|
+
s.extra_rdoc_files = ["README", "LICENSE", 'TODO']
|
21
|
+
s.summary = SUMMARY
|
22
|
+
s.description = s.summary
|
23
|
+
s.author = AUTHOR
|
24
|
+
s.email = EMAIL
|
25
|
+
s.homepage = HOMEPAGE
|
26
|
+
s.add_dependency('merb', '>= 1.0')
|
27
|
+
s.require_path = 'lib'
|
28
|
+
s.files = %w(LICENSE README Rakefile TODO) + Dir.glob("{lib,spec}/**/*")
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
Rake::GemPackageTask.new(spec) do |pkg|
|
33
|
+
pkg.gem_spec = spec
|
34
|
+
end
|
35
|
+
|
36
|
+
desc "install the plugin as a gem"
|
37
|
+
task :install do
|
38
|
+
Merb::RakeHelper.install(GEM_NAME, :version => GEM_VERSION)
|
39
|
+
end
|
40
|
+
|
41
|
+
desc "Uninstall the gem"
|
42
|
+
task :uninstall do
|
43
|
+
Merb::RakeHelper.uninstall(GEM_NAME, :version => GEM_VERSION)
|
44
|
+
end
|
45
|
+
|
46
|
+
desc "Create a gemspec file"
|
47
|
+
task :gemspec do
|
48
|
+
File.open("#{GEM_NAME}.gemspec", "w") do |file|
|
49
|
+
file.puts spec.to_ruby
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Merb::Generators
|
2
|
+
|
3
|
+
class PaperclipGenerator < NamespacedGenerator
|
4
|
+
|
5
|
+
def self.source_root
|
6
|
+
File.dirname(__FILE__) / 'templates'
|
7
|
+
end
|
8
|
+
|
9
|
+
desc <<-DESC
|
10
|
+
Generators a paperclip migration
|
11
|
+
DESC
|
12
|
+
|
13
|
+
first_argument :name, :required => true, :desc => "model name"
|
14
|
+
second_argument :attachments, :required => true, :as => :array, :default => [], :desc => "space separated list of fields"
|
15
|
+
|
16
|
+
template :paperclip do
|
17
|
+
source(File.dirname(__FILE__) / 'templates' / '%file_name%.rb')
|
18
|
+
destination("schema/migrations/#{migration_file_name}.rb")
|
19
|
+
end
|
20
|
+
|
21
|
+
def version
|
22
|
+
format("%03d", current_migration_nr + 1)
|
23
|
+
end
|
24
|
+
|
25
|
+
def migration_file_name
|
26
|
+
names = migration_attachments
|
27
|
+
"#{version}_add_attachments_#{names.join("_")}_to_#{class_name.underscore}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def migration_name
|
31
|
+
names = migration_attachments
|
32
|
+
"add_attachments_#{names.join("_")}_to_#{class_name.underscore}".classify
|
33
|
+
end
|
34
|
+
|
35
|
+
protected
|
36
|
+
|
37
|
+
def migration_attachments
|
38
|
+
names = attachments.map(&:underscore)
|
39
|
+
attachments.length == 1 ? names : names[0..-2] + ["and", names[-1]]
|
40
|
+
end
|
41
|
+
|
42
|
+
def destination_directory
|
43
|
+
File.join(destination_root, 'schema', 'migrations')
|
44
|
+
end
|
45
|
+
|
46
|
+
def current_migration_nr
|
47
|
+
Dir["#{destination_directory}/*"].map{|f| File.basename(f).match(/^(\d+)/)[0].to_i }.max.to_i
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
add :paperclip, PaperclipGenerator
|
53
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class <%= migration_name %> < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
<% attachments.each do |attachment| -%>
|
4
|
+
add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_name, :string
|
5
|
+
add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_content_type, :string
|
6
|
+
add_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_size, :integer
|
7
|
+
<% end -%>
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.down
|
11
|
+
<% attachments.each do |attachment| -%>
|
12
|
+
remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_name
|
13
|
+
remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_content_type
|
14
|
+
remove_column :<%= class_name.underscore.camelize.tableize %>, :<%= attachment %>_file_size
|
15
|
+
<% end -%>
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
def obtain_class
|
2
|
+
class_name = ENV['CLASS'] || ENV['class']
|
3
|
+
raise "Must specify CLASS" unless class_name
|
4
|
+
@klass = Object.const_get(class_name)
|
5
|
+
end
|
6
|
+
|
7
|
+
def obtain_attachments
|
8
|
+
name = ENV['ATTACHMENT'] || ENV['attachment']
|
9
|
+
raise "Class #{@klass.name} has no attachments specified" unless @klass.respond_to?(:attachment_definitions)
|
10
|
+
if !name.blank? && @klass.attachment_definitions.keys.include?(name)
|
11
|
+
[ name ]
|
12
|
+
else
|
13
|
+
@klass.attachment_definitions.keys
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
namespace :paperclip do
|
18
|
+
desc "Regenerates thumbnails for a given CLASS (and optional ATTACHMENT)"
|
19
|
+
task :refresh => :environment do
|
20
|
+
klass = obtain_class
|
21
|
+
names = obtain_attachments
|
22
|
+
instances = klass.find(:all)
|
23
|
+
|
24
|
+
puts "Regenerating thumbnails for #{instances.length} instances of #{klass.name}:"
|
25
|
+
instances.each do |instance|
|
26
|
+
names.each do |name|
|
27
|
+
result = if instance.send("#{ name }?")
|
28
|
+
instance.send(name).reprocess!
|
29
|
+
instance.send(name).save
|
30
|
+
else
|
31
|
+
true
|
32
|
+
end
|
33
|
+
print result ? "." : "x"; $stdout.flush
|
34
|
+
end
|
35
|
+
end
|
36
|
+
puts " Done."
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# make sure we're running inside Merb
|
2
|
+
if defined?(Merb::Plugins)
|
3
|
+
dependency "activerecord"
|
4
|
+
|
5
|
+
# Merb gives you a Merb::Plugins.config hash...feel free to put your stuff in your piece of it
|
6
|
+
Merb::Plugins.config[:merb_paperclip] = {
|
7
|
+
:chickens => false
|
8
|
+
}
|
9
|
+
|
10
|
+
Merb::BootLoader.before_app_loads do
|
11
|
+
require File.join(File.dirname(__FILE__), "paperclip")
|
12
|
+
Merb.add_generators(File.join(File.dirname(__FILE__), 'generators', 'paperclip_generator'))
|
13
|
+
end
|
14
|
+
|
15
|
+
Merb::BootLoader.after_app_loads do
|
16
|
+
end
|
17
|
+
|
18
|
+
Merb::Plugins.add_rakefiles "merb_paperclip/merbtasks"
|
19
|
+
end
|
@@ -0,0 +1,287 @@
|
|
1
|
+
module Paperclip
|
2
|
+
# The Attachment class manages the files for a given attachment. It saves when the model saves,
|
3
|
+
# deletes when the model is destroyed, and processes the file upon assignment.
|
4
|
+
class Attachment
|
5
|
+
|
6
|
+
def self.default_options
|
7
|
+
@default_options ||= {
|
8
|
+
:url => "/:attachment/:id/:style/:basename.:extension",
|
9
|
+
:path => ":merb_root/public/:attachment/:id/:style/:basename.:extension",
|
10
|
+
:styles => {},
|
11
|
+
:default_url => "/:attachment/:style/missing.png",
|
12
|
+
:default_style => :original,
|
13
|
+
:validations => [],
|
14
|
+
:storage => :filesystem
|
15
|
+
}
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_reader :name, :instance, :styles, :default_style
|
19
|
+
|
20
|
+
# Creates an Attachment object. +name+ is the name of the attachment, +instance+ is the
|
21
|
+
# ActiveRecord object instance it's attached to, and +options+ is the same as the hash
|
22
|
+
# passed to +has_attached_file+.
|
23
|
+
def initialize name, instance, options = {}
|
24
|
+
@name = name
|
25
|
+
@instance = instance
|
26
|
+
|
27
|
+
options = self.class.default_options.merge(options)
|
28
|
+
|
29
|
+
@url = options[:url]
|
30
|
+
@path = options[:path]
|
31
|
+
@styles = options[:styles]
|
32
|
+
@default_url = options[:default_url]
|
33
|
+
@validations = options[:validations]
|
34
|
+
@default_style = options[:default_style]
|
35
|
+
@storage = options[:storage]
|
36
|
+
@whiny_thumbnails = options[:whiny_thumbnails]
|
37
|
+
@options = options
|
38
|
+
@queued_for_delete = []
|
39
|
+
@queued_for_write = {}
|
40
|
+
@errors = []
|
41
|
+
@validation_errors = nil
|
42
|
+
@dirty = false
|
43
|
+
|
44
|
+
normalize_style_definition
|
45
|
+
initialize_storage
|
46
|
+
|
47
|
+
logger.info("[paperclip] Paperclip attachment #{name} on #{instance.class} initialized.")
|
48
|
+
end
|
49
|
+
|
50
|
+
# What gets called when you call instance.attachment = File. It clears errors,
|
51
|
+
# assigns attributes, processes the file, and runs validations. It also queues up
|
52
|
+
# the previous file for deletion, to be flushed away on #save of its host.
|
53
|
+
# In addition to form uploads, you can also assign another Paperclip attachment:
|
54
|
+
# new_user.avatar = old_user.avatar
|
55
|
+
def assign uploaded_file
|
56
|
+
%w(file_name).each do |field|
|
57
|
+
unless @instance.class.column_names.include?("#{name}_#{field}")
|
58
|
+
raise PaperclipError.new("#{self} model does not have required column '#{name}_#{field}'")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
if uploaded_file.is_a?(Paperclip::Attachment)
|
63
|
+
uploaded_file = uploaded_file.to_file(:original)
|
64
|
+
end
|
65
|
+
|
66
|
+
return nil unless valid_assignment?(uploaded_file)
|
67
|
+
logger.info("[paperclip] Assigning #{uploaded_file.inspect} to #{name}")
|
68
|
+
|
69
|
+
queue_existing_for_delete
|
70
|
+
@errors = []
|
71
|
+
@validation_errors = nil
|
72
|
+
|
73
|
+
return nil if uploaded_file.nil?
|
74
|
+
|
75
|
+
logger.info("[paperclip] Writing attributes for #{name}")
|
76
|
+
@queued_for_write[:original] = uploaded_file['tempfile']
|
77
|
+
@instance[:"#{@name}_file_name"] = uploaded_file['tempfile'].original_filename.strip.gsub /[^\w\d\.\-]+/, '_'
|
78
|
+
@instance[:"#{@name}_content_type"] = uploaded_file['tempfile'].content_type.strip
|
79
|
+
@instance[:"#{@name}_file_size"] = uploaded_file['tempfile'].size.to_i
|
80
|
+
@instance[:"#{@name}_updated_at"] = Time.now
|
81
|
+
|
82
|
+
@dirty = true
|
83
|
+
|
84
|
+
post_process
|
85
|
+
ensure
|
86
|
+
validate
|
87
|
+
end
|
88
|
+
|
89
|
+
# Returns the public URL of the attachment, with a given style. Note that this
|
90
|
+
# does not necessarily need to point to a file that your web server can access
|
91
|
+
# and can point to an action in your app, if you need fine grained security.
|
92
|
+
# This is not recommended if you don't need the security, however, for
|
93
|
+
# performance reasons.
|
94
|
+
def url style = default_style
|
95
|
+
url = original_filename.nil? ? interpolate(@default_url, style) : interpolate(@url, style)
|
96
|
+
updated_at ? [url, updated_at].compact.join(url.include?("?") ? "&" : "?") : url
|
97
|
+
end
|
98
|
+
|
99
|
+
# Returns the path of the attachment as defined by the :path option. If the
|
100
|
+
# file is stored in the filesystem the path refers to the path of the file on
|
101
|
+
# disk. If the file is stored in S3, the path is the "key" part of the URL,
|
102
|
+
# and the :bucket option refers to the S3 bucket.
|
103
|
+
def path style = nil #:nodoc:
|
104
|
+
interpolate(@path, style)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Alias to +url+
|
108
|
+
def to_s style = nil
|
109
|
+
url(style)
|
110
|
+
end
|
111
|
+
|
112
|
+
# Returns true if there are no errors on this attachment.
|
113
|
+
def valid?
|
114
|
+
validate
|
115
|
+
errors.length == 0
|
116
|
+
end
|
117
|
+
|
118
|
+
# Returns an array containing the errors on this attachment.
|
119
|
+
def errors
|
120
|
+
@errors.compact.uniq
|
121
|
+
end
|
122
|
+
|
123
|
+
# Returns true if there are changes that need to be saved.
|
124
|
+
def dirty?
|
125
|
+
@dirty
|
126
|
+
end
|
127
|
+
|
128
|
+
# Saves the file, if there are no errors. If there are, it flushes them to
|
129
|
+
# the instance's errors and returns false, cancelling the save.
|
130
|
+
def save
|
131
|
+
if valid?
|
132
|
+
logger.info("[paperclip] Saving files for #{name}")
|
133
|
+
flush_deletes
|
134
|
+
flush_writes
|
135
|
+
@dirty = false
|
136
|
+
true
|
137
|
+
else
|
138
|
+
logger.info("[paperclip] Errors on #{name}. Not saving.")
|
139
|
+
flush_errors
|
140
|
+
false
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Returns the name of the file as originally assigned, and as lives in the
|
145
|
+
# <attachment>_file_name attribute of the model.
|
146
|
+
def original_filename
|
147
|
+
instance[:"#{name}_file_name"]
|
148
|
+
end
|
149
|
+
|
150
|
+
def updated_at
|
151
|
+
time = instance[:"#{name}_updated_at"]
|
152
|
+
time && time.to_i
|
153
|
+
end
|
154
|
+
|
155
|
+
# A hash of procs that are run during the interpolation of a path or url.
|
156
|
+
# A variable of the format :name will be replaced with the return value of
|
157
|
+
# the proc named ":name". Each lambda takes the attachment and the current
|
158
|
+
# style as arguments. This hash can be added to with your own proc if
|
159
|
+
# necessary.
|
160
|
+
def self.interpolations
|
161
|
+
@interpolations ||= {
|
162
|
+
:merb_root => lambda{|attachment,style| Merb.root },
|
163
|
+
:class => lambda do |attachment,style|
|
164
|
+
attachment.instance.class.name.underscore.pluralize
|
165
|
+
end,
|
166
|
+
:basename => lambda do |attachment,style|
|
167
|
+
attachment.original_filename.gsub(File.extname(attachment.original_filename), "")
|
168
|
+
end,
|
169
|
+
:extension => lambda do |attachment,style|
|
170
|
+
((style = attachment.styles[style]) && style.last) ||
|
171
|
+
File.extname(attachment.original_filename).gsub(/^\.+/, "")
|
172
|
+
end,
|
173
|
+
:id => lambda{|attachment,style| attachment.instance.id },
|
174
|
+
:id_partition => lambda do |attachment, style|
|
175
|
+
("%09d" % attachment.instance.id).scan(/\d{3}/).join("/")
|
176
|
+
end,
|
177
|
+
:attachment => lambda{|attachment,style| attachment.name.to_s.downcase.pluralize },
|
178
|
+
:style => lambda{|attachment,style| style || attachment.default_style },
|
179
|
+
}
|
180
|
+
end
|
181
|
+
|
182
|
+
# This method really shouldn't be called that often. It's expected use is in the
|
183
|
+
# paperclip:refresh rake task and that's it. It will regenerate all thumbnails
|
184
|
+
# forcefully, by reobtaining the original file and going through the post-process
|
185
|
+
# again.
|
186
|
+
def reprocess!
|
187
|
+
new_original = Tempfile.new("paperclip-reprocess")
|
188
|
+
if old_original = to_file(:original)
|
189
|
+
new_original.write( old_original.read )
|
190
|
+
new_original.rewind
|
191
|
+
|
192
|
+
@queued_for_write = { :original => new_original }
|
193
|
+
post_process
|
194
|
+
|
195
|
+
old_original.close if old_original.respond_to?(:close)
|
196
|
+
|
197
|
+
save
|
198
|
+
else
|
199
|
+
true
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def file?
|
204
|
+
!original_filename.blank?
|
205
|
+
end
|
206
|
+
|
207
|
+
private
|
208
|
+
|
209
|
+
def logger
|
210
|
+
instance.logger
|
211
|
+
end
|
212
|
+
|
213
|
+
def valid_assignment? file #:nodoc:
|
214
|
+
file.nil? || (file.is_a?(Mash) && file.has_key?(:tempfile))
|
215
|
+
end
|
216
|
+
|
217
|
+
def validate #:nodoc:
|
218
|
+
unless @validation_errors
|
219
|
+
@validation_errors = @validations.collect do |v|
|
220
|
+
v.call(self, instance)
|
221
|
+
end.flatten.compact.uniq
|
222
|
+
@errors += @validation_errors
|
223
|
+
end
|
224
|
+
@validation_errors
|
225
|
+
end
|
226
|
+
|
227
|
+
def normalize_style_definition
|
228
|
+
@styles.each do |name, args|
|
229
|
+
dimensions, format = [args, nil].flatten[0..1]
|
230
|
+
format = nil if format == ""
|
231
|
+
@styles[name] = [dimensions, format]
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def initialize_storage
|
236
|
+
@storage_module = Paperclip::Storage.const_get(@storage.to_s.capitalize)
|
237
|
+
self.extend(@storage_module)
|
238
|
+
end
|
239
|
+
|
240
|
+
def post_process #:nodoc:
|
241
|
+
return if @queued_for_write[:original].nil?
|
242
|
+
logger.info("[paperclip] Post-processing #{name}")
|
243
|
+
@styles.each do |name, args|
|
244
|
+
begin
|
245
|
+
dimensions, format = args
|
246
|
+
dimensions = dimensions.call(instance) if dimensions.respond_to? :call
|
247
|
+
@queued_for_write[name] = Thumbnail.make(@queued_for_write[:original],
|
248
|
+
dimensions,
|
249
|
+
format,
|
250
|
+
@whiny_thumnails)
|
251
|
+
rescue PaperclipError => e
|
252
|
+
@errors << e.message if @whiny_thumbnails
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
def interpolate pattern, style = default_style #:nodoc:
|
258
|
+
interpolations = self.class.interpolations.sort{|a,b| a.first.to_s <=> b.first.to_s }
|
259
|
+
interpolations.reverse.inject( pattern.dup ) do |result, interpolation|
|
260
|
+
tag, blk = interpolation
|
261
|
+
result.gsub(/:#{tag}/) do |match|
|
262
|
+
blk.call( self, style )
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
def queue_existing_for_delete #:nodoc:
|
268
|
+
return unless file?
|
269
|
+
logger.info("[paperclip] Queueing the existing files for #{name} for deletion.")
|
270
|
+
@queued_for_delete += [:original, *@styles.keys].uniq.map do |style|
|
271
|
+
path(style) if exists?(style)
|
272
|
+
end.compact
|
273
|
+
@instance[:"#{@name}_file_name"] = nil
|
274
|
+
@instance[:"#{@name}_content_type"] = nil
|
275
|
+
@instance[:"#{@name}_file_size"] = nil
|
276
|
+
@instance[:"#{@name}_updated_at"] = nil
|
277
|
+
end
|
278
|
+
|
279
|
+
def flush_errors #:nodoc:
|
280
|
+
@errors.each do |error|
|
281
|
+
instance.errors.add(name, error)
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
@@ -0,0 +1,109 @@
|
|
1
|
+
module Paperclip
|
2
|
+
|
3
|
+
# Defines the geometry of an image.
|
4
|
+
class Geometry
|
5
|
+
attr_accessor :height, :width, :modifier
|
6
|
+
|
7
|
+
# Gives a Geometry representing the given height and width
|
8
|
+
def initialize width = nil, height = nil, modifier = nil
|
9
|
+
height = nil if height == ""
|
10
|
+
width = nil if width == ""
|
11
|
+
@height = (height || width).to_f
|
12
|
+
@width = (width || height).to_f
|
13
|
+
@modifier = modifier
|
14
|
+
end
|
15
|
+
|
16
|
+
# Uses ImageMagick to determing the dimensions of a file, passed in as either a
|
17
|
+
# File or path.
|
18
|
+
def self.from_file file
|
19
|
+
file = file.path if file.respond_to? "path"
|
20
|
+
parse(`#{Paperclip.path_for_command('identify')} "#{file}"`) ||
|
21
|
+
raise(NotIdentifiedByImageMagickError.new("#{file} is not recognized by the 'identify' command."))
|
22
|
+
end
|
23
|
+
|
24
|
+
# Parses a "WxH" formatted string, where W is the width and H is the height.
|
25
|
+
def self.parse string
|
26
|
+
if match = (string && string.match(/\b(\d*)x(\d*)\b([\>\<\#\@\%^!])?/))
|
27
|
+
Geometry.new(*match[1,3])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# True if the dimensions represent a square
|
32
|
+
def square?
|
33
|
+
height == width
|
34
|
+
end
|
35
|
+
|
36
|
+
# True if the dimensions represent a horizontal rectangle
|
37
|
+
def horizontal?
|
38
|
+
height < width
|
39
|
+
end
|
40
|
+
|
41
|
+
# True if the dimensions represent a vertical rectangle
|
42
|
+
def vertical?
|
43
|
+
height > width
|
44
|
+
end
|
45
|
+
|
46
|
+
# The aspect ratio of the dimensions.
|
47
|
+
def aspect
|
48
|
+
width / height
|
49
|
+
end
|
50
|
+
|
51
|
+
# Returns the larger of the two dimensions
|
52
|
+
def larger
|
53
|
+
[height, width].max
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns the smaller of the two dimensions
|
57
|
+
def smaller
|
58
|
+
[height, width].min
|
59
|
+
end
|
60
|
+
|
61
|
+
# Returns the width and height in a format suitable to be passed to Geometry.parse
|
62
|
+
def to_s
|
63
|
+
"%dx%d%s" % [width, height, modifier]
|
64
|
+
end
|
65
|
+
|
66
|
+
# Same as to_s
|
67
|
+
def inspect
|
68
|
+
to_s
|
69
|
+
end
|
70
|
+
|
71
|
+
# Returns the scaling and cropping geometries (in string-based ImageMagick format)
|
72
|
+
# neccessary to transform this Geometry into the Geometry given. If crop is true,
|
73
|
+
# then it is assumed the destination Geometry will be the exact final resolution.
|
74
|
+
# In this case, the source Geometry is scaled so that an image containing the
|
75
|
+
# destination Geometry would be completely filled by the source image, and any
|
76
|
+
# overhanging image would be cropped. Useful for square thumbnail images. The cropping
|
77
|
+
# is weighted at the center of the Geometry.
|
78
|
+
def transformation_to dst, crop = false
|
79
|
+
|
80
|
+
if crop
|
81
|
+
ratio = Geometry.new( dst.width / self.width, dst.height / self.height )
|
82
|
+
scale_geometry, scale = scaling(dst, ratio)
|
83
|
+
crop_geometry = cropping(dst, ratio, scale)
|
84
|
+
else
|
85
|
+
scale_geometry = dst.to_s
|
86
|
+
end
|
87
|
+
|
88
|
+
[ scale_geometry, crop_geometry ]
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def scaling dst, ratio
|
94
|
+
if ratio.horizontal? || ratio.square?
|
95
|
+
[ "%dx" % dst.width, ratio.width ]
|
96
|
+
else
|
97
|
+
[ "x%d" % dst.height, ratio.height ]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def cropping dst, ratio, scale
|
102
|
+
if ratio.horizontal? || ratio.square?
|
103
|
+
"%dx%d+%d+%d" % [ dst.width, dst.height, 0, (self.height * scale - dst.height) / 2 ]
|
104
|
+
else
|
105
|
+
"%dx%d+%d+%d" % [ dst.width, dst.height, (self.width * scale - dst.width) / 2, 0 ]
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# Provides method that can be included on File-type objects (IO, StringIO, Tempfile, etc) to allow stream copying
|
2
|
+
# and Tempfile conversion.
|
3
|
+
module IOStream
|
4
|
+
|
5
|
+
# Returns a Tempfile containing the contents of the readable object.
|
6
|
+
def to_tempfile
|
7
|
+
tempfile = Tempfile.new("stream")
|
8
|
+
tempfile.binmode
|
9
|
+
self.stream_to(tempfile)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Copies one read-able object from one place to another in blocks, obviating the need to load
|
13
|
+
# the whole thing into memory. Defaults to 8k blocks. If this module is included in both
|
14
|
+
# StringIO and Tempfile, then either can have its data copied anywhere else without typing
|
15
|
+
# worries or memory overhead worries. Returns a File if a String is passed in as the destination
|
16
|
+
# and returns the IO or Tempfile as passed in if one is sent as the destination.
|
17
|
+
def stream_to path_or_file, in_blocks_of = 8192
|
18
|
+
dstio = case path_or_file
|
19
|
+
when String then File.new(path_or_file, "wb+")
|
20
|
+
when IO then path_or_file
|
21
|
+
when Tempfile then path_or_file
|
22
|
+
end
|
23
|
+
buffer = ""
|
24
|
+
self.rewind
|
25
|
+
while self.read(in_blocks_of, buffer) do
|
26
|
+
dstio.write(buffer)
|
27
|
+
end
|
28
|
+
dstio.rewind
|
29
|
+
dstio
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
class IO
|
34
|
+
include IOStream
|
35
|
+
end
|
36
|
+
|
37
|
+
class Mash
|
38
|
+
include IOStream
|
39
|
+
end
|
40
|
+
|
41
|
+
%w( Tempfile StringIO ).each do |klass|
|
42
|
+
if Object.const_defined? klass
|
43
|
+
Object.const_get(klass).class_eval do
|
44
|
+
include IOStream
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,204 @@
|
|
1
|
+
module Paperclip
|
2
|
+
module Storage
|
3
|
+
|
4
|
+
# The default place to store attachments is in the filesystem. Files on the local
|
5
|
+
# filesystem can be very easily served by Apache without requiring a hit to your app.
|
6
|
+
# They also can be processed more easily after they've been saved, as they're just
|
7
|
+
# normal files. There is one Filesystem-specific option for has_attached_file.
|
8
|
+
# * +path+: The location of the repository of attachments on disk. This can (and, in
|
9
|
+
# almost all cases, should) be coordinated with the value of the +url+ option to
|
10
|
+
# allow files to be saved into a place where Apache can serve them without
|
11
|
+
# hitting your app. Defaults to
|
12
|
+
# ":merb_root/public/:attachment/:id/:style/:basename.:extension"
|
13
|
+
# By default this places the files in the app's public directory which can be served
|
14
|
+
# directly. If you are using capistrano for deployment, a good idea would be to
|
15
|
+
# make a symlink to the capistrano-created system directory from inside your app's
|
16
|
+
# public directory.
|
17
|
+
# See Paperclip::Attachment#interpolate for more information on variable interpolaton.
|
18
|
+
# :path => "/var/app/attachments/:class/:id/:style/:filename"
|
19
|
+
module Filesystem
|
20
|
+
def self.extended base
|
21
|
+
end
|
22
|
+
|
23
|
+
def exists?(style = default_style)
|
24
|
+
if original_filename
|
25
|
+
File.exist?(path(style))
|
26
|
+
else
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns representation of the data of the file assigned to the given
|
32
|
+
# style, in the format most representative of the current storage.
|
33
|
+
def to_file style = default_style
|
34
|
+
@queued_for_write[style] || (File.new(path(style)) if exists?(style))
|
35
|
+
end
|
36
|
+
alias_method :to_io, :to_file
|
37
|
+
|
38
|
+
def flush_writes #:nodoc:
|
39
|
+
logger.info("[paperclip] Writing files for #{name}")
|
40
|
+
@queued_for_write.each do |style, file|
|
41
|
+
FileUtils.mkdir_p(File.dirname(path(style)))
|
42
|
+
logger.info("[paperclip] -> #{path(style)}")
|
43
|
+
result = file.stream_to(path(style))
|
44
|
+
file.close
|
45
|
+
result.close
|
46
|
+
end
|
47
|
+
@queued_for_write = {}
|
48
|
+
end
|
49
|
+
|
50
|
+
def flush_deletes #:nodoc:
|
51
|
+
logger.info("[paperclip] Deleting files for #{name}")
|
52
|
+
@queued_for_delete.each do |path|
|
53
|
+
begin
|
54
|
+
logger.info("[paperclip] -> #{path}")
|
55
|
+
FileUtils.rm(path) if File.exist?(path)
|
56
|
+
rescue Errno::ENOENT => e
|
57
|
+
# ignore file-not-found, let everything else pass
|
58
|
+
end
|
59
|
+
end
|
60
|
+
@queued_for_delete = []
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Amazon's S3 file hosting service is a scalable, easy place to store files for
|
65
|
+
# distribution. You can find out more about it at http://aws.amazon.com/s3
|
66
|
+
# There are a few S3-specific options for has_attached_file:
|
67
|
+
# * +s3_credentials+: Takes a path, a File, or a Hash. The path (or File) must point
|
68
|
+
# to a YAML file containing the +access_key_id+ and +secret_access_key+ that Amazon
|
69
|
+
# gives you. You can 'environment-space' this just like you do to your
|
70
|
+
# database.yml file, so different environments can use different accounts:
|
71
|
+
# development:
|
72
|
+
# access_key_id: 123...
|
73
|
+
# secret_access_key: 123...
|
74
|
+
# test:
|
75
|
+
# access_key_id: abc...
|
76
|
+
# secret_access_key: abc...
|
77
|
+
# production:
|
78
|
+
# access_key_id: 456...
|
79
|
+
# secret_access_key: 456...
|
80
|
+
# This is not required, however, and the file may simply look like this:
|
81
|
+
# access_key_id: 456...
|
82
|
+
# secret_access_key: 456...
|
83
|
+
# In which case, those access keys will be used in all environments.
|
84
|
+
# * +s3_permissions+: This is a String that should be one of the "canned" access
|
85
|
+
# policies that S3 provides (more information can be found here:
|
86
|
+
# http://docs.amazonwebservices.com/AmazonS3/2006-03-01/RESTAccessPolicy.html#RESTCannedAccessPolicies)
|
87
|
+
# The default for Paperclip is "public-read".
|
88
|
+
# * +s3_protocol+: The protocol for the URLs generated to your S3 assets. Can be either
|
89
|
+
# 'http' or 'https'. Defaults to 'http' when your :s3_permissions are 'public-read' (the
|
90
|
+
# default), and 'https' when your :s3_permissions are anything else.
|
91
|
+
# * +bucket+: This is the name of the S3 bucket that will store your files. Remember
|
92
|
+
# that the bucket must be unique across all of Amazon S3. If the bucket does not exist
|
93
|
+
# Paperclip will attempt to create it. The bucket name will not be interpolated.
|
94
|
+
# * +url+: There are two options for the S3 url. You can choose to have the bucket's name
|
95
|
+
# placed domain-style (bucket.s3.amazonaws.com) or path-style (s3.amazonaws.com/bucket).
|
96
|
+
# Normally, this won't matter in the slightest and you can leave the default (which is
|
97
|
+
# path-style, or :s3_path_url). But in some cases paths don't work and you need to use
|
98
|
+
# the domain-style (:s3_domain_url). Anything else here will be treated like path-style.
|
99
|
+
# * +path+: This is the key under the bucket in which the file will be stored. The
|
100
|
+
# URL will be constructed from the bucket and the path. This is what you will want
|
101
|
+
# to interpolate. Keys should be unique, like filenames, and despite the fact that
|
102
|
+
# S3 (strictly speaking) does not support directories, you can still use a / to
|
103
|
+
# separate parts of your file name.
|
104
|
+
module S3
|
105
|
+
def self.extended base
|
106
|
+
require 'right_aws'
|
107
|
+
base.instance_eval do
|
108
|
+
@bucket = @options[:bucket]
|
109
|
+
@s3_credentials = parse_credentials(@options[:s3_credentials])
|
110
|
+
@s3_options = @options[:s3_options] || {}
|
111
|
+
@s3_permissions = @options[:s3_permissions] || 'public-read'
|
112
|
+
@s3_protocol = @options[:s3_protocol] || (@s3_permissions == 'public-read' ? 'http' : 'https')
|
113
|
+
@url = ":s3_path_url" unless @url.to_s.match(/^s3.*url$/)
|
114
|
+
end
|
115
|
+
base.class.interpolations[:s3_path_url] = lambda do |attachment, style|
|
116
|
+
"#{attachment.s3_protocol}://s3.amazonaws.com/#{attachment.bucket_name}/#{attachment.path(style).gsub(%r{^/}, "")}"
|
117
|
+
end
|
118
|
+
base.class.interpolations[:s3_domain_url] = lambda do |attachment, style|
|
119
|
+
"#{attachment.s3_protocol}://#{attachment.bucket_name}.s3.amazonaws.com/#{attachment.path(style).gsub(%r{^/}, "")}"
|
120
|
+
end
|
121
|
+
ActiveRecord::Base.logger.info("[paperclip] S3 Storage Initalized.")
|
122
|
+
end
|
123
|
+
|
124
|
+
def s3
|
125
|
+
@s3 ||= RightAws::S3.new(@s3_credentials[:access_key_id],
|
126
|
+
@s3_credentials[:secret_access_key],
|
127
|
+
@s3_options)
|
128
|
+
end
|
129
|
+
|
130
|
+
def s3_bucket
|
131
|
+
@s3_bucket ||= s3.bucket(@bucket, true, @s3_permissions)
|
132
|
+
end
|
133
|
+
|
134
|
+
def bucket_name
|
135
|
+
@bucket
|
136
|
+
end
|
137
|
+
|
138
|
+
def parse_credentials creds
|
139
|
+
creds = find_credentials(creds).stringify_keys
|
140
|
+
(creds[Merb.env] || creds).symbolize_keys
|
141
|
+
end
|
142
|
+
|
143
|
+
def exists?(style = default_style)
|
144
|
+
s3_bucket.key(path(style)) ? true : false
|
145
|
+
end
|
146
|
+
|
147
|
+
def s3_protocol
|
148
|
+
@s3_protocol
|
149
|
+
end
|
150
|
+
|
151
|
+
# Returns representation of the data of the file assigned to the given
|
152
|
+
# style, in the format most representative of the current storage.
|
153
|
+
def to_file style = default_style
|
154
|
+
@queued_for_write[style] || s3_bucket.key(path(style))
|
155
|
+
end
|
156
|
+
alias_method :to_io, :to_file
|
157
|
+
|
158
|
+
def flush_writes #:nodoc:
|
159
|
+
logger.info("[paperclip] Writing files for #{name}")
|
160
|
+
@queued_for_write.each do |style, file|
|
161
|
+
begin
|
162
|
+
logger.info("[paperclip] -> #{path(style)}")
|
163
|
+
key = s3_bucket.key(path(style))
|
164
|
+
key.data = file
|
165
|
+
key.put(nil, @s3_permissions)
|
166
|
+
rescue RightAws::AwsError => e
|
167
|
+
raise
|
168
|
+
end
|
169
|
+
end
|
170
|
+
@queued_for_write = {}
|
171
|
+
end
|
172
|
+
|
173
|
+
def flush_deletes #:nodoc:
|
174
|
+
logger.info("[paperclip] Writing files for #{name}")
|
175
|
+
@queued_for_delete.each do |path|
|
176
|
+
begin
|
177
|
+
logger.info("[paperclip] -> #{path}")
|
178
|
+
if file = s3_bucket.key(path)
|
179
|
+
file.delete
|
180
|
+
end
|
181
|
+
rescue RightAws::AwsError
|
182
|
+
# Ignore this.
|
183
|
+
end
|
184
|
+
end
|
185
|
+
@queued_for_delete = []
|
186
|
+
end
|
187
|
+
|
188
|
+
def find_credentials creds
|
189
|
+
case creds
|
190
|
+
when File:
|
191
|
+
YAML.load_file(creds.path)
|
192
|
+
when String:
|
193
|
+
YAML.load_file(creds)
|
194
|
+
when Hash:
|
195
|
+
creds
|
196
|
+
else
|
197
|
+
raise ArgumentError, "Credentials are not a path, file, or hash."
|
198
|
+
end
|
199
|
+
end
|
200
|
+
private :find_credentials
|
201
|
+
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
module Paperclip
|
2
|
+
# Handles thumbnailing images that are uploaded.
|
3
|
+
class Thumbnail
|
4
|
+
|
5
|
+
attr_accessor :file, :current_geometry, :target_geometry, :format, :whiny_thumbnails
|
6
|
+
|
7
|
+
# Creates a Thumbnail object set to work on the +file+ given. It
|
8
|
+
# will attempt to transform the image into one defined by +target_geometry+
|
9
|
+
# which is a "WxH"-style string. +format+ will be inferred from the +file+
|
10
|
+
# unless specified. Thumbnail creation will raise no errors unless
|
11
|
+
# +whiny_thumbnails+ is true (which it is, by default.
|
12
|
+
def initialize file, target_geometry, format = nil, whiny_thumbnails = true
|
13
|
+
@file = file
|
14
|
+
@crop = target_geometry[-1,1] == '#'
|
15
|
+
@target_geometry = Geometry.parse target_geometry
|
16
|
+
@current_geometry = Geometry.from_file file
|
17
|
+
@whiny_thumbnails = whiny_thumbnails
|
18
|
+
|
19
|
+
@current_format = File.extname(@file.path)
|
20
|
+
@basename = File.basename(@file.path, @current_format)
|
21
|
+
|
22
|
+
@format = format
|
23
|
+
end
|
24
|
+
|
25
|
+
# Creates a thumbnail, as specified in +initialize+, +make+s it, and returns the
|
26
|
+
# resulting Tempfile.
|
27
|
+
def self.make file, dimensions, format = nil, whiny_thumbnails = true
|
28
|
+
new(file, dimensions, format, whiny_thumbnails).make
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns true if the +target_geometry+ is meant to crop.
|
32
|
+
def crop?
|
33
|
+
@crop
|
34
|
+
end
|
35
|
+
|
36
|
+
# Performs the conversion of the +file+ into a thumbnail. Returns the Tempfile
|
37
|
+
# that contains the new image.
|
38
|
+
def make
|
39
|
+
src = @file
|
40
|
+
dst = Tempfile.new([@basename, @format].compact.join("."))
|
41
|
+
dst.binmode
|
42
|
+
|
43
|
+
command = <<-end_command
|
44
|
+
#{ Paperclip.path_for_command('convert') }
|
45
|
+
"#{ File.expand_path(src.path) }"
|
46
|
+
#{ transformation_command }
|
47
|
+
"#{ File.expand_path(dst.path) }"
|
48
|
+
end_command
|
49
|
+
success = system(command.gsub(/\s+/, " "))
|
50
|
+
|
51
|
+
if success && $?.exitstatus != 0 && @whiny_thumbnails
|
52
|
+
raise PaperclipError, "There was an error processing this thumbnail"
|
53
|
+
end
|
54
|
+
|
55
|
+
dst
|
56
|
+
end
|
57
|
+
|
58
|
+
# Returns the command ImageMagick's +convert+ needs to transform the image
|
59
|
+
# into the thumbnail.
|
60
|
+
def transformation_command
|
61
|
+
scale, crop = @current_geometry.transformation_to(@target_geometry, crop?)
|
62
|
+
trans = "-scale \"#{scale}\""
|
63
|
+
trans << " -crop \"#{crop}\" +repage" if crop
|
64
|
+
trans
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Due to how ImageMagick handles its image format conversion and how Tempfile
|
69
|
+
# handles its naming scheme, it is necessary to override how Tempfile makes
|
70
|
+
# its names so as to allow for file extensions. Idea taken from the comments
|
71
|
+
# on this blog post:
|
72
|
+
# http://marsorange.com/archives/of-mogrify-ruby-tempfile-dynamic-class-definitions
|
73
|
+
class Tempfile < ::Tempfile
|
74
|
+
# Replaces Tempfile's +make_tmpname+ with one that honors file extensions.
|
75
|
+
def make_tmpname(basename, n)
|
76
|
+
extension = File.extname(basename)
|
77
|
+
sprintf("%s,%d,%d%s", File.basename(basename, extension), $$, n, extension)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Paperclip
|
2
|
+
# The Upfile module is a convenience module for adding uploaded-file-type methods
|
3
|
+
# to the +File+ class. Useful for testing.
|
4
|
+
# user.avatar = File.new("test/test_avatar.jpg")
|
5
|
+
module Upfile
|
6
|
+
|
7
|
+
# Infer the MIME-type of the file from the extension.
|
8
|
+
def content_type
|
9
|
+
type = (self.path.match(/\.(\w+)$/)[1] rescue "octet-stream").downcase
|
10
|
+
case type
|
11
|
+
when %r"jpe?g" then "image/jpeg"
|
12
|
+
when %r"tiff?" then "image/tiff"
|
13
|
+
when %r"png", "gif", "bmp" then "image/#{type}"
|
14
|
+
when "txt" then "text/plain"
|
15
|
+
when %r"html?" then "text/html"
|
16
|
+
when "csv", "xml", "css", "js" then "text/#{type}"
|
17
|
+
else "application/x-#{type}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns the file's normal name.
|
22
|
+
def original_filename
|
23
|
+
File.basename(self.path)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Returns the size of the file.
|
27
|
+
def size
|
28
|
+
File.size(self)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
class File #:nodoc:
|
35
|
+
include Paperclip::Upfile
|
36
|
+
end
|
37
|
+
|
data/lib/paperclip.rb
ADDED
@@ -0,0 +1,246 @@
|
|
1
|
+
# Paperclip allows file attachments that are stored in the filesystem. All graphical
|
2
|
+
# transformations are done using the Graphics/ImageMagick command line utilities and
|
3
|
+
# are stored in Tempfiles until the record is saved. Paperclip does not require a
|
4
|
+
# separate model for storing the attachment's information, instead adding a few simple
|
5
|
+
# columns to your table.
|
6
|
+
#
|
7
|
+
# Author:: Jon Yurek
|
8
|
+
# Copyright:: Copyright (c) 2008 thoughtbot, inc.
|
9
|
+
# License:: MIT License (http://www.opensource.org/licenses/mit-license.php)
|
10
|
+
#
|
11
|
+
# Paperclip defines an attachment as any file, though it makes special considerations
|
12
|
+
# for image files. You can declare that a model has an attached file with the
|
13
|
+
# +has_attached_file+ method:
|
14
|
+
#
|
15
|
+
# class User < ActiveRecord::Base
|
16
|
+
# has_attached_file :avatar, :styles => { :thumb => "100x100" }
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# user = User.new
|
20
|
+
# user.avatar = params[:user][:avatar]
|
21
|
+
# user.avatar.url
|
22
|
+
# # => "/users/avatars/4/original_me.jpg"
|
23
|
+
# user.avatar.url(:thumb)
|
24
|
+
# # => "/users/avatars/4/thumb_me.jpg"
|
25
|
+
#
|
26
|
+
# See the +has_attached_file+ documentation for more details.
|
27
|
+
|
28
|
+
require 'tempfile'
|
29
|
+
require 'paperclip/upfile'
|
30
|
+
require 'paperclip/iostream'
|
31
|
+
require 'paperclip/geometry'
|
32
|
+
require 'paperclip/thumbnail'
|
33
|
+
require 'paperclip/storage'
|
34
|
+
require 'paperclip/attachment'
|
35
|
+
|
36
|
+
# The base module that gets included in ActiveRecord::Base. See the
|
37
|
+
# documentation for Paperclip::ClassMethods for more useful information.
|
38
|
+
module Paperclip
|
39
|
+
|
40
|
+
VERSION = "2.1.2"
|
41
|
+
|
42
|
+
class << self
|
43
|
+
# Provides configurability to Paperclip. There are a number of options available, such as:
|
44
|
+
# * whiny_thumbnails: Will raise an error if Paperclip cannot process thumbnails of
|
45
|
+
# an uploaded image. Defaults to true.
|
46
|
+
# * image_magick_path: Defines the path at which to find the +convert+ and +identify+
|
47
|
+
# programs if they are not visible to Merb the system's search path. Defaults to
|
48
|
+
# nil, which uses the first executable found in the search path.
|
49
|
+
def options
|
50
|
+
@options ||= {
|
51
|
+
:whiny_thumbnails => true,
|
52
|
+
:image_magick_path => nil
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
def path_for_command command #:nodoc:
|
57
|
+
path = [options[:image_magick_path], command].compact
|
58
|
+
File.join(*path)
|
59
|
+
end
|
60
|
+
|
61
|
+
def included base #:nodoc:
|
62
|
+
base.extend ClassMethods
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
class PaperclipError < StandardError #:nodoc:
|
67
|
+
end
|
68
|
+
|
69
|
+
class NotIdentifiedByImageMagickError < PaperclipError #:nodoc:
|
70
|
+
end
|
71
|
+
|
72
|
+
module ClassMethods
|
73
|
+
# +has_attached_file+ gives the class it is called on an attribute that maps to a file. This
|
74
|
+
# is typically a file stored somewhere on the filesystem and has been uploaded by a user.
|
75
|
+
# The attribute returns a Paperclip::Attachment object which handles the management of
|
76
|
+
# that file. The intent is to make the attachment as much like a normal attribute. The
|
77
|
+
# thumbnails will be created when the new file is assigned, but they will *not* be saved
|
78
|
+
# until +save+ is called on the record. Likewise, if the attribute is set to +nil+ is
|
79
|
+
# called on it, the attachment will *not* be deleted until +save+ is called. See the
|
80
|
+
# Paperclip::Attachment documentation for more specifics. There are a number of options
|
81
|
+
# you can set to change the behavior of a Paperclip attachment:
|
82
|
+
# * +url+: The full URL of where the attachment is publically accessible. This can just
|
83
|
+
# as easily point to a directory served directly through Apache as it can to an action
|
84
|
+
# that can control permissions. You can specify the full domain and path, but usually
|
85
|
+
# just an absolute path is sufficient. The leading slash must be included manually for
|
86
|
+
# absolute paths. The default value is "/:class/:attachment/:id/:style_:filename". See
|
87
|
+
# Paperclip::Attachment#interpolate for more information on variable interpolaton.
|
88
|
+
# :url => "/:attachment/:id/:style_:basename:extension"
|
89
|
+
# :url => "http://some.other.host/stuff/:class/:id_:extension"
|
90
|
+
# * +default_url+: The URL that will be returned if there is no attachment assigned.
|
91
|
+
# This field is interpolated just as the url is. The default value is
|
92
|
+
# "/:class/:attachment/missing_:style.png"
|
93
|
+
# has_attached_file :avatar, :default_url => "/images/default_:style_avatar.png"
|
94
|
+
# User.new.avatar_url(:small) # => "/images/default_small_avatar.png"
|
95
|
+
# * +styles+: A hash of thumbnail styles and their geometries. You can find more about
|
96
|
+
# geometry strings at the ImageMagick website
|
97
|
+
# (http://www.imagemagick.org/script/command-line-options.php#resize). Paperclip
|
98
|
+
# also adds the "#" option (e.g. "50x50#"), which will resize the image to fit maximally
|
99
|
+
# inside the dimensions and then crop the rest off (weighted at the center). The
|
100
|
+
# default value is to generate no thumbnails.
|
101
|
+
# * +default_style+: The thumbnail style that will be used by default URLs.
|
102
|
+
# Defaults to +original+.
|
103
|
+
# has_attached_file :avatar, :styles => { :normal => "100x100#" },
|
104
|
+
# :default_style => :normal
|
105
|
+
# user.avatar.url # => "/avatars/23/normal_me.png"
|
106
|
+
# * +whiny_thumbnails+: Will raise an error if Paperclip cannot process thumbnails of an
|
107
|
+
# uploaded image. This will ovrride the global setting for this attachment.
|
108
|
+
# Defaults to true.
|
109
|
+
# * +storage+: Chooses the storage backend where the files will be stored. The current
|
110
|
+
# choices are :filesystem and :s3. The default is :filesystem. Make sure you read the
|
111
|
+
# documentation for Paperclip::Storage::Filesystem and Paperclip::Storage::S3
|
112
|
+
# for backend-specific options.
|
113
|
+
def has_attached_file name, options = {}
|
114
|
+
include InstanceMethods
|
115
|
+
|
116
|
+
write_inheritable_attribute(:attachment_definitions, {}) if attachment_definitions.nil?
|
117
|
+
attachment_definitions[name] = {:validations => []}.merge(options)
|
118
|
+
|
119
|
+
after_save :save_attached_files
|
120
|
+
before_destroy :destroy_attached_files
|
121
|
+
|
122
|
+
define_method name do |*args|
|
123
|
+
a = attachment_for(name)
|
124
|
+
(args.length > 0) ? a.to_s(args.first) : a
|
125
|
+
end
|
126
|
+
|
127
|
+
define_method "#{name}=" do |file|
|
128
|
+
attachment_for(name).assign(file)
|
129
|
+
end
|
130
|
+
|
131
|
+
define_method "#{name}?" do
|
132
|
+
attachment_for(name).file?
|
133
|
+
end
|
134
|
+
|
135
|
+
validates_each(name) do |record, attr, value|
|
136
|
+
value.send(:flush_errors) unless value.valid?
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# Places ActiveRecord-style validations on the size of the file assigned. The
|
141
|
+
# possible options are:
|
142
|
+
# * +in+: a Range of bytes (i.e. +1..1.megabyte+),
|
143
|
+
# * +less_than+: equivalent to :in => 0..options[:less_than]
|
144
|
+
# * +greater_than+: equivalent to :in => options[:greater_than]..Infinity
|
145
|
+
# * +message+: error message to display, use :min and :max as replacements
|
146
|
+
def validates_attachment_size name, options = {}
|
147
|
+
attachment_definitions[name][:validations] << lambda do |attachment, instance|
|
148
|
+
unless options[:greater_than].nil?
|
149
|
+
options[:in] = (options[:greater_than]..(1/0)) # 1/0 => Infinity
|
150
|
+
end
|
151
|
+
unless options[:less_than].nil?
|
152
|
+
options[:in] = (0..options[:less_than])
|
153
|
+
end
|
154
|
+
|
155
|
+
if attachment.file? && !options[:in].include?(instance[:"#{name}_file_size"].to_i)
|
156
|
+
min = options[:in].first
|
157
|
+
max = options[:in].last
|
158
|
+
|
159
|
+
if options[:message]
|
160
|
+
options[:message].gsub(/:min/, min.to_s).gsub(/:max/, max.to_s)
|
161
|
+
else
|
162
|
+
"file size is not between #{min} and #{max} bytes."
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Adds errors if thumbnail creation fails. The same as specifying :whiny_thumbnails => true.
|
169
|
+
def validates_attachment_thumbnails name, options = {}
|
170
|
+
attachment_definitions[name][:whiny_thumbnails] = true
|
171
|
+
end
|
172
|
+
|
173
|
+
# Places ActiveRecord-style validations on the presence of a file.
|
174
|
+
def validates_attachment_presence name, options = {}
|
175
|
+
attachment_definitions[name][:validations] << lambda do |attachment, instance|
|
176
|
+
unless attachment.file?
|
177
|
+
options[:message] || "must be set."
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Places ActiveRecord-style validations on the content type of the file assigned. The
|
183
|
+
# possible options are:
|
184
|
+
# * +content_type+: Allowed content types. Can be a single content type or an array.
|
185
|
+
# Each type can be a String or a Regexp. It should be noted that Internet Explorer uploads
|
186
|
+
# files with content_types that you may not expect. For example, JPEG images are given
|
187
|
+
# image/pjpeg and PNGs are image/x-png, so keep that in mind when determining how you match.
|
188
|
+
# Allows all by default.
|
189
|
+
# * +message+: The message to display when the uploaded file has an invalid content type.
|
190
|
+
def validates_attachment_content_type name, options = {}
|
191
|
+
attachment_definitions[name][:validations] << lambda do |attachment, instance|
|
192
|
+
valid_types = [options[:content_type]].flatten
|
193
|
+
|
194
|
+
unless attachment.original_filename.nil?
|
195
|
+
unless options[:content_type].blank?
|
196
|
+
content_type = instance[:"#{name}_content_type"]
|
197
|
+
unless valid_types.any?{|t| t === content_type }
|
198
|
+
options[:message] || "is not one of the allowed file types."
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Returns the attachment definitions defined by each call to has_attached_file.
|
206
|
+
def attachment_definitions
|
207
|
+
read_inheritable_attribute(:attachment_definitions)
|
208
|
+
end
|
209
|
+
|
210
|
+
end
|
211
|
+
|
212
|
+
module InstanceMethods #:nodoc:
|
213
|
+
def attachment_for name
|
214
|
+
@attachments ||= {}
|
215
|
+
@attachments[name] ||= Attachment.new(name, self, self.class.attachment_definitions[name])
|
216
|
+
end
|
217
|
+
|
218
|
+
def each_attachment
|
219
|
+
self.class.attachment_definitions.each do |name, definition|
|
220
|
+
yield(name, attachment_for(name))
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def save_attached_files
|
225
|
+
logger.info("[paperclip] Saving attachments.")
|
226
|
+
each_attachment do |name, attachment|
|
227
|
+
attachment.send(:save)
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
def destroy_attached_files
|
232
|
+
logger.info("[paperclip] Deleting attachments.")
|
233
|
+
each_attachment do |name, attachment|
|
234
|
+
attachment.send(:queue_existing_for_delete)
|
235
|
+
attachment.send(:flush_deletes)
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
end
|
241
|
+
|
242
|
+
# Set it all up.
|
243
|
+
if Object.const_defined?("ActiveRecord")
|
244
|
+
ActiveRecord::Base.send(:include, Paperclip)
|
245
|
+
File.send(:include, Paperclip::Upfile)
|
246
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
$:.push File.join(File.dirname(__FILE__), '..', 'lib')
|
metadata
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: jeremydurham-merb_paperclip
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.9.12
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jeremy Durham
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-11-01 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: merb
|
17
|
+
version_requirement:
|
18
|
+
version_requirements: !ruby/object:Gem::Requirement
|
19
|
+
requirements:
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: 0.9.4
|
23
|
+
version:
|
24
|
+
description: A Merb plugin that is essentially a port of Jon Yurek's paperclip
|
25
|
+
email: jeremydurham@gmail.com
|
26
|
+
executables: []
|
27
|
+
|
28
|
+
extensions: []
|
29
|
+
|
30
|
+
extra_rdoc_files:
|
31
|
+
- README
|
32
|
+
- LICENSE
|
33
|
+
- TODO
|
34
|
+
files:
|
35
|
+
- LICENSE
|
36
|
+
- README
|
37
|
+
- Rakefile
|
38
|
+
- TODO
|
39
|
+
- lib/generators
|
40
|
+
- lib/generators/paperclip_generator.rb
|
41
|
+
- lib/generators/templates
|
42
|
+
- lib/generators/templates/%file_name%.rb
|
43
|
+
- lib/merb_paperclip
|
44
|
+
- lib/merb_paperclip/merbtasks.rb
|
45
|
+
- lib/merb_paperclip.rb
|
46
|
+
- lib/paperclip
|
47
|
+
- lib/paperclip/attachment.rb
|
48
|
+
- lib/paperclip/geometry.rb
|
49
|
+
- lib/paperclip/iostream.rb
|
50
|
+
- lib/paperclip/storage.rb
|
51
|
+
- lib/paperclip/thumbnail.rb
|
52
|
+
- lib/paperclip/upfile.rb
|
53
|
+
- lib/paperclip.rb
|
54
|
+
- spec/merb_paperclip_spec.rb
|
55
|
+
- spec/spec_helper.rb
|
56
|
+
has_rdoc: true
|
57
|
+
homepage: http://www.thoughtbot.com/projects/paperclip/
|
58
|
+
post_install_message:
|
59
|
+
rdoc_options: []
|
60
|
+
|
61
|
+
require_paths:
|
62
|
+
- lib
|
63
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: "0"
|
68
|
+
version:
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: "0"
|
74
|
+
version:
|
75
|
+
requirements: []
|
76
|
+
|
77
|
+
rubyforge_project: merb
|
78
|
+
rubygems_version: 1.2.0
|
79
|
+
signing_key:
|
80
|
+
specification_version: 2
|
81
|
+
summary: A Merb plugin that is essentially a port of Jon Yurek's paperclip
|
82
|
+
test_files: []
|
83
|
+
|