citrusbyte-milton 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+