Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.16% covered (warning)
53.16%
101 / 190
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ArticlesController
53.16% covered (warning)
53.16%
101 / 190
12.50% covered (danger)
12.50%
1 / 8
144.93
0.00% covered (danger)
0.00%
0 / 1
 clearContentCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 treeIndex
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
30
 updateTree
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 index
98.41% covered (success)
98.41%
62 / 63
0.00% covered (danger)
0.00%
0 / 1
4
 view
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 add
48.00% covered (danger)
48.00%
12 / 25
0.00% covered (danger)
0.00%
0 / 1
11.06
 edit
52.94% covered (warning)
52.94%
18 / 34
0.00% covered (danger)
0.00%
0 / 1
17.44
 delete
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2declare(strict_types=1);
3
4namespace App\Controller\Admin;
5
6use App\Controller\AppController;
7use Cake\Cache\Cache;
8use Cake\Datasource\Exception\RecordNotFoundException;
9use Cake\Http\Response;
10use Exception;
11
12/**
13 * Articles Controller
14 *
15 * Handles CRUD operations for articles, including pages and blog posts.
16 *
17 * @property \App\Model\Table\ArticlesTable $Articles
18 */
19class ArticlesController extends AppController
20{
21    /**
22     * Clears the content cache (used for both articles and tags)
23     *
24     * @return void
25     */
26    private function clearContentCache(): void
27    {
28        Cache::clear('content');
29    }
30
31    /**
32     * Retrieves a hierarchical list of articles that are marked as pages.
33     *
34     * @return \Cake\Http\Response|null
35     */
36    public function treeIndex(): ?Response
37    {
38        $statusFilter = $this->request->getQuery('status');
39        $conditions = [
40            'Articles.kind' => 'page',
41        ];
42
43        if ($statusFilter === '1') {
44            $conditions['Articles.is_published'] = '1';
45        } elseif ($statusFilter === '0') {
46            $conditions['Articles.is_published'] = '0';
47        }
48
49        if ($this->request->is('ajax')) {
50            $search = $this->request->getQuery('search');
51            if (!empty($search)) {
52                $conditions['OR'] = [
53                    'Articles.title LIKE' => '%' . $search . '%',
54                    'Articles.slug LIKE' => '%' . $search . '%',
55                    'Articles.body LIKE' => '%' . $search . '%',
56                    'Articles.meta_title LIKE' => '%' . $search . '%',
57                    'Articles.meta_description LIKE' => '%' . $search . '%',
58                    'Articles.meta_keywords LIKE' => '%' . $search . '%',
59                ];
60            }
61            $articles = $this->Articles->getTree($conditions, [
62                'slug',
63                'created',
64                'modified',
65                'is_published',
66            ]);
67
68            $this->set(compact('articles'));
69            $this->viewBuilder()->setLayout('ajax');
70
71            return $this->render('tree_index_search_results');
72        }
73
74        $articles = $this->Articles->getTree($conditions, [
75            'slug',
76            'created',
77            'modified',
78            'view_count',
79            'is_published',
80        ]);
81        $this->set(compact('articles'));
82
83        return null;
84    }
85
86    /**
87     * Updates the tree structure of articles.
88     *
89     * @return \Cake\Http\Response|null The JSON response indicating success or failure.
90     * @throws \Exception If an error occurs during the reordering process.
91     */
92    public function updateTree(): ?Response
93    {
94        $this->request->allowMethod(['post', 'put']);
95        $data = $this->request->getData();
96
97        try {
98            $result = $this->Articles->reorder($data);
99            $this->clearContentCache();
100
101            return $this->response->withType('application/json')
102                ->withStringBody(json_encode(['success' => true, 'result' => $result]));
103        } catch (Exception $e) {
104            return $this->response->withType('application/json')
105                ->withStringBody(json_encode(['success' => false, 'error' => $e->getMessage()]));
106        }
107    }
108
109    /**
110     * Displays a list of articles with search functionality.
111     *
112     * @return \Cake\Http\Response|null
113     */
114    public function index(): ?Response
115    {
116        $statusFilter = $this->request->getQuery('status');
117
118        $query = $this->Articles->find()
119            ->select([
120                'Articles.id',
121                'Articles.user_id',
122                'Articles.title',
123                'Articles.slug',
124                'Articles.image',
125                'Articles.dir',
126                'Articles.alt_text',
127                'Articles.created',
128                'Articles.modified',
129                'Articles.published',
130                'Articles.is_published',
131                'Articles.body',
132                'Articles.summary',
133                'Articles.meta_title',
134                'Articles.meta_description',
135                'Articles.meta_keywords',
136                'Articles.linkedin_description',
137                'Articles.facebook_description',
138                'Articles.instagram_description',
139                'Articles.twitter_description',
140                'Articles.word_count',
141                'Articles.view_count',
142                'Users.id',
143                'Users.username',
144            ])
145            ->leftJoinWith('Users')
146            ->leftJoinWith('PageViews')
147            ->where(['Articles.kind' => 'article'])
148            ->groupBy([
149                'Articles.id',
150                'Articles.user_id',
151                'Articles.title',
152                'Articles.slug',
153                'Articles.created',
154                'Articles.modified',
155                'Users.id',
156                'Users.username',
157            ])
158            ->orderBy(['Articles.created' => 'DESC']);
159
160        if ($statusFilter !== null) {
161            $query->where(['Articles.is_published' => (int)$statusFilter]);
162        }
163
164        $search = $this->request->getQuery('search');
165        if (!empty($search)) {
166            $query->where([
167                'OR' => [
168                    'Articles.title LIKE' => '%' . $search . '%',
169                    'Articles.slug LIKE' => '%' . $search . '%',
170                    'Articles.body LIKE' => '%' . $search . '%',
171                    'Articles.meta_title LIKE' => '%' . $search . '%',
172                    'Articles.meta_description LIKE' => '%' . $search . '%',
173                    'Articles.meta_keywords LIKE' => '%' . $search . '%',
174                ],
175            ]);
176        }
177        $articles = $this->paginate($query);
178        if ($this->request->is('ajax')) {
179            $this->set(compact('articles', 'search'));
180            $this->viewBuilder()->setLayout('ajax');
181
182            return $this->render('search_results');
183        }
184        $this->set(compact('articles'));
185
186        return null;
187    }
188
189    /**
190     * Displays details of a specific article.
191     *
192     * @param string|null $id Article id.
193     * @return void
194     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
195     */
196    public function view(?string $id = null): void
197    {
198        $article = $this->Articles->get($id, contain: [
199            'Users',
200            'PageViews',
201            'Tags',
202            'Images',
203            'Slugs',
204            'Comments',
205        ]);
206
207        if (!$article) {
208            throw new RecordNotFoundException(__('Article not found'));
209        }
210
211        $this->set(compact('article'));
212    }
213
214    /**
215     * Adds a new article.
216     *
217     * @return \Cake\Http\Response|null Redirects on successful add, renders view otherwise.
218     */
219    public function add(): ?Response
220    {
221        $article = $this->Articles->newEmptyEntity();
222        if ($this->request->is('post')) {
223            $data = $this->request->getData();
224            $data['kind'] = $this->request->getQuery('kind', 'article');
225            $article = $this->Articles->patchEntity($article, $data);
226
227            // Handle image uploads
228            $imageUploads = $this->request->getUploadedFiles();
229            if (!empty($imageUploads['image_uploads'])) {
230                $article->imageUploads = $imageUploads['image_uploads'];
231            }
232
233            if ($this->Articles->save($article)) {
234                $this->clearContentCache();
235                $this->Flash->success(__('The article has been saved.'));
236
237                // Redirect to treeIndex if is page, otherwise to index
238                if ($article->kind == 'page') {
239                    return $this->redirect(['action' => 'treeIndex']);
240                } else {
241                    return $this->redirect(['action' => 'index']);
242                }
243            }
244            $this->Flash->error(__('The article could not be saved. Please, try again.'));
245        }
246
247        // Fetch parent articles if 'kind' is page
248        $parentArticles = [];
249        if ($this->request->getQuery('kind') == 'page') {
250            $parentArticles = $this->Articles->find('list')
251                ->where(['kind' => 'page'])
252                ->all();
253        }
254
255        $users = $this->Articles->Users->find('list', limit: 200)->all();
256        $tags = $this->Articles->Tags->find('list', limit: 200)->all();
257        $token = $this->request->getAttribute('csrfToken');
258        $this->set(compact('article', 'users', 'tags', 'token', 'parentArticles'));
259
260        return null;
261    }
262
263    /**
264     * Edits an existing article.
265     *
266     * @param string|null $id Article ID.
267     * @return \Cake\Http\Response|null Redirects on successful edit, renders view otherwise.
268     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
269     */
270    public function edit(?string $id = null): ?Response
271    {
272        $article = $this->Articles->get($id, contain: ['Tags', 'Images']);
273
274        if (!empty($article->body) && empty($article->markdown)) {
275            $article->markdown = $article->body;
276        }
277
278        if ($this->request->is(['patch', 'post', 'put'])) {
279            $data = $this->request->getData();
280
281            $data['kind'] = $this->request->getQuery('kind', 'article');
282            $article = $this->Articles->patchEntity($article, $data);
283
284            // Handle image uploads
285            $imageUploads = $this->request->getUploadedFiles();
286            if (!empty($imageUploads['image_uploads'])) {
287                $article->imageUploads = $imageUploads['image_uploads'];
288            }
289
290            // Handle image unlinking
291            $unlinkedImages = $this->request->getData('unlink_images') ?? [];
292            $article->unlinkedImages = $unlinkedImages;
293
294            $saveOptions = [];
295            if (isset($data['regenerateTags'])) {
296                $saveOptions['regenerateTags'] = $data['regenerateTags'];
297            }
298
299            if ($this->Articles->save($article, $saveOptions)) {
300                $this->clearContentCache();
301                $this->Flash->success(__('The article has been saved.'));
302
303                // Redirect to treeIndex if kind is page, otherwise to index
304                if ($article->kind == 'page') {
305                    return $this->redirect(['action' => 'treeIndex']);
306                } else {
307                    return $this->redirect(['action' => 'index']);
308                }
309            }
310            $this->Flash->error(__('The article could not be saved. Please, try again.'));
311        }
312
313        // Fetch parent articles if 'kind' is page
314        $parentArticles = [];
315        if ($this->request->getQuery('kind') == 'page') {
316            $parentArticles = $this->Articles->find('list')
317                ->where([
318                    'kind' => 'page',
319                    'id !=' => $id,
320                    ])
321                ->all();
322        }
323
324        $users = $this->Articles->Users->find('list', limit: 200)->all();
325        $tags = $this->Articles->Tags->find('list', limit: 200)->all();
326        $this->set(compact('article', 'users', 'tags', 'parentArticles'));
327
328        return null;
329    }
330
331    /**
332     * Deletes an article.
333     *
334     * @param string|null $id Article ID.
335     * @return \Cake\Http\Response|null
336     * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found.
337     */
338    public function delete(?string $id = null): ?Response
339    {
340        $this->request->allowMethod(['post', 'delete']);
341        $article = $this->Articles->get($id);
342        if ($this->Articles->delete($article)) {
343            $this->clearContentCache();
344
345            $this->Flash->success(__('The article has been deleted.'));
346        } else {
347            $this->Flash->error(__('The article could not be deleted. Please, try again.'));
348        }
349
350        // Check if 'kind' is in the request parameters
351        if ($this->request->getData('kind') == 'page') {
352            return $this->redirect(['action' => 'treeIndex']);
353        }
354
355        $action = $this->request->getQuery('kind') == 'page' ? 'treeIndex' : 'index';
356
357        return $this->redirect(['action' => $action]);
358    }
359}