Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
CustomPage
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
18 / 18
29
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAllPages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getPagesPaginated
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 getAllLanguagesPaginated
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 countPages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 countAllLanguages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getById
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getBySlug
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getExistingLanguages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createTranslation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 activate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 slugExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 generateUniqueSlug
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 mapRowToArray
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 mapRowToEntity
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * The CustomPage class for phpMyFAQ custom pages.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-12
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ;
21
22use DateTime;
23use phpMyFAQ\CustomPage\CustomPageRepository;
24use phpMyFAQ\CustomPage\CustomPageRepositoryInterface;
25use phpMyFAQ\Entity\CustomPageEntity;
26use phpMyFAQ\Seo\SeoRepository;
27use phpMyFAQ\Seo\SeoRepositoryInterface;
28use stdClass;
29
30/**
31 * Class CustomPage
32 *
33 * @package phpMyFAQ
34 */
35readonly class CustomPage
36{
37    private CustomPageRepositoryInterface $repository;
38
39    private SeoRepositoryInterface $seoRepository;
40
41    /**
42     * Constructor.
43     */
44    public function __construct(
45        private Configuration $configuration,
46        ?CustomPageRepositoryInterface $repository = null,
47        ?SeoRepositoryInterface $seoRepository = null,
48    ) {
49        $this->repository = $repository ?? new CustomPageRepository($this->configuration);
50        $this->seoRepository = $seoRepository ?? new SeoRepository($this->configuration);
51    }
52
53    /**
54     * Get all custom pages for the current language.
55     *
56     * @param bool $activeOnly Filter by active status
57     * @return array<int, array<string, mixed>>
58     */
59    public function getAllPages(bool $activeOnly = false): array
60    {
61        $pages = [];
62        $language = $this->configuration->getLanguage()->getLanguage();
63
64        foreach ($this->repository->getAll($language, $activeOnly) as $row) {
65            $pages[] = $this->mapRowToArray($row);
66        }
67
68        return $pages;
69    }
70
71    /**
72     * Get paginated custom pages with sorting support.
73     *
74     * @param bool $activeOnly Filter by active status
75     * @param int $limit Number of items per page
76     * @param int $offset Starting offset
77     * @param string $sortField Field to sort by
78     * @param string $sortOrder Sort direction (ASC, DESC)
79     * @return array
80     */
81    public function getPagesPaginated(
82        bool $activeOnly = false,
83        int $limit = 25,
84        int $offset = 0,
85        string $sortField = 'created',
86        string $sortOrder = 'DESC',
87    ): array {
88        $pages = [];
89        $language = $this->configuration->getLanguage()->getLanguage();
90
91        foreach ($this->repository->getAllPaginated(
92            $language,
93            $activeOnly,
94            $limit,
95            $offset,
96            $sortField,
97            $sortOrder,
98        ) as $row) {
99            $pages[] = $this->mapRowToArray($row);
100        }
101
102        return $pages;
103    }
104
105    /**
106     * Get paginated custom pages across all languages with sorting support.
107     *
108     * @param bool $activeOnly Filter by active status
109     * @param int $limit Number of items per page
110     * @param int $offset Starting offset
111     * @param string $sortField Field to sort by
112     * @param string $sortOrder Sort direction (ASC, DESC)
113     * @return array
114     */
115    public function getAllLanguagesPaginated(
116        bool $activeOnly = false,
117        int $limit = 25,
118        int $offset = 0,
119        string $sortField = 'created',
120        string $sortOrder = 'DESC',
121    ): array {
122        $pages = [];
123
124        foreach ($this->repository->getAllLanguagesPaginated(
125            $activeOnly,
126            $limit,
127            $offset,
128            $sortField,
129            $sortOrder,
130        ) as $row) {
131            $pages[] = $this->mapRowToArray($row);
132        }
133
134        return $pages;
135    }
136
137    /**
138     * Count total pages for the current language.
139     *
140     * @param bool $activeOnly Filter by active status
141     * @return int Total count
142     */
143    public function countPages(bool $activeOnly = false): int
144    {
145        $language = $this->configuration->getLanguage()->getLanguage();
146        return $this->repository->countAll($language, $activeOnly);
147    }
148
149    /**
150     * Count total pages across all languages.
151     *
152     * @param bool $activeOnly Filter by active status
153     * @return int Total count
154     */
155    public function countAllLanguages(bool $activeOnly = false): int
156    {
157        return $this->repository->countAllLanguages($activeOnly);
158    }
159
160    /**
161     * Get a custom page by ID.
162     *
163     * @param int $pageId Page ID
164     * @param string|null $language Language code (optional, uses current if not provided)
165     * @return CustomPageEntity|null Page entity or null if not found
166     */
167    public function getById(int $pageId, ?string $language = null): ?CustomPageEntity
168    {
169        $language ??= $this->configuration->getLanguage()->getLanguage();
170        $row = $this->repository->getById($pageId, $language);
171        return $row ? $this->mapRowToEntity($row) : null;
172    }
173
174    /**
175     * Get a custom page by slug.
176     *
177     * @param string $slug URL slug
178     * @param string|null $language Language code (optional, uses current if not provided)
179     * @return CustomPageEntity|null Page entity or null if not found
180     */
181    public function getBySlug(string $slug, ?string $language = null): ?CustomPageEntity
182    {
183        $language ??= $this->configuration->getLanguage()->getLanguage();
184        $row = $this->repository->getBySlug($slug, $language);
185        return $row ? $this->mapRowToEntity($row) : null;
186    }
187
188    /**
189     * Get all existing languages for a given page ID.
190     *
191     * @param int $pageId Page ID
192     * @return array<string> Array of language codes
193     */
194    public function getExistingLanguages(int $pageId): array
195    {
196        return $this->repository->getExistingLanguages($pageId);
197    }
198
199    /**
200     * Create a new custom page.
201     *
202     * @param CustomPageEntity $page Custom page entity
203     * @return int The new page ID
204     */
205    public function create(CustomPageEntity $page): int
206    {
207        return $this->repository->insert($page);
208    }
209
210    /**
211     * Create a translation for an existing custom page.
212     *
213     * @param CustomPageEntity $page Custom page entity
214     * @param int $pageId The existing page ID to use
215     * @return bool Success status
216     */
217    public function createTranslation(CustomPageEntity $page, int $pageId): bool
218    {
219        return $this->repository->insertTranslation($page, $pageId);
220    }
221
222    /**
223     * Update an existing custom page.
224     *
225     * @param CustomPageEntity $page Custom page entity
226     * @return bool Success status
227     */
228    public function update(CustomPageEntity $page): bool
229    {
230        if (!$page->getUpdated()) {
231            $page->setUpdated(new DateTime());
232        }
233        return $this->repository->update($page);
234    }
235
236    /**
237     * Delete a custom page.
238     *
239     * @param int $pageId Page ID
240     * @param string|null $language Language code (optional, uses current if not provided)
241     * @return bool Success status
242     */
243    public function delete(int $pageId, ?string $language = null): bool
244    {
245        $language ??= $this->configuration->getLanguage()->getLanguage();
246        return $this->repository->delete($pageId, $language);
247    }
248
249    /**
250     * Activate or deactivate a custom page.
251     *
252     * @param int $pageId Page ID
253     * @param bool $status Active status
254     * @return bool Success status
255     */
256    public function activate(int $pageId, bool $status): bool
257    {
258        return $this->repository->activate($pageId, $status);
259    }
260
261    /**
262     * Check if a slug exists.
263     *
264     * @param string $slug URL slug
265     * @param string|null $language Language code (optional, uses current if not provided)
266     * @param int|null $excludeId Optional page ID to exclude from check
267     * @return bool True if slug exists
268     */
269    public function slugExists(string $slug, ?string $language = null, ?int $excludeId = null): bool
270    {
271        $language ??= $this->configuration->getLanguage()->getLanguage();
272        return $this->repository->slugExists($slug, $language, $excludeId);
273    }
274
275    /**
276     * Generate a unique slug from a base string.
277     *
278     * @param string $baseSlug Base slug string
279     * @param string|null $language Language code (optional, uses current if not provided)
280     * @param int|null $excludeId Optional page ID to exclude from check
281     * @return string Unique slug
282     */
283    public function generateUniqueSlug(string $baseSlug, ?string $language = null, ?int $excludeId = null): string
284    {
285        $language ??= $this->configuration->getLanguage()->getLanguage();
286        $slug = $baseSlug;
287        $counter = 1;
288
289        while ($this->slugExists($slug, $language, $excludeId)) {
290            $slug = $baseSlug . '-' . $counter;
291            $counter++;
292        }
293
294        return $slug;
295    }
296
297    /**
298     * Map database row to array.
299     *
300     * @param stdClass $row Database row
301     * @return array<string, mixed> Mapped data
302     */
303    private function mapRowToArray(stdClass $row): array
304    {
305        return [
306            'id' => (int) $row->id,
307            'lang' => $row->lang,
308            'page_title' => $row->page_title,
309            'slug' => $row->slug,
310            'content' => $row->content,
311            'author_name' => $row->author_name,
312            'author_email' => $row->author_email,
313            'active' => $row->active,
314            'created' => $row->created,
315            'updated' => $row->updated ?? null,
316            'seo_title' => $row->seo_title ?? null,
317            'seo_description' => $row->seo_description ?? null,
318            'seo_robots' => $row->seo_robots ?? 'index,follow',
319        ];
320    }
321
322    /**
323     * Map database row to CustomPageEntity.
324     *
325     * @param stdClass $row Database row
326     * @return CustomPageEntity Page entity
327     * @throws \DateMalformedStringException
328     */
329    private function mapRowToEntity(stdClass $row): CustomPageEntity
330    {
331        $seoTitle = $row->seo_title ?? null;
332        $seoDescription = $row->seo_description ?? null;
333
334        $entity = new CustomPageEntity();
335        $entity
336            ->setId((int) $row->id)
337            ->setLanguage((string) $row->lang)
338            ->setPageTitle((string) $row->page_title)
339            ->setSlug((string) $row->slug)
340            ->setContent((string) $row->content)
341            ->setAuthorName((string) $row->author_name)
342            ->setAuthorEmail((string) $row->author_email)
343            ->setActive($row->active === 'y')
344            ->setSeoTitle($seoTitle === null ? null : (string) $seoTitle)
345            ->setSeoDescription($seoDescription === null ? null : (string) $seoDescription)
346            ->setSeoRobots((string) ($row->seo_robots ?? 'index,follow'))
347            ->setCreated(new DateTime((string) $row->created));
348
349        if (property_exists($row, 'updated') && $row->updated !== null) {
350            $entity->setUpdated(new DateTime((string) $row->updated));
351        }
352
353        return $entity;
354    }
355}