diff --git a/wcfsetup/install/files/acp/templates/languageImport.tpl b/wcfsetup/install/files/acp/templates/languageImport.tpl index e7117800d62..7e7a753f2d8 100644 --- a/wcfsetup/install/files/acp/templates/languageImport.tpl +++ b/wcfsetup/install/files/acp/templates/languageImport.tpl @@ -58,28 +58,6 @@ {lang}wcf.acp.language.add.source.description{/lang} - - -
-
- - {if $errorField == 'packageID'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.language.add.package.error.{$errorType}{/lang} - {/if} - - {/if} - {lang}wcf.acp.language.add.package.description{/lang} -
- {event name='fields'} diff --git a/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php b/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php index 244aa3812bc..ba83b6c84d4 100644 --- a/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/LanguageImportForm.class.php @@ -4,8 +4,6 @@ use wcf\data\language\Language; use wcf\data\language\LanguageEditor; -use wcf\data\package\Package; -use wcf\data\package\PackageCache; use wcf\event\language\LanguageImported; use wcf\form\AbstractForm; use wcf\system\cache\CacheHandler; @@ -65,12 +63,6 @@ class LanguageImportForm extends AbstractForm */ public $sourceLanguageID = 0; - /** - * @var int - * @since 5.4 - */ - public $packageID = 0; - #[\Override] public function readParameters() { @@ -90,9 +82,6 @@ public function readFormParameters() if (isset($_POST['sourceLanguageID'])) { $this->sourceLanguageID = \intval($_POST['sourceLanguageID']); } - if (isset($_POST['packageID'])) { - $this->packageID = \intval($_POST['packageID']); - } } #[\Override] @@ -115,10 +104,6 @@ public function validate() throw new UserInputException('sourceLanguageID'); } - if (PackageCache::getInstance()->getPackage($this->packageID) === null) { - throw new UserInputException('packageID'); - } - // try to import try { // open xml document @@ -126,7 +111,7 @@ public function validate() $xml->load($this->filename); // import xml document - $this->language = LanguageEditor::importFromXML($xml, $this->packageID, $this->sourceLanguage); + $this->language = LanguageEditor::importLocalization($xml, $this->sourceLanguage); // copy content if (!isset($this->languages[$this->language->languageID])) { @@ -156,7 +141,6 @@ public function save() // reset fields $this->sourceLanguageID = 0; - $this->packageID = 0; // show success message WCF::getTPL()->assign('success', true); @@ -167,18 +151,9 @@ public function assignVariables() { parent::assignVariables(); - $packages = PackageCache::getInstance()->getPackages(); - $collator = new \Collator(WCF::getLanguage()->getLocale()); - \usort( - $packages, - static fn(Package $a, Package $b) => $collator->compare($a->getName(), $b->getName()) - ); - WCF::getTPL()->assign([ 'languages' => $this->languages, 'sourceLanguageID' => $this->sourceLanguageID, - 'packages' => $packages, - 'packageID' => $this->packageID, ]); } } diff --git a/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php b/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php index ece7025697c..3a705cfa5e1 100644 --- a/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php +++ b/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php @@ -409,37 +409,9 @@ public function updateFromXML(XML $xml, int $packageID, bool $updateFiles = true $elements = $xpath->query('child::*', $category); if ($categoryName === 'shadow.invalid.page') { - /** @var \DOMElement $element */ - foreach ($elements as $element) { - if ( - \preg_match( - '/^shadow\.invalid\.page\.(.*)\.(title|content)/', - $element->getAttribute('name'), - $match - ) - ) { - if (!isset($pageContents[$match[1]])) { - $pageContents[$match[1]] = []; - } - $pageContents[$match[1]][$match[2]] = $element->nodeValue; - } - } + $pageContents = self::readShadowContents($elements, 'page', $pageContents); } elseif ($categoryName === 'shadow.invalid.box') { - /** @var \DOMElement $element */ - foreach ($elements as $element) { - if ( - \preg_match( - '/^shadow\.invalid\.box\.(.*)\.(title|content)/', - $element->getAttribute('name'), - $match - ) - ) { - if (!isset($boxContents[$match[1]])) { - $boxContents[$match[1]] = []; - } - $boxContents[$match[1]][$match[2]] = $element->nodeValue; - } - } + $boxContents = self::readShadowContents($elements, 'box', $boxContents); } else { $categoryID = $usedCategories[$categoryName]; @@ -576,117 +548,331 @@ public function updateFromXML(XML $xml, int $packageID, bool $updateFiles = true WCF::getDB()->commitTransaction(); } - // save page content if ($pageContents !== []) { - // get page ids - $pageIDs = []; - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("identifier IN (?)", [\array_keys($pageContents)]); - $sql = "SELECT pageID, identifier - FROM wcf1_page - " . $conditions; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditions->getParameters()); - while ($row = $statement->fetchArray()) { - $pageIDs[$row['identifier']] = $row['pageID']; - } + $this->savePageContents($pageContents); + } - $sql = "INSERT IGNORE INTO wcf1_page_content - (pageID, languageID, title, content, metaDescription, customURL, hasEmbeddedObjects) - SELECT pageID, ?, title, content, metaDescription, CASE WHEN customURL <> '' THEN CONCAT(customURL, '_', ?) ELSE '' END, hasEmbeddedObjects - FROM wcf1_page_content - WHERE pageID = ? - AND languageID = ?"; - $createLanguageVersionStatement = WCF::getDB()->prepare($sql); - $sql = "UPDATE wcf1_page_content - SET title = ? - WHERE pageID = ? - AND languageID = ?"; - $updateTitleStatement = WCF::getDB()->prepare($sql); - $sql = "UPDATE wcf1_page_content - SET content = ? - WHERE pageID = ? - AND languageID = ?"; - $updateContentStatement = WCF::getDB()->prepare($sql); - - foreach ($pageContents as $identifier => $pageContent) { - if (!isset($pageIDs[$identifier])) { - continue; // unknown page + if ($boxContents !== []) { + $this->saveBoxContents($boxContents); + } + + // update the relevant language files + if ($updateFiles) { + self::deleteLanguageFiles((string)$this->languageID); + } + + // delete relevant template compilations + $this->deleteCompiledTemplates(); + } + + /** + * Reads the localized title and content of pages or boxes from the shadow + * phrases `shadow.invalid...(title|content)`. + * + * @param \DOMNodeList<\DOMNode> $elements + * @param 'page'|'box' $type + * @param array> $contents + * @return array> + */ + private static function readShadowContents(\DOMNodeList $elements, string $type, array $contents): array + { + /** @var \DOMElement $element */ + foreach ($elements as $element) { + if ( + \preg_match( + '/^shadow\.invalid\.' . $type . '\.(.*)\.(title|content)/', + $element->getAttribute('name'), + $match + ) + ) { + if (!isset($contents[$match[1]])) { + $contents[$match[1]] = []; } + $contents[$match[1]][$match[2]] = $element->nodeValue; + } + } - $createLanguageVersionStatement->execute([ - $this->languageID, - $this->languageID, + return $contents; + } + + /** + * @param array> $pageContents + */ + private function savePageContents(array $pageContents): void + { + // get page ids + $pageIDs = []; + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("identifier IN (?)", [\array_keys($pageContents)]); + $sql = "SELECT pageID, identifier + FROM wcf1_page + " . $conditions; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditions->getParameters()); + while ($row = $statement->fetchArray()) { + $pageIDs[$row['identifier']] = $row['pageID']; + } + + $sql = "INSERT IGNORE INTO wcf1_page_content + (pageID, languageID, title, content, metaDescription, customURL, hasEmbeddedObjects) + SELECT pageID, ?, title, content, metaDescription, CASE WHEN customURL <> '' THEN CONCAT(customURL, '_', ?) ELSE '' END, hasEmbeddedObjects + FROM wcf1_page_content + WHERE pageID = ? + AND languageID = ?"; + $createLanguageVersionStatement = WCF::getDB()->prepare($sql); + $sql = "UPDATE wcf1_page_content + SET title = ? + WHERE pageID = ? + AND languageID = ?"; + $updateTitleStatement = WCF::getDB()->prepare($sql); + $sql = "UPDATE wcf1_page_content + SET content = ? + WHERE pageID = ? + AND languageID = ?"; + $updateContentStatement = WCF::getDB()->prepare($sql); + + foreach ($pageContents as $identifier => $pageContent) { + if (!isset($pageIDs[$identifier])) { + continue; // unknown page + } + + $createLanguageVersionStatement->execute([ + $this->languageID, + $this->languageID, + $pageIDs[$identifier], + LanguageFactory::getInstance()->getDefaultLanguageID(), + ]); + if (isset($pageContent['title'])) { + $updateTitleStatement->execute([$pageContent['title'], $pageIDs[$identifier], $this->languageID]); + } + if (isset($pageContent['content'])) { + $updateContentStatement->execute([ + $pageContent['content'], $pageIDs[$identifier], - LanguageFactory::getInstance()->getDefaultLanguageID(), + $this->languageID, ]); - if (isset($pageContent['title'])) { - $updateTitleStatement->execute([$pageContent['title'], $pageIDs[$identifier], $this->languageID]); - } - if (isset($pageContent['content'])) { - $updateContentStatement->execute([ - $pageContent['content'], - $pageIDs[$identifier], - $this->languageID, - ]); + } + } + } + + /** + * @param array> $boxContents + */ + private function saveBoxContents(array $boxContents): void + { + // get box ids + $boxIDs = []; + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("identifier IN (?)", [\array_keys($boxContents)]); + $sql = "SELECT boxID, identifier + FROM wcf1_box + " . $conditions; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditions->getParameters()); + while ($row = $statement->fetchArray()) { + $boxIDs[$row['identifier']] = $row['boxID']; + } + + $sql = "INSERT IGNORE INTO wcf1_box_content + (boxID, languageID) + VALUES (?, ?)"; + $createLanguageVersionStatement = WCF::getDB()->prepare($sql); + $sql = "UPDATE wcf1_box_content + SET title = ? + WHERE boxID = ? + AND languageID = ?"; + $updateTitleStatement = WCF::getDB()->prepare($sql); + $sql = "UPDATE wcf1_box_content + SET content = ? + WHERE boxID = ? + AND languageID = ?"; + $updateContentStatement = WCF::getDB()->prepare($sql); + + foreach ($boxContents as $identifier => $boxContent) { + if (!isset($boxIDs[$identifier])) { + continue; // unknown box + } + + $createLanguageVersionStatement->execute([$boxIDs[$identifier], $this->languageID]); + if (isset($boxContent['title'])) { + $updateTitleStatement->execute([$boxContent['title'], $boxIDs[$identifier], $this->languageID]); + } + if (isset($boxContent['content'])) { + $updateContentStatement->execute([$boxContent['content'], $boxIDs[$identifier], $this->languageID]); + } + } + + BoxEditor::resetCache(); + } + + /** + * Imports the phrases of a localization into this language. + * + * A localization provides values for existing phrases only. A phrase is + * known if a package has installed it into one of the authoritative + * languages `de` and `en`; the package and category of the phrase are + * taken from there. Unknown phrases, phrases not installed by a package + * and the `delete` section of the XML document are ignored. + * + * @throws \InvalidArgumentException if the XML document is invalid + * @since 6.3 + */ + public function updateFromLocalization(XML $xml): void + { + $this->validateXMLStructure($xml); + + $xpath = $xml->xpath(); + + $categories = $xpath->query('/ns:language/ns:import/ns:category'); + if ($categories->length === 0) { + // Fallback for the old, deprecated version before WSC 5.5, which only supported imports. + $categories = $xpath->query('/ns:language/ns:category'); + } + + $items = $pageContents = $boxContents = []; + + /** @var \DOMElement $category */ + foreach ($categories as $category) { + $categoryName = $category->getAttribute('name'); + + self::validateCategoryName($categoryName); + + $elements = $xpath->query('child::*', $category); + + if ($categoryName === 'shadow.invalid.page') { + $pageContents = self::readShadowContents($elements, 'page', $pageContents); + } elseif ($categoryName === 'shadow.invalid.box') { + $boxContents = self::readShadowContents($elements, 'box', $boxContents); + } else { + /** @var \DOMElement $element */ + foreach ($elements as $element) { + $itemName = $element->getAttribute('name'); + + self::validateItemName($itemName, $categoryName); + + $items[$itemName] = $element->nodeValue; } } } - // save box content + if ($items !== []) { + $this->saveLocalizedItems($items); + } + + if ($pageContents !== []) { + $this->savePageContents($pageContents); + } + if ($boxContents !== []) { - // get box ids - $boxIDs = []; + $this->saveBoxContents($boxContents); + } + + self::deleteLanguageFiles((string)$this->languageID); + + $this->deleteCompiledTemplates(); + } + + /** + * @param array $items + */ + private function saveLocalizedItems(array $items): void + { + // The number of phrases in a localization of all packages can exceed + // the limit of placeholders per statement. + $itemNameChunks = \array_chunk(\array_keys($items), 1_000); + + $knownItems = []; + foreach ($itemNameChunks as $itemNames) { $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("identifier IN (?)", [\array_keys($boxContents)]); - $sql = "SELECT boxID, identifier - FROM wcf1_box - " . $conditions; + $conditions->add("language_item.languageItem IN (?)", [$itemNames]); + $conditions->add("language_item.languageItemOriginIsSystem = ?", [1]); + $conditions->add("language.languageCode IN (?)", [['de', 'en']]); + + $sql = "SELECT language_item.languageItem, + language_item.languageCategoryID, + language_item.packageID + FROM wcf1_language_item language_item + INNER JOIN wcf1_language language + ON language.languageID = language_item.languageID + {$conditions}"; $statement = WCF::getDB()->prepare($sql); $statement->execute($conditions->getParameters()); while ($row = $statement->fetchArray()) { - $boxIDs[$row['identifier']] = $row['boxID']; + // The collation matches phrase names case-insensitively, a + // differently cased name in the localization is unknown. + if (!isset($items[$row['languageItem']])) { + continue; + } + + $knownItems[$row['languageItem']] = $row; } + } - $sql = "INSERT IGNORE INTO wcf1_box_content - (boxID, languageID) - VALUES (?, ?)"; - $createLanguageVersionStatement = WCF::getDB()->prepare($sql); - $sql = "UPDATE wcf1_box_content - SET title = ? - WHERE boxID = ? - AND languageID = ?"; - $updateTitleStatement = WCF::getDB()->prepare($sql); - $sql = "UPDATE wcf1_box_content - SET content = ? - WHERE boxID = ? - AND languageID = ?"; - $updateContentStatement = WCF::getDB()->prepare($sql); - - foreach ($boxContents as $identifier => $boxContent) { - if (!isset($boxIDs[$identifier])) { - continue; // unknown box - } + if ($knownItems === []) { + return; + } - $createLanguageVersionStatement->execute([$boxIDs[$identifier], $this->languageID]); - if (isset($boxContent['title'])) { - $updateTitleStatement->execute([$boxContent['title'], $boxIDs[$identifier], $this->languageID]); - } - if (isset($boxContent['content'])) { - $updateContentStatement->execute([$boxContent['content'], $boxIDs[$identifier], $this->languageID]); + // A changed value disables the custom value of the phrase, which flags + // it for review in the ACP, the same as an update of the package does. + $updatedItemIDs = []; + foreach (\array_chunk(\array_keys($knownItems), 1_000) as $itemNames) { + $conditions = new PreparedStatementConditionBuilder(); + $conditions->add("languageItem IN (?)", [$itemNames]); + $conditions->add("languageID = ?", [$this->languageID]); + $conditions->add("languageUseCustomValue = ?", [1]); + $conditions->add("languageItemOriginIsSystem = ?", [1]); + + $sql = "SELECT languageItemID, languageItem, languageItemValue + FROM wcf1_language_item + {$conditions}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditions->getParameters()); + while ($row = $statement->fetchArray()) { + if ($row['languageItemValue'] !== $items[$row['languageItem']]) { + $updatedItemIDs[] = $row['languageItemID']; } } + } + + WCF::getDB()->beginTransaction(); - BoxEditor::resetCache(); + if ($updatedItemIDs !== []) { + $sql = "UPDATE wcf1_language_item + SET languageItemOldValue = languageItemValue, + languageCustomItemDisableTime = ?, + languageUseCustomValue = ? + WHERE languageItemID = ?"; + $statement = WCF::getDB()->prepare($sql); + foreach ($updatedItemIDs as $languageItemID) { + $statement->execute([ + \TIME_NOW, + 0, + $languageItemID, + ]); + } } - // update the relevant language files - if ($updateFiles) { - self::deleteLanguageFiles((string)$this->languageID); + // insert/update a maximum of 50 items per run (prevents issues with max_allowed_packet) + foreach (\array_chunk($knownItems, 50, true) as $knownItemsChunk) { + $parameters = []; + foreach ($knownItemsChunk as $itemName => $knownItem) { + $parameters[] = $this->languageID; + $parameters[] = $itemName; + $parameters[] = $items[$itemName]; + $parameters[] = $knownItem['languageCategoryID']; + $parameters[] = $knownItem['packageID']; + } + + $placeholders = \implode(',', \array_fill(0, \count($knownItemsChunk), '(?, ?, ?, ?, ?)')); + $sql = "INSERT INTO wcf1_language_item + (languageID, languageItem, languageItemValue, languageCategoryID, packageID) + VALUES {$placeholders} + ON DUPLICATE KEY UPDATE languageItemValue = IF(languageItemOriginIsSystem = 0, languageItemValue, VALUES(languageItemValue))"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($parameters); } - // delete relevant template compilations - $this->deleteCompiledTemplates(); + WCF::getDB()->commitTransaction(); } /** @@ -847,6 +1033,32 @@ public static function readCountryCodeFromXML(XML $xml) * @return LanguageEditor */ public static function importFromXML(XML $xml, int $packageID, ?Language $source = null) + { + $languageEditor = new self(self::getOrCreateFromXML($xml, $source)); + $languageEditor->updateFromXML($xml, $packageID); + + return $languageEditor; + } + + /** + * Imports a localization into a new or a current language. + * + * @see LanguageEditor::updateFromLocalization() + * @since 6.3 + */ + public static function importLocalization(XML $xml, Language $source): self + { + $languageEditor = new self(self::getOrCreateFromXML($xml, $source)); + $languageEditor->updateFromLocalization($xml); + + return $languageEditor; + } + + /** + * Returns the language with the language code of the XML document. A new + * language is created as a copy of `$source`. + */ + private static function getOrCreateFromXML(XML $xml, ?Language $source): Language { $languageCode = self::readLanguageCodeFromXML($xml); @@ -874,12 +1086,7 @@ public static function importFromXML(XML $xml, int $packageID, ?Language $source } } - // import xml - $languageEditor = new self($language); - $languageEditor->updateFromXML($xml, $packageID); - - // return language object - return $languageEditor; + return $language; } /** diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 891e2e36010..d32a936abbc 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -1094,8 +1094,6 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE 1}e{/if}.]]> - - @@ -5917,5 +5915,7 @@ Erlaubte Dateiendungen: {', '|implode:$allowedFileExtensions}]]> + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index bd6ec5aa734..913438f7b78 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -1072,8 +1072,6 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru 1}s{/if}.]]> - - @@ -5919,5 +5917,7 @@ Allowed extensions: {', '|implode:$allowedFileExtensions}]]> + +