feat(daten): export als zip, json und csv sowie import eines archivs
Unter Verwaltung -> Daten laedt der Admin alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON oder die Sammlung als CSV - letzteres mit Semikolon und BOM, damit deutsches Excel es ohne Rueckfragen oeffnet. Die CSV steht auch Mitgliedern offen, das vollstaendige Archiv nur Administratoren. Bewusst nicht im Export: Konten, Passwoerter und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner waere ein Risiko und kein Backup - das bleibt ./data. Ebenfalls draussen: Reservierungen und Teilen-Links, die zur jeweiligen Instanz gehoeren. Der Import ersetzt alle Anwendungsdaten in einer Transaktion, statt zu vermischen: Zusammenfuehren zweier Sammlungen braeuchte Konfliktregeln fuer jede Tabelle und waere im Ergebnis nicht vorhersagbar. Deshalb ist er Administratoren vorbehalten und verlangt ein getipptes ERSETZEN. Wunschlisten gehoeren danach dem importierenden Admin und bekommen frische, zunaechst nicht geteilte Links. Pfade aus dem Archiv werden gegen Ausbrueche aus dem Dateiverzeichnis geprueft. 10 Tests decken den Rundweg ab: Export, Loeschen, Import, Ersetzen statt Vermischen, und dass ein fremdes Archiv abgelehnt wird, ohne etwas zu loeschen. Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||
import { importArchive, ImportError, type ImportSummary } from '@/lib/export';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
|
||||
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
/** Typed by hand before anything is deleted. */
|
||||
const CONFIRMATION = 'ERSETZEN';
|
||||
|
||||
export async function importArchiveAction(
|
||||
formData: FormData,
|
||||
): Promise<ActionResult<ImportSummary>> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (formData.get('confirmation') !== CONFIRMATION) {
|
||||
return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`);
|
||||
}
|
||||
|
||||
const file = formData.get('archive');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.');
|
||||
}
|
||||
if (file.size > MAX_ARCHIVE_BYTES) {
|
||||
return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.');
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = importArchive(
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
admin.id,
|
||||
);
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
return actionData(summary);
|
||||
} catch (error) {
|
||||
if (error instanceof ImportError) return actionError(error.message);
|
||||
console.error('[import] Archiv konnte nicht eingelesen werden', error);
|
||||
return actionError(
|
||||
'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { DataTransfer } from '@/components/verwaltung/data-transfer';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Daten' };
|
||||
|
||||
export default async function DatenPage() {
|
||||
await requireAdmin();
|
||||
return <DataTransfer />;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
buildCollectionCsv,
|
||||
buildExportPayload,
|
||||
buildExportZip,
|
||||
exportFileName,
|
||||
} from '@/lib/export';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { centsToInput } from '@/lib/money';
|
||||
import { listCollectionItems } from '@/lib/queries/collection';
|
||||
import { getCurrentUser } from '@/lib/session';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
{ key: 'setNumber', label: 'Setnummer' },
|
||||
{ key: 'setName', label: 'Name' },
|
||||
{ key: 'manufacturer', label: 'Hersteller' },
|
||||
{ key: 'theme', label: 'Thema' },
|
||||
{ key: 'year', label: 'Jahr' },
|
||||
{ key: 'pieceCount', label: 'Teile' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'completeness', label: 'Vollständigkeit' },
|
||||
{ key: 'condition', label: 'Zustand' },
|
||||
{ key: 'location', label: 'Standort' },
|
||||
{ key: 'acquiredAt', label: 'Im Besitz seit' },
|
||||
{ key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
|
||||
{ key: 'retailPrice', label: 'UVP (EUR)' },
|
||||
{ key: 'acquiredFrom', label: 'Gekauft bei' },
|
||||
{ key: 'giftedBy', label: 'Geschenkt von' },
|
||||
{ key: 'notes', label: 'Notizen' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Downloads.
|
||||
*
|
||||
* The full archive is for admins - it contains the whole collection. The CSV
|
||||
* of the collection is available to every signed-in member.
|
||||
*/
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ kind: string }> },
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
|
||||
|
||||
const { kind } = await context.params;
|
||||
|
||||
if (kind === 'csv') {
|
||||
const rows = listCollectionItems({}).map((item) => ({
|
||||
setNumber: item.setNumber,
|
||||
setName: item.setName,
|
||||
manufacturer: item.manufacturerName,
|
||||
theme: item.themeName,
|
||||
year: item.year,
|
||||
pieceCount: item.pieceCount,
|
||||
status: statusLabel(item.status),
|
||||
completeness: completenessLabel(item.completeness),
|
||||
condition: conditionLabel(item.condition),
|
||||
location: item.locationName,
|
||||
acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
|
||||
acquiredPrice: centsToInput(item.acquiredPriceCents),
|
||||
retailPrice: centsToInput(item.retailPriceCents),
|
||||
acquiredFrom: item.acquiredFrom,
|
||||
giftedBy: item.giftedBy,
|
||||
notes: item.notes,
|
||||
}));
|
||||
|
||||
return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
|
||||
}
|
||||
|
||||
if (kind === 'json') {
|
||||
return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (kind === 'zip') {
|
||||
const archive = buildExportZip();
|
||||
return new NextResponse(archive as unknown as BodyInit, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': String(archive.byteLength),
|
||||
'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse('Unbekanntes Format', { status: 404 });
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { AlertTriangle, Download, FileSpreadsheet, FileText, Loader2, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { importArchiveAction } from '@/app/(app)/verwaltung/daten/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
const CONFIRMATION = 'ERSETZEN';
|
||||
|
||||
export function DataTransfer() {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await importArchiveAction(new FormData(form));
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Eingelesen: ${result.data.sets} Sets, ${result.data.collectionItems} Sammlungseinträge, ${result.data.wishlists} Wunschlisten, ${result.data.files} Dateien.`,
|
||||
);
|
||||
form.reset();
|
||||
setConfirmation('');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exportieren</CardTitle>
|
||||
<CardDescription>
|
||||
Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche
|
||||
Backup bleibt das Verzeichnis <code className="font-mono text-xs">./data</code>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<Button asChild size="lg">
|
||||
<a href="/api/export/zip" download>
|
||||
<Download />
|
||||
Alles als ZIP
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<a href="/api/export/json" download>
|
||||
<FileText />
|
||||
Nur Daten (JSON)
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<a href="/api/export/csv" download>
|
||||
<FileSpreadsheet />
|
||||
Sammlung als CSV
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Was im Export steckt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<span className="font-medium text-foreground">Dabei:</span> Hersteller, Themen, Sets,
|
||||
Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP
|
||||
zusätzlich alle hochgeladenen Dateien.
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium text-foreground">Nicht dabei:</span> Konten, Passwörter
|
||||
und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner wäre ein Risiko,
|
||||
kein Backup. Ebenfalls draußen: Reservierungen und Teilen-Links – die gehören zu der
|
||||
Instanz, auf der sie entstanden sind.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-destructive" aria-hidden />
|
||||
Importieren
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ein Import <strong>ersetzt</strong> alle vorhandenen Daten – Katalog, Sammlung und
|
||||
Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und
|
||||
sind zunächst nicht geteilt.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Field label="Archiv" htmlFor="archive" required>
|
||||
<Input id="archive" name="archive" type="file" accept=".zip,application/zip" required />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={`Zum Bestätigen ${CONFIRMATION} eintippen`}
|
||||
htmlFor="confirmation"
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="confirmation"
|
||||
name="confirmation"
|
||||
value={confirmation}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
placeholder={CONFIRMATION}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
variant="destructive"
|
||||
disabled={pending || confirmation !== CONFIRMATION}
|
||||
>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Upload />}
|
||||
Daten ersetzen
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const tabs = [
|
||||
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
|
||||
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
|
||||
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
|
||||
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
|
||||
] as const;
|
||||
|
||||
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'server-only';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
|
||||
import { db } from '@/db';
|
||||
import * as schema from '@/db/schema';
|
||||
import { env } from '@/env';
|
||||
import { formatDate } from './format';
|
||||
|
||||
/**
|
||||
* Export and import of the whole collection.
|
||||
*
|
||||
* What this is: a portable copy of the data - to move to another machine, to
|
||||
* look at outside the app, or as a second safety net.
|
||||
*
|
||||
* What this is not: a replacement for backing up ./data. Accounts, passwords
|
||||
* and passkeys are deliberately left out, because a JSON file full of password
|
||||
* hashes in someone's download folder is a liability, not a backup.
|
||||
*/
|
||||
|
||||
export const EXPORT_FORMAT_VERSION = 1;
|
||||
|
||||
export type ExportPayload = {
|
||||
formatVersion: number;
|
||||
exportedAt: string;
|
||||
app: string;
|
||||
data: {
|
||||
manufacturers: unknown[];
|
||||
themes: unknown[];
|
||||
sets: unknown[];
|
||||
tags: unknown[];
|
||||
setTags: unknown[];
|
||||
instructions: unknown[];
|
||||
locations: unknown[];
|
||||
collectionItems: unknown[];
|
||||
missingParts: unknown[];
|
||||
photos: unknown[];
|
||||
wishlists: unknown[];
|
||||
wishlistItems: unknown[];
|
||||
};
|
||||
};
|
||||
|
||||
/** Everything except accounts and sessions. */
|
||||
export function buildExportPayload(): ExportPayload {
|
||||
return {
|
||||
formatVersion: EXPORT_FORMAT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: 'bricktrack',
|
||||
data: {
|
||||
manufacturers: db.select().from(schema.manufacturer).all(),
|
||||
themes: db.select().from(schema.theme).all(),
|
||||
sets: db.select().from(schema.set).all(),
|
||||
tags: db.select().from(schema.tag).all(),
|
||||
setTags: db.select().from(schema.setTag).all(),
|
||||
instructions: db.select().from(schema.instruction).all(),
|
||||
locations: db.select().from(schema.location).all(),
|
||||
collectionItems: db.select().from(schema.collectionItem).all(),
|
||||
missingParts: db.select().from(schema.missingPart).all(),
|
||||
photos: db.select().from(schema.photo).all(),
|
||||
// Reservations are personal data of people without an account and have
|
||||
// no meaning outside the running instance - so they stay behind.
|
||||
wishlists: db
|
||||
.select()
|
||||
.from(schema.wishlist)
|
||||
.all()
|
||||
.map(({ shareToken, ...rest }) => ({ ...rest, shareToken: null })),
|
||||
wishlistItems: db
|
||||
.select()
|
||||
.from(schema.wishlistItem)
|
||||
.all()
|
||||
.map(({ reservationTokenHash, reservedByName, reservedAt, ...rest }) => rest),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects every uploaded file, keyed by its path relative to FILES_DIR. */
|
||||
function collectFiles(): Record<string, Uint8Array> {
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
const root = env.FILES_DIR;
|
||||
if (!fs.existsSync(root)) return files;
|
||||
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const absolute = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(absolute);
|
||||
continue;
|
||||
}
|
||||
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
||||
files[`files/${relative}`] = new Uint8Array(fs.readFileSync(absolute));
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
/** One archive with the data and every uploaded file. */
|
||||
export function buildExportZip(): Uint8Array {
|
||||
const payload = buildExportPayload();
|
||||
|
||||
return zipSync(
|
||||
{
|
||||
'bricktrack.json': strToU8(JSON.stringify(payload, null, 2)),
|
||||
...collectFiles(),
|
||||
},
|
||||
// Level 6 is the sweet spot: JPEGs and PDFs barely compress anyway.
|
||||
{ level: 6 },
|
||||
);
|
||||
}
|
||||
|
||||
export function exportFileName(extension: string): string {
|
||||
const today = formatDate(new Date()).split('.').reverse().join('-');
|
||||
return `bricktrack-${today}.${extension}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const text = String(value);
|
||||
return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection as CSV, semicolon separated with a BOM - that is what German
|
||||
* Excel opens without asking questions.
|
||||
*/
|
||||
export function buildCollectionCsv(
|
||||
rows: Array<Record<string, string | number | null>>,
|
||||
columns: Array<{ key: string; label: string }>,
|
||||
): string {
|
||||
const lines = [columns.map((column) => csvCell(column.label)).join(';')];
|
||||
|
||||
for (const row of rows) {
|
||||
lines.push(columns.map((column) => csvCell(row[column.key])).join(';'));
|
||||
}
|
||||
|
||||
return `${lines.join('\r\n')}\r\n`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ImportError extends Error {}
|
||||
|
||||
export type ImportSummary = {
|
||||
sets: number;
|
||||
collectionItems: number;
|
||||
wishlists: number;
|
||||
files: number;
|
||||
};
|
||||
|
||||
type ArchiveContents = {
|
||||
payload: ExportPayload;
|
||||
files: Record<string, Uint8Array>;
|
||||
};
|
||||
|
||||
function readArchive(bytes: Uint8Array): ArchiveContents {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
entries = unzipSync(bytes);
|
||||
} catch {
|
||||
throw new ImportError('Die Datei ist kein gültiges Bricktrack-Archiv (ZIP erwartet).');
|
||||
}
|
||||
|
||||
const json = entries['bricktrack.json'];
|
||||
if (!json) {
|
||||
throw new ImportError('Im Archiv fehlt bricktrack.json. Stammt es wirklich aus Bricktrack?');
|
||||
}
|
||||
|
||||
let payload: ExportPayload;
|
||||
try {
|
||||
payload = JSON.parse(strFromU8(json)) as ExportPayload;
|
||||
} catch {
|
||||
throw new ImportError('bricktrack.json lässt sich nicht lesen.');
|
||||
}
|
||||
|
||||
if (payload.app !== 'bricktrack' || typeof payload.data !== 'object') {
|
||||
throw new ImportError('Das Archiv stammt nicht aus Bricktrack.');
|
||||
}
|
||||
if (payload.formatVersion > EXPORT_FORMAT_VERSION) {
|
||||
throw new ImportError(
|
||||
`Das Archiv wurde mit einer neueren Version erstellt (Format ${payload.formatVersion}). Bitte zuerst Bricktrack aktualisieren.`,
|
||||
);
|
||||
}
|
||||
|
||||
const files: Record<string, Uint8Array> = {};
|
||||
for (const [name, content] of Object.entries(entries)) {
|
||||
if (name.startsWith('files/') && !name.endsWith('/')) {
|
||||
files[name.slice('files/'.length)] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return { payload, files };
|
||||
}
|
||||
|
||||
/** Guards against a path escaping FILES_DIR through the archive. */
|
||||
function safeTarget(relative: string): string | null {
|
||||
const absolute = path.resolve(env.FILES_DIR, relative);
|
||||
const root = path.resolve(env.FILES_DIR);
|
||||
return absolute.startsWith(root + path.sep) ? absolute : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces all application data with the contents of the archive.
|
||||
*
|
||||
* Deliberately a replacement and not a merge: merging two collections would
|
||||
* need conflict rules for every table, and the result would be impossible to
|
||||
* predict. Wishlists are handed to the importing admin, since the original
|
||||
* owner's account does not travel with the archive.
|
||||
*/
|
||||
export function importArchive(bytes: Uint8Array, ownerUserId: string): ImportSummary {
|
||||
const { payload, files } = readArchive(bytes);
|
||||
const data = payload.data;
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Order matters only for readability - the foreign keys cascade anyway.
|
||||
tx.delete(schema.wishlistItem).run();
|
||||
tx.delete(schema.wishlist).run();
|
||||
tx.delete(schema.missingPart).run();
|
||||
tx.delete(schema.photo).run();
|
||||
tx.delete(schema.instruction).run();
|
||||
tx.delete(schema.collectionItem).run();
|
||||
tx.delete(schema.setTag).run();
|
||||
tx.delete(schema.set).run();
|
||||
tx.delete(schema.tag).run();
|
||||
tx.delete(schema.theme).run();
|
||||
tx.delete(schema.manufacturer).run();
|
||||
tx.delete(schema.location).run();
|
||||
|
||||
const insertAll = (table: never, rows: unknown[]) => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return;
|
||||
// Insert in batches: SQLite caps the number of bound variables.
|
||||
for (let index = 0; index < rows.length; index += 100) {
|
||||
tx.insert(table)
|
||||
.values(rows.slice(index, index + 100) as never)
|
||||
.run();
|
||||
}
|
||||
};
|
||||
|
||||
insertAll(schema.manufacturer as never, revive(data.manufacturers));
|
||||
insertAll(schema.location as never, revive(data.locations));
|
||||
insertAll(schema.theme as never, revive(data.themes));
|
||||
insertAll(schema.set as never, revive(data.sets));
|
||||
insertAll(schema.tag as never, revive(data.tags));
|
||||
insertAll(schema.setTag as never, revive(data.setTags));
|
||||
insertAll(schema.instruction as never, revive(data.instructions));
|
||||
insertAll(schema.collectionItem as never, revive(data.collectionItems));
|
||||
insertAll(schema.missingPart as never, revive(data.missingParts));
|
||||
insertAll(schema.photo as never, revive(data.photos));
|
||||
|
||||
const wishlists = revive(data.wishlists).map((row) => ({
|
||||
...(row as Record<string, unknown>),
|
||||
ownerUserId,
|
||||
// Fresh tokens: links from the exporting instance must not resurrect.
|
||||
shareToken: crypto.randomUUID().replace(/-/g, ''),
|
||||
isPublic: false,
|
||||
}));
|
||||
insertAll(schema.wishlist as never, wishlists);
|
||||
insertAll(schema.wishlistItem as never, revive(data.wishlistItems));
|
||||
});
|
||||
|
||||
let restored = 0;
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const target = safeTarget(relative);
|
||||
if (!target) continue;
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
restored += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
sets: data.sets?.length ?? 0,
|
||||
collectionItems: data.collectionItems?.length ?? 0,
|
||||
wishlists: data.wishlists?.length ?? 0,
|
||||
files: restored,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON has no date type, so timestamps come back as strings. Drizzle expects
|
||||
* Date objects for its timestamp columns.
|
||||
*/
|
||||
function revive(rows: unknown[]): unknown[] {
|
||||
if (!Array.isArray(rows)) return [];
|
||||
|
||||
return rows.map((row) => {
|
||||
if (typeof row !== 'object' || row === null) return row;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
|
||||
const looksLikeDate =
|
||||
typeof value === 'string' &&
|
||||
/(At|Date)$/.test(key) &&
|
||||
/^\d{4}-\d{2}-\d{2}T/.test(value);
|
||||
result[key] = looksLikeDate ? new Date(value) : value;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user