From 79c4bd19671b97d3e9ef418475a745b8254ab789 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 09:29:50 +0200 Subject: [PATCH] feat(export): Aufgaben in Export, Import und Sicherung aufnehmen Aufgaben gehen mit - JSON-Export, Import und Instanz-Sicherung. Beim Import wird die Position aus der Datei uebernommen, notfalls die Reihenfolge in ihr. Wie bei den Notizen werden Aufgaben beim Ersetzen ausgetauscht statt zusammengefuehrt, damit ein erneuter Import keine Dubletten erzeugt. Der Markdown-Export bekommt einen Abschnitt "Aufgaben", nach Spalten gegliedert und als Checkliste geschrieben - so bleibt der Stand auch ausserhalb von Nebula lesbar. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/api/admin/backup/route.ts | 1 + src/app/api/projects/[id]/export/route.ts | 1 + src/app/api/projects/import/route.ts | 21 ++++++++++++- src/lib/export/format.ts | 12 +++++++ src/lib/export/project.ts | 38 +++++++++++++++++++++-- 5 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts index ec25a7a..8f9423d 100644 --- a/src/app/api/admin/backup/route.ts +++ b/src/app/api/admin/backup/route.ts @@ -56,6 +56,7 @@ export async function GET() { prisma.project.findMany({ include: { notes: { orderBy: { createdAt: "asc" } }, + tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, // Snapshots bleiben draussen: reproduzierbar und gross. }, orderBy: { name: "asc" }, diff --git a/src/app/api/projects/[id]/export/route.ts b/src/app/api/projects/[id]/export/route.ts index d4b939e..67b71a9 100644 --- a/src/app/api/projects/[id]/export/route.ts +++ b/src/app/api/projects/[id]/export/route.ts @@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: Params) { where: { id, ownerId: auth.user.id }, include: { notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, connection: { select: { name: true, provider: true, baseUrl: true } }, snapshot: true, }, diff --git a/src/app/api/projects/import/route.ts b/src/app/api/projects/import/route.ts index a78eb35..104a6bd 100644 --- a/src/app/api/projects/import/route.ts +++ b/src/app/api/projects/import/route.ts @@ -159,8 +159,25 @@ export async function POST(request: Request) { repoOwner: incoming.repoOwner ?? null, repoName: incoming.repoName ?? null, connectionId: connection?.id ?? null, + progressFromTasks: incoming.progressFromTasks, }; + const tasks = incoming.tasks.map((task, index) => ({ + title: task.title, + description: task.description ?? null, + status: task.status, + // Position aus der Datei uebernehmen, notfalls die Reihenfolge in ihr. + position: task.position ?? index, + dueDate: task.dueDate && !Number.isNaN(Date.parse(task.dueDate)) + ? new Date(task.dueDate) + : null, + labels: task.labels, + doneAt: task.status === "DONE" ? new Date() : null, + ...(task.createdAt && !Number.isNaN(Date.parse(task.createdAt)) + ? { createdAt: new Date(task.createdAt) } + : {}), + })); + const notes = incoming.notes.map((note) => ({ title: note.title ?? null, content: note.content, @@ -176,9 +193,10 @@ export async function POST(request: Request) { // erneuter Import erzeugt keine Dubletten. await prisma.$transaction([ prisma.note.deleteMany({ where: { projectId: existing.id } }), + prisma.task.deleteMany({ where: { projectId: existing.id } }), prisma.project.update({ where: { id: existing.id }, - data: { ...data, slug, notes: { create: notes } }, + data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } }, }), ]); results.push({ ...base, action: "overwrite" }); @@ -190,6 +208,7 @@ export async function POST(request: Request) { ownerId: auth.user.id, slug: finalSlug, notes: { create: notes }, + tasks: { create: tasks }, }, }); results.push({ diff --git a/src/lib/export/format.ts b/src/lib/export/format.ts index cb8c7d9..674f9f4 100644 --- a/src/lib/export/format.ts +++ b/src/lib/export/format.ts @@ -27,6 +27,16 @@ export const exportedNoteSchema = z.object({ createdAt: z.string().optional(), }); +export const exportedTaskSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(5000).nullable().optional(), + status: z.enum(["TODO", "DOING", "BLOCKED", "DONE"]).optional().default("TODO"), + position: z.number().int().min(0).optional().default(0), + dueDate: z.string().nullable().optional(), + labels: z.array(z.string().max(30)).max(8).optional().default([]), + createdAt: z.string().optional(), +}); + export const exportedConnectionSchema = z.object({ name: z.string().max(80), provider: z.enum(PROVIDERS), @@ -51,6 +61,8 @@ export const exportedProjectSchema = z.object({ /** Nur zur Wiedererkennung – ohne Token. */ connection: exportedConnectionSchema.nullable().optional(), notes: z.array(exportedNoteSchema).max(500).optional().default([]), + tasks: z.array(exportedTaskSchema).max(1000).optional().default([]), + progressFromTasks: z.boolean().optional().default(false), createdAt: z.string().optional(), }); diff --git a/src/lib/export/project.ts b/src/lib/export/project.ts index 9c44344..1816a70 100644 --- a/src/lib/export/project.ts +++ b/src/lib/export/project.ts @@ -1,12 +1,13 @@ -import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client"; +import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client"; import { APP_NAME } from "@/lib/config"; import { prisma } from "@/lib/db"; import { PRIORITY_META, statusOf } from "@/lib/status"; -import { formatDateTime } from "@/lib/utils"; +import { formatDate, formatDateTime } from "@/lib/utils"; import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format"; type ProjectForExport = Project & { notes: Note[]; + tasks: Task[]; connection: Pick | null; }; @@ -40,6 +41,16 @@ export function toExportedProject(project: ProjectForExport): ExportedProject { pinned: note.pinned, createdAt: note.createdAt.toISOString(), })), + tasks: project.tasks.map((task) => ({ + title: task.title, + description: task.description, + status: task.status, + position: task.position, + dueDate: task.dueDate?.toISOString() ?? null, + labels: task.labels, + createdAt: task.createdAt.toISOString(), + })), + progressFromTasks: project.progressFromTasks, createdAt: project.createdAt.toISOString(), }; } @@ -63,6 +74,7 @@ export async function loadProjectsForExport( where: { ownerId, ...(projectId ? { id: projectId } : {}) }, include: { notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, connection: { select: { name: true, provider: true, baseUrl: true } }, }, orderBy: { name: "asc" }, @@ -77,6 +89,13 @@ const PROVIDER_LABEL: Record = { GITEA: "Gitea", }; +const TASK_LABEL: Record = { + TODO: "Offen", + DOING: "In Arbeit", + BLOCKED: "Blockiert", + DONE: "Erledigt", +}; + /** * Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage * fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die @@ -131,6 +150,21 @@ export function toMarkdown( lines.push(""); } + if (project.tasks.length) { + lines.push("## Aufgaben", ""); + for (const status of ["TODO", "DOING", "BLOCKED", "DONE"] as const) { + const inColumn = project.tasks.filter((task) => task.status === status); + if (!inColumn.length) continue; + lines.push(`### ${TASK_LABEL[status]}`, ""); + for (const task of inColumn) { + const box = status === "DONE" ? "[x]" : "[ ]"; + const due = task.dueDate ? ` _(faellig ${formatDate(task.dueDate)})_` : ""; + lines.push(`- ${box} ${task.title}${due}`); + } + lines.push(""); + } + } + if (project.notes.length) { lines.push("## Notizen", ""); for (const note of project.notes) {