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}]]>
+
+