feat: Add ICS Feed support (#13337)

* feat: ics-feed proof of concept pt. 1

* feat: ics-feedcalendar fixes

* fix: ics feed i18n and icon issues

* fix: remove debug log

* chore: revert yarn.lock

* fix: remove debug log

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
authored and GitHub committed 2024-01-23 12:30:40 +00:00
1 parent 942dc38b58
commit 0abff9f562
16 files changed
+587

No files matched your search

@@ -302,6 +302,7 @@
"welcome_to_calcom": "Welcome to {{appName}}",
"welcome_instructions": "Tell us what to call you and let us know what timezone you’re in. You’ll be able to edit this later.",
"connect_caldav": "Connect to CalDav (Beta)",
"connect_ics_feed": "Connect to an ICS Feed",
"connect": "Connect",
"try_for_free": "Try it for free",
"create_booking_link_with_calcom": "Create your own booking link with {{appName}}",
@@ -70,6 +70,7 @@
"current_time": "Jelenlegi idő",
"welcome": "Üdvözöljük",
"connect_caldav": "Csatlakozás a CalDav szerverhez",
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
"connect": "Csatlakozás",
"try_for_free": "Próbáld ki ingyen",
"who": "Ki",
@@ -9,6 +9,7 @@ export const AppSetupMap = {
"exchange2013-calendar": dynamic(() => import("../../exchange2013calendar/pages/setup")),
"exchange2016-calendar": dynamic(() => import("../../exchange2016calendar/pages/setup")),
"caldav-calendar": dynamic(() => import("../../caldavcalendar/pages/setup")),
"ics-feed": dynamic(() => import("../../ics-feedcalendar/pages/setup")),
zapier: dynamic(() => import("../../zapier/pages/setup")),
make: dynamic(() => import("../../make/pages/setup")),
closecom: dynamic(() => import("../../closecom/pages/setup")),
@@ -30,6 +30,7 @@ import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import gtm_config_json from "./gtm/config.json";
import { metadata as hubspot__metadata_ts } from "./hubspot/_metadata";
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
import ics_feedcalendar_config_json from "./ics-feedcalendar/config.json";
import intercom_config_json from "./intercom/config.json";
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
@@ -111,6 +112,7 @@ export const appStoreMetadata = {
gtm: gtm_config_json,
hubspot: hubspot__metadata_ts,
huddle01video: huddle01video__metadata_ts,
"ics-feedcalendar": ics_feedcalendar_config_json,
intercom: intercom_config_json,
jitsivideo: jitsivideo__metadata_ts,
larkcalendar: larkcalendar__metadata_ts,
@@ -30,6 +30,7 @@ export const apiHandlers = {
gtm: import("./gtm/api"),
hubspot: import("./hubspot/api"),
huddle01video: import("./huddle01video/api"),
"ics-feedcalendar": import("./ics-feedcalendar/api"),
intercom: import("./intercom/api"),
jitsivideo: import("./jitsivideo/api"),
larkcalendar: import("./larkcalendar/api"),
@@ -0,0 +1 @@
{DESCRIPTION}
@@ -0,0 +1,60 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import appConfig from "../config.json";
import { CalendarService } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const { urls } = req.body;
// Get user
const user = await prisma.user.findFirstOrThrow({
where: {
id: req.session?.user?.id,
},
select: {
id: true,
email: true,
},
});
const data = {
type: appConfig.type,
key: symmetricEncrypt(JSON.stringify({ urls }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
userId: user.id,
teamId: null,
appId: appConfig.slug,
invalid: false,
};
try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: user.email },
});
const listedCals = await dav.listCalendars();
if (listedCals.length !== urls.length) {
throw new Error(`Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}`);
}
await prisma.credential.create({
data,
});
} catch (e) {
logger.error("Could not add ICS feeds", e);
return res.status(500).json({ message: "Could not add ICS feeds" });
}
return res.status(200).json({ url: getInstalledAppPath({ variant: "calendar", slug: "ics-feed" }) });
}
if (req.method === "GET") {
return res.status(200).json({ url: "/apps/ics-feed/setup" });
}
}
@@ -0,0 +1 @@
export { default as add } from "./add";
@@ -0,0 +1,14 @@
{
"name": "ICS Feed",
"title": "ICS Feed",
"slug": "ics-feed",
"type": "ics-feed_calendar",
"logo": "icon.svg",
"variant": "calendar",
"categories": ["calendar"],
"publisher": "Cal.com, Inc.",
"email": "help@cal.com",
"description": "Import events from an ICS Feed into Cal.com.",
"isTemplate": false,
"__createdUsingCli": true
}
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,272 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference path="../../../types/ical.d.ts"/>
import ICAL from "ical.js";
import dayjs from "@calcom/dayjs";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import type {
Calendar,
IntegrationCalendar,
EventBusyDate,
CalendarEvent,
NewCalendarEventType,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
// for Apple's Travel Time feature only (for now)
const getTravelDurationInSeconds = (vevent: ICAL.Component) => {
const travelDuration: ICAL.Duration = vevent.getFirstPropertyValue("x-apple-travel-duration");
if (!travelDuration) return 0;
// we can't rely on this being a valid duration and it's painful to check, so just try and catch if anything throws
try {
const travelSeconds = travelDuration.toSeconds();
// integer validation as we can never be sure with ical.js
if (!Number.isInteger(travelSeconds)) return 0;
return travelSeconds;
} catch (e) {
return 0;
}
};
const applyTravelDuration = (event: ICAL.Event, seconds: number) => {
if (seconds <= 0) return event;
// move event start date back by the specified travel time
event.startDate.second -= seconds;
return event;
};
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
export default class ICSFeedCalendarService implements Calendar {
private urls: string[] = [];
protected integrationName = "ics-feed_calendar";
constructor(credential: CredentialPayload) {
const { urls } = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
this.urls = urls;
}
createEvent(_event: CalendarEvent, _credentialId: number): Promise<NewCalendarEventType> {
throw new Error("createEvent called on read-only ICS feed");
}
deleteEvent(_uid: string, _event: CalendarEvent, _externalCalendarId?: string): Promise<unknown> {
throw new Error("deleteEvent called on read-only ICS feed");
}
updateEvent(
_uid: string,
_event: CalendarEvent,
_externalCalendarId?: string
): Promise<NewCalendarEventType | NewCalendarEventType[]> {
throw new Error("updateEvent called on read-only ICS feed");
}
fetchCalendars = async (): Promise<{ url: string; vcalendar: ICAL.Component }[]> => {
const reqPromises = await Promise.allSettled(this.urls.map((x) => fetch(x).then((y) => [x, y])));
const reqs = reqPromises
.filter((x) => x.status === "fulfilled")
.map((x) => (x as PromiseFulfilledResult<[string, Response]>).value);
const res = await Promise.all(reqs.map((x) => x[1].text().then((y) => [x[0], y])));
return res
.map((x) => {
try {
const jcalData = ICAL.parse(x[1]);
return {
url: x[0],
vcalendar: new ICAL.Component(jcalData),
};
} catch (e) {
console.error("Error parsing calendar object: ", e);
return null;
}
})
.filter((x) => x !== null) as { url: string; vcalendar: ICAL.Component }[];
};
/**
* getUserTimezoneFromDB() retrieves the timezone of a user from the database.
*
* @param {number} id - The user's unique identifier.
* @returns {Promise<string | undefined>} - A Promise that resolves to the user's timezone or "Europe/London" as a default value if the timezone is not found.
*/
getUserTimezoneFromDB = async (id: number): Promise<string | undefined> => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const user = await prisma.user.findUnique({
where: {
id,
},
select: {
timeZone: true,
},
});
return user?.timeZone;
};
/**
* getUserId() extracts the user ID from the first calendar in an array of IntegrationCalendars.
*
* @param {IntegrationCalendar[]} selectedCalendars - An array of IntegrationCalendars.
* @returns {number | null} - The user ID associated with the first calendar in the array, or null if the array is empty or the user ID is not found.
*/
getUserId = (selectedCalendars: IntegrationCalendar[]): number | null => {
if (selectedCalendars.length === 0) {
return null;
}
return selectedCalendars[0].userId || null;
};
async getAvailability(
dateFrom: string,
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
const startISOString = new Date(dateFrom).toISOString();
const calendars = await this.fetchCalendars();
const userId = this.getUserId(selectedCalendars);
// we use the userId from selectedCalendars to fetch the user's timeZone from the database primarily for all-day events without any timezone information
const userTimeZone = userId ? await this.getUserTimezoneFromDB(userId) : "Europe/London";
const events: { start: string; end: string }[] = [];
calendars.forEach(({ vcalendar }) => {
const vevents = vcalendar.getAllSubcomponents("vevent");
vevents.forEach((vevent) => {
// if event status is free or transparent, DON'T return (unlike usual getAvailability)
//
// commented out because a lot of public ICS feeds that describe stuff like
// public holidays have them marked as transparent. if that is explicitly
// added to cal.com as an ICS feed, it should probably not be ignored.
// if (vevent?.getFirstPropertyValue("transp") === "TRANSPARENT") return;
const event = new ICAL.Event(vevent);
const dtstart: { [key: string]: string } | undefined = vevent?.getFirstPropertyValue("dtstart");
const timezone = dtstart ? dtstart["timezone"] : undefined;
// We check if the dtstart timezone is in UTC which is actually represented by Z instead, but not recognized as that in ICAL.js as UTC
const isUTC = timezone === "Z";
const tzid: string | undefined = vevent?.getFirstPropertyValue("tzid") || isUTC ? "UTC" : timezone;
// In case of icalendar, when only tzid is available without vtimezone, we need to add vtimezone explicitly to take care of timezone diff
if (!vcalendar.getFirstSubcomponent("vtimezone")) {
const timezoneToUse = tzid || userTimeZone;
if (timezoneToUse) {
try {
const timezoneComp = new ICAL.Component("vtimezone");
timezoneComp.addPropertyWithValue("tzid", timezoneToUse);
const standard = new ICAL.Component("standard");
// get timezone offset
const tzoffsetfrom = dayjs(event.startDate.toJSDate()).tz(timezoneToUse).format("Z");
const tzoffsetto = dayjs(event.endDate.toJSDate()).tz(timezoneToUse).format("Z");
// set timezone offset
standard.addPropertyWithValue("tzoffsetfrom", tzoffsetfrom);
standard.addPropertyWithValue("tzoffsetto", tzoffsetto);
// provide a standard dtstart
standard.addPropertyWithValue("dtstart", "1601-01-01T00:00:00");
timezoneComp.addSubcomponent(standard);
vcalendar.addSubcomponent(timezoneComp);
} catch (e) {
// Adds try-catch to ensure the code proceeds when Apple Calendar provides non-standard TZIDs
console.log("error in adding vtimezone", e);
}
} else {
console.error("No timezone found");
}
}
const vtimezone = vcalendar.getFirstSubcomponent("vtimezone");
// mutate event to consider travel time
applyTravelDuration(event, getTravelDurationInSeconds(vevent));
if (event.isRecurring()) {
let maxIterations = 365;
if (["HOURLY", "SECONDLY", "MINUTELY"].includes(event.getRecurrenceTypes())) {
console.error(`Won't handle [${event.getRecurrenceTypes()}] recurrence`);
return;
}
const start = dayjs(dateFrom);
const end = dayjs(dateTo);
const startDate = ICAL.Time.fromDateTimeString(startISOString);
startDate.hour = event.startDate.hour;
startDate.minute = event.startDate.minute;
startDate.second = event.startDate.second;
const iterator = event.iterator(startDate);
let current: ICAL.Time;
let currentEvent;
let currentStart = null;
let currentError;
while (
maxIterations > 0 &&
(currentStart === null || currentStart.isAfter(end) === false) &&
// this iterator was poorly implemented, normally done is expected to be
// returned
(current = iterator.next())
) {
maxIterations -= 1;
try {
// @see https://github.com/mozilla-comm/ical.js/issues/514
currentEvent = event.getOccurrenceDetails(current);
} catch (error) {
if (error instanceof Error && error.message !== currentError) {
currentError = error.message;
}
}
if (!currentEvent) return;
// do not mix up caldav and icalendar! For the recurring events here, the timezone
// provided is relevant, not as pointed out in https://datatracker.ietf.org/doc/html/rfc4791#section-9.6.5
// where recurring events are always in utc (in caldav!). Thus, apply the time zone here.
if (vtimezone) {
const zone = new ICAL.Timezone(vtimezone);
currentEvent.startDate = currentEvent.startDate.convertToZone(zone);
currentEvent.endDate = currentEvent.endDate.convertToZone(zone);
}
currentStart = dayjs(currentEvent.startDate.toJSDate());
if (currentStart.isBetween(start, end) === true) {
events.push({
start: currentStart.toISOString(),
end: dayjs(currentEvent.endDate.toJSDate()).toISOString(),
});
}
}
if (maxIterations <= 0) {
console.warn("could not find any occurrence for recurring event in 365 iterations");
}
return;
}
if (vtimezone) {
const zone = new ICAL.Timezone(vtimezone);
event.startDate = event.startDate.convertToZone(zone);
event.endDate = event.endDate.convertToZone(zone);
}
return events.push({
start: dayjs(event.startDate.toJSDate()).toISOString(),
end: dayjs(event.endDate.toJSDate()).toISOString(),
});
});
});
return Promise.resolve(events);
}
async listCalendars(): Promise<IntegrationCalendar[]> {
const vcals = await this.fetchCalendars();
return vcals.map(({ url, vcalendar }) => {
const name: string = vcalendar.getFirstPropertyValue("x-wr-calname");
return {
name,
readOnly: true,
externalId: url,
integrationName: this.integrationName,
};
});
}
}
@@ -0,0 +1 @@
export { default as CalendarService } from "./CalendarService";
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/ics-feed",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/core": "*",
"@calcom/lib": "*",
"@calcom/prisma": "*",
"@calcom/ui": "*",
"react-hook-form": "^7.43.3"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Import events from an ICS Feed into Cal.com."
}
@@ -0,0 +1,124 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, Form, TextField } from "@calcom/ui";
import { Plus, Trash } from "@calcom/ui/components/icon";
export default function ICSFeedSetup() {
const { t } = useLocale();
const router = useRouter();
const form = useForm({
defaultValues: {},
});
const [urls, setUrls] = useState<string[]>([""]);
const [errorMessage, setErrorMessage] = useState("");
const [errorActionUrl, setErrorActionUrl] = useState("");
return (
<div className="bg-emphasis flex h-screen">
<div className="bg-default m-auto rounded p-5 md:w-[560px] md:p-10">
<div className="flex flex-col space-y-5 md:flex-row md:space-x-5 md:space-y-0">
<div>
{/* eslint-disable @next/next/no-img-element */}
<img src="/api/app-store/ics-feed/icon.svg" alt="ICS Feed" className="h-12 w-12 max-w-2xl" />
</div>
<div className="flex w-10/12 flex-col">
<h1 className="text-default">{t("connect_ics_feed")}</h1>
<div className="mt-1 text-sm">{t("credentials_stored_encrypted")}</div>
<div className="my-2 mt-3">
<Form
form={form}
handleSubmit={async (_) => {
setErrorMessage("");
const res = await fetch("/api/integrations/ics-feed/add", {
method: "POST",
body: JSON.stringify({ urls }),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (!res.ok) {
setErrorMessage(json?.message || t("something_went_wrong"));
if (json.actionUrl) {
setErrorActionUrl(json.actionUrl);
}
} else {
router.push(json.url);
}
}}>
<fieldset className="space-y-2" disabled={form.formState.isSubmitting}>
{urls.map((url, i) => (
<div key={i} className="flex w-full items-center gap-2">
<TextField
required
type="text"
label={t("calendar_url")}
value={url}
containerClassName={`w-full ${i === 0 ? "mr-6" : ""}`}
onChange={(e) => {
const newVal = e.target.value as string;
setUrls((urls) => urls.map((x, ii) => (ii === i ? newVal : x)));
}}
placeholder="https://example.com/calendar.ics"
/>
{i !== 0 ? (
<button
type="button"
className="mb-2 h-min text-sm"
onClick={() => setUrls((urls) => urls.filter((_, ii) => i !== ii))}>
<Trash size={16} />
</button>
) : null}
</div>
))}
</fieldset>
<button
className="text-sm"
type="button"
onClick={() => {
setUrls((urls) => urls.concat(""));
}}>
{t("add")} <Plus size={16} />
</button>
{errorMessage && (
<Alert
severity="error"
title={errorMessage}
actions={
errorActionUrl !== "" ? (
<Button
href={errorActionUrl}
color="secondary"
target="_blank"
className="ml-5 w-32 !p-5">
Go to Admin
</Button>
) : undefined
}
className="my-4"
/>
)}
<div className="mt-5 justify-end space-x-2 rtl:space-x-reverse sm:mt-4 sm:flex">
<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,87 @@
<svg
width="3004"
height="3004"
viewBox="0 0 3004 3004"
fill="none"
version="1.1"
id="svg16"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs16" />
<path
fill="#DFE4E4"
stroke="#fff"
stroke-width="100"
d="m2809.88 802.052 12.75 14.238v1801.24c0 168.34-136.45 304.8-304.8 304.8H485.801c-168.343 0-304.801-136.46-304.801-304.8V825.541l3.742-9.121 28.646-69.814 12.728-31.02H2732.48l14.91 16.652 62.49 69.814Z"
id="path1" />
<path
fill="#CD2E47"
stroke="#fff"
stroke-width="100"
d="M2822.63 835.4v50H181V585.507c0-168.345 136.457-304.814 304.801-304.814H2517.83c168.35 0 304.8 136.47 304.8 304.814V835.4Z"
id="path2" />
<path
fill="#1C202E"
stroke="#fff"
stroke-width="70"
d="M593.032 611.307h-3.084c-53.065 0-96.079-43.018-96.079-96.08V192.08c0-53.062 43.015-96.08 96.079-96.08h3.084c53.064 0 96.078 43.018 96.078 96.08v323.147c0 53.062-43.014 96.08-96.078 96.08ZM1050.09 611.307h-3.08c-53.065 0-96.081-43.018-96.081-96.08V192.08c0-53.063 43.016-96.08 96.081-96.08h3.08c53.07 0 96.08 43.017 96.08 96.08v323.147c0 53.062-43.01 96.08-96.08 96.08ZM1507.15 611.307h-3.08c-53.06 0-96.08-43.017-96.08-96.08V192.08c0-53.063 43.02-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08ZM1964.22 611.307h-3.08c-53.07 0-96.08-43.017-96.08-96.08V192.08c0-53.063 43.01-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08ZM2421.27 611.307h-3.08c-53.06 0-96.08-43.017-96.08-96.08V192.08c0-53.064 43.02-96.08 96.08-96.08h3.08c53.06 0 96.08 43.017 96.08 96.08v323.147c0 53.063-43.02 96.08-96.08 96.08Z"
id="path3" />
<path
fill="#DFE4E4"
d="M2772.63 835.4v1782.13c0 140.72-114.07 254.8-254.8 254.8H485.801c-140.728 0-254.801-114.08-254.801-254.8V835.4l28.645-69.813H2710.14l62.49 69.813Z"
id="path4" />
<path
fill="#CD2E47"
d="M2772.63 585.507V835.4H231V585.507c0-140.734 114.073-254.814 254.801-254.814H2517.83c140.73 0 254.8 114.08 254.8 254.814Z"
id="path5" />
<path
fill="#9B213B"
d="M684.276 632.147H491.114c-21.767 0-39.414-17.64-39.414-39.414V510.72c0-21.773 17.647-39.413 39.414-39.413h193.162c21.768 0 39.414 17.64 39.414 39.413v82.013c0 21.774-17.646 39.414-39.414 39.414Z"
id="path6" />
<path
fill="#1C202E"
d="M593.032 576.307h-3.084c-33.733 0-61.079-27.347-61.079-61.08V192.08c0-33.733 27.346-61.08 61.079-61.08h3.084c33.733 0 61.078 27.347 61.078 61.08v323.147c0 33.733-27.345 61.08-61.078 61.08Z"
id="path7" />
<path
fill="#9B213B"
d="M1141.34 632.147H948.174c-21.768 0-39.414-17.64-39.414-39.414V510.72c0-21.773 17.646-39.413 39.414-39.413h193.166c21.76 0 39.41 17.64 39.41 39.413v82.013c0 21.774-17.65 39.414-39.41 39.414Z"
id="path8" />
<path
fill="#1C202E"
d="M1050.09 576.307h-3.08c-33.73 0-61.081-27.347-61.081-61.08V192.08c0-33.733 27.351-61.08 61.081-61.08h3.08c33.74 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.34 61.08-61.08 61.08Z"
id="path9" />
<path
fill="#9B213B"
d="M1598.4 632.147h-193.17c-21.76 0-39.41-17.64-39.41-39.414V510.72c0-21.773 17.65-39.413 39.41-39.413h193.17c21.76 0 39.42 17.64 39.42 39.413v82.013c0 21.774-17.66 39.414-39.42 39.414Z"
id="path10" />
<path
fill="#1C202E"
d="M1507.15 576.307h-3.08c-33.73 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.35-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"
id="path11" />
<path
fill="#9B213B"
d="M2055.46 632.147H1862.3c-21.78 0-39.42-17.64-39.42-39.414V510.72c0-21.773 17.64-39.413 39.42-39.413h193.16c21.77 0 39.41 17.64 39.41 39.413v82.013c0 21.774-17.64 39.414-39.41 39.414Z"
id="path12" />
<path
fill="#1C202E"
d="M1964.22 576.307h-3.08c-33.74 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.34-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"
id="path13" />
<path
fill="#9B213B"
d="M2512.52 632.147h-193.16c-21.77 0-39.42-17.64-39.42-39.414V510.72c0-21.773 17.65-39.413 39.42-39.413h193.16c21.76 0 39.42 17.64 39.42 39.413v82.013c0 21.774-17.66 39.414-39.42 39.414Z"
id="path14" />
<path
fill="#1C202E"
d="M2421.27 576.307h-3.08c-33.73 0-61.08-27.347-61.08-61.08V192.08c0-33.733 27.35-61.08 61.08-61.08h3.08c33.73 0 61.08 27.347 61.08 61.08v323.147c0 33.733-27.35 61.08-61.08 61.08Z"
id="path15" />
<path
fill="#B9C3C4"
d="M842.32 959.6h154.945c21.575 0 39.065 17.48 39.065 39.053v154.957c0 21.56-17.49 39.06-39.065 39.06H842.32c-21.573 0-39.063-17.5-39.063-39.06V998.653c0-21.573 17.49-39.053 39.063-39.053ZM1618.35 959.6h154.95c21.57 0 39.06 17.48 39.06 39.053v154.957c0 21.56-17.49 39.06-39.06 39.06h-154.95c-21.57 0-39.07-17.5-39.07-39.06V998.653c0-21.573 17.5-39.053 39.07-39.053ZM2588.39 998.653v154.957c0 21.56-17.49 39.06-39.07 39.06h-154.94c-21.58 0-39.06-17.5-39.06-39.06V998.653c0-21.573 17.48-39.053 39.06-39.053h154.94c21.58 0 39.07 17.48 39.07 39.053ZM454.304 959.6h154.945c21.574 0 39.063 17.48 39.063 39.053v154.957c0 21.56-17.489 39.06-39.063 39.06H454.304c-21.573 0-39.063-17.5-39.063-39.06V998.653c0-21.573 17.49-39.053 39.063-39.053ZM2006.36 959.6h154.95c21.57 0 39.07 17.48 39.07 39.053v154.957c0 21.56-17.5 39.06-39.07 39.06h-154.95c-21.57 0-39.05-17.5-39.05-39.06V998.653c0-21.573 17.48-39.053 39.05-39.053ZM1230.33 959.6h154.95c21.58 0 39.07 17.48 39.07 39.053v154.957c0 21.56-17.49 39.06-39.07 39.06h-154.95c-21.57 0-39.06-17.5-39.06-39.06V998.653c0-21.573 17.49-39.053 39.06-39.053ZM1618.35 2092.4h154.95c21.57 0 39.06 17.48 39.06 39.05v154.95c0 21.57-17.49 39.07-39.06 39.07h-154.95c-21.57 0-39.07-17.5-39.07-39.07v-154.95c0-21.57 17.5-39.05 39.07-39.05ZM2588.39 2131.45v154.95c0 21.57-17.49 39.07-39.07 39.07h-154.94c-21.58 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.48-39.05 39.06-39.05h154.94c21.58 0 39.07 17.48 39.07 39.05ZM2006.36 2092.4h154.95c21.57 0 39.07 17.48 39.07 39.05v154.95c0 21.57-17.5 39.07-39.07 39.07h-154.95c-21.57 0-39.05-17.5-39.05-39.07v-154.95c0-21.57 17.48-39.05 39.05-39.05ZM1230.33 2092.4h154.95c21.58 0 39.07 17.48 39.07 39.05v154.95c0 21.57-17.49 39.07-39.07 39.07h-154.95c-21.57 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.49-39.05 39.06-39.05ZM842.32 2092.4h154.945c21.575 0 39.065 17.48 39.065 39.05v154.95c0 21.57-17.49 39.07-39.065 39.07H842.32c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM454.304 2092.4h154.945c21.574 0 39.063 17.48 39.063 39.05v154.95c0 21.57-17.489 39.07-39.063 39.07H454.304c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM454.304 2470h154.945c21.574 0 39.063 17.48 39.063 39.05V2664c0 21.57-17.489 39.07-39.063 39.07H454.304c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM842.32 2470h154.945c21.575 0 39.065 17.48 39.065 39.05V2664c0 21.57-17.49 39.07-39.065 39.07H842.32c-21.573 0-39.063-17.5-39.063-39.07v-154.95c0-21.57 17.49-39.05 39.063-39.05ZM1424.35 2509.05V2664c0 21.57-17.49 39.07-39.07 39.07h-154.95c-21.57 0-39.06-17.5-39.06-39.07v-154.95c0-21.57 17.49-39.05 39.06-39.05h154.95c21.58 0 39.07 17.48 39.07 39.05Z"
id="path16" />
<path
style="font-weight:bold;font-size:666.667px;font-family:'Noto Serif Yezidi';-inkscape-font-specification:'Noto Serif Yezidi Bold';fill:#1c202e;stroke-width:40;paint-order:markers stroke fill"
d="M 1157.7616,1891.9453 H 873.90725 v -85.9375 h 79.42712 v -312.8257 h -79.42712 v -85.9375 h 283.85435 v 85.9375 h -79.4272 v 312.8257 h 79.4272 z m 320.9636,9.4401 q -54.0364,0 -99.9349,-15.9505 -45.5729,-15.9505 -78.4506,-47.5261 -32.8776,-31.5755 -51.1068,-78.7761 -17.9036,-47.2005 -17.9036,-109.0495 0,-57.6172 17.2526,-104.4922 17.2526,-46.875 50.1302,-80.4037 31.5756,-32.2266 78.1251,-49.8047 46.875,-17.5782 102.2136,-17.5782 30.5989,0 55.013,3.5808 24.7396,3.2552 45.573,8.789 21.8099,6.1849 39.388,13.9974 17.9036,7.487 31.25,13.9974 v 117.5131 h -14.3229 q -9.1146,-7.8125 -23.112,-18.5547 -13.6719,-10.7422 -31.25,-21.1588 -17.9037,-10.4167 -38.737,-17.5782 -20.8334,-7.1614 -44.5964,-7.1614 -26.3672,0 -50.1302,8.4635 -23.7631,8.138 -43.9454,27.3438 -19.2057,18.5547 -31.25,49.1536 -11.7187,30.599 -11.7187,74.2188 0,45.573 12.6953,76.1719 13.0208,30.599 32.5521,48.1772 19.8568,17.9036 44.2708,25.7161 24.4141,7.487 48.1771,7.487 22.7865,0 44.9219,-6.8359 22.461,-6.836 41.3412,-18.5547 15.9505,-9.4401 29.6224,-20.1823 13.6719,-10.7422 22.461,-18.5547 h 13.0208 v 115.8854 q -18.2292,8.1381 -34.8307,15.2995 -16.6016,7.1615 -34.8308,12.3698 -23.763,6.836 -44.5964,10.4167 -20.8333,3.5807 -57.2917,3.5807 z m 651.3676,-161.7839 q 0,71.6146 -60.8724,116.5365 -60.5469,44.5964 -164.7137,44.5964 -60.2213,0 -105.1432,-10.4167 -44.5964,-10.7422 -83.6589,-27.0182 v -116.211 h 13.6718 q 38.737,30.9245 86.5886,47.526 48.1771,16.6016 92.448,16.6016 11.3932,0 29.9479,-1.9531 18.5547,-1.9531 30.2735,-6.5104 14.3229,-5.8594 23.4375,-14.6485 9.4401,-8.7891 9.4401,-26.0417 0,-15.9505 -13.6719,-27.3437 -13.3463,-11.7188 -39.388,-17.9037 -27.3438,-6.5104 -57.9428,-12.0443 -30.2734,-5.8593 -56.9661,-14.6484 -61.198,-19.8568 -88.2162,-53.711 -26.6927,-34.1797 -26.6927,-84.6354 0,-67.7084 60.5469,-110.3516 60.8724,-42.9688 156.25,-42.9688 47.8516,0 94.4011,9.4401 46.875,9.1146 81.0548,23.112 v 111.6537 h -13.3464 q -29.2969,-23.4375 -71.9401,-39.0625 -42.3178,-15.9506 -86.5886,-15.9506 -15.625,0 -31.25,2.2787 -15.2995,1.9531 -29.6225,7.8125 -12.6953,4.8828 -21.8099,14.974 -9.1146,9.7656 -9.1146,22.4609 0,19.2057 14.6485,29.6224 14.6484,10.0912 55.3386,18.5547 26.6927,5.5339 51.1068,10.7422 24.7396,5.2083 53.0599,14.3229 55.6641,18.2292 82.0313,49.8047 26.6927,31.2501 26.6927,81.3803 z"
id="text16"
aria-label="ICS" />
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+1
View File
@@ -10,6 +10,7 @@ const appStore = {
googlevideo: () => import("./googlevideo"),
hubspot: () => import("./hubspot"),
huddle01video: () => import("./huddle01video"),
"ics-feedcalendar": () => import("./ics-feedcalendar"),
jitsivideo: () => import("./jitsivideo"),
larkcalendar: () => import("./larkcalendar"),
office365calendar: () => import("./office365calendar"),