Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

View 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 ?? [];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
import { Button, Group, PasswordInput, Stack } from "@mantine/core";
import { z } from "zod/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") });

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View 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() });
};

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

View File

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

View File

@@ -0,0 +1,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(),
});

View 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>>;

View 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");
};

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

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

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

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

View File

@@ -0,0 +1,48 @@
const oldColors = [
"dark",
"gray",
"red",
"pink",
"grape",
"violet",
"indigo",
"blue",
"cyan",
"green",
"lime",
"yellow",
"orange",
"teal",
] as const;
type OldColor = (typeof oldColors)[number];
export const mapColor = (color: string | undefined, fallback: string) => {
if (!color) {
return fallback;
}
if (!oldColors.some((mantineColor) => color === mantineColor)) {
return fallback;
}
const mantineColor = color as OldColor;
return mappedColors[mantineColor];
};
const mappedColors: Record<(typeof oldColors)[number], string> = {
blue: "#228be6",
cyan: "#15aabf",
dark: "#2e2e2e",
grape: "#be4bdb",
gray: "#868e96",
green: "#40c057",
indigo: "#4c6ef5",
lime: "#82c91e",
orange: "#fd7e14",
pink: "#e64980",
red: "#fa5252",
teal: "#12b886",
violet: "#7950f2",
yellow: "#fab005",
};

View File

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

View 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,
},
];
};

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

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

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

View 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",
},
]);
});
});

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

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

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

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

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

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

View File

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

View 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>;

View 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];

View File

@@ -0,0 +1,11 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrCalendarDefinition = CommonOldmarrWidgetDefinition<
"calendar",
{
hideWeekDays: boolean;
showUnmonitored: boolean;
radarrReleaseType: "inCinemas" | "physicalRelease" | "digitalRelease";
fontSize: "xs" | "sm" | "md" | "lg" | "xl";
}
>;

View File

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

View File

@@ -0,0 +1,53 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrDashdotDefinition = CommonOldmarrWidgetDefinition<
"dashdot",
{
dashName: string;
url: string;
usePercentages: boolean;
columns: number;
graphHeight: number;
graphsOrder: (
| {
key: "storage";
subValues: {
enabled: boolean;
compactView: boolean;
span: number;
multiView: boolean;
};
}
| {
key: "network";
subValues: {
enabled: boolean;
compactView: boolean;
span: number;
};
}
| {
key: "cpu";
subValues: {
enabled: boolean;
multiView: boolean;
span: number;
};
}
| {
key: "ram";
subValues: {
enabled: boolean;
span: number;
};
}
| {
key: "gpu";
subValues: {
enabled: boolean;
span: number;
};
}
)[];
}
>;

View File

@@ -0,0 +1,21 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrDateDefinition = CommonOldmarrWidgetDefinition<
"date",
{
timezone: string;
customTitle: string;
display24HourFormat: boolean;
dateFormat:
| "hide"
| "dddd, MMMM D"
| "dddd, D MMMM"
| "MMM D"
| "D MMM"
| "DD/MM/YYYY"
| "MM/DD/YYYY"
| "DD/MM"
| "MM/DD";
titleState: "none" | "city" | "both";
}
>;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrHealthMonitoringDefinition = CommonOldmarrWidgetDefinition<
"health-monitoring",
{
fahrenheit: boolean;
cpu: boolean;
memory: boolean;
fileSystem: boolean;
defaultTabState: "system" | "cluster";
node: string;
defaultViewState: "storage" | "none" | "node" | "vm" | "lxc";
summary: boolean;
showNode: boolean;
showVM: boolean;
showLXCs: boolean;
showStorage: boolean;
sectionIndicatorColor: "all" | "any";
ignoreCert: boolean;
}
>;

View File

@@ -0,0 +1,16 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrIframeDefinition = CommonOldmarrWidgetDefinition<
"iframe",
{
embedUrl: string;
allowFullScreen: boolean;
allowScrolling: boolean;
allowTransparency: boolean;
allowPayment: boolean;
allowAutoPlay: boolean;
allowMicrophone: boolean;
allowCamera: boolean;
allowGeolocation: boolean;
}
>;

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrMediaTranscodingDefinition = CommonOldmarrWidgetDefinition<
"media-transcoding",
{
defaultView: "workers" | "queue" | "statistics";
showHealthCheck: boolean;
showHealthChecksInQueue: boolean;
queuePageSize: number;
showAppIcon: boolean;
}
>;

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrSmartHomeEntityStateDefinition = CommonOldmarrWidgetDefinition<
"smart-home/entity-state",
{
entityId: string;
appendUnit: boolean;
genericToggle: boolean;
automationId: string;
displayName: string;
displayFriendlyName: boolean;
}
>;

View File

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

View File

@@ -0,0 +1,18 @@
import type { CommonOldmarrWidgetDefinition } from "./common";
export type OldmarrTorrentStatusDefinition = CommonOldmarrWidgetDefinition<
"torrents-status",
{
displayCompletedTorrents: boolean;
displayActiveTorrents: boolean;
speedLimitOfActiveTorrents: number;
displayStaleTorrents: boolean;
labelFilterIsWhitelist: boolean;
labelFilter: string[];
displayRatioWithFilter: boolean;
columnOrdering: boolean;
rowSorting: boolean;
columns: ("up" | "down" | "eta" | "progress")[];
nameColumnSize: number;
}
>;

View File

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

View File

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

View File

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

View 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"];
};