diff --git a/.gitignore b/.gitignore index c959965..3504e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,3 @@ app/[Cc]onfig/database.php /vendor/ composer.lock -.idea/* diff --git a/composer.json b/composer.json index 62a3c21..62080e5 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "davidyell/proffer", "description": "An upload plugin for CakePHP 3", "type": "cakephp-plugin", - "keywords": ["cakephp", "cakephp3", "upload", "file", "image"], + "keywords": ["cakephp", "cakephp3", "upload", "file", "image", "orm"], "homepage": "https://github.com/davidyell/CakePHP3-Proffer", "license": "MIT", "authors": [ @@ -18,11 +18,13 @@ }, "require": { "php": ">=5.4.16", - "imagine/imagine": "0.6.2", - "cakephp/cakephp": "~3.0" + "imagine/imagine": "^0.6", + "cakephp/orm": "3.*" }, "require-dev": { - "phpunit/phpunit": "*" + "phpunit/phpunit": "*", + "cakephp/cakephp": "~3.0", + "cakephp/cakephp-codesniffer": "^2.0" }, "autoload": { "psr-4": { diff --git a/docs/customisation.md b/docs/customisation.md index 86d54d7..66cccd4 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -2,7 +2,9 @@ This manual page deals with customising the behaviour of the Proffer plugin. How to change the upload location and changing file names. It also cover how you can use the Proffer events to change the way the plugin behaves. -##Customising upload file names and paths using an event listener +##Customising using an event listener + +###Customising upload file names and paths Using the `Proffer.afterPath` event you can hook into all the details about the file upload before it is processed. Using this event you can change the name of the file and the upload path to match whatever convention you want. I have created an example listener which is [available as an example](examples/UploadFilenameListener.md). @@ -23,6 +25,11 @@ file and also attach this listener to multiple tables, if you wanted the same na :warning: The listener will overwrite any settings that are configured in the path class. This includes if you are using your own path class. +###Customising behavior of file creation/deletion +Proffer’s image creation can be hooked by using `Proffer.afterCreateImage` event, and by using `Proffer.beforeDeleteImage` event, Proffer’s image deletion can be hooked. +These events can be used to copy files to external services (e.g. Amazon S3), or deleting files from external services at the same time of Proffer creating/deleting images. +I have created an example listener which is [available as an example](examples/UploadAndDeleteImageListener.md). + ##Advanced customisation If you want more control over how the plugin is handling paths or creating thumbnails you can replace these components with your own by creating a class using the provided interfaces and injecting them into the plugin. diff --git a/docs/examples.md b/docs/examples.md index e08f06e..1f108f1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -13,6 +13,7 @@ echo $this->Html->image('../files///' . $data->get('image_dir') . ## Example event listener Here are some basic event listener example classes * [Customize the upload folder and filename](examples/UploadFilenameListener.md) +* [Customize behavior of file creation/deletion](examples/UploadAndDeleteImageListener.md) ##Uploading multiple related images This example will show you how to upload many images which are related to your @@ -73,4 +74,33 @@ field names, so that your request data is formatted correctly. How you deal with the display of existing images, deletion of existing images, and adding of new upload fields is up to you, and outside the scope of this example. +###Deleting images but preserving data +If you need to delete an upload and remove it's associated data from your data store, you can achieve this in your controller. + +The easiest way is to add a checkbox to your form and then look for it when processing your post data. + +An example form might look like. It's important to note that I've disabled the `hiddenField` option here. + +```php +echo $this->Form->input('cover', ['type' => 'file']); +if (!empty($league->cover)) { + echo $this->Form->input('delete_cover', ['type' => 'checkbox', 'hiddenField' => false, 'label' => 'Remove my cover photo']); +} +``` + +Then in your controller, check for the field before using `patchEntity` + +```php +// Deleting the upload? +if (isset($this->request->data['delete_cover'])) { + $this->request->data['image_dir'] = null; + $this->request->data['cover'] = null; + + $path = new \Proffer\Lib\ProfferPath($this->Leagues, $league, 'cover', $this->Leagues->behaviors()->Proffer->config('cover')); + $path->deleteFiles($path->getFolder(), true); +} + +// patchEntity etc +``` + [< Shell tasks](shell.md) | [FAQ >](faq.md) diff --git a/docs/examples/UploadAndDeleteImageListener.md b/docs/examples/UploadAndDeleteImageListener.md new file mode 100644 index 0000000..c23fa5c --- /dev/null +++ b/docs/examples/UploadAndDeleteImageListener.md @@ -0,0 +1,48 @@ +##Customizing behavior of file creation/deletion using event listener + +You can hook Proffer's image creation/deletion as below. + +###Create src/Event/UploadAndDeleteImageListener.php + +```php + 'createImage', + 'Proffer.beforeDeleteImage' => 'deleteImage', + ]; + } + + public function createImage(Event $event, ProfferPath $path, $imagePath) + { + Log::write('debug', 'hook event of createImage path: ' . $imagePath); + + // copy file to external service (e.g. Amazon S3) + // delete locale file + } + + public function deleteImage(Event $event, ProfferPath $path) + { + Log::write('debug', 'hook event of deleteImage folder: ' . $path->getFolder()); + + // delete file from external service (e.g. Amazon S3) + } +} +``` + +###Register listener to EventManager in config/bootstrap.php + +```php +Cake\Event\EventManager::instance()->on(new \App\Event\UploadAndDeleteImageListener()); +``` diff --git a/docs/examples/UploadFilenameListener.md b/docs/examples/UploadFilenameListener.md index 6bfd700..0ab7c8d 100644 --- a/docs/examples/UploadFilenameListener.md +++ b/docs/examples/UploadFilenameListener.md @@ -66,7 +66,7 @@ class UploadFilenameListener implements EventListenerInterface // Change the filename in both the path to be saved, and in the entity data for saving to the db $path->setFilename($newFilename); - $event->subject()['image']['name'] = $newFilename; + $event->subject('image')['name'] = $newFilename; // Must return the modified path instance, so that things are saved in the right place return $path; diff --git a/src/Lib/ImageTransform.php b/src/Lib/ImageTransform.php index 1f4f7ce..8e025a7 100644 --- a/src/Lib/ImageTransform.php +++ b/src/Lib/ImageTransform.php @@ -85,12 +85,13 @@ protected function setImagine($engine = 'gd') * Take an upload fields configuration and create all the thumbnails * * @param array $config The upload fields configuration - * @return void + * @return array */ public function processThumbnails(array $config) { + $thumbnailPaths = []; if (!isset($config['thumbnailSizes'])) { - return; + return $thumbnailPaths; } foreach ($config['thumbnailSizes'] as $prefix => $thumbnailConfig) { @@ -99,8 +100,10 @@ public function processThumbnails(array $config) $method = $config['thumbnailMethod']; } - $this->makeThumbnail($prefix, $thumbnailConfig, $method); + $thumbnailPath = $this->makeThumbnail($prefix, $thumbnailConfig, $method); + $thumbnailPaths[] = $thumbnailPath; } + return $thumbnailPaths; } /** @@ -109,7 +112,7 @@ public function processThumbnails(array $config) * @param string $prefix The thumbnail prefix * @param array $config Array of thumbnail config * @param string $thumbnailMethod Which engine to use to make thumbnails - * @return void + * @return string */ public function makeThumbnail($prefix, array $config, $thumbnailMethod = 'gd') { @@ -130,6 +133,7 @@ public function makeThumbnail($prefix, array $config, $thumbnailMethod = 'gd') unset($config['crop'], $config['w'], $config['h']); $image->save($this->Path->fullPath($prefix), $config); + return $this->Path->fullPath($prefix); } /** diff --git a/src/Lib/ImageTransformInterface.php b/src/Lib/ImageTransformInterface.php index 7dda9f3..2ef5ecd 100644 --- a/src/Lib/ImageTransformInterface.php +++ b/src/Lib/ImageTransformInterface.php @@ -17,7 +17,7 @@ interface ImageTransformInterface * Take an upload fields configuration and process each configured thumbnail * * @param array $config The upload fields configuration - * @return void + * @return array */ public function processThumbnails(array $config); @@ -27,7 +27,7 @@ public function processThumbnails(array $config); * @param string $prefix The prefix name for the thumbnail * @param array $dimensions The thumbnail dimensions * @param string $thumbnailMethod Which method to use to create the thumbnail - * @return void + * @return string */ public function makeThumbnail($prefix, array $dimensions, $thumbnailMethod = 'gd'); } diff --git a/src/Model/Behavior/ProfferBehavior.php b/src/Model/Behavior/ProfferBehavior.php index e8359b0..86cf303 100644 --- a/src/Model/Behavior/ProfferBehavior.php +++ b/src/Model/Behavior/ProfferBehavior.php @@ -10,6 +10,7 @@ use ArrayObject; use Cake\Database\Type; +use Cake\Datasource\EntityInterface; use Cake\Event\Event; use Cake\ORM\Behavior; use Cake\ORM\Entity; @@ -27,6 +28,7 @@ class ProfferBehavior extends Behavior * Build the behaviour * * @param array $config Passed configuration + * * @return void */ public function initialize(array $config) @@ -42,9 +44,12 @@ public function initialize(array $config) /** * beforeMarshal event * - * @param Event $event Event instance + * If a field is allowed to be empty as defined in the validation it should be unset to prevent processing + * + * @param \Cake\Event\Event $event Event instance * @param ArrayObject $data Data to process * @param ArrayObject $options Array of options for event + * * @return void */ public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $options) @@ -61,18 +66,23 @@ public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $opti /** * beforeSave method * - * @param Event $event The event - * @param Entity $entity The entity + * Process any uploaded files, generate paths, move the files and kick of thumbnail generation if it's an image + * + * @param \Cake\Event\Event $event The event + * @param \Cake\Datasource\EntityInterface $entity The entity * @param ArrayObject $options Array of options - * @param ProfferPathInterface $path Inject an instance of ProfferPath + * @param \Proffer\Lib\ProfferPathInterface $path Inject an instance of ProfferPath + * * @return true - * @throws Exception + * + * @throws \Exception */ - public function beforeSave(Event $event, Entity $entity, ArrayObject $options, ProfferPathInterface $path = null) + public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options, ProfferPathInterface $path = null) { foreach ($this->config() as $field => $settings) { if ($entity->has($field) && is_array($entity->get($field)) && $entity->get($field)['error'] === UPLOAD_ERR_OK) { + // Allow path to be injected or set in config if (!empty($settings['pathClass'])) { $path = new $settings['pathClass']($this->_table, $entity, $field, $settings); @@ -89,6 +99,8 @@ public function beforeSave(Event $event, Entity $entity, ArrayObject $options, P $path->createPathFolder(); if ($this->moveUploadedFile($entity->get($field)['tmp_name'], $path->fullPath())) { + $imagePaths = [$path->fullPath()]; + $entity->set($field, $path->getFilename()); $entity->set($settings['dir'], $path->getSeed()); @@ -101,7 +113,12 @@ public function beforeSave(Event $event, Entity $entity, ArrayObject $options, P $imageTransform = new ImageTransform($this->_table, $path); } - $imageTransform->processThumbnails($settings); + $thumbnailPaths = $imageTransform->processThumbnails($settings); + $imagePaths = array_merge($imagePaths, $thumbnailPaths); + + $eventData = ['path' => $path, 'images' => $imagePaths]; + $event = new Event('Proffer.afterCreateImage', $entity, $eventData); + $this->_table->eventManager()->dispatch($event); } } else { throw new Exception('Cannot upload file'); @@ -118,22 +135,27 @@ public function beforeSave(Event $event, Entity $entity, ArrayObject $options, P * * Remove images from records which have been deleted, if they exist * - * @param Event $event The passed event - * @param Entity $entity The entity + * @param \Cake\Event\Event $event The passed event + * @param \Cake\Datasource\EntityInterface $entity The entity * @param ArrayObject $options Array of options - * @param ProfferPathInterface $path Inject and instance of ProfferPath - * @return bool + * @param \Proffer\Lib\ProfferPathInterface $path Inject an instance of ProfferPath + * + * @return true */ - public function afterDelete(Event $event, Entity $entity, ArrayObject $options, ProfferPathInterface $path = null) + public function afterDelete(Event $event, EntityInterface $entity, ArrayObject $options, ProfferPathInterface $path = null) { foreach ($this->config() as $field => $settings) { $dir = $entity->get($settings['dir']); if (!empty($entity) && !empty($dir)) { - if (!$path) { + if (!empty($settings['pathClass'])) { + $path = new $settings['pathClass']($this->_table, $entity, $field, $settings); + } elseif (!isset($path)) { $path = new ProfferPath($this->_table, $entity, $field, $settings); } + $event = new Event('Proffer.beforeDeleteFolder', $entity, ['path' => $path]); + $this->_table->eventManager()->dispatch($event); $path->deleteFiles($path->getFolder(), true); } @@ -149,6 +171,7 @@ public function afterDelete(Event $event, Entity $entity, ArrayObject $options, * * @param string $file Path to the uploaded file * @param string $destination The destination file name + * * @return bool */ protected function moveUploadedFile($file, $destination) diff --git a/src/Shell/ProfferShell.php b/src/Shell/ProfferShell.php index 3f0b6da..a1eb5b0 100644 --- a/src/Shell/ProfferShell.php +++ b/src/Shell/ProfferShell.php @@ -42,7 +42,10 @@ public function getOptionParser() 'image-class' => [ 'short' => 'i', 'help' => __('Fully name spaced custom image transform class, you must use double backslash.') - ] + ], + 'remove-behaviors' => [ + 'help' => __('The behaviors to remove before generate.'), + ], ] ] ]); @@ -58,7 +61,10 @@ public function getOptionParser() 'short' => 'd', 'help' => __('Do a dry run and don\'t delete any files.'), 'boolean' => true - ] + ], + 'remove-behaviors' => [ + 'help' => __('The behaviors to remove before cleanup.'), + ], ] ], ]); @@ -163,12 +169,17 @@ public function cleanup($table) // Loop through each upload field configured for this table (field) foreach ($uploadFieldFolders as $fieldFolder) { // Loop through each instance of an upload for this field (seed) + $pathFieldName = pathinfo($fieldFolder, PATHINFO_BASENAME); $uploadFolders = glob($fieldFolder . DS . '*'); foreach ($uploadFolders as $seedFolder) { // Does the seed exist in the db? $seed = pathinfo($seedFolder, PATHINFO_BASENAME); foreach ($config as $field => $settings) { + if ($pathFieldName != $field) { + continue; + } + $targets = []; $record = $this->{$this->Table->alias()}->find() @@ -294,5 +305,12 @@ protected function checkTable($table) } } + + if ($this->param('remove-behaviors')) { + $removeBehaviors = explode(',', (string)$this->param('remove-behaviors')); + foreach ($removeBehaviors as $removeBehavior) { + $this->Table->removeBehavior($removeBehavior); + } + } } } diff --git a/tests/TestCase/Model/Behavior/ProfferBehaviorTest.php b/tests/TestCase/Model/Behavior/ProfferBehaviorTest.php index 8d057c4..12eb994 100644 --- a/tests/TestCase/Model/Behavior/ProfferBehaviorTest.php +++ b/tests/TestCase/Model/Behavior/ProfferBehaviorTest.php @@ -381,7 +381,8 @@ public function testFailedToMoveFile() public function testAfterDelete() { $schema = $this->getMock('Cake\Database\Schema\Table', null, ['examples']); - $table = $this->getMock('Cake\ORM\Table', ['alias'], [['schema' => $schema]]); + $eventManager = $this->getMock('Cake\Event\EventManager'); + $table = $this->getMock('Cake\ORM\Table', ['alias'], [['eventManager' => $eventManager, 'schema' => $schema]]); $table->method('alias') ->willReturn('ProfferTest'); @@ -412,6 +413,11 @@ public function testAfterDelete() $testUploadPath . 'portrait_image_640x480.jpg' ); + $event = new Event('Proffer.beforeDeleteFolder', $entity, ['path' => $path]); + $eventManager->expects($this->at(0)) + ->method('dispatch') + ->with($this->equalTo($event)); + $Proffer->afterDelete( $this->getMock('Cake\Event\Event', null, ['afterDelete']), $entity, @@ -462,7 +468,7 @@ public function testAfterDeleteWithMissingFiles() $this->assertFileNotExists($testUploadPath . 'portrait_image_640x480.jpg'); } - public function testAfterPathEvent() + public function testEventsForBeforeSave() { $entityData = [ 'photo' => [ @@ -488,11 +494,22 @@ public function testAfterPathEvent() $path = $this->_getProfferPathMock($table, $entity, 'photo'); - $event = new Event('Proffer.afterPath', $entity, ['path' => $path]); + $eventAfterPath = new Event('Proffer.afterPath', $entity, ['path' => $path]); $eventManager->expects($this->at(0)) ->method('dispatch') - ->with($this->equalTo($event)); + ->with($this->equalTo($eventAfterPath)); + + $images = [ + $path->getFolder() . 'image_640x480.jpg', + $path->getFolder() . 'square_image_640x480.jpg', + $path->getFolder() . 'portrait_image_640x480.jpg', + ]; + $eventAfterCreateImage = new Event('Proffer.afterCreateImage', $entity, ['path' => $path, 'images' => $images]); + + $eventManager->expects($this->at(1)) + ->method('dispatch') + ->with($this->equalTo($eventAfterCreateImage)); $Proffer = $this->getMockBuilder('Proffer\Model\Behavior\ProfferBehavior') ->setConstructorArgs([$table, $this->config])