WebOrbiton
v1.0.0.0

StocketBase

770 lines · 26.4 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/config.php';
  6. require_once __DIR__ . '/database.php';
  7. require_once __DIR__ . '/site-front.php';
  8. ​
  9. final class Languages
  10. {
  11. public const CODE_PATTERN = '/^[a-z]{2,3}(?:-[a-z]{2,4})?$/';
  12. ​
  13. private const TRANSLATION_FIELDS = ['title', 'excerpt', 'content_blocks', 'seo_title', 'seo_description'];
  14. ​
  15. private static ?string $current = null;
  16. private static ?array $rows = null;
  17. private static array $stringCache = [];
  18. ​
  19. public static function enabled(): bool
  20. {
  21. return (SiteFront::settings()['multilang_enabled'] ?? '0') === '1';
  22. }
  23. ​
  24. public static function isValidCode(string $code): bool
  25. {
  26. return preg_match(self::CODE_PATTERN, $code) === 1;
  27. }
  28. ​
  29. public static function defaultCode(): string
  30. {
  31. $code = strtolower(trim((string) (SiteFront::settings()['multilang_default_code'] ?? 'en')));
  32. ​
  33. return self::isValidCode($code) ? $code : 'en';
  34. }
  35. ​
  36. public static function defaultName(): string
  37. {
  38. $name = trim((string) (SiteFront::settings()['multilang_default_name'] ?? ''));
  39. ​
  40. return $name !== '' ? $name : 'English';
  41. }
  42. ​
  43. public static function all(): array
  44. {
  45. if (self::$rows !== null) {
  46. return self::$rows;
  47. }
  48. ​
  49. try {
  50. self::$rows = Database::site()->query('SELECT code, name, sort_order, is_active FROM site_languages ORDER BY sort_order ASC, code ASC')->fetchAll();
  51. } catch (PDOException $e) {
  52. self::$rows = [];
  53. }
  54. ​
  55. return self::$rows;
  56. }
  57. ​
  58. public static function active(): array
  59. {
  60. return array_values(array_filter(self::all(), static fn(array $row): bool => (int) $row['is_active'] === 1));
  61. }
  62. ​
  63. public static function activeCodes(): array
  64. {
  65. return array_column(self::active(), 'code');
  66. }
  67. ​
  68. public static function find(string $code): ?array
  69. {
  70. foreach (self::all() as $row) {
  71. if ($row['code'] === $code) {
  72. return $row;
  73. }
  74. }
  75. ​
  76. return null;
  77. }
  78. ​
  79. public static function resetCache(): void
  80. {
  81. self::$rows = null;
  82. self::$current = null;
  83. self::$stringCache = [];
  84. }
  85. ​
  86. public static function current(): string
  87. {
  88. if (self::$current !== null) {
  89. return self::$current;
  90. }
  91. ​
  92. self::$current = '';
  93. if (!self::enabled()) {
  94. return '';
  95. }
  96. ​
  97. $requested = strtolower(trim((string) ($_GET['stocketbase_lang'] ?? '')));
  98. if ($requested !== '' && self::isValidCode($requested) && in_array($requested, self::activeCodes(), true)) {
  99. self::$current = $requested;
  100. }
  101. ​
  102. return self::$current;
  103. }
  104. ​
  105. public static function basePath(): string
  106. {
  107. return rtrim((string) Config::get('APP_BASE_PATH', ''), '/');
  108. }
  109. ​
  110. public static function prefix(): string
  111. {
  112. $code = self::current();
  113. ​
  114. return $code === '' ? '' : '/' . $code;
  115. }
  116. ​
  117. public static function htmlLang(): string
  118. {
  119. $code = self::current();
  120. ​
  121. return $code !== '' ? $code : (self::enabled() ? self::defaultCode() : 'en');
  122. }
  123. ​
  124. public static function homeUrl(string $code): string
  125. {
  126. return self::basePath() . ($code === '' ? '/' : '/' . $code . '/');
  127. }
  128. ​
  129. public static function switchUrl(string $code): string
  130. {
  131. $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
  132. $path = (string) (parse_url($uri, PHP_URL_PATH) ?? '/');
  133. $query = (string) (parse_url($uri, PHP_URL_QUERY) ?? '');
  134. ​
  135. $base = self::basePath();
  136. if ($base !== '' && str_starts_with($path, $base)) {
  137. $path = substr($path, strlen($base));
  138. }
  139. ​
  140. $current = self::current();
  141. if ($current !== '' && preg_match('#^/' . preg_quote($current, '#') . '(?=/|$)#', $path) === 1) {
  142. $path = substr($path, strlen($current) + 1);
  143. }
  144. ​
  145. if ($path === '' || $path[0] !== '/') {
  146. $path = '/' . $path;
  147. }
  148. ​
  149. $target = $code === '' ? $path : '/' . $code . ($path === '/' ? '/' : $path);
  150. ​
  151. return $base . $target . ($query !== '' ? '?' . $query : '');
  152. }
  153. ​
  154. public static function options(): array
  155. {
  156. $options = [['code' => '', 'name' => self::defaultName(), 'hreflang' => self::defaultCode()]];
  157. foreach (self::active() as $row) {
  158. $options[] = ['code' => $row['code'], 'name' => $row['name'], 'hreflang' => $row['code']];
  159. }
  160. ​
  161. return $options;
  162. }
  163. ​
  164. public static function absolutizeUrls(string $html): string
  165. {
  166. if (self::current() === '') {
  167. return $html;
  168. }
  169. ​
  170. $base = self::basePath();
  171. $html = preg_replace('~(\s(?:src|href|poster|data-src)=["\'])(?:\./)?(media/|assets/)~i', '$1' . $base . '/$2', $html) ?? $html;
  172. ​
  173. return preg_replace('~(url\(\s*["\']?)(?:\./)?(media/|assets/)~i', '$1' . $base . '/$2', $html) ?? $html;
  174. }
  175. ​
  176. public static function renderAlternates(): string
  177. {
  178. if (!self::enabled() || empty(self::active())) {
  179. return '';
  180. }
  181. ​
  182. $origin = rtrim((string) Config::get('APP_URL', ''), '/');
  183. $html = '';
  184. foreach (self::options() as $option) {
  185. $html .= '<link rel="alternate" hreflang="' . htmlspecialchars($option['hreflang'], ENT_QUOTES) . '" href="' . htmlspecialchars($origin . self::switchUrl($option['code']), ENT_QUOTES) . '">' . "\n";
  186. }
  187. $html .= '<link rel="alternate" hreflang="x-default" href="' . htmlspecialchars($origin . self::switchUrl(''), ENT_QUOTES) . '">' . "\n";
  188. ​
  189. return $html;
  190. }
  191. ​
  192. public static function renderSwitcher(): string
  193. {
  194. $settings = SiteFront::settings();
  195. if (!self::enabled() || ($settings['multilang_switcher'] ?? '1') !== '1' || empty(self::active())) {
  196. return '';
  197. }
  198. ​
  199. $current = self::current();
  200. $label = Language::get('language_switcher_label', 'Language');
  201. $options = self::options();
  202. $currentName = self::defaultName();
  203. foreach ($options as $option) {
  204. if ($option['code'] === $current) {
  205. $currentName = $option['name'];
  206. }
  207. }
  208. ​
  209. $html = '<details class="site-language-switcher" data-language-switcher>'
  210. . '<summary aria-label="' . htmlspecialchars($label, ENT_QUOTES) . '"><span>' . htmlspecialchars($currentName) . '</span>'
  211. . '<svg class="icon icon-sm site-language-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"></path></svg></summary>'
  212. . '<ul class="site-language-menu">';
  213. foreach ($options as $option) {
  214. $isCurrent = $option['code'] === $current;
  215. $html .= '<li><a href="' . htmlspecialchars(self::switchUrl($option['code']), ENT_QUOTES) . '" hreflang="' . htmlspecialchars($option['hreflang'], ENT_QUOTES) . '" lang="' . htmlspecialchars($option['hreflang'], ENT_QUOTES) . '"'
  216. . ($isCurrent ? ' aria-current="true" class="is-current"' : '') . '>' . htmlspecialchars($option['name']) . '</a></li>';
  217. }
  218. ​
  219. return $html . '</ul></details>';
  220. }
  221. ​
  222. public static function stringsPath(string $code): string
  223. {
  224. return dirname(__DIR__) . '/translations/language-' . $code . '.php';
  225. }
  226. ​
  227. public static function strings(string $code): array
  228. {
  229. if (!self::isValidCode($code)) {
  230. return [];
  231. }
  232. ​
  233. if (isset(self::$stringCache[$code])) {
  234. return self::$stringCache[$code];
  235. }
  236. ​
  237. $path = self::stringsPath($code);
  238. $loaded = is_file($path) ? require $path : [];
  239. self::$stringCache[$code] = is_array($loaded) ? $loaded : [];
  240. ​
  241. return self::$stringCache[$code];
  242. }
  243. ​
  244. public static function saveStrings(string $code, array $strings): bool
  245. {
  246. if (!self::isValidCode($code)) {
  247. return false;
  248. }
  249. ​
  250. $base = Language::base();
  251. $clean = [];
  252. foreach ($base as $key => $unused) {
  253. $value = trim(strip_tags((string) ($strings[$key] ?? '')));
  254. if ($value !== '') {
  255. $clean[$key] = $value;
  256. }
  257. }
  258. ​
  259. $export = "<?php\n\ndeclare(strict_types=1);\n\nreturn [\n";
  260. foreach ($clean as $key => $value) {
  261. $export .= ' ' . var_export($key, true) . ' => ' . var_export($value, true) . ",\n";
  262. }
  263. $export .= "];\n";
  264. ​
  265. $path = self::stringsPath($code);
  266. $temporary = $path . '.tmp' . bin2hex(random_bytes(4));
  267. if (file_put_contents($temporary, $export) === false) {
  268. return false;
  269. }
  270. ​
  271. if (!@rename($temporary, $path)) {
  272. @unlink($temporary);
  273. ​
  274. return false;
  275. }
  276. ​
  277. unset(self::$stringCache[$code]);
  278. ​
  279. return true;
  280. }
  281. ​
  282. public static function copyMissingStrings(string $code): int
  283. {
  284. if (!self::isValidCode($code)) {
  285. return 0;
  286. }
  287. ​
  288. $base = Language::base();
  289. $current = self::strings($code);
  290. $added = 0;
  291. foreach ($base as $key => $value) {
  292. if (!isset($current[$key]) || trim((string) $current[$key]) === '') {
  293. $current[$key] = $value;
  294. $added++;
  295. }
  296. }
  297. ​
  298. if ($added > 0 && !self::saveStrings($code, $current)) {
  299. return 0;
  300. }
  301. ​
  302. return $added;
  303. }
  304. ​
  305. public static function missingStringCount(string $code): int
  306. {
  307. $current = self::strings($code);
  308. $missing = 0;
  309. foreach (Language::base() as $key => $unused) {
  310. if (!isset($current[$key]) || trim((string) $current[$key]) === '') {
  311. $missing++;
  312. }
  313. }
  314. ​
  315. return $missing;
  316. }
  317. ​
  318. public static function addLanguage(string $code, string $name): ?string
  319. {
  320. $code = strtolower(trim($code));
  321. $name = trim(strip_tags($name));
  322. ​
  323. if (!self::isValidCode($code)) {
  324. return 'Language code must be 2-3 letters, optionally with a region such as pt-br.';
  325. }
  326. if ($name === '' || mb_strlen($name) > 60) {
  327. return 'Language name is required (up to 60 characters).';
  328. }
  329. if ($code === self::defaultCode()) {
  330. return 'This is already the original language of the site.';
  331. }
  332. if (self::find($code) !== null) {
  333. return 'This language already exists.';
  334. }
  335. ​
  336. $order = count(self::all()) + 1;
  337. $statement = Database::site()->prepare('INSERT INTO site_languages (code, name, sort_order, is_active) VALUES (:code, :name, :sort, 0)');
  338. $statement->execute(['code' => $code, 'name' => $name, 'sort' => $order]);
  339. self::resetCache();
  340. ​
  341. return null;
  342. }
  343. ​
  344. public static function setSetting(string $key, string $value): void
  345. {
  346. $statement = Database::site()->prepare(
  347. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
  348. );
  349. $statement->execute(['key' => $key, 'value' => $value]);
  350. }
  351. ​
  352. public static function translation(string $type, int $id, string $code): ?array
  353. {
  354. try {
  355. $statement = Database::site()->prepare(
  356. 'SELECT * FROM content_translations WHERE entity_type = :type AND entity_id = :id AND lang = :lang LIMIT 1'
  357. );
  358. $statement->execute(['type' => $type, 'id' => $id, 'lang' => $code]);
  359. } catch (PDOException $e) {
  360. return null;
  361. }
  362. ​
  363. return $statement->fetch() ?: null;
  364. }
  365. ​
  366. public static function translatedIds(string $type, string $code): array
  367. {
  368. try {
  369. $statement = Database::site()->prepare(
  370. "SELECT entity_id FROM content_translations WHERE entity_type = :type AND lang = :lang AND title <> ''"
  371. );
  372. $statement->execute(['type' => $type, 'lang' => $code]);
  373. } catch (PDOException $e) {
  374. return [];
  375. }
  376. ​
  377. return array_map('intval', $statement->fetchAll(PDO::FETCH_COLUMN));
  378. }
  379. ​
  380. public static function saveTranslation(string $type, int $id, string $code, array $fields): void
  381. {
  382. $values = [];
  383. foreach (self::TRANSLATION_FIELDS as $field) {
  384. $values[$field] = (string) ($fields[$field] ?? '');
  385. }
  386. ​
  387. $statement = Database::site()->prepare(
  388. 'INSERT INTO content_translations (entity_type, entity_id, lang, title, excerpt, content_blocks, seo_title, seo_description)
  389. VALUES (:type, :id, :lang, :title, :excerpt, :content_blocks, :seo_title, :seo_description)
  390. ON DUPLICATE KEY UPDATE title = VALUES(title), excerpt = VALUES(excerpt), content_blocks = VALUES(content_blocks),
  391. seo_title = VALUES(seo_title), seo_description = VALUES(seo_description)'
  392. );
  393. $statement->execute(['type' => $type, 'id' => $id, 'lang' => $code] + $values);
  394. }
  395. ​
  396. public static function deleteTranslation(string $type, int $id, string $code): void
  397. {
  398. $statement = Database::site()->prepare('DELETE FROM content_translations WHERE entity_type = :type AND entity_id = :id AND lang = :lang');
  399. $statement->execute(['type' => $type, 'id' => $id, 'lang' => $code]);
  400. }
  401. ​
  402. private static function batch(string $type, array $ids, string $code): array
  403. {
  404. $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
  405. if (empty($ids)) {
  406. return [];
  407. }
  408. ​
  409. try {
  410. $statement = Database::site()->prepare(
  411. 'SELECT * FROM content_translations WHERE entity_type = ? AND lang = ? AND entity_id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')'
  412. );
  413. $statement->execute(array_merge([$type, $code], $ids));
  414. } catch (PDOException $e) {
  415. return [];
  416. }
  417. ​
  418. $map = [];
  419. foreach ($statement->fetchAll() as $row) {
  420. $map[(int) $row['entity_id']] = $row;
  421. }
  422. ​
  423. return $map;
  424. }
  425. ​
  426. private static function overlay(array $row, ?array $translation, array $fieldMap): array
  427. {
  428. if ($translation === null) {
  429. return $row;
  430. }
  431. ​
  432. foreach ($fieldMap as $column => $translationField) {
  433. $value = (string) ($translation[$translationField] ?? '');
  434. if (trim($value) === '') {
  435. continue;
  436. }
  437. ​
  438. if ($translationField === 'content_blocks') {
  439. $decoded = json_decode($value, true);
  440. if (!is_array($decoded) || empty($decoded['blocks'])) {
  441. continue;
  442. }
  443. }
  444. ​
  445. $row[$column] = $value;
  446. }
  447. ​
  448. return $row;
  449. }
  450. ​
  451. public static function articles(array $rows): array
  452. {
  453. $code = self::current();
  454. if ($code === '' || empty($rows)) {
  455. return $rows;
  456. }
  457. ​
  458. $map = self::batch('article', array_column($rows, 'id'), $code);
  459. $categoryIds = array_column($rows, 'category_id');
  460. $categoryMap = self::batch('category', $categoryIds, $code);
  461. $fieldMap = ['title' => 'title', 'excerpt' => 'excerpt', 'content_blocks' => 'content_blocks', 'seo_title' => 'seo_title', 'seo_description' => 'seo_description'];
  462. ​
  463. foreach ($rows as $index => $row) {
  464. if (!isset($row['id'])) {
  465. continue;
  466. }
  467. $rows[$index] = self::overlay($row, $map[(int) $row['id']] ?? null, $fieldMap);
  468. if (!empty($row['category_id']) && isset($categoryMap[(int) $row['category_id']]) && array_key_exists('category_name', $row)) {
  469. $categoryTitle = trim((string) $categoryMap[(int) $row['category_id']]['title']);
  470. if ($categoryTitle !== '') {
  471. $rows[$index]['category_name'] = $categoryTitle;
  472. }
  473. }
  474. }
  475. ​
  476. return $rows;
  477. }
  478. ​
  479. public static function article(array $row): array
  480. {
  481. return self::articles([$row])[0];
  482. }
  483. ​
  484. public static function hasTranslation(string $type, int $id): bool
  485. {
  486. $code = self::current();
  487. if ($code === '') {
  488. return false;
  489. }
  490. ​
  491. $translation = self::batch($type, [$id], $code)[$id] ?? null;
  492. ​
  493. return $translation !== null && trim((string) $translation['title']) !== '';
  494. }
  495. ​
  496. public static function categories(array $rows, string $nameKey = 'name', string $descriptionKey = 'description'): array
  497. {
  498. $code = self::current();
  499. if ($code === '' || empty($rows)) {
  500. return $rows;
  501. }
  502. ​
  503. $map = self::batch('category', array_column($rows, 'id'), $code);
  504. foreach ($rows as $index => $row) {
  505. $rows[$index] = self::overlay($row, $map[(int) ($row['id'] ?? 0)] ?? null, [$nameKey => 'title', $descriptionKey => 'excerpt']);
  506. }
  507. ​
  508. return $rows;
  509. }
  510. ​
  511. public static function pages(array $rows): array
  512. {
  513. $code = self::current();
  514. if ($code === '' || empty($rows)) {
  515. return $rows;
  516. }
  517. ​
  518. $map = self::batch('page', array_column($rows, 'id'), $code);
  519. foreach ($rows as $index => $row) {
  520. $rows[$index] = self::overlay($row, $map[(int) ($row['id'] ?? 0)] ?? null, ['title' => 'title', 'content_blocks' => 'content_blocks', 'seo_title' => 'seo_title', 'seo_description' => 'seo_description']);
  521. }
  522. ​
  523. return $rows;
  524. }
  525. ​
  526. public static function applySiteTexts(array $settings): array
  527. {
  528. $code = self::current();
  529. if ($code === '') {
  530. return $settings;
  531. }
  532. ​
  533. $row = self::translation('site', 0, $code);
  534. if ($row === null) {
  535. return $settings;
  536. }
  537. ​
  538. foreach (['site_name' => 'title', 'site_description' => 'excerpt', 'seo_title' => 'seo_title', 'seo_description' => 'seo_description'] as $key => $field) {
  539. $value = trim((string) ($row[$field] ?? ''));
  540. if ($value !== '') {
  541. $settings[$key] = $value;
  542. }
  543. }
  544. ​
  545. return $settings;
  546. }
  547. ​
  548. public static function titles(string $type, array $ids): array
  549. {
  550. $code = self::current();
  551. if ($code === '' || empty($ids)) {
  552. return [];
  553. }
  554. ​
  555. $titles = [];
  556. foreach (self::batch($type, $ids, $code) as $id => $row) {
  557. $title = trim((string) $row['title']);
  558. if ($title !== '') {
  559. $titles[$id] = $title;
  560. }
  561. }
  562. ​
  563. return $titles;
  564. }
  565. ​
  566. public static function footerColumns(array $columns): array
  567. {
  568. $code = self::current();
  569. if ($code === '' || empty($columns)) {
  570. return $columns;
  571. }
  572. ​
  573. $row = self::translation('footer', 0, $code);
  574. if ($row === null) {
  575. return $columns;
  576. }
  577. ​
  578. $decoded = json_decode((string) $row['content_blocks'], true);
  579. $translated = is_array($decoded) && is_array($decoded['columns'] ?? null) ? $decoded['columns'] : [];
  580. ​
  581. foreach ($columns as $index => $column) {
  582. $entry = $translated[$index] ?? null;
  583. if (!is_array($entry)) {
  584. continue;
  585. }
  586. ​
  587. $title = trim((string) ($entry['title'] ?? ''));
  588. if ($title !== '') {
  589. $columns[$index]['title'] = $title;
  590. }
  591. ​
  592. foreach (($column['links'] ?? []) as $linkIndex => $link) {
  593. $label = trim((string) ($entry['links'][$linkIndex] ?? ''));
  594. if ($label !== '') {
  595. $columns[$index]['links'][$linkIndex]['label'] = $label;
  596. }
  597. }
  598. }
  599. ​
  600. return $columns;
  601. }
  602. ​
  603. public static function navOriginals(): array
  604. {
  605. try {
  606. $rows = Database::site()->query('SELECT id, item_type, ref_id, label FROM nav_items ORDER BY sort_order ASC, id ASC')->fetchAll();
  607. } catch (PDOException $e) {
  608. return [];
  609. }
  610. ​
  611. $originals = [];
  612. foreach ($rows as $row) {
  613. $label = trim((string) ($row['label'] ?? ''));
  614. if ($label === '' && in_array($row['item_type'], ['category', 'page'], true)) {
  615. $lookup = Database::site()->prepare($row['item_type'] === 'category' ? 'SELECT name FROM categories WHERE id = :id' : 'SELECT title FROM pages WHERE id = :id');
  616. $lookup->execute(['id' => (int) $row['ref_id']]);
  617. $label = trim((string) $lookup->fetchColumn());
  618. }
  619. if ($label !== '') {
  620. $originals[(int) $row['id']] = $label;
  621. }
  622. }
  623. ​
  624. return $originals;
  625. }
  626. ​
  627. public static function siteOriginals(): array
  628. {
  629. require_once __DIR__ . '/footer-links.php';
  630. ​
  631. $settings = Database::site()->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll(PDO::FETCH_KEY_PAIR);
  632. $columns = [];
  633. foreach (FooterLinks::decodeColumns((string) ($settings['footer_columns'] ?? '[]')) as $column) {
  634. $columns[] = [
  635. 'title' => (string) ($column['title'] ?? ''),
  636. 'links' => array_map(static fn($link): string => (string) ($link['label'] ?? ''), $column['links'] ?? []),
  637. ];
  638. }
  639. ​
  640. return [
  641. 'title' => (string) ($settings['site_name'] ?? Config::get('APP_NAME', 'StocketBase')),
  642. 'excerpt' => (string) ($settings['site_description'] ?? ''),
  643. 'seo_title' => (string) ($settings['seo_title'] ?? ''),
  644. 'seo_description' => (string) ($settings['seo_description'] ?? ''),
  645. 'nav' => self::navOriginals(),
  646. 'footer' => $columns,
  647. ];
  648. }
  649. ​
  650. public static function siteValues(string $code): array
  651. {
  652. $site = self::translation('site', 0, $code) ?? [];
  653. $nav = [];
  654. foreach (array_keys(self::navOriginals()) as $id) {
  655. $row = self::translation('nav', $id, $code);
  656. $nav[$id] = $row !== null ? (string) $row['title'] : '';
  657. }
  658. ​
  659. $footerRow = self::translation('footer', 0, $code);
  660. $decoded = $footerRow !== null ? json_decode((string) $footerRow['content_blocks'], true) : null;
  661. ​
  662. return [
  663. 'title' => (string) ($site['title'] ?? ''),
  664. 'excerpt' => (string) ($site['excerpt'] ?? ''),
  665. 'seo_title' => (string) ($site['seo_title'] ?? ''),
  666. 'seo_description' => (string) ($site['seo_description'] ?? ''),
  667. 'nav' => $nav,
  668. 'footer' => is_array($decoded) && is_array($decoded['columns'] ?? null) ? $decoded['columns'] : [],
  669. ];
  670. }
  671. ​
  672. public static function saveSiteValues(string $code, array $values): void
  673. {
  674. $title = mb_substr(trim((string) ($values['title'] ?? '')), 0, 255);
  675. $excerpt = trim((string) ($values['excerpt'] ?? ''));
  676. $seoTitle = mb_substr(trim((string) ($values['seo_title'] ?? '')), 0, 255);
  677. $seoDescription = mb_substr(trim((string) ($values['seo_description'] ?? '')), 0, 500);
  678. ​
  679. if ($title === '' && $excerpt === '' && $seoTitle === '' && $seoDescription === '') {
  680. self::deleteTranslation('site', 0, $code);
  681. } else {
  682. self::saveTranslation('site', 0, $code, ['title' => $title, 'excerpt' => $excerpt, 'content_blocks' => '', 'seo_title' => $seoTitle, 'seo_description' => $seoDescription]);
  683. }
  684. ​
  685. foreach (array_keys(self::navOriginals()) as $id) {
  686. $label = mb_substr(trim((string) ($values['nav'][$id] ?? '')), 0, 255);
  687. if ($label === '') {
  688. self::deleteTranslation('nav', $id, $code);
  689. } else {
  690. self::saveTranslation('nav', $id, $code, ['title' => $label]);
  691. }
  692. }
  693. ​
  694. $columns = [];
  695. $hasFooter = false;
  696. foreach (self::siteOriginals()['footer'] as $index => $original) {
  697. $columnTitle = mb_substr(trim((string) ($values['footer'][$index]['title'] ?? '')), 0, 120);
  698. $links = [];
  699. foreach (array_keys($original['links']) as $linkIndex) {
  700. $links[$linkIndex] = mb_substr(trim((string) ($values['footer'][$index]['links'][$linkIndex] ?? '')), 0, 120);
  701. $hasFooter = $hasFooter || $links[$linkIndex] !== '';
  702. }
  703. $hasFooter = $hasFooter || $columnTitle !== '';
  704. $columns[$index] = ['title' => $columnTitle, 'links' => $links];
  705. }
  706. ​
  707. if ($hasFooter) {
  708. self::saveTranslation('footer', 0, $code, ['title' => 'footer', 'content_blocks' => (string) json_encode(['columns' => $columns], JSON_UNESCAPED_UNICODE)]);
  709. } else {
  710. self::deleteTranslation('footer', 0, $code);
  711. }
  712. }
  713. ​
  714. public static function copySiteMissing(string $code): int
  715. {
  716. $originals = self::siteOriginals();
  717. $values = self::siteValues($code);
  718. $added = 0;
  719. ​
  720. foreach (['title', 'excerpt', 'seo_title', 'seo_description'] as $field) {
  721. if (trim($values[$field]) === '' && trim($originals[$field]) !== '') {
  722. $values[$field] = $originals[$field];
  723. $added++;
  724. }
  725. }
  726. ​
  727. foreach ($originals['nav'] as $id => $label) {
  728. if (trim((string) ($values['nav'][$id] ?? '')) === '') {
  729. $values['nav'][$id] = $label;
  730. $added++;
  731. }
  732. }
  733. ​
  734. foreach ($originals['footer'] as $index => $column) {
  735. if (trim((string) ($values['footer'][$index]['title'] ?? '')) === '' && trim($column['title']) !== '') {
  736. $values['footer'][$index]['title'] = $column['title'];
  737. $added++;
  738. }
  739. foreach ($column['links'] as $linkIndex => $label) {
  740. if (trim((string) ($values['footer'][$index]['links'][$linkIndex] ?? '')) === '' && trim($label) !== '') {
  741. $values['footer'][$index]['links'][$linkIndex] = $label;
  742. $added++;
  743. }
  744. }
  745. }
  746. ​
  747. if ($added > 0) {
  748. self::saveSiteValues($code, $values);
  749. }
  750. ​
  751. return $added;
  752. }
  753. ​
  754. public static function onlyTranslated(array $rows): array
  755. {
  756. if (!self::requiresTranslation() || empty($rows)) {
  757. return $rows;
  758. }
  759. ​
  760. $translated = self::batch('article', array_column($rows, 'id'), self::current());
  761. ​
  762. return array_values(array_filter($rows, static fn(array $row): bool => isset($translated[(int) ($row['id'] ?? 0)]) && trim((string) $translated[(int) $row['id']]['title']) !== ''));
  763. }
  764. ​
  765. public static function requiresTranslation(): bool
  766. {
  767. return self::current() !== '';
  768. }
  769. }
  770. ​