Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.07% covered (warning)
87.07%
128 / 147
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageGalleriesTable
87.07% covered (warning)
87.07%
128 / 147
50.00% covered (danger)
50.00%
4 / 8
28.57
0.00% covered (danger)
0.00%
0 / 1
 initialize
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
1
 validationDefault
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
1
 buildRules
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 afterSave
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
17.69
 beforeDelete
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 queuePreviewGeneration
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
3.19
 getGalleryForPlaceholder
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
5
 imagesChanged
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare(strict_types=1);
3
4namespace App\Model\Table;
5
6use App\Utility\SettingsManager;
7use ArrayObject;
8use Cake\Cache\Cache;
9use Cake\Datasource\EntityInterface;
10use Cake\Event\EventInterface;
11use Cake\Log\LogTrait;
12use Cake\ORM\Behavior\Translate\TranslateTrait;
13use Cake\ORM\RulesChecker;
14use Cake\ORM\Table;
15use Cake\Validation\Validator;
16use Exception;
17
18/**
19 * ImageGalleries Model
20 *
21 * @property \App\Model\Table\ImagesTable&\Cake\ORM\Association\BelongsToMany $Images
22 * @method \App\Model\Entity\ImageGallery newEmptyEntity()
23 * @method \App\Model\Entity\ImageGallery newEntity(array $data, array $options = [])
24 * @method array<\App\Model\Entity\ImageGallery> newEntities(array $data, array $options = [])
25 * @method \App\Model\Entity\ImageGallery get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args)
26 * @method \App\Model\Entity\ImageGallery findOrCreate($search, ?callable $callback = null, array $options = [])
27 * @method \App\Model\Entity\ImageGallery patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
28 * @method array<\App\Model\Entity\ImageGallery> patchEntities(iterable $entities, array $data, array $options = [])
29 * @method \App\Model\Entity\ImageGallery|false save(\Cake\Datasource\EntityInterface $entity, array $options = [])
30 * @method \App\Model\Entity\ImageGallery saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = [])
31 * @method iterable<\App\Model\Entity\ImageGallery>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ImageGallery>|false saveMany(iterable $entities, array $options = [])
32 * @method iterable<\App\Model\Entity\ImageGallery>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ImageGallery> saveManyOrFail(iterable $entities, array $options = [])
33 * @method iterable<\App\Model\Entity\ImageGallery>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ImageGallery>|false deleteMany(iterable $entities, array $options = [])
34 * @method iterable<\App\Model\Entity\ImageGallery>|\Cake\Datasource\ResultSetInterface<\App\Model\Entity\ImageGallery> deleteManyOrFail(iterable $entities, array $options = [])
35 * @method void setLocale(string $locale)
36 * @method string getLocale()
37 * @method object|null getGalleryForPlaceholder(string $galleryId, bool $requirePublished = true, ?string $cacheKey = null)
38 * @mixin \Cake\ORM\Behavior\TimestampBehavior
39 * @mixin \Cake\ORM\Behavior\TranslateBehavior
40 * @mixin \App\Model\Behavior\SlugBehavior
41 */
42class ImageGalleriesTable extends Table
43{
44    use LogTrait;
45    use QueueableJobsTrait;
46    use SeoFieldsTrait;
47    use TranslateTrait;
48
49    /**
50     * Initialize method
51     *
52     * @param array<string, mixed> $config The configuration for the Table.
53     * @return void
54     */
55    public function initialize(array $config): void
56    {
57        parent::initialize($config);
58
59        $this->setTable('image_galleries');
60        $this->setDisplayField('name');
61        $this->setPrimaryKey('id');
62
63        $this->addBehavior('Timestamp');
64        $this->addBehavior('Slug', [
65            'sourceField' => 'name',
66            'targetField' => 'slug',
67            'maxLength' => 255,
68        ]);
69        $this->addBehavior('Translate', [
70            'fields' => [
71                'name',
72                'description',
73                'meta_title',
74                'meta_description',
75                'meta_keywords',
76                'facebook_description',
77                'linkedin_description',
78                'instagram_description',
79                'twitter_description',
80            ],
81            'defaultLocale' => 'en_GB',
82            'allowEmptyTranslations' => false,
83        ]);
84
85        $this->hasMany('ImageGalleriesImages', [
86            'foreignKey' => 'image_gallery_id',
87            'dependent' => true,
88        ]);
89
90        $this->belongsToMany('Images', [
91            'foreignKey' => 'image_gallery_id',
92            'targetForeignKey' => 'image_id',
93            'joinTable' => 'image_galleries_images',
94            'through' => 'ImageGalleriesImages',
95        ]);
96    }
97
98    /**
99     * Default validation rules.
100     *
101     * @param \Cake\Validation\Validator $validator Validator instance.
102     * @return \Cake\Validation\Validator
103     */
104    public function validationDefault(Validator $validator): Validator
105    {
106        $validator
107            ->scalar('name')
108            ->maxLength('name', 255)
109            ->requirePresence('name', 'create')
110            ->notEmptyString('name');
111
112        $validator
113            ->scalar('slug')
114            ->maxLength('slug', 255)
115            ->allowEmptyString('slug');
116
117        $validator
118            ->scalar('description')
119            ->allowEmptyString('description');
120
121        $validator
122            ->boolean('is_published')
123            ->notEmptyString('is_published');
124
125        $validator
126            ->uuid('created_by')
127            ->allowEmptyString('created_by');
128
129        $validator
130            ->uuid('modified_by')
131            ->allowEmptyString('modified_by');
132
133        $validator
134            ->scalar('meta_title')
135            ->maxLength('meta_title', 255)
136            ->allowEmptyString('meta_title');
137
138        $validator
139            ->scalar('meta_description')
140            ->allowEmptyString('meta_description');
141
142        $validator
143            ->scalar('meta_keywords')
144            ->allowEmptyString('meta_keywords');
145
146        $validator
147            ->scalar('facebook_description')
148            ->allowEmptyString('facebook_description');
149
150        $validator
151            ->scalar('linkedin_description')
152            ->allowEmptyString('linkedin_description');
153
154        $validator
155            ->scalar('instagram_description')
156            ->allowEmptyString('instagram_description');
157
158        $validator
159            ->scalar('twitter_description')
160            ->allowEmptyString('twitter_description');
161
162        return $validator;
163    }
164
165    /**
166     * Returns a rules checker object that will be used for validating
167     * application integrity.
168     *
169     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified.
170     * @return \Cake\ORM\RulesChecker
171     */
172    public function buildRules(RulesChecker $rules): RulesChecker
173    {
174        $rules->add($rules->isUnique(['slug']), ['errorField' => 'slug']);
175
176        return $rules;
177    }
178
179    /**
180     * afterSave callback - Queue preview generation job when gallery changes
181     *
182     * @param \Cake\Event\EventInterface $event The event object
183     * @param \Cake\Datasource\EntityInterface $entity The gallery entity
184     * @param \ArrayObject $options Options for the save operation
185     * @return void
186     */
187    public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
188    {
189        // noMessage flag will be true if save came from a Job (stops looping)
190        $noMessage = $options['noMessage'] ?? false;
191
192        // Queue preview generation for new galleries or when images might have changed
193        if ($entity->isNew() || $entity->isDirty('name') || $entity->isDirty('description')) {
194            $this->queuePreviewGeneration($entity->id);
195        }
196
197        // Clear article cache when gallery published status changes
198        // This ensures articles show/hide galleries immediately when status changes
199        if ($entity->isDirty('is_published')) {
200            Cache::clear('content');
201            $this->log(
202                sprintf('Cleared article cache due to gallery %s publish status change', $entity->id),
203                'info',
204                ['group_name' => 'ImageGalleriesTable'],
205            );
206        }
207
208        // Queue AI jobs for published galleries when AI is enabled
209        if (
210            $entity->is_published
211            && SettingsManager::read('AI.enabled')
212            && !$noMessage
213        ) {
214            $data = [
215                'id' => $entity->id,
216                'name' => $entity->name,
217            ];
218
219            // Queue SEO generation job if SEO setting is enabled and there are empty SEO fields
220            if (SettingsManager::read('AI.gallerySEO') && !empty($this->emptySeoFields($entity))) {
221                $this->queueJob('App\\Job\\ImageGallerySeoUpdateJob', $data);
222            }
223
224            // Queue translation job if translations are enabled
225            if (SettingsManager::read('AI.galleryTranslations', false)) {
226                $this->queueJob('App\\Job\\TranslateImageGalleryJob', $data);
227            }
228        }
229    }
230
231    /**
232     * beforeDelete callback - Clean up preview image file
233     *
234     * @param \Cake\Event\EventInterface $event The event object
235     * @param \Cake\Datasource\EntityInterface $entity The gallery entity
236     * @param \ArrayObject $options Options for the delete operation
237     * @return void
238     */
239    public function beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
240    {
241        // Clean up preview image file
242        if ($entity->preview_image) {
243            $previewPath = WWW_ROOT . 'files' . DS . 'ImageGalleries' . DS . 'preview' . DS . $entity->preview_image;
244            if (file_exists($previewPath)) {
245                unlink($previewPath);
246            }
247        }
248
249        // Also clean up gallery ID-based file
250        $galleryPreviewPath = WWW_ROOT . 'files' . DS . 'ImageGalleries' . DS . 'preview' . DS . $entity->id . '.jpg';
251        if (file_exists($galleryPreviewPath)) {
252            unlink($galleryPreviewPath);
253        }
254    }
255
256    /**
257     * Queue preview generation job for a gallery
258     *
259     * @param string $galleryId Gallery ID
260     * @return void
261     */
262    public function queuePreviewGeneration(string $galleryId): void
263    {
264        try {
265            $this->queueJob('App\\Job\\GenerateGalleryPreviewJob', [
266                'gallery_id' => $galleryId,
267            ]);
268        } catch (Exception $e) {
269            $this->log(
270                sprintf('Failed to queue preview generation for gallery %s: %s', $galleryId, $e->getMessage()),
271                'error',
272                ['group_name' => 'App\\Model\\Table\\ImageGalleriesTable'],
273            );
274        }
275    }
276
277    /**
278     * Get a gallery for placeholder rendering with caching
279     *
280     * @param string $galleryId Gallery UUID
281     * @param bool $requirePublished Whether to require the gallery to be published (default: true)
282     * @param string|null $cacheKey Locale-aware cache key from controller
283     * @return \App\Model\Entity\ImageGallery|null Gallery entity or null if not found
284     */
285    public function getGalleryForPlaceholder(
286        string $galleryId,
287        bool $requirePublished = true,
288        ?string $cacheKey = null,
289    ): ?object {
290        // Generate locale-aware cache key if provided, otherwise fall back to static key
291        if ($cacheKey) {
292            $baseKey = $requirePublished
293                ? "gallery_placeholder_{$galleryId}"
294                : "gallery_placeholder_admin_{$galleryId}";
295            $finalCacheKey = $baseKey . $cacheKey;
296        } else {
297            $finalCacheKey = $requirePublished
298                ? "gallery_placeholder_{$galleryId}"
299                : "gallery_placeholder_admin_{$galleryId}";
300        }
301
302        // Debug: Log cache key and locale being used
303        $this->log(
304            sprintf('ImageGalleriesTable: Using cache key %s with locale %s', $finalCacheKey, $this->getLocale()),
305            'debug',
306        );
307
308        $conditions = ['ImageGalleries.id' => $galleryId];
309        if ($requirePublished) {
310            $conditions['ImageGalleries.is_published'] = true;
311        }
312
313        return $this->find()
314            ->cache($finalCacheKey, 'default')
315            ->contain([
316                'Images' => function ($query) {
317                    return $query->where([
318                        'Images.image IS NOT' => null,
319                        'Images.image !=' => '',
320                    ])
321                    ->orderBy(['ImageGalleriesImages.position' => 'ASC']);
322                },
323            ])
324            ->where($conditions)
325            ->first();
326    }
327
328    /**
329     * Check if gallery images have changed since last save
330     *
331     * @param \Cake\Datasource\EntityInterface $entity Gallery entity
332     * @return bool True if images have changed
333     */
334    private function imagesChanged(EntityInterface $entity): bool
335    {
336        // This is a basic check - in practice, image changes happen via the junction table
337        // The ImageGalleriesImagesTable will handle queuing preview regeneration
338        return $entity->isDirty('images') || $entity->isDirty('_joinData');
339    }
340}