Translation::normalize()   A
last analyzed

Complexity

Conditions 2
Paths 1

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 11
ccs 0
cts 0
cp 0
rs 10
cc 2
nc 1
nop 1
crap 6
1
<?php
2
3
namespace GeminiLabs\SiteReviews\Modules;
4
5
use GeminiLabs\Sepia\PoParser\Parser;
6
use GeminiLabs\SiteReviews\Database\OptionManager;
7
use GeminiLabs\SiteReviews\Helpers\Arr;
8
use GeminiLabs\SiteReviews\Modules\Html\Template;
9
10
class Translation
11
{
12
    public const CONTEXT_ADMIN_KEY = 'admin-text';
13
    public const SEARCH_THRESHOLD = 3;
14
15
    protected array $entries = [];
16
17
    protected array $results = [];
18
19
    /**
20
     * Returns all saved custom strings with translation context.
21
     */
22
    public function all(): array
23
    {
24
        $strings = $this->strings();
25
        $entries = $this->filter($strings, $this->entries())->results();
26
        array_walk($strings, function (&$entry) use ($entries) {
27
            $entry['desc'] = array_key_exists($entry['id'], $entries)
28
                ? $this->getEntryString($entries[$entry['id']], 'msgctxt')
29
                : '';
30
        });
31
        return $strings;
32
    }
33
34
    public function entries(): array
35
    {
36
        if (empty($this->entries)) {
37
            $potFile = glsr()->path(glsr()->languages.'/'.glsr()->id.'.pot');
38
            $entries = $this->extractEntriesFromPotFile($potFile, glsr()->id);
39
            $entries = glsr()->filterArray('translation/entries', $entries);
40
            $this->entries = $entries;
41
        }
42
        return $this->entries;
43
    }
44
45
    /**
46
     * @return static
47
     */
48
    public function exclude(?array $entriesToExclude = null, ?array $entries = null)
49
    {
50
        return $this->filter($entriesToExclude, $entries, false);
51
    }
52
53
    public function extractEntriesFromPotFile(string $potFile, string $domain, array $entries = []): array
54
    {
55
        try {
56
            $potEntries = $this->normalize(Parser::parseFile($potFile)->getEntries());
57
            foreach ($potEntries as $key => $entry) {
58
                if (str_contains(Arr::get($entry, 'msgctxt'), static::CONTEXT_ADMIN_KEY)) {
59
                    continue;
60
                }
61
                $entry['domain'] = $domain; // the text-domain of the entry
62
                $entries[html_entity_decode($key, ENT_COMPAT, 'UTF-8')] = $entry;
63
            }
64
        } catch (\Exception $e) {
65
            glsr_log()->error($e->getMessage());
66
        }
67
        return $entries;
68
    }
69
70
    /**
71
     * @return static
72
     */
73
    public function filter(?array $filterWith = null, ?array $entries = null, bool $intersect = true)
74
    {
75
        if (!is_array($entries)) {
76
            $entries = $this->results;
77
        }
78
        if (!is_array($filterWith)) {
79
            $filterWith = $this->strings();
80
        }
81
        $keys = array_flip(wp_list_pluck($filterWith, 'id'));
82
        $this->results = $intersect
83
            ? array_intersect_key($entries, $keys)
84
            : array_diff_key($entries, $keys);
85
        return $this;
86
    }
87
88
    public function isInvalid(array $entry): bool
89
    {
90
        $s1 = $entry['s1'] ?? '';
91
        $s2 = $entry['s2'] ?? '';
92
        $p1 = $entry['p1'] ?? '';
93
        $p2 = $entry['p2'] ?? '';
94
        if ($s1 === $s2 && $p1 === $p2) {
95
            return false;
96
        }
97
        if ($this->placeholders($s1) !== $this->placeholders($s2)) {
98
            return true;
99
        }
100
        if ($this->placeholders($p1) !== $this->placeholders($p2)) {
101
            return true;
102
        }
103
        return false;
104
    }
105
106
    public function isMissing(array $entry): bool
107
    {
108
        if (empty($entry['s1'])) {
109
            return false;
110
        }
111
        $s1 = $entry['s1'];
112
        return false === Arr::searchByKey($s1, $this->entries(), 'msgid')
113
            && false === Arr::searchByKey(htmlentities2($s1), $this->entries(), 'msgid');
114
    }
115
116
    public function placeholders(string $format): int
117
    {
118
        $count = $i = 0;
119
        $len = strlen($format);
120
        $specifiers = 'sducfxXeE';
121
        while ($i < $len) {
122
            if ('%' !== $format[$i++]) {
123
                continue;
124
            }
125
            if ($i === $len || '%' === $format[$i]) {
126
                ++$i;
127
                continue;
128
            }
129
            if (false !== strpos($specifiers, $format[$i])) {
130
                ++$count;
131
            } elseif ('.' === $format[$i] || ctype_digit($format[$i])) {
132
                // check for float specifiers
133
                $idx = $i;
134
                while ($idx < $len && ('.' === $format[$idx] || ctype_digit($format[$idx]))) {
135
                    ++$idx;
136
                }
137
                if ($idx < $len && false !== strpos('fFeE', $format[$idx])) {
138
                    ++$count;
139
                    $i = $idx;
140
                }
141
            }
142
            ++$i;
143
        }
144
        return $count;
145
    }
146
147
    public function render(string $template, array $entry): string
148
    {
149
        $data = array_combine(array_map(fn ($key) => "data.{$key}", array_keys($entry)), $entry);
150
        $data['data.class'] = '';
151
        $data['data.error'] = '';
152
        if ($this->isMissing($entry)) {
153
            $data['data.class'] = 'is-invalid';
154
            $data['data.error'] = _x('This custom text is invalid because the original text has been changed or removed.', 'admin-text', 'site-reviews');
155
        } elseif ($this->isInvalid($entry)) {
156
            $data['data.class'] = 'is-invalid';
157
            $data['data.error'] = _x('The placeholder tags are missing in your custom text.', 'admin-text', 'site-reviews');
158
        }
159
        return glsr(Template::class)->build("partials/strings/{$template}", [
160
            'context' => array_map('esc_html', $data),
161
        ]);
162
    }
163
164
    /**
165
     * Returns a rendered string of all saved custom strings with translation context.
166
     */
167
    public function renderAll(): string
168
    {
169
        $rendered = '';
170
        foreach ($this->all() as $index => $entry) {
171
            $entry['index'] = $index;
172
            $entry['prefix'] = OptionManager::databaseKey();
173
            $rendered .= $this->render($entry['type'], $entry);
174
        }
175
        return $rendered;
176
    }
177
178
    public function renderResults(bool $resetAfterRender = true): string
179
    {
180
        $rendered = '';
181
        foreach ($this->results as $id => $entry) {
182
            $data = [
183
                'desc' => $this->getEntryString($entry, 'msgctxt'),
184
                'id' => $id,
185
                'p1' => $this->getEntryString($entry, 'msgid_plural'),
186
                's1' => $this->getEntryString($entry, 'msgid'),
187
            ];
188
            $text = !empty($data['p1'])
189
                ? sprintf('%s | %s', $data['s1'], $data['p1'])
190
                : $data['s1'];
191
            $rendered .= $this->render('result', [
192
                'domain' => $this->getEntryString($entry, 'domain'),
193
                'entry' => wp_json_encode($data, JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
194
                'text' => wp_strip_all_tags($text),
195
            ]);
196
        }
197
        if ($resetAfterRender) {
198
            $this->reset();
199
        }
200
        return $rendered;
201
    }
202
203
    public function reset(): void
204
    {
205
        $this->results = [];
206
    }
207
208
    public function results(): array
209
    {
210
        $results = $this->results;
211
        $this->reset();
212
        return $results;
213
    }
214
215
    /**
216
     * @return static
217
     */
218
    public function search(string $needle = '')
219
    {
220
        $this->reset();
221
        $needle = trim(strtolower($needle));
222
        foreach ($this->entries() as $key => $entry) {
223
            $single = strtolower($this->getEntryString($entry, 'msgid'));
224
            $plural = strtolower($this->getEntryString($entry, 'msgid_plural'));
225
            if (strlen($needle) < static::SEARCH_THRESHOLD) {
226
                if (in_array($needle, [$single, $plural])) {
227
                    $this->results[$key] = $entry;
228
                }
229
            } elseif (str_contains(sprintf('%s %s', $single, $plural), $needle)) {
230
                $this->results[$key] = $entry;
231
            }
232
        }
233
        return $this;
234
    }
235
236
    /**
237
     * Store the strings to avoid unnecessary loops.
238
     */
239
    public function strings(): array
240
    {
241
        static $strings;
242
        if (empty($strings)) {
243
            // we need to bypass the filter hooks because this is run before the settings are initiated
244
            $settings = get_option(OptionManager::databaseKey());
245
            $strings = Arr::getAs('array', $settings, 'settings.strings');
246
            $strings = $this->normalizeStrings($strings);
247
        }
248
        return $strings;
249
    }
250
251
    protected function getEntryString(array $entry, string $key): string
252
    {
253
        return isset($entry[$key])
254
            ? implode('', (array) $entry[$key])
255
            : '';
256
    }
257
258
    protected function normalize(array $entries): array
259
    {
260
        $keys = [
261
            'msgctxt', 'msgid', 'msgid_plural', 'msgstr', 'msgstr[0]', 'msgstr[1]',
262
        ];
263
        array_walk($entries, function (&$entry) use ($keys) {
264
            foreach ($keys as $key) {
265
                $entry = $this->normalizeEntryString($entry, $key);
266
            }
267
        });
268
        return $entries;
269
    }
270
271
    protected function normalizeEntryString(array $entry, string $key): array
272
    {
273
        if (isset($entry[$key])) {
274
            $entry[$key] = $this->getEntryString($entry, $key);
275
        }
276
        return $entry;
277
    }
278
279
    protected function normalizeStrings(array $strings): array
280
    {
281
        $defaultString = array_fill_keys(['id', 's1', 's2', 'p1', 'p2'], '');
282
        $strings = array_filter($strings, 'is_array');
283
        foreach ($strings as &$string) {
284
            $string['type'] = isset($string['p1']) ? 'plural' : 'single';
285
            $string = wp_parse_args($string, $defaultString);
286
        }
287
        return array_filter($strings, fn ($string) => !empty($string['id']));
288
    }
289
}
290