Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
105 / 105
100.00% covered (success)
100.00%
13 / 13
CRAP
100.00% covered (success)
100.00%
1 / 1
DatabaseMessageSource
100.00% covered (success)
100.00%
104 / 104
100.00% covered (success)
100.00%
13 / 13
43
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
 getMessage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveRawMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 lookupInCurrentOrSubCategory
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 lookupDotNotatedKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 resolveFallbackCategories
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 getMessages
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 invalidateCache
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
8
 loadFromApi
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 readCompiledCache
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 writeCompiledCache
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getCacheFilePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 formatMessage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
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;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Api\ApiClientInterface;
12use App\Core\Translation\Presentation\Api\TranslationApiController;
13use Throwable;
14use Yiisoft\Translator\MessageReaderInterface;
15
16/**
17 * High-Performance API & Cached Message Source.
18 *
19 * Implements Yii3 MessageReaderInterface consuming REST API /api/v1/translations
20 * with layered fallback resolution, OPcache file compilation, and instant cache invalidation.
21 *
22 * @package App\Core\Translation
23 */
24final class DatabaseMessageSource implements MessageReaderInterface
25{
26    /** @var array<string, array<string, array<string, string>>> In-memory category messages cache. */
27    private array $memoryCache = [];
28
29    /**
30     * DatabaseMessageSource constructor.
31     *
32     * @param TranslationApiController|ApiClientInterface $apiSource API Controller or ApiClient instance.
33     * @param string $cacheDir Directory path for compiled translation files.
34     */
35    public function __construct(
36        private readonly TranslationApiController|ApiClientInterface $apiSource,
37        private readonly string $cacheDir
38    ) {
39    }
40
41    /**
42     * {@inheritdoc}
43     */
44    public function getMessage(string $id, string $category, string $locale, array $parameters = []): ?string
45    {
46        $resolved = $this->resolveRawMessage($id, $category, $locale);
47        if ($resolved !== null) {
48            return $this->formatMessage($resolved, $parameters);
49        }
50
51        return null;
52    }
53
54    /**
55     * Resolves raw unformatted message string using layered fallback mechanism.
56     *
57     * @param string $id Message key ID.
58     * @param string $category Message category.
59     * @param string $locale Locale code.
60     * @return string|null Raw translation string or null.
61     */
62    private function resolveRawMessage(string $id, string $category, string $locale): ?string
63    {
64        $resolved = $this->lookupInCurrentOrSubCategory($id, $category, $locale);
65        if ($resolved !== null) {
66            return $resolved;
67        }
68
69        $fallback = $this->resolveFallbackCategories($id, $category, $locale);
70        if ($fallback !== null) {
71            return $fallback;
72        }
73
74        return $locale !== 'en' ? $this->lookupInCurrentOrSubCategory($id, $category, 'en') : null;
75    }
76
77    /**
78     * Looks up message in current category or derived prefix category for dot-notated keys.
79     *
80     * @param string $id Message key.
81     * @param string $category Message category.
82     * @param string $locale Locale code.
83     * @return string|null Raw message string or null.
84     */
85    private function lookupInCurrentOrSubCategory(string $id, string $category, string $locale): ?string
86    {
87        $messages = $this->getMessages($category, $locale);
88        $candidateKeys = [
89            $id,
90            strtolower($id),
91            str_replace(' ', '_', strtolower($id)),
92            strtolower(str_replace('_', ' ', $id)),
93            ucwords(str_replace('_', ' ', strtolower($id))),
94            ucfirst($id),
95        ];
96
97        foreach ($candidateKeys as $candidate) {
98            if (isset($messages[$candidate]['message'])) {
99                return $messages[$candidate]['message'];
100            }
101        }
102
103        return str_contains($id, '.')
104            ? $this->lookupDotNotatedKey($id, $category, $locale, $messages)
105            : null;
106    }
107
108    /**
109     * Looks up dot-notated sub-key in prefix category or current messages.
110     *
111     * @param string $id Message key with dots.
112     * @param string $category Current category.
113     * @param string $locale Target locale.
114     * @param array<string, array<string, string>> $messages Current category messages.
115     * @return string|null Resolved message or null.
116     */
117    private function lookupDotNotatedKey(
118        string $id,
119        string $category,
120        string $locale,
121        array  $messages
122    ): ?string {
123        $parts = explode('.', $id);
124        $prefix = $parts[0];
125        $subKey = implode('.', array_slice($parts, 1));
126        if ($prefix !== $category) {
127            $prefixMessages = $this->getMessages($prefix, $locale);
128            if (isset($prefixMessages[$subKey]['message'])) {
129                return $prefixMessages[$subKey]['message'];
130            }
131        }
132
133        return $messages[$subKey]['message'] ?? null;
134    }
135
136    /**
137     * Resolves fallback categories for unregistered modules.
138     */
139    private function resolveFallbackCategories(string $id, string $category, string $locale): ?string
140    {
141        if (in_array($category, ['app', 'field', 'action'], true)) {
142            return null;
143        }
144
145        foreach (['field', 'action', 'app'] as $fallbackCategory) {
146            $fallback = $this->getMessages($fallbackCategory, $locale);
147            $candidateKeys = [
148                $id,
149                strtolower($id),
150                str_replace(' ', '_', strtolower($id)),
151            ];
152            foreach ($candidateKeys as $candidate) {
153                if (isset($fallback[$candidate]['message'])) {
154                    return $fallback[$candidate]['message'];
155                }
156            }
157        }
158
159        return null;
160    }
161
162    /**
163     * {@inheritdoc}
164     */
165    public function getMessages(string $category, string $locale): array
166    {
167        $normalizedLocale = strtolower(trim($locale));
168        $cacheKey = "{$normalizedLocale}:{$category}";
169
170        if (isset($this->memoryCache[$cacheKey])) {
171            return $this->memoryCache[$cacheKey];
172        }
173
174        $cachedData = $this->readCompiledCache($normalizedLocale, $category);
175        if ($cachedData !== null) {
176            $this->memoryCache[$cacheKey] = $cachedData;
177            return $cachedData;
178        }
179
180        $messages = $this->loadFromApi($category, $normalizedLocale);
181        $this->writeCompiledCache($normalizedLocale, $category, $messages);
182        $this->memoryCache[$cacheKey] = $messages;
183
184        return $messages;
185    }
186
187    /**
188     * Invalidates compiled file cache and in-memory caches.
189     *
190     * @param string|null $locale Specific locale to invalidate or null for all.
191     * @param string|null $category Specific category to invalidate or null for all.
192     */
193    public function invalidateCache(?string $locale = null, ?string $category = null): void
194    {
195        $this->memoryCache = [];
196
197        if (!is_dir($this->cacheDir)) {
198            return;
199        }
200
201        $pattern = $this->cacheDir . '/*.php';
202        $files = glob($pattern) ?: [];
203
204        foreach ($files as $file) {
205            $filename = basename($file, '.php');
206            if ($locale !== null && !str_starts_with($filename, $locale . '_')) {
207                continue;
208            }
209            if ($category !== null) {
210                $sanitizedCat = str_replace('.', '_', $category);
211                if (!str_ends_with($filename, '_' . $sanitizedCat)) {
212                    continue;
213                }
214            }
215            @unlink($file);
216        }
217    }
218
219    /**
220     * Loads translation messages for a category and locale from REST API.
221     *
222     * @param string $category Message category.
223     * @param string $locale Language code.
224     * @return array<string, array<string, string>> Messages map.
225     */
226    private function loadFromApi(string $category, string $locale): array
227    {
228        if ($this->apiSource instanceof TranslationApiController) {
229            return $this->apiSource->fetchMessagesFromDb($category, $locale);
230        }
231
232        $response = $this->apiSource->get('/api/v1/translations/' . $category, [
233            'locale' => $locale,
234        ]);
235
236        return (array)($response['messages'] ?? []);
237    }
238
239    /**
240     * Reads messages from compiled PHP cache file.
241     *
242     * @param string $locale Locale code.
243     * @param string $category Message category.
244     * @return array<string, array<string, string>>|null Cached messages or null.
245     */
246    private function readCompiledCache(string $locale, string $category): ?array
247    {
248        $filePath = $this->getCacheFilePath($locale, $category);
249        if (!file_exists($filePath)) {
250            return null;
251        }
252
253        try {
254            /** @var mixed $data */
255            $data = (static function (string $f): mixed {
256                return require_once $f;
257            })($filePath);
258            return is_array($data) ? $data : null;
259        } catch (Throwable) {
260            return null;
261        }
262    }
263
264    /**
265     * Writes messages to compiled PHP cache file atomically.
266     *
267     * @param string $locale Locale code.
268     * @param string $category Message category.
269     * @param array<string, array<string, string>> $messages Messages to write.
270     */
271    private function writeCompiledCache(string $locale, string $category, array $messages): void
272    {
273        if (!is_dir($this->cacheDir)) {
274            @mkdir($this->cacheDir, 0775, true);
275        }
276
277        $filePath = $this->getCacheFilePath($locale, $category);
278        $tmpPath = $filePath . '.' . bin2hex(random_bytes(4)) . '.tmp';
279
280        $exported = var_export($messages, true);
281        $code = "<?php\n\ndeclare(strict_types=1);\n\nreturn {$exported};\n";
282
283        if (@file_put_contents($tmpPath, $code) !== false) {
284            @rename($tmpPath, $filePath);
285        }
286    }
287
288    /**
289     * Returns absolute cache file path for locale and category.
290     *
291     * @param string $locale Locale code.
292     * @param string $category Message category.
293     * @return string Absolute file path.
294     */
295    private function getCacheFilePath(string $locale, string $category): string
296    {
297        $sanitizedCat = preg_replace('/[^a-zA-Z0-9_-]/', '_', $category) ?? 'default';
298
299        return $this->cacheDir . '/' . $locale . '_' . $sanitizedCat . '.php';
300    }
301
302    /**
303     * Interpolates parameter placeholders in translation message.
304     *
305     * @param string $message Raw message string.
306     * @param array<string, mixed> $parameters Parameters map.
307     * @return string Formatted message.
308     */
309    private function formatMessage(string $message, array $parameters): string
310    {
311        if (empty($parameters)) {
312            return $message;
313        }
314
315        $replace = [];
316        foreach ($parameters as $key => $val) {
317            $replace['{' . $key . '}'] = (string)$val;
318            $replace[':' . $key] = (string)$val;
319        }
320
321        return strtr($message, $replace);
322    }
323}