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