Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
153 / 153
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
PageController
100.00% covered (success)
100.00%
153 / 153
100.00% covered (success)
100.00%
6 / 6
10
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 index
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 translate
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 edit
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
3
 getBaseTemplateVars
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The Page Administration Controller
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-15
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Administration;
21
22use phpMyFAQ\Core\Exception;
23use phpMyFAQ\CustomPage;
24use phpMyFAQ\Enums\PermissionType;
25use phpMyFAQ\Filter;
26use phpMyFAQ\Helper\LanguageHelper;
27use phpMyFAQ\Pagination;
28use phpMyFAQ\Pagination\UrlConfig;
29use phpMyFAQ\Session\Token;
30use phpMyFAQ\Translation;
31use phpMyFAQ\Twig\Extensions\FormatDateTwigExtension;
32use phpMyFAQ\Twig\Extensions\IsoDateTwigExtension;
33use phpMyFAQ\Twig\Extensions\LanguageCodeTwigExtension;
34use Symfony\Component\HttpFoundation\RedirectResponse;
35use Symfony\Component\HttpFoundation\Request;
36use Symfony\Component\HttpFoundation\Response;
37use Symfony\Component\Routing\Attribute\Route;
38use Twig\Error\LoaderError;
39use Twig\Extension\AttributeExtension;
40
41final class PageController extends AbstractAdministrationController
42{
43    public function __construct(
44        private readonly CustomPage $customPage,
45    ) {
46        parent::__construct();
47    }
48
49    /**
50     * @throws Exception
51     * @throws LoaderError
52     * @throws \Exception
53     */
54    #[Route(path: '/pages', name: 'admin.pages', methods: ['GET'])]
55    public function index(Request $request): Response
56    {
57        $this->userHasPermission(PermissionType::PAGE_ADD);
58        $this->userHasPermission(PermissionType::PAGE_DELETE);
59        $this->userHasPermission(PermissionType::PAGE_EDIT);
60
61        $customPage = $this->customPage;
62
63        $itemsPerPage = 25;
64        $page = Filter::filterVar($request->query->get('page'), FILTER_VALIDATE_INT, 1);
65
66        // Get ALL pages across all languages
67        $totalPages = $customPage->countAllLanguages(activeOnly: false);
68
69        $pagination = new Pagination(
70            baseUrl: $request->getUri(),
71            total: $totalPages,
72            perPage: $itemsPerPage,
73            urlConfig: new UrlConfig(pageParamName: 'page'),
74        );
75
76        $offset = ($page - 1) * $itemsPerPage;
77        $pages = $customPage->getAllLanguagesPaginated(
78            activeOnly: false,
79            limit: $itemsPerPage,
80            offset: $offset,
81            sortField: 'id',
82            sortOrder: 'DESC',
83        );
84
85        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
86        $this->addExtension(new AttributeExtension(FormatDateTwigExtension::class));
87        $this->addExtension(new AttributeExtension(LanguageCodeTwigExtension::class));
88        return $this->render('@admin/content/pages.twig', [
89            ...$this->getHeader($request),
90            ...$this->getFooter(),
91            ...$this->getBaseTemplateVars(),
92            'pages' => $pages,
93            'pagination' => $pagination->render(),
94        ]);
95    }
96
97    /**
98     * @throws LoaderError
99     * @throws Exception
100     * @throws \Exception
101     */
102    #[Route(path: '/page/add', name: 'admin.page.add', methods: ['GET'])]
103    public function add(Request $request): Response
104    {
105        $this->userHasPermission(PermissionType::PAGE_ADD);
106
107        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
108        $this->addExtension(new AttributeExtension(FormatDateTwigExtension::class));
109        return $this->render('@admin/content/page.add.twig', [
110            ...$this->getHeader($request),
111            ...$this->getFooter(),
112            ...$this->getBaseTemplateVars(),
113            'userEmail' => $this->currentUser->getUserData('email'),
114            'userName' => $this->currentUser->getUserData('display_name'),
115        ]);
116    }
117
118    /**
119     * @throws LoaderError
120     * @throws Exception
121     * @throws \Exception
122     */
123    #[Route(path: '/page/translate/{pageId}', name: 'admin.page.translate', methods: ['GET'])]
124    public function translate(Request $request): Response
125    {
126        $this->userHasPermission(PermissionType::PAGE_ADD);
127
128        $pageId = (int) Filter::filterVar($request->attributes->get('pageId'), FILTER_VALIDATE_INT);
129
130        $customPage = $this->customPage;
131        $pageEntity = $customPage->getById($pageId);
132
133        if ($pageEntity === null) {
134            throw new Exception('Page not found');
135        }
136
137        // Get all existing languages for this page ID
138        $existingLanguages = $customPage->getExistingLanguages($pageId);
139
140        // Get available languages that don't have translations yet
141        $allLanguages = LanguageHelper::getAvailableLanguages();
142        $availableLanguages = array_diff_key($allLanguages, array_flip($existingLanguages));
143
144        if ($availableLanguages === []) {
145            // All languages already have translations - redirect back to list
146            return new RedirectResponse('./pages');
147        }
148
149        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
150        $this->addExtension(new AttributeExtension(FormatDateTwigExtension::class));
151        $this->addExtension(new AttributeExtension(LanguageCodeTwigExtension::class));
152        return $this->render('@admin/content/page.translate.twig', [
153            ...$this->getHeader($request),
154            ...$this->getFooter(),
155            ...$this->getBaseTemplateVars(),
156            'pageId' => $pageId,
157            'originalTitle' => $pageEntity->getPageTitle(),
158            'originalLanguage' => $pageEntity->getLanguage(),
159            'originalSlug' => $pageEntity->getSlug(),
160            'existingLanguages' => $existingLanguages,
161            'availableLanguages' => $availableLanguages,
162            'userEmail' => $this->currentUser->getUserData('email'),
163            'userName' => $this->currentUser->getUserData('display_name'),
164            'csrfTokenTranslate' => Token::getInstance($this->session)->getTokenString('translate'),
165        ]);
166    }
167
168    /**
169     * @throws LoaderError
170     * @throws Exception
171     * @throws \Exception
172     */
173    #[Route(path: '/page/edit/{pageId}', name: 'admin.page.edit', methods: ['GET'])]
174    public function edit(Request $request): Response
175    {
176        $this->userHasPermission(PermissionType::PAGE_EDIT);
177
178        $pageId = (int) Filter::filterVar($request->attributes->get('pageId'), FILTER_VALIDATE_INT);
179
180        $customPage = $this->customPage;
181        $pageEntity = $customPage->getById($pageId);
182
183        if ($pageEntity === null) {
184            throw new Exception('Page not found');
185        }
186
187        // Convert entity to array for template
188        $pageData = [
189            'id' => $pageEntity->getId(),
190            'lang' => $pageEntity->getLanguage(),
191            'page_title' => $pageEntity->getPageTitle(),
192            'slug' => $pageEntity->getSlug(),
193            'content' => $pageEntity->getContent(),
194            'author_name' => $pageEntity->getAuthorName(),
195            'author_email' => $pageEntity->getAuthorEmail(),
196            'active' => $pageEntity->isActive() ? 'y' : 'n',
197            'seo_title' => $pageEntity->getSeoTitle(),
198            'seo_description' => $pageEntity->getSeoDescription(),
199            'seo_robots' => $pageEntity->getSeoRobots(),
200        ];
201
202        $this->addExtension(new AttributeExtension(IsoDateTwigExtension::class));
203        $this->addExtension(new AttributeExtension(FormatDateTwigExtension::class));
204        return $this->render('@admin/content/page.edit.twig', [
205            ...$this->getHeader($request),
206            ...$this->getFooter(),
207            ...$this->getBaseTemplateVars(),
208            'pageData' => $pageData,
209            'pageDataContent' => htmlspecialchars($pageEntity->getContent(), ENT_QUOTES),
210            'pageId' => $pageId,
211            'availableLanguages' => LanguageHelper::getAvailableLanguages(),
212        ]);
213    }
214
215    /**
216     * @return array<string, mixed>
217     * @throws \Exception
218     */
219    private function getBaseTemplateVars(): array
220    {
221        $user = $this->currentUser;
222        $language = $this->configuration->getLanguage()->getLanguage();
223
224        return [
225            'permissionAddPage' => $user?->perm->hasPermission($user->getUserId(), PermissionType::PAGE_ADD),
226            'permissionEditPage' => $user->perm->hasPermission($user->getUserId(), PermissionType::PAGE_EDIT),
227            'permissionDeletePage' => $user->perm->hasPermission($user->getUserId(), PermissionType::PAGE_DELETE),
228            'defaultUrl' => $this->configuration->getDefaultUrl(),
229            'enableWysiwyg' => $this->configuration->get(item: 'main.enableWysiwygEditor'),
230            'ad_page_add' => Translation::get(key: 'ad_page_add'),
231            'csrfToken_savePage' => Token::getInstance($this->session)->getTokenString('save-page'),
232            'ad_page_author_name' => Translation::get(key: 'ad_page_author_name'),
233            'ad_page_author_email' => Translation::get(key: 'ad_page_author_email'),
234            'ad_page_set_active' => Translation::get(key: 'ad_page_set_active'),
235            'selectLanguage' => LanguageHelper::renderSelectLanguage($language, false, [], 'lang'),
236            'ad_menu_pages' => Translation::get(key: 'ad_menu_pages'),
237            'ad_page_title' => Translation::get(key: 'ad_page_title'),
238            'ad_page_slug' => Translation::get(key: 'ad_page_slug'),
239            'ad_page_created' => Translation::get(key: 'ad_page_created'),
240            'ad_page_updated' => Translation::get(key: 'ad_page_updated'),
241            'ad_page_update' => Translation::get(key: 'ad_page_update'),
242            'ad_page_delete' => Translation::get(key: 'ad_page_delete'),
243            'ad_page_nodata' => Translation::get(key: 'ad_page_nodata'),
244            'ad_page_edit' => Translation::get(key: 'ad_page_edit'),
245            'ad_page_content' => Translation::get(key: 'ad_page_content'),
246            'msgLanguage' => Translation::get(key: 'msgLanguage'),
247            'ad_page_insertfail' => Translation::get(key: 'ad_page_insertfail'),
248            'msgPages' => Translation::get(key: 'msgPages'),
249            'msgCancel' => Translation::get(key: 'msgCancel'),
250            'ad_page_del' => Translation::get(key: 'ad_page_del'),
251            'ad_page_nodelete' => Translation::get(key: 'ad_page_nodelete'),
252            'ad_page_yesdelete' => Translation::get(key: 'ad_page_yesdelete'),
253            'ad_page_delsuc' => Translation::get(key: 'ad_page_delsuc'),
254            'ad_page_updatesuc' => Translation::get(key: 'ad_page_updatesuc'),
255            'msgDeletePage' => Translation::get(key: 'msgDeletePage'),
256            'csrfToken_deletePage' => Token::getInstance($this->session)->getTokenString('delete-page'),
257            'csrfToken_updatePage' => Token::getInstance($this->session)->getTokenString('update-page'),
258            'ad_entry_active' => Translation::get(key: 'ad_entry_active'),
259            'csrfToken_activatePage' => Token::getInstance($this->session)->getTokenString('activate-page'),
260            'ad_page_tab_content' => Translation::get(key: 'ad_page_tab_content'),
261            'ad_page_tab_seo' => Translation::get(key: 'ad_page_tab_seo'),
262            'ad_page_tab_settings' => Translation::get(key: 'ad_page_tab_settings'),
263            'ad_page_seo_title' => Translation::get(key: 'ad_page_seo_title'),
264            'ad_page_seo_description' => Translation::get(key: 'ad_page_seo_description'),
265            'ad_page_seo_robots' => Translation::get(key: 'ad_page_seo_robots'),
266            'ad_page_slug_help' => Translation::get(key: 'ad_page_slug_help'),
267            'ad_page_lang_cannot_change' => Translation::get(key: 'ad_page_lang_cannot_change'),
268            'ad_page_add_translation' => Translation::get(key: 'ad_page_add_translation'),
269            'ad_page_translate' => Translation::get(key: 'ad_page_translate'),
270            'ad_page_original_language' => Translation::get(key: 'ad_page_original_language'),
271            'ad_page_select_translation_language' => Translation::get(key: 'ad_page_select_translation_language'),
272            'ad_page_existing_translations' => Translation::get(key: 'ad_page_existing_translations'),
273            'ad_page_no_available_languages' => Translation::get(key: 'ad_page_no_available_languages'),
274        ];
275    }
276}