diff --git a/apps/files/js/fileactions.js b/apps/files/js/fileactions.js index 3e0cf99825..3da9b06b0d 100644 --- a/apps/files/js/fileactions.js +++ b/apps/files/js/fileactions.js @@ -618,16 +618,21 @@ }); this.registerAction({ - name: 'Move', - displayName: t('files', 'Move'), + name: 'MoveCopy', + displayName: t('files', 'Move or copy'), mime: 'all', order: -25, permissions: OC.PERMISSION_UPDATE, iconClass: 'icon-external', actionHandler: function (filename, context) { - OC.dialogs.filepicker(t('files', 'Target folder'), function(targetPath) { - context.fileList.move(filename, targetPath); - }, false, "httpd/unix-directory", true); + OC.dialogs.filepicker(t('files', 'Target folder'), function(targetPath, type) { + if (type === OC.dialogs.FILEPICKER_TYPE_COPY) { + context.fileList.copy(filename, targetPath); + } + if (type === OC.dialogs.FILEPICKER_TYPE_MOVE) { + context.fileList.move(filename, targetPath); + } + }, false, "httpd/unix-directory", true, OC.dialogs.FILEPICKER_TYPE_COPY_MOVE); } }); diff --git a/apps/files/js/filelist.js b/apps/files/js/filelist.js index 95fbc2b756..48ac0f4e33 100644 --- a/apps/files/js/filelist.js +++ b/apps/files/js/filelist.js @@ -337,7 +337,7 @@ this.$el.on('urlChanged', _.bind(this._onUrlChanged, this)); this.$el.find('.select-all').click(_.bind(this._onClickSelectAll, this)); this.$el.find('.download').click(_.bind(this._onClickDownloadSelected, this)); - this.$el.find('.move').click(_.bind(this._onClickMoveSelected, this)); + this.$el.find('.copy-move').click(_.bind(this._onClickCopyMoveSelected, this)); this.$el.find('.delete-selected').click(_.bind(this._onClickDeleteSelected, this)); this.$el.find('.selectedActions a').tooltip({placement:'top'}); @@ -761,7 +761,7 @@ /** * Event handler for when clicking on "Move" for the selected files */ - _onClickMoveSelected: function(event) { + _onClickCopyMoveSelected: function(event) { var files; var self = this; @@ -779,14 +779,17 @@ OCA.Files.FileActions.updateFileActionSpinner(moveFileAction, false); }; - OCA.Files.FileActions.updateFileActionSpinner(moveFileAction, true); - OC.dialogs.filepicker(t('files', 'Target folder'), function(targetPath) { - self.move(files, targetPath, disableLoadingState); - }, false, "httpd/unix-directory", true); + OC.dialogs.filepicker(t('files', 'Target folder'), function(targetPath, type) { + if (type === OC.dialogs.FILEPICKER_TYPE_COPY) { + self.copy(files, targetPath, disableLoadingState); + } + if (type === OC.dialogs.FILEPICKER_TYPE_MOVE) { + self.move(files, targetPath, disableLoadingState); + } + }, false, "httpd/unix-directory", true, OC.dialogs.FILEPICKER_TYPE_COPY_MOVE); return false; }, - /** * Event handler for when clicking on "Delete" for the selected files */ @@ -1974,6 +1977,7 @@ } return index; }, + /** * Moves a file to a given target folder. * @@ -2037,6 +2041,112 @@ }, + /** + * Copies a file to a given target folder. + * + * @param fileNames array of file names to copy + * @param targetPath absolute target path + * @param callback to call when copy is finished with success + */ + copy: function(fileNames, targetPath, callback) { + var self = this; + var filesToNotify = []; + var count = 0; + + var dir = this.getCurrentDirectory(); + if (dir.charAt(dir.length - 1) !== '/') { + dir += '/'; + } + var target = OC.basename(targetPath); + if (!_.isArray(fileNames)) { + fileNames = [fileNames]; + } + _.each(fileNames, function(fileName) { + var $tr = self.findFileEl(fileName); + self.showFileBusyState($tr, true); + if (targetPath.charAt(targetPath.length - 1) !== '/') { + // make sure we move the files into the target dir, + // not overwrite it + targetPath = targetPath + '/'; + } + self.filesClient.copy(dir + fileName, targetPath + fileName) + .done(function () { + filesToNotify.push(fileName); + + // if still viewing the same directory + if (OC.joinPaths(self.getCurrentDirectory(), '/') === dir) { + // recalculate folder size + var oldFile = self.findFileEl(target); + var newFile = self.findFileEl(fileName); + var oldSize = oldFile.data('size'); + var newSize = oldSize + newFile.data('size'); + oldFile.data('size', newSize); + oldFile.find('td.filesize').text(OC.Util.humanFileSize(newSize)); + } + }) + .fail(function(status) { + if (status === 412) { + // TODO: some day here we should invoke the conflict dialog + OC.Notification.show(t('files', 'Could not copy "{file}", target exists', + {file: fileName}), {type: 'error'} + ); + } else { + OC.Notification.show(t('files', 'Could not copy "{file}"', + {file: fileName}), {type: 'error'} + ); + } + }) + .always(function() { + self.showFileBusyState($tr, false); + count++; + + /** + * We only show the notifications once the last file has been copied + */ + if (count === fileNames.length) { + // Remove leading and ending / + if (targetPath.slice(0, 1) === '/') { + targetPath = targetPath.slice(1, targetPath.length); + } + if (targetPath.slice(-1) === '/') { + targetPath = targetPath.slice(0, -1); + } + + if (filesToNotify.length > 0) { + // Since there's no visual indication that the files were copied, let's send some notifications ! + if (filesToNotify.length === 1) { + OC.Notification.show(t('files', 'Copied {origin} inside {destination}', + { + origin: filesToNotify[0], + destination: targetPath + } + ), {timeout: 10}); + } else if (filesToNotify.length > 0 && filesToNotify.length < 3) { + OC.Notification.show(t('files', 'Copied {origin} inside {destination}', + { + origin: filesToNotify.join(', '), + destination: targetPath + } + ), {timeout: 10}); + } else { + OC.Notification.show(t('files', 'Copied {origin} and {nbfiles} other files inside {destination}', + { + origin: filesToNotify[0], + nbfiles: filesToNotify.length - 1, + destination: targetPath + } + ), {timeout: 10}); + } + } + } + }); + }); + + if (callback) { + callback(); + } + }, + /** * Updates the given row with the given file info * diff --git a/apps/files/templates/list.php b/apps/files/templates/list.php index 67c330c38c..f3b6759644 100644 --- a/apps/files/templates/list.php +++ b/apps/files/templates/list.php @@ -47,9 +47,9 @@ t( 'Name' )); ?> - + - t('Move'))?> + t('Move or copy'))?> diff --git a/apps/files/tests/js/filelistSpec.js b/apps/files/tests/js/filelistSpec.js index a12c0ff49b..5061d70c4c 100644 --- a/apps/files/tests/js/filelistSpec.js +++ b/apps/files/tests/js/filelistSpec.js @@ -853,6 +853,104 @@ describe('OCA.Files.FileList tests', function() { .toEqual(OC.imagePath('core', 'filetypes/text.svg')); }); }); + + describe('Copying files', function() { + var deferredCopy; + var copyStub; + + beforeEach(function() { + deferredCopy = $.Deferred(); + copyStub = sinon.stub(filesClient, 'copy').returns(deferredCopy.promise()); + + fileList.setFiles(testFiles); + }); + afterEach(function() { + copyStub.restore(); + }); + + it('Copies single file to target folder', function() { + fileList.copy('One.txt', '/somedir'); + + expect(copyStub.calledOnce).toEqual(true); + expect(copyStub.getCall(0).args[0]).toEqual('/subdir/One.txt'); + expect(copyStub.getCall(0).args[1]).toEqual('/somedir/One.txt'); + + deferredCopy.resolve(201); + + // File is still here + expect(fileList.findFileEl('One.txt').length).toEqual(1); + + // folder size has increased + expect(fileList.findFileEl('somedir').data('size')).toEqual(262); + expect(fileList.findFileEl('somedir').find('.filesize').text()).toEqual('262 B'); + + // Copying sents a notification to tell that we've successfully copied file + expect(notificationStub.notCalled).toEqual(false); + }); + it('Copies list of files to target folder', function() { + var deferredCopy1 = $.Deferred(); + var deferredCopy2 = $.Deferred(); + copyStub.onCall(0).returns(deferredCopy1.promise()); + copyStub.onCall(1).returns(deferredCopy2.promise()); + + fileList.copy(['One.txt', 'Two.jpg'], '/somedir'); + + expect(copyStub.calledTwice).toEqual(true); + expect(copyStub.getCall(0).args[0]).toEqual('/subdir/One.txt'); + expect(copyStub.getCall(0).args[1]).toEqual('/somedir/One.txt'); + expect(copyStub.getCall(1).args[0]).toEqual('/subdir/Two.jpg'); + expect(copyStub.getCall(1).args[1]).toEqual('/somedir/Two.jpg'); + + deferredCopy1.resolve(201); + + expect(fileList.findFileEl('One.txt').length).toEqual(1); + + // folder size has increased during copy + expect(fileList.findFileEl('somedir').data('size')).toEqual(262); + expect(fileList.findFileEl('somedir').find('.filesize').text()).toEqual('262 B'); + + deferredCopy2.resolve(201); + + expect(fileList.findFileEl('Two.jpg').length).toEqual(1); + + // folder size has increased + expect(fileList.findFileEl('somedir').data('size')).toEqual(12311); + expect(fileList.findFileEl('somedir').find('.filesize').text()).toEqual('12 KB'); + + expect(notificationStub.notCalled).toEqual(false); + }); + it('Shows notification if a file could not be copied', function() { + fileList.copy('One.txt', '/somedir'); + + expect(copyStub.calledOnce).toEqual(true); + + deferredCopy.reject(409); + + expect(fileList.findFileEl('One.txt').length).toEqual(1); + + expect(notificationStub.calledOnce).toEqual(true); + expect(notificationStub.getCall(0).args[0]).toEqual('Could not copy "One.txt"'); + }); + it('Restores thumbnail if a file could not be copied', function() { + fileList.copy('One.txt', '/somedir'); + + expect(OC.TestUtil.getImageUrl(fileList.findFileEl('One.txt').find('.thumbnail'))) + .toEqual(OC.imagePath('core', 'loading.gif')); + + expect(copyStub.calledOnce).toEqual(true); + + deferredCopy.reject(409); + + expect(fileList.findFileEl('One.txt').length).toEqual(1); + + expect(notificationStub.calledOnce).toEqual(true); + expect(notificationStub.getCall(0).args[0]).toEqual('Could not copy "One.txt"'); + + expect(OC.TestUtil.getImageUrl(fileList.findFileEl('One.txt').find('.thumbnail'))) + .toEqual(OC.imagePath('core', 'filetypes/text.svg')); + }); + }); + describe('Update file', function() { it('does not change summary', function() { var $summary = $('#filestable .summary'); diff --git a/core/css/jquery.ocdialog.css b/core/css/jquery.ocdialog.css index 487bc1c4f6..2100a3db7a 100644 --- a/core/css/jquery.ocdialog.css +++ b/core/css/jquery.ocdialog.css @@ -38,6 +38,9 @@ .oc-dialog-buttonrow.twobuttons button:nth-child(1) { float: left; } +.oc-dialog-buttonrow.twobuttons.aside button:nth-child(1) { + float: none; +} .oc-dialog-buttonrow.twobuttons button:nth-child(2) { float: right; } diff --git a/core/js/files/client.js b/core/js/files/client.js index 176cabf04b..dc9f6ade64 100644 --- a/core/js/files/client.js +++ b/core/js/files/client.js @@ -736,6 +736,51 @@ return promise; }, + /** + * Copies path to another path + * + * @param {String} path path to copy + * @param {String} destinationPath destination path + * @param {boolean} [allowOverwrite=false] true to allow overwriting, + * false otherwise + * + * @return {Promise} promise + */ + copy: function (path, destinationPath, allowOverwrite) { + if (!path) { + throw 'Missing argument "path"'; + } + if (!destinationPath) { + throw 'Missing argument "destinationPath"'; + } + + var self = this; + var deferred = $.Deferred(); + var promise = deferred.promise(); + var headers = { + 'Destination' : this._buildUrl(destinationPath) + }; + + if (!allowOverwrite) { + headers.Overwrite = 'F'; + } + + this._client.request( + 'COPY', + this._buildUrl(path), + headers + ).then( + function(response) { + if (self._isSuccessStatus(response.status)) { + deferred.resolve(response.status); + } else { + deferred.reject(response.status); + } + } + ); + return promise; + }, + /** * Add a file info parser function * diff --git a/core/js/jquery.ocdialog.js b/core/js/jquery.ocdialog.js index b54cce2c0c..555b35e59f 100644 --- a/core/js/jquery.ocdialog.js +++ b/core/js/jquery.ocdialog.js @@ -130,6 +130,11 @@ }); this._setSizes(); break; + case 'style': + if (value.buttons !== undefined) { + this.$buttonrow.addClass(value.buttons); + } + break; case 'closeButton': if(value) { var $closeButton = $(''); diff --git a/core/js/oc-dialogs.js b/core/js/oc-dialogs.js index 5fc224e38b..1bc1399466 100644 --- a/core/js/oc-dialogs.js +++ b/core/js/oc-dialogs.js @@ -29,6 +29,12 @@ var OCdialogs = { // dialog button types YES_NO_BUTTONS: 70, OK_BUTTONS: 71, + + FILEPICKER_TYPE_CHOOSE: 1, + FILEPICKER_TYPE_MOVE: 2, + FILEPICKER_TYPE_COPY: 3, + FILEPICKER_TYPE_COPY_MOVE: 4, + // used to name each dialog dialogsCounter: 0, /** @@ -174,13 +180,19 @@ var OCdialogs = { * @param multiselect whether it should be possible to select multiple files * @param mimetypeFilter mimetype to filter by - directories will always be included * @param modal make the dialog modal + * @param type Type of file picker : Choose, copy, move, copy and move */ - filepicker:function(title, callback, multiselect, mimetypeFilter, modal) { + filepicker:function(title, callback, multiselect, mimetypeFilter, modal, type) { var self = this; // avoid opening the picker twice if (this.filepicker.loading) { return; } + + if (type === undefined) { + type = this.FILEPICKER_TYPE_CHOOSE; + } + this.filepicker.loading = true; this.filepicker.filesClient = (OCA.Sharing && OCA.Sharing.PublicApp && OCA.Sharing.PublicApp.fileList)? OCA.Sharing.PublicApp.fileList.filesClient: OC.Files.getClient(); $.when(this._getFilePickerTemplate()).then(function($tmpl) { @@ -210,15 +222,17 @@ var OCdialogs = { self.$filePicker.ready(function() { self.$filelist = self.$filePicker.find('.filelist tbody'); self.$dirTree = self.$filePicker.find('.dirtree'); - self.$dirTree.on('click', 'div:not(:last-child)', self, self._handleTreeListSelect.bind(self)); + self.$dirTree.on('click', 'div:not(:last-child)', self, function (event) { + self._handleTreeListSelect(event, type); + }); self.$filelist.on('click', 'tr', function(event) { - self._handlePickerClick(event, $(this)); + self._handlePickerClick(event, $(this), type); }); self._fillFilePicker(''); }); // build buttons - var functionToCall = function() { + var functionToCall = function(returnType) { if (callback !== undefined) { var datapath; if (multiselect === true) { @@ -233,15 +247,46 @@ var OCdialogs = { datapath += '/' + selectedName; } } - callback(datapath); + callback(datapath, returnType); self.$filePicker.ocdialog('close'); } }; - var buttonlist = [{ - text: t('core', 'Choose'), - click: functionToCall, - defaultButton: true - }]; + + var chooseCallback = function () { + functionToCall(OCdialogs.FILEPICKER_TYPE_CHOOSE); + }; + + var copyCallback = function () { + functionToCall(OCdialogs.FILEPICKER_TYPE_COPY); + }; + + var moveCallback = function () { + functionToCall(OCdialogs.FILEPICKER_TYPE_MOVE); + }; + + var buttonlist = []; + if (type === OCdialogs.FILEPICKER_TYPE_CHOOSE) { + buttonlist.push({ + text: t('core', 'Choose'), + click: chooseCallback, + defaultButton: true + }); + } else { + if (type === OCdialogs.FILEPICKER_TYPE_COPY || type === OCdialogs.FILEPICKER_TYPE_COPY_MOVE) { + buttonlist.push({ + text: t('core', 'Copy'), + click: copyCallback, + defaultButton: false + }); + } + if (type === OCdialogs.FILEPICKER_TYPE_MOVE || type === OCdialogs.FILEPICKER_TYPE_COPY_MOVE) { + buttonlist.push({ + text: t('core', 'Move'), + click: moveCallback, + defaultButton: true + }); + } + } self.$filePicker.ocdialog({ closeOnEscape: true, @@ -250,6 +295,9 @@ var OCdialogs = { height: 500, modal: modal, buttons: buttonlist, + style: { + buttons: 'aside', + }, close: function() { try { $(this).ocdialog('destroy').remove(); @@ -879,12 +927,13 @@ var OCdialogs = { /** * handle selection made in the tree list */ - _handleTreeListSelect:function(event) { + _handleTreeListSelect:function(event, type) { var self = event.data; var dir = $(event.target).parent().data('dir'); self._fillFilePicker(dir); var getOcDialog = (event.target).closest('.oc-dialog'); var buttonEnableDisable = $('.primary', getOcDialog); + this._changeButtonsText(type, dir.split(/[/]+/).pop()); if (this.$filePicker.data('mimetype') === "httpd/unix-directory") { buttonEnableDisable.prop("disabled", false); } else { @@ -894,7 +943,7 @@ var OCdialogs = { /** * handle clicks made in the filepicker */ - _handlePickerClick:function(event, $element) { + _handlePickerClick:function(event, $element, type) { var getOcDialog = this.$filePicker.closest('.oc-dialog'); var buttonEnableDisable = getOcDialog.find('.primary'); if ($element.data('type') === 'file') { @@ -905,11 +954,38 @@ var OCdialogs = { buttonEnableDisable.prop("disabled", false); } else if ( $element.data('type') === 'dir' ) { this._fillFilePicker(this.$filePicker.data('path') + '/' + $element.data('entryname')); + this._changeButtonsText(type, $element.data('entryname')); if (this.$filePicker.data('mimetype') === "httpd/unix-directory") { buttonEnableDisable.prop("disabled", false); } else { buttonEnableDisable.prop("disabled", true); } } + }, + + /** + * Handle + * @param type of action + * @param dir on which to change buttons text + * @private + */ + _changeButtonsText: function(type, dir) { + var copyText = dir === '' ? t('core', 'Copy') : t('core', 'Copy to {folder}', {folder: dir}); + var moveText = dir === '' ? t('core', 'Move') : t('core', 'Move to {folder}', {folder: dir}); + var buttons = $('.oc-dialog-buttonrow button'); + switch (type) { + case this.FILEPICKER_TYPE_CHOOSE: + break; + case this.FILEPICKER_TYPE_COPY: + buttons.text(copyText); + break; + case this.FILEPICKER_TYPE_MOVE: + buttons.text(moveText); + break; + case this.FILEPICKER_TYPE_COPY_MOVE: + buttons.eq(0).text(copyText); + buttons.eq(1).text(moveText); + break; + } } };