diff --git a/lang/en/messages.php b/lang/en/messages.php index ae79894ece6..5edbfe45c25 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -272,6 +272,18 @@ 'updater_require_version_command' => 'To require this specific version, run the following command', 'updater_update_to_latest_command' => 'To update to the latest version, run the following command', 'uploader_append_timestamp' => 'Append timestamp', + 'asset_conflict_title' => 'File conflict', + 'asset_conflict_message' => ':existing_descriptor item named ":filename" already exists in this location. Do you want to replace it with the :moving_age one you\'re moving?', + 'asset_upload_conflict_message' => 'An item named ":filename" already exists in this location. What would you like to do?', + 'asset_conflict_a_newer' => 'A newer', + 'asset_conflict_an_older' => 'An older', + 'asset_conflict_newer' => 'newer', + 'asset_conflict_older' => 'older', + 'asset_conflict_cancel' => 'Cancel', + 'asset_conflict_keep_both' => 'Keep Both', + 'asset_conflict_overwrite' => 'Overwrite', + 'asset_conflict_apply_to_all' => 'Do this for all remaining conflicts', + 'asset_conflict_pending' => 'Waiting for conflict decision', 'uploader_choose_new_filename' => 'Choose new filename', 'uploader_discard_use_existing' => 'Discard and use existing file', 'uploader_overwrite_existing' => 'Overwrite existing file', diff --git a/resources/js/components/assets/Browser/AssetBrowserMixin.js b/resources/js/components/assets/Browser/AssetBrowserMixin.js index 9bd2664c71a..59879888fab 100644 --- a/resources/js/components/assets/Browser/AssetBrowserMixin.js +++ b/resources/js/components/assets/Browser/AssetBrowserMixin.js @@ -6,6 +6,10 @@ export default { folderActionUrl: String, folders: Array, path: String, + selectedAssets: { + type: Array, + default: () => [], + }, restrictFolderNavigation: Boolean, creatingFolder: Boolean, creatingFolderError: Boolean, @@ -82,25 +86,53 @@ export default { return folder.actions.some((action) => action.handle === 'move_asset_folder'); }, + getDraggingAssetSelections() { + const selectedAssetIds = Array.isArray(this.selectedAssets) ? this.selectedAssets : []; + + if (selectedAssetIds.includes(this.draggingAsset)) { + return selectedAssetIds; + } + + return this.draggingAsset ? [this.draggingAsset] : []; + }, + handleFolderDrop(destinationFolder) { if (this.draggingAsset) { let asset = this.assets.find((asset) => asset.id === this.draggingAsset); let action = asset.actions.find((action) => action.handle === 'move_asset'); + const selections = this.getDraggingAssetSelections(); - if (!action) { + if (!action || selections.length === 0) { return; } const payload = { action: action.handle, context: action.context, - selections: [this.draggingAsset], + selections, values: { folder: destinationFolder.path }, }; this.$axios .post(this.actionUrl, payload) - .then(response => this.$emit('action-completed', true, response)) + .then(({ data }) => { + if (data.success === false && data.conflict?.type === 'asset_move') { + this.$emit('asset-move-conflict', { + action, + asset, + destinationFolder, + selections, + message: data.message, + conflict: data.conflict, + completedMoves: data.completed_moves, + }); + + return; + } + + this.$emit('action-completed', data.success !== false, data); + }) + .catch((error) => this.$emit('action-completed', false, error.response?.data || {})) .finally(() => this.draggingAsset = null); } @@ -121,7 +153,8 @@ export default { this.$axios .post(this.folderActionUrl, payload) - .then(response => this.$emit('action-completed', true, response)) + .then(({ data }) => this.$emit('action-completed', data.success !== false, data)) + .catch((error) => this.$emit('action-completed', false, error.response?.data || {})) .finally(() => this.draggingFolder = null); } }, diff --git a/resources/js/components/assets/Browser/Browser.vue b/resources/js/components/assets/Browser/Browser.vue index deb04499e31..01377a8a098 100644 --- a/resources/js/components/assets/Browser/Browser.vue +++ b/resources/js/components/assets/Browser/Browser.vue @@ -143,6 +143,7 @@ :columns="columns" :visible-columns="visibleColumns" :is-searching="isSearching" + :selected-assets="selectedAssets" v-bind="sharedAssetProps" v-on="sharedAssetEvents" /> @@ -181,6 +182,58 @@ @action-started="actionStarted" @action-completed="actionCompleted" /> + + +

{{ moveConflictMessage }}

+ + +
+ + +

{{ uploadConflictMessage }}

+ + +
@@ -214,6 +267,8 @@ import { Icon, ToggleGroup, ToggleItem, + Modal, + Checkbox, } from '@ui'; import Breadcrumbs from './Breadcrumbs.vue'; import useCheckerboard from '@/composables/checkerboard.js'; @@ -249,6 +304,8 @@ export default { Icon, ToggleGroup, ToggleItem, + Modal, + Checkbox, }, props: { @@ -301,6 +358,17 @@ export default { creatingFolder: false, creatingFolderError: false, uploads: [], + uploadConflictPolicy: null, + uploadConflictApplyToAll: false, + uploadConflictUploadId: null, + uploadConflictMessage: '', + showUploadConflictModal: false, + uploadConflictQueue: [], + moveConflictContext: null, + moveConflictMessage: '', + showMoveConflictModal: false, + moveConflictApplyToAll: false, + moveConflictPolicy: null, page: 1, preferencesPrefix: `assets.${this.container.id}`, meta: {}, @@ -435,10 +503,19 @@ export default { this.creatingFolder = false; this.creatingFolderError = false; }, + 'asset-move-conflict': this.openMoveConflictModal, 'prevent-dragging': (preventDragging) => (this.preventDragging = preventDragging), 'update:creatingFolderError': (value) => (this.creatingFolderError = value), }; }, + + showMoveConflictApplyToAll() { + return (this.moveConflictContext?.pendingSelections?.length || 0) > 1; + }, + + showUploadConflictApplyToAll() { + return this.uploads.filter((upload) => upload.errorStatus === 409).length > 1; + }, }, mounted() { @@ -507,6 +584,32 @@ export default { }, methods: { + onMoveConflictModalOpenUpdated(open) { + if (open) { + this.showMoveConflictModal = true; + return; + } + + if (!this.showMoveConflictModal) { + return; + } + + this.resolveMoveConflict('cancel'); + }, + + onUploadConflictModalOpenUpdated(open) { + if (open) { + this.showUploadConflictModal = true; + return; + } + + if (!this.showUploadConflictModal) { + return; + } + + this.resolveUploadConflict('cancel'); + }, + filtersUpdated(filters) { this.activeFilters = filters; }, @@ -542,7 +645,13 @@ export default { this.loading = true; }, - actionCompleted() { + actionCompleted(successful = null, response = {}) { + if (successful === true && response.message !== false) { + this.$toast.success(response.message || __('Action completed')); + } else if (successful === false) { + this.$toast.error(response.message || __('Action failed')); + } + // Intentionally not completing the loading state here since // the listing will refresh and immediately restart it. // this.loading = false; @@ -724,7 +833,15 @@ export default { this.lastItemClicked = index; }, - uploadCompleted(asset) { + uploadCompleted(asset, uploads, upload) { + if (['overwrite', 'timestamp'].includes(upload?.resolution)) { + const urls = this.getUploadConflictCacheBustUrls(upload); + + if (urls.length) { + Statamic.$callbacks.call('bustAndReloadImageCaches', urls); + } + } + if (this.autoselectUploads) { this.sortColumn = 'last_modified'; this.sortDirection = 'desc'; @@ -743,11 +860,343 @@ export default { uploadError(upload, uploads) { this.uploads = uploads; - this.$toast.error(upload.errorMessage); + + if (upload.errorStatus !== 409) { + this.$toast.error(upload.errorMessage); + return; + } + + if (this.uploadConflictPolicy) { + this.applyUploadConflict(upload, this.uploadConflictPolicy); + return; + } + + this.enqueueUploadConflict(upload.id); + this.openNextUploadConflictFromQueue(); }, uploadsUpdated(uploads) { this.uploads = uploads; + + if (uploads.length === 0) { + this.uploadConflictPolicy = null; + this.uploadConflictApplyToAll = false; + this.uploadConflictQueue = []; + this.uploadConflictUploadId = null; + this.showUploadConflictModal = false; + } else { + const uploadIds = new Set(uploads.map((upload) => upload.id)); + this.uploadConflictQueue = this.uploadConflictQueue.filter((id) => uploadIds.has(id)); + } + }, + + getUploadById(id) { + return this.uploads.find((upload) => upload.id === id); + }, + + openUploadConflictModal(upload) { + this.uploadConflictUploadId = upload.id; + this.uploadConflictMessage = __('messages.asset_upload_conflict_message', { + filename: upload.basename, + }); + this.showUploadConflictModal = true; + }, + + resolveUploadConflict(strategy) { + const currentConflictUploadId = this.uploadConflictUploadId; + const upload = this.getUploadById(this.uploadConflictUploadId); + + if (this.uploadConflictApplyToAll) { + this.uploadConflictPolicy = strategy; + + this.uploads + .filter((item) => item.errorStatus === 409) + .forEach((item) => this.applyUploadConflict(item, strategy)); + + this.uploadConflictQueue = []; + this.uploadConflictUploadId = null; + this.uploadConflictMessage = ''; + this.showUploadConflictModal = false; + } else if (upload) { + this.applyUploadConflict(upload, strategy); + this.dequeueUploadConflict(currentConflictUploadId); + this.uploadConflictUploadId = null; + this.uploadConflictMessage = ''; + + // Keep the same modal open and dynamically swap to the next conflict. + const hasNextConflict = this.openNextUploadConflictFromQueue(); + + if (!hasNextConflict) { + this.showUploadConflictModal = false; + } + } else { + this.showUploadConflictModal = false; + } + + this.uploadConflictApplyToAll = false; + }, + + applyUploadConflict(upload, strategy) { + if (strategy === 'cancel') { + upload.skip(); + return; + } + + upload.retry({ + option: strategy, + }, { + conflict: upload.conflict, + resolution: strategy, + }); + }, + + getUploadConflictCacheBustUrls(upload) { + if (upload?.conflict?.existing) { + return [upload.conflict.existing.preview, upload.conflict.existing.thumbnail].filter(Boolean); + } + + const folderPath = (this.folder?.path || '').replace(/^\/+|\/+$/g, ''); + const basename = upload?.basename || ''; + const fullPath = [folderPath, basename].filter(Boolean).join('/'); + const existingAsset = this.assets.find((asset) => { + if (asset.path === fullPath) { + return true; + } + + return folderPath === '' && asset.basename === basename; + }); + + if (!existingAsset) { + return []; + } + + return [existingAsset.preview, existingAsset.thumbnail].filter(Boolean); + }, + + enqueueUploadConflict(id) { + if (!id || this.uploadConflictQueue.includes(id)) { + return; + } + + this.uploadConflictQueue.push(id); + }, + + dequeueUploadConflict(id) { + if (!id) { + return; + } + + this.uploadConflictQueue = this.uploadConflictQueue.filter((queuedId) => queuedId !== id); + }, + + openNextUploadConflictFromQueue() { + if (this.showUploadConflictModal || this.uploadConflictPolicy) { + if (!this.uploadConflictUploadId) { + // Modal is visible but no active conflict selected yet. + // Continue to resolve the next queued conflict. + } else { + return false; + } + } + + while (this.uploadConflictQueue.length > 0) { + const nextConflictId = this.uploadConflictQueue[0]; + const nextConflict = this.getUploadById(nextConflictId); + + if (!nextConflict || nextConflict.errorStatus !== 409) { + this.uploadConflictQueue.shift(); + continue; + } + + this.openUploadConflictModal(nextConflict); + return true; + } + + return false; + }, + + openMoveConflictModal({ action, asset, destinationFolder, selections, message, conflict, completedMoves }) { + const initialRemap = + completedMoves && typeof completedMoves === 'object' && !Array.isArray(completedMoves) ? completedMoves : {}; + const completedSelectionIds = new Set(Object.keys(initialRemap).map((id) => String(id))); + const pendingSelections = Array.from(new Set((selections || [asset?.id]).filter(Boolean))).filter( + (id) => !completedSelectionIds.has(String(id)), + ); + + this.moveConflictContext = { + action, + asset, + destinationFolder, + pendingSelections, + conflict, + idRemap: { ...initialRemap }, + }; + this.moveConflictMessage = message; + this.showMoveConflictModal = true; + }, + + remapMoveConflictAssetId(id, idRemap = {}) { + let current = id; + const seen = new Set(); + + while (current && idRemap[current] && !seen.has(current)) { + seen.add(current); + current = idRemap[current]; + } + + return current; + }, + + async resolveMoveConflict(strategy) { + const conflictContext = this.moveConflictContext; + this.showMoveConflictModal = false; + this.moveConflictContext = null; + + if (!conflictContext) { + return; + } + + if (this.moveConflictApplyToAll) { + this.moveConflictPolicy = strategy; + } + + this.moveConflictApplyToAll = false; + this.actionStarted(); + await this.continueMoveConflictResolution(conflictContext, strategy); + }, + + async continueMoveConflictResolution(context, strategy = null) { + let nextStrategy = strategy; + + while (true) { + const conflictAssetId = context.conflict?.asset?.id; + const resolution = this.moveConflictPolicy; + + if (conflictAssetId) { + const idRemap = context.idRemap || {}; + + context.pendingSelections = context.pendingSelections.filter( + (id) => this.remapMoveConflictAssetId(id, idRemap) !== conflictAssetId, + ); + } + + if (nextStrategy && nextStrategy !== 'cancel' && conflictAssetId) { + const resolutionResult = await this.runMoveConflictAction( + context, + [this.remapMoveConflictAssetId(conflictAssetId, context.idRemap || {})], + nextStrategy, + ); + + if (resolutionResult.success === false) { + this.moveConflictPolicy = null; + this.actionCompleted(false, resolutionResult); + return; + } + + if (nextStrategy === 'overwrite') { + const urls = [ + context.conflict?.existing?.preview, + context.conflict?.existing?.thumbnail, + ].filter(Boolean); + + if (urls.length) { + Statamic.$callbacks.call('bustAndReloadImageCaches', urls); + } + } + } + + if (context.pendingSelections.length === 0) { + this.moveConflictPolicy = null; + this.actionCompleted(true, { + message: false, + }); + return; + } + + const response = await this.runMoveConflictAction(context, context.pendingSelections, null); + + if (response.success !== false) { + this.moveConflictPolicy = null; + this.actionCompleted(true, response); + return; + } + + if (response.conflict?.type !== 'asset_move') { + this.moveConflictPolicy = null; + this.actionCompleted(false, response); + return; + } + + if ( + response.completed_moves && + typeof response.completed_moves === 'object' && + !Array.isArray(response.completed_moves) && + Object.keys(response.completed_moves).length + ) { + context.idRemap = { ...context.idRemap, ...response.completed_moves }; + } + + context.conflict = response.conflict; + this.moveConflictMessage = response.message; + + const currentConflictAssetId = response.conflict?.asset?.id; + + if (!currentConflictAssetId) { + this.moveConflictPolicy = null; + this.actionCompleted(false, response); + return; + } + + if (resolution) { + nextStrategy = resolution; + continue; + } + + this.moveConflictContext = context; + this.showMoveConflictModal = true; + return; + } + }, + + async runMoveConflictAction(context, selections, strategy = null) { + const idRemap = context.idRemap || {}; + const selectedAssetIds = Array.from( + new Set( + (selections || []) + .filter(Boolean) + .map((id) => this.remapMoveConflictAssetId(id, idRemap)), + ), + ); + + if (selectedAssetIds.length === 0) { + return { + success: true, + message: false, + }; + } + + const payload = { + action: context.action.handle, + context: { + ...context.action.context, + ...(strategy ? { conflict: strategy } : {}), + }, + selections: selectedAssetIds, + values: { + folder: context.destinationFolder.path, + }, + }; + + try { + const { data } = await this.$axios.post(this.actionUrl, payload); + + return data || {}; + } catch ({ response }) { + return response?.data || { + success: false, + message: __('Action failed'), + }; + } }, addToCommandPalette() { diff --git a/resources/js/components/assets/Browser/Table.vue b/resources/js/components/assets/Browser/Table.vue index f8ed0777a9c..1c32377eec2 100644 --- a/resources/js/components/assets/Browser/Table.vue +++ b/resources/js/components/assets/Browser/Table.vue @@ -161,7 +161,7 @@ export default { loading: Boolean, columns: Array, visibleColumns: Array, - isSearching: Boolean + isSearching: Boolean, }, watch: { diff --git a/resources/js/components/assets/Upload.vue b/resources/js/components/assets/Upload.vue index 3b6ce478b23..e9676c09ec3 100644 --- a/resources/js/components/assets/Upload.vue +++ b/resources/js/components/assets/Upload.vue @@ -14,44 +14,21 @@
- - - - - - - - - + + {{ __('messages.asset_conflict_pending') }} +
- - - - - - diff --git a/resources/js/components/assets/Uploader.vue b/resources/js/components/assets/Uploader.vue index e67e8b2c003..3f051e76e1b 100644 --- a/resources/js/components/assets/Uploader.vue +++ b/resources/js/components/assets/Uploader.vue @@ -168,7 +168,7 @@ export default { return readEntries(); }, - addFile(file, data = {}) { + addFile(file, data = {}, meta = {}) { if (!this.enabled) return; const id = uniqid(); @@ -181,8 +181,11 @@ export default { percent: 0, errorMessage: null, errorStatus: null, + conflict: meta.conflict ?? null, + resolution: meta.resolution ?? null, instance: upload, - retry: (opts) => this.retry(id, opts), + retry: (opts, retryMeta = {}) => this.retry(id, opts, retryMeta), + skip: () => this.skip(id), }); }, @@ -266,7 +269,8 @@ export default { }, handleUploadSuccess(id, response) { - this.$emit('upload-complete', response.data, this.uploads); + const upload = this.findUpload(id); + this.$emit('upload-complete', response.data, this.uploads, upload); this.uploads.splice(this.findUploadIndex(id), 1); this.handleToasts(response._toasts ?? []); @@ -291,6 +295,7 @@ export default { upload.errorMessage = msg; upload.errorStatus = status; + upload.conflict = response?.conflict ?? null; this.$emit('error', upload, this.uploads); this.processUploadQueue(); }, @@ -299,10 +304,21 @@ export default { toasts.forEach((toast) => Statamic.$toast[toast.type](toast.message, { duration: toast.duration })); }, - retry(id, args) { - let file = this.findUpload(id).instance.form.get('file'); - this.addFile(file, args); + retry(id, args = {}, meta = {}) { + const currentUpload = this.findUpload(id); + let file = currentUpload.instance.form.get('file'); + + this.addFile(file, args, { + conflict: meta.conflict ?? currentUpload.conflict ?? null, + resolution: meta.resolution ?? currentUpload.resolution ?? null, + }); + + this.uploads.splice(this.findUploadIndex(id), 1); + }, + + skip(id) { this.uploads.splice(this.findUploadIndex(id), 1); + this.processUploadQueue(); }, }, }; diff --git a/src/Actions/MoveAsset.php b/src/Actions/MoveAsset.php index 3abd2684ac3..bda29d4841a 100644 --- a/src/Actions/MoveAsset.php +++ b/src/Actions/MoveAsset.php @@ -3,8 +3,13 @@ namespace Statamic\Actions; use Statamic\Contracts\Assets\Asset; +use Statamic\Exceptions\AssetConflictException; +use Statamic\Facades\Asset as AssetRepository; use Statamic\Facades\AssetContainer; use Statamic\Facades\Blink; +use Statamic\Facades\Glide; +use Statamic\Facades\Path; +use Statamic\Support\Str; class MoveAsset extends Action { @@ -39,7 +44,91 @@ public function confirmationText() public function run($assets, $values) { - $ids = $assets->each->move($values['folder'])->map->id()->all(); + $folder = $values['folder']; + $strategy = $this->context['conflict'] ?? 'cancel'; + $timestamp = now()->timestamp; + $timestampCount = 0; + $ids = []; + $completedMoves = []; + + foreach ($assets as $index => $asset) { + $destinationPath = Str::removeLeft(Path::tidy($folder.'/'.$asset->basename()), '/'); + $conflicts = $asset->path() !== $destinationPath && $asset->disk()->exists($destinationPath); + + if ($conflicts) { + $existingAsset = $asset->container()->asset($destinationPath); + $sourceLastModified = $asset->disk()->lastModified($asset->path()); + $destinationLastModified = $asset->disk()->lastModified($destinationPath); + $movingAge = $sourceLastModified >= $destinationLastModified + ? __('statamic::messages.asset_conflict_newer') + : __('statamic::messages.asset_conflict_older'); + $existingDescriptor = $sourceLastModified >= $destinationLastModified + ? __('statamic::messages.asset_conflict_an_older') + : __('statamic::messages.asset_conflict_a_newer'); + + if ($strategy === 'overwrite') { + $assetForGlideCacheClear = $existingAsset ?? $asset->container()->makeAsset($destinationPath); + Glide::clearAsset($assetForGlideCacheClear); + + // Remove the pre-existing destination record before moving the source + // so we never leave behind stale repository entries for this path. + if ($existingAsset) { + AssetRepository::delete($existingAsset); + } + + $oldId = $asset->id(); + $newId = $asset->move($folder)->id(); + $completedMoves[$oldId] = $newId; + $ids[] = $newId; + + continue; + } + + if ($strategy === 'timestamp') { + $filename = $asset->filename().'-'.$timestamp; + + if ($timestampCount > 0) { + $filename .= '-'.$timestampCount; + } + + $timestampCount++; + $oldId = $asset->id(); + $newId = $asset->moveUnique($folder, $filename)->id(); + $completedMoves[$oldId] = $newId; + $ids[] = $newId; + + continue; + } + + throw new AssetConflictException( + __('statamic::messages.asset_conflict_message', [ + 'filename' => $asset->basename(), + 'existing_descriptor' => $existingDescriptor, + 'moving_age' => $movingAge, + ]), + [ + 'conflict' => [ + 'type' => 'asset_move', + 'asset' => [ + 'id' => $asset->id(), + 'basename' => $asset->basename(), + ], + 'existing' => [ + 'preview' => $existingAsset ? ($existingAsset->container()->accessible() ? $existingAsset->url() : $existingAsset->thumbnailUrl()) : null, + 'thumbnail' => $existingAsset?->thumbnailUrl('small'), + ], + 'destination' => $folder, + ], + 'completed_moves' => (object) $completedMoves, + ], + ); + } + + $oldId = $asset->id(); + $newId = $asset->move($folder)->id(); + $completedMoves[$oldId] = $newId; + $ids[] = $newId; + } return [ 'ids' => $ids, diff --git a/src/Assets/Asset.php b/src/Assets/Asset.php index f820b780c9b..7da41a56f16 100644 --- a/src/Assets/Asset.php +++ b/src/Assets/Asset.php @@ -763,6 +763,7 @@ public function move($folder, $filename = null) { $filename = Uploader::getSafeFilename($filename ?: $this->filename()); $oldPath = $this->path(); + $oldMetaCacheKey = $this->metaCacheKey(); $oldMetaPath = $this->metaPath(); $newPath = Str::removeLeft(Path::tidy($folder.'/'.$filename.'.'.pathinfo($oldPath, PATHINFO_EXTENSION)), '/'); @@ -772,6 +773,7 @@ public function move($folder, $filename = null) $this->hydrate(); $this->disk()->rename($oldPath, $newPath); + $this->cacheStore()->forget($oldMetaCacheKey); $this->path($newPath); $this->save(); diff --git a/src/Exceptions/AssetConflictException.php b/src/Exceptions/AssetConflictException.php new file mode 100644 index 00000000000..9d0b28bc814 --- /dev/null +++ b/src/Exceptions/AssetConflictException.php @@ -0,0 +1,20 @@ +context; + } +} diff --git a/src/Http/Controllers/CP/ActionController.php b/src/Http/Controllers/CP/ActionController.php index 205dcc7de3d..08f9a601295 100644 --- a/src/Http/Controllers/CP/ActionController.php +++ b/src/Http/Controllers/CP/ActionController.php @@ -4,6 +4,7 @@ use Exception; use Illuminate\Http\Request; +use Statamic\Exceptions\AssetConflictException; use Statamic\Facades\Action; use Statamic\Facades\User; use Statamic\Support\Arr; @@ -45,7 +46,13 @@ public function run(Request $request) try { $response = $action->run($items, $values); } catch (Exception $e) { - $response = empty($msg = $e->getMessage()) ? __('Action failed') : $msg; + if ($e instanceof AssetConflictException) { + $response = array_merge([ + 'message' => $e->getMessage(), + ], $e->context()); + } else { + $response = empty($msg = $e->getMessage()) ? __('Action failed') : $msg; + } $successful = false; } diff --git a/src/Http/Controllers/CP/Assets/AssetsController.php b/src/Http/Controllers/CP/Assets/AssetsController.php index a4bb8b4ffa0..55c635e065f 100644 --- a/src/Http/Controllers/CP/Assets/AssetsController.php +++ b/src/Http/Controllers/CP/Assets/AssetsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\CP\Assets; use Facades\Statamic\Fields\Validator as FieldValidator; +use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; @@ -120,7 +121,20 @@ public function store(Request $request) try { $validator->validate(); } catch (ValidationException $e) { - throw $e->status(409); + $existingAsset = $container->asset($path); + + throw new HttpResponseException(response()->json([ + 'message' => $e->getMessage(), + 'errors' => $e->errors(), + 'conflict' => [ + 'type' => 'asset_upload', + 'filename' => $basename, + 'existing' => [ + 'preview' => $existingAsset ? ($existingAsset->container()->accessible() ? $existingAsset->url() : $existingAsset->thumbnailUrl()) : null, + 'thumbnail' => $existingAsset?->thumbnailUrl('small'), + ], + ], + ], 409)); } } diff --git a/src/Listeners/ClearAssetGlideCache.php b/src/Listeners/ClearAssetGlideCache.php index 36bdeac8820..e90e20c5776 100644 --- a/src/Listeners/ClearAssetGlideCache.php +++ b/src/Listeners/ClearAssetGlideCache.php @@ -7,6 +7,7 @@ use Statamic\Events\AssetDeleted; use Statamic\Events\AssetReuploaded; use Statamic\Events\AssetSaved; +use Statamic\Events\AssetUploaded; use Statamic\Events\Subscriber; use Statamic\Facades\Glide; use Statamic\Imaging\PresetGenerator; @@ -22,6 +23,7 @@ class ClearAssetGlideCache extends Subscriber implements ShouldQueue AssetSaved::class => 'handleSaved', AssetDeleted::class => 'handleDeleted', AssetReuploaded::class => 'handleReuploaded', + AssetUploaded::class => 'handleUploaded', ]; public function __construct(PresetGenerator $generator) @@ -34,6 +36,11 @@ public function handleReuploaded(AssetReuploaded $event) $this->clear($event->asset); } + public function handleUploaded(AssetUploaded $event) + { + $this->clear($event->asset); + } + public function handleDeleted(AssetDeleted $event) { $this->clear($event->asset); diff --git a/tests/Actions/MoveAssetTest.php b/tests/Actions/MoveAssetTest.php new file mode 100644 index 00000000000..73d072ca979 --- /dev/null +++ b/tests/Actions/MoveAssetTest.php @@ -0,0 +1,250 @@ +container = tap( + (new AssetContainer)->handle('test_container')->disk('test') + )->save(); + } + + private function createAsset(string $path, string $contents = 'contents'): void + { + Storage::disk('test')->put($path, $contents); + $this->container->makeAsset($path)->save(); + } + + private function move(string $path, string $folder, ?string $strategy = null) + { + $context = ['container' => 'test_container']; + + if ($strategy) { + $context['conflict'] = $strategy; + } + + return $this->post(cp_route('assets.actions.run'), [ + 'action' => 'move_asset', + 'context' => $context, + 'selections' => ['test_container::'.$path], + 'values' => ['folder' => $folder], + ]); + } + + #[Test] + public function it_moves_asset_when_no_conflict_exists(): void + { + $this->createAsset('source/logo.svg', 'new'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target') + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertMissing('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + $this->assertEquals('new', Storage::disk('test')->get('target/logo.svg')); + } + + #[Test] + public function it_is_a_no_op_when_moving_to_the_same_folder(): void + { + $this->createAsset('source/logo.svg', 'contents'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'source') + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertExists('source/logo.svg'); + Storage::disk('test')->assertMissing('target/logo.svg'); + $this->assertEquals('contents', Storage::disk('test')->get('source/logo.svg')); + } + + #[Test] + public function it_reports_completed_moves_when_a_later_asset_conflicts(): void + { + $this->createAsset('source/a.svg', 'a'); + $this->createAsset('source/b.svg', 'b'); + $this->createAsset('target/b.svg', 'existing'); + + $idA = 'test_container::source/a.svg'; + $idB = 'test_container::source/b.svg'; + $idAAtTarget = 'test_container::target/a.svg'; + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->post(cp_route('assets.actions.run'), [ + 'action' => 'move_asset', + 'context' => ['container' => 'test_container'], + 'selections' => [$idA, $idB], + 'values' => ['folder' => 'target'], + ]) + ->assertOk() + ->assertJson([ + 'success' => false, + 'conflict' => [ + 'type' => 'asset_move', + 'asset' => [ + 'id' => $idB, + ], + ], + 'completed_moves' => [ + $idA => $idAAtTarget, + ], + ]); + + Storage::disk('test')->assertMissing('source/a.svg'); + Storage::disk('test')->assertExists('target/a.svg'); + Storage::disk('test')->assertExists('source/b.svg'); + } + + #[Test] + public function it_blocks_conflicting_move_without_strategy(): void + { + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target') + ->assertOk() + ->assertJson([ + 'success' => false, + 'conflict' => [ + 'type' => 'asset_move', + 'destination' => 'target', + ], + ]); + + Storage::disk('test')->assertExists('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + $this->assertEquals('existing', Storage::disk('test')->get('target/logo.svg')); + } + + #[Test] + public function it_blocks_conflicting_move_with_explicit_cancel_strategy(): void + { + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target', 'cancel') + ->assertOk() + ->assertJson([ + 'success' => false, + 'conflict' => [ + 'type' => 'asset_move', + 'destination' => 'target', + ], + ]); + + Storage::disk('test')->assertExists('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + $this->assertEquals('new', Storage::disk('test')->get('source/logo.svg')); + $this->assertEquals('existing', Storage::disk('test')->get('target/logo.svg')); + } + + #[Test] + public function it_can_overwrite_conflicting_move_when_strategy_is_overwrite(): void + { + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target', 'overwrite') + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertMissing('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + $this->assertEquals('new', Storage::disk('test')->get('target/logo.svg')); + $this->assertCount(1, $this->container->assets('/', true)); + $this->assertSame(['test_container::target/logo.svg'], $this->container->assets('/', true)->pluck('id')->values()->all()); + } + + #[Test] + public function it_can_keep_both_with_timestamp_strategy(): void + { + Carbon::setTestNow(Carbon::createFromTimestamp(1712000000, config('app.timezone'))); + + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->move('source/logo.svg', 'target', 'timestamp') + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertMissing('source/logo.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + Storage::disk('test')->assertExists('target/logo-1712000000.svg'); + $this->assertEquals('existing', Storage::disk('test')->get('target/logo.svg')); + $this->assertEquals('new', Storage::disk('test')->get('target/logo-1712000000.svg')); + } + + #[Test] + public function it_does_not_add_index_suffix_to_first_conflicting_asset_in_batch_with_non_conflicting_assets_before_it(): void + { + Carbon::setTestNow(Carbon::createFromTimestamp(1712000000, config('app.timezone'))); + + $this->createAsset('source/a.svg', 'a-new'); + $this->createAsset('source/logo.svg', 'new'); + $this->createAsset('target/logo.svg', 'existing'); + + $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->post(cp_route('assets.actions.run'), [ + 'action' => 'move_asset', + 'context' => ['container' => 'test_container', 'conflict' => 'timestamp'], + 'selections' => ['test_container::source/a.svg', 'test_container::source/logo.svg'], + 'values' => ['folder' => 'target'], + ]) + ->assertOk() + ->assertJson([ + 'success' => true, + ]); + + Storage::disk('test')->assertMissing('source/a.svg'); + Storage::disk('test')->assertMissing('source/logo.svg'); + Storage::disk('test')->assertExists('target/a.svg'); + Storage::disk('test')->assertExists('target/logo.svg'); + Storage::disk('test')->assertExists('target/logo-1712000000.svg'); + $this->assertEquals('a-new', Storage::disk('test')->get('target/a.svg')); + $this->assertEquals('existing', Storage::disk('test')->get('target/logo.svg')); + $this->assertEquals('new', Storage::disk('test')->get('target/logo-1712000000.svg')); + } +} diff --git a/tests/Feature/Assets/ClearAssetGlideCacheTest.php b/tests/Feature/Assets/ClearAssetGlideCacheTest.php index 381f07df8c1..26bf1699668 100644 --- a/tests/Feature/Assets/ClearAssetGlideCacheTest.php +++ b/tests/Feature/Assets/ClearAssetGlideCacheTest.php @@ -9,6 +9,7 @@ use Statamic\Events\AssetDeleted; use Statamic\Events\AssetReuploaded; use Statamic\Events\AssetSaved; +use Statamic\Events\AssetUploaded; use Statamic\Facades\Glide; use Statamic\Imaging\PresetGenerator; use Statamic\Listeners\ClearAssetGlideCache; @@ -20,9 +21,10 @@ class ClearAssetGlideCacheTest extends TestCase public function it_subscribes() { $events = Mockery::mock(Dispatcher::class); - $events->shouldReceive('listen')->with(AssetReuploaded::class, [ClearAssetGlideCache::class, 'handleReuploaded'])->once(); - $events->shouldReceive('listen')->with(AssetDeleted::class, [ClearAssetGlideCache::class, 'handleDeleted'])->once(); $events->shouldReceive('listen')->with(AssetSaved::class, [ClearAssetGlideCache::class, 'handleSaved'])->once(); + $events->shouldReceive('listen')->with(AssetDeleted::class, [ClearAssetGlideCache::class, 'handleDeleted'])->once(); + $events->shouldReceive('listen')->with(AssetReuploaded::class, [ClearAssetGlideCache::class, 'handleReuploaded'])->once(); + $events->shouldReceive('listen')->with(AssetUploaded::class, [ClearAssetGlideCache::class, 'handleUploaded'])->once(); app(ClearAssetGlideCache::class)->subscribe($events); }