From f2c4e89f93e25f6b64a2757450e1a9c7900ee09e Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Wed, 29 Jan 2020 15:53:01 +0800 Subject: [PATCH 1/9] Support parsing object (flattened to JSON string) as image (requires path property), better support for relative URLs, remove debug routes file --- Plugin.php | 4 +++- classes/Resizer.php | 16 +++++++++------- models/Settings.php | 2 +- routes.php | 1 + updates/version.yaml | 3 +-- 5 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 routes.php diff --git a/Plugin.php b/Plugin.php index e57cfc1..c51dd0d 100644 --- a/Plugin.php +++ b/Plugin.php @@ -25,10 +25,12 @@ public function registerMarkupTags() 'filters' => [ 'resize' => function ($image, $width, $height = null, $options = []) { $resizer = new Resizer((string) $image); + return $resizer->resize((int) $width, (int) $height, (array) $options); }, 'modify' => function ($image, $options = []) { $resizer = new Resizer((string) $image); + return $resizer->resize(null, null, (array) $options); } ] @@ -50,7 +52,7 @@ public function registerSettings() ] ]; } - + public function registerPermissions() { return [ diff --git a/classes/Resizer.php b/classes/Resizer.php index f01005e..4e7286f 100644 --- a/classes/Resizer.php +++ b/classes/Resizer.php @@ -79,17 +79,19 @@ public function setImage(string $image): void { $absolutePath = false; - // Check if the image is an absolute url to the same server, if so get the storage path of the image - if (preg_match('/^(?:https?:\/\/)?' . $_SERVER['SERVER_NAME'] . '(?::\d+)?\/storage\/(.+)$/', $image, $m)) { - // Convert spaces, not going to urldecode as it will mess with pluses - $image = storage_path(str_replace('%20', ' ', $m[1])); - $absolutePath = true; + if (substr($image, 0, 2) === '{"') { + $attempt = json_decode($image); + + if (!empty($attempt->path)) { + $image = $attempt->path; + } } // Check if the image is an absolute url to the same server, if so get the storage path of the image - if (preg_match('/^(?:https?:\/\/)?' . $_SERVER['SERVER_NAME'] . '(?::\d+)?\/theme\/(.+)$/', $image, $m)) { + $regex = '/^(?:https?:\/\/)?' . $_SERVER['SERVER_NAME'] . '(?::\d+)?\/(.+)$/'; + if (preg_match($regex, $image, $m)) { // Convert spaces, not going to urldecode as it will mess with pluses - $image = base_path('theme/' . str_replace('%20', ' ', $m[1])); + $image = base_path(str_replace('%20', ' ', $m[1])); $absolutePath = true; } diff --git a/models/Settings.php b/models/Settings.php index 895f46b..33e41c4 100644 --- a/models/Settings.php +++ b/models/Settings.php @@ -11,7 +11,7 @@ class Settings extends Model use Validation; - const DEFAULT_IMAGE_NOT_FOUND = '/plugins/abwebdevelopers/imageresize/assets/image-not-found.png'; + const DEFAULT_IMAGE_NOT_FOUND = 'plugins/abwebdevelopers/imageresize/assets/image-not-found.png'; /** * Implement settings model diff --git a/routes.php b/routes.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/routes.php @@ -0,0 +1 @@ + Date: Wed, 29 Jan 2020 17:15:45 +0800 Subject: [PATCH 2/9] Create new publicly accessible route for resizing images OTF --- classes/Resizer.php | 251 +++++++++++++++++++++++++++++++------------ models/Settings.php | 10 +- routes.php | 24 +++++ updates/version.yaml | 3 +- 4 files changed, 215 insertions(+), 73 deletions(-) diff --git a/classes/Resizer.php b/classes/Resizer.php index 4e7286f..22dd1eb 100644 --- a/classes/Resizer.php +++ b/classes/Resizer.php @@ -2,13 +2,16 @@ namespace ABWebDevelopers\ImageResize\Classes; +use ABWebDevelopers\ImageResize\Models\Settings; +use Cache; +use Carbon\Carbon; +use Exception; use Intervention\Image\ImageManagerStatic as Image; use Validator; -use Exception; -use ABWebDevelopers\ImageResize\Models\Settings; class Resizer { + public const CACHE_PREFIX = 'image_resize_'; /** * A list of computed values to override $options @@ -64,49 +67,85 @@ class Resizer * * @param string $image */ - public function __construct(string $image) + public function __construct(string $image = null, bool $doNotModifyPath = false) + { + $this->setImage($image, $doNotModifyPath); + } + + /** + * Instantiate an instance using an image path + * + * @param string|null $image + * @return void + */ + public static function using(string $image = null) { - $this->setImage($image); + $that = new static($image, true); + + return $that; } /** * Specify the image to use * * @param string $image - * @return void + * @return $this */ - public function setImage(string $image): void + public function setImage(string $image = null, bool $doNotModifyPath = false) { - $absolutePath = false; + if ($doNotModifyPath) { + $this->image = $image; + + return $this; + } - if (substr($image, 0, 2) === '{"') { - $attempt = json_decode($image); + if ($image !== null) { + $absolutePath = false; - if (!empty($attempt->path)) { - $image = $attempt->path; + // Support JSON objects containing path property, e.g: {"path":"USETHISPATH",...} + if (substr($image, 0, 2) === '{"') { + $attempt = json_decode($image); + + if (!empty($attempt->path)) { + $image = $attempt->path; + } } - } - // Check if the image is an absolute url to the same server, if so get the storage path of the image - $regex = '/^(?:https?:\/\/)?' . $_SERVER['SERVER_NAME'] . '(?::\d+)?\/(.+)$/'; - if (preg_match($regex, $image, $m)) { - // Convert spaces, not going to urldecode as it will mess with pluses - $image = base_path(str_replace('%20', ' ', $m[1])); - $absolutePath = true; - } + // Check if the image is an absolute url to the same server, if so get the storage path of the image + $regex = '/^(?:https?:\/\/)?' . $_SERVER['SERVER_NAME'] . '(?::\d+)?\/(.+)$/'; + if (preg_match($regex, $image, $m)) { + // Convert spaces, not going to urldecode as it will mess with pluses + $image = base_path(str_replace('%20', ' ', $m[1])); + $absolutePath = true; + } - // If not an absolute path, set it to an absolute path - if (!$absolutePath) { - $image = base_path(trim($image, '/')); + // If not an absolute path, set it to an absolute path + if (!$absolutePath) { + $image = base_path(trim($image, '/')); + } } + // Set the image + $this->image = $image; + + return $this; + } + + /** + * Get the path to the image (or default if necessary) + * + * @return string + */ + public function getImagePath(): string + { + $image = $this->image; + // If the image is invalid, default to Image Not Found if ($image === null || $image === '' || !file_exists($image)) { $image = $this->getDefaultImage(); } - // Set the image - $this->image = $image; + return $image; } /** @@ -137,12 +176,40 @@ protected function getDefaultImage(): string return $image; } + /** + * Set some options for the image resizer + * + * @param array $options + * @return $this + */ + public function setOptions(array $options) + { + foreach ($options as $key => $value) { + $this->options[$key] = $value; + } + + return $this; + } + + /** + * Set the hash for this file + * + * @param string $hash + * @return $this + */ + public function setHash(string $hash) + { + $this->hash = $hash; + + return $this; + } + /** * Initialise the resource - Creates the original and (to be) modified image resource entities * - * @return void + * @return $this */ - public function initResource(): void + public function initResource() { if (empty($this->im)) { Image::configure([ @@ -155,6 +222,8 @@ public function initResource(): void $this->im = $this->original = Image::make($this->getDefaultImage()); } } + + return $this; } /** @@ -162,9 +231,9 @@ public function initResource(): void * setting the hash to be used for caching * * @param array $options - * @return void + * @return $this */ - private function initOptions(array $options = null): void + private function initOptions(array $options = null) { if ($options !== null) { // Allow options with key $k, in place of key $v @@ -244,79 +313,91 @@ private function initOptions(array $options = null): void // Set hash based on image and options $this->hash = hash('sha256', $this->image . json_encode($this->options)); + + return $this; } /** - * Get the physical path of the image + * Get the absolute physical path of the image * * @return string */ - public function getPath(): string + public function getAbsolutePath(): string { - return storage_path($this->storagePath()); + return storage_path($this->getRelativePath()); } /** - * Get the storage path - used for public access and for physical path generation + * Get the path relative to the storage directory * * @return string */ - private function storagePath(): string + private function getRelativePath(): string { - // Get format from destination file (or original, if not specified) - list($mime, $format) = $this->detectFormat(true); - return 'app/uploads/public/' + return 'app/imageresizecache/' . substr($this->hash, 0, 3) . '/' . substr($this->hash, 3, 3) . '/' . substr($this->hash, 6, 3) . '/' - . 'thumb_' . $this->hash . '.' . $format; + . $this->hash; } /** - * Check to see if the file exists in the cache and if so, return the public facing path to it + * Get the publicly accessible URL for this image * - * @return bool|string + * @return string */ - public function getCache() + public function getPublicUrl(): string { - // If explicitly told to not cache, don't cache it then - if (isset($this->options['cache']) && !$this->options['cache']) { - return false; - } - - $path = $this->storagePath(); + return '/imageresize/' . $this->hash; + } - if (file_exists(storage_path($path))) { - return '/storage/' . $path; - } + /** + * Store the configuration in the cache, and retrieve the URL + * + * @return bool|string + */ + public function storeCacheAndGetPublicUrl() + { + Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addWeek(), function () { + return [ + 'image' => $this->image, + 'options' => $this->options, + ]; + }); - return false; + return $this->getPublicUrl(); } /** * Set the cache, storing the modified image to file and return the public facing path to it * - * @return string + * @return $this */ - public function setCache(): string + public function storeResizedImage() { - $path = $this->storagePath(); + // Get absolute path + $path = $this->getAbsolutePath(); - $base = storage_path(substr($path, 0, strrpos($path, '/'))); + // Create directory if not exists + $base = dirname($path); if (!file_exists($base)) { mkdir($base, 0775, true); } - $this->im->save(storage_path($path), $this->options['quality'] ?? null); + // Save to file + $this->im->save($path, $this->options['quality'] ?? null); - return '/storage/' . $path; + return $this; } /** - * Resize - Optionally resize the image, and/or modify the image with options + * Resize - Optionally resize the image, and/or modify the image with options. + * + * Contrary to function name, this [as of v2.0] only returns a publicly accessible URL for the image. + * Resizing happens in the public endpoint. * - * @param integer $width - * @param integer $height + * @param int $width + * @param int $height * @param array $options * @return string */ @@ -333,8 +414,38 @@ public function resize(int $width = null, int $height = null, array $options = n $this->initOptions($options); // Get cache if exists - if ($cached = $this->getCache()) { - return $cached; + return $this->storeCacheAndGetPublicUrl(); + } + + /** + * Does the current file exist in the filesystem? + * + * @return bool + */ + public function hasStoredFile(): bool + { + return file_exists($this->getAbsolutePath()); + } + + /** + * Should the image be cached? + * + * @return bool + */ + public function shouldCache(): bool + { + return !isset($this->options['cache']) || (bool) $this->options['cache']; + } + + /** + * Do the resizing (if applicable) + * + * @return $this + */ + public function doResize() + { + if ($this->shouldCache() && $this->hasStoredFile()) { + return $this; } $width = $this->options['width']; @@ -484,13 +595,17 @@ public function resize(int $width = null, int $height = null, array $options = n // Run the modifications on the image $this->modify(); - // Return the publicly accessible image path after caching the image - return $this->setCache(); + $this->storeResizedImage(); + + return $this; } /** * Detect format of input file for default export format * + * Return value is: [mime, format] + * Example: ['image/jpeg', 'jpg'] + * * @param array $options * @return array */ @@ -563,9 +678,9 @@ private function detectAlpha(): bool /** * Modify the image with the options provided * - * @return void + * @return $this */ - public function modify(): void + public function modify() { // Initialise the resouce if not already initialised $this->initResource(); @@ -673,10 +788,12 @@ public function modify(): void break; } } + + return $this; } /** - * Render the image in the desired output format, exiting immediately after + * Render the image (from the filesystem) in the desired output format, exiting immediately after. * * @return void */ @@ -685,7 +802,7 @@ public function render(): void list($mime, $format) = $this->detectFormat(true); header('Content-Type: image/' . $mime); - echo $this->im->encode($format); + echo file_get_contents($this->getAbsolutePath()); exit(); } } diff --git a/models/Settings.php b/models/Settings.php index 33e41c4..3c1e185 100644 --- a/models/Settings.php +++ b/models/Settings.php @@ -8,10 +8,9 @@ class Settings extends Model { - use Validation; - const DEFAULT_IMAGE_NOT_FOUND = 'plugins/abwebdevelopers/imageresize/assets/image-not-found.png'; + public const DEFAULT_IMAGE_NOT_FOUND = 'plugins/abwebdevelopers/imageresize/assets/image-not-found.png'; /** * Implement settings model @@ -118,7 +117,8 @@ class Settings extends Model * * @return void */ - public function beforeValidate() { + public function beforeValidate() + { if (!empty($this->value)) { $data = $this->value; @@ -154,7 +154,8 @@ public function beforeValidate() { * * @return void */ - public function beforeSave() { + public function beforeSave() + { if (!empty($this->value)) { $data = $this->value; if (!empty($data['filters'])) { @@ -180,5 +181,4 @@ public function beforeSave() { } } } - } diff --git a/routes.php b/routes.php index b3d9bbc..4f6644c 100644 --- a/routes.php +++ b/routes.php @@ -1 +1,25 @@ null, + 'options' => [], + ]; + } + + return Resizer::using($config['image']) + ->setHash($hash) + ->setOptions($config['options']) + ->doResize() + ->render(); +}); diff --git a/updates/version.yaml b/updates/version.yaml index 02198b4..0ce3b94 100644 --- a/updates/version.yaml +++ b/updates/version.yaml @@ -5,4 +5,5 @@ 1.1.3: Fix PHP 7.0 incompatibility issue, support all relative URL images 1.1.4: Register access permissions 1.1.5: Fix issue with spaces in filenames -1.1.6: Fix return type errors \ No newline at end of file +1.1.6: Fix return type errors +2.0.0: Optimise image resizing on initial pageload by offloading to separate thread (by resizing when requesting images) \ No newline at end of file From d1a6c4a68d62f0270a4b88b01c48a4797fa708c7 Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Wed, 29 Jan 2020 17:18:27 +0800 Subject: [PATCH 3/9] Undo removal of 1.1.7 in version yaml --- updates/version.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/updates/version.yaml b/updates/version.yaml index 0ce3b94..d1dfc52 100644 --- a/updates/version.yaml +++ b/updates/version.yaml @@ -6,4 +6,5 @@ 1.1.4: Register access permissions 1.1.5: Fix issue with spaces in filenames 1.1.6: Fix return type errors +1.1.7: Remove debug routes file 2.0.0: Optimise image resizing on initial pageload by offloading to separate thread (by resizing when requesting images) \ No newline at end of file From 8dce642403f5af78f0bd3105a97ffc55be3f9110 Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Wed, 29 Jan 2020 17:36:15 +0800 Subject: [PATCH 4/9] After initial load, reference the resized image directly to reduce PHP usage --- classes/Resizer.php | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/classes/Resizer.php b/classes/Resizer.php index 22dd1eb..c05b92f 100644 --- a/classes/Resizer.php +++ b/classes/Resizer.php @@ -318,7 +318,7 @@ private function initOptions(array $options = null) } /** - * Get the absolute physical path of the image + * Get the absolute physical path of the resized image * * @return string */ @@ -328,13 +328,13 @@ public function getAbsolutePath(): string } /** - * Get the path relative to the storage directory + * Get the path (of resized image) relative to the storage directory * * @return string */ private function getRelativePath(): string { - return 'app/imageresizecache/' + return 'app/media/imageresizecache/' . substr($this->hash, 0, 3) . '/' . substr($this->hash, 3, 3) . '/' . substr($this->hash, 6, 3) . '/' @@ -342,30 +342,42 @@ private function getRelativePath(): string } /** - * Get the publicly accessible URL for this image + * Get the URL for resizing this image for the first time * * @return string */ - public function getPublicUrl(): string + public function getFirstTimeUrl(): string { return '/imageresize/' . $this->hash; } + /** + * Get the URL of the resized (and cached) image + * + * @return string + */ + public function getCacheUrl(): string + { + return '/storage/' . $this->getRelativePath(); + } + /** * Store the configuration in the cache, and retrieve the URL * - * @return bool|string + * @return string */ - public function storeCacheAndGetPublicUrl() + public function storeCacheAndgetFirstTimeUrl(): string { - Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addWeek(), function () { + Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addMinute(), function () { return [ 'image' => $this->image, 'options' => $this->options, ]; }); - return $this->getPublicUrl(); + $cacheExists = $this->hasStoredFile(); + + return ($cacheExists) ? $this->getCacheUrl() : $this->getFirstTimeUrl(); } /** @@ -414,7 +426,7 @@ public function resize(int $width = null, int $height = null, array $options = n $this->initOptions($options); // Get cache if exists - return $this->storeCacheAndGetPublicUrl(); + return $this->storeCacheAndgetFirstTimeUrl(); } /** From 264b4149791e1439d02ff1c78d8022df998ab554 Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Wed, 29 Jan 2020 17:39:25 +0800 Subject: [PATCH 5/9] Only store cache for first time imageresize if the cached image already exists --- classes/Resizer.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/classes/Resizer.php b/classes/Resizer.php index c05b92f..d6835f2 100644 --- a/classes/Resizer.php +++ b/classes/Resizer.php @@ -356,7 +356,7 @@ public function getFirstTimeUrl(): string * * @return string */ - public function getCacheUrl(): string + public function getCachedUrl(): string { return '/storage/' . $this->getRelativePath(); } @@ -368,6 +368,10 @@ public function getCacheUrl(): string */ public function storeCacheAndgetFirstTimeUrl(): string { + if ($this->hasCachedFile()) { + return $this->getCachedUrl(); + } + Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addMinute(), function () { return [ 'image' => $this->image, @@ -375,9 +379,7 @@ public function storeCacheAndgetFirstTimeUrl(): string ]; }); - $cacheExists = $this->hasStoredFile(); - - return ($cacheExists) ? $this->getCacheUrl() : $this->getFirstTimeUrl(); + return $this->getFirstTimeUrl(); } /** @@ -434,7 +436,7 @@ public function resize(int $width = null, int $height = null, array $options = n * * @return bool */ - public function hasStoredFile(): bool + public function hasCachedFile(): bool { return file_exists($this->getAbsolutePath()); } @@ -456,7 +458,7 @@ public function shouldCache(): bool */ public function doResize() { - if ($this->shouldCache() && $this->hasStoredFile()) { + if ($this->shouldCache() && $this->hasCachedFile()) { return $this; } From a76e30076957bbf7fe662c8042e9b162efdc5ff1 Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Thu, 30 Jan 2020 07:57:39 +0800 Subject: [PATCH 6/9] trivial changes to readme and docblocks --- README.md | 42 +++++++++++++++++++++++++++++++----------- classes/Resizer.php | 34 ++++++++++++++++------------------ 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e236476..9a650e8 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Using composer: `composer require abwebdevelopers/oc-imageresize-plugin` ### October CMS usage -This plugin utilises [Intervention Image](https://github.com/Intervention/image)'s magical powers to easily resize and transform your images with ease, allow us to create a wrapper for it. Please note it does not do everything intervention/image does, however a fair few features are available. +This plugin utilises [Intervention Image](https://github.com/Intervention/image)'s magical powers to easily resize and transform your images with ease, allow us to create a wrapper for it. Please note it does not do everything `intervention/image` does, however a fair few features are available. **Basic Resizing** @@ -62,7 +62,7 @@ Stretch and morph it to fit exatly in the defined dimensions **Further Modifications** -A few image adjustment tools have been implemented into this plugin, which utilise their intervention/image counterparts: +A few image adjustment tools have been implemented into this plugin, which utilise their `intervention/image` counterparts: Usage of the modifiers is simple, either add them in a `key: value` fashion in the 3rd argument of the resize filter, or by using the modify filter, as such: @@ -79,13 +79,13 @@ Usage of the modifiers is simple, either add them in a `key: value` fashion in t | Brightness | brightness | min:-100 max:100 | `-100`, `50`, `100` | Brightens (or darkens) the image | Contrast | contrast | min:-100 max:100 | `-100`, `50`, `100` | Increases/decreases the contrast of the image | Pixelate | pixelate | min:1 max:1000 | `1`, `500`, `1000` | Pixelates the image -| Greyscale | greyscale/grayscale | accepted | `true`, `1` | See [accepted](https://octobercms.com/docs/services/validation#rule-accepted) rule. Sets the image mode to greyscale. Both codes are accepted (one just maps to the other) | +| Greyscale | greyscale/grayscale | accepted | `true`, `1` | See [accepted](https://octobercms.com/docs/services/validation#rule-accepted) rule. Sets the image mode to greyscale. Both `code`s are accepted (one just maps to the other) | | Invert | invert | accepted | `true`, `1` | See [accepted](https://octobercms.com/docs/services/validation#rule-accepted) rule. Inverts all image colors | | Opacity | opacity | min:0 max:100 | `0`, `50`, `100` | Set the opacity of the image | Rotate | rotate | min:0 max:360 | `45`, `90`, `360` | Rotate the image (width / height does not constrain the rotated image, the image is resized prior to modifications) | Flip | flip | 'h' or 'v' | `h`, `v` | Flip horizontally (h) or vertically (v) | -| Background | fill/background | Hex color | `#fff`, `#123456`, `000` | Set a background color - Hex color (with or without hashtag). Both codes are accepted (one just maps to the other) | -| Colorize | colourise/colorize | string (format: r,g,b) | `255,0,0`, `0,50,25` | Colorize the image. String containing 3 numbers (0-255), comma separated. Both codes are accepted (one just maps to the other) | +| Background | fill/background | Hex color | `#fff`, `#123456`, `000` | Set a background color - Hex color (with or without hashtag). Both `code`s are accepted (one just maps to the other) | +| Colorize | colourise/colorize | string (format: r,g,b) | `255,0,0`, `0,50,25` | Colorize the image. String containing 3 numbers (0-255), comma separated. Both `code`s are accepted (one just maps to the other) | A couple examples from the above: ``` @@ -98,9 +98,8 @@ A couple examples from the above: ### Filters (templates for configuration) -Filters are similar to filters in intervention\image in the sense that you can define a list of rules for each image using the filter. A common example would be a basic thumbnail - you want this to always be `format: jpg`, `mode: cover`, `quality: 60`, `max_width: 200`, `max_height: 200` and maybe `background: #fff`. +Filters are similar to filters in `intervention/image` in the sense that you can define a list of rules (modifiers) to be used for each image that uses the filter. A common example would be a basic thumbnail - let's say it's consists of `format: jpg`, `mode: cover`, `quality: 60`, `max_width: 200`, `max_height: 200` and maybe `background: #fff`. Applying this filter to any image will automatically apply the respective modifiers. See below for example: -With filters, you can specify the above, call it something useful like `thumbnail`, then simply do the following: ``` @@ -112,14 +111,14 @@ or Which will use the predefined list of modifiers and have them overwritten by any that are supplied, for example: ``` - + ``` -which would use create an image in jpg format, cover, 60% quality, no bigger than 200x200, background #ff and darken and increase the constrast of it. Simple, flexible, powerful. +which would create an image in `jpg` format, `cover` mode, `100%` quality, no bigger than `200x200`, background `#fff` and increase the constrast of it. Simple and flexible, yet powerful. **Please Note** -There are a couple new modifiers for filters which include: `min_width`, `max_width`, `min_height`, `max_height` which all act as constraints for the dimensions of the images using filters. Should you use one, please note that if you use it with the `| resize(w, h)` function, your supplied dimensions will be ignored *if* they are out of bounds of the constraints. If the supplied dimensions are within the constraints, the image will be displayed at the supplied dimensions +There are a couple modifiers for filters which include: `min_width`, `max_width`, `min_height`, `max_height` which all act as constraints for the dimensions of the images using filters. Should you use one, please note that if you use it with the `| resize(w, h)` function, your supplied dimensions will be ignored (and the constraints will be used) *if* they are out of bounds of the constraints. If the supplied dimensions are within the constraints, the image will be displayed at the supplied dimensions **Using the library in PHP** @@ -128,10 +127,31 @@ Should you want to implement your own use of this library outside of twig, you c ``` $resizer = new \ABWebDevelopers\ImageResize\Classes\Resizer($image); + +// v1: $resizer->resize(800, 250, [ 'rotate' => 45 ]); -// $resizer->render(); // only use this if you intend on aborting the script immediately at this point +$resizer->render(); // aborts script + +// v2: +$resizer->resize(800, 250, [ + 'rotate' => 45 +]); // will not resize, only return a URL of the image +$resize->doResize(); // required to actually perform the resize +$resizer->render(); // aborts script +``` + +or + +``` +// The :using() method requires a fully qualified path to the file (it will not guess or try fix it unlike the example above) +\ABWebDevelopers\ImageResize\Classes\Resizer::using('/resolvable/path/to/file') + // ->setHash($hash) // optional usage + // ->setOptions($config['options']) // optional usage + ->resize(800, 250, [ 'rotate' => 45, ]) + ->doResize() + ->render(); ``` Which is synonymous to: diff --git a/classes/Resizer.php b/classes/Resizer.php index d6835f2..ff00873 100644 --- a/classes/Resizer.php +++ b/classes/Resizer.php @@ -346,7 +346,7 @@ private function getRelativePath(): string * * @return string */ - public function getFirstTimeUrl(): string + public function getTempImageUrl(): string { return '/imageresize/' . $this->hash; } @@ -356,7 +356,7 @@ public function getFirstTimeUrl(): string * * @return string */ - public function getCachedUrl(): string + public function hasCachedImageUrl(): string { return '/storage/' . $this->getRelativePath(); } @@ -366,10 +366,10 @@ public function getCachedUrl(): string * * @return string */ - public function storeCacheAndgetFirstTimeUrl(): string + public function getImageUrl(): string { - if ($this->hasCachedFile()) { - return $this->getCachedUrl(); + if ($this->hasCachedImage()) { + return $this->hasCachedImageUrl(); } Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addMinute(), function () { @@ -379,15 +379,15 @@ public function storeCacheAndgetFirstTimeUrl(): string ]; }); - return $this->getFirstTimeUrl(); + return $this->getTempImageUrl(); } /** - * Set the cache, storing the modified image to file and return the public facing path to it + * Store the modified image to file * * @return $this */ - public function storeResizedImage() + public function saveImageToFile() { // Get absolute path $path = $this->getAbsolutePath(); @@ -407,8 +407,9 @@ public function storeResizedImage() /** * Resize - Optionally resize the image, and/or modify the image with options. * - * Contrary to function name, this [as of v2.0] only returns a publicly accessible URL for the image. - * Resizing happens in the public endpoint. + * Contrary to function name, this [as of v2.0] only returns a publicly accessible + * URL for the image if not resized yet (which is where/when the resizing occurs), + * or, will return the image path to the resized image if already resized. * * @param int $width * @param int $height @@ -428,7 +429,7 @@ public function resize(int $width = null, int $height = null, array $options = n $this->initOptions($options); // Get cache if exists - return $this->storeCacheAndgetFirstTimeUrl(); + return $this->getImageUrl(); } /** @@ -436,7 +437,7 @@ public function resize(int $width = null, int $height = null, array $options = n * * @return bool */ - public function hasCachedFile(): bool + public function hasCachedImage(): bool { return file_exists($this->getAbsolutePath()); } @@ -446,7 +447,7 @@ public function hasCachedFile(): bool * * @return bool */ - public function shouldCache(): bool + public function shouldCacheImage(): bool { return !isset($this->options['cache']) || (bool) $this->options['cache']; } @@ -458,7 +459,7 @@ public function shouldCache(): bool */ public function doResize() { - if ($this->shouldCache() && $this->hasCachedFile()) { + if ($this->shouldCacheImage() && $this->hasCachedImage()) { return $this; } @@ -606,10 +607,7 @@ public function doResize() $this->options['background'] = '#fff'; } - // Run the modifications on the image - $this->modify(); - - $this->storeResizedImage(); + $this->saveImageToFile(); return $this; } From dc9ba928bdb2603f2cc92c37beb29957efcbdd97 Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Thu, 30 Jan 2020 11:05:33 +0800 Subject: [PATCH 7/9] Rename functions, add intermediary getter for Settings model constants --- Plugin.php | 32 +++++++++++ README.md | 42 ++++---------- classes/Resizer.php | 101 +++++++++++++++++++++++----------- commands/ImageResizeClear.php | 25 +++++++++ models/Settings.php | 59 ++++++++++++++++++++ routes.php | 2 - 6 files changed, 196 insertions(+), 65 deletions(-) create mode 100644 commands/ImageResizeClear.php diff --git a/Plugin.php b/Plugin.php index c51dd0d..d1f129c 100644 --- a/Plugin.php +++ b/Plugin.php @@ -4,10 +4,14 @@ use System\Classes\PluginBase; use ABWebDevelopers\ImageResize\Classes\Resizer; +use ABWebDevelopers\ImageResize\Commands\ImageResizeClear; use Event; class Plugin extends PluginBase { + /** + * @inheritDoc + */ public function pluginDetails() { return [ @@ -19,6 +23,9 @@ public function pluginDetails() ]; } + /** + * @inheritDoc + */ public function registerMarkupTags() { return [ @@ -37,6 +44,9 @@ public function registerMarkupTags() ]; } + /** + * @inheritDoc + */ public function registerSettings() { return [ @@ -53,6 +63,9 @@ public function registerSettings() ]; } + /** + * @inheritDoc + */ public function registerPermissions() { return [ @@ -60,6 +73,9 @@ public function registerPermissions() ]; } + /** + * @inheritDoc + */ public function boot() { Event::listen('backend.page.beforeDisplay', function ($controller, $action, $params) { @@ -72,4 +88,20 @@ public function boot() } }); } + + /** + * @inheritDoc + */ + public function register() + { + $this->registerConsoleCommand('imageresize:clear', ImageResizeClear::class); + } + + /** + * @inheritDoc + */ + public function registerSchedule($schedule) + { + $schedule->command('imageresize:clear')->everyMinute(); + } } diff --git a/README.md b/README.md index 9a650e8..e236476 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Using composer: `composer require abwebdevelopers/oc-imageresize-plugin` ### October CMS usage -This plugin utilises [Intervention Image](https://github.com/Intervention/image)'s magical powers to easily resize and transform your images with ease, allow us to create a wrapper for it. Please note it does not do everything `intervention/image` does, however a fair few features are available. +This plugin utilises [Intervention Image](https://github.com/Intervention/image)'s magical powers to easily resize and transform your images with ease, allow us to create a wrapper for it. Please note it does not do everything intervention/image does, however a fair few features are available. **Basic Resizing** @@ -62,7 +62,7 @@ Stretch and morph it to fit exatly in the defined dimensions **Further Modifications** -A few image adjustment tools have been implemented into this plugin, which utilise their `intervention/image` counterparts: +A few image adjustment tools have been implemented into this plugin, which utilise their intervention/image counterparts: Usage of the modifiers is simple, either add them in a `key: value` fashion in the 3rd argument of the resize filter, or by using the modify filter, as such: @@ -79,13 +79,13 @@ Usage of the modifiers is simple, either add them in a `key: value` fashion in t | Brightness | brightness | min:-100 max:100 | `-100`, `50`, `100` | Brightens (or darkens) the image | Contrast | contrast | min:-100 max:100 | `-100`, `50`, `100` | Increases/decreases the contrast of the image | Pixelate | pixelate | min:1 max:1000 | `1`, `500`, `1000` | Pixelates the image -| Greyscale | greyscale/grayscale | accepted | `true`, `1` | See [accepted](https://octobercms.com/docs/services/validation#rule-accepted) rule. Sets the image mode to greyscale. Both `code`s are accepted (one just maps to the other) | +| Greyscale | greyscale/grayscale | accepted | `true`, `1` | See [accepted](https://octobercms.com/docs/services/validation#rule-accepted) rule. Sets the image mode to greyscale. Both codes are accepted (one just maps to the other) | | Invert | invert | accepted | `true`, `1` | See [accepted](https://octobercms.com/docs/services/validation#rule-accepted) rule. Inverts all image colors | | Opacity | opacity | min:0 max:100 | `0`, `50`, `100` | Set the opacity of the image | Rotate | rotate | min:0 max:360 | `45`, `90`, `360` | Rotate the image (width / height does not constrain the rotated image, the image is resized prior to modifications) | Flip | flip | 'h' or 'v' | `h`, `v` | Flip horizontally (h) or vertically (v) | -| Background | fill/background | Hex color | `#fff`, `#123456`, `000` | Set a background color - Hex color (with or without hashtag). Both `code`s are accepted (one just maps to the other) | -| Colorize | colourise/colorize | string (format: r,g,b) | `255,0,0`, `0,50,25` | Colorize the image. String containing 3 numbers (0-255), comma separated. Both `code`s are accepted (one just maps to the other) | +| Background | fill/background | Hex color | `#fff`, `#123456`, `000` | Set a background color - Hex color (with or without hashtag). Both codes are accepted (one just maps to the other) | +| Colorize | colourise/colorize | string (format: r,g,b) | `255,0,0`, `0,50,25` | Colorize the image. String containing 3 numbers (0-255), comma separated. Both codes are accepted (one just maps to the other) | A couple examples from the above: ``` @@ -98,8 +98,9 @@ A couple examples from the above: ### Filters (templates for configuration) -Filters are similar to filters in `intervention/image` in the sense that you can define a list of rules (modifiers) to be used for each image that uses the filter. A common example would be a basic thumbnail - let's say it's consists of `format: jpg`, `mode: cover`, `quality: 60`, `max_width: 200`, `max_height: 200` and maybe `background: #fff`. Applying this filter to any image will automatically apply the respective modifiers. See below for example: +Filters are similar to filters in intervention\image in the sense that you can define a list of rules for each image using the filter. A common example would be a basic thumbnail - you want this to always be `format: jpg`, `mode: cover`, `quality: 60`, `max_width: 200`, `max_height: 200` and maybe `background: #fff`. +With filters, you can specify the above, call it something useful like `thumbnail`, then simply do the following: ``` @@ -111,14 +112,14 @@ or Which will use the predefined list of modifiers and have them overwritten by any that are supplied, for example: ``` - + ``` -which would create an image in `jpg` format, `cover` mode, `100%` quality, no bigger than `200x200`, background `#fff` and increase the constrast of it. Simple and flexible, yet powerful. +which would use create an image in jpg format, cover, 60% quality, no bigger than 200x200, background #ff and darken and increase the constrast of it. Simple, flexible, powerful. **Please Note** -There are a couple modifiers for filters which include: `min_width`, `max_width`, `min_height`, `max_height` which all act as constraints for the dimensions of the images using filters. Should you use one, please note that if you use it with the `| resize(w, h)` function, your supplied dimensions will be ignored (and the constraints will be used) *if* they are out of bounds of the constraints. If the supplied dimensions are within the constraints, the image will be displayed at the supplied dimensions +There are a couple new modifiers for filters which include: `min_width`, `max_width`, `min_height`, `max_height` which all act as constraints for the dimensions of the images using filters. Should you use one, please note that if you use it with the `| resize(w, h)` function, your supplied dimensions will be ignored *if* they are out of bounds of the constraints. If the supplied dimensions are within the constraints, the image will be displayed at the supplied dimensions **Using the library in PHP** @@ -127,31 +128,10 @@ Should you want to implement your own use of this library outside of twig, you c ``` $resizer = new \ABWebDevelopers\ImageResize\Classes\Resizer($image); - -// v1: $resizer->resize(800, 250, [ 'rotate' => 45 ]); -$resizer->render(); // aborts script - -// v2: -$resizer->resize(800, 250, [ - 'rotate' => 45 -]); // will not resize, only return a URL of the image -$resize->doResize(); // required to actually perform the resize -$resizer->render(); // aborts script -``` - -or - -``` -// The :using() method requires a fully qualified path to the file (it will not guess or try fix it unlike the example above) -\ABWebDevelopers\ImageResize\Classes\Resizer::using('/resolvable/path/to/file') - // ->setHash($hash) // optional usage - // ->setOptions($config['options']) // optional usage - ->resize(800, 250, [ 'rotate' => 45, ]) - ->doResize() - ->render(); +// $resizer->render(); // only use this if you intend on aborting the script immediately at this point ``` Which is synonymous to: diff --git a/classes/Resizer.php b/classes/Resizer.php index ff00873..94c8185 100644 --- a/classes/Resizer.php +++ b/classes/Resizer.php @@ -6,6 +6,8 @@ use Cache; use Carbon\Carbon; use Exception; +use Event; +use File; use Intervention\Image\ImageManagerStatic as Image; use Validator; @@ -165,7 +167,7 @@ protected function getDefaultImage(): string // If the image still doesn't exist, use the provided Image Not Found image if (!$image || !file_exists($image)) { - $image = base_path(Settings::DEFAULT_IMAGE_NOT_FOUND); + $image = Settings::getDefaultImageNotFound(true); } // Use the default Image Not Found background, mode and quality @@ -318,76 +320,77 @@ private function initOptions(array $options = null) } /** - * Get the absolute physical path of the resized image + * Get the absolute physical path of the image * * @return string */ public function getAbsolutePath(): string { - return storage_path($this->getRelativePath()); + return base_path($this->getRelativePath()); } /** - * Get the path (of resized image) relative to the storage directory + * Get the path relative to the base directory * * @return string */ private function getRelativePath(): string { - return 'app/media/imageresizecache/' - . substr($this->hash, 0, 3) . '/' - . substr($this->hash, 3, 3) . '/' - . substr($this->hash, 6, 3) . '/' - . $this->hash; + $rel = '/' . substr($this->hash, 0, 3) . + '/' . substr($this->hash, 3, 3) . + '/' . substr($this->hash, 6, 3) . + '/' . $this->hash; + + return Settings::getBasePath($rel); } /** - * Get the URL for resizing this image for the first time + * Get the URL for resizing this image for the first time. * * @return string */ - public function getTempImageUrl(): string + public function getFirstTimeUrl(): string { return '/imageresize/' . $this->hash; } /** - * Get the URL of the resized (and cached) image + * Get the URL of the resized (and cached) image. + * + * Simply returns a relative URL to the website. * * @return string */ - public function hasCachedImageUrl(): string + public function getCacheUrl(): string { - return '/storage/' . $this->getRelativePath(); + return '/' . $this->getRelativePath(); } /** * Store the configuration in the cache, and retrieve the URL * - * @return string + * @return bool|string */ - public function getImageUrl(): string + public function storeCacheAndgetFirstTimeUrl() { - if ($this->hasCachedImage()) { - return $this->hasCachedImageUrl(); - } - - Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addMinute(), function () { + Cache::remember(static::CACHE_PREFIX . $this->hash, Carbon::now()->addWeek(), function () { return [ 'image' => $this->image, 'options' => $this->options, ]; }); - return $this->getTempImageUrl(); + $cacheExists = $this->hasStoredFile(); + + return ($cacheExists) ? $this->getCacheUrl() : $this->getFirstTimeUrl(); } /** - * Store the modified image to file + * Set the cache, storing the modified image to file and return the public facing path to it * * @return $this */ - public function saveImageToFile() + public function storeResizedImage() { // Get absolute path $path = $this->getAbsolutePath(); @@ -407,9 +410,8 @@ public function saveImageToFile() /** * Resize - Optionally resize the image, and/or modify the image with options. * - * Contrary to function name, this [as of v2.0] only returns a publicly accessible - * URL for the image if not resized yet (which is where/when the resizing occurs), - * or, will return the image path to the resized image if already resized. + * Contrary to function name, this [as of v2.0] only returns a publicly accessible URL for the image. + * Resizing happens in the public endpoint. * * @param int $width * @param int $height @@ -429,7 +431,7 @@ public function resize(int $width = null, int $height = null, array $options = n $this->initOptions($options); // Get cache if exists - return $this->getImageUrl(); + return $this->storeCacheAndgetFirstTimeUrl(); } /** @@ -437,7 +439,7 @@ public function resize(int $width = null, int $height = null, array $options = n * * @return bool */ - public function hasCachedImage(): bool + public function hasStoredFile(): bool { return file_exists($this->getAbsolutePath()); } @@ -447,7 +449,7 @@ public function hasCachedImage(): bool * * @return bool */ - public function shouldCacheImage(): bool + public function shouldCache(): bool { return !isset($this->options['cache']) || (bool) $this->options['cache']; } @@ -459,7 +461,7 @@ public function shouldCacheImage(): bool */ public function doResize() { - if ($this->shouldCacheImage() && $this->hasCachedImage()) { + if ($this->shouldCache() && $this->hasStoredFile()) { return $this; } @@ -607,7 +609,10 @@ public function doResize() $this->options['background'] = '#fff'; } - $this->saveImageToFile(); + // Run the modifications on the image + $this->modify(); + + $this->storeResizedImage(); return $this; } @@ -817,4 +822,36 @@ public function render(): void echo file_get_contents($this->getAbsolutePath()); exit(); } + + /** + * Delete all cached images. + * + * @param Carbon|null $minAge Optional minimum age (delete before this date), `null` for all files. + * @return int Number of files deleted + */ + public static function clearFiles(Carbon $minAge = null): int + { + $files = collect(File::allFiles(Settings::getBasePath())) + ->transform(function ($file) use ($minAge) { + $delete = true; + + // If a custom time was given, only delete if the file is older + if ($minAge !== null) { + $mtime = Carbon::createFromTimestamp($file->getMTime()); + $delete = $mtime->lte($minAge); + } + + return ($delete) ? $file->getRealPath() : null; + }) + ->filter() + ->toArray(); + + // Fire event to hook into and modify $files before deleting + Event::fire('abweb.imageresize.clearFiles', [ &$files, $minAge ]); + + // Delete the files + File::delete($files); + + return count($files); + } } diff --git a/commands/ImageResizeClear.php b/commands/ImageResizeClear.php new file mode 100644 index 0000000..9ce8b34 --- /dev/null +++ b/commands/ImageResizeClear.php @@ -0,0 +1,25 @@ +modify('-' . Settings::getAgeToDelete()); + + $deleted = Resizer::clearFiles($minAge); + + $this->info('Successfully deleted ' . $deleted . ' ' . Str::plural('file', $deleted)); + } +} diff --git a/models/Settings.php b/models/Settings.php index 3c1e185..4ae046c 100644 --- a/models/Settings.php +++ b/models/Settings.php @@ -10,8 +10,15 @@ class Settings extends Model { use Validation; + /** @var string The default Image Not Found image, @see ::getDefaultImageNotFound() */ public const DEFAULT_IMAGE_NOT_FOUND = 'plugins/abwebdevelopers/imageresize/assets/image-not-found.png'; + /** @var string Storage path for cached images, @see ::getBasePath() */ + public const DEFAULT_STORAGE_PATH = 'storage/app/media/imageresizecache'; + + /** @var string The age a cached file must be before scheduled deletion, @see ::getAgeToDelete() */ + public const DEFAULT_CACHE_CLEAR_AGE = '12 hours'; + /** * Implement settings model * @@ -181,4 +188,56 @@ public function beforeSave() } } } + + /** + * Retrieve the default "Not Found" image path + * + * @param bool $absolute Return absolute path? + * @return string + */ + public static function getDefaultImageNotFound(bool $absolute = false): string + { + $path = static::DEFAULT_IMAGE_NOT_FOUND; + + if ($absolute) { + $path = (substr($path, 0, 1) === '/') ? $path : base_path($path); + } + + return $path; + } + + /** + * Get the base path for all cached images. + * + * Similarly to base_path(), storage_path(), etc, you can provide a subdir path + * as the first argument. This function will never return a trailing slash + * unless you provide it in the first argument. + * + * @return string + */ + public static function getBasePath(string $subdirectoryPath = null, bool $absolute = false): string + { + $path = rtrim(static::DEFAULT_STORAGE_PATH, '/'); + + if ($subdirectoryPath !== null) { + $path .= '/' . $subdirectoryPath; + } + + if ($absolute) { + $path = (substr($path, 0, 1) === '/') ? $path : base_path($path); + } + + return $path; + } + + /** + * Get the age a cached file must be before scheduled deletion. + * Equivalent to: `->modify("-{$age}")` (now minus the age) + * + * @return string + */ + public static function getAgeToDelete(): string + { + return static::DEFAULT_CACHE_CLEAR_AGE; + } } diff --git a/routes.php b/routes.php index 4f6644c..2c0e4dd 100644 --- a/routes.php +++ b/routes.php @@ -1,8 +1,6 @@ Date: Thu, 30 Jan 2020 11:06:25 +0800 Subject: [PATCH 8/9] Change to daily cron --- Plugin.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugin.php b/Plugin.php index d1f129c..740ff9c 100644 --- a/Plugin.php +++ b/Plugin.php @@ -102,6 +102,6 @@ public function register() */ public function registerSchedule($schedule) { - $schedule->command('imageresize:clear')->everyMinute(); + $schedule->command('imageresize:clear')->daily(); } } From 0ea3bb57779a2f4f811850349081925f4eef92e8 Mon Sep 17 00:00:00 2001 From: Bradie Tilley Date: Thu, 30 Jan 2020 12:53:48 +0800 Subject: [PATCH 9/9] Add Gc command to delete old images, Clear command to delete all images --- Plugin.php | 4 +++- commands/ImageResizeClear.php | 8 ++------ commands/ImageResizeGc.php | 25 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 commands/ImageResizeGc.php diff --git a/Plugin.php b/Plugin.php index 740ff9c..1448c59 100644 --- a/Plugin.php +++ b/Plugin.php @@ -5,6 +5,7 @@ use System\Classes\PluginBase; use ABWebDevelopers\ImageResize\Classes\Resizer; use ABWebDevelopers\ImageResize\Commands\ImageResizeClear; +use ABWebDevelopers\ImageResize\Commands\ImageResizeGc; use Event; class Plugin extends PluginBase @@ -94,6 +95,7 @@ public function boot() */ public function register() { + $this->registerConsoleCommand('imageresize:gc', ImageResizeGc::class); $this->registerConsoleCommand('imageresize:clear', ImageResizeClear::class); } @@ -102,6 +104,6 @@ public function register() */ public function registerSchedule($schedule) { - $schedule->command('imageresize:clear')->daily(); + $schedule->command('imageresize:gc')->daily(); } } diff --git a/commands/ImageResizeClear.php b/commands/ImageResizeClear.php index 9ce8b34..7a275c1 100644 --- a/commands/ImageResizeClear.php +++ b/commands/ImageResizeClear.php @@ -3,8 +3,6 @@ namespace ABWebDevelopers\ImageResize\Commands; use ABWebDevelopers\ImageResize\Classes\Resizer; -use ABWebDevelopers\ImageResize\Models\Settings; -use Carbon\Carbon; use Illuminate\Console\Command; use Illuminate\Support\Str; @@ -12,13 +10,11 @@ class ImageResizeClear extends Command { protected $name = 'imageresize:clear'; - protected $description = 'Clear all resized images'; + protected $description = 'Clear all resized images.'; public function handle() { - $minAge = Carbon::now()->modify('-' . Settings::getAgeToDelete()); - - $deleted = Resizer::clearFiles($minAge); + $deleted = Resizer::clearFiles(); $this->info('Successfully deleted ' . $deleted . ' ' . Str::plural('file', $deleted)); } diff --git a/commands/ImageResizeGc.php b/commands/ImageResizeGc.php new file mode 100644 index 0000000..46e92b6 --- /dev/null +++ b/commands/ImageResizeGc.php @@ -0,0 +1,25 @@ +modify('-' . Settings::getAgeToDelete()); + + $deleted = Resizer::clearFiles($minAge); + + $this->info('Successfully deleted ' . $deleted . ' ' . Str::plural('file', $deleted)); + } +}