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) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:29:50 +02:00
co-authored by Claude Opus 5
parent 0212dad2bf
commit 79c4bd1967
5 changed files with 70 additions and 3 deletions
+1
View File
@@ -56,6 +56,7 @@ export async function GET() {
prisma.project.findMany({ prisma.project.findMany({
include: { include: {
notes: { orderBy: { createdAt: "asc" } }, notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross. // Snapshots bleiben draussen: reproduzierbar und gross.
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -25,6 +25,7 @@ export async function GET(request: Request, { params }: Params) {
where: { id, ownerId: auth.user.id }, where: { id, ownerId: auth.user.id },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true, snapshot: true,
}, },
+20 -1
View File
@@ -159,8 +159,25 @@ export async function POST(request: Request) {
repoOwner: incoming.repoOwner ?? null, repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null, repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? 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) => ({ const notes = incoming.notes.map((note) => ({
title: note.title ?? null, title: note.title ?? null,
content: note.content, content: note.content,
@@ -176,9 +193,10 @@ export async function POST(request: Request) {
// erneuter Import erzeugt keine Dubletten. // erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([ await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }), prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({ prisma.project.update({
where: { id: existing.id }, where: { id: existing.id },
data: { ...data, slug, notes: { create: notes } }, data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}), }),
]); ]);
results.push({ ...base, action: "overwrite" }); results.push({ ...base, action: "overwrite" });
@@ -190,6 +208,7 @@ export async function POST(request: Request) {
ownerId: auth.user.id, ownerId: auth.user.id,
slug: finalSlug, slug: finalSlug,
notes: { create: notes }, notes: { create: notes },
tasks: { create: tasks },
}, },
}); });
results.push({ results.push({
+12
View File
@@ -27,6 +27,16 @@ export const exportedNoteSchema = z.object({
createdAt: z.string().optional(), 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({ export const exportedConnectionSchema = z.object({
name: z.string().max(80), name: z.string().max(80),
provider: z.enum(PROVIDERS), provider: z.enum(PROVIDERS),
@@ -51,6 +61,8 @@ export const exportedProjectSchema = z.object({
/** Nur zur Wiedererkennung – ohne Token. */ /** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(), connection: exportedConnectionSchema.nullable().optional(),
notes: z.array(exportedNoteSchema).max(500).optional().default([]), 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(), createdAt: z.string().optional(),
}); });
+36 -2
View File
@@ -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 { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { PRIORITY_META, statusOf } from "@/lib/status"; 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"; import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & { type ProjectForExport = Project & {
notes: Note[]; notes: Note[];
tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null; connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
}; };
@@ -40,6 +41,16 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
pinned: note.pinned, pinned: note.pinned,
createdAt: note.createdAt.toISOString(), 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(), createdAt: project.createdAt.toISOString(),
}; };
} }
@@ -63,6 +74,7 @@ export async function loadProjectsForExport(
where: { ownerId, ...(projectId ? { id: projectId } : {}) }, where: { ownerId, ...(projectId ? { id: projectId } : {}) },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -77,6 +89,13 @@ const PROVIDER_LABEL: Record<string, string> = {
GITEA: "Gitea", GITEA: "Gitea",
}; };
const TASK_LABEL: Record<string, string> = {
TODO: "Offen",
DOING: "In Arbeit",
BLOCKED: "Blockiert",
DONE: "Erledigt",
};
/** /**
* Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage * Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die * fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
@@ -131,6 +150,21 @@ export function toMarkdown(
lines.push(""); 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) { if (project.notes.length) {
lines.push("## Notizen", ""); lines.push("## Notizen", "");
for (const note of project.notes) { for (const note of project.notes) {