feat(integration): add search engine creation (#1816)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-12-31 11:40:37 +01:00
committed by GitHub
parent aeb681a858
commit f5076454cd
16 changed files with 3398 additions and 5 deletions

View File

@@ -39,5 +39,5 @@
"i18n-ally.localesPaths": [ "i18n-ally.localesPaths": [
"packages/translation/src/lang", "packages/translation/src/lang",
], ],
"i18n-ally.keystyle": "auto", "i18n-ally.keystyle": "nested",
} }

View File

@@ -3,13 +3,13 @@
import { useCallback } from "react"; import { useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Button, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core"; import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions"; import { getAllSecretKindOptions, getIntegrationName, integrationDefs } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form"; import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client"; import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
@@ -38,6 +38,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
kind, kind,
value: "", value: "",
})), })),
attemptSearchEngineCreation: true,
}, },
}); });
const { mutateAsync, isPending } = clientApi.integration.create.useMutation(); const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
@@ -78,6 +79,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
); );
}; };
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
<Stack> <Stack>
@@ -104,6 +107,16 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
</Stack> </Stack>
</Fieldset> </Fieldset>
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(searchParams.kind),
})}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/>
)}
<Group justify="end" align="center"> <Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations"> <Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")} {t("common.action.backToOverview")}

View File

@@ -11,9 +11,11 @@ import {
integrations, integrations,
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
searchEngines,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationSecretKind } from "@homarr/definitions";
import { import {
getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationDefs, integrationDefs,
@@ -192,6 +194,18 @@ export const integrationRouter = createTRPCRouter({
})), })),
); );
} }
if (input.attemptSearchEngineCreation) {
const icon = getIconUrl(input.kind);
await ctx.db.insert(searchEngines).values({
id: createId(),
name: input.name,
integrationId,
type: "fromIntegration",
iconUrl: icon,
short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
});
}
}), }),
update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => { update: protectedProcedure.input(validation.integration.update).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -411,6 +425,36 @@ interface AddSecretInput {
value: string; value: string;
kind: IntegrationSecretKind; kind: IntegrationSecretKind;
} }
const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
const searchEngines = await db.query.searchEngines.findMany({
columns: {
short: true,
},
});
const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
if (!usedShortNames.includes(nameByIntegrationName)) {
return nameByIntegrationName;
}
// 8 is max length constraint
for (let i = 2; i < 9999999; i++) {
const generatedName = `${nameByIntegrationName}${i}`;
if (usedShortNames.includes(generatedName)) {
continue;
}
return generatedName;
}
throw new Error(
"Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
);
};
const addSecretAsync = async (db: Database, input: AddSecretInput) => { const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({ await db.insert(integrationSecrets).values({
kind: input.kind, kind: input.kind,

View File

@@ -179,6 +179,7 @@ describe("create should create a new integration", () => {
kind: "jellyfin" as const, kind: "jellyfin" as const,
url: "http://jellyfin.local", url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }], secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
}; };
const fakeNow = new Date("2023-07-01T00:00:00Z"); const fakeNow = new Date("2023-07-01T00:00:00Z");
@@ -201,6 +202,48 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow); expect(dbSecret!.updatedAt).toEqual(fakeNow);
}); });
test("with create integration access should create a new integration when creating search engine", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
session: defaultSessionWithPermissions(["integration-create"]),
});
const input = {
name: "Jellyseerr",
kind: "jellyseerr" as const,
url: "http://jellyseerr.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: true,
};
const fakeNow = new Date("2023-07-01T00:00:00Z");
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
await caller.create(input);
vi.useRealTimers();
const dbIntegration = await db.query.integrations.findFirst();
const dbSecret = await db.query.integrationSecrets.findFirst();
const dbSearchEngine = await db.query.searchEngines.findFirst();
expect(dbIntegration).toBeDefined();
expect(dbIntegration!.name).toBe(input.name);
expect(dbIntegration!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
expect(dbSecret).toBeDefined();
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
);
});
test("without create integration access should throw permission error", async () => { test("without create integration access should throw permission error", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();
@@ -213,6 +256,7 @@ describe("create should create a new integration", () => {
kind: "jellyfin" as const, kind: "jellyfin" as const,
url: "http://jellyfin.local", url: "http://jellyfin.local",
secrets: [{ kind: "apiKey" as const, value: "1234567890" }], secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
attemptSearchEngineCreation: false,
}; };
// Act // Act

View File

@@ -0,0 +1 @@
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_short_unique` UNIQUE(`short`);

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,13 @@
"when": 1733777544067, "when": 1733777544067,
"tag": "0017_tired_penance", "tag": "0017_tired_penance",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "5",
"when": 1735593853768,
"tag": "0018_mighty_shaman",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1 @@
CREATE UNIQUE INDEX `search_engine_short_unique` ON `search_engine` (`short`);

File diff suppressed because it is too large Load Diff

View File

@@ -127,6 +127,13 @@
"when": 1733777395703, "when": 1733777395703,
"tag": "0017_small_rumiko_fujikawa", "tag": "0017_small_rumiko_fujikawa",
"breakpoints": true "breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1735593831501,
"tag": "0018_cheerful_tattoo",
"breakpoints": true
} }
] ]
} }

View File

@@ -389,7 +389,7 @@ export const searchEngines = mysqlTable("search_engine", {
id: varchar({ length: 64 }).notNull().primaryKey(), id: varchar({ length: 64 }).notNull().primaryKey(),
iconUrl: text().notNull(), iconUrl: text().notNull(),
name: varchar({ length: 64 }).notNull(), name: varchar({ length: 64 }).notNull(),
short: varchar({ length: 8 }).notNull(), short: varchar({ length: 8 }).unique().notNull(),
description: text(), description: text(),
urlTemplate: text(), urlTemplate: text(),
type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"), type: varchar({ length: 64 }).$type<SearchEngineType>().notNull().default("generic"),

View File

@@ -375,7 +375,7 @@ export const searchEngines = sqliteTable("search_engine", {
id: text().notNull().primaryKey(), id: text().notNull().primaryKey(),
iconUrl: text().notNull(), iconUrl: text().notNull(),
name: text().notNull(), name: text().notNull(),
short: text().notNull(), short: text().unique().notNull(),
description: text(), description: text(),
urlTemplate: text(), urlTemplate: text(),
type: text().$type<SearchEngineType>().notNull().default("generic"), type: text().$type<SearchEngineType>().notNull().default("generic"),

View File

@@ -1,2 +1,3 @@
export * from "./src/icons-fetcher"; export * from "./src/icons-fetcher";
export * from "./src/types"; export * from "./src/types";
export * from "./src/auto-icon-searcher";

View File

@@ -0,0 +1,9 @@
import type { Database } from "@homarr/db";
import { like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
export const getIconForNameAsync = async (db: Database, name: string) => {
return await db.query.icons.findFirst({
where: like(icons.name, `%${name}%`),
});
};

View File

@@ -612,6 +612,10 @@
}, },
"url": { "url": {
"label": "Url" "label": "Url"
},
"attemptSearchEngineCreation": {
"label": "Create Search Engine",
"description": "Integration \"{kind}\" can be used with the search engines. Check this to automatically configure the search engine."
} }
}, },
"action": { "action": {

View File

@@ -15,6 +15,7 @@ const integrationCreateSchema = z.object({
value: z.string().nonempty(), value: z.string().nonempty(),
}), }),
), ),
attemptSearchEngineCreation: z.boolean(),
}); });
const integrationUpdateSchema = z.object({ const integrationUpdateSchema = z.object({