mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 type { TranslationFunction } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -25,6 +29,8 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
const { submitButtonTranslation, handleSubmit, initialValues, isPending, disableShort } = props;
|
||||
const t = useI18n();
|
||||
|
||||
const [integrationData] = clientApi.integration.allThatSupportSearch.useSuspenseQuery();
|
||||
|
||||
const form = useZodForm(validation.searchEngine.manage, {
|
||||
initialValues: initialValues ?? {
|
||||
name: "",
|
||||
@@ -32,6 +38,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
iconUrl: "",
|
||||
urlTemplate: "",
|
||||
description: "",
|
||||
type: "generic",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,11 +59,40 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
|
||||
<TextInput
|
||||
{...form.getInputProps("urlTemplate")}
|
||||
withAsterisk
|
||||
label={t("search.engine.field.urlTemplate.label")}
|
||||
/>
|
||||
|
||||
<Fieldset legend={t("search.engine.page.edit.configControl")}>
|
||||
<SegmentedControl
|
||||
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")} />
|
||||
|
||||
<Group justify="end">
|
||||
|
||||
@@ -91,9 +91,16 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
|
||||
{searchEngine.description}
|
||||
</Text>
|
||||
)}
|
||||
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
|
||||
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
|
||||
{searchEngine.urlTemplate}
|
||||
</Anchor>
|
||||
)}
|
||||
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("page.list.interactive")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
|
||||
@@ -12,7 +13,14 @@ import {
|
||||
integrationUserPermissions,
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
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 { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
@@ -62,6 +70,54 @@ export const integrationRouter = createTRPCRouter({
|
||||
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
|
||||
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
|
||||
.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 {
|
||||
|
||||
@@ -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 }) => {
|
||||
return await ctx.db.query.searchEngines.findMany({
|
||||
@@ -53,8 +65,10 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
short: input.short.toLowerCase(),
|
||||
iconUrl: input.iconUrl,
|
||||
urlTemplate: input.urlTemplate,
|
||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||
description: input.description,
|
||||
type: input.type,
|
||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||
});
|
||||
}),
|
||||
update: protectedProcedure.input(validation.searchEngine.edit).mutation(async ({ ctx, input }) => {
|
||||
@@ -74,8 +88,10 @@ export const searchEngineRouter = createTRPCRouter({
|
||||
.set({
|
||||
name: input.name,
|
||||
iconUrl: input.iconUrl,
|
||||
urlTemplate: input.urlTemplate,
|
||||
urlTemplate: "urlTemplate" in input ? input.urlTemplate : null,
|
||||
description: input.description,
|
||||
integrationId: "integrationId" in input ? input.integrationId : null,
|
||||
type: input.type,
|
||||
})
|
||||
.where(eq(searchEngines.id, input.id));
|
||||
}),
|
||||
|
||||
4
packages/db/migrations/mysql/0015_unknown_firedrake.sql
Normal file
4
packages/db/migrations/mysql/0015_unknown_firedrake.sql
Normal 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;
|
||||
1627
packages/db/migrations/mysql/meta/0015_snapshot.json
Normal file
1627
packages/db/migrations/mysql/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1729524382483,
|
||||
"tag": "0014_bizarre_red_shift",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "5",
|
||||
"when": 1730653393442,
|
||||
"tag": "0015_unknown_firedrake",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
17
packages/db/migrations/sqlite/0015_superb_psylocke.sql
Normal file
17
packages/db/migrations/sqlite/0015_superb_psylocke.sql
Normal 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;
|
||||
1556
packages/db/migrations/sqlite/meta/0015_snapshot.json
Normal file
1556
packages/db/migrations/sqlite/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1729524387583,
|
||||
"tag": "0014_colorful_cargill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1730653336134,
|
||||
"tag": "0015_superb_psylocke",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
SupportedAuthProvider,
|
||||
WidgetKind,
|
||||
@@ -395,7 +396,9 @@ export const searchEngines = mysqlTable("search_engine", {
|
||||
name: varchar("name", { length: 64 }).notNull(),
|
||||
short: varchar("short", { length: 8 }).notNull(),
|
||||
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 }) => ({
|
||||
@@ -568,3 +571,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
|
||||
references: [items.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [searchEngines.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
IntegrationKind,
|
||||
IntegrationPermission,
|
||||
IntegrationSecretKind,
|
||||
SearchEngineType,
|
||||
SectionKind,
|
||||
SupportedAuthProvider,
|
||||
WidgetKind,
|
||||
@@ -382,7 +383,9 @@ export const searchEngines = sqliteTable("search_engine", {
|
||||
name: text("name").notNull(),
|
||||
short: text("short").notNull(),
|
||||
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 }) => ({
|
||||
@@ -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 Account = InferSelectModel<typeof accounts>;
|
||||
export type Session = InferSelectModel<typeof sessions>;
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from "./user";
|
||||
export * from "./group";
|
||||
export * from "./docs";
|
||||
export * from "./cookie";
|
||||
export * from "./search-engine";
|
||||
|
||||
@@ -14,6 +14,7 @@ interface integrationDefinition {
|
||||
iconUrl: string;
|
||||
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
|
||||
category: AtLeastOneOf<IntegrationCategory>;
|
||||
supportsSearch: boolean;
|
||||
}
|
||||
|
||||
export const integrationDefs = {
|
||||
@@ -22,108 +23,126 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
|
||||
category: ["downloadClient", "usenet"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
nzbGet: {
|
||||
name: "NZBGet",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
|
||||
category: ["downloadClient", "usenet"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
deluge: {
|
||||
name: "Deluge",
|
||||
secretKinds: [["password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
|
||||
category: ["downloadClient", "torrent"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
transmission: {
|
||||
name: "Transmission",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
|
||||
category: ["downloadClient", "torrent"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
qBittorrent: {
|
||||
name: "qBittorrent",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
|
||||
category: ["downloadClient", "torrent"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
sonarr: {
|
||||
name: "Sonarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
|
||||
category: ["calendar"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
radarr: {
|
||||
name: "Radarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
|
||||
category: ["calendar"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
lidarr: {
|
||||
name: "Lidarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
|
||||
category: ["calendar"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
readarr: {
|
||||
name: "Readarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
|
||||
category: ["calendar"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
prowlarr: {
|
||||
name: "Prowlarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png",
|
||||
category: ["indexerManager"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
jellyfin: {
|
||||
name: "Jellyfin",
|
||||
secretKinds: [["username", "password"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
|
||||
category: ["mediaService"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
plex: {
|
||||
name: "Plex",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
|
||||
category: ["mediaService"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
jellyseerr: {
|
||||
name: "Jellyseerr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
|
||||
category: ["mediaSearch", "mediaRequest"],
|
||||
supportsSearch: true,
|
||||
},
|
||||
overseerr: {
|
||||
name: "Overseerr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
|
||||
category: ["mediaSearch", "mediaRequest"],
|
||||
supportsSearch: true,
|
||||
},
|
||||
piHole: {
|
||||
name: "Pi-hole",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
|
||||
category: ["dnsHole"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
adGuardHome: {
|
||||
name: "AdGuard Home",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
|
||||
category: ["dnsHole"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
homeAssistant: {
|
||||
name: "Home Assistant",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
|
||||
category: ["smartHomeServer"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
openmediavault: {
|
||||
name: "OpenMediaVault",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png",
|
||||
category: ["healthMonitoring"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
|
||||
@@ -162,6 +181,22 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
|
||||
U
|
||||
: 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 IntegrationKind = keyof typeof integrationDefs;
|
||||
export type IntegrationCategory =
|
||||
|
||||
2
packages/definitions/src/search-engine.ts
Normal file
2
packages/definitions/src/search-engine.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const searchEngineTypes = ["generic", "fromIntegration"] as const;
|
||||
export type SearchEngineType = (typeof searchEngineTypes)[number];
|
||||
@@ -2,11 +2,14 @@ import { useForm, zodResolver } from "@mantine/form";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
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";
|
||||
|
||||
export const useZodForm = <
|
||||
TSchema extends AnyZodObject | ZodEffects<AnyZodObject> | ZodIntersection<AnyZodObject, AnyZodObject>,
|
||||
TSchema extends
|
||||
| AnyZodObject
|
||||
| ZodEffects<AnyZodObject>
|
||||
| ZodIntersection<AnyZodObject | ZodDiscriminatedUnion<string, AnyZodObject[]>, AnyZodObject>,
|
||||
>(
|
||||
schema: TSchema,
|
||||
options: Omit<
|
||||
|
||||
3
packages/integrations/src/base/searchable-integration.ts
Normal file
3
packages/integrations/src/base/searchable-integration.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface ISearchableIntegration {
|
||||
searchAsync(query: string): Promise<{ image?: string; name: string; link: string }[]>;
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import type { ISearchableIntegration } from "../base/searchable-integration";
|
||||
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
|
||||
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
|
||||
headers: {
|
||||
@@ -180,6 +200,35 @@ interface MovieInformation {
|
||||
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({
|
||||
results: z
|
||||
.array(
|
||||
@@ -239,3 +288,32 @@ const getUsersSchema = z.object({
|
||||
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}'`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./interfaces/health-monitoring/healt-monitoring";
|
||||
export * from "./interfaces/indexer-manager/indexer";
|
||||
export * from "./interfaces/media-requests/media-request";
|
||||
export * from "./pi-hole/pi-hole-types";
|
||||
export * from "./base/searchable-integration";
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
|
||||
@@ -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 type { RouterOutputs } from "@homarr/api";
|
||||
@@ -12,29 +12,69 @@ import { interaction } from "../../lib/interaction";
|
||||
type SearchEngine = RouterOutputs["searchEngine"]["search"][number];
|
||||
|
||||
export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>({
|
||||
useActions: () => [
|
||||
{
|
||||
key: "search",
|
||||
Component: ({ name }) => {
|
||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||
useActions: (searchEngine, query) => {
|
||||
const { data } = clientApi.integration.searchInIntegration.useQuery(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
{ integrationId: searchEngine.integrationId!, query },
|
||||
{
|
||||
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 (
|
||||
<Group mx="md" my="sm">
|
||||
<IconSearch stroke={1.5} />
|
||||
<Text>{tChildren("action.search.label", { name })}</Text>
|
||||
<Group mx="md" my="sm" wrap="nowrap">
|
||||
{searchResult.image ? (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
||||
href: urlTemplate.replace("%s", query),
|
||||
useInteraction: interaction.link(() => ({
|
||||
href: searchResult.link,
|
||||
newTab: true,
|
||||
})),
|
||||
},
|
||||
],
|
||||
}));
|
||||
},
|
||||
DetailComponent({ options }) {
|
||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||
return (
|
||||
<Stack mx="md" my="sm">
|
||||
<Text>{tChildren("detail.title")}</Text>
|
||||
<Text>{options.type === "generic" ? tChildren("detail.title") : tChildren("searchResults.title")}</Text>
|
||||
<Group>
|
||||
<img height={24} width={24} src={options.iconUrl} alt={options.name} />
|
||||
<Text>{options.name}</Text>
|
||||
@@ -72,10 +112,24 @@ export const searchEnginesSearchGroups = createGroup<SearchEngine>({
|
||||
|
||||
setChildrenOptions(searchEnginesChildrenOptions(engine));
|
||||
},
|
||||
useInteraction: interaction.link(({ urlTemplate }, query) => ({
|
||||
href: urlTemplate.replace("%s", query),
|
||||
newTab: true,
|
||||
})),
|
||||
useInteraction: (searchEngine, query) => {
|
||||
if (searchEngine.type === "generic" && searchEngine.urlTemplate) {
|
||||
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) {
|
||||
return clientApi.searchEngine.search.useQuery({
|
||||
query: query.trim(),
|
||||
|
||||
@@ -2427,6 +2427,9 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Select an action for the search engine"
|
||||
},
|
||||
"searchResults": {
|
||||
"title": "Select a search result for actions"
|
||||
}
|
||||
},
|
||||
"option": {
|
||||
@@ -2596,7 +2599,8 @@
|
||||
"noResults": {
|
||||
"title": "There aren't any search engines",
|
||||
"action": "Create your first search engine"
|
||||
}
|
||||
},
|
||||
"interactive": "Interactive, uses an integration"
|
||||
},
|
||||
"create": {
|
||||
"title": "New search engine",
|
||||
@@ -2622,6 +2626,11 @@
|
||||
"title": "Unable to apply changes",
|
||||
"message": "The search engine could not be saved"
|
||||
}
|
||||
},
|
||||
"configControl": "Configuration",
|
||||
"searchEngineType": {
|
||||
"generic": "Generic",
|
||||
"fromIntegration": "From integration"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import type { ZodTypeAny } 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({
|
||||
name: z.string().min(1).max(64),
|
||||
short: z.string().min(1).max(8),
|
||||
iconUrl: z.string().min(1),
|
||||
urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
|
||||
description: z.string().max(512).nullable(),
|
||||
});
|
||||
|
||||
const editSearchEngineSchema = manageSearchEngineSchema
|
||||
.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
.omit({ short: true });
|
||||
const createManageSearchEngineSchema = <T extends ZodTypeAny>(
|
||||
callback: (schema: typeof manageSearchEngineSchema) => T,
|
||||
) =>
|
||||
z
|
||||
.discriminatedUnion("type", [genericSearchEngine, fromIntegrationSearchEngine])
|
||||
.and(callback(manageSearchEngineSchema));
|
||||
|
||||
const editSearchEngineSchema = createManageSearchEngineSchema((schema) =>
|
||||
schema
|
||||
.extend({
|
||||
id: z.string(),
|
||||
})
|
||||
.omit({ short: true }),
|
||||
);
|
||||
|
||||
export const searchEngineSchemas = {
|
||||
manage: manageSearchEngineSchema,
|
||||
manage: createManageSearchEngineSchema((schema) => schema),
|
||||
edit: editSearchEngineSchema,
|
||||
};
|
||||
|
||||
@@ -30,13 +30,16 @@ interface WidgetIntegrationSelectProps {
|
||||
error?: string;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
|
||||
canSelectMultiple?: boolean;
|
||||
data: IntegrationSelectOption[];
|
||||
withAsterisk?: boolean;
|
||||
}
|
||||
export const WidgetIntegrationSelect = ({
|
||||
data,
|
||||
onChange,
|
||||
value: valueProp,
|
||||
canSelectMultiple = true,
|
||||
withAsterisk = false,
|
||||
...props
|
||||
}: WidgetIntegrationSelectProps) => {
|
||||
const t = useI18n();
|
||||
@@ -47,12 +50,16 @@ export const WidgetIntegrationSelect = ({
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const handleValueSelect = (selectedValue: string) =>
|
||||
const handleValueSelect = (selectedValue: string) => {
|
||||
onChange(
|
||||
multiSelectValues.includes(selectedValue)
|
||||
? multiSelectValues.filter((value) => value !== selectedValue)
|
||||
: [...multiSelectValues, selectedValue],
|
||||
);
|
||||
if (!canSelectMultiple) {
|
||||
combobox.closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueRemove = (valueToRemove: string) =>
|
||||
onChange(multiSelectValues.filter((value) => value !== valueToRemove));
|
||||
@@ -63,7 +70,14 @@ export const WidgetIntegrationSelect = ({
|
||||
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) => {
|
||||
@@ -103,6 +117,7 @@ export const WidgetIntegrationSelect = ({
|
||||
}
|
||||
pointer
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
withAsterisk={withAsterisk}
|
||||
{...props}
|
||||
>
|
||||
<Pill.Group>
|
||||
@@ -150,14 +165,17 @@ export interface IntegrationSelectOption {
|
||||
interface IntegrationPillProps {
|
||||
option: IntegrationSelectOption;
|
||||
onRemove: () => void;
|
||||
showRemoveButton: boolean;
|
||||
}
|
||||
|
||||
const IntegrationPill = ({ option, onRemove }: IntegrationPillProps) => (
|
||||
<Group align="center" wrap="nowrap" gap={0} className={classes.pill}>
|
||||
const IntegrationPill = ({ option, onRemove, showRemoveButton }: IntegrationPillProps) => (
|
||||
<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} />
|
||||
<Text span size="xs" lh={1} fw={500}>
|
||||
{option.name}
|
||||
</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>
|
||||
);
|
||||
|
||||
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@@ -24,7 +24,7 @@ importers:
|
||||
version: 4.3.3(vite@5.4.5(@types/node@22.9.0)(sass@1.80.6)(sugarss@4.0.1(postcss@8.4.47))(terser@5.32.0))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^2.1.4
|
||||
version: 2.1.4(vitest@2.1.4)
|
||||
version: 2.1.4(vitest@2.1.4(@types/node@22.8.6)(@vitest/ui@2.1.4)(jsdom@25.0.1)(sass@1.80.6)(sugarss@4.0.1(postcss@8.4.47))(terser@5.32.0))
|
||||
'@vitest/ui':
|
||||
specifier: ^2.1.4
|
||||
version: 2.1.4(vitest@2.1.4)
|
||||
@@ -1344,6 +1344,9 @@ importers:
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../definitions
|
||||
'@homarr/integrations':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../integrations
|
||||
'@homarr/modals':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../modals
|
||||
@@ -10472,7 +10475,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@2.1.4(vitest@2.1.4)':
|
||||
'@vitest/coverage-v8@2.1.4(vitest@2.1.4(@types/node@22.8.6)(@vitest/ui@2.1.4)(jsdom@25.0.1)(sass@1.80.6)(sugarss@4.0.1(postcss@8.4.47))(terser@5.32.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 0.2.3
|
||||
|
||||
Reference in New Issue
Block a user