diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx
index 441ccec03..bf38141a8 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx
@@ -62,6 +62,22 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
}
};
+ const copyLegacyDefinition = async () => {
+ try {
+ const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
+ await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
+ showSuccessNotification({
+ title: t("action.copyLegacyDefinition"),
+ message: t("notification.legacyDefinitionCopied"),
+ });
+ } catch (error) {
+ showErrorNotification({
+ title: t("action.copyLegacyDefinition"),
+ message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"),
+ });
+ }
+ };
+
const queueMigratedWidget = (text: string) => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
@@ -122,6 +138,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
}
};
+ const exportLegacyDefinition = async () => {
+ try {
+ const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
+ downloadJson(data, `${widget.name}.legacy.json`);
+ } catch {
+ showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") });
+ }
+ };
+
const handleDuplicate = () => {
duplicateMutation.mutate(
{ id: widget.id },
@@ -195,6 +220,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
)}
{widget.migrationRequired && (
<>
+
void copyLegacyDefinition()} leftSection={}>
+ {t("action.copyLegacyDefinition")}
+
+ void exportLegacyDefinition()} leftSection={}>
+ {t("action.exportLegacyDefinition")}
+
+
void copyMigrationPrompt()} leftSection={}>
{t("action.copyMigrationPrompt")}
@@ -247,3 +279,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
>
);
};
+
+function downloadJson(data: unknown, filename: string) {
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/packages/api/src/router/custom-widget/transfer-procedures.ts b/packages/api/src/router/custom-widget/transfer-procedures.ts
index 4be5619d9..7b295d639 100644
--- a/packages/api/src/router/custom-widget/transfer-procedures.ts
+++ b/packages/api/src/router/custom-widget/transfer-procedures.ts
@@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server";
+import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
@@ -26,6 +27,32 @@ export const transferProcedures = {
return parseStoredCustomWidgetDefinition(definition);
}),
+ exportLegacy: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
+ where: eq(legacyCustomWidgetDefinitions.id, input.id),
+ with: { secrets: true },
+ });
+ if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
+
+ return {
+ $schema: "homarr-custom-widget-v1" as const,
+ name: definition.name,
+ description: definition.description,
+ iconUrl: definition.iconUrl,
+ url: definition.url,
+ authType: definition.authType,
+ headerName: definition.headerName,
+ method: definition.method,
+ requestBody: definition.requestBody,
+ displayType: definition.displayType,
+ displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
+ configuredSecretKinds: definition.secrets.map(({ kind }) => kind),
+ };
+ }),
+
import: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
@@ -117,6 +144,14 @@ export const transferProcedures = {
}),
};
+function parseLegacyDisplayConfig(value: string): unknown {
+ try {
+ return parseSuperJson(value);
+ } catch {
+ return value;
+ }
+}
+
function canPreserveLegacySecrets(
legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
widget: z.infer,
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 8643424be..ddcfbd77c 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -836,7 +836,7 @@
"invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.",
"migrationRequired": "Migration required",
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
- "migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then copy its single JSON response and choose Paste migrated widget. Existing board placements keep working after migration.",
+ "migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.",
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
},
@@ -866,6 +866,8 @@
"downloadSkill": "View Custom Widget Skill",
"publishWorkshop": "Publish to Workshop",
"migrate": "Migrate Custom Widget",
+ "copyLegacyDefinition": "Copy original widget content",
+ "exportLegacyDefinition": "Export original widget content",
"copyMigrationPrompt": "Copy LLM migration prompt",
"pasteMigration": "Paste migrated widget",
"importMigration": "Import migrated JSON file",
@@ -1224,6 +1226,8 @@
"imported": "Widget imported successfully.",
"migrated": "Legacy widget migrated successfully. Existing board placements now use the v2 definition.",
"migrationError": "Failed to migrate the legacy widget. Ensure the file uses the homarr-custom-widget-v2 schema.",
+ "legacyDefinitionCopied": "The original widget content was copied. Stored credential values are not included.",
+ "legacyDefinitionCopyError": "Failed to copy the original widget content.",
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
"migrationPromptCopyError": "Failed to copy the migration prompt.",
"aiPromptCopied": "AI prompt copied to clipboard",