Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.67% covered (warning)
66.67%
110 / 165
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ArticlesTable
66.67% covered (warning)
66.67%
110 / 165
70.00% covered (warning)
70.00%
7 / 10
76.81
0.00% covered (danger)
0.00%
0 / 1
 initialize
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
1
 validationDefault
100.00% covered (success)
100.00%
13 / 13
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
 beforeSave
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 afterSave
23.08% covered (danger)
23.08%
6 / 26
0.00% covered (danger)
0.00%
0 / 1
132.52
 getFeatured
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getRootPages
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getMainMenuPages
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getArchiveDates
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 getRecentArticles
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3
4namespace App\Model\Table;
5
6use App\Model\Behavior\ImageValidationTrait;
7use App\Utility\ContentSanitizer;
8use App\Utility\SettingsManager;
9use ArrayObject;
10use Cake\Datasource\EntityInterface;
11use Cake\Event\EventInterface;
12use Cake\Log\LogTrait;
13use Cake\ORM\Behavior\Translate\TranslateTrait;
14use Cake\ORM\RulesChecker;
15use Cake\ORM\Table;
16use Cake\Validation\Validator;
17use DateTime;
18
19/**
20 * Articles Table
21 *
22 * Manages article content with features including:
23 * - Multi-language support
24 * - SEO metadata
25 * - Image handling
26 * - Commenting system
27 * - Page view tracking
28 * - AI-powered content enhancement
29 *
30 * @property \Cake\ORM\Association\BelongsTo $Users
31 * @property \Cake\ORM\Association\BelongsToMany $Tags
32 * @property \Cake\ORM\Association\HasMany $PageViews
33 * @property \Cake\ORM\Association\HasMany $Slugs
34 * @property \Cake\ORM\Association\HasMany $Comments
35 * @method \App\Model\Entity\Article newEmptyEntity()
36 * @method \App\Model\Entity\Article newEntity(array $data, array $options = [])
37 * @method \App\Model\Entity\Article[] newEntities(array $data, array $options = [])
38 * @method \App\Model\Entity\Article get($primaryKey, $options = [])
39 * @method \App\Model\Entity\Article findOrCreate($search, ?callable $callback = null, $options = [])
40 * @method \App\Model\Entity\Article patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = [])
41 * @method \App\Model\Entity\Article[] patchEntities(iterable $entities, array $data, array $options = [])
42 * @method \App\Model\Entity\Article|false save(\Cake\Datasource\EntityInterface $entity, $options = [])
43 * @method void setLocale(string $locale)
44 * @method string getLocale()
45 * @method array getTree(array $conditions = [], array $fields = [])
46 * @method bool reorder(array $data)
47 * @mixin \Cake\ORM\Behavior\TimestampBehavior
48 * @mixin \Cake\ORM\Behavior\TranslateBehavior
49 * @mixin \App\Model\Behavior\CommentableBehavior
50 * @mixin \App\Model\Behavior\OrderableBehavior
51 * @mixin \App\Model\Behavior\SlugBehavior
52 * @mixin \App\Model\Behavior\ImageAssociableBehavior
53 * @mixin \App\Model\Behavior\QueueableImageBehavior
54 */
55class ArticlesTable extends Table
56{
57    use ImageValidationTrait;
58    use LogTrait;
59    use QueueableJobsTrait;
60    use SeoFieldsTrait;
61    use TranslateTrait;
62
63    /**
64     * Initialize method
65     *
66     * Configures table associations, behaviors, and other settings
67     *
68     * @param array<string, mixed> $config Configuration array
69     * @return void
70     */
71    public function initialize(array $config): void
72    {
73        parent::initialize($config);
74
75        $this->setTable('articles');
76        $this->setDisplayField('title');
77        $this->setPrimaryKey('id');
78
79        $this->addBehavior('Timestamp');
80
81        $this->addBehavior('Commentable');
82
83        $this->addBehavior('Orderable', [
84            'displayField' => 'title',
85        ]);
86
87        $this->addBehavior('Slug');
88
89        $this->addBehavior('ImageAssociable');
90
91        $this->addBehavior('QueueableImage', [
92            'folder_path' => 'files/Articles/image/',
93            'field' => 'image',
94        ]);
95
96        $this->addBehavior('Translate', [
97            'fields' => [
98                'title',
99                'body',
100                'summary',
101                'meta_title',
102                'meta_description',
103                'meta_keywords',
104                'facebook_description',
105                'linkedin_description',
106                'instagram_description',
107                'twitter_description',
108            ],
109            'defaultLocale' => 'en_GB',
110            'allowEmptyTranslations' => false,
111        ]);
112
113        $this->belongsTo('Users', [
114            'foreignKey' => 'user_id',
115            'joinType' => 'LEFT',
116        ]);
117        $this->belongsToMany('Tags', [
118            'foreignKey' => 'article_id',
119            'targetForeignKey' => 'tag_id',
120            'joinTable' => 'articles_tags',
121        ]);
122
123        $this->hasMany('PageViews', [
124            'foreignKey' => 'article_id',
125            'dependent' => true,
126            'cascadeCallbacks' => true,
127        ]);
128    }
129
130    /**
131     * Default validation rules
132     *
133     * Sets up validation rules for article fields including:
134     * - User ID validation
135     * - Title requirements
136     * - Body content validation
137     * - Image upload restrictions
138     *
139     * @param \Cake\Validation\Validator $validator Validator instance
140     * @return \Cake\Validation\Validator
141     */
142    public function validationDefault(Validator $validator): Validator
143    {
144        $validator
145            ->uuid('user_id')
146            ->notEmptyString('user_id');
147
148        $validator
149            ->scalar('title')
150            ->maxLength('title', 255)
151            ->requirePresence('title', 'create')
152            ->notEmptyString('title');
153
154        $validator
155            ->scalar('body')
156            ->allowEmptyString('body');
157
158        $this->addOptionalImageValidation($validator, 'image');
159
160        return $validator;
161    }
162
163    /**
164     * Returns a rules checker object that will be used for validating application integrity
165     *
166     * @param \Cake\ORM\RulesChecker $rules The rules object to be modified
167     * @return \Cake\ORM\RulesChecker
168     */
169    public function buildRules(RulesChecker $rules): RulesChecker
170    {
171        $rules->add($rules->existsIn(['user_id'], 'Users'), ['errorField' => 'user_id']);
172
173        return $rules;
174    }
175
176    /**
177     * Before save callback
178     *
179     * Handles:
180     * - Setting publication date when article is published
181     * - Calculating word count for article body
182     *
183     * @param \Cake\Event\EventInterface $event The beforeSave event that was fired
184     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
185     * @param \ArrayObject $options The options passed to the save method
186     * @return void
187     */
188    public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
189    {
190        // Check if is_published has changed to published
191        if ($entity->isDirty('is_published') && $entity->is_published) {
192            $entity->published = new DateTime('now');
193        }
194
195        // Sanitize body content to prevent XSS attacks
196        if ($entity->isDirty('body') && !empty($entity->body)) {
197            $entity->body = ContentSanitizer::sanitize((string)$entity->body);
198        }
199
200        // Calculate word count if body is set or modified
201        if ($entity->isDirty('body') || ($entity->isNew() && !empty($entity->body))) {
202            $strippedBody = strip_tags((string)$entity->body); // Ensure body is a string
203            $wordCount = str_word_count($strippedBody);
204            $entity->word_count = $wordCount;
205        }
206    }
207
208    /**
209     * After save callback
210     *
211     * Handles AI-powered enhancements including:
212     * - Article tagging
213     * - Summary generation
214     * - SEO field population
215     * - Content translation
216     *
217     * @param \Cake\Event\EventInterface $event The afterSave event that was fired
218     * @param \Cake\Datasource\EntityInterface $entity The entity that was saved
219     * @param \ArrayObject $options The options passed to the save method
220     * @return void
221     */
222    public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
223    {
224        // noMessage flag will be true if save came from a Job (stops looping)
225        $noMessage = $options['noMessage'] ?? false;
226
227        // All Articles should be tagged from the start
228        if (
229            SettingsManager::read('AI.enabled')
230            && !$noMessage
231        ) {
232            $data = [
233                'id' => $entity->id,
234                'title' => $entity->title,
235            ];
236
237            if (
238                $entity->kind == 'article' &&
239                ((isset($options['regenerateTags']) &&
240                $options['regenerateTags'] == 1) ||
241                !isset($options['regenerateTags']))
242            ) {
243                // Queue up an ArticleTagUpdateJob
244                if (SettingsManager::read('AI.articleTags')) {
245                    $this->queueJob('App\Job\ArticleTagUpdateJob', $data);
246                }
247            }
248
249            // Queue up an ArticleSummaryUpdateJob
250            if (SettingsManager::read('AI.articleSummaries') && empty($entity->summary)) {
251                $this->queueJob('App\Job\ArticleSummaryUpdateJob', $data);
252            }
253        }
254
255        // Published Articles should be SEO ready with translations
256        if (
257            $entity->is_published
258            && SettingsManager::read('AI.enabled')
259            && !$noMessage
260        ) {
261            $data = [
262                'id' => $entity->id,
263                'title' => $entity->title,
264            ];
265
266            // Queue a job to update the Article SEO fields
267            if (SettingsManager::read('AI.articleSEO') && !empty($this->emptySeoFields($entity))) {
268                $this->queueJob('App\Job\ArticleSeoUpdateJob', $data);
269            }
270
271            // Queue a job to translate the Article
272            if (SettingsManager::read('AI.articleTranslations')) {
273                $this->queueJob('App\Job\TranslateArticleJob', $data);
274            }
275        }
276    }
277
278    /**
279     * Retrieves a list of featured articles with optional additional conditions.
280     *
281     * This method constructs a query to find articles that are marked as featured.
282     * Additional conditions can be provided to further filter the results.
283     * The results are ordered by the 'lft' field in ascending order.
284     *
285     * @param array $additionalConditions An array of additional conditions to apply to the query.
286     * @return array A list of featured articles that match the specified conditions.
287     */
288    public function getFeatured(string $cacheKey, array $additionalConditions = []): array
289    {
290        $conditions = [
291            'Articles.kind' => 'article',
292            'Articles.featured' => 1,
293            'Articles.is_published' => 1,
294        ];
295        $conditions = array_merge($conditions, $additionalConditions);
296        $query = $this->find()
297            ->where($conditions)
298            ->orderBy(['lft' => 'ASC'])
299            ->cache($cacheKey . 'featured_articles', 'content');
300
301        $results = $query->all()->toList();
302
303        return $results;
304    }
305
306    /**
307     * Retrieves a list of root pages from the Articles table.
308     *
309     * This method fetches articles that are categorized as 'page', have no parent (i.e., root pages),
310     * and are published. Additional conditions can be provided to further filter the results.
311     *
312     * @param array $additionalConditions An associative array of additional conditions to apply to the query.
313     *                                    These conditions will be merged with the default conditions.
314     * @return array An array of root pages that match the specified conditions,
315     * ordered by the 'lft' field in ascending order.
316     */
317    public function getRootPages(string $cacheKey, array $additionalConditions = []): array
318    {
319        $conditions = [
320            'Articles.kind' => 'page',
321            'Articles.parent_id IS' => null,
322            'Articles.is_published' => 1,
323        ];
324        $conditions = array_merge($conditions, $additionalConditions);
325        $query = $this->find()
326            ->where($conditions)
327            ->orderBy(['lft' => 'ASC'])
328            ->cache($cacheKey . 'root_pages', 'content');
329
330        $results = $query->all()->toList();
331
332        return $results;
333    }
334
335    /**
336     * Retrieves published pages marked for display in the main menu.
337     *
338     * This method fetches articles that meet the following criteria:
339     * - Are of type 'page'
340     * - Are published (is_published = 1)
341     * - Are marked for main menu display (main_menu = 1)
342     * Results are ordered by the 'lft' field for proper tree structure display.
343     * Results are cached using the 'main_menu_pages' key in the 'content' cache config.
344     *
345     * @param array $additionalConditions Additional conditions to merge with the default query conditions
346     * @return array List of Article entities matching the criteria
347     * @throws \Cake\Database\Exception\DatabaseException When the database query fails
348     * @throws \Cake\Cache\Exception\InvalidArgumentException When cache configuration is invalid
349     */
350    public function getMainMenuPages(string $cacheKey, array $additionalConditions = []): array
351    {
352        $conditions = [
353            'Articles.kind' => 'page',
354            'Articles.is_published' => 1,
355            'Articles.main_menu' => 1,
356        ];
357        $conditions = array_merge($conditions, $additionalConditions);
358        $query = $this->find()
359            ->where($conditions)
360            ->orderBy(['lft' => 'ASC'])
361            ->cache($cacheKey . 'main_menu_pages', 'content');
362
363        $results = $query->all()->toList();
364
365        return $results;
366    }
367
368    /**
369     * Gets an array of years and months that have published articles.
370     *
371     * This method queries the articles table to find all unique year/month combinations
372     * where articles were published, organizing them in a hierarchical array structure
373     * with years as keys and months as values. Results are cached using the 'content'
374     * cache configuration to improve performance.
375     *
376     * @return array An array where keys are years and values are arrays of month numbers
377     *              that have published articles, sorted in descending order.
378     */
379    public function getArchiveDates(string $cacheKey): array
380    {
381        $query = $this->find()
382            ->select([
383                'year' => 'YEAR(published)',
384                'month' => 'MONTH(published)',
385            ])
386            ->where([
387                'Articles.is_published' => 1,
388                'Articles.kind' => 'article',
389                'Articles.published IS NOT' => null,
390            ])
391            ->groupBy(['year', 'month'])
392            ->orderBy([
393                'year' => 'DESC',
394                'month' => 'DESC',
395            ])
396            ->cache($cacheKey . 'archive_dates', 'content');
397
398        $dates = [];
399        foreach ($query as $result) {
400            /** @var object{year: int, month: int} $result */
401            $year = $result->year;
402            if (!isset($dates[$year])) {
403                $dates[$year] = [];
404            }
405            $dates[$year][] = (int)$result->month;
406        }
407
408        return $dates;
409    }
410
411    /**
412     * Retrieves the most recent published articles.
413     *
414     * This method queries the Articles table to find articles that are of kind 'article' and are published.
415     * It includes associated Users and Tags data, orders the results by the published date in descending order,
416     * and limits the results to the top 3 most recent articles.
417     *
418     * @return array An array of the most recent published articles, including associated Users and Tags data.
419     */
420    public function getRecentArticles(string $cacheKey, array $additionalConditions = []): array
421    {
422        $conditions = [
423            'Articles.kind' => 'article',
424            'Articles.is_published' => 1,
425        ];
426        $conditions = array_merge($conditions, $additionalConditions);
427
428        $query = $this->find()
429            ->where($conditions)
430            ->contain(['Users', 'Tags'])
431            ->orderBy(['Articles.published' => 'DESC'])
432            ->limit(3)
433            ->cache($cacheKey . 'recent_articles', 'content');
434
435        return $query->all()->toArray();
436    }
437}