mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat(integrations): add ICal (#3980)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
IconCode,
|
||||
IconGrid3x3,
|
||||
IconKey,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
IconPassword,
|
||||
IconPasswordUser,
|
||||
@@ -21,6 +22,7 @@ export const integrationSecretIcons = {
|
||||
tokenId: IconGrid3x3,
|
||||
personalAccessToken: IconPasswordUser,
|
||||
topic: IconMessage,
|
||||
url: IconLink,
|
||||
opnsenseApiKey: IconKey,
|
||||
opnsenseApiSecret: IconPassword,
|
||||
githubAppId: IconCode,
|
||||
|
||||
@@ -33,6 +33,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
integration.secrets.every((secret) => secretKinds.includes(secret.kind)),
|
||||
) ?? getDefaultSecretKinds(integration.kind);
|
||||
|
||||
const hasUrlSecret = secretsKinds.includes("url");
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
|
||||
initialValues: {
|
||||
@@ -50,10 +52,14 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
await mutateAsync(
|
||||
{
|
||||
id: integration.id,
|
||||
...values,
|
||||
url,
|
||||
secrets: values.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: secret.value === "" ? null : secret.value,
|
||||
@@ -92,7 +98,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
|
||||
@@ -55,12 +55,19 @@ const formSchema = integrationCreateSchema.omit({ kind: true }).and(
|
||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const router = useRouter();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
||||
if (hasUrlSecret) {
|
||||
// Placeholder Url, replaced with origin of the secret Url on submit
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||
url: searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "",
|
||||
url,
|
||||
secrets: secretKinds[0].map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
@@ -83,10 +90,14 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||
|
||||
const handleSubmitAsync = async (values: FormType) => {
|
||||
const url = hasUrlSecret
|
||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||
: values.url;
|
||||
await createIntegrationAsync(
|
||||
{
|
||||
kind: searchParams.kind,
|
||||
...values,
|
||||
url,
|
||||
},
|
||||
{
|
||||
async onSuccess(data) {
|
||||
@@ -114,10 +125,10 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
await createAppAsync(
|
||||
{
|
||||
name: values.name,
|
||||
href: hasCustomHref ? values.appHref : values.url,
|
||||
href: hasCustomHref ? values.appHref : url,
|
||||
iconUrl: getIconUrl(searchParams.kind),
|
||||
description: null,
|
||||
pingUrl: values.url,
|
||||
pingUrl: url,
|
||||
},
|
||||
{
|
||||
async onSettled() {
|
||||
@@ -149,7 +160,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
||||
<Stack>
|
||||
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
|
||||
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
{hasUrlSecret ? null : (
|
||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||
)}
|
||||
|
||||
<Fieldset legend={t("integration.secrets.title")}>
|
||||
<Stack gap="sm">
|
||||
|
||||
@@ -12,7 +12,11 @@ export class LoggingAgent extends Agent {
|
||||
}
|
||||
|
||||
dispatch(options: Dispatcher.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean {
|
||||
const url = new URL(`${options.origin as string}${options.path}`);
|
||||
const path = options.path
|
||||
.split("/")
|
||||
.map((segment) => (segment.length >= 32 && !segment.startsWith("?") ? "REDACTED" : segment))
|
||||
.join("/");
|
||||
const url = new URL(`${options.origin as string}${path}`);
|
||||
|
||||
// The below code should prevent sensitive data from being logged as
|
||||
// some integrations use query parameters for auth
|
||||
|
||||
@@ -66,6 +66,7 @@ describe("LoggingAgent should log all requests", () => {
|
||||
["/?one=a1&two=b2&three=c3", `/?one=${REDACTED}&two=${REDACTED}&three=${REDACTED}`],
|
||||
["/?numberWith13Chars=1234567890123", `/?numberWith13Chars=${REDACTED}`],
|
||||
[`/?stringWith13Chars=${"a".repeat(13)}`, `/?stringWith13Chars=${REDACTED}`],
|
||||
[`/${"a".repeat(32)}/?param=123`, `/${REDACTED}/?param=123`],
|
||||
])("should redact sensitive data in url https://homarr.dev%s", (path, expected) => {
|
||||
// Arrange
|
||||
const infoLogSpy = vi.spyOn(logger, "debug");
|
||||
|
||||
@@ -13,6 +13,7 @@ export const integrationSecretKindObject = {
|
||||
topic: { isPublic: true, multiline: false },
|
||||
opnsenseApiKey: { isPublic: false, multiline: false },
|
||||
opnsenseApiSecret: { isPublic: false, multiline: false },
|
||||
url: { isPublic: false, multiline: false },
|
||||
privateKey: { isPublic: false, multiline: true },
|
||||
githubAppId: { isPublic: true, multiline: false },
|
||||
githubInstallationId: { isPublic: true, multiline: false },
|
||||
@@ -283,6 +284,13 @@ export const integrationDefs = {
|
||||
category: ["notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
||||
},
|
||||
ical: {
|
||||
name: "iCal",
|
||||
secretKinds: [["url"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
|
||||
},
|
||||
truenas: {
|
||||
name: "TrueNAS",
|
||||
secretKinds: [["username", "password"]],
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.11.0",
|
||||
"@octokit/auth-app": "^8.1.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"maria2": "^0.4.1",
|
||||
"node-ical": "^0.20.1",
|
||||
"octokit": "^5.0.3",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
|
||||
@@ -112,6 +113,7 @@ export const integrationCreators = {
|
||||
codeberg: CodebergIntegration,
|
||||
linuxServerIO: LinuxServerIOIntegration,
|
||||
gitHubContainerRegistry: GitHubContainerRegistryIntegration,
|
||||
ical: ICalIntegration,
|
||||
quay: QuayIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
mock: MockIntegration,
|
||||
|
||||
67
packages/integrations/src/ical/ical-integration.ts
Normal file
67
packages/integrations/src/ical/ical-integration.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import ICAL from "ical.js";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
|
||||
|
||||
export class ICalIntegration extends Integration implements ICalendarIntegration {
|
||||
async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
|
||||
const result = await response.text();
|
||||
const jcal = ICAL.parse(result) as unknown[];
|
||||
const comp = new ICAL.Component(jcal);
|
||||
|
||||
return comp.getAllSubcomponents("vevent").reduce((prev, vevent) => {
|
||||
const event = new ICAL.Event(vevent);
|
||||
const startDate = event.startDate.toJSDate();
|
||||
const endDate = event.endDate.toJSDate();
|
||||
|
||||
if (startDate > end) return prev;
|
||||
if (endDate < start) return prev;
|
||||
|
||||
return prev.concat({
|
||||
title: event.summary,
|
||||
subTitle: null,
|
||||
description: event.description,
|
||||
startDate,
|
||||
endDate,
|
||||
image: null,
|
||||
location: event.location,
|
||||
indicatorColor: "red",
|
||||
links: [],
|
||||
});
|
||||
}, [] as CalendarEvent[]);
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(super.getSecretValue("url"));
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
const result = await response.text();
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const jcal = ICAL.parse(result);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const comp = new ICAL.Component(jcal);
|
||||
return comp.getAllSubcomponents("vevent").length > 0
|
||||
? { success: true }
|
||||
: TestConnectionError.ParseResult({
|
||||
name: "Calendar parse error",
|
||||
message: "No events found",
|
||||
cause: new Error("No events found"),
|
||||
});
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult({
|
||||
name: "Calendar parse error",
|
||||
message: "Failed to parse calendar",
|
||||
cause: error as Error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export { PlexIntegration } from "./plex/plex-integration";
|
||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||
export { ICalIntegration } from "./ical/ical-integration";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
|
||||
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
|
||||
|
||||
export interface CalendarEvent {
|
||||
name: string;
|
||||
subName: string;
|
||||
date: Date;
|
||||
dates?: { type: RadarrReleaseType; date: Date }[];
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
mediaInformation?: {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
};
|
||||
links: {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string | undefined;
|
||||
notificationColor?: string | undefined;
|
||||
isDark: boolean | undefined;
|
||||
logo: string;
|
||||
}[];
|
||||
export interface RadarrMetadata {
|
||||
type: "radarr";
|
||||
releaseType: RadarrReleaseType;
|
||||
}
|
||||
|
||||
export type CalendarMetadata = RadarrMetadata;
|
||||
|
||||
export interface CalendarLink {
|
||||
name: string;
|
||||
isDark: boolean;
|
||||
href: string;
|
||||
color?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface CalendarImageBadge {
|
||||
content: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CalendarImage {
|
||||
src: string;
|
||||
badge?: CalendarImageBadge;
|
||||
aspectRatio?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
title: string;
|
||||
subTitle: string | null;
|
||||
description: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
image: CalendarImage | null;
|
||||
location: string | null;
|
||||
metadata?: CalendarMetadata;
|
||||
indicatorColor: string;
|
||||
links: CalendarLink[];
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
export class LidarrIntegration extends Integration implements ICalendarIntegration {
|
||||
@@ -44,22 +44,28 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
|
||||
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => {
|
||||
const imageSrc = this.chooseBestImage(lidarrCalendarEvent);
|
||||
return {
|
||||
name: lidarrCalendarEvent.title,
|
||||
subName: lidarrCalendarEvent.artist.artistName,
|
||||
description: lidarrCalendarEvent.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(lidarrCalendarEvent),
|
||||
date: lidarrCalendarEvent.releaseDate,
|
||||
mediaInformation: {
|
||||
type: "audio",
|
||||
},
|
||||
title: lidarrCalendarEvent.title,
|
||||
subTitle: lidarrCalendarEvent.artist.artistName,
|
||||
description: lidarrCalendarEvent.overview ?? null,
|
||||
startDate: lidarrCalendarEvent.releaseDate,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc.remoteUrl,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
indicatorColor: "cyan",
|
||||
links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [];
|
||||
const links: CalendarLink[] = [];
|
||||
|
||||
for (const link of event.artist.links) {
|
||||
switch (link.name) {
|
||||
@@ -70,7 +76,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/vgmdb.svg",
|
||||
notificationColor: "cyan",
|
||||
});
|
||||
break;
|
||||
case "imdb":
|
||||
@@ -80,7 +85,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
notificationColor: "cyan",
|
||||
});
|
||||
break;
|
||||
case "last":
|
||||
@@ -90,7 +94,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
|
||||
color: "#cf222a",
|
||||
isDark: false,
|
||||
logo: "/images/apps/lastfm.svg",
|
||||
notificationColor: "cyan",
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { Integration } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
@@ -34,33 +33,44 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
||||
});
|
||||
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return radarrCalendarEvents.map((radarrCalendarEvent): CalendarEvent => {
|
||||
const dates = radarrReleaseTypes
|
||||
.map((type) => (radarrCalendarEvent[type] ? { type, date: radarrCalendarEvent[type] } : undefined))
|
||||
.filter((date) => date) as AtLeastOneOf<Exclude<CalendarEvent["dates"], undefined>[number]>;
|
||||
return {
|
||||
name: radarrCalendarEvent.title,
|
||||
subName: radarrCalendarEvent.originalTitle,
|
||||
description: radarrCalendarEvent.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
|
||||
date: dates[0].date,
|
||||
dates,
|
||||
mediaInformation: {
|
||||
type: "movie",
|
||||
},
|
||||
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
|
||||
};
|
||||
return radarrCalendarEvents.flatMap((radarrCalendarEvent): CalendarEvent[] => {
|
||||
const imageSrc = this.chooseBestImageAsURL(radarrCalendarEvent);
|
||||
|
||||
return radarrReleaseTypes
|
||||
.map((releaseType) => ({ type: releaseType, date: radarrCalendarEvent[releaseType] }))
|
||||
.filter((item) => item.date !== undefined)
|
||||
.map((item) => ({
|
||||
title: radarrCalendarEvent.title,
|
||||
subTitle: radarrCalendarEvent.originalTitle,
|
||||
description: radarrCalendarEvent.overview ?? null,
|
||||
// Check is done above in the filter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
startDate: item.date!,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
metadata: {
|
||||
type: "radarr",
|
||||
releaseType: item.type,
|
||||
},
|
||||
indicatorColor: "yellow",
|
||||
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [
|
||||
const links: CalendarLink[] = [
|
||||
{
|
||||
href: this.url(`/movie/${event.titleSlug}`).toString(),
|
||||
name: "Radarr",
|
||||
logo: "/images/apps/radarr.svg",
|
||||
color: undefined,
|
||||
notificationColor: "yellow",
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
|
||||
@@ -50,15 +50,22 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
||||
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => {
|
||||
const imageSrc = this.chooseBestImageAsURL(readarrCalendarEvent);
|
||||
|
||||
return {
|
||||
name: readarrCalendarEvent.title,
|
||||
subName: readarrCalendarEvent.author.authorName,
|
||||
description: readarrCalendarEvent.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(readarrCalendarEvent),
|
||||
date: readarrCalendarEvent.releaseDate,
|
||||
mediaInformation: {
|
||||
type: "audio",
|
||||
},
|
||||
title: readarrCalendarEvent.title,
|
||||
subTitle: readarrCalendarEvent.author.authorName,
|
||||
description: readarrCalendarEvent.overview ?? null,
|
||||
startDate: readarrCalendarEvent.releaseDate,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
indicatorColor: "#f5c518",
|
||||
links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent),
|
||||
};
|
||||
});
|
||||
@@ -72,9 +79,8 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
||||
isDark: false,
|
||||
logo: "/images/apps/readarr.svg",
|
||||
name: "Readarr",
|
||||
notificationColor: "#f5c518",
|
||||
},
|
||||
] satisfies CalendarEvent["links"];
|
||||
] satisfies CalendarLink[];
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
export class SonarrIntegration extends Integration implements ICalendarIntegration {
|
||||
@@ -33,33 +33,36 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const sonarCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
|
||||
const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return sonarCalendarEvents.map(
|
||||
(sonarCalendarEvent): CalendarEvent => ({
|
||||
name: sonarCalendarEvent.title,
|
||||
subName: sonarCalendarEvent.series.title,
|
||||
description: sonarCalendarEvent.series.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(sonarCalendarEvent),
|
||||
date: sonarCalendarEvent.airDateUtc,
|
||||
mediaInformation: {
|
||||
type: "tv",
|
||||
episodeNumber: sonarCalendarEvent.episodeNumber,
|
||||
seasonNumber: sonarCalendarEvent.seasonNumber,
|
||||
},
|
||||
links: this.getLinksForSonarCalendarEvent(sonarCalendarEvent),
|
||||
}),
|
||||
);
|
||||
return sonarrCalendarEvents.map((event): CalendarEvent => {
|
||||
const imageSrc = this.chooseBestImageAsURL(event);
|
||||
return {
|
||||
title: event.title,
|
||||
subTitle: event.series.title,
|
||||
description: event.series.overview ?? null,
|
||||
startDate: event.airDateUtc,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
indicatorColor: "blue",
|
||||
links: this.getLinksForSonarrCalendarEvent(event),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [
|
||||
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
|
||||
const links: CalendarLink[] = [
|
||||
{
|
||||
href: this.url(`/series/${event.series.titleSlug}`).toString(),
|
||||
name: "Sonarr",
|
||||
logo: "/images/apps/sonarr.svg",
|
||||
color: undefined,
|
||||
notificationColor: "blue",
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,32 +8,46 @@ export class CalendarMockService implements ICalendarIntegration {
|
||||
}
|
||||
}
|
||||
|
||||
const homarrMeetup = (start: Date, end: Date): CalendarEvent => ({
|
||||
name: "Homarr Meetup",
|
||||
subName: "",
|
||||
description: "Yearly meetup of the Homarr community",
|
||||
date: randomDateBetween(start, end),
|
||||
links: [
|
||||
{
|
||||
href: "https://homarr.dev",
|
||||
name: "Homarr",
|
||||
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
color: "#000000",
|
||||
notificationColor: "#fa5252",
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
|
||||
const startDate = randomDateBetween(start, end);
|
||||
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
|
||||
return {
|
||||
title: "Homarr Meetup",
|
||||
subTitle: "",
|
||||
description: "Yearly meetup of the Homarr community",
|
||||
startDate,
|
||||
endDate,
|
||||
image: null,
|
||||
location: "Mountains",
|
||||
indicatorColor: "#fa5252",
|
||||
links: [
|
||||
{
|
||||
href: "https://homarr.dev",
|
||||
name: "Homarr",
|
||||
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
color: "#000000",
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
name: "Titanic",
|
||||
subName: "A classic movie",
|
||||
title: "Titanic",
|
||||
subTitle: "A classic movie",
|
||||
description: "A tragic love story set on the ill-fated RMS Titanic.",
|
||||
date: randomDateBetween(start, end),
|
||||
thumbnail: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
|
||||
mediaInformation: {
|
||||
type: "movie",
|
||||
startDate: randomDateBetween(start, end),
|
||||
endDate: null,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
},
|
||||
location: null,
|
||||
metadata: {
|
||||
type: "radarr",
|
||||
releaseType: "inCinemas",
|
||||
},
|
||||
indicatorColor: "cyan",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt0120338/",
|
||||
@@ -41,22 +55,26 @@ const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
notificationColor: "cyan",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
name: "The Mandalorian",
|
||||
subName: "A Star Wars Series",
|
||||
title: "The Mandalorian",
|
||||
subTitle: "A Star Wars Series",
|
||||
description: "A lone bounty hunter in the outer reaches of the galaxy.",
|
||||
date: randomDateBetween(start, end),
|
||||
thumbnail: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
|
||||
mediaInformation: {
|
||||
type: "tv",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startDate: randomDateBetween(start, end),
|
||||
endDate: null,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: {
|
||||
content: "S1:E1",
|
||||
color: "red",
|
||||
},
|
||||
},
|
||||
location: null,
|
||||
indicatorColor: "blue",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt8111088/",
|
||||
@@ -64,7 +82,6 @@ const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
notificationColor: "blue",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -63,17 +63,20 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
|
||||
);
|
||||
|
||||
return {
|
||||
name: veventObject.summary,
|
||||
date,
|
||||
subName: "",
|
||||
title: veventObject.summary,
|
||||
subTitle: null,
|
||||
description: veventObject.description,
|
||||
startDate: date,
|
||||
endDate: veventObject.end,
|
||||
image: null,
|
||||
location: veventObject.location || null,
|
||||
indicatorColor: "#ff8600",
|
||||
links: [
|
||||
{
|
||||
href: url.toString(),
|
||||
name: "Nextcloud",
|
||||
logo: "/images/apps/nextcloud.svg",
|
||||
color: undefined,
|
||||
notificationColor: "#ff8600",
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -945,6 +945,10 @@
|
||||
"label": "Topic",
|
||||
"newLabel": "New topic"
|
||||
},
|
||||
"url": {
|
||||
"label": "Url",
|
||||
"newLabel": "New url"
|
||||
},
|
||||
"opnsenseApiKey": {
|
||||
"label": "API Key (Key)",
|
||||
"newLabel": "New API Key (Key)"
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
Text,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconClock } from "@tabler/icons-react";
|
||||
import { IconClock, IconPin } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { isNullOrWhitespace } from "@homarr/common";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -40,85 +41,108 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||
<Stack>
|
||||
{events.map((event, eventIndex) => (
|
||||
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
|
||||
<Box pos={"relative"} w={70} h={120}>
|
||||
<Image
|
||||
src={event.thumbnail}
|
||||
w={70}
|
||||
h={120}
|
||||
radius={"sm"}
|
||||
fallbackSrc={"https://placehold.co/400x600?text=No%20image"}
|
||||
/>
|
||||
{event.mediaInformation?.type === "tv" && (
|
||||
<Badge
|
||||
pos={"absolute"}
|
||||
bottom={-6}
|
||||
left={"50%"}
|
||||
w={"inherit"}
|
||||
className={classes.badge}
|
||||
>{`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`}</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{event.image !== null && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={event.image.src}
|
||||
w={70}
|
||||
mah={150}
|
||||
style={{
|
||||
aspectRatio: event.image.aspectRatio
|
||||
? `${event.image.aspectRatio.width} / ${event.image.aspectRatio.height}`
|
||||
: "1/1",
|
||||
}}
|
||||
radius="sm"
|
||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
||||
/>
|
||||
{event.image.badge !== undefined && (
|
||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||
{event.image.badge.content}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Stack style={{ flexGrow: 1 }} gap={0}>
|
||||
<Group justify={"space-between"} align={"start"} mb={"xs"} wrap="nowrap">
|
||||
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
|
||||
<Stack gap={0}>
|
||||
{event.subName && (
|
||||
{event.subTitle !== null && (
|
||||
<Text lineClamp={1} size="sm">
|
||||
{event.subName}
|
||||
{event.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||
{event.name}
|
||||
{event.title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{event.dates ? (
|
||||
{event.metadata?.type === "radarr" && (
|
||||
<Group wrap="nowrap">
|
||||
<Text c="dimmed" size="sm">
|
||||
{t(
|
||||
`widget.calendar.option.releaseType.options.${event.dates.find(({ date }) => event.date === date)?.type ?? "inCinemas"}`,
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.date).format("HH:mm")}
|
||||
{t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap={3} wrap="nowrap" align={"center"}>
|
||||
<IconClock opacity={0.7} size={"1rem"} />
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.startDate).format("HH:mm")}
|
||||
</Text>
|
||||
|
||||
{event.endDate !== null && (
|
||||
<>
|
||||
-{" "}
|
||||
<Text c={"dimmed"} size={"sm"}>
|
||||
{dayjs(event.endDate).format("HH:mm")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{event.description && (
|
||||
|
||||
{event.location !== null && (
|
||||
<Group gap={4} mb={isNullOrWhitespace(event.description) ? 0 : "sm"}>
|
||||
<IconPin opacity={0.7} size={"1rem"} />
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={1}>
|
||||
{event.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{!isNullOrWhitespace(event.description) && (
|
||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.links.length > 0 && (
|
||||
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
|
||||
{event.links.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
{event.links
|
||||
.filter((link) => link.href)
|
||||
.map((link) => (
|
||||
<Button
|
||||
key={link.href}
|
||||
component={"a"}
|
||||
href={link.href.toString()}
|
||||
target={"_blank"}
|
||||
size={"xs"}
|
||||
radius={"xl"}
|
||||
variant={link.color ? undefined : "default"}
|
||||
styles={{
|
||||
root: {
|
||||
backgroundColor: link.color,
|
||||
color: link.isDark && colorScheme === "dark" ? "white" : "black",
|
||||
"&:hover": link.color
|
||||
? {
|
||||
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
|
||||
>
|
||||
<Text>{link.name}</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -79,7 +79,7 @@ interface NotificationIndicatorProps {
|
||||
}
|
||||
|
||||
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
|
||||
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
|
||||
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
|
||||
/* position bottom is lower when small to not be on top of number*/
|
||||
return (
|
||||
<Flex w="75%" pos={"absolute"} bottom={isSmall ? 4 : 10} left={"12.5%"} p={0} direction={"row"} justify={"center"}>
|
||||
|
||||
@@ -10,7 +10,6 @@ import dayjs from "dayjs";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -124,13 +123,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
}}
|
||||
renderDay={(tileDate) => {
|
||||
const eventsForDate = events
|
||||
.map((event) => ({
|
||||
...event,
|
||||
date: (event.dates?.filter(({ type }) => options.releaseType.includes(type)) ?? [event]).find(({ date }) =>
|
||||
dayjs(date).isSame(tileDate, "day"),
|
||||
)?.date,
|
||||
}))
|
||||
.filter((event): event is CalendarEvent => Boolean(event.date));
|
||||
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
|
||||
.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
)
|
||||
.sort((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
|
||||
return (
|
||||
<CalendarDay
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -1477,6 +1477,9 @@ importers:
|
||||
'@octokit/auth-app':
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
ical.js:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
maria2:
|
||||
specifier: ^0.4.1
|
||||
version: 0.4.1
|
||||
@@ -6925,6 +6928,9 @@ packages:
|
||||
resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
ical.js@2.2.1:
|
||||
resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -15991,6 +15997,8 @@ snapshots:
|
||||
|
||||
human-signals@8.0.0: {}
|
||||
|
||||
ical.js@2.2.1: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
Reference in New Issue
Block a user