- Paste a link to turn it into a memory
+ Paste one link — or many — to turn them into memories
-
+
+ {showBulkOffer && (
+
+
+
+
+
+ {detectedUrls.length} links
+ {" "}
+ detected — save each as its own memory?
+
+
+
+
+
+
+
+ )}
+
+
+
)
}
diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts
index 544cf2e7..b9a08cc6 100644
--- a/apps/web/hooks/use-document-mutations.ts
+++ b/apps/web/hooks/use-document-mutations.ts
@@ -210,6 +210,7 @@ function restoreQueriesFromSnapshot(
}
const FILE_UPLOAD_CONCURRENCY = 3
+const BULK_LINK_BATCH_SIZE = 500
const fullDocumentQueryKey = (documentId: string) =>
["document-full", documentId] as const
@@ -380,6 +381,100 @@ export function useDocumentMutations({
},
})
+ const bulkLinkMutation = useMutation({
+ mutationFn: async ({
+ urls,
+ project,
+ }: {
+ urls: string[]
+ project: string
+ }): Promise<{ success: number; failed: number }> => {
+ let success = 0
+ let failed = 0
+
+ for (let i = 0; i < urls.length; i += BULK_LINK_BATCH_SIZE) {
+ const chunk = urls.slice(i, i + BULK_LINK_BATCH_SIZE)
+ const response = await $fetch("@post/documents/batch", {
+ body: {
+ documents: chunk.map((url) => ({
+ content: url,
+ containerTags: [project],
+ entityContext,
+ metadata: { sm_source: "consumer" },
+ })),
+ },
+ })
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to add links")
+ }
+ success += response.data?.success ?? 0
+ failed += response.data?.failed ?? 0
+ }
+
+ return { success, failed }
+ },
+ onMutate: async ({ urls, project }) => {
+ const previousQueries = await cancelAndSnapshotQueries(queryClient)
+ const now = new Date().toISOString()
+
+ for (const url of urls) {
+ const optimisticMemory: OptimisticMemory = {
+ id: `temp-${crypto.randomUUID()}`,
+ content: "",
+ url,
+ title: "Processing...",
+ description: "Extracting content...",
+ containerTags: [project],
+ createdAt: now,
+ updatedAt: now,
+ status: "queued",
+ type: "link",
+ metadata: {
+ processingStage: "queued",
+ processingMessage: "Added to processing queue",
+ },
+ memoryEntries: [],
+ isOptimistic: true,
+ }
+ queryClient.setQueriesData(
+ { queryKey: ["documents-with-memories"] },
+ (old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
+ )
+ }
+
+ return { previousQueries }
+ },
+ onError: (error, _variables, context) => {
+ restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
+ toast.error("Failed to add links", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ onSuccess: (data, variables) => {
+ for (let i = 0; i < data.success; i++) {
+ analytics.documentAdded({ type: "link", project_id: variables.project })
+ }
+ queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
+ queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
+ if (data.failed === 0) {
+ toast.success(`${data.success} links added!`, {
+ description: "Your links are being processed",
+ })
+ onClose?.()
+ return
+ }
+ if (data.success === 0) {
+ toast.error("Failed to add links", {
+ description: `All ${data.failed} links failed`,
+ })
+ return
+ }
+ toast.warning("Some links failed", {
+ description: `${data.success} added, ${data.failed} failed`,
+ })
+ },
+ })
+
const fileMutation = useMutation({
mutationFn: async ({
fileEntries,
@@ -669,6 +764,7 @@ export function useDocumentMutations({
return {
noteMutation,
linkMutation,
+ bulkLinkMutation,
fileMutation,
updateMutation,
deleteMutation,
diff --git a/apps/web/lib/url-helpers.ts b/apps/web/lib/url-helpers.ts
index 8fc7d29e..0d3afa6a 100644
--- a/apps/web/lib/url-helpers.ts
+++ b/apps/web/lib/url-helpers.ts
@@ -83,6 +83,43 @@ export const normalizeUrl = (url: string): string => {
return `https://${url}`
}
+const URL_TOKEN_REGEX =
+ /(?:https?:\/\/)?(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(?:[/?#][^\s<>[\]"'`]*)?/g
+const MARKDOWN_LINK_REGEX = /\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g
+const ANGLE_LINK_REGEX = /<(https?:\/\/[^\s>]+)>/g
+
+/** Pull every distinct URL out of a free-text blob; handles markdown `[t](url)`, `
`, and bare links. */
+export const extractUrls = (
+ text: string,
+): { urls: string[]; duplicates: number } => {
+ if (!text.trim()) return { urls: [], duplicates: 0 }
+ const unwrapped = text
+ .replace(MARKDOWN_LINK_REGEX, " $1 ")
+ .replace(ANGLE_LINK_REGEX, " $1 ")
+ const matches = unwrapped.match(URL_TOKEN_REGEX) ?? []
+ const seen = new Set()
+ const urls: string[] = []
+ let duplicates = 0
+ for (const match of matches) {
+ let trimmed = match.trim().replace(/[.,;!]+$/, "")
+ const opens = (trimmed.match(/\(/g) ?? []).length
+ const closes = (trimmed.match(/\)/g) ?? []).length
+ if (closes > opens && trimmed.endsWith(")")) {
+ trimmed = trimmed.replace(/\)+$/, "").replace(/[.,;!]+$/, "")
+ }
+ const normalized = normalizeUrl(trimmed)
+ if (!isValidUrl(normalized)) continue
+ const key = normalized.toLowerCase().replace(/\/+$/, "")
+ if (seen.has(key)) {
+ duplicates++
+ continue
+ }
+ seen.add(key)
+ urls.push(normalized)
+ }
+ return { urls, duplicates }
+}
+
/**
* Checks if a URL is a Twitter/X URL.
*/
diff --git a/packages/lib/api.ts b/packages/lib/api.ts
index 7d33b130..483a3f65 100644
--- a/packages/lib/api.ts
+++ b/packages/lib/api.ts
@@ -191,6 +191,37 @@ export const apiSchema = createSchema({
input: MemoryAddSchema,
output: MemoryResponseSchema,
},
+ "@post/documents/batch": {
+ input: z.object({
+ documents: z
+ .array(
+ z.object({
+ content: z.string(),
+ containerTags: z.array(z.string()).optional(),
+ containerTag: z.string().optional(),
+ entityContext: z.string().max(1500).optional(),
+ metadata: z.record(z.unknown()).optional(),
+ }),
+ )
+ .min(1)
+ .max(600),
+ containerTag: z.string().optional(),
+ entityContext: z.string().max(1500).optional(),
+ metadata: z.record(z.unknown()).optional(),
+ }),
+ output: z.object({
+ results: z.array(
+ z.object({
+ id: z.string(),
+ status: z.string(),
+ error: z.string().optional(),
+ details: z.string().optional(),
+ }),
+ ),
+ success: z.number(),
+ failed: z.number(),
+ }),
+ },
"@post/documents/list": {
body: z
.object({