Overhauled language import form
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / data / language / LanguageEditor.class.php
1 <?php
2 namespace wcf\data\language;
3 use wcf\data\language\category\LanguageCategory;
4 use wcf\data\language\category\LanguageCategoryEditor;
5 use wcf\data\language\item\LanguageItemEditor;
6 use wcf\data\language\item\LanguageItemList;
7 use wcf\data\DatabaseObjectEditor;
8 use wcf\data\IEditableCachedObject;
9 use wcf\system\cache\builder\LanguageCacheBuilder;
10 use wcf\system\database\util\PreparedStatementConditionBuilder;
11 use wcf\system\exception\SystemException;
12 use wcf\system\io\AtomicWriter;
13 use wcf\system\language\LanguageFactory;
14 use wcf\system\Regex;
15 use wcf\system\WCF;
16 use wcf\util\DirectoryUtil;
17 use wcf\util\FileUtil;
18 use wcf\util\StringUtil;
19 use wcf\util\XML;
20
21 /**
22 * Provides functions to edit languages.
23 *
24 * @author Alexander Ebert
25 * @copyright 2001-2016 WoltLab GmbH
26 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
27 * @package com.woltlab.wcf
28 * @subpackage data.language
29 * @category Community Framework
30 *
31 * @method Language getDecoratedObject()
32 * @mixin Language
33 */
34 class LanguageEditor extends DatabaseObjectEditor implements IEditableCachedObject {
35 /**
36 * @inheritDoc
37 */
38 protected static $baseClass = Language::class;
39
40 /**
41 * @inheritDoc
42 */
43 public function delete() {
44 parent::delete();
45
46 self::deleteLanguageFiles($this->languageID);
47 }
48
49 /**
50 * Updates the language files for the given category.
51 *
52 * @param LanguageCategory $languageCategory
53 */
54 public function updateCategory(LanguageCategory $languageCategory) {
55 $this->writeLanguageFiles([$languageCategory->languageCategoryID]);
56 }
57
58 /**
59 * Write the languages files.
60 *
61 * @param integer[] $languageCategoryIDs
62 */
63 protected function writeLanguageFiles(array $languageCategoryIDs) {
64 $conditions = new PreparedStatementConditionBuilder();
65 $conditions->add("languageID = ?", [$this->languageID]);
66 $conditions->add("languageCategoryID IN (?)", [$languageCategoryIDs]);
67
68 // get language items
69 $sql = "SELECT languageItem, languageItemValue, languageCustomItemValue,
70 languageUseCustomValue, languageCategoryID
71 FROM wcf".WCF_N."_language_item
72 ".$conditions;
73 $statement = WCF::getDB()->prepareStatement($sql);
74 $statement->execute($conditions->getParameters());
75 $items = [];
76 while ($row = $statement->fetchArray()) {
77 $languageCategoryID = $row['languageCategoryID'];
78 if (!isset($items[$languageCategoryID])) {
79 $items[$languageCategoryID] = [];
80 }
81
82 $items[$languageCategoryID][$row['languageItem']] = ($row['languageUseCustomValue']) ? $row['languageCustomItemValue'] : $row['languageItemValue'];
83 }
84
85 foreach ($items as $languageCategoryID => $languageItems) {
86 $category = LanguageFactory::getInstance()->getCategoryByID($languageCategoryID);
87 if ($category === null) {
88 continue;
89 }
90
91 $filename = WCF_DIR.'language/'.$this->languageID.'_'.$category->languageCategory.'.php';
92 $writer = new AtomicWriter($filename);
93
94 $writer->write("<?php\n/**\n* WoltLab Community Framework\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");
95 foreach ($languageItems as $languageItem => $languageItemValue) {
96 $writer->write("\$this->items['".$languageItem."'] = '".str_replace("'", "\'", $languageItemValue)."';\n");
97
98 // compile dynamic language variables
99 if ($category->languageCategory != 'wcf.global' && strpos($languageItemValue, '{') !== false) {
100 $writer->write("\$this->dynamicItems['".$languageItem."'] = '");
101
102 $output = LanguageFactory::getInstance()->getScriptingCompiler()->compileString($languageItem, $languageItemValue);
103 $writer->write(str_replace("'", "\'", $output['template']));
104
105 $writer->write("';\n");
106 }
107 }
108
109 $writer->flush();
110 $writer->close();
111 FileUtil::makeWritable($filename);
112 }
113 }
114
115 /**
116 * Exports this language.
117 *
118 * @param integer[] $packageIDArray
119 * @param boolean $exportCustomValues
120 */
121 public function export($packageIDArray = [], $exportCustomValues = false) {
122 $conditions = new PreparedStatementConditionBuilder();
123 $conditions->add("language_item.languageID = ?", [$this->languageID]);
124
125 // bom
126 echo "\xEF\xBB\xBF";
127
128 // header
129 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/maelstrom/language.xsd\" languagecode=\"".$this->languageCode."\" languagename=\"".$this->languageName."\" countrycode=\"".$this->countryCode."\">\n";
130
131 // get items
132 $items = [];
133 if (!empty($packageIDArray)) {
134 $conditions->add("language_item.packageID IN (?)", [$packageIDArray]);
135 }
136
137 $sql = "SELECT languageItem, " . ($exportCustomValues ? "CASE WHEN languageUseCustomValue > 0 THEN languageCustomItemValue ELSE languageItemValue END AS languageItemValue" : "languageItemValue") . ", languageCategory
138 FROM wcf".WCF_N."_language_item language_item
139 LEFT JOIN wcf".WCF_N."_language_category language_category
140 ON (language_category.languageCategoryID = language_item.languageCategoryID)
141 ".$conditions;
142 $statement = WCF::getDB()->prepareStatement($sql);
143 $statement->execute($conditions->getParameters());
144 while ($row = $statement->fetchArray()) {
145 $items[$row['languageCategory']][$row['languageItem']] = $row['languageItemValue'];
146 }
147
148 // sort categories
149 ksort($items);
150
151 foreach ($items as $category => $categoryItems) {
152 // sort items
153 ksort($categoryItems);
154
155 // category header
156 echo "\t<category name=\"".$category."\">\n";
157
158 // items
159 foreach ($categoryItems as $item => $value) {
160 echo "\t\t<item name=\"".$item."\"><![CDATA[".StringUtil::escapeCDATA($value)."]]></item>\n";
161 }
162
163 // category footer
164 echo "\t</category>\n";
165 }
166
167 // footer
168 echo "</language>";
169 }
170
171 /**
172 * Imports language items from an XML file into this language.
173 * Updates the relevant language files automatically.
174 *
175 * @param XML $xml
176 * @param integer $packageID
177 * @param boolean $updateFiles
178 * @param boolean $updateExistingItems
179 */
180 public function updateFromXML(XML $xml, $packageID, $updateFiles = true, $updateExistingItems = true) {
181 $xpath = $xml->xpath();
182 $usedCategories = [];
183
184 // fetch categories
185 $categories = $xpath->query('/ns:language/ns:category');
186 foreach ($categories as $category) {
187 $usedCategories[$category->getAttribute('name')] = 0;
188 }
189
190 if (empty($usedCategories)) return;
191
192 // select existing categories
193 $conditions = new PreparedStatementConditionBuilder();
194 $conditions->add("languageCategory IN (?)", [array_keys($usedCategories)]);
195
196 $sql = "SELECT languageCategoryID, languageCategory
197 FROM wcf".WCF_N."_language_category
198 ".$conditions;
199 $statement = WCF::getDB()->prepareStatement($sql);
200 $statement->execute($conditions->getParameters());
201 while ($row = $statement->fetchArray()) {
202 $usedCategories[$row['languageCategory']] = $row['languageCategoryID'];
203 }
204
205 // create new categories
206 foreach ($usedCategories as $categoryName => $categoryID) {
207 if ($categoryID) continue;
208
209 $category = LanguageCategoryEditor::create([
210 'languageCategory' => $categoryName
211 ]);
212 $usedCategories[$categoryName] = $category->languageCategoryID;
213 }
214
215 // loop through categories to import items
216 $itemData = [];
217 foreach ($categories as $category) {
218 $categoryName = $category->getAttribute('name');
219 $categoryID = $usedCategories[$categoryName];
220
221 // loop through items
222 $elements = $xpath->query('child::*', $category);
223 foreach ($elements as $element) {
224 $itemName = $element->getAttribute('name');
225 $itemValue = $element->nodeValue;
226
227 $itemData[] = $this->languageID;
228 $itemData[] = $itemName;
229 $itemData[] = $itemValue;
230 $itemData[] = $categoryID;
231 if ($packageID) $itemData[] = ($packageID == -1) ? PACKAGE_ID : $packageID;
232 }
233 }
234
235 if (!empty($itemData)) {
236 // insert/update a maximum of 50 items per run (prevents issues with max_allowed_packet)
237 $step = ($packageID) ? 5 : 4;
238 WCF::getDB()->beginTransaction();
239 for ($i = 0, $length = count($itemData); $i < $length; $i += 50 * $step) {
240 $parameters = array_slice($itemData, $i, 50 * $step);
241 $repeat = count($parameters) / $step;
242
243 $sql = "INSERT".(!$updateExistingItems ? " IGNORE" : "")." INTO wcf".WCF_N."_language_item
244 (languageID, languageItem, languageItemValue, languageCategoryID". ($packageID ? ", packageID" : "") . ")
245 VALUES ".substr(str_repeat('(?, ?, ?, ?'. ($packageID ? ', ?' : '') .'), ', $repeat), 0, -2);
246
247 if ($updateExistingItems) {
248 if ($packageID > 0) {
249 // do not update anything if language item is owned by a different package
250 $sql .= " ON DUPLICATE KEY
251 UPDATE languageUseCustomValue = IF(packageID = ".$packageID.", IF(languageItemValue = VALUES(languageItemValue), languageUseCustomValue, 0), languageUseCustomValue),
252 languageItemValue = IF(packageID = ".$packageID.", IF(languageItemOriginIsSystem = 0, languageItemValue, VALUES(languageItemValue)), languageItemValue),
253 languageCategoryID = IF(packageID = ".$packageID.", VALUES(languageCategoryID), languageCategoryID)";
254 }
255 else {
256 // skip package id check during WCFSetup (packageID = 0) or if using the ACP form (packageID = -1)
257 $sql .= " ON DUPLICATE KEY
258 UPDATE languageUseCustomValue = IF(languageItemValue = VALUES(languageItemValue), languageUseCustomValue, 0),
259 languageItemValue = IF(languageItemOriginIsSystem = 0, languageItemValue, VALUES(languageItemValue)),
260 languageCategoryID = VALUES(languageCategoryID)";
261 }
262 }
263
264 $statement = WCF::getDB()->prepareStatement($sql);
265 $statement->execute($parameters);
266 }
267 WCF::getDB()->commitTransaction();
268 }
269
270 // update the relevant language files
271 if ($updateFiles) {
272 self::deleteLanguageFiles($this->languageID);
273 }
274
275 // delete relevant template compilations
276 $this->deleteCompiledTemplates();
277 }
278
279 /**
280 * Deletes the language cache.
281 *
282 * @param string $languageID
283 * @param string $category
284 */
285 public static function deleteLanguageFiles($languageID = '.*', $category = '.*') {
286 if ($category != '.*') $category = preg_quote($category, '~');
287 if ($languageID != '.*') $languageID = intval($languageID);
288
289 DirectoryUtil::getInstance(WCF_DIR.'language/')->removePattern(new Regex($languageID.'_'.$category.'\.php$'));
290 }
291
292 /**
293 * Deletes relevant template compilations.
294 */
295 public function deleteCompiledTemplates() {
296 // templates
297 DirectoryUtil::getInstance(WCF_DIR.'templates/compiled/')->removePattern(new Regex('.*_'.$this->languageID.'_.*\.php$'));
298 // acp templates
299 DirectoryUtil::getInstance(WCF_DIR.'acp/templates/compiled/')->removePattern(new Regex('.*_'.$this->languageID.'_.*\.php$'));
300 }
301
302 /**
303 * Updates all language files of the given package id.
304 */
305 public static function updateAll() {
306 self::deleteLanguageFiles();
307 }
308
309 /**
310 * Takes an XML object and returns the specific language code.
311 *
312 * @param XML $xml
313 * @return string
314 * @throws SystemException
315 */
316 public static function readLanguageCodeFromXML(XML $xml) {
317 $rootNode = $xml->xpath()->query('/ns:language')->item(0);
318 $attributes = $xml->xpath()->query('attribute::*', $rootNode);
319 foreach ($attributes as $attribute) {
320 if ($attribute->name == 'languagecode') {
321 return $attribute->value;
322 }
323 }
324
325 throw new SystemException("missing attribute 'languagecode' in language file");
326 }
327
328 /**
329 * Takes an XML object and returns the specific language name.
330 *
331 * @param XML $xml
332 * @return string language name
333 * @throws SystemException
334 */
335 public static function readLanguageNameFromXML(XML $xml) {
336 $rootNode = $xml->xpath()->query('/ns:language')->item(0);
337 $attributes = $xml->xpath()->query('attribute::*', $rootNode);
338 foreach ($attributes as $attribute) {
339 if ($attribute->name == 'languagename') {
340 return $attribute->value;
341 }
342 }
343
344 throw new SystemException("missing attribute 'languagename' in language file");
345 }
346
347 /**
348 * Takes an XML object and returns the specific country code.
349 *
350 * @param XML $xml
351 * @return string country code
352 * @throws SystemException
353 */
354 public static function readCountryCodeFromXML(XML $xml) {
355 $rootNode = $xml->xpath()->query('/ns:language')->item(0);
356 $attributes = $xml->xpath()->query('attribute::*', $rootNode);
357 foreach ($attributes as $attribute) {
358 if ($attribute->name == 'countrycode') {
359 return $attribute->value;
360 }
361 }
362
363 throw new SystemException("missing attribute 'countrycode' in language file");
364 }
365
366 /**
367 * Imports language items from an XML file into a new or a current language.
368 * Updates the relevant language files automatically.
369 *
370 * @param XML $xml
371 * @param integer $packageID
372 * @param Language $source
373 * @return LanguageEditor
374 */
375 public static function importFromXML(XML $xml, $packageID, Language $source = null) {
376 $languageCode = self::readLanguageCodeFromXML($xml);
377
378 // try to find an existing language with the given language code
379 $language = LanguageFactory::getInstance()->getLanguageByCode($languageCode);
380
381 // create new language
382 if ($language === null) {
383 $countryCode = self::readCountryCodeFromXML($xml);
384 $languageName = self::readLanguageNameFromXML($xml);
385 $language = self::create([
386 'countryCode' => $countryCode,
387 'languageCode' => $languageCode,
388 'languageName' => $languageName
389 ]);
390
391 if ($source) {
392 $sourceEditor = new LanguageEditor($source);
393 $sourceEditor->copy($language);
394 }
395 }
396
397 // import xml
398 $languageEditor = new LanguageEditor($language);
399 $languageEditor->updateFromXML($xml, $packageID);
400
401 // return language object
402 return $languageEditor;
403 }
404
405 /**
406 * Copies all language variables from current language to language specified as $destination.
407 * Caution: This method expects that target language does not have any items!
408 *
409 * @param Language $destination
410 */
411 public function copy(Language $destination) {
412 $sql = "INSERT INTO wcf".WCF_N."_language_item
413 (languageID, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID)
414 SELECT ?, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID
415 FROM wcf".WCF_N."_language_item
416 WHERE languageID = ?";
417 $statement = WCF::getDB()->prepareStatement($sql);
418 $statement->execute([
419 $destination->languageID,
420 $this->languageID
421 ]);
422 }
423
424 /**
425 * Updates the language items of a language category.
426 *
427 * @param array $items
428 * @param LanguageCategory $category
429 * @param integer $packageID
430 * @param array $useCustom
431 */
432 public function updateItems(array $items, LanguageCategory $category, $packageID = PACKAGE_ID, array $useCustom = []) {
433 if (empty($items)) return;
434
435 // find existing language items
436 $languageItemList = new LanguageItemList();
437 $languageItemList->getConditionBuilder()->add("language_item.languageItem IN (?)", [array_keys($items)]);
438 $languageItemList->getConditionBuilder()->add("languageID = ?", [$this->languageID]);
439 $languageItemList->readObjects();
440
441 foreach ($languageItemList->getObjects() as $languageItem) {
442 $languageItemEditor = new LanguageItemEditor($languageItem);
443 $languageItemEditor->update([
444 'languageCustomItemValue' => $items[$languageItem->languageItem],
445 'languageUseCustomValue' => (isset($useCustom[$languageItem->languageItem])) ? 1 : 0
446 ]);
447
448 // remove updated items, leaving items to be created within
449 unset($items[$languageItem->languageItem]);
450 }
451
452 // create remaining items
453 if (!empty($items)) {
454 // bypass LanguageItemEditor::create() for performance reasons
455 $sql = "INSERT INTO wcf".WCF_N."_language_item
456 (languageID, languageItem, languageItemValue, languageItemOriginIsSystem, languageCategoryID, packageID)
457 VALUES (?, ?, ?, ?, ?, ?)";
458 $statement = WCF::getDB()->prepareStatement($sql);
459
460 foreach ($items as $itemName => $itemValue) {
461 $statement->execute([
462 $this->languageID,
463 $itemName,
464 $itemValue,
465 0,
466 $category->languageCategoryID,
467 $packageID
468 ]);
469 }
470 }
471
472 // update the relevant language files
473 self::deleteLanguageFiles($this->languageID, $category->languageCategory);
474
475 // delete relevant template compilations
476 $this->deleteCompiledTemplates();
477 }
478
479 /**
480 * Sets current language as default language.
481 */
482 public function setAsDefault() {
483 // remove default flag from all languages
484 $sql = "UPDATE wcf".WCF_N."_language
485 SET isDefault = ?";
486 $statement = WCF::getDB()->prepareStatement($sql);
487 $statement->execute([0]);
488
489 // set current language as default language
490 $this->update(['isDefault' => 1]);
491
492 $this->clearCache();
493 }
494
495 /**
496 * Clears language cache.
497 */
498 public function clearCache() {
499 LanguageCacheBuilder::getInstance()->reset();
500 }
501
502 /**
503 * Enables the multilingualism feature for given languages.
504 *
505 * @param array $languageIDs
506 */
507 public static function enableMultilingualism(array $languageIDs = []) {
508 $sql = "UPDATE wcf".WCF_N."_language
509 SET hasContent = ?";
510 $statement = WCF::getDB()->prepareStatement($sql);
511 $statement->execute([0]);
512
513 if (!empty($languageIDs)) {
514 $sql = '';
515 $statementParameters = [];
516 foreach ($languageIDs as $languageID) {
517 if (!empty($sql)) $sql .= ',';
518 $sql .= '?';
519 $statementParameters[] = $languageID;
520 }
521
522 $sql = "UPDATE wcf".WCF_N."_language
523 SET hasContent = ?
524 WHERE languageID IN (".$sql.")";
525 $statement = WCF::getDB()->prepareStatement($sql);
526 array_unshift($statementParameters, 1);
527 $statement->execute($statementParameters);
528 }
529 }
530
531 /**
532 * @inheritDoc
533 */
534 public static function resetCache() {
535 LanguageFactory::getInstance()->clearCache();
536 }
537 }