From ed788cdfc41628f1dbeacde797b32b16a72e92d8 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 16 Oct 2026 21:39:52 +0200 Subject: [PATCH 01/50] Add MediaBuilder, MediaCollection, commands and l10n storage for media --- com.woltlab.wcf/package.xml | 1 + phpstan-baseline.neon | 36 --- .../update_com.woltlab.wcf_6.3_step1.php | 28 ++ .../update_com.woltlab.wcf_6.3_step3.php | 4 + .../update_com.woltlab.wcf_6.3_mediaL10n.php | 115 +++++++ .../files/lib/acp/form/BoxAddForm.class.php | 4 +- .../lib/acp/page/MediaListPage.class.php | 12 +- .../lib/command/media/CreateMedia.class.php | 32 ++ .../lib/command/media/DeleteMedia.class.php | 55 ++++ .../lib/command/media/UpdateMedia.class.php | 32 ++ .../data/TCollectionUserProfiles.class.php | 2 - .../files/lib/data/article/Article.class.php | 6 +- .../article/content/ArticleContent.class.php | 28 +- .../ArticleContentCollection.class.php | 20 +- .../install/files/lib/data/box/Box.class.php | 5 +- .../lib/data/box/content/BoxContent.class.php | 10 +- .../data/box/content/BoxContentList.class.php | 4 +- .../data/language/LanguageEditor.class.php | 17 +- .../files/lib/data/media/Media.class.php | 297 ++++++++++++++++-- .../lib/data/media/MediaAction.class.php | 246 +++++++-------- .../lib/data/media/MediaBuilder.class.php | 247 +++++++++++++++ .../lib/data/media/MediaCollection.class.php | 30 ++ .../lib/data/media/MediaEditor.class.php | 1 + .../files/lib/data/media/MediaList.class.php | 17 +- .../lib/data/media/ViewableMedia.class.php | 190 +---------- .../data/media/ViewableMediaList.class.php | 27 +- .../lib/event/media/MediaCreated.class.php | 23 ++ .../lib/event/media/MediaDeleted.class.php | 24 ++ .../lib/event/media/MediaUpdated.class.php | 23 ++ .../files/lib/page/MediaPage.class.php | 9 +- .../bbcode/WoltLabSuiteMediaBBCode.class.php | 4 +- .../lib/system/box/IBoxController.class.php | 3 +- .../ViewableMediaRuntimeCache.class.php | 1 + .../SingleMediaSelectionFormField.class.php | 9 +- .../system/importer/MediaImporter.class.php | 171 +++++----- ...ediaMessageEmbeddedObjectHandler.class.php | 33 +- wcfsetup/setup/db/install_com.woltlab.wcf.php | 12 +- 37 files changed, 1207 insertions(+), 571 deletions(-) create mode 100644 wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_mediaL10n.php create mode 100644 wcfsetup/install/files/lib/command/media/CreateMedia.class.php create mode 100644 wcfsetup/install/files/lib/command/media/DeleteMedia.class.php create mode 100644 wcfsetup/install/files/lib/command/media/UpdateMedia.class.php create mode 100644 wcfsetup/install/files/lib/data/media/MediaBuilder.class.php create mode 100644 wcfsetup/install/files/lib/data/media/MediaCollection.class.php create mode 100644 wcfsetup/install/files/lib/event/media/MediaCreated.class.php create mode 100644 wcfsetup/install/files/lib/event/media/MediaDeleted.class.php create mode 100644 wcfsetup/install/files/lib/event/media/MediaUpdated.class.php diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml index 302aabd286a..98b2d4014e8 100644 --- a/com.woltlab.wcf/package.xml +++ b/com.woltlab.wcf/package.xml @@ -93,6 +93,7 @@ acp/update_com.woltlab.wcf_6.3_userOptionCategoryL10n.php acp/update_com.woltlab.wcf_6.3_captchaQuestionL10n.php acp/update_com.woltlab.wcf_6.3_userOptionConfiguration.php + acp/update_com.woltlab.wcf_6.3_mediaL10n.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: diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b77413ae7cc..28a9b3183b7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -312,12 +312,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/data/article/ArticleAction.class.php - - - message: '#^Instanceof between wcf\\data\\article\\Article and wcf\\data\\DatabaseObject will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: wcfsetup/install/files/lib/data/article/ArticleCollection.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -384,24 +378,12 @@ parameters: count: 2 path: wcfsetup/install/files/lib/data/comment/CommentAction.class.php - - - message: '#^Instanceof between wcf\\data\\comment\\Comment and wcf\\data\\DatabaseObject will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: wcfsetup/install/files/lib/data/comment/CommentCollection.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed count: 1 path: wcfsetup/install/files/lib/data/comment/response/CommentResponseAction.class.php - - - message: '#^Instanceof between wcf\\data\\comment\\response\\CommentResponse and wcf\\data\\DatabaseObject will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - 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 @@ -648,18 +630,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/data/user/profile/menu/item/UserProfileMenuItemEditor.class.php - - - message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorCollection.class.php - - - - message: '#^Instanceof between wcf\\data\\user\\profile\\visitor\\UserProfileVisitor and wcf\\data\\DatabaseObject will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorCollection.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed @@ -1788,12 +1758,6 @@ parameters: count: 1 path: wcfsetup/install/files/lib/system/importer/LabelGroupImporter.class.php - - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' - identifier: empty.notAllowed - count: 7 - path: wcfsetup/install/files/lib/system/importer/MediaImporter.class.php - - message: '#^Construct empty\(\) is not allowed\. Use more strict comparison\.$#' identifier: empty.notAllowed 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 f9b04ed91ad..e3bb2adceee 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 @@ -115,6 +115,34 @@ ->onDelete('CASCADE') ->onUpdate('NO ACTION'), ]), + DatabaseTable::create('wcf1_media_l10n') + ->columns([ + NotNullInt10DatabaseTableColumn::create('mediaID'), + IntDatabaseTableColumn::create('languageID'), + VarcharDatabaseTableColumn::create('title') + ->length(255), + MediumtextDatabaseTableColumn::create('caption'), + VarcharDatabaseTableColumn::create('altText') + ->length(255), + ]) + ->indices([ + DatabaseTableIndex::create('mediaID') + ->columns(['mediaID', 'languageID']), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['mediaID']) + ->referencedTable('wcf1_media') + ->referencedColumns(['mediaID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + DatabaseTableForeignKey::create() + ->columns(['languageID']) + ->referencedTable('wcf1_language') + ->referencedColumns(['languageID']) + ->onDelete('CASCADE') + ->onUpdate('NO ACTION'), + ]), PartialDatabaseTable::create('wcf1_like_object') ->columns([ TextDatabaseTableColumn::create('cachedUsers') diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php index 305cede2e28..59fada45572 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step3.php @@ -12,6 +12,7 @@ use wcf\system\database\table\column\NotNullVarchar255DatabaseTableColumn; use wcf\system\database\table\column\TextDatabaseTableColumn; use wcf\system\database\table\column\VarcharDatabaseTableColumn; +use wcf\system\database\table\DatabaseTable; use wcf\system\database\table\PartialDatabaseTable; return [ @@ -81,6 +82,9 @@ MediumtextDatabaseTableColumn::create('answers') ->drop(), ]), + // The localized content is migrated to `wcf1_media_l10n`. + DatabaseTable::create('wcf1_media_content') + ->drop(), // The link pattern is migrated to the configuration of the user options. PartialDatabaseTable::create('wcf1_user_option') ->columns([ diff --git a/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_mediaL10n.php b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_mediaL10n.php new file mode 100644 index 00000000000..c8d6d31d4b8 --- /dev/null +++ b/wcfsetup/install/files/acp/update_com.woltlab.wcf_6.3_mediaL10n.php @@ -0,0 +1,115 @@ + + */ + +use wcf\system\language\LanguageFactory; +use wcf\system\WCF; + +// This script owns the table's content at this point (idempotency on re-runs). +WCF::getDB()->prepare("DELETE FROM wcf1_media_l10n")->execute(); + +$sql = "INSERT INTO wcf1_media_l10n + (mediaID, languageID, title, caption, altText) + SELECT media_content.mediaID, media_content.languageID, + media_content.title, media_content.caption, media_content.altText + FROM wcf1_media_content media_content + INNER JOIN wcf1_media media + ON media.mediaID = media_content.mediaID + WHERE media.isMultilingual = ? + AND media_content.languageID IS NOT NULL"; +$statement = WCF::getDB()->prepare($sql); +$statement->execute([1]); + +$defaultLanguageID = LanguageFactory::getInstance()->getDefaultLanguageID(); + +$sql = "SELECT media_content.*, media.languageID AS mediaLanguageID + FROM wcf1_media_content media_content + INNER JOIN wcf1_media media + ON media.mediaID = media_content.mediaID + WHERE media.isMultilingual = ? + AND media_content.mediaID > ? + ORDER BY media_content.mediaID"; +$selectStatement = WCF::getDB()->prepare($sql, 1_000); + +$sql = "INSERT INTO wcf1_media_l10n + (mediaID, languageID, title, caption, altText) + VALUES (?, NULL, ?, ?, ?)"; +$insertStatement = WCF::getDB()->prepare($sql); + +/** + * Picks the content row that was displayed for a monolingual media file: + * its own language, a row without a language, the default language and + * finally the lowest language id. + * + * @param list> $rows + * @return array + */ +$pickRow = static function (array $rows) use ($defaultLanguageID): array { + $mediaLanguageID = $rows[0]['mediaLanguageID']; + + $preferred = []; + foreach ($rows as $row) { + $preferred[] = match (true) { + $mediaLanguageID !== null && $row['languageID'] === $mediaLanguageID => -3, + $row['languageID'] === null => -2, + $row['languageID'] === $defaultLanguageID => -1, + default => $row['languageID'], + }; + } + + return $rows[\array_search(\min($preferred), $preferred, true)]; +}; + +$lastMediaID = 0; +do { + $selectStatement->execute([0, $lastMediaID]); + + $rowsByMedia = []; + while ($row = $selectStatement->fetchArray()) { + $rowsByMedia[$row['mediaID']][] = $row; + } + + if ($rowsByMedia === []) { + break; + } + + // The last media file may be incomplete due to the limit, it is read + // again in the next iteration unless it is the only one. + $mediaIDs = \array_keys($rowsByMedia); + if (\count($mediaIDs) > 1) { + unset($rowsByMedia[\end($mediaIDs)]); + } + + WCF::getDB()->beginTransaction(); + foreach ($rowsByMedia as $mediaID => $rows) { + $row = $pickRow($rows); + + $insertStatement->execute([ + $mediaID, + $row['title'], + $row['caption'], + $row['altText'], + ]); + + $lastMediaID = $mediaID; + } + WCF::getDB()->commitTransaction(); +} while (true); diff --git a/wcfsetup/install/files/lib/acp/form/BoxAddForm.class.php b/wcfsetup/install/files/lib/acp/form/BoxAddForm.class.php index 4c87343a619..2ba33a3e456 100644 --- a/wcfsetup/install/files/lib/acp/form/BoxAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/BoxAddForm.class.php @@ -8,7 +8,7 @@ use wcf\data\box\BoxAction; use wcf\data\box\BoxEditor; use wcf\data\media\Media; -use wcf\data\media\ViewableMediaList; +use wcf\data\media\MediaList; use wcf\data\object\type\ObjectType; use wcf\data\object\type\ObjectTypeCache; use wcf\data\page\Page; @@ -428,7 +428,7 @@ protected function readBoxImages() return; } - $mediaList = new ViewableMediaList(); + $mediaList = new MediaList(); $mediaList->setObjectIDs(\array_values($imageIDs)); $mediaList->readObjects(); diff --git a/wcfsetup/install/files/lib/acp/page/MediaListPage.class.php b/wcfsetup/install/files/lib/acp/page/MediaListPage.class.php index c9ea573628c..f19c483af0b 100644 --- a/wcfsetup/install/files/lib/acp/page/MediaListPage.class.php +++ b/wcfsetup/install/files/lib/acp/page/MediaListPage.class.php @@ -4,9 +4,11 @@ use wcf\data\category\CategoryNode; use wcf\data\category\CategoryNodeTree; -use wcf\data\media\ViewableMediaList; +use wcf\data\media\Media; +use wcf\data\media\MediaList; use wcf\page\SortablePage; use wcf\system\clipboard\ClipboardHandler; +use wcf\system\l10n\L10nStorage; use wcf\system\request\LinkHandler; use wcf\system\WCF; use wcf\util\StringUtil; @@ -18,7 +20,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends SortablePage + * @extends SortablePage */ class MediaListPage extends SortablePage { @@ -68,7 +70,7 @@ class MediaListPage extends SortablePage /** * @inheritDoc */ - public $objectListClassName = ViewableMediaList::class; + public $objectListClassName = MediaList::class; /** * name of the user who uploaded the searched media files @@ -108,6 +110,10 @@ protected function initObjectList() { parent::initObjectList(); + // required to sort by the localized title + $this->objectList->sqlSelects = (new L10nStorage(Media::getL10nDefinition())) + ->getSubSelect('title', 'media') . ' AS title'; + if (WCF::getSession()->hasPermission('admin.content.cms.canOnlyAccessOwnMedia')) { $this->objectList->getConditionBuilder()->add('media.userID = ?', [WCF::getUser()->userID]); } diff --git a/wcfsetup/install/files/lib/command/media/CreateMedia.class.php b/wcfsetup/install/files/lib/command/media/CreateMedia.class.php new file mode 100644 index 00000000000..55196c91489 --- /dev/null +++ b/wcfsetup/install/files/lib/command/media/CreateMedia.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class CreateMedia +{ + public function __construct( + private readonly MediaBuilder $builder, + ) {} + + public function __invoke(): Media + { + $media = $this->builder->create(); + + EventHandler::getInstance()->fire(new MediaCreated($media, $this->builder)); + + return $media; + } +} diff --git a/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php b/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php new file mode 100644 index 00000000000..c00c1288bb6 --- /dev/null +++ b/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php @@ -0,0 +1,55 @@ + + * @since 6.3 + */ +final class DeleteMedia +{ + /** + * @param Media[] $media + */ + public function __construct( + private readonly array $media, + ) {} + + public function __invoke(): void + { + if ($this->media === []) { + return; + } + + $mediaIDs = \array_values(\array_map(static fn(Media $media) => $media->mediaID, $this->media)); + + MediaBuilder::deleteAll($mediaIDs); + + // The files are deleted only after the media files, because deleting a file cannot be rolled back. + foreach ($this->media as $media) { + $this->deleteLegacyFiles($media); + } + + EventHandler::getInstance()->fire(new MediaDeleted($this->media)); + } + + private function deleteLegacyFiles(Media $media): void + { + @\unlink($media->getLocation()); + + if ($media->isImage !== 0) { + foreach (\array_keys(Media::getThumbnailSizes()) as $size) { + @\unlink($media->getThumbnailLocation($size)); + } + } + } +} diff --git a/wcfsetup/install/files/lib/command/media/UpdateMedia.class.php b/wcfsetup/install/files/lib/command/media/UpdateMedia.class.php new file mode 100644 index 00000000000..009aee72911 --- /dev/null +++ b/wcfsetup/install/files/lib/command/media/UpdateMedia.class.php @@ -0,0 +1,32 @@ + + * @since 6.3 + */ +final class UpdateMedia +{ + public function __construct( + private readonly MediaBuilder $builder, + ) {} + + public function __invoke(): Media + { + $media = $this->builder->update(); + + EventHandler::getInstance()->fire(new MediaUpdated($media, $this->builder)); + + return $media; + } +} diff --git a/wcfsetup/install/files/lib/data/TCollectionUserProfiles.class.php b/wcfsetup/install/files/lib/data/TCollectionUserProfiles.class.php index 41b7bfda864..8aa260afcbb 100644 --- a/wcfsetup/install/files/lib/data/TCollectionUserProfiles.class.php +++ b/wcfsetup/install/files/lib/data/TCollectionUserProfiles.class.php @@ -43,8 +43,6 @@ private function loadUserProfiles(string $userIdProperty): void $userIDs = []; foreach ($this->getObjects() as $object) { - \assert($object instanceof DatabaseObject); - if ($object->{$userIdProperty}) { $userIDs[] = $object->{$userIdProperty}; } diff --git a/wcfsetup/install/files/lib/data/article/Article.class.php b/wcfsetup/install/files/lib/data/article/Article.class.php index 86351328587..a02b6fd4fa2 100644 --- a/wcfsetup/install/files/lib/data/article/Article.class.php +++ b/wcfsetup/install/files/lib/data/article/Article.class.php @@ -10,7 +10,7 @@ use wcf\data\IPopoverObject; use wcf\data\IUserContent; use wcf\data\label\Label; -use wcf\data\media\ViewableMedia; +use wcf\data\media\Media; use wcf\data\object\type\ObjectTypeCache; use wcf\data\user\UserProfile; use wcf\system\article\discussion\CommentArticleDiscussionProvider; @@ -398,7 +398,7 @@ public function getUserProfile(): UserProfile * * @since 6.3 */ - public function getImage(): ?ViewableMedia + public function getImage(): ?Media { if ($this->getArticleContent() !== null) { return $this->getArticleContent()->getImage(); @@ -412,7 +412,7 @@ public function getImage(): ?ViewableMedia * * @since 6.3 */ - public function getTeaserImage(): ?ViewableMedia + public function getTeaserImage(): ?Media { if ($this->getArticleContent() !== null) { return $this->getArticleContent()->getTeaserImage(); 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 eb3006b08f2..d249d7fbeff 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContent.class.php @@ -8,7 +8,7 @@ use wcf\data\IMessage; use wcf\data\ITitledLinkObject; use wcf\data\language\Language; -use wcf\data\media\ViewableMedia; +use wcf\data\media\Media; use wcf\page\ArticlePage; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\html\output\HtmlOutputProcessor; @@ -294,18 +294,13 @@ public function loadEmbeddedObjects(): void * * @since 6.3 */ - public function getImage(): ?ViewableMedia + public function getImage(): ?Media { if ($this->imageID === null) { return null; } - $image = $this->getCollection()->getImage($this->imageID); - if ($image === null || !$image->isAccessible()) { - return null; - } - - return $image; + return $this->getAccessibleImage($this->imageID); } /** @@ -313,17 +308,30 @@ public function getImage(): ?ViewableMedia * * @since 6.3 */ - public function getTeaserImage(): ?ViewableMedia + public function getTeaserImage(): ?Media { if ($this->teaserImageID === null) { return $this->getImage(); } - $image = $this->getCollection()->getImage($this->teaserImageID); + return $this->getAccessibleImage($this->teaserImageID); + } + + /** + * Returns the image with the given id localized for the language of + * this content if the active user can access it. + */ + private function getAccessibleImage(int $mediaID): ?Media + { + $image = $this->getCollection()->getImage($mediaID); if ($image === null || !$image->isAccessible()) { return null; } + if ($this->languageID !== null) { + return $image->getLocalizedVersion($this->languageID); + } + return $image; } diff --git a/wcfsetup/install/files/lib/data/article/content/ArticleContentCollection.class.php b/wcfsetup/install/files/lib/data/article/content/ArticleContentCollection.class.php index eb8f125348a..3de85cdbd4d 100644 --- a/wcfsetup/install/files/lib/data/article/content/ArticleContentCollection.class.php +++ b/wcfsetup/install/files/lib/data/article/content/ArticleContentCollection.class.php @@ -4,8 +4,8 @@ use wcf\data\article\Article; use wcf\data\DatabaseObjectCollection; -use wcf\data\media\ViewableMedia; -use wcf\data\media\ViewableMediaList; +use wcf\data\media\Media; +use wcf\data\media\MediaList; use wcf\data\TCollectionAttachments; use wcf\data\TCollectionEmbeddedObjects; use wcf\system\cache\runtime\ArticleRuntimeCache; @@ -26,13 +26,13 @@ class ArticleContentCollection extends DatabaseObjectCollection use TCollectionAttachments; /** - * @var array + * @var array */ private array $images; private bool $articlesCached = false; - public function getImage(int $imageID): ?ViewableMedia + public function getImage(int $imageID): ?Media { $this->loadImages(); @@ -51,22 +51,12 @@ private function loadImages(): void return; } - $mediaList = new ViewableMediaList($this->getContentLanguageID()); + $mediaList = new MediaList(); $mediaList->setObjectIDs($imageIDs); $mediaList->readObjects(); $this->images = $mediaList->getObjects(); } - protected function getContentLanguageID(): ?int - { - $objects = $this->getObjects(); - if (\count($objects) === 1) { - return \reset($objects)->languageID; - } - - return null; - } - /** * @return list */ diff --git a/wcfsetup/install/files/lib/data/box/Box.class.php b/wcfsetup/install/files/lib/data/box/Box.class.php index 6532a26d746..263a7a9a05d 100644 --- a/wcfsetup/install/files/lib/data/box/Box.class.php +++ b/wcfsetup/install/files/lib/data/box/Box.class.php @@ -5,6 +5,7 @@ use wcf\data\box\content\BoxContent; use wcf\data\condition\Condition; use wcf\data\DatabaseObject; +use wcf\data\media\Media; use wcf\data\media\ViewableMedia; use wcf\data\menu\Menu; use wcf\data\menu\MenuCache; @@ -61,7 +62,7 @@ class Box extends DatabaseObject /** * image media object - * @var ?ViewableMedia + * @var Media|ViewableMedia|null */ protected $image; @@ -392,7 +393,7 @@ public function getMenu() /** * Returns the image of this box or `null` if the box has no image. * - * @return ViewableMedia|null + * @return Media|ViewableMedia|null */ public function getImage() { diff --git a/wcfsetup/install/files/lib/data/box/content/BoxContent.class.php b/wcfsetup/install/files/lib/data/box/content/BoxContent.class.php index e6daf79509a..da3905cb221 100644 --- a/wcfsetup/install/files/lib/data/box/content/BoxContent.class.php +++ b/wcfsetup/install/files/lib/data/box/content/BoxContent.class.php @@ -3,7 +3,9 @@ namespace wcf\data\box\content; use wcf\data\DatabaseObject; +use wcf\data\media\Media; use wcf\data\media\ViewableMedia; +use wcf\system\cache\runtime\MediaRuntimeCache; use wcf\system\html\output\HtmlOutputProcessor; use wcf\system\html\simple\HtmlSimpleParser; use wcf\system\WCF; @@ -37,7 +39,7 @@ class BoxContent extends DatabaseObject /** * image media object - * @var ?ViewableMedia + * @var Media|ViewableMedia|null */ protected $image; @@ -74,13 +76,13 @@ public static function getBoxContent(int $boxID, ?int $languageID) /** * Returns the image of this box content. * - * @return ViewableMedia|null + * @return Media|ViewableMedia|null */ public function getImage() { if ($this->image === null) { if ($this->imageID !== null) { - $this->image = ViewableMedia::getMedia($this->imageID); + $this->image = MediaRuntimeCache::getInstance()->getObject($this->imageID); } } @@ -92,7 +94,7 @@ public function getImage() * * @return void */ - public function setImage(ViewableMedia $image) + public function setImage(Media|ViewableMedia $image) { $this->image = $image; } diff --git a/wcfsetup/install/files/lib/data/box/content/BoxContentList.class.php b/wcfsetup/install/files/lib/data/box/content/BoxContentList.class.php index 4eaada92250..bf9fefe5edc 100644 --- a/wcfsetup/install/files/lib/data/box/content/BoxContentList.class.php +++ b/wcfsetup/install/files/lib/data/box/content/BoxContentList.class.php @@ -3,7 +3,7 @@ namespace wcf\data\box\content; use wcf\data\DatabaseObjectList; -use wcf\data\media\ViewableMediaList; +use wcf\data\media\MediaList; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; /** @@ -67,7 +67,7 @@ public static function loadImages(array $boxContents): void return; } - $mediaList = new ViewableMediaList(); + $mediaList = new MediaList(); $mediaList->setObjectIDs($imageIDs); $mediaList->readObjects(); $images = $mediaList->getObjects(); diff --git a/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php b/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php index dda13e20772..e81271b4d01 100644 --- a/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php +++ b/wcfsetup/install/files/lib/data/language/LanguageEditor.class.php @@ -1076,13 +1076,18 @@ public static function copyLanguageContent(int $sourceLanguageID, int $destinati } // media content - $sql = "INSERT IGNORE INTO wcf1_media_content - (mediaID, languageID, title, caption, altText) - SELECT mediaID, ?, title, caption, altText - FROM wcf1_media_content - WHERE languageID = ?"; + $sql = "INSERT INTO wcf1_media_l10n + (mediaID, languageID, title, caption, altText) + SELECT mediaID, ?, title, caption, altText + FROM wcf1_media_l10n + WHERE languageID = ? + AND mediaID NOT IN ( + SELECT mediaID + FROM wcf1_media_l10n + WHERE languageID = ? + )"; $statement = WCF::getDB()->prepare($sql); - $statement->execute([$destinationLanguageID, $sourceLanguageID]); + $statement->execute([$destinationLanguageID, $sourceLanguageID, $destinationLanguageID]); // page content $sql = "INSERT IGNORE INTO wcf1_page_content diff --git a/wcfsetup/install/files/lib/data/media/Media.class.php b/wcfsetup/install/files/lib/data/media/Media.class.php index 46523fa8e9e..43de636fd8c 100644 --- a/wcfsetup/install/files/lib/data/media/Media.class.php +++ b/wcfsetup/install/files/lib/data/media/Media.class.php @@ -2,23 +2,31 @@ namespace wcf\data\media; -use wcf\data\DatabaseObject; +use wcf\data\CollectionDatabaseObject; use wcf\data\ILinkableObject; use wcf\data\IThumbnailFile; +use wcf\data\user\UserProfile; use wcf\page\MediaPage; use wcf\system\acl\simple\SimpleAclResolver; use wcf\system\file\processor\IImageDataProvider; use wcf\system\file\processor\ImageData; +use wcf\system\l10n\L10nDefinition; +use wcf\system\l10n\L10nStorage; use wcf\system\request\IRouteController; use wcf\system\request\LinkHandler; +use wcf\system\style\FontAwesomeIcon; use wcf\system\WCF; use wcf\util\FileUtil; +use wcf\util\StringUtil; /** * Represents a media file. * - * @author Matthias Schmidt - * @copyright 2001-2019 WoltLab GmbH + * The localized title, caption and alternative text are stored in the + * `wcf1_media_l10n` table. + * + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * * @property-read int $mediaID unique id of the media file @@ -55,14 +63,31 @@ * @property-read int $largeThumbnailHeight height of the large thumbnail file for the media file if `$isImage` is `1`, otherwise `0` * @property-read int $downloads number of times the media file has been downloaded * @property-read int $lastDownloadTime timestamp at which the media file has been downloaded the last time + * @property-read string $title localized title of the media file + * @property-read string $caption localized caption of the media file + * @property-read string $altText localized alternative text of the media file + * + * @extends CollectionDatabaseObject + * + * @phpstan-import-type L10nValue from L10nStorage */ -class Media extends DatabaseObject implements ILinkableObject, IRouteController, IThumbnailFile, IImageDataProvider +class Media extends CollectionDatabaseObject implements + ILinkableObject, + IRouteController, + IThumbnailFile, + IImageDataProvider, + \Stringable { /** - * i18n media data grouped by language id for all language - * @var string[][]|null + * names of the localized columns that are exposed as properties + */ + private const L10N_COLUMNS = ['title', 'caption', 'altText']; + + /** + * language id used to resolve the localized values instead of the active + * user's language, see `getLocalizedVersion()` */ - protected $i18nData; + private ?int $forcedLanguageID = null; /** * data of the different thumbnail sizes @@ -178,12 +203,92 @@ public function getThumbnailLocation(string $size) ) . '/' . $this->mediaID . '-' . $size . '-' . $this->fileHash; } + /** + * Returns the localized title of the media file or its filename if it has + * no title. + */ #[\Override] public function getTitle(): string { + $title = $this->getL10nValue('title'); + if ($title !== '') { + return $title; + } + return $this->filename; } + /** + * @since 6.3 + */ + public function getCaption(): string + { + return $this->getL10nValue('caption'); + } + + /** + * @since 6.3 + */ + public function getAltText(): string + { + return $this->getL10nValue('altText'); + } + + /** + * Returns the localized values of the given column as a + * `languageID => value` map (see `L10nStorage`). + * + * @return L10nValue + * @since 6.3 + */ + public function getL10nValues(string $columnName): array + { + if (!\in_array($columnName, self::L10N_COLUMNS, true)) { + throw new \InvalidArgumentException("Invalid column name given."); + } + + return $this->getCollection()->getL10nValues($this, $columnName); + } + + /** + * Returns a copy of this media file that resolves its localized values + * for the given language instead of the active user's language. + * + * @since 6.3 + */ + public function getLocalizedVersion(int $languageID): static + { + $media = clone $this; + $media->forcedLanguageID = $languageID; + + return $media; + } + + private function getL10nValue(string $columnName): string + { + return L10nStorage::resolveValue($this->getL10nValues($columnName), $this->forcedLanguageID); + } + + #[\Override] + public function __get(string $name) + { + if (\in_array($name, self::L10N_COLUMNS, true)) { + return $this->getL10nValue($name); + } + + return parent::__get($name); + } + + #[\Override] + public function __isset(string $name) + { + if (\in_array($name, self::L10N_COLUMNS, true)) { + return true; + } + + return parent::__isset($name); + } + /** * Returns the icon name for this media file based on its filename. * @@ -202,30 +307,16 @@ public function getIconName(): string * Returns the i18n media data grouped by language id for all language. * * @return string[][] + * @deprecated 6.3 Use `getL10nValues()` instead. */ public function getI18nData() { - if ($this->i18nData === null) { - $this->i18nData = [ - 'altText' => [], - 'caption' => [], - 'title' => [], - ]; - - $sql = "SELECT * - FROM wcf1_media_content - WHERE mediaID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([$this->mediaID]); - - while ($row = $statement->fetchArray()) { - $this->i18nData['altText'][$row['languageID']] = $row['altText']; - $this->i18nData['caption'][$row['languageID']] = $row['caption']; - $this->i18nData['title'][$row['languageID']] = $row['title']; - } + $i18nData = []; + foreach (self::L10N_COLUMNS as $columnName) { + $i18nData[$columnName] = $this->getL10nValues($columnName); } - return $this->i18nData; + return $i18nData; } /** @@ -299,6 +390,146 @@ public function isAudio(): bool return \str_starts_with($this->fileType, 'audio/'); } + /** + * Returns the user profile of the user who uploaded the media file. + * + * @since 6.3 + */ + public function getUserProfile(): UserProfile + { + return $this->getCollection()->getUserProfile($this); + } + + /** + * Returns a textual representation of the media file to be used in templates. + * + * @since 6.3 + */ + #[\Override] + public function __toString(): string + { + if ($this->isImage !== 0) { + return \sprintf( + '%s', + StringUtil::encodeHTML($this->getLink()), + StringUtil::encodeHTML($this->getAltText()), + $this->getTitleAttribute(), + ); + } + + return \sprintf( + '%s', + StringUtil::encodeHTML($this->getLink()), + StringUtil::encodeHTML($this->getTitle()), + ); + } + + /** + * Returns a tag to display the media element in a square box of the given + * size. + * + * @since 6.3 + */ + public function getElementTag(int $size): string + { + if ($this->isImage === 0) { + return FontAwesomeIcon::fromValues($this->getIconName(), false)->toHtml($size); + } + + $width = $size; + $height = $size; + $link = null; + $marginTop = 0; + + if ($this->tinyThumbnailType !== '') { + $link = $this->getThumbnailLink('tiny'); + + if ($size <= $this->tinyThumbnailWidth && $size <= $this->tinyThumbnailHeight) { + if ($this->tinyThumbnailHeight < $this->tinyThumbnailWidth) { + $height = \round($this->tinyThumbnailHeight / $this->tinyThumbnailWidth * $size); + } else { + $width = \round($this->tinyThumbnailWidth / $this->tinyThumbnailHeight * $size); + } + } elseif ($size <= $this->tinyThumbnailWidth) { + $height = \round($this->tinyThumbnailHeight / $this->tinyThumbnailWidth * $size); + } elseif ($size <= $this->tinyThumbnailHeight) { + $width = \round($this->tinyThumbnailWidth / $this->tinyThumbnailHeight * $size); + } else { + $link = null; + } + + $marginTop = \floor(($size - $height) / 2); + } + + if ($link === null) { + $link = $this->getLink(); + + if ($size <= $this->width && $size <= $this->height) { + if ($this->height < $this->width) { + $height = \round($this->height / $this->width * $size); + } else { + $width = \round($this->width / $this->height * $size); + } + } elseif ($size <= $this->width) { + $height = \round($this->height / $this->width * $size); + } elseif ($size <= $this->height) { + $width = \round($this->width / $this->height * $size); + } else { + $width = $this->width; + $height = $this->height; + } + + $marginTop = \floor(($size - $height) / 2); + } + + return \sprintf( + '' + . '%3$s' + . '', + $size, + StringUtil::encodeHTML($link), + StringUtil::encodeHTML($this->getAltText()), + $this->getTitleAttribute(), + $marginTop, + $height, + $width, + ); + } + + /** + * Returns a tag to display the thumbnail with the given size. + * + * @since 6.3 + */ + public function getThumbnailTag(string $size = 'tiny'): string + { + if (!isset(self::$thumbnailSizes[$size])) { + throw new \InvalidArgumentException("Unknown thumbnail size '" . $size . "'"); + } + + return \sprintf( + '%s', + StringUtil::encodeHTML($this->getThumbnailLink($size)), + StringUtil::encodeHTML($this->getAltText()), + $this->getThumbnailWidth($size), + $this->getThumbnailHeight($size), + $this->getTitleAttribute() + ); + } + + /** + * Returns the `title` attribute of the media file or an empty string if it has no title. + */ + private function getTitleAttribute(): string + { + $title = $this->getL10nValue('title'); + if ($title === '') { + return ''; + } + + return 'title="' . StringUtil::encodeHTML($title) . '" '; + } + /** * Returns the storage path of the media files. * @@ -348,4 +579,18 @@ public function getImageData(?int $minWidth = null, ?int $minHeight = null): ?Im return new ImageData($this->getLink(), $this->width, $this->height); } + + /** + * @since 6.3 + */ + public static function getL10nDefinition(): L10nDefinition + { + return new L10nDefinition( + 'wcf1_media', + 'wcf1_media_l10n', + 'mediaID', + ['title', 'caption', 'altText'], + maximumLengths: ['title' => 255, 'altText' => 255], + ); + } } diff --git a/wcfsetup/install/files/lib/data/media/MediaAction.class.php b/wcfsetup/install/files/lib/data/media/MediaAction.class.php index 76b0f83bf80..9f330143fdf 100644 --- a/wcfsetup/install/files/lib/data/media/MediaAction.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaAction.class.php @@ -2,6 +2,8 @@ namespace wcf\data\media; +use wcf\command\media\DeleteMedia; +use wcf\command\media\UpdateMedia; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\category\CategoryNodeTree; use wcf\data\ISearchAction; @@ -9,11 +11,11 @@ use wcf\system\acl\simple\SimpleAclHandler; use wcf\system\category\CategoryHandler; use wcf\system\clipboard\ClipboardHandler; -use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\IllegalLinkException; use wcf\system\exception\PermissionDeniedException; use wcf\system\exception\UserInputException; use wcf\system\language\I18nHandler; +use wcf\system\l10n\L10nStorage; use wcf\system\language\LanguageFactory; use wcf\system\request\LinkHandler; use wcf\system\upload\DefaultUploadFileSaveStrategy; @@ -29,6 +31,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `MediaBuilder` and commands instead. * * @extends AbstractDatabaseObjectAction */ @@ -89,7 +92,7 @@ public function upload() } // fetch media objects from database - $mediaList = new ViewableMediaList(); + $mediaList = new MediaList(); $mediaList->setObjectIDs($mediaIDs); $mediaList->readObjects(); @@ -138,15 +141,15 @@ public function generateThumbnails() * * @return mixed[] */ - protected function getMediaData(Media|ViewableMedia $media) + protected function getMediaData(Media $media) { return [ - 'altText' => $media instanceof ViewableMedia ? $media->altText : [], - 'caption' => $media instanceof ViewableMedia ? $media->caption : [], + 'altText' => $media->getAltText(), + 'caption' => $media->getCaption(), 'captionEnableHtml' => $media->captionEnableHtml, 'categoryID' => $media->categoryID, - 'elementTag' => $media instanceof ViewableMedia ? $media->getElementTag($this->parameters['elementTagSize'] ?? 144) : '', - 'elementTag48' => $media instanceof ViewableMedia ? $media->getElementTag(48) : '', + 'elementTag' => $media->getElementTag($this->parameters['elementTagSize'] ?? 144), + 'elementTag48' => $media->getElementTag(48), 'fileHash' => $media->fileHash, 'filename' => $media->filename, 'filesize' => $media->filesize, @@ -181,17 +184,17 @@ protected function getMediaData(Media|ViewableMedia $media) 'tinyThumbnailLink' => $media->tinyThumbnailType !== '' ? $media->getThumbnailLink('tiny') : '', 'tinyThumbnailType' => $media->tinyThumbnailType, 'tinyThumbnailWidth' => $media->tinyThumbnailWidth, - 'title' => $media instanceof ViewableMedia ? $media->title : [], + 'title' => $media->getL10nValues('title') !== [] ? $media->title : '', 'uploadTime' => $media->uploadTime, 'userID' => $media->userID, 'userLink' => $media->userID !== null ? LinkHandler::getInstance()->getLink('User', [ 'id' => $media->userID, 'title' => $media->username, ]) : '', - 'userLinkElement' => $media instanceof ViewableMedia ? WCF::getTPL()->fetchString( + 'userLinkElement' => WCF::getTPL()->fetchString( WCF::getTPL()->getCompiler()->compileString('userLink', '{user object=$userProfile}')['template'], ['userProfile' => $media->getUserProfile()] - ) : '', + ), 'username' => $media->username, 'width' => $media->width, ]; @@ -223,7 +226,7 @@ public function validateGetManagementDialog() */ public function getManagementDialog() { - $mediaList = new ViewableMediaList(); + $mediaList = new MediaList(); if (WCF::getSession()->hasPermission('admin.content.cms.canOnlyAccessOwnMedia')) { $mediaList->getConditionBuilder()->add('media.userID = ?', [WCF::getUser()->userID]); } @@ -260,37 +263,23 @@ protected function getI18nMediaData(MediaList $mediaList) return []; } - $conditionBuilder = new PreparedStatementConditionBuilder(); - $conditionBuilder->add('mediaID IN (?)', [$mediaList->getObjectIDs()]); - - $sql = "SELECT * - FROM wcf1_media_content - " . $conditionBuilder; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditionBuilder->getParameters()); - - $mediaData = []; - while ($row = $statement->fetchArray()) { - if (!isset($mediaData[$row['mediaID']])) { - $mediaData[$row['mediaID']] = [ - 'altText' => [], - 'caption' => [], - 'title' => [], - ]; - } - - $mediaData[$row['mediaID']]['altText'][\intval($row['languageID'])] = $row['altText']; - $mediaData[$row['mediaID']]['caption'][\intval($row['languageID'])] = $row['caption']; - $mediaData[$row['mediaID']]['title'][\intval($row['languageID'])] = $row['title']; - } - $i18nMediaData = []; foreach ($mediaList as $media) { - if (!isset($mediaData[$media->mediaID])) { - $mediaData[$media->mediaID] = []; + $mediaData = []; + foreach (['altText', 'caption', 'title'] as $columnName) { + $values = $media->getL10nValues($columnName); + + // The legacy editor expects the monolingual value for the language of the media file. + if (isset($values[L10nStorage::MONOLINGUAL])) { + $values = [ + ($media->languageID ?: WCF::getLanguage()->languageID) => $values[L10nStorage::MONOLINGUAL], + ]; + } + + $mediaData[$columnName] = $values; } - $i18nMediaData[$media->mediaID] = \array_merge($this->getMediaData($media), $mediaData[$media->mediaID]); + $i18nMediaData[$media->mediaID] = \array_merge($this->getMediaData($media), $mediaData); } return $i18nMediaData; @@ -319,7 +308,7 @@ public function validateGetEditorDialog() */ public function getEditorDialog() { - $mediaList = new ViewableMediaList(); + $mediaList = new MediaList(); $mediaList->setObjectIDs([$this->getSingleObject()->mediaID]); $mediaList->readObjects(); $media = $mediaList->search($this->getSingleObject()->mediaID); @@ -414,90 +403,94 @@ public function validateUpdate() #[\Override] public function update() { - if (isset($this->parameters['data']['categoryID']) && $this->parameters['data']['categoryID'] === 0) { - $this->parameters['data']['categoryID'] = null; - } - if ($this->objects === []) { $this->readObjects(); } - parent::update(); + $data = $this->parameters['data'] ?? []; - if (\count($this->objects) === 1 && (isset($this->parameters['title']) || isset($this->parameters['caption']) || isset($this->parameters['altText']))) { - $media = \reset($this->objects); + foreach ($this->getObjects() as $mediaEditor) { + $media = $mediaEditor->getDecoratedObject(); + $builder = MediaBuilder::forUpdate($media); - $isMultilingual = $media->isMultilingual; - if (isset($this->parameters['data']['isMultilingual'])) { - $isMultilingual = $this->parameters['data']['isMultilingual']; + if (\array_key_exists('categoryID', $data)) { + $builder->setCategoryID($data['categoryID'] ?: null); + } + if (isset($data['captionEnableHtml'])) { + $builder->setCaptionEnableHtml((bool)$data['captionEnableHtml']); } - $sql = "DELETE FROM wcf1_media_content - WHERE mediaID = ?"; - $statement = WCF::getDB()->prepare($sql); - $statement->execute([$media->mediaID]); + $isMultilingual = $data['isMultilingual'] ?? $media->isMultilingual; + $languageID = \array_key_exists('languageID', $data) ? $data['languageID'] : $media->languageID; + if (isset($data['isMultilingual'])) { + $builder->setCustomProperty('isMultilingual', $isMultilingual); + } + if (\array_key_exists('languageID', $data)) { + $builder->setCustomProperty('languageID', $languageID); + } - $sql = "INSERT INTO wcf1_media_content - (mediaID, languageID, title, caption, altText) - VALUES (?, ?, ?, ?, ?)"; - $statement = WCF::getDB()->prepare($sql); + if (\count($this->objects) === 1) { + foreach (['title', 'caption', 'altText'] as $columnName) { + if (!isset($this->parameters[$columnName])) { + continue; + } - if ($isMultilingual === 0) { - $languageID = $media->languageID; - if (isset($this->parameters['data']['languageID'])) { - $languageID = $this->parameters['data']['languageID']; + $values = $this->getL10nValuesFromParameter( + $this->parameters[$columnName], + $isMultilingual ? null : $languageID, + $columnName === 'caption' ? null : 255 + ); + + match ($columnName) { + 'title' => $builder->setTitle($values), + 'caption' => $builder->setCaption($values), + 'altText' => $builder->setAltText($values), + }; } - $statement->execute([ - $media->mediaID, - $languageID, - isset($this->parameters['title'][$languageID]) ? \mb_substr( - $this->parameters['title'][$languageID], - 0, - 255 - ) : '', - $this->parameters['caption'][$languageID] ?? '', - isset($this->parameters['altText'][$languageID]) ? \mb_substr( - $this->parameters['altText'][$languageID], - 0, - 255 - ) : '', - ]); - } else { - $languages = LanguageFactory::getInstance()->getLanguages(); - foreach ($languages as $language) { - $title = $caption = $altText = ''; - foreach (['title', 'caption', 'altText'] as $type) { - if (isset($this->parameters[$type])) { - if (\is_array($this->parameters[$type])) { - if (isset($this->parameters[$type][$language->languageID])) { - ${$type} = $this->parameters[$type][$language->languageID]; - } - } else { - ${$type} = $this->parameters[$type]; - } - } - } - $statement->execute([ - $media->mediaID, - $language->languageID, - \mb_substr($title, 0, 255), - $caption, - \mb_substr($altText, 0, 255), + if (!empty($this->parameters['aclValues'])) { + $builder->setAclValues([ + 'allowAll' => (bool)($this->parameters['aclValues']['allowAll'] ?? true), + 'user' => \array_map(\intval(...), $this->parameters['aclValues']['user'] ?? []), + 'group' => \array_map(\intval(...), $this->parameters['aclValues']['group'] ?? []), ]); } } - if (!empty($this->parameters['aclValues'])) { - SimpleAclHandler::getInstance()->setValues( - 'com.woltlab.wcf.media', - $media->mediaID, - $this->parameters['aclValues'] - ); - } + (new UpdateMedia($builder))(); } } + /** + * Converts the values submitted by the legacy editor into l10n values. + * + * @param string|array $parameter + * @return array + */ + private function getL10nValuesFromParameter( + string|array $parameter, + ?int $monolingualLanguageID, + ?int $maximumLength + ): array { + $shorten = static fn(string $value): string => $maximumLength === null + ? $value + : \mb_substr($value, 0, $maximumLength); + + if ($monolingualLanguageID !== null) { + $value = \is_array($parameter) ? ($parameter[$monolingualLanguageID] ?? '') : $parameter; + + return [L10nStorage::MONOLINGUAL => $shorten($value)]; + } + + $values = []; + foreach (LanguageFactory::getInstance()->getLanguages() as $language) { + $value = \is_array($parameter) ? ($parameter[$language->languageID] ?? '') : $parameter; + $values[$language->languageID] = $shorten($value); + } + + return $values; + } + #[\Override] public function validateGetSearchResultList() { @@ -545,7 +538,7 @@ public function getSearchResultList() $mediaList->sqlOrderBy = 'media.uploadTime DESC, media.mediaID DESC'; $mediaList->sqlLimit = static::ITEMS_PER_MANAGER_DIALOG_PAGE; $mediaList->sqlOffset = ($this->parameters['pageNo'] - 1) * static::ITEMS_PER_MANAGER_DIALOG_PAGE; - $mediaList->readObjectIDs(); + $mediaList->readObjects(); if ($mediaList->getObjectIDs() === []) { // check if page is requested that might have existed but does not exist anymore due to deleted @@ -563,16 +556,12 @@ public function getSearchResultList() ]; } - $viewableMediaList = new ViewableMediaList(); - $viewableMediaList->setObjectIDs($mediaList->getObjectIDs()); - $viewableMediaList->readObjects(); - return [ - 'media' => $this->getI18nMediaData($viewableMediaList), + 'media' => $this->getI18nMediaData($mediaList), 'pageCount' => \ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE), 'pageNo' => $this->parameters['pageNo'], 'template' => WCF::getTPL()->render('wcf', 'mediaListItems', [ - 'mediaList' => $viewableMediaList, + 'mediaList' => $mediaList, 'mode' => $this->parameters['mode'], ]), ]; @@ -607,15 +596,15 @@ public function delete() $this->readObjects(); } - foreach ($this->getObjects() as $mediaEditor) { - $mediaEditor->deleteFiles(); - } - - $count = parent::delete(); + $media = \array_map( + static fn(MediaEditor $mediaEditor) => $mediaEditor->getDecoratedObject(), + $this->getObjects() + ); + (new DeleteMedia($media))(); $this->unmarkItems(); - return $count; + return \count($media); } /** @@ -713,17 +702,16 @@ public function validateSetCategory() */ public function setCategory() { - $conditionBuilder = new PreparedStatementConditionBuilder(); - $conditionBuilder->add('mediaID IN (?)', [$this->objectIDs]); - - $sql = "UPDATE wcf1_media - SET categoryID = ? - " . $conditionBuilder; - $statement = WCF::getDB()->prepare($sql); - $statement->execute(\array_merge( - [$this->parameters['categoryID'] ?: null], - $conditionBuilder->getParameters() - )); + if ($this->objects === []) { + $this->readObjects(); + } + + foreach ($this->getObjects() as $mediaEditor) { + (new UpdateMedia( + MediaBuilder::forUpdate($mediaEditor->getDecoratedObject()) + ->setCategoryID($this->parameters['categoryID'] ?: null) + ))(); + } $this->unmarkItems(); } @@ -808,7 +796,7 @@ public function replaceFile() } // fetch media objects from database - $mediaList = new ViewableMediaList(); + $mediaList = new MediaList(); $mediaList->setObjectIDs($mediaIDs); $mediaList->readObjects(); diff --git a/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php b/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php new file mode 100644 index 00000000000..1e0b30dc9a5 --- /dev/null +++ b/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php @@ -0,0 +1,247 @@ + + * @since 6.3 + * + * @extends DatabaseObjectBuilder + * + * @phpstan-import-type L10nValue from L10nStorage + * @phpstan-type AclValues array{allowAll: bool, user: list, group: list} + */ +final class MediaBuilder extends DatabaseObjectBuilder +{ + private const ACL_OBJECT_TYPE = 'com.woltlab.wcf.media'; + + /** + * @var L10nValue + */ + private array $title; + + /** + * @var L10nValue + */ + private array $caption; + + /** + * @var L10nValue + */ + private array $altText; + + /** + * @var AclValues + */ + private array $aclValues; + + public function setCategoryID(?int $categoryID): static + { + $this->properties['categoryID'] = $categoryID; + + return $this; + } + + /** + * Sets the user who uploaded the media file. + */ + public function setUser(?int $userID, string $username): static + { + $this->properties['userID'] = $userID; + $this->properties['username'] = $username; + + return $this; + } + + public function setUploadTime(int $uploadTime): static + { + $this->properties['uploadTime'] = $uploadTime; + + return $this; + } + + /** + * Sets the time at which the physical file was replaced the last time. + */ + public function setFileUpdateTime(int $fileUpdateTime): static + { + $this->properties['fileUpdateTime'] = $fileUpdateTime; + + return $this; + } + + /** + * Sets the meta data of the physical file. + */ + public function setFileData( + string $filename, + int $filesize, + string $fileType, + bool $isImage, + int $width, + int $height + ): static { + $this->properties['filename'] = $filename; + $this->properties['filesize'] = $filesize; + $this->properties['fileType'] = $fileType; + $this->properties['isImage'] = (int)$isImage; + $this->properties['width'] = $width; + $this->properties['height'] = $height; + + return $this; + } + + /** + * Sets the hash of the physical file in the legacy storage. + * + * @deprecated 6.3 Only required for media files that have not been migrated to `wcf1_file` yet. + */ + public function setFileHash(string $fileHash): static + { + $this->properties['fileHash'] = $fileHash; + + return $this; + } + + /** + * @param L10nValue $title + */ + public function setTitle(array $title): static + { + $this->title = $title; + + return $this; + } + + /** + * @param L10nValue $caption + */ + public function setCaption(array $caption): static + { + $this->caption = $caption; + + return $this; + } + + /** + * @param L10nValue $altText + */ + public function setAltText(array $altText): static + { + $this->altText = $altText; + + return $this; + } + + public function setCaptionEnableHtml(bool $captionEnableHtml): static + { + $this->properties['captionEnableHtml'] = (int)$captionEnableHtml; + + return $this; + } + + /** + * Sets the users and groups that can access the media file, see `SimpleAclHandler`. + * + * @param AclValues $aclValues + */ + public function setAclValues(array $aclValues): static + { + $this->aclValues = $aclValues; + + return $this; + } + + /** + * Increases the number of downloads and updates the last download time. + */ + public function trackDownload(int $time): static + { + $this->incrementProperties['downloads'] = 1; + $this->properties['lastDownloadTime'] = $time; + + return $this; + } + + #[\Override] + protected function getRequiredProperties(): array + { + return ['filename', 'uploadTime', 'username']; + } + + #[\Override] + protected function afterCreate(DatabaseObject $object): void + { + $this->saveL10nValues($object); + $this->saveAclValues($object); + } + + #[\Override] + protected function afterUpdate(DatabaseObject $object): void + { + if (isset($this->title) || isset($this->caption) || isset($this->altText)) { + $this->saveL10nValues($object); + } + + $this->saveAclValues($object); + } + + #[\Override] + protected static function beforeDeleteAll(array $objectIDs): void + { + $objectTypeID = SimpleAclHandler::getInstance()->getObjectTypeID(self::ACL_OBJECT_TYPE); + + foreach (['wcf1_acl_simple_to_user', 'wcf1_acl_simple_to_group'] as $tableName) { + $conditionBuilder = new PreparedStatementConditionBuilder(); + $conditionBuilder->add('objectTypeID = ?', [$objectTypeID]); + $conditionBuilder->add('objectID IN (?)', [$objectIDs]); + + $sql = "DELETE FROM {$tableName} + {$conditionBuilder}"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute($conditionBuilder->getParameters()); + } + + SimpleAclResolver::getInstance()->resetCache(self::ACL_OBJECT_TYPE); + } + + private function saveL10nValues(Media $media): void + { + $values = []; + foreach (['title', 'caption', 'altText'] as $columnName) { + $value = $this->{$columnName} ?? null; + if ($value === null && $this->isUpdate()) { + $value = $this->getObject()->getL10nValues($columnName); + } + + $values[$columnName] = $value ?: [L10nStorage::MONOLINGUAL => '']; + } + + // `L10nStorage::setValues()` replaces all rows of the media file. + (new L10nStorage(Media::getL10nDefinition()))->setValues($media->mediaID, $values); + } + + private function saveAclValues(Media $media): void + { + if (!isset($this->aclValues)) { + return; + } + + SimpleAclHandler::getInstance()->setValues(self::ACL_OBJECT_TYPE, $media->mediaID, $this->aclValues); + } +} diff --git a/wcfsetup/install/files/lib/data/media/MediaCollection.class.php b/wcfsetup/install/files/lib/data/media/MediaCollection.class.php new file mode 100644 index 00000000000..aa5ebbc29b7 --- /dev/null +++ b/wcfsetup/install/files/lib/data/media/MediaCollection.class.php @@ -0,0 +1,30 @@ + + * @since 6.3 + * + * @extends DatabaseObjectCollection + */ +class MediaCollection extends DatabaseObjectCollection +{ + use TCollectionL10n; + use TCollectionUserProfiles; + + #[\Override] + protected function getL10nDefinition(): L10nDefinition + { + return Media::getL10nDefinition(); + } +} diff --git a/wcfsetup/install/files/lib/data/media/MediaEditor.class.php b/wcfsetup/install/files/lib/data/media/MediaEditor.class.php index 8876376a597..e8be86d9c73 100644 --- a/wcfsetup/install/files/lib/data/media/MediaEditor.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaEditor.class.php @@ -10,6 +10,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `MediaBuilder` instead. * * @mixin Media * @extends DatabaseObjectEditor diff --git a/wcfsetup/install/files/lib/data/media/MediaList.class.php b/wcfsetup/install/files/lib/data/media/MediaList.class.php index 8fadeb1caef..467e3c1ceff 100644 --- a/wcfsetup/install/files/lib/data/media/MediaList.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaList.class.php @@ -34,14 +34,17 @@ public function addSearchConditions(string $searchString) $searchString = '%' . \addcslashes($searchString, '_%') . '%'; - $this->sqlConditionJoins .= ' - LEFT JOIN wcf1_media_content media_content - ON media_content.mediaID = media.mediaID'; - $conditionBuilder = new PreparedStatementConditionBuilder(false, 'OR'); - $conditionBuilder->add('media_content.title LIKE ?', [$searchString]); - $conditionBuilder->add('media_content.caption LIKE ?', [$searchString]); - $conditionBuilder->add('media_content.altText LIKE ?', [$searchString]); + $conditionBuilder->add( + 'media.mediaID IN ( + SELECT mediaID + FROM wcf1_media_l10n + WHERE title LIKE ? + OR caption LIKE ? + OR altText LIKE ? + )', + [$searchString, $searchString, $searchString] + ); $conditionBuilder->add('media.filename LIKE ?', [$searchString]); $this->getConditionBuilder()->add('(' . $conditionBuilder->__toString() . ')', $conditionBuilder->getParameters()); } diff --git a/wcfsetup/install/files/lib/data/media/ViewableMedia.class.php b/wcfsetup/install/files/lib/data/media/ViewableMedia.class.php index 4a1b89be3ba..4f9d291df14 100644 --- a/wcfsetup/install/files/lib/data/media/ViewableMedia.class.php +++ b/wcfsetup/install/files/lib/data/media/ViewableMedia.class.php @@ -3,10 +3,6 @@ namespace wcf\data\media; use wcf\data\DatabaseObjectDecorator; -use wcf\data\user\UserProfile; -use wcf\system\cache\runtime\UserProfileRuntimeCache; -use wcf\system\style\FontAwesomeIcon; -use wcf\util\StringUtil; /** * Represents a viewable media file. @@ -14,47 +10,26 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `Media` instead. * * @mixin Media - * @property-read string|null $title title of the media file in the active user's language or `null` if object has not been fetched via `ViewableMediaList` - * @property-read string|null $caption caption of the media file in the active user's language or `null` if object has not been fetched via `ViewableMediaList` - * @property-read string|null $altText alternative text of the media file in the active user's language or `null` if object has not been fetched via `ViewableMediaList` * @extends DatabaseObjectDecorator */ class ViewableMedia extends DatabaseObjectDecorator implements \Stringable { - /** - * force localized content by language id - * @var ?int - */ - protected $forceLanguageID; - - /** - * localized content per language id - * @var string[][] - */ - protected $localizedContent = []; - - /** - * user profile of the user who uploaded the media file - * @var ?UserProfile - */ - protected $userProfile; - /** * @inheritDoc */ protected static $baseClass = Media::class; /** - * Registers localized content by language id. - * * @param string[] $content * @return void + * @deprecated 6.3 The localized content is loaded by `Media` itself. */ public function setLocalizedContent(int $languageID, array $content) { - $this->localizedContent[$languageID] = $content; + // does nothing } /** @@ -64,34 +39,7 @@ public function setLocalizedContent(int $languageID, array $content) */ public function getLocalizedVersion(int $languageID) { - if (isset($this->localizedContent[$languageID])) { - $localized = clone $this; - $localized->forceLanguageID($languageID); - - return $localized; - } - - return $this; - } - - /** - * Forces the localized values by language id. - * - * @return void - */ - protected function forceLanguageID(int $languageID) - { - $this->forceLanguageID = $languageID; - } - - #[\Override] - public function __get(string $name) - { - if ($this->forceLanguageID !== null && isset($this->localizedContent[$this->forceLanguageID][$name])) { - return $this->localizedContent[$this->forceLanguageID][$name]; - } - - return $this->object->__get($name); + return new self($this->getDecoratedObject()->getLocalizedVersion($languageID)); } /** @@ -99,141 +47,13 @@ public function __get(string $name) */ public function getTitle() { - if ($this->title !== null && $this->title !== '') { - return $this->title; - } - return $this->getDecoratedObject()->getTitle(); } - /** - * Returns the `title` attribute of the media file or an empty string if it has no title. - */ - private function getTitleAttribute(): string - { - if ($this->title === null || $this->title === '') { - return ''; - } - - return 'title="' . StringUtil::encodeHTML($this->title) . '" '; - } - - /** - * Returns a textual representation of the media file to be used in templates. - */ #[\Override] public function __toString(): string { - if ($this->isImage !== 0) { - return '' . StringUtil::encodeHTML($this->altText) . 'getTitleAttribute() . '/>'; - } - - return '' . StringUtil::encodeHTML($this->getTitle()) . ''; - } - - /** - * Returns a tag to display the media element. - * - * @return string - */ - public function getElementTag(int $size) - { - if ($this->isImage !== 0) { - $width = $size; - $height = $size; - $link = null; - $marginTop = 0; - - if ($this->tinyThumbnailType !== '') { - $link = $this->getThumbnailLink('tiny'); - - if ($size <= $this->tinyThumbnailWidth && $size <= $this->tinyThumbnailHeight) { - if ($this->tinyThumbnailHeight < $this->tinyThumbnailWidth) { - $height = \round($this->tinyThumbnailHeight / $this->tinyThumbnailWidth * $size); - } else { - $width = \round($this->tinyThumbnailWidth / $this->tinyThumbnailHeight * $size); - } - } elseif ($size <= $this->tinyThumbnailWidth) { - $height = \round($this->tinyThumbnailHeight / $this->tinyThumbnailWidth * $size); - } elseif ($size <= $this->tinyThumbnailHeight) { - $width = \round($this->tinyThumbnailWidth / $this->tinyThumbnailHeight * $size); - } else { - $link = null; - } - - $marginTop = \floor(($size - $height) / 2); - } - - if ($link === null) { - $link = $this->getLink(); - - // round smaller dimension to - if ($size <= $this->width && $size <= $this->height) { - if ($this->height < $this->width) { - $height = \round($this->height / $this->width * $size); - } else { - $width = \round($this->width / $this->height * $size); - } - } elseif ($size <= $this->width) { - $height = \round($this->height / $this->width * $size); - } elseif ($size <= $this->height) { - $width = \round($this->width / $this->height * $size); - } else { - $width = $this->width; - $height = $this->height; - } - - $marginTop = \floor(($size - $height) / 2); - } - - return ' - ' . StringUtil::encodeHTML($this->altText)
-                . 'getTitleAttribute() - . 'style="margin-top: ' . $marginTop . 'px;" height="' . $height . '" width="' . $width . '" loading="lazy"> - '; - } - - return FontAwesomeIcon::fromValues($this->getIconName(), false)->toHtml($size); - } - - /** - * Returns a tag to display a certain thumbnail. - * - * @return string - * @throws \InvalidArgumentException - */ - public function getThumbnailTag(string $size = 'tiny') - { - if (!isset(Media::getThumbnailSizes()[$size])) { - throw new \InvalidArgumentException("Unknown thumbnail size '" . $size . "'"); - } - - return \sprintf( - '%s', - StringUtil::encodeHTML($this->getThumbnailLink($size)), - StringUtil::encodeHTML($this->altText), - $this->getThumbnailWidth($size), - $this->getThumbnailHeight($size), - $this->getTitleAttribute() - ); - } - - /** - * Returns the user profile of the user who uploaded the media file. - * - * @return UserProfile - */ - public function getUserProfile() - { - if ($this->userProfile === null) { - if ($this->userID !== null) { - $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->userID); - } else { - $this->userProfile = UserProfile::getGuestUserProfile($this->username); - } - } - - return $this->userProfile; + return $this->getDecoratedObject()->__toString(); } /** diff --git a/wcfsetup/install/files/lib/data/media/ViewableMediaList.class.php b/wcfsetup/install/files/lib/data/media/ViewableMediaList.class.php index 3e9534d8145..16c8a50ea16 100644 --- a/wcfsetup/install/files/lib/data/media/ViewableMediaList.class.php +++ b/wcfsetup/install/files/lib/data/media/ViewableMediaList.class.php @@ -2,14 +2,13 @@ namespace wcf\data\media; -use wcf\system\WCF; - /** * Represents a list of viewable media files. * * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `MediaList` instead. * * @method ViewableMedia current() * @method ViewableMedia[] getObjects() @@ -24,19 +23,21 @@ class ViewableMediaList extends MediaList */ public $decoratorClassName = ViewableMedia::class; - public function __construct(?int $languageID = null) - { + public function __construct( + private readonly ?int $languageID = null + ) { parent::__construct(); + } - if ($languageID === null) { - $languageID = WCF::getLanguage()->languageID; - } + #[\Override] + public function readObjects() + { + parent::readObjects(); - // fetch content data - $this->sqlSelects .= "media_content.*, COALESCE(media.languageID, " . $languageID . ") AS localizedLanguageID"; - $this->sqlJoins .= " - LEFT JOIN wcf1_media_content media_content - ON media_content.mediaID = media.mediaID - AND media_content.languageID = COALESCE(media.languageID, " . $languageID . ")"; + if ($this->languageID !== null) { + foreach ($this->objects as $mediaID => $media) { + $this->objects[$mediaID] = $media->getLocalizedVersion($this->languageID); + } + } } } diff --git a/wcfsetup/install/files/lib/event/media/MediaCreated.class.php b/wcfsetup/install/files/lib/event/media/MediaCreated.class.php new file mode 100644 index 00000000000..4f4181756f1 --- /dev/null +++ b/wcfsetup/install/files/lib/event/media/MediaCreated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class MediaCreated implements IPsr14Event +{ + public function __construct( + public readonly Media $media, + public readonly MediaBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/media/MediaDeleted.class.php b/wcfsetup/install/files/lib/event/media/MediaDeleted.class.php new file mode 100644 index 00000000000..3d1e47fa537 --- /dev/null +++ b/wcfsetup/install/files/lib/event/media/MediaDeleted.class.php @@ -0,0 +1,24 @@ + + * @since 6.3 + */ +final class MediaDeleted implements IPsr14Event +{ + /** + * @param Media[] $media + */ + public function __construct( + public readonly array $media, + ) {} +} diff --git a/wcfsetup/install/files/lib/event/media/MediaUpdated.class.php b/wcfsetup/install/files/lib/event/media/MediaUpdated.class.php new file mode 100644 index 00000000000..f5719bd4b59 --- /dev/null +++ b/wcfsetup/install/files/lib/event/media/MediaUpdated.class.php @@ -0,0 +1,23 @@ + + * @since 6.3 + */ +final class MediaUpdated implements IPsr14Event +{ + public function __construct( + public readonly Media $media, + public readonly MediaBuilder $builder, + ) {} +} diff --git a/wcfsetup/install/files/lib/page/MediaPage.class.php b/wcfsetup/install/files/lib/page/MediaPage.class.php index 36e5c22186d..9a54b2a5f4c 100644 --- a/wcfsetup/install/files/lib/page/MediaPage.class.php +++ b/wcfsetup/install/files/lib/page/MediaPage.class.php @@ -4,7 +4,7 @@ use Laminas\Diactoros\Response\EmptyResponse; use wcf\data\media\Media; -use wcf\data\media\MediaEditor; +use wcf\data\media\MediaBuilder; use wcf\http\Helper; use wcf\system\exception\IllegalLinkException; use wcf\system\exception\PermissionDeniedException; @@ -143,10 +143,9 @@ public function show() if ($this->thumbnail === '') { // update download count - (new MediaEditor($this->media))->update([ - 'downloads' => $this->media->downloads + 1, - 'lastDownloadTime' => \TIME_NOW, - ]); + MediaBuilder::forUpdate($this->media) + ->trackDownload(\TIME_NOW) + ->update(); } // send file to client diff --git a/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php b/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php index 47448c2adb2..4aa8230d98c 100644 --- a/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php +++ b/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php @@ -2,7 +2,7 @@ namespace wcf\system\bbcode; -use wcf\data\media\ViewableMedia; +use wcf\data\media\Media; use wcf\system\message\embedded\object\MessageEmbeddedObjectManager; use wcf\system\view\ContentNotVisibleView; use wcf\system\WCF; @@ -42,7 +42,7 @@ public function getParsedTag(array $openingTag, string $content, array $closingT $removeLinks = true; } - /** @var ?ViewableMedia $media */ + /** @var ?Media $media */ $media = MessageEmbeddedObjectManager::getInstance()->getObject('com.woltlab.wcf.media', $mediaID); if ($media === null) { return ContentNotVisibleView::forNotAvailable(); diff --git a/wcfsetup/install/files/lib/system/box/IBoxController.class.php b/wcfsetup/install/files/lib/system/box/IBoxController.class.php index d0a3588a4ab..fa2f60647fd 100644 --- a/wcfsetup/install/files/lib/system/box/IBoxController.class.php +++ b/wcfsetup/install/files/lib/system/box/IBoxController.class.php @@ -3,6 +3,7 @@ namespace wcf\system\box; use wcf\data\box\Box; +use wcf\data\media\Media; use wcf\data\media\ViewableMedia; /** @@ -33,7 +34,7 @@ public function hasContent(); * * Note: The box itself checks if the active user can access the returned media file. * - * @return ?ViewableMedia + * @return Media|ViewableMedia|null */ public function getImage(); diff --git a/wcfsetup/install/files/lib/system/cache/runtime/ViewableMediaRuntimeCache.class.php b/wcfsetup/install/files/lib/system/cache/runtime/ViewableMediaRuntimeCache.class.php index 981dd22045f..d3b7e4645a9 100644 --- a/wcfsetup/install/files/lib/system/cache/runtime/ViewableMediaRuntimeCache.class.php +++ b/wcfsetup/install/files/lib/system/cache/runtime/ViewableMediaRuntimeCache.class.php @@ -11,6 +11,7 @@ * @author Alexander Ebert * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `MediaRuntimeCache` instead. * * @extends AbstractRuntimeCache */ diff --git a/wcfsetup/install/files/lib/system/form/builder/field/media/SingleMediaSelectionFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/media/SingleMediaSelectionFormField.class.php index 82de9e4a80b..712d374c4d5 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/media/SingleMediaSelectionFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/media/SingleMediaSelectionFormField.class.php @@ -2,7 +2,8 @@ namespace wcf\system\form\builder\field\media; -use wcf\data\media\ViewableMedia; +use wcf\data\media\Media; +use wcf\system\cache\runtime\MediaRuntimeCache; use wcf\system\form\builder\field\AbstractFormField; use wcf\system\form\builder\field\IImmutableFormField; use wcf\system\form\builder\field\TImmutableFormField; @@ -28,7 +29,7 @@ final class SingleMediaSelectionFormField extends AbstractFormField implements I /** * media object with the current value as id */ - protected ?ViewableMedia $media = null; + protected ?Media $media = null; /** * @inheritDoc @@ -46,7 +47,7 @@ final class SingleMediaSelectionFormField extends AbstractFormField implements I * @throws \InvalidArgumentException if no or an invalid media id is set as value * @throws \UnexpectedValueException if no or an invalid media id is set as value */ - public function getMedia(): ViewableMedia + public function getMedia(): Media { if ($this->media === null) { if (empty($this->getValue())) { @@ -55,7 +56,7 @@ public function getMedia(): ViewableMedia ); } - $this->media = ViewableMedia::getMedia($this->getValue()); + $this->media = MediaRuntimeCache::getInstance()->getObject($this->getValue()); if ($this->media === null) { throw new \UnexpectedValueException( "Cannot load media with id '{$this->getValue()}' for field '{$this->getId()}'." diff --git a/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php b/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php index 0b379aad17c..b1615daa159 100644 --- a/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php @@ -2,18 +2,18 @@ namespace wcf\system\importer; +use wcf\command\media\CreateMedia; use wcf\data\media\Media; -use wcf\data\media\MediaEditor; -use wcf\system\exception\SystemException; +use wcf\data\media\MediaBuilder; +use wcf\system\l10n\L10nStorage; use wcf\system\language\LanguageFactory; -use wcf\system\WCF; use wcf\util\FileUtil; /** * Imports cms media. * * @author Marcel Werk - * @copyright 2001-2021 WoltLab GmbH + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License */ class MediaImporter extends AbstractImporter @@ -34,74 +34,61 @@ public function import(mixed $oldID, array $data, array $additionalData = []) // Extract metadata from the file ourselves, because the // information pulled from the source database might not // be reliable. - $data['fileHash'] = \sha1_file($additionalData['fileLocation']); - $data['filesize'] = \filesize($additionalData['fileLocation']); - $data['fileType'] = FileUtil::getMimeType($additionalData['fileLocation']); - $imageData = @\getimagesize($additionalData['fileLocation']); - if ($imageData !== false) { - $data['isImage'] = 1; - $data['width'] = $imageData[0]; - $data['height'] = $imageData[1]; - } else { - $data['isImage'] = 0; - $data['width'] = 0; - $data['height'] = 0; - } - - $data['userID'] = ImportHandler::getInstance()->getNewID('com.woltlab.wcf.user', $data['userID']); - - $contents = []; - if (!empty($additionalData['contents'])) { - foreach ($additionalData['contents'] as $languageCode => $contentData) { - $languageID = 0; - if (!empty($languageCode)) { - if (($language = LanguageFactory::getInstance()->getLanguageByCode($languageCode)) !== null) { - $languageID = $language->languageID; - } else { - continue; - } - } - $contents[$languageID] = [ - 'title' => (!empty($contentData['title']) ? $contentData['title'] : ''), - 'caption' => (!empty($contentData['caption']) ? $contentData['caption'] : ''), - 'altText' => (!empty($contentData['altText']) ? $contentData['altText'] : ''), - ]; - } - if (\count($contents) > 1) { - $data['isMultilingual'] = 1; - } + $builder = MediaBuilder::forCreate() + ->setFileData( + $data['filename'], + \filesize($additionalData['fileLocation']), + FileUtil::getMimeType($additionalData['fileLocation']), + $imageData !== false, + $imageData !== false ? $imageData[0] : 0, + $imageData !== false ? $imageData[1] : 0, + ) + ->setFileHash(\sha1_file($additionalData['fileLocation'])) + ->setUser( + ImportHandler::getInstance()->getNewID('com.woltlab.wcf.user', $data['userID'] ?? null), + $data['username'] ?? '' + ) + ->setUploadTime($data['uploadTime'] ?? \TIME_NOW) + ->setCaptionEnableHtml((bool)($data['captionEnableHtml'] ?? false)); + + if (isset($data['fileUpdateTime'])) { + $builder->setFileUpdateTime((int)$data['fileUpdateTime']); + } + if (isset($data['downloads'])) { + $builder->setCustomProperty('downloads', (int)$data['downloads']); + } + if (isset($data['lastDownloadTime'])) { + $builder->setCustomProperty('lastDownloadTime', (int)$data['lastDownloadTime']); } - // handle language - if (!empty($additionalData['languageCode'])) { - if (($language = LanguageFactory::getInstance()->getLanguageByCode($additionalData['languageCode'])) !== null) { - $data['languageID'] = $language->languageID; + $languageCode = (string)($additionalData['languageCode'] ?? ''); + if ($languageCode !== '') { + $language = LanguageFactory::getInstance()->getLanguageByCode($languageCode); + if ($language !== null) { + $builder->setCustomProperty('languageID', $language->languageID); } } + $this->setContents($builder, $additionalData['contents'] ?? []); + // check old id if (\ctype_digit((string)$oldID)) { $media = new Media($oldID); if ($media->isNil()) { - $data['mediaID'] = $oldID; + $builder->setID($oldID); } } // category - $categoryID = null; - if (!empty($data['categoryID'])) { - $categoryID = ImportHandler::getInstance()->getNewID('com.woltlab.wcf.media.category', $data['categoryID']); - } - if ($categoryID !== null) { - $data['categoryID'] = $categoryID; + if (isset($data['categoryID'])) { + $builder->setCategoryID( + ImportHandler::getInstance()->getNewID('com.woltlab.wcf.media.category', $data['categoryID']) + ); } - $data['captionEnableHtml'] ??= 0; - - // save media - $media = MediaEditor::create($data); + $media = (new CreateMedia($builder))(); // check media directory // and create subdirectory if necessary @@ -111,35 +98,65 @@ public function import(mixed $oldID, array $data, array $additionalData = []) } // copy file - try { - if (!\copy($additionalData['fileLocation'], $media->getLocation())) { - throw new SystemException(); - } - } catch (SystemException $e) { + if (!@\copy($additionalData['fileLocation'], $media->getLocation())) { // copy failed; delete media - $editor = new MediaEditor($media); - $editor->delete(); + MediaBuilder::delete($media); return 0; } - // save media content - $sql = "INSERT INTO wcf1_media_content - (mediaID, languageID, title, caption, altText) - VALUES (?, ?, ?, ?, ?)"; - $statement = WCF::getDB()->prepare($sql); - foreach ($contents as $languageID => $contentData) { - $statement->execute([ - $media->mediaID, - $languageID ?: null, - $contentData['title'], - $contentData['caption'], - $contentData['altText'], - ]); - } - ImportHandler::getInstance()->saveNewID('com.woltlab.wcf.media', $oldID, $media->mediaID); return $media->mediaID; } + + /** + * Sets the localized title, caption and alternative text from the + * imported contents which are grouped by language code. + * + * @param array $contents + */ + private function setContents(MediaBuilder $builder, array $contents): void + { + $values = []; + foreach ($contents as $languageCode => $contentData) { + $languageID = L10nStorage::MONOLINGUAL; + if ($languageCode !== '') { + $language = LanguageFactory::getInstance()->getLanguageByCode($languageCode); + if ($language === null) { + continue; + } + + $languageID = $language->languageID; + } + + $values[$languageID] = $contentData; + } + + if ($values === []) { + return; + } + + // A single content is shown in every language. + if (\count($values) === 1) { + $values = [L10nStorage::MONOLINGUAL => \reset($values)]; + } else { + unset($values[L10nStorage::MONOLINGUAL]); + + $builder->setCustomProperty('isMultilingual', 1); + } + + foreach (['title', 'caption', 'altText'] as $columnName) { + $columnValues = \array_map( + static fn(array $contentData) => $contentData[$columnName] ?? '', + $values + ); + + match ($columnName) { + 'title' => $builder->setTitle($columnValues), + 'caption' => $builder->setCaption($columnValues), + 'altText' => $builder->setAltText($columnValues), + }; + } + } } diff --git a/wcfsetup/install/files/lib/system/message/embedded/object/MediaMessageEmbeddedObjectHandler.class.php b/wcfsetup/install/files/lib/system/message/embedded/object/MediaMessageEmbeddedObjectHandler.class.php index b37f050327a..77f0de78e80 100644 --- a/wcfsetup/install/files/lib/system/message/embedded/object/MediaMessageEmbeddedObjectHandler.class.php +++ b/wcfsetup/install/files/lib/system/message/embedded/object/MediaMessageEmbeddedObjectHandler.class.php @@ -4,10 +4,8 @@ use wcf\data\media\Media; use wcf\data\media\MediaList; -use wcf\system\cache\runtime\ViewableMediaRuntimeCache; -use wcf\system\database\util\PreparedStatementConditionBuilder; +use wcf\system\cache\runtime\MediaRuntimeCache; use wcf\system\html\input\HtmlInputProcessor; -use wcf\system\WCF; use wcf\util\ArrayUtil; use wcf\util\StringUtil; @@ -42,34 +40,7 @@ public function parse(HtmlInputProcessor $htmlInputProcessor, array $embeddedDat #[\Override] public function loadObjects(array $objectIDs) { - $viewableMedia = ViewableMediaRuntimeCache::getInstance()->getObjects($objectIDs); - $contentLanguageID = MessageEmbeddedObjectManager::getInstance()->getContentLanguageID(); - if ($contentLanguageID !== null) { - $mediaIDs = []; - foreach ($viewableMedia as $media) { - // @phpstan-ignore property.notFound - if ($media !== null && (int)$media->localizedLanguageID !== $contentLanguageID) { - $mediaIDs[] = $media->getDecoratedObject()->mediaID; - } - } - - if ($mediaIDs !== []) { - $conditions = new PreparedStatementConditionBuilder(); - $conditions->add("mediaID IN (?)", [$mediaIDs]); - $conditions->add("languageID = ?", [$contentLanguageID]); - - $sql = "SELECT * - FROM wcf1_media_content - " . $conditions; - $statement = WCF::getDB()->prepare($sql); - $statement->execute($conditions->getParameters()); - while ($row = $statement->fetchArray()) { - $viewableMedia[$row['mediaID']]->setLocalizedContent($row['languageID'], $row); - } - } - } - - return $viewableMedia; + return MediaRuntimeCache::getInstance()->getObjects($objectIDs); } #[\Override] diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index a1cc42b1735..f910c4ef3ac 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -2043,18 +2043,18 @@ ->onDelete('SET NULL') ->onUpdate('NO ACTION'), ]), - DatabaseTable::create('wcf1_media_content') + DatabaseTable::create('wcf1_media_l10n') ->columns([ NotNullInt10DatabaseTableColumn::create('mediaID'), IntDatabaseTableColumn::create('languageID'), - NotNullVarchar255DatabaseTableColumn::create('title'), - TextDatabaseTableColumn::create('caption'), - NotNullVarchar255DatabaseTableColumn::create('altText') - ->defaultValue(''), + VarcharDatabaseTableColumn::create('title') + ->length(255), + MediumtextDatabaseTableColumn::create('caption'), + VarcharDatabaseTableColumn::create('altText') + ->length(255), ]) ->indices([ DatabaseTableIndex::create('mediaID') - ->type(DatabaseTableIndex::UNIQUE_TYPE) ->columns(['mediaID', 'languageID']), ]) ->foreignKeys([ From 98fcaaaecc0c31817088d3c6357d8c0bb1ee441c Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 16 Oct 2026 21:54:02 +0200 Subject: [PATCH 02/50] Migrate media files to wcf1_file with MediaFileProcessor --- com.woltlab.wcf/objectType.xml | 5 + .../update_com.woltlab.wcf_6.3_step1.php | 12 + .../lib/command/media/DeleteMedia.class.php | 27 +- .../files/lib/data/media/Media.class.php | 224 ++++++++++++- .../lib/data/media/MediaAction.class.php | 193 +++++------ .../lib/data/media/MediaBuilder.class.php | 47 +++ .../lib/data/media/MediaCollection.class.php | 2 + .../lib/data/media/MediaEditor.class.php | 9 +- .../files/lib/page/MediaPage.class.php | 30 +- .../bbcode/WoltLabSuiteMediaBBCode.class.php | 2 +- .../processor/MediaFileProcessor.class.php | 306 ++++++++++++++++++ .../input/node/HtmlInputNodeImg.class.php | 5 +- .../upcast/WsmMetacodeUpcast.class.php | 7 +- .../system/importer/MediaImporter.class.php | 43 +-- .../DefaultUploadFileSaveStrategy.class.php | 1 + ...laceUploadFileValidationStrategy.class.php | 1 + ...ediaUploadFileValidationStrategy.class.php | 1 + .../worker/MediaRebuildDataWorker.class.php | 90 ++++-- wcfsetup/setup/db/install_com.woltlab.wcf.php | 7 + 19 files changed, 822 insertions(+), 190 deletions(-) create mode 100644 wcfsetup/install/files/lib/system/file/processor/MediaFileProcessor.class.php diff --git a/com.woltlab.wcf/objectType.xml b/com.woltlab.wcf/objectType.xml index 4a1cc9cdac9..dd1e705921d 100644 --- a/com.woltlab.wcf/objectType.xml +++ b/com.woltlab.wcf/objectType.xml @@ -1176,6 +1176,11 @@ com.woltlab.wcf.file wcf\system\file\processor\TrophyImageFileProcessor + + com.woltlab.wcf.media + com.woltlab.wcf.file + wcf\system\file\processor\MediaFileProcessor + 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 e3bb2adceee..d0b110c8c55 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 @@ -115,6 +115,18 @@ ->onDelete('CASCADE') ->onUpdate('NO ACTION'), ]), + PartialDatabaseTable::create('wcf1_media') + ->columns([ + IntDatabaseTableColumn::create('fileID'), + ]) + ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['fileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('SET NULL') + ->onUpdate('NO ACTION'), + ]), DatabaseTable::create('wcf1_media_l10n') ->columns([ NotNullInt10DatabaseTableColumn::create('mediaID'), diff --git a/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php b/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php index c00c1288bb6..41b2ecfe044 100644 --- a/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php +++ b/wcfsetup/install/files/lib/command/media/DeleteMedia.class.php @@ -2,6 +2,7 @@ namespace wcf\command\media; +use wcf\command\file\DeleteFiles; use wcf\data\media\Media; use wcf\data\media\MediaBuilder; use wcf\event\media\MediaDeleted; @@ -30,26 +31,26 @@ public function __invoke(): void return; } - $mediaIDs = \array_values(\array_map(static fn(Media $media) => $media->mediaID, $this->media)); + $mediaIDs = $files = []; + foreach ($this->media as $media) { + $mediaIDs[] = $media->mediaID; + + $file = $media->getFile(); + if ($file !== null) { + $files[] = $file; + } + } MediaBuilder::deleteAll($mediaIDs); // The files are deleted only after the media files, because deleting a file cannot be rolled back. + if ($files !== []) { + new DeleteFiles($files)(); + } foreach ($this->media as $media) { - $this->deleteLegacyFiles($media); + $media->deleteLegacyFiles(); } EventHandler::getInstance()->fire(new MediaDeleted($this->media)); } - - private function deleteLegacyFiles(Media $media): void - { - @\unlink($media->getLocation()); - - if ($media->isImage !== 0) { - foreach (\array_keys(Media::getThumbnailSizes()) as $size) { - @\unlink($media->getThumbnailLocation($size)); - } - } - } } diff --git a/wcfsetup/install/files/lib/data/media/Media.class.php b/wcfsetup/install/files/lib/data/media/Media.class.php index 43de636fd8c..e113914cb67 100644 --- a/wcfsetup/install/files/lib/data/media/Media.class.php +++ b/wcfsetup/install/files/lib/data/media/Media.class.php @@ -4,6 +4,8 @@ use wcf\data\CollectionDatabaseObject; use wcf\data\ILinkableObject; +use wcf\data\file\File; +use wcf\data\file\thumbnail\FileThumbnail; use wcf\data\IThumbnailFile; use wcf\data\user\UserProfile; use wcf\page\MediaPage; @@ -31,10 +33,11 @@ * * @property-read int $mediaID unique id of the media file * @property-read ?int $categoryID id of the category the media file belongs to or `null` if it belongs to no category + * @property-read ?int $fileID id of the physical file or `null` if the media file has not been migrated to the new upload API yet * @property-read string $filename name of the physical media file * @property-read int $filesize size of the physical media file * @property-read string $fileType type of the physical media file - * @property-read string $fileHash hash of the physical media file + * @property-read string $fileHash hash of the physical media file in the legacy storage * @property-read int $uploadTime timestamp at which the media file has been uploaded * @property-read int $fileUpdateTime timestamp at which the media file was updated the last or `0` if it has not been updated * @property-read ?int $userID id of the user who uploaded the media file or null if the user does not exist anymore @@ -83,6 +86,21 @@ class Media extends CollectionDatabaseObject implements */ private const L10N_COLUMNS = ['title', 'caption', 'altText']; + /** + * names of the columns that are resolved from the physical file of + * migrated media files, besides the thumbnail columns + */ + private const FILE_COLUMNS = [ + 'downloads', + 'lastDownloadTime', + 'filename', + 'filesize', + 'fileType', + 'isImage', + 'width', + 'height', + ]; + /** * language id used to resolve the localized values instead of the active * user's language, see `getLocalizedVersion()` @@ -128,9 +146,74 @@ public function getLink(): string ]); } + /** + * Returns the link that is used to embed the media file in the editor, + * which is stable across a replacement of the physical file. + * + * @param string $size thumbnail size or `original` + * @since 6.3 + */ + public function getEditorLink(string $size): string + { + if ($size === 'original') { + return $this->getLink(); + } + + if (!isset(self::$thumbnailSizes[$size])) { + throw new \InvalidArgumentException("Unknown thumbnail size '" . $size . "'"); + } + + return LinkHandler::getInstance()->getControllerLink(MediaPage::class, [ + 'object' => $this, + 'thumbnail' => $size, + ]); + } + + /** + * Returns the link to the full size image that can be served directly by + * the web server or the link to the media page otherwise. + * + * @since 6.3 + */ + public function getFullSizeImageSource(): string + { + return $this->getFile()?->getFullSizeImageSource() ?? $this->getLink(); + } + + /** + * Returns the physical file or `null` if the media file has not been + * migrated to the new upload API yet. + * + * @since 6.3 + */ + public function getFile(): ?File + { + if ($this->fileID === null) { + return null; + } + + return $this->getCollection()->getFile($this); + } + + /** + * Returns the thumbnail with the given size or `null` if the media file + * has no such thumbnail or has not been migrated to the new upload API yet. + * + * @since 6.3 + */ + private function getFileThumbnail(string $size): ?FileThumbnail + { + return $this->getFile()?->getThumbnail($size); + } + #[\Override] public function getLocation() { + $file = $this->getFile(); + if ($file !== null) { + return $file->getPathname(); + } + return self::getStorage() . \substr($this->fileHash, 0, 2) . '/' . $this->mediaID . '-' . $this->fileHash; } @@ -141,14 +224,15 @@ public function getThumbnailLink(string $size) throw new \InvalidArgumentException("Unknown thumbnail size '" . $size . "'"); } + if ($this->getFile() !== null) { + return $this->getFileThumbnail($size)?->getLink() ?? $this->getFullSizeImageSource(); + } + if (!$this->{$size . 'ThumbnailType'}) { return $this->getLink(); } - return LinkHandler::getInstance()->getControllerLink(MediaPage::class, [ - 'object' => $this, - 'thumbnail' => $size, - ]); + return $this->getEditorLink($size); } /** @@ -196,6 +280,11 @@ public function getThumbnailLocation(string $size) throw new \InvalidArgumentException("Unknown thumbnail size '" . $size . "'"); } + $file = $this->getFile(); + if ($file !== null) { + return $this->getFileThumbnail($size)?->getPathname() ?? $file->getPathname(); + } + return self::getStorage() . \substr( $this->fileHash, 0, @@ -276,7 +365,52 @@ public function __get(string $name) return $this->getL10nValue($name); } - return parent::__get($name); + $isThumbnailColumn = \preg_match( + '~^(?tiny|small|medium|large)Thumbnail(?Type|Size|Width|Height)$~', + $name, + $matches + ) === 1; + + // `getFile()` reads `fileID` itself, therefore all other columns must + // be resolved without it to not recurse into `__get()`. + if (!$isThumbnailColumn && !\in_array($name, self::FILE_COLUMNS, true)) { + return parent::__get($name); + } + + $file = $this->getFile(); + if ($file === null) { + return parent::__get($name); + } + + if ($name === 'downloads' || $name === 'lastDownloadTime') { + // Static files are served by the web server directly, the stored + // value is suppressed because it would never be increased again. + if ($file->isStaticFile()) { + return 0; + } + + return parent::__get($name); + } + + if ($isThumbnailColumn) { + $thumbnail = $this->getFileThumbnail($matches['size']); + + return match ($matches['property']) { + 'Type' => $thumbnail?->getMimeType() ?? '', + 'Size' => $thumbnail !== null ? (int)@\filesize($thumbnail->getPathname()) : 0, + 'Width' => $thumbnail->width ?? 0, + 'Height' => $thumbnail->height ?? 0, + }; + } + + return match ($name) { + 'filename' => $file->filename, + 'filesize' => $file->fileSize, + 'fileType' => $file->mimeType, + 'isImage' => $file->isImage() ? 1 : 0, + 'width' => $file->width ?? 0, + 'height' => $file->height ?? 0, + }; } #[\Override] @@ -411,7 +545,7 @@ public function __toString(): string if ($this->isImage !== 0) { return \sprintf( '%s', - StringUtil::encodeHTML($this->getLink()), + StringUtil::encodeHTML($this->getFullSizeImageSource()), StringUtil::encodeHTML($this->getAltText()), $this->getTitleAttribute(), ); @@ -462,7 +596,7 @@ public function getElementTag(int $size): string } if ($link === null) { - $link = $this->getLink(); + $link = $this->getFullSizeImageSource(); if ($size <= $this->width && $size <= $this->height) { if ($this->height < $this->width) { @@ -530,10 +664,82 @@ private function getTitleAttribute(): string return 'title="' . StringUtil::encodeHTML($title) . '" '; } + /** + * Returns the thumbnail sizes that can be embedded in the editor. The + * thumbnails of new files are generated asynchronously, therefore the + * availability is derived from the image dimensions. + * + * @return list + * @since 6.3 + */ + public function getEmbeddableThumbnailSizes(): array + { + if ($this->isImage === 0) { + return []; + } + + $sizes = []; + foreach (['small', 'medium', 'large'] as $size) { + if ($this->getFile() === null) { + if ($this->{$size . 'ThumbnailType'} !== '') { + $sizes[] = $size; + } + + continue; + } + + // see `FileProcessor::generateThumbnails()` + $thumbnailSize = self::$thumbnailSizes[$size]; + if ($thumbnailSize['width'] <= $this->width || $thumbnailSize['height'] <= $this->height) { + $sizes[] = $size; + } + } + + return $sizes; + } + + /** + * Deletes the physical file and the thumbnails in the legacy storage. + * + * @internal + * @since 6.3 + */ + public function deleteLegacyFiles(): void + { + if ($this->fileID !== null) { + return; + } + + @\unlink($this->getLocation()); + + if ($this->isImage !== 0) { + foreach (\array_keys(self::$thumbnailSizes) as $size) { + @\unlink($this->getThumbnailLocation($size)); + } + } + } + + /** + * Returns the media file that uses the given file. + * + * @since 6.3 + */ + public static function findByFileID(int $fileID): ?self + { + $sql = "SELECT * + FROM wcf1_media + WHERE fileID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([$fileID]); + + return $statement->fetchObject(self::class); + } + /** * Returns the storage path of the media files. * * @return string + * @deprecated 6.3 Only required for media files that have not been migrated to the new upload API yet. */ public static function getStorage() { @@ -577,7 +783,7 @@ public function getImageData(?int $minWidth = null, ?int $minHeight = null): ?Im } } - return new ImageData($this->getLink(), $this->width, $this->height); + return new ImageData($this->getFullSizeImageSource(), $this->width, $this->height); } /** diff --git a/wcfsetup/install/files/lib/data/media/MediaAction.class.php b/wcfsetup/install/files/lib/data/media/MediaAction.class.php index 9f330143fdf..0ae50e51d32 100644 --- a/wcfsetup/install/files/lib/data/media/MediaAction.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaAction.class.php @@ -2,18 +2,24 @@ namespace wcf\data\media; +use wcf\command\file\CreateFileFromExistingFile; +use wcf\command\media\CreateMedia; use wcf\command\media\DeleteMedia; use wcf\command\media\UpdateMedia; use wcf\data\AbstractDatabaseObjectAction; use wcf\data\category\CategoryNodeTree; +use wcf\data\file\File; use wcf\data\ISearchAction; use wcf\data\IUploadAction; use wcf\system\acl\simple\SimpleAclHandler; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\category\CategoryHandler; use wcf\system\clipboard\ClipboardHandler; use wcf\system\exception\IllegalLinkException; use wcf\system\exception\PermissionDeniedException; use wcf\system\exception\UserInputException; +use wcf\system\file\processor\exception\DamagedImage; +use wcf\system\file\processor\FileProcessor; use wcf\system\language\I18nHandler; use wcf\system\l10n\L10nStorage; use wcf\system\language\LanguageFactory; @@ -25,6 +31,8 @@ use wcf\system\WCF; use wcf\util\FileUtil; +use function wcf\functions\exception\logThrowable; + /** * Executes media file-related actions. * @@ -63,63 +71,76 @@ public function validateUpload() #[\Override] public function upload() { - $additionalData = ['username' => WCF::getUser()->username]; - if ($this->parameters['categoryID'] !== 0) { - $additionalData['categoryID'] = $this->parameters['categoryID']; - } - - // save files - $saveStrategy = new DefaultUploadFileSaveStrategy(self::class, [ - 'generateThumbnails' => true, - 'rotateImages' => true, - ], $additionalData); - - $this->parameters['__files']->saveFiles($saveStrategy); - - /** @var Media[] $mediaFiles */ - $mediaFiles = $saveStrategy->getObjects(); + $categoryID = $this->parameters['categoryID'] ?: null; $result = [ 'errors' => [], 'media' => [], ]; - if ($mediaFiles !== []) { - $mediaIDs = $mediaToFileID = []; - foreach ($mediaFiles as $internalFileID => $media) { - $mediaIDs[] = $media->mediaID; - $mediaToFileID[$media->mediaID] = $internalFileID; + /** @var UploadFile[] $files */ + $files = $this->parameters['__files']->getFiles(); + foreach ($files as $uploadFile) { + if ($uploadFile->getValidationErrorType() === '') { + $file = $this->createFileFromUpload($uploadFile); + if ($file !== null) { + $media = (new CreateMedia( + MediaBuilder::forCreate() + ->setFile($file) + ->setCategoryID($categoryID) + ->setUser(WCF::getUser()->userID ?: null, WCF::getUser()->username) + ->setUploadTime(\TIME_NOW) + ))(); + + $result['media'][$uploadFile->getInternalFileID()] = $this->getMediaData(new Media($media->mediaID)); + + continue; + } + + $uploadFile->setValidationErrorType('uploadFailed'); } - // fetch media objects from database - $mediaList = new MediaList(); - $mediaList->setObjectIDs($mediaIDs); - $mediaList->readObjects(); + $result['errors'][$uploadFile->getInternalFileID()] = [ + 'filename' => $uploadFile->getFilename(), + 'filesize' => $uploadFile->getFilesize(), + 'errorType' => $uploadFile->getValidationErrorType(), + ]; + } - foreach ($mediaList as $media) { - $result['media'][$mediaToFileID[$media->mediaID]] = $this->getMediaData($media); - } + return $result; + } + + /** + * Creates a file from the uploaded file and generates its thumbnails, + * because the legacy client does not request them. + */ + private function createFileFromUpload(UploadFile $uploadFile): ?File + { + $file = (new CreateFileFromExistingFile( + $uploadFile->getLocation(), + $uploadFile->getFilename(), + 'com.woltlab.wcf.media' + ))(); + if ($file === null) { + return null; } - /** @var UploadFile[] $files */ - $files = $this->parameters['__files']->getFiles(); - foreach ($files as $file) { - if ($file->getValidationErrorType() !== '') { - $result['errors'][$file->getInternalFileID()] = [ - 'filename' => $file->getFilename(), - 'filesize' => $file->getFilesize(), - 'errorType' => $file->getValidationErrorType(), - ]; - } + try { + FileProcessor::getInstance()->generateThumbnails($file); + } catch (DamagedImage $e) { + logThrowable($e); } - return $result; + FileRuntimeCache::getInstance()->removeObject($file->fileID); + + return $file; } /** * Generates thumbnails. * * @return void + * @deprecated 6.3 The thumbnails are generated by the `FileProcessor`. */ public function generateThumbnails() { @@ -130,8 +151,13 @@ public function generateThumbnails() $saveStrategy = new DefaultUploadFileSaveStrategy(self::class); foreach ($this->getObjects() as $mediaEditor) { - if ($mediaEditor->getDecoratedObject()->isImage !== 0) { - $saveStrategy->generateThumbnails($mediaEditor->getDecoratedObject()); + $media = $mediaEditor->getDecoratedObject(); + + $file = $media->getFile(); + if ($file !== null) { + FileProcessor::getInstance()->generateThumbnails($file); + } elseif ($media->isImage !== 0) { + $saveStrategy->generateThumbnails($media); } } } @@ -750,80 +776,39 @@ public function validateReplaceFile() */ public function replaceFile() { - $saveStrategy = new DefaultUploadFileSaveStrategy(static::class, [ - 'action' => 'update', - 'generateThumbnails' => true, - 'object' => $this->getSingleObject()->getDecoratedObject(), - 'rotateImages' => true, - ], [ - 'fileUpdateTime' => \TIME_NOW, - 'userID' => $this->getSingleObject()->userID, - 'username' => $this->getSingleObject()->username, - // Reset thumbnail data in case the new file has no thumbnails. - 'tinyThumbnailType' => '', - 'tinyThumbnailSize' => 0, - 'tinyThumbnailWidth' => 0, - 'tinyThumbnailHeight' => 0, - 'smallThumbnailType' => '', - 'smallThumbnailSize' => 0, - 'smallThumbnailWidth' => 0, - 'smallThumbnailHeight' => 0, - 'mediumThumbnailType' => '', - 'mediumThumbnailSize' => 0, - 'mediumThumbnailWidth' => 0, - 'mediumThumbnailHeight' => 0, - 'largeThumbnailType' => '', - 'largeThumbnailSize' => 0, - 'largeThumbnailWidth' => 0, - 'largeThumbnailHeight' => 0, - ]); - - $this->parameters['__files']->saveFiles($saveStrategy); - - /** @var Media[] $mediaFiles */ - $mediaFiles = $saveStrategy->getObjects(); + $media = $this->getSingleObject()->getDecoratedObject(); $result = [ 'errors' => [], 'media' => [], ]; - if ($mediaFiles !== []) { - $mediaIDs = $mediaToFileID = []; - foreach ($mediaFiles as $internalFileID => $media) { - $mediaIDs[] = $media->mediaID; - $mediaToFileID[$media->mediaID] = $internalFileID; - } - - // fetch media objects from database - $mediaList = new MediaList(); - $mediaList->setObjectIDs($mediaIDs); - $mediaList->readObjects(); - - foreach ($mediaList as $media) { - $result['media'][$mediaToFileID[$media->mediaID]] = $this->getMediaData($media); - } - } - /** @var UploadFile[] $files */ $files = $this->parameters['__files']->getFiles(); - foreach ($files as $file) { - if ($file->getValidationErrorType() !== '') { - $result['errors'][$file->getInternalFileID()] = [ - 'filename' => $file->getFilename(), - 'filesize' => $file->getFilesize(), - 'errorType' => $file->getValidationErrorType(), - ]; - } - } + foreach ($files as $uploadFile) { + if ($uploadFile->getValidationErrorType() === '') { + $file = $this->createFileFromUpload($uploadFile); + if ($file !== null) { + // The previous file is deleted by the builder. + (new UpdateMedia( + MediaBuilder::forUpdate($media) + ->setFile($file) + ->setFileUpdateTime(\TIME_NOW) + ))(); + + $result['media'][$uploadFile->getInternalFileID()] = $this->getMediaData(new Media($media->mediaID)); + + continue; + } - $outdatedMediaFile = $this->getSingleObject(); - $updatedMediaFile = new Media($this->getSingleObject()->mediaID); + $uploadFile->setValidationErrorType('uploadFailed'); + } - // Delete *old* files using the non-updated local media editor object if the new file is - // stored in a different location. - if ($result['errors'] === [] && $updatedMediaFile->getLocation() !== $outdatedMediaFile->getLocation()) { - $outdatedMediaFile->deleteFiles(); + $result['errors'][$uploadFile->getInternalFileID()] = [ + 'filename' => $uploadFile->getFilename(), + 'filesize' => $uploadFile->getFilesize(), + 'errorType' => $uploadFile->getValidationErrorType(), + ]; } return $result; diff --git a/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php b/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php index 1e0b30dc9a5..921a9bbfe2b 100644 --- a/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaBuilder.class.php @@ -2,10 +2,13 @@ namespace wcf\data\media; +use wcf\command\file\DeleteFiles; use wcf\data\DatabaseObject; use wcf\data\DatabaseObjectBuilder; +use wcf\data\file\File; use wcf\system\acl\simple\SimpleAclHandler; use wcf\system\acl\simple\SimpleAclResolver; +use wcf\system\cache\runtime\FileRuntimeCache; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\l10n\L10nStorage; use wcf\system\WCF; @@ -85,6 +88,25 @@ public function setFileUpdateTime(int $fileUpdateTime): static return $this; } + /** + * Sets the physical file and the meta data that is denormalized for + * sorting and filtering. A previous file is deleted when the media file + * is updated. + */ + public function setFile(File $file): static + { + $this->properties['fileID'] = $file->fileID; + + return $this->setFileData( + $file->filename, + $file->fileSize, + $file->mimeType, + $file->isImage(), + $file->width ?? 0, + $file->height ?? 0, + ); + } + /** * Sets the meta data of the physical file. */ @@ -199,6 +221,7 @@ protected function afterUpdate(DatabaseObject $object): void } $this->saveAclValues($object); + $this->deleteReplacedFile($object); } #[\Override] @@ -236,6 +259,30 @@ private function saveL10nValues(Media $media): void (new L10nStorage(Media::getL10nDefinition()))->setValues($media->mediaID, $values); } + /** + * Deletes the previous physical file after it has been replaced. The file + * is deleted only after the update, because deleting a file cannot be + * rolled back. + */ + private function deleteReplacedFile(Media $media): void + { + $oldMedia = $this->getObject(); + if (!\array_key_exists('fileID', $this->properties) || $oldMedia->fileID === $media->fileID) { + return; + } + + if ($oldMedia->fileID === null) { + $oldMedia->deleteLegacyFiles(); + + return; + } + + $file = FileRuntimeCache::getInstance()->getObject($oldMedia->fileID); + if ($file !== null) { + new DeleteFiles([$file])(); + } + } + private function saveAclValues(Media $media): void { if (!isset($this->aclValues)) { diff --git a/wcfsetup/install/files/lib/data/media/MediaCollection.class.php b/wcfsetup/install/files/lib/data/media/MediaCollection.class.php index aa5ebbc29b7..c6ae728b37a 100644 --- a/wcfsetup/install/files/lib/data/media/MediaCollection.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaCollection.class.php @@ -3,6 +3,7 @@ namespace wcf\data\media; use wcf\data\DatabaseObjectCollection; +use wcf\data\TCollectionFiles; use wcf\data\TCollectionL10n; use wcf\data\TCollectionUserProfiles; use wcf\system\l10n\L10nDefinition; @@ -19,6 +20,7 @@ */ class MediaCollection extends DatabaseObjectCollection { + use TCollectionFiles; use TCollectionL10n; use TCollectionUserProfiles; diff --git a/wcfsetup/install/files/lib/data/media/MediaEditor.class.php b/wcfsetup/install/files/lib/data/media/MediaEditor.class.php index e8be86d9c73..27c9fdf6a51 100644 --- a/wcfsetup/install/files/lib/data/media/MediaEditor.class.php +++ b/wcfsetup/install/files/lib/data/media/MediaEditor.class.php @@ -29,13 +29,6 @@ class MediaEditor extends DatabaseObjectEditor */ public function deleteFiles() { - @\unlink($this->getLocation()); - - // delete thumbnails - if ($this->isImage !== 0) { - foreach (Media::getThumbnailSizes() as $size => $data) { - @\unlink($this->getThumbnailLocation($size)); - } - } + $this->getDecoratedObject()->deleteLegacyFiles(); } } diff --git a/wcfsetup/install/files/lib/page/MediaPage.class.php b/wcfsetup/install/files/lib/page/MediaPage.class.php index 9a54b2a5f4c..475d1b614fe 100644 --- a/wcfsetup/install/files/lib/page/MediaPage.class.php +++ b/wcfsetup/install/files/lib/page/MediaPage.class.php @@ -3,6 +3,7 @@ namespace wcf\page; use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\RedirectResponse; use wcf\data\media\Media; use wcf\data\media\MediaBuilder; use wcf\http\Helper; @@ -46,6 +47,13 @@ class MediaPage extends AbstractPage */ public $thumbnail = ''; + /** + * link to the physical file of media files that have been migrated to + * the new upload API + * @since 6.3 + */ + protected string $fileLink = ''; + /** * @inheritDoc */ @@ -70,6 +78,20 @@ public function readData() { parent::readData(); + $file = $this->media->getFile(); + if ($file !== null) { + if ($this->thumbnail !== '') { + $this->fileLink = $this->media->getThumbnailLink($this->thumbnail); + } elseif ($file->isStaticFile()) { + $this->fileLink = $this->media->getFullSizeImageSource(); + } else { + // The download is tracked by the file processor. + $this->fileLink = $file->getLink(); + } + + return; + } + // get file data if ($this->thumbnail !== '') { $mimeType = $this->media->{$this->thumbnail . 'ThumbnailType'}; @@ -124,7 +146,7 @@ public function readParameters() throw new IllegalLinkException(); } - if ($this->thumbnail !== '' && !$this->media->{$this->thumbnail . 'ThumbnailType'}) { + if ($this->thumbnail !== '' && $this->media->getFile() === null && !$this->media->{$this->thumbnail . 'ThumbnailType'}) { $this->thumbnail = ''; } } @@ -134,6 +156,12 @@ public function show() { parent::show(); + if ($this->fileLink !== '') { + $this->setPsr7Response(new RedirectResponse($this->fileLink)); + + return; + } + // etag caching if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === '"' . $this->eTag . '"') { $this->setPsr7Response(new EmptyResponse(304)); diff --git a/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php b/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php index 4aa8230d98c..b38601df3a6 100644 --- a/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php +++ b/wcfsetup/install/files/lib/system/bbcode/WoltLabSuiteMediaBBCode.class.php @@ -68,7 +68,7 @@ public function getParsedTag(array $openingTag, string $content, array $closingT $width = (!empty($openingTag['attributes'][3])) ? $openingTag['attributes'][3] : 'auto'; return WCF::getTPL()->render('wcf', 'shared_bbcode_wsm', [ - 'mediaLink' => $media->getLink(), + 'mediaLink' => $media->getFullSizeImageSource(), 'removeLinks' => $removeLinks, 'thumbnailLink' => $thumbnailSize !== 'original' ? $media->getThumbnailLink( $thumbnailSize diff --git a/wcfsetup/install/files/lib/system/file/processor/MediaFileProcessor.class.php b/wcfsetup/install/files/lib/system/file/processor/MediaFileProcessor.class.php new file mode 100644 index 00000000000..c2c9042e719 --- /dev/null +++ b/wcfsetup/install/files/lib/system/file/processor/MediaFileProcessor.class.php @@ -0,0 +1,306 @@ + + * @since 6.3 + */ +final class MediaFileProcessor extends AbstractFileProcessor +{ + private const IMAGE_EXTENSIONS = ['gif', 'jpg', 'jpeg', 'png', 'webp']; + + #[\Override] + public function getObjectTypeName(): string + { + return 'com.woltlab.wcf.media'; + } + + #[\Override] + public function getAllowedFileExtensions(array $context): array + { + $parameters = $this->mapContext($context); + if ($parameters === null) { + return []; + } + + $media = $this->getMediaFromContext($parameters); + if ($media !== null) { + $extension = \mb_strtolower(\pathinfo($media->filename, \PATHINFO_EXTENSION)); + + return [$extension !== '' ? $extension : '*']; + } + + if ($parameters->imagesOnly) { + return self::IMAGE_EXTENSIONS; + } + + return ['*']; + } + + #[\Override] + public function acceptUpload(string $filename, int $fileSize, array $context): FileProcessorPreflightResult + { + if (!WCF::getSession()->hasPermission('admin.content.cms.canManageMedia')) { + return FileProcessorPreflightResult::InsufficientPermissions; + } + + $parameters = $this->mapContext($context); + if ($parameters === null) { + return FileProcessorPreflightResult::InvalidContext; + } + + if ($parameters->mediaID !== 0) { + $media = $this->getMediaFromContext($parameters); + if ($media === null) { + return FileProcessorPreflightResult::InvalidContext; + } + + if (!$media->canManage()) { + return FileProcessorPreflightResult::InsufficientPermissions; + } + } + + if ($parameters->categoryID !== 0 && !self::isValidCategory($parameters->categoryID)) { + return FileProcessorPreflightResult::InvalidContext; + } + + $allowedFileExtensions = $this->getAllowedFileExtensions($context); + if ( + !\in_array('*', $allowedFileExtensions, true) + && !FileUtil::endsWithAllowedExtension($filename, $allowedFileExtensions) + ) { + return FileProcessorPreflightResult::FileExtensionNotPermitted; + } + + return FileProcessorPreflightResult::Passed; + } + + #[\Override] + public function canAdopt(File $file, array $context): bool + { + $parameters = $this->mapContext($context); + if ($parameters === null) { + return false; + } + + $media = Media::findByFileID($file->fileID); + if ($media === null) { + return true; + } + + return $media->mediaID === $parameters->mediaID; + } + + #[\Override] + public function adopt(File $file, array $context): void + { + $parameters = $this->mapContext($context); + if ($parameters === null) { + return; + } + + if ($parameters->mediaID !== 0) { + // The replacement is assigned when the form is saved. + return; + } + + if ($parameters->imagesOnly && !$file->isImage()) { + // The file is not adopted and will be removed eventually. + return; + } + + $categoryID = null; + if ($parameters->categoryID !== 0 && self::isValidCategory($parameters->categoryID)) { + $categoryID = $parameters->categoryID; + } + + new CreateMedia( + MediaBuilder::forCreate() + ->setFile($file) + ->setCategoryID($categoryID) + ->setUser(WCF::getUser()->userID ?: null, WCF::getUser()->username) + ->setUploadTime(\TIME_NOW) + )(); + } + + #[\Override] + public function canDelete(File $file): bool + { + return Media::findByFileID($file->fileID)?->canManage() ?? false; + } + + #[\Override] + public function canDownload(File $file): bool + { + return Media::findByFileID($file->fileID)?->isAccessible() ?? false; + } + + #[\Override] + public function getMaximumCount(array $context): ?int + { + $parameters = $this->mapContext($context); + if ($parameters === null) { + return 0; + } + + if ($parameters->mediaID !== 0) { + return 1; + } + + return null; + } + + #[\Override] + public function getThumbnailFormats(): array + { + $formats = []; + foreach (Media::getThumbnailSizes() as $identifier => $size) { + $formats[] = new ThumbnailFormat( + $identifier, + $size['height'], + $size['width'], + (bool)$size['retainDimensions'], + ); + } + + return $formats; + } + + #[\Override] + public function getUploadResponse(File $file): array + { + $media = Media::findByFileID($file->fileID); + if ($media === null) { + return []; + } + + return [ + 'mediaID' => $media->mediaID, + ]; + } + + #[\Override] + public function trackDownload(File $file): void + { + $media = Media::findByFileID($file->fileID); + if ($media === null) { + return; + } + + MediaBuilder::forUpdate($media) + ->trackDownload(\TIME_NOW) + ->update(); + } + + #[\Override] + public function sourceFilenameChanged(File $file): void + { + $media = Media::findByFileID($file->fileID); + if ($media === null) { + return; + } + + // The conversion of the image format changes the denormalized meta data. + MediaBuilder::forUpdate($media) + ->setFile($file) + ->update(); + } + + #[\Override] + public function delete(array $fileIDs, array $thumbnailIDs): void + { + $mediaList = new MediaList(); + $mediaList->getConditionBuilder()->add('media.fileID IN (?)', [$fileIDs]); + $mediaList->readObjects(); + + if ($mediaList->count() === 0) { + return; + } + + // The files are being deleted by the caller. + MediaBuilder::deleteAll($mediaList->getObjectIDs()); + + EventHandler::getInstance()->fire(new MediaDeleted($mediaList->getObjects())); + } + + /** + * Returns the html element to upload new media files. + * + * @since 6.3 + */ + public function toHtmlElement(int $categoryID = 0, bool $imagesOnly = false): string + { + return FileProcessor::getInstance()->getHtmlElement($this, [ + 'categoryID' => $categoryID, + 'imagesOnly' => (int)$imagesOnly, + ]); + } + + private static function isValidCategory(int $categoryID): bool + { + $category = CategoryHandler::getInstance()->getCategory($categoryID); + + return $category !== null && $category->getObjectType()->objectType === 'com.woltlab.wcf.media.category'; + } + + private function getMediaFromContext(MediaFileProcessorContext $parameters): ?Media + { + if ($parameters->mediaID === 0) { + return null; + } + + return MediaRuntimeCache::getInstance()->getObject($parameters->mediaID); + } + + /** + * @param array $context + */ + private function mapContext(array $context): ?MediaFileProcessorContext + { + try { + $parameters = Helper::mapQueryParameters($context, MediaFileProcessorContext::class); + \assert($parameters instanceof MediaFileProcessorContext); + } catch (MappingError) { + return null; + } + + return $parameters; + } +} + +/** @internal */ +final class MediaFileProcessorContext +{ + public function __construct( + /** @var non-negative-int */ + public readonly int $categoryID = 0, + + public readonly bool $imagesOnly = false, + + /** @var non-negative-int */ + public readonly int $mediaID = 0, + ) {} +} diff --git a/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeImg.class.php b/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeImg.class.php index 556077ea8ed..85a57a9aa7d 100644 --- a/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeImg.class.php +++ b/wcfsetup/install/files/lib/system/html/input/node/HtmlInputNodeImg.class.php @@ -163,7 +163,10 @@ private function handleMedium(\DOMElement $element): void $width = "auto"; } - if ( + $mediaSize = $element->getAttribute('data-media-size'); + if (\in_array($mediaSize, ['small', 'medium', 'large'], true)) { + $thumbnail = $mediaSize; + } elseif ( \preg_match( '~thumbnail=(?Psmall|large|medium)\b~', $element->getAttribute('src'), diff --git a/wcfsetup/install/files/lib/system/html/metacode/upcast/WsmMetacodeUpcast.class.php b/wcfsetup/install/files/lib/system/html/metacode/upcast/WsmMetacodeUpcast.class.php index b2349722172..8bbb260ffec 100644 --- a/wcfsetup/install/files/lib/system/html/metacode/upcast/WsmMetacodeUpcast.class.php +++ b/wcfsetup/install/files/lib/system/html/metacode/upcast/WsmMetacodeUpcast.class.php @@ -32,11 +32,8 @@ public function upcast(\DOMElement $element, array $attributes): void } $imgElement = $element->ownerDocument->createElement('img'); - if ($thumbnail === 'original') { - $imgElement->setAttribute('src', StringUtil::decodeHTML($media->getLink())); - } else { - $imgElement->setAttribute('src', StringUtil::decodeHTML($media->getThumbnailLink($thumbnail))); - } + // The editor link is stable across a replacement of the physical file. + $imgElement->setAttribute('src', StringUtil::decodeHTML($media->getEditorLink($thumbnail))); if ($width !== 'auto') { $imgElement->setAttribute('width', (string)\intval($width)); $imgElement->setAttribute('data-width', \intval($width) . 'px'); diff --git a/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php b/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php index b1615daa159..6f1a3137108 100644 --- a/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php +++ b/wcfsetup/install/files/lib/system/importer/MediaImporter.class.php @@ -2,12 +2,12 @@ namespace wcf\system\importer; +use wcf\command\file\CreateFileFromExistingFile; use wcf\command\media\CreateMedia; use wcf\data\media\Media; use wcf\data\media\MediaBuilder; use wcf\system\l10n\L10nStorage; use wcf\system\language\LanguageFactory; -use wcf\util\FileUtil; /** * Imports cms media. @@ -31,21 +31,21 @@ public function import(mixed $oldID, array $data, array $additionalData = []) return 0; } - // Extract metadata from the file ourselves, because the - // information pulled from the source database might not - // be reliable. - $imageData = @\getimagesize($additionalData['fileLocation']); + // The meta data is extracted from the file itself, because the + // information pulled from the source database might not be reliable. + $file = new CreateFileFromExistingFile( + $additionalData['fileLocation'], + $data['filename'], + 'com.woltlab.wcf.media', + copy: true, + uploadTime: isset($data['uploadTime']) ? (int)$data['uploadTime'] : null, + )(); + if ($file === null) { + return 0; + } $builder = MediaBuilder::forCreate() - ->setFileData( - $data['filename'], - \filesize($additionalData['fileLocation']), - FileUtil::getMimeType($additionalData['fileLocation']), - $imageData !== false, - $imageData !== false ? $imageData[0] : 0, - $imageData !== false ? $imageData[1] : 0, - ) - ->setFileHash(\sha1_file($additionalData['fileLocation'])) + ->setFile($file) ->setUser( ImportHandler::getInstance()->getNewID('com.woltlab.wcf.user', $data['userID'] ?? null), $data['username'] ?? '' @@ -90,21 +90,6 @@ public function import(mixed $oldID, array $data, array $additionalData = []) $media = (new CreateMedia($builder))(); - // check media directory - // and create subdirectory if necessary - $dir = \dirname($media->getLocation()); - if (!@\file_exists($dir)) { - @\mkdir($dir, 0777); - } - - // copy file - if (!@\copy($additionalData['fileLocation'], $media->getLocation())) { - // copy failed; delete media - MediaBuilder::delete($media); - - return 0; - } - ImportHandler::getInstance()->saveNewID('com.woltlab.wcf.media', $oldID, $media->mediaID); return $media->mediaID; diff --git a/wcfsetup/install/files/lib/system/upload/DefaultUploadFileSaveStrategy.class.php b/wcfsetup/install/files/lib/system/upload/DefaultUploadFileSaveStrategy.class.php index 621611dbc1e..fcce434aba6 100644 --- a/wcfsetup/install/files/lib/system/upload/DefaultUploadFileSaveStrategy.class.php +++ b/wcfsetup/install/files/lib/system/upload/DefaultUploadFileSaveStrategy.class.php @@ -23,6 +23,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use the file processors of the new upload API instead. */ class DefaultUploadFileSaveStrategy implements IUploadFileSaveStrategy { diff --git a/wcfsetup/install/files/lib/system/upload/MediaReplaceUploadFileValidationStrategy.class.php b/wcfsetup/install/files/lib/system/upload/MediaReplaceUploadFileValidationStrategy.class.php index 924d736bc5c..0b304bd4f6b 100644 --- a/wcfsetup/install/files/lib/system/upload/MediaReplaceUploadFileValidationStrategy.class.php +++ b/wcfsetup/install/files/lib/system/upload/MediaReplaceUploadFileValidationStrategy.class.php @@ -11,6 +11,7 @@ * @copyright 2001-2020 WoltLab GmbH * @license GNU Lesser General Public License * @since 5.3 + * @deprecated 6.3 Use `MediaFileProcessor` instead. */ class MediaReplaceUploadFileValidationStrategy extends MediaUploadFileValidationStrategy { diff --git a/wcfsetup/install/files/lib/system/upload/MediaUploadFileValidationStrategy.class.php b/wcfsetup/install/files/lib/system/upload/MediaUploadFileValidationStrategy.class.php index b9f71ccbdcb..f01ac892927 100644 --- a/wcfsetup/install/files/lib/system/upload/MediaUploadFileValidationStrategy.class.php +++ b/wcfsetup/install/files/lib/system/upload/MediaUploadFileValidationStrategy.class.php @@ -8,6 +8,7 @@ * @author Matthias Schmidt * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License + * @deprecated 6.3 Use `MediaFileProcessor` instead. */ class MediaUploadFileValidationStrategy implements IUploadFileValidationStrategy { diff --git a/wcfsetup/install/files/lib/system/worker/MediaRebuildDataWorker.class.php b/wcfsetup/install/files/lib/system/worker/MediaRebuildDataWorker.class.php index 6335d55a6bd..5b6f4d76291 100644 --- a/wcfsetup/install/files/lib/system/worker/MediaRebuildDataWorker.class.php +++ b/wcfsetup/install/files/lib/system/worker/MediaRebuildDataWorker.class.php @@ -2,20 +2,27 @@ namespace wcf\system\worker; -use wcf\data\media\MediaAction; +use wcf\command\file\CreateFileFromExistingFile; +use wcf\data\media\Media; +use wcf\data\media\MediaBuilder; use wcf\data\media\MediaList; -use wcf\system\exception\SystemException; +use wcf\system\WCF; + +use function wcf\functions\exception\logThrowable; /** - * Worker implementation for updating media thumbnails. + * Worker implementation for migrating media files to the new upload API. + * + * The thumbnails of migrated media files are generated by the + * `FileRebuildDataWorker`. * - * @author Alexander Ebert - * @copyright 2001-2019 WoltLab GmbH + * @author Alexander Ebert, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * - * @extends AbstractRebuildDataWorker + * @extends AbstractLinearRebuildDataWorker */ -class MediaRebuildDataWorker extends AbstractRebuildDataWorker +class MediaRebuildDataWorker extends AbstractLinearRebuildDataWorker { /** * @inheritDoc @@ -25,26 +32,71 @@ class MediaRebuildDataWorker extends AbstractRebuildDataWorker /** * @inheritDoc */ - protected $limit = 10; + protected $limit = 50; #[\Override] - protected function initObjectList() + public function execute() { - parent::initObjectList(); + parent::execute(); - $this->objectList->sqlOrderBy = 'media.mediaID'; - $this->objectList->getConditionBuilder()->add('media.isImage = ?', [1]); + foreach ($this->objectList as $media) { + if ($media->fileID !== null) { + continue; + } + + $this->migrate($media); + } } - #[\Override] - public function execute() + private function migrate(Media $media): void { - parent::execute(); + $file = new CreateFileFromExistingFile( + $media->getLocation(), + $media->filename, + 'com.woltlab.wcf.media', + uploadTime: $media->uploadTime ?: null, + )(); - foreach ($this->objectList as $media) { - try { - (new MediaAction([$media], 'generateThumbnails'))->executeAction(); - } catch (SystemException $e) { + if ($file === null) { + // Media files are referenced by content, therefore a missing + // physical file must not remove the media file silently. + logThrowable(new \RuntimeException( + "Unable to migrate the media file '{$media->mediaID}', its physical file is missing." + )); + + return; + } + + // The physical file has been moved, the media file must reference the + // new file immediately to not lose track of it. + $sql = "UPDATE wcf1_media + SET fileID = ? + WHERE mediaID = ?"; + $statement = WCF::getDB()->prepare($sql); + $statement->execute([ + $file->fileID, + $media->mediaID, + ]); + + // The denormalized meta data is updated from the actual file, because + // the rotation of images may have been normalized. + MediaBuilder::forUpdate(new Media($media->mediaID)) + ->setFile($file) + ->update(); + + $this->deleteLegacyThumbnails($media); + } + + private function deleteLegacyThumbnails(Media $media): void + { + if ($media->isImage === 0) { + return; + } + + foreach (\array_keys(Media::getThumbnailSizes()) as $size) { + $location = $media->getThumbnailLocation($size); + if (\file_exists($location)) { + @\unlink($location); } } } diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index f910c4ef3ac..b195f119269 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -1950,6 +1950,7 @@ ->columns([ ObjectIdDatabaseTableColumn::create('mediaID'), IntDatabaseTableColumn::create('categoryID'), + IntDatabaseTableColumn::create('fileID'), NotNullVarchar255DatabaseTableColumn::create('filename') ->defaultValue(''), NotNullInt10DatabaseTableColumn::create('filesize') @@ -2024,6 +2025,12 @@ ->columns(['mediaID']), ]) ->foreignKeys([ + DatabaseTableForeignKey::create() + ->columns(['fileID']) + ->referencedTable('wcf1_file') + ->referencedColumns(['fileID']) + ->onDelete('SET NULL') + ->onUpdate('NO ACTION'), DatabaseTableForeignKey::create() ->columns(['categoryID']) ->referencedTable('wcf1_category') From efb3243e92f0582874d18f9a2f8fb3b236b79bd5 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 16 Oct 2026 22:01:04 +0200 Subject: [PATCH 03/50] Migrate the media edit dialog to a form builder dialog --- com.woltlab.wcf/templates/mediaJavaScript.tpl | 6 + .../templates/shared_mediaEditInformation.tpl | 30 ++ ts/WoltLabSuite/Core/Controller/Media/List.ts | 4 +- ts/WoltLabSuite/Core/Media/Editor.ts | 385 ++---------------- ts/WoltLabSuite/Core/Media/Manager/Base.ts | 8 +- .../files/acp/templates/mediaJavaScript.tpl | 6 + .../Core/Controller/Media/List.js | 4 +- .../js/WoltLabSuite/Core/Media/Editor.js | 326 ++------------- .../WoltLabSuite/Core/Media/Manager/Base.js | 9 +- .../lib/action/MediaEditAction.class.php | 272 +++++++++++++ wcfsetup/install/lang/de.xml | 2 + wcfsetup/install/lang/en.xml | 2 + 12 files changed, 389 insertions(+), 665 deletions(-) create mode 100644 com.woltlab.wcf/templates/shared_mediaEditInformation.tpl create mode 100644 wcfsetup/install/files/lib/action/MediaEditAction.class.php diff --git a/com.woltlab.wcf/templates/mediaJavaScript.tpl b/com.woltlab.wcf/templates/mediaJavaScript.tpl index e30743ad84e..51c024066dd 100644 --- a/com.woltlab.wcf/templates/mediaJavaScript.tpl +++ b/com.woltlab.wcf/templates/mediaJavaScript.tpl @@ -25,4 +25,10 @@ Permission.add('admin.content.cms.canManageMedia', {if $__wcf->session->getPermission('admin.content.cms.canManageMedia')}true{else}false{/if}); }); + + {if $__wcf->session->getPermission('admin.content.cms.canManageMedia')} + require(['WoltLabSuite/Core/Media/Editor'], (MediaEditor) => { + MediaEditor.setEndpoint('{link controller='MediaEdit' forceFrontend=true encode=false}id=%s{/link}'); + }); + {/if} {/if} diff --git a/com.woltlab.wcf/templates/shared_mediaEditInformation.tpl b/com.woltlab.wcf/templates/shared_mediaEditInformation.tpl new file mode 100644 index 00000000000..b255247ea12 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_mediaEditInformation.tpl @@ -0,0 +1,30 @@ +
+ {unsafe:$media->getElementTag(64)} + +
+
{lang}wcf.media.filename{/lang}
+
{$media->filename}
+ +
{lang}wcf.media.filesize{/lang}
+
{$media->filesize|filesize}
+ + {if $media->isImage} +
{lang}wcf.media.imageDimensions{/lang}
+
{lang}wcf.media.imageDimensions.value{/lang}
+ {/if} + +
{lang}wcf.media.uploader{/lang}
+
{user object=$media->getUserProfile()}
+ +
{lang}wcf.media.uploadTime{/lang}
+
{time time=$media->uploadTime}
+ + {if $media->downloads} +
{lang}wcf.media.downloads{/lang}
+
{#$media->downloads}
+ +
{lang}wcf.media.lastDownloadTime{/lang}
+
{time time=$media->lastDownloadTime}
+ {/if} +
+
diff --git a/ts/WoltLabSuite/Core/Controller/Media/List.ts b/ts/WoltLabSuite/Core/Controller/Media/List.ts index 884b8e5a988..c31cf697f5c 100644 --- a/ts/WoltLabSuite/Core/Controller/Media/List.ts +++ b/ts/WoltLabSuite/Core/Controller/Media/List.ts @@ -67,7 +67,7 @@ function addButtonEventListeners(): void { * Is called when a media edit icon is clicked. */ function edit(event: Event): void { - _mediaEditor.edit(~~(event.currentTarget as HTMLElement).dataset.objectId!); + void _mediaEditor.edit(~~(event.currentTarget as HTMLElement).dataset.objectId!); } /** @@ -78,7 +78,7 @@ function openEditorAfterUpload(data: MediaUploadSuccessEventData) { const keys = Object.keys(data.media); if (keys.length) { - _mediaEditor.edit(data.media[keys[0]]); + void _mediaEditor.edit(data.media[keys[0]]); } } } diff --git a/ts/WoltLabSuite/Core/Media/Editor.ts b/ts/WoltLabSuite/Core/Media/Editor.ts index 6bd9437c3c0..ff8e290123d 100644 --- a/ts/WoltLabSuite/Core/Media/Editor.ts +++ b/ts/WoltLabSuite/Core/Media/Editor.ts @@ -1,42 +1,23 @@ /** * Handles editing media files via dialog. * - * @author Matthias Schmidt - * @copyright 2001-2021 WoltLab GmbH + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny */ import { Media, MediaEditorCallbackObject } from "./Data"; -import { AjaxCallbackObject, AjaxCallbackSetup } from "../Ajax/Data"; -import * as UiDialog from "../Ui/Dialog"; -import { DialogCallbackObject } from "../Ui/Dialog/Data"; -import * as LanguageChooser from "../Language/Chooser"; -import * as LanguageInput from "../Language/Input"; -import * as DomUtil from "../Dom/Util"; -import * as DomTraverse from "../Dom/Traverse"; -import DomChangeListener from "../Dom/Change/Listener"; -import { getPhrase } from "WoltLabSuite/Core/Language"; -import * as Ajax from "../Ajax"; -import MediaReplace from "./Replace"; -import { I18nValues } from "../Language/Input"; +import { dialogFactory } from "../Component/Dialog"; import { showDefaultSuccessSnackbar } from "../Component/Snackbar"; -interface InitEditorData { - returnValues: { - availableLanguageCount: number; - categoryIDs: number[]; - mediaData?: Media; - }; -} +type MediaEditResult = Pick; + +let endpoint = ""; -class MediaEditor implements AjaxCallbackObject { - protected _availableLanguageCount = 1; - protected _categoryIds: number[] = []; - protected _dialogs = new Map(); +class MediaEditor { protected readonly _callbackObject: MediaEditorCallbackObject; protected _media: Media | null = null; - protected _oldCategoryId = 0; constructor(callbackObject: MediaEditorCallbackObject) { this._callbackObject = callbackObject || {}; @@ -49,352 +30,50 @@ class MediaEditor implements AjaxCallbackObject { } } - public _ajaxSetup(): ReturnType { - return { - data: { - actionName: "update", - className: "wcf\\data\\media\\MediaAction", - }, - }; - } - - public _ajaxSuccess(): void { - showDefaultSuccessSnackbar(); - - if (this._callbackObject._editorSuccess) { - this._callbackObject._editorSuccess(this._media, this._oldCategoryId); - this._oldCategoryId = 0; - } - - UiDialog.close(`mediaEditor_${this._media!.mediaID}`); - - this._media = null; - } - - /** - * Is called if an editor is manually closed by the user. - */ - protected _close(): void { - this._media = null; - - if (this._callbackObject._editorClose) { - this._callbackObject._editorClose(); - } - } - /** - * Initializes the editor dialog. + * Sets the link of the edit dialog, `%s` is replaced with the id of the + * media file. * - * @since 5.3 + * @since 6.3 */ - protected _initEditor(content: HTMLElement, data: InitEditorData): void { - this._availableLanguageCount = ~~data.returnValues.availableLanguageCount; - this._categoryIds = data.returnValues.categoryIDs.map((number) => ~~number); - - if (data.returnValues.mediaData) { - this._media = data.returnValues.mediaData; - } - const media = this._media!; - const mediaId = media.mediaID; - - // make sure that the language chooser is initialized first - setTimeout(() => { - if (this._availableLanguageCount > 1) { - LanguageChooser.setLanguageId(`mediaEditor_${mediaId}_languageID`, media.languageID || window.LANGUAGE_ID); - } - - if (this._categoryIds.length) { - const categoryID = content.querySelector("select[name=categoryID]") as HTMLSelectElement; - if (media.categoryID) { - categoryID.value = media.categoryID.toString(); - } else { - categoryID.value = "0"; - } - } - - const title = content.querySelector("input[name=title]") as HTMLInputElement; - const altText = content.querySelector("input[name=altText]") as HTMLInputElement; - const caption = content.querySelector("textarea[name=caption]") as HTMLInputElement; - - if (this._availableLanguageCount > 1 && media.isMultilingual) { - if (document.getElementById(`altText_${mediaId}`)) { - LanguageInput.setValues(`altText_${mediaId}`, (media.altText || {}) as I18nValues); - } - - if (document.getElementById(`caption_${mediaId}`)) { - LanguageInput.setValues(`caption_${mediaId}`, (media.caption || {}) as I18nValues); - } - - LanguageInput.setValues(`title_${mediaId}`, (media.title || {}) as I18nValues); - } else { - title.value = media.title ? media.title[media.languageID || window.LANGUAGE_ID] : ""; - if (altText) { - altText.value = media.altText ? media.altText[media.languageID || window.LANGUAGE_ID] : ""; - } - if (caption) { - caption.value = media.caption ? media.caption[media.languageID || window.LANGUAGE_ID] : ""; - } - } - - if (this._availableLanguageCount > 1) { - const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement; - isMultilingual.addEventListener("change", (ev) => this._updateLanguageFields(ev)); - - this._updateLanguageFields(null, isMultilingual); - } - - if (altText) { - altText.addEventListener("keypress", (ev) => this._keyPress(ev)); - } - title.addEventListener("keypress", (ev) => this._keyPress(ev)); - - content.querySelector("button[data-type=submit]")!.addEventListener("click", () => this._saveData()); - - // remove focus from input elements and scroll dialog to top - (document.activeElement! as HTMLElement).blur(); - (document.getElementById(`mediaEditor_${mediaId}`)!.parentNode as HTMLElement).scrollTop = 0; - - // Initialize button to replace media file. - const uploadButton = content.querySelector(".mediaManagerMediaReplaceButton")!; - let target = content.querySelector(".mediaThumbnail"); - if (!target) { - target = document.createElement("div"); - content.appendChild(target); - } - new MediaReplace( - mediaId, - DomUtil.identify(uploadButton), - // Pass an anonymous element for non-images which is required internally - // but not needed in this case. - DomUtil.identify(target), - { - mediaEditor: this, - }, - ); - - DomChangeListener.trigger(); - }, 200); + public static setEndpoint(link: string): void { + endpoint = link; } /** - * Handles the `[ENTER]` key to submit the form. - */ - protected _keyPress(event: KeyboardEvent): void { - if (event.key === "Enter") { - event.preventDefault(); - - this._saveData(); - } - } - - /** - * Saves the data of the currently edited media. + * Edits the media with the given data or id. */ - protected _saveData(): void { - const content = UiDialog.getDialog(`mediaEditor_${this._media!.mediaID}`)!.content; - - const categoryId = content.querySelector("select[name=categoryID]") as HTMLSelectElement; - const altText = content.querySelector("input[name=altText]") as HTMLInputElement; - const caption = content.querySelector("textarea[name=caption]") as HTMLTextAreaElement; - const captionEnableHtml = content.querySelector("input[name=captionEnableHtml]") as HTMLInputElement; - const title = content.querySelector("input[name=title]") as HTMLInputElement; - - let hasError = false; - const altTextError = altText ? DomTraverse.childByClass(altText.parentNode! as HTMLElement, "innerError") : false; - const captionError = caption ? DomTraverse.childByClass(caption.parentNode! as HTMLElement, "innerError") : false; - const titleError = DomTraverse.childByClass(title.parentNode! as HTMLElement, "innerError"); - - // category - this._oldCategoryId = this._media!.categoryID; - if (this._categoryIds.length) { - this._media!.categoryID = ~~categoryId.value; - - // if the selected category id not valid (manipulated DOM), ignore - if (this._categoryIds.indexOf(this._media!.categoryID) === -1) { - this._media!.categoryID = 0; - } - } - - // language and multilingualism - if (this._availableLanguageCount > 1) { - const isMultilingual = content.querySelector("input[name=isMultilingual]") as HTMLInputElement; - this._media!.isMultilingual = ~~isMultilingual.checked; - this._media!.languageID = this._media!.isMultilingual - ? null - : LanguageChooser.getLanguageId(`mediaEditor_${this._media!.mediaID}_languageID`); - } else { - this._media!.languageID = window.LANGUAGE_ID; - } + public async edit(editedMedia: Media | number): Promise { + const media: Media = + typeof editedMedia === "object" ? editedMedia : ({ mediaID: editedMedia, categoryID: 0 } as Media); - // altText, caption and title - this._media!.altText = {}; - this._media!.caption = {}; - this._media!.title = {}; - if (this._availableLanguageCount > 1 && this._media!.isMultilingual) { - if (altText && !LanguageInput.validate(altText.id, true)) { - hasError = true; - if (!altTextError) { - DomUtil.innerError(altText, getPhrase("wcf.global.form.error.multilingual")); - } - } - if (caption && !LanguageInput.validate(caption.id, true)) { - hasError = true; - if (!captionError) { - DomUtil.innerError(caption, getPhrase("wcf.global.form.error.multilingual")); - } - } - if (!LanguageInput.validate(title.id, true)) { - hasError = true; - if (!titleError) { - DomUtil.innerError(title, getPhrase("wcf.global.form.error.multilingual")); - } - } - - this._media!.altText = altText ? this.mapToI18nValues(LanguageInput.getValues(altText.id)) : ""; - this._media!.caption = caption ? this.mapToI18nValues(LanguageInput.getValues(caption.id)) : ""; - this._media!.title = this.mapToI18nValues(LanguageInput.getValues(title.id)); - } else { - this._media!.altText[this._media!.languageID!] = altText ? altText.value : ""; - this._media!.caption[this._media!.languageID!] = caption ? caption.value : ""; - this._media!.title[this._media!.languageID!] = title.value; - } - - // captionEnableHtml - if (captionEnableHtml) { - this._media!.captionEnableHtml = ~~captionEnableHtml.checked; - } else { - this._media!.captionEnableHtml = 0; + if (this._media !== null) { + throw new Error( + `Cannot edit media with id ${media.mediaID} while editing media with id '${this._media.mediaID}'.`, + ); } - const aclValues = { - allowAll: ~~(document.getElementById(`mediaEditor_${this._media!.mediaID}_aclAllowAll`)! as HTMLInputElement) - .checked, - group: Array.from( - content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[group][]"]`), - ).map((aclGroup: HTMLInputElement) => ~~aclGroup.value), - user: Array.from( - content.querySelectorAll(`input[name="mediaEditor_${this._media!.mediaID}_aclValues[user][]"]`), - ).map((aclUser: HTMLInputElement) => ~~aclUser.value), - }; - - if (!hasError) { - if (altTextError) { - altTextError.remove(); - } - if (captionError) { - captionError.remove(); - } - if (titleError) { - titleError.remove(); - } - - Ajax.api(this, { - actionName: "update", - objectIDs: [this._media!.mediaID], - parameters: { - aclValues: aclValues, - altText: this._media!.altText, - caption: this._media!.caption, - data: { - captionEnableHtml: this._media!.captionEnableHtml, - categoryID: this._media!.categoryID, - isMultilingual: this._media!.isMultilingual, - languageID: this._media!.languageID, - }, - title: this._media!.title, - }, - }); + if (endpoint === "") { + throw new Error("The endpoint of the media editor has not been set."); } - } - private mapToI18nValues(values: Map): I18nValues { - const obj = {}; - values.forEach((value, key) => (obj[key] = value)); - - return obj; - } - - /** - * Updates language-related input fields depending on whether multilingualis is enabled. - */ - protected _updateLanguageFields(event: Event | null, element?: HTMLInputElement): void { - if (event) { - element = event.currentTarget as HTMLInputElement; - } + this._media = media; - const mediaId = this._media!.mediaID; - const languageChooserContainer = document.getElementById(`mediaEditor_${mediaId}_languageIDContainer`)! - .parentNode! as HTMLElement; + const { ok, result } = await dialogFactory() + .usingFormBuilder() + .fromEndpoint(endpoint.replace("%s", media.mediaID.toString())); - if (element!.checked) { - LanguageInput.enable(`title_${mediaId}`); - if (document.getElementById(`caption_${mediaId}`)) { - LanguageInput.enable(`caption_${mediaId}`); - } - if (document.getElementById(`altText_${mediaId}`)) { - LanguageInput.enable(`altText_${mediaId}`); - } + this._media = null; - DomUtil.hide(languageChooserContainer); - } else { - LanguageInput.disable(`title_${mediaId}`); - if (document.getElementById(`caption_${mediaId}`)) { - LanguageInput.disable(`caption_${mediaId}`); - } - if (document.getElementById(`altText_${mediaId}`)) { - LanguageInput.disable(`altText_${mediaId}`); - } + if (!ok) { + this._callbackObject._editorClose?.(); - DomUtil.show(languageChooserContainer); + return; } - } - /** - * Edits the media with the given data or id. - */ - public edit(editedMedia: Media | number): void { - let media: Media; - let mediaId = 0; - if (typeof editedMedia === "object") { - media = editedMedia; - mediaId = media.mediaID; - } else { - media = { - mediaID: editedMedia, - } as Media; - mediaId = editedMedia; - } - - if (this._media !== null) { - throw new Error(`Cannot edit media with id ${mediaId} while editing media with id '${this._media.mediaID}'.`); - } - - this._media = media; - - if (!this._dialogs.has(`mediaEditor_${mediaId}`)) { - this._dialogs.set(`mediaEditor_${mediaId}`, { - _dialogSetup: () => { - return { - id: `mediaEditor_${mediaId}`, - options: { - backdropCloseOnClick: false, - onClose: () => this._close(), - title: getPhrase("wcf.media.edit"), - }, - source: { - after: (content: HTMLElement, responseData: InitEditorData) => this._initEditor(content, responseData), - data: { - actionName: "getEditorDialog", - className: "wcf\\data\\media\\MediaAction", - objectIDs: [mediaId], - }, - }, - }; - }, - }); - } + showDefaultSuccessSnackbar(); - UiDialog.open(this._dialogs.get(`mediaEditor_${mediaId}`)!); + this._callbackObject._editorSuccess?.({ ...media, ...result }, media.categoryID); } /** diff --git a/ts/WoltLabSuite/Core/Media/Manager/Base.ts b/ts/WoltLabSuite/Core/Media/Manager/Base.ts index 3ad95020853..7fb5474b646 100644 --- a/ts/WoltLabSuite/Core/Media/Manager/Base.ts +++ b/ts/WoltLabSuite/Core/Media/Manager/Base.ts @@ -225,7 +225,7 @@ abstract class MediaManagersession->getPermission('admin.content.cms.canManageMedia')}true{else}false{/if}); }); + + {if $__wcf->session->getPermission('admin.content.cms.canManageMedia')} + require(['WoltLabSuite/Core/Media/Editor'], (MediaEditor) => { + MediaEditor.setEndpoint('{link controller='MediaEdit' forceFrontend=true encode=false}id=%s{/link}'); + }); + {/if} {/if} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/Media/List.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/Media/List.js index 3a40eaa3b13..8149e97802f 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/Media/List.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Controller/Media/List.js @@ -53,7 +53,7 @@ define(["require", "exports", "tslib", "../../Media/List/Upload", "../../Media/C * Is called when a media edit icon is clicked. */ function edit(event) { - _mediaEditor.edit(~~event.currentTarget.dataset.objectId); + void _mediaEditor.edit(~~event.currentTarget.dataset.objectId); } /** * Opens the media editor after uploading a single file. @@ -62,7 +62,7 @@ define(["require", "exports", "tslib", "../../Media/List/Upload", "../../Media/C if (data.upload === _upload && !data.isMultiFileUpload && !_upload.hasPendingUploads()) { const keys = Object.keys(data.media); if (keys.length) { - _mediaEditor.edit(data.media[keys[0]]); + void _mediaEditor.edit(data.media[keys[0]]); } } } diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Editor.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Editor.js index 39edae7a5ca..0a62438b55c 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Editor.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Editor.js @@ -1,28 +1,17 @@ /** * Handles editing media files via dialog. * - * @author Matthias Schmidt - * @copyright 2001-2021 WoltLab GmbH + * @author Matthias Schmidt, Marcel Werk + * @copyright 2001-2026 WoltLab GmbH * @license GNU Lesser General Public License * @woltlabExcludeBundle tiny */ -define(["require", "exports", "tslib", "../Ui/Dialog", "../Language/Chooser", "../Language/Input", "../Dom/Util", "../Dom/Traverse", "../Dom/Change/Listener", "WoltLabSuite/Core/Language", "../Ajax", "./Replace", "../Component/Snackbar"], function (require, exports, tslib_1, UiDialog, LanguageChooser, LanguageInput, DomUtil, DomTraverse, Listener_1, Language_1, Ajax, Replace_1, Snackbar_1) { +define(["require", "exports", "../Component/Dialog", "../Component/Snackbar"], function (require, exports, Dialog_1, Snackbar_1) { "use strict"; - UiDialog = tslib_1.__importStar(UiDialog); - LanguageChooser = tslib_1.__importStar(LanguageChooser); - LanguageInput = tslib_1.__importStar(LanguageInput); - DomUtil = tslib_1.__importStar(DomUtil); - DomTraverse = tslib_1.__importStar(DomTraverse); - Listener_1 = tslib_1.__importDefault(Listener_1); - Ajax = tslib_1.__importStar(Ajax); - Replace_1 = tslib_1.__importDefault(Replace_1); + let endpoint = ""; class MediaEditor { - _availableLanguageCount = 1; - _categoryIds = []; - _dialogs = new Map(); _callbackObject; _media = null; - _oldCategoryId = 0; constructor(callbackObject) { this._callbackObject = callbackObject || {}; if (this._callbackObject._editorClose && typeof this._callbackObject._editorClose !== "function") { @@ -32,304 +21,37 @@ define(["require", "exports", "tslib", "../Ui/Dialog", "../Language/Chooser", ". throw new TypeError("Callback object has no function '_editorSuccess'."); } } - _ajaxSetup() { - return { - data: { - actionName: "update", - className: "wcf\\data\\media\\MediaAction", - }, - }; - } - _ajaxSuccess() { - (0, Snackbar_1.showDefaultSuccessSnackbar)(); - if (this._callbackObject._editorSuccess) { - this._callbackObject._editorSuccess(this._media, this._oldCategoryId); - this._oldCategoryId = 0; - } - UiDialog.close(`mediaEditor_${this._media.mediaID}`); - this._media = null; - } - /** - * Is called if an editor is manually closed by the user. - */ - _close() { - this._media = null; - if (this._callbackObject._editorClose) { - this._callbackObject._editorClose(); - } - } /** - * Initializes the editor dialog. + * Sets the link of the edit dialog, `%s` is replaced with the id of the + * media file. * - * @since 5.3 - */ - _initEditor(content, data) { - this._availableLanguageCount = ~~data.returnValues.availableLanguageCount; - this._categoryIds = data.returnValues.categoryIDs.map((number) => ~~number); - if (data.returnValues.mediaData) { - this._media = data.returnValues.mediaData; - } - const media = this._media; - const mediaId = media.mediaID; - // make sure that the language chooser is initialized first - setTimeout(() => { - if (this._availableLanguageCount > 1) { - LanguageChooser.setLanguageId(`mediaEditor_${mediaId}_languageID`, media.languageID || window.LANGUAGE_ID); - } - if (this._categoryIds.length) { - const categoryID = content.querySelector("select[name=categoryID]"); - if (media.categoryID) { - categoryID.value = media.categoryID.toString(); - } - else { - categoryID.value = "0"; - } - } - const title = content.querySelector("input[name=title]"); - const altText = content.querySelector("input[name=altText]"); - const caption = content.querySelector("textarea[name=caption]"); - if (this._availableLanguageCount > 1 && media.isMultilingual) { - if (document.getElementById(`altText_${mediaId}`)) { - LanguageInput.setValues(`altText_${mediaId}`, (media.altText || {})); - } - if (document.getElementById(`caption_${mediaId}`)) { - LanguageInput.setValues(`caption_${mediaId}`, (media.caption || {})); - } - LanguageInput.setValues(`title_${mediaId}`, (media.title || {})); - } - else { - title.value = media.title ? media.title[media.languageID || window.LANGUAGE_ID] : ""; - if (altText) { - altText.value = media.altText ? media.altText[media.languageID || window.LANGUAGE_ID] : ""; - } - if (caption) { - caption.value = media.caption ? media.caption[media.languageID || window.LANGUAGE_ID] : ""; - } - } - if (this._availableLanguageCount > 1) { - const isMultilingual = content.querySelector("input[name=isMultilingual]"); - isMultilingual.addEventListener("change", (ev) => this._updateLanguageFields(ev)); - this._updateLanguageFields(null, isMultilingual); - } - if (altText) { - altText.addEventListener("keypress", (ev) => this._keyPress(ev)); - } - title.addEventListener("keypress", (ev) => this._keyPress(ev)); - content.querySelector("button[data-type=submit]").addEventListener("click", () => this._saveData()); - // remove focus from input elements and scroll dialog to top - document.activeElement.blur(); - document.getElementById(`mediaEditor_${mediaId}`).parentNode.scrollTop = 0; - // Initialize button to replace media file. - const uploadButton = content.querySelector(".mediaManagerMediaReplaceButton"); - let target = content.querySelector(".mediaThumbnail"); - if (!target) { - target = document.createElement("div"); - content.appendChild(target); - } - new Replace_1.default(mediaId, DomUtil.identify(uploadButton), - // Pass an anonymous element for non-images which is required internally - // but not needed in this case. - DomUtil.identify(target), { - mediaEditor: this, - }); - Listener_1.default.trigger(); - }, 200); - } - /** - * Handles the `[ENTER]` key to submit the form. + * @since 6.3 */ - _keyPress(event) { - if (event.key === "Enter") { - event.preventDefault(); - this._saveData(); - } - } - /** - * Saves the data of the currently edited media. - */ - _saveData() { - const content = UiDialog.getDialog(`mediaEditor_${this._media.mediaID}`).content; - const categoryId = content.querySelector("select[name=categoryID]"); - const altText = content.querySelector("input[name=altText]"); - const caption = content.querySelector("textarea[name=caption]"); - const captionEnableHtml = content.querySelector("input[name=captionEnableHtml]"); - const title = content.querySelector("input[name=title]"); - let hasError = false; - const altTextError = altText ? DomTraverse.childByClass(altText.parentNode, "innerError") : false; - const captionError = caption ? DomTraverse.childByClass(caption.parentNode, "innerError") : false; - const titleError = DomTraverse.childByClass(title.parentNode, "innerError"); - // category - this._oldCategoryId = this._media.categoryID; - if (this._categoryIds.length) { - this._media.categoryID = ~~categoryId.value; - // if the selected category id not valid (manipulated DOM), ignore - if (this._categoryIds.indexOf(this._media.categoryID) === -1) { - this._media.categoryID = 0; - } - } - // language and multilingualism - if (this._availableLanguageCount > 1) { - const isMultilingual = content.querySelector("input[name=isMultilingual]"); - this._media.isMultilingual = ~~isMultilingual.checked; - this._media.languageID = this._media.isMultilingual - ? null - : LanguageChooser.getLanguageId(`mediaEditor_${this._media.mediaID}_languageID`); - } - else { - this._media.languageID = window.LANGUAGE_ID; - } - // altText, caption and title - this._media.altText = {}; - this._media.caption = {}; - this._media.title = {}; - if (this._availableLanguageCount > 1 && this._media.isMultilingual) { - if (altText && !LanguageInput.validate(altText.id, true)) { - hasError = true; - if (!altTextError) { - DomUtil.innerError(altText, (0, Language_1.getPhrase)("wcf.global.form.error.multilingual")); - } - } - if (caption && !LanguageInput.validate(caption.id, true)) { - hasError = true; - if (!captionError) { - DomUtil.innerError(caption, (0, Language_1.getPhrase)("wcf.global.form.error.multilingual")); - } - } - if (!LanguageInput.validate(title.id, true)) { - hasError = true; - if (!titleError) { - DomUtil.innerError(title, (0, Language_1.getPhrase)("wcf.global.form.error.multilingual")); - } - } - this._media.altText = altText ? this.mapToI18nValues(LanguageInput.getValues(altText.id)) : ""; - this._media.caption = caption ? this.mapToI18nValues(LanguageInput.getValues(caption.id)) : ""; - this._media.title = this.mapToI18nValues(LanguageInput.getValues(title.id)); - } - else { - this._media.altText[this._media.languageID] = altText ? altText.value : ""; - this._media.caption[this._media.languageID] = caption ? caption.value : ""; - this._media.title[this._media.languageID] = title.value; - } - // captionEnableHtml - if (captionEnableHtml) { - this._media.captionEnableHtml = ~~captionEnableHtml.checked; - } - else { - this._media.captionEnableHtml = 0; - } - const aclValues = { - allowAll: ~~document.getElementById(`mediaEditor_${this._media.mediaID}_aclAllowAll`) - .checked, - group: Array.from(content.querySelectorAll(`input[name="mediaEditor_${this._media.mediaID}_aclValues[group][]"]`)).map((aclGroup) => ~~aclGroup.value), - user: Array.from(content.querySelectorAll(`input[name="mediaEditor_${this._media.mediaID}_aclValues[user][]"]`)).map((aclUser) => ~~aclUser.value), - }; - if (!hasError) { - if (altTextError) { - altTextError.remove(); - } - if (captionError) { - captionError.remove(); - } - if (titleError) { - titleError.remove(); - } - Ajax.api(this, { - actionName: "update", - objectIDs: [this._media.mediaID], - parameters: { - aclValues: aclValues, - altText: this._media.altText, - caption: this._media.caption, - data: { - captionEnableHtml: this._media.captionEnableHtml, - categoryID: this._media.categoryID, - isMultilingual: this._media.isMultilingual, - languageID: this._media.languageID, - }, - title: this._media.title, - }, - }); - } - } - mapToI18nValues(values) { - const obj = {}; - values.forEach((value, key) => (obj[key] = value)); - return obj; - } - /** - * Updates language-related input fields depending on whether multilingualis is enabled. - */ - _updateLanguageFields(event, element) { - if (event) { - element = event.currentTarget; - } - const mediaId = this._media.mediaID; - const languageChooserContainer = document.getElementById(`mediaEditor_${mediaId}_languageIDContainer`) - .parentNode; - if (element.checked) { - LanguageInput.enable(`title_${mediaId}`); - if (document.getElementById(`caption_${mediaId}`)) { - LanguageInput.enable(`caption_${mediaId}`); - } - if (document.getElementById(`altText_${mediaId}`)) { - LanguageInput.enable(`altText_${mediaId}`); - } - DomUtil.hide(languageChooserContainer); - } - else { - LanguageInput.disable(`title_${mediaId}`); - if (document.getElementById(`caption_${mediaId}`)) { - LanguageInput.disable(`caption_${mediaId}`); - } - if (document.getElementById(`altText_${mediaId}`)) { - LanguageInput.disable(`altText_${mediaId}`); - } - DomUtil.show(languageChooserContainer); - } + static setEndpoint(link) { + endpoint = link; } /** * Edits the media with the given data or id. */ - edit(editedMedia) { - let media; - let mediaId = 0; - if (typeof editedMedia === "object") { - media = editedMedia; - mediaId = media.mediaID; - } - else { - media = { - mediaID: editedMedia, - }; - mediaId = editedMedia; - } + async edit(editedMedia) { + const media = typeof editedMedia === "object" ? editedMedia : { mediaID: editedMedia, categoryID: 0 }; if (this._media !== null) { - throw new Error(`Cannot edit media with id ${mediaId} while editing media with id '${this._media.mediaID}'.`); + throw new Error(`Cannot edit media with id ${media.mediaID} while editing media with id '${this._media.mediaID}'.`); + } + if (endpoint === "") { + throw new Error("The endpoint of the media editor has not been set."); } this._media = media; - if (!this._dialogs.has(`mediaEditor_${mediaId}`)) { - this._dialogs.set(`mediaEditor_${mediaId}`, { - _dialogSetup: () => { - return { - id: `mediaEditor_${mediaId}`, - options: { - backdropCloseOnClick: false, - onClose: () => this._close(), - title: (0, Language_1.getPhrase)("wcf.media.edit"), - }, - source: { - after: (content, responseData) => this._initEditor(content, responseData), - data: { - actionName: "getEditorDialog", - className: "wcf\\data\\media\\MediaAction", - objectIDs: [mediaId], - }, - }, - }; - }, - }); + const { ok, result } = await (0, Dialog_1.dialogFactory)() + .usingFormBuilder() + .fromEndpoint(endpoint.replace("%s", media.mediaID.toString())); + this._media = null; + if (!ok) { + this._callbackObject._editorClose?.(); + return; } - UiDialog.open(this._dialogs.get(`mediaEditor_${mediaId}`)); + (0, Snackbar_1.showDefaultSuccessSnackbar)(); + this._callbackObject._editorSuccess?.({ ...media, ...result }, media.categoryID); } /** * Updates the data of the currently edited media file. diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Manager/Base.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Manager/Base.js index e0e92ee6ad3..f4449a4e0b7 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Manager/Base.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Media/Manager/Base.js @@ -171,7 +171,7 @@ define(["require", "exports", "tslib", "../../Core", "WoltLabSuite/Core/Language } UiDialog.close(this); const target = event.currentTarget; - this._mediaEditor.edit(this._media.get(~~target.dataset.objectId)); + void this._mediaEditor.edit(this._media.get(~~target.dataset.objectId)); } /** * Re-opens the manager dialog after closing the editor dialog. @@ -201,7 +201,10 @@ define(["require", "exports", "tslib", "../../Core", "WoltLabSuite/Core/Language this._media.set(~~media.mediaID, media); const listItem = this._listItems.get(~~media.mediaID); const p = listItem.querySelector(".mediaTitle"); - if (media.isMultilingual) { + if (typeof media.title === "string") { + p.textContent = media.title || media.filename; + } + else if (media.isMultilingual) { if (media.title && media.title[window.LANGUAGE_ID]) { p.textContent = media.title[window.LANGUAGE_ID]; } @@ -263,7 +266,7 @@ define(["require", "exports", "tslib", "../../Core", "WoltLabSuite/Core/Language const keys = Object.keys(data.media); if (keys.length) { UiDialog.close(this); - this._mediaEditor.edit(this._media.get(~~data.media[keys[0]].mediaID)); + void this._mediaEditor.edit(this._media.get(~~data.media[keys[0]].mediaID)); } } } diff --git a/wcfsetup/install/files/lib/action/MediaEditAction.class.php b/wcfsetup/install/files/lib/action/MediaEditAction.class.php new file mode 100644 index 00000000000..5e2615b4d60 --- /dev/null +++ b/wcfsetup/install/files/lib/action/MediaEditAction.class.php @@ -0,0 +1,272 @@ + + * @since 6.3 + */ +final class MediaEditAction implements RequestHandlerInterface +{ + private Media $media; + + #[\Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + $parameters = Helper::mapQueryParameters( + $request->getQueryParams(), + <<<'EOT' + array { + id: positive-int + } + EOT + ); + + $media = MediaRuntimeCache::getInstance()->getObject($parameters['id']); + if ($media === null) { + throw new IllegalLinkException(); + } + if (!$media->canManage()) { + throw new PermissionDeniedException(); + } + $this->media = $media; + + $form = $this->getForm(); + + if ($request->getMethod() === 'GET') { + return $form->toResponse(); + } elseif ($request->getMethod() === 'POST') { + $response = $form->validateRequest($request); + if ($response !== null) { + return $response; + } + + $builder = MediaBuilder::forUpdate($this->media); + $form->applyValuesToBuilder($builder); + $media = (new UpdateMedia($builder))(); + + return new JsonResponse([ + 'result' => $this->getResult(new Media($media->mediaID)), + ]); + } else { + throw new \LogicException('Unreachable'); + } + } + + private function getForm(): Psr15BuilderDialogForm + { + $form = new Psr15BuilderDialogForm( + static::class, + WCF::getLanguage()->get('wcf.media.edit') + ); + + $form->appendChildren([ + TemplateFormNode::create('information') + ->templateName('shared_mediaEditInformation') + ->variables(['media' => $this->media]), + FormContainer::create('general') + ->appendChildren([ + $this->getFileFormField(), + SingleSelectionFormField::create('categoryID') + ->label('wcf.global.category') + ->options($this->getCategoryOptions(), true) + ->value(0) + ->saveValueCallback(static function (MediaBuilder $builder, SingleSelectionFormField $field): void { + $builder->setCategoryID((int)$field->getSaveValue() ?: null); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + $field->value($object->categoryID ?? 0); + }), + ]), + FormContainer::create('content') + ->label('wcf.global.form.data') + ->appendChildren([ + TitleFormField::create('title') + ->l10n() + ->maximumLength(255) + ->saveValueCallback(static function (MediaBuilder $builder, TitleFormField $field): void { + $builder->setTitle($field->getL10nValues()); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + $field->value($object->getL10nValues('title')); + }), + MultilineTextFormField::create('caption') + ->label('wcf.media.caption') + ->l10n() + ->saveValueCallback(static function (MediaBuilder $builder, MultilineTextFormField $field): void { + $builder->setCaption($field->getL10nValues()); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + $field->value($object->getL10nValues('caption')); + }), + BooleanFormField::create('captionEnableHtml') + ->label('wcf.media.caption.enableHtml') + ->saveValueCallback(static function (MediaBuilder $builder, BooleanFormField $field): void { + $builder->setCaptionEnableHtml((bool)$field->getSaveValue()); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + $field->value($object->captionEnableHtml); + }), + TextFormField::create('altText') + ->label('wcf.media.altText') + ->l10n() + ->maximumLength(255) + ->saveValueCallback(static function (MediaBuilder $builder, TextFormField $field): void { + $builder->setAltText($field->getL10nValues()); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + $field->value($object->getL10nValues('altText')); + }), + ]), + FormContainer::create('access') + ->label('wcf.acl.access') + ->appendChild( + SimpleAclFormField::create('acl') + ->saveValueCallback(static function (MediaBuilder $builder, SimpleAclFormField $field): void { + $value = $field->getValue(); + if (!\is_array($value) || $value === []) { + return; + } + + $builder->setAclValues([ + 'allowAll' => (bool)($value['allowAll'] ?? true), + 'user' => \array_values(\array_map(\intval(...), $value['user'] ?? [])), + 'group' => \array_values(\array_map(\intval(...), $value['group'] ?? [])), + ]); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + $field->value( + SimpleAclHandler::getInstance()->getValues('com.woltlab.wcf.media', $object->mediaID) + ); + }) + ), + ]); + + $form->updatedObject($this->media); + $form->build(); + + return $form; + } + + private function getFileFormField(): FileProcessorFormField + { + $media = $this->media; + + return FileProcessorFormField::create('file') + ->objectType('com.woltlab.wcf.media') + ->label('wcf.media.file') + ->description('wcf.media.file.description') + ->singleFileUpload() + ->simpleReplace() + ->hideDeleteButton() + ->context(['mediaID' => $media->mediaID]) + ->addValidator(new FormFieldValidator( + 'sameFileType', + static function (FileProcessorFormField $field) use ($media): void { + $file = $field->getFiles()[$field->getValue()] ?? null; + if ($file === null || $file->fileID === $media->fileID) { + return; + } + + if ($file->mimeType !== $media->fileType) { + $field->addValidationError(new FormFieldValidationError( + 'differentFileType', + 'wcf.media.upload.error.differentFileType' + )); + } + } + )) + ->saveValueCallback(static function (MediaBuilder $builder, FileProcessorFormField $field) use ($media): void { + $file = $field->getFiles()[$field->getValue()] ?? null; + if (!($file instanceof File) || $file->fileID === $media->fileID) { + return; + } + + // The previous file is deleted by the builder. + $builder->setFile($file) + ->setFileUpdateTime(\TIME_NOW); + }) + ->loadValueCallback(static function (Media $object, IFormField $field): void { + if ($object->fileID !== null) { + $field->value($object->fileID); + } + }); + } + + /** + * @return list + */ + private function getCategoryOptions(): array + { + $options = [ + [ + 'depth' => 0, + 'label' => WCF::getLanguage()->get('wcf.global.noSelection'), + 'value' => 0, + ], + ]; + + foreach (new CategoryNodeTree('com.woltlab.wcf.media.category') as $category) { + $options[] = [ + 'depth' => $category->getDepth() - 1, + 'label' => $category->getTitle(), + 'value' => $category->categoryID, + ]; + } + + return $options; + } + + /** + * @return array{ + * mediaID: int, + * categoryID: ?int, + * title: string, + * filename: string, + * elementTag: string, + * } + */ + private function getResult(Media $media): array + { + return [ + 'mediaID' => $media->mediaID, + 'categoryID' => $media->categoryID, + 'title' => $media->getTitle(), + 'filename' => $media->filename, + 'elementTag' => $media->getElementTag(144), + ]; + } +} diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index 60eddb60128..297c52b6959 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -4197,6 +4197,8 @@ Erlaubte Dateiendungen: gif, jpg, jpeg, png, webp]]> filesize|filesize} – {plural value=$media->downloads 1="1 Download" other="# Downloads"}]]> + + filename}“ herunterladen]]> diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index d8f417b4eea..1ac67dc1f2f 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -4143,6 +4143,8 @@ Allowed extensions: gif, jpg, jpeg, png, webp]]> filesize|filesize} – {plural value=$media->downloads 1="1 Download" other="# Downloads"}]]> + + filename}”]]> From 7d1e8a392ddfae17afce24e9271dfbf68792e2aa Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Fri, 16 Oct 2026 22:10:12 +0200 Subject: [PATCH 04/50] Migrate the ACP media list to a list view --- com.woltlab.wcf/clipboardAction.xml | 2 - com.woltlab.wcf/fileDelete.xml | 2 + .../templates/shared_mediaListViewItems.tpl | 35 +++ .../Core/Acp/Component/Media/List.ts | 32 +++ ts/WoltLabSuite/Core/Controller/Media/List.ts | 101 --------- ts/WoltLabSuite/Core/Media/List/Upload.ts | 42 ---- .../install/files/acp/templates/mediaList.tpl | 214 ++---------------- .../Core/Acp/Component/Media/List.js | 31 +++ .../Core/Controller/Media/List.js | 83 ------- .../js/WoltLabSuite/Core/Media/List/Upload.js | 33 --- .../action/MediaSetCategoryAction.class.php | 109 +++++++++ .../lib/acp/page/MediaListPage.class.php | 176 ++------------ .../lib/action/MediaEditAction.class.php | 36 +-- .../MediaInteractionCollecting.class.php | 19 ++ .../MediaBulkInteractionCollecting.class.php | 19 ++ .../admin/MediaListViewInitialized.class.php | 19 ++ .../core/media/DeleteMedia.class.php | 39 ++++ .../media/MediaCategoryFormField.class.php | 67 ++++++ .../admin/MediaInteractions.class.php | 54 +++++ .../admin/MediaBulkInteractions.class.php | 49 ++++ .../listView/admin/MediaListView.class.php | 110 +++++++++ wcfsetup/install/files/style/ui/media.scss | 72 ++++++ wcfsetup/install/lang/de.xml | 2 + wcfsetup/install/lang/en.xml | 2 + 24 files changed, 696 insertions(+), 652 deletions(-) create mode 100644 com.woltlab.wcf/templates/shared_mediaListViewItems.tpl create mode 100644 ts/WoltLabSuite/Core/Acp/Component/Media/List.ts delete mode 100644 ts/WoltLabSuite/Core/Controller/Media/List.ts delete mode 100644 ts/WoltLabSuite/Core/Media/List/Upload.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Acp/Component/Media/List.js delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Controller/Media/List.js delete mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Media/List/Upload.js create mode 100644 wcfsetup/install/files/lib/acp/action/MediaSetCategoryAction.class.php create mode 100644 wcfsetup/install/files/lib/event/interaction/admin/MediaInteractionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/event/interaction/bulk/admin/MediaBulkInteractionCollecting.class.php create mode 100644 wcfsetup/install/files/lib/event/listView/admin/MediaListViewInitialized.class.php create mode 100644 wcfsetup/install/files/lib/system/endpoint/controller/core/media/DeleteMedia.class.php create mode 100644 wcfsetup/install/files/lib/system/form/builder/field/media/MediaCategoryFormField.class.php create mode 100644 wcfsetup/install/files/lib/system/interaction/admin/MediaInteractions.class.php create mode 100644 wcfsetup/install/files/lib/system/interaction/bulk/admin/MediaBulkInteractions.class.php create mode 100644 wcfsetup/install/files/lib/system/listView/admin/MediaListView.class.php diff --git a/com.woltlab.wcf/clipboardAction.xml b/com.woltlab.wcf/clipboardAction.xml index 21ffa86a7bf..9604dd1ccbc 100644 --- a/com.woltlab.wcf/clipboardAction.xml +++ b/com.woltlab.wcf/clipboardAction.xml @@ -13,7 +13,6 @@ wcf\system\clipboard\action\MediaClipboardAction 2 - wcf\acp\page\MediaListPage menuManagerDialog-editor menuManagerDialog-select @@ -22,7 +21,6 @@ wcf\system\clipboard\action\MediaClipboardAction 3 - wcf\acp\page\MediaListPage menuManagerDialog-editor menuManagerDialog-select diff --git a/com.woltlab.wcf/fileDelete.xml b/com.woltlab.wcf/fileDelete.xml index be6ebaf4e9a..54ca08e7763 100644 --- a/com.woltlab.wcf/fileDelete.xml +++ b/com.woltlab.wcf/fileDelete.xml @@ -26,6 +26,8 @@ js/WoltLabSuite/Core/Acp/Ui/User/Content/Remove/Handler.js js/WoltLabSuite/Core/Ui/User/Editor.js js/WoltLabSuite/Core/Ui/User/Trophy/List.js + js/WoltLabSuite/Core/Controller/Media/List.js + js/WoltLabSuite/Core/Media/List/Upload.js lib/acp/action/UserQuickSearchAction.class.php lib/acp/form/UserSearchForm.class.php lib/data/user/UserContentAction.class.php diff --git a/com.woltlab.wcf/templates/shared_mediaListViewItems.tpl b/com.woltlab.wcf/templates/shared_mediaListViewItems.tpl new file mode 100644 index 00000000000..6623976b9a8 --- /dev/null +++ b/com.woltlab.wcf/templates/shared_mediaListViewItems.tpl @@ -0,0 +1,35 @@ +{foreach from=$view->getItems() item='media'} +
+
+ {unsafe:$media->getElementTag(128)} +
+ +
+

+ {$media->getTitle()} +

+
+ {if $media->getTitle() !== $media->filename}{$media->filename} {/if}{$media->filesize|filesize} +
+
+ {user object=$media->getUserProfile()} {time time=$media->uploadTime} +
+ + {event name='content'} +
+ + {hascontent} +
+ {content} + {if $view->hasBulkInteractions()} + + {/if} + + {unsafe:$view->renderInteractionContextMenuButton($media)} + {/content} +
+ {/hascontent} +
+{/foreach} diff --git a/ts/WoltLabSuite/Core/Acp/Component/Media/List.ts b/ts/WoltLabSuite/Core/Acp/Component/Media/List.ts new file mode 100644 index 00000000000..4eeec29b492 --- /dev/null +++ b/ts/WoltLabSuite/Core/Acp/Component/Media/List.ts @@ -0,0 +1,32 @@ +/** + * Reloads the list of media files after new files have been uploaded. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + * @woltlabExcludeBundle all + */ + +import type WoltlabCoreFileElement from "WoltLabSuite/Core/Component/File/woltlab-core-file"; + +export function setup(uploadButton: HTMLElement, listViewItems: HTMLElement): void { + let pendingUploads = 0; + + uploadButton.addEventListener("uploadStart", (event: CustomEvent) => { + pendingUploads++; + + void event.detail.ready + .catch(() => { + // The upload has failed, the error is shown by the upload button. + }) + .finally(() => { + pendingUploads--; + + // Reload the list only once after a batch of files has been uploaded. + if (pendingUploads === 0) { + listViewItems.dispatchEvent(new CustomEvent("interaction:invalidate-all")); + } + }); + }); +} diff --git a/ts/WoltLabSuite/Core/Controller/Media/List.ts b/ts/WoltLabSuite/Core/Controller/Media/List.ts deleted file mode 100644 index c31cf697f5c..00000000000 --- a/ts/WoltLabSuite/Core/Controller/Media/List.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Initializes modules required for media list view. - * - * @author Matthias Schmidt - * @copyright 2001-2021 WoltLab GmbH - * @license GNU Lesser General Public License - * @woltlabExcludeBundle all - */ - -import MediaListUpload from "../../Media/List/Upload"; -import * as MediaClipboard from "../../Media/Clipboard"; -import * as EventHandler from "../../Event/Handler"; -import MediaEditor from "../../Media/Editor"; -import * as DomChangeListener from "../../Dom/Change/Listener"; -import { Media, MediaUploadSuccessEventData } from "../../Media/Data"; -import MediaManager from "../../Media/Manager/Base"; - -const _mediaEditor = new MediaEditor({ - _editorSuccess: (media: Media, oldCategoryId: number, closedEditorDialog = true) => { - if (media.categoryID != oldCategoryId || closedEditorDialog) { - window.setTimeout(() => { - window.location.reload(); - }, 500); - } - }, -}); -const _tableBody = document.getElementById("mediaListTableBody")!; -let _upload: MediaListUpload; - -interface MediaListOptions { - categoryId?: number; - hasMarkedItems?: boolean; -} - -export function init(options: MediaListOptions): void { - options = options || {}; - _upload = new MediaListUpload("uploadButton", "mediaListTableBody", { - categoryId: options.categoryId, - multiple: true, - elementTagSize: 48, - }); - - MediaClipboard.init("wcf\\acp\\page\\MediaListPage", options.hasMarkedItems || false, { - clipboardDeleteMedia: (mediaIds: number[]) => clipboardDeleteMedia(mediaIds), - } as MediaManager); - - addButtonEventListeners(); - - DomChangeListener.add("WoltLabSuite/Core/Controller/Media/List", () => addButtonEventListeners()); - - EventHandler.add("com.woltlab.wcf.media.upload", "success", (data: MediaUploadSuccessEventData) => - openEditorAfterUpload(data), - ); -} - -/** - * Adds the `click` event listeners to the media edit icons in new media table rows. - */ -function addButtonEventListeners(): void { - Array.from(_tableBody.getElementsByClassName("jsMediaEditButton")).forEach((button) => { - button.classList.remove("jsMediaEditButton"); - button.addEventListener("click", (ev) => edit(ev)); - }); -} - -/** - * Is called when a media edit icon is clicked. - */ -function edit(event: Event): void { - void _mediaEditor.edit(~~(event.currentTarget as HTMLElement).dataset.objectId!); -} - -/** - * Opens the media editor after uploading a single file. - */ -function openEditorAfterUpload(data: MediaUploadSuccessEventData) { - if (data.upload === _upload && !data.isMultiFileUpload && !_upload.hasPendingUploads()) { - const keys = Object.keys(data.media); - - if (keys.length) { - void _mediaEditor.edit(data.media[keys[0]]); - } - } -} - -/** - * Is called after the media files with the given ids have been deleted via clipboard. - */ -function clipboardDeleteMedia(mediaIds: number[]) { - Array.from(document.getElementsByClassName("jsMediaRow")).forEach((media) => { - const mediaID = ~~(media.querySelector(".jsClipboardItem") as HTMLElement).dataset.objectId!; - - if (mediaIds.indexOf(mediaID) !== -1) { - media.remove(); - } - }); - - if (!document.getElementsByClassName("jsMediaRow").length) { - window.location.reload(); - } -} diff --git a/ts/WoltLabSuite/Core/Media/List/Upload.ts b/ts/WoltLabSuite/Core/Media/List/Upload.ts deleted file mode 100644 index 7fcf2a9fcd5..00000000000 --- a/ts/WoltLabSuite/Core/Media/List/Upload.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Uploads media files. - * - * @author Matthias Schmidt - * @copyright 2001-2021 WoltLab GmbH - * @license GNU Lesser General Public License - * @woltlabExcludeBundle tiny - */ - -import MediaUpload from "../Upload"; -import { MediaListUploadOptions } from "../Data"; -import * as Core from "../../Core"; - -class MediaListUpload extends MediaUpload { - protected _createButton(): void { - super._createButton(); - - const span = this._button.querySelector("span") as HTMLSpanElement; - - const space = document.createTextNode(" "); - span.insertBefore(space, span.childNodes[0]); - - const icon = document.createElement("fa-icon"); - icon.setIcon("upload"); - span.insertBefore(icon, span.childNodes[0]); - } - - protected _getParameters(): ArbitraryObject { - if (this._options.categoryId) { - return Core.extend( - super._getParameters(), - { - categoryID: this._options.categoryId, - }, - ) as ArbitraryObject; - } - - return super._getParameters(); - } -} - -export = MediaListUpload; diff --git a/wcfsetup/install/files/acp/templates/mediaList.tpl b/wcfsetup/install/files/acp/templates/mediaList.tpl index 51341a710f2..c5b24450fbb 100644 --- a/wcfsetup/install/files/acp/templates/mediaList.tpl +++ b/wcfsetup/install/files/acp/templates/mediaList.tpl @@ -1,216 +1,30 @@ {include file='header' pageTitle='wcf.media.media.pageTitle'} - -
-

{lang}wcf.media.media.pageTitle{/lang}{if $items} {#$items}{/if}

+

{lang}wcf.media.media.pageTitle{/lang}

- +
-{include file='shared_formError'} - -
-
-

{lang}wcf.global.filter{/lang}

- -
- {hascontent} -
-
-
- -
-
- {/hascontent} - -
-
-
- -
-
- -
-
-
- -
-
- - {event name='filterFields'} -
- -
- - {csrfToken} -
-
-
- -{assign var='linkParameters' value=''} -{if $username}{capture append=linkParameters}&username={unsafe:$username|rawurlencode}{/capture}{/if} -{if $q}{capture append=linkParameters}&q={unsafe:$q|rawurlencode}{/capture}{/if} -{if $categoryID}{capture append=linkParameters}&categoryID={$categoryID}{/capture}{/if} - -{if $pages > 1} -
- -
-{/if} - -