diff --git a/resources/js/components/assets/Editor/Editor.vue b/resources/js/components/assets/Editor/Editor.vue index 57a3ec71038..b14fb343174 100644 --- a/resources/js/components/assets/Editor/Editor.vue +++ b/resources/js/components/assets/Editor/Editor.vue @@ -8,7 +8,14 @@ - + + + {{ loadError || __('Unable to load asset') }} + + + + + + @@ -139,6 +153,15 @@ + + + + @@ -165,7 +188,7 @@ + + @@ -198,6 +231,7 @@ import FocalPointEditor from './FocalPointEditor.vue'; import CropEditor from './CropEditor.vue'; import PdfViewer from './PdfViewer.vue'; +import SiteSelector from '../../SiteSelector.vue'; import { pick, flatten } from 'lodash-es'; import { router } from '@inertiajs/vue3'; import { @@ -225,6 +259,7 @@ export default { FocalPointEditor, CropEditor, PdfViewer, + SiteSelector, PublishContainer, PublishTabs, Icon, @@ -245,6 +280,10 @@ export default { return true; }, }, + site: { + type: String, + default: null, + }, }, data() { @@ -264,12 +303,22 @@ export default { errors: {}, actions: [], closingWithChanges: false, + pendingSiteSwitch: null, + activeSite: this.site, + localizations: [], + localizedFields: [], + hasOrigin: false, + originValues: {}, + originMeta: {}, + syncFieldConfirmationText: __('messages.sync_entry_field_confirmation_text'), + loadId: 0, + loadError: null, }; }, computed: { readOnly() { - return !this.asset.isEditable; + return this.asset ? !this.asset.isEditable : true; }, isImage() { @@ -297,6 +346,10 @@ export default { isFocalPointEditorEnabled() { return Statamic.$config.get('focalPointEditorEnabled'); }, + + showLocalizationSelector() { + return this.localizations.length > 1; + }, }, setup() { @@ -335,22 +388,34 @@ export default { * This component is given an asset ID. * It needs to get the corresponding data from the server. */ - load() { + load(site = null) { this.loading = true; + this.loadError = null; + const loadId = ++this.loadId; const url = cp_url(`assets/${utf8btoa(this.id)}`); + const requestedSite = site ?? this.activeSite ?? this.site; - return this.$axios.get(url).then((response) => { + this.$axios.get(url, { + params: requestedSite ? { site: requestedSite } : {}, + }).then((response) => { + if (loadId !== this.loadId) return; const data = response.data.data; this.asset = data; // If there are no fields, it will be an empty array when PHP encodes // it into JSON on the server. We'll ensure it's always an object. - this.values = Array.isArray(data.values) ? {} : data.values; + this.values = Array.isArray(data.values) ? {} : (data.values || {}); - this.meta = data.meta; + this.meta = data.meta || {}; this.actionUrl = data.actionUrl; - this.actions = data.actions; + this.actions = Array.isArray(data.actions) ? data.actions : []; + this.activeSite = data.locale || requestedSite; + this.localizations = data.localizations || []; + this.localizedFields = data.localizedFields || []; + this.hasOrigin = data.hasOrigin || false; + this.originValues = data.originValues || {}; + this.originMeta = data.originMeta || {}; this.fieldset = data.blueprint; @@ -373,10 +438,24 @@ export default { ]); this.loading = false; + }).catch((err) => { + if (loadId === this.loadId) { + this.loading = false; + this.loadError = err?.response?.data?.message || __('Unable to load asset'); + } }); }, keydown(event) { + const target = event.target; + const isFormField = target instanceof HTMLElement && ( + target.matches('input, textarea, select') || target.isContentEditable + ); + + if (isFormField) { + return; + } + if ((event.metaKey || event.ctrlKey) && event.key === 'ArrowLeft') { this.navigateToPreviousAsset(); } @@ -438,21 +517,23 @@ export default { }, updateValues(values) { - let updated = { ...event, focus: values.focus }; - - if (JSON.stringify(values) === JSON.stringify(updated)) { - return - } - - values = updated; + this.values = values; }, save() { this.saving = true; const url = cp_url(`assets/${utf8btoa(this.id)}`); + const payload = { + ...this.$refs.container.visibleValues, + site: this.activeSite, + }; + + if (this.hasOrigin) { + payload._localized = this.localizedFields; + } return this.$axios - .patch(url, this.$refs.container.visibleValues) + .patch(url, payload) .then((response) => { this.$emit('saved', response.data.asset); this.$toast.success(__('Saved')); @@ -478,6 +559,29 @@ export default { }); }, + localizationSelected(site) { + if (site === this.activeSite) { + return; + } + + if (this.$dirty.has(this.publishContainer)) { + this.pendingSiteSwitch = site; + return; + } + + this.activeSite = site; + this.load(site); + }, + + confirmSwitchSite() { + const site = this.pendingSiteSwitch; + this.pendingSiteSwitch = null; + this.$dirty.remove(this.publishContainer); + this.$refs.container?.clearDirtyState?.(); + this.activeSite = site; + this.load(site); + }, + saveAndClose() { this.save().then(() => this.$emit('closed')); }, @@ -515,7 +619,7 @@ export default { }, canRunAction(handle) { - return this.actions.find((action) => action.handle == handle); + return (this.actions || []).find((action) => action.handle == handle); }, runAction(actions, handle) { diff --git a/resources/js/components/fieldtypes/assets/Asset.js b/resources/js/components/fieldtypes/assets/Asset.js index 980a9c9f12c..a4e12a112af 100644 --- a/resources/js/components/fieldtypes/assets/Asset.js +++ b/resources/js/components/fieldtypes/assets/Asset.js @@ -16,6 +16,10 @@ export default { type: Boolean, default: true, }, + site: { + type: String, + default: null, + }, }, data() { diff --git a/resources/js/components/fieldtypes/assets/AssetRow.vue b/resources/js/components/fieldtypes/assets/AssetRow.vue index fc9969c658f..34066ec3adc 100644 --- a/resources/js/components/fieldtypes/assets/AssetRow.vue +++ b/resources/js/components/fieldtypes/assets/AssetRow.vue @@ -54,6 +54,7 @@ { this.assets = response.data; @@ -667,6 +674,11 @@ export default { showSelector(selecting) { this.$emit(selecting ? 'focus' : 'blur'); }, + + currentSite(site, previous) { + if (!site || site === previous || !this.assets.length) return; + this.loadAssets(this.assetIds); + }, }, mounted() { diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index f820b780c9b..4197760e7ad 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -20,6 +20,7 @@ use Statamic\Data\ContainsData; use Statamic\Data\HasAugmentedInstance; use Statamic\Data\HasDirtyState; +use Statamic\Data\HasOrigin; use Statamic\Data\TracksQueriedColumns; use Statamic\Data\TracksQueriedRelations; use Statamic\Events\AssetContainerBlueprintFound; @@ -38,6 +39,7 @@ use Statamic\Facades\Blink; use Statamic\Facades\Image; use Statamic\Facades\Path; +use Statamic\Facades\Site; use Statamic\Facades\URL; use Statamic\Facades\YAML; use Statamic\GraphQL\ResolvesValues; @@ -54,7 +56,7 @@ class Asset implements Arrayable, ArrayAccess, AssetContract, Augmentable, ContainsQueryableValues, ResolvesValuesContract, SearchableContract { use ContainsData, FluentlyGetsAndSets, HasAugmentedInstance, HasDirtyState, - Hookable, Searchable, + HasOrigin, Hookable, Searchable, TracksQueriedColumns, TracksQueriedRelations { set as traitSet; get as traitGet; @@ -69,6 +71,7 @@ class Asset implements Arrayable, ArrayAccess, AssetContract, Augmentable, Conta protected $container; protected $path; protected $meta; + protected $locale; protected $withEvents = true; protected $shouldHydrate = true; protected $removedData = []; @@ -138,6 +141,39 @@ public function reference() return "asset::{$this->id()}"; } + public function locale($locale = null) + { + return $this + ->fluentlyGetOrSet('locale') + ->setter(function ($locale) { + $locale = $locale instanceof \Statamic\Sites\Site ? $locale->handle() : $locale; + + if ($this->locale !== $locale) { + $this->meta = null; + $this->data = collect(); + $this->removedData = []; + } + + return $locale; + }) + ->getter(function ($locale) { + return $locale ?? Site::selected()?->handle() ?? Site::default()->handle(); + }) + ->args(func_get_args()); + } + + public function site() + { + return Site::get($this->locale()); + } + + public function in($locale) + { + $asset = clone $this; + + return $asset->locale($locale)->hydrate(); + } + public function get($key, $fallback = null) { return $this->hydrate()->traitGet($key, $fallback); @@ -167,7 +203,11 @@ public function data($data = null) $this->hydrate(); if (func_get_args()) { - $this->removedData = collect($this->meta['data']) + $current = $this->usesLocalizedData() + ? Arr::get($this->meta, 'data.'.$this->locale(), []) + : ($this->meta['data'] ?? []); + + $this->removedData = collect($current) ->diffKeys($data) ->keys() ->merge($this->removedKeys) @@ -177,6 +217,17 @@ public function data($data = null) return call_user_func_array([$this, 'traitData'], func_get_args()); } + public function localizedData() + { + $this->hydrate(); + + if (! $this->usesLocalizedData()) { + return collect($this->data->all()); + } + + return collect(Arr::get($this->meta, 'data.'.$this->locale(), [])); + } + public function hydrate() { if (! $this->shouldHydrate) { @@ -185,7 +236,9 @@ public function hydrate() $this->meta = $this->meta(); - $this->data = collect($this->meta['data']); + $this->data = $this->usesLocalizedData() + ? $this->dataForLocale($this->meta, $this->locale()) + : collect($this->meta['data'] ?? []); $this->removedData = []; @@ -238,25 +291,33 @@ public function meta($key = null) } if (! $this->exists()) { - return $this->generateMeta(); + return $this->normalizeMeta($this->generateMeta()); } if (! config('statamic.assets.cache_meta')) { - return $this->generateMeta(); + return $this->normalizeMeta($this->generateMeta()); } if ($this->meta) { - $meta = $this->meta; - - $meta['data'] = collect(Arr::get($meta, 'data', [])) - ->merge($this->data->all()) - ->except($this->removedData) - ->all(); + $meta = $this->normalizeMeta($this->meta); + + if ($this->usesLocalizedData()) { + $locale = $this->locale(); + $updated = $this->localizedDataForPersistence($meta); + + Arr::set($meta, "data.{$locale}", $updated); + $this->syncFocusToDefaultLocale($meta, $locale); + } else { + $meta['data'] = collect(Arr::get($meta, 'data', [])) + ->merge($this->data->all()) + ->except($this->removedData) + ->all(); + } return $meta; } - return $this->meta = $this->cacheStore()->rememberForever($this->metaCacheKey(), function () { + $meta = $this->cacheStore()->rememberForever($this->metaCacheKey(), function () { if ($contents = $this->disk()->get($path = $this->metaPath())) { return YAML::file($path)->parse($contents); } @@ -265,10 +326,20 @@ public function meta($key = null) return $meta; }); + + return $this->meta = $this->normalizeMeta($meta); } private function metaValue($key) { + if ($key === 'data') { + $meta = $this->meta(); + + return $this->usesLocalizedData() + ? $this->dataForLocale($meta, $this->locale())->all() + : Arr::get($meta, 'data', []); + } + $value = Arr::get($this->meta(), $key); if (! is_null($value)) { @@ -284,7 +355,18 @@ private function metaValue($key) public function generateMeta() { - $meta = ['data' => $this->data->all()]; + $rawMeta = $this->meta; + if (! $rawMeta && $this->exists() && $contents = $this->disk()->get($path = $this->metaPath())) { + $rawMeta = YAML::file($path)->parse($contents); + } + + $data = $this->usesLocalizedData() && $rawMeta + ? $this->localizedDataForPersistence($this->normalizeMeta($rawMeta)) + : $this->data->all(); + + $meta = ['data' => $this->usesLocalizedData() + ? [$this->locale() => $data] + : $data]; if ($this->exists()) { $attributes = Attributes::asset($this)->get(); @@ -316,7 +398,24 @@ protected function metaExists() public function writeMeta($meta) { - $meta['data'] = Arr::removeNullValues($meta['data']); + $meta = $this->normalizeMeta($meta); + + if ($this->usesLocalizedData()) { + $meta['data'] = collect($meta['data'] ?? []) + ->map(fn ($localeData) => Arr::removeNullValues($localeData ?? [])) + ->filter(fn ($localeData) => ! empty($localeData)) + ->all(); + + $siteOrigins = $this->siteOriginsForMeta($meta); + + if ($this->siteOriginsAreDefault($siteOrigins)) { + unset($meta['sites']); + } else { + $meta['sites'] = $siteOrigins; + } + } else { + $meta['data'] = Arr::removeNullValues($meta['data'] ?? []); + } $contents = YAML::dump($meta); @@ -325,7 +424,224 @@ public function writeMeta($meta) public function metaCacheKey() { - return 'asset-meta-'.$this->id(); + $key = 'asset-meta-'.$this->id(); + + if ($this->usesLocalizedData()) { + $key .= '-'.$this->locale(); + } + + return $key; + } + + protected function usesLocalizedData(): bool + { + return Site::multiEnabled() && $this->container()?->localizable(); + } + + protected function normalizeMeta(array $meta): array + { + if (! $this->usesLocalizedData()) { + $meta['data'] = Arr::get($meta, 'data', []); + + return $meta; + } + + $siteOrigins = $this->siteOriginsForMeta($meta); + $siteHandles = array_keys($siteOrigins); + $data = Arr::get($meta, 'data', []); + + if (! is_array($data)) { + $data = []; + } + + // Check if already localized before filtering. Pre-migration data has flat + // field keys (alt, title, etc.), not site handles. Filtering first would + // remove all keys and lose existing asset field data. + if (! $this->isLocalizedMetaData($data, $siteOrigins)) { + $default = Site::default()->handle(); + $data = [$default => $data]; + } else { + $data = collect($data)->filter(fn ($_, $key) => in_array($key, $siteHandles))->all(); + } + + if ($this->siteOriginsAreDefault($siteOrigins)) { + unset($meta['sites']); + } else { + $meta['sites'] = $siteOrigins; + } + $meta['data'] = $data; + + return $meta; + } + + protected function isLocalizedMetaData(array $data, array $siteOrigins): bool + { + if (empty($data)) { + return false; + } + + $siteHandles = array_keys($siteOrigins); + + return collect(array_keys($data))->every(fn ($key) => in_array($key, $siteHandles)); + } + + protected function defaultSiteOrigins(): array + { + $default = Site::default()->handle(); + $sites = Site::all()->mapWithKeys(function ($site) use ($default) { + return [$site->handle() => $site->handle() === $default ? null : $default]; + })->all(); + + return empty($sites) ? [$default => null] : $sites; + } + + protected function siteOriginsForMeta(array $meta): array + { + $defaultSiteOrigins = $this->defaultSiteOrigins(); + $sites = Arr::get($meta, 'sites'); + + if (! is_array($sites)) { + return $defaultSiteOrigins; + } + + return collect($defaultSiteOrigins)->mapWithKeys(function ($origin, $site) use ($sites) { + return [$site => array_key_exists($site, $sites) ? $sites[$site] : $origin]; + })->all(); + } + + protected function siteOriginsAreDefault(array $siteOrigins): bool + { + return $siteOrigins === $this->defaultSiteOrigins(); + } + + protected function dataForLocale(array $meta, $locale) + { + $meta = $this->normalizeMeta($meta); + $siteOrigins = collect($this->siteOriginsForMeta($meta)); + + $data = collect(Arr::get($meta, "data.{$locale}", [])); + $origin = $siteOrigins->get($locale); + $visited = [$locale]; + + while ($origin) { + if (in_array($origin, $visited, true)) { + break; + } + $visited[] = $origin; + $data = collect(Arr::get($meta, "data.{$origin}", []))->merge($data); + $origin = $siteOrigins->get($origin); + } + + if ($this->usesLocalizedData() && ! array_key_exists('focus', $data->all())) { + $focus = Arr::get($meta, 'data.'.Site::default()->handle().'.focus'); + + if (! is_null($focus)) { + $data->put('focus', $focus); + } + } + + return $data; + } + + protected function localizedDataForPersistence(array $meta): array + { + $locale = $this->locale(); + $existing = collect(Arr::get($meta, "data.{$locale}", [])); + $hydrated = $this->dataForLocale($meta, $locale); + + // If nothing has touched asset data, keep explicit localized values as-is. + // This prevents save operations like move/rename from persisting inherited + // origin values into the localized override bucket. + if ($this->removedData === [] && $this->data->all() === $hydrated->all()) { + return $existing->all(); + } + + return $existing + ->merge($this->data->all()) + ->except($this->removedData) + ->all(); + } + + protected function syncFocusToDefaultLocale(array &$meta, string $locale): void + { + $default = Site::default()->handle(); + + if (in_array('focus', $this->removedData, true)) { + $data = Arr::get($meta, 'data', []); + + foreach ($data as $site => $siteData) { + unset($siteData['focus']); + $data[$site] = $siteData; + } + + $meta['data'] = $data; + + return; + } + + $focus = Arr::get($meta, "data.{$locale}.focus", Arr::get($meta, "data.{$default}.focus")); + + if (is_null($focus)) { + return; + } + + Arr::set($meta, "data.{$default}.focus", $focus); + + if ($locale !== $default) { + Arr::forget($meta, "data.{$locale}.focus"); + } + } + + public function origin($origin = null) + { + if (func_num_args() === 0) { + if (! $this->usesLocalizedData()) { + return null; + } + + $siteOrigins = $this->siteOriginsForMeta($this->meta()); + + return $this->getOriginByString( + collect($siteOrigins)->get($this->locale()) + ); + } + + throw new \Exception('The origin cannot be set directly. It is derived from site configuration.'); + } + + public function getOriginByString($origin) + { + return $origin ? $this->in($origin) : null; + } + + /** + * Get merged origin values without following the origin chain (cycle-safe). + * Use this instead of origin()->values() when serializing to avoid infinite + * recursion with cyclic site origin maps. + */ + public function originValuesData(): \Illuminate\Support\Collection + { + if (! $this->usesLocalizedData()) { + return collect(); + } + + $originLocale = collect($this->siteOriginsForMeta($this->meta()))->get($this->locale()); + + if (! $originLocale) { + return collect(); + } + + return $this->dataForLocale($this->meta(), $originLocale); + } + + protected function getOriginIdFromObject($origin) + { + return $origin->locale(); + } + + protected function getOriginBlinkKey() + { + return 'origin-asset-'.$this->id().'-'.$this->locale(); } /** @@ -698,7 +1014,13 @@ public function delete() protected function clearCaches() { $this->meta = null; - $this->cacheStore()->forget($this->metaCacheKey()); + + if ($this->usesLocalizedData()) { + Site::all()->each(fn ($site) => $this->cacheStore()->forget('asset-meta-'.$this->id().'-'.$site->handle())); + $this->cacheStore()->forget('asset-meta-'.$this->id()); + } else { + $this->cacheStore()->forget($this->metaCacheKey()); + } } /** diff --git a/src/Assets/AssetContainer.php b/src/Assets/AssetContainer.php index 9d94368d527..f96b243755c 100644 --- a/src/Assets/AssetContainer.php +++ b/src/Assets/AssetContainer.php @@ -45,6 +45,7 @@ class AssetContainer implements Arrayable, ArrayAccess, AssetContainerContract, protected $sortField; protected $sortDirection; protected $validation; + protected $localizable; public function id($id = null) { @@ -109,6 +110,14 @@ public function validationRules($rules = null) ->args(func_get_args()); } + public function localizable($localizable = null) + { + return $this + ->fluentlyGetOrSet('localizable') + ->getter(fn ($localizable) => (bool) ($localizable ?? false)) + ->args(func_get_args()); + } + public function diskPath() { return rtrim($this->disk()->path('/'), '/'); @@ -569,6 +578,7 @@ public function fileData() 'source_preset' => $this->sourcePreset, 'warm_presets' => $this->warmPresets, 'validate' => $this->validation, + 'localizable' => $this->localizable ?: null, ]; $array = Arr::removeNullValues(array_merge($array, [ diff --git a/src/Assets/AssetRepository.php b/src/Assets/AssetRepository.php index 786b107f087..8252faa00a4 100644 --- a/src/Assets/AssetRepository.php +++ b/src/Assets/AssetRepository.php @@ -148,7 +148,7 @@ public function save($asset) $store->save($asset); - $asset->writeMeta($asset->generateMeta()); + $asset->writeMeta($asset->meta()); } public function delete($asset) diff --git a/src/Assets/AugmentedAsset.php b/src/Assets/AugmentedAsset.php index a49727b431a..b0c31ec2cd8 100644 --- a/src/Assets/AugmentedAsset.php +++ b/src/Assets/AugmentedAsset.php @@ -35,6 +35,7 @@ public function keys() 'edit_url', 'container', 'folder', + 'locale', 'url', 'permalink', 'api_url', @@ -84,6 +85,11 @@ protected function permalink() return $this->data->absoluteUrl(); } + protected function locale() + { + return $this->data->locale(); + } + protected function size() { return Str::fileSizeForHumans($this->sizeBytes()); diff --git a/src/Console/Commands/AssetsMigrateLocalizable.php b/src/Console/Commands/AssetsMigrateLocalizable.php new file mode 100644 index 00000000000..46b7ae7eb5a --- /dev/null +++ b/src/Console/Commands/AssetsMigrateLocalizable.php @@ -0,0 +1,260 @@ +option('site') ?: Site::default()->handle(); + $siteOrigins = $this->siteOrigins($rootSite); + $containers = $this->containers(); + + $fileMigrated = 0; + $fileScanned = 0; + + foreach ($containers as $container) { + if (! $container->localizable()) { + $this->components->warn(__('Skipping [:container] because localizable metadata is disabled.', [ + 'container' => $container->handle(), + ])); + + continue; + } + + foreach ($container->metaFiles() as $metaPath) { + $fileScanned++; + $contents = $container->disk()->get($metaPath); + $meta = $contents ? YAML::file($metaPath)->parse($contents) : []; + $normalized = $this->normalizeMeta($meta, $siteOrigins, $rootSite); + + if ($normalized === $meta) { + continue; + } + + $container->disk()->put($metaPath, YAML::dump($normalized)); + $this->clearAssetMetaCache( + $container->handle(), + $this->assetPathFromMetaPath($metaPath) + ); + $fileMigrated++; + } + } + + $localizableHandles = $containers + ->filter(fn ($c) => $c->localizable()) + ->map(fn ($c) => $c->handle()) + ->values() + ->all(); + + [$dbMigrated, $dbScanned] = $this->migrateEloquentMeta($siteOrigins, $rootSite, $localizableHandles); + + $this->components->info(__('Migrated :migrated of :scanned asset metadata files.', [ + 'migrated' => $fileMigrated, + 'scanned' => $fileScanned, + ])); + + if ($dbScanned > 0) { + $this->components->info(__('Migrated :migrated of :scanned Eloquent asset metadata rows.', [ + 'migrated' => $dbMigrated, + 'scanned' => $dbScanned, + ])); + $this->components->warn(__('For future optimization, add a dedicated locale column in the eloquent-driver assets table.')); + } + + return self::SUCCESS; + } + + protected function containers() + { + if (! $handle = $this->argument('container')) { + return AssetContainer::all(); + } + + $container = AssetContainer::find($handle); + + if (! $container) { + throw new \InvalidArgumentException(__('Invalid container [:container].', ['container' => $handle])); + } + + return collect([$container]); + } + + protected function siteOrigins(string $rootSite): array + { + return Site::all()->mapWithKeys(function ($site) use ($rootSite) { + return [$site->handle() => $site->handle() === $rootSite ? null : $rootSite]; + })->all(); + } + + protected function normalizeMeta(array $meta, array $siteOrigins, string $rootSite): array + { + $data = $meta['data'] ?? []; + + if (! is_array($data)) { + $data = []; + } + + if (! $this->isLocalizedData($data, $siteOrigins)) { + $data = [$rootSite => $data]; + } + + $data = collect($data) + ->map(fn ($localeData) => array_filter(($localeData ?? []), fn ($value) => ! is_null($value))) + ->filter(fn ($localeData) => ! empty($localeData)) + ->all(); + + $resolvedSiteOrigins = $this->siteOriginsForMeta($meta, $siteOrigins); + + if ($this->siteOriginsAreDefault($resolvedSiteOrigins)) { + unset($meta['sites']); + } else { + $meta['sites'] = $resolvedSiteOrigins; + } + + $meta['data'] = $data; + + return $meta; + } + + protected function isLocalizedData(array $data, array $siteOrigins): bool + { + if (empty($data)) { + return false; + } + + $siteHandles = array_keys($siteOrigins); + + return collect(array_keys($data))->every(fn ($key) => in_array($key, $siteHandles)); + } + + protected function defaultSiteOrigins(): array + { + $default = Site::default()->handle(); + + return Site::all()->mapWithKeys(function ($site) use ($default) { + return [$site->handle() => $site->handle() === $default ? null : $default]; + })->all(); + } + + protected function siteOriginsForMeta(array $meta, array $fallbackSiteOrigins): array + { + $sites = $meta['sites'] ?? null; + + if (! is_array($sites)) { + return $fallbackSiteOrigins; + } + + return collect($fallbackSiteOrigins)->mapWithKeys(function ($origin, $site) use ($sites) { + return [$site => array_key_exists($site, $sites) ? $sites[$site] : $origin]; + })->all(); + } + + protected function siteOriginsAreDefault(array $siteOrigins): bool + { + return $siteOrigins === $this->defaultSiteOrigins(); + } + + protected function migrateEloquentMeta(array $siteOrigins, string $rootSite, array $localizableContainerHandles): array + { + $modelClass = app()->bound('statamic.eloquent.assets.model') + ? app('statamic.eloquent.assets.model') + : null; + + if (! $modelClass) { + return [0, 0]; + } + + $table = (new $modelClass)->getTable(); + + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'meta')) { + return [0, 0]; + } + + if (empty($localizableContainerHandles)) { + return [0, 0]; + } + + if (! Schema::hasColumn($table, 'container')) { + $this->components->warn(__('Skipping Eloquent migration: assets table has no container column. Add one to filter by container.')); + + return [0, 0]; + } + + $migrated = 0; + $scanned = 0; + + $query = DB::table($table) + ->select(['id', 'meta', 'container', 'path']) + ->whereIn('container', $localizableContainerHandles) + ->orderBy('id'); + + $query->lazy()->each(function ($row) use ($modelClass, $siteOrigins, $rootSite, $table, &$migrated, &$scanned) { + $scanned++; + $meta = is_array($row->meta) ? $row->meta : (json_decode($row->meta ?? '{}', true) ?: []); + $normalized = $this->normalizeMeta($meta, $siteOrigins, $rootSite); + + if ($normalized === $meta) { + return; + } + + $model = $modelClass::find($row->id); + if ($model) { + $model->meta = $normalized; + if (! Schema::hasColumn($table, 'updated_at')) { + $model->timestamps = false; + } else { + $model->updated_at = now(); + } + $model->save(); + $this->clearAssetMetaCache($row->container, $row->path); + $migrated++; + } + }); + + return [$migrated, $scanned]; + } + + protected function assetPathFromMetaPath(string $metaPath): string + { + return Str::of($metaPath) + ->replace('/.meta/', '/') + ->replace('.meta/', '') + ->replaceEnd('.yaml', '') + ->trim('/') + ->toString(); + } + + protected function clearAssetMetaCache(string $container, string $path): void + { + if (! config('statamic.assets.cache_meta') || blank($container) || blank($path)) { + return; + } + + $assetId = "{$container}::{$path}"; + $cacheStore = Cache::store(config()->has('cache.stores.asset_meta') ? 'asset_meta' : null); + + $cacheStore->forget('asset-meta-'.$assetId); + Site::all()->each(function ($site) use ($cacheStore, $assetId) { + $cacheStore->forget('asset-meta-'.$assetId.'-'.$site->handle()); + }); + } +} diff --git a/src/Contracts/Assets/Asset.php b/src/Contracts/Assets/Asset.php index 3cdd211dd2b..c8f128ac09e 100644 --- a/src/Contracts/Assets/Asset.php +++ b/src/Contracts/Assets/Asset.php @@ -2,9 +2,11 @@ namespace Statamic\Contracts\Assets; +use Statamic\Contracts\Data\Localizable; +use Statamic\Sites\Site; use Symfony\Component\HttpFoundation\File\UploadedFile; -interface Asset +interface Asset extends Localizable { /** * Get the filename. @@ -84,4 +86,27 @@ public function download(?string $name = null, array $headers = []); * @return mixed */ public function contents(); + + /** + * Get or set the locale. + * + * @param string|Site|null $locale + * @return mixed + */ + public function locale($locale = null); + + /** + * Get the asset in a locale. + * + * @param string|Site $locale + * @return self + */ + public function in($locale); + + /** + * Get the site for the active locale. + * + * @return Site|null + */ + public function site(); } diff --git a/src/Contracts/Assets/AssetContainer.php b/src/Contracts/Assets/AssetContainer.php index 7c77892fc96..e441bfcfb3c 100644 --- a/src/Contracts/Assets/AssetContainer.php +++ b/src/Contracts/Assets/AssetContainer.php @@ -109,4 +109,12 @@ public function private(); * @return array */ public function validationRules($rules = null); + + /** + * Whether metadata in this container is localizable. + * + * @param bool|null $localizable + * @return bool|self + */ + public function localizable($localizable = null); } diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index 150d2febae2..6e57752a6d4 100644 --- a/src/Fieldtypes/Assets/Assets.php +++ b/src/Fieldtypes/Assets/Assets.php @@ -341,12 +341,14 @@ private function renameFolderAction($dynamicFolder) ]; } - public function getItemData($items) + public function getItemData($items, $site = null) { - return collect($items)->map(function ($url) { - return ($asset = Asset::find($url)) - ? (new AssetResource($asset))->resolve()['data'] - : null; + return collect($items)->map(function ($url) use ($site) { + if (! $asset = Asset::find($url)) { + return null; + } + + return (new AssetResource($site ? $asset->in($site) : $asset))->resolve()['data']; })->filter()->values(); } diff --git a/src/GraphQL/Queries/AssetQuery.php b/src/GraphQL/Queries/AssetQuery.php index edb01a0e92a..9f7eb39059e 100644 --- a/src/GraphQL/Queries/AssetQuery.php +++ b/src/GraphQL/Queries/AssetQuery.php @@ -32,6 +32,7 @@ public function args(): array 'id' => GraphQL::string(), 'container' => GraphQL::string(), 'path' => GraphQL::string(), + 'site' => GraphQL::string(), ]; } @@ -43,6 +44,10 @@ public function resolve($root, $args) $asset = AssetContainer::findByHandle($args['container'])->asset($args['path']); } + if ($asset && ($site = $args['site'] ?? null)) { + $asset = $asset->in($site); + } + // The middleware will take care of authorization when using `container` arg, // but this is still required when the user queries by the asset `id` arg. if ($asset && ! in_array($container = $asset->container()->handle(), $this->allowedSubResources())) { diff --git a/src/GraphQL/Queries/AssetsQuery.php b/src/GraphQL/Queries/AssetsQuery.php index ff393d2bdd1..510d1d502e1 100644 --- a/src/GraphQL/Queries/AssetsQuery.php +++ b/src/GraphQL/Queries/AssetsQuery.php @@ -40,6 +40,7 @@ public function args(): array 'container' => GraphQL::nonNull(GraphQL::string()), 'limit' => GraphQL::int(), 'page' => GraphQL::int(), + 'site' => GraphQL::string(), 'filter' => GraphQL::type(JsonArgument::NAME), 'sort' => GraphQL::listOf(GraphQL::string()), ]; @@ -57,7 +58,13 @@ public function resolve($root, $args) $this->sortQuery($query, $sort); } - return $query->paginate($args['limit'] ?? 1000); + $assets = $query->paginate($args['limit'] ?? 1000); + + if ($site = $args['site'] ?? null) { + $assets->setCollection($assets->getCollection()->map(fn ($asset) => $asset->in($site))); + } + + return $assets; } private function sortQuery($query, $sorts) diff --git a/src/GraphQL/Types/AssetInterface.php b/src/GraphQL/Types/AssetInterface.php index 2a466c3037e..62471b63397 100644 --- a/src/GraphQL/Types/AssetInterface.php +++ b/src/GraphQL/Types/AssetInterface.php @@ -48,6 +48,9 @@ public function fields(): array 'folder' => [ 'type' => GraphQL::string(), ], + 'locale' => [ + 'type' => GraphQL::string(), + ], 'url' => [ 'type' => GraphQL::string(), ], diff --git a/src/Http/Controllers/CP/Assets/AssetContainersController.php b/src/Http/Controllers/CP/Assets/AssetContainersController.php index 7d360d2fb77..687fd603cf9 100644 --- a/src/Http/Controllers/CP/Assets/AssetContainersController.php +++ b/src/Http/Controllers/CP/Assets/AssetContainersController.php @@ -7,6 +7,7 @@ use Statamic\CP\PublishForm; use Statamic\Facades\AssetContainer; use Statamic\Facades\Blueprint; +use Statamic\Facades\Site; use Statamic\Http\Controllers\CP\CpController; use Statamic\Rules\Handle; @@ -29,6 +30,7 @@ public function edit($container) 'warm_intelligent' => $intelligent = $container->warmsPresetsIntelligently(), 'warm_presets' => $intelligent ? [] : $container->warmPresets(), 'validation' => $container->validationRules(), + 'localizable' => $container->localizable(), ]; return PublishForm::make($this->formBlueprint($container)) @@ -53,7 +55,8 @@ public function update(Request $request, $container) ->disk($values['disk']) ->sourcePreset($values['source_preset']) ->warmPresets($values['warm_intelligent'] ? null : $values['warm_presets']) - ->validationRules($values['validation'] ?? null); + ->validationRules($values['validation'] ?? null) + ->localizable($values['localizable'] ?? false); $container->save(); @@ -91,7 +94,8 @@ public function store(Request $request) ->title($values['title']) ->disk($values['disk']) ->sourcePreset($values['source_preset']) - ->warmPresets($values['warm_intelligent'] ? null : $values['warm_presets']); + ->warmPresets($values['warm_intelligent'] ? null : $values['warm_presets']) + ->localizable($values['localizable'] ?? false); $container->save(); @@ -187,10 +191,19 @@ protected function formBlueprint($container = null) 'display' => __('Validation Rules'), 'instructions' => __('statamic::messages.asset_container_validation_rules_instructions'), ], + 'localizable' => [ + 'type' => 'toggle', + 'display' => __('Localizable Metadata'), + 'instructions' => __('When enabled, asset field data like alt text can be translated per site.'), + ], ], ], ]); + if (! Site::multiEnabled()) { + unset($fields['settings']['fields']['localizable']); + } + $fields = array_merge($fields, [ 'image_manipulation' => [ 'display' => __('Image Manipulation'), diff --git a/src/Http/Controllers/CP/Assets/AssetsController.php b/src/Http/Controllers/CP/Assets/AssetsController.php index a4bb8b4ffa0..6f0ae03537d 100644 --- a/src/Http/Controllers/CP/Assets/AssetsController.php +++ b/src/Http/Controllers/CP/Assets/AssetsController.php @@ -16,6 +16,7 @@ use Statamic\Exceptions\NotFoundHttpException; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; +use Statamic\Facades\Site; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; use Statamic\Http\Resources\CP\Assets\Asset as AssetResource; @@ -39,12 +40,19 @@ public function index() throw new AuthorizationException; } - public function show($asset) + public function show(Request $request, $asset) { $asset = Asset::find(base64_decode($asset)); abort_if(! $asset, 404); + $site = $request->input('site') ?: (Site::multiEnabled() ? Site::selected()->handle() : null); + + if ($site) { + abort_unless(Site::get($site), 422, __('Invalid site.')); + $asset = $asset->in($site); + } + $this->authorize('view', $asset); return new AssetResource($asset); @@ -54,6 +62,15 @@ public function update(Request $request, $asset) { $asset = Asset::find(base64_decode($asset)); + abort_if(! $asset, 404); + + $site = $request->input('site') ?: (Site::multiEnabled() ? Site::selected()->handle() : null); + + if ($site) { + abort_unless(Site::get($site), 422, __('Invalid site.')); + $asset = $asset->in($site); + } + $this->authorize('edit', $asset); $fields = $asset->blueprint()->fields()->addValues($request->all()); @@ -64,8 +81,34 @@ public function update(Request $request, $asset) 'focus' => $request->focus, ]); - foreach ($values as $key => $value) { - $asset->set($key, $value); + if ($asset->hasOrigin()) { + $localizedHandles = collect($request->input('_localized', [])); + $localizedInput = $values->only($localizedHandles->all()); + + // Remove: (1) fields synced back to origin (in localizedData but not in _localized), + // (2) fields in _localized with null/empty values + $keysToForget = $asset->localizedData() + ->keys() + ->diff($localizedHandles) + ->merge( + $localizedInput + ->filter(fn ($value) => $value === null || $value === '') + ->keys() + ) + ->unique() + ->values(); + + $keysToUpsert = $localizedInput + ->reject(fn ($value) => $value === null || $value === ''); + + $asset->data( + $asset->localizedData() + ->except($keysToForget->all()) + ->merge($keysToUpsert) + ->all() + ); + } else { + $asset->merge($values->all()); } $asset->save(); diff --git a/src/Http/Controllers/CP/Assets/FieldtypeController.php b/src/Http/Controllers/CP/Assets/FieldtypeController.php index afcb2ae998e..a4bb00c5b10 100644 --- a/src/Http/Controllers/CP/Assets/FieldtypeController.php +++ b/src/Http/Controllers/CP/Assets/FieldtypeController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\CP\Assets; use Illuminate\Http\Request; +use Statamic\Facades\Site; use Statamic\Fieldtypes\Assets\Assets as AssetsFieldtype; use Statamic\Http\Controllers\CP\CpController; @@ -10,7 +11,13 @@ class FieldtypeController extends CpController { public function index(Request $request) { + $site = $request->input('site'); + + if (! $site && Site::multiEnabled()) { + $site = Site::selected()->handle(); + } + return (new AssetsFieldtype) - ->getItemData($request->input('assets', [])); + ->getItemData($request->input('assets', []), $site); } } diff --git a/src/Http/Resources/API/AssetResource.php b/src/Http/Resources/API/AssetResource.php index 19c1cab3ebe..339af024f8c 100644 --- a/src/Http/Resources/API/AssetResource.php +++ b/src/Http/Resources/API/AssetResource.php @@ -14,11 +14,17 @@ class AssetResource extends JsonResource */ public function toArray($request) { - $with = $this->blueprint() + $asset = $this->resource; + + if ($site = $request->input('site')) { + $asset = $asset->in($site); + } + + $with = $asset->blueprint() ->fields()->all() ->filter->isRelationship()->keys()->all(); - return $this->resource + return $asset ->toAugmentedCollection() ->withRelations($with) ->withShallowNesting() diff --git a/src/Http/Resources/CP/Assets/Asset.php b/src/Http/Resources/CP/Assets/Asset.php index 72f29e47d4f..cb63d6e6fc9 100644 --- a/src/Http/Resources/CP/Assets/Asset.php +++ b/src/Http/Resources/CP/Assets/Asset.php @@ -5,6 +5,7 @@ use Illuminate\Http\Resources\Json\JsonResource; use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Facades\Action; +use Statamic\Facades\Site; use Statamic\Facades\User; use Statamic\Support\Str; @@ -61,6 +62,10 @@ public function toArray($request) $this->merge($this->thumbnails()), $this->merge($this->publishFormData()), + $this->mergeWhen( + Site::multiEnabled() && $this->container()->localizable(), + fn () => $this->localizationData() + ), 'actionUrl' => cp_route('assets.actions.run'), 'actions' => Action::for($this->resource, [ @@ -77,13 +82,58 @@ public function toArray($request) protected function publishFormData() { + $asset = $this->resource; + $asset->hydrate(); + + // Use data() instead of values() to avoid infinite recursion when sites + // origin map is cyclic. values() follows origin()->values() recursively; + // data() returns the cycle-safe collection populated by dataForLocale(). + $values = ($asset->data() ?? collect())->all(); + $fields = $this->blueprint()->fields() - ->addValues($this->data()->all()) + ->addValues($values) ->preProcess(); return [ - 'values' => $this->data()->merge($fields->values()), - 'meta' => $fields->meta(), + 'values' => collect($values)->merge($fields->values())->all(), + 'meta' => $fields->meta()->all(), + ]; + } + + protected function localizationData() + { + if (! Site::multiEnabled() || ! $this->container()->localizable()) { + return []; + } + + $originValues = null; + $originMeta = null; + + if ($this->hasOrigin()) { + $fields = $this->blueprint()->fields() + ->addValues($this->originValuesData()->all()) + ->preProcess(); + + $originValues = $fields->values()->all(); + $originMeta = $fields->meta()->all(); + } + + return [ + 'locale' => $this->locale(), + 'localizedFields' => $this->localizedData()->keys()->values()->all(), + 'hasOrigin' => $this->hasOrigin(), + 'originValues' => $originValues, + 'originMeta' => $originMeta, + 'localizations' => Site::all()->map(function ($site) { + $localized = $this->in($site->handle()); + + return [ + 'handle' => $site->handle(), + 'name' => $site->name(), + 'active' => $site->handle() === $this->locale(), + 'origin' => ! $localized->hasOrigin(), + ]; + })->values()->all(), ]; } } diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 8d058c405ed..e166d08c45f 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -15,6 +15,7 @@ class ConsoleServiceProvider extends ServiceProvider Commands\AssetsGeneratePresets::class, Commands\AssetsMeta::class, Commands\AssetsMetaClean::class, + Commands\AssetsMigrateLocalizable::class, Commands\GlideClear::class, Commands\Install::class, Commands\InstallCollaboration::class, diff --git a/src/Stache/Stores/AssetContainersStore.php b/src/Stache/Stores/AssetContainersStore.php index a86def19fe0..d948ecf0a87 100644 --- a/src/Stache/Stores/AssetContainersStore.php +++ b/src/Stache/Stores/AssetContainersStore.php @@ -26,6 +26,7 @@ public function makeItemFromFile($path, $contents) ->searchIndex(Arr::get($data, 'search_index')) ->sortField(Arr::get($data, 'sort_by')) ->sortDirection(Arr::get($data, 'sort_dir')) - ->validationRules(Arr::get($data, 'validate')); + ->validationRules(Arr::get($data, 'validate')) + ->localizable(Arr::get($data, 'localizable', false)); } } diff --git a/tests/Assets/AssetRepositoryTest.php b/tests/Assets/AssetRepositoryTest.php index 6d9d4c755f8..781c4c2eac4 100644 --- a/tests/Assets/AssetRepositoryTest.php +++ b/tests/Assets/AssetRepositoryTest.php @@ -11,6 +11,7 @@ use Statamic\Exceptions\AssetNotFoundException; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; +use Statamic\Facades\YAML; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -131,4 +132,225 @@ public function test_find_or_fail_throws_exception_when_asset_does_not_exist() $assetRepository->findOrFail('does-not-exist'); } + + #[Test] + public function it_preserves_other_locales_when_saving_a_localized_asset() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en'], + 'es' => ['url' => '/es/', 'locale' => 'es'], + ]); + + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'hello'); + $disk->put('foo/.meta/test.txt.yaml', YAML::dump([ + 'data' => [ + 'en' => ['alt' => 'Bob Ross'], + ], + 'size' => 5, + 'last_modified' => 123, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + $container = tap(AssetContainer::make('test')->disk('test')->localizable(true))->save(); + $asset = $container->makeAsset('foo/test.txt')->in('es'); + $asset->data(['alt' => 'El Bob Rosso']); + + (new AssetRepository)->save($asset); + + $meta = YAML::parse($disk->get('foo/.meta/test.txt.yaml')); + + $this->assertSame('Bob Ross', $meta['data']['en']['alt']); + $this->assertSame('El Bob Rosso', $meta['data']['es']['alt']); + } + + #[Test] + public function it_does_not_materialize_inherited_values_when_saving_without_localized_changes() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en'], + 'es' => ['url' => '/es/', 'locale' => 'es'], + ]); + + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'hello'); + $disk->put('foo/.meta/test.txt.yaml', YAML::dump([ + 'data' => [ + 'en' => ['alt' => 'Bob Ross'], + ], + 'size' => 5, + 'last_modified' => 123, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + $container = tap(AssetContainer::make('test')->disk('test')->localizable(true))->save(); + $asset = $container->makeAsset('foo/test.txt')->in('es'); + + (new AssetRepository)->save($asset); + + $meta = YAML::parse($disk->get('foo/.meta/test.txt.yaml')); + + $this->assertArrayHasKey('en', $meta['data']); + $this->assertArrayNotHasKey('es', $meta['data']); + } + + #[Test] + public function it_drops_empty_localized_buckets_and_omits_default_sites_map() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en'], + 'es' => ['url' => '/es/', 'locale' => 'es'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr'], + ]); + + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'hello'); + $disk->put('foo/.meta/test.txt.yaml', YAML::dump([ + 'data' => [ + 'en' => ['alt' => 'Bob Ross'], + 'es' => ['alt' => 'El Bob Rosso'], + ], + 'sites' => [ + 'en' => null, + 'es' => 'en', + 'fr' => 'en', + ], + 'size' => 5, + 'last_modified' => 123, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + $container = tap(AssetContainer::make('test')->disk('test')->localizable(true))->save(); + $asset = $container->makeAsset('foo/test.txt')->in('es'); + $asset->data([]); + + (new AssetRepository)->save($asset); + + $meta = YAML::parse($disk->get('foo/.meta/test.txt.yaml')); + + $this->assertArrayHasKey('en', $meta['data']); + $this->assertArrayNotHasKey('es', $meta['data']); + $this->assertArrayNotHasKey('sites', $meta); + } + + #[Test] + public function it_persists_sites_map_when_it_differs_from_default_site_origins() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en'], + 'es' => ['url' => '/es/', 'locale' => 'es'], + ]); + + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'hello'); + $disk->put('foo/.meta/test.txt.yaml', YAML::dump([ + 'data' => [ + 'es' => ['alt' => 'El Bob Rosso'], + ], + 'sites' => [ + 'en' => 'es', + 'es' => null, + ], + 'size' => 5, + 'last_modified' => 123, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + $container = tap(AssetContainer::make('test')->disk('test')->localizable(true))->save(); + $asset = $container->makeAsset('foo/test.txt')->in('en'); + $asset->data(['more_text' => 'custom']); + + (new AssetRepository)->save($asset); + + $meta = YAML::parse($disk->get('foo/.meta/test.txt.yaml')); + + $this->assertArrayHasKey('sites', $meta); + $this->assertSame('es', $meta['sites']['en']); + $this->assertNull($meta['sites']['es']); + } + + #[Test] + public function it_does_not_infinite_loop_when_sites_metadata_has_cycles() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr'], + ]); + + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'hello'); + $disk->put('foo/.meta/test.txt.yaml', YAML::dump([ + 'data' => [ + 'en' => ['alt' => 'English alt'], + 'fr' => ['alt' => 'French alt'], + ], + 'sites' => [ + 'en' => 'fr', + 'fr' => 'en', + ], + 'size' => 5, + 'last_modified' => 123, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + $container = tap(AssetContainer::make('test')->disk('test')->localizable(true))->save(); + $asset = $container->makeAsset('foo/test.txt')->in('en'); + + $this->assertSame('English alt', $asset->get('alt')); + } + + #[Test] + public function focal_points_are_not_localized() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en'], + 'es' => ['url' => '/es/', 'locale' => 'es'], + ]); + + $disk = Storage::fake('test'); + $disk->put('foo/test.txt', 'hello'); + $disk->put('foo/.meta/test.txt.yaml', YAML::dump([ + 'data' => [ + 'en' => [ + 'alt' => 'Bob Ross', + 'focus' => '10-20', + ], + 'es' => [ + 'alt' => 'El Bob Rosso', + ], + ], + 'size' => 5, + 'last_modified' => 123, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + $container = tap(AssetContainer::make('test')->disk('test')->localizable(true))->save(); + $asset = $container->makeAsset('foo/test.txt')->in('es'); + $asset->set('focus', '75-25'); + + (new AssetRepository)->save($asset); + + $meta = YAML::parse($disk->get('foo/.meta/test.txt.yaml')); + + $this->assertSame('75-25', $meta['data']['en']['focus']); + $this->assertArrayNotHasKey('focus', $meta['data']['es']); + } } diff --git a/tests/Console/Commands/AssetsMetaTest.php b/tests/Console/Commands/AssetsMetaTest.php index 428a8997c05..d72f9ecb800 100644 --- a/tests/Console/Commands/AssetsMetaTest.php +++ b/tests/Console/Commands/AssetsMetaTest.php @@ -98,4 +98,85 @@ public function it_preserves_data_property_in_meta_data_file() 'bar' ); } + + #[Test] + public function it_migrates_localizable_meta_without_persisting_default_sites_map() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US'], + 'es' => ['url' => '/es', 'locale' => 'es_ES'], + ]); + + Storage::disk('test')->put('foo/bar.txt', 'foobar'); + Storage::disk('test')->put('foo/.meta/bar.txt.yaml', YAML::dump([ + 'data' => [ + 'en' => ['alt' => 'Bob Ross'], + 'es' => [], + ], + 'sites' => [ + 'en' => null, + 'es' => 'en', + ], + 'size' => 6, + 'last_modified' => 1665086377, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + AssetContainer::make('test_container') + ->disk('test') + ->localizable(true) + ->save(); + + $this->artisan('statamic:assets:migrate-localizable test_container') + ->expectsOutputToContain('asset metadata files.'); + + $meta = YAML::parse(Storage::disk('test')->get('foo/.meta/bar.txt.yaml')); + + $this->assertArrayNotHasKey('sites', $meta); + $this->assertSame(['en' => ['alt' => 'Bob Ross']], $meta['data']); + } + + #[Test] + public function it_migrates_localizable_meta_and_keeps_non_default_sites_map() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US'], + 'es' => ['url' => '/es', 'locale' => 'es_ES'], + ]); + + Storage::disk('test')->put('foo/baz.txt', 'foobar'); + Storage::disk('test')->put('foo/.meta/baz.txt.yaml', YAML::dump([ + 'data' => [ + 'es' => ['alt' => 'El Bob Rosso'], + ], + 'sites' => [ + 'en' => 'es', + 'es' => null, + ], + 'size' => 6, + 'last_modified' => 1665086377, + 'width' => null, + 'height' => null, + 'mime_type' => 'text/plain', + 'duration' => null, + ])); + + AssetContainer::make('test_container') + ->disk('test') + ->localizable(true) + ->save(); + + $this->artisan('statamic:assets:migrate-localizable test_container') + ->expectsOutputToContain('asset metadata files.'); + + $meta = YAML::parse(Storage::disk('test')->get('foo/.meta/baz.txt.yaml')); + + $this->assertArrayHasKey('sites', $meta); + $this->assertSame('es', $meta['sites']['en']); + $this->assertNull($meta['sites']['es']); + $this->assertSame(['es' => ['alt' => 'El Bob Rosso']], $meta['data']); + } } diff --git a/tests/Feature/Assets/UpdateAssetTest.php b/tests/Feature/Assets/UpdateAssetTest.php new file mode 100644 index 00000000000..6b5449404c8 --- /dev/null +++ b/tests/Feature/Assets/UpdateAssetTest.php @@ -0,0 +1,26 @@ +setTestRoles(['test' => ['access cp', 'edit test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->patchJson('/cp/assets/'.base64_encode('test::unknown.txt').'?site='.Site::default()->handle(), []) + ->assertNotFound(); + } +} diff --git a/tests/Tags/AssetsTest.php b/tests/Tags/AssetsTest.php new file mode 100644 index 00000000000..850a5b3f92d --- /dev/null +++ b/tests/Tags/AssetsTest.php @@ -0,0 +1,153 @@ +setSites([ + 'en' => ['name' => 'English', 'locale' => 'en_US', 'url' => 'http://localhost/'], + 'es' => ['name' => 'Spanish', 'locale' => 'es_ES', 'url' => 'http://localhost/es/'], + ]); + + Storage::fake('test', ['url' => '/assets']); + + Storage::disk('test')->put('a.jpg', ''); + Storage::disk('test')->put('b.jpg', ''); + Storage::disk('test')->put('c.mp4', ''); + Storage::disk('test')->put('nested/private/d.jpg', ''); + Storage::disk('test')->put('nested/public/e.jpg', ''); + + 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::nested/private/d.jpg')->data(['title' => 'Delta'])->save(); + Asset::find('test::nested/public/e.jpg')->data(['title' => 'Epsilon'])->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', 'd', 'e'], $this->getFilenames([ + 'extension:is' => 'jpg', + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_filters_assets_by_localized_field_conditions() + { + Asset::find('test::b.jpg')->data([ + 'en' => ['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', 'd', 'e'], $this->getFilenames([ + 'query_scope' => AssetsTagJpgScope::handle(), + 'sort' => 'filename:asc', + ])); + } + + #[Test] + public function it_supports_paginating_assets() + { + $results = $this->runTag([ + 'sort' => 'filename:asc', + 'paginate' => 2, + ]); + + $this->assertIsArray($results); + $this->assertArrayHasKey('results', $results); + $this->assertArrayHasKey('paginate', $results); + $this->assertCount(2, $results['results']); + $this->assertSame(['a', 'b'], collect($results['results'])->map->filename()->all()); + $this->assertSame(5, $results['paginate']['total_items']); + } + + #[Test] + public function it_keeps_legacy_filtering_params_working() + { + $this->assertSame(['e'], $this->getFilenames([ + 'folder' => 'nested', + 'recursive' => true, + 'sort' => 'filename:asc', + 'offset' => 1, + 'limit' => 1, + ])); + + $this->assertSame(['a', 'b', 'd', 'e'], $this->getFilenames([ + 'type' => 'image', + 'sort' => 'filename:asc', + ])); + + $this->assertSame(['a', 'b', 'c', 'e'], $this->getFilenames([ + 'not_in' => '/?nested/private', + 'sort' => 'filename:asc', + ])); + } + + private function runTag(array $params = []) + { + $tag = new Assets; + $tag->setContext([]); + $tag->setParameters(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'); + } +}
{{ loadError || __('Unable to load asset') }}