diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index 4665105bef..3166c11493 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -29,6 +29,8 @@ SmallintDatabaseTableColumn::create('attachments') ->notNull() ->defaultValue(0), + NotNullVarchar255DatabaseTableColumn::create('slug') + ->defaultValue(''), ]), PartialDatabaseTable::create('wcf1_label_group') ->columns([ diff --git a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php index f0750f7e75..9590b3ac2c 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php @@ -6,6 +6,7 @@ use wcf\data\article\Article; use wcf\data\article\ArticleAction; use wcf\data\article\category\ArticleCategory; +use wcf\data\article\content\ArticleContentEditor; use wcf\data\category\CategoryNodeTree; use wcf\data\user\User; use wcf\form\AbstractFormBuilderForm; @@ -40,6 +41,7 @@ use wcf\system\WCF; use wcf\util\HeaderUtil; use wcf\util\HtmlString; +use wcf\util\StringUtil; /** * Shows the article add form. @@ -280,6 +282,11 @@ protected function createMonolingualForm(): void TitleFormField::create('title') ->required() ->maximumLength(255), + TextFormField::create('slug') + ->label('wcf.acp.article.slug') + ->description('wcf.acp.article.slug.description') + ->maximumLength(255) + ->addValidator($this->getSlugValidator(null)), MultilineTextFormField::create('teaser') ->label('wcf.acp.article.teaser'), TagFormField::create('tags') @@ -326,6 +333,11 @@ protected function createMultilingualForm(): void TitleFormField::create("title_{$lc}") ->required() ->maximumLength(255), + TextFormField::create("slug_{$lc}") + ->label('wcf.acp.article.slug') + ->description('wcf.acp.article.slug.description') + ->maximumLength(255) + ->addValidator($this->getSlugValidator($language->languageID)), MultilineTextFormField::create("teaser_{$lc}") ->label('wcf.acp.article.teaser'), TagFormField::create("tags_{$lc}") @@ -358,6 +370,36 @@ protected function createMultilingualForm(): void } } + /** + * Returns a validator ensuring that the article slug is properly formatted + * and unique within the given language. + */ + protected function getSlugValidator(?int $languageID): FormFieldValidator + { + return new FormFieldValidator('slug', function (IFormField $field) use ($languageID) { + $slug = \mb_strtolower(StringUtil::trim((string)$field->getSaveValue())); + if ($slug === '') { + return; + } + + if (\preg_match('~^[a-z0-9\-_]+$~', $slug) === 0) { + $field->addValidationError(new FormFieldValidationError( + 'invalid', + 'wcf.acp.article.slug.error.invalid' + )); + return; + } + + $excludedArticleID = $this->formObject !== null ? $this->formObject->articleID : null; + if (!ArticleContentEditor::isUniqueSlug($slug, $languageID, $excludedArticleID)) { + $field->addValidationError(new FormFieldValidationError( + 'notUnique', + 'wcf.acp.article.slug.error.notUnique' + )); + } + }); + } + #[\Override] public function finalizeForm(): void { @@ -403,6 +445,7 @@ function (IFormDocument $document, array $parameters) { $parameters['content'][$lid] = [ 'title' => $parameters['data']["title_{$lc}"] ?? '', + 'slug' => \mb_strtolower(StringUtil::trim($parameters['data']["slug_{$lc}"] ?? '')), 'tags' => $parameters["tags_{$lc}"] ?? [], 'teaser' => $parameters['data']["teaser_{$lc}"] ?? '', 'content' => $parameters['data']["content_{$lc}"] ?? '', @@ -418,6 +461,7 @@ function (IFormDocument $document, array $parameters) { unset( $parameters['data']["title_{$lc}"], + $parameters['data']["slug_{$lc}"], $parameters['data']["teaser_{$lc}"], $parameters['data']["content_{$lc}"], $parameters['data']["imageID_{$lc}"], @@ -432,6 +476,7 @@ function (IFormDocument $document, array $parameters) { } else { $parameters['content'][0] = [ 'title' => $parameters['data']['title'] ?? '', + 'slug' => \mb_strtolower(StringUtil::trim($parameters['data']['slug'] ?? '')), 'tags' => $parameters['tags'] ?? [], 'teaser' => $parameters['data']['teaser'] ?? '', 'content' => $parameters['data']['content'] ?? '', @@ -448,6 +493,7 @@ function (IFormDocument $document, array $parameters) { unset( $parameters['data']['title'], + $parameters['data']['slug'], $parameters['data']['teaser'], $parameters['data']['content'], $parameters['data']['imageID'], diff --git a/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php index 3d1b4d29e3..80c4237274 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleEditForm.class.php @@ -118,6 +118,7 @@ function (IFormDocument $document, array $data, IStorableObject $object) { $lc = $language->languageCode; $data["title_{$lc}"] = $content->title; + $data["slug_{$lc}"] = $content->slug; $data["teaser_{$lc}"] = $content->teaser; $data["content_{$lc}"] = $content->content; $data["imageID_{$lc}"] = $content->imageID; @@ -134,6 +135,7 @@ function (IFormDocument $document, array $data, IStorableObject $object) { } } else { $data['title'] = $content->title; + $data['slug'] = $content->slug; $data['teaser'] = $content->teaser; $data['content'] = $content->content; $data['imageID'] = $content->imageID; diff --git a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php index e445b8a712..d53c7cec4c 100644 --- a/wcfsetup/install/files/lib/command/article/EnableI18n.class.php +++ b/wcfsetup/install/files/lib/command/article/EnableI18n.class.php @@ -34,6 +34,7 @@ public function __invoke(): void foreach (LanguageFactory::getInstance()->getLanguages() as $language) { $data[$language->languageID] = [ 'title' => $articleContent->title, + 'slug' => $articleContent->slug, 'teaser' => $articleContent->teaser, 'content' => $articleContent->content, 'imageID' => $articleContent->imageID ?: null, diff --git a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php index e4c2aa7a5f..03887c6238 100644 --- a/wcfsetup/install/files/lib/data/article/ArticleAction.class.php +++ b/wcfsetup/install/files/lib/data/article/ArticleAction.class.php @@ -106,6 +106,7 @@ public function create() 'articleID' => $article->articleID, 'languageID' => $languageID ?: null, 'title' => $content['title'], + 'slug' => $content['slug'] ?? '', 'teaser' => $content['teaser'], 'content' => $content['content'], 'imageID' => $content['imageID'], @@ -204,6 +205,7 @@ public function update() if ($articleContent !== null) { $updateData = [ 'title' => $content['title'], + 'slug' => $isRevert ? $articleContent->slug : ($content['slug'] ?? ''), 'teaser' => $content['teaser'], 'content' => $content['content'], 'imageID' => ($isRevert) ? $articleContent->imageID : $content['imageID'], @@ -237,6 +239,7 @@ public function update() 'articleID' => $article->articleID, 'languageID' => $languageID ?: null, 'title' => $content['title'], + 'slug' => $isRevert ? '' : ($content['slug'] ?? ''), 'teaser' => $content['teaser'], 'content' => $content['content'], 'imageID' => ($isRevert) ? null : $content['imageID'], diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php index 1a2e718507..504a31e813 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php @@ -28,6 +28,7 @@ * @property-read int $articleID id of the article the article content belongs to * @property-read ?int $languageID id of the article content's language * @property-read string $title title of the article in the associated language + * @property-read string $slug custom URL slug of the article in the associated language or empty to derive it from the title * @property-read ?string $content actual content of the article in the associated language * @property-read ?string $teaser teaser of the article in the associated language or empty if no teaser exists * @property-read ?int $imageID id of the (image) media object used as article image for the associated language or `null` if no image is used @@ -50,6 +51,13 @@ class ArticleContent extends CollectionDatabaseObject implements ILinkableObject #[\Override] public function getLink(): string { + if ($this->slug !== '') { + return LinkHandler::getInstance()->getControllerLink(ArticlePage::class, [ + 'id' => $this->articleContentID, + 'title' => $this->slug, + ]); + } + return LinkHandler::getInstance()->getControllerLink(ArticlePage::class, [ 'object' => $this, ]); diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php index 70db28f93b..e3e5cc22ad 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentEditor.class.php @@ -3,6 +3,8 @@ namespace wcf\data\article\content; use wcf\data\DatabaseObjectEditor; +use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\WCF; /** * Provides functions to edit article content. @@ -20,4 +22,38 @@ class ArticleContentEditor extends DatabaseObjectEditor * @inheritDoc */ protected static $baseClass = ArticleContent::class; + + /** + * Returns whether the given slug is unique within the given language scope. + * The `$excludedArticleID` is excluded from the lookup to allow updates of + * an existing article. + * + * @since 6.3 + */ + public static function isUniqueSlug(string $slug, ?int $languageID, ?int $excludedArticleID = null): bool + { + if ($slug === '') { + return true; + } + + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('slug = ?', [$slug]); + + if ($languageID === null) { + $conditionBuilder->add('languageID IS NULL'); + } else { + $conditionBuilder->add('(languageID = ? OR languageID IS NULL)', [$languageID]); + } + if ($excludedArticleID !== null) { + $conditionBuilder->add('articleID <> ?', [$excludedArticleID]); + } + + $sql = "SELECT COUNT(*) + FROM wcf1_article_content + " . $conditionBuilder; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + + return $statement->fetchSingleColumn() === 0; + } } diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 03b69b201d..7d40f1a7b6 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -109,6 +109,10 @@ + + a-z, 0-9, -, _.]]> + + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index 7ff1d70d6a..3cfd262900 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -109,6 +109,10 @@ + + a-z, 0-9, -, _.]]> + + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 3f8939f1e5..f1ad25700a 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -464,6 +464,8 @@ NotNullInt10DatabaseTableColumn::create('articleID'), IntDatabaseTableColumn::create('languageID'), NotNullVarchar255DatabaseTableColumn::create('title'), + NotNullVarchar255DatabaseTableColumn::create('slug') + ->defaultValue(''), TextDatabaseTableColumn::create('teaser'), MediumtextDatabaseTableColumn::create('content'), IntDatabaseTableColumn::create('imageID'),