bulldog 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. data/.gitignore +2 -0
  2. data/DESCRIPTION.txt +3 -0
  3. data/LICENSE +20 -0
  4. data/README.rdoc +18 -0
  5. data/Rakefile +64 -0
  6. data/VERSION +1 -0
  7. data/bulldog.gemspec +157 -0
  8. data/lib/bulldog.rb +95 -0
  9. data/lib/bulldog/attachment.rb +49 -0
  10. data/lib/bulldog/attachment/base.rb +167 -0
  11. data/lib/bulldog/attachment/has_dimensions.rb +94 -0
  12. data/lib/bulldog/attachment/image.rb +63 -0
  13. data/lib/bulldog/attachment/maybe.rb +229 -0
  14. data/lib/bulldog/attachment/none.rb +37 -0
  15. data/lib/bulldog/attachment/pdf.rb +63 -0
  16. data/lib/bulldog/attachment/unknown.rb +11 -0
  17. data/lib/bulldog/attachment/video.rb +143 -0
  18. data/lib/bulldog/error.rb +5 -0
  19. data/lib/bulldog/has_attachment.rb +214 -0
  20. data/lib/bulldog/interpolation.rb +73 -0
  21. data/lib/bulldog/missing_file.rb +12 -0
  22. data/lib/bulldog/processor.rb +5 -0
  23. data/lib/bulldog/processor/argument_tree.rb +116 -0
  24. data/lib/bulldog/processor/base.rb +124 -0
  25. data/lib/bulldog/processor/ffmpeg.rb +172 -0
  26. data/lib/bulldog/processor/image_magick.rb +134 -0
  27. data/lib/bulldog/processor/one_shot.rb +19 -0
  28. data/lib/bulldog/reflection.rb +234 -0
  29. data/lib/bulldog/saved_file.rb +19 -0
  30. data/lib/bulldog/stream.rb +186 -0
  31. data/lib/bulldog/style.rb +38 -0
  32. data/lib/bulldog/style_set.rb +101 -0
  33. data/lib/bulldog/tempfile.rb +28 -0
  34. data/lib/bulldog/util.rb +92 -0
  35. data/lib/bulldog/validations.rb +68 -0
  36. data/lib/bulldog/vector2.rb +18 -0
  37. data/rails/init.rb +9 -0
  38. data/script/console +8 -0
  39. data/spec/data/empty.txt +0 -0
  40. data/spec/data/test.jpg +0 -0
  41. data/spec/data/test.mov +0 -0
  42. data/spec/data/test.pdf +0 -0
  43. data/spec/data/test.png +0 -0
  44. data/spec/data/test2.jpg +0 -0
  45. data/spec/helpers/image_creation.rb +8 -0
  46. data/spec/helpers/temporary_directory.rb +25 -0
  47. data/spec/helpers/temporary_models.rb +76 -0
  48. data/spec/helpers/temporary_values.rb +102 -0
  49. data/spec/helpers/test_upload_files.rb +108 -0
  50. data/spec/helpers/time_travel.rb +20 -0
  51. data/spec/integration/data/test.jpg +0 -0
  52. data/spec/integration/lifecycle_hooks_spec.rb +213 -0
  53. data/spec/integration/processing_image_attachments.rb +72 -0
  54. data/spec/integration/processing_video_attachments_spec.rb +82 -0
  55. data/spec/integration/saving_an_attachment_spec.rb +31 -0
  56. data/spec/matchers/file_operations.rb +159 -0
  57. data/spec/spec_helper.rb +76 -0
  58. data/spec/unit/attachment/base_spec.rb +311 -0
  59. data/spec/unit/attachment/image_spec.rb +128 -0
  60. data/spec/unit/attachment/maybe_spec.rb +126 -0
  61. data/spec/unit/attachment/pdf_spec.rb +137 -0
  62. data/spec/unit/attachment/video_spec.rb +176 -0
  63. data/spec/unit/attachment_spec.rb +61 -0
  64. data/spec/unit/has_attachment_spec.rb +700 -0
  65. data/spec/unit/interpolation_spec.rb +108 -0
  66. data/spec/unit/processor/argument_tree_spec.rb +159 -0
  67. data/spec/unit/processor/ffmpeg_spec.rb +467 -0
  68. data/spec/unit/processor/image_magick_spec.rb +260 -0
  69. data/spec/unit/processor/one_shot_spec.rb +70 -0
  70. data/spec/unit/reflection_spec.rb +338 -0
  71. data/spec/unit/stream_spec.rb +234 -0
  72. data/spec/unit/style_set_spec.rb +44 -0
  73. data/spec/unit/style_spec.rb +51 -0
  74. data/spec/unit/validations_spec.rb +491 -0
  75. data/spec/unit/vector2_spec.rb +27 -0
  76. data/tasks/bulldog_tasks.rake +4 -0
  77. metadata +193 -0
@@ -0,0 +1,11 @@
1
+ module Bulldog
2
+ module Attachment
3
+ #
4
+ # Represents an attachment we don't know anything about.
5
+ #
6
+ # This simply inherits Base.
7
+ #
8
+ class Unknown < Base
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,143 @@
1
+ module Bulldog
2
+ module Attachment
3
+ class Video < Base
4
+ handle :video
5
+
6
+ #
7
+ # Return the width and height of the named style, as a 2-element
8
+ # array.
9
+ #
10
+ # This runs ffmpeg for, and only for, the original style.
11
+ #
12
+ # +style_name+ defaults to the attribute's #default_style.
13
+ #
14
+ def dimensions(style_name)
15
+ video_tracks(style_name).first.dimensions
16
+ end
17
+
18
+ include HasDimensions
19
+
20
+ #
21
+ # Return the duration of the named style, as an
22
+ # ActiveSupport::Duration.
23
+ #
24
+ # This runs ffmpeg for, and only for, the original style.
25
+ #
26
+ # +style_name+ defaults to the attribute's #default_style.
27
+ #
28
+ def duration(style_name)
29
+ # TODO: support styles with different durations
30
+ if stream.missing?
31
+ 0
32
+ else
33
+ examine
34
+ @original_duration
35
+ end
36
+ end
37
+
38
+ #
39
+ # Return the video tracks of the named style, as an array of
40
+ # VideoTrack objects.
41
+ #
42
+ # Each VideoTrack has:
43
+ #
44
+ # * <tt>#dimension</tt> - the dimensions of the video track,
45
+ # [width, height].
46
+ #
47
+ def video_tracks(style_name=nil)
48
+ style_name ||= reflection.default_style
49
+ if style_name == :original
50
+ if stream.missing?
51
+ [VideoTrack.new(:dimensions => [2, 2])]
52
+ else
53
+ examine
54
+ if @original_video_tracks.empty?
55
+ @original_video_tracks << VideoTrack.new(:dimensions => [2, 2])
56
+ end
57
+ @original_video_tracks
58
+ end
59
+ else
60
+ style = reflection.styles[style_name]
61
+ target_dimensions = style[:size].split(/x/).map(&:to_i)
62
+ video_tracks(:original).map do |video_track|
63
+ dimensions = resized_dimensions(dimensions(:original), target_dimensions, style[:filled])
64
+ dimensions.map!{|i| i &= -2} # some codecs require multiples of 2
65
+ VideoTrack.new(:dimensions => dimensions)
66
+ end
67
+ end
68
+ end
69
+
70
+ #
71
+ # Return the video tracks of the named style, as an array of
72
+ # AudioTrack objects.
73
+ #
74
+ # AudioTrack objects do not yet have any useful methods.
75
+ #
76
+ def audio_tracks(style_name)
77
+ examine
78
+ @original_audio_tracks
79
+ end
80
+
81
+ storable_attribute :duration , :per_style => true, :memoize => true
82
+
83
+ protected # ---------------------------------------------------
84
+
85
+ #
86
+ # Return the default processor class to use for this attachment.
87
+ #
88
+ def default_processor_type
89
+ :ffmpeg
90
+ end
91
+
92
+ private # -----------------------------------------------------
93
+
94
+ #
95
+ # Read the original image metadata with ffmpeg.
96
+ #
97
+ def run_examination
98
+ return false if stream.missing?
99
+ output = `ffmpeg -i #{stream.path} 2>&1`
100
+ parse_output(output)
101
+ end
102
+
103
+ def parse_output(output)
104
+ result = false
105
+ @original_duration = 0
106
+ @original_video_tracks = []
107
+ @original_audio_tracks = []
108
+ io = StringIO.new(output)
109
+ while (line = io.gets)
110
+ case line
111
+ when /^Input #0, (.*?), from '(?:.*)':$/
112
+ result = true
113
+ when /^ Duration: (\d+):(\d+):(\d+)\.(\d+)/
114
+ @original_duration = $1.to_i.hours + $2.to_i.minutes + $3.to_i.seconds
115
+ when /Stream #(?:.*?): Video: /
116
+ if $' =~ /(\d+)x(\d+)/
117
+ dimensions = [$1.to_i, $2.to_i]
118
+ end
119
+ @original_video_tracks << VideoTrack.new(:dimensions => dimensions)
120
+ when /Stream #(?:.*?): Audio: (.*?)/
121
+ @original_audio_tracks << AudioTrack.new
122
+ end
123
+ end
124
+ result
125
+ end
126
+
127
+ class Track
128
+ def initialize(attributes={})
129
+ attributes.each do |name, value|
130
+ send("#{name}=", value)
131
+ end
132
+ end
133
+ end
134
+
135
+ class VideoTrack < Track
136
+ attr_accessor :dimensions
137
+ end
138
+
139
+ class AudioTrack < Track
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,5 @@
1
+ module Bulldog
2
+ Error = Class.new(::RuntimeError)
3
+ ConfigurationError = Class.new(Error)
4
+ ProcessingError = Class.new(Error)
5
+ end
@@ -0,0 +1,214 @@
1
+ module Bulldog
2
+ module HasAttachment
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ base.instance_variable_set(:@attachment_reflections, {})
6
+
7
+ # We need to store the attachment changes ourselves, since
8
+ # they're unavailable in an after_save.
9
+ base.before_save :store_original_attachments
10
+ base.after_save :save_attachments
11
+ base.after_save :clear_original_attachments
12
+
13
+ base.before_save :update_attachment_timestamps
14
+ base.after_destroy :destroy_attachments
15
+
16
+ # Force initialization of attachments, as #destroy will freeze
17
+ # the attributes afterwards.
18
+ base.before_destroy :initialize_remaining_attachments
19
+
20
+ %w[validation save create update].each do |event|
21
+ base.send("before_#{event}", "process_attachments_for_before_#{event}")
22
+ base.send("after_#{event}", "process_attachments_for_after_#{event}")
23
+ end
24
+ end
25
+
26
+ def save_attachments
27
+ attachment_reflections.each do |name, reflection|
28
+ original_attachment = @original_attachments[name] and
29
+ original_attachment.destroy
30
+ _attachment_for(name).save
31
+ end
32
+ end
33
+
34
+ def destroy_attachments
35
+ attachment_reflections.each do |name, reflection|
36
+ _attachment_for(name).destroy
37
+ end
38
+ end
39
+
40
+ def update_attachment_timestamps
41
+ attachment_reflections.each do |name, reflection|
42
+ next unless send("#{name}_changed?")
43
+ setter = "#{name}_updated_at="
44
+ if respond_to?(setter)
45
+ send(setter, Time.now)
46
+ end
47
+ end
48
+ end
49
+
50
+ def process_attachment(name, event, *args)
51
+ reflection = attachment_reflections[name] or
52
+ raise ArgumentError, "no such attachment: #{name}"
53
+ _attachment_for(name).process(event, *args)
54
+ end
55
+
56
+ def attachment_reflection_for(name)
57
+ self.class.attachment_reflections[name]
58
+ end
59
+
60
+ private # -------------------------------------------------------
61
+
62
+ # Prefixed with '_', as it would collide with paperclip otherwise.
63
+ def _attachment_for(name)
64
+ read_attribute(name) or
65
+ initialize_attachment(name)
66
+ end
67
+
68
+ def initialize_attachment(name)
69
+ if new_record?
70
+ value = nil
71
+ else
72
+ reflection = attachment_reflection_for(name)
73
+ file_name_column = reflection.column_name_for_stored_attribute(:file_name)
74
+ file_name = file_name_column ? send(file_name_column) : nil
75
+ if file_name_column && file_name.nil?
76
+ value = nil
77
+ else
78
+ template = reflection.path_template
79
+ style = reflection.styles[:original]
80
+ original_path = Interpolation.interpolate(template, self, name, style, :basename => file_name)
81
+ if File.exist?(original_path)
82
+ value = SavedFile.new(original_path, :file_name => file_name)
83
+ else
84
+ if file_name_column
85
+ value = MissingFile.new(:file_name => file_name)
86
+ else
87
+ value = nil
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ attachment = make_attachment_for(name, value)
94
+ write_attribute_without_dirty(name, attachment)
95
+ attachment.read_storable_attributes
96
+ attachment
97
+ end
98
+
99
+ def assign_attachment(name, value)
100
+ old_attachment = _attachment_for(name)
101
+ unless old_attachment.value == value
102
+ old_attachment.clear_stored_attributes
103
+ new_attachment = make_attachment_for(name, value)
104
+ new_attachment.set_stored_attributes
105
+ write_attribute(name, new_attachment)
106
+ end
107
+ end
108
+
109
+ def make_attachment_for(name, value)
110
+ return Attachment.none(self, name) if value.nil?
111
+ stream = Stream.new(value)
112
+ reflection = attachment_reflection_for(name)
113
+ type = reflection.detect_attachment_type(self, stream)
114
+ Attachment.of_type(type, self, name, stream)
115
+ end
116
+
117
+ def store_original_attachments
118
+ @original_attachments = {}
119
+ attachment_reflections.each do |name, reflection|
120
+ if send("#{name}_changed?")
121
+ @original_attachments[name] = send("#{name}_was")
122
+ end
123
+ end
124
+ end
125
+
126
+ def clear_original_attachments
127
+ remove_instance_variable :@original_attachments
128
+ end
129
+
130
+ def process_attachments_for_event(event, *args)
131
+ self.class.attachment_reflections.each do |name, reflection|
132
+ _attachment_for(reflection.name).process(event, *args)
133
+ end
134
+ end
135
+
136
+ def initialize_remaining_attachments
137
+ self.attachment_reflections.each do |name, reflection|
138
+ _attachment_for(name) # force initialization
139
+ end
140
+ end
141
+
142
+ %w[validation save create update].each do |event|
143
+ module_eval <<-EOS
144
+ def process_attachments_for_before_#{event}
145
+ process_attachments_for_event(:before_#{event})
146
+ end
147
+ def process_attachments_for_after_#{event}
148
+ process_attachments_for_event(:after_#{event})
149
+ end
150
+ EOS
151
+ end
152
+
153
+ delegate :attachment_reflections, :to => 'self.class'
154
+
155
+ module ClassMethods
156
+ def attachment_reflections
157
+ @attachment_reflections ||=
158
+ begin
159
+ hash = {}
160
+ superhash = superclass.attachment_reflections
161
+ superhash.map do |name, reflection|
162
+ hash[name] = reflection.clone
163
+ end
164
+ hash
165
+ end
166
+ end
167
+
168
+ #
169
+ # Declare that this model has an attachment.
170
+ #
171
+ # TODO: example that shows all the options.
172
+ #
173
+ def has_attachment(name, &block)
174
+ reflection = attachment_reflections[name] || Reflection.new(self, name)
175
+ reflection.configure(&block)
176
+ attachment_reflections[name] = reflection
177
+ define_attachment_accessors(reflection.name)
178
+ define_attachment_attribute_methods(reflection.name)
179
+ end
180
+
181
+ def define_attachment_accessors(name)
182
+ module_eval <<-EOS, __FILE__, __LINE__
183
+ def #{name}
184
+ _attachment_for(:#{name})
185
+ end
186
+
187
+ def #{name}=(value)
188
+ assign_attachment(:#{name}, value)
189
+ end
190
+
191
+ def #{name}?
192
+ _attachment_for(:#{name}).present?
193
+ end
194
+ EOS
195
+ end
196
+
197
+ def define_attachment_attribute_methods(name)
198
+ # HACK: Without this, methods defined via
199
+ # #attribute_method_suffix (e.g., #ATTACHMENT_changed?) won't
200
+ # be defined unless the attachment is assigned first.
201
+ # ActiveRecord appears to give us no other way without
202
+ # defining an after_initialize, which is slow.
203
+ attribute_method_suffixes.each do |suffix|
204
+ next unless suffix[0] == ?_ # skip =, ?.
205
+ class_eval <<-EOS
206
+ def #{name}#{suffix}(*args, &block)
207
+ attribute#{suffix}('#{name}', *args, &block)
208
+ end
209
+ EOS
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,73 @@
1
+ module Bulldog
2
+ module Interpolation
3
+ Error = Class.new(Bulldog::Error)
4
+
5
+ def self.interpolate(template, record, name, style, overrides={})
6
+ # TODO: would be nice if this wasn't such a special case.
7
+ if overrides[:basename]
8
+ extension = File.extname(overrides[:basename]).sub(/\A./, '')
9
+ overrides[:extension] ||= extension
10
+ end
11
+ template.gsub(/:(?:(\w+)|\{(\w+?)\})/) do
12
+ key = ($1 || $2).to_sym
13
+ if (override = overrides[key])
14
+ override
15
+ elsif @interpolations.key?(key)
16
+ @interpolations[key].call(record, name, style)
17
+ else
18
+ raise Error, "no such interpolation key: #{key}"
19
+ end
20
+ end
21
+ end
22
+
23
+ def self.to_interpolate(key, &substitution)
24
+ @interpolations[key] = substitution
25
+ end
26
+
27
+ def self.reset
28
+ @interpolations = {}
29
+
30
+ to_interpolate :class do |record, name, style|
31
+ record.class.name.underscore.pluralize
32
+ end
33
+
34
+ to_interpolate :id do |record, name, style|
35
+ record.send(record.class.primary_key)
36
+ end
37
+
38
+ to_interpolate :id_partition do |record, name, style|
39
+ id = record.send(record.class.primary_key)
40
+ ("%09d" % id).scan(/\d{3}/).join("/")
41
+ end
42
+
43
+ to_interpolate :attachment do |record, name, style|
44
+ name
45
+ end
46
+
47
+ to_interpolate :style do |record, name, style|
48
+ style.name
49
+ end
50
+
51
+ to_interpolate :basename do |record, name, style|
52
+ reflection = record.attachment_reflection_for(name)
53
+ column_name = reflection.column_name_for_stored_attribute(:file_name) or
54
+ raise Error, ":basename interpolation requires storing the file name - add a column #{name}_file_name or use store_attributes"
55
+ record.send(column_name)
56
+ end
57
+
58
+ to_interpolate :extension do |record, name, style|
59
+ reflection = record.attachment_reflection_for(name)
60
+ column_name = reflection.column_name_for_stored_attribute(:file_name) or
61
+ raise Error, ":extension interpolation requires storing the file name - add a column #{name}_file_name or use store_attributes"
62
+ basename = record.send(column_name) or
63
+ raise Error, ":extension interpolation used when file_name not set - if you need to interpolate the url, pass a :basename override"
64
+ File.extname(basename).sub(/\A\./, '')
65
+ end
66
+ end
67
+
68
+ #
69
+ # Reset the list of interpolation definitions.
70
+ #
71
+ reset
72
+ end
73
+ end