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:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
189
apps/nextjs/src/components/manage/boards/import-board-modal.tsx
Normal file
189
apps/nextjs/src/components/manage/boards/import-board-modal.tsx
Normal 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",
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = []) => {
|
||||
|
||||
9
packages/old-import/eslint.config.js
Normal file
9
packages/old-import/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
1
packages/old-import/index.ts
Normal file
1
packages/old-import/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
40
packages/old-import/package.json
Normal file
40
packages/old-import/package.json
Normal 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"
|
||||
}
|
||||
49
packages/old-import/src/fix-section-issues.ts
Normal file
49
packages/old-import/src/fix-section-issues.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
59
packages/old-import/src/import-apps.ts
Normal file
59
packages/old-import/src/import-apps.ts
Normal 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;
|
||||
};
|
||||
35
packages/old-import/src/import-board.ts
Normal file
35
packages/old-import/src/import-board.ts
Normal 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;
|
||||
};
|
||||
16
packages/old-import/src/import-error.ts
Normal file
16
packages/old-import/src/import-error.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
98
packages/old-import/src/import-items.ts
Normal file
98
packages/old-import/src/import-items.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
51
packages/old-import/src/import-sections.ts
Normal file
51
packages/old-import/src/import-sections.ts
Normal 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;
|
||||
};
|
||||
47
packages/old-import/src/index.ts
Normal file
47
packages/old-import/src/index.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
48
packages/old-import/src/mappers/map-colors.ts
Normal file
48
packages/old-import/src/mappers/map-colors.ts
Normal 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",
|
||||
};
|
||||
15
packages/old-import/src/mappers/map-column-count.ts
Normal file
15
packages/old-import/src/mappers/map-column-count.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
300
packages/old-import/src/move-widgets-and-apps-merge.ts
Normal file
300
packages/old-import/src/move-widgets-and-apps-merge.ts
Normal 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;
|
||||
};
|
||||
18
packages/old-import/src/widgets/definitions/bookmark.ts
Normal file
18
packages/old-import/src/widgets/definitions/bookmark.ts
Normal 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";
|
||||
}
|
||||
>;
|
||||
11
packages/old-import/src/widgets/definitions/calendar.ts
Normal file
11
packages/old-import/src/widgets/definitions/calendar.ts
Normal 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";
|
||||
}
|
||||
>;
|
||||
9
packages/old-import/src/widgets/definitions/common.ts
Normal file
9
packages/old-import/src/widgets/definitions/common.ts
Normal 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;
|
||||
}
|
||||
53
packages/old-import/src/widgets/definitions/dashdot.ts
Normal file
53
packages/old-import/src/widgets/definitions/dashdot.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
)[];
|
||||
}
|
||||
>;
|
||||
21
packages/old-import/src/widgets/definitions/date.ts
Normal file
21
packages/old-import/src/widgets/definitions/date.ts
Normal 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";
|
||||
}
|
||||
>;
|
||||
4
packages/old-import/src/widgets/definitions/dlspeed.ts
Normal file
4
packages/old-import/src/widgets/definitions/dlspeed.ts
Normal 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", {}>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrDnsHoleControlsDefinition = CommonOldmarrWidgetDefinition<
|
||||
"dns-hole-controls",
|
||||
{
|
||||
showToggleAllButtons: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrDnsHoleSummaryDefinition = CommonOldmarrWidgetDefinition<
|
||||
"dns-hole-summary",
|
||||
{ usePiHoleColors: boolean; layout: "column" | "row" | "grid" }
|
||||
>;
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
16
packages/old-import/src/widgets/definitions/iframe.ts
Normal file
16
packages/old-import/src/widgets/definitions/iframe.ts
Normal 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;
|
||||
}
|
||||
>;
|
||||
75
packages/old-import/src/widgets/definitions/index.ts
Normal file
75
packages/old-import/src/widgets/definitions/index.ts
Normal 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];
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrIndexerManagerDefinition = CommonOldmarrWidgetDefinition<
|
||||
"indexer-manager",
|
||||
{
|
||||
openIndexerSiteInNewTab: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrMediaRequestListDefinition = CommonOldmarrWidgetDefinition<
|
||||
"media-requests-list",
|
||||
{
|
||||
replaceLinksWithExternalHost: boolean;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrMediaRequestStatsDefinition = CommonOldmarrWidgetDefinition<
|
||||
"media-requests-stats",
|
||||
{
|
||||
replaceLinksWithExternalHost: boolean;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -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", {}>;
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
10
packages/old-import/src/widgets/definitions/notebook.ts
Normal file
10
packages/old-import/src/widgets/definitions/notebook.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrNotebookDefinition = CommonOldmarrWidgetDefinition<
|
||||
"notebook",
|
||||
{
|
||||
showToolbar: boolean;
|
||||
allowReadOnlyCheck: boolean;
|
||||
content: string;
|
||||
}
|
||||
>;
|
||||
14
packages/old-import/src/widgets/definitions/rss.ts
Normal file
14
packages/old-import/src/widgets/definitions/rss.ts
Normal 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;
|
||||
}
|
||||
>;
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrSmartHomeTriggerAutomationDefinition = CommonOldmarrWidgetDefinition<
|
||||
"smart-home/trigger-automation",
|
||||
{
|
||||
automationId: string;
|
||||
displayName: string;
|
||||
}
|
||||
>;
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
4
packages/old-import/src/widgets/definitions/usenet.ts
Normal file
4
packages/old-import/src/widgets/definitions/usenet.ts
Normal 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", {}>;
|
||||
11
packages/old-import/src/widgets/definitions/video-stream.ts
Normal file
11
packages/old-import/src/widgets/definitions/video-stream.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrVideoStreamDefinition = CommonOldmarrWidgetDefinition<
|
||||
"video-stream",
|
||||
{
|
||||
FeedUrl: string;
|
||||
autoPlay: boolean;
|
||||
muted: boolean;
|
||||
controls: boolean;
|
||||
}
|
||||
>;
|
||||
16
packages/old-import/src/widgets/definitions/weather.ts
Normal file
16
packages/old-import/src/widgets/definitions/weather.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
>;
|
||||
121
packages/old-import/src/widgets/options.ts
Normal file
121
packages/old-import/src/widgets/options.ts
Normal 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"];
|
||||
};
|
||||
8
packages/old-import/tsconfig.json
Normal file
8
packages/old-import/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
9
packages/old-schema/eslint.config.js
Normal file
9
packages/old-schema/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
1
packages/old-schema/index.ts
Normal file
1
packages/old-schema/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
34
packages/old-schema/package.json
Normal file
34
packages/old-schema/package.json
Normal 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"
|
||||
}
|
||||
77
packages/old-schema/src/app.ts
Normal file
77
packages/old-schema/src/app.ts
Normal 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>;
|
||||
30
packages/old-schema/src/config.ts
Normal file
30
packages/old-schema/src/config.ts
Normal 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>;
|
||||
5
packages/old-schema/src/index.ts
Normal file
5
packages/old-schema/src/index.ts
Normal 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";
|
||||
75
packages/old-schema/src/setting.ts
Normal file
75
packages/old-schema/src/setting.ts
Normal 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,
|
||||
});
|
||||
55
packages/old-schema/src/tile.ts
Normal file
55
packages/old-schema/src/tile.ts
Normal 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,
|
||||
});
|
||||
40
packages/old-schema/src/widget.ts
Normal file
40
packages/old-schema/src/widget.ts
Normal 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>;
|
||||
8
packages/old-schema/tsconfig.json
Normal file
8
packages/old-schema/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
17
packages/ui/src/components/beta-badge.tsx
Normal file
17
packages/ui/src/components/beta-badge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
86
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user