diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index bbf5922e535..44bfec4759b 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -54,6 +54,7 @@ Required order of the following steps for the update to 6.2: acp/database/update_com.woltlab.wcf_62_step1.php acp/update_com.woltlab.wcf_6.2_contactOptions.php + acp/update_com.woltlab.wcf_6.2_captchaQuestion.php acp/database/update_com.woltlab.wcf_62_step2.php --> diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php index d34fe5609eb..82b74cc927c 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step1.php @@ -87,4 +87,31 @@ ->referencedColumns(['languageID']) ->onDelete('CASCADE'), ]), + DatabaseTable::create('wcf1_captcha_question_content') + ->columns([ + ObjectIdDatabaseTableColumn::create('contentID'), + IntDatabaseTableColumn::create('questionID') + ->notNull(), + IntDatabaseTableColumn::create('languageID'), + NotNullVarchar255DatabaseTableColumn::create('question'), + MediumtextDatabaseTableColumn::create('answers'), + ]) + ->indices([ + DatabaseTablePrimaryIndex::create() + ->columns(['contentID']), + DatabaseTableIndex::create('id') + ->columns(['questionID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['questionID']) + ->referencedTable('wcf1_captcha_question') + ->referencedColumns(['questionID']) + ->onDelete('CASCADE'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE'), + ]), ]; diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step2.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step2.php index f10a241c93d..34c8d0d4a03 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step2.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.2_step2.php @@ -9,6 +9,7 @@ */ use wcf\system\database\table\column\MediumtextDatabaseTableColumn; +use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn; use wcf\system\database\table\column\TextDatabaseTableColumn; use wcf\system\database\table\column\TinyintDatabaseTableColumn; use wcf\system\database\table\PartialDatabaseTable; @@ -25,4 +26,11 @@ TinyintDatabaseTableColumn::create('required') ->drop(), ]), + PartialDatabaseTable::create('wcf1_captcha_question') + ->columns([ + NotNullVarchar255DatabaseTableColumn::create('question') + ->drop(), + MediumtextDatabaseTableColumn::create('answers') + ->drop(), + ]), ]; diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.2_captchaQuestion.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.2_captchaQuestion.php new file mode 100644 index 00000000000..db2a600429c --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.2_captchaQuestion.php @@ -0,0 +1,66 @@ +prepare($sql); +$statement->execute(); + +$questionIDs = $questions = $answers = []; +while ($row = $statement->fetchArray()) { + $questionIDs[] = $row['questionID']; + $questions[$row['questionID']] = $row['question']; + $answers[$row['questionID']] = $row['answers']; +} + +$sql = "INSERT INTO wcf1_captcha_question_content + (questionID, languageID, question, answers) + VALUES (?, ?, ?, ?)"; +$statement = WCF::getDB()->prepare($sql); + +$languageItems = []; +foreach ($questionIDs as $questionID) { + $answer = $answers[$questionID]; + $question = $questions[$questionID]; + $multilingual = false; + + if (\preg_match('~^wcf\.captcha\.question\.question\.question\d+$~', $question, $matches)) { + $languageItems[] = $question; + $multilingual = true; + } + if (\preg_match('~^wcf\.captcha\.question\.answers\.question\d+$~', $answer, $matches)) { + $languageItems[] = $answer; + $multilingual = true; + } + + if ($multilingual) { + foreach (LanguageFactory::getInstance()->getLanguages() as $language) { + $statement->execute([ + $questionID, + $language->languageID, + $language->get($question), + $language->get($answer), + ]); + } + } else { + $statement->execute([ + $questionID, + null, + $question, + $answer, + ]); + } +} + +if ($languageItems !== []) { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('languageItem IN (?)', [$languageItems]); + + $sql = "DELETE FROM wcf1_language_item + {$conditionBuilder}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); +} diff --git a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php index 0b8209c25bd..7120ae7449d 100644 --- a/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/CaptchaQuestionAddForm.class.php @@ -7,7 +7,12 @@ use wcf\data\language\Language; use wcf\form\AbstractFormBuilderForm; use wcf\system\form\builder\container\FormContainer; +use wcf\system\form\builder\container\MultilingualFormContainer; +use wcf\system\form\builder\data\processor\MultilingualFormDataProcessor; +use wcf\system\form\builder\data\processor\VoidFormDataProcessor; use wcf\system\form\builder\field\BooleanFormField; +use wcf\system\form\builder\field\dependency\EmptyFormFieldDependency; +use wcf\system\form\builder\field\IFormField; use wcf\system\form\builder\field\MultilineTextFormField; use wcf\system\form\builder\field\TextFormField; use wcf\system\form\builder\field\validation\FormFieldValidationError; @@ -51,43 +56,76 @@ protected function createForm() { parent::createForm(); + $multilingualContainer = MultilingualFormContainer::create('content') + ->label('wcf.acp.captcha.question.content') + ->appendChildren($this->getContentFields()); + + foreach ($multilingualContainer->getLangaugeContainers() as $langaugeCode => $container) { + $container->appendChildren( + $this->getContentFields(LanguageFactory::getInstance()->getLanguageByCode($langaugeCode)) + ); + } + $this->form->appendChildren([ FormContainer::create('general') ->appendChildren([ - TextFormField::create('question') - ->label('wcf.acp.captcha.question.question') - ->i18n() - ->languageItemPattern('wcf.captcha.question.question.question\d+') - ->required(), - MultilineTextFormField::create('answers') - ->label('wcf.acp.captcha.question.answers') - ->i18n() - ->languageItemPattern('wcf.captcha.question.answers.question\d+') - ->required() - ->addValidator( - new FormFieldValidator('regexValidator', function (MultilineTextFormField $formField) { - $value = $formField->getValue(); - - if ($formField->hasPlainValue()) { - $this->validateAnswer($value, $formField); - } else { - foreach ($value as $languageID => $languageValue) { - $this->validateAnswer( - $languageValue, - $formField, - LanguageFactory::getInstance()->getLanguage($languageID) - ); - } - } - }) - ), BooleanFormField::create('isDisabled') ->label('wcf.acp.captcha.question.isDisabled') - ->value(false) - ]) + ->value(false), + ]), + $multilingualContainer, ]); } + #[\Override] + protected function finalizeForm() + { + parent::finalizeForm(); + + $this->form->getDataHandler() + ->addProcessor( + new MultilingualFormDataProcessor( + 'wcf1_captcha_question_content', + ['question' => 'question', 'answers' => 'answers'] + ) + ) + ->addProcessor(new VoidFormDataProcessor('isMultilingual')); + } + + /** + * @return IFormField[] + */ + protected function getContentFields(?Language $language = null): array + { + $questionFormField = TextFormField::create('question' . ($language ? '_' . $language->languageCode : '')) + ->label('wcf.acp.captcha.question.question') + ->required(); + + $answerFormField = MultilineTextFormField::create('answers' . ($language ? '_' . $language->languageCode : '')) + ->label('wcf.acp.captcha.question.answers') + ->required() + ->addValidator( + new FormFieldValidator('regexValidator', function (MultilineTextFormField $formField) use ($language) { + $value = $formField->getValue(); + + $this->validateAnswer($value, $formField, $language); + }) + ); + + if ($language === null) { + $questionFormField->addDependency( + EmptyFormFieldDependency::create('isMultilingual') + ->fieldId('isMultilingual') + ); + $answerFormField->addDependency( + EmptyFormFieldDependency::create('isMultilingual') + ->fieldId('isMultilingual') + ); + } + + return [$questionFormField, $answerFormField]; + } + protected function validateAnswer( string $answer, MultilineTextFormField $formField, @@ -105,7 +143,7 @@ protected function validateAnswer( 'wcf.acp.captcha.question.answers.error.invalidRegex', [ 'invalidRegex' => $answer, - 'language' => $language + 'language' => $language, ] ) ); diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php index 0c3fe3b8109..e8b592435b1 100644 --- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestion.class.php @@ -3,6 +3,7 @@ namespace wcf\data\captcha\question; use wcf\data\DatabaseObject; +use wcf\system\language\LanguageFactory; use wcf\system\Regex; use wcf\system\WCF; use wcf\util\StringUtil; @@ -15,12 +16,15 @@ * @license GNU Lesser General Public License * * @property-read int $questionID unique id of the captcha question - * @property-read string $question question of the captcha or name of language item which contains the question - * @property-read string $answers newline-separated list of answers or name of language item which contains the answers * @property-read int $isDisabled is `1` if the captcha question is disabled and thus not offered to answer, otherwise `0` */ class CaptchaQuestion extends DatabaseObject { + /** + * @var array + */ + protected array $content; + /** * Returns the question in the active user's language. * @@ -29,7 +33,7 @@ class CaptchaQuestion extends DatabaseObject */ public function getQuestion() { - return WCF::getLanguage()->get($this->question); + return $this->getContent()['question']; } /** @@ -40,7 +44,9 @@ public function getQuestion() */ public function isAnswer($answer) { - $answers = \explode("\n", StringUtil::unifyNewlines(WCF::getLanguage()->get($this->answers))); + $this->loadContent(); + + $answers = \explode("\n", StringUtil::unifyNewlines($this->getContent()['answers'])); foreach ($answers as $__answer) { if (\mb_substr($__answer, 0, 1) == '~' && \mb_substr($__answer, -1, 1) == '~') { if (Regex::compile(\mb_substr($__answer, 1, \mb_strlen($__answer) - 2), Regex::CASE_INSENSITIVE)->match($answer)) { @@ -55,4 +61,57 @@ public function isAnswer($answer) return false; } + + /** + * @since 6.2 + */ + protected function loadContent(): void + { + if (isset($this->content)) { + return; + } + + $sql = "SELECT languageID, question, answers + FROM wcf1_captcha_question_content + WHERE questionID = ?"; + + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$this->questionID]); + + $this->content = []; + while ($row = $statement->fetchArray()) { + $this->content[$row['languageID'] ?: 0] = [ + 'question' => $row['question'], + 'answers' => $row['answers'], + ]; + } + } + + /** + * @return array{question: string, answers: string} + * @since 6.2 + */ + protected function getContent(): array + { + $this->loadContent(); + + return $this->content[WCF::getLanguage()->languageID] + ?? $this->content[LanguageFactory::getInstance()->getDefaultLanguageID()] + ?? \reset($this->content); + } + + /** + * @since 6.2 + */ + public function setContent(?int $languageID, string $question, string $answers): void + { + if (!isset($this->content)) { + $this->content = []; + } + + $this->content[$languageID ?: 0] = [ + 'question' => $question, + 'answers' => $answers, + ]; + } } diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php index 52651d3201c..a5c3c08bccc 100644 --- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionAction.class.php @@ -5,7 +5,8 @@ use wcf\data\AbstractDatabaseObjectAction; use wcf\data\IToggleAction; use wcf\data\TDatabaseObjectToggle; -use wcf\data\TI18nDatabaseObjectAction; +use wcf\system\captcha\question\command\SaveContent; +use wcf\system\form\builder\data\processor\MultilingualFormDataProcessor; /** * Executes captcha question-related actions. @@ -19,7 +20,6 @@ class CaptchaQuestionAction extends AbstractDatabaseObjectAction implements IToggleAction { use TDatabaseObjectToggle; - use TI18nDatabaseObjectAction; /** * @inheritDoc @@ -31,60 +31,27 @@ class CaptchaQuestionAction extends AbstractDatabaseObjectAction implements ITog */ protected $permissionsUpdate = ['admin.captcha.canManageCaptchaQuestion']; - /** - * @return array - */ - #[\Override] - public function getI18nSaveTypes(): array - { - return [ - 'question' => 'wcf.captcha.question.question.question\d+', - 'answers' => 'wcf.captcha.question.answers.question\d+', - ]; - } - - #[\Override] - public function getLanguageCategory(): string - { - return 'wcf.captcha.question'; - } - - #[\Override] - public function getPackageID(): int - { - return PACKAGE_ID; - } - #[\Override] public function update() { parent::update(); - foreach ($this->objects as $object) { - $this->saveI18nValue($object->getDecoratedObject()); + if (isset($this->parameters[MultilingualFormDataProcessor::ARRAY_INDEX])) { + foreach ($this->objects as $object) { + (new SaveContent($object->questionID, $this->parameters[MultilingualFormDataProcessor::ARRAY_INDEX]))(); + } } } #[\Override] public function create() { - // Question column doesn't have a default value - $this->parameters['data']['question'] = $this->parameters['data']['question'] ?? ''; - $captchaQuestion = parent::create(); - $this->saveI18nValue($captchaQuestion); + if (isset($this->parameters[MultilingualFormDataProcessor::ARRAY_INDEX])) { + (new SaveContent($captchaQuestion->questionID, $this->parameters[MultilingualFormDataProcessor::ARRAY_INDEX]))(); + } return $captchaQuestion; } - - #[\Override] - public function delete() - { - $returnValue = parent::delete(); - - $this->deleteI18nValues(); - - return $returnValue; - } } diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionEditor.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionEditor.class.php index 839dea59661..fa4d23cb916 100644 --- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionEditor.class.php +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionEditor.class.php @@ -4,7 +4,7 @@ use wcf\data\DatabaseObjectEditor; use wcf\data\IEditableCachedObject; -use wcf\system\cache\builder\CaptchaQuestionCacheBuilder; +use wcf\system\cache\eager\CaptchaQuestionCache; /** * Provides functions to edit captcha questions. @@ -29,6 +29,6 @@ class CaptchaQuestionEditor extends DatabaseObjectEditor implements IEditableCac */ public static function resetCache() { - CaptchaQuestionCacheBuilder::getInstance()->reset(); + (new CaptchaQuestionCache())->rebuild(); } } diff --git a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php index 5cfaa578011..d72965d9105 100644 --- a/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php +++ b/wcfsetup/install/files/lib/data/captcha/question/CaptchaQuestionList.class.php @@ -3,6 +3,8 @@ namespace wcf\data\captcha\question; use wcf\data\DatabaseObjectList; +use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\WCF; /** * Represents a list of captcha questions. @@ -13,4 +15,31 @@ * * @extends DatabaseObjectList */ -class CaptchaQuestionList extends DatabaseObjectList {} +class CaptchaQuestionList extends DatabaseObjectList +{ + #[\Override] + public function readObjects() + { + parent::readObjects(); + + if ($this->objectIDs !== []) { + $this->loadContent(); + } + } + + private function loadContent(): void + { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add("questionID IN(?)", [$this->objectIDs]); + + $sql = "SELECT * + FROM wcf1_captcha_question_content + {$conditionBuilder}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + + while ($row = $statement->fetchArray()) { + $this->objects[$row['questionID']]->setContent($row['languageID'], $row['question'], $row['answers']); + } + } +} diff --git a/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php b/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php deleted file mode 100644 index 4784e731df5..00000000000 --- a/wcfsetup/install/files/lib/data/captcha/question/I18nCaptchaQuestionList.class.php +++ /dev/null @@ -1,28 +0,0 @@ - - * @since 6.2 - * - * @extends I18nDatabaseObjectList - */ -class I18nCaptchaQuestionList extends I18nDatabaseObjectList -{ - /** - * @inheritDoc - */ - public $i18nFields = ['question' => 'questionI18n']; - - /** - * @inheritDoc - */ - public $className = CaptchaQuestion::class; -} diff --git a/wcfsetup/install/files/lib/system/cache/builder/CaptchaQuestionCacheBuilder.class.php b/wcfsetup/install/files/lib/system/cache/builder/CaptchaQuestionCacheBuilder.class.php index 8a91a6f8bb3..32f237801c2 100644 --- a/wcfsetup/install/files/lib/system/cache/builder/CaptchaQuestionCacheBuilder.class.php +++ b/wcfsetup/install/files/lib/system/cache/builder/CaptchaQuestionCacheBuilder.class.php @@ -2,7 +2,7 @@ namespace wcf\system\cache\builder; -use wcf\data\captcha\question\CaptchaQuestionList; +use wcf\system\cache\eager\CaptchaQuestionCache; /** * Caches the enabled captcha questions. @@ -10,18 +10,20 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * + * @deprecated 6.2 use `CaptchaQuestionCache` instead */ -class CaptchaQuestionCacheBuilder extends AbstractCacheBuilder +class CaptchaQuestionCacheBuilder extends AbstractLegacyCacheBuilder { - /** - * @inheritDoc - */ - public function rebuild(array $parameters) + #[\Override] + protected function rebuild(array $parameters): array { - $questionList = new CaptchaQuestionList(); - $questionList->getConditionBuilder()->add('isDisabled = ?', [0]); - $questionList->readObjects(); + return (new CaptchaQuestionCache())->getCache(); + } - return $questionList->getObjects(); + #[\Override] + public function reset(array $parameters = []) + { + (new CaptchaQuestionCache())->rebuild(); } } diff --git a/wcfsetup/install/files/lib/system/cache/eager/CaptchaQuestionCache.class.php b/wcfsetup/install/files/lib/system/cache/eager/CaptchaQuestionCache.class.php new file mode 100644 index 00000000000..a7f22404595 --- /dev/null +++ b/wcfsetup/install/files/lib/system/cache/eager/CaptchaQuestionCache.class.php @@ -0,0 +1,29 @@ + + * @since 6.2 + * + * @extends AbstractEagerCache> + */ +final class CaptchaQuestionCache extends AbstractEagerCache +{ + #[\Override] + protected function getCacheData(): array + { + $questionList = new CaptchaQuestionList(); + $questionList->getConditionBuilder()->add('isDisabled = ?', [0]); + $questionList->readObjects(); + + return $questionList->getObjects(); + } +} diff --git a/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php b/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php index e5321901d14..17791c405c2 100644 --- a/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php +++ b/wcfsetup/install/files/lib/system/captcha/CaptchaQuestionHandler.class.php @@ -5,7 +5,7 @@ use ParagonIE\ConstantTime\Hex; use wcf\data\captcha\question\CaptchaQuestion; use wcf\data\captcha\question\CaptchaQuestionEditor; -use wcf\system\cache\builder\CaptchaQuestionCacheBuilder; +use wcf\system\cache\eager\CaptchaQuestionCache; use wcf\system\exception\UserInputException; use wcf\system\WCF; use wcf\util\StringUtil; @@ -42,7 +42,7 @@ final class CaptchaQuestionHandler implements ICaptchaHandler public function __construct() { - $this->questions = CaptchaQuestionCacheBuilder::getInstance()->getData(); + $this->questions = (new CaptchaQuestionCache())->getCache(); } /** diff --git a/wcfsetup/install/files/lib/system/captcha/question/command/SaveContent.class.php b/wcfsetup/install/files/lib/system/captcha/question/command/SaveContent.class.php new file mode 100644 index 00000000000..5f586e2a7fe --- /dev/null +++ b/wcfsetup/install/files/lib/system/captcha/question/command/SaveContent.class.php @@ -0,0 +1,59 @@ + + * @since 6.2 + */ +final class SaveContent +{ + public function __construct( + public readonly int $questionID, + /** @var array */ + public readonly array $contents, + ) { + } + + public function __invoke(): void + { + if ($this->contents === []) { + return; + } + + $this->deleteOldContent($this->questionID); + $this->saveContent($this->questionID, $this->contents); + } + + private function deleteOldContent(int $questionID): void + { + $sql = "DELETE FROM wcf1_captcha_question_content + WHERE questionID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$questionID]); + } + + /** + * @param array $contents + */ + private function saveContent(int $questionID, array $contents): void + { + $sql = "INSERT INTO wcf1_captcha_question_content + (questionID, languageID, question, answers) + VALUES (?, ?, ?, ?)"; + $statement = WCF::getDB()->prepare($sql); + + foreach ($contents as $languageID => $content) { + $statement->execute([ + $questionID, + $languageID ?: null, + $content['question'], + $content['answers'], + ]); + } + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/container/MultilingualFormContainer.class.php b/wcfsetup/install/files/lib/system/form/builder/container/MultilingualFormContainer.class.php new file mode 100644 index 00000000000..6c1c401c25f --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/container/MultilingualFormContainer.class.php @@ -0,0 +1,91 @@ + + * @since 6.2 + */ +final class MultilingualFormContainer extends FormContainer +{ + public readonly TabMenuFormContainer $tabContainer; + + public function __construct() + { + parent::__construct(); + + $this->appendChild( + BooleanFormField::create('isMultilingual') + ->label('wcf.global.isMultilingual') + ->available(\count(LanguageFactory::getInstance()->getLanguages()) > 1) + ); + + $this->tabContainer = TabMenuFormContainer::create('languages') + ->addDependency( + NonEmptyFormFieldDependency::create('isMultilingual') + ->fieldId('isMultilingual') + ) + ->available(\count(LanguageFactory::getInstance()->getLanguages()) > 1); + $this->appendChild($this->tabContainer); + } + + #[\Override] + public static function create($id): static + { + $formField = (new static())->id($id); + + foreach (LanguageFactory::getInstance()->getLanguages() as $language) { + $tab = TabFormContainer::create("{$formField->getId()}_language_{$language->languageCode}") + ->label($language->languageName) + ->appendChildren([ + FormContainer::create("{$formField->getId()}_{$language->languageCode}"), + ]); + + $formField->tabContainer->appendChild($tab); + } + + return $formField; + } + + /** + * Returns a map of language codes to their respective tab containers. + * + * @return array + */ + public function getLangaugeTabs(): array + { + $result = []; + foreach (LanguageFactory::getInstance()->getLanguages() as $language) { + $tab = $this->tabContainer->getNodeById("{$this->getId()}_language_{$language->languageCode}"); + \assert($tab instanceof TabFormContainer); + + $result[$language->languageCode] = $tab; + } + + return $result; + } + + /** + * Returns a map of language codes to their respective form containers in the tab. + * + * @return array + */ + public function getLangaugeContainers(): array + { + $result = []; + foreach (LanguageFactory::getInstance()->getLanguages() as $language) { + $container = $this->tabContainer->getNodeById("{$this->getId()}_{$language->languageCode}"); + \assert($container instanceof FormContainer); + + $result[$language->languageCode] = $container; + } + + return $result; + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/data/processor/MultilingualFormDataProcessor.class.php b/wcfsetup/install/files/lib/system/form/builder/data/processor/MultilingualFormDataProcessor.class.php new file mode 100644 index 00000000000..e23c675ee07 --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/data/processor/MultilingualFormDataProcessor.class.php @@ -0,0 +1,219 @@ + + * @since 6.2 + */ +final class MultilingualFormDataProcessor extends AbstractFormDataProcessor +{ + public const ARRAY_INDEX = 'content'; + + public function __construct( + public readonly string $contentTableName, + /** + * Mapping of field id to database column name + * + * @var array + */ + public readonly array $fieldIds + ) { + } + + #[\Override] + public function processObjectData(IFormDocument $document, array $data, IStorableObject $object) + { + if ($this->fieldIds === []) { + return $data; + } + + $indexName = $object::getDatabaseTableIndexName(); + $select = \implode(', ', \array_values($this->fieldIds)); + + $sql = "SELECT languageID, {$select} + FROM {$this->contentTableName} + WHERE {$indexName} = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$object->{$indexName}]); + + $contents = []; + while ($row = $statement->fetchArray()) { + $languageCode = $row['languageID'] ? LanguageFactory::getInstance()->getLanguage($row['languageID'])->languageCode : ""; + + $content = []; + foreach ($this->fieldIds as $fieldId => $columnName) { + $content[$fieldId] = $row[$columnName]; + } + + $contents[$languageCode] = $content; + } + + if (\count($contents) > 1) { + $data['isMultilingual'] = 1; + + foreach ($contents as $languageCode => $content) { + foreach (\array_keys($this->fieldIds) as $fieldId) { + $data["{$fieldId}_{$languageCode}"] = $content[$fieldId]; + } + } + } else { + $data['isMultilingual'] = 0; + + if ($contents !== []) { + $content = \reset($contents); + foreach (\array_keys($this->fieldIds) as $fieldId) { + $data[$fieldId] = $content[$fieldId]; + } + } + } + + return $data; + } + + #[\Override] + public function processFormData(IFormDocument $document, array $parameters) + { + $languages = LanguageFactory::getInstance()->getLanguages(); + $parameters[self::ARRAY_INDEX] = []; + + $isMultilingual = $parameters['data']['isMultilingual'] ?? false; + $isMultilingual = $isMultilingual && \count($languages) > 1; + + $parameters['data']['isMultilingual'] = $isMultilingual ? 1 : 0; + + if ($isMultilingual) { + $parameters = $this->removeMonolingualValues($parameters, $languages); + $parameters = $this->processMultilingualValues($parameters, $languages); + } else { + $parameters = $this->removeMultilingualValues($parameters, $languages); + $parameters = $this->processMonolingualValues($parameters); + } + + return $parameters; + } + + /** + * @param array $parameters + * @param Language[] $languages + * + * @return array + */ + private function removeMonolingualValues(array $parameters, array $languages): array + { + return \array_filter($parameters, function ($key) use ($languages) { + foreach ($this->fieldIds as $fieldId) { + if (!\str_starts_with($key, "{$fieldId}_")) { + continue; + } + + foreach ($languages as $language) { + if (\str_starts_with($key, "{$fieldId}_{$language->languageCode}")) { + return true; + } + } + + return false; + } + + return true; + }, \ARRAY_FILTER_USE_KEY); + } + + /** + * @param array $parameters + * @param Language[] $languages + * + * @return array + */ + private function processMultilingualValues(array $parameters, array $languages): array + { + foreach ($languages as $language) { + foreach ($this->fieldIds as $fieldId) { + $languageFieldId = "{$fieldId}_{$language->languageCode}"; + + if (isset($parameters["data"][$languageFieldId])) { + $parameters[self::ARRAY_INDEX][$language->languageID][$fieldId] = $parameters["data"][$languageFieldId]; + unset($parameters["data"][$languageFieldId]); + } + + foreach ($parameters as $key => $value) { + if (\str_starts_with($key, "{$languageFieldId}_")) { + $index = \substr($key, \strlen($languageFieldId) + 1); + + $parameters[self::ARRAY_INDEX][$language->languageID][$index] = $value; + unset($parameters[$key]); + } + } + } + } + + return $parameters; + } + + /** + * @param array $parameters + * @param Language[] $languages + * + * @return array + */ + private function removeMultilingualValues(array $parameters, array $languages): array + { + return \array_filter($parameters, function ($key) use ($languages) { + foreach ($this->fieldIds as $fieldId) { + if (!\str_starts_with($key, "{$fieldId}_")) { + continue; + } + + foreach ($languages as $language) { + if (\str_starts_with($key, "{$fieldId}_{$language->languageCode}")) { + return false; + } + } + } + + return true; + }, \ARRAY_FILTER_USE_KEY); + } + + /** + * @param array $parameters + * + * @return array + */ + private function processMonolingualValues(array $parameters): array + { + $defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID(); + + $parameters[self::ARRAY_INDEX][$defaultLanguageID] = []; + foreach ($this->fieldIds as $fieldId) { + if (isset($parameters["data"][$fieldId])) { + $parameters[self::ARRAY_INDEX][$defaultLanguageID][$fieldId] = $parameters["data"][$fieldId]; + unset($parameters["data"][$fieldId]); + } + + foreach ($parameters as $key => $value) { + if (\str_starts_with($key, "{$fieldId}_")) { + $index = \substr($key, \strlen($fieldId) + 1); + + $parameters[self::ARRAY_INDEX][$defaultLanguageID][$index] = $value; + unset($parameters[$key]); + } + } + } + + return $parameters; + } +} diff --git a/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php index bcdd8254f25..0acfbd077a4 100644 --- a/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php +++ b/wcfsetup/install/files/lib/system/gridView/admin/CaptchaQuestionGridView.class.php @@ -4,20 +4,24 @@ use wcf\acp\form\CaptchaQuestionEditForm; use wcf\data\captcha\question\CaptchaQuestion; -use wcf\data\captcha\question\I18nCaptchaQuestionList; +use wcf\data\captcha\question\CaptchaQuestionList; +use wcf\data\DatabaseObject; use wcf\event\gridView\admin\CaptchaQuestionGridViewInitialized; use wcf\system\gridView\AbstractGridView; -use wcf\system\gridView\filter\I18nTextFilter; use wcf\system\gridView\filter\NumericFilter; +use wcf\system\gridView\filter\TextFilter; use wcf\system\gridView\GridViewColumn; use wcf\system\gridView\GridViewRowLink; +use wcf\system\gridView\renderer\DefaultColumnRenderer; use wcf\system\gridView\renderer\ObjectIdColumnRenderer; use wcf\system\interaction\admin\CaptchaQuestionInteractions; use wcf\system\interaction\bulk\admin\CaptchaQuestionBulkInteractions; use wcf\system\interaction\Divider; use wcf\system\interaction\EditInteraction; use wcf\system\interaction\ToggleInteraction; +use wcf\system\language\MultilingualHelper; use wcf\system\WCF; +use wcf\util\StringUtil; /** * Grid view for the list of user ranks. @@ -27,7 +31,7 @@ * @license GNU Lesser General Public License * @since 6.2 * - * @extends AbstractGridView + * @extends AbstractGridView */ final class CaptchaQuestionGridView extends AbstractGridView { @@ -41,8 +45,19 @@ public function __construct() GridViewColumn::for('question') ->label('wcf.acp.captcha.question.question') ->titleColumn() - ->filter(new I18nTextFilter()) - ->sortable(sortByDatabaseColumn: 'questionI18n'), + ->renderer( + new class extends DefaultColumnRenderer { + #[\Override] + public function render(mixed $value, DatabaseObject $row): string + { + \assert($row instanceof CaptchaQuestion); + + return StringUtil::encodeHTML($row->getQuestion()); + } + } + ) + ->filter(new TextFilter($this->subqueryQuestion())) + ->sortable(sortByDatabaseColumn: $this->subqueryQuestion()), GridViewColumn::for('views') ->label('wcf.acp.captcha.question.views') ->sortable() @@ -60,7 +75,7 @@ public function __construct() $provider = new CaptchaQuestionInteractions(); $provider->addInteractions([ new Divider(), - new EditInteraction(CaptchaQuestionEditForm::class) + new EditInteraction(CaptchaQuestionEditForm::class), ]); $this->setInteractionProvider($provider); $this->setBulkInteractionProvider(new CaptchaQuestionBulkInteractions()); @@ -80,9 +95,9 @@ public function isAccessible(): bool } #[\Override] - protected function createObjectList(): I18nCaptchaQuestionList + protected function createObjectList(): CaptchaQuestionList { - return new I18nCaptchaQuestionList(); + return new CaptchaQuestionList(); } #[\Override] @@ -90,4 +105,14 @@ protected function getInitializedEvent(): CaptchaQuestionGridViewInitialized { return new CaptchaQuestionGridViewInitialized($this); } + + private function subqueryQuestion(): string + { + return MultilingualHelper::subqueryForContentTable( + "question", + "wcf1_captcha_question_content", + "questionID", + "captcha_question", + ); + } } diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 6af8180a16e..a3a71333ffb 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -277,6 +277,7 @@ + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 5ee9959ff08..51e5a783ade 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -276,6 +276,7 @@ + diff --git a/wcfsetup/setup/db/install.sql b/wcfsetup/setup/db/install.sql index f79b2a3b987..c4079b22d13 100644 --- a/wcfsetup/setup/db/install.sql +++ b/wcfsetup/setup/db/install.sql @@ -361,14 +361,24 @@ CREATE TABLE wcf1_box_to_page ( DROP TABLE IF EXISTS wcf1_captcha_question; CREATE TABLE wcf1_captcha_question ( questionID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY, - question VARCHAR(255) NOT NULL, - answers MEDIUMTEXT, isDisabled TINYINT(1) NOT NULL DEFAULT 0, views INT(10) NOT NULL DEFAULT 0, correctSubmissions INT(10) NOT NULL DEFAULT 0, incorrectSubmissions INT(10) NOT NULL DEFAULT 0 ); +DROP TABLE IF EXISTS wcf1_captcha_question_content; +CREATE TABLE wcf1_captcha_question_content ( + contentID INT(10) NOT NULL AUTO_INCREMENT, + questionID INT NOT NULL, + languageID INT, + question VARCHAR(255) NOT NULL, + answers MEDIUMTEXT, + + PRIMARY KEY(contentID), + KEY id (questionID, languageID) +); + DROP TABLE IF EXISTS wcf1_category; CREATE TABLE wcf1_category ( categoryID INT(10) NOT NULL AUTO_INCREMENT PRIMARY KEY, @@ -2080,6 +2090,9 @@ ALTER TABLE wcf1_box_content ADD FOREIGN KEY (imageID) REFERENCES wcf1_media (me ALTER TABLE wcf1_box_to_page ADD FOREIGN KEY (boxID) REFERENCES wcf1_box (boxID) ON DELETE CASCADE; ALTER TABLE wcf1_box_to_page ADD FOREIGN KEY (pageID) REFERENCES wcf1_page (pageID) ON DELETE CASCADE; +ALTER TABLE wcf1_captcha_question_content ADD FOREIGN KEY (questionID) REFERENCES wcf1_captcha_question (questionID) ON DELETE CASCADE; +ALTER TABLE wcf1_captcha_question_content ADD FOREIGN KEY (languageID) REFERENCES wcf1_language (languageID) ON DELETE CASCADE; + ALTER TABLE wcf1_category ADD FOREIGN KEY (objectTypeID) REFERENCES wcf1_object_type (objectTypeID) ON DELETE CASCADE; ALTER TABLE wcf1_clipboard_action ADD FOREIGN KEY (packageID) REFERENCES wcf1_package (packageID) ON DELETE CASCADE;