Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.91% covered (success)
96.91%
94 / 97
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslationApiController
96.88% covered (success)
96.88%
93 / 96
71.43% covered (warning)
71.43%
5 / 7
21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 categoryMessages
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 activeLanguages
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 fetchMessagesFromDb
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 registerKeyVariants
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 registerMessageEntries
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getDistinctCategories
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Translation\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Shared\Infrastructure\Http\ApiResponseTrait;
12use PDO;
13use Psr\Http\Message\ResponseFactoryInterface;
14use Psr\Http\Message\ResponseInterface;
15use Psr\Http\Message\ServerRequestInterface;
16use Throwable;
17
18/**
19 * REST API Controller for System Translations and Languages.
20 *
21 * Exposes /api/v1/translations/{category} and /api/v1/languages JSON endpoints.
22 *
23 * @package App\Core\Translation\Presentation\Api
24 */
25final readonly class TranslationApiController
26{
27    use ApiResponseTrait;
28
29    private const string CACHE_CONTROL_HEADER = 'public, max-age=3600, must-revalidate';
30
31    /**
32     * TranslationApiController constructor.
33     *
34     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
35     * @param PDO                      $pdo             PDO database connection instance.
36     * @param string                   $tablePrefix     Database table prefix.
37     */
38    public function __construct(
39        private ResponseFactoryInterface $responseFactory,
40        private PDO                      $pdo,
41        private string                   $tablePrefix = 'a_'
42    ) {
43    }
44
45    /**
46     * Handles /api/v1/translations/{category} request.
47     *
48     * @param ServerRequestInterface $request  PSR-7 Server request.
49     * @param string                 $category Translation category name.
50     * @return ResponseInterface JSON API response.
51     */
52    public function categoryMessages(ServerRequestInterface $request, string $category): ResponseInterface
53    {
54        $params = $request->getQueryParams();
55        $locale = strtolower(trim((string) ($params['locale'] ?? 'en')));
56        if ($locale === '') {
57            $locale = 'en';
58        }
59
60        $messages = $this->fetchMessagesFromDb($category, $locale);
61        $payload = [
62            'status'   => true,
63            'category' => $category,
64            'locale'   => $locale,
65            'messages' => $messages,
66        ];
67        $json = (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
68        $etag = '"' . hash('sha256', $json) . '"';
69
70        if ($request->getHeaderLine('If-None-Match') === $etag) {
71            return $this->responseFactory->createResponse(304)
72                ->withHeader('ETag', $etag)
73                ->withHeader('Cache-Control', self::CACHE_CONTROL_HEADER);
74        }
75
76        $response = $this->responseFactory->createResponse(200);
77        $response->getBody()->write($json);
78
79        return $response
80            ->withHeader('Content-Type', 'application/json; charset=utf-8')
81            ->withHeader('ETag', $etag)
82            ->withHeader('Cache-Control', self::CACHE_CONTROL_HEADER);
83    }
84
85    /**
86     * Handles /api/v1/languages request.
87     *
88     * @param ServerRequestInterface|null $request Optional PSR-7 Server request.
89     * @return ResponseInterface JSON API response containing active languages.
90     */
91    public function activeLanguages(?ServerRequestInterface $request = null): ResponseInterface
92    {
93        $tableName = $this->tablePrefix . 'core_language_records';
94        $sql = "SELECT `id`, `code`, `name`, `native_name`, `flag_icon`, `is_default`, `sort_order`
95                FROM `{$tableName}`
96                WHERE `is_active` = 1
97                ORDER BY `sort_order` ASC, `id` ASC";
98
99        try {
100            $stmt = $this->pdo->query($sql);
101            $languages = [];
102            if ($stmt !== false) {
103                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
104                    $code = (string) $row['code'];
105                    $languages[$code] = $row;
106                }
107            }
108        } catch (Throwable) {
109            $languages = [];
110        }
111
112        $payload = [
113            'status' => true,
114            'data'   => $languages,
115        ];
116        $json = (string)json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
117
118        return $this->jsonCached($this->responseFactory, $json, $request, self::CACHE_CONTROL_HEADER);
119    }
120
121    /**
122     * Fetches translation messages directly from database with JOIN.
123     *
124     * @param string $category Message category.
125     * @param string $locale   Language code.
126     * @return array<string, array{message: string, comment: string}> Messages map.
127     */
128    public function fetchMessagesFromDb(string $category, string $locale): array
129    {
130        $sourceTable = $this->tablePrefix . 'core_translation_source_records';
131        $msgTable = $this->tablePrefix . 'core_translation_message_records';
132
133        $sql = "SELECT s.message_key, s.default_message, s.description,
134                       m.translation, m.is_custom
135                FROM `{$sourceTable}` s
136                LEFT JOIN `{$msgTable}` m ON (m.source_id = s.id AND m.language_code = :locale)
137                WHERE s.category = :category
138                ORDER BY m.is_custom DESC, s.id ASC";
139
140        $result = [];
141        try {
142            $stmt = $this->pdo->prepare($sql);
143            $stmt->execute([
144                ':locale'   => $locale,
145                ':category' => $category,
146            ]);
147
148            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
149                $key = (string) $row['message_key'];
150                $defaultMsg = (string) $row['default_message'];
151                $transText = (string) ($row['translation'] ?? '');
152                $translation = $transText !== '' ? $transText : $defaultMsg;
153                $entry = [
154                    'message' => $translation,
155                    'comment' => (string) ($row['description'] ?? ''),
156                ];
157
158                $this->registerMessageEntries($result, $key, $defaultMsg, $entry);
159            }
160        } catch (Throwable) {
161            return [];
162        }
163
164        return $result;
165    }
166
167    /**
168     * Registers standard case and delimiter variants for a given text key.
169     *
170     * @param array<string, array{message: string, comment: string}> $result
171     * @param array{message: string, comment: string} $entry
172     */
173    private function registerKeyVariants(array &$result, string $text, array $entry): void
174    {
175        if ($text === '') {
176            return;
177        }
178        $lower = strtolower($text);
179        $variants = [
180            $text,
181            $lower,
182            ucfirst($lower),
183            str_replace(' ', '_', $lower),
184            ucwords(str_replace('_', ' ', $lower)),
185        ];
186        foreach ($variants as $variant) {
187            $result[$variant] ??= $entry;
188        }
189    }
190
191    /**
192     * Registers all message key aliases, defaults, and sub-keys.
193     *
194     * @param array<string, array{message: string, comment: string}> $result
195     * @param array{message: string, comment: string} $entry
196     */
197    private function registerMessageEntries(
198        array &$result,
199        string $key,
200        string $defaultMsg,
201        array $entry
202    ): void {
203        $this->registerKeyVariants($result, $key, $entry);
204        if ($defaultMsg !== '') {
205            $this->registerKeyVariants($result, $defaultMsg, $entry);
206        }
207        if (str_contains($key, '.')) {
208            $subKey = substr($key, strpos($key, '.') + 1);
209            $result[$subKey] ??= $entry;
210        }
211    }
212
213    /**
214     * Returns all distinct translation categories present in the database.
215     *
216     * @return array<int, string> List of category names.
217     */
218    public function getDistinctCategories(): array
219    {
220        try {
221            $table = $this->tablePrefix . 'core_translation_source_records';
222            $sql = "SELECT DISTINCT `category` FROM `{$table}` WHERE `category` != '' ORDER BY `category` ASC";
223            $stmt = $this->pdo->query($sql);
224            if ($stmt === false) {
225                return ['app', 'menu', 'action', 'field', 'msg', 'module', 'picklist'];
226            }
227            /** @var array<int, mixed> $categories */
228            $categories = $stmt->fetchAll(PDO::FETCH_COLUMN);
229            $cleanCategories = array_values(array_filter($categories, 'is_string'));
230            $defaults = ['app', 'menu', 'action', 'field', 'msg', 'module', 'picklist'];
231            return array_values(array_unique(array_merge($defaults, $cleanCategories)));
232        } catch (Throwable) {
233            return ['app', 'menu', 'action', 'field', 'msg', 'module', 'picklist'];
234        }
235    }
236}