feat: add calendar widget (#663)

* feat: add calendar widget

* feat: add artifacts to gitignore
This commit is contained in:
Manuel
2024-07-02 12:13:13 +02:00
committed by GitHub
parent 83ee03b192
commit dba97a3bd6
37 changed files with 707 additions and 9 deletions

4
.gitignore vendored
View File

@@ -14,8 +14,8 @@ coverage
out/
next-env.d.ts
# nest.js
apps/nestjs/dist
# artifacts
packages/db/migrations/*/migrate.cjs
# nitro
.nitro/

View File

@@ -8,7 +8,14 @@
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": ["cqmin", "homarr", "superjson", "trpc", "Umami"],
"cSpell.words": [
"cqmin",
"homarr",
"Sonarr",
"superjson",
"trpc",
"Umami"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1 @@
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="m0 0h1024v1024h-1024z"/><clipPath id="b"><use clip-rule="evenodd" xlink:href="#a"/></clipPath></defs><g clip-path="url(#b)"><use fill="none" xlink:href="#a"/><g transform="translate(70 21.00012)"><path d="m105.302 154.943 7.522 714.549c-60.173 7.522-105.30242-22.565-105.30242-82.737l-7.52158-594.205c0-188.03894 172.996-233.1684 278.298-157.9526l534.032 308.3846c75.216 52.651 90.259 150.431 52.651 218.125-7.521-52.651-30.086-82.737-75.216-112.823l-601.726-338.471c-45.129-30.0862-82.737-22.5646-82.737 45.13z" fill="#24292e"/><path d="m0 376.079c45.1295 15.043 90.259 7.521 127.867-15.043l616.769-361.036c37.608 52.651 30.087 105.302-15.043 135.388l-518.989 300.863c-75.216 37.608-172.9961 0-210.604-60.172z" fill="#24292e" transform="translate(60.17249 531.0214)"/><path d="m0 413.687 368.557-210.604-361.03543-203.083z" fill="#ffc230" transform="translate(240.6902 282.8092)"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.97895 0 0 .97895 -2.2026 -2.2026)"><g stroke="#443c3c" stroke-width="1.5"><circle cx="513" cy="513" r="510" fill="#eee"/><circle cx="513" cy="513" r="440" fill="#443c3c"/><circle cx="513" cy="513" r="387" fill="#8e2222"/></g><g stroke-width="1.5"><circle cx="513" cy="513" r="378" fill="#eee" stroke="#8e2222"/><circle cx="511.67" cy="514.33" r="265" fill="#443c3c" stroke="#443c3c"/></g><g stroke="#8e2222"><path d="m176.71 682.24-5.71-356.67c0.634-53.106 17.5-47.829 30.454-49.405 198.58 10.83 270.91 71.252 275.35 73.499 13.323 5.018 20.937 31.782 20.302 31.123 0.634 0.658 4.441 420.6 3.807 419.94 3.172 22.455-13.323 21.002-13.958 20.343-124.99-98.152-297.56-122.85-298.19-123.51-12.055-0.795-12.055-15.326-12.055-15.326zm670.08 0.82 5.711-357.54c-0.635-53.236-17.501-47.946-30.456-49.526-198.6 10.857-270.93 71.426-275.38 73.679-13.325 5.03-20.939 31.859-20.304 31.199-0.635 0.66-4.442 421.63-3.807 420.97-3.173 22.51 13.325 21.053 13.959 20.393 125-98.392 297.58-123.15 298.22-123.81 12.056-0.797 12.056-15.363 12.056-15.363z" fill="#eee" stroke-width="10"/><path d="m174.14 739.57-5.802-356.67c0.645-53.106 17.782-47.829 30.945-49.405 201.79 10.83 275.28 71.252 279.8 73.499 13.539 5.018 21.275 31.782 20.63 31.123 0.645 0.658 4.513 420.6 3.868 419.94 3.224 22.455-13.539 21.002-14.183 20.343-127-98.152-302.36-122.85-303.01-123.51-12.249-0.795-12.249-15.326-12.249-15.326zm675.22 0.49 5.803-357.54c-0.645-53.236-17.784-47.946-30.948-49.526-201.81 10.857-275.31 71.426-279.82 73.679-13.54 5.03-21.277 31.859-20.632 31.199-0.645 0.66-4.513 421.63-3.869 420.97-3.224 22.51 13.54 21.053 14.184 20.393 127.02-98.392 302.39-123.15 303.03-123.81 12.25-0.797 12.25-15.363 12.25-15.363z" fill="#8e2222" stroke-width="5"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><g clip-rule="evenodd"><path fill="#eee" fill-rule="evenodd" d="M47.978 24c0 6.602-2.331 12.26-6.993 16.974a3.773 3.773 0 0 1-.52.509 20.53 20.53 0 0 1-2.435 2.047C33.988 46.51 29.318 48 24.022 48c-5.304 0-9.966-1.49-13.986-4.47a21.726 21.726 0 0 1-2.988-2.556c-3.622-3.6-5.846-7.783-6.672-12.548-.162-.93-.27-1.874-.32-2.833a38.27 38.27 0 0 1 0-3.197c0-.052.014-.104.044-.155.346-5.887 2.662-10.973 6.948-15.259C11.762 2.327 17.42 0 24.022 0c6.624 0 12.279 2.327 16.963 6.982 4.662 4.743 6.993 10.416 6.993 17.018z"/><path fill="#3a3f51" fill-rule="evenodd" d="m43.098 9.405-4.957 4.957c-2.899 2.899-3.153 5.422-3.153 9.87 0 3.97.63 7.602 3.585 10.556 2.156 2.157 4.204 4.194 4.204 4.194a27.962 27.962 0 0 1-1.792 1.992 3.773 3.773 0 0 1-.52.509 20.05 20.05 0 0 1-1.749 1.538l-3.883-3.884c-3.452-3.452-6.196-3.784-10.756-3.784-4.375 0-7.352.403-10.556 3.607a2715.831 2715.831 0 0 0-4.105 4.116 21.196 21.196 0 0 1-2.368-2.102 27.739 27.739 0 0 1-1.737-1.903s2.168-2.18 4.238-4.25c3.066-3.065 3.563-6.62 3.563-10.589 0-3.872-.636-7.485-3.452-10.301C7.705 11.975 5 9.284 5 9.284a25.954 25.954 0 0 1 2.047-2.302A29.761 29.761 0 0 1 9.04 5.201l4.504 4.503c2.877 2.877 6.565 3.618 10.533 3.618 4.087 0 7.763-.791 10.756-3.784 1.84-1.841 4.27-4.26 4.27-4.26a25.168 25.168 0 0 1 1.882 1.704c.767.782 1.471 1.59 2.113 2.423z"/><path fill="#0cf" fill-rule="evenodd" d="M17.438 25.228a6.986 6.986 0 0 1-.1-1.228c0-.155.005-.303.012-.443 0-.014.004-.029.011-.044.096-1.63.738-3.039 1.925-4.227 1.306-1.29 2.874-1.936 4.703-1.936 1.837 0 3.404.645 4.703 1.936 1.29 1.313 1.936 2.884 1.936 4.714s-.645 3.397-1.936 4.703c-.045.051-.093.1-.144.143a6.056 6.056 0 0 1-.675.565c-1.121.826-2.416 1.239-3.884 1.239s-2.759-.413-3.873-1.24a5.818 5.818 0 0 1-.83-.707c-1.003-.996-1.619-2.155-1.848-3.475z"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width=".4426" d="m34.943 13.223-3.32 3.242M6.834 7.198l9.044 9.012M34.6 34.855l6.154 6.369m.41-34.056-6.22 6.056M7.18 41.107l6.053-6.063"/><path fill="none" stroke="#0cf" stroke-miterlimit="1" stroke-width="1.5491" d="m34.943 13.223-3.75 3.806m-18.12-3.617 3.806 3.795m-3.662 17.854 3.706-3.851m13.705-.309 3.99 3.971"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100px" height="54px" viewBox="0 0 100 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Logo tvdb</title>
<g id="Logo-tvdb" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M0,5.09590006 C0,1.81024006 2.9636,-0.441498938 6.46228,0.0733078623 L6.46228,0.0733078623 L52.10124,6.03470006 C54.15254,6.33652006 55.78724,8.54666006 55.78724,10.9536001 L55.78724,10.9536001 L55.78654,17.1835001 C51.94104,19.7605001 49.42044,24.0737001 49.42044,28.9596001 C49.42044,33.8924001 51.87974,38.1680001 55.78724,40.7361001 L55.78724,40.7361001 L55.78724,43.4756001 C55.78724,45.8825001 54.15254,48.0927001 52.10124,48.3945001 L52.10124,48.3945001 L11.60314,53.9266001 C8.10444,54.4417001 5.14084,52.1897001 5.14084,48.9040001 L5.14084,48.9040001 Z M19.68044,10.8218001 L13.66114,10.8218001 L13.66114,18.7064001 L9.84244,18.7064001 L9.84244,23.2621001 L13.66114,23.2621001 L13.66114,32.0227001 C13.4846091,37.5274601 15.6467584,39.9923503 20.6149401,40.0386142 L25.25134,40.0387001 L25.25134,35.4830001 L22.87064,35.4830001 C20.17484,35.3516001 19.59134,34.5631001 19.68074,31.0149001 L19.68074,23.2617001 L27.08014,23.2617001 L33.93424,40.0384001 L40.40294,40.0384001 L49.83694,18.7061001 L43.45734,18.7061001 L37.34794,33.3806001 L31.77694,18.7064001 L19.68044,18.7064001 L19.68044,10.8218001 Z" id="Combined-Shape" fill="#6CD591" fill-rule="nonzero"></path>
<path d="M88.60974,18.2771001 C92.51784,18.2771001 95.12314,19.2407001 97.09994,21.4310001 C98.71734,23.1831001 99.57074,25.7677001 99.57074,28.6584001 C99.57074,32.8634001 97.86394,36.1487001 94.76414,38.0323001 C92.74234,39.2590001 90.99054,39.6094001 87.03734,39.6094001 L77.24404,39.6094001 L77.24404,10.3925001 L83.26404,10.3925001 L83.26404,18.2771001 L88.60974,18.2771001 Z M83.26404,35.0537001 L87.71094,35.0537001 C91.26004,35.0537001 93.41634,32.6884001 93.41634,28.8334001 C93.41634,24.8035001 91.52964,22.8324001 87.71094,22.8324001 L83.26404,22.8324001 L83.26404,35.0537001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
<path d="M68.01354,10.3925001 L74.03354,10.3925001 L74.03354,39.6094001 L63.65594,39.6094001 C59.43354,39.6094001 57.41174,38.9962001 55.25524,37.1126001 C53.05394,35.1416001 51.93124,32.3384001 51.93124,28.7898001 C51.93124,25.1102001 53.14404,22.3070001 55.70494,20.2481001 C57.32204,18.9342001 59.52364,18.2771001 62.35354,18.2771001 L68.01384,18.2771001 L68.01384,10.3925001 L68.01354,10.3925001 Z M68.01354,22.8327001 L63.65594,22.8327001 C60.15224,22.8327001 58.04064,25.0667001 58.04064,28.7898001 C58.04064,32.6884001 60.19654,35.0537001 63.65594,35.0537001 L68.01354,35.0537001 L68.01354,22.8327001 Z" id="Shape" fill="#FFFFFF" fill-rule="nonzero"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 90 90" xmlns="http://www.w3.org/2000/svg"><g fill="none"><path fill="#31BEEC" d="M90 38.197v19.137L48.942 80.999V61.864z"/><path d="M41.086 61.863V81L0 57.333V38.197l18.566 10.687c.02.016.043.03.067.04l22.453 12.94Z" fill="#0095D5"/><path fill="#AEADAE" d="m61.621 45.506-16.607 9.576-16.622-9.576 16.622-9.575z"/><path fill="#0095D5" d="M86.086 31.416 69.464 40.99 48.942 29.15V10z"/><path fill="#31BEEC" d="M41.086 10v19.15l-20.55 11.827-16.621-9.561z"/></g></svg>

After

Width:  |  Height:  |  Size: 484 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><linearGradient id="a" x1="100%" x2="0" y1="0" y2="100%" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff8d30"/><stop offset="1" stop-color="#e32929"/></linearGradient></defs><circle cx="50%" cy="50%" r="50%" fill="url(#a)"/><path fill="#fff" d="M246.6 200.8h18.7v110.6h-18.7zm-182.3 0H83v110.7H64.3zm91.1 123.9h18.7V367h-18.7zm-45.7-47.5h18.7v68.5h-18.7zm91.2 0h18.6v68.4h-18.6zm228.2-76.5h18.7v110.7h-18.7zM338 145.5h18.7v42.3H338zm45.7 21.2h18.7v68.2h-18.7zm-91.5 0h18.7v68.1h-18.7z"/></svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -19,19 +19,20 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/ping": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/cron-jobs-core": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"dayjs": "^1.11.11",
"@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/cron-job-runner": "workspace:^0.1.0",
"dotenv": "^16.4.5",

View File

@@ -49,6 +49,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: true,
},
});
@@ -74,3 +75,49 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(.
});
});
};
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(...kinds: TKind[]) => {
return publicProcedure
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
.use(async ({ ctx, input, next }) => {
const dbIntegrations = await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
secrets: true,
items: true,
},
});
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`,
});
}
const dbIntegrationWithItem = dbIntegrations.filter((integration) =>
integration.items.some((item) => item.itemId === input.itemId),
);
if (dbIntegrationWithItem.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration for item was not found",
});
}
return next({
ctx: {
integrations: dbIntegrationWithItem.map(({ secrets, kind, ...rest }) => ({
...rest,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
})),
},
});
});
};

View File

@@ -0,0 +1,20 @@
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemWithIntegrationChannel } from "@homarr/redis";
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.unstable_concat(createManyIntegrationOfOneItemMiddleware("sonarr", "radarr", "readarr", "lidarr"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.flatMap(async (integration) => {
for (const item of integration.items) {
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(item.itemId, integration.id);
return await cache.getAsync();
}
}),
);
}),
});

View File

@@ -1,5 +1,6 @@
import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { notebookRouter } from "./notebook";
import { smartHomeRouter } from "./smart-home";
@@ -11,4 +12,5 @@ export const widgetRouter = createTRPCRouter({
app: appRouter,
dnsHole: dnsHoleRouter,
smartHome: smartHomeRouter,
calendar: calendarRouter,
});

View File

@@ -1,6 +1,7 @@
import { analyticsJob } from "./jobs/analytics";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { pingJob } from "./jobs/ping";
import { createCronJobGroup } from "./lib";
@@ -9,6 +10,7 @@ export const jobGroup = createCronJobGroup({
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
smartHomeEntityState: smartHomeEntityStateJob,
mediaOrganizer: mediaOrganizerJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];

View File

@@ -0,0 +1,57 @@
import dayjs from "dayjs";
import SuperJSON from "superjson";
import { decryptSecret } from "@homarr/common";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { SonarrIntegration } from "@homarr/integrations";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemWithIntegrationChannel } from "@homarr/redis";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../../widgets";
import { createCronJob } from "../../lib";
export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "calendar"),
with: {
integrations: {
with: {
integration: {
with: {
secrets: {
columns: {
kind: true,
value: true,
},
},
},
},
},
},
},
});
for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
const options = SuperJSON.parse<WidgetComponentProps<"calendar">["options"]>(itemForIntegration.options);
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();
const sonarr = new SonarrIntegration({
...integration.integration,
decryptedSecrets: integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
const events = await sonarr.getCalendarEventsAsync(start, end);
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(itemForIntegration.id, integration.integrationId);
await cache.setAsync(events);
}
}
});

View File

@@ -8,5 +8,6 @@ export const widgetKinds = [
"dnsHoleSummary",
"smartHome-entityState",
"smartHome-executeAutomation",
"calendar",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import type { IntegrationInput } from "./integration";
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
return new PiHoleIntegration(integration);
case "homeAssistant":
return new HomeAssistantIntegration(integration);
case "sonarr":
return new SonarrIntegration(integration);
default:
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
}

View File

@@ -0,0 +1,20 @@
export interface CalendarEvent {
name: string;
subName: string;
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;
}[];
}

View File

@@ -1,6 +1,7 @@
// General integrations
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
// Helpers
export { IntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -0,0 +1,137 @@
import { appendPath } from "@homarr/common";
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";
import { Integration } from "../../base/integration";
import type { CalendarEvent } from "../../calendar-types";
export class SonarrIntegration extends Integration {
/**
* Priority list that determines the quality of images using their order.
* Types at the start of the list are better than those at the end.
* We do this to attempt to find the best quality image for the show.
*/
private readonly priorities: z.infer<typeof sonarCalendarEventSchema>["images"][number]["coverType"][] = [
"poster", // Official, perfect aspect ratio
"banner", // Official, bad aspect ratio
"fanart", // Unofficial, possibly bad quality
"screenshot", // Bad aspect ratio, possibly bad quality
"clearlogo", // Without background, bad aspect ratio
];
/**
* Gets the events in the Sonarr calendar between two dates.
* @param start The start date
* @param end The end date
* @param includeUnmonitored When true results will include unmonitored items of the Sonarr library.
*/
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
const url = new URL(this.integration.url);
url.pathname = "/api/v3/calendar";
url.searchParams.append("start", start.toISOString());
url.searchParams.append("end", end.toISOString());
url.searchParams.append("includeSeries", "true");
url.searchParams.append("includeEpisodeFile", "true");
url.searchParams.append("includeEpisodeImages", "true");
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
const response = await fetch(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const sonarCalendarEvents = await z.array(sonarCalendarEventSchema).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),
}),
);
}
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [
{
href: `${this.integration.url}/series/${event.series.titleSlug}`,
name: "Sonarr",
logo: "/images/apps/sonarr.svg",
color: undefined,
notificationColor: "blue",
isDark: true,
},
];
if (event.series.imdbId) {
links.push({
href: `https://www.imdb.com/title/${event.series.imdbId}/`,
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.png",
});
}
return links;
};
private chooseBestImage = (
event: z.infer<typeof sonarCalendarEventSchema>,
): z.infer<typeof sonarCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images, ...event.series.images];
const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
);
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0];
};
private chooseBestImageAsURL = (event: z.infer<typeof sonarCalendarEventSchema>): string | undefined => {
const bestImage = this.chooseBestImage(event);
if (!bestImage) {
return undefined;
}
return bestImage.remoteUrl;
};
public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(appendPath(this.integration.url, "/api/ping"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
});
}
}
const sonarCalendarEventImageSchema = z.array(
z.object({
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo"]),
remoteUrl: z.string().url(),
}),
);
const sonarCalendarEventSchema = z.object({
title: z.string(),
airDateUtc: z.string().transform((value) => new Date(value)),
seasonNumber: z.number().min(0),
episodeNumber: z.number().min(0),
series: z.object({
overview: z.string(),
title: z.string(),
titleSlug: z.string(),
images: sonarCalendarEventImageSchema,
imdbId: z.string().optional(),
}),
images: sonarCalendarEventImageSchema,
});

View File

@@ -1 +1,2 @@
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./calendar-types";

View File

@@ -1,6 +1,6 @@
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
export { createCacheChannel } from "./lib/channel";
export { createCacheChannel, createItemWithIntegrationChannel } from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(

View File

@@ -168,6 +168,9 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
};
};
export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);
const queueClient = createRedisConnection();
type WithId<TItem> = TItem & { _id: string };

View File

@@ -16,7 +16,7 @@ export default {
},
init: {
title: "New Homarr installation",
subtitle: "Please create the initial administator user",
subtitle: "Please create the initial administrator user",
},
},
field: {
@@ -907,6 +907,18 @@ export default {
},
},
},
calendar: {
name: "Calendar",
description: "Display events from your integrations in a calendar view within a certain relative time period",
option: {
filterPastMonths: {
label: "Start from",
},
filterFutureMonths: {
label: "End at",
},
},
},
weather: {
name: "Weather",
description: "Displays the current weather information of a set location.",
@@ -1473,6 +1485,9 @@ export default {
ping: {
label: "Pings",
},
mediaOrganizer: {
label: "Media Organizers",
},
},
},
},

View File

@@ -0,0 +1,3 @@
.badge {
transform: translateX(-50%);
}

View File

@@ -0,0 +1,109 @@
import {
Badge,
Box,
Button,
darken,
Group,
Image,
lighten,
ScrollArea,
Stack,
Text,
useMantineColorScheme,
} from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { CalendarEvent } from "@homarr/integrations/types";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
}
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
const { colorScheme } = useMantineColorScheme();
return (
<ScrollArea
offsetScrollbars
pt={5}
w={400}
styles={{
viewport: {
maxHeight: 450,
},
}}
>
<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"} />
{event.mediaInformation?.type === "tv" && (
<Badge
pos={"absolute"}
bottom={-6}
left={"50%"}
className={classes.badge}
>{`S${event.mediaInformation.seasonNumber} / E${event.mediaInformation.episodeNumber}`}</Badge>
)}
</Box>
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify={"space-between"} align={"start"} mb={"xs"} wrap="nowrap">
<Stack gap={0}>
{event.subName && (
<Text lineClamp={1} size="sm">
{event.subName}
</Text>
)}
<Text fw={"bold"} lineClamp={1}>
{event.name}
</Text>
</Stack>
<Group gap={3} wrap="nowrap">
<IconClock opacity={0.7} size={"1rem"} />
<Text c={"dimmed"}>{dayjs(event.date.toString()).format("HH:mm")}</Text>
</Group>
</Group>
{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>
))}
</Group>
)}
</Stack>
</Group>
))}
</Stack>
</ScrollArea>
);
};

View File

@@ -0,0 +1,89 @@
import { Container, Popover, useMantineTheme } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import type { CalendarEvent } from "@homarr/integrations/types";
import { CalendarEventList } from "./calendar-event-list";
interface CalendarDayProps {
date: Date;
events: CalendarEvent[];
disabled: boolean;
}
export const CalendarDay = ({ date, events, disabled }: CalendarDayProps) => {
const [opened, { close, open }] = useDisclosure(false);
const { primaryColor } = useMantineTheme();
return (
<Popover
position="bottom"
withArrow
withinPortal
radius="lg"
shadow="sm"
transitionProps={{
transition: "pop",
}}
onClose={close}
opened={opened}
disabled={disabled}
>
<Popover.Target>
<Container
onClick={events.length > 0 && !opened ? open : close}
h="100%"
w="100%"
p={0}
m={0}
bd={`1cqmin solid ${opened && !disabled ? primaryColor : "transparent"}`}
style={{
alignContent: "center",
borderRadius: "3.5cqmin",
cursor: events.length === 0 || disabled ? "default" : "pointer",
}}
>
<div
style={{
textAlign: "center",
whiteSpace: "nowrap",
fontSize: "5cqmin",
lineHeight: "5cqmin",
paddingTop: "1.25cqmin",
}}
>
{date.getDate()}
</div>
<NotificationIndicator events={events} />
</Container>
</Popover.Target>
<Popover.Dropdown>
<CalendarEventList events={events} />
</Popover.Dropdown>
</Popover>
);
};
interface NotificationIndicatorProps {
events: CalendarEvent[];
}
const NotificationIndicator = ({ events }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
return (
<Container h="0.7cqmin" w="80%" display="flex" p={0} style={{ flexDirection: "row", justifyContent: "center" }}>
{notificationEvents.map((notificationEvent) => {
return (
<Container
key={notificationEvent}
bg={notificationEvent}
h="100%"
mx="0.25cqmin"
p={0}
style={{ flex: 1, borderRadius: "1000px" }}
/>
);
})}
</Container>
);
};

View File

@@ -0,0 +1,5 @@
.calendar div[data-month-level] {
width: 100%;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
import classes from "./component.module.css";
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(new Date());
const params = useParams();
const locale = params.locale as string;
return (
<Calendar
defaultDate={new Date()}
onPreviousMonth={setMonth}
onNextMonth={setMonth}
locale={locale}
hideWeekdays={false}
date={month}
maxLevel="month"
w="100%"
h="100%"
static={isEditMode}
className={classes.calendar}
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
height: "12cqmin",
width: "12cqmin",
borderRadius: "3.5cqmin",
},
calendarHeaderLevel: {
height: "12cqmin",
fontSize: "6cqmin",
pointerEvents: "none",
},
levelsGroup: {
height: "100%",
padding: "2.5cqmin",
},
calendarHeader: {
maxWidth: "unset",
marginBottom: 0,
},
day: {
width: "12cqmin",
height: "12cqmin",
borderRadius: "3.5cqmin",
},
monthCell: {
textAlign: "center",
},
month: {
height: "100%",
},
weekday: {
fontSize: "5.5cqmin",
padding: 0,
},
}}
renderDay={(date) => {
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
}}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { IconCalendar } from "@tabler/icons-react";
import { z } from "@homarr/validation";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
options: optionsBuilder.from((factory) => ({
filterPastMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
filterFutureMonths: factory.number({
validate: z.number().min(2).max(9999),
defaultValue: 2,
}),
})),
supportedIntegrations: ["sonarr", "radarr", "lidarr", "readarr"],
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,35 @@
"use server";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
if (!itemId) {
return {
initialData: [],
};
}
try {
const data = await api.widget.calendar.findAllEvents({
integrationIds,
itemId,
});
return {
initialData: data
.filter(
(
item,
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
item !== null && item !== undefined,
)
.flatMap((item) => item.data),
};
} catch (error) {
return {
initialData: [],
};
}
}

View File

@@ -79,6 +79,7 @@ export interface WidgetDefinition {
export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[];
itemId: string | undefined; // undefined when in preview mode
}
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
@@ -90,7 +91,6 @@ type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] ext
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
itemId: string | undefined; // undefined when in preview mode
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
width: number;

View File

@@ -6,6 +6,7 @@ import { Loader as UiLoader } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import * as app from "./app";
import * as calendar from "./calendar";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleSummary from "./dns-hole/summary";
@@ -33,6 +34,7 @@ export const widgetImports = {
dnsHoleSummary,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
calendar,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -45,6 +45,7 @@ const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const data = await loader.default({
...item,
options: optionsWithDefault as never,
itemId: item.id,
});
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
};

3
pnpm-lock.yaml generated
View File

@@ -300,6 +300,9 @@ importers:
'@homarr/widgets':
specifier: workspace:^0.1.0
version: link:../../packages/widgets
dayjs:
specifier: ^1.11.11
version: 1.11.11
dotenv:
specifier: ^16.4.5
version: 16.4.5