From 5404cebf5b04c5a93249c5674607640f70b798d3 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 7 Sep 2024 18:13:24 +0200 Subject: [PATCH] feat: add import for config files from oldmarr (#1019) * wip: add oldmarr config import * wip: add support for wrong amount of categories / sections with autofix, color mapping, position adjustments of wrappers * fix: lockfile broken * feat: add support for form data trpc requests * wip: improve file upload * refactor: restructure import, add import configuration * wip: add configurations for import to modal * refactor: move oldmarr import to old-import package * fix: column count not respects screen size for board * feat: add beta badge for oldmarr config import * chore: address pull request feedback * fix: format issues * fix: inconsistent versions * fix: deepsource issues * fix: revert {} to Record convertion to prevent typecheck issue * fix: inconsistent zod version * fix: format issue * chore: address pull request feedback * fix: wrong import * fix: broken lock file * fix: inconsistent versions * fix: format issues --- apps/nextjs/package.json | 1 + .../app/[locale]/_client-providers/trpc.tsx | 51 ++- .../_components/create-board-button.tsx | 51 ++- .../manage/boards/import-board-modal.tsx | 189 +++++++++++ packages/api/package.json | 2 + packages/api/src/router/board.ts | 9 + packages/old-import/eslint.config.js | 9 + packages/old-import/index.ts | 1 + packages/old-import/package.json | 40 +++ packages/old-import/src/fix-section-issues.ts | 49 +++ packages/old-import/src/import-apps.ts | 59 ++++ packages/old-import/src/import-board.ts | 35 ++ packages/old-import/src/import-error.ts | 16 + packages/old-import/src/import-items.ts | 98 ++++++ packages/old-import/src/import-sections.ts | 51 +++ packages/old-import/src/index.ts | 47 +++ packages/old-import/src/mappers/map-colors.ts | 48 +++ .../src/mappers/map-column-count.ts | 15 + .../src/move-widgets-and-apps-merge.ts | 300 ++++++++++++++++++ .../src/widgets/definitions/bookmark.ts | 18 ++ .../src/widgets/definitions/calendar.ts | 11 + .../src/widgets/definitions/common.ts | 9 + .../src/widgets/definitions/dashdot.ts | 53 ++++ .../src/widgets/definitions/date.ts | 21 ++ .../src/widgets/definitions/dlspeed.ts | 4 + .../widgets/definitions/dns-hole-controls.ts | 8 + .../widgets/definitions/dns-hole-summary.ts | 6 + .../widgets/definitions/health-monitoring.ts | 21 ++ .../src/widgets/definitions/iframe.ts | 16 + .../src/widgets/definitions/index.ts | 75 +++++ .../widgets/definitions/indexer-manager.ts | 8 + .../definitions/media-requests-list.ts | 9 + .../definitions/media-requests-stats.ts | 9 + .../src/widgets/definitions/media-server.ts | 4 + .../widgets/definitions/media-transcoding.ts | 12 + .../src/widgets/definitions/notebook.ts | 10 + .../old-import/src/widgets/definitions/rss.ts | 14 + .../definitions/smart-home-entity-state.ts | 13 + .../smart-home-trigger-automation.ts | 9 + .../src/widgets/definitions/torrent-status.ts | 18 ++ .../src/widgets/definitions/usenet.ts | 4 + .../src/widgets/definitions/video-stream.ts | 11 + .../src/widgets/definitions/weather.ts | 16 + packages/old-import/src/widgets/options.ts | 121 +++++++ packages/old-import/tsconfig.json | 8 + packages/old-schema/eslint.config.js | 9 + packages/old-schema/index.ts | 1 + packages/old-schema/package.json | 34 ++ packages/old-schema/src/app.ts | 77 +++++ packages/old-schema/src/config.ts | 30 ++ packages/old-schema/src/index.ts | 5 + packages/old-schema/src/setting.ts | 75 +++++ packages/old-schema/src/tile.ts | 55 ++++ packages/old-schema/src/widget.ts | 40 +++ packages/old-schema/tsconfig.json | 8 + packages/translation/src/lang/en.ts | 60 ++++ packages/ui/src/components/beta-badge.tsx | 17 + packages/ui/src/components/index.tsx | 1 + .../components/select-with-custom-items.tsx | 2 + packages/validation/package.json | 4 +- packages/validation/src/board.ts | 58 ++++ packages/validation/src/form/i18n.ts | 21 +- packages/validation/src/index.ts | 2 + pnpm-lock.yaml | 86 +++++ turbo/generators/templates/package.json.hbs | 2 +- 65 files changed, 2132 insertions(+), 34 deletions(-) create mode 100644 apps/nextjs/src/components/manage/boards/import-board-modal.tsx create mode 100644 packages/old-import/eslint.config.js create mode 100644 packages/old-import/index.ts create mode 100644 packages/old-import/package.json create mode 100644 packages/old-import/src/fix-section-issues.ts create mode 100644 packages/old-import/src/import-apps.ts create mode 100644 packages/old-import/src/import-board.ts create mode 100644 packages/old-import/src/import-error.ts create mode 100644 packages/old-import/src/import-items.ts create mode 100644 packages/old-import/src/import-sections.ts create mode 100644 packages/old-import/src/index.ts create mode 100644 packages/old-import/src/mappers/map-colors.ts create mode 100644 packages/old-import/src/mappers/map-column-count.ts create mode 100644 packages/old-import/src/move-widgets-and-apps-merge.ts create mode 100644 packages/old-import/src/widgets/definitions/bookmark.ts create mode 100644 packages/old-import/src/widgets/definitions/calendar.ts create mode 100644 packages/old-import/src/widgets/definitions/common.ts create mode 100644 packages/old-import/src/widgets/definitions/dashdot.ts create mode 100644 packages/old-import/src/widgets/definitions/date.ts create mode 100644 packages/old-import/src/widgets/definitions/dlspeed.ts create mode 100644 packages/old-import/src/widgets/definitions/dns-hole-controls.ts create mode 100644 packages/old-import/src/widgets/definitions/dns-hole-summary.ts create mode 100644 packages/old-import/src/widgets/definitions/health-monitoring.ts create mode 100644 packages/old-import/src/widgets/definitions/iframe.ts create mode 100644 packages/old-import/src/widgets/definitions/index.ts create mode 100644 packages/old-import/src/widgets/definitions/indexer-manager.ts create mode 100644 packages/old-import/src/widgets/definitions/media-requests-list.ts create mode 100644 packages/old-import/src/widgets/definitions/media-requests-stats.ts create mode 100644 packages/old-import/src/widgets/definitions/media-server.ts create mode 100644 packages/old-import/src/widgets/definitions/media-transcoding.ts create mode 100644 packages/old-import/src/widgets/definitions/notebook.ts create mode 100644 packages/old-import/src/widgets/definitions/rss.ts create mode 100644 packages/old-import/src/widgets/definitions/smart-home-entity-state.ts create mode 100644 packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts create mode 100644 packages/old-import/src/widgets/definitions/torrent-status.ts create mode 100644 packages/old-import/src/widgets/definitions/usenet.ts create mode 100644 packages/old-import/src/widgets/definitions/video-stream.ts create mode 100644 packages/old-import/src/widgets/definitions/weather.ts create mode 100644 packages/old-import/src/widgets/options.ts create mode 100644 packages/old-import/tsconfig.json create mode 100644 packages/old-schema/eslint.config.js create mode 100644 packages/old-schema/index.ts create mode 100644 packages/old-schema/package.json create mode 100644 packages/old-schema/src/app.ts create mode 100644 packages/old-schema/src/config.ts create mode 100644 packages/old-schema/src/index.ts create mode 100644 packages/old-schema/src/setting.ts create mode 100644 packages/old-schema/src/tile.ts create mode 100644 packages/old-schema/src/widget.ts create mode 100644 packages/old-schema/tsconfig.json create mode 100644 packages/ui/src/components/beta-badge.tsx diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 786ccf634..18bb3636a 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -28,6 +28,7 @@ "@homarr/log": "workspace:^", "@homarr/modals": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", + "@homarr/old-schema": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index ff3078ae6..8583ac24f 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -5,7 +5,15 @@ import { useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; -import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client"; +import { + createWSClient, + httpLink, + isNonJsonSerializable, + loggerLink, + splitLink, + unstable_httpBatchStreamLink, + wsLink, +} from "@trpc/client"; import superjson from "superjson"; import type { AppRouter } from "@homarr/api"; @@ -34,18 +42,29 @@ export function TRPCReactProvider(props: PropsWithChildren) { enabled: (opts) => process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error), }), - (args) => { - return ({ op, next }) => { - console.log("op", op.type, op.input, op.path, op.id); - if (op.type === "subscription") { - const link = wsLink({ - client: wsClient, - transformer: superjson, - }); - return link(args)({ op, next }); - } - - return unstable_httpBatchStreamLink({ + splitLink({ + condition: ({ type }) => type === "subscription", + true: wsLink({ + client: wsClient, + transformer: superjson, + }), + false: splitLink({ + condition: ({ input }) => isNonJsonSerializable(input), + true: httpLink({ + /** + * We don't want to transform the data here as we want to use form data + */ + transformer: { + serialize(object: unknown) { + return object; + }, + deserialize(data: unknown) { + return data; + }, + }, + url: `${getBaseUrl()}/api/trpc`, + }), + false: unstable_httpBatchStreamLink({ transformer: superjson, url: `${getBaseUrl()}/api/trpc`, headers() { @@ -53,9 +72,9 @@ export function TRPCReactProvider(props: PropsWithChildren) { headers.set("x-trpc-source", "nextjs-react"); return headers; }, - })(args)({ op, next }); - }; - }, + }), + }), + }), ], }); }); diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index ed30fb23d..864c5ea83 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -1,15 +1,17 @@ "use client"; import { useCallback } from "react"; -import { IconCategoryPlus } from "@tabler/icons-react"; +import { Affix, Button, Group, Menu } from "@mantine/core"; +import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { BetaBadge } from "@homarr/ui"; import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; import { AddBoardModal } from "~/components/manage/boards/add-board-modal"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { ImportBoardModal } from "~/components/manage/boards/import-board-modal"; interface CreateBoardButtonProps { boardNames: string[]; @@ -17,7 +19,8 @@ interface CreateBoardButtonProps { export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => { const t = useI18n(); - const { openModal } = useModalAction(AddBoardModal); + const { openModal: openAddModal } = useModalAction(AddBoardModal); + const { openModal: openImportModal } = useModalAction(ImportBoardModal); const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({ onSettled: async () => { @@ -25,8 +28,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => { }, }); - const onClick = useCallback(() => { - openModal({ + const onCreateClick = useCallback(() => { + openAddModal({ onSuccess: async (values) => { await mutateAsync({ name: values.name, @@ -36,11 +39,41 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => { }, boardNames, }); - }, [mutateAsync, boardNames, openModal]); + }, [mutateAsync, boardNames, openAddModal]); + + const onImportClick = useCallback(() => { + openImportModal({ boardNames }); + }, [openImportModal, boardNames]); + + const buttonGroupContent = ( + <> + + + + + + + }> + + {t("board.action.oldImport.label")} + + + + + + + ); return ( - } onClick={onClick} loading={isPending}> - {t("management.page.board.action.new.label")} - + <> + {buttonGroupContent} + + {buttonGroupContent} + + ); }; diff --git a/apps/nextjs/src/components/manage/boards/import-board-modal.tsx b/apps/nextjs/src/components/manage/boards/import-board-modal.tsx new file mode 100644 index 000000000..bf201477f --- /dev/null +++ b/apps/nextjs/src/components/manage/boards/import-board-modal.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import { Button, Fieldset, FileInput, Grid, Group, Radio, Stack, Switch, TextInput } from "@mantine/core"; +import { IconFileUpload } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useZodForm } from "@homarr/form"; +import { createModal } from "@homarr/modals"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { oldmarrConfigSchema } from "@homarr/old-schema"; +import { useScopedI18n } from "@homarr/translation/client"; +import { SelectWithDescription } from "@homarr/ui"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; +import { createOldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation"; + +import { revalidatePathActionAsync } from "~/app/revalidatePathAction"; + +interface InnerProps { + boardNames: string[]; +} + +export const ImportBoardModal = createModal(({ actions, innerProps }) => { + const tOldImport = useScopedI18n("board.action.oldImport"); + const tCommon = useScopedI18n("common"); + const [fileValid, setFileValid] = useState(true); + const form = useZodForm( + z.object({ + file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile), + configuration: createOldmarrImportConfigurationSchema(innerProps.boardNames), + }), + { + initialValues: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: null!, + configuration: { + distinctAppsByHref: true, + onlyImportApps: false, + screenSize: "lg", + sidebarBehaviour: "last-section", + name: "", + }, + }, + onValuesChange(values, previous) { + // This is a workarround until async validation is supported by mantine + void (async () => { + if (values.file === previous.file) { + return; + } + + if (!values.file) { + return; + } + + const content = await values.file.text(); + const result = oldmarrConfigSchema.safeParse(JSON.parse(content)); + + if (!result.success) { + console.error(result.error.errors); + setFileValid(false); + return; + } + + setFileValid(true); + form.setFieldValue("configuration.name", result.data.configProperties.name); + })(); + }, + }, + ); + + const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation(); + + const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => { + const formData = new FormData(); + formData.set("file", values.file); + formData.set("configuration", JSON.stringify(values.configuration)); + + await mutateAsync(formData, { + async onSuccess() { + actions.closeModal(); + await revalidatePathActionAsync("/manage/boards"); + showSuccessNotification({ + title: tOldImport("notification.success.title"), + message: tOldImport("notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: tOldImport("notification.error.title"), + message: tOldImport("notification.error.message"), + }); + }, + }); + }; + + return ( +
{ + if (!fileValid) { + return; + } + + void handleSubmitAsync({ + // It's checked for null in the superrefine + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: values.file!, + configuration: values.configuration, + }); + })} + > + + } + withAsterisk + accept="application/json" + {...form.getInputProps("file")} + error={ + (form.getInputProps("file").error as string | undefined) ?? + (!fileValid && form.isDirty("file") ? tOldImport("form.file.invalidError") : undefined) + } + type="button" + label={tOldImport("form.file.label")} + /> + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+
+ ); +}).withOptions({ + defaultTitle: (t) => t("board.action.oldImport.label"), + size: "lg", +}); diff --git a/packages/api/package.json b/packages/api/package.json index 963f07615..9afb612a4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -29,6 +29,8 @@ "@homarr/definitions": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", "@homarr/log": "workspace:^", + "@homarr/old-import": "workspace:^0.1.0", + "@homarr/old-schema": "workspace:^0.1.0", "@homarr/ping": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index aa6464156..0a937f374 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -16,6 +16,8 @@ import { } from "@homarr/db/schema/sqlite"; import type { WidgetKind } from "@homarr/definitions"; import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions"; +import { importAsync } from "@homarr/old-import"; +import { oldmarrConfigSchema } from "@homarr/old-schema"; import type { BoardItemAdvancedOptions } from "@homarr/validation"; import { createSectionSchema, sharedItemSchema, validation, z } from "@homarr/validation"; @@ -451,6 +453,13 @@ export const boardRouter = createTRPCRouter({ ); }); }), + importOldmarrConfig: protectedProcedure + .input(validation.board.importOldmarrConfig) + .mutation(async ({ input, ctx }) => { + const content = await input.file.text(); + const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content)); + await importAsync(ctx.db, oldmarr, input.configuration); + }), }); const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: string[] = []) => { diff --git a/packages/old-import/eslint.config.js b/packages/old-import/eslint.config.js new file mode 100644 index 000000000..5b19b6f8a --- /dev/null +++ b/packages/old-import/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/old-import/index.ts b/packages/old-import/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/old-import/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/old-import/package.json b/packages/old-import/package.json new file mode 100644 index 000000000..ff87c64c2 --- /dev/null +++ b/packages/old-import/package.json @@ -0,0 +1,40 @@ +{ + "name": "@homarr/old-import", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@homarr/common": "workspace:^0.1.0", + "@homarr/db": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", + "@homarr/log": "workspace:^0.1.0", + "@homarr/old-schema": "workspace:^0.1.0", + "@homarr/validation": "workspace:^0.1.0", + "superjson": "2.2.1" + }, + "devDependencies": { + "@homarr/eslint-config": "workspace:^0.2.0", + "@homarr/prettier-config": "workspace:^0.1.0", + "@homarr/tsconfig": "workspace:^0.1.0", + "eslint": "^9.10.0", + "typescript": "^5.5.4" + }, + "prettier": "@homarr/prettier-config" +} diff --git a/packages/old-import/src/fix-section-issues.ts b/packages/old-import/src/fix-section-issues.ts new file mode 100644 index 000000000..8ac481031 --- /dev/null +++ b/packages/old-import/src/fix-section-issues.ts @@ -0,0 +1,49 @@ +import { createId } from "@homarr/db"; +import { logger } from "@homarr/log"; +import type { OldmarrConfig } from "@homarr/old-schema"; + +export const fixSectionIssues = (old: OldmarrConfig) => { + const wrappers = old.wrappers.sort((wrapperA, wrapperB) => wrapperA.position - wrapperB.position); + const categories = old.categories.sort((categoryA, categoryB) => categoryA.position - categoryB.position); + + const neededSectionCount = categories.length * 2 + 1; + const hasToMuchEmptyWrappers = wrappers.length > categories.length + 1; + + logger.debug( + `Fixing section issues neededSectionCount=${neededSectionCount} hasToMuchEmptyWrappers=${hasToMuchEmptyWrappers}`, + ); + + for (let position = 0; position < neededSectionCount; position++) { + const index = Math.floor(position / 2); + const isEmpty = position % 2 === 0; + const section = isEmpty ? wrappers[index] : categories[index]; + if (!section) { + // If there are not enough empty sections for categories we need to insert them + if (isEmpty) { + // Insert empty wrapper for between categories + wrappers.push({ + id: createId(), + position, + }); + } + continue; + } + + section.position = position; + } + + // Find all wrappers that should be merged into one + const wrapperIdsToMerge = wrappers.slice(categories.length).map((section) => section.id); + // Remove all wrappers after the first at the end + wrappers.splice(categories.length + 1); + + if (wrapperIdsToMerge.length >= 2) { + logger.debug(`Found wrappers to merge count=${wrapperIdsToMerge.length}`); + } + + return { + wrappers, + categories, + wrapperIdsToMerge, + }; +}; diff --git a/packages/old-import/src/import-apps.ts b/packages/old-import/src/import-apps.ts new file mode 100644 index 000000000..4ef2b2771 --- /dev/null +++ b/packages/old-import/src/import-apps.ts @@ -0,0 +1,59 @@ +import { createId, inArray } from "@homarr/db"; +import type { Database, InferInsertModel } from "@homarr/db"; +import { apps as appsTable } from "@homarr/db/schema/sqlite"; +import { logger } from "@homarr/log"; +import type { OldmarrApp } from "@homarr/old-schema"; + +export const insertAppsAsync = async ( + db: Database, + apps: OldmarrApp[], + distinctAppsByHref: boolean, + configName: string, +) => { + logger.info( + `Importing old homarr apps configuration=${configName} distinctAppsByHref=${distinctAppsByHref} apps=${apps.length}`, + ); + const existingAppsWithHref = distinctAppsByHref + ? await db.query.apps.findMany({ + where: inArray(appsTable.href, [...new Set(apps.map((app) => app.url))]), + }) + : []; + + logger.debug(`Found existing apps with href count=${existingAppsWithHref.length}`); + + const mappedApps = apps.map((app) => ({ + // Use id of existing app when it has the same href and distinctAppsByHref is true + newId: distinctAppsByHref + ? (existingAppsWithHref.find( + (existingApp) => + existingApp.href === (app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl) && + existingApp.name === app.name && + existingApp.iconUrl === app.appearance.iconUrl, + )?.id ?? createId()) + : createId(), + ...app, + })); + + const appsToCreate = mappedApps + .filter((app) => !existingAppsWithHref.some((existingApp) => existingApp.id === app.newId)) + .map( + (app) => + ({ + id: app.newId, + name: app.name, + iconUrl: app.appearance.iconUrl, + href: app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl, + description: app.behaviour.tooltipDescription, + }) satisfies InferInsertModel, + ); + + logger.debug(`Creating apps count=${appsToCreate.length}`); + + if (appsToCreate.length > 0) { + await db.insert(appsTable).values(appsToCreate); + } + + logger.info(`Imported apps count=${appsToCreate.length}`); + + return mappedApps; +}; diff --git a/packages/old-import/src/import-board.ts b/packages/old-import/src/import-board.ts new file mode 100644 index 000000000..9ea155929 --- /dev/null +++ b/packages/old-import/src/import-board.ts @@ -0,0 +1,35 @@ +import type { Database } from "@homarr/db"; +import { createId } from "@homarr/db"; +import { boards } from "@homarr/db/schema/sqlite"; +import { logger } from "@homarr/log"; +import type { OldmarrConfig } from "@homarr/old-schema"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; + +import { mapColor } from "./mappers/map-colors"; +import { mapColumnCount } from "./mappers/map-column-count"; + +export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => { + logger.info(`Importing old homarr board configuration=${old.configProperties.name}`); + const boardId = createId(); + await db.insert(boards).values({ + id: boardId, + name: configuration.name, + backgroundImageAttachment: old.settings.customization.backgroundImageAttachment, + backgroundImageUrl: old.settings.customization.backgroundImageUrl, + backgroundImageRepeat: old.settings.customization.backgroundImageRepeat, + backgroundImageSize: old.settings.customization.backgroundImageSize, + columnCount: mapColumnCount(old, configuration.screenSize), + faviconImageUrl: old.settings.customization.faviconUrl, + isPublic: old.settings.access.allowGuests, + logoImageUrl: old.settings.customization.logoImageUrl, + pageTitle: old.settings.customization.pageTitle, + metaTitle: old.settings.customization.metaTitle, + opacity: old.settings.customization.appOpacity, + primaryColor: mapColor(old.settings.customization.colors.primary, "#fa5252"), + secondaryColor: mapColor(old.settings.customization.colors.secondary, "#fd7e14"), + }); + + logger.info(`Imported board id=${boardId}`); + + return boardId; +}; diff --git a/packages/old-import/src/import-error.ts b/packages/old-import/src/import-error.ts new file mode 100644 index 000000000..89479aa2f --- /dev/null +++ b/packages/old-import/src/import-error.ts @@ -0,0 +1,16 @@ +import type { OldmarrConfig } from "@homarr/old-schema"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; + +export class OldHomarrImportError extends Error { + constructor(oldConfig: OldmarrConfig, cause: unknown) { + super(`Failed to import old homarr configuration name=${oldConfig.configProperties.name}`, { + cause, + }); + } +} + +export class OldHomarrScreenSizeError extends Error { + constructor(type: "app" | "widget", id: string, screenSize: OldmarrImportConfiguration["screenSize"]) { + super(`Screen size not found for type=${type} id=${id} screenSize=${screenSize}`); + } +} diff --git a/packages/old-import/src/import-items.ts b/packages/old-import/src/import-items.ts new file mode 100644 index 000000000..69a98a2c5 --- /dev/null +++ b/packages/old-import/src/import-items.ts @@ -0,0 +1,98 @@ +import SuperJSON from "superjson"; + +import type { Database } from "@homarr/db"; +import { createId } from "@homarr/db"; +import { items } from "@homarr/db/schema/sqlite"; +import { logger } from "@homarr/log"; +import type { OldmarrApp, OldmarrWidget } from "@homarr/old-schema"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; + +import type { WidgetComponentProps } from "../../widgets/src/definition"; +import { OldHomarrScreenSizeError } from "./import-error"; +import { mapKind } from "./widgets/definitions"; +import { mapOptions } from "./widgets/options"; + +export const insertItemsAsync = async ( + db: Database, + widgets: OldmarrWidget[], + mappedApps: (OldmarrApp & { newId: string })[], + sectionIdMaps: Map, + configuration: OldmarrImportConfiguration, +) => { + logger.info(`Importing old homarr items widgets=${widgets.length} apps=${mappedApps.length}`); + + for (const widget of widgets) { + // All items should have been moved to the last wrapper + if (widget.area.type === "sidebar") { + continue; + } + + const kind = mapKind(widget.type); + + logger.debug(`Mapped widget kind id=${widget.id} previous=${widget.type} current=${kind}`); + + if (!kind) { + logger.error(`Widget has no kind id=${widget.id} type=${widget.type}`); + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sectionId = sectionIdMaps.get(widget.area.properties.id)!; + + logger.debug(`Inserting widget id=${widget.id} sectionId=${sectionId}`); + + const screenSizeShape = widget.shape[configuration.screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("widget", widget.id, configuration.screenSize); + } + + await db.insert(items).values({ + id: createId(), + sectionId, + height: screenSizeShape.size.height, + width: screenSizeShape.size.width, + xOffset: screenSizeShape.location.x, + yOffset: screenSizeShape.location.y, + kind, + options: SuperJSON.stringify(mapOptions(kind, widget.properties)), + }); + + logger.debug(`Inserted widget id=${widget.id} sectionId=${sectionId}`); + } + + for (const app of mappedApps) { + // All items should have been moved to the last wrapper + if (app.area.type === "sidebar") { + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sectionId = sectionIdMaps.get(app.area.properties.id)!; + + logger.debug(`Inserting app name=${app.name} sectionId=${sectionId}`); + + const screenSizeShape = app.shape[configuration.screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("app", app.id, configuration.screenSize); + } + + await db.insert(items).values({ + id: createId(), + sectionId, + height: screenSizeShape.size.height, + width: screenSizeShape.size.width, + xOffset: screenSizeShape.location.x, + yOffset: screenSizeShape.location.y, + kind: "app", + options: SuperJSON.stringify({ + appId: app.newId, + openInNewTab: app.behaviour.isOpeningNewTab, + pingEnabled: app.network.enabledStatusChecker, + showDescriptionTooltip: app.behaviour.tooltipDescription !== "", + showTitle: app.appearance.appNameStatus === "normal", + } satisfies WidgetComponentProps<"app">["options"]), + }); + + logger.debug(`Inserted app name=${app.name} sectionId=${sectionId}`); + } +}; diff --git a/packages/old-import/src/import-sections.ts b/packages/old-import/src/import-sections.ts new file mode 100644 index 000000000..1aa8afc87 --- /dev/null +++ b/packages/old-import/src/import-sections.ts @@ -0,0 +1,51 @@ +import { createId } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { sections } from "@homarr/db/schema/sqlite"; +import { logger } from "@homarr/log"; +import type { OldmarrConfig } from "@homarr/old-schema"; + +export const insertSectionsAsync = async ( + db: Database, + categories: OldmarrConfig["categories"], + wrappers: OldmarrConfig["wrappers"], + boardId: string, +) => { + logger.info( + `Importing old homarr sections boardId=${boardId} categories=${categories.length} wrappers=${wrappers.length}`, + ); + + const wrapperIds = wrappers.map((section) => section.id); + const categoryIds = categories.map((section) => section.id); + const idMaps = new Map([...wrapperIds, ...categoryIds].map((id) => [id, createId()])); + + const wrappersToInsert = wrappers.map((section) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: idMaps.get(section.id)!, + boardId, + xOffset: 0, + yOffset: section.position, + kind: "empty" as const, + })); + + const categoriesToInsert = categories.map((section) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: idMaps.get(section.id)!, + boardId, + xOffset: 0, + yOffset: section.position, + kind: "category" as const, + name: section.name, + })); + + if (wrappersToInsert.length > 0) { + await db.insert(sections).values(wrappersToInsert); + } + + if (categoriesToInsert.length > 0) { + await db.insert(sections).values(categoriesToInsert); + } + + logger.info(`Imported sections count=${wrappersToInsert.length + categoriesToInsert.length}`); + + return idMaps; +}; diff --git a/packages/old-import/src/index.ts b/packages/old-import/src/index.ts new file mode 100644 index 000000000..b1ed0caaa --- /dev/null +++ b/packages/old-import/src/index.ts @@ -0,0 +1,47 @@ +import type { Database } from "@homarr/db"; +import type { OldmarrConfig } from "@homarr/old-schema"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; + +import { fixSectionIssues } from "./fix-section-issues"; +import { insertAppsAsync } from "./import-apps"; +import { insertBoardAsync } from "./import-board"; +import { OldHomarrImportError, OldHomarrScreenSizeError } from "./import-error"; +import { insertItemsAsync } from "./import-items"; +import { insertSectionsAsync } from "./import-sections"; +import { moveWidgetsAndAppsIfMerge } from "./move-widgets-and-apps-merge"; + +export const importAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => { + if (configuration.onlyImportApps) { + await db + .transaction(async (trasaction) => { + await insertAppsAsync(trasaction, old.apps, configuration.distinctAppsByHref, old.configProperties.name); + }) + .catch((error) => { + throw new OldHomarrImportError(old, error); + }); + return; + } + + await db + .transaction(async (trasaction) => { + const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(old); + const { apps, widgets } = moveWidgetsAndAppsIfMerge(old, wrapperIdsToMerge, configuration); + + const boardId = await insertBoardAsync(trasaction, old, configuration); + const sectionIdMaps = await insertSectionsAsync(trasaction, categories, wrappers, boardId); + const mappedApps = await insertAppsAsync( + trasaction, + apps, + configuration.distinctAppsByHref, + old.configProperties.name, + ); + await insertItemsAsync(trasaction, widgets, mappedApps, sectionIdMaps, configuration); + }) + .catch((error) => { + if (error instanceof OldHomarrScreenSizeError) { + throw error; + } + + throw new OldHomarrImportError(old, error); + }); +}; diff --git a/packages/old-import/src/mappers/map-colors.ts b/packages/old-import/src/mappers/map-colors.ts new file mode 100644 index 000000000..73318514c --- /dev/null +++ b/packages/old-import/src/mappers/map-colors.ts @@ -0,0 +1,48 @@ +const oldColors = [ + "dark", + "gray", + "red", + "pink", + "grape", + "violet", + "indigo", + "blue", + "cyan", + "green", + "lime", + "yellow", + "orange", + "teal", +] as const; +type OldColor = (typeof oldColors)[number]; + +export const mapColor = (color: string | undefined, fallback: string) => { + if (!color) { + return fallback; + } + + if (!oldColors.some((mantineColor) => color === mantineColor)) { + return fallback; + } + + const mantineColor = color as OldColor; + + return mappedColors[mantineColor]; +}; + +const mappedColors: Record<(typeof oldColors)[number], string> = { + blue: "#228be6", + cyan: "#15aabf", + dark: "#2e2e2e", + grape: "#be4bdb", + gray: "#868e96", + green: "#40c057", + indigo: "#4c6ef5", + lime: "#82c91e", + orange: "#fd7e14", + pink: "#e64980", + red: "#fa5252", + teal: "#12b886", + violet: "#7950f2", + yellow: "#fab005", +}; diff --git a/packages/old-import/src/mappers/map-column-count.ts b/packages/old-import/src/mappers/map-column-count.ts new file mode 100644 index 000000000..0f38a98e5 --- /dev/null +++ b/packages/old-import/src/mappers/map-column-count.ts @@ -0,0 +1,15 @@ +import type { OldmarrConfig } from "@homarr/old-schema"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; + +export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => { + switch (screenSize) { + case "lg": + return old.settings.customization.gridstack.columnCountLarge; + case "md": + return old.settings.customization.gridstack.columnCountMedium; + case "sm": + return old.settings.customization.gridstack.columnCountSmall; + default: + return 10; + } +}; diff --git a/packages/old-import/src/move-widgets-and-apps-merge.ts b/packages/old-import/src/move-widgets-and-apps-merge.ts new file mode 100644 index 000000000..0305a8832 --- /dev/null +++ b/packages/old-import/src/move-widgets-and-apps-merge.ts @@ -0,0 +1,300 @@ +import { objectEntries } from "@homarr/common"; +import { logger } from "@homarr/log"; +import type { OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema"; +import type { OldmarrImportConfiguration } from "@homarr/validation"; + +import { OldHomarrScreenSizeError } from "./import-error"; +import { mapColumnCount } from "./mappers/map-column-count"; + +export const moveWidgetsAndAppsIfMerge = ( + old: OldmarrConfig, + wrapperIdsToMerge: string[], + configuration: OldmarrImportConfiguration, +) => { + const firstId = wrapperIdsToMerge[0]; + if (!firstId) { + return { apps: old.apps, widgets: old.widgets }; + } + + const affectedMap = new Map( + wrapperIdsToMerge.map((id) => [ + id, + { + apps: old.apps.filter((app) => app.area.type !== "sidebar" && id === app.area.properties.id), + widgets: old.widgets.filter((app) => app.area.type !== "sidebar" && id === app.area.properties.id), + }, + ]), + ); + + logger.debug(`Merging wrappers at the end of the board count=${wrapperIdsToMerge.length}`); + + let offset = 0; + for (const id of wrapperIdsToMerge) { + let requiredHeight = 0; + const affected = affectedMap.get(id); + if (!affected) { + continue; + } + + const apps = affected.apps; + const widgets = affected.widgets; + + for (const app of apps) { + if (app.area.type === "sidebar") continue; + // Move item to first wrapper + app.area.properties.id = firstId; + + const screenSizeShape = app.shape[configuration.screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("app", app.id, configuration.screenSize); + } + + // Find the highest widget in the wrapper to increase the offset accordingly + if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) { + requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height; + } + + // Move item down as much as needed to not overlap with other items + screenSizeShape.location.y += offset; + } + + for (const widget of widgets) { + if (widget.area.type === "sidebar") continue; + // Move item to first wrapper + widget.area.properties.id = firstId; + + const screenSizeShape = widget.shape[configuration.screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("widget", widget.id, configuration.screenSize); + } + + // Find the highest widget in the wrapper to increase the offset accordingly + if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) { + requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height; + } + + // Move item down as much as needed to not overlap with other items + screenSizeShape.location.y += offset; + } + + offset += requiredHeight; + } + + if (configuration.sidebarBehaviour === "last-section") { + if (old.settings.customization.layout.enabledLeftSidebar) { + offset = moveWidgetsAndAppsInLeftSidebar(old, firstId, offset, configuration.screenSize); + } + + if (old.settings.customization.layout.enabledRightSidebar) { + moveWidgetsAndAppsInRightSidebar(old, firstId, offset, configuration.screenSize); + } + } + + return { apps: old.apps, widgets: old.widgets }; +}; + +const moveWidgetsAndAppsInLeftSidebar = ( + old: OldmarrConfig, + firstId: string, + offset: number, + screenSize: OldmarrImportConfiguration["screenSize"], +) => { + const columnCount = mapColumnCount(old, screenSize); + let requiredHeight = updateItems({ + // This should work as the reference of the items did not change, only the array reference did + items: [...old.widgets, ...old.apps], + screenSize, + filter: (item) => + item.area.type === "sidebar" && + item.area.properties.location === "left" && + (columnCount >= 2 || item.shape[screenSize]?.location.x === 0), + update: (item) => { + const screenSizeShape = item.shape[screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize); + } + // Reduce width to one if column count is one + if (screenSizeShape.size.width > columnCount) { + screenSizeShape.size.width = columnCount; + } + + item.area = { + type: "wrapper", + properties: { + id: firstId, + }, + }; + + screenSizeShape.location.y += offset; + }, + }); + + // Only increase offset if there are less than 3 columns because then the items have to be stacked + if (columnCount <= 3) { + offset += requiredHeight; + } + + // When column count is 0 we need to stack the items of the sidebar on top of each other + if (columnCount !== 1) { + return offset; + } + + requiredHeight = updateItems({ + // This should work as the reference of the items did not change, only the array reference did + items: [...old.widgets, ...old.apps], + screenSize, + filter: (item) => + item.area.type === "sidebar" && + item.area.properties.location === "left" && + item.shape[screenSize]?.location.x === 1, + update: (item) => { + const screenSizeShape = item.shape[screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize); + } + + item.area = { + type: "wrapper", + properties: { + id: firstId, + }, + }; + + screenSizeShape.location.x = 0; + screenSizeShape.location.y += offset; + }, + }); + + offset += requiredHeight; + return offset; +}; + +const moveWidgetsAndAppsInRightSidebar = ( + old: OldmarrConfig, + firstId: string, + offset: number, + screenSize: OldmarrImportConfiguration["screenSize"], +) => { + const columnCount = mapColumnCount(old, screenSize); + const xOffsetDelta = Math.max(columnCount - 2, 0); + const requiredHeight = updateItems({ + // This should work as the reference of the items did not change, only the array reference did + items: [...old.widgets, ...old.apps], + screenSize, + filter: (item) => + item.area.type === "sidebar" && + item.area.properties.location === "right" && + (columnCount >= 2 || item.shape[screenSize]?.location.x === 0), + update: (item) => { + const screenSizeShape = item.shape[screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize); + } + + // Reduce width to one if column count is one + if (screenSizeShape.size.width > columnCount) { + screenSizeShape.size.width = columnCount; + } + + item.area = { + type: "wrapper", + properties: { + id: firstId, + }, + }; + + screenSizeShape.location.y += offset; + screenSizeShape.location.x += xOffsetDelta; + }, + }); + + // When column count is 0 we need to stack the items of the sidebar on top of each other + if (columnCount !== 1) { + return; + } + + offset += requiredHeight; + + updateItems({ + // This should work as the reference of the items did not change, only the array reference did + items: [...old.widgets, ...old.apps], + screenSize, + filter: (item) => + item.area.type === "sidebar" && + item.area.properties.location === "left" && + item.shape[screenSize]?.location.x === 1, + update: (item) => { + const screenSizeShape = item.shape[screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, screenSize); + } + + item.area = { + type: "wrapper", + properties: { + id: firstId, + }, + }; + + screenSizeShape.location.x = 0; + screenSizeShape.location.y += offset; + }, + }); +}; + +const createItemSnapshot = ( + item: OldmarrApp | OldmarrWidget, + screenSize: OldmarrImportConfiguration["screenSize"], +) => ({ + x: item.shape[screenSize]?.location.x, + y: item.shape[screenSize]?.location.y, + height: item.shape[screenSize]?.size.height, + width: item.shape[screenSize]?.size.width, + section: + item.area.type === "sidebar" + ? { + type: "sidebar", + location: item.area.properties.location, + } + : { + type: item.area.type, + id: item.area.properties.id, + }, + toString(): string { + return objectEntries(this) + .filter(([key]) => key !== "toString") + .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) + .join(" "); + }, +}); + +const updateItems = (options: { + items: (OldmarrApp | OldmarrWidget)[]; + filter: (item: OldmarrApp | OldmarrWidget) => boolean; + update: (item: OldmarrApp | OldmarrWidget) => void; + screenSize: OldmarrImportConfiguration["screenSize"]; +}) => { + const items = options.items.filter(options.filter); + let requiredHeight = 0; + for (const item of items) { + const before = createItemSnapshot(item, options.screenSize); + + const screenSizeShape = item.shape[options.screenSize]; + if (!screenSizeShape) { + throw new OldHomarrScreenSizeError("kind" in item ? "widget" : "app", item.id, options.screenSize); + } + + if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) { + requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height; + } + + options.update(item); + const after = createItemSnapshot(item, options.screenSize); + + logger.debug( + `Moved item ${item.id}\n [snapshot before]: ${before.toString()}\n [snapshot after]: ${after.toString()}`, + ); + } + + return requiredHeight; +}; diff --git a/packages/old-import/src/widgets/definitions/bookmark.ts b/packages/old-import/src/widgets/definitions/bookmark.ts new file mode 100644 index 000000000..178185777 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/bookmark.ts @@ -0,0 +1,18 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrBookmarkDefinition = CommonOldmarrWidgetDefinition< + "bookmark", + { + name: string; + items: { + id: string; + name: string; + href: string; + iconUrl: string; + openNewTab: boolean; + hideHostname: boolean; + hideIcon: boolean; + }[]; + layout: "autoGrid" | "horizontal" | "vertical"; + } +>; diff --git a/packages/old-import/src/widgets/definitions/calendar.ts b/packages/old-import/src/widgets/definitions/calendar.ts new file mode 100644 index 000000000..0220cd832 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/calendar.ts @@ -0,0 +1,11 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrCalendarDefinition = CommonOldmarrWidgetDefinition< + "calendar", + { + hideWeekDays: boolean; + showUnmonitored: boolean; + radarrReleaseType: "inCinemas" | "physicalRelease" | "digitalRelease"; + fontSize: "xs" | "sm" | "md" | "lg" | "xl"; + } +>; diff --git a/packages/old-import/src/widgets/definitions/common.ts b/packages/old-import/src/widgets/definitions/common.ts new file mode 100644 index 000000000..254966e24 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/common.ts @@ -0,0 +1,9 @@ +import type { OldmarrWidgetKind } from "@homarr/old-schema"; + +export interface CommonOldmarrWidgetDefinition< + TId extends OldmarrWidgetKind, + TOptions extends Record, +> { + id: TId; + options: TOptions; +} diff --git a/packages/old-import/src/widgets/definitions/dashdot.ts b/packages/old-import/src/widgets/definitions/dashdot.ts new file mode 100644 index 000000000..229f194d8 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/dashdot.ts @@ -0,0 +1,53 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrDashdotDefinition = CommonOldmarrWidgetDefinition< + "dashdot", + { + dashName: string; + url: string; + usePercentages: boolean; + columns: number; + graphHeight: number; + graphsOrder: ( + | { + key: "storage"; + subValues: { + enabled: boolean; + compactView: boolean; + span: number; + multiView: boolean; + }; + } + | { + key: "network"; + subValues: { + enabled: boolean; + compactView: boolean; + span: number; + }; + } + | { + key: "cpu"; + subValues: { + enabled: boolean; + multiView: boolean; + span: number; + }; + } + | { + key: "ram"; + subValues: { + enabled: boolean; + span: number; + }; + } + | { + key: "gpu"; + subValues: { + enabled: boolean; + span: number; + }; + } + )[]; + } +>; diff --git a/packages/old-import/src/widgets/definitions/date.ts b/packages/old-import/src/widgets/definitions/date.ts new file mode 100644 index 000000000..b43594a02 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/date.ts @@ -0,0 +1,21 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrDateDefinition = CommonOldmarrWidgetDefinition< + "date", + { + timezone: string; + customTitle: string; + display24HourFormat: boolean; + dateFormat: + | "hide" + | "dddd, MMMM D" + | "dddd, D MMMM" + | "MMM D" + | "D MMM" + | "DD/MM/YYYY" + | "MM/DD/YYYY" + | "DD/MM" + | "MM/DD"; + titleState: "none" | "city" | "both"; + } +>; diff --git a/packages/old-import/src/widgets/definitions/dlspeed.ts b/packages/old-import/src/widgets/definitions/dlspeed.ts new file mode 100644 index 000000000..d77f33df9 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/dlspeed.ts @@ -0,0 +1,4 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type OldmarrDlspeedDefinition = CommonOldmarrWidgetDefinition<"dlspeed", {}>; diff --git a/packages/old-import/src/widgets/definitions/dns-hole-controls.ts b/packages/old-import/src/widgets/definitions/dns-hole-controls.ts new file mode 100644 index 000000000..cb16aa36d --- /dev/null +++ b/packages/old-import/src/widgets/definitions/dns-hole-controls.ts @@ -0,0 +1,8 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrDnsHoleControlsDefinition = CommonOldmarrWidgetDefinition< + "dns-hole-controls", + { + showToggleAllButtons: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/dns-hole-summary.ts b/packages/old-import/src/widgets/definitions/dns-hole-summary.ts new file mode 100644 index 000000000..94190bf6f --- /dev/null +++ b/packages/old-import/src/widgets/definitions/dns-hole-summary.ts @@ -0,0 +1,6 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrDnsHoleSummaryDefinition = CommonOldmarrWidgetDefinition< + "dns-hole-summary", + { usePiHoleColors: boolean; layout: "column" | "row" | "grid" } +>; diff --git a/packages/old-import/src/widgets/definitions/health-monitoring.ts b/packages/old-import/src/widgets/definitions/health-monitoring.ts new file mode 100644 index 000000000..f310bb9dc --- /dev/null +++ b/packages/old-import/src/widgets/definitions/health-monitoring.ts @@ -0,0 +1,21 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrHealthMonitoringDefinition = CommonOldmarrWidgetDefinition< + "health-monitoring", + { + fahrenheit: boolean; + cpu: boolean; + memory: boolean; + fileSystem: boolean; + defaultTabState: "system" | "cluster"; + node: string; + defaultViewState: "storage" | "none" | "node" | "vm" | "lxc"; + summary: boolean; + showNode: boolean; + showVM: boolean; + showLXCs: boolean; + showStorage: boolean; + sectionIndicatorColor: "all" | "any"; + ignoreCert: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/iframe.ts b/packages/old-import/src/widgets/definitions/iframe.ts new file mode 100644 index 000000000..21e29e83d --- /dev/null +++ b/packages/old-import/src/widgets/definitions/iframe.ts @@ -0,0 +1,16 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrIframeDefinition = CommonOldmarrWidgetDefinition< + "iframe", + { + embedUrl: string; + allowFullScreen: boolean; + allowScrolling: boolean; + allowTransparency: boolean; + allowPayment: boolean; + allowAutoPlay: boolean; + allowMicrophone: boolean; + allowCamera: boolean; + allowGeolocation: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts new file mode 100644 index 000000000..20abf310e --- /dev/null +++ b/packages/old-import/src/widgets/definitions/index.ts @@ -0,0 +1,75 @@ +import { objectEntries } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; + +import type { OldmarrBookmarkDefinition } from "./bookmark"; +import type { OldmarrCalendarDefinition } from "./calendar"; +import type { OldmarrDashdotDefinition } from "./dashdot"; +import type { OldmarrDateDefinition } from "./date"; +import type { OldmarrDlspeedDefinition } from "./dlspeed"; +import type { OldmarrDnsHoleControlsDefinition } from "./dns-hole-controls"; +import type { OldmarrDnsHoleSummaryDefinition } from "./dns-hole-summary"; +import type { OldmarrHealthMonitoringDefinition } from "./health-monitoring"; +import type { OldmarrIframeDefinition } from "./iframe"; +import type { OldmarrIndexerManagerDefinition } from "./indexer-manager"; +import type { OldmarrMediaRequestListDefinition } from "./media-requests-list"; +import type { OldmarrMediaRequestStatsDefinition } from "./media-requests-stats"; +import type { OldmarrMediaServerDefinition } from "./media-server"; +import type { OldmarrMediaTranscodingDefinition } from "./media-transcoding"; +import type { OldmarrNotebookDefinition } from "./notebook"; +import type { OldmarrRssDefinition } from "./rss"; +import type { OldmarrSmartHomeEntityStateDefinition } from "./smart-home-entity-state"; +import type { OldmarrSmartHomeTriggerAutomationDefinition } from "./smart-home-trigger-automation"; +import type { OldmarrTorrentStatusDefinition } from "./torrent-status"; +import type { OldmarrUsenetDefinition } from "./usenet"; +import type { OldmarrVideoStreamDefinition } from "./video-stream"; +import type { OldmarrWeatherDefinition } from "./weather"; + +export type OldmarrWidgetDefinitions = + | OldmarrWeatherDefinition + | OldmarrDateDefinition + | OldmarrCalendarDefinition + | OldmarrIndexerManagerDefinition + | OldmarrDashdotDefinition + | OldmarrUsenetDefinition + | OldmarrTorrentStatusDefinition + | OldmarrDlspeedDefinition + | OldmarrRssDefinition + | OldmarrVideoStreamDefinition + | OldmarrIframeDefinition + | OldmarrMediaServerDefinition + | OldmarrMediaRequestListDefinition + | OldmarrMediaRequestStatsDefinition + | OldmarrDnsHoleSummaryDefinition + | OldmarrDnsHoleControlsDefinition + | OldmarrBookmarkDefinition + | OldmarrNotebookDefinition + | OldmarrSmartHomeEntityStateDefinition + | OldmarrSmartHomeTriggerAutomationDefinition + | OldmarrHealthMonitoringDefinition + | OldmarrMediaTranscodingDefinition; + +export const widgetKindMapping = { + app: null, // In oldmarr apps were not widgets + clock: "date", + calendar: "calendar", + weather: "weather", + rssFeed: "rss", + video: "video-stream", + iframe: "iframe", + mediaServer: "media-server", + dnsHoleSummary: "dns-hole-summary", + dnsHoleControls: "dns-hole-controls", + notebook: "notebook", + "smartHome-entityState": "smart-home/entity-state", + "smartHome-executeAutomation": "smart-home/trigger-automation", + "mediaRequests-requestList": "media-requests-list", + "mediaRequests-requestStats": "media-requests-stats", +} satisfies Record; +// Use null for widgets that did not exist in oldmarr +// TODO: revert assignment so that only old widgets are needed in the object, +// this can be done ones all widgets are implemented + +export type WidgetMapping = typeof widgetKindMapping; + +export const mapKind = (kind: OldmarrWidgetDefinitions["id"]): WidgetKind | undefined => + objectEntries(widgetKindMapping).find(([_, value]) => value === kind)?.[0]; diff --git a/packages/old-import/src/widgets/definitions/indexer-manager.ts b/packages/old-import/src/widgets/definitions/indexer-manager.ts new file mode 100644 index 000000000..2061a8cfa --- /dev/null +++ b/packages/old-import/src/widgets/definitions/indexer-manager.ts @@ -0,0 +1,8 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrIndexerManagerDefinition = CommonOldmarrWidgetDefinition< + "indexer-manager", + { + openIndexerSiteInNewTab: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/media-requests-list.ts b/packages/old-import/src/widgets/definitions/media-requests-list.ts new file mode 100644 index 000000000..a8f8a7fc1 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/media-requests-list.ts @@ -0,0 +1,9 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrMediaRequestListDefinition = CommonOldmarrWidgetDefinition< + "media-requests-list", + { + replaceLinksWithExternalHost: boolean; + openInNewTab: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/media-requests-stats.ts b/packages/old-import/src/widgets/definitions/media-requests-stats.ts new file mode 100644 index 000000000..12fd9f388 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/media-requests-stats.ts @@ -0,0 +1,9 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrMediaRequestStatsDefinition = CommonOldmarrWidgetDefinition< + "media-requests-stats", + { + replaceLinksWithExternalHost: boolean; + openInNewTab: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/media-server.ts b/packages/old-import/src/widgets/definitions/media-server.ts new file mode 100644 index 000000000..4c3d46fd2 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/media-server.ts @@ -0,0 +1,4 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type OldmarrMediaServerDefinition = CommonOldmarrWidgetDefinition<"media-server", {}>; diff --git a/packages/old-import/src/widgets/definitions/media-transcoding.ts b/packages/old-import/src/widgets/definitions/media-transcoding.ts new file mode 100644 index 000000000..82f278745 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/media-transcoding.ts @@ -0,0 +1,12 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrMediaTranscodingDefinition = CommonOldmarrWidgetDefinition< + "media-transcoding", + { + defaultView: "workers" | "queue" | "statistics"; + showHealthCheck: boolean; + showHealthChecksInQueue: boolean; + queuePageSize: number; + showAppIcon: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/notebook.ts b/packages/old-import/src/widgets/definitions/notebook.ts new file mode 100644 index 000000000..6f589a3c0 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/notebook.ts @@ -0,0 +1,10 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrNotebookDefinition = CommonOldmarrWidgetDefinition< + "notebook", + { + showToolbar: boolean; + allowReadOnlyCheck: boolean; + content: string; + } +>; diff --git a/packages/old-import/src/widgets/definitions/rss.ts b/packages/old-import/src/widgets/definitions/rss.ts new file mode 100644 index 000000000..095b6c2fc --- /dev/null +++ b/packages/old-import/src/widgets/definitions/rss.ts @@ -0,0 +1,14 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrRssDefinition = CommonOldmarrWidgetDefinition< + "rss", + { + rssFeedUrl: string[]; + refreshInterval: number; + dangerousAllowSanitizedItemContent: boolean; + textLinesClamp: number; + sortByPublishDateAscending: boolean; + sortPostsWithoutPublishDateToTheTop: boolean; + maximumAmountOfPosts: number; + } +>; diff --git a/packages/old-import/src/widgets/definitions/smart-home-entity-state.ts b/packages/old-import/src/widgets/definitions/smart-home-entity-state.ts new file mode 100644 index 000000000..169b9c46d --- /dev/null +++ b/packages/old-import/src/widgets/definitions/smart-home-entity-state.ts @@ -0,0 +1,13 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrSmartHomeEntityStateDefinition = CommonOldmarrWidgetDefinition< + "smart-home/entity-state", + { + entityId: string; + appendUnit: boolean; + genericToggle: boolean; + automationId: string; + displayName: string; + displayFriendlyName: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts b/packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts new file mode 100644 index 000000000..5991e4631 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/smart-home-trigger-automation.ts @@ -0,0 +1,9 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrSmartHomeTriggerAutomationDefinition = CommonOldmarrWidgetDefinition< + "smart-home/trigger-automation", + { + automationId: string; + displayName: string; + } +>; diff --git a/packages/old-import/src/widgets/definitions/torrent-status.ts b/packages/old-import/src/widgets/definitions/torrent-status.ts new file mode 100644 index 000000000..8e7adb341 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/torrent-status.ts @@ -0,0 +1,18 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrTorrentStatusDefinition = CommonOldmarrWidgetDefinition< + "torrents-status", + { + displayCompletedTorrents: boolean; + displayActiveTorrents: boolean; + speedLimitOfActiveTorrents: number; + displayStaleTorrents: boolean; + labelFilterIsWhitelist: boolean; + labelFilter: string[]; + displayRatioWithFilter: boolean; + columnOrdering: boolean; + rowSorting: boolean; + columns: ("up" | "down" | "eta" | "progress")[]; + nameColumnSize: number; + } +>; diff --git a/packages/old-import/src/widgets/definitions/usenet.ts b/packages/old-import/src/widgets/definitions/usenet.ts new file mode 100644 index 000000000..3e9e9ec5c --- /dev/null +++ b/packages/old-import/src/widgets/definitions/usenet.ts @@ -0,0 +1,4 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type OldmarrUsenetDefinition = CommonOldmarrWidgetDefinition<"usenet", {}>; diff --git a/packages/old-import/src/widgets/definitions/video-stream.ts b/packages/old-import/src/widgets/definitions/video-stream.ts new file mode 100644 index 000000000..6294e2168 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/video-stream.ts @@ -0,0 +1,11 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrVideoStreamDefinition = CommonOldmarrWidgetDefinition< + "video-stream", + { + FeedUrl: string; + autoPlay: boolean; + muted: boolean; + controls: boolean; + } +>; diff --git a/packages/old-import/src/widgets/definitions/weather.ts b/packages/old-import/src/widgets/definitions/weather.ts new file mode 100644 index 000000000..f883eefa4 --- /dev/null +++ b/packages/old-import/src/widgets/definitions/weather.ts @@ -0,0 +1,16 @@ +import type { CommonOldmarrWidgetDefinition } from "./common"; + +export type OldmarrWeatherDefinition = CommonOldmarrWidgetDefinition< + "weather", + { + displayInFahrenheit: boolean; + displayCityName: boolean; + displayWeekly: boolean; + forecastDays: number; + location: { + name: string; + latitude: number; + longitude: number; + }; + } +>; diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts new file mode 100644 index 000000000..be0cf7217 --- /dev/null +++ b/packages/old-import/src/widgets/options.ts @@ -0,0 +1,121 @@ +import { objectEntries } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import type { WidgetComponentProps } from "../../../widgets/src/definition"; +import type { OldmarrWidgetDefinitions, WidgetMapping } from "./definitions"; + +// This type enforces, that for all widget mappings there is a corresponding option mapping, +// each option of newmarr can be mapped from the value of the oldmarr options +type OptionMapping = { + [WidgetKey in keyof WidgetMapping]: WidgetMapping[WidgetKey] extends null + ? null + : { + [OptionsKey in keyof WidgetComponentProps["options"]]: ( + oldOptions: Extract["options"], + ) => WidgetComponentProps["options"][OptionsKey] | undefined; + }; +}; + +const optionMapping: OptionMapping = { + "mediaRequests-requestList": { + linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, + }, + "mediaRequests-requestStats": {}, + calendar: { + filterFutureMonths: () => undefined, + filterPastMonths: () => undefined, + }, + clock: { + customTitle: (oldOptions) => oldOptions.customTitle, + customTitleToggle: (oldOptions) => oldOptions.titleState !== "none", + dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat), + is24HourFormat: (oldOptions) => oldOptions.display24HourFormat, + showDate: (oldOptions) => oldOptions.dateFormat !== "hide", + showSeconds: () => undefined, + timezone: (oldOptions) => oldOptions.timezone, + useCustomTimezone: () => true, + }, + weather: { + forecastDayCount: (oldOptions) => oldOptions.forecastDays, + hasForecast: (oldOptions) => oldOptions.displayWeekly, + isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit, + location: (oldOptions) => oldOptions.location, + showCity: (oldOptions) => oldOptions.displayCityName, + }, + iframe: { + embedUrl: (oldOptions) => oldOptions.embedUrl, + allowAutoPlay: (oldOptions) => oldOptions.allowAutoPlay, + allowFullScreen: (oldOptions) => oldOptions.allowFullScreen, + allowPayment: (oldOptions) => oldOptions.allowPayment, + allowCamera: (oldOptions) => oldOptions.allowCamera, + allowMicrophone: (oldOptions) => oldOptions.allowMicrophone, + allowGeolocation: (oldOptions) => oldOptions.allowGeolocation, + allowScrolling: (oldOptions) => oldOptions.allowScrolling, + allowTransparency: (oldOptions) => oldOptions.allowTransparency, + }, + video: { + feedUrl: (oldOptions) => oldOptions.FeedUrl, + hasAutoPlay: (oldOptions) => oldOptions.autoPlay, + hasControls: (oldOptions) => oldOptions.controls, + isMuted: (oldOptions) => oldOptions.muted, + }, + dnsHoleControls: { + showToggleAllButtons: (oldOptions) => oldOptions.showToggleAllButtons, + }, + dnsHoleSummary: { + layout: (oldOptions) => oldOptions.layout, + usePiHoleColors: (oldOptions) => oldOptions.usePiHoleColors, + }, + rssFeed: { + feedUrls: (oldOptions) => oldOptions.rssFeedUrl, + maximumAmountPosts: (oldOptions) => oldOptions.maximumAmountOfPosts, + textLinesClamp: (oldOptions) => oldOptions.textLinesClamp, + }, + notebook: { + allowReadOnlyCheck: (oldOptions) => oldOptions.allowReadOnlyCheck, + content: (oldOptions) => oldOptions.content, + showToolbar: (oldOptions) => oldOptions.showToolbar, + }, + "smartHome-entityState": { + entityId: (oldOptions) => oldOptions.entityId, + displayName: (oldOptions) => oldOptions.displayName, + clickable: () => undefined, + entityUnit: () => undefined, + }, + "smartHome-executeAutomation": { + automationId: (oldOptions) => oldOptions.automationId, + displayName: (oldOptions) => oldOptions.displayName, + }, + mediaServer: {}, + app: null, +}; + +/** + * Maps the oldmarr options to the newmarr options + * @param kind item kind to map + * @param oldOptions oldmarr options for this item + * @returns newmarr options for this item or null if the item did not exist in oldmarr + */ +export const mapOptions = ( + kind: K, + oldOptions: Extract["options"], +) => { + logger.debug(`Mapping old homarr options for widget kind=${kind} options=${JSON.stringify(oldOptions)}`); + if (optionMapping[kind] === null) { + return null; + } + + const mapping = optionMapping[kind]; + return objectEntries(mapping).reduce( + (acc, [key, value]) => { + const newValue = value(oldOptions as never); + logger.debug(`Mapping old homarr option kind=${kind} key=${key as string} newValue=${newValue as string}`); + if (newValue !== undefined) { + acc[key as string] = newValue; + } + return acc; + }, + {} as Record, + ) as WidgetComponentProps["options"]; +}; diff --git a/packages/old-import/tsconfig.json b/packages/old-import/tsconfig.json new file mode 100644 index 000000000..cbe8483d9 --- /dev/null +++ b/packages/old-import/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/old-schema/eslint.config.js b/packages/old-schema/eslint.config.js new file mode 100644 index 000000000..5b19b6f8a --- /dev/null +++ b/packages/old-schema/eslint.config.js @@ -0,0 +1,9 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: [], + }, + ...baseConfig, +]; diff --git a/packages/old-schema/index.ts b/packages/old-schema/index.ts new file mode 100644 index 000000000..3bd16e178 --- /dev/null +++ b/packages/old-schema/index.ts @@ -0,0 +1 @@ +export * from "./src"; diff --git a/packages/old-schema/package.json b/packages/old-schema/package.json new file mode 100644 index 000000000..6a955dc03 --- /dev/null +++ b/packages/old-schema/package.json @@ -0,0 +1,34 @@ +{ + "name": "@homarr/old-schema", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf .turbo node_modules", + "lint": "eslint", + "format": "prettier --check . --ignore-path ../../.gitignore", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@homarr/eslint-config": "workspace:^0.2.0", + "@homarr/prettier-config": "workspace:^0.1.0", + "@homarr/tsconfig": "workspace:^0.1.0", + "eslint": "^9.10.0", + "typescript": "^5.5.4" + }, + "prettier": "@homarr/prettier-config" +} diff --git a/packages/old-schema/src/app.ts b/packages/old-schema/src/app.ts new file mode 100644 index 000000000..0befa9ae2 --- /dev/null +++ b/packages/old-schema/src/app.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { tileBaseSchema } from "./tile"; + +const appBehaviourSchema = z.object({ + externalUrl: z.string(), + isOpeningNewTab: z.boolean(), + tooltipDescription: z.string().optional(), +}); + +const appNetworkSchema = z.object({ + enabledStatusChecker: z.boolean(), + okStatus: z.array(z.number()).optional(), + statusCodes: z.array(z.string()), +}); + +const appAppearanceSchema = z.object({ + iconUrl: z.string(), + appNameStatus: z.union([z.literal("normal"), z.literal("hover"), z.literal("hidden")]), + positionAppName: z.union([ + z.literal("row"), + z.literal("column"), + z.literal("row-reverse"), + z.literal("column-reverse"), + ]), + appNameFontSize: z.number(), + lineClampAppName: z.number(), +}); + +const integrationSchema = z.enum([ + "readarr", + "radarr", + "sonarr", + "lidarr", + "prowlarr", + "sabnzbd", + "jellyseerr", + "overseerr", + "deluge", + "qBittorrent", + "transmission", + "plex", + "jellyfin", + "nzbGet", + "pihole", + "adGuardHome", + "homeAssistant", + "openmediavault", + "proxmox", + "tdarr", +]); + +const appIntegrationPropertySchema = z.object({ + type: z.enum(["private", "public"]), + field: z.enum(["apiKey", "password", "username"]), + value: z.string().nullable().optional(), + isDefined: z.boolean().optional(), +}); + +const appIntegrationSchema = z.object({ + type: integrationSchema.optional().nullable(), + properties: z.array(appIntegrationPropertySchema), +}); + +export const oldmarrAppSchema = z + .object({ + id: z.string(), + name: z.string(), + url: z.string(), + behaviour: appBehaviourSchema, + network: appNetworkSchema, + appearance: appAppearanceSchema, + integration: appIntegrationSchema.optional(), + }) + .and(tileBaseSchema); + +export type OldmarrApp = z.infer; diff --git a/packages/old-schema/src/config.ts b/packages/old-schema/src/config.ts new file mode 100644 index 000000000..5ca103739 --- /dev/null +++ b/packages/old-schema/src/config.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { oldmarrAppSchema } from "./app"; +import { settingsSchema } from "./setting"; +import { oldmarrWidgetSchema } from "./widget"; + +const categorySchema = z.object({ + id: z.string(), + position: z.number(), + name: z.string(), +}); + +const wrapperSchema = z.object({ + id: z.string(), + position: z.number(), +}); + +export const oldmarrConfigSchema = z.object({ + schemaVersion: z.number(), + configProperties: z.object({ + name: z.string(), + }), + categories: z.array(categorySchema), + wrappers: z.array(wrapperSchema), + apps: z.array(oldmarrAppSchema), + widgets: z.array(oldmarrWidgetSchema), + settings: settingsSchema, +}); + +export type OldmarrConfig = z.infer; diff --git a/packages/old-schema/src/index.ts b/packages/old-schema/src/index.ts new file mode 100644 index 000000000..6b2e36d27 --- /dev/null +++ b/packages/old-schema/src/index.ts @@ -0,0 +1,5 @@ +export type { OldmarrConfig } from "./config"; +export { oldmarrConfigSchema } from "./config"; +export type { OldmarrApp } from "./app"; +export type { OldmarrWidget, OldmarrWidgetKind } from "./widget"; +export { oldmarrWidgetKinds } from "./widget"; diff --git a/packages/old-schema/src/setting.ts b/packages/old-schema/src/setting.ts new file mode 100644 index 000000000..56c30c188 --- /dev/null +++ b/packages/old-schema/src/setting.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +const baseSearchEngineSchema = z.object({ + properties: z.object({ + openInNewTab: z.boolean().default(true), + enabled: z.boolean().default(true), + }), +}); + +const commonSearchEngineSchema = z + .object({ + type: z.enum(["google", "duckDuckGo", "bing"]), + }) + .and(baseSearchEngineSchema); + +const customSearchEngineSchema = z + .object({ + type: z.literal("custom"), + properties: z.object({ + template: z.string(), + }), + }) + .and(baseSearchEngineSchema); + +const searchEngineSchema = z.union([commonSearchEngineSchema, customSearchEngineSchema]); + +const commonSettingsSchema = z.object({ + searchEngine: searchEngineSchema, +}); + +const accessSettingsSchema = z.object({ + allowGuests: z.boolean(), +}); + +const gridstackSettingsSchema = z.object({ + columnCountSmall: z.number(), + columnCountMedium: z.number(), + columnCountLarge: z.number(), +}); + +const layoutSettingsSchema = z.object({ + enabledLeftSidebar: z.boolean(), + enabledRightSidebar: z.boolean(), + enabledDocker: z.boolean(), + enabledPing: z.boolean(), + enabledSearchbar: z.boolean(), +}); + +const colorsSettingsSchema = z.object({ + primary: z.string().optional(), + secondary: z.string().optional(), + shade: z.number().optional(), +}); + +const customizationSettingsSchema = z.object({ + layout: layoutSettingsSchema, + pageTitle: z.string().optional(), + metaTitle: z.string().optional(), + logoImageUrl: z.string().optional(), + faviconUrl: z.string().optional(), + backgroundImageUrl: z.string().optional(), + backgroundImageAttachment: z.enum(["fixed", "scroll"]).optional(), + backgroundImageSize: z.enum(["cover", "contain"]).optional(), + backgroundImageRepeat: z.enum(["no-repeat", "repeat", "repeat-x", "repeat-y"]).optional(), + customCss: z.string().optional(), + colors: colorsSettingsSchema, + appOpacity: z.number().optional(), + gridstack: gridstackSettingsSchema, +}); + +export const settingsSchema = z.object({ + common: commonSettingsSchema, + customization: customizationSettingsSchema, + access: accessSettingsSchema, +}); diff --git a/packages/old-schema/src/tile.ts b/packages/old-schema/src/tile.ts new file mode 100644 index 000000000..f90071175 --- /dev/null +++ b/packages/old-schema/src/tile.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +const createAreaSchema = ( + type: TType, + propertiesSchema: TPropertiesSchema, +) => + z.object({ + type: z.literal(type), + properties: propertiesSchema, + }); + +const wrapperAreaSchema = createAreaSchema( + "wrapper", + z.object({ + id: z.string(), + }), +); + +const categoryAreaSchema = createAreaSchema( + "category", + z.object({ + id: z.string(), + }), +); + +const sidebarAreaSchema = createAreaSchema( + "sidebar", + z.object({ + location: z.union([z.literal("right"), z.literal("left")]), + }), +); + +const areaSchema = z.union([wrapperAreaSchema, categoryAreaSchema, sidebarAreaSchema]); + +const sizedShapeSchema = z.object({ + location: z.object({ + x: z.number(), + y: z.number(), + }), + size: z.object({ + width: z.number(), + height: z.number(), + }), +}); + +const shapeSchema = z.object({ + lg: sizedShapeSchema.optional(), + md: sizedShapeSchema.optional(), + sm: sizedShapeSchema.optional(), +}); + +export const tileBaseSchema = z.object({ + area: areaSchema, + shape: shapeSchema, +}); diff --git a/packages/old-schema/src/widget.ts b/packages/old-schema/src/widget.ts new file mode 100644 index 000000000..96a7396ea --- /dev/null +++ b/packages/old-schema/src/widget.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +import { tileBaseSchema } from "./tile"; + +export const oldmarrWidgetKinds = [ + "calendar", + "indexer-manager", + "dashdot", + "usenet", + "weather", + "torrents-status", + "dlspeed", + "date", + "rss", + "video-stream", + "iframe", + "media-server", + "media-requests-list", + "media-requests-stats", + "dns-hole-summary", + "dns-hole-controls", + "bookmark", + "notebook", + "smart-home/entity-state", + "smart-home/trigger-automation", + "health-monitoring", + "media-transcoding", +] as const; + +export type OldmarrWidgetKind = (typeof oldmarrWidgetKinds)[number]; + +export const oldmarrWidgetSchema = z + .object({ + id: z.string(), + type: z.enum(oldmarrWidgetKinds), + properties: z.record(z.unknown()), + }) + .and(tileBaseSchema); + +export type OldmarrWidget = z.infer; diff --git a/packages/old-schema/tsconfig.json b/packages/old-schema/tsconfig.json new file mode 100644 index 000000000..a7b28ed61 --- /dev/null +++ b/packages/old-schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src", "../old-import/src/widgets/options.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index aadea37dc..cae59613d 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -530,6 +530,7 @@ export default { symbols: { colon: ": ", }, + beta: "Beta", error: "Error", errors: { noData: "No data to show", @@ -541,6 +542,7 @@ export default { backToOverview: "Back to overview", create: "Create", edit: "Edit", + import: "Import", insert: "Insert", remove: "Remove", save: "Save", @@ -644,6 +646,9 @@ export default { passwordsDoNotMatch: "Passwords do not match", passwordRequirements: "Password does not meet the requirements", boardAlreadyExists: "A board with this name already exists", + invalidFileType: "Invalid file type, expected {expected}", + fileTooLarge: "File is too large, maximum size is {maxSize}", + invalidConfiguration: "Invalid configuration", }, }, }, @@ -1202,6 +1207,61 @@ export default { }, }, }, + oldImport: { + label: "Import from homarr before 1.0.0", + notification: { + success: { + title: "Import successful", + message: "The board was successfully imported", + }, + error: { + title: "Import failed", + message: "The board could not be imported, check the logs for further details", + }, + }, + form: { + file: { + label: "Select JSON file", + invalidError: "Invalid configuration file", + }, + apps: { + label: "Apps", + avoidDuplicates: { + label: "Avoid duplicates", + description: "Ignores apps where an app with the same href already exists", + }, + onlyImportApps: { + label: "Only import apps", + description: "Only adds the apps, the board need to be recreated manually", + }, + }, + name: { + label: "Board name", + }, + screenSize: { + label: "Screen size", + option: { + sm: "Small", + md: "Medium", + lg: "Large", + }, + }, + sidebarBehavior: { + label: "Sidebar behavior", + description: "Sidebars were removed in 1.0, you can select what should happen with the items inside them.", + option: { + lastSection: { + label: "Last section", + description: "Sidebar will be displayed below the last section", + }, + removeItems: { + label: "Remove items", + description: "Items contained in the sidebar will be removed", + }, + }, + }, + }, + }, }, field: { pageTitle: { diff --git a/packages/ui/src/components/beta-badge.tsx b/packages/ui/src/components/beta-badge.tsx new file mode 100644 index 000000000..4c7381196 --- /dev/null +++ b/packages/ui/src/components/beta-badge.tsx @@ -0,0 +1,17 @@ +import type { BadgeProps } from "@mantine/core"; +import { Badge } from "@mantine/core"; + +import { useI18n } from "@homarr/translation/client"; + +interface BetaBadgeProps { + size: BadgeProps["size"]; +} + +export const BetaBadge = ({ size }: BetaBadgeProps) => { + const t = useI18n(); + return ( + + {t("common.beta")} + + ); +}; diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 442b76913..ff01de8b2 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -8,3 +8,4 @@ export { TextMultiSelect } from "./text-multi-select"; export { UserAvatar } from "./user-avatar"; export { UserAvatarGroup } from "./user-avatar-group"; export { CustomPasswordInput } from "./password-input/password-input"; +export { BetaBadge } from "./beta-badge"; diff --git a/packages/ui/src/components/select-with-custom-items.tsx b/packages/ui/src/components/select-with-custom-items.tsx index 94b756a58..410fe3429 100644 --- a/packages/ui/src/components/select-with-custom-items.tsx +++ b/packages/ui/src/components/select-with-custom-items.tsx @@ -13,6 +13,8 @@ interface BaseSelectItem { export interface SelectWithCustomItemsProps extends Pick { data: TSelectItem[]; + description?: string; + withAsterisk?: boolean; onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; } diff --git a/packages/validation/package.json b/packages/validation/package.json index 046bb33a6..e99a9bd03 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -24,8 +24,10 @@ "prettier": "@homarr/prettier-config", "dependencies": { "@homarr/definitions": "workspace:^0.1.0", + "@homarr/old-schema": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-form-data": "^2.0.2" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts index c7eff93a6..8e5302aa7 100644 --- a/packages/validation/src/board.ts +++ b/packages/validation/src/board.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { zfd } from "zod-form-data"; import { backgroundImageAttachments, @@ -8,6 +9,7 @@ import { } from "@homarr/definitions"; import { zodEnumFromArray } from "./enums"; +import { createCustomErrorParams } from "./form/i18n"; import { createSavePermissionsSchema } from "./permissions"; import { commonItemSchema, createSectionSchema } from "./shared"; @@ -67,6 +69,61 @@ const permissionsSchema = z.object({ id: z.string(), }); +export const createOldmarrImportConfigurationSchema = (existingBoardNames: string[]) => + z.object({ + name: boardNameSchema.refine( + (value) => { + return existingBoardNames.every((name) => name.toLowerCase().trim() !== value.toLowerCase().trim()); + }, + { + params: createCustomErrorParams("boardAlreadyExists"), + }, + ), + onlyImportApps: z.boolean().default(false), + distinctAppsByHref: z.boolean().default(true), + screenSize: z.enum(["lg", "md", "sm"]).default("lg"), + sidebarBehaviour: z.enum(["remove-items", "last-section"]).default("last-section"), + }); + +export type OldmarrImportConfiguration = z.infer>; + +export const superRefineJsonImportFile = (value: File | null, context: z.RefinementCtx) => { + if (!value) { + return context.addIssue({ + code: "invalid_type", + expected: "object", + received: "null", + }); + } + + if (value.type !== "application/json") { + return context.addIssue({ + code: "custom", + params: createCustomErrorParams({ + key: "invalidFileType", + params: { expected: "JSON" }, + }), + }); + } + + if (value.size > 1024 * 1024) { + return context.addIssue({ + code: "custom", + params: createCustomErrorParams({ + key: "fileTooLarge", + params: { maxSize: "1 MB" }, + }), + }); + } + + return null; +}; + +const importJsonFileSchema = zfd.formData({ + file: zfd.file().superRefine(superRefineJsonImportFile), + configuration: zfd.json(createOldmarrImportConfigurationSchema([])), +}); + const savePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(boardPermissions)); z.object({ @@ -88,4 +145,5 @@ export const boardSchemas = { changeVisibility: changeVisibilitySchema, permissions: permissionsSchema, savePermissions: savePermissionsSchema, + importOldmarrConfig: importJsonFileSchema, }; diff --git a/packages/validation/src/form/i18n.ts b/packages/validation/src/form/i18n.ts index 1f94a5c8c..c0577248d 100644 --- a/packages/validation/src/form/i18n.ts +++ b/packages/validation/src/form/i18n.ts @@ -1,3 +1,4 @@ +import type { ParamsObject } from "international-types"; import type { ErrorMapCtx, z, ZodTooBigIssue, ZodTooSmallIssue } from "zod"; import { ZodIssueCode } from "zod"; @@ -114,16 +115,17 @@ const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => { if (issue.code === ZodIssueCode.too_big) { return handleTooBigError(issue); } - if (issue.code === ZodIssueCode.invalid_type && ctx.data === "") { + if (issue.code === ZodIssueCode.invalid_type && (ctx.data === "" || issue.received === "null")) { return { key: "errors.required", params: {}, } as const; } if (issue.code === ZodIssueCode.custom && issue.params?.i18n) { - const { i18n } = issue.params as CustomErrorParams; + const { i18n } = issue.params as CustomErrorParams; return { key: `errors.custom.${i18n.key}`, + params: i18n.params, } as const; } @@ -132,12 +134,17 @@ const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => { }; }; -export interface CustomErrorParams { +type CustomErrorKey = keyof TranslationObject["common"]["zod"]["errors"]["custom"]; + +export interface CustomErrorParams { i18n: { - key: keyof TranslationObject["common"]["zod"]["errors"]["custom"]; - params?: Record; + key: TKey; + params: ParamsObject; }; } -export const createCustomErrorParams = (i18n: CustomErrorParams["i18n"] | CustomErrorParams["i18n"]["key"]) => - typeof i18n === "string" ? { i18n: { key: i18n } } : { i18n }; +export const createCustomErrorParams = ( + i18n: keyof CustomErrorParams["i18n"]["params"] extends never + ? CustomErrorParams["i18n"]["key"] + : CustomErrorParams["i18n"], +) => (typeof i18n === "string" ? { i18n: { key: i18n, params: {} } } : { i18n }); diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index f18457678..6361c31a7 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -26,3 +26,5 @@ export { type BoardItemAdvancedOptions, } from "./shared"; export { passwordRequirements } from "./user"; +export { createOldmarrImportConfigurationSchema, superRefineJsonImportFile } from "./board"; +export type { OldmarrImportConfiguration } from "./board"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b19c3c5c7..f0daef1d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: '@homarr/notifications': specifier: workspace:^0.1.0 version: link:../../packages/notifications + '@homarr/old-schema': + specifier: workspace:^0.1.0 + version: link:../../packages/old-schema '@homarr/server-settings': specifier: workspace:^0.1.0 version: link:../../packages/server-settings @@ -476,6 +479,12 @@ importers: '@homarr/log': specifier: workspace:^ version: link:../log + '@homarr/old-import': + specifier: workspace:^0.1.0 + version: link:../old-import + '@homarr/old-schema': + specifier: workspace:^0.1.0 + version: link:../old-schema '@homarr/ping': specifier: workspace:^0.1.0 version: link:../ping @@ -1072,6 +1081,68 @@ importers: specifier: ^5.5.4 version: 5.5.4 + packages/old-import: + dependencies: + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions + '@homarr/log': + specifier: workspace:^0.1.0 + version: link:../log + '@homarr/old-schema': + specifier: workspace:^0.1.0 + version: link:../old-schema + '@homarr/validation': + specifier: workspace:^0.1.0 + version: link:../validation + superjson: + specifier: 2.2.1 + version: 2.2.1 + devDependencies: + '@homarr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@homarr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^9.10.0 + version: 9.10.0 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + + packages/old-schema: + dependencies: + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@homarr/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../tooling/eslint + '@homarr/prettier-config': + specifier: workspace:^0.1.0 + version: link:../../tooling/prettier + '@homarr/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../tooling/typescript + eslint: + specifier: ^9.10.0 + version: 9.10.0 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + packages/ping: dependencies: '@homarr/common': @@ -1289,12 +1360,18 @@ importers: '@homarr/definitions': specifier: workspace:^0.1.0 version: link:../definitions + '@homarr/old-schema': + specifier: workspace:^0.1.0 + version: link:../old-schema '@homarr/translation': specifier: workspace:^0.1.0 version: link:../translation zod: specifier: ^3.23.8 version: 3.23.8 + zod-form-data: + specifier: ^2.0.2 + version: 2.0.2(zod@3.23.8) devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -7488,6 +7565,11 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-form-data@2.0.2: + resolution: {integrity: sha512-sKTi+k0fvkxdakD0V5rq+9WVJA3cuTQUfEmNqvHrTzPLvjfLmkkBLfR0ed3qOi9MScJXTHIDH/jUNnEJ3CBX4g==} + peerDependencies: + zod: '>= 3.11.0' + zod-to-json-schema@3.23.0: resolution: {integrity: sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag==} peerDependencies: @@ -14192,6 +14274,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.5.2 + zod-form-data@2.0.2(zod@3.23.8): + dependencies: + zod: 3.23.8 + zod-to-json-schema@3.23.0(zod@3.23.8): dependencies: zod: 3.23.8 diff --git a/turbo/generators/templates/package.json.hbs b/turbo/generators/templates/package.json.hbs index cd72632c4..f49557ca7 100644 --- a/turbo/generators/templates/package.json.hbs +++ b/turbo/generators/templates/package.json.hbs @@ -24,7 +24,7 @@ "@homarr/eslint-config": "workspace:^0.2.0", "@homarr/prettier-config": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0", - "eslint": "^9.8.0", + "eslint": "^9.9.1", "typescript": "^5.5.4" }, "prettier": "@homarr/prettier-config"