citrusbyte-milton 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/INSTALL ADDED
@@ -0,0 +1,26 @@
1
+ == Installing Milton
2
+
3
+ GemPlugin (Rails 2.1+)
4
+ -------------------------------------------------------------------------------
5
+
6
+ Add to your environment.rb:
7
+
8
+ config.gem "citrusbyte-milton", :source => "http://gems.github.com", :lib => "milton"
9
+
10
+ Then run "rake gems:install" to install the gem.
11
+
12
+ Plugin
13
+ -------------------------------------------------------------------------------
14
+
15
+ script/plugin install git://github.com/citrusbyte/milton.git
16
+
17
+ Gem
18
+ -------------------------------------------------------------------------------
19
+
20
+ gem install citrusbyte-milton --source http://gems.github.com
21
+
22
+ == Installing ImageMagick (for image resizing)
23
+
24
+ == Installing a FileSystem
25
+
26
+ ...j/k j/k
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Citrusbyte, LLC
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,12 @@
1
+ == Milton
2
+
3
+ Milton is an extensible attachment handling plugin that makes few assumptions
4
+ but provides a lot of power.
5
+
6
+ == Dependencies
7
+ * ActiveRecord
8
+ * Rails (for now?)
9
+ * A filesystem (more storage solutions coming soon...)
10
+
11
+ === For Image manipulation (not required!)
12
+ * ImageMagick (more processors coming soon)
data/init.rb ADDED
@@ -0,0 +1,2 @@
1
+ require 'milton'
2
+ ActiveRecord::Base.send(:include, Citrusbyte::Milton)
@@ -0,0 +1,265 @@
1
+ require 'ftools'
2
+ require 'fileutils'
3
+
4
+ module Citrusbyte
5
+ module Milton
6
+ module Attachment
7
+ def self.included(base)
8
+ base.extend Citrusbyte::Milton::Attachment::AttachmentMethods
9
+ end
10
+
11
+ module AttachmentMethods
12
+ def has_attachment_methods(options={})
13
+ raise "Milton requires a filename column on #{table_name} table" unless column_names.include?("filename")
14
+
15
+ # character used to seperate a filename from its derivative options, this
16
+ # character will be stripped from all incoming filenames and replaced by
17
+ # replacement
18
+ options[:separator] ||= '.'
19
+ options[:replacement] ||= '-'
20
+
21
+ # root of where the underlying files are stored (or will be stored)
22
+ # on the file system
23
+ options[:file_system_path] ||= File.join(RAILS_ROOT, "public", table_name)
24
+
25
+ # mode to set on stored files and created directories
26
+ options[:chmod] ||= 0755
27
+
28
+ AttachableFile.options = options
29
+
30
+ validates_presence_of :filename
31
+
32
+ before_destroy :destroy_attached_file
33
+
34
+ include Citrusbyte::Milton::Attachment::InstanceMethods
35
+ end
36
+ end
37
+
38
+ module InstanceMethods
39
+ # Sets the filename to the given filename (sanitizes the given filename
40
+ # as well)
41
+ #
42
+ # TODO: change the filename on the underlying file system on save so as
43
+ # not to orphan the file
44
+ def filename=(name)
45
+ write_attribute :filename, AttachableFile.sanitize_filename(name)
46
+ end
47
+
48
+ # The path to the file, takes an optional hash of options which can be
49
+ # used to determine a particular derivative of the file desired
50
+ def path(options={})
51
+ attached_file.path(options)
52
+ end
53
+
54
+ protected
55
+ # A reference to the attached file, this is probably what you want to
56
+ # overwrite to introduce a new behavior
57
+ #
58
+ # i.e.
59
+ # have attached_file return a ResizeableFile, or a TranscodableFile
60
+ def attached_file
61
+ @attached_file ||= AttachableFile.new(self, filename)
62
+ end
63
+
64
+ # Clean the file from the filesystem
65
+ def destroy_attached_file
66
+ attached_file.destroy
67
+ end
68
+ end
69
+ end
70
+
71
+ # AttachableFile is what Milton uses to interface between your model and
72
+ # the underlying file. Rather than just pushing a whole bunch of methods
73
+ # into your model, you get a reference to an AttachableFile (or something
74
+ # that extends AttachableFile).
75
+ class AttachableFile
76
+ class_inheritable_accessor :options
77
+
78
+ class << self
79
+ # Sanitizes the given filename, removes pathnames and the special chars
80
+ # needed for options seperation for derivatives
81
+ def sanitize_filename(filename)
82
+ File.basename(filename, File.extname(filename)).gsub(/^.*(\\|\/)/, '').
83
+ gsub(/[^\w]|#{Regexp.escape(options[:separator])}/, options[:replacement]).
84
+ strip + File.extname(filename)
85
+ end
86
+
87
+ # Creates the given directory and sets it to the mode given in
88
+ # options[:chmod]
89
+ def recreate_directory(directory)
90
+ FileUtils.mkdir_p(directory)
91
+ File.chmod(options[:chmod], directory)
92
+ end
93
+
94
+ # Partitioner that takes an id, pads it up to 12 digits then splits
95
+ # that into 4 folders deep, each 3 digits long.
96
+ #
97
+ # i.e.
98
+ # 000/000/012/139
99
+ #
100
+ # Scheme allows for 1000 billion files while never storing more than
101
+ # 1000 files in a single folder.
102
+ #
103
+ # Can overwrite this method to provide your own partitioning scheme.
104
+ def partition(id)
105
+ # TODO: there's probably some fancy 1-line way to do this...
106
+ padded = ("0"*(12-id.to_s.size)+id.to_s).split('')
107
+ File.join(*[0, 3, 6, 9].collect{ |i| padded.slice(i, 3).join })
108
+ end
109
+ end
110
+
111
+ # TODO: can probably fanagle a way to only pass a reference to the model
112
+ # and not need the filename (or better yet just the filename and
113
+ # decouple)
114
+ def initialize(attachment, filename)
115
+ @attachment = attachment
116
+ @filename = filename
117
+ end
118
+
119
+ # Returns the full path and filename to the file with the given options.
120
+ # If no options are given then returns the path and filename to the
121
+ # original file.
122
+ def path(options={})
123
+ options.empty? ? File.join(dirname, @filename) : Derivative.new(@filename, options).path
124
+ end
125
+
126
+ # Returns the full directory path up to the file, w/o the filename.
127
+ def dirname
128
+ File.join(root_path, partitioned_path)
129
+ end
130
+
131
+ # Returns true if the file exists on the underlying file system.
132
+ def exists?
133
+ File.exist?(path)
134
+ end
135
+
136
+ # Removes the file from the underlying file system and any derivatives of
137
+ # the file.
138
+ def destroy
139
+ destroy_derivatives
140
+ destroy_file
141
+ end
142
+
143
+ protected
144
+ # Returns the file as a File object opened for reading.
145
+ def file_reference
146
+ File.new(path)
147
+ end
148
+
149
+ # Returns the partitioned path segment based on the id of the model
150
+ # this file is attached to.
151
+ def partitioned_path
152
+ self.class.partition(@attachment.id)
153
+ end
154
+
155
+ # The full path to the root of where files will be stored on disk.
156
+ def root_path
157
+ self.class.options[:file_system_path]
158
+ end
159
+
160
+ # Recreates the directory this file will be stored in.
161
+ def recreate_directory
162
+ self.class.recreate_directory(dirname) unless File.exists?(dirname)
163
+ end
164
+
165
+ # Removes the file from the filesystem.
166
+ def destroy_file
167
+ FileUtils.rm path if File.exists?(path)
168
+ end
169
+
170
+ # Derivatives of this Attachment ====================================
171
+
172
+ # Returns an array of derivatives of this attachment
173
+ def derivatives
174
+ Dir.glob(Derivative.dirname_for(path)).collect do |filename|
175
+ Derivative.from_filename(filename)
176
+ end
177
+ end
178
+
179
+ # Recreates the directory derivatives of this file will be stored in.
180
+ def recreate_derivative_directory
181
+ dirname = Derivative.dirname_for(path)
182
+ self.class.recreate_directory(dirname) unless File.exists?(dirname)
183
+ end
184
+
185
+ # Removes the derivatives folder for this file and all files within.
186
+ def destroy_derivatives
187
+ FileUtils.rm_rf dirname if File.exists?(dirname)
188
+ end
189
+ end
190
+
191
+ # Represents a file created on the file system that is a derivative of the
192
+ # one referenced by the model, i.e. a thumbnail of an image, or a transcode
193
+ # of a video.
194
+ #
195
+ # Provides a container for options and a uniform API to dealing with
196
+ # passing options for the creation of derivatives.
197
+ #
198
+ # Files created as derivatives have their creation options appended into
199
+ # their filenames so it can be checked later if a file w/ the given
200
+ # options already exists (so as not to create it again).
201
+ #
202
+ class Derivative
203
+ attr_reader :options
204
+
205
+ class << self
206
+ # Given a string of attachment options, splits them out into a hash,
207
+ # useful for things that take options on the query string or from
208
+ # filenames
209
+ def options_from(string)
210
+ Hash[*(string.split('_').collect { |option|
211
+ key, value = option.split('=')
212
+ [ key.to_sym, value ]
213
+ }).flatten]
214
+ end
215
+
216
+ # Merges the given options to build a derviative filename and returns
217
+ # the resulting filename.
218
+ def filename_for(filename, options={})
219
+ append = options.collect{ |k, v| "#{k}=#{v}" }.sort.join('_')
220
+ File.basename(filename, File.extname(filename)) + (append.blank? ? '' : "#{AttachableFile.options[:separator]}#{append}") + File.extname(filename)
221
+ end
222
+
223
+ # Given a filename (presumably with options embedded in it) parses out
224
+ # the options and returns them as a hash.
225
+ def extract_options_from(filename)
226
+ File.basename(filename, File.extname(filename))[(filename.rindex(AttachableFile.options[:separator]) + 1)..-1]
227
+ end
228
+
229
+ # Creates a new Derivative from the given filename by extracting the
230
+ # options.
231
+ def from_filename(filename)
232
+ Derivative.new(filename, options_from(extract_options_from(filename)))
233
+ end
234
+
235
+ # Gives the path to where derivatives of this file are stored.
236
+ # Derivatives are any files which are based off of this file but are
237
+ # not Attachments themselves (i.e. thumbnails, transcoded copies,
238
+ # etc...)
239
+ def dirname_for(path)
240
+ File.join(File.dirname(path), File.basename(path, File.extname(path)))
241
+ end
242
+ end
243
+
244
+ def initialize(file, options)
245
+ @file = file
246
+ @options = options
247
+ end
248
+
249
+ # The filename of this Derivative with embedded options.
250
+ def filename
251
+ self.class.filename_for(@file.path, options)
252
+ end
253
+
254
+ # The full path and filename to this Derivative.
255
+ def path
256
+ File.join(Derivative.dirname_for(@file.path), filename)
257
+ end
258
+
259
+ # Returns true if the file resulting from this Derivative exists.
260
+ def exists?
261
+ File.exists?(path)
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,31 @@
1
+ module Citrusbyte
2
+ module Milton
3
+ module IsImage
4
+ def self.included(base)
5
+ base.extend IsMethods
6
+ end
7
+
8
+ module IsMethods
9
+ # Stupid little helper for defining something as an image, this used to
10
+ # have more functionality, it's just being kept around because it will
11
+ # probably be useful in the future. For the time being it just allows
12
+ # you to do:
13
+ #
14
+ # class Image < ActiveRecord::Base
15
+ # is_image
16
+ # end
17
+ #
18
+ # rather than:
19
+ #
20
+ # class Image < ActiveRecord::Base
21
+ # is_uploadable
22
+ # is_resizeable
23
+ # end
24
+ def is_image(options={})
25
+ is_uploadable options
26
+ is_resizeable options
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,212 @@
1
+ module Citrusbyte
2
+ module Milton
3
+ module IsResizeable
4
+ def self.included(base)
5
+ base.extend IsMethods
6
+ end
7
+
8
+ module IsMethods
9
+ def is_resizeable(options={})
10
+ raise "is_resizeable requires a content_type column on #{class_name} table" unless column_names.include?("content_type")
11
+
12
+ ensure_attachment_methods options
13
+
14
+ ResizeableFile.options = AttachableFile.options.merge(options)
15
+
16
+ extend Citrusbyte::Milton::IsResizeable::ClassMethods
17
+ include Citrusbyte::Milton::IsResizeable::InstanceMethods
18
+ end
19
+ end
20
+
21
+ module ClassMethods
22
+ end
23
+
24
+ module InstanceMethods
25
+ # Returns the content_type of this attachment, tries to determine it if
26
+ # hasn't been determined yet or is not saved to the database
27
+ def content_type
28
+ return self[:content_type] unless self[:content_type].blank?
29
+ self.content_type = file_reference.mime_type? if file_reference.respond_to?(:mime_type?)
30
+ end
31
+
32
+ # Sets the content type to the given type
33
+ def content_type=(type)
34
+ write_attribute :content_type, type.to_s.strip
35
+ end
36
+
37
+ protected
38
+ def attached_file
39
+ @attached_file ||= ResizeableFile.new(self, filename)
40
+ end
41
+ end
42
+
43
+ # Generic view of an "Image", or rather, something with a width and a
44
+ # height we care about =).
45
+ class Image
46
+ attr_accessor :width
47
+ attr_accessor :height
48
+
49
+ class << self
50
+ # Instantiates a new image from the given path. Uses ImageMagick's
51
+ # identify method to determine the width and height of the image with
52
+ # the given path and returns a new Image with those dimensions.
53
+ #
54
+ # Raises a MissingFileError if the given path could not be identify'd
55
+ # by ImageMagick (resulting in a height and width).
56
+ def from_path(path)
57
+ raise Citrusbyte::Milton::MissingFileError.new("Could not identify #{path} as an image, does the file exist?") unless `identify #{path}` =~ /.*? (\d+)x(\d+)\+\d+\+\d+/
58
+ new($1, $2)
59
+ end
60
+
61
+ # Instantiates a new image from the given geometry string. A geometry
62
+ # string is just something like 50x40. The first number is the width
63
+ # and the second is the height.
64
+ def from_geometry(geometry)
65
+ new(*(geometry.split("x").collect(&:to_i)))
66
+ end
67
+ end
68
+
69
+ # Instantiates a new Image with the given width and height
70
+ def initialize(width=nil, height=nil)
71
+ @width = width.to_i
72
+ @height = height.to_i
73
+ end
74
+
75
+ # Returns the larger dimension of the Image
76
+ def larger_dimension
77
+ width > height ? width : height
78
+ end
79
+
80
+ # Returns true if the Image is wider than it is tall
81
+ def wider?
82
+ width > height
83
+ end
84
+
85
+ # Returns true if the Image is square
86
+ def square?
87
+ width == height
88
+ end
89
+ end
90
+
91
+ class CropCalculator
92
+ attr_reader :original, :target
93
+
94
+ # Initializes a new CropCalculator with the two given Images.
95
+ #
96
+ # A CropCalculator is used to calculate the proper zoom/crop dimensions
97
+ # to be passed to ImageMagick's convert method in order to transform
98
+ # the original Image's dimensions into the target Image's dimensions
99
+ # with sensible zoom/cropping.
100
+ def initialize(original, target)
101
+ @original = original
102
+ @target = target
103
+ end
104
+
105
+ # Returns the geometry string to send to ImageMagick's convert -resize
106
+ # argument -- that is, the dimensions that the original Image would
107
+ # need to be resized to in order to result in the given target Image's
108
+ # dimensions with cropping.
109
+ def resizing_geometry
110
+ case
111
+ when original.wider? then "#{resized_width}x#{target.height}"
112
+ when original.square? && target.wider? then "#{target.width}x#{resized_height}"
113
+ when original.square? && !target.wider? then "#{resized_width}x#{target.height}"
114
+ else "#{target.width}x#{resized_height}"
115
+ end
116
+ end
117
+
118
+ # The geometry string to send to ImageMagick's convert -crop argument.
119
+ def cropping_geometry
120
+ "#{target.width}x#{target.height}+0+0"
121
+ end
122
+
123
+ # The gravity to use for cropping.
124
+ def gravity
125
+ original.wider? ? "center" : "north"
126
+ end
127
+
128
+ private
129
+ def resized_width
130
+ (target.height * original.width / original.height).to_i
131
+ end
132
+
133
+ def resized_height
134
+ (target.width * original.height / original.width).to_i
135
+ end
136
+
137
+ # TODO: this is the old-school cropping w/ coords, need to implement
138
+ # cropping w/ coords using the new system calls
139
+ # def crop_with_coordinates(img, x, y, size, options={})
140
+ # gravity = options[:gravity] || Magick::NorthGravity
141
+ # cropped_img = nil
142
+ # img = Magick::Image.read(img).first unless img.is_a?(Magick::Image)
143
+ # szx, szy = img.columns, img.rows
144
+ # sz = self.class.get_size_from_parameter(size)
145
+ # # logger.info "crop_with_coordinates: img.crop!(#{x}, #{y}, #{sz[0]}, #{sz[1]}, true)"
146
+ # # cropped_img = img.resize!(sz[0], sz[1]) # EEEEEK
147
+ # cropped_img = img.crop!(x, y, szx, szy, true)
148
+ # cropped_img.crop_resized!(sz[0], sz[1], gravity) # EEEEEK
149
+ # self.temp_path = write_to_temp_file(cropped_img.to_blob)
150
+ # end
151
+ end
152
+ end
153
+
154
+ class ResizeableFile < AttachableFile
155
+ class << self
156
+ # Returns the given size as an array of two integers, width first. Can
157
+ # handle:
158
+ #
159
+ # A fixnum argument, which results in a square sizing:
160
+ # parse_size(40) => [40, 40]
161
+ # An Array argument, which is simply returned:
162
+ # parse_size([40, 40]) => [40, 40]
163
+ # A String argument, which is split on 'x' and converted:
164
+ # parse_size("40x40") => [40, 40]
165
+ def parse_size(size)
166
+ case size.class.to_s
167
+ when "Fixnum" then [size.to_i, size.to_i]
168
+ when "Array" then size
169
+ when "String" then size.split('x').collect(&:to_i)
170
+ end
171
+ end
172
+ end
173
+
174
+ def initialize(attachment, filename)
175
+ super attachment, filename
176
+ end
177
+
178
+ def path(options={})
179
+ options = Derivative.options_from(options) if options.is_a?(String)
180
+ return super if options.empty?
181
+
182
+ derivative = Derivative.new(self, options)
183
+ resize(derivative) unless derivative.exists?
184
+ derivative.path
185
+ end
186
+
187
+ protected
188
+ # For speed, any derivatives less than 640-wide are made from a
189
+ # 640-wide version of the image (so you're not generating tiny
190
+ # thumbnails from an 8-megapixel upload)
191
+ def presize_options(derivative)
192
+ image.width > 640 && IsResizeable::Image.from_geometry(derivative.options[:size]).width < 640 ? { :size => '640x' } : {}
193
+ end
194
+
195
+ def image
196
+ @image ||= IsResizeable::Image.from_path(path)
197
+ end
198
+
199
+ def resize(derivative)
200
+ raise "target size must be specified for resizing" unless derivative.options.has_key?(:size)
201
+
202
+ if derivative.options[:crop]
203
+ crop = IsResizeable::CropCalculator.new(image, IsResizeable::Image.from_geometry(derivative.options[:size]))
204
+ size = crop.resizing_geometry
205
+ conversion_options = %Q(-gravity #{crop.gravity} -crop #{crop.cropping_geometry})
206
+ end
207
+
208
+ system %Q(convert -geometry #{size || derivative.options[:size]} #{ResizeableFile.new(@attachment, @attachment.filename).path(presize_options(derivative))} #{conversion_options || ''} +repage "#{derivative.path}")
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,127 @@
1
+ module Citrusbyte
2
+ module Milton
3
+ module IsUploadable
4
+ def self.included(base)
5
+ base.extend IsMethods
6
+ end
7
+
8
+ module IsMethods
9
+ def is_uploadable(options = {})
10
+ raise "Milton's is_uploadable requires a filename column on #{class_name} table" unless column_names.include?("filename")
11
+
12
+ # TODO: implement size validations
13
+ # options[:min_size] ||= 1
14
+ # options[:max_size] ||= 4.megabytes
15
+ # options[:size] ||= (options[:min_size]..options[:max_size])
16
+
17
+ options[:tempfile_path] ||= File.join(RAILS_ROOT, "tmp", "milton")
18
+
19
+ ensure_attachment_methods options
20
+
21
+ UploadableFile.options = AttachableFile.options.merge(options)
22
+
23
+ after_create :save_uploaded_file
24
+
25
+ extend Citrusbyte::Milton::IsUploadable::ClassMethods
26
+ include Citrusbyte::Milton::IsUploadable::InstanceMethods
27
+ end
28
+ end
29
+
30
+ module ClassMethods
31
+ def self.extended(base)
32
+ # Rails 2.1 fix for callbacks
33
+ if defined?(::ActiveSupport::Callbacks)
34
+ base.define_callbacks :before_file_saved, :after_file_saved
35
+ end
36
+ end
37
+
38
+ unless defined?(::ActiveSupport::Callbacks)
39
+ def before_file_saved(&block)
40
+ write_inheritable_array(:before_file_saved, [block])
41
+ end
42
+
43
+ def after_file_saved(&block)
44
+ write_inheritable_array(:after_file_saved, [block])
45
+ end
46
+ end
47
+ end
48
+
49
+ module InstanceMethods
50
+ FILENAME_REGEX = /^[^\/\\]+$/
51
+
52
+ def self.included(base)
53
+ # Nasty rails 2.1 fix for callbacks
54
+ base.define_callbacks *[:before_file_saved, :after_file_saved] if base.respond_to?(:define_callbacks)
55
+ end
56
+
57
+ def file=(file)
58
+ return nil if file.nil? || file.size == 0
59
+ @upload = UploadableFile.new(self, file)
60
+ self.filename = @upload.filename
61
+ self.size = @upload.size if respond_to?(:size=)
62
+ self.content_type = @upload.content_type if respond_to?(:content_type=)
63
+ end
64
+
65
+ protected
66
+ def save_uploaded_file
67
+ unless @upload.saved?
68
+ callback :before_file_saved
69
+ @upload.save
70
+ callback :after_file_saved
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ class UploadableFile < AttachableFile
77
+ attr_reader :content_type, :filename, :size
78
+
79
+ class << self
80
+ def write_to_temp_file(data_or_path)
81
+ FileUtils.mkdir_p(self.options[:tempfile_path]) unless File.exists?(self.options[:tempfile_path])
82
+
83
+ tempfile = Tempfile.new("#{rand(Time.now.to_i)}", self.options[:tempfile_path])
84
+
85
+ if data_or_path.is_a?(StringIO)
86
+ tempfile.binmode
87
+ tempfile.write data
88
+ tempfile.close
89
+ else
90
+ tempfile.close
91
+ FileUtils.cp((data_or_path.respond_to?(:path) ? data_or_path.path : data_or_path), tempfile.path)
92
+ end
93
+
94
+ tempfile
95
+ end
96
+ end
97
+
98
+ def initialize(attachment, data_or_path)
99
+ @has_been_saved = false
100
+ @content_type = data_or_path.content_type
101
+ @filename = AttachableFile.sanitize_filename(data_or_path.original_filename) if respond_to?(:filename)
102
+ @tempfile = UploadableFile.write_to_temp_file(data_or_path)
103
+ @size = File.size(self.temp_path)
104
+
105
+ super attachment, filename
106
+ end
107
+
108
+ def saved?
109
+ @has_been_saved
110
+ end
111
+
112
+ def save
113
+ return true if self.saved?
114
+ recreate_directory
115
+ recreate_derivative_directory
116
+ File.cp(temp_path, path)
117
+ File.chmod(self.class.options[:chmod], path)
118
+ @has_been_saved = true
119
+ end
120
+
121
+ protected
122
+ def temp_path
123
+ @tempfile.respond_to?(:path) ? @tempfile.path : @tempfile.to_s
124
+ end
125
+ end
126
+ end
127
+ end
data/lib/milton.rb ADDED
@@ -0,0 +1,76 @@
1
+ require 'milton/attachment'
2
+ require 'milton/is_image'
3
+ require 'milton/is_resizeable'
4
+ require 'milton/is_uploadable'
5
+
6
+ module Citrusbyte
7
+ module Milton
8
+ # Raised when a file which was expected to exist appears not to exist
9
+ class MissingFileError < StandardError;end;
10
+
11
+ # Some definitions for file semantics used throughout Milton, understanding
12
+ # this will make understanding the code a bit easier and avoid ambiguity:
13
+ #
14
+ # path:
15
+ # the full path to a file or directory in the filesystem
16
+ # /var/log/apache2 or /var/log/apache2/access.log
17
+ # can also be defined as:
18
+ # path == dirname + filename
19
+ # path == dirname + basename + extension
20
+ #
21
+ # dirname:
22
+ # the directory portion of the path to a file or directory, all the chars
23
+ # up to the final /
24
+ # /var/log/apache2 => /var/log
25
+ # /var/log/apache2/ => /var/log/apache2
26
+ # /var/log/apache2/access.log => /var/log/apache2
27
+ #
28
+ # basename:
29
+ # the portion of a filename *with no extension* (ruby's "basename" may or
30
+ # may not have an extension), all the chars after the last / and before
31
+ # the last .
32
+ # /var/log/apache2 => apache2
33
+ # /var/log/apache2/ => nil
34
+ # /var/log/apache2/access.log => access
35
+ # /var/log/apache2/access.2008.log => access.2008
36
+ #
37
+ # extension:
38
+ # the extension portion of a filename w/ no preceding ., all the chars
39
+ # after the final .
40
+ # /var/log/apache2 => nil
41
+ # /var/log/apache2/ => nil
42
+ # /var/log/apache2/access.log => log
43
+ # /var/log/apache2/access.2008.log => log
44
+ #
45
+ # filename:
46
+ # the filename portion of a path w/ extension, all the chars after the
47
+ # final /
48
+ # /var/log/apache2 => apache2
49
+ # /var/log/apache2/ => nil
50
+ # /var/log/apache2/access.log => access.log
51
+ # /var/log/apache2/access.2008.log => access.2008.log
52
+ # can also be defined as:
53
+ # filename == basename + (extension ? '.' + extension : '')
54
+ #
55
+
56
+ def self.included(base)
57
+ base.extend Citrusbyte::Milton::BaseMethods
58
+ base.extend Citrusbyte::Milton::IsUploadable::IsMethods
59
+ base.extend Citrusbyte::Milton::IsResizeable::IsMethods
60
+ base.extend Citrusbyte::Milton::IsImage::IsMethods
61
+ end
62
+
63
+ module BaseMethods
64
+ protected
65
+ # The attachment methods give the core of Milton's file-handling, so
66
+ # various extensions can use this when they're included to make sure
67
+ # that the core attachment methods are available
68
+ def ensure_attachment_methods(options={})
69
+ unless included_modules.include?(Citrusbyte::Milton::Attachment)
70
+ include Citrusbyte::Milton::Attachment
71
+ has_attachment_methods(options)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
Binary file
Binary file
Binary file
@@ -0,0 +1,53 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+ describe Attachment do
4
+ describe "being destroyed" do
5
+ before :each do
6
+ @attachment = Attachment.create :file => upload('milton.jpg')
7
+ @derivative_path = File.dirname(@attachment.path) + '/milton'
8
+ end
9
+
10
+ it "should delete the underlying file from the filesystem" do
11
+ @attachment.destroy
12
+ File.exists?(@attachment.path).should be_false
13
+ end
14
+
15
+ it "should have a derivative path before being destroyed" do
16
+ File.exists?(@derivative_path).should be_true
17
+ end
18
+
19
+ it "should delete the derivative folder from the filesystem" do
20
+ @attachment.destroy
21
+ File.exists?(@derivative_path).should be_false
22
+ end
23
+ end
24
+
25
+ describe "instantiating" do
26
+ before :each do
27
+ @image = Image.new :file => upload('milton.jpg')
28
+ end
29
+
30
+ it "should have a file= method" do
31
+ @image.should respond_to(:file=)
32
+ end
33
+
34
+ it "should set the filename from the uploaded file" do
35
+ @image.filename.should eql('milton.jpg')
36
+ end
37
+
38
+ it "should strip seperator (.) from the filename and replace them with replacement (-)" do
39
+ @image.filename = 'foo.bar.baz.jpg'
40
+ @image.filename.should eql('foo-bar-baz.jpg')
41
+ end
42
+ end
43
+
44
+ describe "path partitioning" do
45
+ before :each do
46
+ @image = Image.new :file => upload('milton.jpg')
47
+ end
48
+
49
+ it "should be stored in a partitioned folder based on its id" do
50
+ @image.path.should =~ /^.*\/#{Citrusbyte::Milton::AttachableFile.partition(@image.id)}\/#{@image.filename}$/
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+ describe Citrusbyte::Milton::IsImage do
4
+ end
@@ -0,0 +1,105 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+ describe Citrusbyte::Milton::IsUploadable do
4
+ class NotUploadable < ActiveRecord::Base
5
+ end
6
+
7
+ describe "filename column" do
8
+ it "should raise an exception if there is no filename column" do
9
+ lambda { NotUploadable.class_eval("is_uploadable") }.should raise_error
10
+ end
11
+
12
+ it 'should not raise an exception if there is a filename column' do
13
+ lambda { Attachment.class_eval("is_uploadable") }.should_not raise_error
14
+ end
15
+ end
16
+
17
+ describe "setting :file_system_path" do
18
+ it "should allow options to be accessed" do
19
+ Citrusbyte::Milton::UploadableFile.options.should be_kind_of(Hash)
20
+ end
21
+
22
+ it "should be able to overwrite file_system_path from is_uploadable call" do
23
+ Attachment.class_eval("is_uploadable(:file_system_path => 'foo')")
24
+ Citrusbyte::Milton::UploadableFile.options[:file_system_path].should eql('foo')
25
+ end
26
+
27
+ after :all do
28
+ Citrusbyte::Milton::UploadableFile.options[:file_system_path] = File.join(File.dirname(__FILE__), '..', 'output')
29
+ end
30
+ end
31
+
32
+ describe "class extensions" do
33
+ describe "class methods" do
34
+ it "should add before_file_saved callback" do
35
+ Attachment.should respond_to(:before_file_saved)
36
+ end
37
+
38
+ it "should add after_file_saved callback" do
39
+ Attachment.should respond_to(:after_file_saved)
40
+ end
41
+ end
42
+ end
43
+
44
+ describe "handling file upload" do
45
+ describe "saving upload" do
46
+ before :each do
47
+ @attachment = Attachment.new :file => upload('milton.jpg')
48
+ end
49
+
50
+ it "should save the upload to the filesystem on save" do
51
+ @attachment.save
52
+ File.exists?(@attachment.path).should be_true
53
+ end
54
+
55
+ it "should have the same filesize as original file when large enough not to be a StringIO" do
56
+ @attachment.save
57
+ File.size(@attachment.path).should be_eql(File.size(File.join(File.dirname(__FILE__), '..', 'fixtures', 'milton.jpg')))
58
+ end
59
+
60
+ it "should have the same filesize as original file when small enough to be a StringIO" do
61
+ File.size(Attachment.create(:file => upload('mini-milton.jpg')).path).should be_eql(File.size(File.join(File.dirname(__FILE__), '..', 'fixtures', 'mini-milton.jpg')))
62
+ end
63
+ end
64
+
65
+ describe "stored full filename" do
66
+ before :each do
67
+ @attachment = Attachment.create! :file => upload('milton.jpg')
68
+ end
69
+
70
+ it "should use set file_system_path" do
71
+ @attachment.path.should =~ /^#{Citrusbyte::Milton::AttachableFile.options[:file_system_path]}.*$/
72
+ end
73
+
74
+ it "should use uploaded filename" do
75
+ @attachment.path.should =~ /^.*#{@attachment.filename}$/
76
+ end
77
+ end
78
+
79
+ describe "sanitizing filename" do
80
+ before :each do
81
+ @attachment = Attachment.create! :file => upload('unsanitary .milton.jpg')
82
+ end
83
+
84
+ it "should strip the space and . and replace them with -" do
85
+ @attachment.path.should =~ /^.*\/unsanitary--milton.jpg$/
86
+ end
87
+
88
+ it "should exist with sanitized filename" do
89
+ File.exists?(@attachment.path).should be_true
90
+ end
91
+ end
92
+
93
+ describe "saving attachment after upload" do
94
+ before :each do
95
+ @attachment = Attachment.create! :file => upload('unsanitary .milton.jpg')
96
+ end
97
+
98
+ it "should save the file again" do
99
+ lambda {
100
+ Attachment.find(@attachment.id).save!
101
+ }.should_not raise_error
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,4 @@
1
+ require File.dirname(__FILE__) + '/../spec_helper'
2
+
3
+ describe Citrusbyte::Milton do
4
+ end
data/spec/schema.rb ADDED
@@ -0,0 +1,10 @@
1
+ ActiveRecord::Schema.define :version => 0 do
2
+ create_table :attachments, :force => true do |t|
3
+ t.string :filename
4
+ end
5
+
6
+ create_table :images, :force => true do |t|
7
+ t.string :filename
8
+ t.string :content_type
9
+ end
10
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1,7 @@
1
+ --colour
2
+ --format
3
+ specdoc
4
+ --loadby
5
+ mtime
6
+ --reverse
7
+ --backtrace
@@ -0,0 +1,26 @@
1
+ require File.dirname(__FILE__) + '/../../../../spec/spec_helper'
2
+
3
+ plugin_spec_dir = File.dirname(__FILE__)
4
+ ActiveRecord::Base.logger = Logger.new(plugin_spec_dir + "/debug.log")
5
+
6
+ load(File.dirname(__FILE__) + '/schema.rb')
7
+
8
+ Spec::Runner.configure do |config|
9
+ config.fixture_path = File.join(File.dirname(__FILE__), 'fixtures/')
10
+
11
+ # remove files created from previous spec run, happens before instead of
12
+ # after so you can view them after you run the specs
13
+ FileUtils.rm_rf(File.join(File.dirname(__FILE__), 'output'))
14
+ end
15
+
16
+ def upload(file, type='image/jpg')
17
+ fixture_file_upload file, type
18
+ end
19
+
20
+ class Attachment < ActiveRecord::Base
21
+ is_uploadable :file_system_path => File.join(File.dirname(__FILE__), 'output')
22
+ end
23
+
24
+ class Image < ActiveRecord::Base
25
+ is_image :file_system_path => File.join(File.dirname(__FILE__), 'output')
26
+ end
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: citrusbyte-milton
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Ben Alavi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-12-27 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: ""
17
+ email: ben.alavi@citrusbyte.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - INSTALL
26
+ - MIT-LICENSE
27
+ - README
28
+ - init.rb
29
+ - lib/milton.rb
30
+ - lib/milton/attachment.rb
31
+ - lib/milton/is_image.rb
32
+ - lib/milton/is_resizeable.rb
33
+ - lib/milton/is_uploadable.rb
34
+ - spec/schema.rb
35
+ - spec/spec.opts
36
+ - spec/spec_helper.rb
37
+ - spec/fixtures/big-milton.jpg
38
+ - spec/fixtures/milton.jpg
39
+ - spec/fixtures/mini-milton.jpg
40
+ - spec/fixtures/unsanitary .milton.jpg
41
+ - spec/milton/attachment_spec.rb
42
+ - spec/milton/is_image_spec.rb
43
+ - spec/milton/is_resizeable_spec.rb
44
+ - spec/milton/is_uploadable_spec.rb
45
+ - spec/milton/milton_spec.rb
46
+ has_rdoc: true
47
+ homepage: http://labs.citrusbyte.com/milton
48
+ post_install_message:
49
+ rdoc_options: []
50
+
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: "0"
58
+ version:
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: "0"
64
+ version:
65
+ requirements: []
66
+
67
+ rubyforge_project:
68
+ rubygems_version: 1.2.0
69
+ signing_key:
70
+ specification_version: 2
71
+ summary: Asset handling Rails plugin that makes few assumptions and is highly extensible.
72
+ test_files: []
73
+