diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts index 0faccc11c..652cad7aa 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts @@ -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, diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx index 3b97e65f1..3aa5f3707 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx @@ -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) => { - + {hasUrlSecret ? null : ( + + )}
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 3bb768e5e..078e1d3e1 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -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); 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) => - + {hasUrlSecret ? null : ( + + )}
diff --git a/packages/common/src/fetch-agent.ts b/packages/common/src/fetch-agent.ts index b541f5301..ca670cd36 100644 --- a/packages/common/src/fetch-agent.ts +++ b/packages/common/src/fetch-agent.ts @@ -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 diff --git a/packages/common/src/test/fetch-agent.spec.ts b/packages/common/src/test/fetch-agent.spec.ts index 12cb8040d..9617a9587 100644 --- a/packages/common/src/test/fetch-agent.spec.ts +++ b/packages/common/src/test/fetch-agent.spec.ts @@ -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"); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 54dba1d49..1e8269bbc 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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"]], diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 2bf6b8304..f68168096 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -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", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 1d34528b0..e0bdd2ab4 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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, diff --git a/packages/integrations/src/ical/ical-integration.ts b/packages/integrations/src/ical/ical-integration.ts new file mode 100644 index 000000000..8280d4303 --- /dev/null +++ b/packages/integrations/src/ical/ical-integration.ts @@ -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 { + 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 { + 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, + }); + } + } +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index ac21e3080..393264350 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/interfaces/calendar/calendar-types.ts b/packages/integrations/src/interfaces/calendar/calendar-types.ts index 0aad96b03..2466aa1b1 100644 --- a/packages/integrations/src/interfaces/calendar/calendar-types.ts +++ b/packages/integrations/src/interfaces/calendar/calendar-types.ts @@ -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[]; } diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts index 6348f9ec1..f047048b1 100644 --- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts +++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts @@ -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) => { - 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; } diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts index 8dc6aff87..a5a176a0d 100644 --- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -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[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) => { - 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, }, ]; diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts index a9d8154cd..da5db4fb3 100644 --- a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts +++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts @@ -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 = ( diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts index a3f623881..d44c53c58 100644 --- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts +++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts @@ -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) => { - const links: CalendarEvent["links"] = [ + private getLinksForSonarrCalendarEvent = (event: z.infer) => { + const links: CalendarLink[] = [ { href: this.url(`/series/${event.series.titleSlug}`).toString(), name: "Sonarr", logo: "/images/apps/sonarr.svg", color: undefined, - notificationColor: "blue", isDark: true, }, ]; diff --git a/packages/integrations/src/mock/data/calendar.ts b/packages/integrations/src/mock/data/calendar.ts index efc8576d7..9c298fc91 100644 --- a/packages/integrations/src/mock/data/calendar.ts +++ b/packages/integrations/src/mock/data/calendar.ts @@ -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", }, ], }); diff --git a/packages/integrations/src/nextcloud/nextcloud.integration.ts b/packages/integrations/src/nextcloud/nextcloud.integration.ts index a855ce297..4e3dbb82e 100644 --- a/packages/integrations/src/nextcloud/nextcloud.integration.ts +++ b/packages/integrations/src/nextcloud/nextcloud.integration.ts @@ -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, }, ], diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e7c63f9f6..255e716ab 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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)" diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx index f601ecc05..0ad0a805f 100644 --- a/packages/widgets/src/calendar/calendar-event-list.tsx +++ b/packages/widgets/src/calendar/calendar-event-list.tsx @@ -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) => { {events.map((event, eventIndex) => ( - - - {event.mediaInformation?.type === "tv" && ( - {`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`} - )} - + {event.image !== null && ( + + + {event.image.badge !== undefined && ( + + {event.image.badge.content} + + )} + + )} - + - {event.subName && ( + {event.subTitle !== null && ( - {event.subName} + {event.subTitle} )} - {event.name} + {event.title} - {event.dates ? ( + {event.metadata?.type === "radarr" && ( - {t( - `widget.calendar.option.releaseType.options.${event.dates.find(({ date }) => event.date === date)?.type ?? "inCinemas"}`, - )} - - - ) : ( - - - - {dayjs(event.date).format("HH:mm")} + {t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)} )} + + + + + {dayjs(event.startDate).format("HH:mm")} + + + {event.endDate !== null && ( + <> + -{" "} + + {dayjs(event.endDate).format("HH:mm")} + + + )} + - {event.description && ( + + {event.location !== null && ( + + + + {event.location} + + + )} + + {!isNullOrWhitespace(event.description) && ( {event.description} )} + {event.links.length > 0 && ( - {event.links.map((link) => ( - - ))} + {event.links + .filter((link) => link.href) + .map((link) => ( + + ))} )} diff --git a/packages/widgets/src/calendar/calender-day.tsx b/packages/widgets/src/calendar/calender-day.tsx index a0d2644ee..3629a937f 100644 --- a/packages/widgets/src/calendar/calender-day.tsx +++ b/packages/widgets/src/calendar/calender-day.tsx @@ -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 ( diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index bb9a067c1..d3534176d 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -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 ( =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