chore(release): automatic release v0.1.0

This commit is contained in:
homarr-releases[bot]
2024-11-08 19:12:25 +00:00
committed by GitHub
56 changed files with 4312 additions and 494 deletions

View File

@@ -67,11 +67,33 @@ jobs:
- name: Test - name: Test
run: pnpm test run: pnpm test
- name: 'Report Coverage' - name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing # Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above # Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always() if: always()
uses: davelosert/vitest-coverage-report-action@v2 uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup
uses: ./tooling/github/setup
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
context: .
push: false
tags: homarr-e2e
network: host
env:
SKIP_ENV_VALIDATION: true
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -33,9 +33,6 @@ jobs:
deploy: deploy:
name: Deploy docker image name: Deploy docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20]
steps: steps:
- name: Discord notification - name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }} if: ${{ github.events.inputs.send-notifications != false }}
@@ -57,21 +54,7 @@ jobs:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master uses: Ilshidur/action-discord@master
with: with:
args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}" args: "Semver computed next tag to be ${{ steps.semver.outputs.next }}. Current is ${{ steps.semver.outputs.current }}. Building images..."
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Discord notification
if: ${{ github.events.inputs.send-notifications != false }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: "Built application artifacts. Building images..."
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -90,9 +73,9 @@ jobs:
tags: | tags: |
type=raw,value=alpha type=raw,value=alpha
type=raw,value=early-adopters type=raw,value=early-adopters
# tags: | # tags: |
# type=raw,value=latest # type=raw,value=latest
# type=raw,value=${{ steps.semver.outputs.next }} # type=raw,value=${{ steps.semver.outputs.next }}
- name: Build and push - name: Build and push
id: buildPushAction id: buildPushAction
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

View File

@@ -59,8 +59,6 @@ WORKDIR /app
# gettext is required for envsubst # gettext is required for envsubst
RUN apk add --no-cache redis nginx bash gettext RUN apk add --no-cache redis nginx bash gettext
RUN mkdir /appdata RUN mkdir /appdata
RUN mkdir /appdata/db
RUN mkdir /appdata/redis
VOLUME /appdata VOLUME /appdata
RUN mkdir /secrets RUN mkdir /secrets
VOLUME /secrets VOLUME /secrets

View File

@@ -37,17 +37,17 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.13.4", "@mantine/colors-generator": "^7.13.5",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.5",
"@mantine/hooks": "^7.13.4", "@mantine/hooks": "^7.13.5",
"@mantine/modals": "^7.13.4", "@mantine/modals": "^7.13.5",
"@mantine/tiptap": "^7.13.4", "@mantine/tiptap": "^7.13.5",
"@million/lint": "1.0.11", "@million/lint": "1.0.11",
"@t3-oss/env-nextjs": "^0.11.1", "@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.21.0", "@tabler/icons-react": "^3.21.0",
"@tanstack/react-query": "^5.59.19", "@tanstack/react-query": "^5.59.20",
"@tanstack/react-query-devtools": "^5.59.19", "@tanstack/react-query-devtools": "^5.59.20",
"@tanstack/react-query-next-experimental": "5.59.19", "@tanstack/react-query-next-experimental": "5.59.20",
"@trpc/client": "next", "@trpc/client": "next",
"@trpc/next": "next", "@trpc/next": "next",
"@trpc/react-query": "next", "@trpc/react-query": "next",
@@ -63,7 +63,7 @@
"glob": "^11.0.0", "glob": "^11.0.0",
"jotai": "^2.10.1", "jotai": "^2.10.1",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16", "next": "^14.2.17",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -72,7 +72,7 @@
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.80.6", "sass": "^1.80.6",
"superjson": "2.2.1", "superjson": "2.2.1",
"swagger-ui-react": "^5.17.14", "swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },
"devDependencies": { "devDependencies": {
@@ -80,7 +80,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.4", "@types/chroma-js": "2.4.4",
"@types/node": "^22.8.7", "@types/node": "^22.9.0",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",

View File

@@ -1,8 +1,12 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { Button, Grid, Group, Stack, Textarea, TextInput } from "@mantine/core"; import type { SegmentedControlItem } from "@mantine/core";
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -25,6 +29,8 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props; const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
const t = useI18n(); const t = useI18n();
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
const form = useZodForm(validation.searchEngine.manage, { const form = useZodForm(validation.searchEngine.manage, {
initialValues: initialValues ?? { initialValues: initialValues ?? {
name: "", name: "",
@@ -32,6 +38,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
iconUrl: "", iconUrl: "",
urlTemplate: "", urlTemplate: "",
description: "", description: "",
type: "generic",
}, },
}); });
@@ -52,11 +59,40 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} /> <IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<TextInput
{...form.getInputProps("urlTemplate")} <Fieldset legend={t("search.engine.page.edit.configControl")}>
withAsterisk <SegmentedControl
label={t("search.engine.field.urlTemplate.label")} data={searchEngineTypes.map(
/> (type) =>
({
label: t(`search.engine.page.edit.searchEngineType.${type}`),
value: type,
}) satisfies SegmentedControlItem,
)}
{...form.getInputProps("type")}
fullWidth
/>
{form.values.type === "generic" && (
<TextInput
{...form.getInputProps("urlTemplate")}
withAsterisk
label={t("search.engine.field.urlTemplate.label")}
/>
)}
{form.values.type === "fromIntegration" && (
<WidgetIntegrationSelect
label="Integration"
data={integrationData}
canSelectMultiple={false}
onChange={(value) => form.setFieldValue("integrationId", value[0])}
value={form.values.integrationId !== undefined ? [form.values.integrationId] : []}
withAsterisk
/>
)}
</Fieldset>
<Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} /> <Textarea {...form.getInputProps("description")} label={t("search.engine.field.description.label")} />
<Group justify="end"> <Group justify="end">

View File

@@ -91,9 +91,16 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
{searchEngine.description} {searchEngine.description}
</Text> </Text>
)} )}
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm"> {searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
{searchEngine.urlTemplate} <Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
</Anchor> {searchEngine.urlTemplate}
</Anchor>
)}
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
<Text c="dimmed" size="sm">
{t("page.list.interactive")}
</Text>
)}
</Stack> </Stack>
</Group> </Group>
<Group> <Group>

View File

@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.8.7", "@types/node": "^22.9.0",
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",

18
e2e/health-checks.spec.ts Normal file
View File

@@ -0,0 +1,18 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
describe("Health checks", () => {
test("ready and live should return 200 OK", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const readyResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/ready`);
const liveResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/api/health/live`);
// Assert
expect(readyResponse.status).toBe(200);
expect(liveResponse.status).toBe(200);
}, 20_000);
});

16
e2e/home.spec.ts Normal file
View File

@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
describe("Home", () => {
test("should open with status code 200", async () => {
// Arrange
const homarrContainer = await createHomarrContainer().start();
// Act
const homeResponse = await fetch(`http://localhost:${homarrContainer.getMappedPort(7575)}/`);
// Assert
expect(homeResponse.status).toBe(200);
}, 20_000);
});

View File

@@ -0,0 +1,14 @@
import { GenericContainer, Wait } from "testcontainers";
export const createHomarrContainer = () => {
if (!process.env.CI) {
throw new Error("This test should only be run in CI or with a homarr image named 'homarr-e2e'");
}
return new GenericContainer("homarr-e2e")
.withEnvironment({
AUTH_SECRET: "secret",
})
.withExposedPorts(7575)
.withWaitStrategy(Wait.forHttp("/api/health/ready", 7575));
};

View File

@@ -19,8 +19,9 @@
"lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache", "lint": "turbo lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache",
"lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", "lint:fix": "turbo lint --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache",
"lint:ws": "pnpm dlx sherif@latest", "lint:ws": "pnpm dlx sherif@latest",
"test": "cross-env NODE_ENV=development vitest run --coverage.enabled", "test": "cross-env NODE_ENV=development vitest run --exclude e2e --coverage.enabled ",
"test:ui": "cross-env NODE_ENV=development vitest --ui --coverage.enabled", "test:e2e": "cross-env NODE_ENV=development vitest e2e",
"test:ui": "cross-env NODE_ENV=development vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck", "typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --" "with-env": "dotenv -e .env --"
}, },
@@ -34,7 +35,7 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"testcontainers": "^10.13.2", "testcontainers": "^10.14.0",
"turbo": "^2.2.3", "turbo": "^2.2.3",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite-tsconfig-paths": "^5.1.0", "vite-tsconfig-paths": "^5.1.0",
@@ -49,4 +50,4 @@
"trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch" "trpc-swagger@1.2.6": "patches/trpc-swagger@1.2.6.patch"
} }
} }
} }

View File

@@ -39,7 +39,7 @@
"@trpc/react-query": "next", "@trpc/react-query": "next",
"@trpc/server": "next", "@trpc/server": "next",
"dockerode": "^4.0.2", "dockerode": "^4.0.2",
"next": "^14.2.16", "next": "^14.2.17",
"react": "^18.3.1", "react": "^18.3.1",
"superjson": "2.2.1", "superjson": "2.2.1",
"trpc-swagger": "^1.2.6" "trpc-swagger": "^1.2.6"

View File

@@ -1,5 +1,6 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, asc, createId, eq, inArray, like } from "@homarr/db"; import { and, asc, createId, eq, inArray, like } from "@homarr/db";
@@ -12,7 +13,14 @@ import {
integrationUserPermissions, integrationUserPermissions,
} from "@homarr/db/schema/sqlite"; } from "@homarr/db/schema/sqlite";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions"; import {
getPermissionsWithParents,
integrationDefs,
integrationKinds,
integrationSecretKindObject,
isIntegrationWithSearchSupport,
} from "@homarr/definitions";
import { integrationCreatorFromSecrets } from "@homarr/integrations";
import { validation, z } from "@homarr/validation"; import { validation, z } from "@homarr/validation";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
@@ -62,6 +70,54 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
); );
}), }),
allThatSupportSearch: publicProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(
integrations.kind,
objectEntries(integrationDefs)
.filter(([_, integration]) => integration.supportsSearch)
.map(([kind, _]) => kind),
),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -326,6 +382,33 @@ export const integrationRouter = createTRPCRouter({
); );
}); });
}), }),
searchInIntegration: protectedProcedure
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.integrationId),
with: {
secrets: true,
},
});
if (!integration) {
throw new TRPCError({
code: "NOT_FOUND",
message: "The requested integration does not exist",
});
}
if (!isIntegrationWithSearchSupport(integration)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The requested integration does not support searching",
});
}
const integrationInstance = integrationCreatorFromSecrets(integration);
return await integrationInstance.searchAsync(input.query);
}),
}); });
interface UpdateSecretInput { interface UpdateSecretInput {

View File

@@ -39,7 +39,19 @@ export const searchEngineRouter = createTRPCRouter({
}); });
} }
return searchEngine; return searchEngine.type === "fromIntegration"
? {
...searchEngine,
type: "fromIntegration" as const,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
integrationId: searchEngine.integrationId!,
}
: {
...searchEngine,
type: "generic" as const,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
urlTemplate: searchEngine.urlTemplate!,
};
}), }),
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => { search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({ return await ctx.db.query.searchEngines.findMany({
@@ -53,8 +65,10 @@ export const searchEngineRouter = createTRPCRouter({
name: input.name, name: input.name,
short: input.short.toLowerCase(), short: input.short.toLowerCase(),
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
urlTemplate: input.urlTemplate, urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description, description: input.description,
type: input.type,
integrationId: "integrationId" in input ? input.integrationId : null,
}); });
}), }),
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
@@ -74,8 +88,10 @@ export const searchEngineRouter = createTRPCRouter({
.set({ .set({
name: input.name, name: input.name,
iconUrl: input.iconUrl, iconUrl: input.iconUrl,
urlTemplate: input.urlTemplate, urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
description: input.description, description: input.description,
integrationId: "integrationId" in input ? input.integrationId : null,
type: input.type,
}) })
.where(eq(searchEngines.id, input.id)); .where(eq(searchEngines.id, input.id));
}), }),

View File

@@ -34,7 +34,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "7.2.1", "ldapts": "7.2.1",
"next": "^14.2.16", "next": "^14.2.17",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.25",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"

View File

@@ -23,7 +23,7 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@drizzle-team/brocli": "^0.10.2", "@drizzle-team/brocli": "^0.11.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",

View File

@@ -27,9 +27,9 @@
"dependencies": { "dependencies": {
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.16", "next": "^14.2.17",
"react": "^18.3.1", "react": "^18.3.1",
"tldts": "^6.1.58" "tldts": "^6.1.59"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -0,0 +1,4 @@
ALTER TABLE `search_engine` MODIFY COLUMN `url_template` text;--> statement-breakpoint
ALTER TABLE `search_engine` ADD `type` varchar(64) DEFAULT 'generic' NOT NULL;--> statement-breakpoint
ALTER TABLE `search_engine` ADD `integration_id` varchar(64);--> statement-breakpoint
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,13 @@
"when": 1729524382483, "when": 1729524382483,
"tag": "0014_bizarre_red_shift", "tag": "0014_bizarre_red_shift",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "5",
"when": 1730653393442,
"tag": "0015_unknown_firedrake",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,17 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_search_engine` (
`id` text PRIMARY KEY NOT NULL,
`icon_url` text NOT NULL,
`name` text NOT NULL,
`short` text NOT NULL,
`description` text,
`url_template` text,
`type` text DEFAULT 'generic' NOT NULL,
`integration_id` text,
FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_search_engine`("id", "icon_url", "name", "short", "description", "url_template") SELECT "id", "icon_url", "name", "short", "description", "url_template" FROM `search_engine`;--> statement-breakpoint
DROP TABLE `search_engine`;--> statement-breakpoint
ALTER TABLE `__new_search_engine` RENAME TO `search_engine`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,13 @@
"when": 1729524387583, "when": 1729524387583,
"tag": "0014_colorful_cargill", "tag": "0014_colorful_cargill",
"breakpoints": true "breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1730653336134,
"tag": "0015_superb_psylocke",
"breakpoints": true
} }
] ]
} }

View File

@@ -41,12 +41,12 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^10.13.2", "@testcontainers/mysql": "^10.14.0",
"better-sqlite3": "^11.5.0", "better-sqlite3": "^11.5.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-kit": "^0.27.1", "drizzle-kit": "^0.28.0",
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.1",
"mysql2": "3.11.3" "mysql2": "3.11.4"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -25,6 +25,7 @@ import type {
IntegrationKind, IntegrationKind,
IntegrationPermission, IntegrationPermission,
IntegrationSecretKind, IntegrationSecretKind,
SearchEngineType,
SectionKind, SectionKind,
SupportedAuthProvider, SupportedAuthProvider,
WidgetKind, WidgetKind,
@@ -395,7 +396,9 @@ export const searchEngines = mysqlTable("search_engine", {
name: varchar("name", { length: 64 }).notNull(), name: varchar("name", { length: 64 }).notNull(),
short: varchar("short", { length: 8 }).notNull(), short: varchar("short", { length: 8 }).notNull(),
description: text("description"), description: text("description"),
urlTemplate: text("url_template").notNull(), urlTemplate: text("url_template"),
type: varchar("type", { length: 64 }).$type<SearchEngineType>().notNull().default("generic"),
integrationId: varchar("integration_id", { length: 64 }).references(() => integrations.id, { onDelete: "cascade" }),
}); });
export const accountRelations = relations(accounts, ({ one }) => ({ export const accountRelations = relations(accounts, ({ one }) => ({
@@ -568,3 +571,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
references: [items.id], references: [items.id],
}), }),
})); }));
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
}));

View File

@@ -16,6 +16,7 @@ import type {
IntegrationKind, IntegrationKind,
IntegrationPermission, IntegrationPermission,
IntegrationSecretKind, IntegrationSecretKind,
SearchEngineType,
SectionKind, SectionKind,
SupportedAuthProvider, SupportedAuthProvider,
WidgetKind, WidgetKind,
@@ -382,7 +383,9 @@ export const searchEngines = sqliteTable("search_engine", {
name: text("name").notNull(), name: text("name").notNull(),
short: text("short").notNull(), short: text("short").notNull(),
description: text("description"), description: text("description"),
urlTemplate: text("url_template").notNull(), urlTemplate: text("url_template"),
type: text("type").$type<SearchEngineType>().notNull().default("generic"),
integrationId: text("integration_id").references(() => integrations.id, { onDelete: "cascade" }),
}); });
export const accountRelations = relations(accounts, ({ one }) => ({ export const accountRelations = relations(accounts, ({ one }) => ({
@@ -557,6 +560,13 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
}), }),
})); }));
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
}));
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Account = InferSelectModel<typeof accounts>; export type Account = InferSelectModel<typeof accounts>;
export type Session = InferSelectModel<typeof sessions>; export type Session = InferSelectModel<typeof sessions>;

View File

@@ -9,3 +9,4 @@ export * from "./user";
export * from "./group"; export * from "./group";
export * from "./docs"; export * from "./docs";
export * from "./cookie"; export * from "./cookie";
export * from "./search-engine";

View File

@@ -14,6 +14,7 @@ interface integrationDefinition {
iconUrl: string; iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>; category: AtLeastOneOf<IntegrationCategory>;
supportsSearch: boolean;
} }
export const integrationDefs = { export const integrationDefs = {
@@ -22,108 +23,126 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
category: ["downloadClient", "usenet"], category: ["downloadClient", "usenet"],
supportsSearch: false,
}, },
nzbGet: { nzbGet: {
name: "NZBGet", name: "NZBGet",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
category: ["downloadClient", "usenet"], category: ["downloadClient", "usenet"],
supportsSearch: false,
}, },
deluge: { deluge: {
name: "Deluge", name: "Deluge",
secretKinds: [["password"]], secretKinds: [["password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
category: ["downloadClient", "torrent"], category: ["downloadClient", "torrent"],
supportsSearch: false,
}, },
transmission: { transmission: {
name: "Transmission", name: "Transmission",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
category: ["downloadClient", "torrent"], category: ["downloadClient", "torrent"],
supportsSearch: false,
}, },
qBittorrent: { qBittorrent: {
name: "qBittorrent", name: "qBittorrent",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
category: ["downloadClient", "torrent"], category: ["downloadClient", "torrent"],
supportsSearch: false,
}, },
sonarr: { sonarr: {
name: "Sonarr", name: "Sonarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
category: ["calendar"], category: ["calendar"],
supportsSearch: false,
}, },
radarr: { radarr: {
name: "Radarr", name: "Radarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
category: ["calendar"], category: ["calendar"],
supportsSearch: false,
}, },
lidarr: { lidarr: {
name: "Lidarr", name: "Lidarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
category: ["calendar"], category: ["calendar"],
supportsSearch: false,
}, },
readarr: { readarr: {
name: "Readarr", name: "Readarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
category: ["calendar"], category: ["calendar"],
supportsSearch: false,
}, },
prowlarr: { prowlarr: {
name: "Prowlarr", name: "Prowlarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png",
category: ["indexerManager"], category: ["indexerManager"],
supportsSearch: false,
}, },
jellyfin: { jellyfin: {
name: "Jellyfin", name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]], secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
category: ["mediaService"], category: ["mediaService"],
supportsSearch: false,
}, },
plex: { plex: {
name: "Plex", name: "Plex",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
category: ["mediaService"], category: ["mediaService"],
supportsSearch: false,
}, },
jellyseerr: { jellyseerr: {
name: "Jellyseerr", name: "Jellyseerr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
category: ["mediaSearch", "mediaRequest"], category: ["mediaSearch", "mediaRequest"],
supportsSearch: true,
}, },
overseerr: { overseerr: {
name: "Overseerr", name: "Overseerr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
category: ["mediaSearch", "mediaRequest"], category: ["mediaSearch", "mediaRequest"],
supportsSearch: true,
}, },
piHole: { piHole: {
name: "Pi-hole", name: "Pi-hole",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
category: ["dnsHole"], category: ["dnsHole"],
supportsSearch: false,
}, },
adGuardHome: { adGuardHome: {
name: "AdGuard Home", name: "AdGuard Home",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
category: ["dnsHole"], category: ["dnsHole"],
supportsSearch: false,
}, },
homeAssistant: { homeAssistant: {
name: "Home Assistant", name: "Home Assistant",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
category: ["smartHomeServer"], category: ["smartHomeServer"],
supportsSearch: false,
}, },
openmediavault: { openmediavault: {
name: "OpenMediaVault", name: "OpenMediaVault",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png", iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png",
category: ["healthMonitoring"], category: ["healthMonitoring"],
supportsSearch: false,
}, },
} as const satisfies Record<string, integrationDefinition>; } as const satisfies Record<string, integrationDefinition>;
@@ -162,6 +181,22 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
U U
: never; : never;
/**
* Checks if search is supported by the integration
* Uses a typescript guard with is to allow only integrations with search support within if statement
* @param integration integration with kind
* @returns true if the integration supports search
*/
export const isIntegrationWithSearchSupport = (integration: {
kind: IntegrationKind;
}): integration is { kind: IntegrationWithSearchSupport } => {
return integrationDefs[integration.kind].supportsSearch;
};
type IntegrationWithSearchSupport = {
[Key in keyof typeof integrationDefs]: true extends (typeof integrationDefs)[Key]["supportsSearch"] ? Key : never;
}[keyof typeof integrationDefs];
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject; export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs; export type IntegrationKind = keyof typeof integrationDefs;
export type IntegrationCategory = export type IntegrationCategory =

View File

@@ -0,0 +1,2 @@
export const searchEngineTypes = ["generic", "fromIntegration"] as const;
export type SearchEngineType = (typeof searchEngineTypes)[number];

View File

@@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.13.4" "@mantine/form": "^7.13.5"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -2,11 +2,14 @@ import { useForm, zodResolver } from "@mantine/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import type { AnyZodObject, ZodEffects, ZodIntersection } from "@homarr/validation"; import type { AnyZodObject, ZodDiscriminatedUnion, ZodEffects, ZodIntersection } from "@homarr/validation";
import { zodErrorMap } from "@homarr/validation/form"; import { zodErrorMap } from "@homarr/validation/form";
export const useZodForm = < export const useZodForm = <
TSchema extends AnyZodObject | ZodEffects<AnyZodObject> | ZodIntersection<AnyZodObject, AnyZodObject>, TSchema extends
| AnyZodObject
| ZodEffects<AnyZodObject>
| ZodIntersection<AnyZodObject | ZodDiscriminatedUnion<string, AnyZodObject[]>, AnyZodObject>,
>( >(
schema: TSchema, schema: TSchema,
options: Omit< options: Omit<

View File

@@ -0,0 +1,3 @@
export interface ISearchableIntegration {
searchAsync(query: string): Promise<{ image?: string; name: string; link: string }[]>;
}

View File

@@ -1,13 +1,33 @@
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
import { Integration } from "../base/integration"; import { Integration } from "../base/integration";
import type { ISearchableIntegration } from "../base/searchable-integration";
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request"; import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request"; import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
/** /**
* Overseerr Integration. See https://api-docs.overseerr.dev * Overseerr Integration. See https://api-docs.overseerr.dev
*/ */
export class OverseerrIntegration extends Integration { export class OverseerrIntegration extends Integration implements ISearchableIntegration {
public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> {
const response = await fetch(`${this.integration.url}/api/v1/search?query=${query}`, {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const schemaData = await searchSchema.parseAsync(await response.json());
if (!schemaData.results) {
return [];
}
return schemaData.results.map((result) => ({
name: "name" in result ? result.name : result.title,
link: `${this.integration.url}/${result.mediaType}/${result.id}`,
image: constructSearchResultImage(this.integration.url, result),
text: "overview" in result ? result.overview : undefined,
}));
}
public async testConnectionAsync(): Promise<void> { public async testConnectionAsync(): Promise<void> {
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, { const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
headers: { headers: {
@@ -180,6 +200,35 @@ interface MovieInformation {
releaseDate: string; releaseDate: string;
} }
const searchSchema = z.object({
results: z
.array(
z.discriminatedUnion("mediaType", [
z.object({
id: z.number(),
mediaType: z.literal("tv"),
name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
}),
z.object({
id: z.number(),
mediaType: z.literal("movie"),
title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
}),
z.object({
id: z.number(),
mediaType: z.literal("person"),
name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
}),
]),
)
.optional(),
});
const getRequestsSchema = z.object({ const getRequestsSchema = z.object({
results: z results: z
.array( .array(
@@ -239,3 +288,32 @@ const getUsersSchema = z.object({
return val; return val;
}), }),
}); });
const constructSearchResultImage = (
appUrl: string,
result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number],
) => {
const path = getResultImagePath(appUrl, result);
if (!path) {
return undefined;
}
return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${path}`;
};
const getResultImagePath = (
appUrl: string,
result: Exclude<z.infer<typeof searchSchema>["results"], undefined>[number],
) => {
switch (result.mediaType) {
case "person":
return result.profilePath;
case "tv":
case "movie":
return result.posterPath;
default:
throw new Error(
`Unable to get search result image from media type '${(result as { mediaType: string }).mediaType}'`,
);
}
};

View File

@@ -4,3 +4,4 @@ export * from "./interfaces/health-monitoring/healt-monitoring";
export * from "./interfaces/indexer-manager/indexer"; export * from "./interfaces/indexer-manager/indexer";
export * from "./interfaces/media-requests/media-request"; export * from "./interfaces/media-requests/media-request";
export * from "./pi-hole/pi-hole-types"; export * from "./pi-hole/pi-hole-types";
export * from "./base/searchable-integration";

View File

@@ -30,10 +30,10 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.5",
"@tabler/icons-react": "^3.21.0", "@tabler/icons-react": "^3.21.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "^14.2.16", "next": "^14.2.17",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -24,8 +24,8 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.5",
"@mantine/hooks": "^7.13.4", "@mantine/hooks": "^7.13.5",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -24,7 +24,7 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.13.4", "@mantine/notifications": "^7.13.5",
"@tabler/icons-react": "^3.21.0" "@tabler/icons-react": "^3.21.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -12,5 +12,15 @@ export type OldmarrWeatherDefinition = CommonOldmarrWidgetDefinition<
latitude: number; latitude: number;
longitude: number; longitude: number;
}; };
dateFormat:
| "hide"
| "dddd, MMMM D"
| "dddd, D MMMM"
| "MMM D"
| "D MMM"
| "DD/MM/YYYY"
| "MM/DD/YYYY"
| "DD/MM"
| "MM/DD";
} }
>; >;

View File

@@ -72,6 +72,7 @@ const optionMapping: OptionMapping = {
isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit, isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit,
location: (oldOptions) => oldOptions.location, location: (oldOptions) => oldOptions.location,
showCity: (oldOptions) => oldOptions.displayCityName, showCity: (oldOptions) => oldOptions.displayCityName,
dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat),
}, },
iframe: { iframe: {
embedUrl: (oldOptions) => oldOptions.embedUrl, embedUrl: (oldOptions) => oldOptions.embedUrl,

View File

@@ -26,16 +26,17 @@
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0", "@homarr/modals-collection": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.5",
"@mantine/hooks": "^7.13.4", "@mantine/hooks": "^7.13.5",
"@mantine/spotlight": "^7.13.4", "@mantine/spotlight": "^7.13.5",
"@tabler/icons-react": "^3.21.0", "@tabler/icons-react": "^3.21.0",
"jotai": "^2.10.1", "jotai": "^2.10.1",
"next": "^14.2.16", "next": "^14.2.17",
"react": "^18.3.1", "react": "^18.3.1",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
}, },

View File

@@ -1,4 +1,4 @@
import { Group, Kbd, Stack, Text } from "@mantine/core"; import { Group, Image, Kbd, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -12,29 +12,69 @@ import { interaction } from "../../lib/interaction";
type SearchEngine = RouterOutputs["searchEngine"]["search"][number]; type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
useActions: () => [ useActions: (searchEngine, query) => {
{ const { data } = clientApi.integration.searchInIntegration.useQuery(
key: "search", // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Component: ({ name }) => { { integrationId: searchEngine.integrationId!, query },
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children"); {
enabled: searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && query.length > 0,
},
);
if (searchEngine.type === "generic") {
return [
{
key: "search",
Component: ({ name }) => {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return (
<Group mx="md" my="sm">
<IconSearch stroke={1.5} />
<Text>{tChildren("action.search.label", { name })}</Text>
</Group>
);
},
useInteraction: interaction.link(({ urlTemplate }, query) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: urlTemplate!.replace("%s", query),
})),
},
];
}
return (data ?? []).map((searchResult, index) => ({
key: `search-result-${index}`,
Component: () => {
return ( return (
<Group mx="md" my="sm"> <Group mx="md" my="sm" wrap="nowrap">
<IconSearch stroke={1.5} /> {searchResult.image ? (
<Text>{tChildren("action.search.label", { name })}</Text> <Image src={searchResult.image} w={35} h={50} fit="cover" radius={"md"} />
) : (
<IconSearch stroke={1.5} size={35} />
)}
<Stack gap={2}>
<Text>{searchResult.name}</Text>
{searchResult.text && (
<Text c="dimmed" size="sm" lineClamp={2}>
{searchResult.text}
</Text>
)}
</Stack>
</Group> </Group>
); );
}, },
useInteraction: interaction.link(({ urlTemplate }, query) => ({ useInteraction: interaction.link(() => ({
href: urlTemplate.replace("%s", query), href: searchResult.link,
newTab: true,
})), })),
}, }));
], },
DetailComponent({ options }) { DetailComponent({ options }) {
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children"); const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
return ( return (
<Stack mx="md" my="sm"> <Stack mx="md" my="sm">
<Text>{tChildren("detail.title")}</Text> <Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
<Group> <Group>
<img height={24} width={24} src={options.iconUrl} alt={options.name} /> <img height={24} width={24} src={options.iconUrl} alt={options.name} />
<Text>{options.name}</Text> <Text>{options.name}</Text>
@@ -72,10 +112,24 @@ export const searchEnginesSearchGroups = createGroup<SearchEngine>({
setChildrenOptions(searchEnginesChildrenOptions(engine)); setChildrenOptions(searchEnginesChildrenOptions(engine));
}, },
useInteraction: interaction.link(({ urlTemplate }, query) => ({ useInteraction: (searchEngine, query) => {
href: urlTemplate.replace("%s", query), if (searchEngine.type === "generic" && searchEngine.urlTemplate) {
newTab: true, return {
})), type: "link" as const,
href: searchEngine.urlTemplate.replace("%s", query),
newTab: true,
};
}
if (searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null) {
return {
type: "children",
...searchEnginesChildrenOptions(searchEngine),
};
}
throw new Error(`Unable to process search engine with type ${searchEngine.type}`);
},
useQueryOptions(query) { useQueryOptions(query) {
return clientApi.searchEngine.search.useQuery({ return clientApi.searchEngine.search.useQuery({
query: query.trim(), query: query.trim(),

View File

@@ -32,8 +32,8 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"deepmerge": "4.3.1", "deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16", "next": "^14.2.17",
"next-intl": "3.24.0", "next-intl": "3.25.0",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1134,6 +1134,10 @@
"forecastDayCount": { "forecastDayCount": {
"label": "Amount of forecast days", "label": "Amount of forecast days",
"description": "When the widget is not wide enough, less days are shown" "description": "When the widget is not wide enough, less days are shown"
},
"dateFormat": {
"label": "Date Format",
"description": "How the date should look like"
} }
}, },
"kind": { "kind": {
@@ -2427,6 +2431,9 @@
}, },
"detail": { "detail": {
"title": "Select an action for the search engine" "title": "Select an action for the search engine"
},
"searchResults": {
"title": "Select a search result for actions"
} }
}, },
"option": { "option": {
@@ -2596,7 +2603,8 @@
"noResults": { "noResults": {
"title": "There aren't any search engines", "title": "There aren't any search engines",
"action": "Create your first search engine" "action": "Create your first search engine"
} },
"interactive": "Interactive, uses an integration"
}, },
"create": { "create": {
"title": "New search engine", "title": "New search engine",
@@ -2622,6 +2630,11 @@
"title": "Unable to apply changes", "title": "Unable to apply changes",
"message": "The search engine could not be saved" "message": "The search engine could not be saved"
} }
},
"configControl": "Configuration",
"searchEngineType": {
"generic": "Generic",
"fromIntegration": "From integration"
} }
}, },
"delete": { "delete": {

View File

@@ -28,12 +28,12 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.5",
"@mantine/dates": "^7.13.4", "@mantine/dates": "^7.13.5",
"@mantine/hooks": "^7.13.4", "@mantine/hooks": "^7.13.5",
"@tabler/icons-react": "^3.21.0", "@tabler/icons-react": "^3.21.0",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16", "next": "^14.2.17",
"react": "^18.3.1" "react": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,20 +1,41 @@
import type { ZodTypeAny } from "zod";
import { z } from "zod"; import { z } from "zod";
import type { SearchEngineType } from "@homarr/definitions";
const genericSearchEngine = z.object({
type: z.literal("generic" satisfies SearchEngineType),
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
});
const fromIntegrationSearchEngine = z.object({
type: z.literal("fromIntegration" satisfies SearchEngineType),
integrationId: z.string().optional(),
});
const manageSearchEngineSchema = z.object({ const manageSearchEngineSchema = z.object({
name: z.string().min(1).max(64), name: z.string().min(1).max(64),
short: z.string().min(1).max(8), short: z.string().min(1).max(8),
iconUrl: z.string().min(1), iconUrl: z.string().min(1),
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
description: z.string().max(512).nullable(), description: z.string().max(512).nullable(),
}); });
const editSearchEngineSchema = manageSearchEngineSchema const createManageSearchEngineSchema = <T extends ZodTypeAny>(
.extend({ callback: (schema: typeof manageSearchEngineSchema) => T,
id: z.string(), ) =>
}) z
.omit({ short: true }); .discriminatedUnion("type", [genericSearchEngine, fromIntegrationSearchEngine])
.and(callback(manageSearchEngineSchema));
const editSearchEngineSchema = createManageSearchEngineSchema((schema) =>
schema
.extend({
id: z.string(),
})
.omit({ short: true }),
);
export const searchEngineSchemas = { export const searchEngineSchemas = {
manage: manageSearchEngineSchema, manage: createManageSearchEngineSchema((schema) => schema),
edit: editSearchEngineSchema, edit: editSearchEngineSchema,
}; };

View File

@@ -40,8 +40,8 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.13.5",
"@mantine/hooks": "^7.13.4", "@mantine/hooks": "^7.13.5",
"@tabler/icons-react": "^3.21.0", "@tabler/icons-react": "^3.21.0",
"@tiptap/extension-color": "2.9.1", "@tiptap/extension-color": "2.9.1",
"@tiptap/extension-highlight": "2.9.1", "@tiptap/extension-highlight": "2.9.1",
@@ -61,7 +61,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"mantine-react-table": "2.0.0-beta.7", "mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.16", "next": "^14.2.17",
"react": "^18.3.1", "react": "^18.3.1",
"video.js": "^8.19.1" "video.js": "^8.19.1"
}, },

View File

@@ -115,6 +115,7 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
}; };
function Forecast({ weather, options }: WeatherProps) { function Forecast({ weather, options }: WeatherProps) {
const dateFormat = options.dateFormat;
return ( return (
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="2.5cqmin"> <Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="2.5cqmin">
{weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => ( {weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
@@ -136,6 +137,7 @@ function Forecast({ weather, options }: WeatherProps) {
</HoverCard.Target> </HoverCard.Target>
<HoverCard.Dropdown> <HoverCard.Dropdown>
<WeatherDescription <WeatherDescription
dateFormat={dateFormat}
time={dayWeather.time} time={dayWeather.time}
weatherCode={dayWeather.weatherCode} weatherCode={dayWeather.weatherCode}
maxTemp={getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)} maxTemp={getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit)}

View File

@@ -15,6 +15,8 @@ import type { TranslationObject } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import type { WidgetProps } from "../definition";
interface WeatherIconProps { interface WeatherIconProps {
code: number; code: number;
size?: string | number; size?: string | number;
@@ -34,6 +36,7 @@ export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => {
interface WeatherDescriptionProps { interface WeatherDescriptionProps {
weatherOnly?: boolean; weatherOnly?: boolean;
dateFormat?: WidgetProps<"weather">["options"]["dateFormat"];
time?: string; time?: string;
weatherCode: number; weatherCode: number;
maxTemp?: string; maxTemp?: string;
@@ -42,13 +45,21 @@ interface WeatherDescriptionProps {
/** /**
* Description Dropdown for a given set of parameters * Description Dropdown for a given set of parameters
* @param dateFormat format of the date that will be displayed on the widget
* @param time date that can be formatted by dayjs * @param time date that can be formatted by dayjs
* @param weatherCode weather code from api * @param weatherCode weather code from api
* @param maxTemp preformatted string for max temperature * @param maxTemp preformatted string for max temperature
* @param minTemp preformatted string for min temperature * @param minTemp preformatted string for min temperature
* @returns Content for a HoverCard dropdown presenting weather information * @returns Content for a HoverCard dropdown presenting weather information
*/ */
export const WeatherDescription = ({ weatherOnly, time, weatherCode, maxTemp, minTemp }: WeatherDescriptionProps) => { export const WeatherDescription = ({
weatherOnly,
dateFormat,
time,
weatherCode,
maxTemp,
minTemp,
}: WeatherDescriptionProps) => {
const t = useScopedI18n("widget.weather"); const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
@@ -60,7 +71,7 @@ export const WeatherDescription = ({ weatherOnly, time, weatherCode, maxTemp, mi
return ( return (
<Stack align="center" gap="0"> <Stack align="center" gap="0">
<Text fz="24px">{dayjs(time).format("dddd MMMM D YYYY")}</Text> <Text fz="24px">{dayjs(time).format(dateFormat)}</Text>
<Text fz="16px">{t(`kind.${name}`)}</Text> <Text fz="16px">{t(`kind.${name}`)}</Text>
<Text fz="16px">{`${tCommon("information.max")}: ${maxTemp}`}</Text> <Text fz="16px">{`${tCommon("information.max")}: ${maxTemp}`}</Text>
<Text fz="16px">{`${tCommon("information.min")}: ${minTemp}`}</Text> <Text fz="16px">{`${tCommon("information.min")}: ${minTemp}`}</Text>

View File

@@ -1,4 +1,5 @@
import { IconCloud } from "@tabler/icons-react"; import { IconCloud } from "@tabler/icons-react";
import dayjs from "dayjs";
import { z } from "@homarr/validation"; import { z } from "@homarr/validation";
@@ -17,6 +18,20 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
longitude: 2.3488, longitude: 2.3488,
}, },
}), }),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch(), showCity: factory.switch(),
hasForecast: factory.switch(), hasForecast: factory.switch(),
forecastDayCount: factory.slider({ forecastDayCount: factory.slider({

View File

@@ -30,13 +30,16 @@ interface WidgetIntegrationSelectProps {
error?: string; error?: string;
onFocus?: FocusEventHandler<HTMLInputElement>; onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
canSelectMultiple?: boolean;
data: IntegrationSelectOption[]; data: IntegrationSelectOption[];
withAsterisk?: boolean;
} }
export const WidgetIntegrationSelect = ({ export const WidgetIntegrationSelect = ({
data, data,
onChange, onChange,
value: valueProp, value: valueProp,
canSelectMultiple = true,
withAsterisk = false,
...props ...props
}: WidgetIntegrationSelectProps) => { }: WidgetIntegrationSelectProps) => {
const t = useI18n(); const t = useI18n();
@@ -47,12 +50,16 @@ export const WidgetIntegrationSelect = ({
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"), onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
}); });
const handleValueSelect = (selectedValue: string) => const handleValueSelect = (selectedValue: string) => {
onChange( onChange(
multiSelectValues.includes(selectedValue) multiSelectValues.includes(selectedValue)
? multiSelectValues.filter((value) => value !== selectedValue) ? multiSelectValues.filter((value) => value !== selectedValue)
: [...multiSelectValues, selectedValue], : [...multiSelectValues, selectedValue],
); );
if (!canSelectMultiple) {
combobox.closeDropdown();
}
};
const handleValueRemove = (valueToRemove: string) => const handleValueRemove = (valueToRemove: string) =>
onChange(multiSelectValues.filter((value) => value !== valueToRemove)); onChange(multiSelectValues.filter((value) => value !== valueToRemove));
@@ -63,7 +70,14 @@ export const WidgetIntegrationSelect = ({
return null; return null;
} }
return <IntegrationPill key={item} option={option} onRemove={() => handleValueRemove(item)} />; return (
<IntegrationPill
key={item}
option={option}
onRemove={() => handleValueRemove(item)}
showRemoveButton={canSelectMultiple}
/>
);
}); });
const options = data.map((item) => { const options = data.map((item) => {
@@ -103,6 +117,7 @@ export const WidgetIntegrationSelect = ({
} }
pointer pointer
onClick={() => combobox.toggleDropdown()} onClick={() => combobox.toggleDropdown()}
withAsterisk={withAsterisk}
{...props} {...props}
> >
<Pill.Group> <Pill.Group>
@@ -150,14 +165,17 @@ export interface IntegrationSelectOption {
interface IntegrationPillProps { interface IntegrationPillProps {
option: IntegrationSelectOption; option: IntegrationSelectOption;
onRemove: () => void; onRemove: () => void;
showRemoveButton: boolean;
} }
const IntegrationPill = ({ option, onRemove }: IntegrationPillProps) => ( const IntegrationPill = ({ option, onRemove, showRemoveButton }: IntegrationPillProps) => (
<Group align="center" wrap="nowrap" gap={0} className={classes.pill}> <Group align="center" wrap="nowrap" gap={0} className={classes.pill} mih={24} pr={!showRemoveButton ? 10 : undefined}>
<Avatar src={getIconUrl(option.kind)} size={14} mr={6} /> <Avatar src={getIconUrl(option.kind)} size={14} mr={6} />
<Text span size="xs" lh={1} fw={500}> <Text span size="xs" lh={1} fw={500}>
{option.name} {option.name}
</Text> </Text>
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} /> {showRemoveButton && (
<CloseButton onMouseDown={onRemove} variant="transparent" color="gray" size={22} iconSize={14} tabIndex={-1} />
)}
</Group> </Group>
); );

843
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
# Creating folders in volume
mkdir -p /appdata/db
mkdir -p /appdata/redis
# Run migrations # Run migrations
if [ $DB_MIGRATIONS_DISABLED = "true" ]; then if [ $DB_MIGRATIONS_DISABLED = "true" ]; then
echo "DB migrations are disabled, skipping" echo "DB migrations are disabled, skipping"

View File

@@ -16,7 +16,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "^14.2.16", "@next/eslint-plugin-next": "^14.2.17",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.2.3", "eslint-config-turbo": "^2.2.3",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-import": "^2.31.0",

View File

@@ -7,7 +7,7 @@ runs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22
cache: "pnpm" cache: "pnpm"
- shell: bash - shell: bash

View File

@@ -9,7 +9,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.3.1", "@ianvs/prettier-plugin-sort-imports": "^4.4.0",
"prettier": "^3.3.3" "prettier": "^3.3.3"
}, },
"devDependencies": { "devDependencies": {

8
tsconfig.e2e.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["e2e"],
"exclude": ["node_modules"]
}