diff --git a/docs/docs/cmd/spp/model/model-remove.mdx b/docs/docs/cmd/spp/model/model-remove.mdx new file mode 100644 index 00000000000..71fe9562857 --- /dev/null +++ b/docs/docs/cmd/spp/model/model-remove.mdx @@ -0,0 +1,57 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spp model remove + +Deletes a document understanding model + +## Usage + +```sh +m365 spp model remove [options] +``` + +## Options + +```md definition-list +`-u, --siteUrl ` +: The URL of the content center site. + +`-i, --id [id]` +: The unique ID of the model. Specify either `id` or `title` but not both. + +`-t, --title [title]` +: The display name of the model. Specify either `id` or `title` but not both. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Remarks + +:::note + +The model must be removed from all libraries before it can be deleted. + +::: + +## Examples + +Delete a SharePoint Premium document understanding model using the model’s UniqueId. + +```sh +m365 spp model remove --siteUrl "https://contoso.sharepoint.com/sites/ContentCenter" --id "7645e69d-21fb-4a24-a17a-9bdfa7cb63dc" +``` + +Delete a SharePoint Premium document understanding model using the model’s title. + +```sh +m365 spp model remove --siteUrl "https://contoso.sharepoint.com/sites/ContentCenter" --title "climicrosoft365Model.classifier" +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index dd119e7f527..114c5646ad9 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3993,6 +3993,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'model list', id: 'cmd/spp/model/model-list' + }, + { + type: 'doc', + label: 'model remove', + id: 'cmd/spp/model/model-remove' } ] } diff --git a/src/m365/spp/commands.ts b/src/m365/spp/commands.ts index 97f9c21b5bb..a657043c662 100644 --- a/src/m365/spp/commands.ts +++ b/src/m365/spp/commands.ts @@ -2,5 +2,6 @@ const prefix: string = 'spp'; export default { CONTENTCENTER_LIST: `${prefix} contentcenter list`, - MODEL_LIST: `${prefix} model list` + MODEL_LIST: `${prefix} model list`, + MODEL_REMOVE: `${prefix} model remove` }; \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-remove.spec.ts b/src/m365/spp/commands/model/model-remove.spec.ts new file mode 100644 index 00000000000..aed7f099279 --- /dev/null +++ b/src/m365/spp/commands/model/model-remove.spec.ts @@ -0,0 +1,202 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './model-remove.js'; +import { spp } from '../../../../utils/spp.js'; + +describe(commands.MODEL_REMOVE, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(spp, 'assertSiteIsContentCenter').resolves(); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MODEL_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation when required parameters are valid with id', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with title', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with id and force', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation when siteUrl is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'invalidUrl', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when id is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('correctly handles an error when the model id is not found', async () => { + sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + throw { + error: { + "odata.error": { + code: "-1, Microsoft.Office.Server.ContentCenter.ModelNotFoundException", + message: { + lang: "en-US", + value: "File Not Found." + } + } + } + }; + } + }); + + await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }), + new CommandError('File Not Found.')); + }); + + it('correctly handles an error when the model title is not found', async () => { + sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbytitle('modeltitle.classifier')`) { + return { + "odata.null": true + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'modelTitle.classifier', force: true } }), + new CommandError('Model not found.')); + }); + + it('is the confirmation prompt called with id information', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + assert(confirmationStub.args[0][0].message.startsWith(`Are you sure you want to remove model '9b1b1e42-794b-4c71-93ac-5ed92488b67f'?`)); + }); + + it('is the confirmation prompt called with title information', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'modelTitle' } }); + assert(confirmationStub.args[0][0].message.startsWith(`Are you sure you want to remove model 'modelTitle'?`)); + }); + + it('deletes model by id', async () => { + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }); + assert(stubDelete.calledOnce); + }); + + it('does not delete model when confirmation is not accepted', async () => { + sinon.stub(cli, 'promptForConfirmation').resolves(false); + + const stubDelete = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + assert(stubDelete.notCalled); + }); + + it('deletes model when the the site URL has trailing slash', async () => { + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal/', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }); + assert(stubDelete.calledOnce); + }); + + it('deletes model by title', async () => { + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbytitle('modelname.classifier')`) { + return ''; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName', force: true } }); + assert(stubDelete.calledOnce); + }); + + it('deletes model by title with .classifier suffic', async () => { + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbytitle('modelname.classifier')`) { + return ''; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName.classifier', force: true } }); + assert(stubDelete.calledOnce); + }); +}); \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-remove.ts b/src/m365/spp/commands/model/model-remove.ts new file mode 100644 index 00000000000..4855d3ef42a --- /dev/null +++ b/src/m365/spp/commands/model/model-remove.ts @@ -0,0 +1,141 @@ +import { cli } from '../../../../cli/cli.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { spp } from '../../../../utils/spp.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + siteUrl: string; + id?: string; + title?: string; + force?: boolean; +} + +class SppModelRemoveCommand extends SpoCommand { + public get name(): string { + return commands.MODEL_REMOVE; + } + + public get description(): string { + return 'Deletes a document understanding model'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + id: typeof args.options.id !== 'undefined', + title: typeof args.options.title !== 'undefined', + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --siteUrl ' + }, + { + option: '-i, --id [id]' + }, + { + option: '-t, --title [title]' + }, + { + option: '-f --force' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.id && !validation.isValidGuid(args.options.id)) { + return `${args.options.id} is not a valid GUID for option 'id'.`; + } + + return validation.isValidSharePointUrl(args.options.siteUrl); + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['id', 'title'] }); + } + + #initTypes(): void { + this.types.string.push('siteUrl', 'id', 'title'); + this.types.boolean.push('force'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (!args.options.force) { + const confirmationResult = await cli.promptForConfirmation({ message: `Are you sure you want to remove model '${args.options.title ? args.options.title : args.options.id}'?` }); + + if (!confirmationResult) { + return; + } + } + + if (this.verbose) { + await logger.log(`Removing model from ${args.options.siteUrl}...`); + } + + const siteUrl = urlUtil.removeTrailingSlashes(args.options.siteUrl); + await spp.assertSiteIsContentCenter(siteUrl); + let requestUrl = `${siteUrl}/_api/machinelearning/models/`; + + if (args.options.title) { + let requestTitle = args.options.title.toLowerCase(); + + if (!requestTitle.endsWith('.classifier')) { + requestTitle += '.classifier'; + } + + requestUrl += `getbytitle('${formatting.encodeQueryParameter(requestTitle)}')`; + } + else { + requestUrl += `getbyuniqueid('${args.options.id}')`; + } + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata=nometadata', + 'if-match': '*' + }, + responseType: 'json' + }; + + const result = await request.delete(requestOptions); + if (result?.['odata.null'] === true) { + throw "Model not found."; + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SppModelRemoveCommand(); \ No newline at end of file diff --git a/src/utils/spp.ts b/src/utils/spp.ts index 83544d92284..0b6dff3454b 100644 --- a/src/utils/spp.ts +++ b/src/utils/spp.ts @@ -1,10 +1,15 @@ import request, { CliRequestOptions } from '../request.js'; +export interface SppModel { + UniqueId: string; + Publications?: any[]; +} + export const spp = { /** - * Asserts whether the specified site is a content center. - * @param siteUrl The URL of the site to check. - * @throws Error when the site is not a content center. + * Asserts whether the specified site is a content center + * @param siteUrl The URL of the site to check + * @throws error when site is not a content center. */ async assertSiteIsContentCenter(siteUrl: string): Promise { const requestOptions: CliRequestOptions = {