Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.29% covered (warning)
64.29%
36 / 56
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueueableImageBehavior
64.29% covered (warning)
64.29%
36 / 56
0.00% covered (danger)
0.00%
0 / 3
22.93
0.00% covered (danger)
0.00%
0 / 1
 initialize
75.86% covered (warning)
75.86%
22 / 29
0.00% covered (danger)
0.00%
0 / 1
3.13
 beforeSave
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
7.39
 afterSave
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
13.26
1<?php
2declare(strict_types=1);
3
4namespace App\Model\Behavior;
5
6use App\Utility\SettingsManager;
7use ArrayObject;
8use Cake\Datasource\EntityInterface;
9use Cake\Event\EventInterface;
10use Cake\ORM\Behavior;
11use Cake\Utility\Text;
12
13/**
14 * QueueableImage Behavior
15 *
16 * This behavior handles file uploads, deletion of old files, and queues image processing
17 * and AI analysis jobs for uploaded images. It integrates with the Josegonzalez/Upload
18 * plugin for file handling.
19 */
20class QueueableImageBehavior extends Behavior
21{
22    /**
23     * Default configuration.
24     *
25     * @var array<string, mixed>
26     */
27    protected array $_defaultConfig = [
28        'folder_path' => 'files/', // Relative path within webroot where images are stored (e.g., 'img/uploads')
29        'field' => 'image', // The entity field name that holds the uploaded file.
30    ];
31
32    /**
33     * Initialize method.
34     *
35     * Sets up the Josegonzalez/Upload behavior configuration for the image field.
36     *
37     * @param array<string, mixed> $config The configuration settings provided to the behavior.
38     * @return void
39     */
40    public function initialize(array $config): void
41    {
42        parent::initialize($config);
43
44        $field = $this->getConfig('field');
45
46        // Prepare Upload behavior configuration
47        $uploadConfig = [
48            $field => [
49                'fields' => [
50                    'dir' => 'dir', // Field to store the directory info (optional)
51                    'size' => 'size', // Field to store the file size
52                    'type' => 'mime', // Field to store the MIME type
53                ],
54                /**
55                 * Callback to generate a unique filename for the uploaded file.
56                 *
57                 * @param \Cake\ORM\Table $table The table instance.
58                 * @param \Cake\Datasource\EntityInterface $entity The entity instance.
59                 * @param array<string, mixed> $data The uploaded file data.
60                 * @param string $field The field name.
61                 * @param array<string, mixed> $settings The behavior settings.
62                 * @return string The generated unique filename.
63                 */
64                'nameCallback' => function ($table, $entity, $data, $field, $settings) {
65                    $file = $entity->{$field};
66                    $clientFilename = $file->getClientFilename();
67                    $ext = pathinfo($clientFilename, PATHINFO_EXTENSION);
68
69                    return Text::uuid() . '.' . strtolower($ext);
70                },
71                /**
72                 * Callback to specify paths to delete when an entity is deleted or updated
73                 * with a new file.
74                 *
75                 * @param string $path The base path where the file is stored.
76                 * @param \Cake\Datasource\EntityInterface $entity The entity instance.
77                 * @param string $field The field name.
78                 * @param array<string, mixed> $settings The behavior settings.
79                 * @return array<string> An array of file paths to delete.
80                 */
81                'deleteCallback' => function ($path, $entity, $field, $settings) {
82                    $paths = [
83                        $path . $entity->{$field}, // Original file path
84                    ];
85
86                    // Add paths for all resized versions based on 'ImageSizes' setting
87                    $imageSizes = SettingsManager::read('ImageSizes', []);
88                    foreach ($imageSizes as $width) {
89                        $paths[] = $path . $width . DS . $entity->{$field};
90                    }
91
92                    return $paths;
93                },
94                'keepFilesOnDelete' => false, // Ensure files are deleted from disk when entity is deleted.
95            ],
96        ];
97
98        // Add the Upload behavior if it's not already added to prevent re-adding.
99        if (!$this->_table->hasBehavior('Josegonzalez/Upload.Upload')) {
100            $this->_table->addBehavior('Josegonzalez/Upload.Upload', $uploadConfig);
101        }
102    }
103
104    /**
105     * beforeSave callback.
106     *
107     * Handles deletion of the old image file and its resized versions when a new image
108     * is uploaded during an entity update.
109     *
110     * @param \Cake\Event\EventInterface $event The event object.
111     * @param \Cake\Datasource\EntityInterface $entity The entity being saved.
112     * @param \ArrayObject $options Options for the save operation.
113     * @return void
114     */
115    public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
116    {
117        if (!$entity->isNew() && $entity->isDirty($this->getConfig('field'))) {
118            $originalImage = $entity->getOriginal($this->getConfig('field'));
119
120            if ($originalImage) {
121                $tableName = $this->_table->getTable();
122                $field = $this->getConfig('field');
123                $basePath = WWW_ROOT . 'files' . DS . ucfirst($tableName) . DS . $field . DS;
124
125                $mainFilePath = $basePath . $originalImage;
126                if (file_exists($mainFilePath)) {
127                    unlink($mainFilePath);
128                }
129
130                $imageSizes = SettingsManager::read('ImageSizes', []);
131                foreach ($imageSizes as $width) {
132                    $resizedPath = $basePath . $width . DS . $originalImage;
133                    if (file_exists($resizedPath)) {
134                        unlink($resizedPath);
135                    }
136                }
137
138                // Clear file stat cache after deletions
139                clearstatcache();
140            }
141        }
142    }
143
144    /**
145     * afterSave callback.
146     *
147     * Queues an image processing job and optionally an AI image analysis job
148     * after an entity with an image is successfully saved.
149     *
150     * @param \Cake\Event\EventInterface $event The event object.
151     * @param \Cake\Datasource\EntityInterface $entity The entity that was saved.
152     * @param \ArrayObject $options Options for the save operation.
153     * @return void
154     */
155    public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
156    {
157        $config = $this->getConfig();
158        // Check if the image field was changed/uploaded in this save operation
159        if ($entity->isDirty($config['field'])) {
160            $data = [
161                'folder_path' => WWW_ROOT . $config['folder_path'],
162                'file' => $entity->{$config['field']},
163                'id' => $entity->id,
164            ];
165
166            // Queue up an image processing job to generate different sizes/versions.
167            $this->_table->queueJob('App\Job\ProcessImageJob', $data);
168
169            // Check if AI features are enabled for image analysis.
170            if (SettingsManager::read('AI.enabled')) {
171                // Add the model alias to the data for AI job.
172                $data['model'] = $event->getSubject()->getAlias();
173
174                // If image analysis is specifically enabled, queue that job.
175                if (SettingsManager::read('AI.imageAnalysis')) {
176                    $this->_table->queueJob('App\Job\ImageAnalysisJob', $data);
177                }
178            }
179        }
180    }
181}