feat: add onboarding with oldmarr import (#1606)

This commit is contained in:
Meier Lukas
2024-12-15 15:40:26 +01:00
committed by GitHub
parent 82ec77d2da
commit 6de74d9525
108 changed files with 6045 additions and 312 deletions

View File

@@ -0,0 +1,64 @@
import AdmZip from "adm-zip";
import { z } from "zod";
import { logger } from "@homarr/log";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import { oldmarrImportUserSchema } from "../user-schema";
import type { analyseOldmarrImportInputSchema } from "./input";
export const analyseOldmarrImportForRouterAsync = async (input: z.infer<typeof analyseOldmarrImportInputSchema>) => {
const { configs, checksum, users } = await analyseOldmarrImportAsync(input.file);
return {
configs,
checksum,
userCount: users.length,
};
};
export const analyseOldmarrImportAsync = async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const zip = new AdmZip(Buffer.from(arrayBuffer));
const entries = zip.getEntries();
const configEntries = entries.filter((entry) => entry.entryName.endsWith(".json") && !entry.entryName.includes("/"));
const configs = configEntries.map((entry) => {
const result = oldmarrConfigSchema.safeParse(JSON.parse(entry.getData().toString()));
if (!result.success) {
logger.error(`Failed to parse config ${entry.entryName} with error: ${JSON.stringify(result.error)}`);
}
return {
name: entry.name,
config: result.data ?? null,
isError: !result.success,
};
});
const userEntry = entries.find((entry) => entry.entryName === "users/users.json");
const users = parseUsers(userEntry);
const checksum = entries
.find((entry) => entry.entryName === "checksum.txt")
?.getData()
.toString("utf-8");
return {
configs,
users,
checksum,
};
};
export type AnalyseResult = Awaited<ReturnType<typeof analyseOldmarrImportForRouterAsync>>;
const parseUsers = (entry: AdmZip.IZipEntry | undefined) => {
if (!entry) return [];
const result = z.array(oldmarrImportUserSchema).safeParse(JSON.parse(entry.getData().toString()));
if (!result.success) {
logger.error(`Failed to parse users with error: ${JSON.stringify(result.error)}`);
}
return result.data ?? [];
};

View File

@@ -0,0 +1,2 @@
export * from "./input";
export { analyseOldmarrImportForRouterAsync } from "./analyse-oldmarr-import";

View File

@@ -0,0 +1,5 @@
import { zfd } from "zod-form-data";
export const analyseOldmarrImportInputSchema = zfd.formData({
file: zfd.file(),
});

View File

@@ -0,0 +1,7 @@
import type { Modify } from "@homarr/common/types";
import type { OldmarrConfig } from "@homarr/old-schema";
import type { AnalyseResult } from "./analyse-oldmarr-import";
export type AnalyseConfig = AnalyseResult["configs"][number];
export type ValidAnalyseConfig = Modify<AnalyseConfig, { config: OldmarrConfig }>;

View File

@@ -0,0 +1,3 @@
export { InitialOldmarrImport } from "./initial-oldmarr-import";
export { SidebarBehaviourSelect } from "./shared/sidebar-behaviour-select";
export { OldmarrImportAppsSettings } from "./shared/apps-section";

View File

@@ -0,0 +1,112 @@
import { useMemo, useState } from "react";
import { Stack } from "@mantine/core";
import SuperJSON from "superjson";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useModalAction } from "@homarr/modals";
import { boardSizes } from "@homarr/old-schema";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
import { clientApi } from "../../../api/src/client";
import type { AnalyseResult } from "../analyse/analyse-oldmarr-import";
import { prepareMultipleImports } from "../prepare/prepare-multiple";
import type { InitialOldmarrImportSettings } from "../settings";
import { defaultSidebarBehaviour } from "../settings";
import type { BoardSelectionMap, BoardSizeRecord } from "./initial/board-selection-card";
import { BoardSelectionCard } from "./initial/board-selection-card";
import { ImportSettingsCard } from "./initial/import-settings-card";
import { ImportSummaryCard } from "./initial/import-summary-card";
import { ImportTokenModal } from "./initial/token-modal";
interface InitialOldmarrImportProps {
file: File;
analyseResult: AnalyseResult;
}
export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImportProps) => {
const [boardSelections, setBoardSelections] = useState<BoardSelectionMap>(
new Map(createDefaultSelections(analyseResult.configs)),
);
const [settings, setSettings] = useState<InitialOldmarrImportSettings>({
onlyImportApps: false,
sidebarBehaviour: defaultSidebarBehaviour,
});
const { preparedApps, preparedBoards, preparedIntegrations } = useMemo(
() => prepareMultipleImports(analyseResult.configs, settings, boardSelections),
[analyseResult, boardSelections, settings],
);
const { mutateAsync, isPending } = clientApi.import.importInitialOldmarrImport.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const { openModal } = useModalAction(ImportTokenModal);
const createFormData = (token: string | null) => {
const formData = new FormData();
formData.set("file", file);
formData.set("settings", JSON.stringify(settings));
// Map can not be send over the wire without superjson
formData.set("boardSelections", SuperJSON.stringify(boardSelections));
if (token) {
formData.set("token", token);
}
return formData;
};
const handleSubmitAsync = async () => {
if (analyseResult.checksum) {
openModal({
checksum: analyseResult.checksum,
onSuccessAsync: async (token) => {
await mutateAsync(createFormData(token));
},
});
return;
}
await mutateAsync(createFormData(null));
};
return (
<Stack mb="sm">
<ImportSettingsCard
settings={settings}
updateSetting={(setting, value) => {
setSettings((settings) => ({ ...settings, [setting]: value }));
}}
/>
{settings.onlyImportApps ? null : (
<BoardSelectionCard selections={boardSelections} updateSelections={setBoardSelections} />
)}
<ImportSummaryCard
counts={{
apps: preparedApps.length,
boards: preparedBoards.length,
integrations: preparedIntegrations.length,
credentialUsers: analyseResult.userCount,
}}
onSubmit={handleSubmitAsync}
loading={isPending}
/>
</Stack>
);
};
const createDefaultSelections = (configs: AnalyseResult["configs"]) => {
return configs
.map(({ name, config }) => {
if (!config) return null;
const shapes = config.apps.flatMap((app) => app.shape).concat(config.widgets.flatMap((widget) => widget.shape));
const boardSizeRecord = boardSizes.reduce<BoardSizeRecord>((acc, size) => {
const allInclude = shapes.every((shape) => Boolean(shape[size]));
acc[size] = allInclude ? true : null;
return acc;
}, {} as BoardSizeRecord);
return [name, boardSizeRecord];
})
.filter((selection): selection is [string, BoardSizeRecord] => Boolean(selection));
};

View File

@@ -0,0 +1,156 @@
import type { ChangeEvent } from "react";
import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core";
import { objectEntries, objectKeys } from "@homarr/common";
import { boardSizes } from "@homarr/old-schema";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
type BoardSize = (typeof boardSizes)[number];
export type BoardSizeRecord = Record<BoardSize, boolean | null>;
export type BoardSelectionMap = Map<string, BoardSizeRecord>;
interface BoardSelectionCardProps {
selections: BoardSelectionMap;
updateSelections: (callback: (selections: BoardSelectionMap) => BoardSelectionMap) => void;
}
const allChecked = (map: BoardSelectionMap) => {
return [...map.values()].every((selection) => groupChecked(selection));
};
const groupChecked = (selection: BoardSizeRecord) =>
objectEntries(selection).every(([_, value]) => value === true || value === null);
export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => {
const tBoardSelection = useScopedI18n("init.step.import.boardSelection");
const t = useI18n();
const areAllChecked = allChecked(selections);
const handleToggleAll = () => {
updateSelections((selections) => {
const updated = new Map(selections);
[...selections.entries()].forEach(([name, selection]) => {
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = !areAllChecked;
});
updated.set(name, selection);
});
return updated;
});
};
const registerToggleGroup = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
objectKeys(selection).forEach((size) => {
if (selection[size] === null) return;
selection[size] = event.target.checked;
});
updated.set(name, selection);
return updated;
});
};
const registerToggle = (name: string, size: BoardSize) => (event: ChangeEvent<HTMLInputElement>) => {
updateSelections((selections) => {
const updated = new Map(selections);
const selection = selections.get(name);
if (!selection) return updated;
selection[size] = event.target.checked;
updated.set(name, selection);
return updated;
});
};
if (selections.size === 0) {
return null;
}
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Group justify="space-between" align="center">
<Text fw={500}>{tBoardSelection("title", { count: selections.size })}</Text>
<Anchor component="button" onClick={handleToggleAll}>
{areAllChecked ? tBoardSelection("action.unselectAll") : tBoardSelection("action.selectAll")}
</Anchor>
</Group>
<Text size="sm" c="gray.6">
{tBoardSelection("description")}
</Text>
<Text size="xs" c="gray.6">
{t("board.action.oldImport.form.screenSize.description")}
</Text>
</Stack>
<Stack gap="sm">
{[...selections.entries()].map(([name, selection]) => (
<Card key={name} withBorder>
<Group justify="space-between" align="center" visibleFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Group>
{boardSizes.map((size) => (
<Checkbox
key={size}
disabled={selection[size] === null}
checked={selection[size] ?? undefined}
onChange={registerToggle(name, size)}
label={t(`board.action.oldImport.form.screenSize.option.${size}`)}
/>
))}
</Group>
</Group>
<Stack hiddenFrom="md">
<Checkbox
checked={groupChecked(selection)}
onChange={registerToggleGroup(name)}
label={
<Text fw={500} size="sm">
{name}
</Text>
}
/>
<Stack gap="sm" ps="sm">
{objectEntries(selection)
.filter(([_, value]) => value !== null)
.map(([size, value]) => (
<Checkbox
key={size}
checked={value ?? undefined}
onChange={registerToggle(name, size)}
label={`screenSize.${size}`}
/>
))}
</Stack>
</Stack>
</Card>
))}
</Stack>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { Card, Stack, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { InitialOldmarrImportSettings } from "../../settings";
import { OldmarrImportAppsSettings } from "../shared/apps-section";
import { SidebarBehaviourSelect } from "../shared/sidebar-behaviour-select";
interface ImportSettingsCardProps {
settings: InitialOldmarrImportSettings;
updateSetting: <TKey extends keyof InitialOldmarrImportSettings>(
setting: TKey,
value: InitialOldmarrImportSettings[TKey],
) => void;
}
export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => {
const tImportSettings = useScopedI18n("init.step.import.importSettings");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Text fw={500}>{tImportSettings("title")}</Text>
<Text size="sm" c="gray.6">
{tImportSettings("description")}
</Text>
</Stack>
<OldmarrImportAppsSettings
background="transparent"
onlyImportApps={{
checked: settings.onlyImportApps,
onChange: (event) => updateSetting("onlyImportApps", event.target.checked),
}}
/>
<SidebarBehaviourSelect
value={settings.sidebarBehaviour}
onChange={(value) => updateSetting("sidebarBehaviour", value)}
/>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,44 @@
import { Button, Card, Group, Stack, Text } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportSummaryCardProps {
counts: { apps: number; boards: number; integrations: number; credentialUsers: number };
loading: boolean;
onSubmit: () => MaybePromise<void>;
}
export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => {
const tSummary = useScopedI18n("init.step.import.summary");
return (
<Card w={64 * 12 + 8} maw="90vw">
<Stack gap="sm">
<Stack gap={0}>
<Text fw={500}>{tSummary("title")}</Text>
<Text size="sm" c="gray.6">
{tSummary("description")}
</Text>
</Stack>
<Stack gap="xs">
{objectEntries(counts).map(([key, count]) => (
<Card key={key} withBorder p="sm">
<Group justify="space-between" align="center">
<Text fw={500} size="sm">
{tSummary(`entities.${key}`)}
</Text>
<Text size="sm">{count}</Text>
</Group>
</Card>
))}
</Stack>
<Button onClick={onSubmit} loading={loading}>
{tSummary("action.import")}
</Button>
</Stack>
</Card>
);
};

View File

@@ -0,0 +1,67 @@
import { Button, Group, PasswordInput, Stack } from "@mantine/core";
import { z } from "zod";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
// We don't have access to the API client here, so we need to import it from the API package
// In the future we should consider having the used router also in this package
import { clientApi } from "../../../../api/src/client";
interface InnerProps {
checksum: string;
onSuccessAsync: (token: string) => Promise<void>;
}
const formSchema = z.object({
token: z.string().min(1).max(256),
});
export const ImportTokenModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const tTokenModal = useScopedI18n("init.step.import.tokenModal");
const { mutate, isPending } = clientApi.import.validateToken.useMutation();
const form = useZodForm(formSchema, { initialValues: { token: "" } });
const handleSubmit = (values: z.infer<typeof formSchema>) => {
mutate(
{ checksum: innerProps.checksum, token: values.token },
{
async onSuccess(isValid) {
if (isValid) {
actions.closeModal();
await innerProps.onSuccessAsync(values.token);
} else {
showErrorNotification({
title: tTokenModal("notification.error.title"),
message: tTokenModal("notification.error.message"),
});
}
},
},
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<PasswordInput
{...form.getInputProps("token")}
label={tTokenModal("field.token.label")}
description={tTokenModal("field.token.description")}
withAsterisk
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.confirm")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({ defaultTitle: (t) => t("init.step.import.tokenModal.title") });

View File

@@ -0,0 +1,23 @@
import { Fieldset, Switch } from "@mantine/core";
import type { CheckboxProps } from "@homarr/form/types";
import { useScopedI18n } from "@homarr/translation/client";
interface OldmarrImportAppsSettingsProps {
onlyImportApps: CheckboxProps;
background?: string;
}
export const OldmarrImportAppsSettings = ({ background, onlyImportApps }: OldmarrImportAppsSettingsProps) => {
const tApps = useScopedI18n("board.action.oldImport.form.apps");
return (
<Fieldset legend={tApps("label")} bg={background}>
<Switch
{...onlyImportApps}
label={tApps("onlyImportApps.label")}
description={tApps("onlyImportApps.description")}
/>
</Fieldset>
);
};

View File

@@ -0,0 +1,31 @@
import type { InputPropsFor } from "@homarr/form/types";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithDescription } from "@homarr/ui";
import type { SidebarBehaviour } from "../../settings";
export const SidebarBehaviourSelect = (props: InputPropsFor<SidebarBehaviour, SidebarBehaviour, HTMLButtonElement>) => {
const tSidebarBehaviour = useScopedI18n("board.action.oldImport.form.sidebarBehavior");
return (
<SelectWithDescription
withAsterisk
label={tSidebarBehaviour("label")}
description={tSidebarBehaviour("description")}
data={[
{
value: "last-section",
label: tSidebarBehaviour("option.lastSection.label"),
description: tSidebarBehaviour("option.lastSection.description"),
},
{
value: "remove-items",
label: tSidebarBehaviour("option.removeItems.label"),
description: tSidebarBehaviour("option.removeItems.description"),
},
]}
{...props}
onChange={(value) => (value ? props.onChange(value as SidebarBehaviour) : null)}
/>
);
};

View File

@@ -3,10 +3,10 @@ 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";
import type { OldmarrImportConfiguration } from "./settings";
export const insertBoardAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
logger.info(`Importing old homarr board configuration=${old.configProperties.name}`);

View File

@@ -1,5 +1,6 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import type { OldmarrImportConfiguration } from "./settings";
export class OldHomarrImportError extends Error {
constructor(oldConfig: OldmarrConfig, cause: unknown) {

View File

@@ -5,10 +5,10 @@ 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 type { OldmarrImportConfiguration } from "./settings";
import { mapKind } from "./widgets/definitions";
import { mapOptions } from "./widgets/options";

View File

@@ -0,0 +1,78 @@
import { createId } from "@homarr/db";
import { logger } from "@homarr/log";
import { fixSectionIssues } from "../../fix-section-issues";
import { mapBoard } from "../../mappers/map-board";
import { moveWidgetsAndAppsIfMerge } from "../../move-widgets-and-apps-merge";
import { prepareItems } from "../../prepare/prepare-items";
import type { prepareMultipleImports } from "../../prepare/prepare-multiple";
import { prepareSections } from "../../prepare/prepare-sections";
import type { InitialOldmarrImportSettings } from "../../settings";
import { createDbInsertCollection } from "./common";
export const createBoardInsertCollection = (
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
settings: InitialOldmarrImportSettings,
) => {
const insertCollection = createDbInsertCollection(["apps", "boards", "sections", "items"]);
logger.info("Preparing boards for insert collection");
const appsMap = new Map(
preparedApps.flatMap(({ ids, ...app }) => {
const id = app.existingId ?? createId();
return ids.map((oldId) => [oldId, { id, ...app }] as const);
}),
);
for (const app of appsMap.values()) {
// Skip duplicate apps
if (insertCollection.apps.some((appEntry) => appEntry.id === app.id)) {
continue;
}
// Skip apps that already exist in the database
if (app.existingId) {
continue;
}
insertCollection.apps.push(app);
}
if (settings.onlyImportApps) {
logger.info(
`Skipping boards and sections import due to onlyImportApps setting appCount=${insertCollection.apps.length}`,
);
return insertCollection;
}
logger.debug(`Added apps to board insert collection count=${insertCollection.apps.length}`);
preparedBoards.forEach((board) => {
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config);
const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, {
...settings,
screenSize: board.size,
name: board.name,
});
logger.debug(`Fixed issues with sections and item positions fileName=${board.name}`);
const mappedBoard = mapBoard(board);
logger.debug(`Mapped board fileName=${board.name} boardId=${mappedBoard.id}`);
insertCollection.boards.push(mappedBoard);
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
for (const section of preparedSections.values()) {
insertCollection.sections.push(section);
}
logger.debug(`Added sections to board insert collection count=${insertCollection.sections.length}`);
const preparedItems = prepareItems({ apps, widgets }, board.size, appsMap, preparedSections);
preparedItems.forEach((item) => insertCollection.items.push(item));
logger.debug(`Added items to board insert collection count=${insertCollection.items.length}`);
});
logger.info(
`Board collection prepared boardCount=${insertCollection.boards.length} sectionCount=${insertCollection.sections.length} itemCount=${insertCollection.items.length} appCount=${insertCollection.apps.length}`,
);
return insertCollection;
};

View File

@@ -0,0 +1,33 @@
import { objectEntries } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { schema } from "@homarr/db";
type TableKey = {
[K in keyof typeof schema]: (typeof schema)[K] extends { _: { brand: "Table" } } ? K : never;
}[keyof typeof schema];
export const createDbInsertCollection = <TTableKey extends TableKey>(tablesInInsertOrder: TTableKey[]) => {
const context = tablesInInsertOrder.reduce(
(acc, key) => {
acc[key] = [];
return acc;
},
{} as { [K in TTableKey]: InferInsertModel<(typeof schema)[K]>[] },
);
return {
...context,
insertAll: (db: Database) => {
db.transaction((transaction) => {
for (const [key, values] of objectEntries(context)) {
if (values.length >= 1) {
transaction
.insert(schema[key])
.values(values as never)
.run();
}
}
});
},
};
};

View File

@@ -0,0 +1,47 @@
import { encryptSecret } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { mapAndDecryptIntegrations } from "../../mappers/map-integration";
import type { PreparedIntegration } from "../../prepare/prepare-integrations";
import { createDbInsertCollection } from "./common";
export const createIntegrationInsertCollection = (
preparedIntegrations: PreparedIntegration[],
encryptionToken: string | null,
) => {
const insertCollection = createDbInsertCollection(["integrations", "integrationSecrets"]);
logger.info(`Preparing integrations for insert collection count=${preparedIntegrations.length}`);
if (encryptionToken === null) {
logger.debug("Skipping integration decryption due to missing token");
return insertCollection;
}
const preparedIntegrationsDecrypted = mapAndDecryptIntegrations(preparedIntegrations, encryptionToken);
preparedIntegrationsDecrypted.forEach((integration) => {
insertCollection.integrations.push({
id: integration.id,
kind: integration.kind,
name: integration.name,
url: integration.url,
});
integration.secrets
.filter((secret) => secret.value !== null)
.forEach((secret) => {
insertCollection.integrationSecrets.push({
integrationId: integration.id,
kind: secret.field,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: encryptSecret(secret.value!),
});
});
});
logger.info(
`Added integrations and secrets to insert collection integrationCount=${insertCollection.integrations.length} secretCount=${insertCollection.integrationSecrets.length}`,
);
return insertCollection;
};

View File

@@ -0,0 +1,53 @@
import { createId } from "@homarr/db";
import { credentialsAdminGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { mapAndDecryptUsers } from "../../mappers/map-user";
import type { OldmarrImportUser } from "../../user-schema";
import { createDbInsertCollection } from "./common";
export const createUserInsertCollection = (importUsers: OldmarrImportUser[], encryptionToken: string | null) => {
const insertCollection = createDbInsertCollection(["users", "groups", "groupMembers", "groupPermissions"]);
logger.info(`Preparing users for insert collection count=${importUsers.length}`);
if (encryptionToken === null) {
logger.debug("Skipping user decryption due to missing token");
return insertCollection;
}
const preparedUsers = mapAndDecryptUsers(importUsers, encryptionToken);
preparedUsers.forEach((user) => insertCollection.users.push(user));
logger.debug(`Added users to insert collection count=${insertCollection.users.length}`);
if (!preparedUsers.some((user) => user.isAdmin)) {
logger.warn("No admin users found, skipping admin group creation");
return insertCollection;
}
const adminGroupId = createId();
insertCollection.groups.push({
id: adminGroupId,
name: credentialsAdminGroup,
});
insertCollection.groupPermissions.push({
groupId: adminGroupId,
permission: "admin",
});
const admins = preparedUsers.filter((user) => user.isAdmin);
admins.forEach((user) => {
insertCollection.groupMembers.push({
groupId: adminGroupId,
userId: user.id,
});
});
logger.info(
`Added admin group and permissions to insert collection adminGroupId=${adminGroupId} adminUsersCount=${admins.length}`,
);
return insertCollection;
};

View File

@@ -0,0 +1,45 @@
import type { z } from "zod";
import { Stopwatch } from "@homarr/common";
import type { Database } from "@homarr/db";
import { logger } from "@homarr/log";
import { analyseOldmarrImportAsync } from "../analyse/analyse-oldmarr-import";
import { prepareMultipleImports } from "../prepare/prepare-multiple";
import { createBoardInsertCollection } from "./collections/board-collection";
import { createIntegrationInsertCollection } from "./collections/integration-collection";
import { createUserInsertCollection } from "./collections/user-collection";
import type { importInitialOldmarrInputSchema } from "./input";
import { ensureValidTokenOrThrow } from "./validate-token";
export const importInitialOldmarrAsync = async (
db: Database,
input: z.infer<typeof importInitialOldmarrInputSchema>,
) => {
const stopwatch = new Stopwatch();
const { checksum, configs, users: importUsers } = await analyseOldmarrImportAsync(input.file);
ensureValidTokenOrThrow(checksum, input.token);
const { preparedApps, preparedBoards, preparedIntegrations } = prepareMultipleImports(
configs,
input.settings,
input.boardSelections,
);
logger.info("Preparing import data in insert collections for database");
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, input.settings);
const userInsertCollection = createUserInsertCollection(importUsers, input.token);
const integrationInsertCollection = createIntegrationInsertCollection(preparedIntegrations, input.token);
logger.info("Inserting import data to database");
// Due to a limitation with better-sqlite it's only possible to use it synchronously
db.transaction((transaction) => {
boardInsertCollection.insertAll(transaction);
userInsertCollection.insertAll(transaction);
integrationInsertCollection.insertAll(transaction);
});
logger.info(`Import successful (in ${stopwatch.getElapsedInHumanWords()})`);
};

View File

@@ -0,0 +1,36 @@
import { inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import type { OldmarrConfig } from "@homarr/old-schema";
import { doAppsMatch } from "../prepare/prepare-apps";
import { prepareSingleImport } from "../prepare/prepare-single";
import type { OldmarrImportConfiguration } from "../settings";
import { createBoardInsertCollection } from "./collections/board-collection";
export const importSingleOldmarrConfigAsync = async (
db: Database,
config: OldmarrConfig,
settings: OldmarrImportConfiguration,
) => {
const { preparedApps, preparedBoards } = prepareSingleImport(config, settings);
const existingApps = await db.query.apps.findMany({
where: inArray(
apps.href,
preparedApps.map((app) => app.href).filter((href) => href !== null),
),
});
preparedApps.forEach((app) => {
const existingApp = existingApps.find((existingApp) => doAppsMatch(existingApp, app));
if (existingApp) {
app.existingId = existingApp.id;
}
return app;
});
const boardInsertCollection = createBoardInsertCollection({ preparedApps, preparedBoards }, settings);
// Due to a limitation with better-sqlite it's only possible to use it synchronously
boardInsertCollection.insertAll(db);
};

View File

@@ -0,0 +1,3 @@
export { importInitialOldmarrAsync } from "./import-initial-oldmarr";
export * from "./input";
export { ensureValidTokenOrThrow } from "./validate-token";

View File

@@ -0,0 +1,24 @@
import SuperJSON from "superjson";
import { z } from "zod";
import { zfd } from "zod-form-data";
import { initialOldmarrImportSettings } from "../settings";
const boardSelectionMapSchema = z.map(
z.string(),
z.object({
sm: z.boolean().nullable(),
md: z.boolean().nullable(),
lg: z.boolean().nullable(),
}),
);
export const importInitialOldmarrInputSchema = zfd.formData({
file: zfd.file(),
settings: zfd.json(initialOldmarrImportSettings),
boardSelections: zfd.text().transform((value) => {
const map = boardSelectionMapSchema.parse(SuperJSON.parse(value));
return map;
}),
token: zfd.text().nullable(),
});

View File

@@ -0,0 +1,18 @@
import { decryptSecretWithKey } from "@homarr/common/server";
export const ensureValidTokenOrThrow = (checksum: string | undefined, encryptionToken: string | null) => {
if (!encryptionToken || !checksum) return;
const [first, second] = checksum.split("\n");
if (!first || !second) throw new Error("Malformed checksum");
const key = Buffer.from(encryptionToken, "hex");
let decrypted: string;
try {
decrypted = decryptSecretWithKey(second as `${string}.${string}`, key);
} catch {
throw new Error("Invalid checksum");
}
const isValid = decrypted === first;
if (!isValid) throw new Error("Invalid checksum");
};

View File

@@ -1,60 +1,13 @@
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";
import type { BookmarkApp } from "./widgets/definitions/bookmark";
import { importSingleOldmarrConfigAsync } from "./import/import-single-oldmarr";
import type { OldmarrImportConfiguration } from "./settings";
export const importAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => {
const bookmarkApps = old.widgets
.filter((widget) => widget.type === "bookmark")
.map((widget) => widget.properties.items)
.flat() as BookmarkApp[];
if (configuration.onlyImportApps) {
await db
.transaction(async (trasaction) => {
await insertAppsAsync(
trasaction,
old.apps,
bookmarkApps,
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 appsMap = await insertAppsAsync(
trasaction,
apps,
bookmarkApps,
configuration.distinctAppsByHref,
old.configProperties.name,
);
await insertItemsAsync(trasaction, widgets, apps, appsMap, sectionIdMaps, configuration);
})
.catch((error) => {
if (error instanceof OldHomarrScreenSizeError) {
throw error;
}
throw new OldHomarrImportError(old, error);
});
export const importOldmarrAsync = async (
db: Database,
old: OldmarrConfig,
configuration: OldmarrImportConfiguration,
) => {
await importSingleOldmarrConfigAsync(db, old, configuration);
};

View File

@@ -0,0 +1,27 @@
import type { InferSelectModel } from "@homarr/db";
import type { apps } from "@homarr/db/schema/sqlite";
import type { OldmarrApp } from "@homarr/old-schema";
import type { OldmarrBookmarkDefinition } from "../widgets/definitions/bookmark";
export const mapOldmarrApp = (app: OldmarrApp): InferSelectModel<typeof apps> => {
return {
id: app.id,
name: app.name,
iconUrl: app.appearance.iconUrl,
description: app.behaviour.tooltipDescription ?? null,
href: app.behaviour.externalUrl || app.url,
};
};
export const mapOldmarrBookmarkApp = (
app: OldmarrBookmarkDefinition["options"]["items"][number],
): InferSelectModel<typeof apps> => {
return {
id: app.id,
name: app.name,
iconUrl: app.iconUrl,
description: null,
href: app.href,
};
};

View File

@@ -0,0 +1,27 @@
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { boards } from "@homarr/db/schema/sqlite";
import type { prepareMultipleImports } from "../prepare/prepare-multiple";
import { mapColor } from "./map-colors";
import { mapColumnCount } from "./map-column-count";
type PreparedBoard = ReturnType<typeof prepareMultipleImports>["preparedBoards"][number];
export const mapBoard = (preparedBoard: PreparedBoard): InferInsertModel<typeof boards> => ({
id: createId(),
name: preparedBoard.name,
backgroundImageAttachment: preparedBoard.config.settings.customization.backgroundImageAttachment,
backgroundImageUrl: preparedBoard.config.settings.customization.backgroundImageUrl,
backgroundImageRepeat: preparedBoard.config.settings.customization.backgroundImageRepeat,
backgroundImageSize: preparedBoard.config.settings.customization.backgroundImageSize,
columnCount: mapColumnCount(preparedBoard.config, preparedBoard.size),
faviconImageUrl: preparedBoard.config.settings.customization.faviconUrl,
isPublic: preparedBoard.config.settings.access.allowGuests,
logoImageUrl: preparedBoard.config.settings.customization.logoImageUrl,
pageTitle: preparedBoard.config.settings.customization.pageTitle,
metaTitle: preparedBoard.config.settings.customization.metaTitle,
opacity: preparedBoard.config.settings.customization.appOpacity,
primaryColor: mapColor(preparedBoard.config.settings.customization.colors.primary, "#fa5252"),
secondaryColor: mapColor(preparedBoard.config.settings.customization.colors.secondary, "#fd7e14"),
});

View File

@@ -1,5 +1,6 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "@homarr/validation";
import type { OldmarrImportConfiguration } from "../settings";
export const mapColumnCount = (old: OldmarrConfig, screenSize: OldmarrImportConfiguration["screenSize"]) => {
switch (screenSize) {

View File

@@ -0,0 +1,60 @@
import { decryptSecretWithKey } from "@homarr/common/server";
import { createId } from "@homarr/db";
import type { IntegrationKind } from "@homarr/definitions";
import type { OldmarrIntegrationType } from "@homarr/old-schema";
import type { PreparedIntegration } from "../prepare/prepare-integrations";
export const mapIntegrationType = (type: OldmarrIntegrationType) => {
const kind = mapping[type];
if (!kind) {
throw new Error(`Integration type ${type} is not supported yet`);
}
return kind;
};
const mapping: Record<OldmarrIntegrationType, IntegrationKind | null> = {
adGuardHome: "adGuardHome",
deluge: "deluge",
homeAssistant: "homeAssistant",
jellyfin: "jellyfin",
jellyseerr: "jellyseerr",
lidarr: "lidarr",
nzbGet: "nzbGet",
openmediavault: "openmediavault",
overseerr: "overseerr",
pihole: "piHole",
prowlarr: "prowlarr",
proxmox: null,
qBittorrent: "qBittorrent",
radarr: "radarr",
readarr: "readarr",
sabnzbd: "sabNzbd",
sonarr: "sonarr",
tdarr: null,
transmission: "transmission",
plex: "plex",
};
export const mapAndDecryptIntegrations = (
preparedIntegrations: PreparedIntegration[],
encryptionToken: string | null,
) => {
if (encryptionToken === null) {
return [];
}
const key = Buffer.from(encryptionToken, "hex");
return preparedIntegrations.map(({ type, name, url, properties }) => ({
id: createId(),
name,
url,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
kind: mapIntegrationType(type!),
secrets: properties.map((property) => ({
...property,
value: property.value ? decryptSecretWithKey(property.value as `${string}.${string}`, key) : null,
})),
}));
};

View File

@@ -0,0 +1,89 @@
import SuperJSON from "superjson";
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { items } from "@homarr/db/schema/sqlite";
import { logger } from "@homarr/log";
import type { BoardSize, OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import { mapKind } from "../widgets/definitions";
import { mapOptions } from "../widgets/options";
export const mapApp = (
app: OldmarrApp,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> => {
if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported");
const shapeForSize = app.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${boardSize}'`);
}
const sectionId = sectionMap.get(app.area.properties.id)?.id;
if (!sectionId) {
throw new Error(`Failed to find section for app appId='${app.id}' sectionId='${app.area.properties.id}'`);
}
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
kind: "app",
options: SuperJSON.stringify({
// it's safe to assume that the app exists in the map
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain
appId: appsMap.get(app.id)?.id!,
openInNewTab: app.behaviour.isOpeningNewTab,
pingEnabled: app.network.enabledStatusChecker,
showDescriptionTooltip: app.behaviour.tooltipDescription !== "",
showTitle: app.appearance.appNameStatus === "normal",
} satisfies WidgetComponentProps<"app">["options"]),
};
};
export const mapWidget = (
widget: OldmarrWidget,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
): InferInsertModel<typeof items> | null => {
if (widget.area.type === "sidebar") throw new Error("Mapping widget in sidebar is not supported");
const shapeForSize = widget.shape[boardSize];
if (!shapeForSize) {
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${boardSize}'`);
}
const kind = mapKind(widget.type);
if (!kind) {
logger.warn(`Failed to map widget type='${widget.type}'. It's no longer supported`);
return null;
}
const sectionId = sectionMap.get(widget.area.properties.id)?.id;
if (!sectionId) {
throw new Error(
`Failed to find section for widget widgetId='${widget.id}' sectionId='${widget.area.properties.id}'`,
);
}
return {
id: createId(),
sectionId,
height: shapeForSize.size.height,
width: shapeForSize.size.width,
xOffset: shapeForSize.location.x,
yOffset: shapeForSize.location.y,
kind,
options: SuperJSON.stringify(
mapOptions(kind, widget.properties, new Map([...appsMap.entries()].map(([key, value]) => [key, value.id]))),
),
};
};

View File

@@ -0,0 +1,24 @@
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { sections } from "@homarr/db/schema/sqlite";
import type { OldmarrCategorySection, OldmarrEmptySection } from "@homarr/old-schema";
export const mapCategorySection = (
boardId: string,
category: OldmarrCategorySection,
): InferInsertModel<typeof sections> => ({
id: createId(),
boardId,
kind: "category",
xOffset: 0,
yOffset: category.position,
name: category.name,
});
export const mapEmptySection = (boardId: string, wrapper: OldmarrEmptySection): InferInsertModel<typeof sections> => ({
id: createId(),
boardId,
kind: "empty",
xOffset: 0,
yOffset: wrapper.position,
});

View File

@@ -0,0 +1,35 @@
import { decryptSecretWithKey } from "@homarr/common/server";
import type { InferInsertModel } from "@homarr/db";
import { createId } from "@homarr/db";
import type { users } from "@homarr/db/schema/sqlite";
import type { OldmarrImportUser } from "../user-schema";
export const mapAndDecryptUsers = (importUsers: OldmarrImportUser[], encryptionToken: string | null) => {
if (encryptionToken === null) {
return [];
}
const key = Buffer.from(encryptionToken, "hex");
return importUsers.map(
({
id,
password,
salt,
settings,
...user
}): InferInsertModel<typeof users> & { oldId: string; isAdmin: boolean } => ({
...user,
oldId: id,
id: createId(),
colorScheme: settings?.colorScheme === "environment" ? undefined : settings?.colorScheme,
firstDayOfWeek: settings?.firstDayOfWeek === "sunday" ? 0 : settings?.firstDayOfWeek === "monday" ? 1 : 6,
provider: "credentials",
pingIconsEnabled: settings?.replacePingWithIcons,
isAdmin: user.isAdmin || user.isOwner,
password: decryptSecretWithKey(password, key),
salt: decryptSecretWithKey(salt, key),
}),
);
};

View File

@@ -1,10 +1,10 @@
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";
import type { OldmarrImportConfiguration } from "./settings";
export const moveWidgetsAndAppsIfMerge = (
old: OldmarrConfig,

View File

@@ -0,0 +1,59 @@
import type { InferSelectModel } from "@homarr/db";
import type { apps } from "@homarr/db/schema/sqlite";
import type { OldmarrConfig } from "@homarr/old-schema";
import type { ValidAnalyseConfig } from "../analyse/types";
import { mapOldmarrApp, mapOldmarrBookmarkApp } from "../mappers/map-app";
import type { OldmarrBookmarkDefinition } from "../widgets/definitions/bookmark";
export type PreparedApp = Omit<InferSelectModel<typeof apps>, "id"> & { ids: string[]; existingId?: string };
export const prepareApps = (analyseConfigs: ValidAnalyseConfig[]) => {
const preparedApps: PreparedApp[] = [];
analyseConfigs.forEach(({ config }) => {
const appsFromConfig = extractAppsFromConfig(config).concat(extractBookmarkAppsFromConfig(config));
addAppsToPreparedApps(preparedApps, appsFromConfig);
});
return preparedApps;
};
const extractAppsFromConfig = (config: OldmarrConfig) => {
return config.apps.map(mapOldmarrApp);
};
const extractBookmarkAppsFromConfig = (config: OldmarrConfig) => {
const bookmarkWidgets = config.widgets.filter((widget) => widget.type === "bookmark");
return bookmarkWidgets.flatMap((widget) =>
(widget.properties as OldmarrBookmarkDefinition["options"]).items.map(mapOldmarrBookmarkApp),
);
};
const addAppsToPreparedApps = (preparedApps: PreparedApp[], configApps: InferSelectModel<typeof apps>[]) => {
configApps.forEach(({ id, ...app }) => {
const existingApp = preparedApps.find((preparedApp) => doAppsMatch(preparedApp, app));
if (existingApp) {
existingApp.ids.push(id);
return;
}
preparedApps.push({
...app,
ids: [id],
});
});
};
export const doAppsMatch = (
app1: Omit<InferSelectModel<typeof apps>, "id">,
app2: Omit<InferSelectModel<typeof apps>, "id">,
) => {
return (
app1.name === app2.name &&
app1.iconUrl === app2.iconUrl &&
app1.description === app2.description &&
app1.href === app2.href
);
};

View File

@@ -0,0 +1,34 @@
import { objectEntries } from "@homarr/common";
import type { BoardSize } from "@homarr/old-schema";
import type { ValidAnalyseConfig } from "../analyse/types";
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
const boardSizeSuffix: Record<BoardSize, string> = {
lg: "large",
md: "medium",
sm: "small",
};
export const createBoardName = (fileName: string, boardSize: BoardSize) => {
return `${fileName.replace(".json", "")}-${boardSizeSuffix[boardSize]}`;
};
export const prepareBoards = (analyseConfigs: ValidAnalyseConfig[], selections: BoardSelectionMap) => {
return analyseConfigs.flatMap(({ name, config }) => {
const selectedSizes = selections.get(name);
if (!selectedSizes) return [];
return objectEntries(selectedSizes)
.map(([size, selected]) => {
if (!selected) return null;
return {
name: createBoardName(name, size),
size,
config,
};
})
.filter((board) => board !== null);
});
};

View File

@@ -0,0 +1,19 @@
import type { ValidAnalyseConfig } from "../analyse/types";
export type PreparedIntegration = ReturnType<typeof prepareIntegrations>[number];
export const prepareIntegrations = (analyseConfigs: ValidAnalyseConfig[]) => {
return analyseConfigs.flatMap(({ config }) => {
return config.apps
.map((app) =>
app.integration?.type
? {
...app.integration,
name: app.name,
url: app.url,
}
: null,
)
.filter((integration) => integration !== null);
});
};

View File

@@ -0,0 +1,14 @@
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import { mapApp, mapWidget } from "../mappers/map-item";
export const prepareItems = (
{ apps, widgets }: Pick<OldmarrConfig, "apps" | "widgets">,
boardSize: BoardSize,
appsMap: Map<string, { id: string }>,
sectionMap: Map<string, { id: string }>,
) =>
widgets
.map((widget) => mapWidget(widget, boardSize, appsMap, sectionMap))
.filter((widget) => widget !== null)
.concat(apps.map((app) => mapApp(app, boardSize, appsMap, sectionMap)));

View File

@@ -0,0 +1,25 @@
import type { AnalyseConfig, ValidAnalyseConfig } from "../analyse/types";
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
import type { InitialOldmarrImportSettings } from "../settings";
import { prepareApps } from "./prepare-apps";
import { prepareBoards } from "./prepare-boards";
import { prepareIntegrations } from "./prepare-integrations";
export const prepareMultipleImports = (
analyseConfigs: AnalyseConfig[],
settings: InitialOldmarrImportSettings,
selections: BoardSelectionMap,
) => {
const invalidConfigs = analyseConfigs.filter((item) => item.config === null);
invalidConfigs.forEach(({ name }) => {
console.warn(`Skipping import of ${name} due to error in configuration. See logs of container for more details.`);
});
const filteredConfigs = analyseConfigs.filter((item): item is ValidAnalyseConfig => item.config !== null);
return {
preparedApps: prepareApps(filteredConfigs),
preparedBoards: settings.onlyImportApps ? [] : prepareBoards(filteredConfigs, selections),
preparedIntegrations: prepareIntegrations(filteredConfigs),
};
};

View File

@@ -0,0 +1,13 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import { mapCategorySection, mapEmptySection } from "../mappers/map-section";
export const prepareSections = (
boardId: string,
{ categories, wrappers }: Pick<OldmarrConfig, "categories" | "wrappers">,
) =>
new Map(
categories
.map((category) => [category.id, mapCategorySection(boardId, category)] as const)
.concat(wrappers.map((wrapper) => [wrapper.id, mapEmptySection(boardId, wrapper)] as const)),
);

View File

@@ -0,0 +1,21 @@
import type { OldmarrConfig } from "@homarr/old-schema";
import type { OldmarrImportConfiguration } from "../settings";
import { prepareApps } from "./prepare-apps";
export const prepareSingleImport = (config: OldmarrConfig, settings: OldmarrImportConfiguration) => {
const validAnalyseConfigs = [{ name: settings.name, config, isError: false }];
return {
preparedApps: prepareApps(validAnalyseConfigs),
preparedBoards: settings.onlyImportApps
? []
: [
{
name: settings.name,
size: settings.screenSize,
config,
},
],
};
};

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
import { zfd } from "zod-form-data";
import { boardSizes } from "@homarr/old-schema";
import { validation, zodEnumFromArray } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
export const sidebarBehaviours = ["remove-items", "last-section"] as const;
export const defaultSidebarBehaviour = "last-section";
export type SidebarBehaviour = (typeof sidebarBehaviours)[number];
export const oldmarrImportConfigurationSchema = z.object({
name: validation.board.name,
onlyImportApps: z.boolean().default(false),
screenSize: zodEnumFromArray(boardSizes).default("lg"),
sidebarBehaviour: z.enum(sidebarBehaviours).default(defaultSidebarBehaviour),
});
export type OldmarrImportConfiguration = z.infer<typeof oldmarrImportConfigurationSchema>;
export const initialOldmarrImportSettings = oldmarrImportConfigurationSchema.pick({
onlyImportApps: true,
sidebarBehaviour: true,
});
export type InitialOldmarrImportSettings = z.infer<typeof initialOldmarrImportSettings>;
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;
};
export const importJsonFileSchema = zfd.formData({
file: zfd.file().superRefine(superRefineJsonImportFile),
configuration: zfd.json(oldmarrImportConfigurationSchema),
});

View File

@@ -0,0 +1,2 @@
export { importJsonFileSchema, superRefineJsonImportFile, oldmarrImportConfigurationSchema } from "./settings";
export type { OldmarrImportConfiguration } from "./settings";

View File

@@ -0,0 +1,27 @@
import { z } from "zod";
const regexEncryptedSchema = z.string().regex(/^[a-f0-9]+\.[a-f0-9]+$/g);
const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncryptedSchema.safeParse(value).success);
export const oldmarrImportUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().nullable(),
emailVerified: z.date().nullable(),
image: z.string().nullable(),
isAdmin: z.boolean(),
isOwner: z.boolean(),
settings: z
.object({
colorScheme: z.enum(["environment", "light", "dark"]),
defaultBoard: z.string(),
firstDayOfWeek: z.enum(["monday", "saturday", "sunday"]),
replacePingWithIcons: z.boolean(),
})
.nullable(),
password: encryptedSchema,
salt: encryptedSchema,
});
export type OldmarrImportUser = z.infer<typeof oldmarrImportUserSchema>;