diff --git a/database/Factories/AssetFactory.php b/database/Factories/AssetFactory.php new file mode 100644 index 00000000000..0a3d6f998d1 --- /dev/null +++ b/database/Factories/AssetFactory.php @@ -0,0 +1,46 @@ + Element::factory()->set('type', \CraftCms\Cms\Element\Elements\Asset::class), + 'volumeId' => Volume::factory(), + 'folderId' => VolumeFolder::factory(), + 'filename' => fake()->word().'.jpg', + 'kind' => 'image', + ]; + } + + #[\Override] + public function configure(): self + { + return $this->afterCreating(function (Asset $asset) { + // For some reason the element factory doesn't get saved properly + if ($asset->id === 0) { + $asset->update([ + 'id' => Element::query() + ->where('type', \CraftCms\Cms\Element\Elements\Asset::class) + ->latest('id') + ->first() + ->id, + ]); + } + }); + } +} diff --git a/database/Factories/VolumeFactory.php b/database/Factories/VolumeFactory.php new file mode 100644 index 00000000000..ff622d895af --- /dev/null +++ b/database/Factories/VolumeFactory.php @@ -0,0 +1,25 @@ + fake()->word(), + 'handle' => fake()->slug(), + 'fs' => Local::class, + ]; + } +} diff --git a/database/Factories/VolumeFolderFactory.php b/database/Factories/VolumeFolderFactory.php new file mode 100644 index 00000000000..e952ac6a2cb --- /dev/null +++ b/database/Factories/VolumeFolderFactory.php @@ -0,0 +1,24 @@ + Volume::factory(), + 'name' => fake()->word(), + ]; + } +} diff --git a/src/Asset/Commands/Concerns/IndexesAssets.php b/src/Asset/Commands/Concerns/IndexesAssets.php index 1a07892d176..885022c5f89 100644 --- a/src/Asset/Commands/Concerns/IndexesAssets.php +++ b/src/Asset/Commands/Concerns/IndexesAssets.php @@ -5,7 +5,6 @@ namespace CraftCms\Cms\Asset\Commands\Concerns; use craft\console\Application; -use craft\elements\Asset; use craft\errors\AssetDisallowedExtensionException; use craft\errors\AssetNotIndexableException; use craft\errors\MissingAssetException; @@ -15,6 +14,7 @@ use craft\models\Volume; use craft\services\AssetIndexer; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Asset; use CraftCms\Cms\Support\Str; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -109,8 +109,8 @@ protected function indexAssets(Application $craft, array $volumes, string $path $this->components->task( 'Deleting the'.($totalMissingFiles > 1 ? ' '.$totalMissingFiles : '').' missing asset record'.Str::plural('record', $totalMissingFiles), function () use ($craft, $assetIds) { - /** @var Asset[] $assets */ - $assets = Asset::find()->id($assetIds)->all(); + /** @var \craft\elements\ElementCollection $assets */ + $assets = Asset::find()->id($assetIds)->get(); foreach ($assets as $asset) { $craft->getImageTransforms()->deleteCreatedTransformsForAsset($asset); diff --git a/src/Asset/Models/Asset.php b/src/Asset/Models/Asset.php new file mode 100644 index 00000000000..0561dc70b30 --- /dev/null +++ b/src/Asset/Models/Asset.php @@ -0,0 +1,75 @@ + 'int', + 'height' => 'int', + 'size' => 'int', + 'deletedWithVolume' => 'bool', + 'keptFile' => 'bool', + 'dateModified' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function element(): BelongsTo + { + return $this->belongsTo(Element::class, 'id'); + } + + /** + * @return BelongsToMany + */ + public function sites(): BelongsToMany + { + return $this->belongsToMany(Site::class, 'assets_sites', 'assetId', 'siteId') + ->withPivot('alt'); + } + + /** + * @return BelongsTo + */ + public function volume(): BelongsTo + { + return $this->belongsTo(Volume::class, 'volumeId'); + } + + /** + * @return BelongsTo + */ + public function folder(): BelongsTo + { + return $this->belongsTo(VolumeFolder::class, 'folderId'); + } + + /** + * @return BelongsTo + */ + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaderId'); + } +} diff --git a/src/Asset/Models/Volume.php b/src/Asset/Models/Volume.php new file mode 100644 index 00000000000..be021852686 --- /dev/null +++ b/src/Asset/Models/Volume.php @@ -0,0 +1,37 @@ + 'int', + ]; + } + + /** + * @return BelongsTo + */ + public function fieldLayout(): BelongsTo + { + return $this->belongsTo(FieldLayout::class, 'fieldLayoutId'); + } +} diff --git a/src/Asset/Models/VolumeFolder.php b/src/Asset/Models/VolumeFolder.php new file mode 100644 index 00000000000..c001f4e90de --- /dev/null +++ b/src/Asset/Models/VolumeFolder.php @@ -0,0 +1,44 @@ + + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parentId'); + } + + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parentId'); + } + + /** + * @return BelongsTo + */ + public function volume(): BelongsTo + { + return $this->belongsTo(Volume::class, 'volumeId'); + } +} diff --git a/src/Console/Commands/Utils/AsciiFilenamesCommand.php b/src/Console/Commands/Utils/AsciiFilenamesCommand.php index 36e49b73058..7b6a78a0f58 100644 --- a/src/Console/Commands/Utils/AsciiFilenamesCommand.php +++ b/src/Console/Commands/Utils/AsciiFilenamesCommand.php @@ -5,15 +5,15 @@ namespace CraftCms\Cms\Console\Commands\Utils; use Craft; -use craft\elements\Asset; use craft\errors\InvalidElementException; use craft\helpers\FileHelper; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Console\CraftCommand; +use CraftCms\Cms\Element\Elements\Asset; +use Exception; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Throwable; -use yii\db\Expression; use function Laravel\Prompts\confirm; @@ -42,15 +42,15 @@ public function handle(GeneralConfig $generalConfig): int match (DB::connection()->getDriverName()) { // h/t https://stackoverflow.com/a/11741314/1688568 - 'mysql' => $query->andWhere(new Expression('[[filename]] <> CONVERT([[filename]] USING ASCII)')), + 'mysql' => $query->whereRaw('filename <> CONVERT(filename USING ASCII)'), // h/t https://dba.stackexchange.com/a/167571/205387 - 'pgsql' => $query->andWhere(new Expression("[[filename]] ~ '[^[:ascii:]]'")), - default => throw new \Exception('Invalid driver name: '.DB::connection()->getDriverName().'.') + 'pgsql' => $query->whereRaw("filename ~ '[^[:ascii:]]'"), + default => throw new Exception('Invalid driver name: '.DB::connection()->getDriverName().'.') }; - /** @var Asset[] $assets */ - $assets = $query->all(); - $total = count($assets); + /** @var \Illuminate\Support\Collection $assets */ + $assets = $query->get(); + $total = $assets->count(); if ($total === 0) { $this->components->success('No assets found with non-ASCII filenames.'); @@ -60,10 +60,9 @@ public function handle(GeneralConfig $generalConfig): int $this->components->info("$total assets found with non-ASCII filenames:"); - $this->components->bulletList(array_map( + $this->components->bulletList($assets->map( fn (Asset $asset) => $asset->getFilename(), - $assets, - )); + )->all()); if (! confirm('Ready to rename these filenames as ASCII?')) { return self::SUCCESS; diff --git a/src/Database/Queries/AssetQuery.php b/src/Database/Queries/AssetQuery.php new file mode 100644 index 00000000000..9accf90ad8a --- /dev/null +++ b/src/Database/Queries/AssetQuery.php @@ -0,0 +1,225 @@ +joinElementTable(Table::ASSETS); + + $this->query->addSelect([ + 'assets.volumeId as volumeId', + 'assets.folderId as folderId', + 'assets.uploaderId as uploaderId', + 'assets.filename as filename', + 'assets.kind as kind', + 'assets.width as width', + 'assets.height as height', + 'assets.size as size', + 'assets.alt as alt', + 'assets.focalPoint as focalPoint', + 'assets.keptFile as keptFile', + 'assets.dateModified as dateModified', + 'assets.mimeType as mimeType', + 'assets_sites.alt as siteAlt', + 'volumeFolders.path as folderPath', + ]); + + $this->beforeQuery(function (self $elementQuery) { + $elementQuery->query->leftJoin(new Alias(Table::ASSETS_SITES, 'assets_sites'), function (JoinClause $join) { + $join->on('assets_sites.assetId', '=', 'assets.id') + ->whereColumn('assets_sites.siteId', '=', 'elements_sites.siteId'); + }); + + $elementQuery->applyAuthParam($elementQuery->editable, 'viewAssets', 'viewPeerAssets'); + $elementQuery->applyAuthParam($elementQuery->savable, 'saveAssets', 'savePeerAssets'); + }); + } + + /** + * Sets the [[$editable]] property. + * + * @uses $editable + */ + public function editable(?bool $value = true): self + { + $this->editable = $value; + + return $this; + } + + /** + * Sets the [[$savable]] property. + * + * @uses $savable + */ + public function savable(?bool $value = true): self + { + $this->savable = $value; + + return $this; + } + + private function applyAuthParam(?bool $value, string $permissionPrefix, string $peerPermissionPrefix): void + { + if ($value === null) { + return; + } + + $user = Auth::user(); + + if (! $user) { + throw new QueryAbortedException; + } + + $fullyAuthorizedVolumeIds = []; + $partiallyAuthorizedVolumeIds = []; + $unauthorizedVolumeIds = []; + + foreach (Craft::$app->getVolumes()->getAllVolumes() as $volume) { + if ($user->can("$peerPermissionPrefix:$volume->uid")) { + $fullyAuthorizedVolumeIds[] = $volume->id; + } elseif ($user->can("$permissionPrefix:$volume->uid")) { + $partiallyAuthorizedVolumeIds[] = $volume->id; + } else { + $unauthorizedVolumeIds[] = $volume->id; + } + } + + if ($value) { + if (! $fullyAuthorizedVolumeIds && ! $partiallyAuthorizedVolumeIds) { + throw new QueryAbortedException; + } + + $this->subQuery->where(function (Builder $query) use ($user, $fullyAuthorizedVolumeIds, $partiallyAuthorizedVolumeIds) { + if ($fullyAuthorizedVolumeIds) { + $query->orWhereIn('assets.volumeId', $fullyAuthorizedVolumeIds); + } + + if ($partiallyAuthorizedVolumeIds) { + $query->orWhere(fn (Builder $query) => $query + ->whereIn('assets.volumeId', $partiallyAuthorizedVolumeIds) + ->where('assets.uploaderId', $user->id), + ); + } + }); + + return; + } + + if (! $unauthorizedVolumeIds && ! $partiallyAuthorizedVolumeIds) { + throw new QueryAbortedException; + } + + $this->subQuery->where(function (Builder $query) use ($user, $unauthorizedVolumeIds, $partiallyAuthorizedVolumeIds) { + if ($unauthorizedVolumeIds) { + $query->orWhereIn('assets.volumeId', $unauthorizedVolumeIds); + } + + if ($partiallyAuthorizedVolumeIds) { + $query->orWhere(function (Builder $query) use ($user, $partiallyAuthorizedVolumeIds) { + $query->whereIn('assets.volumeId', $partiallyAuthorizedVolumeIds) + ->where(function (Builder $query) use ($user) { + $query->where('assets.uploaderId', '!=', $user->id) + ->orWhereNull('assets.uploaderId'); + }); + }); + } + }); + } + + protected function createElement(array $row): ElementInterface + { + // Use the site-specific alt text, if set + $siteAlt = Arr::pull($row, 'siteAlt'); + + if ($siteAlt !== null) { + $row['alt'] = $siteAlt; + } + + return parent::createElement($row); + } + + /** + * {@inheritdoc} + */ + protected function cacheTags(): array + { + $tags = []; + + if ($this->volumeId && $this->volumeId !== ':empty:') { + foreach ($this->volumeId as $volumeId) { + $tags[] = "volume:$volumeId"; + } + } + + return $tags; + } + + /** + * {@inheritdoc} + */ + protected function fieldLayouts(): Collection + { + if (! $this->volumeId) { + return parent::fieldLayouts(); + } + + if ($this->volumeId === ':empty:') { + return parent::fieldLayouts(); + } + + $fieldLayouts = []; + $volumesService = Craft::$app->getVolumes(); + + foreach (Arr::wrap($this->volumeId) as $volumeId) { + if ($volume = $volumesService->getVolumeById($volumeId)) { + $fieldLayouts[] = $volume->getFieldLayout(); + } + } + + return new Collection($fieldLayouts); + } +} diff --git a/src/Database/Queries/Concerns/Asset/EagerloadsTransforms.php b/src/Database/Queries/Concerns/Asset/EagerloadsTransforms.php new file mode 100644 index 00000000000..50278d8dd55 --- /dev/null +++ b/src/Database/Queries/Concerns/Asset/EagerloadsTransforms.php @@ -0,0 +1,117 @@ +kind('image') + * ->withTransforms(['thumb']) + * ->all(); + * ``` + * ```twig{4} + * {# fetch images with their 'thumb' transforms preloaded #} + * {% set logos = craft.assets() + * .kind('image') + * .withTransforms(['thumb']) + * .all() %} + * ``` + * + * @used-by withTransforms() + */ + public mixed $withTransforms = null; + + protected function initEagerloadsTransforms(): void + { + $this->afterQuery(function (mixed $result) { + if (! $result instanceof Collection) { + return $result; + } + + // Eager-load transforms? + if (! $this->withTransforms) { + return $result; + } + + if ($this->asArray) { + return $result; + } + + $transforms = $this->withTransforms; + if (! is_array($transforms)) { + $transforms = is_string($transforms) + ? str($transforms)->explode(',')->all() + : [$transforms]; + } + + Craft::$app->getImageTransforms()->eagerLoadTransforms($result->all(), $transforms); + + return $result; + }); + } + + /** + * Causes the query to return matching assets eager-loaded with image transform indexes. + * + * This can improve performance when displaying several image transforms at once, if the transforms + * have already been generated. + * + * Transforms can be specified as their handle or an object that contains `width` and/or `height` properties. + * + * You can include `srcset`-style sizes (e.g. `100w` or `2x`) following a normal transform definition, for example: + * + * ::: code + * + * ```twig + * [{width: 1000, height: 600}, '1.5x', '2x', '3x'] + * ``` + * + * ```php + * [['width' => 1000, 'height' => 600], '1.5x', '2x', '3x'] + * ``` + * + * ::: + * + * When a `srcset`-style size is encountered, the preceding normal transform definition will be used as a + * reference when determining the resulting transform dimensions. + * + * --- + * + * ```twig + * {# Fetch assets with the 'thumbnail' and 'hiResThumbnail' transform data preloaded #} + * {% set {elements-var} = {twig-method} + * .kind('image') + * .withTransforms(['thumbnail', 'hiResThumbnail']) + * .all() %} + * ``` + * + * ```php + * // Fetch assets with the 'thumbnail' and 'hiResThumbnail' transform data preloaded + * ${elements-var} = {php-method} + * ->kind('image') + * ->withTransforms(['thumbnail', 'hiResThumbnail']) + * ->all(); + * ``` + * + * @uses $withTransforms + */ + public function withTransforms(string|array|null $value = null): static + { + $this->withTransforms = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/Asset/QueriesAlt.php b/src/Database/Queries/Concerns/Asset/QueriesAlt.php new file mode 100644 index 00000000000..1a42e34e34c --- /dev/null +++ b/src/Database/Queries/Concerns/Asset/QueriesAlt.php @@ -0,0 +1,72 @@ +beforeQuery(function (AssetQuery $assetQuery) { + if ($assetQuery->hasAlt === null) { + return; + } + + $hasAltCondition = function (Builder $query) { + $query->where('assets_sites.alt', '!=', '') + ->orWhere(function (Builder $query) { + $query->whereNull('assets_sites.alt') + ->where('assets.alt', '!=', '') + ->whereNotNull('assets.alt'); + }); + }; + + $withoutAltCondition = function (Builder $query) { + $query->where('assets_sites.alt', '=', '') + ->orWhere(function (Builder $query) { + $query->whereNull('assets_sites.alt') + ->where(function (Builder $query) { + $query->where('assets.alt', '=', '') + ->orWhereNull('assets.alt'); + }); + }); + }; + + $this->subQuery + ->leftJoin(new Alias(Table::ASSETS_SITES, 'assets_sites'), function (JoinClause $join) { + $join->on('assets_sites.assetId', '=', 'assets.id') + ->whereColumn('assets_sites.siteId', '=', 'elements_sites.siteId'); + }) + ->where($this->hasAlt ? $hasAltCondition : $withoutAltCondition); + }); + } + + /** + * Narrows the query results based on whether the assets have alternative text. + * + * @uses $hasAlt + */ + public function hasAlt(?bool $value = true): static + { + $this->hasAlt = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/Asset/QueriesAssetLocation.php b/src/Database/Queries/Concerns/Asset/QueriesAssetLocation.php new file mode 100644 index 00000000000..426792e4a59 --- /dev/null +++ b/src/Database/Queries/Concerns/Asset/QueriesAssetLocation.php @@ -0,0 +1,368 @@ +volume('logos') + * ->all(); + * ``` + * ```twig + * {# fetch assets in the Logos volume #} + * {% set logos = craft.assets() + * .volume('logos') + * .all() %} + * ``` + * + * @used-by volume() + * @used-by volumeId() + */ + public mixed $volumeId = null; + + /** + * @var bool Whether the query should search the subfolders of [[folderId]]. + * + * @used-by includeSubfolders() + */ + public bool $includeSubfolders = false; + + /** + * @var mixed The folder path that resulting assets must live within + * + * @used-by folderPath() + */ + public mixed $folderPath = null; + + /** + * @var mixed The asset folder ID(s) that the resulting assets must be in. + * + * @used-by folderId() + */ + public mixed $folderId = null; + + protected function initQueriesAssetLocation(): void + { + $this->beforeQuery(function (AssetQuery $assetQuery) { + $this->normalizeVolumeId(); + + // See if 'volume' was set to an invalid handle + if ($this->volumeId === []) { + throw new QueryAbortedException; + } + + $assetQuery->subQuery->join(new Alias(Table::VOLUMEFOLDERS, 'volumeFolders'), 'volumeFolders.id', '=', 'assets.folderId'); + $assetQuery->query->join(new Alias(Table::VOLUMEFOLDERS, 'volumeFolders'), 'volumeFolders.id', '=', 'assets.folderId'); + + if ($assetQuery->volumeId) { + if ($assetQuery->volumeId === ':empty:') { + $assetQuery->subQuery->whereNull('assets.volumeId'); + } else { + $assetQuery->subQuery->whereIn('assets.volumeId', Arr::wrap($this->volumeId)); + } + } + + if ($assetQuery->folderId) { + // [X] => X, so includeSubfolders works with GraphQL + // (see https://github.com/craftcms/cms/issues/17023) + if (is_array($assetQuery->folderId) && count($assetQuery->folderId) === 1 && Arr::isNumeric($assetQuery->folderId)) { + $assetQuery->folderId = reset($assetQuery->folderId); + } + + $assetQuery->subQuery->where(function (Builder $query) use ($assetQuery) { + $query->whereNumericParam('assets.folderId', $assetQuery->folderId) + ->when( + is_numeric($assetQuery->folderId) && $assetQuery->includeSubfolders, + function (Builder $query) use ($assetQuery) { + $assetsService = Craft::$app->getAssets(); + $descendants = $assetsService->getAllDescendantFolders($assetsService->getFolderById($assetQuery->folderId)); + + $query->orWhereIn('assets.folderId', array_keys($descendants)); + } + ); + }); + } + + if ($assetQuery->folderPath) { + $folderPath = Arr::wrap($assetQuery->folderPath); + + foreach ($folderPath as &$path) { + if ( + is_string($path) && + ! str_ends_with($path, '/') && + Query::escapeParam($path) === $path + ) { + $path .= '/'; + } + } + + $assetQuery->subQuery->whereParam('volumeFolders.path', $folderPath); + } + }); + } + + /** + * Narrows the query results based on the volume the assets belong to. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `'foo'` | in a volume with a handle of `foo`. + * | `'not foo'` | not in a volume with a handle of `foo`. + * | `['foo', 'bar']` | in a volume with a handle of `foo` or `bar`. + * | `['not', 'foo', 'bar']` | not in a volume with a handle of `foo` or `bar`. + * | a [[Volume]] object | in a volume represented by the object. + * + * --- + * + * ```twig + * {# Fetch assets in the Foo volume #} + * {% set {elements-var} = {twig-method} + * .volume('foo') + * .all() %} + * ``` + * + * ```php + * // Fetch assets in the Foo group + * ${elements-var} = {php-method} + * ->volume('foo') + * ->all(); + * ``` + * + * @uses $volumeId + */ + public function volume(mixed $value): static + { + if (Query::normalizeParam($value, function ($item) { + if (is_string($item)) { + $item = Craft::$app->getVolumes()->getVolumeByHandle($item); + } + + if (is_numeric($item)) { + return (int) $item; + } + + return $item instanceof Volume ? $item->id : null; + })) { + $this->volumeId = $value; + } elseif ($value !== null) { + $this->volumeId = DB::table(Table::VOLUMES) + ->whereParam('handle', $value) + ->whereNull('dateDeleted') + ->pluck('id') + ->all(); + } else { + $this->volumeId = null; + } + + return $this; + } + + /** + * Narrows the query results based on the volumes the assets belong to, per the volumes’ IDs. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `1` | in a volume with an ID of 1. + * | `'not 1'` | not in a volume with an ID of 1. + * | `[1, 2]` | in a volume with an ID of 1 or 2. + * | `['not', 1, 2]` | not in a volume with an ID of 1 or 2. + * | `':empty:'` | that haven’t been stored in a volume yet + * + * --- + * + * ```twig + * {# Fetch assets in the volume with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .volumeId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch assets in the volume with an ID of 1 + * ${elements-var} = {php-method} + * ->volumeId(1) + * ->all(); + * ``` + * + * @uses $volumeId + */ + public function volumeId(mixed $value): static + { + $this->volumeId = $value; + + return $this; + } + + /** + * Narrows the query results based on the folders the assets belong to, per the folders’ IDs. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `1` | in a folder with an ID of 1. + * | `'not 1'` | not in a folder with an ID of 1. + * | `[1, 2]` | in a folder with an ID of 1 or 2. + * | `['not', 1, 2]` | not in a folder with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch assets in the folder with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .folderId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch assets in the folder with an ID of 1 + * ${elements-var} = {php-method} + * ->folderId(1) + * ->all(); + * ``` + * + * --- + * + * ::: tip + * This can be combined with [[includeSubfolders()]] if you want to include assets in all the subfolders of a certain folder. + * ::: + * + * @uses $folderId + */ + public function folderId(mixed $value): static + { + $this->folderId = $value; + + return $this; + } + + /** + * Broadens the query results to include assets from any of the subfolders of the folder specified by [[folderId()]]. + * + * --- + * + * ```twig + * {# Fetch assets in the folder with an ID of 1 (including its subfolders) #} + * {% set {elements-var} = {twig-method} + * .folderId(1) + * .includeSubfolders() + * .all() %} + * ``` + * + * ```php + * // Fetch assets in the folder with an ID of 1 (including its subfolders) + * ${elements-var} = {php-method} + * ->folderId(1) + * ->includeSubfolders() + * ->all(); + * ``` + * + * --- + * + * ::: warning + * This will only work if [[folderId()]] was set to a single folder ID. + * ::: + * + * @param bool $value The property value (defaults to true) + * @return static self reference + * + * @uses $includeSubfolders + */ + public function includeSubfolders(bool $value = true): static + { + $this->includeSubfolders = $value; + + return $this; + } + + /** + * Narrows the query results based on the folders the assets belong to, per the folders’ paths. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `foo/` | in a `foo/` folder (excluding nested folders). + * | `foo/*` | in a `foo/` folder (including nested folders). + * | `'not foo/*'` | not in a `foo/` folder (including nested folders). + * | `['foo/*', 'bar/*']` | in a `foo/` or `bar/` folder (including nested folders). + * | `['not', 'foo/*', 'bar/*']` | not in a `foo/` or `bar/` folder (including nested folders). + * + * --- + * + * ```twig + * {# Fetch assets in the foo/ folder or its nested folders #} + * {% set {elements-var} = {twig-method} + * .folderPath('foo/*') + * .all() %} + * ``` + * + * ```php + * // Fetch assets in the foo/ folder or its nested folders + * ${elements-var} = {php-method} + * ->folderPath('foo/*') + * ->all(); + * ``` + * + * @uses $folderPath + */ + public function folderPath(mixed $value): static + { + $this->folderPath = $value; + + return $this; + } + + /** + * Normalizes the volumeId param to an array of IDs or null + */ + private function normalizeVolumeId(): void + { + if ($this->volumeId === ':empty:') { + return; + } + + if (empty($this->volumeId)) { + $this->volumeId = is_array($this->volumeId) ? [] : null; + + return; + } + if (is_numeric($this->volumeId)) { + $this->volumeId = [$this->volumeId]; + + return; + } + + if (! is_array($this->volumeId) || ! Arr::isNumeric($this->volumeId)) { + $this->volumeId = DB::table(Table::VOLUMES) + ->whereNumericParam('id', $this->volumeId) + ->pluck('id') + ->all(); + } + } +} diff --git a/src/Database/Queries/Concerns/Asset/QueriesAssetProperties.php b/src/Database/Queries/Concerns/Asset/QueriesAssetProperties.php new file mode 100644 index 00000000000..accc3a15d83 --- /dev/null +++ b/src/Database/Queries/Concerns/Asset/QueriesAssetProperties.php @@ -0,0 +1,300 @@ +kind('image') + * ->all(); + * ``` + * ```twig + * {# fetch only images #} + * {% set logos = craft.assets() + * .kind('image') + * .all() %} + * ``` + * + * @used-by kind() + */ + public mixed $kind = null; + + /** + * @var mixed The Date Modified that the resulting assets must have. + * + * @used-by dateModified() + */ + public mixed $dateModified = null; + + protected function initQueriesAssetProperties(): void + { + $this->beforeQuery(function (AssetQuery $assetQuery) { + if ($assetQuery->uploaderId) { + $assetQuery->subQuery->whereIn('uploaderId', Arr::wrap($assetQuery->uploaderId)); + } + + if ($assetQuery->filename) { + $assetQuery->subQuery->whereParam('assets.filename', $assetQuery->filename); + } + + if ($assetQuery->kind) { + $assetQuery->subQuery->where(function (Builder $query) use ($assetQuery) { + $query->whereParam('assets.kind', $assetQuery->kind); + + $kinds = Assets::getFileKinds(); + + foreach ((array) $assetQuery->kind as $kind) { + if (! isset($kinds[$kind])) { + continue; + } + + foreach ($kinds[$kind]['extensions'] as $extension) { + $query->orWhereLike('assets.filename', "%.$extension"); + } + } + }); + } + + if ($assetQuery->dateModified) { + $assetQuery->subQuery->whereDateParam('assets.dateModified', $assetQuery->dateModified); + } + }); + } + + /** + * Narrows the query results based on the user the assets were uploaded by, per the user’s IDs. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `1` | uploaded by the user with an ID of 1. + * | a [[User]] object | uploaded by the user represented by the object. + * + * --- + * + * ```twig + * {# Fetch assets uploaded by the user with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .uploader(1) + * .all() %} + * ``` + * + * ```php + * // Fetch assets uploaded by the user with an ID of 1 + * ${elements-var} = {php-method} + * ->uploader(1) + * ->all(); + * ``` + * + * @uses $uploaderId + */ + public function uploader(int|User|UserModel|null $value): static + { + if ($value instanceof User || $value instanceof UserModel) { + $this->uploaderId = $value->id; + } elseif (is_numeric($value)) { + $this->uploaderId = $value; + } else { + throw new InvalidArgumentException('Invalid uploader value'); + } + + return $this; + } + + /** + * Narrows the query results based on the assets’ filenames. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `'foo.jpg'` | with a filename of `foo.jpg`. + * | `'foo*'` | with a filename that begins with `foo`. + * | `'*.jpg'` | with a filename that ends with `.jpg`. + * | `'*foo*'` | with a filename that contains `foo`. + * | `'not *foo*'` | with a filename that doesn’t contain `foo`. + * | `['*foo*', '*bar*']` | with a filename that contains `foo` or `bar`. + * | `['not', '*foo*', '*bar*']` | with a filename that doesn’t contain `foo` or `bar`. + * + * --- + * + * ```twig + * {# Fetch all the hi-res images #} + * {% set {elements-var} = {twig-method} + * + * .filename('*@2x*') + * .all() %} + * ``` + * + * ```php + * // Fetch all the hi-res images + * ${elements-var} = {php-method} + * + * ->filename('*@2x*') + * ->all(); + * ``` + * + * @uses $filename + */ + public function filename(mixed $value): static + { + $this->filename = $value; + + return $this; + } + + /** + * Narrows the query results based on the assets’ file kinds. + * + * Supported file kinds: + * - `access` + * - `audio` + * - `compressed` + * - `excel` + * - `flash` + * - `html` + * - `illustrator` + * - `image` + * - `javascript` + * - `json` + * - `pdf` + * - `photoshop` + * - `php` + * - `powerpoint` + * - `text` + * - `video` + * - `word` + * - `xml` + * - `unknown` + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `'image'` | with a file kind of `image`. + * | `'not image'` | not with a file kind of `image`.. + * | `['image', 'pdf']` | with a file kind of `image` or `pdf`. + * | `['not', 'image', 'pdf']` | not with a file kind of `image` or `pdf`. + * + * --- + * + * ```twig + * {# Fetch all the images #} + * {% set {elements-var} = {twig-method} + * .kind('image') + * .all() %} + * ``` + * + * ```php + * // Fetch all the images + * ${elements-var} = {php-method} + * ->kind('image') + * ->all(); + * ``` + * + * @uses $kind + */ + public function kind(mixed $value): static + { + $this->kind = $value; + + return $this; + } + + /** + * Narrows the query results based on the assets’ files’ last-modified dates. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `'>= 2018-04-01'` | that were modified on or after 2018-04-01. + * | `'< 2018-05-01'` | that were modified before 2018-05-01. + * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were modified between 2018-04-01 and 2018-05-01. + * | `now`/`today`/`tomorrow`/`yesterday` | that were modified at midnight of the specified relative date. + * + * --- + * + * ```twig + * {# Fetch assets modified in the last month #} + * {% set start = date('30 days ago')|atom %} + * + * {% set {elements-var} = {twig-method} + * .dateModified(">= #{start}") + * .all() %} + * ``` + * + * ```php + * // Fetch assets modified in the last month + * $start = (new \DateTime('30 days ago'))->format(\DateTime::ATOM); + * + * ${elements-var} = {php-method} + * ->dateModified(">= {$start}") + * ->all(); + * ``` + * + * @uses $dateModified + */ + public function dateModified(mixed $value): static + { + $this->dateModified = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/Asset/QueriesSizes.php b/src/Database/Queries/Concerns/Asset/QueriesSizes.php new file mode 100644 index 00000000000..347c5efc521 --- /dev/null +++ b/src/Database/Queries/Concerns/Asset/QueriesSizes.php @@ -0,0 +1,194 @@ +kind('image') + * ->width('>= 500') + * ->all(); + * ``` + * ```twig{4} + * {# fetch images that are at least 500 pixes wide #} + * {% set logos = craft.assets() + * .kind('image') + * .width('>= 500') + * .all() %} + * ``` + * + * @used-by width() + */ + public mixed $width = null; + + /** + * @var mixed The height (in pixels) that the resulting assets must have. + * --- + * ```php{4} + * // fetch images that are at least 500 pixels high + * $images = \craft\elements\Asset::find() + * ->kind('image') + * ->height('>= 500') + * ->all(); + * ``` + * ```twig{4} + * {# fetch images that are at least 500 pixes high #} + * {% set logos = craft.assets() + * .kind('image') + * .height('>= 500') + * .all() %} + * ``` + * + * @used-by height() + */ + public mixed $height = null; + + /** + * @var mixed The size (in bytes) that the resulting assets must have. + * + * @used-by size() + */ + public mixed $size = null; + + protected function initQueriesSizes(): void + { + $this->beforeQuery(function (AssetQuery $assetQuery) { + if ($assetQuery->width) { + $assetQuery->subQuery->whereNumericParam('assets.width', $assetQuery->width); + } + + if ($assetQuery->height) { + $assetQuery->subQuery->whereNumericParam('assets.height', $assetQuery->height); + } + + if ($assetQuery->size) { + $assetQuery->subQuery->whereNumericParam('assets.size', $assetQuery->size, '=', Query::TYPE_BIGINT); + } + }); + } + + /** + * Narrows the query results based on the assets’ image widths. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `100` | with a width of 100. + * | `'>= 100'` | with a width of at least 100. + * | `['>= 100', '<= 1000']` | with a width between 100 and 1,000. + * + * --- + * + * ```twig + * {# Fetch XL images #} + * {% set {elements-var} = {twig-method} + * .kind('image') + * .width('>= 1000') + * .all() %} + * ``` + * + * ```php + * // Fetch XL images + * ${elements-var} = {php-method} + * ->kind('image') + * ->width('>= 1000') + * ->all(); + * ``` + * + * @uses $width + */ + public function width(mixed $value): static + { + $this->width = $value; + + return $this; + } + + /** + * Narrows the query results based on the assets’ image heights. + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `100` | with a height of 100. + * | `'>= 100'` | with a height of at least 100. + * | `['>= 100', '<= 1000']` | with a height between 100 and 1,000. + * + * --- + * + * ```twig + * {# Fetch XL images #} + * {% set {elements-var} = {twig-method} + * .kind('image') + * .height('>= 1000') + * .all() %} + * ``` + * + * ```php + * // Fetch XL images + * ${elements-var} = {php-method} + * ->kind('image') + * ->height('>= 1000') + * ->all(); + * ``` + * + * @uses $height + */ + public function height(mixed $value): static + { + $this->height = $value; + + return $this; + } + + /** + * Narrows the query results based on the assets’ file sizes (in bytes). + * + * Possible values include: + * + * | Value | Fetches assets… + * | - | - + * | `1000` | with a size of 1,000 bytes (1KB). + * | `'< 1000000'` | with a size of less than 1,000,000 bytes (1MB). + * | `['>= 1000', '< 1000000']` | with a size between 1KB and 1MB. + * + * --- + * + * ```twig + * {# Fetch assets that are smaller than 1KB #} + * {% set {elements-var} = {twig-method} + * .size('< 1000') + * .all() %} + * ``` + * + * ```php + * // Fetch assets that are smaller than 1KB + * ${elements-var} = {php-method} + * ->size('< 1000') + * ->all(); + * ``` + * + * @uses $size + */ + public function size(mixed $value): static + { + $this->size = $value; + + return $this; + } +} diff --git a/src/Element/Elements/Asset.php b/src/Element/Elements/Asset.php new file mode 100644 index 00000000000..37e2dca863f --- /dev/null +++ b/src/Element/Elements/Asset.php @@ -0,0 +1,26 @@ +', AssetQuery::class, ElementCollection::class, Asset::class); @@ -230,7 +231,7 @@ public function __construct(array $config = []) parent::__construct($config); } - #[\Override] + #[Override] public static function getRules(): array { return array_merge(parent::getRules(), [ @@ -243,7 +244,7 @@ public static function getRules(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getSourceOptions(): array { $sourceOptions = []; @@ -277,7 +278,7 @@ public function getFileKindOptions(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string { try { @@ -298,7 +299,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inl /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getElementValidationRules(): array { $rules = parent::getElementValidationRules(); @@ -382,7 +383,7 @@ public function validateFileSize(ElementInterface $element): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function normalizeValue(mixed $value, ?ElementInterface $element): mixed { // If data strings are passed along, make sure the array keys are retained. @@ -421,7 +422,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed /** * {@inheritdoc} */ - #[\Override] + #[Override] public function isValueEmpty(mixed $value, ElementInterface $element): bool { return parent::isValueEmpty($value, $element) && empty($this->_getUploadedFiles($element)); @@ -438,7 +439,7 @@ public function resolveDynamicPathToFolderId(?ElementInterface $element = null): /** * {@inheritdoc} */ - #[\Override] + #[Override] public function includeInGqlSchema(GqlSchema $schema): bool { return Gql::canQueryAssets($schema); @@ -447,7 +448,7 @@ public function includeInGqlSchema(GqlSchema $schema): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlType(): array { return [ @@ -462,7 +463,7 @@ public function getContentGqlType(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function previewHtml(ElementCollection $elements): string { return Cp::elementPreviewHtml( @@ -474,7 +475,7 @@ protected function previewHtml(ElementCollection $elements): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function previewPlaceholderHtml(mixed $value, ?ElementInterface $element): string { $asset = new Asset; @@ -499,7 +500,7 @@ public function previewPlaceholderHtml(mixed $value, ?ElementInterface $element) /** * {@inheritdoc} */ - #[\Override] + #[Override] public function beforeElementSave(ElementInterface $element, bool $isNew): bool { // Only handle file uploads for the initial site @@ -582,7 +583,7 @@ public function beforeElementSave(ElementInterface $element, bool $isNew): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterElementSave(ElementInterface $element, bool $isNew): void { // No special treatment for revisions @@ -658,7 +659,7 @@ public function afterElementSave(ElementInterface $element, bool $isNew): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getEagerLoadingGqlConditions(): ?array { $allowedEntities = Gql::extractAllowedEntitiesFromSchema(); @@ -683,7 +684,7 @@ public function getEagerLoadingGqlConditions(): ?array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getInputSources(?ElementInterface $element = null): array { $folder = $this->_uploadFolder($element, false, false); @@ -755,7 +756,7 @@ public function getInputSources(?ElementInterface $element = null): array /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function inputTemplateVariables(array|ElementQueryInterface|null $value = null, ?ElementInterface $element = null): array { $variables = parent::inputTemplateVariables($value, $element); @@ -798,7 +799,7 @@ protected function inputTemplateVariables(array|ElementQueryInterface|null $valu /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getInputSelectionCriteria(): array { $criteria = parent::getInputSelectionCriteria(); @@ -825,7 +826,7 @@ protected function createSelectionCondition(): ElementCondition /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function showSearchInput(?ElementInterface $element): bool { if (! $this->showSearchInput || $this->sources === '*') { diff --git a/tests/Database/Queries/AssetQueryTest.php b/tests/Database/Queries/AssetQueryTest.php new file mode 100644 index 00000000000..a50422a0b2e --- /dev/null +++ b/tests/Database/Queries/AssetQueryTest.php @@ -0,0 +1,39 @@ +create(); + + expect(assetQuery()->$method()->count())->toBe(1); + + actingAs(User::factory()->create()); + + // Access to nothing + expect(assetQuery()->$method()->count())->toBe(0); +})->with([ + 'editable', + 'savable', +]); + +test('it adds the volume as a cache tag', function () { + Craft::$app->getElements()->startCollectingCacheInfo(); + + $asset = AssetModel::factory()->create(); + + assetQuery()->volumeId($asset->volumeId)->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain('element::'.Asset::class.'::volume:'.$asset->volumeId); +}); diff --git a/tests/Database/Queries/Concerns/Asset/QueriesAltTest.php b/tests/Database/Queries/Concerns/Asset/QueriesAltTest.php new file mode 100644 index 00000000000..17e8aee7a69 --- /dev/null +++ b/tests/Database/Queries/Concerns/Asset/QueriesAltTest.php @@ -0,0 +1,21 @@ +create(); + + // With alt + Asset::factory()->create([ + 'alt' => 'Alt text', + ]); + + $assetWithAltInSite = Asset::factory()->create(); + $assetWithAltInSite->sites()->attach(Site::first(), ['alt' => 'Alt text in site']); + + expect(assetQuery()->count())->toBe(3); + expect(assetQuery()->hasAlt()->count())->toBe(2); + expect(assetQuery()->hasAlt(false)->count())->toBe(1); +}); diff --git a/tests/Database/Queries/Concerns/Asset/QueriesAssetLocationTest.php b/tests/Database/Queries/Concerns/Asset/QueriesAssetLocationTest.php new file mode 100644 index 00000000000..e2b02da4ef5 --- /dev/null +++ b/tests/Database/Queries/Concerns/Asset/QueriesAssetLocationTest.php @@ -0,0 +1,43 @@ +create(); + $withoutVolume->update(['volumeId' => null]); + + $withVolume = Asset::factory()->create(); + $withVolume->update(['volumeId' => ($volume = Volume::factory()->create())->id]); + + expect(assetQuery()->count())->toBe(2); + expect(assetQuery()->volumeId($volume->id)->count())->toBe(1); + expect(assetQuery()->volumeId(':empty:')->count())->toBe(1); + expect(assetQuery()->volume($volume->handle)->count())->toBe(1); + expect(assetQuery()->volume($volume->id)->count())->toBe(1); +}); + +test('folder', function () { + $folder = VolumeFolder::factory()->create([ + 'path' => 'foo/', + ]); + $subFolder = VolumeFolder::factory()->create([ + 'parentId' => $folder->id, + 'volumeId' => $folder->volumeId, + 'path' => 'foo/bar/', + ]); + + Asset::factory()->create(['folderId' => $folder->id]); + Asset::factory()->create(['folderId' => $subFolder->id]); + + expect(assetQuery()->count())->toBe(2); + expect(assetQuery()->folderId($folder->id)->count())->toBe(1); + expect(assetQuery()->folderId($subFolder->id)->count())->toBe(1); + expect(assetQuery()->folderId($folder->id)->includeSubfolders()->count())->toBe(2); + + expect(assetQuery()->folderPath('foo/')->count())->toBe(1); + expect(assetQuery()->folderPath('foo/*')->count())->toBe(2); + expect(assetQuery()->folderPath('foo/bar/')->count())->toBe(1); + expect(assetQuery()->folderPath('not foo/bar/')->count())->toBe(1); +}); diff --git a/tests/Database/Queries/Concerns/Asset/QueriesAssetPropertiesTest.php b/tests/Database/Queries/Concerns/Asset/QueriesAssetPropertiesTest.php new file mode 100644 index 00000000000..4f7d6036fec --- /dev/null +++ b/tests/Database/Queries/Concerns/Asset/QueriesAssetPropertiesTest.php @@ -0,0 +1,45 @@ +create([ + 'uploaderId' => User::factory()->create()->id, + ]); + + Asset::factory()->create([ + 'uploaderId' => User::factory()->create()->id, + ]); + + expect(assetQuery()->count())->toBe(2); + expect(assetQuery()->uploader($asset1->uploaderId)->count())->toBe(1); + expect(assetQuery()->uploader($asset1->uploader)->count())->toBe(1); +}); + +test('filename', function (mixed $param, int $expectedCount) { + Asset::factory()->create([ + 'filename' => 'some-filename.jpg', + ]); + + Asset::factory()->create([ + 'filename' => 'another-filename.jpg', + ]); + + expect(assetQuery()->filename($param)->count())->toBe($expectedCount); +})->with([ + [null, 2], + ['some-filename.jpg', 1], + ['*.jpg', 2], + ['*filename*', 2], + ['not *filename*', 0], +]); + +test('kind', function () { + Asset::factory()->create(['kind' => 'image', 'filename' => 'file.jpg']); + Asset::factory()->create(['kind' => 'unknown', 'filename' => 'file.jpg']); + Asset::factory()->create(['kind' => 'audio', 'filename' => 'file.mp3']); + + expect(assetQuery()->count())->toBe(3); + expect(assetQuery()->kind('image')->count())->toBe(2); +}); diff --git a/tests/Database/Queries/Concerns/Asset/QueriesSizesTest.php b/tests/Database/Queries/Concerns/Asset/QueriesSizesTest.php new file mode 100644 index 00000000000..b40da3be98e --- /dev/null +++ b/tests/Database/Queries/Concerns/Asset/QueriesSizesTest.php @@ -0,0 +1,23 @@ +create([$property => $value]); + } + + expect(assetQuery()->$property($parameter)->count())->toBe($expectedCount); +})->with([ + 'width', + 'height', + 'size', +])->with([ + ['10', 1], + [10, 1], + ['>10', 2], + ['>=10', 3], + [['or', '< 20', '> 20'], 2], + [['< 20', '> 20'], 2], // OR is default + [['and', '< 20', '> 20'], 0], +]); diff --git a/tests/Database/Queries/Concerns/QueriesAuthorsTest.php b/tests/Database/Queries/Concerns/Entry/QueriesAuthorsTest.php similarity index 100% rename from tests/Database/Queries/Concerns/QueriesAuthorsTest.php rename to tests/Database/Queries/Concerns/Entry/QueriesAuthorsTest.php diff --git a/tests/Database/Queries/Concerns/QueriesEntryDatesTest.php b/tests/Database/Queries/Concerns/Entry/QueriesEntryDatesTest.php similarity index 100% rename from tests/Database/Queries/Concerns/QueriesEntryDatesTest.php rename to tests/Database/Queries/Concerns/Entry/QueriesEntryDatesTest.php diff --git a/tests/Database/Queries/Concerns/QueriesEntryTypesTest.php b/tests/Database/Queries/Concerns/Entry/QueriesEntryTypesTest.php similarity index 100% rename from tests/Database/Queries/Concerns/QueriesEntryTypesTest.php rename to tests/Database/Queries/Concerns/Entry/QueriesEntryTypesTest.php diff --git a/tests/Database/Queries/Concerns/QueriesRefTest.php b/tests/Database/Queries/Concerns/Entry/QueriesRefTest.php similarity index 100% rename from tests/Database/Queries/Concerns/QueriesRefTest.php rename to tests/Database/Queries/Concerns/Entry/QueriesRefTest.php diff --git a/tests/Database/Queries/Concerns/QueriesSectionsTest.php b/tests/Database/Queries/Concerns/Entry/QueriesSectionsTest.php similarity index 100% rename from tests/Database/Queries/Concerns/QueriesSectionsTest.php rename to tests/Database/Queries/Concerns/Entry/QueriesSectionsTest.php diff --git a/tests/Pest.php b/tests/Pest.php index 6ff37fe0aed..329f740c643 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Database\Queries\AssetQuery; use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Shared\Enums\LicenseKeyStatus; @@ -58,3 +59,8 @@ function entryQuery(array $config = []): EntryQuery { return new EntryQuery($config); } + +function assetQuery(array $config = []): AssetQuery +{ + return new AssetQuery($config); +} diff --git a/yii2-adapter/legacy/elements/Asset.php b/yii2-adapter/legacy/elements/Asset.php index 0fc9e63d88f..0e83cc94a62 100644 --- a/yii2-adapter/legacy/elements/Asset.php +++ b/yii2-adapter/legacy/elements/Asset.php @@ -289,7 +289,7 @@ public static function isLocalized(): bool * @inheritdoc * @return AssetQuery The newly created [[AssetQuery]] instance. */ - public static function find(): AssetQuery + public static function find(): AssetQuery|\CraftCms\Cms\Database\Queries\AssetQuery { return new AssetQuery(static::class); } @@ -1595,7 +1595,7 @@ protected function safeActionMenuItems(): array $view->registerJsWithVars(fn($id, $assetId, $settings) => << { - new Craft.PreviewFileModal($assetId, $settings); + new Craft.PreviewFileModal($assetId, $settings) }); JS, [ $view->namespaceInputId($previewId), @@ -1678,7 +1678,7 @@ protected function safeActionMenuItems(): array const result = event instanceof CustomEvent ? event.detail : data.result; // Update the filename input and serialized param value - const filenameInput = $('#' + Craft.namespaceId('new-filename', $namespace)); + const filenameInput = $('#' + Craft.namespaceId('new-filename', $namespace)) const oldFilenameValue = encodeURIComponent(filenameInput.val()); filenameInput.val(result.filename); @@ -1705,7 +1705,7 @@ protected function safeActionMenuItems(): array .attr('title', result.formattedSizeInBytes); // Update the dimensions value - let dimensionsVal = $('#' + Craft.namespaceId('dimensions-value', $namespace)); + let dimensionsVal = $('#' + Craft.namespaceId('dimensions-value', $namespace)) if (result.dimensions) { if (!dimensionsVal.length) { $( @@ -1713,8 +1713,8 @@ protected function safeActionMenuItems(): array '
' + $dimensionsLabel + '' + '
' + '' - ).appendTo($('#' + Craft.namespaceId('details', $namespace) + ' > .meta.read-only')); - dimensionsVal = $('#' + Craft.namespaceId('dimensions-value', $namespace)); + ).appendTo($('#' + Craft.namespaceId('details', $namespace) + ' > .meta.read-only')) + dimensionsVal = $('#' + Craft.namespaceId('dimensions-value', $namespace)) } dimensionsVal.text(result.dimensions); } else if (dimensionsVal.length) { @@ -1734,7 +1734,7 @@ protected function safeActionMenuItems(): array Craft.broadcaster.postMessage({ event: 'saveElement', id: $assetId, - }); + }) } if (result.error) { @@ -1774,11 +1774,11 @@ protected function safeActionMenuItems(): array $('#' + Craft.namespaceId('thumb-container', $namespace)).removeClass('loading'); }, } - }); + }) uploader.setParams({ assetId: $assetId, - }); + }) fileInput.click(); }); @@ -1814,7 +1814,7 @@ protected function safeActionMenuItems(): array $updatePreviewThumbJs } }, - }); + }) }); JS,[ $view->namespaceInputId($editImageId), @@ -2925,7 +2925,7 @@ public function getPreviewHtml(): string $jsSettings = Json::encode($settings); $js = << { - new Craft.PreviewFileModal($this->id, null, $jsSettings); + new Craft.PreviewFileModal($this->id, null, $jsSettings) }); JS; $view->registerJs($js); @@ -2956,7 +2956,7 @@ public function getPreviewHtml(): string $updatePreviewThumbJs }, - }); + }) }); JS; $view->registerJs($js);