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);
}