diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index 1f51200606b..7e7e5e9fe4a 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -80,6 +80,18 @@ The following steps require the new files and must run after the `objectType` PIP: acp/update_com.woltlab.wcf_6.3_userRankImage.php acp/update_com.woltlab.wcf_6.3_reactionTypeIcon.php + acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php + acp/update_com.woltlab.wcf_6.3_userRankL10n.php + acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php + acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php + acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php + acp/update_com.woltlab.wcf_6.3_userOptionL10n.php + acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php acp/database/update_com.woltlab.wcf_6.3_step3.php + + The following step requires the new files and must run before the `cronjob` PIP, which precedes the `objectType` PIP: + acp/update_com.woltlab.wcf_6.3_cronjobL10n.php + + `acp/database/update_com.woltlab.wcf_6.3_step3.php` must run after all of the scripts above, including `acp/update_com.woltlab.wcf_6.3_cronjobL10n.php`. --> diff --git a/com.woltlab.wcf/templates/messageSidebar.tpl b/com.woltlab.wcf/templates/messageSidebar.tpl index b1b73d95f05..1933caca995 100644 --- a/com.woltlab.wcf/templates/messageSidebar.tpl +++ b/com.woltlab.wcf/templates/messageSidebar.tpl @@ -117,7 +117,7 @@ {if $userProfile->getUserOption($__sidebarUserOption)} {assign var='__formattedUserOption' value=$userProfile->getFormattedUserOption($__sidebarUserOption)} {if $__formattedUserOption} -
{lang}wcf.user.option.{$__sidebarUserOption}{/lang}
+
{$userProfile->getUserOptionTitle($__sidebarUserOption)}
{unsafe:$__formattedUserOption}
{/if} {/if} diff --git a/com.woltlab.wcf/templates/optionFieldList.tpl b/com.woltlab.wcf/templates/optionFieldList.tpl index 0466f05027d..076c4e7602d 100644 --- a/com.woltlab.wcf/templates/optionFieldList.tpl +++ b/com.woltlab.wcf/templates/optionFieldList.tpl @@ -15,7 +15,7 @@ {icon name='bolt'} {/if} - {lang}{$langPrefix}{$option->optionName}{/lang} + {if $optionData[title]|isset}{$optionData[title]}{else}{lang}{$langPrefix}{$option->optionName}{/lang}{/if} {/if} @@ -29,7 +29,7 @@ {/if} {/if} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {if $optionData[description]|isset}{$optionData[description]}{else}{lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang}{/if} {/foreach} diff --git a/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl b/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl index 85fbaeb7ca0..55bbbdead8b 100644 --- a/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl +++ b/com.woltlab.wcf/templates/shared_wysiwygFormField.tpl @@ -11,6 +11,10 @@ *}{/if}{* *}{/if}{* *}{foreach from=$field->getFieldAttributes() key='attributeName' item='attributeValue'} {$attributeName}="{$attributeValue}"{/foreach}{* -*}>{$field->getValue()} +*}>{if !$field->isL10n() || !$field->hasI18nValues() || $availableLanguages|count === 1}{$field->getValue()}{/if} {include file='shared_wysiwyg' wysiwygSelector=$field->getPrefixedId()} + +{if $field->isL10n()} + {include file='shared_multipleLanguageInputJavascript'} +{/if} diff --git a/com.woltlab.wcf/templates/userOptionFieldList.tpl b/com.woltlab.wcf/templates/userOptionFieldList.tpl index 423340d4f60..4af1dcf5bde 100644 --- a/com.woltlab.wcf/templates/userOptionFieldList.tpl +++ b/com.woltlab.wcf/templates/userOptionFieldList.tpl @@ -1,9 +1,9 @@ {foreach from=$options item=optionData} {assign var=option value=$optionData[object]}
- {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{if $isSearchMode|empty && $option->required} *{/if}{/if} + {if $isSearchMode|empty || !$optionData[hideLabelInSearch]}{if $isSearchMode|empty && $option->required} *{/if}{/if}
{unsafe:$optionData[html]} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {$option->getDescription()} {if $errorType|is_array && $errorType[$option->optionName]|isset} diff --git a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl index cb00c78c65c..6d654a1b5a2 100644 --- a/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl +++ b/com.woltlab.wcf/templates/userProfileOptionFieldList.tpl @@ -6,7 +6,7 @@ {assign var=error value=''} {/if}
- +
{unsafe:$optionData[html]} {if $error} @@ -17,7 +17,7 @@ {/if} {/if} - {lang __optional=true}{$langPrefix}{$option->optionName}.description{/lang} + {$option->getDescription()}
{/foreach} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5620e62d133..601699ca85c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -121,12 +121,6 @@ parameters: count: 11 path: wcfsetup/install/files/lib/acp/form/PageEditForm.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 3 - path: wcfsetup/install/files/lib/acp/form/PaidSubscriptionAddForm.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -457,12 +451,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/data/comment/response/CommentResponseCollection.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 2 - path: wcfsetup/install/files/lib/data/contact/recipient/ContactRecipient.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed diff --git a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts index 8417189c2a9..9eaf4f9ab8d 100644 --- a/ts/WoltLabSuite/Core/Component/Reaction/Button.ts +++ b/ts/WoltLabSuite/Core/Component/Reaction/Button.ts @@ -95,7 +95,7 @@ class ReactionPopover { const reactionTypeButtonTitle = document.createElement("span"); reactionTypeButtonTitle.className = "reactionTypeButtonTitle"; - reactionTypeButtonTitle.innerHTML = reactionType.title; + reactionTypeButtonTitle.textContent = reactionType.title; reactionTypeButton.appendChild(reactionTypeButtonTitle); reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID)); diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.ts new file mode 100644 index 00000000000..2a8009d0410 --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.ts @@ -0,0 +1,30 @@ +/** + * Data handler for CKEditor with l10n support. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import ValueI18n from "./ValueI18n"; +import { FormBuilderData } from "../Data"; +import { dispatchToCkeditor } from "WoltLabSuite/Core/Component/Ckeditor/Event"; +import { getCkeditorById } from "WoltLabSuite/Core/Component/Ckeditor"; + +export class CkeditorI18n extends ValueI18n { + protected _getData(): FormBuilderData { + // CKEditor does not permanently mirror the contents to the - {include file='shared_wysiwyg' wysiwygSelector='description'} - {if $errorField == 'description'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.description.error.{$errorType}{/lang} - {/if} - - {/if} -
-
- -
-
-
- - {lang}wcf.acp.paidSubscription.showOrder.description{/lang} -
-
- -
-
-
- - {lang}wcf.acp.paidSubscription.isDisabled.description{/lang} -
-
- - {if $availableSubscriptions|count} -
-
{lang}wcf.acp.paidSubscription.excludedSubscriptions{/lang}
-
- {foreach from=$availableSubscriptions item=availableSubscription} - - {/foreach} - {lang}wcf.acp.paidSubscription.excludedSubscriptions.description{/lang} -
-
- {/if} - - {event name='dataFields'} - - -
-

{lang}wcf.acp.paidSubscription.paymentOptions{/lang}

- - -
-
- - - {if $errorField == 'cost'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.cost.error.{$errorType}{/lang} - {/if} - - {/if} -
- - -
-
-
- -
-
- -
-
-
- - - {if $errorField == 'subscriptionLength'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.subscriptionLength.error.{$errorType}{/lang} - {/if} - - {/if} -
-
- -
-
-
- - {lang}wcf.acp.paidSubscription.isRecurring.description{/lang} -
-
- - -
-
- {foreach from=$availableUserGroups item=userGroup} - - {/foreach} - {if $errorField == 'groupIDs'} - - {if $errorType == 'empty'} - {lang}wcf.global.form.error.empty{/lang} - {else} - {lang}wcf.acp.paidSubscription.userGroups.error.{$errorType}{/lang} - {/if} - - {/if} - {lang}wcf.acp.paidSubscription.userGroups.description{/lang} - -
- -
- - {event name='sections'} - -
- - {csrfToken} -
- +{unsafe:$form->getHtml()} {include file='footer'} diff --git a/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl b/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl index 32616118450..f478c6ff844 100644 --- a/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl +++ b/wcfsetup/install/files/acp/templates/useroptionsOptionType.tpl @@ -31,7 +31,7 @@ @@ -40,6 +40,6 @@ {else} {foreach from=$availableOptions item=availableOption} - + {/foreach} {/if} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php new file mode 100644 index 00000000000..5187b9f241e --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php @@ -0,0 +1,54 @@ +prepare("DELETE FROM wcf1_captcha_question_l10n")->execute(); + +L10nLanguageItemSync::migrate( + CaptchaQuestion::getL10nDefinition(), + static function (array $row): array { + $questionIsPhrase = (bool)\preg_match( + '~^wcf\.captcha\.question\.question\.question\d+$~', + $row['question'] + ); + $answersIsPhrase = $row['answers'] !== null && (bool)\preg_match( + '~^wcf\.captcha\.question\.answers\.question\d+$~', + $row['answers'] + ); + + return [ + 'sources' => [ + 'question' => new L10nLanguageItemSource( + languageItem: $questionIsPhrase ? $row['question'] : null, + literal: $row['question'], + deleteAfterMigration: true, + ), + 'answers' => new L10nLanguageItemSource( + languageItem: $answersIsPhrase ? $row['answers'] : null, + literal: $row['answers'], + deleteAfterMigration: true, + ), + ], + ]; + } +); + +// Cached question objects were created without their localized values. +CaptchaQuestionCacheBuilder::getInstance()->reset(); diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php new file mode 100644 index 00000000000..22380e78985 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_contactRecipientL10n.php @@ -0,0 +1,105 @@ +` and + * `wcf.contact.recipient.email` phrases are removed. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (adding the `l10nIdentifier` column and creating the + * `wcf1_contact_recipient_l10n` table) must run BEFORE this script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `name` and `email` columns) must run AFTER this script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\contact\recipient\ContactRecipient; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +$shippedLanguageItem = 'wcf.contact.recipient.name1'; + +// Legacy `ContactRecipient::getName()` and `getEmail()` resolved any existing +// phrase, which includes the phrases of other recipients and phrases named by +// other packages. +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT name + FROM wcf1_contact_recipient + UNION + SELECT email + FROM wcf1_contact_recipient + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_contact_recipient_l10n")->execute(); + +// Link the shipped administrator recipient to its language variable. A legacy +// monolingual edit deleted the phrase and stored a literal name, such a +// recipient owns its name and stays unlinked. +$sql = "UPDATE wcf1_contact_recipient + SET l10nIdentifier = CASE + WHEN isAdministrator = ? AND originIsSystem = ? AND name = ? THEN name + ELSE NULL + END"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([1, 1, $shippedLanguageItem]); + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + ContactRecipient::getL10nDefinition(), + static function (array $row) use ($existingLanguageItems): array { + $nameIsLanguageItem = \in_array($row['name'], $existingLanguageItems, true); + if ($row['isAdministrator'] === 1) { + $emailSource = new L10nLanguageItemSource(null, ''); + } else { + $emailIsLanguageItem = \in_array($row['email'], $existingLanguageItems, true); + $emailSource = new L10nLanguageItemSource( + languageItem: $emailIsLanguageItem ? $row['email'] : null, + literal: $row['email'], + ); + } + + return [ + 'sources' => [ + 'name' => new L10nLanguageItemSource( + languageItem: $nameIsLanguageItem ? $row['name'] : null, + literal: $row['name'], + ), + 'email' => $emailSource, + ], + ]; + } +); + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ? + AND languageItem <> ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([ + '^wcf\.contact\.recipient\.(name|email)[0-9]+$', + $shippedLanguageItem, +]); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_cronjobL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_cronjobL10n.php new file mode 100644 index 00000000000..4c2561a11de --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_cronjobL10n.php @@ -0,0 +1,72 @@ +` phrases, together with the + * phrases left behind by previously deleted cronjobs. + * + * Cronjobs delivered by a package receive their descriptions from the + * `cronjob` PIP on every later update of that package. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (creating the `wcf1_cronjob_l10n` table) must run BEFORE this script. + * - The `cronjob` PIP must run AFTER this script: a cronjob created by the + * PIP before would be migrated from its empty `description` column, losing + * the values written by the PIP. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `description` column) must run AFTER this script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\cronjob\Cronjob; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +// Legacy `Cronjob::getDescription()` resolved any existing phrase, not only +// the cronjob's own `wcf.acp.cronjob.description.cronjob`. +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT description + FROM wcf1_cronjob + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_cronjob_l10n")->execute(); + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + Cronjob::getL10nDefinition(), + static function (array $row) use ($existingLanguageItems): array { + $isLanguageItem = \in_array($row['description'], $existingLanguageItems, true); + + return [ + 'sources' => [ + 'description' => new L10nLanguageItemSource( + languageItem: $isLanguageItem ? $row['description'] : null, + literal: $row['description'], + ), + ], + ]; + } +); + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.acp\.cronjob\.description\.cronjob[0-9]+$']); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php new file mode 100644 index 00000000000..227113f3df9 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_labelGroupL10n.php @@ -0,0 +1,74 @@ +` phrases + * are removed, together with the phrases that older versions left behind when + * a label group was deleted. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (creating the `wcf1_label_group_l10n` table) must run BEFORE this script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `groupName` column) must run AFTER this script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\label\group\LabelGroup; +use wcf\system\cache\builder\LabelCacheBuilder; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +// Legacy `LabelGroup::getTitle()` resolved any existing phrase, which includes +// the phrases of other label groups and phrases named by other packages. +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT groupName + FROM wcf1_label_group + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_label_group_l10n")->execute(); + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + LabelGroup::getL10nDefinition(), + static function (array $row) use ($existingLanguageItems): array { + $isLanguageItem = \in_array($row['groupName'], $existingLanguageItems, true); + + return [ + 'sources' => [ + 'groupName' => new L10nLanguageItemSource( + languageItem: $isLanguageItem ? $row['groupName'] : null, + literal: $row['groupName'], + ), + ], + ]; + } +); + +// Cached label groups were created without their localized values. Reset +// before the phrases are removed, because a failure after the removal makes a +// retry migrate the phrase names as literals. +LabelCacheBuilder::getInstance()->reset(); + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.acp\.label\.group[0-9]+$']); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php new file mode 100644 index 00000000000..98442fda92f --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_paidSubscriptionL10n.php @@ -0,0 +1,163 @@ +` and + * `wcf.paidSubscription.subscription.description` phrases are removed, + * including those left behind by previously deleted subscriptions. + * + * The positions of the subscriptions are renumbered consecutively, because + * the show order is now maintained relative to the other subscriptions. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (creating the `wcf1_paid_subscription_l10n` table) must run BEFORE this + * script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `title` and `description` columns) must run AFTER this + * script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\paid\subscription\PaidSubscription; +use wcf\system\html\input\HtmlInputProcessor; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +$languageItemPattern = '^wcf\.paidSubscription\.subscription[0-9]+(\.description)?$'; + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_paid_subscription_l10n")->execute(); + +// The length of phrases was never validated, and the language editor allows +// phrases of any length. Titles are shortened by the migration, but the limit of +// the description is measured in bytes. The phrases are removed below anyway, +// shortening them is lossless for a retry. A shortened HTML description is +// repaired by the input processor below. +foreach (['languageItemValue', 'languageCustomItemValue'] as $valueColumn) { + $sql = "UPDATE wcf1_language_item + SET {$valueColumn} = SUBSTRING({$valueColumn}, 1, 16383) + WHERE languageItem REGEXP ? + AND LENGTH({$valueColumn}) > 65535"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute(['^wcf\.paidSubscription\.subscription[0-9]+\.description$']); +} + +// Legacy `PaidSubscription::getTitle()` resolved any existing phrase, which +// includes the phrases of other subscriptions and phrases named by other +// packages. `getDescription()` only resolved the subscription phrase pattern. +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT title + FROM wcf1_paid_subscription + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingTitleLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT description + FROM wcf1_paid_subscription + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingDescriptionLanguageItems = \array_filter( + $statement->fetchAll(\PDO::FETCH_COLUMN), + static fn(string $languageItem) => \preg_match('~^wcf.paidSubscription.subscription\d+.description$~', $languageItem) === 1 +); + +$sql = "SELECT subscriptionID + FROM wcf1_paid_subscription + ORDER BY showOrder, subscriptionID"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$subscriptionIDs = $statement->fetchAll(\PDO::FETCH_COLUMN); + +$sql = "UPDATE wcf1_paid_subscription + SET showOrder = ? + WHERE subscriptionID = ?"; +$statement = WCF::getDB()->prepare($sql); +foreach ($subscriptionIDs as $index => $subscriptionID) { + $statement->execute([ + $index + 1, + $subscriptionID, + ]); +} + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + PaidSubscription::getL10nDefinition(), + static function (array $row) use ($existingTitleLanguageItems, $existingDescriptionLanguageItems): array { + $description = $row['description'] ?? ''; + + return [ + 'sources' => [ + 'title' => new L10nLanguageItemSource( + languageItem: \in_array($row['title'], $existingTitleLanguageItems, true) ? $row['title'] : null, + literal: $row['title'], + ), + 'description' => new L10nLanguageItemSource( + languageItem: \in_array($description, $existingDescriptionLanguageItems, true) ? $description : null, + literal: $description, + ), + ], + ]; + } +); + +$sql = "SELECT subscriptionID, languageID, description + FROM wcf1_paid_subscription_l10n"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$rows = $statement->fetchAll(\PDO::FETCH_ASSOC); + +$sql = "UPDATE wcf1_paid_subscription_l10n + SET description = ? + WHERE subscriptionID = ? + AND languageID <=> ?"; +$updateStatement = WCF::getDB()->prepare($sql); +foreach ($rows as $row) { + // Descriptions without HTML are legacy plain text, which is still rendered + // as such by `PaidSubscription::getFormattedDescription()`. + if ($row['description'] === null || \preg_match('~^<[a-z]+~', $row['description']) !== 1) { + continue; + } + + // The processed HTML can be longer than its source and exceed the column. + $description = $row['description']; + do { + $htmlInputProcessor = new HtmlInputProcessor(); + $htmlInputProcessor->process($description, 'com.woltlab.wcf.paidSubscription', $row['subscriptionID']); + $html = $htmlInputProcessor->getHtml(); + + $description = \mb_substr($description, 0, (int)(\mb_strlen($description) * 0.9)); + } while (\strlen($html) > 65535); + + $updateStatement->execute([ + $html, + $row['subscriptionID'], + $row['languageID'], + ]); +} + +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([$languageItemPattern]); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php new file mode 100644 index 00000000000..b63f64b78d5 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_reactionTypeL10n.php @@ -0,0 +1,122 @@ +` language + * variable) into the `wcf1_reaction_type_l10n` table. + * + * The reaction types shipped with the package (`wcf.reactionType.title1` to + * `wcf.reactionType.title5`) are linked to their language variable via + * `l10nIdentifier`; their localized values are stored as pristine copies and + * kept in sync with the phrases. Reaction types created by an administrator + * own their title: they stay unlinked and their obsolete phrases are removed, + * together with the phrases left behind by previously deleted reaction types. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (adding the `l10nIdentifier` column and creating the + * `wcf1_reaction_type_l10n` table) must run BEFORE this script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `title` column) must run AFTER this script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\reaction\type\ReactionType; +use wcf\system\cache\builder\ReactionTypeCacheBuilder; +use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +$shippedLanguageItems = [ + 'wcf.reactionType.title1', + 'wcf.reactionType.title2', + 'wcf.reactionType.title3', + 'wcf.reactionType.title4', + 'wcf.reactionType.title5', +]; + +// Legacy `ReactionType::getTitle()` resolved any existing phrase, which +// includes phrases named by other packages. +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT title + FROM wcf1_reaction_type + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +$getOwnLanguageItem = static function (array $row): ?string { + $languageItem = 'wcf.reactionType.title' . $row['reactionTypeID']; + + // `wcf.reactionType.title` is a placeholder written by the legacy create + // action before the phrase of the new reaction type was saved. + if ($row['title'] === $languageItem || $row['title'] === 'wcf.reactionType.title') { + return $languageItem; + } + + return null; +}; + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_reaction_type_l10n")->execute(); + +// Link the shipped reaction types to their language variable; reaction types +// created by an administrator own their title and stay unlinked. +$statement = WCF::getDB()->prepare("SELECT reactionTypeID, title FROM wcf1_reaction_type"); +$statement->execute(); +$updateStatement = WCF::getDB()->prepare( + "UPDATE wcf1_reaction_type SET l10nIdentifier = ? WHERE reactionTypeID = ?" +); +while ($row = $statement->fetchArray()) { + $languageItem = $getOwnLanguageItem($row); + + $updateStatement->execute([ + \in_array($languageItem, $shippedLanguageItems, true) ? $languageItem : null, + $row['reactionTypeID'], + ]); +} + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + ReactionType::getL10nDefinition(), + static function (array $row) use ($getOwnLanguageItem, $existingLanguageItems): array { + $languageItem = $getOwnLanguageItem($row); + if ($languageItem === null && \in_array($row['title'], $existingLanguageItems, true)) { + $languageItem = $row['title']; + } + + return [ + 'sources' => [ + 'title' => new L10nLanguageItemSource( + languageItem: $languageItem, + literal: $row['title'], + ), + ], + ]; + } +); + +// Removes the migrated phrases as well as those the legacy code left behind: +// the delete action never removed the phrase of a reaction type, and switching +// a title to a monolingual value kept the previous phrase. +$conditions = new PreparedStatementConditionBuilder(); +$conditions->add('languageItem REGEXP ?', ['^wcf\.reactionType\.title[0-9]+$']); +$conditions->add('languageItem NOT IN (?)', [$shippedLanguageItems]); +$sql = "DELETE FROM wcf1_language_item + {$conditions}"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute($conditions->getParameters()); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} + +// Cached reaction types were created without their localized values. +ReactionTypeCacheBuilder::getInstance()->reset(); diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php new file mode 100644 index 00000000000..ce9306e4712 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userOptionL10n.php @@ -0,0 +1,67 @@ +[.description]` language variables into the + * `wcf1_user_option_l10n` table. + * + * System options (shipped by a package) are linked to their language variable + * via `l10nIdentifier`; their localized values are stored as pristine copies + * and kept in sync with the phrases. Options created by an administrator + * (`option`) own their localized value: they stay unlinked and their + * obsolete phrases are removed. + * + * IMPORTANT ordering constraint for package.xml: The database script + * `acp/database/update_com.woltlab.wcf_6.3_step1.php` (adding the + * `l10nIdentifier` column and creating the `wcf1_user_option_l10n` table) must + * run BEFORE this script. + */ + +use wcf\data\user\option\UserOption; +use wcf\system\cache\builder\UserOptionCacheBuilder; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\WCF; + +$isAdminCreated = static fn(string $optionName): bool => (bool)\preg_match('/^option\d+$/', $optionName); + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_user_option_l10n")->execute(); + +// Link system options to their language variable; administrator created +// options own their localized value and stay unlinked. +$statement = WCF::getDB()->prepare("SELECT optionID, optionName FROM wcf1_user_option"); +$statement->execute(); +$updateStatement = WCF::getDB()->prepare( + "UPDATE wcf1_user_option SET l10nIdentifier = ? WHERE optionID = ?" +); +while ($row = $statement->fetchArray()) { + $updateStatement->execute([ + $isAdminCreated($row['optionName']) ? null : 'wcf.user.option.' . $row['optionName'], + $row['optionID'], + ]); +} + +// Migrate the phrase values into the l10n storage. +L10nLanguageItemSync::migrate( + UserOption::getL10nDefinition(), + static function (array $row) use ($isAdminCreated): array { + $adminCreated = $isAdminCreated($row['optionName']); + $identifier = 'wcf.user.option.' . $row['optionName']; + + return [ + 'sources' => [ + 'title' => new L10nLanguageItemSource( + languageItem: $identifier, + deleteAfterMigration: $adminCreated, + ), + 'description' => new L10nLanguageItemSource( + languageItem: $identifier . '.description', + deleteAfterMigration: $adminCreated, + ), + ], + ]; + } +); + +UserOptionCacheBuilder::getInstance()->reset(); diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userRankL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userRankL10n.php new file mode 100644 index 00000000000..84d1d980d68 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_userRankL10n.php @@ -0,0 +1,109 @@ +` phrases are removed, together with the + * phrases left behind by previously deleted user ranks. + * + * IMPORTANT ordering constraints for package.xml: + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step1.php` + * (adding the `l10nIdentifier` column and creating the + * `wcf1_user_rank_l10n` table) must run BEFORE this script. + * - The database script `acp/database/update_com.woltlab.wcf_6.3_step3.php` + * (dropping the `rankTitle` column) must run AFTER this script. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + */ + +use wcf\data\user\rank\UserRank; +use wcf\system\cache\builder\UserRankCacheBuilder; +use wcf\system\l10n\L10nLanguageItemSource; +use wcf\system\l10n\L10nLanguageItemSync; +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +$shippedLanguageItems = [ + 'wcf.user.rank.administrator', + 'wcf.user.rank.moderator', + 'wcf.user.rank.user0', + 'wcf.user.rank.user1', + 'wcf.user.rank.user2', + 'wcf.user.rank.user3', + 'wcf.user.rank.user4', + 'wcf.user.rank.user5', +]; + +// Legacy `UserRank::getTitle()` resolved any existing phrase, which includes +// the phrases of other user ranks and phrases named by other packages. +$sql = "SELECT DISTINCT languageItem + FROM wcf1_language_item + WHERE languageItem IN ( + SELECT rankTitle + FROM wcf1_user_rank + )"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(); +$existingLanguageItems = $statement->fetchAll(\PDO::FETCH_COLUMN); + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_user_rank_l10n")->execute(); + +// Link the shipped user ranks to their language variable; user ranks created +// by an administrator own their title and stay unlinked. +$statement = WCF::getDB()->prepare("SELECT rankID, rankTitle FROM wcf1_user_rank"); +$statement->execute(); +$updateStatement = WCF::getDB()->prepare( + "UPDATE wcf1_user_rank SET l10nIdentifier = ? WHERE rankID = ?" +); +while ($row = $statement->fetchArray()) { + $updateStatement->execute([ + \in_array($row['rankTitle'], $shippedLanguageItems, true) ? $row['rankTitle'] : null, + $row['rankID'], + ]); +} + +// The phrases are removed only by the final `DELETE` below, so that a failure +// before it leaves them in place for a retry. +L10nLanguageItemSync::migrate( + UserRank::getL10nDefinition(), + static function (array $row) use ($existingLanguageItems): array { + $isLanguageItem = \in_array($row['rankTitle'], $existingLanguageItems, true); + + return [ + 'sources' => [ + 'rankTitle' => new L10nLanguageItemSource( + languageItem: $isLanguageItem ? $row['rankTitle'] : null, + literal: $row['rankTitle'], + ), + ], + ]; + } +); + +// Cached user ranks were created without their localized values. Reset +// before the phrases are removed, because a failure after the removal makes a +// retry migrate the phrase names as literals. +UserRankCacheBuilder::getInstance()->reset(); + +// Removes the migrated phrases as well as those the legacy code left behind: +// deleting a user group removed its user ranks without their phrases, and +// before 6.2 the phrases were saved in the `wcf.user` category, which the +// legacy cleanup did not cover. +$sql = "DELETE FROM wcf1_language_item + WHERE languageItem REGEXP ?"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute(['^wcf\.user\.rank\.userRank[0-9]+$']); +if ($statement->getAffectedRows() > 0) { + LanguageFactory::getInstance()->deleteLanguageCache(); +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js index 024a2cc1a68..b23668dbb83 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Reaction/Button.js @@ -69,7 +69,7 @@ define(["require", "exports", "tslib", "WoltLabSuite/Core/Api/Reactions/RevertRe reactionTypeButton.innerHTML = reactionType.renderedIcon; const reactionTypeButtonTitle = document.createElement("span"); reactionTypeButtonTitle.className = "reactionTypeButtonTitle"; - reactionTypeButtonTitle.innerHTML = reactionType.title; + reactionTypeButtonTitle.textContent = reactionType.title; reactionTypeButton.appendChild(reactionTypeButtonTitle); reactionTypeButton.addEventListener("click", () => this.#click(reactionType.reactionTypeID)); if (!reactionType.isAssignable) { diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.js new file mode 100644 index 00000000000..c2256673134 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/CkeditorI18n.js @@ -0,0 +1,27 @@ +/** + * Data handler for CKEditor with l10n support. + * + * @author Alexander Ebert + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "tslib", "./ValueI18n", "WoltLabSuite/Core/Component/Ckeditor/Event", "WoltLabSuite/Core/Component/Ckeditor"], function (require, exports, tslib_1, ValueI18n_1, Event_1, Ckeditor_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.CkeditorI18n = void 0; + ValueI18n_1 = tslib_1.__importDefault(ValueI18n_1); + class CkeditorI18n extends ValueI18n_1.default { + _getData() { + // CKEditor does not permanently mirror the contents to the