diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 51745b80a..8d221f8a8 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -8,6 +8,7 @@ import { cn } from "@lib/utils" import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" import Account from "@/components/settings/account" import Billing from "@/components/settings/billing" +import { DataPortabilityPanel } from "@/components/settings/data-portability" import Integrations from "@/components/settings/integrations" import ConnectionsMCP from "@/components/settings/connections-mcp" import Support from "@/components/settings/support" @@ -24,6 +25,7 @@ import { LoaderIcon, User as UserIcon, Zap, + Download, HelpCircle, CreditCard, ShieldAlert, @@ -49,6 +51,7 @@ const TABS = [ "account", "billing", "integrations", + "portability", "connections", "support", ] as const @@ -80,6 +83,12 @@ const NAV_ITEMS: NavItem[] = [ description: "Save, sync and search across tools", icon: , }, + { + id: "portability", + label: "Data portability", + description: "Export and restore your memories", + icon: , + }, { id: "connections", label: "Connections & MCP", @@ -572,6 +581,7 @@ export default function SettingsPage() { {activeTab === "account" && } {activeTab === "billing" && } {activeTab === "integrations" && } + {activeTab === "portability" && } {activeTab === "connections" && } {activeTab === "support" && } diff --git a/apps/web/app/api/memories/_utils.ts b/apps/web/app/api/memories/_utils.ts new file mode 100644 index 000000000..e03447dab --- /dev/null +++ b/apps/web/app/api/memories/_utils.ts @@ -0,0 +1,62 @@ +const BACKEND_BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +export function buildBackendHeaders(request: Request): Headers { + const headers = new Headers({ + "Content-Type": "application/json", + "X-App-Source": "nova", + }) + + const cookie = request.headers.get("cookie") + if (cookie) { + headers.set("cookie", cookie) + } + + const authorization = request.headers.get("authorization") + if (authorization) { + headers.set("authorization", authorization) + } + + return headers +} + +export function getBackendBaseUrl() { + return BACKEND_BASE_URL +} + +export function chunkArray(items: T[], size: number): T[][] { + if (size <= 0) return [items] + const chunks: T[][] = [] + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)) + } + return chunks +} + +export function parseContainerTags(searchParams: URLSearchParams): string[] { + const values = searchParams.getAll("containerTags") + if (values.length === 0) return [] + + return [...new Set(values.flatMap((value) => value.split(",")))] + .map((value) => value.trim()) + .filter(Boolean) +} + +export function parseDateBound(value: string, bound: "start" | "end") { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return null + + if (!value.includes("T") && bound === "end") { + date.setUTCHours(23, 59, 59, 999) + } + + if (!value.includes("T") && bound === "start") { + date.setUTCHours(0, 0, 0, 0) + } + + return date +} + +export function escapeMarkdown(value: string) { + return value.replace(/[\\`*_{}[\]()#+\-.!|>]/g, "\\$&") +} diff --git a/apps/web/app/api/memories/export/route.ts b/apps/web/app/api/memories/export/route.ts new file mode 100644 index 000000000..f6abdc144 --- /dev/null +++ b/apps/web/app/api/memories/export/route.ts @@ -0,0 +1,221 @@ +import { NextResponse } from "next/server" +import { + buildBackendHeaders, + escapeMarkdown, + getBackendBaseUrl, + parseContainerTags, + parseDateBound, +} from "../_utils" + +type ExportDocument = { + id: string + customId?: string | null + content?: string | null + summary?: string | null + title?: string | null + url?: string | null + source?: string | null + type?: string | null + status?: string | null + metadata?: Record | null + containerTags?: string[] | null + createdAt?: string | Date + updatedAt?: string | Date + memoryEntries?: Array<{ + id?: string + memory?: string + isStatic?: boolean + createdAt?: string | Date + }> +} + +type DocumentsResponse = { + documents?: ExportDocument[] + pagination?: { + currentPage?: number + totalPages?: number + totalItems?: number + limit?: number + } +} + +async function fetchDocumentsPage( + request: Request, + page: number, + limit: number, + containerTags: string[], +) { + const response = await fetch( + `${getBackendBaseUrl()}/v3/documents/documents`, + { + method: "POST", + headers: buildBackendHeaders(request), + body: JSON.stringify({ + page, + limit, + order: "asc", + sort: "createdAt", + ...(containerTags.length > 0 ? { containerTags } : {}), + }), + }, + ) + + if (!response.ok) { + const message = await response.text() + throw new Error(message || `Export failed with status ${response.status}`) + } + + return (await response.json()) as DocumentsResponse +} + +function buildMarkdownExport(documents: ExportDocument[], exportedAt: string) { + const lines: string[] = [ + "# supermemory memory export", + "", + `- Exported at: ${exportedAt}`, + `- Memory count: ${documents.length}`, + "", + ] + + for (const document of documents) { + const heading = document.title?.trim() || document.customId || document.id + const createdAt = + document.createdAt instanceof Date + ? document.createdAt.toISOString() + : document.createdAt + ? new Date(document.createdAt).toISOString() + : exportedAt + const tags = document.containerTags?.length + ? document.containerTags.join(", ") + : "None" + const content = (document.content ?? document.summary ?? "").trim() + const memoryLines = (document.memoryEntries ?? []).map((entry) => { + const prefix = entry.isStatic ? "static" : "dynamic" + return `- ${prefix}: ${entry.memory ?? ""}`.trim() + }) + + lines.push(`## ${escapeMarkdown(heading)}`) + lines.push("") + lines.push(`- ID: ${document.id}`) + if (document.customId) lines.push(`- Custom ID: ${document.customId}`) + lines.push(`- Created: ${createdAt}`) + if (document.updatedAt) { + const updatedAt = + document.updatedAt instanceof Date + ? document.updatedAt.toISOString() + : new Date(document.updatedAt).toISOString() + lines.push(`- Updated: ${updatedAt}`) + } + lines.push(`- Tags: ${tags}`) + if (document.type) lines.push(`- Type: ${document.type}`) + if (document.status) lines.push(`- Status: ${document.status}`) + if (document.url) lines.push(`- URL: ${document.url}`) + if (document.source) lines.push(`- Source: ${document.source}`) + lines.push("") + lines.push("### Content") + lines.push("") + lines.push(content || "No content available.") + if (memoryLines.length > 0) { + lines.push("") + lines.push("### Memories") + lines.push("") + lines.push(...memoryLines) + } + lines.push("") + lines.push("---") + lines.push("") + } + + return lines.join("\n") +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url) + const format = + searchParams.get("format") === "markdown" ? "markdown" : "json" + const startDateParam = searchParams.get("startDate") + const endDateParam = searchParams.get("endDate") + const containerTags = parseContainerTags(searchParams) + const pageSize = 100 + + const startDate = startDateParam + ? parseDateBound(startDateParam, "start") + : null + const endDate = endDateParam ? parseDateBound(endDateParam, "end") : null + + if (startDateParam && !startDate) { + return NextResponse.json({ error: "Invalid startDate" }, { status: 400 }) + } + + if (endDateParam && !endDate) { + return NextResponse.json({ error: "Invalid endDate" }, { status: 400 }) + } + + const firstPage = await fetchDocumentsPage( + request, + 1, + pageSize, + containerTags, + ) + const documents = [...(firstPage.documents ?? [])] + const totalPages = firstPage.pagination?.totalPages ?? 1 + + for (let page = 2; page <= totalPages; page += 1) { + const response = await fetchDocumentsPage( + request, + page, + pageSize, + containerTags, + ) + documents.push(...(response.documents ?? [])) + } + + const filteredDocuments = documents.filter((document) => { + const createdAt = document.createdAt ? new Date(document.createdAt) : null + if (startDate && (!createdAt || createdAt < startDate)) return false + if (endDate && (!createdAt || createdAt > endDate)) return false + return true + }) + + const exportedAt = new Date().toISOString() + const payload = { + version: 1, + exportedAt, + format, + filters: { + containerTags, + startDate: startDateParam, + endDate: endDateParam, + }, + count: filteredDocuments.length, + documents: filteredDocuments, + } + + if (format === "markdown") { + const markdown = buildMarkdownExport(filteredDocuments, exportedAt) + return new NextResponse(markdown, { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Content-Disposition": `attachment; filename="supermemory-export-${exportedAt}.md"`, + }, + }) + } + + return new NextResponse(JSON.stringify(payload, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="supermemory-export-${exportedAt}.json"`, + }, + }) + } catch (error) { + console.error("Memory export failed:", error) + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to export memories", + }, + { status: 500 }, + ) + } +} diff --git a/apps/web/app/api/memories/import/route.ts b/apps/web/app/api/memories/import/route.ts new file mode 100644 index 000000000..25b366b96 --- /dev/null +++ b/apps/web/app/api/memories/import/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server" +import { buildBackendHeaders, chunkArray, getBackendBaseUrl } from "../_utils" + +type ExportDocument = { + content?: string | null + summary?: string | null + customId?: string | null + containerTags?: string[] | null + metadata?: Record | null + entityContext?: string | null +} + +type ImportPayload = + | { + containerTags?: string[] + targetContainerTags?: string[] + documents?: ExportDocument[] + memories?: ExportDocument[] + data?: ExportDocument[] + } + | ExportDocument[] + +function normalizeDocuments(payload: ImportPayload): ExportDocument[] { + if (Array.isArray(payload)) return payload + if (Array.isArray(payload.documents)) return payload.documents + if (Array.isArray(payload.memories)) return payload.memories + if (Array.isArray(payload.data)) return payload.data + return [] +} + +function pickTargetContainerTags(payload: ImportPayload): string[] | null { + if (Array.isArray(payload)) return null + if ( + Array.isArray(payload.targetContainerTags) && + payload.targetContainerTags.length > 0 + ) { + return payload.targetContainerTags + } + if ( + Array.isArray(payload.containerTags) && + payload.containerTags.length > 0 + ) { + return payload.containerTags + } + return null +} + +export async function POST(request: Request) { + try { + const payload = (await request.json()) as ImportPayload + const documents = normalizeDocuments(payload) + const overrideContainerTags = pickTargetContainerTags(payload) + + if (documents.length === 0) { + return NextResponse.json( + { error: "No memories were provided for import" }, + { status: 400 }, + ) + } + + const batches = chunkArray( + documents + .map((document) => { + const content = (document.content ?? document.summary ?? "").trim() + if (!content) return null + return { + content, + customId: document.customId ?? undefined, + containerTags: + overrideContainerTags ?? document.containerTags ?? undefined, + metadata: document.metadata ?? undefined, + entityContext: document.entityContext ?? undefined, + } + }) + .filter( + (document): document is NonNullable => + document !== null, + ), + 25, + ) + + if (batches.length === 0 || batches.every((batch) => batch.length === 0)) { + return NextResponse.json( + { error: "No importable memories were found in the payload" }, + { status: 400 }, + ) + } + + let imported = 0 + for (const batch of batches) { + if (batch.length === 0) continue + + const response = await fetch( + `${getBackendBaseUrl()}/v3/documents/batch`, + { + method: "POST", + headers: buildBackendHeaders(request), + body: JSON.stringify({ + documents: batch, + metadata: { + sm_source: "supermemory-export", + sm_imported_at: new Date().toISOString(), + }, + }), + }, + ) + + if (!response.ok) { + const message = await response.text() + throw new Error( + message || `Import failed with status ${response.status}`, + ) + } + + imported += batch.length + } + + return NextResponse.json({ success: true, imported }) + } catch (error) { + console.error("Memory import failed:", error) + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to import memories", + }, + { status: 500 }, + ) + } +} diff --git a/apps/web/components/settings/data-portability.tsx b/apps/web/components/settings/data-portability.tsx new file mode 100644 index 000000000..ced097237 --- /dev/null +++ b/apps/web/components/settings/data-portability.tsx @@ -0,0 +1,415 @@ +"use client" + +import { dmSans125ClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { useAuth } from "@lib/auth-context" +import { useContainerTags } from "@/hooks/use-container-tags" +import { Check, Download, FileDown, LoaderIcon, Upload } from "lucide-react" +import { useMemo, useRef, useState } from "react" +import { toast } from "sonner" + +type ExportFormat = "json" | "markdown" + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function SettingsCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function PillButton({ + children, + onClick, + disabled, + className, +}: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string +}) { + return ( + + ) +} + +function formatFilterLabel(value: string) { + return value.replace(/_/g, " ") +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getDownloadFilename(response: Response, fallback: string) { + const disposition = response.headers.get("content-disposition") + if (!disposition) return fallback + + const filenameMatch = disposition.match(/filename="?([^";]+)"?/i) + return filenameMatch?.[1] ?? fallback +} + +export function DataPortabilityPanel() { + const { org } = useAuth() + const { allProjects } = useContainerTags() + const [format, setFormat] = useState("json") + const [selectedTags, setSelectedTags] = useState([]) + const [startDate, setStartDate] = useState("") + const [endDate, setEndDate] = useState("") + const [isExporting, setIsExporting] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const fileInputRef = useRef(null) + + const availableTags = useMemo(() => { + return [ + ...new Set((allProjects ?? []).map((project) => project.containerTag)), + ] + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)) + }, [allProjects]) + + const canExport = !isExporting + const activeTagSummary = + selectedTags.length > 0 + ? `${selectedTags.length} selected` + : org?.id + ? `Current org: ${org.name ?? org.id}` + : "All memories" + + const toggleTag = (tag: string) => { + setSelectedTags((current) => + current.includes(tag) + ? current.filter((currentTag) => currentTag !== tag) + : [...current, tag], + ) + } + + const downloadBlob = (blob: Blob, filename: string) => { + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(url) + } + + const handleExport = async (selectedFormat: ExportFormat) => { + if (!canExport) return + setIsExporting(true) + try { + const params = new URLSearchParams() + params.set("format", selectedFormat) + for (const tag of selectedTags) { + params.append("containerTags", tag) + } + if (startDate) params.set("startDate", startDate) + if (endDate) params.set("endDate", endDate) + + const response = await fetch( + `/api/memories/export?${params.toString()}`, + { + credentials: "include", + }, + ) + + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error( + (isPlainObject(body) && typeof body.error === "string" + ? body.error + : null) ?? "Failed to export memories", + ) + } + + const filename = getDownloadFilename( + response, + `supermemory-export.${selectedFormat === "markdown" ? "md" : "json"}`, + ) + downloadBlob(await response.blob(), filename) + toast.success("Export downloaded") + } catch (error) { + toast.error("Failed to export memories", { + description: error instanceof Error ? error.message : undefined, + }) + } finally { + setIsExporting(false) + } + } + + const handleImportFile = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0] + event.target.value = "" + if (!file) return + + setIsImporting(true) + try { + const text = await file.text() + const parsed = JSON.parse(text) as unknown + + const response = await fetch("/api/memories/import", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(parsed), + }) + + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error( + (isPlainObject(body) && typeof body.error === "string" + ? body.error + : null) ?? "Failed to import memories", + ) + } + + const result = (await response.json()) as { imported?: number } + toast.success("Import completed", { + description: `${result.imported ?? 0} memories imported successfully.`, + }) + } catch (error) { + toast.error("Failed to import memories", { + description: error instanceof Error ? error.message : undefined, + }) + } finally { + setIsImporting(false) + } + } + + return ( +
+
+ Data portability + +
+
+

+ Backup, migrate, or archive your memories. +

+

+ Export memories as JSON or Markdown and import previous JSON + archives later. +

+
+ +
+
+
+
+

+ Export memories +

+

+ Choose a format, then optionally narrow by date or tags. +

+
+ +
+ +
+ setFormat("json")} + className={ + format === "json" + ? "bg-white text-black" + : "bg-white/5 text-white" + } + > + JSON + + setFormat("markdown")} + className={ + format === "markdown" + ? "bg-white text-black" + : "bg-white/5 text-white" + } + > + Markdown + +
+ +
+ + +
+ +
+
+

+ Filter by tags +

+

{activeTagSummary}

+
+
+ {availableTags.length > 0 ? ( + availableTags.map((tag) => { + const isSelected = selectedTags.includes(tag) + return ( + + ) + }) + ) : ( +

+ No container tags are available yet. +

+ )} +
+
+ +
+

+ Markdown is best for human-readable backups. JSON is best + for round-tripping. +

+ handleExport(format)} + disabled={!canExport} + className="bg-[#4BA0FA] text-[#00171A]" + > + {isExporting ? ( + + ) : ( + + )} + {isExporting ? "Exporting..." : "Download export"} + +
+
+ +
+
+
+

+ Import memories +

+

+ Upload a previously exported JSON archive to restore + memories. +

+
+ +
+ +
+ +
+ +

+ Import keeps any container tags stored in the archive. If you + want to move memories into a different workspace, export the + JSON and adjust tags before importing. +

+ { + fileInputRef.current?.click() + }} + className="mt-5 bg-white/5 text-white" + disabled={isImporting} + > + {isImporting ? ( + + ) : ( + + )} + {isImporting ? "Importing..." : "Import JSON archive"} + +
+
+
+
+
+
+ ) +} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index 132ebeb33..afe76f077 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -153,7 +153,13 @@ export const analytics = { // settings / spaces / docs analytics settingsTabChanged: (props: { - tab: "account" | "billing" | "integrations" | "connections" | "support" + tab: + | "account" + | "billing" + | "integrations" + | "portability" + | "connections" + | "support" }) => safeCapture("settings_tab_changed", props), spaceCreated: () => safeCapture("space_created"),