Commit d68a9421 by jyurek

Re-refactoring

git-svn-id: https://svn.thoughtbot.com/plugins/paperclip/trunk@240 7bbfaf0e-4d1d-0410-9690-a8bb5f8ef2aa
parent eba59dac
require 'open-uri'
require File.join(File.dirname(__FILE__), "lib", "paperclip") require File.join(File.dirname(__FILE__), "lib", "paperclip")
ActiveRecord::Base.extend( Thoughtbot::Paperclip::ClassMethods ) ActiveRecord::Base.extend( Thoughtbot::Paperclip::ClassMethods )
File.send :include, Thoughtbot::Paperclip::Upfile File.send :include, Thoughtbot::Paperclip::Upfile
URI.send :include, Thoughtbot::Paperclip::Upfile \ No newline at end of file
\ No newline at end of file
...@@ -8,295 +8,462 @@ ...@@ -8,295 +8,462 @@
# Copyright:: Copyright (c) 2007 thoughtbot, inc. # Copyright:: Copyright (c) 2007 thoughtbot, inc.
# License:: Distrbutes under the same terms as Ruby # License:: Distrbutes under the same terms as Ruby
# #
# Paperclip defines an attachment as any file, though it makes special considerations
# for image files. You can declare that a model has an attached file with the
# +has_attached_file+ method:
#
# class User < ActiveRecord::Base
# has_attached_file :avatar, :thumbnails => { :thumb => "100x100" }
# end
#
# user = User.new
# user.avatar = params[:user][:avatar]
# user.avatar.url
# # => "/users/avatars/4/original_me.jpg"
# user.avatar.url(:thumb)
# # => "/users/avatars/4/thumb_me.jpg"
#
# See the +has_attached_file+ documentation for more details. # See the +has_attached_file+ documentation for more details.
module Paperclip
class << self
# Provides configurability to Paperclip. There are a number of options available, such as:
# * whiny_deletes: Will raise an error if Paperclip is unable to delete an attachment. Defaults to false.
# * whiny_thumbnails: Will raise an error if Paperclip cannot process thumbnails of an uploaded image. Defaults to true.
# * image_magick_path: Defines the path at which to find the +convert+ and +identify+ programs if they are
# not visible to Rails the system's search path. Defaults to nil, which uses the first executable found
# in the search path.
def options
@options ||= {
:whiny_deletes => false,
:whiny_thumbnails => true,
:image_magick_path => nil
}
end
def path_for_command command #:nodoc:
path = [options[:image_magick_path], command].compact
File.join(*path)
end
end
class PaperclipError < StandardError #:nodoc:
end
# Holds the options defined by a call to has_attached_file. If options are not defined here as methods
# they will still be found through +method_missing+. Default values can be modified by modifying the
# hash returned by AttachmentDefinition.defaults directly.
class AttachmentDefinition
def self.defaults
@defaults ||= {
:path => ":rails_root/public/:class/:attachment/:id/:style_:name",
:url => "/:class/:attachment/:id/:style_:name",
:missing_url => "/:class/:attachment/:style_missing.png",
:attachment_type => :image,
:thumbnails => {},
:delete_on_destroy => true,
:default_style => :original
}
end
def initialize name, options
@name = name
@options = AttachmentDefinition.defaults.merge options
end
def name
@name
end
def styles
@styles ||= thumbnails.merge(:original => nil)
end
def thumbnails
@thumbnails ||= @options[:thumbnails]
end
require 'paperclip/upfile' def validate thing, *constraints
require 'paperclip/attachment' @options[:"validate_#{thing}"] = (constraints.length == 1 ? constraints.first : constraints)
require 'paperclip/attachment_definition' end
require 'paperclip/storage/filesystem'
def validations
module Thoughtbot #:nodoc: @validations ||= @options.inject({}) do |valids, opts|
# Paperclip defines an attachment as any file, though it makes special considerations key, val = opts
# for image files. You can declare that a model has an attached file with the if (m = key.to_s.match(/^validate_(.+)/))
# +has_attached_file+ method: valids[m[1]] = val
# end
# class User < ActiveRecord::Base valids
# has_attached_file :avatar, :thumbnails => { :thumb => "100x100" }
# end
#
# See the +has_attached_file+ documentation for more details.
module Paperclip
class << self
# Provides configurability to Paperclip. There are a number of options available, such as:
# * whiny_deletes: Will raise an error if Paperclip is unable to delete an attachment. Defaults to false.
# * whiny_thumbnails: Will raise an error if Paperclip cannot process thumbnails of an uploaded image. Defaults to true.
# * image_magick_path: Defines the path at which to find the +convert+ and +identify+ programs if they are
# not visible to Rails the system's search path. Defaults to nil, which uses the first executable found
# in the search path.
def options
@options ||= {
:whiny_deletes => false,
:whiny_thumbnails => true,
:image_magick_path => nil
}
end end
end
def method_missing meth, *args
@options[meth]
end
end
# == Attachment
# Handles all the file management for the attachment, including saving, loading, presenting URLs, thumbnail
# processing, and database storage.
class Attachment
attr_reader :name, :instance, :original_filename, :content_type, :original_file_size, :definition, :errors
def path_for_command command #:nodoc: def initialize name, active_record, definition
path = [options[:image_magick_path], command].compact @instance = active_record
File.join(*path) @definition = defintiion
@name = name
@errors = []
clear_files
@dirty = true
self.original_filename = @instance["#{name}_file_name"]
self.content_type = @instance["#{name}_content_type"]
self.original_file_size = @instance["#{name}_file_size"]
end
def assign uploaded_file
return queue_destroy if uploaded_file.nil?
return unless is_a_file? uploaded_file
self.original_filename = sanitize_filename(uploaded_file.original_filename)
self.content_type = uploaded_file.content_type
self.original_file_size = uploaded_file.size
self[:original] = uploaded_file
@dirty = true
if definition.type == :image
make_thumbnails_from uploaded_file
end end
end end
def [](style)
@files[style]
end
def []=(style, data)
@files[style] = data
end
def clear_files
@files = {}
definition.styles.each{|style| @files[style] = nil }
@dirty = false
end
def for_attached_files
@files.each do |style, data|
data.rewind if data && data.respond_to?(:rewind)
yield style, (data.respond_to?(:read) ? data.read : data)
end
end
def dirty?
@dirty
end
# Validations
def valid?
definition.validations.each do |validation, constraints|
send("validate_#{validation}", *constraints)
end
errors.uniq!.empty?
end
# ActiveRecord Callbacks
def save
write_attachment if dirty?
delete_attachment if @delete_on_save
@delete_on_save = false
clear_files
end
def queue_destroy(complain = false)
returning true do
@delete_on_save = true
@complain_on_delete = complain
self.original_filename = nil
self.content_type = nil
clear_files
end
end
def destroy
delete_attachment if definition.delete_on_destroy
end
def url style = nil
style ||= definition.default_style
pattern = if original_filename && instance.id
definition.url
else
definition.missing_url
end
interpolate( style, pattern )
end
class PaperclipError < StandardError #:nodoc: def read style = nil
attr_accessor :attachment self[style] ? self[style].read : IO.read(file_name(style))
def initialize attachment end
@attachment = attachment
def validate_existence *constraints
definition.styles.keys.each do |style|
errors << "requires a valid #{style} file." unless file_exists?(style)
end end
end end
module ClassMethods def validate_size *constraints
# == Methods errors << "file too large. Must be under #{constraints.last} bytes." if original_file_size > constraints.last
# +has_attached_file+ attaches a file (or files) with a given name to a model. It creates seven instance errors << "file too small. Must be over #{constraints.first} bytes." if original_file_size <= constraints.first
# methods using the attachment name (where "attachment" in the following is the name end
# passed in to +has_attached_file+):
# * attachment: Returns the name of the file that was attached, with no path information. protected
# * attachment?: Alias for _attachment_ for clarity in determining if the attachment exists.
# * attachment=(file): Sets the attachment to the file and creates the thumbnails (if necessary). def write_attachment
# +file+ can be anything normally accepted as an upload (+StringIO+ or +Tempfile+) or a +File+ ensure_directories
# if it has had the +Upfile+ module included. for_attached_files do |style, data|
# Note this does not save the attachments. File.open( file_name(style), "w" ) do |file|
# user.avatar = File.new("~/pictures/me.png") file.rewind
# user.avatar = params[:user][:avatar] # When :avatar is a file_field file.write(data) if data
# * attachment_file_name(style): The name of the file, including path information. Pass in the
# name of a thumbnail to get the path to that thumbnail.
# user.avatar_file_name(:thumb) # => "public/users/44/thumb/me.png"
# user.avatar_file_name # => "public/users/44/original/me.png"
# * attachment_url(style): The public URL of the attachment, suitable for passing to +image_tag+
# or +link_to+. Pass in the name of a thumbnail to get the url to that thumbnail.
# user.avatar_url(:thumb) # => "http://assethost.com/users/44/thumb/me.png"
# user.avatar_url # => "http://assethost.com/users/44/original/me.png"
# * attachment_valid?: If unsaved, returns true if all thumbnails have data (that is,
# they were successfully made). If saved, returns true if all expected files exist and are
# of nonzero size.
# * destroy_attachment(complain = false): Deletes the attachment and all thumbnails. Sets the +attachment_file_name+
# column and +attachment_content_type+ column to +nil+. Set +complain+ to true to override
# the +whiny_deletes+ option.
#
# == Options
# There are a number of options you can set to change the behavior of Paperclip.
# * +path_prefix+: The location of the repository of attachments on disk. See Interpolation below
# for more control over where the files are located.
# :path_prefix => ":rails_root/public"
# :path_prefix => "/var/app/repository"
# * +url_prefix+: The root URL of where the attachment is publically accessible. See Interpolation below
# for more control over where the files are located.
# :url_prefix => "/"
# :url_prefix => "/user_files"
# :url_prefix => "http://some.other.host/stuff"
# * +path+: Where the files are stored underneath the +path_prefix+ directory and underneath the +url_prefix+ URL.
# See Interpolation below for more control over where the files are located.
# :path => ":class/:style/:id/:name" # => "users/original/13/picture.gif"
# * +attachment_type+: If this is set to :image (which it is, by default), Paperclip will attempt to make thumbnails.
# * +thumbnails+: A hash of thumbnail styles and their geometries. You can find more about geometry strings
# at the ImageMagick website (http://www.imagemagick.org/script/command-line-options.php#resize). Paperclip
# also adds the "#" option, which will resize the image to fit maximally inside the dimensions and then crop
# the rest off (weighted at the center).
# * +delete_on_destroy+: When records are deleted, the attachment that goes with it is also deleted. Set
# this to +false+ to prevent the file from being deleted.
# * +default_style+: The thumbnail style that will be used by default for +attachment_file_name+ and +attachment_url+
# Defaults to +original+.
# has_attached_file :avatar, :thumbnails => { :normal => "100x100#" },
# :default_style => :normal
# user.avatar_url # => "/avatars/23/normal_me.png"
# * +missing_url+: The URL that will be returned if there is no attachment assigned. It should be an absolute
# URL, not relative to the +url_prefix+. This field is interpolated.
# has_attached_file :avatar, :missing_url => "/images/default_:style_avatar.png"
# User.new.avatar_url(:small) # => "/images/default_small_avatar.png"
#
# == Interpolation
# The +path_prefix+, +url_prefix+, and +path+ options can have dynamic interpolation done so that the
# locations of the files can vary depending on a variety of factors. Each variable looks like a Ruby symbol
# and is searched for with +gsub+, so a variety of effects can be achieved. The list of possible variables
# follows:
# * +rails_root+: The value of the +RAILS_ROOT+ constant for your app. Typically used when putting your
# attachments into the public directory. Probably not useful in the +path+ definition.
# * +class+: The underscored, pluralized version of the class in which the attachment is defined.
# * +attachment+: The pluralized name of the attachment as given to +has_attached_file+
# * +style+: The name of the thumbnail style for the current thumbnail. If no style is given, "original" is used.
# * +id+: The record's id.
# * +name+: The file's name, as stored in the attachment_file_name column.
#
# When interpolating, you are not confined to making any one of these into its own directory. This is
# perfectly valid:
# :path => ":attachment/:style/:id-:name" # => "avatars/thumb/44-me.png"
#
# == Model Requirements
# For any given attachment _foo_, the model the attachment is in needs to have both a +foo_file_name+
# and +foo_content_type+ column, as a type of +string+, and a +foo_file_size+ column as type +integer+.
# The +foo_file_name+ column contains only the name
# of the file and none of the path information. However, the +foo_file_name+ column accessor is overwritten
# by the one (defined above) which returns the full path to whichever style thumbnail is passed in.
# To access the name as stored in the database, you can use Attachment#original_filename.
#
# == Event Triggers
# When an attachment is set by using he setter (+model.attachment=+), the thumbnails are created and held in
# memory. They are not saved until the +after_save+ trigger fires, at which point the attachment and all
# thumbnails are written to disk.
#
# Attached files are destroyed when the associated record is destroyed in a +before_destroy+ trigger. Set
# the +delete_on_destroy+ option to +false+ to prevent this behavior. Also note that using the ActiveRecord's
# +delete+ method instead of the +destroy+ method will prevent the +before_destroy+ trigger from firing.
def has_attached_file *attachment_names
options = attachment_names.last.is_a?(Hash) ? attachment_names.pop : {}
@attachment_definitions ||= {}
class << self
attr_reader :attachment_definitions
end
include InstanceMethods
after_save :save_attachments
before_destroy :destroy_attachments
validates_each(*attachment_names) do |record, attr, value|
value.errors.each{|e| record.errors.add(attr, e) unless record.errors.on(attr) && record.errors.on(attr).include?(e) }
end end
end
end
attachment_names.each do |name| def delete_attachment complain = false
whine_about_columns_for name for_attached_files do |style, data|
@attachment_definitions[name] = Thoughtbot::Paperclip::AttachmentDefinition.new(name, options) file_path = file_name(style)
begin
define_method "#{name}=" do |uploaded_file| FileUtils.rm file_path if file_path
attachment_for(name).assign uploaded_file rescue SystemCallError => e
end raise PaperclipError, "Could not delete thumbnail." if Paperclip.options[:whiny_deletes] || complain
define_method name do
attachment_for(name)
end
define_method "#{name}?" do
attachment_for(name).original_filename
end
define_method "#{name}_valid?" do
attachment_for(name).valid?
end
define_method "#{name}_file_name" do |*args|
attachment_for(name).file_name(args.first)
end
define_method "#{name}_url" do |*args|
attachment_for(name).url(args.first)
end
define_method "destroy_#{name}" do |*args|
attachment_for(name).queue_destroy(args.first)
end
end end
end end
end
module InstanceMethods #:nodoc:
unless method_defined? :after_initialize def file_name style = nil
def after_initialize style ||= definition.default_style
# We need this, because Rails won't even try this method unless it is specifically defined. interpolate( style, definition.path )
end end
def file_exists?(style)
style ||= definition.default_style
dirty? ? self[style] : File.exists?( file_name(style) )
end
def ensure_directories
for_attached_files do |style, file|
dirname = File.dirname( file_name(style) )
FileUtils.mkdir_p dirname
end
end
# Image Methods
public
def make_thumbnails_from data
begin
definition.thumbnails.each do |style, geometry|
self[style] = make_thumbnail geometry, data
end end
rescue PaperclipError => e
def after_initialize_with_paperclip errors << e.message
@attachments = {} clear_files
self.class.attachment_definitions.keys.each do |name| self[:original] = data
@attachments[name] = Thoughtbot::Paperclip::Attachment.new(name, self) end
end end
protected
def make_thumbnail geometry, data
return data if geometry.nil?
operator = geometry[-1,1]
begin
geometry, crop_geometry = geometry_for_crop(geometry, data) if operator == '#'
convert = Paperclip.path_for_command("convert")
command = "#{convert} - -scale '#{geometry}' #{operator == '#' ? "-crop '#{crop_geometry}'" : ""} - 2>/dev/null"
thumb = IO.popen(command, "w+") do |io|
data.rewind
io.write(data.read)
io.close_write
StringIO.new(io.read)
end end
alias_method_chain :after_initialize, :paperclip rescue Errno::EPIPE => e
raise PaperclipError, "could not be thumbnailed. Is ImageMagick or GraphicsMagick installed and available?"
def save_attachments rescue SystemCallError => e
@attachments.each do |name, attachment| raise PaperclipError, "could not be thumbnailed."
attachment.save end
if Paperclip.options[:whiny_thumbnails] && !$?.success?
raise PaperclipError, "could not be thumbaniled because of an error with 'convert'."
end
thumb
end
def geometry_for_crop geometry, orig_io
identify = Paperclip.path_for_command("identify")
IO.popen("#{identify} -", "w+") do |io|
orig_io.rewind
io.write(orig_io.read)
io.close_write
if match = io.read.split[2].match(/(\d+)x(\d+)/)
src = match[1,2].map(&:to_f)
srch = src[0] > src[1]
dst = geometry.match(/(\d+)x(\d+)/)[1,2].map(&:to_f)
dsth = dst[0] > dst[1]
ar = src[0] / src[1]
scale_geometry, scale = if dst[0] == dst[1]
if srch
[ "x#{dst[1]}", src[1] / dst[1] ]
else
[ "#{dst[0]}x", src[0] / dst[0] ]
end
elsif dsth
[ "#{dst[0]}x", src[0] / dst[0] ]
else
[ "x#{dst[1]}", src[1] / dst[1] ]
end end
end
crop_geometry = if dsth
def destroy_attachments "%dx%d+%d+%d" % [ dst[0], dst[1], 0, (src[1] / scale - dst[1]) / 2 ]
@attachments.each do |name, attachment| else
attachment.destroy "%dx%d+%d+%d" % [ dst[0], dst[1], (src[0] / scale - dst[0]) / 2, 0 ]
end end
end
[ scale_geometry, crop_geometry ]
def attachment_for name
@attachments[name]
end end
end end
end
def attachment_names
@attachment_definitions.keys # Helper Methods
public
def interpolations
@interpolations ||= {
:rails_root => lambda{|style| RAILS_ROOT },
:id => lambda{|style| self.instance.id },
:class => lambda{|style| self.instance.class.to_s.underscore.pluralize },
:style => lambda{|style| style.to_s },
:attachment => lambda{|style| self.name.to_s.pluralize },
:filename => lambda{|style| self.original_filename }
}
end
def interpolate style, source
returning source.dup do |s|
interpolations.each do |key, proc|
s.gsub!(/:#{key}/){ proc.call(instance, style) }
end
end end
end
# Paperclip always validates whether or not file creation was successful, but does not validate def original_filename= new_name
# the presence or size of the file unless told. You can specify validations either in the instance["#{name}_file_name"] = @original_filename = new_name
# has_attached_file call or with a separate validates_attached_file call, with a syntax similar end
# to has_attached_file. If no options are given, the existence of the file is validated.
# def content_type= new_type
# validates_attached_file :avatar, :existence => true, :size => 0..(500.kilobytes) instance["#{name}_content_type"] = @content_type = new_type
def validates_attached_file *attachment_names end
options = attachment_names.pop if attachment_names.last.is_a? Hash
options ||= { :existence => true } def original_file_size= new_size
attachment_names.each do |name| instance["#{name}_file_size"] = @original_file_size = new_size
options.each do |key, value| end
@attachment_definitions[name].validate key, value
end def to_s
url
end
protected
def is_a_file? data
[:size, :content_type, :original_filename, :read].map do |meth|
data.respond_to? meth
end.all?
end
def sanitize_filename filename
File.basename(filename).gsub(/[^\w\.\_]/,'_')
end
end
module ClassMethods
def has_attached_file *attachment_names
options = attachment_names.last.is_a?(Hash) ? attachment_names.pop : {}
include InstanceMethods
class_inheritable_hash :attachment_definitions
attachment_names.each do |aname|
whine_about_columns_for aname
self.attachment_definitions[aname] = AttachmentDefinition.new(aname, options)
define_method aname do
attachment_for(aname)
end
define_method "#{aname}=" do |uploaded_file|
attachment_for(aname).assign uploaded_file
end end
end end
end
def whine_about_columns_for name #:nodoc:
[ "#{name}_file_name", "#{name}_content_type", "#{name}_file_size" ].each do |column| def attached_files
unless column_names.include?(column) attachment_definitions.keys
raise PaperclipError, "Class #{self.name} does not have all of the necessary columns to have an attachment named #{name}. " + end
"(#{name}_file_name, #{name}_content_type, and #{name}_file_size)"
end # Adds errors if the attachments you specify are either missing or had errors on them.
# Essentially, acts like validates_presence_of for attachments.
def validates_attached_file *attachment_names
validates_each *attachment_names do |record, name, attachment|
attachment.errors.each do |error|
record.add(name, error)
end end
end end
end end
# == Storage Subsystems def whine_about_columns_for attachment #:nodoc:
# While Paperclip focuses primarily on using the filesystem for data storage, it is possible to allow name = attachment[:name]
# other storage mediums. A module inside the Storage module can be used as the storage provider for unless column_names.include?("#{name}_file_name") && column_names.include?("#{name}_content_type")
# attachments when has_attached_file is given the +storage+ option. The value of the option should be error = "Class #{self.name} does not have the necessary columns to have an attachment named #{name}. " +
# the name of the module, symbol-ized (e.g. :filesystem, :s3). You can look at the Filesystem and S3 "(#{name}_file_name and #{name}_content_type)"
# modules for examples of how it typically works. raise PaperclipError, error
# end
# If you want to implement a storage system, you are required to implement the following methods: end
# * file_name(style = nil): Takes a style (i.e. thumbnail name) and should return the canonical name end
# for referencing that file or thumbnail. You may define this how you wish. For example, in
# Filesystem, it is the location of the file under the path_prefix. In S3, it returns the path portion module InstanceMethods #:nodoc:
# of the Amazon URL minus the bucket name. def attachment_for name
# * url(style = nil): Takes a style and should return the URL at which the attachment should be accessed. @attachments ||= {}
# * write_attachment: Write all the files and thumbnails in this attachment to the storage medium. Should @attachments[name] ||= Attachment.new(self, name)
# return true or false depending on success. end
# * delete_attachment: Delete the files and thumbnails from the storage medium. Should return true or false end
# depending on success.
# # The Upfile module is a convenience module for adding uploaded-file-type methods
# When writing a storage system, your code will be mixed into the Attachment that the file represents. You # to the +File+ class. Useful for testing.
# will therefore have access to all of the methods available to Attachments. Some methods of note are: # user.avatar = File.new("test/test_avatar.jpg")
# * definition: Returns the AttachmentDefintion object created by has_attached_file. Useful for getting module Upfile
# style definitions and flags that you want to set. You should open the AttachmentDefinition class to # Infer the MIME-type of the file from the extension.
# add getters for any options you want to be able to set. def content_type
# * instance: Returns the ActiveRecord object that the Attachment is attached to. Can be used to obtain ids. type = self.path.match(/\.(\w+)$/)[1] || "data"
# * original_filename: Returns the original_filename, which is the same as the attachment_file_name column case type
# in the database. Should be nil if there is no attachment. when "jpg", "png", "gif" then "image/#{type}"
# * original_file_size: Returns the size of the original file, as passed to Attachment#assign. when "txt", "csv", "xml", "html", "htm" then "text/#{type}"
# * interpolate: Given a style and a pattern, this will interpolate variables like :rails_root, :name, else "x-application/#{type}"
# and :id. See documentation for has_attached_file for more info on interpolation. end
# * for_attached_files: Iterates over the collection of files for this attachment, passing the style name end
# and the data to the block. Will not call the block if the data is nil.
# * dirty?: Returns true if a new file has been assigned with Attachment#assign, false otherwise. # Returns the file's normal name.
# def original_filename
# == Validations self.path
# Storage systems provide their own validations, since the manner of checking the status of them is usually end
# specific to the means of storage. To provide a validation, define a method starting with "validate_" in
# your module. You are responsible for adding errors to the +errors+ array if validation fails. # Returns the size of the file.
module Storage; end def size
File.size(self)
end
end end
end end
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment