From ce5201a2cc33b675f013913364a6382f3f0eff40 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Thu, 5 Feb 2026 20:24:36 +0100 Subject: [PATCH 1/3] Adds 'outlook calendar add' command. Closes #7102 --- .../cmd/outlook/calendar/calendar-add.mdx | 165 +++++++++++++ docs/src/config/sidebars.ts | 8 +- npm-shrinkwrap.json | 7 + src/m365/outlook/commands.ts | 1 + .../commands/calendar/calendar-add.spec.ts | 220 ++++++++++++++++++ .../outlook/commands/calendar/calendar-add.ts | 102 ++++++++ src/utils/calendarGroup.spec.ts | 131 +++++++++++ src/utils/calendarGroup.ts | 28 +++ 8 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 docs/docs/cmd/outlook/calendar/calendar-add.mdx create mode 100644 src/m365/outlook/commands/calendar/calendar-add.spec.ts create mode 100644 src/m365/outlook/commands/calendar/calendar-add.ts create mode 100644 src/utils/calendarGroup.spec.ts create mode 100644 src/utils/calendarGroup.ts diff --git a/docs/docs/cmd/outlook/calendar/calendar-add.mdx b/docs/docs/cmd/outlook/calendar/calendar-add.mdx new file mode 100644 index 00000000000..c3092d1a720 --- /dev/null +++ b/docs/docs/cmd/outlook/calendar/calendar-add.mdx @@ -0,0 +1,165 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook calendar add + +Creates a new calendar for a user + +## Usage + +```sh +m365 outlook calendar add [options] +``` + +## Options + +```md definition-list +`--userId [userId]` +: ID of the user. Specify either `userId` or `userName`, but not both. + +`--userName [userName]` +: UPN of the user. Specify either `userId` or `userName`, but not both. + +`--name ` +: Name of the calendar. + +`--calendarGroupId [calendarGroupId]` +: Id of the group where the calendar will belong. Specify either `calendarGroupId` or `calendarGroupName`, but not both. + +`--calendarGroupName [calendarGroupName]` +: Name of the group where the calendar will belong. Specify either `calendarGroupId` or `calendarGroupName`, but not both. + +`--color [color]` +: The color of the calendar in UI. Allowed values are `auto`, `lightBlue`, `lightGreen`, `lightOrange`, `lightGray`, `lightYellow`, `lightTeal`, `lightPink`, `lightBrown`, `lightRed`, `maxColor`. Defaults to `auto`. + +`--defaultOnlineMeetingProvider [defaultOnlineMeetingProvider]` +: The default online meeting provider for meetings sent from the calendar. Allowed values are `none`, `teamsForBusiness`. Defaults to `teamsForBusiness`. + +`--default` +: Specify whether the calendar will be the default calendar for new events. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|--------------------| + | Microsoft Graph | Calendar.ReadWrite | + + + + + | Resource | Permissions | + |-----------------|--------------------| + | Microsoft Graph | Calendar.ReadWrite | + + + + +## Examples + +Create a new calendar for a user in a default calendar's group + +```sh +m365 outlook calendar add --userId '@meId' --name 'Holidays' +``` + +Create a new calendar for a user specified by email in a specific calendar group and defined color + +```sh +m365 outlook calendar add --userName 'john.doe@contoso.com' --name 'Interviews' --calendarGroupId 'AAMkADY1YmE3N2FhLWEwMz' --color 'lightBlue' +``` + +## Response + + + + + ```json + { + "id": "AQMkAGRlMCQAAAA==", + "name": "My Calendars", + "color": "auto", + "hexColor": "", + "groupClassId": "0006f0b7-0000-0000-c000-000000000046", + "isDefaultCalendar": false, + "changeKey": "fJKVL07sbkmIfHqjbDnRgQACxSYYpw==", + "canShare": true, + "canViewPrivateItems": true, + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": false, + "isRemovable": true, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + } + ``` + + + + + ```text + allowedOnlineMeetingProviders: ["teamsForBusiness"] + canEdit : true + canShare : true + canViewPrivateItems : true + changeKey : fJKVL07sbkmIfHqjbDnRgQACxSYYug== + color : auto + defaultOnlineMeetingProvider : teamsForBusiness + groupClassId : 0006f0b7-0000-0000-c000-000000000046 + hexColor : + id : AQMkAGRlMCQAAAA== + isDefaultCalendar : false + isRemovable : true + isTallyingResponses : false + name : My Calendars + owner : {"name":"John Doe","address":"john.doe@contoso.com"} + ``` + + + + + ```csv + id,name,color,hexColor,groupClassId,isDefaultCalendar,changeKey,canShare,canViewPrivateItems,canEdit,defaultOnlineMeetingProvider,isTallyingResponses,isRemovable + AQMkAGRlMCQAAAA==,My Calendars,auto,,0006f0b7-0000-0000-c000-000000000046,0,fJKVL07sbkmIfHqjbDnRgQACxSYYzQ==,1,1,1,teamsForBusiness,0,1 + ``` + + + + + ```md + # outlook calendar add --userId "893f9116-e024-4bc6-8e98-54c245129485" --name "My Calendars" + + Date: 2/5/2026 + + ## My Calendars (AQMkAGRlMCQAAAA==) + + Property | Value + ---------|------- + id | AQMkAGRlMCQAAAA== + name | My Calendars + color | auto + hexColor | + groupClassId | 0006f0b7-0000-0000-c000-000000000046 + isDefaultCalendar | false + changeKey | fJKVL07sbkmIfHqjbDnRgQACxSYY4A== + canShare | true + canViewPrivateItems | true + canEdit | true + defaultOnlineMeetingProvider | teamsForBusiness + isTallyingResponses | false + isRemovable | true + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 55e13dca27c..ab2df7c6eda 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1303,7 +1303,13 @@ const sidebars: SidebarsConfig = { { 'Outlook (outlook)': [ { - calendargroup: [ + calendar: [ + { + type: 'doc', + label: 'calendar add', + id: 'cmd/outlook/calendar/calendar-add' + } + ], calendargroup: [ { type: 'doc', label: 'calendargroup list', diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9c0888a2599..ccfa3e2acc3 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1139,6 +1139,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2840,6 +2841,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2983,6 +2985,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3248,6 +3251,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4160,6 +4164,7 @@ "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", "integrity": "sha512-r2HV5qFkUICyoaKlBEpLKHjxMXATUf/l+h8UZPGBHGLy4DDiY2sOLcIctax4eRnTw5wH2jTMExLntGPJ8eOJxw==", "license": "MIT", + "peer": true, "dependencies": { "semver": "^7.5.3" } @@ -4354,6 +4359,7 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7496,6 +7502,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index 79e8dc9f0d6..80951e1b7a7 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -1,6 +1,7 @@ const prefix: string = 'outlook'; export default { + CALENDAR_ADD: `${prefix} calendar add`, CALENDARGROUP_LIST: `${prefix} calendargroup list`, MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`, MAIL_SEND: `${prefix} mail send`, diff --git a/src/m365/outlook/commands/calendar/calendar-add.spec.ts b/src/m365/outlook/commands/calendar/calendar-add.spec.ts new file mode 100644 index 00000000000..a49b8057051 --- /dev/null +++ b/src/m365/outlook/commands/calendar/calendar-add.spec.ts @@ -0,0 +1,220 @@ +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, { options } from './calendar-add.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.js'; + +describe(commands.CALENDAR_ADD, () => { + const userId = 'ae0e8388-cd70-427f-9503-c57498ee3337'; + const userName = 'john.doe@contoso.com'; + const calendarName = 'Volunteer'; + const calendarGroupId = 'AQMkADJmMVAAA='; + const calendarGroupName = 'My Calendars'; + const response = { + "id": "AAMkADJmMVAAA=", + "name": "Volunteer", + "color": "auto", + "changeKey": "DxYSthXJXEWwAQSYQnXvIgAAIxGttg==", + "canShare": true, + "canViewPrivateItems": true, + "hexColor": "", + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }; + + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CALENDAR_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if name is not provided', () => { + const actual = commandOptionsSchema.safeParse({ userId: userId }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + userId: 'foo', + name: calendarName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ + userName: 'foo', + name: calendarName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both userId and userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + userName: userName, + name: calendarName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if color is invalid', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + name: calendarName, + color: 'foo' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if defaultOnlineMeetingProvider is invalid', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + name: calendarName, + defaultOnlineMeetingProvider: 'foo' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('correctly creates a calendar for a user specified by id', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + userId: userId, + name: calendarName, + verbose: true + }); + await command.action(logger, { options: parsedSchema.data! }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('correctly creates a calendar for a user specified by UPN in a calendar group specified by id', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups/${calendarGroupId}/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + userName: userName, + name: calendarName, + calendarGroupId: calendarGroupId, + defaultOnlineMeetingProvider: 'none', + color: 'lightBlue' + }); + await command.action(logger, { options: parsedSchema.data! }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('correctly creates a calendar for a user specified by UPN in a calendar group specified by name', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').withArgs(userName, calendarGroupName, 'id').resolves({ id: calendarGroupId }); + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups/${calendarGroupId}/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + userName: userName, + name: calendarName, + calendarGroupName: calendarGroupName + }); + await command.action(logger, { options: parsedSchema.data! }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + userId: userId, + name: calendarName + }); + await assert.rejects(command.action(logger, { + options: parsedSchema.data! + }), new CommandError('Invalid request')); + }); +}); \ No newline at end of file diff --git a/src/m365/outlook/commands/calendar/calendar-add.ts b/src/m365/outlook/commands/calendar/calendar-add.ts new file mode 100644 index 00000000000..34fde3668da --- /dev/null +++ b/src/m365/outlook/commands/calendar/calendar-add.ts @@ -0,0 +1,102 @@ +import { Calendar } from '@microsoft/microsoft-graph-types'; +import { Logger } from '../../../../cli/Logger.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import { validation } from '../../../../utils/validation.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.string() + .refine(userId => validation.isValidGuid(userId), { + error: e => `'${e.input}' is not a valid GUID.` + }).optional(), + userName: z.string() + .refine(userName => validation.isValidUserPrincipalName(userName), { + error: e => `'${e.input}' is not a valid UPN.` + }).optional(), + name: z.string(), + calendarGroupId: z.string().optional(), + calendarGroupName: z.string().optional(), + color: z.enum(['auto', 'lightBlue', 'lightGreen', 'lightOrange', 'lightGray', 'lightYellow', 'lightTeal', 'lightPink', 'lightBrown', 'maxColor']).optional().default('auto'), + defaultOnlineMeetingProvider: z.enum(['none', 'teamsForBusiness']).optional().default('teamsForBusiness'), + default: z.boolean().optional() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookCalendarAddCommand extends GraphCommand { + public get name(): string { + return commands.CALENDAR_ADD; + } + + public get description(): string { + return 'Creates a new calendar for a user'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !(options.userId && options.userName), { + error: 'Specify either userId or userName, but not both' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + + const userIdentifier = args.options.userId ?? args.options.userName; + + let requestUrl = `${this.resource}/v1.0/users('${userIdentifier}')/`; + + if (args.options.calendarGroupId || args.options.calendarGroupName) { + let calendarGroupId = args.options.calendarGroupId; + if (args.options.calendarGroupName) { + const group = await calendarGroup.getUserCalendarGroupByName(userIdentifier!, args.options.calendarGroupName, 'id'); + calendarGroupId = group.id; + } + requestUrl += `calendarGroups/${calendarGroupId}/calendars`; + } + else { + requestUrl += 'calendars'; + } + + if (args.options.verbose) { + await logger.logToStderr(`Creating a calendar for the user ${userIdentifier}...`); + } + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + responseType: 'json', + data: { + name: args.options.name, + color: args.options.color, + defaultOnlineMeetingProvider: args.options.defaultOnlineMeetingProvider, + isDefaultCalendar: args.options.default + } + }; + + const result = await request.post(requestOptions); + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new OutlookCalendarAddCommand(); diff --git a/src/utils/calendarGroup.spec.ts b/src/utils/calendarGroup.spec.ts new file mode 100644 index 00000000000..c4d6b14540d --- /dev/null +++ b/src/utils/calendarGroup.spec.ts @@ -0,0 +1,131 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { cli } from '../cli/cli.js'; +import request from '../request.js'; +import { sinonUtil } from './sinonUtil.js'; +import { calendarGroup } from './calendarGroup.js'; +import { formatting } from './formatting.js'; +import { settingsNames } from '../settingsNames.js'; + +describe('utils/calendarGroup', () => { + const userId = '729827e3-9c14-49f7-bb1b-9608f156bbb8'; + const groupName = 'My Calendars'; + const invalidGroupName = 'M Calnedar'; + const calendarGroupResponse = { + "name": "My Calendars", + "classId": "0006f0b7-0000-0000-c000-000000000046", + "changeKey": "NreqLYgxdE2DpHBBId74XwAAAAAGZw==", + "id": "AQMkADIxYjJiYgEzLTFmN_F8AAAIBBgAA_F8AAAJjIQAAAA==" + }; + const anotherCalendarGroupResponse = { + "name": "My Calendars", + "classId": "0006f0b7-0000-0000-c000-000000000047", + "changeKey": "MreqLYgxdE2DpHBBId74XwAAAAAGZw==", + "id": "AQMkADIxYjJiYgEzLTFmN_F8AAAIBBgAA_F8AAAJjIQBBB==" + }; + const calendarGroupLimitedResponse = { + "name": "My Calendars", + "id": "AQMkADIxYjJiYgEzLTFmN_F8AAAIBBgAA_F8AAAJjIQAAAA==" + }; + + afterEach(() => { + sinonUtil.restore([ + request.get, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound + ]); + }); + + it('correctly get single calendar group by name using getUserCalendarGroupByName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups?$filter=name eq '${formatting.encodeQueryParameter(groupName)}'`) { + return { + value: [ + calendarGroupResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await calendarGroup.getUserCalendarGroupByName(userId, groupName); + assert.deepStrictEqual(actual, calendarGroupResponse); + }); + + it('correctly get single calendar group by name using getUserCalendarGroupByName with specified properties', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups?$filter=name eq '${formatting.encodeQueryParameter(groupName)}'&$select=id,name`) { + return { + value: [ + calendarGroupLimitedResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await calendarGroup.getUserCalendarGroupByName(userId, groupName, 'id,name'); + assert.deepStrictEqual(actual, calendarGroupLimitedResponse); + }); + + it('handles selecting single calendar group when multiple calendar groups with the specified name found using getUserCalendarGroupByName and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups?$filter=name eq '${formatting.encodeQueryParameter(groupName)}'`) { + return { + value: [ + calendarGroupResponse, + anotherCalendarGroupResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(calendarGroupResponse); + + const actual = await calendarGroup.getUserCalendarGroupByName(userId, groupName); + assert.deepStrictEqual(actual, calendarGroupResponse); + }); + + it('throws error message when no calendar group was found using getUserCalendarGroupByName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups?$filter=name eq '${formatting.encodeQueryParameter(invalidGroupName)}'`) { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(calendarGroup.getUserCalendarGroupByName(userId, invalidGroupName), + new Error(`The specified calendar group '${invalidGroupName}' does not exist.`)); + }); + + it('throws error message when multiple calendar groups were found using getUserCalendarGroupByName', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups?$filter=name eq '${formatting.encodeQueryParameter(groupName)}'`) { + return { + value: [ + calendarGroupResponse, + anotherCalendarGroupResponse + ] + }; + } + + return 'Invalid Request'; + }); + + await assert.rejects(calendarGroup.getUserCalendarGroupByName(userId, groupName), + Error(`Multiple calendar groups with name '${groupName}' found. Found: ${calendarGroupResponse.id}, ${anotherCalendarGroupResponse.id}.`)); + }); +}); \ No newline at end of file diff --git a/src/utils/calendarGroup.ts b/src/utils/calendarGroup.ts new file mode 100644 index 00000000000..80052105962 --- /dev/null +++ b/src/utils/calendarGroup.ts @@ -0,0 +1,28 @@ +import { CalendarGroup } from '@microsoft/microsoft-graph-types'; +import { odata } from './odata.js'; +import { formatting } from './formatting.js'; +import { cli } from '../cli/cli.js'; + +export const calendarGroup = { + async getUserCalendarGroupByName(userId: string, displayName: string, properties?: string): Promise { + let url = `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups?$filter=name eq '${formatting.encodeQueryParameter(displayName)}'`; + + if (properties) { + url += `&$select=${properties}`; + } + + const calendarGroups = await odata.getAllItems(url); + + if (calendarGroups.length === 0) { + throw new Error(`The specified calendar group '${displayName}' does not exist.`); + } + + if (calendarGroups.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', calendarGroups); + const selectedCalendarGroup = await cli.handleMultipleResultsFound(`Multiple calendar groups with name '${displayName}' found.`, resultAsKeyValuePair); + return selectedCalendarGroup; + } + + return calendarGroups[0]; + } +}; \ No newline at end of file From 770f053eb1cada506889fe36e5a48dce380303bd Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sat, 7 Mar 2026 16:15:33 +0100 Subject: [PATCH 2/3] Adds 'outlook calendar add' command. Closes #7102 --- docs/docs/cmd/outlook/calendar/calendar-add.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/cmd/outlook/calendar/calendar-add.mdx b/docs/docs/cmd/outlook/calendar/calendar-add.mdx index c3092d1a720..32ee6575c93 100644 --- a/docs/docs/cmd/outlook/calendar/calendar-add.mdx +++ b/docs/docs/cmd/outlook/calendar/calendar-add.mdx @@ -1,4 +1,4 @@ -import Global from '/docs/cmd/_global.mdx'; +import Global from '../../_global.mdx'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; From 94a52966cbed8323d21f47418918cdb288c8c56d Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sat, 4 Apr 2026 11:45:01 +0200 Subject: [PATCH 3/3] Adds 'outlook calendar add' command. Closes #7102 --- docs/docs/cmd/outlook/calendar/calendar-add.mdx | 12 ++++++------ docs/src/config/sidebars.ts | 3 ++- npm-shrinkwrap.json | 7 ------- src/config.ts | 1 + .../outlook/commands/calendar/calendar-add.spec.ts | 10 ++++++++++ src/m365/outlook/commands/calendar/calendar-add.ts | 6 ++++-- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/docs/cmd/outlook/calendar/calendar-add.mdx b/docs/docs/cmd/outlook/calendar/calendar-add.mdx index 32ee6575c93..9ef1b53f477 100644 --- a/docs/docs/cmd/outlook/calendar/calendar-add.mdx +++ b/docs/docs/cmd/outlook/calendar/calendar-add.mdx @@ -47,16 +47,16 @@ m365 outlook calendar add [options] - | Resource | Permissions | - |-----------------|--------------------| - | Microsoft Graph | Calendar.ReadWrite | + | Resource | Permissions | + |-----------------|---------------------| + | Microsoft Graph | Calendars.ReadWrite | - | Resource | Permissions | - |-----------------|--------------------| - | Microsoft Graph | Calendar.ReadWrite | + | Resource | Permissions | + |-----------------|---------------------| + | Microsoft Graph | Calendars.ReadWrite | diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index ab2df7c6eda..44c047ea986 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1309,7 +1309,8 @@ const sidebars: SidebarsConfig = { label: 'calendar add', id: 'cmd/outlook/calendar/calendar-add' } - ], calendargroup: [ + ], + calendargroup: [ { type: 'doc', label: 'calendargroup list', diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index ccfa3e2acc3..9c0888a2599 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1139,7 +1139,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2841,7 +2840,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2985,7 +2983,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3251,7 +3248,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4164,7 +4160,6 @@ "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", "integrity": "sha512-r2HV5qFkUICyoaKlBEpLKHjxMXATUf/l+h8UZPGBHGLy4DDiY2sOLcIctax4eRnTw5wH2jTMExLntGPJ8eOJxw==", "license": "MIT", - "peer": true, "dependencies": { "semver": "^7.5.3" } @@ -4359,7 +4354,6 @@ "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7502,7 +7496,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/config.ts b/src/config.ts index 0cda3d93475..8218037c14f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ export default { 'https://graph.microsoft.com/AuditLog.Read.All', 'https://graph.microsoft.com/Bookings.Read.All', 'https://graph.microsoft.com/Calendars.Read', + 'https://graph.microsoft.com/Calendars.ReadWrite', 'https://graph.microsoft.com/ChannelMember.ReadWrite.All', 'https://graph.microsoft.com/ChannelMessage.Read.All', 'https://graph.microsoft.com/ChannelMessage.ReadWrite', diff --git a/src/m365/outlook/commands/calendar/calendar-add.spec.ts b/src/m365/outlook/commands/calendar/calendar-add.spec.ts index a49b8057051..aa492c6046c 100644 --- a/src/m365/outlook/commands/calendar/calendar-add.spec.ts +++ b/src/m365/outlook/commands/calendar/calendar-add.spec.ts @@ -122,6 +122,16 @@ describe(commands.CALENDAR_ADD, () => { assert.notStrictEqual(actual.success, true); }); + it('fails validation if both calendarGroupId and calendarGroupName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + name: calendarName, + calendarGroupId: calendarGroupId, + calendarGroupName: calendarGroupName + }); + assert.notStrictEqual(actual.success, true); + }); + it('fails validation if color is invalid', () => { const actual = commandOptionsSchema.safeParse({ userId: userId, diff --git a/src/m365/outlook/commands/calendar/calendar-add.ts b/src/m365/outlook/commands/calendar/calendar-add.ts index 34fde3668da..da5b72bbbe8 100644 --- a/src/m365/outlook/commands/calendar/calendar-add.ts +++ b/src/m365/outlook/commands/calendar/calendar-add.ts @@ -21,7 +21,7 @@ export const options = z.strictObject({ name: z.string(), calendarGroupId: z.string().optional(), calendarGroupName: z.string().optional(), - color: z.enum(['auto', 'lightBlue', 'lightGreen', 'lightOrange', 'lightGray', 'lightYellow', 'lightTeal', 'lightPink', 'lightBrown', 'maxColor']).optional().default('auto'), + color: z.enum(['auto', 'lightBlue', 'lightGreen', 'lightOrange', 'lightGray', 'lightYellow', 'lightTeal', 'lightPink', 'lightBrown', 'lightRed', 'maxColor']).optional().default('auto'), defaultOnlineMeetingProvider: z.enum(['none', 'teamsForBusiness']).optional().default('teamsForBusiness'), default: z.boolean().optional() }); @@ -49,12 +49,14 @@ class OutlookCalendarAddCommand extends GraphCommand { return schema .refine(options => !(options.userId && options.userName), { error: 'Specify either userId or userName, but not both' + }) + .refine(options => !(options.calendarGroupId && options.calendarGroupName), { + error: 'Specify either calendarGroupId or calendarGroupName, but not both' }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { try { - const userIdentifier = args.options.userId ?? args.options.userName; let requestUrl = `${this.resource}/v1.0/users('${userIdentifier}')/`;