Merge branch '6.0'
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / data / language / LanguageEditor.class.php
... / ...
CommitLineData
1<?php
2
3namespace wcf\data\language;
4
5use wcf\data\DatabaseObjectEditor;
6use wcf\data\IEditableCachedObject;
7use wcf\data\language\category\LanguageCategory;
8use wcf\data\language\category\LanguageCategoryEditor;
9use wcf\data\language\item\LanguageItemEditor;
10use wcf\data\language\item\LanguageItemList;
11use wcf\data\page\PageEditor;
12use wcf\event\language\LanguageContentCopying;
13use wcf\system\cache\builder\LanguageCacheBuilder;
14use wcf\system\database\util\PreparedStatementConditionBuilder;
15use wcf\system\event\EventHandler;
16use wcf\system\exception\SystemException;
17use wcf\system\io\AtomicWriter;
18use wcf\system\language\LanguageFactory;
19use wcf\system\Regex;
20use wcf\system\WCF;
21use wcf\util\DirectoryUtil;
22use wcf\util\FileUtil;
23use wcf\util\StringUtil;
24use wcf\util\XML;
25
26/**
27 * Provides functions to edit languages.
28 *
29 * @author Alexander Ebert
30 * @copyright 2001-2019 WoltLab GmbH
31 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
32 *
33 * @method static Language create(array $parameters = [])
34 * @method Language getDecoratedObject()
35 * @mixin Language
36 */
37class LanguageEditor extends DatabaseObjectEditor implements IEditableCachedObject
38{
39 /**
40 * @inheritDoc
41 */
42 protected static $baseClass = Language::class;
43
44 /**
45 * @inheritDoc
46 */
47 public function delete()
48 {
49 parent::delete();
50
51 self::deleteLanguageFiles($this->languageID);
52 }
53
54 /**
55 * Updates the language files for the given category.
56 *
57 * @param LanguageCategory $languageCategory
58 */
59 public function updateCategory(LanguageCategory $languageCategory)
60 {
61 $this->writeLanguageFiles([$languageCategory->languageCategoryID]);
62 }
63
64 /**
65 * Write the languages files.
66 *
67 * @param int[] $languageCategoryIDs
68 */
69 protected function writeLanguageFiles(array $languageCategoryIDs)
70 {
71 $conditions = new PreparedStatementConditionBuilder();
72 $conditions->add("languageID = ?", [$this->languageID]);
73 $conditions->add("languageCategoryID IN (?)", [$languageCategoryIDs]);
74
75 // get language items
76 $sql = "SELECT languageItem, languageItemValue, languageCustomItemValue,
77 languageUseCustomValue, languageCategoryID
78 FROM wcf" . WCF_N . "_language_item
79 " . $conditions;
80 $statement = WCF::getDB()->prepareStatement($sql);
81 $statement->execute($conditions->getParameters());
82 $items = [];
83 while ($row = $statement->fetchArray()) {
84 $languageCategoryID = $row['languageCategoryID'];
85 if (!isset($items[$languageCategoryID])) {
86 $items[$languageCategoryID] = [];
87 }
88
89 $items[$languageCategoryID][$row['languageItem']] = $row['languageUseCustomValue'] ? $row['languageCustomItemValue'] : $row['languageItemValue'];
90 }
91
92 foreach ($items as $languageCategoryID => $languageItems) {
93 $category = LanguageFactory::getInstance()->getCategoryByID($languageCategoryID);
94 if ($category === null) {
95 continue;
96 }
97
98 $filename = WCF_DIR . 'language/' . $this->languageID . '_' . $category->languageCategory . '.php';
99 $writer = new AtomicWriter($filename);
100
101 $writer->write("<?php\n/**\n* WoltLab Suite\n* language: " . $this->languageCode . "\n* encoding: UTF-8\n* category: " . $category->languageCategory . "\n* generated at " . \gmdate("r") . "\n* \n* DO NOT EDIT THIS FILE\n*/\n");
102 foreach ($languageItems as $languageItem => $languageItemValue) {
103 $writer->write("\$this->items['" . $languageItem . "'] = " . \var_export($languageItemValue, true) . ";\n");
104
105 // compile dynamic language variables
106 if ($category->languageCategory != 'wcf.global' && \strpos($languageItemValue, '{') !== false) {
107 try {
108 $output = LanguageFactory::getInstance()->getScriptingCompiler()->compileString(
109 $languageItem,
110 $languageItemValue
111 );
112 } catch (SystemException $e) {
113 continue;
114 } // ignore compiler errors
115
116 $writer->write("\$this->dynamicItems['" . $languageItem . "'] = " . \var_export($output['template'], true) . ";\n");
117 }
118 }
119
120 $writer->flush();
121 $writer->close();
122 FileUtil::makeWritable($filename);
123 }
124 }
125
126 /**
127 * Exports this language.
128 */
129 public function export(int $packageID, bool $exportCustomValues = false)
130 {
131 $conditions = new PreparedStatementConditionBuilder();
132 $conditions->add("language_item.languageID = ?", [$this->languageID]);
133 $conditions->add("language_item.packageID = ?", [$packageID]);
134
135 // header
136 echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<language xmlns=\"http://www.woltlab.com\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://www.woltlab.com http://www.woltlab.com/XSD/6.0/language.xsd\" languagecode=\"" . $this->languageCode . "\" languagename=\"" . $this->languageName . "\" countrycode=\"" . $this->countryCode . "\">\n";
137
138 // get items
139 $sql = "SELECT languageItem,
140 " . ($exportCustomValues ? "CASE WHEN languageUseCustomValue > 0 THEN languageCustomItemValue ELSE languageItemValue END AS languageItemValue" : "languageItemValue") . ",
141 languageCategory
142 FROM wcf" . WCF_N . "_language_item language_item
143 LEFT JOIN wcf" . WCF_N . "_language_category language_category
144 ON language_category.languageCategoryID = language_item.languageCategoryID
145 " . $conditions;
146 $statement = WCF::getDB()->prepareStatement($sql);
147 $statement->execute($conditions->getParameters());
148 $items = [];
149 while ($row = $statement->fetchArray()) {
150 $items[$row['languageCategory']][$row['languageItem']] = $row['languageItemValue'];
151 }
152
153 // sort categories
154 \ksort($items);
155
156 foreach ($items as $category => $categoryItems) {
157 // sort items
158 \ksort($categoryItems);
159
160 // category header
161 echo "\t<category name=\"" . $category . "\">\n";
162
163 // items
164 foreach ($categoryItems as $item => $value) {
165 echo "\t\t<item name=\"" . $item . "\"><![CDATA[" . StringUtil::escapeCDATA($value) . "]]></item>\n";
166 }
167
168 // category footer
169 echo "\t</category>\n";
170 }
171
172 // add shadow values (pages)
173 $pages = [];
174 $conditions = new PreparedStatementConditionBuilder();
175 $conditions->add("page_content.pageID = page.pageID");
176 $conditions->add("page_content.languageID = ?", [$this->languageID]);
177 $conditions->add("page.packageID = ?", [$packageID]);
178 $conditions->add("page.originIsSystem = ?", [1]);
179 $sql = "SELECT page.identifier, page_content.title, page_content.content
180 FROM wcf" . WCF_N . "_page page,
181 wcf" . WCF_N . "_page_content page_content
182 " . $conditions . "
183 ORDER BY page.identifier";
184 $statement = WCF::getDB()->prepareStatement($sql);
185 $statement->execute($conditions->getParameters());
186 while ($row = $statement->fetchArray()) {
187 $pages[] = $row;
188 }
189
190 if (!empty($pages)) {
191 echo "\t<category name=\"shadow.invalid.page\">\n";
192
193 foreach ($pages as $page) {
194 if ($page['title']) {
195 echo "\t\t<item name=\"shadow.invalid.page." . $page['identifier'] . ".title\"><![CDATA[" . StringUtil::escapeCDATA($page['title']) . "]]></item>\n";
196 }
197 if ($page['content']) {
198 echo "\t\t<item name=\"shadow.invalid.page." . $page['identifier'] . ".content\"><![CDATA[" . StringUtil::escapeCDATA($page['content']) . "]]></item>\n";
199 }
200 }
201
202 echo "\t</category>\n";
203 }
204
205 // add shadow values (boxes)
206 $boxes = [];
207 $conditions = new PreparedStatementConditionBuilder();
208 $conditions->add("box_content.boxID = box.boxID");
209 $conditions->add("box_content.languageID = ?", [$this->languageID]);
210 $conditions->add("box.packageID = ?", [$packageID]);
211 $conditions->add("box.originIsSystem = ?", [1]);
212 $sql = "SELECT box.identifier, box_content.title, box_content.content
213 FROM wcf" . WCF_N . "_box box,
214 wcf" . WCF_N . "_box_content box_content
215 " . $conditions . "
216 ORDER BY box.identifier";
217 $statement = WCF::getDB()->prepareStatement($sql);
218 $statement->execute($conditions->getParameters());
219 while ($row = $statement->fetchArray()) {
220 $boxes[] = $row;
221 }
222
223 if (!empty($pages)) {
224 echo "\t<category name=\"shadow.invalid.box\">\n";
225
226 foreach ($boxes as $box) {
227 if ($box['title']) {
228 echo "\t\t<item name=\"shadow.invalid.box." . $box['identifier'] . ".title\"><![CDATA[" . StringUtil::escapeCDATA($box['title']) . "]]></item>\n";
229 }
230 if ($box['content']) {
231 echo "\t\t<item name=\"shadow.invalid.box." . $box['identifier'] . ".content\"><![CDATA[" . StringUtil::escapeCDATA($box['content']) . "]]></item>\n";
232 }
233 }
234
235 echo "\t</category>\n";
236 }
237
238 // footer
239 echo "</language>";
240 }
241
242 /**
243 * Deletes the language items from the given XML document.
244 *
245 * @throws \InvalidArgumentException if given XML document is invalid with regard to deleting language items
246 * @since 5.5
247 */
248 protected function deleteFromXML(XML $xml, int $packageID): void
249 {
250 $xpath = $xml->xpath();
251
252 $items = $xpath->query('/ns:language/ns:delete/ns:item');
253 if (empty($items)) {
254 return;
255 }
256
257 $languageItems = [];
258
259 /** @var \DOMElement $item */
260 foreach ($items as $item) {
261 $itemName = $item->getAttribute('name');
262
263 if (empty($itemName)) {
264 throw new \InvalidArgumentException("The 'name' attribute is missing or empty.");
265 }
266
267 if (StringUtil::trim($itemName) !== $itemName) {
268 throw new \InvalidArgumentException("The name '{$itemName}' contains leading or trailing whitespaces.");
269 }
270
271 $languageItems[] = $itemName;
272 }
273
274 if (empty($languageItems)) {
275 return;
276 }
277
278 $conditions = new PreparedStatementConditionBuilder();
279 $conditions->add('packageID = ?', [$packageID]);
280 $conditions->add('languageItem IN (?)', [$languageItems]);
281 $conditions->add('languageID = ?', [$this->languageID]);
282 $sql = "DELETE FROM wcf1_language_item
283 {$conditions}";
284 $statement = WCF::getDB()->prepare($sql);
285 $statement->execute($conditions->getParameters());
286 }
287
288 /**
289 * Checks the structure of the XML file to ensure that either the old, deprecated structure
290 * with direct `category` children is used or the new structure with `import` and `delete`
291 * children.
292 *
293 * @throws \InvalidArgumentException if old and new structure is mixed
294 * @since 5.5
295 */
296 protected function validateXMLStructure(XML $xml): void
297 {
298 $xpath = $xml->xpath();
299
300 $hasImport = $xpath->query('/ns:language/ns:import')->length !== 0;
301 $hasDelete = $xpath->query('/ns:language/ns:delete')->length !== 0;
302 $hasDirectCategories = $xpath->query('/ns:language/ns:category')->length !== 0;
303
304 if (($hasImport || $hasDelete) && $hasDirectCategories) {
305 throw new \InvalidArgumentException("'category' elements cannot be used next to 'import' and 'delete' elements.");
306 }
307 }
308
309 /**
310 * Imports language items from an XML file into this language.
311 * Updates the relevant language files automatically.
312 *
313 * @param XML $xml
314 * @param int $packageID
315 * @param bool $updateFiles
316 * @param bool $updateExistingItems
317 * @throws \InvalidArgumentException if given XML file is invalid
318 */
319 public function updateFromXML(XML $xml, $packageID, $updateFiles = true, $updateExistingItems = true)
320 {
321 $this->validateXMLStructure($xml);
322
323 $this->deleteFromXML($xml, $packageID);
324
325 $xpath = $xml->xpath();
326 $usedCategories = [];
327
328 // fetch categories
329 $categories = $xpath->query('/ns:language/ns:import/ns:category');
330 if ($categories->length === 0) {
331 // Fallback for the old, deprecated version before WSC 5.5, which only supported imports.
332 $categories = $xpath->query('/ns:language/ns:category');
333 }
334
335 /** @var \DOMElement $category */
336 foreach ($categories as $category) {
337 $usedCategories[$category->getAttribute('name')] = 0;
338 }
339
340 if (empty($usedCategories)) {
341 return;
342 }
343
344 // select existing categories
345 $conditions = new PreparedStatementConditionBuilder();
346 $conditions->add("languageCategory IN (?)", [\array_keys($usedCategories)]);
347
348 $sql = "SELECT languageCategoryID, languageCategory
349 FROM wcf" . WCF_N . "_language_category
350 " . $conditions;
351 $statement = WCF::getDB()->prepareStatement($sql);
352 $statement->execute($conditions->getParameters());
353 while ($row = $statement->fetchArray()) {
354 $usedCategories[$row['languageCategory']] = $row['languageCategoryID'];
355 }
356
357 // create new categories
358 foreach ($usedCategories as $categoryName => $categoryID) {
359 if ($categoryID) {
360 continue;
361 }
362 if (\strpos($categoryName, 'shadow.invalid') === 0) {
363 continue; // ignore shadow items
364 }
365
366 /** @var LanguageCategory $category */
367 $category = LanguageCategoryEditor::create([
368 'languageCategory' => $categoryName,
369 ]);
370 $usedCategories[$categoryName] = $category->languageCategoryID;
371 }
372
373 // loop through categories to import items
374 $itemData = $pageContents = $boxContents = [];
375 $languageItemValues = [];
376
377 /** @var \DOMElement $category */
378 foreach ($categories as $category) {
379 $categoryName = $category->getAttribute('name');
380 $elements = $xpath->query('child::*', $category);
381
382 if ($categoryName == 'shadow.invalid.page') {
383 /** @var \DOMElement $element */
384 foreach ($elements as $element) {
385 if (
386 \preg_match(
387 '/^shadow\.invalid\.page\.(.*)\.(title|content)/',
388 $element->getAttribute('name'),
389 $match
390 )
391 ) {
392 if (!isset($pageContents[$match[1]])) {
393 $pageContents[$match[1]] = [];
394 }
395 $pageContents[$match[1]][$match[2]] = $element->nodeValue;
396 }
397 }
398 } elseif ($categoryName == 'shadow.invalid.box') {
399 /** @var \DOMElement $element */
400 foreach ($elements as $element) {
401 if (
402 \preg_match(
403 '/^shadow\.invalid\.box\.(.*)\.(title|content)/',
404 $element->getAttribute('name'),
405 $match
406 )
407 ) {
408 if (!isset($boxContents[$match[1]])) {
409 $boxContents[$match[1]] = [];
410 }
411 $boxContents[$match[1]][$match[2]] = $element->nodeValue;
412 }
413 }
414 } else {
415 $categoryID = $usedCategories[$categoryName];
416
417 /** @var \DOMElement $element */
418 foreach ($elements as $element) {
419 $itemName = $element->getAttribute('name');
420
421 self::validateItemName($itemName, $categoryName);
422
423 $itemValue = $element->nodeValue;
424
425 $itemData[] = $this->languageID;
426 $itemData[] = $itemName;
427 $itemData[] = $itemValue;
428 $itemData[] = $categoryID;
429 if ($packageID) {
430 if ($packageID == -1) {
431 throw new \BadMethodCallException('Specifying `-1` as the packageID is no longer supported.');
432 }
433
434 $itemData[] = $packageID;
435 }
436
437 if ($updateExistingItems) {
438 $languageItemValues[$itemName] = $itemValue;
439 }
440 }
441 }
442 }
443
444 // save items
445 if (!empty($itemData)) {
446 // select phrases that have custom versions that might get disabled during the update
447 if ($updateExistingItems) {
448 $conditions = new PreparedStatementConditionBuilder();
449 $conditions->add("languageItem IN (?)", [\array_keys($languageItemValues)]);
450 $conditions->add("languageID = ?", [$this->languageID]);
451 if ($packageID > 0) {
452 $conditions->add("packageID = ?", [$packageID]);
453 }
454 $conditions->add("(languageUseCustomValue = ? OR isCustomLanguageItem = ?)", [1, 1]);
455
456 $sql = "SELECT languageItemID, languageItem, languageItemValue, isCustomLanguageItem
457 FROM wcf" . WCF_N . "_language_item
458 " . $conditions;
459 $statement = WCF::getDB()->prepareStatement($sql);
460 $statement->execute($conditions->getParameters());
461 $updateValues = $customLanguageItemIDs = [];
462 while ($row = $statement->fetchArray()) {
463 if ($row['isCustomLanguageItem']) {
464 $customLanguageItemIDs[] = $row['languageItemID'];
465 }
466
467 // also save old values of custom language items
468 if ($row['isCustomLanguageItem'] || $row['languageItemValue'] != $languageItemValues[$row['languageItem']]) {
469 $updateValues[] = $row['languageItemID'];
470 }
471 }
472
473 if (!empty($updateValues)) {
474 $sql = "UPDATE wcf" . WCF_N . "_language_item
475 SET languageItemOldValue = languageItemValue,
476 languageCustomItemDisableTime = ?,
477 languageUseCustomValue = ?
478 WHERE languageItemID = ?";
479 $statement = WCF::getDB()->prepareStatement($sql);
480
481 WCF::getDB()->beginTransaction();
482 foreach ($updateValues as $languageItemID) {
483 $statement->execute([
484 TIME_NOW,
485 0,
486 $languageItemID,
487 ]);
488 }
489 WCF::getDB()->commitTransaction();
490 }
491
492 // make custom language items normal ones
493 if (!empty($customLanguageItemIDs)) {
494 $sql = "UPDATE wcf" . WCF_N . "_language_item
495 SET isCustomLanguageItem = ?,
496 languageItemOriginIsSystem = ?,
497 packageID = ?
498 WHERE languageItemID = ?";
499 $statement = WCF::getDB()->prepareStatement($sql);
500
501 WCF::getDB()->beginTransaction();
502 foreach ($updateValues as $languageItemID) {
503 $statement->execute([
504 0,
505 1,
506 $packageID,
507 $languageItemID,
508 ]);
509 }
510 WCF::getDB()->commitTransaction();
511 }
512 }
513
514 // insert/update a maximum of 50 items per run (prevents issues with max_allowed_packet)
515 $step = $packageID ? 5 : 4;
516 WCF::getDB()->beginTransaction();
517 for ($i = 0, $length = \count($itemData); $i < $length; $i += 50 * $step) {
518 $parameters = \array_slice($itemData, $i, 50 * $step);
519 $repeat = \count($parameters) / $step;
520
521 $placeholders = \substr(
522 \str_repeat('(?, ?, ?, ?' . ($packageID ? ', ?' : '') . '),', $repeat),
523 0,
524 -1
525 );
526 $sql = "INSERT" . (!$updateExistingItems ? " IGNORE" : "") . " INTO wcf" . WCF_N . "_language_item
527 (languageID, languageItem, languageItemValue, languageCategoryID" . ($packageID ? ", packageID" : "") . ")
528 VALUES {$placeholders}";
529
530 if ($updateExistingItems) {
531 if ($packageID > 0) {
532 // do not update anything if language item is owned by a different package
533 $sql .= "
534 ON DUPLICATE KEY UPDATE languageItemValue = IF(packageID = " . $packageID . ", IF(languageItemOriginIsSystem = 0, languageItemValue, VALUES(languageItemValue)), languageItemValue),
535 languageCategoryID = IF(packageID = " . $packageID . ", VALUES(languageCategoryID), languageCategoryID)";
536 } else {
537 // skip package id check during WCFSetup (packageID = 0) or if using the ACP form (packageID = -1)
538 $sql .= "
539 ON DUPLICATE KEY UPDATE languageItemValue = IF(languageItemOriginIsSystem = 0, languageItemValue, VALUES(languageItemValue)),
540 languageCategoryID = VALUES(languageCategoryID)";
541 }
542 }
543
544 $statement = WCF::getDB()->prepareStatement($sql);
545 $statement->execute($parameters);
546 }
547 WCF::getDB()->commitTransaction();
548 }
549
550 // save page content
551 if (!empty($pageContents)) {
552 // get page ids
553 $pageIDs = [];
554 $conditions = new PreparedStatementConditionBuilder();
555 $conditions->add("identifier IN (?)", [\array_keys($pageContents)]);
556 $sql = "SELECT pageID, identifier
557 FROM wcf" . WCF_N . "_page
558 " . $conditions;
559 $statement = WCF::getDB()->prepareStatement($sql);
560 $statement->execute($conditions->getParameters());
561 while ($row = $statement->fetchArray()) {
562 $pageIDs[$row['identifier']] = $row['pageID'];
563 }
564
565 $sql = "INSERT IGNORE INTO wcf1_page_content
566 (pageID, languageID, title, content, metaDescription, customURL, hasEmbeddedObjects)
567 SELECT pageID, ?, title, content, metaDescription, CASE WHEN customURL <> '' THEN CONCAT(customURL, '_', ?) ELSE '' END, hasEmbeddedObjects
568 FROM wcf1_page_content
569 WHERE pageID = ?
570 AND languageID = ?";
571 $createLanguageVersionStatement = WCF::getDB()->prepare($sql);
572 $sql = "UPDATE wcf" . WCF_N . "_page_content
573 SET title = ?
574 WHERE pageID = ?
575 AND languageID = ?";
576 $updateTitleStatement = WCF::getDB()->prepareStatement($sql);
577 $sql = "UPDATE wcf" . WCF_N . "_page_content
578 SET content = ?
579 WHERE pageID = ?
580 AND languageID = ?";
581 $updateContentStatement = WCF::getDB()->prepareStatement($sql);
582
583 foreach ($pageContents as $identifier => $pageContent) {
584 if (!isset($pageIDs[$identifier])) {
585 continue; // unknown page
586 }
587
588 $createLanguageVersionStatement->execute([
589 $this->languageID,
590 $this->languageID,
591 $pageIDs[$identifier],
592 LanguageFactory::getInstance()->getDefaultLanguageID(),
593 ]);
594 if (isset($pageContent['title'])) {
595 $updateTitleStatement->execute([$pageContent['title'], $pageIDs[$identifier], $this->languageID]);
596 }
597 if (isset($pageContent['content'])) {
598 $updateContentStatement->execute([
599 $pageContent['content'],
600 $pageIDs[$identifier],
601 $this->languageID,
602 ]);
603 }
604 }
605 }
606
607 // save box content
608 if (!empty($boxContents)) {
609 // get box ids
610 $boxIDs = [];
611 $conditions = new PreparedStatementConditionBuilder();
612 $conditions->add("identifier IN (?)", [\array_keys($boxContents)]);
613 $sql = "SELECT boxID, identifier
614 FROM wcf" . WCF_N . "_box
615 " . $conditions;
616 $statement = WCF::getDB()->prepareStatement($sql);
617 $statement->execute($conditions->getParameters());
618 while ($row = $statement->fetchArray()) {
619 $boxIDs[$row['identifier']] = $row['boxID'];
620 }
621
622 $sql = "INSERT IGNORE INTO wcf" . WCF_N . "_box_content
623 (boxID, languageID)
624 VALUES (?, ?)";
625 $createLanguageVersionStatement = WCF::getDB()->prepareStatement($sql);
626 $sql = "UPDATE wcf" . WCF_N . "_box_content
627 SET title = ?
628 WHERE boxID = ?
629 AND languageID = ?";
630 $updateTitleStatement = WCF::getDB()->prepareStatement($sql);
631 $sql = "UPDATE wcf" . WCF_N . "_box_content
632 SET content = ?
633 WHERE boxID = ?
634 AND languageID = ?";
635 $updateContentStatement = WCF::getDB()->prepareStatement($sql);
636
637 foreach ($boxContents as $identifier => $boxContent) {
638 if (!isset($boxIDs[$identifier])) {
639 continue; // unknown box
640 }
641
642 $createLanguageVersionStatement->execute([$boxIDs[$identifier], $this->languageID]);
643 if (isset($boxContent['title'])) {
644 $updateTitleStatement->execute([$boxContent['title'], $boxIDs[$identifier], $this->languageID]);
645 }
646 if (isset($boxContent['content'])) {
647 $updateContentStatement->execute([$boxContent['content'], $boxIDs[$identifier], $this->languageID]);
648 }
649 }
650 }
651
652 // update the relevant language files
653 if ($updateFiles) {
654 self::deleteLanguageFiles($this->languageID);
655 }
656
657 // delete relevant template compilations
658 $this->deleteCompiledTemplates();
659 }
660
661 /**
662 * Verifies that the given variable is a valid variable within the given category.
663 * Throws an exception otherwise.
664 *
665 * @since 5.4
666 */
667 final public static function validateItemName(string $itemName, string $categoryName): void
668 {
669 // Safeguard against malformed phrases, an empty name has a strange side effect.
670 if (empty($itemName)) {
671 throw new \InvalidArgumentException("The name attribute is missing or empty.");
672 }
673
674 if ($itemName !== $categoryName && \strpos($itemName, $categoryName . '.') !== 0) {
675 throw new \InvalidArgumentException(WCF::getLanguage()->getDynamicVariable(
676 'wcf.acp.language.import.error.categoryMismatch',
677 [
678 'categoryName' => $categoryName,
679 'languageItem' => $itemName,
680 ]
681 ));
682 }
683
684 if (StringUtil::trim($itemName) !== $itemName) {
685 throw new \InvalidArgumentException("The name '{$itemName}' contains leading or trailing whitespaces.");
686 }
687 }
688
689 /**
690 * Deletes the language cache.
691 *
692 * @param string $languageID
693 * @param string $category
694 */
695 public static function deleteLanguageFiles($languageID = '.*', $category = '.*')
696 {
697 if ($category != '.*') {
698 $category = \preg_quote($category, '~');
699 }
700 if ($languageID != '.*') {
701 $languageID = \intval($languageID);
702 }
703
704 DirectoryUtil::getInstance(WCF_DIR . 'language/')->removePattern(new Regex($languageID . '_' . $category . '\.php$'));
705 }
706
707 /**
708 * Deletes relevant template compilations.
709 */
710 public function deleteCompiledTemplates()
711 {
712 // templates
713 DirectoryUtil::getInstance(WCF_DIR . 'templates/compiled/')->removePattern(new Regex('.*_' . $this->languageID . '_.*\.php$'));
714 // acp templates
715 DirectoryUtil::getInstance(WCF_DIR . 'acp/templates/compiled/')->removePattern(new Regex('.*_' . $this->languageID . '_.*\.php$'));
716 }
717
718 /**
719 * Updates all language files of the given package id.
720 */
721 public static function updateAll()
722 {
723 self::deleteLanguageFiles();
724 }
725
726 /**
727 * Takes an XML object and returns the specific language code.
728 *
729 * @param XML $xml
730 * @return string
731 * @throws SystemException
732 */
733 public static function readLanguageCodeFromXML(XML $xml)
734 {
735 $rootNode = $xml->xpath()->query('/ns:language')->item(0);
736 $attributes = $xml->xpath()->query('attribute::*', $rootNode);
737 foreach ($attributes as $attribute) {
738 if ($attribute->name == 'languagecode') {
739 return $attribute->value;
740 }
741 }
742
743 throw new SystemException("missing attribute 'languagecode' in language file");
744 }
745
746 /**
747 * Takes an XML object and returns the specific language name.
748 *
749 * @param XML $xml
750 * @return string language name
751 * @throws SystemException
752 */
753 public static function readLanguageNameFromXML(XML $xml)
754 {
755 $rootNode = $xml->xpath()->query('/ns:language')->item(0);
756 $attributes = $xml->xpath()->query('attribute::*', $rootNode);
757 foreach ($attributes as $attribute) {
758 if ($attribute->name == 'languagename') {
759 return $attribute->value;
760 }
761 }
762
763 throw new SystemException("missing attribute 'languagename' in language file");
764 }
765
766 /**
767 * Takes an XML object and returns the specific country code.
768 *
769 * @param XML $xml
770 * @return string country code
771 * @throws SystemException
772 */
773 public static function readCountryCodeFromXML(XML $xml)
774 {
775 $rootNode = $xml->xpath()->query('/ns:language')->item(0);
776 $attributes = $xml->xpath()->query('attribute::*', $rootNode);
777 foreach ($attributes as $attribute) {
778 if ($attribute->name == 'countrycode') {
779 return $attribute->value;
780 }
781 }
782
783 throw new SystemException("missing attribute 'countrycode' in language file");
784 }
785
786 /**
787 * Imports language items from an XML file into a new or a current language.
788 * Updates the relevant language files automatically.
789 *
790 * @param XML $xml
791 * @param int $packageID
792 * @param Language $source
793 * @return LanguageEditor
794 */
795 public static function importFromXML(XML $xml, $packageID, ?Language $source = null)
796 {
797 $languageCode = self::readLanguageCodeFromXML($xml);
798
799 // try to find an existing language with the given language code
800 $sql = "SELECT *
801 FROM wcf1_language
802 WHERE languageCode = ?";
803 $statement = WCF::getDB()->prepare($sql);
804 $statement->execute([$languageCode]);
805 $language = $statement->fetchObject(Language::class);
806
807 // create new language
808 if ($language === null) {
809 $countryCode = self::readCountryCodeFromXML($xml);
810 $languageName = self::readLanguageNameFromXML($xml);
811 $language = self::create([
812 'countryCode' => $countryCode,
813 'languageCode' => $languageCode,
814 'languageName' => $languageName,
815 ]);
816
817 if ($source) {
818 $sourceEditor = new self($source);
819 $sourceEditor->copy($language);
820 }
821 }
822
823 // import xml
824 $languageEditor = new self($language);
825 $languageEditor->updateFromXML($xml, $packageID);
826
827 // return language object
828 return $languageEditor;
829 }
830
831 /**
832 * Copies all language variables from current language to language specified as $destination.
833 * Caution: This method expects that target language does not have any items!
834 *
835 * @param Language $destination
836 */
837 public function copy(Language $destination)
838 {
839 $sql = "INSERT INTO wcf" . WCF_N . "_language_item
840 (languageID, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID)
841 SELECT ?, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID
842 FROM wcf" . WCF_N . "_language_item
843 WHERE languageID = ?";
844 $statement = WCF::getDB()->prepareStatement($sql);
845 $statement->execute([
846 $destination->languageID,
847 $this->languageID,
848 ]);
849 }
850
851 /**
852 * Updates the language items of a language category.
853 *
854 * @param array $items
855 * @param LanguageCategory $category
856 * @param int $packageID
857 * @param array $useCustom
858 */
859 public function updateItems(
860 array $items,
861 LanguageCategory $category,
862 $packageID = PACKAGE_ID,
863 array $useCustom = []
864 ) {
865 if (empty($items)) {
866 return;
867 }
868
869 // find existing language items
870 $languageItemList = new LanguageItemList();
871 $languageItemList->getConditionBuilder()->add("language_item.languageItem IN (?)", [\array_keys($items)]);
872 $languageItemList->getConditionBuilder()->add("languageID = ?", [$this->languageID]);
873 $languageItemList->readObjects();
874
875 foreach ($languageItemList->getObjects() as $languageItem) {
876 $languageItemEditor = new LanguageItemEditor($languageItem);
877 $languageItemEditor->update([
878 'languageCustomItemValue' => $items[$languageItem->languageItem],
879 'languageUseCustomValue' => isset($useCustom[$languageItem->languageItem]) ? 1 : 0,
880 ]);
881
882 // remove updated items, leaving items to be created within
883 unset($items[$languageItem->languageItem]);
884 }
885
886 // create remaining items
887 if (!empty($items)) {
888 // bypass LanguageItemEditor::create() for performance reasons
889 $sql = "INSERT INTO wcf" . WCF_N . "_language_item
890 (languageID, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID)
891 VALUES (?, ?, ?, ?, ?, ?)";
892 $statement = WCF::getDB()->prepareStatement($sql);
893
894 foreach ($items as $itemName => $itemValue) {
895 $statement->execute([
896 $this->languageID,
897 $itemName,
898 $itemValue,
899 0,
900 $category->languageCategoryID,
901 $packageID,
902 ]);
903 }
904 }
905
906 // update the relevant language files
907 self::deleteLanguageFiles($this->languageID, $category->languageCategory);
908
909 // delete relevant template compilations
910 $this->deleteCompiledTemplates();
911 }
912
913 /**
914 * Sets current language as default language.
915 */
916 public function setAsDefault()
917 {
918 // remove default flag from all languages
919 $sql = "UPDATE wcf" . WCF_N . "_language
920 SET isDefault = ?";
921 $statement = WCF::getDB()->prepareStatement($sql);
922 $statement->execute([0]);
923
924 // set current language as default language
925 $this->update(['isDefault' => 1]);
926
927 $this->clearCache();
928 }
929
930 /**
931 * Clears language cache.
932 */
933 public function clearCache()
934 {
935 LanguageCacheBuilder::getInstance()->reset();
936 }
937
938 /**
939 * Enables the multilingualism feature for given languages.
940 *
941 * @param array $languageIDs
942 */
943 public static function enableMultilingualism(array $languageIDs = [])
944 {
945 $sql = "UPDATE wcf" . WCF_N . "_language
946 SET hasContent = ?";
947 $statement = WCF::getDB()->prepareStatement($sql);
948 $statement->execute([0]);
949
950 if (!empty($languageIDs)) {
951 $sql = '';
952 $statementParameters = [];
953 foreach ($languageIDs as $languageID) {
954 if (!empty($sql)) {
955 $sql .= ',';
956 }
957 $sql .= '?';
958 $statementParameters[] = $languageID;
959 }
960
961 $sql = "UPDATE wcf" . WCF_N . "_language
962 SET hasContent = ?
963 WHERE languageID IN (" . $sql . ")";
964 $statement = WCF::getDB()->prepareStatement($sql);
965 \array_unshift($statementParameters, 1);
966 $statement->execute($statementParameters);
967 }
968 }
969
970 /**
971 * @inheritDoc
972 */
973 public static function resetCache()
974 {
975 LanguageFactory::getInstance()->clearCache();
976 }
977
978 /**
979 * Copies all cms contents (article, box, media, page) from given source language to language specified as $destinationLanguageID.
980 *
981 * @param int $sourceLanguageID
982 * @param int $destinationLanguageID
983 */
984 public static function copyLanguageContent($sourceLanguageID, $destinationLanguageID)
985 {
986 // article content
987 $sql = "INSERT IGNORE INTO wcf" . WCF_N . "_article_content
988 (articleID, languageID, title, teaser, content, imageID, hasEmbeddedObjects)
989 SELECT articleID, ?, title, teaser, content, imageID, hasEmbeddedObjects
990 FROM wcf" . WCF_N . "_article_content
991 WHERE languageID = ?";
992 $statement = WCF::getDB()->prepareStatement($sql);
993 $statement->execute([$destinationLanguageID, $sourceLanguageID]);
994
995 // box content
996 $sql = "INSERT IGNORE INTO wcf" . WCF_N . "_box_content
997 (boxID, languageID, title, content, imageID, hasEmbeddedObjects)
998 SELECT boxID, ?, title, content, imageID, hasEmbeddedObjects
999 FROM wcf" . WCF_N . "_box_content
1000 WHERE languageID = ?";
1001 $statement = WCF::getDB()->prepareStatement($sql);
1002 $statement->execute([$destinationLanguageID, $sourceLanguageID]);
1003
1004 // create tpl files
1005 $sql = "SELECT *
1006 FROM wcf" . WCF_N . "_box_content
1007 WHERE boxID IN (
1008 SELECT boxID
1009 FROM wcf" . WCF_N . "_box
1010 WHERE boxType = ?
1011 )
1012 AND languageID = ?";
1013 $statement = WCF::getDB()->prepareStatement($sql);
1014 $statement->execute(['tpl', $destinationLanguageID]);
1015 while ($row = $statement->fetchArray()) {
1016 \file_put_contents(
1017 WCF_DIR . 'templates/__cms_box_' . $row['boxID'] . '_' . $destinationLanguageID . '.tpl',
1018 $row['content']
1019 );
1020 }
1021
1022 // media content
1023 $sql = "INSERT IGNORE INTO wcf" . WCF_N . "_media_content
1024 (mediaID, languageID, title, caption, altText)
1025 SELECT mediaID, ?, title, caption, altText
1026 FROM wcf" . WCF_N . "_media_content
1027 WHERE languageID = ?";
1028 $statement = WCF::getDB()->prepareStatement($sql);
1029 $statement->execute([$destinationLanguageID, $sourceLanguageID]);
1030
1031 // page content
1032 $sql = "INSERT IGNORE INTO wcf" . WCF_N . "_page_content
1033 (pageID, languageID, title, content, metaDescription, customURL, hasEmbeddedObjects)
1034 SELECT pageID, ?, title, content, metaDescription, CASE WHEN customURL <> '' THEN CONCAT(customURL, '_', ?) ELSE '' END, hasEmbeddedObjects
1035 FROM wcf" . WCF_N . "_page_content
1036 WHERE languageID = ?";
1037 $statement = WCF::getDB()->prepareStatement($sql);
1038 $statement->execute([$destinationLanguageID, $destinationLanguageID, $sourceLanguageID]);
1039
1040 // create tpl files
1041 $sql = "SELECT *
1042 FROM wcf" . WCF_N . "_page_content
1043 WHERE pageID IN (
1044 SELECT pageID
1045 FROM wcf" . WCF_N . "_page
1046 WHERE pageType = ?
1047 )
1048 AND languageID = ?";
1049 $statement = WCF::getDB()->prepareStatement($sql);
1050 $statement->execute(['tpl', $destinationLanguageID]);
1051 while ($row = $statement->fetchArray()) {
1052 \file_put_contents(
1053 WCF_DIR . 'templates/__cms_page_' . $row['pageID'] . '_' . $destinationLanguageID . '.tpl',
1054 $row['content']
1055 );
1056 }
1057
1058 PageEditor::resetCache();
1059
1060 EventHandler::getInstance()->fire(new LanguageContentCopying(
1061 new Language($sourceLanguageID),
1062 new Language($destinationLanguageID)
1063 ));
1064 }
1065}