From d7d8e52b7d51a763eec1ae901abe69771bbfce55 Mon Sep 17 00:00:00 2001 From: mkm17 Date: Sun, 6 Oct 2024 13:47:06 +0200 Subject: [PATCH] Adds `spp model apply` command. Closes #6119 --- docs/docs/cmd/spp/model/model-apply.mdx | 73 +++ docs/src/config/sidebars.ts | 5 + src/m365/spp/commands.ts | 1 + .../spp/commands/model/model-apply.spec.ts | 530 ++++++++++++++++++ src/m365/spp/commands/model/model-apply.ts | 239 ++++++++ 5 files changed, 848 insertions(+) create mode 100644 docs/docs/cmd/spp/model/model-apply.mdx create mode 100644 src/m365/spp/commands/model/model-apply.spec.ts create mode 100644 src/m365/spp/commands/model/model-apply.ts diff --git a/docs/docs/cmd/spp/model/model-apply.mdx b/docs/docs/cmd/spp/model/model-apply.mdx new file mode 100644 index 00000000000..f14fb5cb063 --- /dev/null +++ b/docs/docs/cmd/spp/model/model-apply.mdx @@ -0,0 +1,73 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spp model apply + +Applies (or syncs) a trained document understanding model to a document library + +## Usage + +```sh +m365 spp model apply [options] +``` + +## Options + +```md definition-list +`-u, --siteUrl ` +: The URL of the site where the library is located. + +`--contentCenterUrl ` +: The URL of the content center site where model is located. + +`-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. + +`--listTitle [listTitle]` +: The title of the document library to which the model will be applied. Specify either `listTitle`, `listId`, or `listUrl` but not multiple. + +`--listId [listId]` +: The ID of the library to which the model will be applied. Specify either `listTitle`, `listId`, or `listUrl` but not multiple. + +`--listUrl [listUrl]` +: Server or web-relative URL of the library to which the model will be applied. Specify either `listTitle`, `listId`, or `listUrl` but not multiple. + +`--viewOption [viewOption]` +: Defines whether the model view should be set as the default view for the document library. Allowed values are: `NewViewAsDefault`, `DoNotChangeDefault`, `TileViewAsDefault`. +``` + + + +## Examples + +Applies a trained document understanding model using its unique ID to a document library, identified by its title. + +```sh +m365 spp model apply --siteUrl "https://contoso.sharepoint.com" --contentCenterUrl "https://contoso.sharepoint.com/sites/ContentCenter" --id "7645e69d-21fb-4a24-a17a-9bdfa7cb63dc" --listTitle "Shared Documents" +``` + +Applies a trained document understanding model using its display name to a document library, identified by its title. + +```sh +m365 spp model apply --siteUrl "https://contoso.sharepoint.com" --contentCenterUrl "https://contoso.sharepoint.com/sites/ContentCenter" --title "ModelExample" --listTitle "Shared Documents" +``` + +Applies a trained document understanding model using its display name to a document library, identified by its URL. + +```sh +m365 spp model apply --siteUrl "https://contoso.sharepoint.com" --contentCenterUrl "https://contoso.sharepoint.com/sites/ContentCenter" --title "ModelExample" --listUrl "/Shared Documents" +``` + +Applies a trained document understanding model using its display name to a document library, identified by its unique ID. + +```sh +m365 spp model apply --siteUrl "https://contoso.sharepoint.com" --contentCenterUrl "https://contoso.sharepoint.com/sites/ContentCenter" --title "ModelExample" --listId "b4cfa0d9-b3d7-49ae-a0f0-f14ffdd005f7" +``` + +## 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 78006f19988..51896ce3c45 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -4013,6 +4013,11 @@ const sidebars: SidebarsConfig = { }, { model: [ + { + type: 'doc', + label: 'model apply', + id: 'cmd/spp/model/model-apply' + }, { type: 'doc', label: 'model list', diff --git a/src/m365/spp/commands.ts b/src/m365/spp/commands.ts index a657043c662..f5aae263a49 100644 --- a/src/m365/spp/commands.ts +++ b/src/m365/spp/commands.ts @@ -2,6 +2,7 @@ const prefix: string = 'spp'; export default { CONTENTCENTER_LIST: `${prefix} contentcenter list`, + MODEL_APPLY: `${prefix} model apply`, 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-apply.spec.ts b/src/m365/spp/commands/model/model-apply.spec.ts new file mode 100644 index 00000000000..b9391d1472d --- /dev/null +++ b/src/m365/spp/commands/model/model-apply.spec.ts @@ -0,0 +1,530 @@ +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 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-apply.js'; +import { spp } from '../../../../utils/spp.js'; +import { CommandError } from '../../../../Command.js'; + +describe(commands.MODEL_APPLY, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + const listResponse = { + RootFolder: { + ServerRelativeUrl: '/sites/portal/Shared Documents' + }, + BaseType: 1 + }; + const modelResponse = { + UniqueId: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' + }; + + 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.get, + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MODEL_APPLY); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation when required parameters are valid with model id and list id', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with model title and list id', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with model id and list title', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listTitle: 'Documents' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with model id and list URL', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listUrl: '/Shared Documents' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with model title and list id and correct viewOption is provided', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'NewViewAsDefault' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation when siteUrl is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'invalidUrl', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'NewViewAsDefault' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when contentCenterUrl is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'invalidUrl', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'NewViewAsDefault' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when model id is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: 'invalidId', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'NewViewAsDefault' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when list id is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: 'invalidId', viewOption: 'NewViewAsDefault' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when viewOption is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'InvalidViewOption' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('apply model to document library by id and list id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', verbose: true } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "NewViewAsDefault" + } + ] + } + }); + }); + + it('apply model to document library by id and list title', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists/getByTitle('Documents')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listTitle: 'Documents' } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "NewViewAsDefault" + } + ] + } + }); + }); + + it('apply model to document library by title and list id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbytitle('modeltitle.classifier')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d' } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "NewViewAsDefault" + } + ] + } + }); + }); + + it('apply model to document library by id and list url', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/GetList('%2Fsites%2Fsales%2FShared%20Documents')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listUrl: '/Shared Documents' } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "NewViewAsDefault" + } + ] + } + }); + }); + + it('apply model to document library by id and list id and DoNotChangeDefault viewOption', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'DoNotChangeDefault' } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "DoNotChangeDefault" + } + ] + } + }); + }); + + it('apply model to document library by id and list id and TileViewAsDefault viewOption', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'TileViewAsDefault' } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "TileViewAsDefault" + } + ] + } + }); + }); + + it('apply model to document library by title with classifier suffix and by list id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbytitle('modeltitle.classifier')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + const stubPost = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return; + } + + throw `${opts.url} is invalid request`; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle.classifier', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', viewOption: 'DoNotChangeDefault' } }); + assert.deepStrictEqual(stubPost.lastCall.args[0].data, { + __metadata: { + type: "Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData" + }, + Publications: { + results: [ + { + ModelUniqueId: "9b1b1e42-794b-4c71-93ac-5ed92488b67f", + TargetSiteUrl: "https://contoso.sharepoint.com/sites/sales", + TargetLibraryServerRelativeUrl: "/sites/portal/Shared Documents", + TargetWebServerRelativeUrl: "/sites/sales", + ViewOption: "DoNotChangeDefault" + } + ] + } + }); + }); + + it('correctly handles error when list is not found', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + throw { + error: { + "odata.error": { + code: "-1, Microsoft.SharePoint.Client.ResourceNotFoundException", + message: { + lang: "en-US", + value: "List does not exist. The page you selected contains a list that does not exist. It may have been deleted by another user." + } + } + } + }; + } + + throw `${opts.url} is invalid request`; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d' } }), new CommandError('List does not exist. The page you selected contains a list that does not exist. It may have been deleted by another user.')); + }); + + it('corretly handles error when try to apply model to SP list', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return { + RootFolder: { + ServerRelativeUrl: '/sites/portal/lists/SPList' + }, + BaseType: 0 + }; + } + + throw `${opts.url} is invalid request`; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', verbose: true } }), new CommandError('The specified list is not a document library.')); + }); + + it('corretly handles error when model is not found by its id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_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." + } + } + } + }; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', verbose: true } }), new CommandError('File Not Found.')); + }); + + it('corretly handles error when model is not found by its title', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbytitle('modeltitle.classifier')`) { + return { + "odata.null": true + }; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', verbose: true } }), new CommandError('Model not found.')); + }); + + it('corretly handles error when apply model failed', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/models/getbytitle('modeltitle.classifier')`) { + return modelResponse; + } + + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/lists(guid'421b1e42-794b-4c71-93ac-5ed92488b67d')?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`) { + return listResponse; + } + + throw `${opts.url} is invalid request`; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/contentCenter/_api/machinelearning/publications`) { + return { + Details: [ + { + ErrorMessage: 'The content type is bound to another model. Please unpublish the existing model or remove the content type in order to publish the model.', + Publication: {}, + StatusCode: 400 + } + ], + TotalFailures: 1, + TotalSuccesses: 0 + }; + } + + throw `${opts.url} is invalid request`; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', contentCenterUrl: 'https://contoso.sharepoint.com/sites/contentCenter', title: 'ModelTitle.classifier', listId: '421b1e42-794b-4c71-93ac-5ed92488b67d', verbose: true } }), new CommandError('The content type is bound to another model. Please unpublish the existing model or remove the content type in order to publish the model.')); + }); +}); \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-apply.ts b/src/m365/spp/commands/model/model-apply.ts new file mode 100644 index 00000000000..7998ee375be --- /dev/null +++ b/src/m365/spp/commands/model/model-apply.ts @@ -0,0 +1,239 @@ +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, SppModel } from '../../../../utils/spp.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { ListInstance } from '../../../spo/commands/list/ListInstance.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + contentCenterUrl: string; + siteUrl: string; + id?: string; + title?: string; + listTitle?: string; + listId?: string; + listUrl?: string; + viewOption?: string; +} + +class SppModelApplyCommand extends SpoCommand { + public readonly viewOptions: string[] = ['NewViewAsDefault', 'DoNotChangeDefault', 'TileViewAsDefault']; + + public get name(): string { + return commands.MODEL_APPLY; + } + + public get description(): string { + return 'Applies (or syncs) a trained document understanding model to a document library'; + } + + 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', + listTitle: typeof args.options.listTitle !== 'undefined', + listId: typeof args.options.listId !== 'undefined', + listUrl: typeof args.options.listUrl !== 'undefined', + viewOption: typeof args.options.viewOption !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --siteUrl ' + }, + { + option: '--contentCenterUrl ' + }, + { + option: '-i, --id [id]' + }, + { + option: '-t, --title [title]' + }, + { + option: '--listTitle [listTitle]' + }, + { + option: '--listId [listId]' + }, + { + option: '--listUrl [listUrl]' + }, + { + option: '--viewOption [viewOption]', + autocomplete: this.viewOptions + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.id && !validation.isValidGuid(args.options.id)) { + return `${args.options.id} in parameter id is not a valid GUID`; + } + + if (args.options.listId && + !validation.isValidGuid(args.options.listId)) { + return `${args.options.listId} in parameter listId is not a valid GUID`; + } + + if (typeof args.options.viewOption !== 'undefined') { + if (!this.viewOptions.some(viewOption => viewOption.toLocaleLowerCase() === args.options.viewOption?.toLowerCase())) { + return `The value of parameter viewOption must be ${this.viewOptions.join(', ')}`; + } + } + + const isContentCenterUrlValid = validation.isValidSharePointUrl(args.options.contentCenterUrl); + + if (isContentCenterUrlValid !== true) { + return isContentCenterUrlValid; + } + + return validation.isValidSharePointUrl(args.options.siteUrl); + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['id', 'title'] }); + this.optionSets.push({ options: ['listTitle', 'listId', 'listUrl'] }); + } + + #initTypes(): void { + this.types.string.push('contentCenterUrl', 'siteUrl', 'id', 'title', 'listTitle', 'listId', 'listUrl', 'viewOption'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.log(`Applying a model to a document library...`); + } + + const contentCenterUrl = urlUtil.removeTrailingSlashes(args.options.contentCenterUrl); + await spp.assertSiteIsContentCenter(contentCenterUrl); + + const model = await this.getModel(contentCenterUrl, args); + const listInstance = await this.getDocumentLibraryInfo(args); + + if (listInstance.BaseType !== 1) { + throw Error(`The specified list is not a document library.`); + } + + const requestOptions: CliRequestOptions = { + url: `${contentCenterUrl}/_api/machinelearning/publications`, + headers: { + accept: 'application/json;odata=nometadata', + "Content-Type": 'application/json;odata=verbose' + }, + responseType: 'json', + data: { + __metadata: { type: 'Microsoft.Office.Server.ContentCenter.SPMachineLearningPublicationsEntityData' }, + Publications: { + results: [ + { + ModelUniqueId: model.UniqueId, + TargetSiteUrl: args.options.siteUrl, + TargetWebServerRelativeUrl: urlUtil.getServerRelativePath(args.options.siteUrl, ''), + TargetLibraryServerRelativeUrl: listInstance.RootFolder.ServerRelativeUrl, + ViewOption: args.options.viewOption ? args.options.viewOption : "NewViewAsDefault" + } + ] + } + } + }; + + const result = await request.post(requestOptions); + const resutlDetails = (result as any)?.Details; + + if (resutlDetails?.length > 0 && resutlDetails[0]?.ErrorMessage) { + throw (result as any).Details[0].ErrorMessage; + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getModel(contentCenterUrl: string, args: CommandArgs): Promise { + let requestUrl = `${contentCenterUrl}/_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' + }, + responseType: 'json' + }; + + const result = await request.get(requestOptions); + + if ((result as any)['odata.null'] === true) { + throw "Model not found."; + } + + return result; + } + + private getDocumentLibraryInfo(args: CommandArgs): Promise { + let requestUrl = `${args.options.siteUrl}/_api/web`; + + if (args.options.listId) { + requestUrl += `/lists(guid'${formatting.encodeQueryParameter(args.options.listId)}')`; + } + else if (args.options.listTitle) { + requestUrl += `/lists/getByTitle('${formatting.encodeQueryParameter(args.options.listTitle)}')`; + } + else if (args.options.listUrl) { + const listServerRelativeUrl: string = urlUtil.getServerRelativePath(args.options.siteUrl, args.options.listUrl); + requestUrl += `/GetList('${formatting.encodeQueryParameter(listServerRelativeUrl)}')`; + } + + const requestOptions: CliRequestOptions = { + url: `${requestUrl}?$select=BaseType,RootFolder/ServerRelativeUrl&$expand=RootFolder`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + return request.get(requestOptions); + } +} + +export default new SppModelApplyCommand(); \ No newline at end of file