diff --git a/composer.json b/composer.json index 553c7ddb97..04f02a42bc 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ }, "require": { "php": "^8.0.2", - "winter/storm": "dev-develop as 1.2", + "winter/storm": "dev-wip/path-enumerable-trait as 1.2", "winter/wn-system-module": "dev-develop", "winter/wn-backend-module": "dev-develop", "winter/wn-cms-module": "dev-develop", diff --git a/modules/backend/formwidgets/codeeditor/assets/js/build-min.js b/modules/backend/formwidgets/codeeditor/assets/js/build-min.js index e1dd79a93e..4a6c376976 100644 --- a/modules/backend/formwidgets/codeeditor/assets/js/build-min.js +++ b/modules/backend/formwidgets/codeeditor/assets/js/build-min.js @@ -1290,9 +1290,9 @@ getData=function(row){return popup.data[row];};popup.getRow=function(){return se z-index: 2;\ }\ .ace_editor.ace_autocomplete .ace_scroller {\ - background: none;\ - border: none;\ - box-shadow: none;\ + background: none;\ + border: none;\ + box-shadow: none;\ }\ .ace_rightAlignedText {\ color: gray;\ diff --git a/modules/backend/widgets/MediaManager.php b/modules/backend/widgets/MediaManager.php index 067d53a50d..2ae8b907ea 100644 --- a/modules/backend/widgets/MediaManager.php +++ b/modules/backend/widgets/MediaManager.php @@ -11,6 +11,7 @@ use System\Classes\ImageResizer; use System\Classes\MediaLibrary; use System\Classes\MediaLibraryItem; +use System\Models\Parameter; /** * Media Manager widget. @@ -833,6 +834,29 @@ public function onResizeImage(): array ]; } + /** + * Executed when the media library has not yet been scanned, or the user opts to manually re-scan the media library. + * + * @return void + */ + public function onScan() + { + return $this->makePartial('scan-popup'); + } + + public function onScanExecute() + { + MediaLibrary::instance()->scan(); + + $this->prepareVars(); + + return [ + '#'.$this->getId('item-list') => $this->makePartial('item-list'), + '#'.$this->getId('folder-path') => $this->makePartial('folder-path'), + '#'.$this->getId('filters') => $this->makePartial('filters') + ]; + } + // // Methods for internal use // @@ -859,6 +883,7 @@ protected function prepareVars() $this->vars['items'] = $this->findFiles($searchTerm, $filter, ['by' => $sortBy, 'direction' => $sortDirection]); } + $this->vars['isScanned'] = !is_null(Parameter::get('media::scan.last_scanned')); $this->vars['currentFolder'] = $folder; $this->vars['isRootFolder'] = $folder == self::FOLDER_ROOT; $this->vars['pathSegments'] = $this->splitPathToSegments($folder); diff --git a/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js b/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js index 4ec9f0e50d..66d599b7ac 100644 --- a/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js +++ b/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js @@ -53,7 +53,8 @@ if(this.options.cropAndInsertButton)this.$el.find('[data-popup-command="crop-and this.updateSidebarPreview() this.generateThumbnails() this.initUploader() -this.initScroll()} +this.initScroll() +if(!this.options.isScanned){this.doScan()}} MediaManager.prototype.registerHandlers=function(){this.$el.on('dblclick',this.proxy(this.onNavigate)) this.$el.on('click.tree-path','ul.tree-path, [data-control="sidebar-labels"]',this.proxy(this.onNavigate)) this.$el.on('click.command','[data-command]',this.proxy(this.onCommandClick)) @@ -358,6 +359,7 @@ break;case'ArrowLeft':case'ArrowUp':this.selectRelative(false,ev.shiftKey) eventHandled=true break;}if(eventHandled){ev.preventDefault() ev.stopPropagation()}} +MediaManager.prototype.doScan=function(){$.popup({handler:'onScan'});} MediaManager.DEFAULTS={url:window.location,uploadHandler:null,alias:'',deleteEmpty:'Please select files to delete.',deleteConfirm:'Delete the selected file(s)?',moveEmpty:'Please select files to move.',selectSingleImage:'Please select a single image.',selectionNotImage:'The selected item is not an image.',bottomToolbar:false,cropAndInsertButton:false} var old=$.fn.mediaManager $.fn.mediaManager=function(option){var args=Array.prototype.slice.call(arguments,1),result=undefined diff --git a/modules/backend/widgets/mediamanager/assets/js/mediamanager.js b/modules/backend/widgets/mediamanager/assets/js/mediamanager.js index af1cec1a3c..f29a1ab652 100644 --- a/modules/backend/widgets/mediamanager/assets/js/mediamanager.js +++ b/modules/backend/widgets/mediamanager/assets/js/mediamanager.js @@ -131,6 +131,10 @@ this.generateThumbnails() this.initUploader() this.initScroll() + + if (!this.options.isScanned) { + this.doScan() + } } MediaManager.prototype.registerHandlers = function() { @@ -1283,6 +1287,16 @@ } } + // + // Media scanning + // + + MediaManager.prototype.doScan = function() { + $.popup({ + handler: 'onScan' + }); + } + // MEDIA MANAGER PLUGIN DEFINITION // ============================ diff --git a/modules/backend/widgets/mediamanager/partials/_body.php b/modules/backend/widgets/mediamanager/partials/_body.php index 3dd1b17e0a..2c2631159d 100644 --- a/modules/backend/widgets/mediamanager/partials/_body.php +++ b/modules/backend/widgets/mediamanager/partials/_body.php @@ -11,6 +11,7 @@ class="layout" data-bottom-toolbar="bottomToolbar ? 'true' : 'false' ?>" data-crop-and-insert-button="cropAndInsertButton ? 'true' : 'false' ?>" data-read-only="readOnly ? 'true' : 'false'; ?>" + data-is-scanned="" tabindex="0" > diff --git a/modules/backend/widgets/mediamanager/partials/_scan-popup.htm b/modules/backend/widgets/mediamanager/partials/_scan-popup.htm new file mode 100644 index 0000000000..09cb04e16a --- /dev/null +++ b/modules/backend/widgets/mediamanager/partials/_scan-popup.htm @@ -0,0 +1,31 @@ + + + diff --git a/modules/system/classes/MediaLibrary.php b/modules/system/classes/MediaLibrary.php index 9bf4eaadb5..37afcdb4f0 100644 --- a/modules/system/classes/MediaLibrary.php +++ b/modules/system/classes/MediaLibrary.php @@ -5,12 +5,18 @@ use Cache; use Config; use Storage; -use Request; use Url; -use Winter\Storm\Filesystem\Definitions as FileDefinitions; +use System\Models\MediaItem; +use System\Models\Parameter; use Illuminate\Filesystem\FilesystemAdapter; -use ApplicationException; -use SystemException; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use League\Flysystem\DirectoryAttributes; +use League\Flysystem\FileAttributes; +use League\Flysystem\StorageAttributes; +use Winter\Storm\Argon\Argon; +use Winter\Storm\Exception\ApplicationException ; +use Winter\Storm\Exception\SystemException; +use Winter\Storm\Filesystem\Definitions as FileDefinitions; /** * Provides abstraction level for the Media Library operations. @@ -66,6 +72,11 @@ class MediaLibrary */ protected $storageFolderNameLength; + /** + * @var array Scanned meta, used to compare a subsequent scan to act only on changes. + */ + protected $scannedMeta; + /** * Initialize this singleton. */ @@ -112,56 +123,15 @@ public function getCacheKey() * @param boolean $ignoreFolders Determines whether folders should be suppressed in the result list. * @return array Returns an array of MediaLibraryItem objects. */ - public function listFolderContents($folder = '/', $sortBy = 'title', $filter = null, $ignoreFolders = false) + public function listFolderContents($path = '/', $sortBy = 'title', $filter = null, $ignoreFolders = false) { - $folder = self::validatePath($folder); - $fullFolderPath = $this->getMediaPath($folder); - - /* - * Try to load the contents from cache - */ - - $cached = Cache::get($this->cacheKey, false); - $cached = $cached ? @unserialize(@base64_decode($cached)) : []; - - if (!is_array($cached)) { - $cached = []; - } - - if (array_key_exists($fullFolderPath, $cached)) { - $folderContents = $cached[$fullFolderPath]; - } - else { - $folderContents = $this->scanFolderContents($fullFolderPath); - - $cached[$fullFolderPath] = $folderContents; - $expiresAt = now()->addMinutes(Config::get('cms.storage.media.ttl', 10)); - Cache::put( - $this->cacheKey, - base64_encode(serialize($cached)), - $expiresAt - ); - } - - /* - * Sort the result and combine the file and folder lists - */ - - if ($sortBy !== false) { - $this->sortItemList($folderContents['files'], $sortBy); - $this->sortItemList($folderContents['folders'], $sortBy); + try { + $folder = MediaItem::folder($path); + } catch (ModelNotFoundException $e) { + return []; } - $this->filterItemList($folderContents['files'], $filter); - - if (!$ignoreFolders) { - $folderContents = array_merge($folderContents['folders'], $folderContents['files']); - } - else { - $folderContents = $folderContents['files']; - } - - return $folderContents; + return $folder->contents($sortBy, $filter, $ignoreFolders); } /** @@ -175,33 +145,7 @@ public function listFolderContents($folder = '/', $sortBy = 'title', $filter = n */ public function findFiles($searchTerm, $sortBy = 'title', $filter = null) { - $words = explode(' ', Str::lower($searchTerm)); - $result = []; - - $findInFolder = function ($folder) use (&$findInFolder, $words, &$result, $sortBy, $filter) { - $folderContents = $this->listFolderContents($folder, $sortBy, $filter); - - foreach ($folderContents as $item) { - if ($item->type == MediaLibraryItem::TYPE_FOLDER) { - $findInFolder($item->path); - } - elseif ($this->pathMatchesSearch($item->path, $words)) { - $result[] = $item; - } - } - }; - - $findInFolder('/'); - - /* - * Sort the result - */ - - if ($sortBy !== false) { - $this->sortItemList($result, $sortBy); - } - - return $result; + return MediaItem::getRoot()->search($searchTerm, $sortBy, $filter); } /** @@ -275,36 +219,9 @@ public function folderExists($path) */ public function listAllDirectories($exclude = []) { - $fullPath = $this->getMediaPath('/'); - - $folders = $this->getStorageDisk()->allDirectories($fullPath); - - $folders = array_unique($folders, SORT_LOCALE_STRING); - - $result = []; - - foreach ($folders as $folder) { - $folder = $this->getMediaRelativePath($folder); - if (!strlen($folder)) { - $folder = '/'; - } - - if (Str::startsWith($folder, $exclude)) { - continue; - } - if (!$this->isVisible($folder)) { - $exclude[] = $folder . '/'; - continue; - } - - $result[] = $folder; - } - - if (!in_array('/', $result)) { - array_unshift($result, '/'); - } - - return $result; + return array_map(function ($item) { + return $item->path; + }, MediaItem::getRoot()->folders($exclude)); } /** @@ -552,6 +469,178 @@ public function getPathUrl($path) } } + /** + * Scans the disk and stores all metadata in the "media_items" table for performant traversing and filtering. + * + * Scanning, by default, will be done in a synchronisation fashion - only metadata that needs to be updated + * will be updated, in order to keep subsequent scans quicker. It does this by tracking the path and the + * modification time, however, you may opt to force a full resync using the `$forceResync` parameter. + * + * @param MediaItem $folder The root media folder for this iteration. If `null`, the system assumes the root. + * @param string|null $path The root path of this folder. + * @param bool $forceResync If `true`, a full resync is done by truncating the "media_items" table. + * + * @return void + */ + public function scan(MediaItem $folder = null, $path = null, $forceResync = false) + { + $isRoot = is_null($folder); + + if ($isRoot) { + if ($forceResync) { + MediaItem::truncate(); + } + + $this->scannedMeta = MediaItem::notRoot() + ->get() + ->pluck('modified_at', 'path') + ->map(function ($item) { + return Argon::parse($item)->getTimestamp(); + }) + ->toArray(); + + $rootMedia = $this->getMediaPath('/'); + $contents = $this->getStorageDisk()->listContents($rootMedia); + $folder = MediaItem::getRoot(); + } else { + $contents = $this->getStorageDisk()->listContents($path); + } + + // Filter contents so that ignored filenames and patterns are applied + $contents = $contents->filter(function (StorageAttributes $item) { + return $this->isVisible($item->path()); + }); + + /** @var StorageAttributes $item */ + foreach ($contents as $item) { + $mediaPath = $this->getMediaRelativePath($item['path']); + + if ($item->type() === 'dir') { + // Determine if we are adding a new directory + if (!isset($this->scannedMeta[$mediaPath])) { + $subFolder = $this->createFolderMeta($folder, $item); + } else { + $subFolder = MediaItem::where('path', $mediaPath)->first(); + unset($this->scannedMeta[$mediaPath]); + } + + if (!is_null($subFolder)) { + $this->scan($subFolder, $item['path']); + } + continue; + } + + if (!isset($this->scannedMeta[$mediaPath])) { + // New file detected + $this->createFileMeta($folder, $item); + continue; + } elseif ($this->scannedMeta[$mediaPath] < $item->timestamp) { + // File was modified + MediaItem::where('path', $mediaPath)->delete(); + $this->createFileMeta($folder, $item); + } + + unset($this->scannedMeta[$mediaPath]); + } + + // Any scanned meta still in the list when we have looped through the root files are now deleted + if ($isRoot && count($this->scannedMeta)) { + foreach (array_keys($this->scannedMeta) as $path) { + MediaItem::where('path', $path)->delete(); + } + } + + // Update last scan parameter + if ($isRoot) { + Parameter::set('media::scan.last_scanned', Argon::now()); + } + } + + /** + * Creates a meta record for a folder in the "media_items" table, as a subfolder of the parent folder. + */ + protected function createFolderMeta(MediaItem $parent, DirectoryAttributes $meta): MediaItem + { + $path = self::validatePath($meta->path()); + + return $parent->children()->create([ + 'name' => basename($path), + 'path' => $this->getMediaRelativePath($path), + 'type' => MediaLibraryItem::TYPE_FOLDER, + 'size' => 0, + 'modified_at' => Argon::createFromTimestamp($meta->lastModified()), + ]); + } + + /** + * Creates a meta record for a file in the "media_items" table, as a child file of the parent folder. + */ + protected function createFileMeta(MediaItem $parent, FileAttributes $meta): MediaItem + { + $path = self::validatePath($meta->path()); + $path = $this->getMediaRelativePath($path); + + // Create a temporary media library item instance + $mediaItem = new MediaLibraryItem( + $path, + $meta->fileSize(), + $meta->lastModified(), + MediaLibraryItem::TYPE_FILE, + $this->getPathUrl($path) + ); + + // Standard metadata + $file = $parent->children()->make([ + 'name' => basename($path), + 'path' => $path, + 'type' => MediaLibraryItem::TYPE_FILE, + 'extension' => strtolower(pathinfo($path, PATHINFO_EXTENSION)), + 'size' => $meta->fileSize(), + 'modified_at' => Argon::createFromTimestamp($meta->lastModified()), + ]); + + // Extra metadata + $file->file_type = $mediaItem->getFileType(); + + if ($mediaItem->getFileType() === MediaLibraryItem::FILE_TYPE_IMAGE) { + $this->setImageMeta($file, $meta->path()); + } + + $file->save(); + + return $file; + } + + /** + * Gets meta for images. + * + * This scans the image for the dimensions and stores them in the meta table. + * + * @param MediaItem $file + * @param string $path + * @return void + */ + protected function setImageMeta(MediaItem $file, $path) + { + $size = @getimagesizefromstring($this->getStorageDisk()->read($path)); + if (!is_array($size)) { + return; + } + + $file->setMetadata([ + 'width' => [ + 'label' => 'system::lang.media.metadata_image_width', + 'order' => 200, + 'value' => $size[0], + ], + 'height' => [ + 'label' => 'system::lang.media.metadata_image_height', + 'order' => 220, + 'value' => $size[1], + ] + ]); + } + /** * Returns a file or folder path with the prefixed storage folder. * @param string $path Specifies a path to process. @@ -695,85 +784,12 @@ protected function scanFolderContents($fullFolderPath) return $result; } - /** - * Sorts the item list by title, size or last modified date. - * @param array $itemList Specifies the item list to sort. - * @param mixed $sortSettings Determines the sorting preference. - * Supported values are 'title', 'size', 'lastModified' (see SORT_BY_XXX class constants) or an associative array with a 'by' key and a 'direction' key: ['by' => SORT_BY_XXX, 'direction' => SORT_DIRECTION_XXX]. - */ - protected function sortItemList(&$itemList, $sortSettings) - { - $files = []; - $folders = []; - - // Convert string $sortBy to array - if (is_string($sortSettings)) { - $sortSettings = [ - 'by' => $sortSettings, - 'direction' => self::SORT_DIRECTION_ASC, - ]; - } - - usort($itemList, function ($a, $b) use ($sortSettings) { - $result = 0; - - switch ($sortSettings['by']) { - case self::SORT_BY_TITLE: - $result = strcasecmp($a->path, $b->path); - break; - case self::SORT_BY_SIZE: - if ($a->size < $b->size) { - $result = -1; - } else { - $result = $a->size > $b->size ? 1 : 0; - } - break; - case self::SORT_BY_MODIFIED: - if ($a->lastModified < $b->lastModified) { - $result = -1; - } else { - $result = $a->lastModified > $b->lastModified ? 1 : 0; - } - break; - } - - // Reverse the polarity of the result to direct sorting in a descending order instead - if ($sortSettings['direction'] === self::SORT_DIRECTION_DESC) { - $result = 0 - $result; - } - - return $result; - }); - } - - /** - * Filters item list by file type. - * @param array $itemList Specifies the item list to sort. - * @param string $filter Determines the document type filtering preference. - * Supported values are 'image', 'video', 'audio', 'document' (see FILE_TYPE_XXX constants of MediaLibraryItem class). - */ - protected function filterItemList(&$itemList, $filter) - { - if (!$filter) { - return; - } - - $result = []; - foreach ($itemList as $item) { - if ($item->getFileType() == $filter) { - $result[] = $item; - } - } - - $itemList = $result; - } - /** * Initializes and returns the Media Library disk. * This method should always be used instead of trying to access the * $storageDisk property directly as initializing the disc requires * communicating with the remote storage. - * @return mixed Returns the storage disk object. + * @return \Illuminate\Contracts\Filesystem\Filesystem Returns the storage disk object. */ public function getStorageDisk(): FilesystemAdapter { @@ -786,30 +802,6 @@ public function getStorageDisk(): FilesystemAdapter ); } - /** - * Determines if file path contains all words form the search term. - * @param string $path Specifies a path to examine. - * @param array $words A list of words to check against. - * @return boolean - */ - protected function pathMatchesSearch($path, $words) - { - $path = Str::lower($path); - - foreach ($words as $word) { - $word = trim($word); - if (!strlen($word)) { - continue; - } - - if (!Str::contains($path, $word)) { - return false; - } - } - - return true; - } - protected function generateRandomTmpFolderName($location) { $temporaryDirBaseName = time(); diff --git a/modules/system/database/migrations/2023_08_31_000028_Db_Media_Items.php b/modules/system/database/migrations/2023_08_31_000028_Db_Media_Items.php new file mode 100644 index 0000000000..51152b3b5d --- /dev/null +++ b/modules/system/database/migrations/2023_08_31_000028_Db_Media_Items.php @@ -0,0 +1,36 @@ +engine = 'InnoDB'; + $table->increments('id'); + $table->integer('parent_id')->unsigned()->nullable(); + $table->string('location', 192)->nullable(); + $table->string('type', 20)->nullable(); + $table->string('file_type', 20)->nullable(); + $table->string('name'); + $table->string('extension', 20)->nullable(); + $table->string('path')->nullable(); + $table->bigInteger('size')->unsigned(); + $table->text('metadata')->nullable(); + $table->dateTime('modified_at')->nullable(); + + // Indexes + $table->index(['parent_id'], 'media_idx_parent_id'); + $table->index(['type'], 'media_idx_type'); + $table->index(['file_type'], 'media_idx_file_type'); + }); + } + + public function down() + { + Schema::drop('media_items'); + } +} diff --git a/modules/system/lang/en/lang.php b/modules/system/lang/en/lang.php index 6cf70bb894..75c74a4a05 100644 --- a/modules/system/lang/en/lang.php +++ b/modules/system/lang/en/lang.php @@ -449,6 +449,10 @@ ], 'media' => [ 'invalid_path' => "Invalid file path specified: ':path'.", + 'folder_size_items' => 'item(s)', + 'metadata_image_width' => 'Width', + 'metadata_image_height' => 'Height', + 'metadata_video_duration' => 'Duration', 'folder_size_items' => 'item|items', ], 'page' => [ diff --git a/modules/system/models/MediaItem.php b/modules/system/models/MediaItem.php new file mode 100644 index 0000000000..ac2aeb4114 --- /dev/null +++ b/modules/system/models/MediaItem.php @@ -0,0 +1,393 @@ +whereNull('parent_id'); + } + + /** + * Scope to get all but the root folder of the media library. + * + * @param Builder $query + * @return void + */ + public function scopeNotRoot(Builder $query) + { + $query->whereNotNull('parent_id'); + } + + /** + * Override of base `setAttribute` method. + * + * Prevents writing to metadata key directly. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + public function setAttribute($key, $value) + { + if ($key === 'metadata') { + return false; + } + + return parent::setAttribute($key, $value); + } + + /** + * Finds a folder at the specific path. + * + * @param string $path + * @return static + */ + public static function folder($path = '/') + { + if ($path === '/' || empty($path)) { + return self::getRoot(); + } + + $query = self::query(); + + $query + ->where('type', MediaLibraryItem::TYPE_FOLDER) + ->where('path', $path); + + return $query->firstOrFail(); + } + + /** + * Retrieves the contents of a folder. + * + * This includes both files and folders. + * + * @param string|array $sortBy + * @param string|null $filter + * @param boolean $ignoreFolders + * @return array + */ + public function contents($sortBy = 'title', $filter = null, $ignoreFolders = false) + { + $query = $this->children(); + + $query = $this->applySort($query, $sortBy); + if (!is_null($filter)) { + $query = $this->applyFilter($query, $filter); + } + + if ($ignoreFolders) { + $query->where('type', MediaLibraryItem::TYPE_FILE); + } + + return $query + ->get() + ->map(function ($item) { + return $item->toMediaLibraryItem(); + }) + ->toArray(); + } + + + /** + * Retrieves the folders within a folder. + * + * You may optionally provide an array of paths to exclude. + * + * @param array $exclude + * @param bool $includeSelf + * @return array + */ + public function folders($exclude = [], $includeSelf = true) + { + $query = $this->descendants()->where('type', MediaLibraryItem::TYPE_FOLDER); + + $directories = $query->get(); + + if ($includeSelf) { + $directories->prepend($this); + } + + return $directories + ->filter(function ($item) use ($exclude) { + foreach ($exclude as $path) { + if (Str::startsWith($item->path, $exclude)) { + return false; + } + } + + return true; + }) + ->map(function ($item) { + return $item->toMediaLibraryItem(); + }) + ->toArray(); + } + + /** + * Searches within a folder recursively for files or folders that match a search query. + * + * @param string|array $sortBy + * @param string|null $filter + * @param boolean $ignoreFolders + * @return array + */ + public function search($searchTerm, $sortBy = 'title', $filter = null) + { + // Normalise search term(s) + $words = explode(' ', Str::lower($searchTerm)); + + $query = $this->descendants(); + + // Only return files as results + $query->where('type', MediaLibraryItem::TYPE_FILE); + + $query = $this->applySort($query, $sortBy); + if (!is_null($filter)) { + $query = $this->applyFilter($query, $filter); + } + + // Add search query + $query->where(function ($query) use ($words) { + foreach ($words as $word) { + $query->orWhere('path', 'like', '%' . $word . '%'); + } + }); + + // Group by type + return $query + ->get() + ->map(function ($item) { + return $item->toMediaLibraryItem(); + }) + ->toArray(); + } + + /** + * Converts a media item model instance into a MediaLibraryItem instance. + * + * @return MediaLibraryItem + */ + protected function toMediaLibraryItem() + { + return new MediaLibraryItem( + $this->path, + $this->size, + (isset($this->modified_at)) ? $this->modified_at->getTimestamp() : null, + $this->type, + '' + ); + } + + /** + * Sets metadata for this media item. + * + * @param string|array $key + * @param mixed $value + * @param string|null $label + * @param integer $order + * @param string $group + * @return void + */ + public function setMetadata($key, $value = null, $label = null, $order = 100, $group = 'Metadata') + { + if (is_array($key)) { + foreach ($key as $name => $options) { + $this->setMetadata( + $name, + $options['value'] ?? null, + $options['label'] ?? $name, + $options['order'] ?? 100, + $options['group'] ?? 'Metadata' + ); + } + return; + } + + $meta = array_replace($this->metadata ?? [], [ + $key => [ + 'label' => $label ?? $key, + 'order' => $order, + 'group' => $group, + 'value' => $value, + ] + ]); + + parent::setAttribute('metadata', $meta); + } + + /** + * Applies sorting to the contents of a folder. + * + * @param HasMany|Builder $query + * @param string|array $sortBy Accepts either a string value of "title", "size", or "lastModified", or an array + * with two keys: "by" which represents the sort type (same 3 options as above), and "direction" - either "asc" + * or "desc". + * @return HasMany|Builder + */ + protected function applySort($query, $sortBy) + { + // Force ordering of folders first + $query->orderBy('type', 'DESC'); + + if (is_array($sortBy)) { + $sortDir = $sortBy['direction'] ?? 'asc'; + $sortBy = $sortBy['by']; + } else { + $sortDir = 'asc'; + } + + if (!in_array($sortBy, ['title', 'size', 'lastModified'])) { + return $query; + } + + switch ($sortBy) { + case 'title': + $query->orderBy('name', $sortDir); + break; + case 'size': + $query->orderBy('size', $sortDir); + break; + case 'lastModified': + $query->orderBy('modified_at', $sortDir); + break; + } + + return $query; + } + + /** + * Applies a filter to the contents of the directory. This only applies to files, not folders. + * + * @param HasMany|Builder $query + * @param string $filter Accepts one of the following: "audio", "document", "image", "video" + * @return HasMany|Builder + */ + protected function applyFilter($query, $filter) + { + if (!in_array($filter, [ + MediaLibraryItem::FILE_TYPE_AUDIO, + MediaLibraryItem::FILE_TYPE_DOCUMENT, + MediaLibraryItem::FILE_TYPE_IMAGE, + MediaLibraryItem::FILE_TYPE_VIDEO, + ])) { + return $query; + } + + return $query->where(function ($query) use ($filter) { + $query->where('file_type', $filter) + ->orWhere('type', MediaLibraryItem::TYPE_FOLDER); + }); + } + + /** + * Creates an empty root node to represent the root folder of the media library. + * + * @return static + */ + protected static function makeRoot() + { + $root = new static; + $root->parent_id = null; + $root->type = MediaLibraryItem::TYPE_FOLDER; + $root->name = 'Root'; + $root->path = '/'; + $root->size = 0; + $root->save(); + + return $root; + } + + /** + * Gets the root folder, or creates one if it does not exist. + * + * @return static + */ + public static function getRoot() + { + if (self::$rootCache) { + return self::$rootCache; + } + + $query = self::query(); + + // Find the root folder node + $query + ->whereNull('parent_id'); + + if (!$query->count()) { + // Create a root node if one does not already exist + return self::$rootCache = self::makeRoot(); + } + + return self::$rootCache = $query->first(); + } +} diff --git a/modules/system/tests/classes/MediaLibraryTest.php b/modules/system/tests/classes/MediaLibraryTest.php index ac9e67f6ae..f454408b61 100644 --- a/modules/system/tests/classes/MediaLibraryTest.php +++ b/modules/system/tests/classes/MediaLibraryTest.php @@ -3,7 +3,7 @@ namespace System\Tests\Classes; use System\Tests\Bootstrap\TestCase; -use Illuminate\Filesystem\FilesystemAdapter; +use League\Flysystem\FilesystemInterface; use System\Classes\MediaLibrary; class MediaLibraryTest extends TestCase @@ -84,38 +84,168 @@ public function testListFolderContents() $this->setUpStorage(); $this->copyMedia(); + // Rescan library + MediaLibrary::instance()->scan(null, null, true); + $contents = MediaLibrary::instance()->listFolderContents(); + $this->assertNotEmpty($contents, 'Media library item is not discovered'); $this->assertCount(3, $contents); - $this->assertEquals('file', $contents[2]->type, 'Media library item does not have the right type'); - $this->assertEquals('/winter.png', $contents[2]->path, 'Media library item does not have the right path'); - $this->assertNotEmpty($contents[2]->lastModified, 'Media library item last modified is empty'); - $this->assertNotEmpty($contents[2]->size, 'Media library item size is empty'); - $this->assertEquals('file', $contents[0]->type, 'Media library item does not have the right type'); $this->assertEquals('/text.txt', $contents[0]->path, 'Media library item does not have the right path'); $this->assertNotEmpty($contents[0]->lastModified, 'Media library item last modified is empty'); $this->assertNotEmpty($contents[0]->size, 'Media library item size is empty'); + + $this->assertEquals('file', $contents[1]->type, 'Media library item does not have the right type'); + $this->assertEquals('/winter.png', $contents[1]->path, 'Media library item does not have the right path'); + $this->assertNotEmpty($contents[1]->lastModified, 'Media library item last modified is empty'); + $this->assertNotEmpty($contents[1]->size, 'Media library item size is empty'); + + $this->assertEquals('file', $contents[2]->type, 'Media library item does not have the right type'); + $this->assertEquals('/winter space.png', $contents[2]->path, 'Media library item does not have the right path'); + $this->assertNotEmpty($contents[2]->lastModified, 'Media library item last modified is empty'); + $this->assertNotEmpty($contents[2]->size, 'Media library item size is empty'); } public function testListAllDirectories() { - $disk = $this->createConfiguredMock(FilesystemAdapter::class, [ - 'allDirectories' => [ - '/media/.ignore1', - '/media/.ignore2', - '/media/dir', - '/media/dir/sub', - '/media/exclude', - '/media/hidden', - '/media/hidden/sub1', - '/media/hidden/sub1/deep1', - '/media/hidden/sub2', - '/media/hidden but not really', - '/media/name' - ] - ]); + $disk = $this->createMock(FilesystemInterface::class); + + $disk->expects($this->any()) + ->method('listContents') + ->willReturnCallback(function ($path) { + switch ($path) { + case '/media/': + return [ + [ + 'type' => 'dir', + 'path' => 'media/.ignore1', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media', + 'basename' => '.ignore1', + 'extension' => '', + 'filename' => '.ignore1' + ], + [ + 'type' => 'dir', + 'path' => 'media/.ignore2', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media', + 'basename' => '.ignore2', + 'extension' => '', + 'filename' => '.ignore2' + ], + [ + 'type' => 'dir', + 'path' => 'media/dir', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media', + 'basename' => 'dir', + 'extension' => '', + 'filename' => 'dir' + ], + [ + 'type' => 'dir', + 'path' => 'media/exclude', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media', + 'basename' => 'exclude', + 'extension' => '', + 'filename' => 'exclude' + ], + [ + 'type' => 'dir', + 'path' => 'media/hidden', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media', + 'basename' => 'hidden', + 'extension' => '', + 'filename' => 'hidden' + ], + [ + 'type' => 'dir', + 'path' => 'media/hidden but not really', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media', + 'basename' => 'hidden but not really', + 'extension' => '', + 'filename' => 'hidden but not really' + ], + [ + 'type' => 'file', + 'path' => 'media/name', + 'timestamp' => now(), + 'size' => 24, + 'dirname' => 'media', + 'basename' => 'name', + 'extension' => '', + 'filename' => 'name' + ], + ]; + break; + case '/media/dir': + return [ + [ + 'type' => 'dir', + 'path' => 'media/dir/sub', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media/dir', + 'basename' => 'sub', + 'extension' => '', + 'filename' => 'sub' + ], + ]; + break; + case '/media/hidden': + return [ + [ + 'type' => 'dir', + 'path' => 'media/hidden/sub1', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media/hidden', + 'basename' => 'sub1', + 'extension' => '', + 'filename' => 'sub1' + ], + [ + 'type' => 'dir', + 'path' => 'media/hidden/sub2', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media/hidden', + 'basename' => 'sub2', + 'extension' => '', + 'filename' => 'sub2' + ], + ]; + break; + case '/media/hidden/sub1': + return [ + [ + 'type' => 'dir', + 'path' => 'media/hidden/sub1/deep1', + 'timestamp' => now(), + 'size' => 0, + 'dirname' => 'media/hidden/sub1', + 'basename' => 'deep1', + 'extension' => '', + 'filename' => 'deep1' + ], + ]; + break; + default: + return []; + } + }); $this->app['config']->set('cms.storage.media.folder', 'media'); $this->app['config']->set('cms.storage.media.ignore', ['hidden']); @@ -123,7 +253,10 @@ public function testListAllDirectories() $instance = MediaLibrary::instance(); $this->setProtectedProperty($instance, 'storageDisk', $disk); - $this->assertEquals(['/', '/dir', '/dir/sub', '/hidden but not really', '/name'], $instance->listAllDirectories(['/exclude'])); + // Rescan library + MediaLibrary::instance()->scan(null, null, true); + + $this->assertEquals(['/', '/dir', '/dir/sub', '/hidden but not really'], $instance->listAllDirectories(['/exclude'])); } protected function setUpStorage()