Replace entire codebase with homarr-labs/homarr
This commit is contained in:
73
packages/old-import/src/analyse/analyse-oldmarr-import.ts
Normal file
73
packages/old-import/src/analyse/analyse-oldmarr-import.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import AdmZip from "adm-zip";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
|
||||
import { oldmarrImportUserSchema } from "../user-schema";
|
||||
import type { analyseOldmarrImportInputSchema } from "./input";
|
||||
|
||||
const logger = createLogger({ module: "analyseOldmarrImport" });
|
||||
|
||||
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(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to parse oldmarr config",
|
||||
{ entryName: entry.entryName },
|
||||
{ cause: result.error },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name.replaceAll(" ", "-").replace(".json", ""),
|
||||
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(new Error("Failed to parse users", { cause: 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";
|
||||
@@ -0,0 +1,94 @@
|
||||
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";
|
||||
|
||||
// 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 { 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<Map<string, boolean>>(
|
||||
new Map(analyseResult.configs.filter(({ config }) => config !== null).map(({ name }) => [name, true])),
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
import { Anchor, Card, Checkbox, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export type BoardSelectionMap = Map<string, boolean>;
|
||||
|
||||
interface BoardSelectionCardProps {
|
||||
selections: BoardSelectionMap;
|
||||
updateSelections: (callback: (selections: BoardSelectionMap) => BoardSelectionMap) => void;
|
||||
}
|
||||
|
||||
const allChecked = (map: BoardSelectionMap) => {
|
||||
return [...map.values()].every((selection) => selection);
|
||||
};
|
||||
|
||||
export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelectionCardProps) => {
|
||||
const tBoardSelection = useScopedI18n("init.step.import.boardSelection");
|
||||
const t = useI18n();
|
||||
const areAllChecked = allChecked(selections);
|
||||
|
||||
const handleToggleAll = () => {
|
||||
updateSelections((selections) => {
|
||||
return new Map([...selections.keys()].map((name) => [name, !areAllChecked] as const));
|
||||
});
|
||||
};
|
||||
|
||||
const registerToggle = (name: string) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
updateSelections((selections) => {
|
||||
const updated = new Map(selections);
|
||||
updated.set(name, event.target.checked);
|
||||
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: String(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, selected]) => (
|
||||
<Card key={name} withBorder>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onChange={registerToggle(name)}
|
||||
label={
|
||||
<Text fw={500} size="sm">
|
||||
{name}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</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/v4";
|
||||
|
||||
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 },
|
||||
{
|
||||
onSuccess(isValid) {
|
||||
if (isValid) {
|
||||
actions.closeModal();
|
||||
void 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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
52
packages/old-import/src/fix-section-issues.ts
Normal file
52
packages/old-import/src/fix-section-issues.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
const logger = createLogger({ module: "fixSectionIssues" });
|
||||
|
||||
export const fixSectionIssues = (old: OldmarrConfig) => {
|
||||
const wrappers = old.wrappers.sort((wrapperA, wrapperB) => wrapperA.position - wrapperB.position);
|
||||
const categories = old.categories.sort((categoryA, categoryB) => categoryA.position - categoryB.position);
|
||||
|
||||
const neededSectionCount = categories.length * 2 + 1;
|
||||
const hasToMuchEmptyWrappers = wrappers.length > categories.length + 1;
|
||||
|
||||
logger.debug("Fixing section issues", {
|
||||
neededSectionCount,
|
||||
hasToMuchEmptyWrappers,
|
||||
});
|
||||
|
||||
for (let position = 0; position < neededSectionCount; position++) {
|
||||
const index = Math.floor(position / 2);
|
||||
const isEmpty = position % 2 === 0;
|
||||
const section = isEmpty ? wrappers[index] : categories[index];
|
||||
if (!section) {
|
||||
// If there are not enough empty sections for categories we need to insert them
|
||||
if (isEmpty) {
|
||||
// Insert empty wrapper for between categories
|
||||
wrappers.push({
|
||||
id: createId(),
|
||||
position,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
section.position = position;
|
||||
}
|
||||
|
||||
// Find all wrappers that should be merged into one
|
||||
const wrapperIdsToMerge = wrappers.slice(categories.length).map((section) => section.id);
|
||||
// Remove all wrappers after the first at the end
|
||||
wrappers.splice(categories.length + 1);
|
||||
|
||||
if (wrapperIdsToMerge.length >= 2) {
|
||||
logger.debug("Found wrappers to merge", { count: wrapperIdsToMerge.length });
|
||||
}
|
||||
|
||||
return {
|
||||
wrappers,
|
||||
categories,
|
||||
wrapperIdsToMerge,
|
||||
};
|
||||
};
|
||||
9
packages/old-import/src/import-error.ts
Normal file
9
packages/old-import/src/import-error.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
export class OldHomarrImportError extends Error {
|
||||
constructor(oldConfig: OldmarrConfig, cause: unknown) {
|
||||
super(`Failed to import old homarr configuration name=${oldConfig.configProperties.name}`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
51
packages/old-import/src/import-sections.ts
Normal file
51
packages/old-import/src/import-sections.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { sections } from "@homarr/db/schema";
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
const logger = createLogger({ module: "importSections" });
|
||||
|
||||
export const insertSectionsAsync = async (
|
||||
db: Database,
|
||||
categories: OldmarrConfig["categories"],
|
||||
wrappers: OldmarrConfig["wrappers"],
|
||||
boardId: string,
|
||||
) => {
|
||||
logger.info("Importing old homarr sections", { boardId, categories: categories.length, wrappers: wrappers.length });
|
||||
|
||||
const wrapperIds = wrappers.map((section) => section.id);
|
||||
const categoryIds = categories.map((section) => section.id);
|
||||
const idMaps = new Map<string, string>([...wrapperIds, ...categoryIds].map((id) => [id, createId()]));
|
||||
|
||||
const wrappersToInsert = wrappers.map((section) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: idMaps.get(section.id)!,
|
||||
boardId,
|
||||
xOffset: 0,
|
||||
yOffset: section.position,
|
||||
kind: "empty" as const,
|
||||
}));
|
||||
|
||||
const categoriesToInsert = categories.map((section) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: idMaps.get(section.id)!,
|
||||
boardId,
|
||||
xOffset: 0,
|
||||
yOffset: section.position,
|
||||
kind: "category" as const,
|
||||
name: section.name,
|
||||
}));
|
||||
|
||||
if (wrappersToInsert.length > 0) {
|
||||
await db.insert(sections).values(wrappersToInsert);
|
||||
}
|
||||
|
||||
if (categoriesToInsert.length > 0) {
|
||||
await db.insert(sections).values(categoriesToInsert);
|
||||
}
|
||||
|
||||
logger.info("Imported sections", { count: wrappersToInsert.length + categoriesToInsert.length });
|
||||
|
||||
return idMaps;
|
||||
};
|
||||
156
packages/old-import/src/import/collections/board-collection.ts
Normal file
156
packages/old-import/src/import/collections/board-collection.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
|
||||
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
|
||||
|
||||
import { fixSectionIssues } from "../../fix-section-issues";
|
||||
import { OldHomarrImportError } from "../../import-error";
|
||||
import { mapBoard } from "../../mappers/map-board";
|
||||
import { mapBreakpoint } from "../../mappers/map-breakpoint";
|
||||
import { mapColumnCount } from "../../mappers/map-column-count";
|
||||
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";
|
||||
|
||||
const logger = createLogger({ module: "boardCollection" });
|
||||
|
||||
export const createBoardInsertCollection = (
|
||||
{ preparedApps, preparedBoards }: Omit<ReturnType<typeof prepareMultipleImports>, "preparedIntegrations">,
|
||||
settings: InitialOldmarrImportSettings,
|
||||
) => {
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"apps",
|
||||
"boards",
|
||||
"layouts",
|
||||
"sections",
|
||||
"items",
|
||||
"itemLayouts",
|
||||
]);
|
||||
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) => {
|
||||
if (!hasEnoughItemShapes(board.config)) {
|
||||
throw new OldHomarrImportError(
|
||||
board.config,
|
||||
new Error("Your config contains items without shapes for all board sizes."),
|
||||
);
|
||||
}
|
||||
|
||||
const { wrappers, categories, wrapperIdsToMerge } = fixSectionIssues(board.config);
|
||||
const { apps, widgets } = moveWidgetsAndAppsIfMerge(board.config, wrapperIdsToMerge, {
|
||||
...settings,
|
||||
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 layoutMapping = boardSizes.reduce(
|
||||
(acc, size) => {
|
||||
acc[size] = createId();
|
||||
return acc;
|
||||
},
|
||||
{} as Record<BoardSize, string>,
|
||||
);
|
||||
|
||||
insertCollection.layouts.push(
|
||||
...boardSizes.map((size) => ({
|
||||
id: layoutMapping[size],
|
||||
boardId: mappedBoard.id,
|
||||
columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
|
||||
breakpoint: mapBreakpoint(size),
|
||||
name: getBoardSizeName(size),
|
||||
})),
|
||||
);
|
||||
|
||||
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,
|
||||
settings: board.config.settings,
|
||||
},
|
||||
appsMap,
|
||||
preparedSections,
|
||||
layoutMapping,
|
||||
mappedBoard.id,
|
||||
);
|
||||
preparedItems.forEach(({ layouts, ...item }) => {
|
||||
insertCollection.items.push(item);
|
||||
insertCollection.itemLayouts.push(...layouts);
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
export const hasEnoughItemShapes = (config: {
|
||||
apps: Pick<OldmarrConfig["apps"][number], "shape">[];
|
||||
widgets: Pick<OldmarrConfig["widgets"][number], "shape">[];
|
||||
}) => {
|
||||
const invalidSizes: BoardSize[] = [];
|
||||
|
||||
for (const size of boardSizes) {
|
||||
if (invalidSizes.includes(size)) continue;
|
||||
|
||||
if (config.apps.some((app) => app.shape[size] === undefined)) {
|
||||
invalidSizes.push(size);
|
||||
}
|
||||
|
||||
if (invalidSizes.includes(size)) continue;
|
||||
|
||||
if (config.widgets.some((widget) => widget.shape[size] === undefined)) {
|
||||
invalidSizes.push(size);
|
||||
}
|
||||
}
|
||||
|
||||
return invalidSizes.length <= 2;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
|
||||
import { mapAndDecryptIntegrations } from "../../mappers/map-integration";
|
||||
import type { PreparedIntegration } from "../../prepare/prepare-integrations";
|
||||
|
||||
const logger = createLogger({ module: "integrationCollection" });
|
||||
|
||||
export const createIntegrationInsertCollection = (
|
||||
preparedIntegrations: PreparedIntegration[],
|
||||
encryptionToken: string | null | undefined,
|
||||
) => {
|
||||
const insertCollection = createDbInsertCollectionForTransaction(["integrations", "integrationSecrets"]);
|
||||
|
||||
if (preparedIntegrations.length === 0) {
|
||||
return insertCollection;
|
||||
}
|
||||
|
||||
logger.info("Preparing integrations for insert collection", { count: preparedIntegrations.length });
|
||||
|
||||
if (encryptionToken === null || encryptionToken === undefined) {
|
||||
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,69 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
|
||||
import { mapAndDecryptUsers } from "../../mappers/map-user";
|
||||
import type { OldmarrImportUser } from "../../user-schema";
|
||||
|
||||
const logger = createLogger({ module: "userCollection" });
|
||||
|
||||
export const createUserInsertCollection = (
|
||||
importUsers: OldmarrImportUser[],
|
||||
encryptionToken: string | null | undefined,
|
||||
) => {
|
||||
const insertCollection = createDbInsertCollectionForTransaction([
|
||||
"users",
|
||||
"groups",
|
||||
"groupMembers",
|
||||
"groupPermissions",
|
||||
]);
|
||||
|
||||
if (importUsers.length === 0) {
|
||||
return insertCollection;
|
||||
}
|
||||
|
||||
logger.info("Preparing users for insert collection", { count: importUsers.length });
|
||||
|
||||
if (encryptionToken === null || encryptionToken === undefined) {
|
||||
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,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
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,
|
||||
adminUsersCount: admins.length,
|
||||
});
|
||||
|
||||
return insertCollection;
|
||||
};
|
||||
58
packages/old-import/src/import/import-initial-oldmarr.ts
Normal file
58
packages/old-import/src/import/import-initial-oldmarr.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { handleTransactionsAsync } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
|
||||
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";
|
||||
|
||||
const logger = createLogger({ module: "importInitialOldmarr" });
|
||||
|
||||
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");
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(db) {
|
||||
await db.transaction(async (transaction) => {
|
||||
await boardInsertCollection.insertAllAsync(transaction);
|
||||
await userInsertCollection.insertAllAsync(transaction);
|
||||
await integrationInsertCollection.insertAllAsync(transaction);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
boardInsertCollection.insertAll(transaction);
|
||||
userInsertCollection.insertAll(transaction);
|
||||
integrationInsertCollection.insertAll(transaction);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Import successful", { duration: stopwatch.getElapsedInHumanWords() });
|
||||
};
|
||||
42
packages/old-import/src/import/import-single-oldmarr.ts
Normal file
42
packages/old-import/src/import/import-single-oldmarr.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { handleTransactionsAsync, inArray } from "@homarr/db";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema";
|
||||
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);
|
||||
|
||||
await handleTransactionsAsync(db, {
|
||||
async handleAsync(db) {
|
||||
await boardInsertCollection.insertAllAsync(db);
|
||||
},
|
||||
handleSync(db) {
|
||||
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";
|
||||
17
packages/old-import/src/import/input.ts
Normal file
17
packages/old-import/src/import/input.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import SuperJSON from "superjson";
|
||||
import { zfd } from "zod-form-data";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { initialOldmarrImportSettings } from "../settings";
|
||||
|
||||
const boardSelectionMapSchema = z.map(z.string(), z.boolean());
|
||||
|
||||
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().optional(),
|
||||
});
|
||||
53
packages/old-import/src/import/test/board-collection.spec.ts
Normal file
53
packages/old-import/src/import/test/board-collection.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { BoardSize } from "@homarr/old-schema";
|
||||
|
||||
import { hasEnoughItemShapes } from "../collections/board-collection";
|
||||
|
||||
const defaultShape = {
|
||||
location: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
size: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
};
|
||||
|
||||
describe("hasEnoughItemShapes should check if there are more than one shape available for automatic reconstruction", () => {
|
||||
test.each([
|
||||
[true, [], []], // no items, so nothing to check
|
||||
[true, [{ lg: true }], []], // lg always exists
|
||||
[true, [], [{ md: true }]], // md always exists
|
||||
[true, [{ md: true, sm: true }], [{ md: true, lg: true }]], // md always exists
|
||||
[true, [{ md: true }], [{ md: true }]], // md always exists
|
||||
[false, [{ md: true }, { md: true }], [{ lg: true }]], // md is missing for widgets
|
||||
[false, [{ md: true }], [{ lg: true }]], // md is missing for widgets
|
||||
[false, [{ md: true }], [{ md: true, lg: true }, { lg: true }]], // md is missing for 2. widget
|
||||
] as [boolean, Shape[], Shape[]][])(
|
||||
"should return %s if there are more than one shape available",
|
||||
(returnValue, appShapes, widgetShapes) => {
|
||||
const result = hasEnoughItemShapes({
|
||||
apps: appShapes.map((shapes) => ({
|
||||
shape: {
|
||||
sm: shapes.sm ? defaultShape : undefined,
|
||||
md: shapes.md ? defaultShape : undefined,
|
||||
lg: shapes.lg ? defaultShape : undefined,
|
||||
},
|
||||
})),
|
||||
widgets: widgetShapes.map((shapes) => ({
|
||||
shape: {
|
||||
sm: shapes.sm ? defaultShape : undefined,
|
||||
md: shapes.md ? defaultShape : undefined,
|
||||
lg: shapes.lg ? defaultShape : undefined,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
expect(result).toBe(returnValue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
type Shape = Partial<Record<BoardSize, true>>;
|
||||
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 | undefined) => {
|
||||
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");
|
||||
};
|
||||
13
packages/old-import/src/index.ts
Normal file
13
packages/old-import/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Database } from "@homarr/db";
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
import { importSingleOldmarrConfigAsync } from "./import/import-single-oldmarr";
|
||||
import type { OldmarrImportConfiguration } from "./settings";
|
||||
|
||||
export const importOldmarrAsync = async (
|
||||
db: Database,
|
||||
old: OldmarrConfig,
|
||||
configuration: OldmarrImportConfiguration,
|
||||
) => {
|
||||
await importSingleOldmarrConfigAsync(db, old, configuration);
|
||||
};
|
||||
29
packages/old-import/src/mappers/map-app.ts
Normal file
29
packages/old-import/src/mappers/map-app.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { InferSelectModel } from "@homarr/db";
|
||||
import type { apps } from "@homarr/db/schema";
|
||||
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,
|
||||
pingUrl: app.url.length > 0 ? app.url : null,
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
pingUrl: null,
|
||||
};
|
||||
};
|
||||
53
packages/old-import/src/mappers/map-board.ts
Normal file
53
packages/old-import/src/mappers/map-board.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { boards } from "@homarr/db/schema";
|
||||
|
||||
import type { prepareMultipleImports } from "../prepare/prepare-multiple";
|
||||
import { mapColor } from "./map-colors";
|
||||
|
||||
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,
|
||||
faviconImageUrl: mapFavicon(preparedBoard.config.settings.customization.faviconUrl),
|
||||
isPublic: preparedBoard.config.settings.access.allowGuests,
|
||||
logoImageUrl: mapLogo(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"),
|
||||
});
|
||||
|
||||
const defaultOldmarrLogoPath = "/imgs/logo/logo.png";
|
||||
|
||||
const mapLogo = (logo: string | null | undefined) => {
|
||||
if (!logo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (logo.trim() === defaultOldmarrLogoPath) {
|
||||
return null; // We fallback to default logo when null
|
||||
}
|
||||
|
||||
return logo;
|
||||
};
|
||||
|
||||
const defaultOldmarrFaviconPath = "/imgs/favicon/favicon-squared.png";
|
||||
|
||||
const mapFavicon = (favicon: string | null | undefined) => {
|
||||
if (!favicon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (favicon.trim() === defaultOldmarrFaviconPath) {
|
||||
return null; // We fallback to default favicon when null
|
||||
}
|
||||
|
||||
return favicon;
|
||||
};
|
||||
18
packages/old-import/src/mappers/map-breakpoint.ts
Normal file
18
packages/old-import/src/mappers/map-breakpoint.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { BoardSize } from "@homarr/old-schema";
|
||||
|
||||
/**
|
||||
* Copied from https://github.com/ajnart/homarr/blob/274eaa92084a8be4d04a69a87f9920860a229128/src/components/Dashboard/Wrappers/gridstack/store.tsx#L21-L30
|
||||
* @param screenSize board size
|
||||
* @returns layout breakpoint for the board
|
||||
*/
|
||||
export const mapBreakpoint = (screenSize: BoardSize) => {
|
||||
switch (screenSize) {
|
||||
case "lg":
|
||||
return 1400;
|
||||
case "md":
|
||||
return 800;
|
||||
case "sm":
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
48
packages/old-import/src/mappers/map-colors.ts
Normal file
48
packages/old-import/src/mappers/map-colors.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const oldColors = [
|
||||
"dark",
|
||||
"gray",
|
||||
"red",
|
||||
"pink",
|
||||
"grape",
|
||||
"violet",
|
||||
"indigo",
|
||||
"blue",
|
||||
"cyan",
|
||||
"green",
|
||||
"lime",
|
||||
"yellow",
|
||||
"orange",
|
||||
"teal",
|
||||
] as const;
|
||||
type OldColor = (typeof oldColors)[number];
|
||||
|
||||
export const mapColor = (color: string | undefined, fallback: string) => {
|
||||
if (!color) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!oldColors.some((mantineColor) => color === mantineColor)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const mantineColor = color as OldColor;
|
||||
|
||||
return mappedColors[mantineColor];
|
||||
};
|
||||
|
||||
const mappedColors: Record<(typeof oldColors)[number], string> = {
|
||||
blue: "#228be6",
|
||||
cyan: "#15aabf",
|
||||
dark: "#2e2e2e",
|
||||
grape: "#be4bdb",
|
||||
gray: "#868e96",
|
||||
green: "#40c057",
|
||||
indigo: "#4c6ef5",
|
||||
lime: "#82c91e",
|
||||
orange: "#fd7e14",
|
||||
pink: "#e64980",
|
||||
red: "#fa5252",
|
||||
teal: "#12b886",
|
||||
violet: "#7950f2",
|
||||
yellow: "#fab005",
|
||||
};
|
||||
17
packages/old-import/src/mappers/map-column-count.ts
Normal file
17
packages/old-import/src/mappers/map-column-count.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
export const mapColumnCount = (
|
||||
gridstackSettings: OldmarrConfig["settings"]["customization"]["gridstack"],
|
||||
screenSize: BoardSize,
|
||||
) => {
|
||||
switch (screenSize) {
|
||||
case "lg":
|
||||
return gridstackSettings.columnCountLarge;
|
||||
case "md":
|
||||
return gridstackSettings.columnCountMedium;
|
||||
case "sm":
|
||||
return gridstackSettings.columnCountSmall;
|
||||
default:
|
||||
return 10;
|
||||
}
|
||||
};
|
||||
118
packages/old-import/src/mappers/map-integration.ts
Normal file
118
packages/old-import/src/mappers/map-integration.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecretWithKey } from "@homarr/common/server";
|
||||
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: "proxmox",
|
||||
qBittorrent: "qBittorrent",
|
||||
radarr: "radarr",
|
||||
readarr: "readarr",
|
||||
sabnzbd: "sabNzbd",
|
||||
sonarr: "sonarr",
|
||||
tdarr: "tdarr",
|
||||
transmission: "transmission",
|
||||
plex: "plex",
|
||||
};
|
||||
|
||||
export const mapAndDecryptIntegrations = (
|
||||
preparedIntegrations: PreparedIntegration[],
|
||||
encryptionToken: string | null,
|
||||
) => {
|
||||
if (encryptionToken === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return preparedIntegrations.map(({ type, name, url, properties }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const kind = mapIntegrationType(type!);
|
||||
|
||||
return {
|
||||
id: createId(),
|
||||
name,
|
||||
url,
|
||||
kind,
|
||||
secrets: mapSecrets(properties, encryptionToken, kind),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const mapSecrets = (properties: PreparedIntegration["properties"], encryptionToken: string, kind: IntegrationKind) => {
|
||||
const key = Buffer.from(encryptionToken, "hex");
|
||||
|
||||
const decryptedProperties = properties.map((property) => ({
|
||||
...property,
|
||||
value: property.value ? decryptSecretWithKey(property.value as `${string}.${string}`, key) : null,
|
||||
}));
|
||||
|
||||
return kind === "proxmox" ? mapProxmoxSecrets(decryptedProperties) : decryptedProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Proxmox secrets have bee split up from format `user@realm!tokenId=secret` to separate fields
|
||||
*/
|
||||
const mapProxmoxSecrets = (decryptedProperties: PreparedIntegration["properties"]) => {
|
||||
const apiToken = decryptedProperties.find((property) => property.field === "apiKey");
|
||||
|
||||
if (!apiToken?.value) return [];
|
||||
|
||||
let splitValues = apiToken.value.split("@");
|
||||
|
||||
if (splitValues.length <= 1) return [];
|
||||
|
||||
const [user, ...rest] = splitValues;
|
||||
|
||||
splitValues = rest.join("@").split("!");
|
||||
|
||||
if (splitValues.length <= 1) return [];
|
||||
|
||||
const [realm, ...rest2] = splitValues;
|
||||
|
||||
splitValues = rest2.join("!").split("=");
|
||||
|
||||
if (splitValues.length <= 1) return [];
|
||||
|
||||
const [tokenId, ...rest3] = splitValues;
|
||||
|
||||
const secret = rest3.join("=");
|
||||
|
||||
return [
|
||||
{
|
||||
field: "username" as const,
|
||||
value: user,
|
||||
},
|
||||
{
|
||||
field: "realm" as const,
|
||||
value: realm,
|
||||
},
|
||||
{
|
||||
field: "tokenId" as const,
|
||||
value: tokenId,
|
||||
},
|
||||
{
|
||||
field: "apiKey" as const,
|
||||
value: secret,
|
||||
},
|
||||
];
|
||||
};
|
||||
124
packages/old-import/src/mappers/map-item.ts
Normal file
124
packages/old-import/src/mappers/map-item.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { itemLayouts, items } from "@homarr/db/schema";
|
||||
import type { BoardSize, OldmarrApp, OldmarrWidget } from "@homarr/old-schema";
|
||||
import { boardSizes } from "@homarr/old-schema";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets/src/definition";
|
||||
import { mapKind } from "../widgets/definitions";
|
||||
import { mapOptions } from "../widgets/options";
|
||||
|
||||
const logger = createLogger({ module: "mapItem" });
|
||||
|
||||
export const mapApp = (
|
||||
app: OldmarrApp,
|
||||
appsMap: Map<string, { id: string }>,
|
||||
sectionMap: Map<string, { id: string }>,
|
||||
layoutMap: Record<BoardSize, string>,
|
||||
boardId: string,
|
||||
): (InferInsertModel<typeof items> & { layouts: InferInsertModel<typeof itemLayouts>[] }) | null => {
|
||||
if (app.area.type === "sidebar") throw new Error("Mapping app in sidebar is not supported");
|
||||
|
||||
const sectionId = sectionMap.get(app.area.properties.id)?.id;
|
||||
if (!sectionId) {
|
||||
logger.warn("Failed to find section for app. Removing app", {
|
||||
appId: app.id,
|
||||
sectionId: app.area.properties.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemId = createId();
|
||||
return {
|
||||
id: itemId,
|
||||
boardId,
|
||||
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,
|
||||
showTitle: app.appearance.appNameStatus === "normal",
|
||||
layout: app.appearance.positionAppName,
|
||||
descriptionDisplayMode: app.behaviour.tooltipDescription !== "" ? "tooltip" : "hidden",
|
||||
} satisfies WidgetComponentProps<"app">["options"]),
|
||||
layouts: boardSizes.map((size) => {
|
||||
const shapeForSize = app.shape[size];
|
||||
if (!shapeForSize) {
|
||||
throw new Error(`Failed to find a shape for appId='${app.id}' screenSize='${size}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
itemId,
|
||||
height: shapeForSize.size.height,
|
||||
width: shapeForSize.size.width,
|
||||
xOffset: shapeForSize.location.x,
|
||||
yOffset: shapeForSize.location.y,
|
||||
sectionId,
|
||||
layoutId: layoutMap[size],
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
export const mapWidget = (
|
||||
widget: OldmarrWidget,
|
||||
appsMap: Map<string, { id: string }>,
|
||||
sectionMap: Map<string, { id: string }>,
|
||||
layoutMap: Record<BoardSize, string>,
|
||||
boardId: string,
|
||||
): (InferInsertModel<typeof items> & { layouts: InferInsertModel<typeof itemLayouts>[] }) | null => {
|
||||
if (widget.area.type === "sidebar") throw new Error("Mapping widget in sidebar is not supported");
|
||||
|
||||
const kind = mapKind(widget.type);
|
||||
if (!kind) {
|
||||
logger.warn("Failed to map widget type. It's no longer supported", {
|
||||
widgetId: widget.id,
|
||||
widgetType: widget.type,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const sectionId = sectionMap.get(widget.area.properties.id)?.id;
|
||||
if (!sectionId) {
|
||||
logger.warn("Failed to find section for widget. Removing widget", {
|
||||
widgetId: widget.id,
|
||||
sectionId: widget.area.properties.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemId = createId();
|
||||
return {
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind,
|
||||
options: SuperJSON.stringify(
|
||||
mapOptions(
|
||||
widget.type,
|
||||
widget.properties,
|
||||
new Map([...appsMap.entries()].map(([key, value]) => [key, value.id])),
|
||||
),
|
||||
),
|
||||
layouts: boardSizes.map((size) => {
|
||||
const shapeForSize = widget.shape[size];
|
||||
if (!shapeForSize) {
|
||||
throw new Error(`Failed to find a shape for widgetId='${widget.id}' screenSize='${size}'`);
|
||||
}
|
||||
|
||||
return {
|
||||
itemId,
|
||||
height: shapeForSize.size.height,
|
||||
width: shapeForSize.size.width,
|
||||
xOffset: shapeForSize.location.x,
|
||||
yOffset: shapeForSize.location.y,
|
||||
sectionId,
|
||||
layoutId: layoutMap[size],
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
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 { createId } from "@homarr/common";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { sections } from "@homarr/db/schema";
|
||||
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,
|
||||
});
|
||||
36
packages/old-import/src/mappers/map-user.ts
Normal file
36
packages/old-import/src/mappers/map-user.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createId } from "@homarr/common";
|
||||
import { decryptSecretWithKey } from "@homarr/common/server";
|
||||
import type { InferInsertModel } from "@homarr/db";
|
||||
import type { users } from "@homarr/db/schema";
|
||||
|
||||
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(),
|
||||
name: user.name.toLowerCase(),
|
||||
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),
|
||||
}),
|
||||
);
|
||||
};
|
||||
46
packages/old-import/src/mappers/test/map-integration.spec.ts
Normal file
46
packages/old-import/src/mappers/test/map-integration.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as commonServer from "@homarr/common/server";
|
||||
|
||||
import type { PreparedIntegration } from "../../prepare/prepare-integrations";
|
||||
import { mapAndDecryptIntegrations } from "../map-integration";
|
||||
|
||||
describe("Map Integrations", () => {
|
||||
test("should map proxmox integration", () => {
|
||||
vi.spyOn(commonServer, "decryptSecretWithKey").mockReturnValue("user@realm!tokenId=secret");
|
||||
|
||||
const proxmoxIntegration: PreparedIntegration = {
|
||||
type: "proxmox",
|
||||
name: "Proxmox",
|
||||
url: "https://proxmox.com",
|
||||
properties: [
|
||||
{
|
||||
field: "apiKey",
|
||||
value: "any-encrypted-value",
|
||||
type: "private",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mappedIntegrations = mapAndDecryptIntegrations([proxmoxIntegration], "encryptionToken");
|
||||
|
||||
expect(mappedIntegrations[0]?.secrets).toEqual([
|
||||
{
|
||||
field: "username",
|
||||
value: "user",
|
||||
},
|
||||
{
|
||||
field: "realm",
|
||||
value: "realm",
|
||||
},
|
||||
{
|
||||
field: "tokenId",
|
||||
value: "tokenId",
|
||||
},
|
||||
{
|
||||
field: "apiKey",
|
||||
value: "secret",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
326
packages/old-import/src/move-widgets-and-apps-merge.ts
Normal file
326
packages/old-import/src/move-widgets-and-apps-merge.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget } from "@homarr/old-schema";
|
||||
import { boardSizes } from "@homarr/old-schema";
|
||||
|
||||
import { mapColumnCount } from "./mappers/map-column-count";
|
||||
import type { OldmarrImportConfiguration } from "./settings";
|
||||
|
||||
const logger = createLogger({ module: "moveWidgetsAndAppsMerge" });
|
||||
|
||||
export const moveWidgetsAndAppsIfMerge = (
|
||||
old: OldmarrConfig,
|
||||
wrapperIdsToMerge: string[],
|
||||
configuration: OldmarrImportConfiguration,
|
||||
) => {
|
||||
const firstId = wrapperIdsToMerge[0];
|
||||
if (!firstId) {
|
||||
return { apps: old.apps, widgets: old.widgets };
|
||||
}
|
||||
|
||||
const affectedMap = new Map<string, { apps: OldmarrApp[]; widgets: OldmarrWidget[] }>(
|
||||
wrapperIdsToMerge.map((id) => [
|
||||
id,
|
||||
{
|
||||
apps: old.apps.filter((app) => app.area.type !== "sidebar" && id === app.area.properties.id),
|
||||
widgets: old.widgets.filter((app) => app.area.type !== "sidebar" && id === app.area.properties.id),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
logger.debug("Merging wrappers at the end of the board", { count: wrapperIdsToMerge.length });
|
||||
|
||||
const offsets = boardSizes.reduce(
|
||||
(previous, screenSize) => {
|
||||
previous[screenSize] = 0;
|
||||
return previous;
|
||||
},
|
||||
{} as Record<BoardSize, number>,
|
||||
);
|
||||
for (const id of wrapperIdsToMerge) {
|
||||
const requiredHeights = boardSizes.reduce(
|
||||
(previous, screenSize) => {
|
||||
previous[screenSize] = 0;
|
||||
return previous;
|
||||
},
|
||||
{} as Record<BoardSize, number>,
|
||||
);
|
||||
const affected = affectedMap.get(id);
|
||||
if (!affected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const apps = affected.apps;
|
||||
const widgets = affected.widgets;
|
||||
|
||||
for (const app of apps) {
|
||||
if (app.area.type === "sidebar") continue;
|
||||
// Move item to first wrapper
|
||||
app.area.properties.id = firstId;
|
||||
|
||||
for (const screenSize of boardSizes) {
|
||||
const screenSizeShape = app.shape[screenSize];
|
||||
if (!screenSizeShape) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the highest widget in the wrapper to increase the offset accordingly
|
||||
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) {
|
||||
requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height;
|
||||
}
|
||||
|
||||
// Move item down as much as needed to not overlap with other items
|
||||
screenSizeShape.location.y += offsets[screenSize];
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (widget.area.type === "sidebar") continue;
|
||||
// Move item to first wrapper
|
||||
widget.area.properties.id = firstId;
|
||||
|
||||
for (const screenSize of boardSizes) {
|
||||
const screenSizeShape = widget.shape[screenSize];
|
||||
if (!screenSizeShape) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the highest widget in the wrapper to increase the offset accordingly
|
||||
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeights[screenSize]) {
|
||||
requiredHeights[screenSize] = screenSizeShape.location.y + screenSizeShape.size.height;
|
||||
}
|
||||
|
||||
// Move item down as much as needed to not overlap with other items
|
||||
screenSizeShape.location.y += offsets[screenSize];
|
||||
}
|
||||
}
|
||||
|
||||
for (const screenSize of boardSizes) {
|
||||
offsets[screenSize] += requiredHeights[screenSize];
|
||||
}
|
||||
}
|
||||
|
||||
if (configuration.sidebarBehaviour === "last-section") {
|
||||
const areas = [...old.apps.map((app) => app.area), ...old.widgets.map((widget) => widget.area)];
|
||||
if (
|
||||
old.settings.customization.layout.enabledLeftSidebar ||
|
||||
areas.some((area) => area.type === "sidebar" && area.properties.location === "left")
|
||||
) {
|
||||
for (const screenSize of boardSizes) {
|
||||
offsets[screenSize] = moveWidgetsAndAppsInLeftSidebar(old, firstId, offsets[screenSize], screenSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
old.settings.customization.layout.enabledRightSidebar ||
|
||||
areas.some((area) => area.type === "sidebar" && area.properties.location === "right")
|
||||
) {
|
||||
for (const screenSize of boardSizes) {
|
||||
moveWidgetsAndAppsInRightSidebar(old, firstId, offsets[screenSize], screenSize);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove all widgets and apps in the sidebar
|
||||
return {
|
||||
apps: old.apps.filter((app) => app.area.type !== "sidebar"),
|
||||
widgets: old.widgets.filter((app) => app.area.type !== "sidebar"),
|
||||
};
|
||||
}
|
||||
|
||||
return { apps: old.apps, widgets: old.widgets };
|
||||
};
|
||||
|
||||
const moveWidgetsAndAppsInLeftSidebar = (
|
||||
old: OldmarrConfig,
|
||||
firstId: string,
|
||||
offset: number,
|
||||
screenSize: BoardSize,
|
||||
) => {
|
||||
const columnCount = mapColumnCount(old.settings.customization.gridstack, screenSize);
|
||||
let requiredHeight = updateItems({
|
||||
// This should work as the reference of the items did not change, only the array reference did
|
||||
items: [...old.widgets, ...old.apps],
|
||||
screenSize,
|
||||
filter: (item) =>
|
||||
item.area.type === "sidebar" &&
|
||||
item.area.properties.location === "left" &&
|
||||
(columnCount >= 2 || item.shape[screenSize]?.location.x === 0),
|
||||
update: (item) => {
|
||||
item.area = {
|
||||
type: "wrapper",
|
||||
properties: {
|
||||
id: firstId,
|
||||
},
|
||||
};
|
||||
|
||||
const screenSizeShape = item.shape[screenSize];
|
||||
if (!screenSizeShape) return;
|
||||
|
||||
// Reduce width to one if column count is one
|
||||
if (screenSizeShape.size.width > columnCount) {
|
||||
screenSizeShape.size.width = columnCount;
|
||||
}
|
||||
|
||||
screenSizeShape.location.y += offset;
|
||||
},
|
||||
});
|
||||
|
||||
// Only increase offset if there are less than 3 columns because then the items have to be stacked
|
||||
if (columnCount <= 3) {
|
||||
offset += requiredHeight;
|
||||
}
|
||||
|
||||
// When column count is 0 we need to stack the items of the sidebar on top of each other
|
||||
if (columnCount !== 1) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
requiredHeight = updateItems({
|
||||
// This should work as the reference of the items did not change, only the array reference did
|
||||
items: [...old.widgets, ...old.apps],
|
||||
screenSize,
|
||||
filter: (item) =>
|
||||
item.area.type === "sidebar" &&
|
||||
item.area.properties.location === "left" &&
|
||||
item.shape[screenSize]?.location.x === 1,
|
||||
update: (item) => {
|
||||
item.area = {
|
||||
type: "wrapper",
|
||||
properties: {
|
||||
id: firstId,
|
||||
},
|
||||
};
|
||||
|
||||
const screenSizeShape = item.shape[screenSize];
|
||||
if (!screenSizeShape) return;
|
||||
|
||||
screenSizeShape.location.x = 0;
|
||||
screenSizeShape.location.y += offset;
|
||||
},
|
||||
});
|
||||
|
||||
offset += requiredHeight;
|
||||
return offset;
|
||||
};
|
||||
|
||||
const moveWidgetsAndAppsInRightSidebar = (
|
||||
old: OldmarrConfig,
|
||||
firstId: string,
|
||||
offset: number,
|
||||
screenSize: BoardSize,
|
||||
) => {
|
||||
const columnCount = mapColumnCount(old.settings.customization.gridstack, screenSize);
|
||||
const xOffsetDelta = Math.max(columnCount - 2, 0);
|
||||
const requiredHeight = updateItems({
|
||||
// This should work as the reference of the items did not change, only the array reference did
|
||||
items: [...old.widgets, ...old.apps],
|
||||
screenSize,
|
||||
filter: (item) =>
|
||||
item.area.type === "sidebar" &&
|
||||
item.area.properties.location === "right" &&
|
||||
(columnCount >= 2 || item.shape[screenSize]?.location.x === 0),
|
||||
update: (item) => {
|
||||
item.area = {
|
||||
type: "wrapper",
|
||||
properties: {
|
||||
id: firstId,
|
||||
},
|
||||
};
|
||||
|
||||
const screenSizeShape = item.shape[screenSize];
|
||||
if (!screenSizeShape) return;
|
||||
|
||||
// Reduce width to one if column count is one
|
||||
if (screenSizeShape.size.width > columnCount) {
|
||||
screenSizeShape.size.width = columnCount;
|
||||
}
|
||||
|
||||
screenSizeShape.location.y += offset;
|
||||
screenSizeShape.location.x += xOffsetDelta;
|
||||
},
|
||||
});
|
||||
|
||||
// When column count is 0 we need to stack the items of the sidebar on top of each other
|
||||
if (columnCount !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
offset += requiredHeight;
|
||||
|
||||
updateItems({
|
||||
// This should work as the reference of the items did not change, only the array reference did
|
||||
items: [...old.widgets, ...old.apps],
|
||||
screenSize,
|
||||
filter: (item) =>
|
||||
item.area.type === "sidebar" &&
|
||||
item.area.properties.location === "left" &&
|
||||
item.shape[screenSize]?.location.x === 1,
|
||||
update: (item) => {
|
||||
item.area = {
|
||||
type: "wrapper",
|
||||
properties: {
|
||||
id: firstId,
|
||||
},
|
||||
};
|
||||
|
||||
const screenSizeShape = item.shape[screenSize];
|
||||
if (!screenSizeShape) return;
|
||||
|
||||
screenSizeShape.location.x = 0;
|
||||
screenSizeShape.location.y += offset;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createItemSnapshot = (item: OldmarrApp | OldmarrWidget, screenSize: BoardSize) => ({
|
||||
x: item.shape[screenSize]?.location.x,
|
||||
y: item.shape[screenSize]?.location.y,
|
||||
height: item.shape[screenSize]?.size.height,
|
||||
width: item.shape[screenSize]?.size.width,
|
||||
section:
|
||||
item.area.type === "sidebar"
|
||||
? {
|
||||
type: "sidebar",
|
||||
location: item.area.properties.location,
|
||||
}
|
||||
: {
|
||||
type: item.area.type,
|
||||
id: item.area.properties.id,
|
||||
},
|
||||
toString(): string {
|
||||
return objectEntries(this)
|
||||
.filter(([key]) => key !== "toString")
|
||||
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
||||
.join(" ");
|
||||
},
|
||||
});
|
||||
|
||||
const updateItems = (options: {
|
||||
items: (OldmarrApp | OldmarrWidget)[];
|
||||
filter: (item: OldmarrApp | OldmarrWidget) => boolean;
|
||||
update: (item: OldmarrApp | OldmarrWidget) => void;
|
||||
screenSize: BoardSize;
|
||||
}) => {
|
||||
const items = options.items.filter(options.filter);
|
||||
let requiredHeight = 0;
|
||||
for (const item of items) {
|
||||
const before = createItemSnapshot(item, options.screenSize);
|
||||
|
||||
options.update(item);
|
||||
|
||||
const screenSizeShape = item.shape[options.screenSize];
|
||||
if (!screenSizeShape) return requiredHeight;
|
||||
|
||||
if (screenSizeShape.location.y + screenSizeShape.size.height > requiredHeight) {
|
||||
requiredHeight = screenSizeShape.location.y + screenSizeShape.size.height;
|
||||
}
|
||||
|
||||
const after = createItemSnapshot(item, options.screenSize);
|
||||
|
||||
logger.debug(
|
||||
`Moved item ${item.id}\n [snapshot before]: ${before.toString()}\n [snapshot after]: ${after.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return requiredHeight;
|
||||
};
|
||||
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";
|
||||
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
|
||||
);
|
||||
};
|
||||
6
packages/old-import/src/prepare/prepare-boards.ts
Normal file
6
packages/old-import/src/prepare/prepare-boards.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { ValidAnalyseConfig } from "../analyse/types";
|
||||
import type { BoardSelectionMap } from "../components/initial/board-selection-card";
|
||||
|
||||
export const prepareBoards = (analyseConfigs: ValidAnalyseConfig[], selections: BoardSelectionMap) => {
|
||||
return analyseConfigs.filter(({ name }) => selections.get(name));
|
||||
};
|
||||
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);
|
||||
});
|
||||
};
|
||||
105
packages/old-import/src/prepare/prepare-items.ts
Normal file
105
packages/old-import/src/prepare/prepare-items.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema";
|
||||
import { boardSizes } from "@homarr/old-schema";
|
||||
|
||||
import type { GridAlgorithmItem } from "../../../api/src/router/board/grid-algorithm";
|
||||
import { generateResponsiveGridFor } from "../../../api/src/router/board/grid-algorithm";
|
||||
import { mapColumnCount } from "../mappers/map-column-count";
|
||||
import { mapApp, mapWidget } from "../mappers/map-item";
|
||||
|
||||
const logger = createLogger({ module: "prepareItems" });
|
||||
|
||||
export const prepareItems = (
|
||||
{ apps, widgets, settings }: Pick<OldmarrConfig, "apps" | "widgets" | "settings">,
|
||||
appsMap: Map<string, { id: string }>,
|
||||
sectionMap: Map<string, { id: string }>,
|
||||
layoutMap: Record<BoardSize, string>,
|
||||
boardId: string,
|
||||
) => {
|
||||
let localApps = apps;
|
||||
let localWidgets = widgets;
|
||||
|
||||
const incompleteSizes = boardSizes.filter((size) =>
|
||||
widgets
|
||||
.map((widget) => widget.shape)
|
||||
.concat(apps.map((app) => app.shape))
|
||||
.some((shape) => !shape[size]),
|
||||
);
|
||||
|
||||
if (incompleteSizes.length > 0) {
|
||||
logger.warn("Found items with incomplete sizes. Generating missing sizes.", {
|
||||
boardId,
|
||||
count: incompleteSizes.length,
|
||||
sizes: incompleteSizes.join(", "),
|
||||
});
|
||||
|
||||
incompleteSizes.forEach((size) => {
|
||||
const columnCount = mapColumnCount(settings.customization.gridstack, size);
|
||||
const previousSize = !incompleteSizes.includes("lg") ? "lg" : incompleteSizes.includes("sm") ? "md" : "sm";
|
||||
const previousWidth = mapColumnCount(settings.customization.gridstack, previousSize);
|
||||
logger.info("Generating missing size", { boardId, from: previousSize, to: size });
|
||||
|
||||
const items = widgets
|
||||
.map((item) => mapItemForGridAlgorithm(item, previousSize))
|
||||
.concat(apps.map((item) => mapItemForGridAlgorithm(item, previousSize)));
|
||||
|
||||
const distinctSectionIds = [...new Set(items.map((item) => item.sectionId))];
|
||||
distinctSectionIds.forEach((sectionId) => {
|
||||
const { items: newItems } = generateResponsiveGridFor({ items, previousWidth, width: columnCount, sectionId });
|
||||
|
||||
localApps = localApps.map((app) => {
|
||||
const item = newItems.find((item) => item.id === app.id);
|
||||
if (!item) return app;
|
||||
|
||||
return {
|
||||
...app,
|
||||
shape: {
|
||||
...app.shape,
|
||||
[size]: mapShapeFromGridAlgorithm(item),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
localWidgets = localWidgets.map((widget) => {
|
||||
const item = newItems.find((item) => item.id === widget.id);
|
||||
if (!item) return widget;
|
||||
|
||||
return {
|
||||
...widget,
|
||||
shape: {
|
||||
...widget.shape,
|
||||
[size]: mapShapeFromGridAlgorithm(item),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return localWidgets
|
||||
.map((widget) => mapWidget(widget, appsMap, sectionMap, layoutMap, boardId))
|
||||
.concat(localApps.map((app) => mapApp(app, appsMap, sectionMap, layoutMap, boardId)))
|
||||
.filter((widget) => widget !== null);
|
||||
};
|
||||
|
||||
const mapItemForGridAlgorithm = (item: OldmarrApp | OldmarrWidget, size: BoardSize): GridAlgorithmItem => ({
|
||||
width: item.shape[size]?.size.width ?? 1,
|
||||
height: item.shape[size]?.size.height ?? 1,
|
||||
xOffset: item.shape[size]?.location.x ?? 0,
|
||||
yOffset: item.shape[size]?.location.y ?? 0,
|
||||
sectionId: item.area.type === "sidebar" ? item.area.properties.location : item.area.properties.id,
|
||||
id: item.id,
|
||||
type: "item",
|
||||
});
|
||||
|
||||
const mapShapeFromGridAlgorithm = (item: GridAlgorithmItem) =>
|
||||
({
|
||||
location: {
|
||||
x: item.xOffset,
|
||||
y: item.yOffset,
|
||||
},
|
||||
size: {
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
},
|
||||
}) satisfies SizedShape;
|
||||
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)),
|
||||
);
|
||||
13
packages/old-import/src/prepare/prepare-single.ts
Normal file
13
packages/old-import/src/prepare/prepare-single.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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 ? [] : validAnalyseConfigs,
|
||||
};
|
||||
};
|
||||
55
packages/old-import/src/settings.ts
Normal file
55
packages/old-import/src/settings.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { zfd } from "zod-form-data";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { boardNameSchema } from "@homarr/validation/board";
|
||||
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
|
||||
|
||||
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: boardNameSchema,
|
||||
onlyImportApps: z.boolean().default(false),
|
||||
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 checkJsonImportFile: z.core.CheckFn<File> = (context) => {
|
||||
if (context.value.type !== "application/json") {
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "invalidFileType",
|
||||
params: { expected: "JSON" },
|
||||
}),
|
||||
input: context.value.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.value.size > 1024 * 1024) {
|
||||
context.issues.push({
|
||||
code: "custom",
|
||||
params: createCustomErrorParams({
|
||||
key: "fileTooLarge",
|
||||
params: { maxSize: "1 MB" },
|
||||
}),
|
||||
input: context.value.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const importJsonFileSchema = zfd.formData({
|
||||
file: zfd.file().check(checkJsonImportFile),
|
||||
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, checkJsonImportFile, 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/v4";
|
||||
|
||||
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>;
|
||||
20
packages/old-import/src/widgets/definitions/bookmark.ts
Normal file
20
packages/old-import/src/widgets/definitions/bookmark.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrBookmarkDefinition = CommonOldmarrWidgetDefinition<
|
||||
"bookmark",
|
||||
{
|
||||
name: string;
|
||||
items: {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
iconUrl: string;
|
||||
openNewTab: boolean;
|
||||
hideHostname: boolean;
|
||||
hideIcon: boolean;
|
||||
}[];
|
||||
layout: "autoGrid" | "horizontal" | "vertical";
|
||||
}
|
||||
>;
|
||||
|
||||
export type BookmarkApp = OldmarrBookmarkDefinition["options"]["items"][number];
|
||||
11
packages/old-import/src/widgets/definitions/calendar.ts
Normal file
11
packages/old-import/src/widgets/definitions/calendar.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrCalendarDefinition = CommonOldmarrWidgetDefinition<
|
||||
"calendar",
|
||||
{
|
||||
hideWeekDays: boolean;
|
||||
showUnmonitored: boolean;
|
||||
radarrReleaseType: "inCinemas" | "physicalRelease" | "digitalRelease";
|
||||
fontSize: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
>;
|
||||
9
packages/old-import/src/widgets/definitions/common.ts
Normal file
9
packages/old-import/src/widgets/definitions/common.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { OldmarrWidgetKind } from "@homarr/old-schema";
|
||||
|
||||
export interface CommonOldmarrWidgetDefinition<
|
||||
TId extends OldmarrWidgetKind,
|
||||
TOptions extends Record<string, unknown>,
|
||||
> {
|
||||
id: TId;
|
||||
options: TOptions;
|
||||
}
|
||||
53
packages/old-import/src/widgets/definitions/dashdot.ts
Normal file
53
packages/old-import/src/widgets/definitions/dashdot.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrDashdotDefinition = CommonOldmarrWidgetDefinition<
|
||||
"dashdot",
|
||||
{
|
||||
dashName: string;
|
||||
url: string;
|
||||
usePercentages: boolean;
|
||||
columns: number;
|
||||
graphHeight: number;
|
||||
graphsOrder: (
|
||||
| {
|
||||
key: "storage";
|
||||
subValues: {
|
||||
enabled: boolean;
|
||||
compactView: boolean;
|
||||
span: number;
|
||||
multiView: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
key: "network";
|
||||
subValues: {
|
||||
enabled: boolean;
|
||||
compactView: boolean;
|
||||
span: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
key: "cpu";
|
||||
subValues: {
|
||||
enabled: boolean;
|
||||
multiView: boolean;
|
||||
span: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
key: "ram";
|
||||
subValues: {
|
||||
enabled: boolean;
|
||||
span: number;
|
||||
};
|
||||
}
|
||||
| {
|
||||
key: "gpu";
|
||||
subValues: {
|
||||
enabled: boolean;
|
||||
span: number;
|
||||
};
|
||||
}
|
||||
)[];
|
||||
}
|
||||
>;
|
||||
21
packages/old-import/src/widgets/definitions/date.ts
Normal file
21
packages/old-import/src/widgets/definitions/date.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrDateDefinition = CommonOldmarrWidgetDefinition<
|
||||
"date",
|
||||
{
|
||||
timezone: string;
|
||||
customTitle: string;
|
||||
display24HourFormat: boolean;
|
||||
dateFormat:
|
||||
| "hide"
|
||||
| "dddd, MMMM D"
|
||||
| "dddd, D MMMM"
|
||||
| "MMM D"
|
||||
| "D MMM"
|
||||
| "DD/MM/YYYY"
|
||||
| "MM/DD/YYYY"
|
||||
| "DD/MM"
|
||||
| "MM/DD";
|
||||
titleState: "none" | "city" | "both";
|
||||
}
|
||||
>;
|
||||
4
packages/old-import/src/widgets/definitions/dlspeed.ts
Normal file
4
packages/old-import/src/widgets/definitions/dlspeed.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export type OldmarrDlspeedDefinition = CommonOldmarrWidgetDefinition<"dlspeed", {}>;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrDnsHoleControlsDefinition = CommonOldmarrWidgetDefinition<
|
||||
"dns-hole-controls",
|
||||
{
|
||||
showToggleAllButtons: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrDnsHoleSummaryDefinition = CommonOldmarrWidgetDefinition<
|
||||
"dns-hole-summary",
|
||||
{ usePiHoleColors: boolean; layout: "column" | "row" | "grid" }
|
||||
>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrHealthMonitoringDefinition = CommonOldmarrWidgetDefinition<
|
||||
"health-monitoring",
|
||||
{
|
||||
fahrenheit: boolean;
|
||||
cpu: boolean;
|
||||
memory: boolean;
|
||||
fileSystem: boolean;
|
||||
defaultTabState: "system" | "cluster";
|
||||
node: string;
|
||||
defaultViewState: "storage" | "none" | "node" | "vm" | "lxc";
|
||||
summary: boolean;
|
||||
showNode: boolean;
|
||||
showVM: boolean;
|
||||
showLXCs: boolean;
|
||||
showStorage: boolean;
|
||||
sectionIndicatorColor: "all" | "any";
|
||||
ignoreCert: boolean;
|
||||
}
|
||||
>;
|
||||
16
packages/old-import/src/widgets/definitions/iframe.ts
Normal file
16
packages/old-import/src/widgets/definitions/iframe.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrIframeDefinition = CommonOldmarrWidgetDefinition<
|
||||
"iframe",
|
||||
{
|
||||
embedUrl: string;
|
||||
allowFullScreen: boolean;
|
||||
allowScrolling: boolean;
|
||||
allowTransparency: boolean;
|
||||
allowPayment: boolean;
|
||||
allowAutoPlay: boolean;
|
||||
allowMicrophone: boolean;
|
||||
allowCamera: boolean;
|
||||
allowGeolocation: boolean;
|
||||
}
|
||||
>;
|
||||
81
packages/old-import/src/widgets/definitions/index.ts
Normal file
81
packages/old-import/src/widgets/definitions/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { Inverse } from "@homarr/common/types";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import type { OldmarrBookmarkDefinition } from "./bookmark";
|
||||
import type { OldmarrCalendarDefinition } from "./calendar";
|
||||
import type { OldmarrDashdotDefinition } from "./dashdot";
|
||||
import type { OldmarrDateDefinition } from "./date";
|
||||
import type { OldmarrDlspeedDefinition } from "./dlspeed";
|
||||
import type { OldmarrDnsHoleControlsDefinition } from "./dns-hole-controls";
|
||||
import type { OldmarrDnsHoleSummaryDefinition } from "./dns-hole-summary";
|
||||
import type { OldmarrHealthMonitoringDefinition } from "./health-monitoring";
|
||||
import type { OldmarrIframeDefinition } from "./iframe";
|
||||
import type { OldmarrIndexerManagerDefinition } from "./indexer-manager";
|
||||
import type { OldmarrMediaRequestListDefinition } from "./media-requests-list";
|
||||
import type { OldmarrMediaRequestStatsDefinition } from "./media-requests-stats";
|
||||
import type { OldmarrMediaServerDefinition } from "./media-server";
|
||||
import type { OldmarrMediaTranscodingDefinition } from "./media-transcoding";
|
||||
import type { OldmarrNotebookDefinition } from "./notebook";
|
||||
import type { OldmarrRssDefinition } from "./rss";
|
||||
import type { OldmarrSmartHomeEntityStateDefinition } from "./smart-home-entity-state";
|
||||
import type { OldmarrSmartHomeTriggerAutomationDefinition } from "./smart-home-trigger-automation";
|
||||
import type { OldmarrTorrentStatusDefinition } from "./torrent-status";
|
||||
import type { OldmarrUsenetDefinition } from "./usenet";
|
||||
import type { OldmarrVideoStreamDefinition } from "./video-stream";
|
||||
import type { OldmarrWeatherDefinition } from "./weather";
|
||||
|
||||
export type OldmarrWidgetDefinitions =
|
||||
| OldmarrWeatherDefinition
|
||||
| OldmarrDateDefinition
|
||||
| OldmarrCalendarDefinition
|
||||
| OldmarrIndexerManagerDefinition
|
||||
| OldmarrDashdotDefinition
|
||||
| OldmarrUsenetDefinition
|
||||
| OldmarrTorrentStatusDefinition
|
||||
| OldmarrDlspeedDefinition
|
||||
| OldmarrRssDefinition
|
||||
| OldmarrVideoStreamDefinition
|
||||
| OldmarrIframeDefinition
|
||||
| OldmarrMediaServerDefinition
|
||||
| OldmarrMediaRequestListDefinition
|
||||
| OldmarrMediaRequestStatsDefinition
|
||||
| OldmarrDnsHoleSummaryDefinition
|
||||
| OldmarrDnsHoleControlsDefinition
|
||||
| OldmarrBookmarkDefinition
|
||||
| OldmarrNotebookDefinition
|
||||
| OldmarrSmartHomeEntityStateDefinition
|
||||
| OldmarrSmartHomeTriggerAutomationDefinition
|
||||
| OldmarrHealthMonitoringDefinition
|
||||
| OldmarrMediaTranscodingDefinition;
|
||||
|
||||
export const widgetKindMapping = {
|
||||
date: "clock",
|
||||
calendar: "calendar",
|
||||
"torrents-status": "downloads",
|
||||
weather: "weather",
|
||||
rss: "rssFeed",
|
||||
"video-stream": "video",
|
||||
iframe: "iframe",
|
||||
"media-server": "mediaServer",
|
||||
"dns-hole-summary": "dnsHoleSummary",
|
||||
"dns-hole-controls": "dnsHoleControls",
|
||||
notebook: "notebook",
|
||||
"smart-home/entity-state": "smartHome-entityState",
|
||||
"smart-home/trigger-automation": "smartHome-executeAutomation",
|
||||
"media-requests-list": "mediaRequests-requestList",
|
||||
"media-requests-stats": "mediaRequests-requestStats",
|
||||
"indexer-manager": "indexerManager",
|
||||
bookmark: "bookmarks",
|
||||
"health-monitoring": "healthMonitoring",
|
||||
dashdot: "healthMonitoring",
|
||||
"media-transcoding": "mediaTranscoding",
|
||||
dlspeed: null,
|
||||
usenet: "downloads",
|
||||
} satisfies Record<OldmarrWidgetDefinitions["id"], WidgetKind | null>;
|
||||
|
||||
export type WidgetMapping = typeof widgetKindMapping;
|
||||
export type InversedWidgetMapping = Inverse<Omit<typeof widgetKindMapping, "dlspeed">>;
|
||||
|
||||
export const mapKind = (kind: OldmarrWidgetDefinitions["id"]): keyof InversedWidgetMapping | null =>
|
||||
objectEntries(widgetKindMapping).find(([key]) => key === kind)?.[1] ?? null;
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrIndexerManagerDefinition = CommonOldmarrWidgetDefinition<
|
||||
"indexer-manager",
|
||||
{
|
||||
openIndexerSiteInNewTab: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrMediaRequestListDefinition = CommonOldmarrWidgetDefinition<
|
||||
"media-requests-list",
|
||||
{
|
||||
replaceLinksWithExternalHost: boolean;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrMediaRequestStatsDefinition = CommonOldmarrWidgetDefinition<
|
||||
"media-requests-stats",
|
||||
{
|
||||
replaceLinksWithExternalHost: boolean;
|
||||
openInNewTab: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export type OldmarrMediaServerDefinition = CommonOldmarrWidgetDefinition<"media-server", {}>;
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrMediaTranscodingDefinition = CommonOldmarrWidgetDefinition<
|
||||
"media-transcoding",
|
||||
{
|
||||
defaultView: "workers" | "queue" | "statistics";
|
||||
showHealthCheck: boolean;
|
||||
showHealthChecksInQueue: boolean;
|
||||
queuePageSize: number;
|
||||
showAppIcon: boolean;
|
||||
}
|
||||
>;
|
||||
10
packages/old-import/src/widgets/definitions/notebook.ts
Normal file
10
packages/old-import/src/widgets/definitions/notebook.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrNotebookDefinition = CommonOldmarrWidgetDefinition<
|
||||
"notebook",
|
||||
{
|
||||
showToolbar: boolean;
|
||||
allowReadOnlyCheck: boolean;
|
||||
content: string;
|
||||
}
|
||||
>;
|
||||
15
packages/old-import/src/widgets/definitions/rss.ts
Normal file
15
packages/old-import/src/widgets/definitions/rss.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrRssDefinition = CommonOldmarrWidgetDefinition<
|
||||
"rss",
|
||||
{
|
||||
rssFeedUrl: string[];
|
||||
enableRtl: boolean;
|
||||
refreshInterval: number;
|
||||
dangerousAllowSanitizedItemContent: boolean;
|
||||
textLinesClamp: number;
|
||||
sortByPublishDateAscending: boolean;
|
||||
sortPostsWithoutPublishDateToTheTop: boolean;
|
||||
maximumAmountOfPosts: number;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrSmartHomeEntityStateDefinition = CommonOldmarrWidgetDefinition<
|
||||
"smart-home/entity-state",
|
||||
{
|
||||
entityId: string;
|
||||
appendUnit: boolean;
|
||||
genericToggle: boolean;
|
||||
automationId: string;
|
||||
displayName: string;
|
||||
displayFriendlyName: boolean;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrSmartHomeTriggerAutomationDefinition = CommonOldmarrWidgetDefinition<
|
||||
"smart-home/trigger-automation",
|
||||
{
|
||||
automationId: string;
|
||||
displayName: string;
|
||||
}
|
||||
>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrTorrentStatusDefinition = CommonOldmarrWidgetDefinition<
|
||||
"torrents-status",
|
||||
{
|
||||
displayCompletedTorrents: boolean;
|
||||
displayActiveTorrents: boolean;
|
||||
speedLimitOfActiveTorrents: number;
|
||||
displayStaleTorrents: boolean;
|
||||
labelFilterIsWhitelist: boolean;
|
||||
labelFilter: string[];
|
||||
displayRatioWithFilter: boolean;
|
||||
columnOrdering: boolean;
|
||||
rowSorting: boolean;
|
||||
columns: ("up" | "down" | "eta" | "progress")[];
|
||||
nameColumnSize: number;
|
||||
}
|
||||
>;
|
||||
4
packages/old-import/src/widgets/definitions/usenet.ts
Normal file
4
packages/old-import/src/widgets/definitions/usenet.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export type OldmarrUsenetDefinition = CommonOldmarrWidgetDefinition<"usenet", {}>;
|
||||
11
packages/old-import/src/widgets/definitions/video-stream.ts
Normal file
11
packages/old-import/src/widgets/definitions/video-stream.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrVideoStreamDefinition = CommonOldmarrWidgetDefinition<
|
||||
"video-stream",
|
||||
{
|
||||
FeedUrl: string;
|
||||
autoPlay: boolean;
|
||||
muted: boolean;
|
||||
controls: boolean;
|
||||
}
|
||||
>;
|
||||
26
packages/old-import/src/widgets/definitions/weather.ts
Normal file
26
packages/old-import/src/widgets/definitions/weather.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { CommonOldmarrWidgetDefinition } from "./common";
|
||||
|
||||
export type OldmarrWeatherDefinition = CommonOldmarrWidgetDefinition<
|
||||
"weather",
|
||||
{
|
||||
displayInFahrenheit: boolean;
|
||||
displayCityName: boolean;
|
||||
displayWeekly: boolean;
|
||||
forecastDays: number;
|
||||
location: {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
dateFormat:
|
||||
| "hide"
|
||||
| "dddd, MMMM D"
|
||||
| "dddd, D MMMM"
|
||||
| "MMM D"
|
||||
| "D MMM"
|
||||
| "DD/MM/YYYY"
|
||||
| "MM/DD/YYYY"
|
||||
| "DD/MM"
|
||||
| "MM/DD";
|
||||
}
|
||||
>;
|
||||
215
packages/old-import/src/widgets/options.ts
Normal file
215
packages/old-import/src/widgets/options.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { WidgetComponentProps } from "../../../widgets/src/definition";
|
||||
import type { InversedWidgetMapping, OldmarrWidgetDefinitions, WidgetMapping } from "./definitions";
|
||||
import { mapKind } from "./definitions";
|
||||
|
||||
const logger = createLogger({ module: "mapOptions" });
|
||||
|
||||
// This type enforces, that for all widget mappings there is a corresponding option mapping,
|
||||
// each option of newmarr can be mapped from the value of the oldmarr options
|
||||
type OptionMapping = {
|
||||
[WidgetKey in keyof InversedWidgetMapping]: InversedWidgetMapping[WidgetKey] extends null
|
||||
? null
|
||||
: {
|
||||
[OptionsKey in keyof WidgetComponentProps<WidgetKey>["options"]]: (
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: InversedWidgetMapping[WidgetKey] }>["options"],
|
||||
appsMap: Map<string, string>,
|
||||
) => WidgetComponentProps<WidgetKey>["options"][OptionsKey] | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
const optionMapping: OptionMapping = {
|
||||
"mediaRequests-requestList": {
|
||||
linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab,
|
||||
},
|
||||
"mediaRequests-requestStats": {},
|
||||
bookmarks: {
|
||||
title: (oldOptions) => oldOptions.name,
|
||||
// It's safe to assume that the app exists, because the app is always created before the widget
|
||||
// And the mapping is created in insertAppsAsync
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
items: (oldOptions, appsMap) => oldOptions.items.map((item) => appsMap.get(item.id)!),
|
||||
layout: (oldOptions) => {
|
||||
const mappedLayouts: Record<typeof oldOptions.layout, WidgetComponentProps<"bookmarks">["options"]["layout"]> = {
|
||||
autoGrid: "grid",
|
||||
horizontal: "row",
|
||||
vertical: "column",
|
||||
};
|
||||
|
||||
return mappedLayouts[oldOptions.layout];
|
||||
},
|
||||
hideTitle: () => undefined,
|
||||
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
|
||||
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
|
||||
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
|
||||
},
|
||||
calendar: {
|
||||
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
||||
filterFutureMonths: () => undefined,
|
||||
filterPastMonths: () => undefined,
|
||||
showUnmonitored: ({ showUnmonitored }) => showUnmonitored,
|
||||
},
|
||||
clock: {
|
||||
customTitle: (oldOptions) => oldOptions.customTitle,
|
||||
customTitleToggle: (oldOptions) => oldOptions.titleState !== "none",
|
||||
dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat),
|
||||
is24HourFormat: (oldOptions) => oldOptions.display24HourFormat,
|
||||
showDate: (oldOptions) => oldOptions.dateFormat !== "hide",
|
||||
showSeconds: () => undefined,
|
||||
timezone: (oldOptions) => oldOptions.timezone,
|
||||
useCustomTimezone: () => true,
|
||||
customTimeFormat: () => undefined,
|
||||
customDateFormat: () => undefined,
|
||||
},
|
||||
downloads: {
|
||||
activeTorrentThreshold: (oldOptions) =>
|
||||
"speedLimitOfActiveTorrents" in oldOptions ? oldOptions.speedLimitOfActiveTorrents : undefined,
|
||||
applyFilterToRatio: (oldOptions) =>
|
||||
"displayRatioWithFilter" in oldOptions ? oldOptions.displayRatioWithFilter : undefined,
|
||||
categoryFilter: (oldOptions) => ("labelFilter" in oldOptions ? oldOptions.labelFilter : undefined),
|
||||
filterIsWhitelist: (oldOptions) =>
|
||||
"labelFilterIsWhitelist" in oldOptions ? oldOptions.labelFilterIsWhitelist : undefined,
|
||||
enableRowSorting: (oldOptions) => ("rowSorting" in oldOptions ? oldOptions.rowSorting : undefined),
|
||||
showCompletedTorrent: (oldOptions) =>
|
||||
"displayCompletedTorrents" in oldOptions ? oldOptions.displayCompletedTorrents : undefined,
|
||||
columns: () => ["integration", "name", "progress", "time", "actions"],
|
||||
defaultSort: () => "type",
|
||||
descendingDefaultSort: () => false,
|
||||
showCompletedUsenet: () => true,
|
||||
showCompletedHttp: () => true,
|
||||
limitPerIntegration: () => undefined,
|
||||
},
|
||||
weather: {
|
||||
forecastDayCount: (oldOptions) => oldOptions.forecastDays,
|
||||
hasForecast: (oldOptions) => oldOptions.displayWeekly,
|
||||
isFormatFahrenheit: (oldOptions) => oldOptions.displayInFahrenheit,
|
||||
disableTemperatureDecimals: () => undefined,
|
||||
showCurrentWindSpeed: () => undefined,
|
||||
location: (oldOptions) => oldOptions.location,
|
||||
showCity: (oldOptions) => oldOptions.displayCityName,
|
||||
dateFormat: (oldOptions) => (oldOptions.dateFormat === "hide" ? undefined : oldOptions.dateFormat),
|
||||
useImperialSpeed: () => undefined,
|
||||
},
|
||||
iframe: {
|
||||
embedUrl: (oldOptions) => oldOptions.embedUrl,
|
||||
allowAutoPlay: (oldOptions) => oldOptions.allowAutoPlay,
|
||||
allowFullScreen: (oldOptions) => oldOptions.allowFullScreen,
|
||||
allowPayment: (oldOptions) => oldOptions.allowPayment,
|
||||
allowCamera: (oldOptions) => oldOptions.allowCamera,
|
||||
allowMicrophone: (oldOptions) => oldOptions.allowMicrophone,
|
||||
allowGeolocation: (oldOptions) => oldOptions.allowGeolocation,
|
||||
allowScrolling: (oldOptions) => oldOptions.allowScrolling,
|
||||
},
|
||||
video: {
|
||||
feedUrl: (oldOptions) => oldOptions.FeedUrl,
|
||||
hasAutoPlay: (oldOptions) => oldOptions.autoPlay,
|
||||
hasControls: (oldOptions) => oldOptions.controls,
|
||||
isMuted: (oldOptions) => oldOptions.muted,
|
||||
},
|
||||
dnsHoleControls: {
|
||||
showToggleAllButtons: (oldOptions) => oldOptions.showToggleAllButtons,
|
||||
},
|
||||
dnsHoleSummary: {
|
||||
layout: (oldOptions) => oldOptions.layout,
|
||||
usePiHoleColors: (oldOptions) => oldOptions.usePiHoleColors,
|
||||
},
|
||||
rssFeed: {
|
||||
feedUrls: (oldOptions) => oldOptions.rssFeedUrl,
|
||||
enableRtl: (oldOptions) => oldOptions.enableRtl,
|
||||
maximumAmountPosts: (oldOptions) => oldOptions.maximumAmountOfPosts,
|
||||
textLinesClamp: (oldOptions) => oldOptions.textLinesClamp,
|
||||
hideDescription: () => undefined,
|
||||
},
|
||||
notebook: {
|
||||
allowReadOnlyCheck: (oldOptions) => oldOptions.allowReadOnlyCheck,
|
||||
content: (oldOptions) => oldOptions.content,
|
||||
showToolbar: (oldOptions) => oldOptions.showToolbar,
|
||||
},
|
||||
"smartHome-entityState": {
|
||||
entityId: (oldOptions) => oldOptions.entityId,
|
||||
displayName: (oldOptions) => oldOptions.displayName,
|
||||
clickable: () => undefined,
|
||||
entityUnit: () => undefined,
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
automationId: (oldOptions) => oldOptions.automationId,
|
||||
displayName: (oldOptions) => oldOptions.displayName,
|
||||
},
|
||||
mediaServer: {
|
||||
showOnlyPlaying: () => undefined,
|
||||
},
|
||||
indexerManager: {
|
||||
openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab,
|
||||
},
|
||||
healthMonitoring: {
|
||||
cpu: (oldOptions) =>
|
||||
"cpu" in oldOptions
|
||||
? oldOptions.cpu
|
||||
: oldOptions.graphsOrder.some((graph) => graph.key === "cpu" && graph.subValues.enabled),
|
||||
memory: (oldOptions) =>
|
||||
"memory" in oldOptions
|
||||
? oldOptions.memory
|
||||
: oldOptions.graphsOrder.some((graph) => graph.key === "ram" && graph.subValues.enabled),
|
||||
fahrenheit: (oldOptions) => ("fahrenheit" in oldOptions ? oldOptions.fahrenheit : undefined),
|
||||
fileSystem: (oldOptions) =>
|
||||
"fileSystem" in oldOptions
|
||||
? oldOptions.fileSystem
|
||||
: oldOptions.graphsOrder.some((graph) => graph.key === "storage" && graph.subValues.enabled),
|
||||
defaultTab: (oldOptions) => ("defaultTabState" in oldOptions ? oldOptions.defaultTabState : undefined),
|
||||
sectionIndicatorRequirement: (oldOptions) =>
|
||||
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
|
||||
showUptime: () => undefined,
|
||||
visibleClusterSections: (oldOptions) => {
|
||||
if (!("showNode" in oldOptions)) return undefined;
|
||||
|
||||
const oldKeys = {
|
||||
showNode: "node" as const,
|
||||
showLXCs: "lxc" as const,
|
||||
showVM: "qemu" as const,
|
||||
showStorage: "storage" as const,
|
||||
} satisfies Partial<Record<keyof typeof oldOptions, string>>;
|
||||
|
||||
return objectEntries(oldKeys)
|
||||
.filter(([key]) => oldOptions[key])
|
||||
.map(([_, section]) => section);
|
||||
},
|
||||
},
|
||||
mediaTranscoding: {
|
||||
defaultView: (oldOptions) => oldOptions.defaultView,
|
||||
queuePageSize: (oldOptions) => oldOptions.queuePageSize,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the oldmarr options to the newmarr options
|
||||
* @param type old widget type
|
||||
* @param oldOptions oldmarr options for this item
|
||||
* @param appsMap map of old app ids to new app ids
|
||||
* @returns newmarr options for this item or null if the item did not exist in oldmarr
|
||||
*/
|
||||
export const mapOptions = <K extends OldmarrWidgetDefinitions["id"]>(
|
||||
type: K,
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: K }>["options"],
|
||||
appsMap: Map<string, string>,
|
||||
) => {
|
||||
logger.debug("Mapping old homarr options for widget", { type, options: JSON.stringify(oldOptions) });
|
||||
const kind = mapKind(type);
|
||||
if (!kind) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapping = optionMapping[kind];
|
||||
return objectEntries(mapping).reduce(
|
||||
(acc, [key, value]: [string, (oldOptions: Record<string, unknown>, appsMap: Map<string, string>) => unknown]) => {
|
||||
const newValue = value(oldOptions, appsMap);
|
||||
logger.debug("Mapping old homarr option", { kind, key, newValue });
|
||||
if (newValue !== undefined) {
|
||||
acc[key] = newValue;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, unknown>,
|
||||
) as WidgetComponentProps<Exclude<WidgetMapping[K], null>>["options"];
|
||||
};
|
||||
Reference in New Issue
Block a user