From f753bde9e48619044365335de1be65c9e7f38db1 Mon Sep 17 00:00:00 2001 From: Milan Holemans <11723921+milanholemans@users.noreply.github.com> Date: Fri, 18 Oct 2024 22:34:27 +0200 Subject: [PATCH] Moves 'spo folder copy' to new endpoint. Closes #6310 --- docs/docs/cmd/spo/folder/folder-copy.mdx | 97 +++- docs/docs/v10-upgrade-guidance.mdx | 8 +- .../spo/commands/folder/folder-copy.spec.ts | 442 +++++++++++++----- src/m365/spo/commands/folder/folder-copy.ts | 92 ++-- 4 files changed, 457 insertions(+), 182 deletions(-) diff --git a/docs/docs/cmd/spo/folder/folder-copy.mdx b/docs/docs/cmd/spo/folder/folder-copy.mdx index 732b913e4c1..5d9c31dbe87 100644 --- a/docs/docs/cmd/spo/folder/folder-copy.mdx +++ b/docs/docs/cmd/spo/folder/folder-copy.mdx @@ -1,4 +1,6 @@ import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # spo folder copy @@ -31,19 +33,14 @@ m365 spo folder copy [options] `--nameConflictBehavior [nameConflictBehavior]` : Behavior when a file or folder with the same name is already present at the destination. Allowed values: `fail`, `rename`. Defaults to `fail`. -`--resetAuthorAndCreated` -: Use this option to clear the author and created date. When not specified, the values from the source folder are used. - -`--bypassSharedLock` -: This indicates whether a folder with a shared lock can still be moved. Use this option to copy a folder that is locked. +`--skipWait` +: Don't wait for the copy operation to complete. ``` ## Remarks -When you copy a folder with documents that have version history, only the latest document version is copied. - When you specify a value for `nameConflictBehavior`, consider the following: - `fail` will throw an error when the destination folder already exists. @@ -69,16 +66,90 @@ Copy a folder to another location and use new name on conflict m365 spo folder copy --webUrl https://contoso.sharepoint.com/sites/project-x --sourceUrl "/sites/project-x/Shared Documents/Reports" --targetUrl "/sites/project-y/Shared Documents/Project files" --nameConflictBehavior rename ``` -Copy a folder referenced by its ID to another document library and reset author and created date +Copy a folder referenced by its ID to another document library and don't wait for the copy operation to finish ```sh -m365 spo folder copy --webUrl https://contoso.sharepoint.com/sites/project-x --sourceId b8cc341b-9c11-4f2d-aa2b-0ce9c18bcba2 --targetUrl "/sites/project-x/Project files" --retainEditorAndModified +m365 spo folder copy --webUrl https://contoso.sharepoint.com/sites/project-x --sourceId b8cc341b-9c11-4f2d-aa2b-0ce9c18bcba2 --targetUrl "/sites/project-x/Project files" --skipWait ``` ## Response -The command won't return a response on success. +### Standard Response + + + + + ```json + { + "Exists": true, + "ExistsAllowThrowForPolicyFailures": true, + "ExistsWithException": true, + "IsWOPIEnabled": false, + "ItemCount": 6, + "Name": "Company", + "ProgID": null, + "ServerRelativeUrl": "/sites/Sales/Icons/Company", + "TimeCreated": "2024-09-26T22:08:53Z", + "TimeLastModified": "2024-09-26T22:09:31Z", + "UniqueId": "d3a37396-ca16-467b-b968-48f5fc41f2b6", + "WelcomePage": "" + } + ``` + + + + + ```text + Exists : true + ExistsAllowThrowForPolicyFailures: true + ExistsWithException : true + IsWOPIEnabled : false + ItemCount : 6 + Name : Company + ProgID : null + ServerRelativeUrl : /sites/Sales/Icons/Company + TimeCreated : 2024-09-26T22:08:53Z + TimeLastModified : 2024-09-26T22:09:31Z + UniqueId : d3a37396-ca16-467b-b968-48f5fc41f2b6 + WelcomePage : + ``` + + + + + ```csv + Exists,ExistsAllowThrowForPolicyFailures,ExistsWithException,IsWOPIEnabled,ItemCount,Name,ProgID,ServerRelativeUrl,TimeCreated,TimeLastModified,UniqueId,WelcomePage + 1,1,1,0,6,Company,,/sites/Sales/Icons/Company,2024-09-26T22:08:53Z,2024-09-26T22:09:31Z,d3a37396-ca16-467b-b968-48f5fc41f2b6, + ``` + + + + + ```md + # spo folder copy --webUrl "https://contoso.sharepoint.com/sites/Marketing" --sourceUrl "/Logos/Contoso" --targetUrl "/sites/Sales/Logos" + + Date: 18/10/2024 + + ## Company (d3a37396-ca16-467b-b968-48f5fc41f2b6) + + Property | Value + ---------|------- + Exists | true + ExistsAllowThrowForPolicyFailures | true + ExistsWithException | true + IsWOPIEnabled | false + ItemCount | 6 + Name | Company + ServerRelativeUrl | /sites/Sales/Icons/Company + TimeCreated | 2024-09-26T22:08:53Z + TimeLastModified | 2024-09-26T22:09:31Z + UniqueId | d3a37396-ca16-467b-b968-48f5fc41f2b6 + WelcomePage | + ``` + + + + +### `skipWait` response -## More information - -- Copy items from a SharePoint document library: [https://support.office.com/en-us/article/move-or-copy-items-from-a-sharepoint-document-library-00e2f483-4df3-46be-a861-1f5f0c1a87bc](https://support.office.com/en-us/article/move-or-copy-items-from-a-sharepoint-document-library-00e2f483-4df3-46be-a861-1f5f0c1a87bc) +The command won't return a response on success. diff --git a/docs/docs/v10-upgrade-guidance.mdx b/docs/docs/v10-upgrade-guidance.mdx index a157e7c0367..acf2053e701 100644 --- a/docs/docs/v10-upgrade-guidance.mdx +++ b/docs/docs/v10-upgrade-guidance.mdx @@ -238,13 +238,13 @@ In the past versions of CLI for Microsoft 365, the command had no output. When u When using the [spo file move](./cmd/spo/file/file-move.mdx) command, please use the new command input. This means that you'll have to remove option `--retainEditorAndModified` from your scripts and automation tools. -### Updated command `spo folder move` +### Updated command `spo folder move` and `spo folder copy` -Because of some limitations of the current [spo folder move](./cmd/spo/folder/folder-move.mdx) command, we have decided to move it to a new endpoint. This change is necessary to ensure the command's functionality and reliability. Because of the new endpoint, the command input and output have changed. +Because of some limitations of the current commands [spo folder move](./cmd/spo/folder/folder-move.mdx) and [spo folder copy](./cmd/spo/folder/folder-copy.mdx), we have decided to move it to a new endpoint. This change is necessary to ensure the command's functionality and reliability. Because of the new endpoint, the command's input and output have changed. **Command options:** -Unfortunately, we had to drop the `--retainEditorAndModified` and `--bypassSharedLock` options as it's no longer supported by the new endpoint. In return, we were able to add a new option: +Unfortunately, we had to drop the `--retainEditorAndModified`, `--resetAuthorAndCreated` and `--bypassSharedLock` options as it's no longer supported by the new endpoint. In return, we were able to add a new option: - `--skipWait`: Don't wait for the move operation to complete. **Command output:** @@ -253,7 +253,7 @@ In the past versions of CLI for Microsoft 365, the command had no output. When u #### What action do I need to take? -When using the [spo folder move](./cmd/spo/folder/folder-move.mdx) command, please use the new command input. This means that you'll have to remove options `--retainEditorAndModified` and `--bypassSharedLock` from your scripts and automation tools. +When using these commands, please use the new command input. This means that you'll have to remove options `--retainEditorAndModified`, `--resetAuthorAndCreated` and `--bypassSharedLock` from your scripts and automation tools. ### Removed `spo folder rename` alias diff --git a/src/m365/spo/commands/folder/folder-copy.spec.ts b/src/m365/spo/commands/folder/folder-copy.spec.ts index 3893ce2139f..8ffad381133 100644 --- a/src/m365/spo/commands/folder/folder-copy.spec.ts +++ b/src/m365/spo/commands/folder/folder-copy.spec.ts @@ -11,22 +11,69 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './folder-copy.js'; +import { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; +import { settingsNames } from '../../../../settingsNames.js'; import { CommandError } from '../../../../Command.js'; -describe(commands.FOLDER_COPY, () => { - const folderName = 'Reports'; - const rootUrl = 'https://contoso.sharepoint.com'; - const webUrl = rootUrl + '/sites/project-x'; - const sourceUrl = '/sites/project-x/documents/' + folderName; - const targetUrl = '/sites/project-y/My Documents'; - const absoluteSourceUrl = rootUrl + sourceUrl; - const absoluteTargetUrl = rootUrl + targetUrl; - const sourceId = 'b8cc341b-9c11-4f2d-aa2b-0ce9c18bcba2'; +const sourceWebUrl = 'https://contoso.sharepoint.com/sites/Sales'; +const sourceFolderName = 'Logos'; +const sourceServerRelUrl = '/sites/Sales/Shared Documents/' + sourceFolderName; +const sourceSiteRelUrl = '/Shared Documents/' + sourceFolderName; +const sourceAbsoluteUrl = 'https://contoso.sharepoint.com' + sourceServerRelUrl; +const sourceFolderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; + +const destWebUrl = 'https://contoso.sharepoint.com/sites/Marketing'; +const destSiteRelUrl = '/Documents'; +const destServerRelUrl = '/sites/Marketing' + destSiteRelUrl; +const destAbsoluteTargetUrl = 'https://contoso.sharepoint.com' + destServerRelUrl; +const destFolderId = '15488d89-b82b-40be-958a-922b2ed79383'; + +const copyJobInfo = { + EncryptionKey: '2by8+2oizihYOFqk02Tlokj8lWUShePAEE+WMuA9lzA=', + JobId: 'd812e5a0-d95a-4e4f-bcb7-d4415e88c8ee', + JobQueueUri: 'https://spoam1db1m020p4.queue.core.windows.net/2-1499-20240831-29533e6c72c6464780b756c71ea3fe92?sv=2018-03-28&sig=aX%2BNOkUimZ3f%2B%2BvdXI95%2FKJI1e5UE6TU703Dw3Eb5c8%3D&st=2024-08-09T00%3A00%3A00Z&se=2024-08-31T00%3A00%3A00Z&sp=rap', + SourceListItemUniqueIds: [ + sourceFolderId + ] +}; + +const copyJobResult = { + Event: 'JobFinishedObjectInfo', + JobId: '6d1eda82-0d1c-41eb-ab05-1d9cd4afe786', + Time: '08/10/2024 18:59:40.145', + SourceObjectFullUrl: sourceAbsoluteUrl, + TargetServerUrl: 'https://contoso.sharepoint.com', + TargetSiteId: '794dada8-4389-45ce-9559-0de74bf3554a', + TargetWebId: '8de9b4d3-3c30-4fd0-a9d7-2452bd065555', + TargetListId: '44b336a5-e397-4e22-a270-c39e9069b123', + TargetObjectUniqueId: destFolderId, + TargetObjectSiteRelativeUrl: destSiteRelUrl.substring(1), + CorrelationId: '5efd44a1-c034-9000-9692-4e1a1b3ca33b' +}; + +const destFolderResponse = { + Exists: true, + ExistsAllowThrowForPolicyFailures: true, + ExistsWithException: true, + IsWOPIEnabled: false, + ItemCount: 6, + Name: sourceFolderName, + ProgID: null, + ServerRelativeUrl: destServerRelUrl, + TimeCreated: '2024-09-26T20:52:07Z', + TimeLastModified: '2024-09-26T21:16:26Z', + UniqueId: '59abed95-34f9-470b-a133-ae8932480b53', + WelcomePage: '' +}; +describe(commands.FOLDER_COPY, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; - let postStub: sinon.SinonStub; + let loggerLogSpy: sinon.SinonSpy; + + let spoUtilCreateCopyJobStub: sinon.SinonStub; + let spoUtilGetCopyJobResultStub: sinon.SinonStub; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -36,6 +83,9 @@ describe(commands.FOLDER_COPY, () => { auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => settingName === settingsNames.prompt ? false : defaultValue); + spoUtilCreateCopyJobStub = sinon.stub(spo, 'createFolderCopyJob').resolves(copyJobInfo); + spoUtilGetCopyJobResultStub = sinon.stub(spo, 'getCopyJobResult').resolves(copyJobResult); }); beforeEach(() => { @@ -52,15 +102,8 @@ describe(commands.FOLDER_COPY, () => { } }; - postStub = sinon.stub(request, 'post').callsFake(async opts => { - if (opts.url === `${webUrl}/_api/SP.MoveCopyUtil.CopyFolderByPath`) { - return { - 'odata.null': true - }; - } - - throw 'Invalid request: ' + opts.url; - }); + loggerLogSpy = sinon.spy(logger, 'log'); + spoUtilGetCopyJobResultStub.resetHistory(); }); afterEach(() => { @@ -88,180 +131,314 @@ describe(commands.FOLDER_COPY, () => { }); it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'foo', sourceUrl: sourceUrl, targetUrl: targetUrl } }, commandInfo); + const actual = await command.validate({ options: { webUrl: 'foo', sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if sourceId is not a valid guid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, sourceId: 'invalid', targetUrl: targetUrl } }, commandInfo); + const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceId: 'invalid', targetUrl: destServerRelUrl } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if nameConflictBehavior is not valid', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, sourceUrl: sourceUrl, targetUrl: targetUrl, nameConflictBehavior: 'invalid' } }, commandInfo); + const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl, nameConflictBehavior: 'invalid' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('passes validation if the sourceId is a valid GUID', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, sourceId: sourceId, targetUrl: targetUrl } }, commandInfo); + const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceId: sourceFolderId, targetUrl: destServerRelUrl } }, commandInfo); assert.strictEqual(actual, true); }); it('passes validation if the webUrl option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: webUrl, sourceUrl: sourceUrl, targetUrl: targetUrl } }, commandInfo); + const actual = await command.validate({ options: { webUrl: sourceWebUrl, sourceUrl: sourceServerRelUrl, targetUrl: destServerRelUrl } }, commandInfo); assert.strictEqual(actual, true); }); - it('copies a folder correctly when specifying sourceId', async () => { - sinon.stub(request, 'get').callsFake(async opts => { - if (opts.url === `${webUrl}/_api/Web/GetFolderById('${sourceId}')?$select=ServerRelativePath`) { + it('correctly outputs exactly one result when folder is copied when using sourceId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${sourceWebUrl}/_api/Web/GetFolderById('${sourceFolderId}')/ServerRelativePath`) { return { - ServerRelativePath: { - DecodedUrl: sourceUrl - } + DecodedUrl: destAbsoluteTargetUrl + `/${sourceFolderName}` }; } + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + throw 'Invalid request: ' + opts.url; }); await command.action(logger, { options: { - webUrl: webUrl, - sourceId: sourceId, - targetUrl: targetUrl, - verbose: true + verbose: true, + webUrl: sourceWebUrl, + sourceId: sourceFolderId, + targetUrl: destAbsoluteTargetUrl } }); - assert.deepStrictEqual(postStub.lastCall.args[0].data, + assert(loggerLogSpy.calledOnce); + }); + + it('correctly outputs result when folder is copied when using sourceId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${sourceWebUrl}/_api/Web/GetFolderById('${sourceFolderId}')/ServerRelativePath`) { + return { + DecodedUrl: sourceAbsoluteUrl + }; + } + + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + webUrl: sourceWebUrl, + sourceId: sourceFolderId, + targetUrl: destAbsoluteTargetUrl + } + }); + + assert(loggerLogSpy.calledOnce); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], destFolderResponse); + }); + + it('correctly outputs result when folder is copied when using sourceUrl', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + verbose: true, + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl + } + }); + + assert(loggerLogSpy.calledOnce); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], destFolderResponse); + }); + + it('correctly copies a folder when using sourceId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${sourceWebUrl}/_api/Web/GetFolderById('${sourceFolderId}')/ServerRelativePath`) { + return { + DecodedUrl: sourceAbsoluteUrl + }; + } + + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + webUrl: sourceWebUrl, + sourceId: sourceFolderId, + targetUrl: destAbsoluteTargetUrl + } + }); + + assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ + sourceWebUrl, + sourceAbsoluteUrl, + destAbsoluteTargetUrl, { - srcPath: { - DecodedUrl: absoluteSourceUrl - }, - destPath: { - DecodedUrl: absoluteTargetUrl + `/${folderName}` - }, - options: { - KeepBoth: false, - ShouldBypassSharedLocks: false, - ResetAuthorAndCreatedOnCopy: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + operation: 'copy', + newName: undefined } - ); + ]); }); - it('copies a folder correctly when specifying sourceUrl with server relative paths', async () => { + it('correctly copies a folder when using sourceUrl', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + await command.action(logger, { options: { - webUrl: webUrl, - sourceUrl: sourceUrl, - targetUrl: targetUrl + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl, + nameConflictBehavior: 'fail' } }); - assert.deepStrictEqual(postStub.lastCall.args[0].data, + assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ + sourceWebUrl, + sourceAbsoluteUrl, + destAbsoluteTargetUrl, { - srcPath: { - DecodedUrl: absoluteSourceUrl - }, - destPath: { - DecodedUrl: absoluteTargetUrl + `/${folderName}` - }, - options: { - KeepBoth: false, - ShouldBypassSharedLocks: false, - ResetAuthorAndCreatedOnCopy: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + operation: 'copy', + newName: undefined } - ); + ]); }); - it('copies a folder correctly when specifying sourceUrl with site relative paths', async () => { + it('correctly copies a folder when using site-relative sourceUrl', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + await command.action(logger, { options: { - webUrl: webUrl, - sourceUrl: `/Shared Documents/${folderName}`, - targetUrl: targetUrl, + webUrl: sourceWebUrl, + sourceUrl: sourceSiteRelUrl, + targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'fail' } }); - assert.deepStrictEqual(postStub.lastCall.args[0].data, + assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ + sourceWebUrl, + sourceAbsoluteUrl, + destAbsoluteTargetUrl, { - srcPath: { - DecodedUrl: webUrl + `/Shared Documents/${folderName}` - }, - destPath: { - DecodedUrl: absoluteTargetUrl + `/${folderName}` - }, - options: { - KeepBoth: false, - ShouldBypassSharedLocks: false, - ResetAuthorAndCreatedOnCopy: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + operation: 'copy', + newName: undefined } - ); + ]); }); - it('copies a folder correctly when specifying sourceUrl with absolute paths', async () => { + it('correctly copies a folder when using absolute urls', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + await command.action(logger, { options: { - webUrl: webUrl, - sourceUrl: rootUrl + sourceUrl, - targetUrl: rootUrl + targetUrl + webUrl: sourceWebUrl, + sourceUrl: sourceAbsoluteUrl, + targetUrl: destAbsoluteTargetUrl, + nameConflictBehavior: 'rename' } }); - assert.deepStrictEqual(postStub.lastCall.args[0].data, + assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ + sourceWebUrl, + sourceAbsoluteUrl, + destAbsoluteTargetUrl, { - srcPath: { - DecodedUrl: absoluteSourceUrl - }, - destPath: { - DecodedUrl: absoluteTargetUrl + `/${folderName}` - }, - options: { - KeepBoth: false, - ShouldBypassSharedLocks: false, - ResetAuthorAndCreatedOnCopy: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + operation: 'copy', + newName: undefined } - ); + ]); }); - it('copies a folder correctly when specifying various options', async () => { + it('correctly copies a folder when using sourceUrl with extra options', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + await command.action(logger, { options: { - webUrl: webUrl, - sourceUrl: sourceUrl, - targetUrl: targetUrl, - newName: 'Reports January', + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'rename', - resetAuthorAndCreated: true, - bypassSharedLock: true + newName: 'Folder-renamed' } }); - assert.deepStrictEqual(postStub.lastCall.args[0].data, + assert.deepStrictEqual(spoUtilCreateCopyJobStub.lastCall.args, [ + sourceWebUrl, + sourceAbsoluteUrl, + destAbsoluteTargetUrl, { - srcPath: { - DecodedUrl: absoluteSourceUrl - }, - destPath: { - DecodedUrl: absoluteTargetUrl + '/Reports January' - }, - options: { - KeepBoth: true, - ShouldBypassSharedLocks: true, - ResetAuthorAndCreatedOnCopy: true - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + operation: 'copy', + newName: 'Folder-renamed' + } + ]); + }); + + it('correctly polls for the copy job to finish', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${destWebUrl}/_api/Web/GetFolderById('${destFolderId}')`) { + return destFolderResponse; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { + options: { + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl + } + }); + + assert.deepStrictEqual(spoUtilGetCopyJobResultStub.lastCall.args, [ + sourceWebUrl, + copyJobInfo + ]); + }); + + it('outputs no result when skipWait is specified', async () => { + await command.action(logger, { + options: { + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl, + skipWait: true + } + }); + + assert(loggerLogSpy.notCalled); + }); + + it('correctly skips polling when skipWait is specified', async () => { + await command.action(logger, { + options: { + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl, + skipWait: true } - ); + }); + + assert(spoUtilGetCopyJobResultStub.notCalled); }); - it('handles error correctly when copying a folder', async () => { - const error = { + it('correctly handles error when sourceId does not exist', async () => { + sinon.stub(request, 'get').rejects({ error: { 'odata.error': { message: { @@ -270,16 +447,27 @@ describe(commands.FOLDER_COPY, () => { } } } - }; + }); + + await assert.rejects(command.action(logger, { + options: { + webUrl: sourceWebUrl, + sourceId: sourceFolderId, + targetUrl: destAbsoluteTargetUrl + } + }), new CommandError('Folder Not Found.')); + }); - sinon.stub(request, 'get').rejects(error); + it('correctly handles error when getCopyJobResult fails', async () => { + spoUtilGetCopyJobResultStub.restore(); + spoUtilGetCopyJobResultStub = sinon.stub(spo, 'getCopyJobResult').rejects(new Error('Target folder already exists.')); await assert.rejects(command.action(logger, { options: { - webUrl: webUrl, - sourceId: sourceId, - targetUrl: targetUrl + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl } - }), new CommandError(error.error['odata.error'].message.value)); + }), new CommandError('Target folder already exists.')); }); -}); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-copy.ts b/src/m365/spo/commands/folder/folder-copy.ts index c1e082dcb75..9d1b16180e8 100644 --- a/src/m365/spo/commands/folder/folder-copy.ts +++ b/src/m365/spo/commands/folder/folder-copy.ts @@ -1,6 +1,7 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -17,8 +18,7 @@ interface Options extends GlobalOptions { targetUrl: string; newName?: string; nameConflictBehavior?: string; - resetAuthorAndCreated?: boolean; - bypassSharedLock?: boolean; + skipWait?: boolean; } class SpoFolderCopyCommand extends SpoCommand { @@ -49,8 +49,7 @@ class SpoFolderCopyCommand extends SpoCommand { sourceId: typeof args.options.sourceId !== 'undefined', newName: typeof args.options.newName !== 'undefined', nameConflictBehavior: typeof args.options.nameConflictBehavior !== 'undefined', - resetAuthorAndCreated: !!args.options.resetAuthorAndCreated, - bypassSharedLock: !!args.options.bypassSharedLock + skipWait: !!args.options.skipWait }); }); } @@ -77,10 +76,7 @@ class SpoFolderCopyCommand extends SpoCommand { autocomplete: this.nameConflictBehaviorOptions }, { - option: '--resetAuthorAndCreated' - }, - { - option: '--bypassSharedLock' + option: '--skipWait' } ); } @@ -112,7 +108,7 @@ class SpoFolderCopyCommand extends SpoCommand { #initTypes(): void { this.types.string.push('webUrl', 'sourceUrl', 'sourceId', 'targetUrl', 'newName', 'nameConflictBehavior'); - this.types.boolean.push('resetAuthorAndCreated', 'bypassSharedLock'); + this.types.boolean.push('skipWait'); } protected getExcludedOptionsWithUrls(): string[] | undefined { @@ -121,51 +117,70 @@ class SpoFolderCopyCommand extends SpoCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { try { - const sourcePath = await this.getSourcePath(logger, args.options); + const sourceServerRelativePath = await this.getSourcePath(logger, args.options); + const sourcePath = this.getAbsoluteUrl(args.options.webUrl, sourceServerRelativePath); + const destinationPath = this.getAbsoluteUrl(args.options.webUrl, args.options.targetUrl); if (this.verbose) { - await logger.logToStderr(`Copying folder ${sourcePath} to ${args.options.targetUrl}...`); + await logger.logToStderr(`Copying folder '${sourcePath}' to '${destinationPath}'...`); } - const absoluteSourcePath = this.getAbsoluteUrl(args.options.webUrl, sourcePath); - let absoluteTargetPath = this.getAbsoluteUrl(args.options.webUrl, args.options.targetUrl) + '/'; + const copyJobResponse = await spo.createFolderCopyJob( + args.options.webUrl, + sourcePath, + destinationPath, + { + nameConflictBehavior: this.getNameConflictBehaviorValue(args.options.nameConflictBehavior), + newName: args.options.newName, + operation: 'copy' + } + ); - if (args.options.newName) { - absoluteTargetPath += args.options.newName; + if (args.options.skipWait) { + return; } - else { - // Keep the original folder name - absoluteTargetPath += sourcePath.substring(sourcePath.lastIndexOf('/') + 1); + + if (this.verbose) { + await logger.logToStderr('Waiting for the copy job to complete...'); + } + + const copyJobResult = await spo.getCopyJobResult(args.options.webUrl, copyJobResponse); + + if (this.verbose) { + await logger.logToStderr('Getting information about the destination folder...'); } + // Get destination folder data + const siteRelativeDestinationFolder = '/' + copyJobResult.TargetObjectSiteRelativeUrl.substring(0, copyJobResult.TargetObjectSiteRelativeUrl.lastIndexOf('/')); + const absoluteWebUrl = destinationPath.substring(0, destinationPath.toLowerCase().lastIndexOf(siteRelativeDestinationFolder.toLowerCase())); + const requestOptions: CliRequestOptions = { - url: `${args.options.webUrl}/_api/SP.MoveCopyUtil.CopyFolderByPath`, + url: `${absoluteWebUrl}/_api/Web/GetFolderById('${copyJobResult.TargetObjectUniqueId}')`, headers: { accept: 'application/json;odata=nometadata' }, - responseType: 'json', - data: { - srcPath: { - DecodedUrl: absoluteSourcePath - }, - destPath: { - DecodedUrl: absoluteTargetPath - }, - options: { - KeepBoth: args.options.nameConflictBehavior === 'rename', - ShouldBypassSharedLocks: !!args.options.bypassSharedLock, - ResetAuthorAndCreatedOnCopy: !!args.options.resetAuthorAndCreated - } - } + responseType: 'json' }; - await request.post(requestOptions); + const destinationFile = await request.get(requestOptions); + await logger.log(destinationFile); } catch (err: any) { this.handleRejectedODataJsonPromise(err); } } + private getNameConflictBehaviorValue(nameConflictBehavior?: string): CreateFolderCopyJobsNameConflictBehavior { + switch (nameConflictBehavior?.toLowerCase()) { + case 'fail': + return CreateFolderCopyJobsNameConflictBehavior.Fail; + case 'rename': + return CreateFolderCopyJobsNameConflictBehavior.Rename; + default: + return CreateFolderCopyJobsNameConflictBehavior.Fail; + } + } + private async getSourcePath(logger: Logger, options: Options): Promise { if (options.sourceUrl) { return urlUtil.getServerRelativePath(options.webUrl, options.sourceUrl); @@ -176,19 +191,20 @@ class SpoFolderCopyCommand extends SpoCommand { } const requestOptions: CliRequestOptions = { - url: `${options.webUrl}/_api/Web/GetFolderById('${options.sourceId}')?$select=ServerRelativePath`, + url: `${options.webUrl}/_api/Web/GetFolderById('${options.sourceId}')/ServerRelativePath`, headers: { accept: 'application/json;odata=nometadata' }, responseType: 'json' }; - const file = await request.get<{ ServerRelativePath: { DecodedUrl: string } }>(requestOptions); - return file.ServerRelativePath.DecodedUrl; + const path = await request.get<{ DecodedUrl: string }>(requestOptions); + return path.DecodedUrl; } private getAbsoluteUrl(webUrl: string, url: string): string { - return url.startsWith('https://') ? url : urlUtil.getAbsoluteUrl(webUrl, url); + const result = url.startsWith('https://') ? url : urlUtil.getAbsoluteUrl(webUrl, url); + return urlUtil.removeTrailingSlashes(result); } }