From d48e589d0329d3103e425157cb8ca4a2bf2d72e8 Mon Sep 17 00:00:00 2001 From: Milan Holemans <11723921+milanholemans@users.noreply.github.com> Date: Fri, 27 Sep 2024 00:22:38 +0200 Subject: [PATCH] Enhances 'spo folder move' with new endpoint. Closes #6154 --- docs/docs/cmd/spo/file/file-move.mdx | 4 +- docs/docs/cmd/spo/folder/folder-move.mdx | 97 +++- docs/docs/v10-upgrade-guidance.mdx | 17 + src/m365/spo/commands/file/file-copy.spec.ts | 12 +- src/m365/spo/commands/file/file-copy.ts | 14 +- src/m365/spo/commands/file/file-move.spec.ts | 16 +- src/m365/spo/commands/file/file-move.ts | 14 +- .../spo/commands/folder/folder-move.spec.ts | 441 +++++++++++++----- src/m365/spo/commands/folder/folder-move.ts | 92 ++-- src/utils/spo.spec.ts | 211 +++++++-- src/utils/spo.ts | 88 +++- src/utils/timersUtil.ts | 3 +- 12 files changed, 737 insertions(+), 272 deletions(-) diff --git a/docs/docs/cmd/spo/file/file-move.mdx b/docs/docs/cmd/spo/file/file-move.mdx index a3470fa73bf..ff3f5ed7e7e 100644 --- a/docs/docs/cmd/spo/file/file-move.mdx +++ b/docs/docs/cmd/spo/file/file-move.mdx @@ -40,15 +40,13 @@ m365 spo file move [options] : This indicates whether a file with a share lock can still be moved. Use this option to move a file that is locked. `--skipWait` -: Don't wait for the copy operation to complete. +: Don't wait for the move operation to complete. ``` ## Remarks -All file versions are retained while moving a file. - When you specify a value for `nameConflictBehavior`, consider the following: - `fail` will throw an error when the destination file already exists. - `replace` will replace the destination file if it already exists. diff --git a/docs/docs/cmd/spo/folder/folder-move.mdx b/docs/docs/cmd/spo/folder/folder-move.mdx index dbd077356e7..4e6f7083301 100644 --- a/docs/docs/cmd/spo/folder/folder-move.mdx +++ b/docs/docs/cmd/spo/folder/folder-move.mdx @@ -1,4 +1,6 @@ import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; # spo folder move @@ -31,19 +33,14 @@ m365 spo folder move [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`. -`--retainEditorAndModified` -: Use this option to retain the editor and modified date. When not specified, these values are reset. - -`--bypassSharedLock` -: This indicates whether a folder with a share lock can still be moved. Use this option to move a folder that is locked. +`--skipWait` +: Don't wait for the move operation to complete. ``` ## Remarks -All folder versions are retained while moving a folder. - 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 @@ Move a folder to another location and use a new name on conflict. m365 spo folder move --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 ``` -Move a folder referenced by its ID to another document library and retain editor and modified date. +Move a folder referenced by its ID to another document library and don't wait for the move job to finish. ```sh -m365 spo folder move --webUrl https://contoso.sharepoint.com/sites/project-x --sourceId b8cc341b-9c11-4f2d-aa2b-0ce9c18bcba2 --targetUrl "/sites/project-x/Project files" --retainEditorAndModified +m365 spo folder move --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 move --webUrl "https://contoso.sharepoint.com/sites/Marketing" --sourceUrl "/Logos/Contoso" --targetUrl "/sites/Sales/Logos" + + Date: 27/09/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 - -- Move 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 5874f182cc5..1402e97ad85 100644 --- a/docs/docs/v10-upgrade-guidance.mdx +++ b/docs/docs/v10-upgrade-guidance.mdx @@ -230,6 +230,23 @@ 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` + +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. + +**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: +- `--skipWait`: Don't wait for the move operation to complete. + +**Command output:** + +In the past versions of CLI for Microsoft 365, the command had no output. When using option `--nameConflictBehavior rename`, it's hard for the user to know what the actual name of the moved folder is. With the new endpoint, the command now returns the folder information about the destination folder, providing you with all the info you need to execute other commands on this folder. + +#### 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. + ### Removed `spo folder rename` alias The `spo folder rename` command was removed and replaced by the [spo folder set](./cmd/spo/folder/folder-set.mdx) command. diff --git a/src/m365/spo/commands/file/file-copy.spec.ts b/src/m365/spo/commands/file/file-copy.spec.ts index c9b10a0c4b6..254eecb9628 100644 --- a/src/m365/spo/commands/file/file-copy.spec.ts +++ b/src/m365/spo/commands/file/file-copy.spec.ts @@ -13,7 +13,7 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './file-copy.js'; import { settingsNames } from '../../../../settingsNames.js'; -import { CreateCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; +import { CreateFileCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; describe(commands.FILE_COPY, () => { const sourceWebUrl = 'https://contoso.sharepoint.com/sites/Sales'; @@ -94,7 +94,7 @@ describe(commands.FILE_COPY, () => { commandInfo = cli.getCommandInfo(command); sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName: string, defaultValue: any) => settingName === settingsNames.prompt ? false : defaultValue); - spoUtilCreateCopyJobStub = sinon.stub(spo, 'createCopyJob').resolves(copyJobInfo); + spoUtilCreateCopyJobStub = sinon.stub(spo, 'createFileCopyJob').resolves(copyJobInfo); }); beforeEach(() => { @@ -300,7 +300,7 @@ describe(commands.FILE_COPY, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Fail, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Fail, bypassSharedLock: false, ignoreVersionHistory: false, operation: 'copy', @@ -332,7 +332,7 @@ describe(commands.FILE_COPY, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Fail, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Fail, bypassSharedLock: false, ignoreVersionHistory: false, operation: 'copy', @@ -367,7 +367,7 @@ describe(commands.FILE_COPY, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Rename, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Rename, bypassSharedLock: true, ignoreVersionHistory: true, operation: 'copy', @@ -400,7 +400,7 @@ describe(commands.FILE_COPY, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Replace, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Replace, bypassSharedLock: false, ignoreVersionHistory: false, operation: 'copy', diff --git a/src/m365/spo/commands/file/file-copy.ts b/src/m365/spo/commands/file/file-copy.ts index ca73f20b733..4740014d378 100644 --- a/src/m365/spo/commands/file/file-copy.ts +++ b/src/m365/spo/commands/file/file-copy.ts @@ -1,7 +1,7 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; -import { CreateCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; +import { CreateFileCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -140,7 +140,7 @@ class SpoFileCopyCommand extends SpoCommand { newName += sourceServerRelativePath.substring(sourceServerRelativePath.lastIndexOf('.')); } - const copyJobResponse = await spo.createCopyJob( + const copyJobResponse = await spo.createFileCopyJob( args.options.webUrl, sourcePath, destinationPath, @@ -208,16 +208,16 @@ class SpoFileCopyCommand extends SpoCommand { return file.DecodedUrl; } - private getNameConflictBehaviorValue(nameConflictBehavior?: string): CreateCopyJobsNameConflictBehavior { + private getNameConflictBehaviorValue(nameConflictBehavior?: string): CreateFileCopyJobsNameConflictBehavior { switch (nameConflictBehavior?.toLowerCase()) { case 'fail': - return CreateCopyJobsNameConflictBehavior.Fail; + return CreateFileCopyJobsNameConflictBehavior.Fail; case 'replace': - return CreateCopyJobsNameConflictBehavior.Replace; + return CreateFileCopyJobsNameConflictBehavior.Replace; case 'rename': - return CreateCopyJobsNameConflictBehavior.Rename; + return CreateFileCopyJobsNameConflictBehavior.Rename; default: - return CreateCopyJobsNameConflictBehavior.Fail; + return CreateFileCopyJobsNameConflictBehavior.Fail; } } diff --git a/src/m365/spo/commands/file/file-move.spec.ts b/src/m365/spo/commands/file/file-move.spec.ts index 6a8bb6be373..5aef495324e 100644 --- a/src/m365/spo/commands/file/file-move.spec.ts +++ b/src/m365/spo/commands/file/file-move.spec.ts @@ -12,7 +12,7 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './file-move.js'; -import { CreateCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; +import { CreateFileCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.FILE_MOVE, () => { @@ -95,7 +95,7 @@ describe(commands.FILE_MOVE, () => { 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, 'createCopyJob').resolves(copyJobInfo); + spoUtilCreateCopyJobStub = sinon.stub(spo, 'createFileCopyJob').resolves(copyJobInfo); }); beforeEach(() => { @@ -296,7 +296,7 @@ describe(commands.FILE_MOVE, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Fail, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Fail, bypassSharedLock: false, includeItemPermissions: false, operation: 'move', @@ -328,7 +328,7 @@ describe(commands.FILE_MOVE, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Fail, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Fail, bypassSharedLock: false, includeItemPermissions: false, operation: 'move', @@ -360,7 +360,7 @@ describe(commands.FILE_MOVE, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Fail, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Fail, bypassSharedLock: false, includeItemPermissions: false, operation: 'move', @@ -392,7 +392,7 @@ describe(commands.FILE_MOVE, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Replace, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Replace, bypassSharedLock: false, includeItemPermissions: false, operation: 'move', @@ -427,7 +427,7 @@ describe(commands.FILE_MOVE, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Rename, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Rename, bypassSharedLock: true, includeItemPermissions: true, operation: 'move', @@ -460,7 +460,7 @@ describe(commands.FILE_MOVE, () => { sourceAbsoluteUrl, destAbsoluteTargetUrl, { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Replace, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Replace, bypassSharedLock: false, includeItemPermissions: false, operation: 'move', diff --git a/src/m365/spo/commands/file/file-move.ts b/src/m365/spo/commands/file/file-move.ts index ae001e31ad9..b3d85665ee1 100644 --- a/src/m365/spo/commands/file/file-move.ts +++ b/src/m365/spo/commands/file/file-move.ts @@ -1,7 +1,7 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; -import { CreateCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; +import { CreateFileCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; @@ -141,7 +141,7 @@ class SpoFileMoveCommand extends SpoCommand { newName += sourceServerRelativePath.substring(sourceServerRelativePath.lastIndexOf('.')); } - const copyJobResponse = await spo.createCopyJob( + const copyJobResponse = await spo.createFileCopyJob( args.options.webUrl, sourcePath, destinationPath, @@ -209,16 +209,16 @@ class SpoFileMoveCommand extends SpoCommand { return file.DecodedUrl; } - private getNameConflictBehaviorValue(nameConflictBehavior?: string): CreateCopyJobsNameConflictBehavior { + private getNameConflictBehaviorValue(nameConflictBehavior?: string): CreateFileCopyJobsNameConflictBehavior { switch (nameConflictBehavior?.toLowerCase()) { case 'fail': - return CreateCopyJobsNameConflictBehavior.Fail; + return CreateFileCopyJobsNameConflictBehavior.Fail; case 'replace': - return CreateCopyJobsNameConflictBehavior.Replace; + return CreateFileCopyJobsNameConflictBehavior.Replace; case 'rename': - return CreateCopyJobsNameConflictBehavior.Rename; + return CreateFileCopyJobsNameConflictBehavior.Rename; default: - return CreateCopyJobsNameConflictBehavior.Fail; + return CreateFileCopyJobsNameConflictBehavior.Fail; } } diff --git a/src/m365/spo/commands/folder/folder-move.spec.ts b/src/m365/spo/commands/folder/folder-move.spec.ts index fe9f9a24b5d..f4e8748d3ea 100644 --- a/src/m365/spo/commands/folder/folder-move.spec.ts +++ b/src/m365/spo/commands/folder/folder-move.spec.ts @@ -12,21 +12,68 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './folder-move.js'; +import { settingsNames } from '../../../../settingsNames.js'; +import { CreateFolderCopyJobsNameConflictBehavior, spo } from '../../../../utils/spo.js'; + +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_MOVE, () => { - 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'; - 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_MOVE, () => { 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_MOVE, () => { } }; - postStub = sinon.stub(request, 'post').callsFake(async opts => { - if (opts.url === `${webUrl}/_api/SP.MoveCopyUtil.MoveFolderByPath`) { - return { - 'odata.null': true - }; - } - - throw 'Invalid request: ' + opts.url; - }); + loggerLogSpy = sinon.spy(logger, 'log'); + spoUtilGetCopyJobResultStub.resetHistory(); }); afterEach(() => { @@ -88,181 +131,314 @@ describe(commands.FOLDER_MOVE, () => { }); it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { webUrl: 'invalid', sourceUrl: sourceUrl, targetUrl: targetUrl } }, commandInfo); + const actual = await command.validate({ options: { webUrl: 'invalid', 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('moves 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 moved 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: { + verbose: true, + webUrl: sourceWebUrl, + sourceId: sourceFolderId, + targetUrl: destAbsoluteTargetUrl + } + }); + + assert(loggerLogSpy.calledOnce); + }); + + it('correctly outputs result when folder is moved 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 moved 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 moves 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: webUrl, - sourceId: sourceId, - targetUrl: targetUrl, - verbose: true + webUrl: sourceWebUrl, + sourceId: sourceFolderId, + targetUrl: destAbsoluteTargetUrl } }); - 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, - RetainEditorAndModifiedOnMove: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + operation: 'move', + newName: undefined } - ); + ]); }); - it('moves a folder correctly when specifying sourceUrl with server relative paths', async () => { + it('correctly moves 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, - RetainEditorAndModifiedOnMove: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + operation: 'move', + newName: undefined } - ); + ]); }); - it('moves a folder correctly when specifying sourceUrl with site relative paths', async () => { + it('correctly moves 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, - RetainEditorAndModifiedOnMove: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + operation: 'move', + newName: undefined } - ); + ]); }); - it('moves a folder correctly when specifying sourceUrl with absolute paths', async () => { + it('correctly moves 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, - nameConflictBehavior: 'replace' + 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, - RetainEditorAndModifiedOnMove: false - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + operation: 'move', + newName: undefined } - ); + ]); }); - it('moves a folder correctly when specifying various options', async () => { + it('correctly moves 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: 'Old reports', + webUrl: sourceWebUrl, + sourceUrl: sourceServerRelUrl, + targetUrl: destAbsoluteTargetUrl, nameConflictBehavior: 'rename', - retainEditorAndModified: 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 + '/Old reports' - }, - options: { - KeepBoth: true, - ShouldBypassSharedLocks: true, - RetainEditorAndModifiedOnMove: true - } + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + operation: 'move', + 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 moving a folder', async () => { - const error = { + it('correctly handles error when sourceId does not exist', async () => { + sinon.stub(request, 'get').rejects({ error: { 'odata.error': { message: { @@ -271,16 +447,27 @@ describe(commands.FOLDER_MOVE, () => { } } } - }; + }); + + 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.')); }); }); diff --git a/src/m365/spo/commands/folder/folder-move.ts b/src/m365/spo/commands/folder/folder-move.ts index ec29e58a38a..0534e123d67 100644 --- a/src/m365/spo/commands/folder/folder-move.ts +++ b/src/m365/spo/commands/folder/folder-move.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; - retainEditorAndModified?: boolean; - bypassSharedLock?: boolean; + skipWait?: boolean; } class SpoFolderMoveCommand extends SpoCommand { @@ -49,8 +49,7 @@ class SpoFolderMoveCommand extends SpoCommand { sourceId: typeof args.options.sourceId !== 'undefined', newName: typeof args.options.newName !== 'undefined', nameConflictBehavior: typeof args.options.nameConflictBehavior !== 'undefined', - retainEditorAndModified: !!args.options.retainEditorAndModified, - bypassSharedLock: !!args.options.bypassSharedLock + skipWait: !!args.options.skipWait }); }); } @@ -77,10 +76,7 @@ class SpoFolderMoveCommand extends SpoCommand { autocomplete: this.nameConflictBehaviorOptions }, { - option: '--retainEditorAndModified' - }, - { - option: '--bypassSharedLock' + option: '--skipWait' } ); } @@ -112,7 +108,7 @@ class SpoFolderMoveCommand extends SpoCommand { #initTypes(): void { this.types.string.push('webUrl', 'sourceUrl', 'sourceId', 'targetUrl', 'newName', 'nameConflictBehavior'); - this.types.boolean.push('retainEditorAndModified', 'bypassSharedLock'); + this.types.boolean.push('skipWait'); } protected getExcludedOptionsWithUrls(): string[] | undefined { @@ -121,51 +117,70 @@ class SpoFolderMoveCommand 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(`Moving folder '${sourcePath}' to '${args.options.targetUrl}'...`); + await logger.logToStderr(`Moving 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: 'move' + } + ); - if (args.options.newName) { - absoluteTargetPath += args.options.newName; + if (args.options.skipWait) { + return; } - else { - // Keep the original file name - absoluteTargetPath += sourcePath.substring(sourcePath.lastIndexOf('/') + 1); + + if (this.verbose) { + await logger.logToStderr('Waiting for the move 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.MoveFolderByPath`, + 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, - RetainEditorAndModifiedOnMove: !!args.options.retainEditorAndModified - } - } + 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 SpoFolderMoveCommand 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); } } diff --git a/src/utils/spo.spec.ts b/src/utils/spo.spec.ts index 51604369902..37debe16ffb 100644 --- a/src/utils/spo.spec.ts +++ b/src/utils/spo.spec.ts @@ -7,10 +7,11 @@ import config from '../config.js'; import { RoleDefinition } from '../m365/spo/commands/roledefinition/RoleDefinition.js'; import request from '../request.js'; import { sinonUtil } from '../utils/sinonUtil.js'; -import { CreateCopyJobsNameConflictBehavior, FormDigestInfo, SpoOperation, spo, settings } from '../utils/spo.js'; +import { CreateFileCopyJobsNameConflictBehavior, FormDigestInfo, SpoOperation, spo, CreateFolderCopyJobsNameConflictBehavior } from '../utils/spo.js'; import { entraGroup } from './entraGroup.js'; import { formatting } from './formatting.js'; import { Group } from '@microsoft/microsoft-graph-types'; +import { timersUtil } from './timersUtil.js'; const stubPostResponses: any = ( folderAddResp: any = null @@ -96,7 +97,7 @@ describe('utils/spo', () => { before(() => { auth.connection.active = true; - sinon.stub(settings, 'pollingInterval').value(0); + sinon.stub(timersUtil, 'setTimeout').resolves(); }); beforeEach(() => { @@ -2680,7 +2681,7 @@ describe('utils/spo', () => { assert.deepEqual(group, fileResponse); }); - it('correctly outputs result when calling createCopyJob', async () => { + it('correctly outputs result when calling createFileCopyJob', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { return { @@ -2693,11 +2694,11 @@ describe('utils/spo', () => { throw 'Invalid request: ' + opts.url; }); - const result = await spo.createCopyJob('https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons/Company.png', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents'); + const result = await spo.createFileCopyJob('https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons/Company.png', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents'); assert.deepStrictEqual(result, copyJobInfo); }); - it('correctly creates a copy job with default options when using createCopyJob', async () => { + it('correctly creates a copy job with default options when using createFileCopyJob', async () => { const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { return { @@ -2710,12 +2711,12 @@ describe('utils/spo', () => { throw 'Invalid request: ' + opts.url; }); - await spo.createCopyJob('https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons/Company.png', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents'); + await spo.createFileCopyJob('https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons/Company.png', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents'); assert.deepStrictEqual(postStub.firstCall.args[0].data, { destinationUri: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', exportObjectUris: ['https://contoso.sharepoint.com/sites/sales/Icons/Company.png'], options: { - NameConflictBehavior: CreateCopyJobsNameConflictBehavior.Fail, + NameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Fail, AllowSchemaMismatch: true, BypassSharedLock: false, IgnoreVersionHistory: false, @@ -2727,7 +2728,7 @@ describe('utils/spo', () => { }); }); - it('correctly creates a copy job with custom options when using createCopyJob', async () => { + it('correctly creates a copy job with custom options when using createFileCopyJob', async () => { const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { return { @@ -2740,12 +2741,12 @@ describe('utils/spo', () => { throw 'Invalid request: ' + opts.url; }); - await spo.createCopyJob( + await spo.createFileCopyJob( 'https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons/Company.png', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Rename, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Rename, bypassSharedLock: true, ignoreVersionHistory: true, newName: 'CompanyV2.png', @@ -2756,7 +2757,7 @@ describe('utils/spo', () => { destinationUri: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', exportObjectUris: ['https://contoso.sharepoint.com/sites/sales/Icons/Company.png'], options: { - NameConflictBehavior: CreateCopyJobsNameConflictBehavior.Rename, + NameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Rename, AllowSchemaMismatch: true, BypassSharedLock: true, IgnoreVersionHistory: true, @@ -2768,7 +2769,7 @@ describe('utils/spo', () => { }); }); - it('correctly creates a copy job with custom move options when using createCopyJob', async () => { + it('correctly creates a copy job with custom move options when using createFileCopyJob', async () => { const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { return { @@ -2781,12 +2782,12 @@ describe('utils/spo', () => { throw 'Invalid request: ' + opts.url; }); - await spo.createCopyJob( + await spo.createFileCopyJob( 'https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons/Company.png', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', { - nameConflictBehavior: CreateCopyJobsNameConflictBehavior.Rename, + nameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Rename, bypassSharedLock: true, includeItemPermissions: true, newName: 'CompanyV2.png', @@ -2797,7 +2798,7 @@ describe('utils/spo', () => { destinationUri: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', exportObjectUris: ['https://contoso.sharepoint.com/sites/sales/Icons/Company.png'], options: { - NameConflictBehavior: CreateCopyJobsNameConflictBehavior.Rename, + NameConflictBehavior: CreateFileCopyJobsNameConflictBehavior.Rename, AllowSchemaMismatch: true, BypassSharedLock: true, IgnoreVersionHistory: false, @@ -2809,6 +2810,122 @@ describe('utils/spo', () => { }); }); + it('correctly outputs result when calling createFolderCopyJob', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { + return { + value: [ + copyJobInfo + ] + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const result = await spo.createFolderCopyJob('https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents'); + assert.deepStrictEqual(result, copyJobInfo); + }); + + it('correctly creates a copy job with default options when using createFolderCopyJob', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { + return { + value: [ + copyJobInfo + ] + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await spo.createFolderCopyJob('https://contoso.sharepoint.com/sites/sales', 'https://contoso.sharepoint.com/sites/sales/Icons', 'https://contoso.sharepoint.com/sites/marketing/Shared Documents'); + assert.deepStrictEqual(postStub.firstCall.args[0].data, { + destinationUri: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', + exportObjectUris: ['https://contoso.sharepoint.com/sites/sales/Icons'], + options: { + NameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Fail, + AllowSchemaMismatch: true, + CustomizedItemName: undefined, + IsMoveMode: false, + SameWebCopyMoveOptimization: true + } + }); + }); + + it('correctly creates a copy job with custom options when using createFolderCopyJob', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { + return { + value: [ + copyJobInfo + ] + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await spo.createFolderCopyJob( + 'https://contoso.sharepoint.com/sites/sales', + 'https://contoso.sharepoint.com/sites/sales/Icons', + 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', + { + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + newName: 'Company icons', + operation: 'copy' + } + ); + assert.deepStrictEqual(postStub.firstCall.args[0].data, { + destinationUri: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', + exportObjectUris: ['https://contoso.sharepoint.com/sites/sales/Icons'], + options: { + NameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + AllowSchemaMismatch: true, + IsMoveMode: false, + CustomizedItemName: ['Company icons'], + SameWebCopyMoveOptimization: true + } + }); + }); + + it('correctly creates a copy job with custom move options when using createFolderCopyJob', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/CreateCopyJobs') { + return { + value: [ + copyJobInfo + ] + }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await spo.createFolderCopyJob( + 'https://contoso.sharepoint.com/sites/sales', + 'https://contoso.sharepoint.com/sites/sales/Icons', + 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', + { + nameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + newName: 'Company icons', + operation: 'move' + } + ); + assert.deepStrictEqual(postStub.firstCall.args[0].data, { + destinationUri: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents', + exportObjectUris: ['https://contoso.sharepoint.com/sites/sales/Icons'], + options: { + NameConflictBehavior: CreateFolderCopyJobsNameConflictBehavior.Rename, + AllowSchemaMismatch: true, + IsMoveMode: true, + CustomizedItemName: ['Company icons'], + SameWebCopyMoveOptimization: true + } + }); + }); + it('correctly polls for copy job status when using getCopyJobResult', async () => { const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === 'https://contoso.sharepoint.com/sites/sales/_api/Site/GetCopyJobProgress') { @@ -2819,37 +2936,45 @@ describe('utils/spo', () => { }; } + if (postStub.callCount === 5) { + return { + JobState: 4, + Logs: [ + JSON.stringify({ + Event: 'JobStart', + JobId: 'fb4cc143-383c-4da0-bd91-02d2acbb01c7', + Time: '08/10/2024 16:30:39.004', + SiteId: '53dec431-9d4f-415b-b12b-010259d5b4e1', + WebId: 'af102f32-b389-49dc-89bf-d116a17e0aa6', + DBId: '5a926054-85d7-4cf6-85f0-c38fa01c4d39', + FarmId: '823af112-cd95-49a2-adf5-eccb09c8ba5d', + ServerId: 'a6145d7e-1b85-4124-895e-b1e618bfe5ae', + SubscriptionId: '18c58817-3bc9-489d-ac63-f7264fb357e5', + TotalRetryCount: '0', + MigrationType: 'Copy', + MigrationDirection: 'Import', + CorrelationId: 'd8f444a1-10a8-9000-862c-0bad6eff1006' + }), + JSON.stringify({ + Event: 'JobFinishedObjectInfo', + JobId: '6d1eda82-0d1c-41eb-ab05-1d9cd4afe786', + Time: '08/10/2024 18:59:40.145', + SourceObjectFullUrl: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents/Icons/Company.png', + TargetServerUrl: 'https://contoso.sharepoint.com', + TargetSiteId: '794dada8-4389-45ce-9559-0de74bf3554a', + TargetWebId: '8de9b4d3-3c30-4fd0-a9d7-2452bd065555', + TargetListId: '44b336a5-e397-4e22-a270-c39e9069b123', + TargetObjectUniqueId: '15488d89-b82b-40be-958a-922b2ed79383', + TargetObjectSiteRelativeUrl: 'Shared Documents/Icons/Company.png', + CorrelationId: '5efd44a1-c034-9000-9692-4e1a1b3ca33b' + }) + ] + }; + } + return { JobState: 0, Logs: [ - JSON.stringify({ - Event: 'JobStart', - JobId: 'fb4cc143-383c-4da0-bd91-02d2acbb01c7', - Time: '08/10/2024 16:30:39.004', - SiteId: '53dec431-9d4f-415b-b12b-010259d5b4e1', - WebId: 'af102f32-b389-49dc-89bf-d116a17e0aa6', - DBId: '5a926054-85d7-4cf6-85f0-c38fa01c4d39', - FarmId: '823af112-cd95-49a2-adf5-eccb09c8ba5d', - ServerId: 'a6145d7e-1b85-4124-895e-b1e618bfe5ae', - SubscriptionId: '18c58817-3bc9-489d-ac63-f7264fb357e5', - TotalRetryCount: '0', - MigrationType: 'Copy', - MigrationDirection: 'Import', - CorrelationId: 'd8f444a1-10a8-9000-862c-0bad6eff1006' - }), - JSON.stringify({ - Event: 'JobFinishedObjectInfo', - JobId: '6d1eda82-0d1c-41eb-ab05-1d9cd4afe786', - Time: '08/10/2024 18:59:40.145', - SourceObjectFullUrl: 'https://contoso.sharepoint.com/sites/marketing/Shared Documents/Icons/Company.png', - TargetServerUrl: 'https://contoso.sharepoint.com', - TargetSiteId: '794dada8-4389-45ce-9559-0de74bf3554a', - TargetWebId: '8de9b4d3-3c30-4fd0-a9d7-2452bd065555', - TargetListId: '44b336a5-e397-4e22-a270-c39e9069b123', - TargetObjectUniqueId: '15488d89-b82b-40be-958a-922b2ed79383', - TargetObjectSiteRelativeUrl: 'Shared Documents/Icons/Company.png', - CorrelationId: '5efd44a1-c034-9000-9692-4e1a1b3ca33b' - }), JSON.stringify({ Event: 'JobEnd', JobId: 'fb4cc143-383c-4da0-bd91-02d2acbb01c7', diff --git a/src/utils/spo.ts b/src/utils/spo.ts index 5950a65adff..2384c73b1dd 100644 --- a/src/utils/spo.ts +++ b/src/utils/spo.ts @@ -22,7 +22,7 @@ import { Group, Site } from '@microsoft/microsoft-graph-types'; import { ListItemInstance } from '../m365/spo/commands/listitem/ListItemInstance.js'; import { ListItemFieldValueResult } from '../m365/spo/commands/listitem/ListItemFieldValueResult.js'; import { FileProperties } from '../m365/spo/commands/file/FileProperties.js'; -import { setTimeout } from 'timers/promises'; +import { timersUtil } from './timersUtil.js'; export interface ContextInfo { FormDigestTimeoutSeconds: number; @@ -89,8 +89,8 @@ export interface User { UserPrincipalName: string | null; } -interface CreateCopyJobsOptions { - nameConflictBehavior?: CreateCopyJobsNameConflictBehavior; +interface CreateFileCopyJobsOptions { + nameConflictBehavior?: CreateFileCopyJobsNameConflictBehavior; newName?: string; bypassSharedLock?: boolean; /** @remarks Use only when using operation copy. */ @@ -100,12 +100,23 @@ interface CreateCopyJobsOptions { operation: 'copy' | 'move'; } -export enum CreateCopyJobsNameConflictBehavior { +interface CreateFolderCopyJobsOptions { + nameConflictBehavior?: CreateFolderCopyJobsNameConflictBehavior; + newName?: string; + operation: 'copy' | 'move'; +} + +export enum CreateFileCopyJobsNameConflictBehavior { Fail = 0, Replace = 1, Rename = 2, } +export enum CreateFolderCopyJobsNameConflictBehavior { + Fail = 0, + Rename = 2, +} + interface CreateCopyJobInfo { EncryptionKey: string; JobId: string; @@ -128,9 +139,7 @@ interface CopyJobObjectInfo { } // Wrapping this into a settings object so we can alter the values in tests -export const settings = { - pollingInterval: 3_000 -}; +const pollingInterval = 3_000; export const spo = { async getRequestDigest(siteUrl: string): Promise { @@ -198,7 +207,7 @@ export const spo = { return; } - await setTimeout(operation.PollingInterval); + await timersUtil.setTimeout(pollingInterval); await spo.waitUntilFinished({ operationId: JSON.stringify(operation._ObjectIdentity_), siteUrl, @@ -926,7 +935,7 @@ export const spo = { return; } - await setTimeout(operation.PollingInterval); + await timersUtil.setTimeout(pollingInterval); await spo.waitUntilFinished({ operationId: JSON.stringify(operation._ObjectIdentity_), siteUrl: spoAdminUrl, @@ -1173,7 +1182,7 @@ export const spo = { return; } - await setTimeout(operation.PollingInterval); + await timersUtil.setTimeout(pollingInterval); await spo.waitUntilFinished({ operationId: JSON.stringify(operation._ObjectIdentity_), siteUrl: spoAdminUrl, @@ -1345,7 +1354,7 @@ export const spo = { const operation: SpoOperation = json[json.length - 1]; const isComplete: boolean = operation.IsComplete; if (!isComplete) { - await setTimeout(operation.PollingInterval); + await timersUtil.setTimeout(pollingInterval); await spo.waitUntilFinished({ operationId: JSON.stringify(operation._ObjectIdentity_), siteUrl: spoAdminUrl, @@ -1935,14 +1944,14 @@ export const spo = { }, /** - * Create a SharePoint copy job to copy a file/folder to another location. - * @param webUrl Absolute web URL where the source file/folder is located. - * @param sourceUrl Absolute URL of the source file/folder. + * Create a SharePoint copy job to copy a file to another location. + * @param webUrl Absolute web URL where the source file is located. + * @param sourceUrl Absolute URL of the source file. * @param destinationUrl Absolute URL of the destination folder. * @param options Options for the copy job. * @returns Copy job information. Use {@link spo.getCopyJobResult} to get the result of the copy job. */ - async createCopyJob(webUrl: string, sourceUrl: string, destinationUrl: string, options?: CreateCopyJobsOptions): Promise { + async createFileCopyJob(webUrl: string, sourceUrl: string, destinationUrl: string, options?: CreateFileCopyJobsOptions): Promise { const requestOptions: CliRequestOptions = { url: `${webUrl}/_api/Site/CreateCopyJobs`, headers: { @@ -1953,7 +1962,7 @@ export const spo = { destinationUri: destinationUrl, exportObjectUris: [sourceUrl], options: { - NameConflictBehavior: options?.nameConflictBehavior ?? CreateCopyJobsNameConflictBehavior.Fail, + NameConflictBehavior: options?.nameConflictBehavior ?? CreateFileCopyJobsNameConflictBehavior.Fail, AllowSchemaMismatch: true, BypassSharedLock: !!options?.bypassSharedLock, IgnoreVersionHistory: !!options?.ignoreVersionHistory, @@ -1969,6 +1978,38 @@ export const spo = { return response.value[0]; }, + /** + * Create a SharePoint copy job to copy a folder to another location. + * @param webUrl Absolute web URL where the source folder is located. + * @param sourceUrl Absolute URL of the source folder. + * @param destinationUrl Absolute URL of the destination folder. + * @param options Options for the copy job. + * @returns Copy job information. Use {@link spo.getCopyJobResult} to get the result of the copy job. + */ + async createFolderCopyJob(webUrl: string, sourceUrl: string, destinationUrl: string, options?: CreateFolderCopyJobsOptions): Promise { + const requestOptions: CliRequestOptions = { + url: `${webUrl}/_api/Site/CreateCopyJobs`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json', + data: { + destinationUri: destinationUrl, + exportObjectUris: [sourceUrl], + options: { + NameConflictBehavior: options?.nameConflictBehavior ?? CreateFolderCopyJobsNameConflictBehavior.Fail, + AllowSchemaMismatch: true, + CustomizedItemName: options?.newName ? [options.newName] : undefined, + SameWebCopyMoveOptimization: true, + IsMoveMode: options?.operation === 'move' + } + } + }; + + const response = await request.post<{ value: CreateCopyJobInfo[] }>(requestOptions); + return response.value[0]; + }, + /** * Poll until the copy job is finished and return the result. * @param webUrl Absolute web URL where the copy job was created. @@ -1987,14 +2028,23 @@ export const spo = { copyJobInfo: copyJobInfo } }; + + const logs = []; let progress = await request.post<{ JobState: number; Logs: string[] }>(requestOptions); + const newLogs = progress.Logs?.map(l => JSON.parse(l)); + if (newLogs?.length > 0) { + logs.push(...newLogs); + } while (progress.JobState !== 0) { - await setTimeout(settings.pollingInterval); + await timersUtil.setTimeout(pollingInterval); progress = await request.post<{ JobState: number; Logs: string[] }>(requestOptions); - } - const logs = progress.Logs.map(l => JSON.parse(l)); + const newLogs = progress.Logs?.map(l => JSON.parse(l)); + if (newLogs?.length > 0) { + logs.push(...newLogs); + } + } // Check if the job has failed const errorLog = logs.find(l => l.Event === 'JobError'); diff --git a/src/utils/timersUtil.ts b/src/utils/timersUtil.ts index eb7c9593f44..da320272e1a 100644 --- a/src/utils/timersUtil.ts +++ b/src/utils/timersUtil.ts @@ -5,7 +5,8 @@ export const timersUtil = { * Timeout for a specific duration. * @param duration Duration in milliseconds. */ - /* c8 ignore next 3 */ + /* c8 ignore next 4 */ + // Function is created so we can easily mock it in our tests async setTimeout(duration: number): Promise { return setTimeout(duration); }