citrusbyte-milton 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|