From 7df323b834f82c5a858ca2216a115bcb7a2773cc Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sun, 29 Mar 2026 17:11:44 +0200 Subject: [PATCH 1/2] Adds 'outlook event list' command. Closes #7083 --- docs/docs/cmd/outlook/event/event-list.mdx | 245 +++++++++++ docs/src/config/sidebars.ts | 9 + src/m365/outlook/commands.ts | 1 + .../outlook/commands/event/event-list.spec.ts | 414 ++++++++++++++++++ src/m365/outlook/commands/event/event-list.ts | 137 ++++++ src/utils/calendar.spec.ts | 189 ++++++++ src/utils/calendar.ts | 47 ++ 7 files changed, 1042 insertions(+) create mode 100644 docs/docs/cmd/outlook/event/event-list.mdx create mode 100644 src/m365/outlook/commands/event/event-list.spec.ts create mode 100644 src/m365/outlook/commands/event/event-list.ts create mode 100644 src/utils/calendar.spec.ts create mode 100644 src/utils/calendar.ts diff --git a/docs/docs/cmd/outlook/event/event-list.mdx b/docs/docs/cmd/outlook/event/event-list.mdx new file mode 100644 index 00000000000..f45013e7171 --- /dev/null +++ b/docs/docs/cmd/outlook/event/event-list.mdx @@ -0,0 +1,245 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# event list + +Retrieves a list of events from a specific calendar of a user. + +## Usage + +```sh +m365 outlook event 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. + +`--calendarId [calendarId]` +: ID of the calendar. Specify either `calendarId` or `calendarName`, but not both. + +`--calendarName [calendarName]` +: Name of the calendar. Specify either `calendarId` or `calendarName`, but not both. + +`--startDateTime [startDateTime]` +: Time indicating the inclusive start of a time range when the event starts. + +`--endDateTime [endDateTime]` +: Time indicating the exclusive end of a time range when the event starts. + +`--timeZone [timeZone]` +: The time zone for the event start and end times. + +`--properties [properties]` +: Comma-separated list of properties to retrieve. + +`--filter [filter]` +: OData filter to apply when retrieving the events. +``` + + + +## Remarks + +:::info + +When you specify a value for `timeZone`, consider the options of the [time zone list](https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/default-time-zones?view=windows-11#time-zones), or [additional time zone list](https://learn.microsoft.com/en-us/graph/api/resources/datetimetimezone?view=graph-rest-1.0#additional-time-zones) + +::: + +## Permissions + + + + + | Resource | Permissions | + |-----------------|-------------------------------------| + | Microsoft Graph | Calendars.ReadBasic, Calendars.Read | + + + + + | Resource | Permissions | + |-----------------|-------------------------------------| + | Microsoft Graph | Calendars.ReadBasic, Calendars.Read | + + + + + +## Examples + +List all events for the current signed-in user from a calendar specified by id. + +```sh +m365 outlook event list --userId "@meId" --calendarId "AAMkAGRkZ" +``` + +List all events for the current signed-in user from a calendar specified by id and return event times in Pacific Standard Time time zone. + +```sh +m365 outlook event list --userId "@meId" --calendarId "AAMkAGRkZ" --timeZone 'Pacific Standard Time' +``` + +List only id, subject, start time and end time of all events for a specific user and specific calendar + +```sh +m365 outlook event list --userName "john.doe@contoso.com" --calendarName "Calendar" --properties "id,subject,start,end" +``` + +Filter events for the current signed-in user + +```sh +m365 outlook event list --userId "@meId" --calendarId "AAMkAGRkZ" --filter "contains(subject, 'contoso')" +``` + +List all events from specific date range + +```sh +m365 outlook event list --userId "@meId" --calendarId "AAMkAGRkZ" --startDateTime '2026-01-01' --endDateTime '2026-01-31' +``` + +## Response + + + + + ```json + [ + { + "id": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07AC6GQ5pgAAAA==", + "createdDateTime": "2026-03-29T13:57:47.9194633Z", + "lastModifiedDateTime": "2026-03-29T13:59:48.6329479Z", + "changeKey": "fJKVL07sbkmIfHqjbDnRgQAC54IeWA==", + "categories": [], + "transactionId": "localevent:c95ac848-7295-ad3e-ee1e-f3832b10bf3e", + "originalStartTimeZone": "Greenwich Standard Time", + "originalEndTimeZone": "Greenwich Standard Time", + "iCalUId": "040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9", + "uid": "040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9", + "reminderMinutesBeforeStart": 15, + "isReminderOn": true, + "hasAttachments": false, + "subject": "Retro", + "bodyPreview": "Retrospective", + "importance": "normal", + "sensitivity": "normal", + "isAllDay": false, + "isCancelled": false, + "isOrganizer": true, + "responseRequested": true, + "seriesMasterId": null, + "showAs": "busy", + "type": "singleInstance", + "webLink": "https://outlook.office365.com/owa/?itemid=AQMkAGRlM2Y%3D%3D&exvsurl=1&path=/calendar/item", + "onlineMeetingUrl": null, + "isOnlineMeeting": false, + "onlineMeetingProvider": "unknown", + "allowNewTimeProposals": true, + "occurrenceId": null, + "isDraft": false, + "hideAttendees": false, + "responseStatus": { + "response": "organizer", + "time": "0001-01-01T00:00:00Z" + }, + "body": { + "contentType": "html", + "content": "\r\\\n\r\\\n\r\\\n\r\\\n\r\\\n
\r\\\nRetrospective
\r\\\n\r\\\n\r\\\n" + }, + "start": { + "dateTime": "2026-03-29T16:00:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2026-03-29T18:00:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [], + "organizer": { + "emailAddress": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }, + "onlineMeeting": null + } + ] + ``` + +
+ + + ```text + id subject + -------------------------------------------------------------------------------------------------------------------------------------------------------- ------- + AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07AC6GQ5pgAAAA== Retro + ``` + + + + + ```csv + id,createdDateTime,lastModifiedDateTime,changeKey,transactionId,originalStartTimeZone,originalEndTimeZone,iCalUId,uid,reminderMinutesBeforeStart,isReminderOn,hasAttachments,subject,bodyPreview,importance,sensitivity,isAllDay,isCancelled,isOrganizer,responseRequested,seriesMasterId,showAs,type,webLink,onlineMeetingUrl,isOnlineMeeting,onlineMeetingProvider,allowNewTimeProposals,occurrenceId,isDraft,hideAttendees,recurrence,onlineMeeting + AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07AC6GQ5pgAAAA==,2026-03-29T13:57:47.9194633Z,2026-03-29T13:59:48.6329479Z,fJKVL07sbkmIfHqjbDnRgQAC54IeWA==,localevent:c95ac848-7295-ad3e-ee1e-f3832b10bf3e,Greenwich Standard Time,Greenwich Standard Time,040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9,040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9,15,1,0,Retro,Retrospective,normal,normal,0,0,1,1,,busy,singleInstance,https://outlook.office365.com/owa/?itemid=AQMkAGRlM2Y=1&path=/calendar/item,,0,unknown,1,,0,0,, + ``` + + + + + ```md + # outlook event list --debug "false" --verbose "false" --userId "893f9116-e024-4bc6-8e98-54c245129485" --startDateTime "2026-03-29" --endDateTime "2026-03-31" + + Date: 3/29/2026 + + ## AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07AC6GQ5pgAAAA== + + Property | Value + ---------|------- + id | AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07AC6GQ5pgAAAA== + createdDateTime | 2026-03-29T13:57:47.9194633Z + lastModifiedDateTime | 2026-03-29T13:59:48.6329479Z + changeKey | fJKVL07sbkmIfHqjbDnRgQAC54IeWA== + transactionId | localevent:c95ac848-7295-ad3e-ee1e-f3832b10bf3e + originalStartTimeZone | Greenwich Standard Time + originalEndTimeZone | Greenwich Standard Time + iCalUId | 040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9 + uid | 040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9 + reminderMinutesBeforeStart | 15 + isReminderOn | true + hasAttachments | false + subject | Retro + bodyPreview | Retrospective + importance | normal + sensitivity | normal + isAllDay | false + isCancelled | false + isOrganizer | true + responseRequested | true + showAs | busy + type | singleInstance + webLink | https://outlook.office365.com/owa/?itemid=AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgENAAAAfJKVL07sbkmIfHqjbDnRgQAC6GQ5pgAAAA%3D%3D&exvsurl=1&path=/calendar/item + isOnlineMeeting | false + onlineMeetingProvider | unknown + allowNewTimeProposals | true + isDraft | false + hideAttendees | false + ``` + + +
diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 55e13dca27c..633678cdc37 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1311,6 +1311,15 @@ const sidebars: SidebarsConfig = { } ] }, + { + event: [ + { + type: 'doc', + label: 'event list', + id: 'cmd/outlook/event/event-list' + } + ] + }, { mail: [ { diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index 79e8dc9f0d6..e21d2d43f13 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -2,6 +2,7 @@ const prefix: string = 'outlook'; export default { CALENDARGROUP_LIST: `${prefix} calendargroup list`, + EVENT_LIST: `${prefix} event list`, MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`, MAIL_SEND: `${prefix} mail send`, MAILBOX_SETTINGS_GET: `${prefix} mailbox settings get`, diff --git a/src/m365/outlook/commands/event/event-list.spec.ts b/src/m365/outlook/commands/event/event-list.spec.ts new file mode 100644 index 00000000000..2131348853c --- /dev/null +++ b/src/m365/outlook/commands/event/event-list.spec.ts @@ -0,0 +1,414 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { cli } from '../../../../cli/cli.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 './event-list.js'; +import { calendar } from '../../../../utils/calendar.js'; + +describe(commands.EVENT_LIST, () => { + const userId = 'b743445a-112c-4fda-9afd-05943f9c7b36'; + const userName = 'john.doe@contoso.com'; + const calendarId = 'AAMkAGI2AAATZQAAA='; + const calendarName = 'My Calendar'; + + const eventsResponse = [ + { + "id": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgENAAAAfJKVL07sbkmIfHqjbDnRgQAC6GQ5pgAAAA==", + "createdDateTime": "2026-03-29T13:57:47.9194633Z", + "lastModifiedDateTime": "2026-03-29T13:59:48.6329479Z", + "changeKey": "fJKVL07sbkmIfHqjbDnRgQAC54IeWA==", + "categories": [], + "transactionId": "localevent:c95ac848-7295-ad3e-ee1e-f3832b10bf3e", + "originalStartTimeZone": "Greenwich Standard Time", + "originalEndTimeZone": "Greenwich Standard Time", + "iCalUId": "040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9", + "uid": "040000008200E00074C5B7101A82E008000000006B71750684BFDC01000000000000000010000000872F2916501A8442A7DB64D2E460E3D9", + "reminderMinutesBeforeStart": 15, + "isReminderOn": true, + "hasAttachments": false, + "subject": "Pub", + "bodyPreview": "sdfsdfsdfsdfdsfsdfsdfsd", + "importance": "normal", + "sensitivity": "normal", + "isAllDay": false, + "isCancelled": false, + "isOrganizer": true, + "responseRequested": true, + "seriesMasterId": null, + "showAs": "busy", + "type": "singleInstance", + "webLink": "https://outlook.office365.com/owa/?itemid=AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgENAAAAfJKVL07sbkmIfHqjbDnRgQAC6GQ5pgAAAA%3D%3D&exvsurl=1&path=/calendar/item", + "onlineMeetingUrl": null, + "isOnlineMeeting": false, + "onlineMeetingProvider": "unknown", + "allowNewTimeProposals": true, + "occurrenceId": null, + "isDraft": false, + "hideAttendees": false, + "responseStatus": { + "response": "organizer", + "time": "0001-01-01T00:00:00Z" + }, + "body": { + "contentType": "html", + "content": "\r\\\n\r\\\n\r\\\n\r\\\n\r\\\n
\r\\\nsdfsdfsdfsdfdsfsdfsdfsd
\r\\\n\r\\\n\r\\\n" + }, + "start": { + "dateTime": "2026-03-29T16:00:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2026-03-29T18:00:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [], + "organizer": { + "emailAddress": { + "name": "Martin Macháček", + "address": "MartinMachacek@4wrvkx.onmicrosoft.com" + } + }, + "onlineMeeting": null + }, + { + "id": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgENAAAAfJKVL07sbkmIfHqjbDnRgQAC6GQ5pQAAAA==", + "createdDateTime": "2026-03-29T13:57:18.3565941Z", + "lastModifiedDateTime": "2026-03-29T13:57:19.5423408Z", + "changeKey": "fJKVL07sbkmIfHqjbDnRgQAC54IdjA==", + "categories": [], + "transactionId": "localevent:0209423d-9958-b2db-5fcc-39360518b2b8", + "originalStartTimeZone": "Greenwich Standard Time", + "originalEndTimeZone": "Greenwich Standard Time", + "iCalUId": "040000008200E00074C5B7101A82E00800000000C699D6F483BFDC0100000000000000001000000035C1CD3344304A40ACDF54500FE2F871", + "uid": "040000008200E00074C5B7101A82E00800000000C699D6F483BFDC0100000000000000001000000035C1CD3344304A40ACDF54500FE2F871", + "reminderMinutesBeforeStart": 15, + "isReminderOn": true, + "hasAttachments": false, + "subject": "Testik", + "bodyPreview": "", + "importance": "normal", + "sensitivity": "normal", + "isAllDay": false, + "isCancelled": false, + "isOrganizer": true, + "responseRequested": true, + "seriesMasterId": null, + "showAs": "busy", + "type": "singleInstance", + "webLink": "https://outlook.office365.com/owa/?itemid=AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YWMyLTJhZGY2MGExMGU0MgBGAAADSG3wPE27kUeySjmT5eRT8QcAfJKVL07sbkmIfHqjbDnRgQAAAgENAAAAfJKVL07sbkmIfHqjbDnRgQAC6GQ5pQAAAA%3D%3D&exvsurl=1&path=/calendar/item", + "onlineMeetingUrl": null, + "isOnlineMeeting": false, + "onlineMeetingProvider": "unknown", + "allowNewTimeProposals": true, + "occurrenceId": null, + "isDraft": false, + "hideAttendees": false, + "responseStatus": { + "response": "organizer", + "time": "0001-01-01T00:00:00Z" + }, + "body": { + "contentType": "html", + "content": "" + }, + "start": { + "dateTime": "2026-03-30T14:30:00.0000000", + "timeZone": "UTC" + }, + "end": { + "dateTime": "2026-03-30T15:00:00.0000000", + "timeZone": "UTC" + }, + "location": { + "displayName": "", + "locationType": "default", + "uniqueIdType": "unknown", + "address": {}, + "coordinates": {} + }, + "locations": [], + "recurrence": null, + "attendees": [], + "organizer": { + "emailAddress": { + "name": "Martin Macháček", + "address": "MartinMachacek@4wrvkx.onmicrosoft.com" + } + }, + "onlineMeeting": null + } + ]; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let loggerLogSpy: sinon.SinonSpy; + 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; + if (!auth.connection.accessTokens[auth.defaultResource]) { + auth.connection.accessTokens[auth.defaultResource] = { + expiresOn: 'abc', + accessToken: 'abc' + }; + } + 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.get, + calendar.getUserCalendarByName + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.EVENT_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct properties for the default output', () => { + assert.deepStrictEqual(command.defaultProperties(), ['id', 'subject']); + }); + + it('passes validation with no options', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with userId', () => { + const actual = commandOptionsSchema.safeParse({ userId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with userName', () => { + const actual = commandOptionsSchema.safeParse({ userName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ userId, userName }); + assert.notStrictEqual(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 calendarId and calendarName are specified', () => { + const actual = commandOptionsSchema.safeParse({ calendarId, calendarName }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if startDateTime is not a valid ISO date-time', () => { + const actual = commandOptionsSchema.safeParse({ startDateTime: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if endDateTime is not a valid ISO date-time', () => { + const actual = commandOptionsSchema.safeParse({ endDateTime: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('retrieves events for the user specified by id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events`) { + return { + value: eventsResponse + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ userId: userId, verbose: true }) }); + assert(loggerLogSpy.calledOnceWith(eventsResponse)); + }); + + it('retrieves filtered events in specific time zone for the user specified by UPN from a calendar specified by id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendars/${calendarId}/events?$filter=contains(subject, 'contoso')`) { + return { + value: eventsResponse + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + userName: userName, + calendarId: calendarId, + timeZone: 'Pacific Standard Time', + filter: "contains(subject, 'contoso')", + verbose: true + }) + }); + assert(loggerLogSpy.calledOnceWith(eventsResponse)); + }); + + it('retrieves filtered events since the specified date for the user specified by UPN from a calendar specified by id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendars/${calendarId}/events?$filter=contains(subject, 'contoso') and start/dateTime ge '2026-03-29T00:00:00Z'`) { + return { + value: eventsResponse + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + userName: userName, + calendarId: calendarId, + startDateTime: '2026-03-29T00:00:00Z', + filter: "contains(subject, 'contoso')", + verbose: true + }) + }); + assert(loggerLogSpy.calledOnceWith(eventsResponse)); + }); + + it('retrieves limited properties of events since the specified date for the user specified by id from a calendar specified by name', async () => { + sinon.stub(calendar, 'getUserCalendarByName').resolves({ id: calendarId }); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}/events?$select=id,subject,start,end&$filter=start/dateTime ge '2026-03-29T00:00:00Z'`) { + return { + value: eventsResponse + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + userId: userId, + calendarName: calendarName, + startDateTime: '2026-03-29T00:00:00Z', + properties: 'id,subject,start,end', + verbose: true + }) + }); + assert(loggerLogSpy.calledOnceWith(eventsResponse)); + }); + + it('retrieves limited properties of events till the specified date for the user specified by id from a calendar specified by name', async () => { + sinon.stub(calendar, 'getUserCalendarByName').resolves({ id: calendarId }); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}/events?$select=id,subject,start,end&$filter=end/dateTime le '2026-03-31T00:00:00Z'`) { + return { + value: eventsResponse + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + userId: userId, + calendarName: calendarName, + endDateTime: '2026-03-31T00:00:00Z', + properties: 'id,subject,start,end', + verbose: true + }) + }); + assert(loggerLogSpy.calledOnceWith(eventsResponse)); + }); + + it('retrieves events in specific date range for the user specified by id', async () => { + sinon.stub(calendar, 'getUserCalendarById').resolves({ id: calendarId }); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events?$expand=attachments($select=id)&$filter=start/dateTime ge '2026-03-29T00:00:00Z' and end/dateTime le '2026-03-31T00:00:00Z'`) { + return { + value: eventsResponse + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + userId: userId, + startDateTime: '2026-03-29T00:00:00Z', + endDateTime: '2026-03-31T00:00:00Z', + properties: 'attachments/id', + verbose: true + }) + }); + assert(loggerLogSpy.calledOnceWith(eventsResponse)); + }); + + it('correctly handles API OData error', async () => { + const errorMessage = 'Something went wrong'; + sinon.stub(request, 'get').rejects({ error: { error: { message: errorMessage } } }); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({}) }), + new CommandError(errorMessage) + ); + }); +}); \ No newline at end of file diff --git a/src/m365/outlook/commands/event/event-list.ts b/src/m365/outlook/commands/event/event-list.ts new file mode 100644 index 00000000000..d16a4bc97b5 --- /dev/null +++ b/src/m365/outlook/commands/event/event-list.ts @@ -0,0 +1,137 @@ +import { Event } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import { odata } from '../../../../utils/odata.js'; +import { CliRequestOptions } from '../../../../request.js'; +import { calendar } from '../../../../utils/calendar.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + userId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }).optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid UPN.` + }).optional(), + calendarId: z.string().optional(), + calendarName: z.string().optional(), + startDateTime: z.string().refine(date => validation.isValidISODateTime(date), { + error: e => `'${e.input}' is not a valid ISO date-time.` + }).optional(), + endDateTime: z.string().refine(date => validation.isValidISODateTime(date), { + error: e => `'${e.input}' is not a valid ISO date-time.` + }).optional(), + timeZone: z.string().optional(), + properties: z.string().optional(), + filter: z.string().optional() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookEventListCommand extends GraphCommand { + public get name(): string { + return commands.EVENT_LIST; + } + + public get description(): string { + return 'Retrieves a list of events from a specific calendar of 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.' + }) + .refine(options => !(options.calendarId && options.calendarName), { + error: 'Specify either calendarId or calendarName, but not both.' + }); + } + + public defaultProperties(): string[] | undefined { + return ['id', 'subject']; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + let events; + const endpoint = await this.getRequestUrl(args.options); + if (args.options.timeZone) { + const requestOptions: CliRequestOptions = { + url: endpoint, + headers: { + accept: 'application/json;odata.metadata=none', + Prefer: `outlook.timezone = "${args.options.timeZone}"` + }, + responseType: 'json' + }; + + events = await odata.getAllItems(requestOptions); + } + else { + events = await odata.getAllItems(endpoint); + } + + await logger.log(events); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getRequestUrl(options: Options): Promise { + const queryParameters: string[] = []; + + if (options.properties) { + const allProperties = options.properties.split(','); + const selectProperties = allProperties.filter(prop => !prop.includes('/')); + const expandProperties = allProperties.filter(prop => prop.includes('/')); + + if (selectProperties.length > 0) { + queryParameters.push(`$select=${selectProperties}`); + } + + if (expandProperties.length > 0) { + const fieldExpands = expandProperties.map(p => `${p.split('/')[0]}($select=${p.split('/')[1]})`); + queryParameters.push(`$expand=${fieldExpands.join(',')}`); + } + } + + if (options.filter || options.startDateTime || options.endDateTime) { + let filter = options.filter || ''; + if (options.startDateTime) { + filter += `${filter ? ' and ' : ''}start/dateTime ge '${options.startDateTime}'`; + } + if (options.endDateTime) { + filter += `${filter ? ' and ' : ''}end/dateTime le '${options.endDateTime}'`; + } + queryParameters.push(`$filter=${filter}`); + } + + const queryString = queryParameters.length > 0 + ? `?${queryParameters.join('&')}` + : ''; + + const userIdentifier = options.userId ?? options.userName; + let calendarId = options.calendarId; + if (options.calendarName) { + calendarId = (await calendar.getUserCalendarByName(userIdentifier!, options.calendarName))!.id; + } + return calendarId + ? `${this.resource}/v1.0/users('${userIdentifier}')/calendars/${calendarId}/events${queryString}` + : `${this.resource}/v1.0/users('${userIdentifier}')/events${queryString}`; + } +} + +export default new OutlookEventListCommand(); \ No newline at end of file diff --git a/src/utils/calendar.spec.ts b/src/utils/calendar.spec.ts new file mode 100644 index 00000000000..fed0380396c --- /dev/null +++ b/src/utils/calendar.spec.ts @@ -0,0 +1,189 @@ +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 { calendar } from './calendar.js'; +import { formatting } from './formatting.js'; +import { settingsNames } from '../settingsNames.js'; + +describe('utils/calendar', () => { + const userId = '729827e3-9c14-49f7-bb1b-9608f156bbb8'; + const calendarId = 'AAMkAGI2TGuLAAA'; + const calendarName = 'My Calendar'; + const invalidCalendarName = 'M Calnedar'; + const calendarGroupId = 'AQMkADIxYjJiYgEzLTFmN_F8AAAIBBgAA_F8AAAJjIQAAAA=='; + const calendarResponse = { + "id": "AAMkAGI2TGuLAAA=", + "name": "Calendar", + "color": "auto", + "isDefaultCalendar": true, + "changeKey": "nfZyf7VcrEKLNoU37KWlkQAAA0x0+w==", + "canShare": true, + "canViewPrivateItems": true, + "hexColor": "", + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }; + const anotherCalendarResponse = { + "id": "AAMkAGI2TGuLBBB=", + "name": "Vacation", + "color": "auto", + "isDefaultCalendar": false, + "changeKey": "abcdf7VcrEKLNoU37KWlkQAAA0x0+w==", + "canShare": false, + "canViewPrivateItems": true, + "hexColor": "", + "canEdit": true, + "allowedOnlineMeetingProviders": [ + ], + "defaultOnlineMeetingProvider": "none", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }; + const calendarLimitedResponse = { + "id": "AAMkAGI2TGuLAAA=", + "name": "Calendar", + "color": "auto" + }; + + afterEach(() => { + sinonUtil.restore([ + request.get, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound + ]); + }); + + it('correctly get single calendar by name using getUserCalendarByName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars?$filter=name eq '${formatting.encodeQueryParameter(calendarName)}'`) { + return { + value: [ + calendarResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await calendar.getUserCalendarByName(userId, calendarName); + assert.deepStrictEqual(actual, calendarResponse); + }); + + it('correctly get single calendar by name from a calendar group using getUserCalendarByName with specified properties', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}/calendars?$filter=name eq '${formatting.encodeQueryParameter(calendarName)}'&$select=id,name`) { + return { + value: [ + calendarLimitedResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await calendar.getUserCalendarByName(userId, calendarName, calendarGroupId, 'id,name'); + assert.deepStrictEqual(actual, calendarLimitedResponse); + }); + + it('handles selecting single calendar when multiple calendars with the specified name found using getUserCalendarByName 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}')/calendars?$filter=name eq '${formatting.encodeQueryParameter(calendarName)}'`) { + return { + value: [ + calendarResponse, + anotherCalendarResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(calendarResponse); + + const actual = await calendar.getUserCalendarByName(userId, calendarName); + assert.deepStrictEqual(actual, calendarResponse); + }); + + it('throws error message when no calendar was found using getUserCalendarByName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars?$filter=name eq '${formatting.encodeQueryParameter(invalidCalendarName)}'`) { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(calendar.getUserCalendarByName(userId, invalidCalendarName), + new Error(`The specified calendar '${invalidCalendarName}' does not exist.`)); + }); + + it('throws error message when multiple calendars were found using getUserCalendarByName', 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}')/calendars?$filter=name eq '${formatting.encodeQueryParameter(calendarName)}'`) { + return { + value: [ + calendarResponse, + anotherCalendarResponse + ] + }; + } + + return 'Invalid Request'; + }); + + await assert.rejects(calendar.getUserCalendarByName(userId, calendarName), + Error(`Multiple calendars with name '${calendarName}' found. Found: ${calendarResponse.id}, ${anotherCalendarResponse.id}.`)); + }); + + it('correctly get single calendar by id using getUserCalendarById', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid Request'; + }); + + const actual = await calendar.getUserCalendarById(userId, calendarId); + assert.deepStrictEqual(actual, calendarResponse); + }); + + it('correctly get single calendar by id from a calendar group using getUserCalendarById with specified properties', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}/calendars/${calendarId}?$select=id,displayName`) { + return calendarLimitedResponse; + } + + throw 'Invalid Request'; + }); + + const actual = await calendar.getUserCalendarById(userId, calendarId, calendarGroupId, 'id,displayName'); + assert.deepStrictEqual(actual, calendarLimitedResponse); + }); +}); diff --git a/src/utils/calendar.ts b/src/utils/calendar.ts new file mode 100644 index 00000000000..b01d81c6cd7 --- /dev/null +++ b/src/utils/calendar.ts @@ -0,0 +1,47 @@ +import { Calendar } from '@microsoft/microsoft-graph-types'; +import { odata } from './odata.js'; +import { formatting } from './formatting.js'; +import { cli } from '../cli/cli.js'; +import request, { CliRequestOptions } from '../request.js'; + +export const calendar = { + async getUserCalendarById(userId: string, calendarId: string, calendarGroupId?: string, properties?: string): Promise { + let url = `https://graph.microsoft.com/v1.0/users('${userId}')/${calendarGroupId ? `calendarGroups/${calendarGroupId}/` : ''}calendars/${calendarId}`; + + if (properties) { + url += `?$select=${properties}`; + } + + const requestOptions: CliRequestOptions = { + url: url, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + return await request.get(requestOptions); + }, + + async getUserCalendarByName(userId: string, name: string, calendarGroupId?: string, properties?: string): Promise { + let url = `https://graph.microsoft.com/v1.0/users('${userId}')/${calendarGroupId ? `calendarGroups/${calendarGroupId}/` : ''}calendars?$filter=name eq '${formatting.encodeQueryParameter(name)}'`; + + if (properties) { + url += `&$select=${properties}`; + } + + const calendars = await odata.getAllItems(url); + + if (calendars.length === 0) { + throw new Error(`The specified calendar '${name}' does not exist.`); + } + + if (calendars.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', calendars); + const selectedCalendar = await cli.handleMultipleResultsFound(`Multiple calendars with name '${name}' found.`, resultAsKeyValuePair); + return selectedCalendar; + } + + return calendars[0]; + } +}; From 1c21e6570c0f2faa6a0124a6dff48a72fd7b0f46 Mon Sep 17 00:00:00 2001 From: Martin Machacek Date: Sat, 4 Apr 2026 15:55:52 +0200 Subject: [PATCH 2/2] Adds 'outlook event list' command. Closes #7083 --- .../outlook/commands/event/event-list.spec.ts | 18 ++++++++++-------- src/m365/outlook/commands/event/event-list.ts | 12 ++++++++---- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/m365/outlook/commands/event/event-list.spec.ts b/src/m365/outlook/commands/event/event-list.spec.ts index 2131348853c..1bf9d6560a4 100644 --- a/src/m365/outlook/commands/event/event-list.spec.ts +++ b/src/m365/outlook/commands/event/event-list.spec.ts @@ -219,11 +219,6 @@ describe(commands.EVENT_LIST, () => { assert.deepStrictEqual(command.defaultProperties(), ['id', 'subject']); }); - it('passes validation with no options', () => { - const actual = commandOptionsSchema.safeParse({}); - assert.strictEqual(actual.success, true); - }); - it('passes validation with userId', () => { const actual = commandOptionsSchema.safeParse({ userId }); assert.strictEqual(actual.success, true); @@ -239,6 +234,13 @@ describe(commands.EVENT_LIST, () => { assert.notStrictEqual(actual.success, true); }); + it('fails validation if neither userId nor userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + id: calendarId + }); + assert.notStrictEqual(actual.success, true); + }); + it('fails validation if userId is not a valid GUID', () => { const actual = commandOptionsSchema.safeParse({ userId: 'foo' }); assert.notStrictEqual(actual.success, true); @@ -357,7 +359,7 @@ describe(commands.EVENT_LIST, () => { it('retrieves limited properties of events till the specified date for the user specified by id from a calendar specified by name', async () => { sinon.stub(calendar, 'getUserCalendarByName').resolves({ id: calendarId }); sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}/events?$select=id,subject,start,end&$filter=end/dateTime le '2026-03-31T00:00:00Z'`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}/events?$select=id,subject,start,end&$filter=start/dateTime lt '2026-03-31T00:00:00Z'`) { return { value: eventsResponse }; @@ -381,7 +383,7 @@ describe(commands.EVENT_LIST, () => { it('retrieves events in specific date range for the user specified by id', async () => { sinon.stub(calendar, 'getUserCalendarById').resolves({ id: calendarId }); sinon.stub(request, 'get').callsFake(async (opts) => { - if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events?$expand=attachments($select=id)&$filter=start/dateTime ge '2026-03-29T00:00:00Z' and end/dateTime le '2026-03-31T00:00:00Z'`) { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/events?$expand=attachments($select=id)&$filter=start/dateTime ge '2026-03-29T00:00:00Z' and start/dateTime lt '2026-03-31T00:00:00Z'`) { return { value: eventsResponse }; @@ -407,7 +409,7 @@ describe(commands.EVENT_LIST, () => { sinon.stub(request, 'get').rejects({ error: { error: { message: errorMessage } } }); await assert.rejects( - command.action(logger, { options: commandOptionsSchema.parse({}) }), + command.action(logger, { options: commandOptionsSchema.parse({ userId: userId }) }), new CommandError(errorMessage) ); }); diff --git a/src/m365/outlook/commands/event/event-list.ts b/src/m365/outlook/commands/event/event-list.ts index d16a4bc97b5..5892f82cd3e 100644 --- a/src/m365/outlook/commands/event/event-list.ts +++ b/src/m365/outlook/commands/event/event-list.ts @@ -51,8 +51,8 @@ class OutlookEventListCommand extends GraphCommand { 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.' + .refine(options => [options.userId, options.userName].filter(x => x !== undefined).length === 1, { + error: 'Specify either userId or userName, but not both' }) .refine(options => !(options.calendarId && options.calendarName), { error: 'Specify either calendarId or calendarName, but not both.' @@ -65,6 +65,10 @@ class OutlookEventListCommand extends GraphCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { try { + if (this.verbose) { + await logger.logToStderr('Getting a list of the events...'); + } + let events; const endpoint = await this.getRequestUrl(args.options); if (args.options.timeZone) { @@ -72,7 +76,7 @@ class OutlookEventListCommand extends GraphCommand { url: endpoint, headers: { accept: 'application/json;odata.metadata=none', - Prefer: `outlook.timezone = "${args.options.timeZone}"` + Prefer: `outlook.timezone="${args.options.timeZone}"` }, responseType: 'json' }; @@ -114,7 +118,7 @@ class OutlookEventListCommand extends GraphCommand { filter += `${filter ? ' and ' : ''}start/dateTime ge '${options.startDateTime}'`; } if (options.endDateTime) { - filter += `${filter ? ' and ' : ''}end/dateTime le '${options.endDateTime}'`; + filter += `${filter ? ' and ' : ''}start/dateTime lt '${options.endDateTime}'`; } queryParameters.push(`$filter=${filter}`); }