feat(export): Projekte exportieren und importieren
Bislang lagen alle Daten ausschliesslich in der Datenbank - ein "docker compose down -v" haette alles geloescht, ohne Rueckweg. Export: - alle eigenen Projekte oder ein einzelnes als JSON - ein Projekt zusaetzlich als lesbares Markdown, inklusive letztem bekannten Git-Stand Was bewusst nicht in der Datei steht: - Access Tokens. Die Verbindung wird nur beschrieben (Name, System, Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine Exportdatei landet sonst schnell in einem Download-Ordner oder einem Cloud-Abgleich. - Repo-Snapshots. Reproduzierbar und gross; ein Abgleich holt sie zurueck. - Datenbank-IDs und der Besitzer. Beides gilt nur in der Quellinstanz. Import: - die hochgeladene Datei ist Fremdinhalt und wird vor jeder Verwendung streng gegen ein Zod-Schema geprueft - Abgleich ueber den Slug, drei Strategien: ueberspringen, zusaetzlich anlegen, ersetzen - dryRun liefert dieselbe Auswertung, ohne zu schreiben - Grundlage der Vorschau in der Oberflaeche - Verbindungen werden ueber Name oder Provider samt Basis-URL zugeordnet; fehlt eine, wird das Projekt ohne Verknuepfung angelegt und das gemeldet - beim Ersetzen werden Notizen ausgetauscht statt zusammengefuehrt: das ist vorhersagbar, und ein erneuter Import erzeugt keine Dubletten Die Instanz-Sicherung fuer Administratoren enthaelt dagegen alles, was zum Wiederherstellen noetig ist - Passwort-Hashes und Token-Chiffrate eingeschlossen. Ohne dasselbe APP_SECRET sind die Tokens wertlos. Sitzungen und Passkeys bleiben draussen: erstere sind kurzlebig, letztere haengen an Geraet und rpID. readJsonBody bekommt eine einstellbare Obergrenze - 256 KB reichen fuer eine Exportdatei mit vielen Notizen nicht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Austauschformat fuer Projekte.
|
||||
*
|
||||
* Bewusst schlank und ohne Geheimnisse:
|
||||
* - keine Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
|
||||
* Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
|
||||
* Exportdatei landet sonst schnell in einem Download-Ordner oder einem
|
||||
* Cloud-Abgleich.
|
||||
* - keine Repo-Snapshots. Die sind reproduzierbar und blaehen die Datei auf;
|
||||
* ein Abgleich nach dem Import holt sie zurueck.
|
||||
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
|
||||
* wurde.
|
||||
*/
|
||||
|
||||
export const EXPORT_KIND = "nebula.projects";
|
||||
export const EXPORT_VERSION = 1;
|
||||
|
||||
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
||||
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
||||
|
||||
export const exportedNoteSchema = z.object({
|
||||
title: z.string().max(160).nullable().optional(),
|
||||
content: z.string().max(20000),
|
||||
pinned: z.boolean().optional().default(false),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const exportedConnectionSchema = z.object({
|
||||
name: z.string().max(80),
|
||||
provider: z.enum(PROVIDERS),
|
||||
baseUrl: z.string().max(300),
|
||||
});
|
||||
|
||||
export const exportedProjectSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
slug: z.string().max(60).optional(),
|
||||
summary: z.string().max(280).nullable().optional(),
|
||||
description: z.string().max(20000).nullable().optional(),
|
||||
status: z.enum(STATUS).optional().default("IDEA"),
|
||||
priority: z.number().int().min(1).max(4).optional().default(2),
|
||||
progress: z.number().int().min(0).max(100).optional().default(0),
|
||||
accent: z.string().max(20).optional().default("violet"),
|
||||
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
||||
favorite: z.boolean().optional().default(false),
|
||||
autoSync: z.boolean().optional().default(true),
|
||||
repoUrl: z.string().max(500).nullable().optional(),
|
||||
repoOwner: z.string().max(200).nullable().optional(),
|
||||
repoName: z.string().max(200).nullable().optional(),
|
||||
/** Nur zur Wiedererkennung – ohne Token. */
|
||||
connection: exportedConnectionSchema.nullable().optional(),
|
||||
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const exportFileSchema = z.object({
|
||||
kind: z.literal(EXPORT_KIND),
|
||||
version: z.number().int().min(1).max(EXPORT_VERSION),
|
||||
exportedAt: z.string().optional(),
|
||||
app: z.string().optional(),
|
||||
projects: z.array(exportedProjectSchema).min(1).max(1000),
|
||||
});
|
||||
|
||||
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
|
||||
export type ExportFile = z.infer<typeof exportFileSchema>;
|
||||
|
||||
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
||||
export function exportFileName(prefix: string, date: Date, extension: string): string {
|
||||
const stamp = date.toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
const safe = prefix
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return `${safe || "nebula"}-${stamp}.${extension}`;
|
||||
}
|
||||
Reference in New Issue
Block a user