feat: add user data export and sofa export re-import support

- Add `GET /api/export/user-data` route that streams a JSON attachment of the authenticated user's full library data, named `sofa-export-<name>-<date>.json`
- Add `generateUserExport` in `@sofa/core/export` and a matching `parseSofaExport` parser so exported files can be re-imported via the existing import pipeline
- Register `sofa` as a new `ImportSource` in the API contract/schemas and wire up `parseSofaExport` in the `parseFile` procedure
- Add `EXPORT_FAILED` error code to the API error registry and surface it in web and native error-message maps
- Update the account settings section with export/import UI (download button, import progress)
- Add tests for `generateUserExport`, `parseSofaExport`, and round-trip fidelity
This commit is contained in:
2026-03-21 17:12:15 -04:00
parent 58cf2e689f
commit 61013ee41f
23 changed files with 1214 additions and 39 deletions
+2
View File
@@ -16,6 +16,7 @@ import { openApiHandler } from "./orpc/openapi-handler";
import authRoutes from "./routes/auth";
import avatarsRoutes from "./routes/avatars";
import backupsRoutes from "./routes/backups";
import exportRoutes from "./routes/export";
import healthRoutes from "./routes/health";
import imagesRoutes from "./routes/images";
import listsRoutes from "./routes/lists";
@@ -66,6 +67,7 @@ app.route("/api/health", healthRoutes);
app.route("/api/auth", authRoutes);
app.route("/api/avatars", avatarsRoutes);
app.route("/api/backup", backupsRoutes);
app.route("/api/export", exportRoutes);
app.route("/api/webhooks", webhooksRoutes);
app.route("/api/lists", listsRoutes);
@@ -8,6 +8,7 @@ import {
insertImportJob,
parseLetterboxdExport,
parseSimklPayload,
parseSofaExport,
parseTraktPayload,
processImportJob,
readImportJob,
@@ -57,6 +58,19 @@ export const parseFile = os.imports.parseFile.use(authed).handler(async ({ input
result = parseSimklPayload(json as Parameters<typeof parseSimklPayload>[0]);
break;
}
case "sofa": {
let json: unknown;
try {
json = await file.json();
} catch {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid JSON file",
data: { code: AppErrorCode.IMPORT_INVALID_FILE },
});
}
result = parseSofaExport(json);
break;
}
}
return {
+35
View File
@@ -0,0 +1,35 @@
import { Hono } from "hono";
import { auth } from "@sofa/auth/server";
import { generateUserExport } from "@sofa/core/export";
const app = new Hono();
app.get("/user-data", async (c) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
return c.json({ error: "Unauthorized" }, 401);
}
const data = generateUserExport(session.user.id, {
name: session.user.name,
email: session.user.email,
});
const safeName = session.user.name.replaceAll(/[^a-zA-Z0-9-_]/g, "-").toLowerCase();
const date = new Date().toISOString().slice(0, 10);
const filename = `sofa-export-${safeName}-${date}.json`;
const json = JSON.stringify(data, null, 2);
return new Response(json, {
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(new TextEncoder().encode(json).byteLength),
"Cache-Control": "no-store",
},
});
});
export default app;