diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index f820b780c9b..a99412b9ba1 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -66,6 +66,10 @@ class Asset implements Arrayable, ArrayAccess, AssetContract, Augmentable, Conta resolveGqlValue as traitResolveGqlValue; } + const AUDIO_EXTENSIONS = ['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav']; + const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']; + const VIDEO_EXTENSIONS = ['h264', 'mp4', 'm4v', 'ogv', 'webm', 'mov']; + protected $container; protected $path; protected $meta; @@ -471,7 +475,7 @@ public function manipulate($params = null) */ public function isAudio() { - return $this->extensionIsOneOf(['aac', 'flac', 'm4a', 'mp3', 'ogg', 'wav']); + return $this->extensionIsOneOf(self::AUDIO_EXTENSIONS); } /** @@ -504,7 +508,7 @@ public function isPreviewable() */ public function isImage() { - return $this->extensionIsOneOf(['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif']); + return $this->extensionIsOneOf(self::IMAGE_EXTENSIONS); } /** @@ -524,7 +528,7 @@ public function isSvg() */ public function isVideo() { - return $this->extensionIsOneOf(['h264', 'mp4', 'm4v', 'ogv', 'webm', 'mov']); + return $this->extensionIsOneOf(self::VIDEO_EXTENSIONS); } /** diff --git a/src/Tags/Assets.php b/src/Tags/Assets.php index 5789415d394..e3c1d818f07 100644 --- a/src/Tags/Assets.php +++ b/src/Tags/Assets.php @@ -2,16 +2,22 @@ namespace Statamic\Tags; +use Statamic\Assets\Asset as AssetModel; use Statamic\Assets\AssetCollection; use Statamic\Contracts\Query\Builder; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; use Statamic\Facades\Entry; +use Statamic\Facades\Pattern; use Statamic\Fields\Value; use Statamic\Support\Arr; class Assets extends Tags { + use Concerns\QueriesConditions, + Concerns\QueriesOrderBys, + Concerns\QueriesScopes; + /** * @var AssetCollection */ @@ -40,7 +46,7 @@ public function wildcard($method) $this->assets = (new AssetCollection([$value]))->flatten(); - return $this->output(); + return $this->outputCollection($this->assets); } if ($value instanceof Value) { @@ -66,15 +72,17 @@ public function index() $path = $this->params->get('path'); $collection = $this->params->get('collection'); - $this->assets = $collection - ? $this->assetsFromCollection($collection) - : $this->assetsFromContainer($id, $path); + if ($collection) { + return $this->outputCollection($this->assetsFromCollection($collection)); + } + + $this->assets = $this->assetsFromContainer($id, $path); if ($this->assets->isEmpty()) { return $this->parseNoResults(); } - return $this->output(); + return $this->assets; } protected function assetsFromContainer($id, $path) @@ -95,9 +103,29 @@ protected function assetsFromContainer($id, $path) return collect(); } - $assets = $container->assets($this->params->get('folder'), $this->params->get('recursive', false)); + $query = $container->queryAssets(); + + $this->queryFolder($query); + $this->queryType($query); + $this->queryConditions($query); + $this->queryScopes($query); + $this->queryOrderBys($query); + + if ($this->params->get('not_in')) { + $assets = $this->filterNotIn($query->get()); + + return $this->limitCollection($assets); + } + + if ($limit = $this->params->int('limit')) { + $query->limit($limit); + } - return $this->filterByType($assets); + if ($offset = $this->params->int('offset')) { + $query->offset($offset); + } + + return $query->get(); } protected function assetsFromCollection($collection) @@ -143,6 +171,10 @@ protected function filterByType($value) } return $value->filter(function ($value) use ($type) { + if ($type === 'audio') { + return $value->isAudio(); + } + if ($type === 'image') { return $value->isImage(); } @@ -161,18 +193,17 @@ protected function filterByType($value) /** * Filter out assets from a requested folder. - * - * @return void */ - private function filterNotIn() + private function filterNotIn($assets) { - if ($not_in = $this->params->get('not_in')) { - $regex = '#^('.$not_in.')#'; - - $this->assets = $this->assets->reject(function ($path) use ($regex) { - return preg_match($regex, $path); - }); + if (! $not_in = $this->params->get('not_in')) { + return $assets; } + + $regex = '#^('.$not_in.')#'; + + // Checking against path for backwards compatibility. Technically folder would be more correct. + return $assets->reject(fn ($asset) => preg_match($regex, $asset->path())); } /** @@ -204,36 +235,74 @@ protected function assets($urls) ]; }); - return $this->output(); + return $this->outputCollection($this->assets); } - private function output() + private function outputCollection($assets) { - $this->filterNotIn(); + $this->assets = $this->filterNotIn($assets); - $this->sort(); - $this->limit(); + if ($sort = $this->params->get('sort')) { + $this->assets = $this->assets->multisort($sort); + } + + $this->assets = $this->limitCollection($this->assets); + + if ($this->assets->isEmpty()) { + return $this->parseNoResults(); + } return $this->assets; } - private function sort() + private function limitCollection($assets) { - if ($sort = $this->params->get('sort')) { - $this->assets = $this->assets->multisort($sort); + $limit = $this->params->int('limit'); + $limit = ($limit == 0) ? $assets->count() : $limit; + $offset = $this->params->int('offset'); + + return $assets->splice($offset, $limit); + } + + protected function queryType($query) + { + $type = $this->params->get('type'); + + if (! $type) { + return; } + + $extensions = match ($type) { + 'audio' => AssetModel::AUDIO_EXTENSIONS, + 'image' => AssetModel::IMAGE_EXTENSIONS, + 'svg' => ['svg'], + 'video' => AssetModel::VIDEO_EXTENSIONS, + default => [], + }; + + $query->whereIn('extension', $extensions); } - /** - * Limit and offset the asset collection. - */ - private function limit() + protected function queryFolder($query) { - $limit = $this->params->int('limit'); - $limit = ($limit == 0) ? $this->assets->count() : $limit; - $offset = $this->params->int('offset'); + $folder = $this->params->get('folder'); + $recursive = $this->params->get('recursive', false); + + if ($folder === '/' && $recursive) { + $folder = null; + } + + if ($folder === null) { + return; + } + + if ($recursive) { + $query->where('path', 'like', Pattern::sqlLikeQuote($folder).'/%'); + + return; + } - $this->assets = $this->assets->splice($offset, $limit); + $query->where('folder', $folder); } private function isAssetsFieldValue($value) diff --git a/tests/Tags/AssetsTest.php b/tests/Tags/AssetsTest.php new file mode 100644 index 00000000000..c4d2aaf67c6 --- /dev/null +++ b/tests/Tags/AssetsTest.php @@ -0,0 +1,283 @@ + '/assets']); + + Storage::disk('test')->put('a.jpg', UploadedFile::fake()->image('a.jpg')->getContent()); + Storage::disk('test')->put('b.jpg', UploadedFile::fake()->image('b.jpg')->getContent()); + Storage::disk('test')->put('c.mp4', UploadedFile::fake()->create('c.mp4')->getContent()); + Storage::disk('test')->put('d.svg', ''); + Storage::disk('test')->put('e.mp3', UploadedFile::fake()->create('e.mp3')->getContent()); + Storage::disk('test')->put('nested/private/f.jpg', UploadedFile::fake()->image('f.jpg')->getContent()); + Storage::disk('test')->put('nested/public/g.jpg', UploadedFile::fake()->image('g.jpg')->getContent()); + + tap(AssetContainer::make('test')->disk('test'))->save(); + + Asset::find('test::a.jpg')->data(['title' => 'Alpha'])->save(); + Asset::find('test::b.jpg')->data(['title' => 'Beta'])->save(); + Asset::find('test::c.mp4')->data(['title' => 'Gamma'])->save(); + Asset::find('test::d.svg')->data(['title' => 'Delta'])->save(); + Asset::find('test::e.mp3')->data(['title' => 'Epsilon'])->save(); + Asset::find('test::nested/private/f.jpg')->data(['title' => 'Zeta'])->save(); + Asset::find('test::nested/public/g.jpg')->data(['title' => 'Eta'])->save(); + } + + #[Test] + public function it_filters_assets_by_conditions() + { + $this->assertSame(['a'], $this->getFilenames([ + 'title:is' => 'Alpha', + ])); + + $this->assertSame(['b'], $this->getFilenames([ + 'filename:starts_with' => 'b', + ])); + + $this->assertSame(['a', 'b', 'f', 'g'], $this->getFilenames([ + 'extension:is' => 'jpg', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_filters_assets_by_custom_field_conditions() + { + Asset::find('test::b.jpg')->data([ + 'title' => 'Beta', + 'alt' => 'Bob Ross', + ])->save(); + + $this->assertSame(['b'], $this->getFilenames([ + 'alt:contains' => 'Bob', + ])); + } + + #[Test] + public function it_supports_query_scopes() + { + app('statamic.scopes')[AssetsTagJpgScope::handle()] = AssetsTagJpgScope::class; + + $this->assertSame(['a', 'b', 'f', 'g'], $this->getFilenames([ + 'query_scope' => AssetsTagJpgScope::handle(), + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_filters_assets_by_type() + { + $this->assertSame(['e'], $this->getFilenames([ + 'type' => 'audio', + 'sort' => 'filename:asc', + ])); + + $this->assertSame(['a', 'b', 'f', 'g'], $this->getFilenames([ + 'type' => 'image', + 'sort' => 'filename:asc', + ])); + + $this->assertSame(['d'], $this->getFilenames([ + 'type' => 'svg', + 'sort' => 'filename:asc', + ])); + + $this->assertSame(['c'], $this->getFilenames([ + 'type' => 'video', + 'sort' => 'filename:asc', + ])); + + $this->assertSame([], $this->getFilenames([ + 'type' => 'invalid', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_gets_assets_from_a_collection() + { + $this->createCollectionWithAssetFields(); + + tap(Entry::make()->collection('articles')->data([ + 'hero' => 'a.jpg', + 'avatar' => 'b.jpg', + ]))->save(); + + tap(Entry::make()->collection('articles')->data([ + 'hero' => 'c.mp4', + ]))->save(); + + $this->assertSame(['a', 'b', 'c'], $this->getFilenames([ + 'collection' => 'articles', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_gets_unique_assets_from_a_collection() + { + $this->createCollectionWithAssetFields(); + + tap(Entry::make()->collection('articles')->data(['hero' => 'a.jpg']))->save(); + tap(Entry::make()->collection('articles')->data(['hero' => 'a.jpg']))->save(); + + $this->assertSame(['a'], $this->getFilenames([ + 'collection' => 'articles', + ])); + } + + #[Test] + public function it_gets_assets_from_a_collection_filtered_by_type() + { + $this->createCollectionWithAssetFields(); + + tap(Entry::make()->collection('articles')->data([ + 'hero' => 'a.jpg', + 'avatar' => 'c.mp4', + ]))->save(); + + $this->assertSame(['a'], $this->getFilenames([ + 'collection' => 'articles', + 'type' => 'image', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_gets_assets_from_a_collection_filtered_by_fields() + { + $this->createCollectionWithAssetFields(); + + tap(Entry::make()->collection('articles')->data([ + 'hero' => 'a.jpg', + 'avatar' => 'b.jpg', + ]))->save(); + + $this->assertSame(['a'], $this->getFilenames([ + 'collection' => 'articles', + 'fields' => 'hero', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_filters_by_folder_non_recursively() + { + $this->assertSame(['f'], $this->getFilenames([ + 'folder' => 'nested/private', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_returns_root_assets_when_folder_is_slash_without_recursive() + { + $this->assertSame(['a', 'b', 'c', 'd', 'e'], $this->getFilenames([ + 'folder' => '/', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_keeps_legacy_filtering_params_working() + { + $this->assertSame(['g'], $this->getFilenames([ + 'folder' => 'nested', + 'recursive' => true, + 'sort' => 'filename:asc', + 'offset' => 1, + 'limit' => 1, + ])); + + $this->assertSame(['a', 'b', 'c', 'd', 'e', 'g'], $this->getFilenames([ + 'not_in' => '/?nested/private', + 'sort' => 'filename:asc', + ])); + + $this->assertSame(['c', 'd'], $this->getFilenames([ + 'not_in' => '/?nested/private', + 'sort' => 'filename:asc', + 'offset' => 2, + 'limit' => 2, + ])); + } + + #[Test] + public function it_returns_no_results_when_query_matches_nothing() + { + $results = $this->runTag(['title:is' => 'nonexistent']); + + $this->assertIsArray($results); + $this->assertTrue($results['no_results']); + $this->assertEquals(0, $results['total_results']); + } + + private function createCollectionWithAssetFields() + { + tap(Collection::make('articles'))->save(); + + $blueprint = tap(Blueprint::make('article')->setContents([ + 'fields' => [ + ['handle' => 'hero', 'field' => ['type' => 'assets', 'container' => 'test', 'max_files' => 1]], + ['handle' => 'avatar', 'field' => ['type' => 'assets', 'container' => 'test', 'max_files' => 1]], + ], + ]))->save(); + + Blueprint::shouldReceive('in')->with('collections/articles')->andReturn(collect([$blueprint])); + } + + private function runTag(array $params = []) + { + $tag = new Assets; + $tag->setContext([]); + $tag->setParameters(isset($params['collection']) + ? $params + : array_merge(['container' => 'test'], $params)); + + return $tag->index(); + } + + private function getFilenames(array $params = []): array + { + $results = $this->runTag($params); + + if (is_array($results) && isset($results['results'])) { + $results = $results['results']; + } + + if (is_array($results) && ($results['no_results'] ?? false)) { + return []; + } + + return collect($results)->map->filename()->values()->all(); + } +} + +class AssetsTagJpgScope extends Scope +{ + public function apply($query, $params) + { + $query->where('extension', 'jpg'); + } +}