bulldog 0.0.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.
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