diff --git a/modules/backend/lang/en/lang.php b/modules/backend/lang/en/lang.php index 83fabed70..2dc6973dd 100644 --- a/modules/backend/lang/en/lang.php +++ b/modules/backend/lang/en/lang.php @@ -603,6 +603,7 @@ 'menu_label' => 'Media', 'upload' => 'Upload', 'move' => 'Move', + 'duplicate' => 'Duplicate', 'delete' => 'Delete', 'add_folder' => 'Add folder', 'search' => 'Search', @@ -631,10 +632,17 @@ 'direction' => 'Direction', 'direction_asc' => 'Ascending', 'direction_desc' => 'Descending', + 'file_exists_autorename' => 'The file already exists. Renamed to: :name', 'folder' => 'Folder', + 'folder_exists_autorename' => 'The folder already exists. Renamed to: :name', 'no_files_found' => 'No files found by your request.', 'delete_empty' => 'Please select items to delete.', 'delete_confirm' => 'Delete the selected item(s)?', + 'duplicate_empty' => 'Please select items to duplicate.', + 'duplicate_multiple_confirm' => 'Multiple items selected. They will be duplicated with generated names. Are you sure?', + 'duplicate_popup_title' => 'Duplicate file or folder', + 'duplicate_new_name' => 'New name', + 'duplicate_button' => 'Duplicate', 'error_renaming_file' => 'Error renaming the item.', 'new_folder_title' => 'New folder', 'folder_name' => 'Folder name', diff --git a/modules/backend/lang/fr/lang.php b/modules/backend/lang/fr/lang.php index fbcebe778..83123df71 100644 --- a/modules/backend/lang/fr/lang.php +++ b/modules/backend/lang/fr/lang.php @@ -596,6 +596,7 @@ 'menu_label' => 'Média', 'upload' => 'Déposer un fichier', 'move' => 'Déplacer', + 'duplicate' => 'Dupliquer', 'delete' => 'Supprimer', 'add_folder' => 'Ajouter un répertoire', 'search' => 'Rechercher', @@ -624,10 +625,17 @@ 'direction' => 'Direction', 'direction_asc' => 'Ascendant', 'direction_desc' => 'Descendant', + 'file_exists_autorename' => 'Le fichier existe déjà. Renommé en : :name', 'folder' => 'Répertoire', + 'folder_exists_autorename' => 'Le dossier existe déjà. Renommé en : :name', 'no_files_found' => 'Aucun fichier trouvé.', 'delete_empty' => 'Veuillez sélectionner les éléments à supprimer.', 'delete_confirm' => 'Confirmer la suppression de ces éléments ?', + 'duplicate_empty' => 'Veuillez sélectionner les éléments à dupliquer.', + 'duplicate_multiple_confirm' => 'Plusieurs éléments sélectionnés. Ils seront clonés avec des noms générés. Êtes-vous sûr ?', + 'duplicate_popup_title' => 'Dupliquer un fichier ou dossier', + 'duplicate_new_name' => 'Nouveau nom', + 'duplicate_button' => 'Dupliquer', 'error_renaming_file' => 'Erreur lors du renommage de l\'élément.', 'new_folder_title' => 'Nouveau répertoire', 'folder_name' => 'Nom du répertoire', diff --git a/modules/backend/widgets/MediaManager.php b/modules/backend/widgets/MediaManager.php index 067d53a50..9fce00d0b 100644 --- a/modules/backend/widgets/MediaManager.php +++ b/modules/backend/widgets/MediaManager.php @@ -11,6 +11,7 @@ use System\Classes\ImageResizer; use System\Classes\MediaLibrary; use System\Classes\MediaLibraryItem; +use Winter\Storm\Support\Facades\Flash; /** * Media Manager widget. @@ -520,6 +521,224 @@ public function onCreateFolder(): array ]; } + /** + * Render the duplicate popup for the provided "path" from the request + */ + public function onLoadDuplicatePopup(): string + { + $this->abortIfReadOnly(); + + $path = Input::get('path'); + $type = Input::get('type'); + $path = MediaLibrary::validatePath($path); + $suggestedName = ''; + + $library = MediaLibrary::instance(); + + if ($type == MediaLibraryItem::TYPE_FILE) { + $suggestedName = $library->generateIncrementedFileName($path); + } else { + $suggestedName = $library->generateIncrementedFolderName($path); + } + + $this->vars['originalPath'] = $path; + $this->vars['newName'] = $suggestedName; + $this->vars['type'] = $type; + + return $this->makePartial('duplicate-form'); + } + + /** + * Duplicate the provided path from the request ("originalPath") to the new name ("name") + * + * @throws ApplicationException if the new name is invalid + */ + public function onDuplicateItem(): array + { + $this->abortIfReadOnly(); + + $newName = Input::get('newName'); + if (!strlen($newName)) { + throw new ApplicationException(Lang::get('cms::lang.asset.name_cant_be_empty')); + } + + if (!$this->validateFileName($newName)) { + throw new ApplicationException(Lang::get('cms::lang.asset.invalid_name')); + } + + + $originalPath = Input::get('originalPath'); + $originalPath = MediaLibrary::validatePath($originalPath); + $newPath = dirname($originalPath) . '/' . $newName; + $type = Input::get('type'); + + $newPath = $this->preventPathOverwrite($originalPath, $newPath, $type); + + $library = MediaLibrary::instance(); + + if ($type == MediaLibraryItem::TYPE_FILE) { + /* + * Validate extension + */ + if (!$this->validateFileType($newName)) { + throw new ApplicationException(Lang::get('backend::lang.media.type_blocked')); + } + + /* + * Duplicate single file + */ + $library->copyFile($originalPath, $newPath); + + /** + * @event media.file.duplicate + * Called after a file is duplicated + * + * Example usage: + * + * Event::listen('media.file.duplicate', function ((\Backend\Widgets\MediaManager) $mediaWidget, (string) $originalPath, (string) $newPath) { + * \Log::info($originalPath . " was duplicated to " . $path); + * }); + * + * Or + * + * $mediaWidget->bindEvent('file.duplicate', function ((string) $originalPath, (string) $newPath) { + * \Log::info($originalPath . " was duplicated to " . $path); + * }); + * + */ + $this->fireSystemEvent('media.file.duplicate', [$originalPath, $newPath]); + } else { + /* + * Duplicate single folder + */ + $library->copyFolder($originalPath, $newPath); + + /** + * @event media.folder.duplicate + * Called after a folder is duplicated + * + * Example usage: + * + * Event::listen('media.folder.duplicate', function ((\Backend\Widgets\MediaManager) $mediaWidget, (string) $originalPath, (string) $newPath) { + * \Log::info($originalPath . " was duplicated to " . $path); + * }); + * + * Or + * + * $mediaWidget->bindEvent('folder.duplicate', function ((string) $originalPath, (string) $newPath) { + * \Log::info($originalPath . " was duplicated to " . $path); + * }); + * + */ + $this->fireSystemEvent('media.folder.duplicate', [$originalPath, $newPath]); + } + + $library->resetCache(); + $this->prepareVars(); + + return [ + '#' . $this->getId('item-list') => $this->makePartial('item-list') + ]; + } + + /** + * Duplicate the selected files or folders without prompting the user + * The new name will be generated in an incremented sequence + * + * @throws ApplicationException if the input data is invalid + */ + public function onDuplicateItems(): array + { + $this->abortIfReadOnly(); + + $paths = Input::get('paths'); + + if (!is_array($paths)) { + throw new ApplicationException('Invalid input data'); + } + + $library = MediaLibrary::instance(); + + $filesToDuplicate = []; + foreach ($paths as $pathInfo) { + $path = array_get($pathInfo, 'path'); + $type = array_get($pathInfo, 'type'); + + if (!$path || !$type) { + throw new ApplicationException('Invalid input data'); + } + + if ($type === MediaLibraryItem::TYPE_FILE) { + /* + * Add to bulk collection + */ + $filesToDuplicate[] = $path; + } elseif ($type === MediaLibraryItem::TYPE_FOLDER) { + /* + * Duplicate single folder + */ + $library->duplicateFolder($path); + + /** + * @event media.folder.duplicate + * Called after a folder is duplicated + * + * Example usage: + * + * Event::listen('media.folder.duplicate', function ((\Backend\Widgets\MediaManager) $mediaWidget, (string) $path) { + * \Log::info($path . " was duplicated"); + * }); + * + * Or + * + * $mediaWidget->bindEvent('folder.duplicate', function ((string) $path) { + * \Log::info($path . " was duplicated"); + * }); + * + */ + $this->fireSystemEvent('media.folder.duplicate', [$path]); + } + } + + if (count($filesToDuplicate) > 0) { + /* + * Duplicate collection of files + */ + $library->duplicateFiles($filesToDuplicate); + + /* + * Extensibility + */ + foreach ($filesToDuplicate as $path) { + /** + * @event media.file.duplicate + * Called after a file is duplicated + * + * Example usage: + * + * Event::listen('media.file.duplicate', function ((\Backend\Widgets\MediaManager) $mediaWidget, (string) $path) { + * \Log::info($path . " was duplicated"); + * }); + * + * Or + * + * $mediaWidget->bindEvent('file.duplicate', function ((string) $path) { + * \Log::info($path . " was duplicated"); + * }); + * + */ + $this->fireSystemEvent('media.file.duplicate', [$path]); + } + } + + $library->resetCache(); + $this->prepareVars(); + + return [ + '#' . $this->getId('item-list') => $this->makePartial('item-list') + ]; + } + /** * Render the move popup with a list of folders to move the selected items to excluding the provided paths in the request ("exclude") * @@ -1376,4 +1595,31 @@ protected function getPreferenceKey() // User preferences should persist across controller usages for the MediaManager return "backend::widgets.media_manager." . strtolower($this->getId()); } + + /** + * Check if file or folder already exists, then return an incremented name to prevent overwriting + * + * @param string $originalPath + * @param string $newPath + * @param string $type + * + * @todo Maybe the overwriting behavior can be config based + */ + protected function preventPathOverwrite($originalPath, $newPath, $type): string + { + $library = MediaLibrary::instance(); + + if ($library->exists($newPath)) { + if ($type == MediaLibraryItem::TYPE_FILE) { + $newName = $library->generateIncrementedFileName($originalPath); + } else { + $newName = $library->generateIncrementedFolderName($originalPath); + } + $newPath = dirname($originalPath) . '/' . $newName; + + Flash::info(Lang::get('backend::lang.media.'. $type .'_exists_autorename', ['name' => $newName])); + } + + return $newPath; + } } diff --git a/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js b/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js index 78f74226c..de1bad4ea 100644 --- a/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js +++ b/modules/backend/widgets/mediamanager/assets/js/mediamanager-browser-min.js @@ -64,6 +64,8 @@ this.$el.on('input','[data-control="search"]',this.proxy(this.onSearchChanged)) this.$el.on('mediarefresh',this.proxy(this.refresh)) this.$el.on('shown.oc.popup','[data-command="create-folder"]',this.proxy(this.onFolderPopupShown)) this.$el.on('hidden.oc.popup','[data-command="create-folder"]',this.proxy(this.onFolderPopupHidden)) +this.$el.on('shown.oc.popup','[data-command="duplicate"]',this.proxy(this.onDuplicatePopupShown)) +this.$el.on('hidden.oc.popup','[data-command="duplicate"]',this.proxy(this.onDuplicatePopupHidden)) this.$el.on('shown.oc.popup','[data-command="move"]',this.proxy(this.onMovePopupShown)) this.$el.on('hidden.oc.popup','[data-command="move"]',this.proxy(this.onMovePopupHidden)) this.$el.on('keydown',this.proxy(this.onKeyDown)) @@ -77,6 +79,8 @@ this.$el.off('change','[data-control="sorting"]',this.proxy(this.onSortingChange this.$el.off('keyup','[data-control="search"]',this.proxy(this.onSearchChanged)) this.$el.off('shown.oc.popup','[data-command="create-folder"]',this.proxy(this.onFolderPopupShown)) this.$el.off('hidden.oc.popup','[data-command="create-folder"]',this.proxy(this.onFolderPopupHidden)) +this.$el.off('shown.oc.popup','[data-command="duplicate"]',this.proxy(this.onDuplicatePopupShown)) +this.$el.off('hidden.oc.popup','[data-command="duplicate"]',this.proxy(this.onDuplicatePopupHidden)) this.$el.off('shown.oc.popup','[data-command="move"]',this.proxy(this.onMovePopupShown)) this.$el.off('hidden.oc.popup','[data-command="move"]',this.proxy(this.onMovePopupHidden)) this.$el.off('keydown',this.proxy(this.onKeyDown)) @@ -100,8 +104,9 @@ MediaManager.prototype.selectItem=function(node,expandSelection){if(!expandSelec for(var i=0,len=items.length;i1){$.wn.confirm(this.options.duplicateMultipleConfirm,this.proxy(this.duplicateMultipleConfirmation))}else{var data={path:items[0].getAttribute('data-path'),type:items[0].getAttribute('data-item-type')} +$(ev.target).popup({handler:this.options.alias+'::onLoadDuplicatePopup',extraData:data,zIndex:1200})}} +MediaManager.prototype.duplicateMultipleConfirmation=function(confirmed){if(!confirmed)return +var items=this.$el.get(0).querySelectorAll('[data-type="media-item"].selected'),paths=[] +for(var i=0,len=items.length;i 1) { + $.wn.confirm(this.options.duplicateMultipleConfirm, this.proxy(this.duplicateMultipleConfirmation)) + } else { + var data = { + path: items[0].getAttribute('data-path'), + type: items[0].getAttribute('data-item-type') + } + + $(ev.target).popup({ + handler: this.options.alias+'::onLoadDuplicatePopup', + extraData: data, + zIndex: 1200 // Media Manager can be opened in a popup, so this new popup should have a higher z-index + }) + } + } + + MediaManager.prototype.duplicateMultipleConfirmation = function (confirmed) { + if (!confirmed) + return + + var items = this.$el.get(0).querySelectorAll('[data-type="media-item"].selected'), + paths = [] + + for (var i = 0, len = items.length; i < len; i++) { + // Skip the 'return to parent' item + if (items[i].hasAttribute('data-root')) { + continue; + } + paths.push({ + 'path': items[i].getAttribute('data-path'), + 'type': items[i].getAttribute('data-item-type') + }) + } + + var data = { + paths: paths + } + + $.wn.stripeLoadIndicator.show() + this.$form.request(this.options.alias + '::onDuplicateItems', { + data: data + }).always(function () { + $.wn.stripeLoadIndicator.hide() + }).done(this.proxy(this.afterNavigate)) + } + + MediaManager.prototype.onDuplicatePopupShown = function (ev, button, popup) { + $(popup).on('submit.media', 'form', this.proxy(this.onDuplicateItemSubmit)) + } + + MediaManager.prototype.onDuplicateItemSubmit = function (ev) { + var item = this.$el.get(0).querySelector('[data-type="media-item"].selected'), + data = { + newName: $(ev.target).find('input[name=newName]').val(), + originalPath: $(ev.target).find('input[name=originalPath]').val(), + type: $(ev.target).find('input[name=type]').val() + } + + $.wn.stripeLoadIndicator.show() + this.$form.request(this.options.alias + '::onDuplicateItem', { + data: data + }).always(function () { + $.wn.stripeLoadIndicator.hide() + }).done(this.proxy(this.itemDuplicated)) + + ev.preventDefault() + return false + } + + MediaManager.prototype.onDuplicatePopupHidden = function (ev, button, popup) { + $(popup).off('.media', 'form') + } + + MediaManager.prototype.itemDuplicated = function () { + this.$el.find('button[data-command="duplicate"]').popup('hide') + + this.afterNavigate() + } + MediaManager.prototype.moveItems = function(ev) { var items = this.$el.get(0).querySelectorAll('[data-type="media-item"].selected') @@ -1108,6 +1200,9 @@ case 'create-folder': this.createFolder(ev) break; + case 'duplicate': + this.duplicateItems(ev) + break; case 'move': this.moveItems(ev) break; @@ -1303,6 +1398,8 @@ url: window.location, uploadHandler: null, alias: '', + duplicateEmpty: 'Please select an item to duplicate.', + duplicateMultipleConfirm: 'Multiple items selected, they will be duplicated with generated names. Are you sure?', deleteEmpty: 'Please select files to delete.', deleteConfirm: 'Delete the selected file(s)?', moveEmpty: 'Please select files to move.', diff --git a/modules/backend/widgets/mediamanager/partials/_body.php b/modules/backend/widgets/mediamanager/partials/_body.php index 3dd1b17e0..ab0ba54b4 100644 --- a/modules/backend/widgets/mediamanager/partials/_body.php +++ b/modules/backend/widgets/mediamanager/partials/_body.php @@ -3,6 +3,8 @@ class="layout" data-alias="alias ?>" data-upload-handler="getEventHandler('onUpload') ?>" + data-duplicate-empty="" + data-duplicate-multiple-confirm="" data-delete-empty="" data-delete-confirm="" data-move-empty="" diff --git a/modules/backend/widgets/mediamanager/partials/_duplicate-form.php b/modules/backend/widgets/mediamanager/partials/_duplicate-form.php new file mode 100644 index 000000000..f349aabf7 --- /dev/null +++ b/modules/backend/widgets/mediamanager/partials/_duplicate-form.php @@ -0,0 +1,34 @@ + + + + + diff --git a/modules/backend/widgets/mediamanager/partials/_toolbar.php b/modules/backend/widgets/mediamanager/partials/_toolbar.php index 537580977..a48da9a73 100644 --- a/modules/backend/widgets/mediamanager/partials/_toolbar.php +++ b/modules/backend/widgets/mediamanager/partials/_toolbar.php @@ -14,6 +14,7 @@ readOnly): ?>
+
diff --git a/modules/system/classes/MediaLibrary.php b/modules/system/classes/MediaLibrary.php index 0a28ea023..e40f94530 100644 --- a/modules/system/classes/MediaLibrary.php +++ b/modules/system/classes/MediaLibrary.php @@ -5,12 +5,14 @@ use ApplicationException; use Cache; use Config; +use File; use Illuminate\Filesystem\FilesystemAdapter; use Lang; use Storage; use SystemException; use Url; use Winter\Storm\Filesystem\Definitions as FileDefinitions; +use Winter\Storm\Support\Arr; use Winter\Storm\Support\Str; use Winter\Storm\Support\Svg; @@ -334,6 +336,68 @@ public function put($path, $contents) return $this->getStorageDisk()->put($fullPath, $contents); } + /** + * Duplicates files from the Library. + * + * @param array $paths A list of file paths relative to the Library root to duplicate. + */ + public function duplicateFiles($paths) + { + $duplicateFiles = function ($paths) { + foreach ($paths as $path) { + $path = self::validatePath($path); + // $fullSrcPath = $this->getMediaPath($path); + $destPath = dirname($path) .'/'. $this->generateIncrementedFileName($path); + + if (!$this->copyFile($path, $destPath)) { + return false; + } + } + + return true; + }; + + return $duplicateFiles($paths); + } + + /** + * Duplicates a folder from the Library. + * @param array $path Specifies the folder path relative to the Library root. + */ + public function duplicateFolder($path) + { + $originalPath = self::validatePath($path); + $newPath = dirname($path) .'/'. $this->generateIncrementedFolderName($originalPath); + + return $this->copyFolder($originalPath, $newPath); + } + + /** + * Copy a file to another location. + * @param string $oldPath Specifies the original path of the file. + * @param string $newPath Specifies the new path of the file. + * @return boolean + */ + public function copyFile($oldPath, $newPath, $isRename = false) + { + $oldPath = self::validatePath($oldPath); + $fullOldPath = $this->getMediaPath($oldPath); + + $newPath = self::validatePath($newPath); + $fullNewPath = $this->getMediaPath($newPath); + + // If the file extension is changed to SVG, ensure that it has been sanitized + $oldExt = pathinfo($oldPath, PATHINFO_EXTENSION); + $newExt = pathinfo($newPath, PATHINFO_EXTENSION); + if ($oldExt !== $newExt && strtolower($newExt) === 'svg') { + $contents = $this->getStorageDisk()->get($fullOldPath); + $contents = Svg::sanitize($contents); + return $this->getStorageDisk()->put($fullNewPath, $contents); + } + + return $this->getStorageDisk()->copy($fullOldPath, $fullNewPath); + } + /** * Moves a file to another location. * @param string $oldPath Specifies the original path of the file. @@ -834,4 +898,42 @@ protected function generateRandomTmpFolderName($location) return $tmpPath; } + + /** + * Generates a incremental file name based + * on the existing files in the same folder. + * + * @param string $path Specifies a file path to check. + * @return string The generated file name. + */ + public function generateIncrementedFileName($path): string + { + $filesInFolder = Arr::map( + $this->getStorageDisk()->files(dirname($this->getMediaPath($path))), + function ($path) { + return basename($path); + } + ); + + return File::unique(basename($path), $filesInFolder); + } + + /** + * Generates a incremental folder name based + * on the existing folders in the same folder. + * + * @param string $path Specifies a folder path to check. + * @return string The generated folder name. + */ + public function generateIncrementedFolderName($path) + { + $foldersInFolder = Arr::map( + $this->getStorageDisk()->directories(dirname($this->getMediaPath($path))), + function ($value) { + return basename($value); + } + ); + + return Str::unique(basename($path), $foldersInFolder); + } }