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 +26 -0
- data/MIT-LICENSE +20 -0
- data/README +12 -0
- data/init.rb +2 -0
- data/lib/milton/attachment.rb +265 -0
- data/lib/milton/is_image.rb +31 -0
- data/lib/milton/is_resizeable.rb +212 -0
- data/lib/milton/is_uploadable.rb +127 -0
- data/lib/milton.rb +76 -0
- data/spec/fixtures/big-milton.jpg +0 -0
- data/spec/fixtures/milton.jpg +0 -0
- data/spec/fixtures/mini-milton.jpg +0 -0
- data/spec/fixtures/unsanitary .milton.jpg +0 -0
- data/spec/milton/attachment_spec.rb +53 -0
- data/spec/milton/is_image_spec.rb +4 -0
- data/spec/milton/is_uploadable_spec.rb +105 -0
- data/spec/milton/milton_spec.rb +4 -0
- data/spec/schema.rb +10 -0
- data/spec/spec.opts +7 -0
- data/spec/spec_helper.rb +26 -0
- metadata +73 -0
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,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
|
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,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
|
data/spec/schema.rb
ADDED
data/spec/spec.opts
ADDED
data/spec/spec_helper.rb
ADDED
@@ -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
|
+
|