013e225fa929b9b0a2b20878dbf7193fbf459139
[selectricity] / vendor / plugins / attachment_fu / lib / technoweenie / attachment_fu.rb
1 module Technoweenie # :nodoc:
2   module AttachmentFu # :nodoc:
3     @@default_processors = %w(ImageScience Rmagick MiniMagick)
4     @@tempfile_path      = File.join(RAILS_ROOT, 'tmp', 'attachment_fu')
5     @@content_types      = ['image/jpeg', 'image/pjpeg', 'image/gif', 'image/png', 'image/x-png', 'image/jpg']
6     mattr_reader :content_types, :tempfile_path, :default_processors
7     mattr_writer :tempfile_path
8
9     class ThumbnailError < StandardError;  end
10     class AttachmentError < StandardError; end
11
12     module ActMethods
13       # Options: 
14       # *  <tt>:content_type</tt> - Allowed content types.  Allows all by default.  Use :image to allow all standard image types.
15       # *  <tt>:min_size</tt> - Minimum size allowed.  1 byte is the default.
16       # *  <tt>:max_size</tt> - Maximum size allowed.  1.megabyte is the default.
17       # *  <tt>:size</tt> - Range of sizes allowed.  (1..1.megabyte) is the default.  This overrides the :min_size and :max_size options.
18       # *  <tt>:resize_to</tt> - Used by RMagick to resize images.  Pass either an array of width/height, or a geometry string.
19       # *  <tt>:thumbnails</tt> - Specifies a set of thumbnails to generate.  This accepts a hash of filename suffixes and RMagick resizing options.
20       # *  <tt>:thumbnail_class</tt> - Set what class to use for thumbnails.  This attachment class is used by default.
21       # *  <tt>:path_prefix</tt> - path to store the uploaded files.  Uses public/#{table_name} by default for the filesystem, and just #{table_name}
22       #      for the S3 backend.  Setting this sets the :storage to :file_system.
23       # *  <tt>:storage</tt> - Use :file_system to specify the attachment data is stored with the file system.  Defaults to :db_system.
24       #
25       # Examples:
26       #   has_attachment :max_size => 1.kilobyte
27       #   has_attachment :size => 1.megabyte..2.megabytes
28       #   has_attachment :content_type => 'application/pdf'
29       #   has_attachment :content_type => ['application/pdf', 'application/msword', 'text/plain']
30       #   has_attachment :content_type => :image, :resize_to => [50,50]
31       #   has_attachment :content_type => ['application/pdf', :image], :resize_to => 'x50'
32       #   has_attachment :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
33       #   has_attachment :storage => :file_system, :path_prefix => 'public/files'
34       #   has_attachment :storage => :file_system, :path_prefix => 'public/files', 
35       #     :content_type => :image, :resize_to => [50,50]
36       #   has_attachment :storage => :file_system, :path_prefix => 'public/files',
37       #     :thumbnails => { :thumb => [50, 50], :geometry => 'x50' }
38       #   has_attachment :storage => :s3
39       def has_attachment(options = {})
40         # this allows you to redefine the acts' options for each subclass, however
41         options[:min_size]         ||= 1
42         options[:max_size]         ||= 1.megabyte
43         options[:size]             ||= (options[:min_size]..options[:max_size])
44         options[:thumbnails]       ||= {}
45         options[:thumbnail_class]  ||= self
46         options[:s3_access]        ||= :public_read
47         options[:content_type] = [options[:content_type]].flatten.collect! { |t| t == :image ? Technoweenie::AttachmentFu.content_types : t }.flatten unless options[:content_type].nil?
48         
49         unless options[:thumbnails].is_a?(Hash)
50           raise ArgumentError, ":thumbnails option should be a hash: e.g. :thumbnails => { :foo => '50x50' }"
51         end
52         
53         # doing these shenanigans so that #attachment_options is available to processors and backends
54         class_inheritable_accessor :attachment_options
55         self.attachment_options = options
56
57         # only need to define these once on a class
58         unless included_modules.include?(InstanceMethods)
59           attr_accessor :thumbnail_resize_options
60
61           attachment_options[:storage]     ||= (attachment_options[:file_system_path] || attachment_options[:path_prefix]) ? :file_system : :db_file
62           attachment_options[:path_prefix] ||= attachment_options[:file_system_path]
63           if attachment_options[:path_prefix].nil?
64             attachment_options[:path_prefix] = attachment_options[:storage] == :s3 ? table_name : File.join("public", table_name)
65           end
66           attachment_options[:path_prefix]   = attachment_options[:path_prefix][1..-1] if options[:path_prefix].first == '/'
67
68           with_options :foreign_key => 'parent_id' do |m|
69             m.has_many   :thumbnails, :class_name => attachment_options[:thumbnail_class].to_s
70             m.belongs_to :parent, :class_name => base_class.to_s
71           end
72           before_destroy :destroy_thumbnails
73
74           before_validation :set_size_from_temp_path
75           after_save :after_process_attachment
76           after_destroy :destroy_file
77           extend  ClassMethods
78           include InstanceMethods
79           include Technoweenie::AttachmentFu::Backends.const_get("#{options[:storage].to_s.classify}Backend")
80           case attachment_options[:processor]
81             when :none
82             when nil
83               processors = Technoweenie::AttachmentFu.default_processors.dup
84               begin
85                 if processors.any?
86                   attachment_options[:processor] = "#{processors.first}Processor"
87                   include Technoweenie::AttachmentFu::Processors.const_get(attachment_options[:processor])
88                 end
89               rescue LoadError, MissingSourceFile
90                 processors.shift
91                 retry
92               end
93             else
94               begin
95                 include Technoweenie::AttachmentFu::Processors.const_get("#{options[:processor].to_s.classify}Processor")
96               rescue LoadError, MissingSourceFile
97                 puts "Problems loading #{options[:processor]}Processor: #{$!}"
98               end
99           end
100           after_validation :process_attachment
101         end
102       end
103     end
104
105     module ClassMethods
106       delegate :content_types, :to => Technoweenie::AttachmentFu
107
108       # Performs common validations for attachment models.
109       def validates_as_attachment
110         validates_presence_of :size, :content_type, :filename
111         validate              :attachment_attributes_valid?
112       end
113
114       # Returns true or false if the given content type is recognized as an image.
115       def image?(content_type)
116         content_types.include?(content_type)
117       end
118
119       # Callback after an image has been resized.
120       #
121       #   class Foo < ActiveRecord::Base
122       #     acts_as_attachment
123       #     after_resize do |record, img| 
124       #       record.aspect_ratio = img.columns.to_f / img.rows.to_f
125       #     end
126       #   end
127       def after_resize(&block)
128         write_inheritable_array(:after_resize, [block])
129       end
130
131       # Callback after an attachment has been saved either to the file system or the DB.
132       # Only called if the file has been changed, not necessarily if the record is updated.
133       #
134       #   class Foo < ActiveRecord::Base
135       #     acts_as_attachment
136       #     after_attachment_saved do |record|
137       #       ...
138       #     end
139       #   end
140       def after_attachment_saved(&block)
141         write_inheritable_array(:after_attachment_saved, [block])
142       end
143
144       # Callback before a thumbnail is saved.  Use this to pass any necessary extra attributes that may be required.
145       #
146       #   class Foo < ActiveRecord::Base
147       #     acts_as_attachment
148       #     before_thumbnail_saved do |record, thumbnail|
149       #       ...
150       #     end
151       #   end
152       def before_thumbnail_saved(&block)
153         write_inheritable_array(:before_thumbnail_saved, [block])
154       end
155
156       # Get the thumbnail class, which is the current attachment class by default.
157       # Configure this with the :thumbnail_class option.
158       def thumbnail_class
159         attachment_options[:thumbnail_class] = attachment_options[:thumbnail_class].constantize unless attachment_options[:thumbnail_class].is_a?(Class)
160         attachment_options[:thumbnail_class]
161       end
162
163       # Copies the given file path to a new tempfile, returning the closed tempfile.
164       def copy_to_temp_file(file, temp_base_name)
165         returning Tempfile.new(temp_base_name, Technoweenie::AttachmentFu.tempfile_path) do |tmp|
166           tmp.close
167           FileUtils.cp file, tmp.path
168         end
169       end
170       
171       # Writes the given data to a new tempfile, returning the closed tempfile.
172       def write_to_temp_file(data, temp_base_name)
173         returning Tempfile.new(temp_base_name, Technoweenie::AttachmentFu.tempfile_path) do |tmp|
174           tmp.binmode
175           tmp.write data
176           tmp.close
177         end
178       end
179     end
180
181     module InstanceMethods
182       # Checks whether the attachment's content type is an image content type
183       def image?
184         self.class.image?(content_type)
185       end
186       
187       # Returns true/false if an attachment is thumbnailable.  A thumbnailable attachment has an image content type and the parent_id attribute.
188       def thumbnailable?
189         image? && respond_to?(:parent_id) && parent_id.nil?
190       end
191
192       # Returns the class used to create new thumbnails for this attachment.
193       def thumbnail_class
194         self.class.thumbnail_class
195       end
196
197       # Gets the thumbnail name for a filename.  'foo.jpg' becomes 'foo_thumbnail.jpg'
198       def thumbnail_name_for(thumbnail = nil)
199         return filename if thumbnail.blank?
200         ext = nil
201         basename = filename.gsub /\.\w+$/ do |s|
202           ext = s; ''
203         end
204         # ImageScience doesn't create gif thumbnails, only pngs
205         ext.sub!(/gif$/, 'png') if attachment_options[:processor] == "ImageScienceProcessor"
206         "#{basename}_#{thumbnail}#{ext}"
207       end
208
209       # Creates or updates the thumbnail for the current attachment.
210       def create_or_update_thumbnail(temp_file, file_name_suffix, *size)
211         thumbnailable? || raise(ThumbnailError.new("Can't create a thumbnail if the content type is not an image or there is no parent_id column"))
212         returning find_or_initialize_thumbnail(file_name_suffix) do |thumb|
213           thumb.attributes = {
214             :content_type             => content_type, 
215             :filename                 => thumbnail_name_for(file_name_suffix), 
216             :temp_path                => temp_file,
217             :thumbnail_resize_options => size
218           }
219           callback_with_args :before_thumbnail_saved, thumb
220           thumb.save!
221         end
222       end
223
224       # Sets the content type.
225       def content_type=(new_type)
226         write_attribute :content_type, new_type.to_s.strip
227       end
228       
229       # Sanitizes a filename.
230       def filename=(new_name)
231         write_attribute :filename, sanitize_filename(new_name)
232       end
233
234       # Returns the width/height in a suitable format for the image_tag helper: (100x100)
235       def image_size
236         [width.to_s, height.to_s] * 'x'
237       end
238
239       # Returns true if the attachment data will be written to the storage system on the next save
240       def save_attachment?
241         File.file?(temp_path.to_s)
242       end
243
244       # nil placeholder in case this field is used in a form.
245       def uploaded_data() nil; end
246
247       # This method handles the uploaded file object.  If you set the field name to uploaded_data, you don't need
248       # any special code in your controller.
249       #
250       #   <% form_for :attachment, :html => { :multipart => true } do |f| -%>
251       #     <p><%= f.file_field :uploaded_data %></p>
252       #     <p><%= submit_tag :Save %>
253       #   <% end -%>
254       #
255       #   @attachment = Attachment.create! params[:attachment]
256       #
257       # TODO: Allow it to work with Merb tempfiles too.
258       def uploaded_data=(file_data)
259         return nil if file_data.nil? || file_data.size == 0 
260         self.content_type = file_data.content_type
261         self.filename     = file_data.original_filename if respond_to?(:filename)
262         if file_data.is_a?(StringIO)
263           file_data.rewind
264           self.temp_data = file_data.read
265         else
266           self.temp_path = file_data.path
267         end
268       end
269
270       # Gets the latest temp path from the collection of temp paths.  While working with an attachment,
271       # multiple Tempfile objects may be created for various processing purposes (resizing, for example).
272       # An array of all the tempfile objects is stored so that the Tempfile instance is held on to until
273       # it's not needed anymore.  The collection is cleared after saving the attachment.
274       def temp_path
275         p = temp_paths.first
276         p.respond_to?(:path) ? p.path : p.to_s
277       end
278       
279       # Gets an array of the currently used temp paths.  Defaults to a copy of #full_filename.
280       def temp_paths
281         @temp_paths ||= (new_record? || !File.exist?(full_filename)) ? [] : [copy_to_temp_file(full_filename)]
282       end
283       
284       # Adds a new temp_path to the array.  This should take a string or a Tempfile.  This class makes no 
285       # attempt to remove the files, so Tempfiles should be used.  Tempfiles remove themselves when they go out of scope.
286       # You can also use string paths for temporary files, such as those used for uploaded files in a web server.
287       def temp_path=(value)
288         temp_paths.unshift value
289         temp_path
290       end
291
292       # Gets the data from the latest temp file.  This will read the file into memory.
293       def temp_data
294         save_attachment? ? File.read(temp_path) : nil
295       end
296       
297       # Writes the given data to a Tempfile and adds it to the collection of temp files.
298       def temp_data=(data)
299         self.temp_path = write_to_temp_file data unless data.nil?
300       end
301       
302       # Copies the given file to a randomly named Tempfile.
303       def copy_to_temp_file(file)
304         self.class.copy_to_temp_file file, random_tempfile_filename
305       end
306       
307       # Writes the given file to a randomly named Tempfile.
308       def write_to_temp_file(data)
309         self.class.write_to_temp_file data, random_tempfile_filename
310       end
311       
312       # Stub for creating a temp file from the attachment data.  This should be defined in the backend module.
313       def create_temp_file() end
314
315       # Allows you to work with a processed representation (RMagick, ImageScience, etc) of the attachment in a block.
316       #
317       #   @attachment.with_image do |img|
318       #     self.data = img.thumbnail(100, 100).to_blob
319       #   end
320       #
321       def with_image(&block)
322         self.class.with_image(temp_path, &block)
323       end
324
325       protected
326         # Generates a unique filename for a Tempfile. 
327         def random_tempfile_filename
328           "#{rand Time.now.to_i}#{filename || 'attachment'}"
329         end
330
331         def sanitize_filename(filename)
332           returning filename.strip do |name|
333             # NOTE: File.basename doesn't work right with Windows paths on Unix
334             # get only the filename, not the whole path
335             name.gsub! /^.*(\\|\/)/, ''
336             
337             # Finally, replace all non alphanumeric, underscore or periods with underscore
338             name.gsub! /[^\w\.\-]/, '_'
339           end
340         end
341
342         # before_validation callback.
343         def set_size_from_temp_path
344           self.size = File.size(temp_path) if save_attachment?
345         end
346
347         # validates the size and content_type attributes according to the current model's options
348         def attachment_attributes_valid?
349           [:size, :content_type].each do |attr_name|
350             enum = attachment_options[attr_name]
351             errors.add attr_name, ActiveRecord::Errors.default_error_messages[:inclusion] unless enum.nil? || enum.include?(send(attr_name))
352           end
353         end
354
355         # Initializes a new thumbnail with the given suffix.
356         def find_or_initialize_thumbnail(file_name_suffix)
357           respond_to?(:parent_id) ?
358             thumbnail_class.find_or_initialize_by_thumbnail_and_parent_id(file_name_suffix.to_s, id) :
359             thumbnail_class.find_or_initialize_by_thumbnail(file_name_suffix.to_s)
360         end
361
362         # Stub for a #process_attachment method in a processor
363         def process_attachment
364           @saved_attachment = save_attachment?
365         end
366
367         # Cleans up after processing.  Thumbnails are created, the attachment is stored to the backend, and the temp_paths are cleared.
368         def after_process_attachment
369           if @saved_attachment
370             if respond_to?(:process_attachment_with_processing) && thumbnailable? && !attachment_options[:thumbnails].blank? && parent_id.nil?
371               temp_file = temp_path || create_temp_file
372               attachment_options[:thumbnails].each { |suffix, size| create_or_update_thumbnail(temp_file, suffix, *size) }
373             end
374             save_to_storage
375             @temp_paths.clear
376             @saved_attachment = nil
377             callback :after_attachment_saved
378           end
379         end
380
381         # Resizes the given processed img object with either the attachment resize options or the thumbnail resize options.
382         def resize_image_or_thumbnail!(img)
383           if (!respond_to?(:parent_id) || parent_id.nil?) && attachment_options[:resize_to] # parent image
384             resize_image(img, attachment_options[:resize_to])
385           elsif thumbnail_resize_options # thumbnail
386             resize_image(img, thumbnail_resize_options) 
387           end
388         end
389
390         # Yanked from ActiveRecord::Callbacks, modified so I can pass args to the callbacks besides self.
391         # Only accept blocks, however
392         def callback_with_args(method, arg = self)
393           notify(method)
394
395           result = nil
396           callbacks_for(method).each do |callback|
397             result = callback.call(self, arg)
398             return false if result == false
399           end
400
401           return result
402         end
403         
404         # Removes the thumbnails for the attachment, if it has any
405         def destroy_thumbnails
406           self.thumbnails.each { |thumbnail| thumbnail.destroy } if thumbnailable?
407         end
408     end
409   end
410 end

Benjamin Mako Hill || Want to submit a patch?