Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 205
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImagesController
0.00% covered (danger)
0.00%
0 / 205
0.00% covered (danger)
0.00%
0 / 10
1260
0.00% covered (danger)
0.00%
0 / 1
 viewClasses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 index
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 view
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 imageSelect
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 add
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 bulkUpload
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
56
 edit
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 delete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 deleteUploadedImage
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 picker
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare(strict_types=1);
3
4namespace App\Controller\Admin;
5
6use App\Controller\AppController;
7use App\Controller\Component\MediaPickerTrait;
8use App\Service\ImageProcessingService;
9use App\Utility\ArchiveExtractor;
10use Cake\Http\Exception\NotFoundException;
11use Cake\Http\Response;
12use Cake\View\JsonView;
13use Exception;
14// use Cake\Http\Exception\BadRequestException; // Not strictly needed if handling error codes directly
15
16/**
17 * Images Controller
18 *
19 * Manages CRUD operations for images and handles image selection for the Trumbowyg and Markdown-It editors.
20 *
21 * @property \App\Model\Table\ImagesTable $Images
22 */
23class ImagesController extends AppController
24{
25    use MediaPickerTrait;
26
27    /**
28     * Specifies the view classes supported by this controller.
29     */
30    public function viewClasses(): array
31    {
32        return [JsonView::class];
33    }
34
35    /**
36     * Lists images with support for standard and AJAX requests.
37     *
38     * @return \Cake\Http\Response The response object containing the rendered view.
39     */
40    public function index(): Response
41    {
42        $session = $this->request->getSession();
43        $viewType = $this->request->getQuery('view');
44
45        // Check if view type is provided in the query, otherwise use session value or default to 'list'
46        if ($viewType) {
47            $session->write('Images.viewType', $viewType);
48        } else {
49            $viewType = $session->read('Images.viewType', 'grid');
50        }
51
52        $query = $this->Images->find()
53            ->select([
54                'Images.id',
55                'Images.name',
56                'Images.image',
57                'Images.dir',
58                'Images.alt_text',
59                'Images.keywords',
60                'Images.created',
61                'Images.modified',
62            ]);
63
64        $search = $this->request->getQuery('search');
65        if (!empty($search)) {
66            $query->where([
67                'OR' => [
68                    'name LIKE' => '%' . $search . '%',
69                    'alt_text LIKE' => '%' . $search . '%',
70                    'keywords LIKE' => '%' . $search . '%',
71                ],
72            ]);
73        }
74        $images = $this->paginate($query);
75        if ($this->request->is('ajax')) {
76            $this->set(compact('images', 'viewType', 'search'));
77            $this->viewBuilder()->setLayout('ajax');
78
79            return $this->render('search_results');
80        }
81        $this->set(compact('images', 'viewType'));
82
83        return $this->render($viewType === 'grid' ? 'index_grid' : 'index');
84    }
85
86    /**
87     * Displays details of a specific image.
88     *
89     * @param string|null $id Image id.
90     * @return void
91     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
92     */
93    public function view(?string $id = null): void
94    {
95        $image = $this->Images->get($id, contain: []);
96        $this->set(compact('image'));
97    }
98
99    /**
100     * Handles the selection of images for the Trumbowyg editor.
101     *
102     * This method sets up pagination for the images with a maximum limit of 8 per page.
103     * It allows searching through images based on their name, alt text, or keywords.
104     * The search query is retrieved from the request's query parameters.
105     *
106     * If a search term is provided, it filters the images accordingly.
107     * The filtered images are then paginated and set to be available in the view.
108     *
109     * Additionally, it checks if only the gallery should be loaded based on the 'gallery_only'
110     * query parameter. If true, it sets the template to 'image_gallery' and uses a minimal layout.
111     * Otherwise, it uses a minimal layout without changing the template.
112     *
113     * @return void
114     */
115    public function imageSelect(): void
116    {
117        $limit = min((int)$this->request->getQuery('limit', 12), 24);
118        $this->paginate = [
119            'limit' => $limit,
120            'maxLimit' => 24,
121            'order' => ['Images.created' => 'DESC'],
122        ];
123
124        $query = $this->Images->find();
125        $search = $this->request->getQuery('search');
126        if (!empty($search)) {
127            $query->where([
128                'OR' => [
129                    'Images.name LIKE' => '%' . $search . '%',
130                    'Images.alt_text LIKE' => '%' . $search . '%',
131                    'Images.keywords LIKE' => '%' . $search . '%',
132                ],
133            ]);
134        }
135
136        $images = $this->paginate($query);
137        $this->set(compact('images', 'search'));
138
139        // Check if this is a search request that should only return results HTML
140        $galleryOnly = $this->request->getQuery('gallery_only');
141        if ($galleryOnly) {
142            // For search requests, only return the results portion to avoid flicker
143            $this->viewBuilder()->setTemplate('image_select_results');
144        } else {
145            // For initial load, return the full template with search form
146            $this->viewBuilder()->setTemplate('image_select');
147        }
148
149        $this->viewBuilder()->setLayout('ajax');
150    }
151
152    /**
153     * Adds a new image.
154     *
155     * This method handles the creation of a new image entity. It uses the 'create'
156     * validation ruleset when processing the submitted form data. On successful save,
157     * it redirects to the index action. If the save fails, it displays an error message.
158     *
159     * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
160     */
161    public function add(): ?Response
162    {
163        $image = $this->Images->newEmptyEntity();
164        if ($this->request->is('post')) {
165            $image = $this->Images->patchEntity($image, $this->request->getData(), ['validate' => 'create']);
166            if ($this->Images->save($image)) {
167                $this->Flash->success(__('The image has been saved.'));
168
169                return $this->redirect(['action' => 'index']);
170            }
171            $this->Flash->error(__('The image could not be saved. Please, try again.'));
172        }
173        $this->set(compact('image'));
174
175        return null;
176    }
177
178    /**
179     * Handles bulk upload of images, showing the upload form for GET requests
180     * and processing AJAX uploads for POST requests.
181     *
182     * @return \Cake\Http\Response|null Returns Response for AJAX requests, void for GET
183     */
184    public function bulkUpload(): ?Response
185    {
186        $this->request->allowMethod(['get', 'post']);
187        $response = $this->getResponse(); // Use getResponse() to get the current response object
188
189        if ($this->request->is('get')) {
190            // Render the bulk_upload.php template by default for GET
191            return null;
192        }
193
194        // AJAX POST request handling
195        // JsonView will be used automatically due to viewClasses() and Accept header
196
197        $uploadedFile = $this->request->getUploadedFile('image');
198
199        // Default error
200        $apiResponse = [
201            'success' => false,
202            'message' => __('An unexpected error occurred.'),
203        ];
204        $statusCode = 500;
205
206        if (!$uploadedFile) {
207            $apiResponse['message'] = __('No file was uploaded or file key "image" is missing.');
208            $statusCode = 400; // Bad Request
209        } else {
210            try {
211                $uploadService = new ImageProcessingService(
212                    $this->Images,
213                    $this->fetchTable('ImageGalleriesImages'),
214                    new ArchiveExtractor(),
215                );
216
217                $result = $uploadService->processUploadedFiles([$uploadedFile]);
218
219                if ($result['success']) {
220                    if ($result['success_count'] === 1) {
221                        // Single image uploaded successfully
222                        $image = $result['created_images'][0];
223                        $apiResponse = [
224                            'success' => true,
225                            'message' => __('Image "{0}" uploaded successfully.', $image['name']),
226                            'image' => $image,
227                        ];
228                        $statusCode = 201; // Created
229                    } else {
230                        // Multiple images from archive
231                        $apiResponse = [
232                            'success' => true,
233                            'message' => $result['message'],
234                            'images' => $result['created_images'],
235                            'total_processed' => $result['total_processed'],
236                            'success_count' => $result['success_count'],
237                            'error_count' => $result['error_count'],
238                        ];
239
240                        if ($result['error_count'] > 0) {
241                            $apiResponse['errors'] = $result['errors'];
242                        }
243
244                        $statusCode = 201; // Created
245                    }
246                } else {
247                    // Failed to process
248                    $apiResponse = [
249                        'success' => false,
250                        'message' => $result['message'],
251                        'errors' => $result['errors'],
252                    ];
253                    $statusCode = 422; // Unprocessable Entity
254                }
255            } catch (Exception $e) {
256                $this->log("Bulk upload: Processing failed: {$e->getMessage()}", 'error');
257                $apiResponse = [
258                    'success' => false,
259                    'message' => __('Failed to process the uploaded file.'),
260                ];
261                $statusCode = 500;
262            }
263        }
264
265        // Suggestion 3: Use setOption('serialize', ...)
266        $this->set($apiResponse);
267        $this->viewBuilder()->setOption('serialize', array_keys($apiResponse));
268
269        return $response->withStatus($statusCode); // Suggestion 4: Return appropriate HTTP status
270    }
271
272    /**
273     * Edits an existing image.
274     *
275     * @param string|null $id Image id.
276     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
277     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
278     */
279    public function edit(?string $id = null): ?Response
280    {
281        $image = $this->Images->get($id, contain: []);
282        if ($this->request->is(['patch', 'post', 'put'])) {
283            $image = $this->Images->patchEntity($image, $this->request->getData(), ['validate' => 'update']);
284            if ($this->Images->save($image)) {
285                $this->Flash->success(__('The image has been saved.'));
286
287                return $this->redirect(['action' => 'index']);
288            }
289            $this->Flash->error(__('The image could not be saved. Please, try again.'));
290        }
291        $this->set(compact('image'));
292
293        return null;
294    }
295
296    /**
297     * Deletes an image.
298     *
299     * @param string|null $id Image id.
300     * @return \Cake\Http\Response Redirects to index.
301     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
302     */
303    public function delete(?string $id = null): Response
304    {
305        $this->request->allowMethod(['post', 'delete']);
306        $image = $this->Images->get($id);
307        if ($this->Images->delete($image)) {
308            $this->Flash->success(__('The image has been deleted.'));
309        } else {
310            $this->Flash->error(__('The image could not be deleted. Please, try again.'));
311        }
312
313        return $this->redirect($this->referer(['action' => 'index']));
314    }
315
316    /**
317     * Deletes an image that was uploaded via bulk uploader.
318     * Intended to be called by Dropzone's removedfile event.
319     *
320     * @param string|null $id Image id.
321     * @return \Cake\Http\Response
322     * @throws \Cake\Http\Exception\NotFoundException When record not found.
323     * @throws \Cake\Http\Exception\MethodNotAllowedException If not a DELETE request.
324     */
325    public function deleteUploadedImage(?string $id = null): Response
326    {
327        $this->request->allowMethod(['delete']); // Suggestion 9: New action
328        $response = $this->getResponse();
329
330        $apiResponse = [
331            'success' => false,
332            'message' => __('Image could not be deleted.'),
333        ];
334        $statusCode = 500;
335
336        if (!$id) {
337            $apiResponse['message'] = __('No image ID provided.');
338            $statusCode = 400;
339        } else {
340            try {
341                $image = $this->Images->get($id);
342                if ($this->Images->delete($image)) {
343                    // Note: Ensure your ImagesTable->delete() or afterDelete event handles file system deletion.
344                    $apiResponse = [
345                        'success' => true,
346                        'message' => __('Image "{0}" deleted successfully from server.', $image->name),
347                    ];
348                    $statusCode = 200; // OK
349                } else {
350                    $apiResponse['message'] = __('The image database record could not be deleted.');
351                    // Errors might be on $image->getErrors() if the delete was blocked by rules.
352                    if ($image->hasErrors()) {
353                        $apiResponse['errors'] = $image->getErrors();
354                        $statusCode = 422; // If rule-based delete failure
355                    }
356                }
357            } catch (NotFoundException $e) {
358                $apiResponse['message'] = __('Image not found.');
359                $statusCode = 404; // Not Found
360            } catch (Exception $e) {
361                $this->log("Error_deleting_uploaded_image_{$id}" . $e->getMessage(), 'error');
362                $apiResponse['message'] = __('An unexpected error occurred while deleting the image.');
363                $statusCode = 500;
364            }
365        }
366
367        $this->set($apiResponse);
368        $this->viewBuilder()->setOption('serialize', array_keys($apiResponse));
369
370        return $response->withStatus($statusCode);
371    }
372
373    /**
374     * Image picker for selecting images to add to galleries
375     * Uses MediaPickerTrait for DRY implementation
376     *
377     * @return \Cake\Http\Response|null|void Renders view
378     */
379    public function picker(): ?Response
380    {
381        $galleryId = $this->request->getQuery('gallery_id');
382        $viewType = $this->request->getQuery('view', 'grid');
383
384        // Build query with trait helper
385        $selectFields = [
386            'Images.id',
387            'Images.name',
388            'Images.alt_text',
389            'Images.keywords',
390            'Images.image',
391            'Images.dir',
392            'Images.size',
393            'Images.mime',
394            'Images.created',
395            'Images.modified',
396        ];
397
398        $query = $this->buildPickerQuery($this->Images, $selectFields);
399
400        // Apply exclusion filter if gallery_id provided
401        if ($galleryId) {
402            $query = $this->applyPickerExclusion(
403                $query,
404                $this->fetchTable('ImageGalleriesImages'),
405                'image_gallery_id',
406                $galleryId,
407                'image_id',
408            );
409        }
410
411        // Handle search with trait helper
412        $search = $this->request->getQuery('search');
413        $searchFields = [
414            'Images.name',
415            'Images.alt_text',
416            'Images.keywords',
417        ];
418        $query = $this->handlePickerSearch($query, $search, $searchFields);
419
420        $images = $this->paginate($query);
421
422        // Handle AJAX requests with trait helper
423        $ajaxResponse = $this->handlePickerAjaxResponse($images, $search, 'picker_search_results');
424        if ($ajaxResponse) {
425            $this->set(compact('galleryId', 'viewType'));
426
427            return $ajaxResponse;
428        }
429
430        $this->set(compact('images', 'galleryId', 'viewType'));
431
432        // Return appropriate template based on view type
433        return $this->render($viewType === 'grid' ? 'picker_grid' : 'picker');
434    }
435}