feat(integration): add search engine creation (#1816)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
packages/db/migrations/mysql/0018_mighty_shaman.sql
Normal file
1
packages/db/migrations/mysql/0018_mighty_shaman.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `search_engine` ADD CONSTRAINT `search_engine_short_unique` UNIQUE(`short`);
|
||||||
1668
packages/db/migrations/mysql/meta/0018_snapshot.json
Normal file
1668
packages/db/migrations/mysql/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/db/migrations/sqlite/0018_cheerful_tattoo.sql
Normal file
1
packages/db/migrations/sqlite/0018_cheerful_tattoo.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX `search_engine_short_unique` ON `search_engine` (`short`);
|
||||||
1593
packages/db/migrations/sqlite/meta/0018_snapshot.json
Normal file
1593
packages/db/migrations/sqlite/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
9
packages/icons/src/auto-icon-searcher.ts
Normal file
9
packages/icons/src/auto-icon-searcher.ts
Normal 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}%`),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user