feat: add onboarding with oldmarr import (#1606)
This commit is contained in:
64
packages/old-import/src/analyse/analyse-oldmarr-import.ts
Normal file
64
packages/old-import/src/analyse/analyse-oldmarr-import.ts
Normal 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 ?? [];
|
||||
};
|
||||
2
packages/old-import/src/analyse/index.ts
Normal file
2
packages/old-import/src/analyse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./input";
|
||||
export { analyseOldmarrImportForRouterAsync } from "./analyse-oldmarr-import";
|
||||
5
packages/old-import/src/analyse/input.ts
Normal file
5
packages/old-import/src/analyse/input.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { zfd } from "zod-form-data";
|
||||
|
||||
export const analyseOldmarrImportInputSchema = zfd.formData({
|
||||
file: zfd.file(),
|
||||
});
|
||||
7
packages/old-import/src/analyse/types.ts
Normal file
7
packages/old-import/src/analyse/types.ts
Normal 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 }>;
|
||||
3
packages/old-import/src/components/index.ts
Normal file
3
packages/old-import/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { InitialOldmarrImport } from "./initial-oldmarr-import";
|
||||
export { SidebarBehaviourSelect } from "./shared/sidebar-behaviour-select";
|
||||
export { OldmarrImportAppsSettings } from "./shared/apps-section";
|
||||
112
packages/old-import/src/components/initial-oldmarr-import.tsx
Normal file
112
packages/old-import/src/components/initial-oldmarr-import.tsx
Normal 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));
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
67
packages/old-import/src/components/initial/token-modal.tsx
Normal file
67
packages/old-import/src/components/initial/token-modal.tsx
Normal 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") });
|
||||
23
packages/old-import/src/components/shared/apps-section.tsx
Normal file
23
packages/old-import/src/components/shared/apps-section.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
33
packages/old-import/src/import/collections/common.ts
Normal file
33
packages/old-import/src/import/collections/common.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
45
packages/old-import/src/import/import-initial-oldmarr.ts
Normal file
45
packages/old-import/src/import/import-initial-oldmarr.ts
Normal 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()})`);
|
||||
};
|
||||
36
packages/old-import/src/import/import-single-oldmarr.ts
Normal file
36
packages/old-import/src/import/import-single-oldmarr.ts
Normal 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);
|
||||
};
|
||||
3
packages/old-import/src/import/index.ts
Normal file
3
packages/old-import/src/import/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { importInitialOldmarrAsync } from "./import-initial-oldmarr";
|
||||
export * from "./input";
|
||||
export { ensureValidTokenOrThrow } from "./validate-token";
|
||||
24
packages/old-import/src/import/input.ts
Normal file
24
packages/old-import/src/import/input.ts
Normal 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(),
|
||||
});
|
||||
18
packages/old-import/src/import/validate-token.ts
Normal file
18
packages/old-import/src/import/validate-token.ts
Normal 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");
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
27
packages/old-import/src/mappers/map-app.ts
Normal file
27
packages/old-import/src/mappers/map-app.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
27
packages/old-import/src/mappers/map-board.ts
Normal file
27
packages/old-import/src/mappers/map-board.ts
Normal 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"),
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
60
packages/old-import/src/mappers/map-integration.ts
Normal file
60
packages/old-import/src/mappers/map-integration.ts
Normal 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,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
89
packages/old-import/src/mappers/map-item.ts
Normal file
89
packages/old-import/src/mappers/map-item.ts
Normal 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]))),
|
||||
),
|
||||
};
|
||||
};
|
||||
24
packages/old-import/src/mappers/map-section.ts
Normal file
24
packages/old-import/src/mappers/map-section.ts
Normal 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,
|
||||
});
|
||||
35
packages/old-import/src/mappers/map-user.ts
Normal file
35
packages/old-import/src/mappers/map-user.ts
Normal 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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
59
packages/old-import/src/prepare/prepare-apps.ts
Normal file
59
packages/old-import/src/prepare/prepare-apps.ts
Normal 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
|
||||
);
|
||||
};
|
||||
34
packages/old-import/src/prepare/prepare-boards.ts
Normal file
34
packages/old-import/src/prepare/prepare-boards.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
19
packages/old-import/src/prepare/prepare-integrations.ts
Normal file
19
packages/old-import/src/prepare/prepare-integrations.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
14
packages/old-import/src/prepare/prepare-items.ts
Normal file
14
packages/old-import/src/prepare/prepare-items.ts
Normal 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)));
|
||||
25
packages/old-import/src/prepare/prepare-multiple.ts
Normal file
25
packages/old-import/src/prepare/prepare-multiple.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
13
packages/old-import/src/prepare/prepare-sections.ts
Normal file
13
packages/old-import/src/prepare/prepare-sections.ts
Normal 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)),
|
||||
);
|
||||
21
packages/old-import/src/prepare/prepare-single.ts
Normal file
21
packages/old-import/src/prepare/prepare-single.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
63
packages/old-import/src/settings.ts
Normal file
63
packages/old-import/src/settings.ts
Normal 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),
|
||||
});
|
||||
2
packages/old-import/src/shared.ts
Normal file
2
packages/old-import/src/shared.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { importJsonFileSchema, superRefineJsonImportFile, oldmarrImportConfigurationSchema } from "./settings";
|
||||
export type { OldmarrImportConfiguration } from "./settings";
|
||||
27
packages/old-import/src/user-schema.ts
Normal file
27
packages/old-import/src/user-schema.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user