// Name: Export Outlook Meetings to Markdown // Description: Fetch your Outlook calendar events via Microsoft Graph and save them as a Markdown file. // Author: okeefj22 // GitHub: okeefj22 import "@johnlindquist/kit" import { PublicClientApplication, LogLevel, type DeviceCodeRequest } from "@azure/msal-node" type GraphEvent = { subject?: string bodyPreview?: string start: { dateTime: string; timeZone: string } end: { dateTime: string; timeZone: string } location?: { displayName?: string } organizer?: { emailAddress?: { name?: string; address?: string } } attendees?: { emailAddress: { name?: string; address?: string }; type?: string }[] isOnlineMeeting?: boolean onlineMeeting?: { joinUrl?: string } webLink?: string } const CLIENT_ID = await env("MSAL_CLIENT_ID", { placeholder: "Enter your Azure AD App (client) ID", hint: md(` - Create an "App registration" in Azure Portal (Public client OK) - Allow "Mobile and desktop applications" with device code flow enabled - Add API permissions: "Microsoft Graph" -> "Delegated" -> "Calendars.Read" `), }) const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" // Quick date range picker type RangeOpt = "Today" | "This Week" | "Next 7 Days" | "Pick Range" const rangeChoice = await arg<RangeOpt>("Select date range", [ "Today", "This Week", "Next 7 Days", "Pick Range", ]) const startEndFromChoice = async () => { const now = new Date() const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0) const endOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59) if (rangeChoice === "Today") { return { start: startOfDay(now), end: endOfDay(now) } } if (rangeChoice === "This Week") { const day = now.getDay() // 0=Sun const diffToMonday = (day + 6) % 7 const monday = new Date(now) monday.setDate(now.getDate() - diffToMonday) const sunday = new Date(monday) sunday.setDate(monday.getDate() + 6) return { start: startOfDay(monday), end: endOfDay(sunday) } } if (rangeChoice === "Next 7 Days") { const start = startOfDay(now) const end = new Date(start) end.setDate(start.getDate() + 6) return { start, end: endOfDay(end) } } // Pick Range const defaultStart = new Date() const defaultEnd = new Date() defaultEnd.setDate(defaultStart.getDate() + 7) const [startInput, endInput] = await fields([ { label: "Start date", type: "date", value: formatDate(defaultStart, "yyyy-MM-dd"), }, { label: "End date", type: "date", value: formatDate(defaultEnd, "yyyy-MM-dd"), }, ]) const parseLocalDate = (s: string) => { // yyyy-MM-dd -> local midnight const [y, m, d] = s.split("-").map(Number) return new Date(y, (m || 1) - 1, d || 1, 0, 0, 0) } const s = parseLocalDate(startInput || formatDate(defaultStart, "yyyy-MM-dd")) const e = parseLocalDate(endInput || formatDate(defaultEnd, "yyyy-MM-dd")) // Extend end to end-of-day e.setHours(23, 59, 59, 999) return { start: s, end: e } } const { start, end } = await startEndFromChoice() // MSAL Device Code auth const pca = new PublicClientApplication({ auth: { clientId: CLIENT_ID, authority: "https://login.microsoftonline.com/common", }, system: { loggerOptions: { logLevel: LogLevel.Warning, loggerCallback: (level, message) => { if (level >= LogLevel.Error) console.warn(message) }, }, }, }) let devicePanelSet = false const deviceCodeRequest: DeviceCodeRequest = { scopes: ["User.Read", "Calendars.Read"], deviceCodeCallback: async info => { const html = md(` ### Sign in to Microsoft 1) Open: ${info.verificationUri} 2) Enter code: \`${info.userCode}\` Expires: ${Math.round(info.expiresIn / 60)} min `) if (!devicePanelSet) { devicePanelSet = true await div(html) } else { await setPanel(html) } }, } const tokenResponse = await pca.acquireTokenByDeviceCode(deviceCodeRequest) if (!tokenResponse?.accessToken) { await div(md(`# Authentication failed`)) exit(1) } const accessToken = tokenResponse.accessToken // Fetch events from Graph (calendarView) const startISO = start.toISOString() const endISO = end.toISOString() let url = `https://graph.microsoft.com/v1.0/me/calendarview` + `?startDateTime=${encodeURIComponent(startISO)}` + `&endDateTime=${encodeURIComponent(endISO)}` + `&$orderby=start/dateTime&$top=50` const allEvents: GraphEvent[] = [] while (url) { const resp = await get(url, { headers: { Authorization: `Bearer ${accessToken}`, Prefer: `outlook.timezone="${tz}"`, }, }) const data = resp?.data || {} const items: GraphEvent[] = data.value || [] allEvents.push(...items) url = data["@odata.nextLink"] || "" } // Build Markdown const fmt = (d: string) => { // Graph returns "YYYY-MM-DDTHH:mm:ss" in the requested tz (no offset) // Treat as local time string const date = new Date(d) // If the string lacks "Z" or offset, Date() treats as local; good for display return formatDate(date, "yyyy-MM-dd HH:mm") } const esc = (s?: string) => (s || "").replace(/\r?\n/g, " ").trim() const header = md(` # Outlook Meetings - Timezone: ${tz} - Range: ${formatDate(start, "yyyy-MM-dd")} → ${formatDate(end, "yyyy-MM-dd")} - Total: ${allEvents.length} `) let body = "" for (const ev of allEvents) { const subject = esc(ev.subject) || "(No subject)" const startStr = fmt(ev.start?.dateTime || "") const endStr = fmt(ev.end?.dateTime || "") const loc = esc(ev.location?.displayName) const orgName = esc(ev.organizer?.emailAddress?.name) const orgEmail = esc(ev.organizer?.emailAddress?.address) const attendees = ev.attendees?.map(a => esc(a.emailAddress?.name) || esc(a.emailAddress?.address)).filter(Boolean) || [] const onlineJoin = (ev as any).onlineMeetingUrl || ev.onlineMeeting?.joinUrl || "" // try both known shapes const outlookLink = ev.webLink || "" body += ` ## ${subject} - When: ${startStr} → ${endStr} (${tz}) ${loc ? `- Location: ${loc}\n` : ""}${orgName || orgEmail ? `- Organizer: ${[orgName, orgEmail].filter(Boolean).join(" <")}${orgEmail ? ">" : ""}\n` : ""}${attendees.length ? `- Attendees: ${attendees.join(", ")}\n` : ""}${onlineJoin ? `- Join: ${onlineJoin}\n` : ""}${outlookLink ? `- Outlook: ${outlookLink}\n` : ""} ` } // Save file const defaultName = `outlook-meetings_${formatDate(start, "yyyy-MM-dd")}_to_${formatDate(end, "yyyy-MM-dd")}.md` const OUTPUT_DIR = await env("MEETINGS_OUTPUT_DIR", async () => { return await path({ placeholder: "Select a folder to save the Markdown file", onlyDirs: true, hint: "Pick your output directory for meeting exports", }) }) const outFilePath = path.join(OUTPUT_DIR, defaultName) await ensureDir(OUTPUT_DIR) await writeFile(outFilePath, `${header}\n${body}`) await notify({ title: "Outlook Meetings Exported", body: path.basename(outFilePath) }) await revealFile(outFilePath)