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="= $this->bottomToolbar ? 'true' : 'false' ?>"
data-crop-and-insert-button="= $this->cropAndInsertButton ? 'true' : 'false' ?>"
data-read-only="= $this->readOnly ? 'true' : 'false'; ?>"
+ data-is-scanned="= $isScanned ? 'true' : 'false'; ?>"
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 @@
+
+
+
+
+
+
+
+ Scanning media library...
+
+
+
+
+
+
+
+
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()