Skip to content

Commit 62080a9

Browse files
committed
New command: spo file version keep. Closes #6774
1 parent c2217b6 commit 62080a9

File tree

5 files changed

+347
-0
lines changed

5 files changed

+347
-0
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Global from '/docs/cmd/_global.mdx';
2+
3+
# spo file version keep
4+
5+
Ensure that a specific file version will never expire.
6+
7+
## Usage
8+
9+
```sh
10+
m365 spo file version keep [options]
11+
```
12+
13+
## Options
14+
15+
```md definition-list
16+
`-u, --webUrl <webUrl>`
17+
: The URL of the site where the file is located.
18+
19+
`--fileUrl [fileUrl]`
20+
: The server- or site-relative decoded URL. Specify either `fileUrl` or `fileId` but not both.
21+
22+
`-i, --fileId [fileId]`
23+
: The UniqueId (GUID) of the file. Specify either `fileUrl` or `fileId` but not both.
24+
25+
`--label <label>`
26+
: Label of the version.
27+
```
28+
29+
<Global />
30+
31+
## Examples
32+
33+
Mark a file version as never expiring by webUrl and fileUrl.
34+
35+
```sh
36+
m365 spo file version keep --webUrl "https://contoso.sharepoint.com/sites/marketing" --fileUrl "/sites/marketing/Documents/report.docx" --label "6.0"
37+
```
38+
39+
Mark a file version as never expiring by webUrl and fileId.
40+
41+
```sh
42+
m365 spo file version keep --webUrl "https://contoso.sharepoint.com/sites/marketing" --fileId "12345678-90ab-cdef-1234-567890abcdef" --label "6.0"
43+
```
44+
45+
## Response
46+
47+
The command won't return a response on success.

docs/src/config/sidebars.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2758,6 +2758,11 @@ const sidebars: SidebarsConfig = {
27582758
label: 'file version get',
27592759
id: 'cmd/spo/file/file-version-get'
27602760
},
2761+
{
2762+
type: 'doc',
2763+
label: 'file version keep',
2764+
id: 'cmd/spo/file/file-version-keep'
2765+
},
27612766
{
27622767
type: 'doc',
27632768
label: 'file version list',

src/m365/spo/commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export default {
8484
FILE_SHARINGLINK_SET: `${prefix} file sharinglink set`,
8585
FILE_VERSION_CLEAR: `${prefix} file version clear`,
8686
FILE_VERSION_GET: `${prefix} file version get`,
87+
FILE_VERSION_KEEP: `${prefix} file version keep`,
8788
FILE_VERSION_LIST: `${prefix} file version list`,
8889
FILE_VERSION_REMOVE: `${prefix} file version remove`,
8990
FILE_VERSION_RESTORE: `${prefix} file version restore`,
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import assert from 'assert';
2+
import sinon from 'sinon';
3+
import auth from '../../../../Auth.js';
4+
import { cli } from '../../../../cli/cli.js';
5+
import { CommandInfo } from '../../../../cli/CommandInfo.js';
6+
import { Logger } from '../../../../cli/Logger.js';
7+
import { CommandError } from '../../../../Command.js';
8+
import request from '../../../../request.js';
9+
import { telemetry } from '../../../../telemetry.js';
10+
import { formatting } from '../../../../utils/formatting.js';
11+
import { pid } from '../../../../utils/pid.js';
12+
import { session } from '../../../../utils/session.js';
13+
import { sinonUtil } from '../../../../utils/sinonUtil.js';
14+
import { z } from 'zod';
15+
import commands from '../../commands.js';
16+
import command from './file-version-keep.js';
17+
18+
describe(commands.FILE_VERSION_KEEP, () => {
19+
let log: any[];
20+
let logger: Logger;
21+
let commandInfo: CommandInfo;
22+
let commandOptionsSchema: z.ZodTypeAny;
23+
const validWebUrl = "https://contoso.sharepoint.com";
24+
const validFileUrl = "/Shared Documents/Document.docx";
25+
const validFileId = "7a9b8bb6-d5c4-4de9-ab76-5210a7879e89";
26+
const validLabel = "1.0";
27+
28+
before(() => {
29+
sinon.stub(auth, 'restoreAuth').resolves();
30+
sinon.stub(telemetry, 'trackEvent').resolves();
31+
sinon.stub(pid, 'getProcessName').returns('');
32+
sinon.stub(session, 'getId').returns('');
33+
commandInfo = cli.getCommandInfo(command);
34+
commandOptionsSchema = commandInfo.command.getSchemaToParse()!;
35+
auth.connection.active = true;
36+
});
37+
38+
beforeEach(() => {
39+
log = [];
40+
logger = {
41+
log: async (msg: string) => {
42+
log.push(msg);
43+
},
44+
logRaw: async (msg: string) => {
45+
log.push(msg);
46+
},
47+
logToStderr: async (msg: string) => {
48+
log.push(msg);
49+
}
50+
};
51+
});
52+
53+
afterEach(() => {
54+
sinonUtil.restore([
55+
request.get,
56+
request.post
57+
]);
58+
});
59+
60+
after(() => {
61+
sinon.restore();
62+
auth.connection.active = false;
63+
});
64+
65+
it('has correct name', () => {
66+
assert.strictEqual(command.name, commands.FILE_VERSION_KEEP);
67+
});
68+
69+
it('has a description', () => {
70+
assert.notStrictEqual(command.description, null);
71+
});
72+
73+
it('fails validation if webUrl is not a valid URL', async () => {
74+
const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', label: validLabel, fileUrl: validFileUrl });
75+
assert.strictEqual(actual.success, false);
76+
});
77+
78+
it('fails validation if fileId is not a valid GUID', async () => {
79+
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, fileId: 'invalid' });
80+
assert.strictEqual(actual.success, false);
81+
});
82+
83+
it('fails validation if fileUrl and fileId are specified', async () => {
84+
const actual = commandOptionsSchema.safeParse({ webUrl: validWebUrl, fileUrl: validFileUrl, fileId: validFileId });
85+
assert.strictEqual(actual.success, false);
86+
});
87+
88+
it('passes validation if fileUrl is specified', async () => {
89+
const actual = await command.validate({ options: { webUrl: validWebUrl, label: validLabel, fileUrl: validFileUrl } }, commandInfo);
90+
assert.strictEqual(actual, true);
91+
});
92+
93+
it('passes validation if fileId is specified', async () => {
94+
const actual = await command.validate({ options: { webUrl: validWebUrl, label: validLabel, fileId: validFileId } }, commandInfo);
95+
assert.strictEqual(actual, true);
96+
});
97+
98+
it('ensures that a specific file version will never expire (fileUrl)', async () => {
99+
sinon.stub(request, 'get').callsFake(async (opts) => {
100+
if (opts.url === `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions/?$filter=VersionLabel eq '${validLabel}'`) {
101+
return {
102+
value: [
103+
{
104+
ID: 1,
105+
VersionLabel: validLabel
106+
}
107+
]
108+
};
109+
}
110+
111+
throw 'Invalid request';
112+
});
113+
114+
sinon.stub(request, 'post').callsFake(async (opts) => {
115+
if (opts.url === `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions(1)/SetExpirationDate()`) {
116+
return;
117+
}
118+
119+
throw 'Invalid request';
120+
});
121+
122+
await command.action(logger, { options: { webUrl: validWebUrl, fileUrl: validFileUrl, label: validLabel, verbose: true } });
123+
});
124+
125+
it('ensures that a specific file version will never expire (fileId)', async () => {
126+
sinon.stub(request, 'get').callsFake(async (opts) => {
127+
if (opts.url === `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions/?$filter=VersionLabel eq '${validLabel}'`) {
128+
return {
129+
value: [
130+
{
131+
ID: 1,
132+
VersionLabel: validLabel
133+
}
134+
]
135+
};
136+
}
137+
138+
throw 'Invalid request';
139+
});
140+
141+
sinon.stub(request, 'post').callsFake(async (opts) => {
142+
if (opts.url === `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions(1)/SetExpirationDate()`) {
143+
return;
144+
}
145+
146+
throw 'Invalid request';
147+
});
148+
149+
await command.action(logger, { options: { webUrl: validWebUrl, fileId: validFileId, label: validLabel, verbose: true } });
150+
});
151+
152+
it('correctly handles error when the specified version does not exist', async () => {
153+
sinon.stub(request, 'get').callsFake(async (opts) => {
154+
if (opts.url === `${validWebUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(validFileUrl)}')/versions/?$filter=VersionLabel eq '${validLabel}'`) {
155+
return { value: [] };
156+
}
157+
158+
throw 'Invalid request';
159+
});
160+
await assert.rejects(command.action(logger, { options: { webUrl: validWebUrl, fileUrl: validFileUrl, label: validLabel } }),
161+
new CommandError(`Version with label '${validLabel}' not found.`));
162+
});
163+
164+
it('correctly handles API OData error', async () => {
165+
sinon.stub(request, 'get').callsFake(async (opts) => {
166+
if (opts.url === `${validWebUrl}/_api/web/GetFileById('${validFileId}')/versions/?$filter=VersionLabel eq '${validLabel}'`) {
167+
throw {
168+
error: {
169+
'odata.error': {
170+
code: '-1, Microsoft.SharePoint.Client.InvalidOperationException',
171+
message: {
172+
value: 'Invalid version request'
173+
}
174+
}
175+
}
176+
};
177+
}
178+
179+
throw 'Invalid request';
180+
});
181+
182+
await assert.rejects(command.action(logger, { options: { webUrl: validWebUrl, fileId: validFileId, label: validLabel } }),
183+
new CommandError('Invalid version request'));
184+
});
185+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import commands from '../../commands.js';
2+
import { Logger } from '../../../../cli/Logger.js';
3+
import SpoCommand from '../../../base/SpoCommand.js';
4+
import { globalOptionsZod } from '../../../../Command.js';
5+
import { z } from 'zod';
6+
import { zod } from '../../../../utils/zod.js';
7+
import { validation } from '../../../../utils/validation.js';
8+
import { urlUtil } from '../../../../utils/urlUtil.js';
9+
import request, { CliRequestOptions } from '../../../../request.js';
10+
import { formatting } from '../../../../utils/formatting.js';
11+
12+
export const options = globalOptionsZod
13+
.extend({
14+
webUrl: zod.alias('u', z.string()
15+
.refine(url => validation.isValidSharePointUrl(url) === true, url => ({
16+
message: `'${url}' is not a valid SharePoint Online site URL.`
17+
}))
18+
),
19+
fileUrl: z.string().optional(),
20+
fileId: zod.alias('i', z.string().optional()
21+
.refine(id => id === undefined || validation.isValidGuid(id), id => ({
22+
message: `'${id}' is not a valid GUID.`
23+
}))
24+
),
25+
label: z.string()
26+
})
27+
.strict();
28+
29+
declare type Options = z.infer<typeof options>;
30+
31+
interface CommandArgs {
32+
options: Options;
33+
}
34+
35+
class SpoFileVersionKeepCommand extends SpoCommand {
36+
public get name(): string {
37+
return commands.FILE_VERSION_KEEP;
38+
}
39+
40+
public get description(): string {
41+
return 'Ensure that a specific file version will never expire';
42+
}
43+
44+
public get schema(): z.ZodTypeAny | undefined {
45+
return options;
46+
}
47+
48+
public getRefinedSchema(schema: z.ZodTypeAny): z.ZodEffects<any> | undefined {
49+
return schema
50+
.refine(options => (options.fileUrl !== undefined) !== (options.fileId !== undefined), {
51+
message: `Specify 'fileUrl' or 'fileId', but not both.`
52+
});
53+
}
54+
55+
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
56+
if (this.verbose) {
57+
await logger.logToStderr(`Ensuring version '${args.options.label}' of file '${args.options.fileUrl || args.options.fileId}' at site '${args.options.webUrl}' will never expire...`);
58+
}
59+
60+
try {
61+
const fileUrl = this.getFileUrl(args.options.webUrl, args.options.fileUrl, args.options.fileId);
62+
63+
const requestVersionOptions: CliRequestOptions = {
64+
url: `${fileUrl}/versions/?$filter=VersionLabel eq '${args.options.label}'`,
65+
headers: {
66+
'accept': 'application/json;odata=nometadata'
67+
},
68+
responseType: 'json'
69+
};
70+
71+
const response = await request.get<{ value: { ID: number }[] }>(requestVersionOptions);
72+
const version: { ID: number; } | undefined = response.value[0];
73+
74+
if (version === undefined) {
75+
throw `Version with label '${args.options.label}' not found.`;
76+
}
77+
78+
const requestExpirationOptions: CliRequestOptions = {
79+
url: `${fileUrl}/versions(${version.ID})/SetExpirationDate()`,
80+
headers: {
81+
'accept': 'application/json;odata=nometadata',
82+
'content-type': 'application/json'
83+
},
84+
responseType: 'json'
85+
};
86+
87+
await request.post(requestExpirationOptions);
88+
}
89+
catch (err: any) {
90+
this.handleRejectedODataJsonPromise(err);
91+
}
92+
}
93+
94+
private getFileUrl(webUrl: string, fileUrl?: string, fileId?: string): string {
95+
let requestUrl: string;
96+
97+
if (fileUrl) {
98+
const serverRelUrl = urlUtil.getServerRelativePath(webUrl, fileUrl);
99+
requestUrl = `${webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(serverRelUrl)}')`;
100+
}
101+
else {
102+
requestUrl = `${webUrl}/_api/web/GetFileById('${fileId}')`;
103+
}
104+
105+
return requestUrl;
106+
}
107+
}
108+
109+
export default new SpoFileVersionKeepCommand();

0 commit comments

Comments
 (0)