feat: Add database import/export functionality (Issue #10)
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

Backend:
- New /api/v1/backup/export endpoint - exports all data as single JSON
- New /api/v1/backup/import endpoint - imports JSON with merge or replace
- Handles vendors, filaments, spools, adjustments, settings
- Remaps IDs to maintain relationships during import

Frontend:
- Added Backup & Restore section to Settings page
- Download Backup button for export
- Import with merge or replace options
- Warning confirmation for replace mode

This enables migrating between PostgreSQL and SQLite databases.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-14 22:47:09 -06:00
parent 34eaa6a9b1
commit 613176dd42
4 changed files with 450 additions and 3 deletions

View File

@@ -348,6 +348,22 @@
"tooltip": "Round prices to the nearest whole number."
}
},
"backup": {
"title": "Backup & Restore",
"description": "Export all your data for backup or migrate to a different database.",
"export_title": "Export Data",
"export_button": "Download Backup",
"export_success": "Backup downloaded successfully",
"export_error": "Failed to export data",
"import_title": "Import Data",
"select_file": "Select backup file",
"import_merge": "Import (Merge)",
"import_replace": "Import (Replace All)",
"import_success": "Data imported successfully",
"import_error": "Failed to import data",
"replace_warning_title": "Replace all data?",
"replace_warning": "This will DELETE all existing data and replace it with the backup. This cannot be undone!"
},
"extra_fields": {
"tab": "Extra Fields",
"description": "<p>Here you can add extra custom fields to your entities.</p><p>Once a field is added, you can not change its key or type, and for choice type fields you can not remove choices or change the multi choice state. If you remove a field, the associated data for all entities will be deleted.</p><p>The key is what other programs read/write the data as, so if your custom field is supposed to integrate with a third-party program, make sure to set it correctly. Default value is only applied to new items.</p><p>Extra fields can not be sorted or filtered in the table views.</p>",

View File

@@ -1,6 +1,9 @@
import { DownloadOutlined, UploadOutlined } from "@ant-design/icons";
import { useTranslate } from "@refinedev/core";
import { Button, Checkbox, Form, Input, message } from "antd";
import { useEffect } from "react";
import { Button, Checkbox, Divider, Form, Input, message, Popconfirm, Space, Upload } from "antd";
import type { UploadFile } from "antd";
import { useEffect, useState } from "react";
import { getAPIURL } from "../../utils/url";
import { useGetSettings, useSetSetting } from "../../utils/querySettings";
export function GeneralSettings() {
@@ -110,7 +113,141 @@ export function GeneralSettings() {
</Button>
</Form.Item>
</Form>
<Divider />
<BackupRestore messageApi={messageApi} t={t} />
{contextHolder}
</>
);
}
interface BackupRestoreProps {
messageApi: ReturnType<typeof message.useMessage>[0];
t: ReturnType<typeof useTranslate>;
}
function BackupRestore({ messageApi, t }: BackupRestoreProps) {
const [importing, setImporting] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const handleExport = async () => {
try {
const response = await fetch(`${getAPIURL()}/backup/export`);
if (!response.ok) throw new Error("Export failed");
const blob = await response.blob();
const filename = response.headers.get("content-disposition")?.match(/filename="(.+)"/)?.[1]
|| `spoolman_backup_${new Date().toISOString().slice(0, 10)}.json`;
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
messageApi.success(t("settings.backup.export_success"));
} catch (error) {
messageApi.error(t("settings.backup.export_error"));
}
};
const handleImport = async (replace: boolean) => {
if (fileList.length === 0) {
messageApi.warning(t("settings.backup.select_file"));
return;
}
setImporting(true);
try {
const formData = new FormData();
formData.append("file", fileList[0].originFileObj as Blob);
const response = await fetch(`${getAPIURL()}/backup/import?replace=${replace}`, {
method: "POST",
body: formData,
});
const result = await response.json();
if (result.success) {
messageApi.success(
`${t("settings.backup.import_success")}: ${result.vendors_imported} vendors, ${result.filaments_imported} filaments, ${result.spools_imported} spools`
);
setFileList([]);
// Reload the page to refresh all data
window.location.reload();
} else {
messageApi.error(`${t("settings.backup.import_error")}: ${result.errors.join(", ")}`);
}
} catch (error) {
messageApi.error(t("settings.backup.import_error"));
} finally {
setImporting(false);
}
};
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h3>{t("settings.backup.title")}</h3>
<p style={{ marginBottom: "1em", color: "#888" }}>
{t("settings.backup.description")}
</p>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div>
<h4>{t("settings.backup.export_title")}</h4>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{t("settings.backup.export_button")}
</Button>
</div>
<div>
<h4>{t("settings.backup.import_title")}</h4>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Upload
accept=".json"
maxCount={1}
fileList={fileList}
beforeUpload={() => false}
onChange={({ fileList }) => setFileList(fileList)}
>
<Button icon={<UploadOutlined />}>{t("settings.backup.select_file")}</Button>
</Upload>
<Space>
<Button
type="primary"
onClick={() => handleImport(false)}
loading={importing}
disabled={fileList.length === 0}
>
{t("settings.backup.import_merge")}
</Button>
<Popconfirm
title={t("settings.backup.replace_warning_title")}
description={t("settings.backup.replace_warning")}
onConfirm={() => handleImport(true)}
okText={t("buttons.yes")}
cancelText={t("buttons.no")}
>
<Button
danger
loading={importing}
disabled={fileList.length === 0}
>
{t("settings.backup.import_replace")}
</Button>
</Popconfirm>
</Space>
</Space>
</div>
</Space>
</div>
);
}