aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2025-01-22 17:23:22 -0700
committerDhravya Shah <[email protected]>2025-01-22 17:23:22 -0700
commit0c0ea871c3d8ffa7c81af32d0e7a40e8939aa731 (patch)
tree393176aba8d32a4828390039fb9bb295ef8c87d0
parentMerge branch 'main' of github.com:supermemoryai/supermemory (diff)
downloadsupermemory-0c0ea871c3d8ffa7c81af32d0e7a40e8939aa731.tar.xz
supermemory-0c0ea871c3d8ffa7c81af32d0e7a40e8939aa731.zip
fix: postgres error
-rw-r--r--README.md2
-rw-r--r--apps/backend/src/routes/actions.ts269
-rw-r--r--apps/web/app/components/memories/Integrations.tsx24
-rw-r--r--apps/web/package.json2
-rw-r--r--apps/web/vite.config.ts2
5 files changed, 283 insertions, 16 deletions
diff --git a/README.md b/README.md
index 1d0820b1..a4021268 100644
--- a/README.md
+++ b/README.md
@@ -164,3 +164,5 @@ Thanks to all the awesome people who have contributed to supermemory.
<a href="https://github.com/Dhravya/SuperMemory/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Dhravya/SuperMemory" />
</a>
+
+
diff --git a/apps/backend/src/routes/actions.ts b/apps/backend/src/routes/actions.ts
index 0c0d32f0..51a01293 100644
--- a/apps/backend/src/routes/actions.ts
+++ b/apps/backend/src/routes/actions.ts
@@ -20,10 +20,19 @@ import {
chunk,
spaces,
spaceAccess,
+ type Space,
} from "@supermemory/db/schema";
import { google, openai } from "../providers";
import { randomId } from "@supermemory/shared";
-import { and, cosineDistance, database, desc, eq, or, sql } from "@supermemory/db";
+import {
+ and,
+ cosineDistance,
+ database,
+ desc,
+ eq,
+ or,
+ sql,
+} from "@supermemory/db";
import { typeDecider } from "../utils/typeDecider";
import { isErr, Ok } from "../errors/results";
@@ -527,7 +536,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
const cached = await c.env.MD_CACHE.get(cacheKey);
if (cached) {
return c.json({
- suggestedLearnings: JSON.parse(cached) as {[x: string]: string},
+ suggestedLearnings: JSON.parse(cached) as { [x: string]: string },
});
}
@@ -714,7 +723,9 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
const data = encoder.encode(content);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
- const documentHash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+ const documentHash = hashArray
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
// Check for duplicates using hash
const existingDocs = await db
@@ -727,10 +738,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
eq(documents.contentHash, documentHash),
and(
eq(documents.type, type.value),
- or(
- eq(documents.url, body.content),
- eq(documents.raw, content)
- )
+ or(eq(documents.url, body.content), eq(documents.raw, content))
)
)
)
@@ -757,13 +765,15 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
return { spaceId, allowed: false, error: "Space not found" };
}
+ const spaceData = space[0] as Space;
+
// If public space, only owner can add content
- if (space[0].isPublic) {
+ if (spaceData.isPublic) {
return {
spaceId,
- allowed: space[0].ownerId === user.id,
+ allowed: spaceData.ownerId === user.id,
error:
- space[0].ownerId !== user.id
+ spaceData.ownerId !== user.id
? "Only space owner can add to public spaces"
: null,
};
@@ -775,7 +785,7 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
.from(spaceAccess)
.where(
and(
- eq(spaceAccess.spaceId, space[0].id),
+ eq(spaceAccess.spaceId, spaceData.id),
eq(spaceAccess.userEmail, user.email),
eq(spaceAccess.status, "accepted")
)
@@ -785,9 +795,9 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
return {
spaceId,
allowed:
- space[0].ownerId === user.id || spaceAccessCheck.length > 0,
+ spaceData.ownerId === user.id || spaceAccessCheck.length > 0,
error:
- space[0].ownerId !== user.id && !spaceAccessCheck.length
+ spaceData.ownerId !== user.id && !spaceAccessCheck.length
? "Not authorized to add to this space"
: null,
};
@@ -856,6 +866,239 @@ const actions = new Hono<{ Variables: Variables; Bindings: Env }>()
return c.json({ error: "Failed to process content" }, 500);
}
}
+ )
+ .post(
+ "/batch-add",
+ zValidator(
+ "json",
+ z.object({
+ urls: z.array(z.string()).min(1, "At least one URL is required"),
+ spaces: z.array(z.string()).max(5).optional(),
+ })
+ ),
+ async (c) => {
+ const user = c.get("user");
+ if (!user) {
+ return c.json({ error: "Unauthorized" }, 401);
+ }
+
+ const { urls, spaces } = await c.req.valid("json");
+
+ // Check space permissions if spaces are specified
+ if (spaces && spaces.length > 0) {
+ const db = database(c.env.HYPERDRIVE.connectionString);
+ const spacePermissions = await Promise.all(
+ spaces.map(async (spaceId) => {
+ const space = await db
+ .select()
+ .from(spaces)
+ .where(eq(spaces.uuid, spaceId))
+ .limit(1);
+
+ if (!space[0]) {
+ return { spaceId, allowed: false, error: "Space not found" };
+ }
+
+ const spaceData = space[0] as Space;
+
+ // If public space, only owner can add content
+ if (spaceData.isPublic) {
+ return {
+ spaceId,
+ allowed: spaceData.ownerId === user.id,
+ error:
+ spaceData.ownerId !== user.id
+ ? "Only space owner can add to public spaces"
+ : null,
+ };
+ }
+
+ // For private spaces, check if user is owner or in allowlist
+ const spaceAccessCheck = await db
+ .select()
+ .from(spaceAccess)
+ .where(
+ and(
+ eq(spaceAccess.spaceId, spaceData.id),
+ eq(spaceAccess.userEmail, user.email),
+ eq(spaceAccess.status, "accepted")
+ )
+ )
+ .limit(1);
+
+ return {
+ spaceId,
+ allowed:
+ spaceData.ownerId === user.id || spaceAccessCheck.length > 0,
+ error:
+ spaceData.ownerId !== user.id && !spaceAccessCheck.length
+ ? "Not authorized to add to this space"
+ : null,
+ };
+ })
+ );
+
+ const unauthorized = spacePermissions.filter((p) => !p.allowed);
+ if (unauthorized.length > 0) {
+ return c.json(
+ {
+ error: "Space permission denied",
+ details: unauthorized
+ .map((u) => `${u.spaceId}: ${u.error}`)
+ .join(", "),
+ },
+ 403
+ );
+ }
+ }
+
+ // Create a new ReadableStream for progress updates
+ const stream = new ReadableStream({
+ async start(controller) {
+ const db = database(c.env.HYPERDRIVE.connectionString);
+ const total = urls.length;
+ let processed = 0;
+ let failed = 0;
+ let succeeded = 0;
+
+ for (const url of urls) {
+ try {
+ processed++;
+
+ // Calculate document hash for duplicate detection
+ const encoder = new TextEncoder();
+ const data = encoder.encode(url);
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const documentHash = hashArray
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+
+ // Check for duplicates
+ const existingDocs = await db
+ .select()
+ .from(documents)
+ .where(
+ and(
+ eq(documents.userId, user.id),
+ or(
+ eq(documents.contentHash, documentHash),
+ eq(documents.url, url)
+ )
+ )
+ );
+
+ if (existingDocs.length > 0) {
+ failed++;
+ controller.enqueue(
+ `data: ${JSON.stringify({
+ progress: Math.round((processed / total) * 100),
+ status: "duplicate",
+ url,
+ processed,
+ total,
+ succeeded,
+ failed,
+ })}\n\n`
+ );
+ continue;
+ }
+
+ const contentId = `add-${user.id}-${randomId()}`;
+ const type = typeDecider(url);
+
+ if (isErr(type)) {
+ failed++;
+ controller.enqueue(
+ `data: ${JSON.stringify({
+ progress: Math.round((processed / total) * 100),
+ status: "error",
+ url,
+ error: type.error.message,
+ processed,
+ total,
+ succeeded,
+ failed,
+ })}\n\n`
+ );
+ continue;
+ }
+
+ // Insert into documents table
+ await db.insert(documents).values({
+ uuid: contentId,
+ userId: user.id,
+ type: type.value,
+ url,
+ contentHash: documentHash,
+ raw: url + "\n\n" + spaces?.join(" "),
+ });
+
+ // Create workflow for processing
+ await c.env.CONTENT_WORKFLOW.create({
+ params: {
+ userId: user.id,
+ content: url,
+ spaces,
+ type: type.value,
+ uuid: contentId,
+ url,
+ },
+ id: contentId,
+ });
+
+ succeeded++;
+ controller.enqueue(
+ `data: ${JSON.stringify({
+ progress: Math.round((processed / total) * 100),
+ status: "success",
+ url,
+ processed,
+ total,
+ succeeded,
+ failed,
+ })}\n\n`
+ );
+ } catch (error) {
+ failed++;
+ controller.enqueue(
+ `data: ${JSON.stringify({
+ progress: Math.round((processed / total) * 100),
+ status: "error",
+ url,
+ error:
+ error instanceof Error ? error.message : "Unknown error",
+ processed,
+ total,
+ succeeded,
+ failed,
+ })}\n\n`
+ );
+ }
+ }
+
+ controller.enqueue(
+ `data: ${JSON.stringify({
+ progress: 100,
+ status: "complete",
+ processed,
+ total,
+ succeeded,
+ failed,
+ })}\n\n`
+ );
+ controller.close();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ },
+ });
+ }
);
export default actions;
diff --git a/apps/web/app/components/memories/Integrations.tsx b/apps/web/app/components/memories/Integrations.tsx
index 74c6e882..3825980c 100644
--- a/apps/web/app/components/memories/Integrations.tsx
+++ b/apps/web/app/components/memories/Integrations.tsx
@@ -7,12 +7,13 @@ import { Card } from "../ui/card";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "../ui/dialog";
import { motion } from "framer-motion";
-import { AlertCircle, CheckCircle, Clipboard, ClipboardCheckIcon, X } from "lucide-react";
+import { AlertCircle, CheckCircle, Clipboard, ClipboardCheckIcon, X, FileUpIcon } from "lucide-react";
import { toast } from "sonner";
import { type IntegrationConfig, getIntegrations } from "~/config/integrations";
import { getChromeExtensionId } from "~/config/util";
import { cn } from "~/lib/utils";
import { loader } from "~/root";
+import { CSVUploadModal } from "./CSVUploadModal";
function IntegrationButton({
integration,
@@ -150,6 +151,7 @@ function Integrations() {
const [loadingIntegration, setLoadingIntegration] = useState<IntegrationConfig | null>(null);
const [apiKey, setApiKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
+ const [isCSVModalOpen, setIsCSVModalOpen] = useState(false);
const handleIntegrationClick = (integration: IntegrationConfig) => {
setLoadingIntegration(integration);
@@ -254,6 +256,24 @@ function Integrations() {
</div>
<div className="flex flex-wrap gap-4 overflow-x-auto">
+ <Card
+ className="group relative overflow-hidden transition-all hover:shadow-lg flex-1 basis-[calc(33.333%-1rem)]"
+ onClick={() => setIsCSVModalOpen(true)}
+ >
+ <div className="absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100 bg-gradient-to-r from-blue-500/10 to-blue-600/10" />
+ <div className="relative z-10 flex flex-col items-center gap-4 p-6">
+ <div className="rounded-full bg-white/10 p-3">
+ <FileUpIcon className="transition-transform group-hover:scale-110 text-blue-500" />
+ </div>
+ <div className="text-center">
+ <h3 className="font-semibold text-neutral-900 dark:text-white">CSV Upload</h3>
+ <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400 hidden md:block">
+ Bulk import URLs from a CSV file
+ </p>
+ </div>
+ </div>
+ </Card>
+
{Object.entries(integrations).map(([key, integration]) => (
<IntegrationButton
key={key}
@@ -270,6 +290,8 @@ function Integrations() {
/>
)}
+ <CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
+
<div className="mt-8 md:mt-12 text-center">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
More integrations coming soon.{" "}
diff --git a/apps/web/package.json b/apps/web/package.json
index 673fa345..1635235d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -119,7 +119,7 @@
"isbot": "^5.1.17",
"lucide-react": "^0.456.0",
"masonic": "^4.0.1",
- "postgres": "^3.4.4",
+ "postgres": "^3.4.5",
"prettier-eslint": "^16.3.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index a20e1d55..30a24be6 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -40,7 +40,7 @@ export default defineConfig((mode) => {
resolve: {
alias: {
...(mode.mode === "development" && {
- postgres: path.resolve(__dirname, "node_modules/postgres/src/index.js"),
+ postgres: path.resolve(__dirname, "../../node_modules/postgres/src/index.js"),
}),
},
extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json", ".css"],