├── .gitignore ├── CHANGES.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── paperdragon.rb └── paperdragon │ ├── attachment.rb │ ├── file.rb │ ├── file │ └── operations.rb │ ├── metadata.rb │ ├── model.rb │ ├── paperclip.rb │ ├── paperclip │ └── model.rb │ ├── task.rb │ └── version.rb ├── paperdragon.gemspec └── test ├── attachment_test.rb ├── file_test.rb ├── fixtures ├── apotomo.png └── trb.png ├── metadata_test.rb ├── model_test.rb ├── paperclip_uid_test.rb ├── task_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | /public 24 | /dragonfly.log 25 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 0.0.11 2 | 3 | * You can now have any name for attachments in `Model`. This also allows having multiple uploads per model. E.g. the following now works. 4 | 5 | ```ruby 6 | processable :avatar, Attachment 7 | processable :image, Attachment 8 | ``` 9 | 10 | You will need `avatar_meta_data` and `image_meta_data` fields, now. 11 | 12 | Many thanks to @mrbongiolo for implementing this. 13 | 14 | # 0.0.10 15 | 16 | * Require Dragonfly 1.0.12 or above and change internal API accordingly. Thanks @acaron for fixing that! 17 | 18 | # 0.0.9 19 | 20 | * Add `Task#delete!` which allows to delete files in task blocks. 21 | 22 | # 0.0.8 23 | 24 | * Introduce `Model::Writer` and `Model::Reader` in case you don't like `Model#image`'s fuzzy API. 25 | 26 | # 0.0.7 27 | 28 | * `Task#process!` (and the delegated `File#process!`) now delete the original version of an attachment if `process!` is used to replace the latter. -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in paperdragon.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Nick Sutterer 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paperdragon 2 | 3 | _Explicit image processing._ 4 | 5 | ## Summary 6 | 7 | Paperdragon gives you image processing as known from [Paperclip](https://github.com/thoughtbot/paperclip), [CarrierWave](https://github.com/carrierwaveuploader/carrierwave) or [Dragonfly](https://github.com/markevans/dragonfly). It allows uploading, cropping, resizing, watermarking, maintaining different versions of an image, and so on. 8 | 9 | It provides a very explicit DSL: **No magic is happening behind the scenes, paperdragon makes _you_ implement the processing steps.** 10 | 11 | With only a little bit of more code you are fully in control of what gets uploaded where, which image version gets resized when and what gets sent to a background job. 12 | 13 | Paperdragon uses the excellent [Dragonfly](https://github.com/markevans/dragonfly) gem for processing, resizing, storing, etc. 14 | 15 | Paperdragon is database-agnostic, doesn't know anything about ActiveRecord and _does not_ hook into AR's callbacks. 16 | 17 | ## Installation 18 | 19 | Add this line to your application's Gemfile: 20 | 21 | ```ruby 22 | gem 'paperdragon' 23 | ``` 24 | 25 | 26 | ## Example 27 | 28 | This README only documents the public DSL. You're free to use the public API [documented here](# TODO) if you don't like the DSL. 29 | 30 | ### Model 31 | 32 | Paperdragon has only one requirement for the model: It needs to have a column `image_meta_data`. This is a serialised hash where paperdragon saves UIDs for the different image versions. We'll learn about this in a minute. 33 | 34 | ```ruby 35 | class User < ActiveRecord::Base # this could be just anything. 36 | include Paperdragon::Model 37 | 38 | processable :image 39 | 40 | serialize :image_meta_data 41 | end 42 | ``` 43 | 44 | Calling `::processable` advises paperdragon to create a `User#image` reader to the attachment. Nothing else is added to the class. 45 | 46 | 47 | ## Uploading 48 | 49 | Processing and storing an uploaded image is an explicit step - you have to code it! This code usually goes to a separate class or an [Operation in Trailblazer](https://github.com/trailblazer/trailblazer#operation), don't leave it in the controller if you don't have to. 50 | 51 | ```ruby 52 | def create 53 | file = params.delete(:image) 54 | 55 | user = User.create(params) # this is your code. 56 | 57 | # upload code: 58 | user.image(file) do |v| 59 | v.process!(:original) # save the unprocessed. 60 | v.process!(:thumb) { |job| job.thumb!("75x75#") } # resizing. 61 | v.process!(:cropped) { |job| job.thumb!("140x140+20+20") } # cropping. 62 | v.process!(:public) { |job| job.watermark! } # watermark. 63 | end 64 | 65 | user.save 66 | end 67 | ``` 68 | 69 | This is a completely transparent process. 70 | 71 | 1. Calling `#image` usually returns the image attachment. However, passing a `file` to it allows to create different versions of the uploaded image in the block. 72 | 2. `#process!` requires you to pass in a name for that particular image version. It is a convention to call the unprocessed image `:original`. 73 | 3. The `job` object is responsible for creating the final version. This is simply a `Dragonfly::Job` object and gives you [everything that can be done with dragonfly](http://markevans.github.io/dragonfly/imagemagick/). 74 | 4. After the block is run, paperdragon pushes a hash with all the images meta data to the model via `model.image_meta_data=`. 75 | 76 | For a better understanding and to see how simple it is, go and check out the `image_meta_data` field. 77 | 78 | ```ruby 79 | user.image_meta_data #=> {original: {uid: "original-logo.jpg", width: 240, height: 800}, 80 | # thumb: {uid: "thumb-logo.jpg", width: 140, height: 140}, 81 | # ..and so on.. 82 | # } 83 | ``` 84 | 85 | 86 | ## Rendering Images 87 | 88 | After processing, you may want to render those image versions in your app. 89 | 90 | ```ruby 91 | user.image[:thumb].url 92 | ``` 93 | 94 | This is all you need to retrieve the URL/path for a stored image. Use this for your image tags. 95 | 96 | ```haml 97 | = img_tag user.image[:thumb].url 98 | ``` 99 | 100 | Internally, Paperdragon will call `model#image_meta_data` and use this hash to find the address of the image. 101 | 102 | While gems like paperclip often use several fields of the model to compute UIDs (addresses) at run-time, paperdragon does that once and then dumps it to the database. This completely removes the dependency to the model. 103 | 104 | 105 | ## Reprocessing And Cropping 106 | 107 | Once an image has been processed to several versions, you might need to reprocess some of them. As an example, users could re-crop their thumbs. 108 | 109 | ```ruby 110 | def crop 111 | user = User.find(params[:id]) # this is your code. 112 | 113 | # reprocessing code: 114 | cropping = "#{params[:w]}x#{params[:h]}#" 115 | 116 | user.image do |v| 117 | v.reprocess!(:thumb, Time.now) { |job| job.thumb!(cropping) } # re-crop. 118 | end 119 | 120 | user.save 121 | end 122 | ``` 123 | 124 | Only a few things have changed compared to the initial processing. 125 | 126 | 1. We do not pass a file to `#image` anymore. This makes sense as reprocessing will re-use the existing original file. 127 | 2. Note that it's not `#process!` but `#reprocess!` indicating a surprising reprocessing. 128 | 3. As a second argument to `#reprocess!` a fingerprint string is required. To understand what this does, let's inspect `image_meta_data` once again. (The fingerprint feature is optional but extremely helpful.) 129 | 130 | 131 | ```ruby 132 | user.image_meta_data # ..original.. 133 | # thumb: {uid: "thumb-logo-1234567890.jpg", width: 48, height: 48}, 134 | # ..and so on.. 135 | # } 136 | ``` 137 | 138 | See how the file name has changed? Paperdragon will automatically append the fingerprint you pass into `#reprocess!` to the existing version's file name. 139 | 140 | 141 | ## Renaming 142 | 143 | Sometimes you just want to rename files without processing them. For instance, when a new fingerprint for an image is introduced, you want to apply that to all versions. 144 | 145 | ```ruby 146 | fingerprint = Time.now 147 | 148 | user.image do |v| 149 | v.reprocess!(:thumb, fingerprint) { |job| job.thumb!(cropping) } # re-crop. 150 | v.rename!(:original, fingerprint) # just rename it. 151 | end 152 | ``` 153 | 154 | This will re-crop the thumb and _rename_ the original. 155 | 156 | ```ruby 157 | user.image_meta_data #=> {original: {uid: "original-logo-1234567890.jpg", ..}, 158 | # thumb: {uid: "thumb-logo-1234567890.jpg", ..}, 159 | # ..and so on.. 160 | # } 161 | ``` 162 | 163 | 164 | ## Deleting 165 | 166 | While making images is a wonderful thing, sometimes you need to destroy to create. This is why paperdragon gives you a deleting mechanism, too. 167 | 168 | ```ruby 169 | user.image do |v| 170 | v.delete!(:thumb) 171 | end 172 | ``` 173 | 174 | This will also remove the associated metadata from the model. 175 | 176 | You can delete all versions of an attachment by omitting the style. 177 | 178 | ```ruby 179 | user.image do |v| 180 | v.delete! # deletes :original and :thumb. 181 | end 182 | ``` 183 | 184 | 185 | ## Replacing Images 186 | 187 | It's ok to run `#process!` again on a model with an existing attachment. 188 | 189 | ```ruby 190 | user.image_meta_data #=> {original: {uid: "original-logo-1234567890.jpg", ..}, 191 | ``` 192 | 193 | Processing here will overwrite the existing attachment. 194 | 195 | ```ruby 196 | user.image(new_file) do |v| 197 | v.process!(:original) # overwrites the existing, deletes old. 198 | end 199 | ``` 200 | 201 | ```ruby 202 | user.image_meta_data #=> {original: {uid: "original-new-file01.jpg", ..}, 203 | ``` 204 | 205 | While replacing the old with the new upload, the old file also gets deleted. 206 | 207 | 208 | ## Fingerprints 209 | 210 | Paperdragon comes with a very simple built-in file naming. 211 | 212 | Computing a file UID (or, name, or path) happens in the `Attachment` class. You need to provide your own implementation if you want to change things. 213 | 214 | ```ruby 215 | class User < ActiveRecord::Base 216 | include Paperdragon::Model 217 | 218 | class Attachment < Paperdragon::Attachment 219 | def build_uid(style, file) 220 | "/path/to/#{style}/#{obfuscator}/#{file.name}" 221 | end 222 | 223 | def obfuscator 224 | Obfuscator.call # this is your code. 225 | end 226 | end 227 | 228 | processable :image, Attachment # use the class you just wrote. 229 | ``` 230 | 231 | The `Attachment#build_uid` method is invoked when processing images. 232 | 233 | ```ruby 234 | user.image(file) do |v| 235 | v.process!(:thumb) { |job| job.thumb!("75x75#") } 236 | end 237 | ``` 238 | 239 | To create the image UID, _your_ attachment is now being used. 240 | 241 | ```ruby 242 | user.image_meta_data # ..original.. 243 | # thumb: {uid: "/path/to/thumb/ac97dnxid8/logo.jpg", ..}, 244 | # ..and so on.. 245 | # } 246 | ``` 247 | 248 | What a beautiful, cryptic and mysterious filename you just created! 249 | 250 | The same pattern applies for _re-building_ UIDs when reprocessing images. 251 | 252 | ```ruby 253 | class Attachment < Paperdragon::Attachment 254 | # def build_uid and the other code from above.. 255 | 256 | def rebuild_uid(file, fingerprint) 257 | file.uid.sub("logo.png", "logo-#{fingerprint}.png") 258 | end 259 | end 260 | ``` 261 | 262 | This code is used to re-compute UIDs in `#reprocess!`. 263 | 264 | That example is stupid, I know, but it shows how you have access to the `Paperdragon::File` instance that represents the existing version of the reprocessed image. 265 | 266 | 267 | ## Local Rails Configuration 268 | 269 | Configuration of paperdragon completely relies on [configuring dragonfly](http://markevans.github.io/dragonfly/configuration/). As an example, for a Rails app with a local file storage, I use the following configuration in `config/initializers/paperdragon.rb`. 270 | 271 | ```ruby 272 | Dragonfly.app.configure do 273 | plugin :imagemagick 274 | 275 | datastore :file, 276 | :server_root => 'public', 277 | :root_path => 'public/images' 278 | end 279 | ``` 280 | 281 | This would result in image UIDs being prefixed accordingly. 282 | 283 | ```ruby 284 | user.image[:thumb].url #=> "/images/logo-1234567890.png" 285 | ``` 286 | 287 | 288 | ## S3 289 | 290 | As [dragonfly allows S3](https://github.com/markevans/dragonfly-s3_data_store), using the amazon cloud service is straight-forward. 291 | 292 | All you need to do is configuring your bucket. The API for paperdragon remains unchanged. 293 | 294 | ```ruby 295 | require 'dragonfly/s3_data_store' 296 | 297 | Dragonfly.app.configure do 298 | datastore :s3, 299 | bucket_name: 'my-bucket', 300 | access_key_id: 'blahblahblah', 301 | secret_access_key: 'blublublublu' 302 | end 303 | ``` 304 | 305 | Images will be stored "in the cloud" when using `#process!`, renaming, deleting and re-processing do the same! 306 | 307 | 308 | ## Background Processing 309 | 310 | The explicit design of paperdragon makes it incredibly simple to move all or certain processing steps to background jobs. 311 | 312 | ```ruby 313 | class Image::Processor 314 | include Sidekiq::Worker 315 | 316 | def perform(params) 317 | user = User.find(params[:id]) 318 | 319 | user.image(params[:file]) do |v| 320 | v.process!(:original) 321 | end 322 | end 323 | end 324 | ``` 325 | 326 | Documentation how to use Sidekiq and paperdragon in Traiblazer will be added shortly. 327 | 328 | ## Validations 329 | 330 | Validating uploads are discussed in the _Callbacks_ chapter of the [Trailblazer 331 | book](https://leanpub.com/trailblazer). We use [file_validators](https://github.com/musaffa/file_validators). 332 | 333 | ## Model: Reader and Writer 334 | 335 | If you don't like `Paperdragon::Model#image`'s fuzzy API you can use `Reader` and `Writer`. 336 | 337 | The `Writer` will usually be mixed into a form. 338 | 339 | ```ruby 340 | class AlbumForm < Reform::Form 341 | extend Paperdragon::Model::Writer 342 | processable_writer :image 343 | ``` 344 | 345 | This provides the `image!` writer for processing a file. 346 | 347 | ```ruby 348 | form.image!(file) { |v| v.thumb!("64x64") } 349 | ``` 350 | 351 | Likewise, `Reader` will usually be used in cells or decorators. 352 | 353 | ```ruby 354 | class AlbumCell < Cell::ViewModel 355 | extend Paperdragon::Model::Reader 356 | processable_reader :image 357 | property :image_meta_data 358 | ``` 359 | 360 | You can now access the `Attachment` via `image`. 361 | 362 | ```ruby 363 | cell.image[:thumb].url 364 | ``` 365 | 366 | 367 | ## Paperclip compatibility 368 | 369 | I wrote paperdragon as an explicit alternative to paperclip. In the process of doing so, I step-wise replaced upload code, but left the rendering code unchanged. Paperclip has a slightly different API for rendering. 370 | 371 | ```ruby 372 | user.image.url(:thumb) 373 | ``` 374 | 375 | Allowing your paperdragon-backed model to expose this API is piece-of-cake. 376 | 377 | ```ruby 378 | class User < ActiveRecord::Base 379 | include Paperdragon::Paperclip::Model 380 | ``` 381 | 382 | This will allow both APIs for a smooth transition. 383 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task :default => [:test] 5 | Rake::TestTask.new(:test) do |test| 6 | test.libs << 'test' 7 | test.test_files = FileList['test/**/*_test.rb'] 8 | test.verbose = true 9 | end -------------------------------------------------------------------------------- /lib/paperdragon.rb: -------------------------------------------------------------------------------- 1 | require "paperdragon/version" 2 | require 'dragonfly' 3 | 4 | module Paperdragon 5 | class MissingUploadError < RuntimeError 6 | end 7 | end 8 | 9 | require 'paperdragon/file' 10 | require 'paperdragon/metadata' 11 | require 'paperdragon/attachment' 12 | require 'paperdragon/task' 13 | require 'paperdragon/model' -------------------------------------------------------------------------------- /lib/paperdragon/attachment.rb: -------------------------------------------------------------------------------- 1 | require 'uber/inheritable_attr' 2 | 3 | module Paperdragon 4 | # Override #build_uid / #rebuild_uid. 5 | # Note that we only encode the UID when computing it the first time. It is then stored encoded 6 | # and no escaping happens at any time after that. 7 | # You may use options. 8 | # only saves metadata, does not know about model. 9 | # Attachment is a builder for File and knows about metadata. It is responsible for creating UID if metadata is empty. 10 | class Attachment 11 | extend Uber::InheritableAttr 12 | inheritable_attr :file_class #, strategy: ->{ tap{} } 13 | self.file_class = ::Paperdragon::File # default value. # !!! be careful, this gets cloned in subclasses and thereby becomes a subclass of PD:File. 14 | 15 | 16 | module InstanceMethods 17 | def initialize(metadata, options={}) 18 | @metadata = Metadata[metadata] 19 | @options = options # to be used in #(re)build_uid for your convenience. # DISCUSS: we pass in the model here - is that what we want? 20 | end 21 | attr_reader :metadata # TODO: test me. 22 | 23 | def [](style, file=nil) # not sure if i like passing file here, consider this method signature semi-public. 24 | file_metadata = @metadata[style] 25 | 26 | uid = file_metadata[:uid] || uid_from(style, file) 27 | self.class.file_class.new(uid, file_metadata) 28 | end 29 | 30 | # DSL method providing the task instance. 31 | # When called with block, it yields the task and returns the generated metadata. 32 | def task(upload=nil, &block) 33 | task = Task.new(self, upload, &block) 34 | 35 | return task unless block_given? 36 | task.metadata_hash 37 | end 38 | 39 | # Computes UID when File doesn't have one, yet. Called in #initialize. 40 | def uid_from(*args) 41 | build_uid(*args) 42 | end 43 | 44 | # Per default, paperdragon tries to increment the fingerprint in the file name, identified by 45 | # the pattern /-\d{10}/ just before the filename extension (.png). 46 | def rebuild_uid(file, fingerprint=nil) # the signature of this method is to be considered semi-private. 47 | ext = ::File.extname(file.uid) 48 | name = ::File.basename(file.uid, ext) 49 | 50 | if fingerprint and matches = name.match(/-(\d{10})$/) 51 | return file.uid.sub(matches[1], fingerprint.to_s) 52 | end 53 | 54 | file.uid.sub(name, "#{name}-#{fingerprint}") 55 | end 56 | 57 | def exists? # should be #uploaded? or #stored? 58 | # not sure if i like that kind of state here, so consider method semi-public. 59 | @metadata.populated? 60 | end 61 | 62 | private 63 | attr_reader :options 64 | 65 | def build_uid(style, file) 66 | # can we use Dragonfly's API here? 67 | "#{style}-#{Dragonfly::TempObject.new(file).name}" 68 | end 69 | end 70 | 71 | 72 | module SanitizeUid 73 | def uid_from(*args) 74 | sanitize(super) 75 | end 76 | 77 | def sanitize(uid) 78 | #URI::encode(uid) # this is wrong, we can't send %21 in path to S3! 79 | uid.gsub(/(#|\?)/, "_") # escape # and ?, only. 80 | end 81 | end 82 | 83 | 84 | include InstanceMethods 85 | include SanitizeUid # overrides #uid_from. 86 | end 87 | end -------------------------------------------------------------------------------- /lib/paperdragon/file.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | # A physical file with a UID. 3 | # 4 | # Files are usually created via an Attachment instance. You can call processing 5 | # methods on file instances. This will save the file and return the new metadata 6 | # hash. 7 | # 8 | # file = Paperdragon::File.new(uid) 9 | # 10 | # metadata = file.reprocess! do |job| 11 | # job.thumb!("16x16") 12 | # end 13 | class File 14 | def initialize(uid, options={}) 15 | @uid = uid 16 | @options = options 17 | @data = nil # DISCUSS: do we need that here? 18 | end 19 | 20 | attr_reader :uid, :options 21 | alias_method :metadata, :options 22 | 23 | def url(opts={}) 24 | Dragonfly.app.remote_url_for(uid, opts) 25 | end 26 | 27 | def data 28 | puts "........................FETCH (data): #{uid}, #{@data ? :cached : (:fetching)}" 29 | @data ||= Dragonfly.app.fetch(uid).data 30 | end 31 | 32 | # attr_reader :meta_data 33 | 34 | require 'paperdragon/file/operations' 35 | include Process 36 | include Delete 37 | include Reprocess 38 | include Rename 39 | 40 | 41 | private 42 | # replaces the UID. 43 | def uid!(new_uid) 44 | @uid = new_uid 45 | end 46 | 47 | # Override if you want to include/exclude properties in this file metadata. 48 | def default_metadata_for(job) 49 | {:width => job.width, :height => job.height, :uid => uid}#, :content_type => job.mime_type} 50 | end 51 | 52 | def metadata_for(job, additional={}) 53 | default_metadata_for(job).merge(additional) 54 | end 55 | end 56 | end -------------------------------------------------------------------------------- /lib/paperdragon/file/operations.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | class File 3 | # DISCUSS: allow the metadata passing here or not? 4 | module Process 5 | def process!(file, new_uid=nil, metadata={}) 6 | job = Dragonfly.app.new_job(file) 7 | 8 | yield job if block_given? 9 | 10 | old_uid = uid 11 | uid!(new_uid) if new_uid # set new uid if this is a replace. 12 | 13 | upload!(job, old_uid, new_uid, metadata) 14 | end 15 | 16 | private 17 | # Upload file, delete old file if there is one. 18 | def upload!(job, old_uid, new_uid, metadata) 19 | puts "........................STORE (process): #{uid}" 20 | job.store(path: uid, :headers => {'x-amz-acl' => 'public-read', "Content-Type" => "image/jpeg"}) 21 | 22 | if new_uid # new uid means delete old one. 23 | puts "........................DELETE (reprocess): #{old_uid}" 24 | Dragonfly.app.destroy(old_uid) 25 | end 26 | 27 | @data = nil 28 | metadata_for(job, metadata) 29 | end 30 | end 31 | 32 | 33 | module Delete 34 | def delete! 35 | puts "........................DELETE (delete): #{uid}" 36 | Dragonfly.app.destroy(uid) 37 | end 38 | end 39 | 40 | 41 | module Reprocess 42 | def reprocess!(new_uid, original, metadata={}) 43 | job = Dragonfly.app.new_job(original.data) # inheritance here somehow? 44 | 45 | yield job if block_given? 46 | 47 | old_uid = uid 48 | uid!(new_uid) # new UID is already computed and set. 49 | 50 | upload!(job, old_uid, new_uid, metadata) 51 | end 52 | end 53 | 54 | 55 | module Rename 56 | def rename!(fingerprint, metadata={}) # fixme: we are currently ignoring the custom metadata. 57 | old_uid = uid 58 | uid!(fingerprint) 59 | 60 | puts "........................MV: 61 | #{old_uid} 62 | #{uid}" 63 | # dragonfly_s3 = Dragonfly.app.datastore 64 | # Dragonfly.app.datastore.storage.copy_object(dragonfly_s3.bucket_name, old_uid, dragonfly_s3.bucket_name, uid, {'x-amz-acl' => 'public-read', "Content-Type" => "image/jpeg"}) 65 | yield old_uid, uid 66 | 67 | puts "........................DELETE: #{old_uid}" 68 | Dragonfly.app.destroy(old_uid) 69 | 70 | 71 | self.metadata.merge(:uid => uid) # usually, metadata is already set to the old metadata when File was created via Attachment. 72 | end 73 | end 74 | end 75 | end -------------------------------------------------------------------------------- /lib/paperdragon/metadata.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | # 2-level meta data hash for a file. Returns empty string if not found. 3 | # Metadata.new(nil)[:original][:width] => "" 4 | # Holds metadata for an attachment. This is a hash keyed by versions, e.g. +:original+, 5 | # +:thumb+, and so on. 6 | class Metadata < Hash 7 | def self.[](hash) # allow Metadata[nil] 8 | super hash || {} 9 | end 10 | 11 | def [](name) 12 | super || {} 13 | end 14 | 15 | def populated? 16 | size > 0 17 | end 18 | 19 | # Consider this semi-public. This is used the make the metadata hash serialisable (as a plain hash). 20 | def to_hash 21 | Hash[self] 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /lib/paperdragon/model.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | # Fuzzy API: gives you #image that can do both upload and rendering. 3 | module Model 4 | def self.included(base) 5 | base.extend ClassMethods 6 | end 7 | 8 | module ClassMethods 9 | def processable(name, attachment_class=Attachment) 10 | include attachment_accessor_for(name, attachment_class) 11 | end 12 | 13 | private 14 | # Creates Avatar#image that returns a Paperdragon::File instance. 15 | def attachment_accessor_for(name, attachment_class) 16 | mod = Module.new do # TODO: abstract that into Uber, we use it everywhere. 17 | define_method name do |file=nil, options={}, &block| 18 | attachment = attachment_class.new(public_send("#{name}_meta_data"), 19 | options.merge(model: self)) 20 | 21 | return attachment unless file or block 22 | 23 | # run the task block and save the returned new metadata in the model. 24 | self.public_send("#{name}_meta_data=", attachment.task(*[file], &block)) 25 | end 26 | end 27 | end 28 | end 29 | 30 | 31 | # class Album 32 | # extend Paperdragon::Model::Writer 33 | # processable_writer :image 34 | # 35 | # Provides Album#image!(file) { |v| v.thumb!("64x64") } 36 | module Writer 37 | def processable_writer(name, attachment_class=Attachment) 38 | mod = Module.new do # TODO: abstract that into Uber, we use it everywhere. 39 | define_method "#{name}!" do |file=nil, options={}, &block| 40 | attachment = attachment_class.new(public_send("#{name}_meta_data"), 41 | options.merge(model: self)) 42 | 43 | # run the task block and save the returned new metadata in the model. 44 | self.public_send("#{name}_meta_data=", attachment.task(*[file], &block)) 45 | end 46 | end 47 | include mod 48 | end 49 | end # Writer. 50 | 51 | 52 | # class Album 53 | # extend Paperdragon::Model::Reader 54 | # processable_reader :image 55 | # 56 | # Provides Album#image #=> Attachment. 57 | module Reader 58 | def processable_reader(name, attachment_class=Attachment) 59 | define_method name do 60 | attachment_class.new(public_send("#{name}_meta_data"), model: self) 61 | end 62 | end 63 | end 64 | end 65 | end -------------------------------------------------------------------------------- /lib/paperdragon/paperclip.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | class Paperclip 3 | 4 | # Compute a UID to be compatible with paperclip. This class is meant to be subclassed so you can write 5 | # your specific file path. 6 | # Immutable 7 | class Uid 8 | def self.from(options) 9 | new(options).call 10 | end 11 | 12 | # "/system/:class/:attachment/:id_partition/:style/:filename" 13 | def initialize(options) 14 | @class_name = options[:class_name] 15 | @attachment = options[:attachment] 16 | @id = options[:id] 17 | @style = options[:style] 18 | @updated_at = options[:updated_at] 19 | @file_name = options[:file_name] 20 | @hash_secret = options[:hash_secret] 21 | @fingerprint = options[:fingerprint] # not used in default. 22 | end 23 | 24 | def call 25 | # default: 26 | # system/:class/:attachment/:id_partition/:style/:filename 27 | "#{root}/#{class_name}/#{attachment}/#{id_partition}/#{hash}/#{style}/#{file_name}" 28 | end 29 | 30 | private 31 | attr_reader :class_name, :attachment, :id, :style, :file_name, :hash_secret, :updated_at, :fingerprint 32 | 33 | def root 34 | "system" 35 | end 36 | 37 | def id_partition 38 | IdPartition.call(id) 39 | end 40 | 41 | def hash 42 | HashKey.call(hash_secret, class_name, attachment, id, style, updated_at) 43 | end 44 | 45 | 46 | class IdPartition 47 | def self.call(id) 48 | ("%09d" % id).scan(/\d{3}/).join("/") # FIXME: only works with integers. 49 | end 50 | end 51 | 52 | 53 | # ":class/:attachment/:id/:style/:updated_at" 54 | class HashKey 55 | require 'openssl' unless defined?(OpenSSL) 56 | 57 | # cover_girls/images/4841/thumb/1402617353 58 | def self.call(secret, class_name, attachment, id, style, updated_at) 59 | data = "#{class_name}/#{attachment}/#{id}/#{style}/#{updated_at}" 60 | # puts "[Paperdragon] HashKey <--------------------- #{data}" 61 | OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, secret, data) 62 | end 63 | end 64 | end 65 | end 66 | end 67 | 68 | require 'paperdragon/paperclip/model' -------------------------------------------------------------------------------- /lib/paperdragon/paperclip/model.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | class Paperclip 3 | # DISCUSS: I want to remove this module. 4 | module Model 5 | def self.included(base) 6 | base.extend ClassMethods 7 | end 8 | 9 | module ClassMethods 10 | def processable(name, attachment_class) 11 | # this overrides #image (or whatever the name is) from Paperclip::Model::processable. 12 | # This allows using both paperclip's `image.url(:thumb)` and the new paperdragon style 13 | # `image(:thumb).url`. 14 | mod = Module.new do # TODO: merge with attachment_accessor_for. 15 | define_method name do # e.g. Avatar#image 16 | Proxy.new(name, self, attachment_class) # provide paperclip DSL. 17 | end 18 | end 19 | include mod 20 | end 21 | end 22 | 23 | 24 | # Needed to expose Paperclip's DSL, like avatar.image.url(:thumb). 25 | class Proxy 26 | def initialize(name, model, attachment_class) 27 | @attachment = attachment_class.new(model.public_send("#{name}_meta_data"), {:model => model}) 28 | end 29 | 30 | def url(style) 31 | @attachment[style].url # Avatar::Photo.new(avatar, :thumb).url 32 | end 33 | 34 | def method_missing(name, *args, &block) 35 | @attachment.send(name, *args, &block) 36 | end 37 | end 38 | end 39 | end 40 | end -------------------------------------------------------------------------------- /lib/paperdragon/task.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | # Gives a simple API for processing multiple versions of a single attachment. 3 | # 4 | # Each processing method will return the updated metadata hash. You still have 5 | # to save that hash back to the model. 6 | class Task 7 | def initialize(attachment, upload=nil) 8 | @attachment = attachment 9 | @upload = upload 10 | @metadata = attachment.metadata.dup # DISCUSS: keep this dependency? 11 | 12 | yield self if block_given? 13 | end 14 | 15 | attr_reader :metadata 16 | def metadata_hash # semi-private, might be removed. 17 | metadata.to_hash 18 | end 19 | 20 | # process!(style, [*args,] &block) : 21 | # version = CoverGirl::Photo.new(@model, style, *args) 22 | # metadata = version.process!(upload, &block) 23 | # merge! {style => metadata} 24 | def process!(style, &block) 25 | version = file(style, upload) 26 | new_uid = new_uid_for(style, version) # new uid when overwriting existing attachment. 27 | 28 | @metadata.merge!(style => version.process!(upload, new_uid, &block)) 29 | end 30 | 31 | # fingerprint optional => filename is gonna remain the same 32 | # original nil => use [:original] 33 | def reprocess!(style, fingerprint=nil, original=nil, &block) 34 | @original ||= file(:original) # this is cached per task instance. 35 | version = file(style) 36 | new_uid = @attachment.rebuild_uid(version, fingerprint) 37 | 38 | @metadata.merge!(style => version.reprocess!(new_uid, @original, &block)) 39 | end 40 | 41 | def rename!(style, fingerprint, &block) 42 | version = file(style) 43 | new_uid = @attachment.rebuild_uid(version, fingerprint) 44 | 45 | @metadata.merge!(style => version.rename!(new_uid, &block)) 46 | end 47 | 48 | def delete!(*styles) 49 | styles = @attachment.metadata.keys if styles.size == 0 50 | 51 | styles.each do |style| 52 | file(style).delete! 53 | @metadata.delete(style) 54 | end 55 | 56 | @metadata 57 | end 58 | 59 | private 60 | def file(style, upload=nil) 61 | @attachment[style, upload] 62 | end 63 | 64 | def upload 65 | @upload or raise MissingUploadError.new("You called #process! but didn't pass an uploaded file to Attachment#task.") 66 | end 67 | 68 | # Returns new UID for new file when overriding an existing attachment with #process!. 69 | def new_uid_for(style, version) 70 | # check if UID is present in existing metadata. 71 | @attachment.metadata[style][:uid] ? @attachment.uid_from(style, upload) : nil # DISCUSS: move to Attachment? 72 | end 73 | end 74 | end -------------------------------------------------------------------------------- /lib/paperdragon/version.rb: -------------------------------------------------------------------------------- 1 | module Paperdragon 2 | VERSION = "0.0.11" 3 | end 4 | -------------------------------------------------------------------------------- /paperdragon.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'paperdragon/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "paperdragon" 8 | spec.version = Paperdragon::VERSION 9 | spec.authors = ["Nick Sutterer"] 10 | spec.email = ["apotonick@gmail.com"] 11 | spec.summary = %q{Explicit image processing based on Dragonfly with Paperclip compatibility.} 12 | spec.description = %q{Explicit image processing based on Dragonfly with Paperclip compatibility.} 13 | spec.homepage = "" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "dragonfly", "~> 1.0.12" 22 | spec.add_dependency "uber" 23 | 24 | spec.add_development_dependency "bundler", "~> 1.6" 25 | spec.add_development_dependency "rake" 26 | spec.add_development_dependency "minitest" 27 | end 28 | -------------------------------------------------------------------------------- /test/attachment_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AttachmentSpec < MiniTest::Spec 4 | class Attachment < Paperdragon::Attachment 5 | private 6 | def uid_from(style, file) 7 | "/uid/#{style}" 8 | end 9 | end 10 | 11 | describe "existing" do 12 | subject { Attachment.new({:original => {:uid=>"/uid/1234.jpg", :width => 99}}) } 13 | 14 | it { subject[:original].uid.must_equal "/uid/1234.jpg" } 15 | it { subject[:original].options.must_equal({:uid=>"/uid/1234.jpg", :width => 99}) } 16 | it { subject.exists?.must_equal true } 17 | end 18 | 19 | describe "new" do 20 | subject { Attachment.new(nil) } 21 | 22 | it { subject[:original].uid.must_equal "/uid/original" } 23 | it { subject[:original].options.must_equal({}) } 24 | it { subject.exists?.must_equal false } 25 | end 26 | 27 | describe "new with empty metadata hash" do 28 | subject { Attachment.new({}) } 29 | 30 | it { subject[:original].uid.must_equal "/uid/original" } 31 | it { subject[:original].options.must_equal({}) } 32 | it { subject.exists?.must_equal false } 33 | end 34 | 35 | # #metadata 36 | it { Attachment.new({}).metadata.to_hash.must_equal( {}) } 37 | 38 | 39 | # test passing options into Attachment and use that in #build_uid. 40 | class AttachmentUsingOptions < Paperdragon::Attachment 41 | private 42 | def build_uid(style, file) 43 | "uid/#{style}/#{options[:filename]}" 44 | end 45 | end 46 | 47 | # use in new --> build_uid. 48 | it { AttachmentUsingOptions.new(nil, {:filename => "apotomo.png"})[:original].uid.must_equal "uid/original/apotomo.png" } 49 | 50 | 51 | # test using custom File class in Attachment. 52 | class OverridingAttachment < Paperdragon::Attachment 53 | class File < Paperdragon::File 54 | def uid 55 | "from/file" 56 | end 57 | end 58 | self.file_class= File 59 | end 60 | 61 | it { OverridingAttachment.new(nil)[:original, Pathname.new("not-considered.JPEG")].uid.must_equal "from/file" } 62 | 63 | 64 | # test UID sanitising. this happens only when computing the UID with a new attachment! 65 | describe "insane filename" do 66 | it { AttachmentUsingOptions.new(nil, {:filename => "(use)? apotomo #1#.png"})[:original].uid.must_equal "uid/original/(use)_ apotomo _1_.png" } 67 | end 68 | end 69 | 70 | 71 | class AttachmentModelSpec < MiniTest::Spec 72 | class Attachment < Paperdragon::Attachment 73 | private 74 | def build_uid(style, file) 75 | "#{options[:model].class}/uid/#{style}/#{options[:filename]}" 76 | end 77 | end 78 | 79 | describe "existing" do 80 | let (:existing) { OpenStruct.new(:image_meta_data => {:original => {:uid=>"/uid/1234.jpg"}}) } 81 | subject { Attachment.new(existing.image_meta_data, :model => existing) } 82 | 83 | it { subject[:original].uid.must_equal "/uid/1234.jpg" } # notice that #uid_from is not called. 84 | end 85 | 86 | describe "new" do 87 | subject { Attachment.new(nil, :filename => "apotomo.png", :model => OpenStruct.new) } # you can pass options into Attachment::new that may be used in #build_uid 88 | 89 | it { subject[:original].uid.must_equal "OpenStruct/uid/original/apotomo.png" } 90 | end 91 | end -------------------------------------------------------------------------------- /test/file_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | Dragonfly.app.configure do 4 | plugin :imagemagick 5 | 6 | #url_host 'http://some.domain.com:4000' 7 | 8 | datastore :file, 9 | :server_root => 'public', 10 | :root_path => 'public/paperdragon' 11 | end 12 | 13 | class PaperdragonFileTest < MiniTest::Spec 14 | it { Paperdragon::File.new("123").uid.must_equal "123" } 15 | it { Paperdragon::File.new("123").url.must_equal "/paperdragon/123" } # FIXME: how to add host? 16 | it { Paperdragon::File.new("123") } 17 | 18 | describe "#metadata" do 19 | it { Paperdragon::File.new("123").metadata.must_equal({}) } 20 | it { Paperdragon::File.new("123", :width => 16).metadata.must_equal({:width => 16}) } 21 | end 22 | 23 | describe "#data" do 24 | it do 25 | Paperdragon::File.new(uid).process!(logo) 26 | Paperdragon::File.new(uid).data.size.must_equal 9632 27 | end 28 | end 29 | 30 | let (:logo) { Pathname("test/fixtures/apotomo.png") } 31 | 32 | # process! saves file 33 | # TODO: remote storage, server root, etc. 34 | let (:uid) { generate_uid } 35 | 36 | 37 | describe "#process!" do 38 | let (:file) { file = Paperdragon::File.new(uid) } 39 | 40 | it do 41 | metadata = file.process!(logo) 42 | 43 | metadata.must_equal({:width=>216, :height=>63, :uid=>uid}) 44 | exists?(uid).must_equal true 45 | end 46 | 47 | # block 48 | it do 49 | # puts file.data.size # 9632 bytes 50 | file.process!(logo) do |job| 51 | job.thumb!("16x16") 52 | end 53 | 54 | assert file.data.size < 500 # smaller after thumb! 55 | end 56 | 57 | # additional metadata 58 | it do 59 | file.process!(logo, nil, :cropping => "16x16") do |job| 60 | job.thumb!("16x16") 61 | end.must_equal({:width=>16, :height=>5, :uid=>uid, :cropping=>"16x16"}) 62 | end 63 | end 64 | 65 | 66 | describe "#delete!" do 67 | it do 68 | file = Paperdragon::File.new(uid) 69 | file.process!(logo) 70 | exists?(uid).must_equal true 71 | 72 | job = Paperdragon::File.new(uid).delete! 73 | 74 | job.must_equal nil 75 | exists?(uid).must_equal false 76 | end 77 | end 78 | 79 | 80 | describe "#reprocess!" do 81 | # existing: 82 | let (:file) { Paperdragon::File.new(uid) } 83 | let (:original) { Paperdragon::File.new("original/#{uid}") } 84 | let (:new_uid) { generate_uid } 85 | 86 | before do 87 | original.process!(logo) # original/uid exists. 88 | exists?(original.uid).must_equal true 89 | file.process!(logo) 90 | exists?(file.uid).must_equal true # file to be reprocessed exists (to test delete). 91 | end 92 | 93 | it do 94 | metadata = file.reprocess!(new_uid, original) 95 | 96 | # it 97 | metadata.must_equal({:width=>216, :height=>63, :uid=>new_uid}) 98 | # it 99 | exists?(uid).must_equal false # deleted 100 | exists?(new_uid).must_equal true 101 | end 102 | 103 | it do 104 | job = file.reprocess!(new_uid, original) do |j| 105 | j.thumb!("16x16") 106 | 107 | end 108 | 109 | assert file.data.size < 500 110 | end 111 | end 112 | 113 | 114 | describe "#rename!" do 115 | # existing: 116 | let (:file) { Paperdragon::File.new(uid, :size => 99) } 117 | let (:original) { Paperdragon::File.new(uid) } 118 | let (:new_uid) { generate_uid } 119 | 120 | before do 121 | original.process!(logo) 122 | exists?(uid).must_equal true 123 | end 124 | 125 | it do 126 | metadata = file.rename!(new_uid) do |uid, new_uid| 127 | File.rename("public/paperdragon/"+uid, "public/paperdragon/"+new_uid) # DISCUSS: should that be simpler? 128 | end 129 | 130 | # it 131 | # metadata.must_equal({:width=>216, :height=>63, :uid=>new_uid, :content_type=>"application/octet-stream", :size=>9632}) 132 | metadata.must_equal(:uid=>new_uid, :size => 99) # we DON'T fetch original metadata here anymore. 133 | 134 | exists?(uid).must_equal false # deleted 135 | exists?(new_uid).must_equal true 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/fixtures/apotomo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apotonick/paperdragon/d4075f8fad095bf387cafe67f55214218a28c52d/test/fixtures/apotomo.png -------------------------------------------------------------------------------- /test/fixtures/trb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apotonick/paperdragon/d4075f8fad095bf387cafe67f55214218a28c52d/test/fixtures/trb.png -------------------------------------------------------------------------------- /test/metadata_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class MetadataTest < MiniTest::Spec 4 | describe "valid" do 5 | let (:valid) { 6 | { 7 | :original=>{:width=>960, :height=>960, :uid=>"403661339/kristylee-38.jpg", :content_type=>"image/jpeg", :size=>198299}, 8 | :thumb =>{:width=>191, :height=>191, :uid=>"ds3661339/kristylee-38.jpg", :content_type=>"image/jpeg", :size=>18132} 9 | } 10 | } 11 | 12 | subject { Paperdragon::Metadata[valid] } 13 | 14 | it { subject.populated?.must_equal true } 15 | it { subject[:original][:width].must_equal 960 } 16 | it { subject[:original][:uid].must_equal "403661339/kristylee-38.jpg" } 17 | it { subject[:thumb][:uid].must_equal "ds3661339/kristylee-38.jpg" } 18 | 19 | it { subject[:page].must_equal({}) } 20 | it { subject[:page][:width].must_equal nil } 21 | end 22 | 23 | 24 | describe "nil" do 25 | subject { Paperdragon::Metadata[nil] } 26 | 27 | it { subject.populated?.must_equal false } 28 | it { subject[:page].must_equal({}) } 29 | it { subject[:page][:width].must_equal nil } 30 | end 31 | 32 | describe "empty hash" do 33 | subject { Paperdragon::Metadata[{}] } 34 | 35 | it { subject.populated?.must_equal false } 36 | it { subject[:page].must_equal({}) } 37 | it { subject[:page][:width].must_equal nil } 38 | end 39 | 40 | let (:original) { {:original => {}} } 41 | 42 | # #dup 43 | # don't change original hash. 44 | it do 45 | Paperdragon::Metadata[original].dup.merge!(:additional => {}) 46 | original[:additional].must_equal nil 47 | end 48 | 49 | # #to_hash 50 | it { Paperdragon::Metadata[original].to_hash.must_equal({:original=>{}}) } 51 | it { Paperdragon::Metadata[original].to_hash.class.must_equal(Hash) } 52 | end -------------------------------------------------------------------------------- /test/model_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class PaperdragonModelTest < MiniTest::Spec 4 | class Avatar 5 | class Photo < Paperdragon::File 6 | end 7 | 8 | class Attachment < Paperdragon::Attachment 9 | self.file_class = Photo 10 | end 11 | 12 | include Paperdragon::Model 13 | processable :image, Attachment 14 | processable :picture, Attachment 15 | 16 | 17 | def image_meta_data 18 | {:thumb => {:uid => "Avatar-thumb"}} 19 | end 20 | 21 | def picture_meta_data 22 | {:thumb => {:uid => "Picture-thumb"}} 23 | end 24 | end 25 | 26 | # model has image_meta_data hash. 27 | it { Avatar.new.image[:thumb].url.must_equal "/paperdragon/Avatar-thumb" } 28 | it { Avatar.new.picture[:thumb].url.must_equal "/paperdragon/Picture-thumb" } 29 | # model doesn't have upload, yet. returns empty attachment. 30 | it { Image.new.image.metadata.must_equal({}) } 31 | it { Image.new.picture.metadata.must_equal({}) } 32 | 33 | 34 | # minimum setup 35 | class Image < OpenStruct 36 | include Paperdragon::Model 37 | processable :image 38 | processable :picture 39 | end 40 | 41 | it { Image.new(:image_meta_data => {:thumb => {:uid => "Avatar-thumb"}}).image[:thumb].url.must_equal "/paperdragon/Avatar-thumb" } 42 | it { Image.new(:picture_meta_data => {:thumb => {:uid => "Picture-thumb"}}).picture[:thumb].url.must_equal "/paperdragon/Picture-thumb" } 43 | 44 | 45 | # process with #image{} 46 | let (:logo) { Pathname("test/fixtures/apotomo.png") } 47 | 48 | it "image" do 49 | model = Image.new 50 | model.image(logo) do |v| 51 | v.process!(:original) 52 | v.process!(:thumb) { |j| j.thumb!("16x16") } 53 | end 54 | 55 | model.image_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>16, :height=>5, :uid=>"thumb-apotomo.png"}}) 56 | 57 | 58 | model.image do |v| 59 | v.reprocess!(:thumb, "1") { |j| j.thumb!("8x8") } 60 | end 61 | 62 | model.image_meta_data.class.must_equal Hash 63 | model.image_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>8, :height=>2, :uid=>"thumb-apotomo-1.png"}}) 64 | 65 | model.image do |v| 66 | v.delete!(:thumb) 67 | end 68 | 69 | model.image_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}}) 70 | end 71 | 72 | it "picture" do 73 | model = Image.new 74 | model.picture(logo) do |v| 75 | v.process!(:original) 76 | v.process!(:thumb) { |j| j.thumb!("16x16") } 77 | end 78 | 79 | model.picture_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>16, :height=>5, :uid=>"thumb-apotomo.png"}}) 80 | 81 | 82 | model.picture do |v| 83 | v.reprocess!(:thumb, "1") { |j| j.thumb!("8x8") } 84 | end 85 | 86 | model.picture_meta_data.class.must_equal Hash 87 | model.picture_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>8, :height=>2, :uid=>"thumb-apotomo-1.png"}}) 88 | 89 | model.picture do |v| 90 | v.delete!(:thumb) 91 | end 92 | 93 | model.picture_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}}) 94 | end 95 | 96 | # passing options from image(file, {..}) to the Attachment. 97 | class ImageWithAttachment < OpenStruct 98 | include Paperdragon::Model 99 | 100 | class Attachment < Paperdragon::Attachment 101 | def build_uid(style, file) 102 | @options.inspect # we use options here! 103 | end 104 | 105 | def rebuild_uid(style, file) 106 | @options.inspect 107 | end 108 | end 109 | 110 | processable :image, Attachment 111 | processable :picture, Attachment 112 | end 113 | 114 | it "image" do 115 | model = ImageWithAttachment.new 116 | model.image(logo, :path => "/") do |v| 117 | v.process!(:original) 118 | end 119 | 120 | # this assures that both :model and :path (see above) are passed through. 121 | model.image_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"{:path=>\"/\", :model=>_}"}}) 122 | 123 | model.image(nil, :new => true) do |v| 124 | v.reprocess!(:original, "1") { |j| j.thumb!("8x8") } 125 | end 126 | 127 | model.image_meta_data.must_equal({:original=>{:width=>8, :height=>2, :uid=>"{:new=>true, :model=>#{:width=>216, :height=>63, :uid=>\"{:path=>\\\"/\\\", :model=>_}\"}}>}"}}) 128 | end 129 | 130 | it "picture" do 131 | model = ImageWithAttachment.new 132 | model.picture(logo, :path => "/") do |v| 133 | v.process!(:original) 134 | end 135 | 136 | # this assures that both :model and :path (see above) are passed through. 137 | model.picture_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"{:path=>\"/\", :model=>_}"}}) 138 | 139 | model.picture(nil, :new => true) do |v| 140 | v.reprocess!(:original, "1") { |j| j.thumb!("8x8") } 141 | end 142 | 143 | model.picture_meta_data.must_equal({:original=>{:width=>8, :height=>2, :uid=>"{:new=>true, :model=>#{:width=>216, :height=>63, :uid=>\"{:path=>\\\"/\\\", :model=>_}\"}}>}"}}) 144 | end 145 | end 146 | 147 | 148 | class ModelWriterTest < MiniTest::Spec 149 | class Image < OpenStruct 150 | extend Paperdragon::Model::Writer 151 | processable_writer :image 152 | end 153 | 154 | # process with #image{} 155 | let (:logo) { Pathname("test/fixtures/apotomo.png") } 156 | 157 | it do 158 | model = Image.new 159 | model.image!(logo) do |v| 160 | v.process!(:original) 161 | v.process!(:thumb) { |j| j.thumb!("16x16") } 162 | end 163 | 164 | model.image_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>16, :height=>5, :uid=>"thumb-apotomo.png"}}) 165 | 166 | 167 | model.image! do |v| 168 | v.reprocess!(:thumb, "1") { |j| j.thumb!("8x8") } 169 | end 170 | 171 | model.image_meta_data.class.must_equal Hash 172 | model.image_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>8, :height=>2, :uid=>"thumb-apotomo-1.png"}}) 173 | end 174 | 175 | # Using another name 176 | class Picture < OpenStruct 177 | extend Paperdragon::Model::Writer 178 | processable_writer :picture 179 | end 180 | 181 | # process with #image{} 182 | let (:logo) { Pathname("test/fixtures/apotomo.png") } 183 | 184 | it do 185 | model = Picture.new 186 | model.picture!(logo) do |v| 187 | v.process!(:original) 188 | v.process!(:thumb) { |j| j.thumb!("16x16") } 189 | end 190 | 191 | model.picture_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>16, :height=>5, :uid=>"thumb-apotomo.png"}}) 192 | 193 | 194 | model.picture! do |v| 195 | v.reprocess!(:thumb, "1") { |j| j.thumb!("8x8") } 196 | end 197 | 198 | model.picture_meta_data.class.must_equal Hash 199 | model.picture_meta_data.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>8, :height=>2, :uid=>"thumb-apotomo-1.png"}}) 200 | end 201 | end 202 | 203 | class ModelReaderTest < MiniTest::Spec 204 | class Image < OpenStruct 205 | extend Paperdragon::Model::Reader 206 | processable_reader :image 207 | end 208 | 209 | it { Image.new(:image_meta_data => {:thumb => {:uid => "Avatar-thumb"}}).image[:thumb].url.must_equal "/paperdragon/Avatar-thumb" } 210 | 211 | # Using another name 212 | class Image < OpenStruct 213 | extend Paperdragon::Model::Reader 214 | processable_reader :picture 215 | end 216 | 217 | it { Image.new(:picture_meta_data => {:thumb => {:uid => "Avatar-thumb"}}).picture[:thumb].url.must_equal "/paperdragon/Avatar-thumb" } 218 | end -------------------------------------------------------------------------------- /test/paperclip_uid_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | require 'paperdragon/paperclip' 4 | 5 | class PaperclipUidTest < MiniTest::Spec 6 | Uid = Paperdragon::Paperclip::Uid 7 | 8 | let (:options) { {:class_name => :avatars, :attachment => :image, :id => 1234, 9 | :style => :original, :updated_at => Time.parse("20-06-2014 9:40:59 +1000").to_i, 10 | :file_name => "kristylee.jpg", :hash_secret => "secret"} } 11 | 12 | it { Uid.from(options). 13 | must_equal "system/avatars/image/000/001/234/9bf15e5874b3234c133f7500e6d615747f709e64/original/kristylee.jpg" } 14 | 15 | 16 | class UidWithFingerprint < Paperdragon::Paperclip::Uid 17 | def call 18 | "#{root}/#{class_name}/#{attachment}/#{id_partition}/#{hash}/#{style}/#{fingerprint}-#{file_name}" 19 | end 20 | end 21 | 22 | it { UidWithFingerprint.from(options.merge(:fingerprint => 8675309)). 23 | must_equal "system/avatars/image/000/001/234/9bf15e5874b3234c133f7500e6d615747f709e64/original/8675309-kristylee.jpg" } 24 | end 25 | 26 | 27 | class PaperclipModelTest < MiniTest::Spec 28 | class Avatar 29 | class Photo < Paperdragon::File 30 | end 31 | 32 | class Attachment < Paperdragon::Attachment 33 | self.file_class = Photo 34 | 35 | def exists? 36 | "Of course!" 37 | end 38 | end 39 | 40 | class PictureAttachment < Paperdragon::Attachment 41 | self.file_class = Photo 42 | 43 | def exists? 44 | "Of course it's a Picture!" 45 | end 46 | end 47 | 48 | include Paperdragon::Paperclip::Model 49 | processable :image, Attachment 50 | processable :picture, PictureAttachment 51 | 52 | 53 | def image_meta_data 54 | {:thumb => {:uid => "Avatar-thumb"}} 55 | end 56 | 57 | def picture_meta_data 58 | {:thumb => {:uid => "Picture-thumb"}} 59 | end 60 | end 61 | 62 | describe "image" do 63 | # old paperclip style 64 | it { Avatar.new.image.url(:thumb).must_equal "/paperdragon/Avatar-thumb" } 65 | 66 | # paperdragon style 67 | it { Avatar.new.image[:thumb].url.must_equal "/paperdragon/Avatar-thumb" } 68 | 69 | # delegates all unknown methods back to Attachment. 70 | it { Avatar.new.image.exists?.must_equal "Of course!" } 71 | end 72 | 73 | describe "picture" do 74 | # old paperclip style 75 | it { Avatar.new.picture.url(:thumb).must_equal "/paperdragon/Picture-thumb" } 76 | 77 | # paperdragon style 78 | it { Avatar.new.picture[:thumb].url.must_equal "/paperdragon/Picture-thumb" } 79 | 80 | # delegates all unknown methods back to Attachment. 81 | it { Avatar.new.picture.exists?.must_equal "Of course it's a Picture!" } 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/task_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TaskSpec < MiniTest::Spec 4 | class Attachment < Paperdragon::Attachment 5 | class File < Paperdragon::File 6 | end 7 | self.file_class= File 8 | end 9 | 10 | let (:logo) { Pathname("test/fixtures/apotomo.png") } 11 | 12 | 13 | # #task allows block and returns metadata hash. 14 | describe "#task" do 15 | it do 16 | Attachment.new(nil).task(logo) do |t| 17 | t.process!(:original) 18 | t.process!(:thumb) { |j| j.thumb!("16x16") } 19 | end.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>16, :height=>5, :uid=>"thumb-apotomo.png"}}) 20 | end 21 | 22 | # modify metadata in task 23 | it do 24 | Attachment.new({:processing => true, :approved => true}).task(logo) do |t| 25 | t.process!(:original) 26 | t.metadata.delete(:processing) 27 | end.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :approved => true}) 28 | end 29 | 30 | end 31 | 32 | # task without block 33 | let (:subject) { Attachment.new(nil).task(logo) } 34 | 35 | describe "#process!" do 36 | it do 37 | subject.process!(:original) 38 | subject.process!(:thumb) { |j| j.thumb!("16x16") } 39 | 40 | original_metadata = subject.metadata_hash 41 | original_metadata.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>16, :height=>5, :uid=>"thumb-apotomo.png"}}) 42 | # processed files must exist. 43 | exists?(original_metadata[:original][:uid]).must_equal true 44 | exists?(original_metadata[:thumb][:uid]).must_equal true 45 | 46 | # calling #process! again, with existing metadata, means OVERWRITING the existing image. 47 | task = Attachment.new(subject.metadata_hash).task(Pathname("test/fixtures/trb.png")) 48 | task.process!(:thumb) { |j| j.thumb!("48x48") } 49 | 50 | overwritten_metadata = task.metadata_hash 51 | overwritten_metadata.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>48, :height=>48, :uid=>"thumb-trb.png"}}) 52 | # original must still exist. 53 | exists?(overwritten_metadata[:original][:uid]).must_equal true 54 | # old thumb must be deleted. 55 | exists?(original_metadata[:thumb][:uid]).must_equal false 56 | # new thumb must be existing 57 | exists?(overwritten_metadata[:thumb][:uid]).must_equal true 58 | end 59 | 60 | it do 61 | assert_raises Paperdragon::MissingUploadError do 62 | Attachment.new(nil).task.process!(:original) 63 | end 64 | end 65 | 66 | it do 67 | # after uploading "new", only delete when uid was in metadata. 68 | existing_metadata = {processing: true} 69 | metadata = Attachment.new(existing_metadata).task(logo).process!(:thumb) 70 | metadata.must_equal({:processing=>true, :thumb=>{:width=>216, :height=>63, :uid=>"thumb-apotomo.png"}}) 71 | exists?("thumb-apotomo.png").must_equal true 72 | end 73 | end 74 | 75 | 76 | describe "#reprocess!" do 77 | let (:original) { Paperdragon::File.new("original/pic.jpg") } 78 | 79 | before do 80 | original.process!(logo) # original/uid exists. 81 | exists?(original.uid).must_equal true 82 | end 83 | 84 | subject { Attachment.new({ 85 | :original=>{:uid=>"original/pic.jpg"}, :thumb=>{:uid=>"original/thumb.jpg"}, :bigger=>{:uid=>"original/bigger.jpg"}}).task 86 | } 87 | 88 | it do 89 | subject.reprocess!(:original, "1", original) 90 | subject.reprocess!(:thumb, "1", original) { |j| j.thumb!("16x16") } 91 | 92 | # FIXME: fingerprint should be added before .png suffix. 93 | subject.metadata_hash.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original/pic-1.jpg"}, :thumb=>{:width=>16, :height=>5, :uid=>"original/thumb-1.jpg"}, :bigger=>{:uid=>"original/bigger.jpg"}}) 94 | 95 | # exists?(original.uri).must_equal false # deleted 96 | # exists?(new_uid).must_equal true 97 | end 98 | 99 | # don't pass in fingerprint+original. 100 | it do 101 | subject.reprocess!(:thumb) { |j| j.thumb!("24x24") } 102 | subject.reprocess!(:bigger) { |j| j.thumb!("48x48") } 103 | 104 | subject.metadata_hash.must_equal({:original=>{:uid=>"original/pic.jpg"}, :thumb=>{:width=>24, :height=>7, :uid=>"original/thumb-.jpg"}, :bigger=>{:uid=>"original/bigger-.jpg", :width=>48, :height=>14}}) 105 | end 106 | 107 | # don't pass in original, this must NOT reload the original file more than once! 108 | it do 109 | subject.reprocess!(:thumb, 1) { |j| j.thumb!("24x24") } 110 | File.unlink("public/paperdragon/#{original.uid}") # removing means the original MUST be cached for next step. 111 | subject.reprocess!(:bigger, 1) { |j| j.thumb!("48x48") } 112 | 113 | subject.metadata_hash.must_equal( 114 | {:original=>{:uid=>"original/pic.jpg"}, :thumb=>{:width=>24, :height=>7, :uid=>"original/thumb-1.jpg"}, :bigger=>{:uid=>"original/bigger-1.jpg", :width=>48, :height=>14}}) 115 | end 116 | 117 | # only process one, should return entire metadata hash 118 | it do 119 | subject.reprocess!(:thumb, "new") { |j| j.thumb!("24x24") } 120 | subject.metadata_hash.must_equal({:original=>{:uid=>"original/pic.jpg"}, :thumb=>{:width=>24, :height=>7, :uid=>"original/thumb-new.jpg"}, :bigger=>{:uid=>"original/bigger.jpg"}}) 121 | 122 | # original must be unchanged 123 | exists?(Attachment.new(subject.metadata_hash)[:original].uid).must_equal true 124 | end 125 | 126 | # #rebuild_uid tries to replace existing fingerprint (default behaviour). 127 | it do 128 | subject.reprocess!(:thumb, "1234567890") { |j| j.thumb!("24x24") } 129 | metadata = subject.metadata_hash 130 | metadata.must_equal({:original=>{:uid=>"original/pic.jpg"}, :thumb=>{:width=>24, :height=>7, :uid=>"original/thumb-1234567890.jpg"}, :bigger=>{:uid=>"original/bigger.jpg"}}) 131 | 132 | # this might happen in the next request. 133 | subject = Attachment.new(metadata).task 134 | subject.reprocess!(:thumb, "0987654321") { |j| j.thumb!("24x24") } 135 | subject.metadata_hash.must_equal({:original=>{:uid=>"original/pic.jpg"}, :thumb=>{:width=>24, :height=>7, :uid=>"original/thumb-0987654321.jpg"}, :bigger=>{:uid=>"original/bigger.jpg"}}) 136 | end 137 | 138 | # #rebuild_uid eats integers. 139 | it { subject.reprocess!(:thumb, 1234081599) { |j| j.thumb!("24x24") }.must_equal({:original=>{:uid=>"original/pic.jpg"}, :thumb=>{:width=>24, :height=>7, :uid=>"original/thumb-1234081599.jpg"}, :bigger=>{:uid=>"original/bigger.jpg"}}) } 140 | end 141 | 142 | 143 | describe "#rename!" do 144 | before do 145 | attachment = Paperdragon::Attachment.new(nil) 146 | @upload_task = attachment.task(logo) 147 | metadata = @upload_task.process!(:original).must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}}) 148 | 149 | # we do not update the attachment from task. 150 | attachment = Paperdragon::Attachment.new(@upload_task.metadata) 151 | exists?(attachment[:original].uid).must_equal true 152 | end 153 | 154 | let (:metadata) { @upload_task.metadata } 155 | 156 | # let (:subject) { Attachment.new(nil).task } 157 | it do 158 | attachment = Paperdragon::Attachment.new(metadata) # {:original=>{:width=>216, :height=>63, :uid=>"uid/original", :size=>9632}} 159 | task = attachment.task 160 | task.rename!(:original, "new") { |uid, new_uid| 161 | File.rename("public/paperdragon/"+uid, "public/paperdragon/"+new_uid) 162 | }.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo-new.png"}}) 163 | end 164 | end 165 | 166 | 167 | describe "#delete!" do 168 | before do 169 | attachment = Paperdragon::Attachment.new(nil) 170 | @upload_task = attachment.task(logo) 171 | metadata = @upload_task.process!(:original).must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}}) 172 | 173 | # we do not update the attachment from task. 174 | attachment = Paperdragon::Attachment.new(@upload_task.metadata) 175 | exists?(attachment[:original].uid).must_equal true 176 | end 177 | 178 | let (:metadata) { @upload_task.metadata } 179 | 180 | # #delete!(:original) 181 | it do 182 | attachment = Paperdragon::Attachment.new(metadata) # {:original=>{:width=>216, :height=>63, :uid=>"uid/original", :size=>9632}} 183 | 184 | attachment.task.delete!(:original).must_equal({}) 185 | 186 | exists?(attachment[:original].uid).must_equal false 187 | end 188 | 189 | # #delete no args. 190 | it do 191 | metadata = @upload_task.process!(:thumb) 192 | metadata.must_equal({:original=>{:width=>216, :height=>63, :uid=>"original-apotomo.png"}, :thumb=>{:width=>216, :height=>63, :uid=>"thumb-apotomo.png"}}) 193 | 194 | attachment = Paperdragon::Attachment.new(metadata) # {:original=>{:width=>216, :height=>63, :uid=>"uid/original", :size=>9632}} 195 | # thumb and original are both there. 196 | exists?(attachment[:original].uid).must_equal true 197 | exists?(attachment[:thumb].uid).must_equal true 198 | 199 | attachment.task.delete!.must_equal({}) 200 | 201 | exists?(attachment[:original].uid).must_equal false 202 | exists?(attachment[:thumb].uid).must_equal false 203 | end 204 | end 205 | end 206 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'paperdragon' 2 | require 'minitest/autorun' 3 | 4 | MiniTest::Spec.class_eval do 5 | def exists?(uid) 6 | File.exists?("public/paperdragon/" + uid) 7 | end 8 | 9 | def generate_uid 10 | Dragonfly.app.datastore.send(:relative_path_for, "aptomo.png") 11 | end 12 | 13 | def self.it(name=nil, *args) 14 | name ||= Random.rand 15 | super 16 | end 17 | end --------------------------------------------------------------------------------