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<string, never> 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
This commit is contained in:
Meier Lukas
2024-09-07 18:13:24 +02:00
committed by GitHub
parent fc1bff2110
commit 5404cebf5b
65 changed files with 2132 additions and 34 deletions

View File

@@ -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",

View File

@@ -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<AppRouter>({
client: wsClient,
transformer: superjson,
});
return link(args)({ op, next });
}
return unstable_httpBatchStreamLink({
splitLink({
condition: ({ type }) => type === "subscription",
true: wsLink<AppRouter>({
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 });
};
},
}),
}),
}),
],
});
});

View File

@@ -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 = (
<>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick} loading={isPending}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
<Group>
{t("board.action.oldImport.label")}
<BetaBadge size="xs" />
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
return (
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
{t("management.page.board.action.new.label")}
</MobileAffixButton>
<>
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
<Button.Group>{buttonGroupContent}</Button.Group>
</Affix>
</>
);
};

View File

@@ -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<InnerProps>(({ 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 (
<form
onSubmit={form.onSubmit((values) => {
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,
});
})}
>
<Stack>
<FileInput
rightSection={<IconFileUpload />}
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")}
/>
<Fieldset legend={tOldImport("form.apps.label")}>
<Grid>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Switch
label={tOldImport("form.apps.avoidDuplicates.label")}
description={tOldImport("form.apps.avoidDuplicates.description")}
{...form.getInputProps("configuration.distinctAppsByHref", { type: "checkbox" })}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Switch
label={tOldImport("form.apps.onlyImportApps.label")}
description={tOldImport("form.apps.onlyImportApps.description")}
{...form.getInputProps("configuration.onlyImportApps", { type: "checkbox" })}
/>
</Grid.Col>
</Grid>
</Fieldset>
<TextInput withAsterisk label={tOldImport("form.name.label")} {...form.getInputProps("configuration.name")} />
<Radio.Group
withAsterisk
label={tOldImport("form.screenSize.label")}
{...form.getInputProps("configuration.screenSize")}
>
<Group mt="xs">
<Radio value="sm" label={tOldImport("form.screenSize.option.sm")} />
<Radio value="md" label={tOldImport("form.screenSize.option.md")} />
<Radio value="lg" label={tOldImport("form.screenSize.option.lg")} />
</Group>
</Radio.Group>
<SelectWithDescription
withAsterisk
label={tOldImport("form.sidebarBehavior.label")}
description={tOldImport("form.sidebarBehavior.description")}
data={[
{
value: "last-section",
label: tOldImport("form.sidebarBehavior.option.lastSection.label"),
description: tOldImport("form.sidebarBehavior.option.lastSection.description"),
},
{
value: "remove-items",
label: tOldImport("form.sidebarBehavior.option.removeItems.label"),
description: tOldImport("form.sidebarBehavior.option.removeItems.description"),
},
]}
{...form.getInputProps("configuration.sidebarBehaviour")}
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{tCommon("action.import")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("board.action.oldImport.label"),
size: "lg",
});

View File

@@ -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",

View File

@@ -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[] = []) => {

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -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"
}

View File

@@ -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,
};
};

View File

@@ -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<typeof appsTable>,
);
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;
};

View File

@@ -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;
};

View File

@@ -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}`);
}
}

View File

@@ -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<string, string>,
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}`);
}
};

View File

@@ -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<string, string>([...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;
};

View File

@@ -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);
});
};

View File

@@ -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",
};

View File

@@ -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;
}
};

View File

@@ -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<string, { apps: OldmarrApp[]; widgets: OldmarrWidget[] }>(
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;
};

View File

@@ -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";
}
>;

View File

@@ -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";
}
>;

View File

@@ -0,0 +1,9 @@
import type { OldmarrWidgetKind } from "@homarr/old-schema";
export interface CommonOldmarrWidgetDefinition<
TId extends OldmarrWidgetKind,
TOptions extends Record<string, unknown>,
> {
id: TId;
options: TOptions;
}

View File

@@ -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;
};
}
)[];
}
>;

View File

@@ -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";
}
>;

View File

@@ -0,0 +1,4 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type OldmarrDlspeedDefinition = CommonOldmarrWidgetDefinition<"dlspeed", {}>;

View File

@@ -0,0 +1,8 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrDnsHoleControlsDefinition = CommonOldmarrWidgetDefinition<
"dns-hole-controls",
{
showToggleAllButtons: boolean;
}
>;

View File

@@ -0,0 +1,6 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrDnsHoleSummaryDefinition = CommonOldmarrWidgetDefinition<
"dns-hole-summary",
{ usePiHoleColors: boolean; layout: "column" | "row" | "grid" }
>;

View File

@@ -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;
}
>;

View File

@@ -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;
}
>;

View File

@@ -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<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
// 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];

View File

@@ -0,0 +1,8 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrIndexerManagerDefinition = CommonOldmarrWidgetDefinition<
"indexer-manager",
{
openIndexerSiteInNewTab: boolean;
}
>;

View File

@@ -0,0 +1,9 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrMediaRequestListDefinition = CommonOldmarrWidgetDefinition<
"media-requests-list",
{
replaceLinksWithExternalHost: boolean;
openInNewTab: boolean;
}
>;

View File

@@ -0,0 +1,9 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrMediaRequestStatsDefinition = CommonOldmarrWidgetDefinition<
"media-requests-stats",
{
replaceLinksWithExternalHost: boolean;
openInNewTab: boolean;
}
>;

View File

@@ -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", {}>;

View File

@@ -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;
}
>;

View File

@@ -0,0 +1,10 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrNotebookDefinition = CommonOldmarrWidgetDefinition<
"notebook",
{
showToolbar: boolean;
allowReadOnlyCheck: boolean;
content: string;
}
>;

View File

@@ -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;
}
>;

View File

@@ -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;
}
>;

View File

@@ -0,0 +1,9 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrSmartHomeTriggerAutomationDefinition = CommonOldmarrWidgetDefinition<
"smart-home/trigger-automation",
{
automationId: string;
displayName: string;
}
>;

View File

@@ -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;
}
>;

View File

@@ -0,0 +1,4 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type OldmarrUsenetDefinition = CommonOldmarrWidgetDefinition<"usenet", {}>;

View File

@@ -0,0 +1,11 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrVideoStreamDefinition = CommonOldmarrWidgetDefinition<
"video-stream",
{
FeedUrl: string;
autoPlay: boolean;
muted: boolean;
controls: boolean;
}
>;

View File

@@ -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;
};
}
>;

View File

@@ -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<WidgetKey>["options"]]: (
oldOptions: Extract<OldmarrWidgetDefinitions, { id: WidgetMapping[WidgetKey] }>["options"],
) => WidgetComponentProps<WidgetKey>["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 = <K extends WidgetKind>(
kind: K,
oldOptions: Extract<OldmarrWidgetDefinitions, { id: WidgetMapping[K] }>["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<string, unknown>,
) as WidgetComponentProps<K>["options"];
};

View File

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

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -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"
}

View File

@@ -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<typeof oldmarrAppSchema>;

View File

@@ -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<typeof oldmarrConfigSchema>;

View File

@@ -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";

View File

@@ -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,
});

View File

@@ -0,0 +1,55 @@
import { z } from "zod";
const createAreaSchema = <TType extends string, TPropertiesSchema extends z.AnyZodObject>(
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,
});

View File

@@ -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<typeof oldmarrWidgetSchema>;

View File

@@ -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"]
}

View File

@@ -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: {

View File

@@ -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 (
<Badge size={size} color="green" variant="outline">
{t("common.beta")}
</Badge>
);
};

View File

@@ -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";

View File

@@ -13,6 +13,8 @@ interface BaseSelectItem {
export interface SelectWithCustomItemsProps<TSelectItem extends BaseSelectItem>
extends Pick<SelectProps, "label" | "error" | "defaultValue" | "value" | "onChange" | "placeholder"> {
data: TSelectItem[];
description?: string;
withAsterisk?: boolean;
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
}

View File

@@ -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",

View File

@@ -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<ReturnType<typeof createOldmarrImportConfigurationSchema>>;
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,
};

View File

@@ -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<CustomErrorKey>;
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<TKey extends CustomErrorKey> {
i18n: {
key: keyof TranslationObject["common"]["zod"]["errors"]["custom"];
params?: Record<string, unknown>;
key: TKey;
params: ParamsObject<TranslationObject["common"]["zod"]["errors"]["custom"][TKey]>;
};
}
export const createCustomErrorParams = (i18n: CustomErrorParams["i18n"] | CustomErrorParams["i18n"]["key"]) =>
typeof i18n === "string" ? { i18n: { key: i18n } } : { i18n };
export const createCustomErrorParams = <TKey extends CustomErrorKey>(
i18n: keyof CustomErrorParams<TKey>["i18n"]["params"] extends never
? CustomErrorParams<TKey>["i18n"]["key"]
: CustomErrorParams<TKey>["i18n"],
) => (typeof i18n === "string" ? { i18n: { key: i18n, params: {} } } : { i18n });

View File

@@ -26,3 +26,5 @@ export {
type BoardItemAdvancedOptions,
} from "./shared";
export { passwordRequirements } from "./user";
export { createOldmarrImportConfigurationSchema, superRefineJsonImportFile } from "./board";
export type { OldmarrImportConfiguration } from "./board";

86
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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"