feat: Exchange with NTLM support (#4127)

* add exchange package

* fix conflicts

* add setup page for v2

* refactor setup page for v1

* return exchange error messages to user if applicable

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
This commit is contained in:
Sascha Schworm
2022-09-14 16:09:00 +00:00
committed by GitHub
co-authored by Peer Richelsen Joe Au-Yeung
parent f1c15f4e8a
commit 91c39a0306
26 changed files with 724 additions and 726 deletions
@@ -1159,5 +1159,10 @@
"connect_calendar_later": "Ich verbinde meinen Kalender später",
"set_my_availability_later": "Ich werde meine Verfügbarkeit später festlegen",
"problem_saving_user_profile": "Beim Speichern Ihrer Daten ist ein Problem aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support.",
"exchange_add": "Mit Microsoft Exchange verbinden",
"exchange_authentication": "Authentifizierungsmethode",
"exchange_authentication_standard": "Basisauthentifizierung",
"exchange_authentication_ntlm": "NTLM-Authentifizierung",
"exchange_compression": "GZip-Kompression",
"token_invalid_expired": "Token ist entweder ungültig oder abgelaufen."
}
@@ -1200,6 +1200,11 @@
"team_disable_cal_branding_description": "Removes any Cal related brandings, i.e. 'Powered by Cal'",
"invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}",
"token_invalid_expired": "Token is either invalid or expired.",
"exchange_add": "Connect to Microsoft Exchange",
"exchange_authentication": "Authentication method",
"exchange_authentication_standard": "Basic authentication",
"exchange_authentication_ntlm": "NTLM authentication",
"exchange_compression": "GZip compression",
"routing_forms_description": "You can see all forms and routes you have created here.",
"add_new_form": "Add new form",
"form_description": "Create your form to route a booker",
@@ -4,6 +4,7 @@ import { DynamicComponent } from "../../_components/DynamicComponent";
export const AppSetupMap = {
"apple-calendar": dynamic(() => import("../../applecalendar/pages/setup")),
exchange: dynamic(() => import("../../exchangecalendar/pages/setup")),
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
@@ -4,6 +4,7 @@ import { DynamicComponent } from "../../../_components/DynamicComponent";
export const AppSetupMap = {
"apple-calendar-V2": dynamic(() => import("../../../applecalendar/pages/v2/setup")),
"exchange-V2": dynamic(() => import("../../../exchangecalendar/pages/v2/setup")),
"exchange2013-calendar-V2": dynamic(() => import("../../../exchange2013calendar/pages/v2/setup")),
"exchange2016-calendar-V2": dynamic(() => import("../../../exchange2016calendar/pages/v2/setup")),
"caldav-calendar-V2": dynamic(() => import("../../../caldavcalendar/pages/v2/setup")),
@@ -13,6 +13,7 @@ import { metadata as dailyvideo_meta } from "./dailyvideo/_metadata";
import { metadata as routing_forms_meta } from "./ee/routing_forms/_metadata";
import { metadata as exchange2013calendar_meta } from "./exchange2013calendar/_metadata";
import { metadata as exchange2016calendar_meta } from "./exchange2016calendar/_metadata";
import { metadata as exchangecalendar_meta } from "./exchangecalendar/_metadata";
import { metadata as giphy_meta } from "./giphy/_metadata";
import { metadata as googlecalendar_meta } from "./googlecalendar/_metadata";
import { metadata as googlevideo_meta } from "./googlevideo/_metadata";
@@ -47,6 +48,7 @@ export const appStoreMetadata = {
routing_forms: routing_forms_meta,
exchange2013calendar: exchange2013calendar_meta,
exchange2016calendar: exchange2016calendar_meta,
exchangecalendar: exchangecalendar_meta,
giphy: giphy_meta,
googlecalendar: googlecalendar_meta,
googlevideo: googlevideo_meta,
@@ -78,6 +80,7 @@ export const InstallAppButtonMap = {
closecomothercalendar: dynamic(() => import("./closecomothercalendar/components/InstallAppButton")),
exchange2013calendar: dynamic(() => import("./exchange2013calendar/components/InstallAppButton")),
exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
exchangecalendar: dynamic(() => import("./exchangecalendar/components/InstallAppButton")),
giphy: dynamic(() => import("./giphy/components/InstallAppButton")),
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
hubspotothercalendar: dynamic(() => import("./hubspotothercalendar/components/InstallAppButton")),
@@ -11,6 +11,7 @@ export const apiHandlers = {
routing_forms: import("./ee/routing_forms/api"),
exchange2013calendar: import("./exchange2013calendar/api"),
exchange2016calendar: import("./exchange2016calendar/api"),
exchangecalendar: import("./exchangecalendar/api"),
giphy: import("./giphy/api"),
googlecalendar: import("./googlecalendar/api"),
hubspotothercalendar: import("./hubspotothercalendar/api"),
@@ -0,0 +1,7 @@
---
description: Fetch Microsoft Exchange calendars and availabilities using Exchange Web Services (EWS).
---
<div>
{description}
</div>
@@ -0,0 +1,15 @@
import type { App } from "@calcom/types/App";
import config from "./config.json";
export const metadata = {
category: "calendar",
installed: true,
rating: 0,
reviews: 0,
trending: true,
verified: true,
...config,
} as App;
export default metadata;
@@ -0,0 +1,5 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ url: "/apps/exchange/setup" });
}
@@ -0,0 +1,45 @@
import { SoapFaultDetails } from "ews-javascript-api";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import checkSession from "../../_utils/auth";
import { ExchangeAuthentication } from "../enums";
import { CalendarService } from "../lib";
const formSchema = z
.object({
url: z.string().url(),
username: z.string().email(),
password: z.string(),
authenticationMethod: z.number().default(ExchangeAuthentication.STANDARD),
useCompression: z.boolean().default(false),
})
.strict();
export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
const session = checkSession(req);
const body = formSchema.parse(req.body);
const encrypted = symmetricEncrypt(JSON.stringify(body), process.env.CALENDSO_ENCRYPTION_KEY || "");
const data = { type: "exchange_calendar", key: encrypted, userId: session.user?.id, appId: "exchange" };
try {
const service = new CalendarService({ id: 0, ...data });
await service?.listCalendars();
await prisma.credential.create({ data });
} catch (reason) {
logger.info(reason);
if (reason instanceof SoapFaultDetails && reason.message != "") {
return res.status(500).json({ message: reason.message });
}
return res.status(500).json({ message: "Could not add this exchange account" });
}
return res.status(200).json({ url: "/apps/installed" });
}
export default defaultResponder(getHandler);
@@ -0,0 +1,6 @@
import { defaultHandler } from "@calcom/lib/server";
export default defaultHandler({
GET: import("./_getAdd"),
POST: import("./_postAdd"),
});
@@ -0,0 +1 @@
export { default as add } from "./add";
@@ -0,0 +1,18 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("exchange_calendar");
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}
@@ -0,0 +1 @@
export { default as InstallAppButton } from "./InstallAppButton";
@@ -0,0 +1,16 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"title": "Microsoft Exchange",
"name": "Microsoft Exchange",
"slug": "exchange",
"type": "exchange_calendar",
"imageSrc": "/api/app-store/exchangecalendar/icon.svg",
"logo": "/api/app-store/exchangecalendar/icon.svg",
"url": "https://cal.com/apps/exchange",
"variant": "calendar",
"categories": ["calendar"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Fetch Microsoft Exchange calendars and availabilities using Exchange Web Services (EWS).",
"__createdUsingCli": true
}
@@ -0,0 +1,4 @@
export enum ExchangeAuthentication {
STANDARD = 0,
NTLM = 1,
}
@@ -0,0 +1,3 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";
@@ -0,0 +1,204 @@
import { XhrApi } from "@ewsjs/xhr";
import { Credential } from "@prisma/client";
import {
Appointment,
Attendee,
BasePropertySet,
CalendarView,
ConflictResolutionMode,
DateTime,
DeleteMode,
ExchangeService,
FindFoldersResults,
FindItemsResults,
Folder,
FolderId,
FolderSchema,
FolderTraversal,
FolderView,
ItemId,
LegacyFreeBusyStatus,
LogicalOperator,
MessageBody,
PropertySet,
SearchFilter,
SendInvitationsMode,
SendInvitationsOrCancellationsMode,
Uri,
WebCredentials,
WellKnownFolderName,
} from "ews-javascript-api";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import {
Calendar,
CalendarEvent,
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
Person,
} from "@calcom/types/Calendar";
import { ExchangeAuthentication } from "../enums";
export default class ExchangeCalendarService implements Calendar {
private integrationName = "";
private log: typeof logger;
private payload;
constructor(credential: Credential) {
this.integrationName = "exchange_calendar";
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
this.payload = JSON.parse(
symmetricDecrypt(credential.key?.toString() || "", process.env.CALENDSO_ENCRYPTION_KEY || "")
);
}
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
const appointment: Appointment = new Appointment(this.getExchangeService());
appointment.Subject = event.title;
appointment.Start = DateTime.Parse(event.startTime);
appointment.End = DateTime.Parse(event.endTime);
appointment.Location = event.location || "";
appointment.Body = new MessageBody(event.description || "");
event.attendees.forEach((attendee: Person) => {
appointment.RequiredAttendees.Add(new Attendee(attendee.email));
});
return appointment
.Save(SendInvitationsMode.SendToAllAndSaveCopy)
.then(() => {
return {
uid: appointment.Id.UniqueId,
id: appointment.Id.UniqueId,
password: "",
type: "",
url: "",
additionalInfo: {},
};
})
.catch((reason) => {
this.log.error(reason);
throw reason;
});
}
async updateEvent(
uid: string,
event: CalendarEvent
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
const appointment: Appointment = await Appointment.Bind(this.getExchangeService(), new ItemId(uid));
appointment.Subject = event.title;
appointment.Start = DateTime.Parse(event.startTime);
appointment.End = DateTime.Parse(event.endTime);
appointment.Location = event.location || "";
appointment.Body = new MessageBody(event.description || "");
event.attendees.forEach((attendee: Person) => {
appointment.RequiredAttendees.Add(new Attendee(attendee.email));
});
return appointment
.Update(
ConflictResolutionMode.AlwaysOverwrite,
SendInvitationsOrCancellationsMode.SendToChangedAndSaveCopy
)
.then(() => {
return {
uid: appointment.Id.UniqueId,
id: appointment.Id.UniqueId,
password: "",
type: "",
url: "",
additionalInfo: {},
};
})
.catch((reason) => {
this.log.error(reason);
throw reason;
});
}
async deleteEvent(uid: string): Promise<void> {
const appointment: Appointment = await Appointment.Bind(this.getExchangeService(), new ItemId(uid));
return appointment.Delete(DeleteMode.MoveToDeletedItems).catch((reason) => {
this.log.error(reason);
throw reason;
});
}
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
const calendars: IntegrationCalendar[] = await this.listCalendars();
const promises: Promise<EventBusyDate[]>[] = calendars
.filter((lcal) => selectedCalendars.some((rcal) => lcal.externalId == rcal.externalId))
.map(async (calendar) => {
return this.getExchangeService()
.FindAppointments(
new FolderId(calendar.externalId),
new CalendarView(DateTime.Parse(dateFrom), DateTime.Parse(dateTo))
)
.then((results: FindItemsResults<Appointment>) => {
return results.Items.filter((appointment: Appointment) => {
return appointment.LegacyFreeBusyStatus != LegacyFreeBusyStatus.Free;
}).map((appointment: Appointment) => {
return {
start: new Date(appointment.Start.ToISOString()),
end: new Date(appointment.End.ToISOString()),
};
});
})
.catch((reason) => {
this.log.error(reason);
throw reason;
});
});
return Promise.all(promises).then((x) => x.flat());
}
async listCalendars(): Promise<IntegrationCalendar[]> {
const service: ExchangeService = this.getExchangeService();
const view: FolderView = new FolderView(1000);
view.PropertySet = new PropertySet(BasePropertySet.IdOnly);
view.PropertySet.Add(FolderSchema.ParentFolderId);
view.PropertySet.Add(FolderSchema.DisplayName);
view.PropertySet.Add(FolderSchema.ChildFolderCount);
view.Traversal = FolderTraversal.Deep;
const deletedItemsFolder: Folder = await Folder.Bind(service, WellKnownFolderName.DeletedItems);
const searchFilterCollection = new SearchFilter.SearchFilterCollection(LogicalOperator.And);
searchFilterCollection.Add(new SearchFilter.IsEqualTo(FolderSchema.FolderClass, "IPF.Appointment"));
return service
.FindFolders(WellKnownFolderName.MsgFolderRoot, searchFilterCollection, view)
.then((res: FindFoldersResults) => {
return res.Folders.filter((folder: Folder) => {
return folder.ParentFolderId.UniqueId != deletedItemsFolder.Id.UniqueId;
}).map((folder: Folder) => {
return {
externalId: folder.Id.UniqueId,
name: folder.DisplayName ?? "",
primary: folder.ChildFolderCount > 0,
integration: this.integrationName,
};
});
})
.catch((reason) => {
this.log.error(reason);
throw reason;
});
}
private getExchangeService(): ExchangeService {
const service: ExchangeService = new ExchangeService();
service.Credentials = new WebCredentials(this.payload.username, this.payload.password);
service.Url = new Uri(this.payload.url);
if (this.payload.authenticationMethod === ExchangeAuthentication.NTLM) {
const xhr: XhrApi = new XhrApi({
rejectUnauthorized: false,
gzip: this.payload.useCompression,
}).useNtlmAuthentication(this.payload.username, this.payload.password);
service.XHRApi = xhr;
}
return service;
}
}
@@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";
@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/exchangecalendar",
"version": "0.0.0",
"main": "./index.ts",
"description": "Fetch Microsoft Exchange calendars and availabilities using Exchange Web Services (EWS).",
"dependencies": {
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@ewsjs/xhr": "^1.4.4",
"ews-javascript-api": "^0.11.0"
},
"devDependencies": {
"@calcom/types": "*"
}
}
@@ -0,0 +1,145 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Select, Switch } from "@calcom/ui";
import { Alert } from "@calcom/ui/Alert";
import { EmailField, Form, Label, PasswordField, TextField } from "@calcom/ui/form/fields";
import { ExchangeAuthentication } from "../../enums";
interface IFormData {
url: string;
username: string;
password: string;
authenticationMethod: ExchangeAuthentication;
useCompression: boolean;
}
const schema = z
.object({
url: z.string().url(),
username: z.string().email(),
password: z.string(),
authenticationMethod: z.number().default(ExchangeAuthentication.STANDARD),
useCompression: z.boolean().default(false),
})
.strict();
export default function ExchangeSetup() {
const { t } = useLocale();
const router = useRouter();
const [errorMessage, setErrorMessage] = useState("");
const form = useForm<IFormData>({
defaultValues: { authenticationMethod: ExchangeAuthentication.STANDARD },
resolver: zodResolver(schema),
});
const authenticationMethods = [
{ value: ExchangeAuthentication.STANDARD, label: t("exchange_authentication_standard") },
{ value: ExchangeAuthentication.NTLM, label: t("exchange_authentication_ntlm") },
];
return (
<>
<div className="flex h-screen bg-gray-200">
<div className="m-auto rounded bg-white p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-y-0 md:space-x-5">
<div className="flex">
<img
src="/api/app-store/exchangecalendar/icon.svg"
alt="Microsoft Exchange Calendar"
className="h-12 w-12 max-w-2xl"
/>
</div>
<div className="grow">
<h1 className="text-gray-600">{t("exchange_add")}</h1>
<div className="text-sm">{t("credentials_stored_encrypted")}</div>
<div className="my-2 mt-5">
<Form
form={form}
handleSubmit={async (values) => {
setErrorMessage("");
const res = await fetch("/api/integrations/exchangecalendar/add", {
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setErrorMessage(json?.message || t("something_went_wrong"));
} else {
router.push(json.url);
}
}}>
<fieldset className="space-y-4" disabled={form.formState.isSubmitting}>
<TextField
required
type="url"
{...form.register("url")}
label={t("url")}
placeholder="https://example.com/Ews/Exchange.asmx"
inputMode="url"
/>
<EmailField
required
{...form.register("username")}
label={t("email_address")}
placeholder="john.doe@example.com"
/>
<PasswordField
required
{...form.register("password")}
label={t("password")}
autoComplete="password"
/>
<div>
<Label>{t("exchange_authentication")}</Label>
<Controller
name="authenticationMethod"
control={form.control}
render={({ field: { onChange } }) => (
<Select
options={authenticationMethods}
defaultValue={authenticationMethods[0]}
className="mt-1"
onChange={async (authentication) => {
onChange(authentication?.value);
form.setValue("authenticationMethod", authentication!.value);
}}
/>
)}
/>
</div>
<Switch
label={t("exchange_compression")}
name="useCompression"
onCheckedChange={async (alt) => {
form.setValue("useCompression", alt);
}}
/>
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-4 flex justify-end space-x-2">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
</>
);
}
@@ -0,0 +1,149 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
Alert,
Button,
EmailField,
Form,
PasswordField,
TextField,
SelectField,
Switch,
} from "@calcom/ui/v2";
import { ExchangeAuthentication } from "../../../enums";
interface IFormData {
url: string;
username: string;
password: string;
authenticationMethod: ExchangeAuthentication;
useCompression: boolean;
}
const schema = z
.object({
url: z.string().url(),
username: z.string().email(),
password: z.string(),
authenticationMethod: z.number().default(ExchangeAuthentication.STANDARD),
useCompression: z.boolean().default(false),
})
.strict();
export default function ExchangeSetup() {
const { t } = useLocale();
const router = useRouter();
const [errorMessage, setErrorMessage] = useState("");
const form = useForm<IFormData>({
defaultValues: { authenticationMethod: ExchangeAuthentication.STANDARD },
resolver: zodResolver(schema),
});
const authenticationMethods = [
{ value: ExchangeAuthentication.STANDARD, label: t("exchange_authentication_standard") },
{ value: ExchangeAuthentication.NTLM, label: t("exchange_authentication_ntlm") },
];
return (
<>
<div className="flex h-screen bg-gray-200">
<div className="m-auto rounded bg-white p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-y-0 md:space-x-5">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img
src="/api/app-store/exchangecalendar/icon.svg"
alt="Microsoft Exchange"
className="h-12 w-12 max-w-2xl"
/>
</div>
<div className="grow">
<h1 className="text-gray-600">{t("exchange_add")}</h1>
<div className="text-sm">{t("credentials_stored_encrypted")}</div>
<div className="my-2 mt-5">
<Form
form={form}
handleSubmit={async (values) => {
setErrorMessage("");
const res = await fetch("/api/integrations/exchangecalendar/add", {
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setErrorMessage(json?.message || t("something_went_wrong"));
} else {
router.push(json.url);
}
}}>
<fieldset className="space-y-4" disabled={form.formState.isSubmitting}>
<TextField
required
type="url"
{...form.register("url")}
label={t("url")}
placeholder="https://example.com/Ews/Exchange.asmx"
inputMode="url"
/>
<EmailField
required
{...form.register("username")}
label={t("email_address")}
placeholder="john.doe@example.com"
/>
<PasswordField
required
{...form.register("password")}
label={t("password")}
autoComplete="password"
/>
<Controller
name="authenticationMethod"
control={form.control}
render={({ field: { onChange } }) => (
<SelectField
label={t("exchange_authentication")}
options={authenticationMethods}
defaultValue={authenticationMethods[0]}
onChange={async (authentication) => {
onChange(authentication?.value);
form.setValue("authenticationMethod", authentication!.value);
}}
/>
)}
/>
<Switch
label={t("exchange_compression")}
name="useCompression"
onCheckedChange={async (alt) => {
form.setValue("useCompression", alt);
}}
/>
</fieldset>
{errorMessage && <Alert severity="error" title={errorMessage} className="my-4" />}
<div className="mt-4 flex justify-end space-x-2">
<Button type="button" color="secondary" onClick={() => router.back()}>
{t("cancel")}
</Button>
<Button type="submit" loading={form.formState.isSubmitting}>
{t("save")}
</Button>
</div>
</Form>
</div>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
</>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 8 55 48"><title>Exchange_64x</title><path d="M55.50977,8h-12.207A3.48835,3.48835,0,0,0,40.835,9.02246L12.02246,37.835A3.48835,3.48835,0,0,0,11,40.30273v12.207A3.49006,3.49006,0,0,0,14.49023,56h12.207A3.48835,3.48835,0,0,0,29.165,54.97754L57.978,26.165A3.48994,3.48994,0,0,0,59,23.69727v-12.207A3.49007,3.49007,0,0,0,55.50977,8Z" fill="#28a8ea"/><path d="M55.51,56H43.30275a3.49,3.49,0,0,1-2.4678-1.0222L35,49.14286V38.24A6.24,6.24,0,0,1,41.24,32H52.14286L57.9778,37.835A3.49,3.49,0,0,1,59,40.30275V52.51A3.49,3.49,0,0,1,55.51,56Z" fill="#0078d4"/><path d="M14.49,8H26.69725a3.49,3.49,0,0,1,2.4678,1.0222L35,14.85714V25.76A6.24,6.24,0,0,1,28.76,32H17.85714L12.0222,26.16505A3.49,3.49,0,0,1,11,23.69725V11.49A3.49,3.49,0,0,1,14.49,8Z" fill="#50d9ff"/><path d="M33,20.33008V46.66992a1.73444,1.73444,0,0,1-.04.3999A2.31378,2.31378,0,0,1,30.66992,49H11V18H30.66992A2.326,2.326,0,0,1,33,20.33008Z" opacity="0.2"/><path d="M34,20.33008V44.66992A3.36171,3.36171,0,0,1,30.66992,48H11V17H30.66992A3.34177,3.34177,0,0,1,34,20.33008Z" opacity="0.1"/><path d="M33,20.33008V44.66992A2.326,2.326,0,0,1,30.66992,47H11V18H30.66992A2.326,2.326,0,0,1,33,20.33008Z" opacity="0.2"/><path d="M32,20.33008V44.66992A2.326,2.326,0,0,1,29.66992,47H11V18H29.66992A2.326,2.326,0,0,1,32,20.33008Z" opacity="0.1"/><rect x="4.00022" y="18" width="28" height="28" rx="2.33333" fill="#0078d4"/><path d="M22.58533,26.88121h-6.5472V30.7098h6.14535v2.45375H16.03813V37.1401h6.89609v2.4434h-9.868V24.4165h9.5191Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+2
View File
@@ -5,6 +5,7 @@ import * as closecomothercalendar from "./closecomothercalendar";
import * as dailyvideo from "./dailyvideo";
import * as exchange2013calendar from "./exchange2013calendar";
import * as exchange2016calendar from "./exchange2016calendar";
import * as exchangecalendar from "./exchangecalendar";
import * as giphy from "./giphy";
import * as googlecalendar from "./googlecalendar";
import * as googlevideo from "./googlevideo";
@@ -46,6 +47,7 @@ const appStore = {
zapier,
exchange2013calendar,
exchange2016calendar,
exchangecalendar,
};
export default appStore;
+7 -1
View File
@@ -55,9 +55,15 @@
"type": "raycast_other"
},
{
"dirName": "n8n",
"dirName": "n8n",
"categories": ["other"],
"slug": "n8n",
"type": "n8n_other"
},
{
"dirName": "exchangecalendar",
"categories": ["calendar"],
"slug": "exchange",
"type": "exchange_calendar"
}
]
+61 -725
View File
File diff suppressed because it is too large Load Diff