diff --git a/docs/docs/cmd/outlook/calendar/calendar-list.mdx b/docs/docs/cmd/outlook/calendar/calendar-list.mdx new file mode 100644 index 00000000000..2329aef6cbd --- /dev/null +++ b/docs/docs/cmd/outlook/calendar/calendar-list.mdx @@ -0,0 +1,155 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook calendar list + +Retrieves a list of all calendars of a user or a group. + +## Usage + +```sh +m365 outlook calendar list [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. + +`--calendarGroupId [calendarGroupId]` +: ID of the calendar group. Specify either `calendarGroupId` or `calendarGroupName`, but not both. + +`--calendarGroupName [calendarGroupName]` +: Name of the calendar group. Specify either `calendarGroupId` or `calendarGroupName`, but not both. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|-----------------| + | Microsoft Graph | Calendars.Read | + + + + + | Resource | Permissions | + |-----------------|-----------------| + | Microsoft Graph | Calendars.Read | + + + + +## Examples + +List all calendars for the current signed-in user. + +```sh +m365 outlook calendar list --userId "@meId" +``` + +List all calendars in a specific calendar group for the current signed-in user. + +```sh +m365 outlook calendar list --userId "@meId" --calendarGroupName "Colleague calendars" +``` + +List all calendars for a specific user. + +```sh +m365 outlook calendar list --userName "john.doe@contoso.com" +``` + +List all calendars from a specific calendar group for a specific user. + +```sh +m365 outlook calendar list --userId b743445a-112c-4fda-9afd-05943f9c7b36 --calendarGroupId "AAMkADIxYjJiYmIzLTFmNjYtNGNhMy0YOkcEEh3vhfAAAGgdFjAAA=" +``` + +## Response + + + + + ```json + [ + { + "id": "AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS0AAA=", + "name": "Calendar", + "color": "auto", + "hexColor": "", + "groupClassId": "0006f0b7-0000-0000-c000-000000000046", + "isDefaultCalendar": true, + "changeKey": "KKQ3ZlF2PE2iyZOQgi6OMwAAAAADcg==", + "canShare": true, + "canViewPrivateItems": true, + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + } + ] + ``` + + + + + ```text + id name + -------------------------------------------------------------------------------------------------------------------------------------------------------- ---------------------- + AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS0AAA= Calendar + ``` + + + + + ```csv + id,name,color,hexColor,groupClassId,isDefaultCalendar,changeKey,canShare,canViewPrivateItems,canEdit,defaultOnlineMeetingProvider,isTallyingResponses,isRemovable + AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS0AAA=,Calendar,auto,,0006f0b7-0000-0000-c000-000000000046,1,KKQ3ZlF2PE2iyZOQgi6OMwAAAAADcg==,1,1,1,teamsForBusiness,1,0 + ``` + + + + + ```md + # outlook calendar list --userId "0649d0bd-53dc-4e1d-a357-76f1d92d447b" + + Date: 5/11/2026 + + ## Calendar (AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS0AAA=) + + Property | Value + ---------|------- + id | AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS0AAA= + name | Calendar + color | auto + hexColor | + groupClassId | 0006f0b7-0000-0000-c000-000000000046 + isDefaultCalendar | true + changeKey | KKQ3ZlF2PE2iyZOQgi6OMwAAAAADcg== + canShare | true + canViewPrivateItems | true + canEdit | true + defaultOnlineMeetingProvider | teamsForBusiness + isTallyingResponses | true + isRemovable | false + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index e6e537e6841..03444fe0969 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1314,6 +1314,11 @@ const sidebars: SidebarsConfig = { label: 'calendar get', id: 'cmd/outlook/calendar/calendar-get' }, + { + type: 'doc', + label: 'calendar list', + id: 'cmd/outlook/calendar/calendar-list' + }, { type: 'doc', label: 'calendar remove', diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index f66c4ac689c..c4a8efe9ab6 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -3,6 +3,7 @@ const prefix: string = 'outlook'; export default { CALENDAR_ADD: `${prefix} calendar add`, CALENDAR_GET: `${prefix} calendar get`, + CALENDAR_LIST: `${prefix} calendar list`, CALENDAR_REMOVE: `${prefix} calendar remove`, CALENDAR_SET: `${prefix} calendar set`, CALENDARGROUP_GET: `${prefix} calendargroup get`, diff --git a/src/m365/outlook/commands/calendar/calendar-list.spec.ts b/src/m365/outlook/commands/calendar/calendar-list.spec.ts new file mode 100644 index 00000000000..08d1ca52e39 --- /dev/null +++ b/src/m365/outlook/commands/calendar/calendar-list.spec.ts @@ -0,0 +1,256 @@ +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 { 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-list.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.js'; +import { odata } from '../../../../utils/odata.js'; + +describe(commands.CALENDAR_LIST, () => { + const userId = 'ae0e8388-cd70-427f-9503-c57498ee3337'; + const userName = 'john.doe@contoso.com'; + const calendarGroupId = 'AQMkADJmMVAAA='; + const calendarGroupName = 'My Calendars'; + const response = [ + { + "id": "AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS0AAA=", + "name": "Calendar", + "color": "auto", + "hexColor": "", + "groupClassId": "0006f0b7-0000-0000-c000-000000000046", + "isDefaultCalendar": true, + "changeKey": "KKQ3ZlF2PE2iyZOQgi6OMwAAAAADcg==", + "canShare": true, + "canViewPrivateItems": true, + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }, + { + "id": "AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS1AAA=", + "name": "United States holidays", + "color": "auto", + "hexColor": "", + "groupClassId": "0006f0b7-0000-0000-c000-000000000046", + "isDefaultCalendar": false, + "changeKey": "KKQ3ZlF2PE2iyZOQgi6OMwAAAAADfA==", + "canShare": false, + "canViewPrivateItems": true, + "canEdit": false, + "allowedOnlineMeetingProviders": [], + "defaultOnlineMeetingProvider": "unknown", + "isTallyingResponses": false, + "isRemovable": true, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }, + { + "id": "AAMkAGI2MDc2YzA0LWQwNTktNGM5Ni05M2VkLWY3NjFkNTUxOTkyZABGAAAAAABeGJMObKvfQbq5qwfGa7kTBwAopDdmUXY8TaLJk5CCLo4zAAAAAAEGAAAopDdmUXY8TaLJk5CCLo4zAAAAAFS4AAA=", + "name": "Birthdays", + "color": "auto", + "hexColor": "", + "groupClassId": "0006f0b7-0000-0000-c000-000000000046", + "isDefaultCalendar": false, + "changeKey": "KKQ3ZlF2PE2iyZOQgi6OMwAAAAAFKg==", + "canShare": false, + "canViewPrivateItems": true, + "canEdit": false, + "allowedOnlineMeetingProviders": [], + "defaultOnlineMeetingProvider": "unknown", + "isTallyingResponses": false, + "isRemovable": true, + "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([ + odata.getAllItems, + calendarGroup.getUserCalendarGroupByName + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CALENDAR_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct properties for the default output', () => { + assert.deepStrictEqual(command.defaultProperties(), ['id', 'name']); + }); + + it('fails validation if both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ userId, userName }); + assert.notStrictEqual(actual.success, true); + }); + + it('passes validation if only userId is specified', () => { + const actual = commandOptionsSchema.safeParse({ userId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if only userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ userName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ userId: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ userName: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both calendarGroupId and calendarGroupName is specified', () => { + const actual = commandOptionsSchema.safeParse({ userId, calendarGroupId, calendarGroupName }); + assert.notStrictEqual(actual.success, true); + }); + + it('passes validation if only calendarGroupId is specified', () => { + const actual = commandOptionsSchema.safeParse({ userId, calendarGroupId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if only calendarGroupName is specified', () => { + const actual = commandOptionsSchema.safeParse({ userId, calendarGroupName }); + assert.strictEqual(actual.success, true); + }); + + it('retrieves calendars for a user by userId', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ userId, verbose: true }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('retrieves calendars for a user by userName', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ userName, verbose: true }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('retrieves calendars for a user and calendar group by calendarGroupId', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ userId, calendarGroupId }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('retrieves calendars for a user and calendar group by calendarGroupName', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').withArgs(userName, calendarGroupName, 'id').resolves({ id: calendarGroupId }); + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendarGroups/${calendarGroupId}/calendars`) { + return response; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ userName, calendarGroupName }) }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('handles error when calendar was not found', async () => { + const invalidUserName = 'invalidUser@contoso.com'; + sinon.stub(odata, 'getAllItems').callsFake(async (url) => { + if (url === `https://graph.microsoft.com/v1.0/users('${invalidUserName}')/calendars`) { + throw { + error: + { + code: 'ErrorInvalidUser', + message: `The requested user '${invalidUserName}' is invalid.` + } + }; + } + throw `Invalid request`; + }); + + await assert.rejects( + command.action(logger, { options: { userName: invalidUserName } }), + new CommandError(`The requested user '${invalidUserName}' is invalid.`) + ); + }); +}); diff --git a/src/m365/outlook/commands/calendar/calendar-list.ts b/src/m365/outlook/commands/calendar/calendar-list.ts new file mode 100644 index 00000000000..509634b926b --- /dev/null +++ b/src/m365/outlook/commands/calendar/calendar-list.ts @@ -0,0 +1,83 @@ +import { Calendar } from '@microsoft/microsoft-graph-types'; +import { Logger } from '../../../../cli/Logger.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'; +import { odata } from '../../../../utils/odata.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(), + calendarGroupId: z.string().optional(), + calendarGroupName: z.string().optional() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookCalendarListCommand extends GraphCommand { + public get name(): string { + return commands.CALENDAR_LIST; + } + + public get description(): string { + return 'Retrieves a list of all calendars of a user or a group'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.userId, options.userName].filter(x => x !== undefined).length === 1, { + 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 defaultProperties(): string[] | undefined { + return ['id', 'name']; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (this.verbose) { + await logger.logToStderr(`Retrieving calendars for ${args.options.userId ?? args.options.userName}...`); + } + + try { + const userIdentifier = args.options.userId ?? args.options.userName; + let calendarGroupId = args.options.calendarGroupId; + + if (args.options.calendarGroupName) { + const group = await calendarGroup.getUserCalendarGroupByName(userIdentifier!, args.options.calendarGroupName, 'id'); + calendarGroupId = group.id; + } + + const url = `${this.resource}/v1.0/users('${userIdentifier}')/${calendarGroupId ? `calendarGroups/${calendarGroupId}/` : ''}calendars`; + const calendars = await odata.getAllItems(url); + + await logger.log(calendars); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new OutlookCalendarListCommand(); \ No newline at end of file