WebOrbiton
v2.0.0.0

Publisium

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