Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.82% covered (danger)
37.82%
73 / 193
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageProcessingService
37.82% covered (danger)
37.82%
73 / 193
40.00% covered (danger)
40.00%
4 / 10
246.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 processUploadedFiles
60.98% covered (warning)
60.98%
25 / 41
0.00% covered (danger)
0.00%
0 / 1
6.49
 createImageFromFile
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 isArchiveFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 processArchive
45.90% covered (danger)
45.90%
28 / 61
0.00% covered (danger)
0.00%
0 / 1
18.13
 processSingleImage
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 addImageToGallery
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 mergeResults
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateResultMessage
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
7.61
 getUploadErrorMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Service;
5
6use App\Model\Entity\Image;
7use App\Model\Table\ImageGalleriesImagesTable;
8use App\Model\Table\ImagesTable;
9use App\Utility\ArchiveExtractor;
10use Cake\Log\LogTrait;
11use Exception;
12use Laminas\Diactoros\UploadedFile;
13use Psr\Http\Message\UploadedFileInterface;
14
15/**
16 * ImageProcessingService Class
17 *
18 * This service handles the upload and processing of image files, including:
19 * - Single image uploads
20 * - Archive extraction and batch processing
21 * - Gallery association for uploaded images
22 * - Integration with QueueableImageBehavior for automatic processing
23 *
24 * The service consolidates duplicate image upload logic from ImageGalleriesController
25 * and ImagesController, providing a reusable, testable solution.
26 */
27class ImageProcessingService
28{
29    use LogTrait;
30
31    /**
32     * @var \App\Model\Table\ImagesTable
33     */
34    private ImagesTable $imagesTable;
35
36    /**
37     * @var \App\Model\Table\ImageGalleriesImagesTable
38     */
39    private ImageGalleriesImagesTable $galleriesImagesTable;
40
41    /**
42     * @var \App\Utility\ArchiveExtractor
43     */
44    private ArchiveExtractor $archiveExtractor;
45
46    /**
47     * ImageProcessingService constructor.
48     *
49     * @param \App\Model\Table\ImagesTable $imagesTable The images table instance
50     * @param \App\Model\Table\ImageGalleriesImagesTable $galleriesImagesTable The galleries images junction table
51     * @param \App\Utility\ArchiveExtractor $archiveExtractor The archive extractor utility
52     */
53    public function __construct(
54        ImagesTable $imagesTable,
55        ImageGalleriesImagesTable $galleriesImagesTable,
56        ArchiveExtractor $archiveExtractor,
57    ) {
58        $this->imagesTable = $imagesTable;
59        $this->galleriesImagesTable = $galleriesImagesTable;
60        $this->archiveExtractor = $archiveExtractor;
61    }
62
63    /**
64     * Process an array of uploaded files, handling both individual images and archives
65     *
66     * @param array $uploadedFiles Array of uploaded files
67     * @param string|null $galleryId Optional gallery ID to associate images with
68     * @return array Result array with success/error counts and details
69     */
70    public function processUploadedFiles(array $uploadedFiles, ?string $galleryId = null): array
71    {
72        $results = [
73            'success' => true,
74            'total_processed' => 0,
75            'success_count' => 0,
76            'error_count' => 0,
77            'created_images' => [],
78            'errors' => [],
79            'message' => '',
80        ];
81
82        foreach ($uploadedFiles as $uploadedFile) {
83            if ($uploadedFile->getError() !== UPLOAD_ERR_OK) {
84                $results['errors'][] = [
85                    'file' => $uploadedFile->getClientFilename(),
86                    'error' => 'Upload error: ' . $this->getUploadErrorMessage($uploadedFile->getError()),
87                ];
88                $results['error_count']++;
89                continue;
90            }
91
92            try {
93                if ($this->isArchiveFile($uploadedFile)) {
94                    $archiveResult = $this->processArchive($uploadedFile, $galleryId);
95                    $this->mergeResults($results, $archiveResult);
96                } else {
97                    $imageResult = $this->processSingleImage($uploadedFile, $galleryId);
98                    $this->mergeResults($results, $imageResult);
99                }
100            } catch (Exception $e) {
101                $this->log(
102                    sprintf(
103                        'Error processing uploaded file %s: %s',
104                        $uploadedFile->getClientFilename(),
105                        $e->getMessage(),
106                    ),
107                    'error',
108                    ['group_name' => 'ImageUploadService'],
109                );
110
111                $results['errors'][] = [
112                    'file' => $uploadedFile->getClientFilename(),
113                    'error' => $e->getMessage(),
114                ];
115                $results['error_count']++;
116            }
117        }
118
119        $results['total_processed'] = $results['success_count'] + $results['error_count'];
120        $results['success'] = $results['success_count'] > 0;
121        $results['message'] = $this->generateResultMessage($results);
122
123        return $results;
124    }
125
126    /**
127     * Create and save an image entity from an uploaded file
128     *
129     * @param \Psr\Http\Message\UploadedFileInterface $uploadedFile The uploaded file
130     * @param string|null $galleryId Optional gallery ID to associate the image with
131     * @return \App\Model\Entity\Image|null The created image entity or null on failure
132     */
133    public function createImageFromFile(UploadedFileInterface $uploadedFile, ?string $galleryId = null): ?Image
134    {
135        $filename = $uploadedFile->getClientFilename();
136
137        // Create new image entity
138        $imageEntity = $this->imagesTable->newEntity([
139            'name' => pathinfo($filename, PATHINFO_FILENAME),
140            'image' => $uploadedFile,
141        ], ['validate' => 'create']);
142
143        $this->log(sprintf('Attempting to save image: %s', $filename), 'info', ['group_name' => 'ImageUploadService']);
144
145        if ($this->imagesTable->save($imageEntity)) {
146            $this->log(
147                sprintf('Successfully created image "%s" (ID: %s)', $imageEntity->name, $imageEntity->id),
148                'info',
149                ['group_name' => 'ImageUploadService'],
150            );
151
152            // Add to gallery if gallery ID provided
153            if ($galleryId && !$this->addImageToGallery($imageEntity, $galleryId)) {
154                $this->log(
155                    sprintf('Failed to add image %s to gallery %s', $imageEntity->id, $galleryId),
156                    'warning',
157                    ['group_name' => 'ImageUploadService'],
158                );
159            }
160
161            return $imageEntity;
162        }
163
164        $this->log(
165            sprintf('Failed to save image "%s". Errors: %s', $filename, json_encode($imageEntity->getErrors())),
166            'error',
167            ['group_name' => 'ImageUploadService'],
168        );
169        $this->log(
170            sprintf('Image entity validation errors: %s', json_encode($imageEntity->getErrors())),
171            'error',
172            ['group_name' => 'ImageUploadService'],
173        );
174
175        return null;
176    }
177
178    /**
179     * Check if an uploaded file is an archive
180     *
181     * @param \Psr\Http\Message\UploadedFileInterface $uploadedFile
182     * @return bool
183     */
184    private function isArchiveFile(UploadedFileInterface $uploadedFile): bool
185    {
186        $filename = $uploadedFile->getClientFilename();
187        $extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
188
189        return in_array($extension, $this->archiveExtractor->getSupportedArchiveTypes()) ||
190               ($extension === 'gz' && strpos($filename, '.tar.') !== false);
191    }
192
193    /**
194     * Process an archive file and extract images
195     *
196     * @param \Psr\Http\Message\UploadedFileInterface $uploadedFile
197     * @param string|null $galleryId
198     * @return array
199     */
200    private function processArchive(UploadedFileInterface $uploadedFile, ?string $galleryId): array
201    {
202        $results = [
203            'success_count' => 0,
204            'error_count' => 0,
205            'created_images' => [],
206            'errors' => [],
207        ];
208
209        // Create archives directory if it doesn't exist
210        $archivesDir = TMP . 'archives' . DS;
211        if (!is_dir($archivesDir)) {
212            mkdir($archivesDir, 0755, true);
213        }
214
215        // Save uploaded file to temporary location
216        $tempPath = $archivesDir . uniqid() . '_' . $uploadedFile->getClientFilename();
217        $uploadedFile->moveTo($tempPath);
218
219        $tempDir = null;
220        try {
221            // Extract archive
222            $extractedFiles = $this->archiveExtractor->extract($tempPath);
223            $tempDir = dirname($extractedFiles[0] ?? ''); // Get the temp directory from the first file
224
225            $this->log(
226                sprintf(
227                    'Extracted %d files from archive "%s"',
228                    count($extractedFiles),
229                    $uploadedFile->getClientFilename(),
230                ),
231                'info',
232                ['group_name' => 'ImageUploadService'],
233            );
234
235            foreach ($extractedFiles as $extractedFile) {
236                try {
237                    $filename = basename($extractedFile);
238
239                    // Create proper UploadedFile object for each extracted file
240                    $fileUploadedFile = new UploadedFile(
241                        $extractedFile, // stream/file path
242                        filesize($extractedFile), // size
243                        UPLOAD_ERR_OK, // error
244                        $filename, // client filename
245                        mime_content_type($extractedFile), // client media type
246                    );
247
248                    $imageEntity = $this->createImageFromFile($fileUploadedFile, $galleryId);
249
250                    if ($imageEntity) {
251                        $results['created_images'][] = [
252                            'id' => $imageEntity->id,
253                            'name' => $imageEntity->name,
254                        ];
255                        $results['success_count']++;
256                    } else {
257                        $results['errors'][] = [
258                            'file' => $filename,
259                            'error' => 'Failed to create image entity',
260                        ];
261                        $results['error_count']++;
262                    }
263                } catch (Exception $e) {
264                    $filename = basename($extractedFile);
265                    $this->log(
266                        sprintf('Error processing extracted file "%s": %s', $filename, $e->getMessage()),
267                        'error',
268                        ['group_name' => 'ImageUploadService'],
269                    );
270
271                    $results['errors'][] = [
272                        'file' => $filename,
273                        'error' => $e->getMessage(),
274                    ];
275                    $results['error_count']++;
276                }
277            }
278        } finally {
279            // Clean up temporary files
280            if (file_exists($tempPath)) {
281                unlink($tempPath);
282            }
283            if ($tempDir && is_dir($tempDir)) {
284                $this->archiveExtractor->cleanup($tempDir);
285            }
286        }
287
288        return $results;
289    }
290
291    /**
292     * Process a single image file
293     *
294     * @param \Psr\Http\Message\UploadedFileInterface $uploadedFile
295     * @param string|null $galleryId
296     * @return array
297     */
298    private function processSingleImage(UploadedFileInterface $uploadedFile, ?string $galleryId): array
299    {
300        $imageEntity = $this->createImageFromFile($uploadedFile, $galleryId);
301
302        if ($imageEntity) {
303            return [
304                'success_count' => 1,
305                'error_count' => 0,
306                'created_images' => [[
307                    'id' => $imageEntity->id,
308                    'name' => $imageEntity->name,
309                ]],
310                'errors' => [],
311            ];
312        }
313
314        return [
315            'success_count' => 0,
316            'error_count' => 1,
317            'created_images' => [],
318            'errors' => [[
319                'file' => $uploadedFile->getClientFilename(),
320                'error' => 'Failed to create image entity',
321            ]],
322        ];
323    }
324
325    /**
326     * Add an image to a gallery
327     *
328     * @param \App\Model\Entity\Image $image
329     * @param string $galleryId
330     * @return bool
331     */
332    private function addImageToGallery(Image $image, string $galleryId): bool
333    {
334        // Check if image is already in gallery
335        $exists = $this->galleriesImagesTable->exists([
336            'image_gallery_id' => $galleryId,
337            'image_id' => $image->id,
338        ]);
339
340        if ($exists) {
341            return true; // Already in gallery
342        }
343
344        $position = $this->galleriesImagesTable->getNextPosition($galleryId);
345        $galleryImage = $this->galleriesImagesTable->newEntity([
346            'image_gallery_id' => $galleryId,
347            'image_id' => $image->id,
348            'position' => $position,
349        ]);
350
351        return (bool)$this->galleriesImagesTable->save($galleryImage);
352    }
353
354    /**
355     * Merge results from individual processing into main results
356     *
357     * @param array $mainResults
358     * @param array $subResults
359     * @return void
360     */
361    private function mergeResults(array &$mainResults, array $subResults): void
362    {
363        $mainResults['success_count'] += $subResults['success_count'];
364        $mainResults['error_count'] += $subResults['error_count'];
365        $mainResults['created_images'] = array_merge($mainResults['created_images'], $subResults['created_images']);
366        $mainResults['errors'] = array_merge($mainResults['errors'], $subResults['errors']);
367    }
368
369    /**
370     * Generate a user-friendly result message
371     *
372     * @param array $results
373     * @return string
374     */
375    private function generateResultMessage(array $results): string
376    {
377        if ($results['success_count'] === 0) {
378            return __('No images were processed successfully.');
379        }
380
381        if ($results['error_count'] === 0) {
382            return __('Successfully processed {0} image(s).', $results['success_count']);
383        }
384
385        return __(
386            'Successfully processed {0} of {1} image(s). {2} had errors.',
387            $results['success_count'],
388            $results['total_processed'],
389            $results['error_count'],
390        );
391    }
392
393    /**
394     * Get human-readable upload error message
395     *
396     * @param int $errorCode
397     * @return string
398     */
399    private function getUploadErrorMessage(int $errorCode): string
400    {
401        return match ($errorCode) {
402            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'File size exceeds limit',
403            UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
404            UPLOAD_ERR_NO_FILE => 'No file was uploaded',
405            UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
406            UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
407            UPLOAD_ERR_EXTENSION => 'Upload stopped by extension',
408            default => 'Unknown upload error',
409        };
410    }
411}