aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/settings/import/page.tsx6
-rw-r--r--apps/web/components/settings/ImportExport.tsx21
-rw-r--r--apps/web/components/settings/ImportSessionCard.tsx257
-rw-r--r--apps/web/components/settings/ImportSessionsSection.tsx79
-rw-r--r--apps/web/lib/hooks/useBookmarkImport.ts21
-rw-r--r--apps/web/lib/hooks/useImportSessions.ts62
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json27
-rw-r--r--packages/db/drizzle/0062_add_import_session.sql25
-rw-r--r--packages/db/drizzle/meta/0062_snapshot.json2489
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts70
-rw-r--r--packages/open-api/karakeep-openapi-spec.json3
-rw-r--r--packages/shared/import-export/importer.test.ts12
-rw-r--r--packages/shared/import-export/importer.ts50
-rw-r--r--packages/shared/types/bookmarks.ts1
-rw-r--r--packages/shared/types/importSessions.ts76
-rw-r--r--packages/trpc/models/importSessions.ts180
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/bookmarks.ts15
-rw-r--r--packages/trpc/routers/importSessions.test.ts204
-rw-r--r--packages/trpc/routers/importSessions.ts48
21 files changed, 3618 insertions, 37 deletions
diff --git a/apps/web/app/settings/import/page.tsx b/apps/web/app/settings/import/page.tsx
index e27aa9a8..11780d51 100644
--- a/apps/web/app/settings/import/page.tsx
+++ b/apps/web/app/settings/import/page.tsx
@@ -1,9 +1,5 @@
import ImportExport from "@/components/settings/ImportExport";
export default function ImportSettingsPage() {
- return (
- <div className="rounded-md border bg-background p-4">
- <ImportExport />
- </div>
- );
+ return <ImportExport />;
}
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx
index c644af27..ee220342 100644
--- a/apps/web/components/settings/ImportExport.tsx
+++ b/apps/web/components/settings/ImportExport.tsx
@@ -19,6 +19,7 @@ import { Download, Loader2, Upload } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { toast } from "../ui/use-toast";
+import { ImportSessionsSection } from "./ImportSessionsSection";
function ImportCard({
text,
@@ -266,11 +267,21 @@ export function ImportExportRow() {
export default function ImportExport() {
const { t } = useTranslation();
return (
- <div className="flex w-full flex-col gap-2">
- <p className="mb-4 text-lg font-medium">
- {t("settings.import.import_export_bookmarks")}
- </p>
- <ImportExportRow />
+ <div className="space-y-3">
+ <div className="rounded-md border bg-background p-4">
+ <div className="flex w-full flex-col gap-6">
+ <div>
+ <p className="mb-4 text-lg font-medium">
+ {t("settings.import.import_export_bookmarks")}
+ </p>
+ <ImportExportRow />
+ </div>
+ </div>
+ </div>
+
+ <div className="rounded-md border bg-background p-4">
+ <ImportSessionsSection />
+ </div>
</div>
);
}
diff --git a/apps/web/components/settings/ImportSessionCard.tsx b/apps/web/components/settings/ImportSessionCard.tsx
new file mode 100644
index 00000000..690caaa5
--- /dev/null
+++ b/apps/web/components/settings/ImportSessionCard.tsx
@@ -0,0 +1,257 @@
+"use client";
+
+import Link from "next/link";
+import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Progress } from "@/components/ui/progress";
+import {
+ useDeleteImportSession,
+ useImportSessionStats,
+} from "@/lib/hooks/useImportSessions";
+import { useTranslation } from "@/lib/i18n/client";
+import { formatDistanceToNow } from "date-fns";
+import {
+ AlertCircle,
+ CheckCircle2,
+ ClipboardList,
+ Clock,
+ ExternalLink,
+ Loader2,
+ Trash2,
+} from "lucide-react";
+
+import type { ZImportSessionWithStats } from "@karakeep/shared/types/importSessions";
+
+interface ImportSessionCardProps {
+ session: ZImportSessionWithStats;
+}
+
+function getStatusColor(status: string) {
+ switch (status) {
+ case "pending":
+ return "bg-muted text-muted-foreground";
+ case "in_progress":
+ return "bg-blue-500/10 text-blue-700 dark:text-blue-400";
+ case "completed":
+ return "bg-green-500/10 text-green-700 dark:text-green-400";
+ case "failed":
+ return "bg-destructive/10 text-destructive";
+ default:
+ return "bg-muted text-muted-foreground";
+ }
+}
+
+function getStatusIcon(status: string) {
+ switch (status) {
+ case "pending":
+ return <Clock className="h-4 w-4" />;
+ case "in_progress":
+ return <Loader2 className="h-4 w-4 animate-spin" />;
+ case "completed":
+ return <CheckCircle2 className="h-4 w-4" />;
+ case "failed":
+ return <AlertCircle className="h-4 w-4" />;
+ default:
+ return <Clock className="h-4 w-4" />;
+ }
+}
+
+export function ImportSessionCard({ session }: ImportSessionCardProps) {
+ const { t } = useTranslation();
+ const { data: liveStats } = useImportSessionStats(session.id);
+ const deleteSession = useDeleteImportSession();
+
+ const statusLabels: Record<string, string> = {
+ pending: t("settings.import_sessions.status.pending"),
+ in_progress: t("settings.import_sessions.status.in_progress"),
+ completed: t("settings.import_sessions.status.completed"),
+ failed: t("settings.import_sessions.status.failed"),
+ };
+
+ // Use live stats if available, otherwise fallback to session stats
+ const stats = liveStats || session;
+ const progress =
+ stats.totalBookmarks > 0
+ ? ((stats.completedBookmarks + stats.failedBookmarks) /
+ stats.totalBookmarks) *
+ 100
+ : 0;
+
+ const canDelete = stats.status !== "in_progress";
+
+ return (
+ <Card className="transition-all hover:shadow-md">
+ <CardHeader className="pb-3">
+ <div className="flex items-start justify-between">
+ <div className="flex-1">
+ <h3 className="font-medium">{session.name}</h3>
+ <p className="mt-1 text-sm text-accent-foreground">
+ {t("settings.import_sessions.created_at", {
+ time: formatDistanceToNow(session.createdAt, {
+ addSuffix: true,
+ }),
+ })}
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge
+ className={`${getStatusColor(stats.status)} hover:bg-inherit`}
+ >
+ {getStatusIcon(stats.status)}
+ <span className="ml-1 capitalize">
+ {statusLabels[stats.status] ?? stats.status.replace("_", " ")}
+ </span>
+ </Badge>
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent className="pt-0">
+ <div className="space-y-3">
+ {/* Progress Section */}
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium text-muted-foreground">
+ {t("settings.import_sessions.progress")}
+ </h4>
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium">
+ {stats.completedBookmarks + stats.failedBookmarks} /{" "}
+ {stats.totalBookmarks}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {Math.round(progress)}%
+ </Badge>
+ </div>
+ </div>
+ {stats.totalBookmarks > 0 && (
+ <Progress value={progress} className="h-3" />
+ )}
+ </div>
+
+ {/* Stats Breakdown */}
+ {stats.totalBookmarks > 0 && (
+ <div className="space-y-3">
+ <div className="flex flex-wrap gap-2">
+ {stats.pendingBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-amber-500/10 text-amber-700 hover:bg-amber-500/10 dark:text-amber-400"
+ >
+ <Clock className="mr-1.5 h-3 w-3" />
+ {t("settings.import_sessions.badges.pending", {
+ count: stats.pendingBookmarks,
+ })}
+ </Badge>
+ )}
+ {stats.processingBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-blue-500/10 text-blue-700 hover:bg-blue-500/10 dark:text-blue-400"
+ >
+ <Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
+ {t("settings.import_sessions.badges.processing", {
+ count: stats.processingBookmarks,
+ })}
+ </Badge>
+ )}
+ {stats.completedBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-green-500/10 text-green-700 hover:bg-green-500/10 dark:text-green-400"
+ >
+ <CheckCircle2 className="mr-1.5 h-3 w-3" />
+ {t("settings.import_sessions.badges.completed", {
+ count: stats.completedBookmarks,
+ })}
+ </Badge>
+ )}
+ {stats.failedBookmarks > 0 && (
+ <Badge
+ variant="secondary"
+ className="bg-destructive/10 text-destructive hover:bg-destructive/10"
+ >
+ <AlertCircle className="mr-1.5 h-3 w-3" />
+ {t("settings.import_sessions.badges.failed", {
+ count: stats.failedBookmarks,
+ })}
+ </Badge>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Root List Link */}
+ {session.rootListId && (
+ <div className="rounded-lg border bg-muted/50 p-3 dark:bg-muted/20">
+ <div className="flex items-center gap-2 text-sm">
+ <ClipboardList className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium text-muted-foreground">
+ {t("settings.import_sessions.imported_to")}
+ </span>
+ <Link
+ href={`/dashboard/lists/${session.rootListId}`}
+ className="flex items-center gap-1 font-medium text-primary transition-colors hover:text-primary/80"
+ target="_blank"
+ >
+ {t("settings.import_sessions.view_list")}
+ <ExternalLink className="h-3 w-3" />
+ </Link>
+ </div>
+ </div>
+ )}
+
+ {/* Message */}
+ {stats.message && (
+ <div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground dark:bg-muted/20">
+ {stats.message}
+ </div>
+ )}
+
+ {/* Actions */}
+ <div className="flex items-center justify-end pt-2">
+ <div className="flex items-center gap-2">
+ {canDelete && (
+ <ActionConfirmingDialog
+ title={t("settings.import_sessions.delete_dialog_title")}
+ description={
+ <div>
+ {t("settings.import_sessions.delete_dialog_description", {
+ name: session.name,
+ })}
+ </div>
+ }
+ actionButton={(setDialogOpen) => (
+ <Button
+ variant="destructive"
+ onClick={() => {
+ deleteSession.mutateAsync({
+ importSessionId: session.id,
+ });
+ setDialogOpen(false);
+ }}
+ disabled={deleteSession.isPending}
+ >
+ {t("settings.import_sessions.delete_session")}
+ </Button>
+ )}
+ >
+ <Button
+ variant="destructive"
+ size="sm"
+ disabled={deleteSession.isPending}
+ >
+ <Trash2 className="mr-1 h-4 w-4" />
+ {t("actions.delete")}
+ </Button>
+ </ActionConfirmingDialog>
+ )}
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/apps/web/components/settings/ImportSessionsSection.tsx b/apps/web/components/settings/ImportSessionsSection.tsx
new file mode 100644
index 00000000..38f248d2
--- /dev/null
+++ b/apps/web/components/settings/ImportSessionsSection.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { Card, CardContent } from "@/components/ui/card";
+import { useListImportSessions } from "@/lib/hooks/useImportSessions";
+import { useTranslation } from "@/lib/i18n/client";
+import { Package } from "lucide-react";
+
+import { FullPageSpinner } from "../ui/full-page-spinner";
+import { ImportSessionCard } from "./ImportSessionCard";
+
+export function ImportSessionsSection() {
+ const { t } = useTranslation();
+ const { data: sessions, isLoading, error } = useListImportSessions();
+
+ if (isLoading) {
+ return (
+ <div className="flex w-full flex-col gap-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-medium">
+ {t("settings.import_sessions.title")}
+ </h3>
+ </div>
+ <FullPageSpinner />
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="flex w-full flex-col gap-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-medium">
+ {t("settings.import_sessions.title")}
+ </h3>
+ </div>
+ <Card>
+ <CardContent className="flex items-center justify-center py-8">
+ <p className="text-gray-600">
+ {t("settings.import_sessions.load_error")}
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex w-full flex-col gap-4">
+ <div>
+ <h3 className="text-lg font-medium">
+ {t("settings.import_sessions.title")}
+ </h3>
+ <p className="mt-1 text-sm text-accent-foreground">
+ {t("settings.import_sessions.description")}
+ </p>
+ </div>
+
+ {sessions && sessions.length > 0 ? (
+ <div className="space-y-4">
+ {sessions.map((session) => (
+ <ImportSessionCard key={session.id} session={session} />
+ ))}
+ </div>
+ ) : (
+ <Card>
+ <CardContent className="flex flex-col items-center justify-center py-12">
+ <Package className="mb-4 h-12 w-12 text-gray-400" />
+ <p className="mb-2 text-center text-gray-600">
+ {t("settings.import_sessions.no_sessions")}
+ </p>
+ <p className="text-center text-sm text-gray-500">
+ {t("settings.import_sessions.no_sessions_detail")}
+ </p>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/lib/hooks/useBookmarkImport.ts b/apps/web/lib/hooks/useBookmarkImport.ts
index de515677..a4ebdd9c 100644
--- a/apps/web/lib/hooks/useBookmarkImport.ts
+++ b/apps/web/lib/hooks/useBookmarkImport.ts
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
-import { useRouter } from "next/navigation";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import { useMutation } from "@tanstack/react-query";
@@ -24,6 +23,8 @@ import {
MAX_BOOKMARK_TITLE_LENGTH,
} from "@karakeep/shared/types/bookmarks";
+import { useCreateImportSession } from "./useImportSessions";
+
export interface ImportProgress {
done: number;
total: number;
@@ -31,12 +32,12 @@ export interface ImportProgress {
export function useBookmarkImport() {
const { t } = useTranslation();
- const router = useRouter();
const [importProgress, setImportProgress] = useState<ImportProgress | null>(
null,
);
+ const { mutateAsync: createImportSession } = useCreateImportSession();
const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook();
const { mutateAsync: createList } = useCreateBookmarkList();
const { mutateAsync: addToList } = useAddBookmarkToList();
@@ -56,8 +57,12 @@ export function useBookmarkImport() {
source,
rootListName: t("settings.import.imported_bookmarks"),
deps: {
- createList: createList,
- createBookmark: async (bookmark: ParsedBookmark) => {
+ createImportSession,
+ createList,
+ createBookmark: async (
+ bookmark: ParsedBookmark,
+ sessionId: string,
+ ) => {
if (bookmark.content === undefined) {
throw new Error("Content is undefined");
}
@@ -69,6 +74,7 @@ export function useBookmarkImport() {
: undefined,
note: bookmark.notes,
archived: bookmark.archived,
+ importSessionId: sessionId,
...(bookmark.content.type === BookmarkTypes.LINK
? {
type: BookmarkTypes.LINK,
@@ -120,6 +126,8 @@ export function useBookmarkImport() {
return result;
},
onSuccess: async (result) => {
+ setImportProgress(null);
+
if (result.counts.total === 0) {
toast({ description: "No bookmarks found in the file." });
return;
@@ -127,7 +135,7 @@ export function useBookmarkImport() {
const { successes, failures, alreadyExisted } = result.counts;
if (successes > 0 || alreadyExisted > 0) {
toast({
- description: `Imported ${successes} bookmarks and skipped ${alreadyExisted} bookmarks that already existed`,
+ description: `Imported ${successes} bookmarks into import session. Background processing will start automatically.`,
variant: "default",
});
}
@@ -137,9 +145,6 @@ export function useBookmarkImport() {
variant: "destructive",
});
}
-
- if (result.rootListId)
- router.push(`/dashboard/lists/${result.rootListId}`);
},
onError: (error) => {
setImportProgress(null);
diff --git a/apps/web/lib/hooks/useImportSessions.ts b/apps/web/lib/hooks/useImportSessions.ts
new file mode 100644
index 00000000..cee99bbc
--- /dev/null
+++ b/apps/web/lib/hooks/useImportSessions.ts
@@ -0,0 +1,62 @@
+"use client";
+
+import { toast } from "@/components/ui/use-toast";
+
+import { api } from "@karakeep/shared-react/trpc";
+
+export function useCreateImportSession() {
+ const apiUtils = api.useUtils();
+
+ return api.importSessions.createImportSession.useMutation({
+ onSuccess: () => {
+ apiUtils.importSessions.listImportSessions.invalidate();
+ },
+ onError: (error) => {
+ toast({
+ description: error.message || "Failed to create import session",
+ variant: "destructive",
+ });
+ },
+ });
+}
+
+export function useListImportSessions() {
+ return api.importSessions.listImportSessions.useQuery(
+ {},
+ {
+ select: (data) => data.sessions,
+ },
+ );
+}
+
+export function useImportSessionStats(importSessionId: string) {
+ return api.importSessions.getImportSessionStats.useQuery(
+ {
+ importSessionId,
+ },
+ {
+ refetchInterval: 5000, // Refetch every 5 seconds to show progress
+ enabled: !!importSessionId,
+ },
+ );
+}
+
+export function useDeleteImportSession() {
+ const apiUtils = api.useUtils();
+
+ return api.importSessions.deleteImportSession.useMutation({
+ onSuccess: () => {
+ apiUtils.importSessions.listImportSessions.invalidate();
+ toast({
+ description: "Import session deleted successfully",
+ variant: "default",
+ });
+ },
+ onError: (error) => {
+ toast({
+ description: error.message || "Failed to delete import session",
+ variant: "destructive",
+ });
+ },
+ });
+}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index ab1306be..450949c6 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -314,6 +314,33 @@
"loading_usage": "Loading usage information...",
"free": "Free",
"paid": "Paid"
+ },
+ "import_sessions": {
+ "title": "Import Sessions",
+ "description": "View and manage your bulk import sessions. Sessions are automatically created when you import bookmarks.",
+ "load_error": "Failed to load import sessions",
+ "no_sessions": "No import sessions yet",
+ "no_sessions_detail": "Import sessions will appear here automatically when you import bookmarks",
+ "created_at": "Created {{time}}",
+ "progress": "Progress",
+ "status": {
+ "pending": "Pending",
+ "in_progress": "In progress",
+ "completed": "Completed",
+ "failed": "Failed",
+ "processing": "Processing"
+ },
+ "badges": {
+ "pending": "{{count}} pending",
+ "processing": "{{count}} processing",
+ "completed": "{{count}} completed",
+ "failed": "{{count}} failed"
+ },
+ "imported_to": "Imported to:",
+ "view_list": "View List",
+ "delete_dialog_title": "Delete Import Session",
+ "delete_dialog_description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone. The bookmarks themselves will not be deleted.",
+ "delete_session": "Delete Session"
}
},
"admin": {
diff --git a/packages/db/drizzle/0062_add_import_session.sql b/packages/db/drizzle/0062_add_import_session.sql
new file mode 100644
index 00000000..ce2823c4
--- /dev/null
+++ b/packages/db/drizzle/0062_add_import_session.sql
@@ -0,0 +1,25 @@
+CREATE TABLE `importSessionBookmarks` (
+ `id` text PRIMARY KEY NOT NULL,
+ `importSessionId` text NOT NULL,
+ `bookmarkId` text NOT NULL,
+ `createdAt` integer NOT NULL,
+ FOREIGN KEY (`importSessionId`) REFERENCES `importSessions`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`bookmarkId`) REFERENCES `bookmarks`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE INDEX `importSessionBookmarks_sessionId_idx` ON `importSessionBookmarks` (`importSessionId`);--> statement-breakpoint
+CREATE INDEX `importSessionBookmarks_bookmarkId_idx` ON `importSessionBookmarks` (`bookmarkId`);--> statement-breakpoint
+CREATE UNIQUE INDEX `importSessionBookmarks_importSessionId_bookmarkId_unique` ON `importSessionBookmarks` (`importSessionId`,`bookmarkId`);--> statement-breakpoint
+CREATE TABLE `importSessions` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `userId` text NOT NULL,
+ `message` text,
+ `rootListId` text,
+ `createdAt` integer NOT NULL,
+ `modifiedAt` integer,
+ FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
+ FOREIGN KEY (`rootListId`) REFERENCES `bookmarkLists`(`id`) ON UPDATE no action ON DELETE set null
+);
+--> statement-breakpoint
+CREATE INDEX `importSessions_userId_idx` ON `importSessions` (`userId`); \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0062_snapshot.json b/packages/db/drizzle/meta/0062_snapshot.json
new file mode 100644
index 00000000..8d8fa60c
--- /dev/null
+++ b/packages/db/drizzle/meta/0062_snapshot.json
@@ -0,0 +1,2489 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "85dc1c66-ccf6-4db8-b722-c5cbaf18f806",
+ "prevId": "e868f89a-f304-428c-8e96-705453facb18",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentAssetId": {
+ "name": "contentAssetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessionBookmarks": {
+ "name": "importSessionBookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "importSessionId": {
+ "name": "importSessionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessionBookmarks_sessionId_idx": {
+ "name": "importSessionBookmarks_sessionId_idx",
+ "columns": [
+ "importSessionId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_bookmarkId_idx": {
+ "name": "importSessionBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "importSessionBookmarks_importSessionId_bookmarkId_unique": {
+ "name": "importSessionBookmarks_importSessionId_bookmarkId_unique",
+ "columns": [
+ "importSessionId",
+ "bookmarkId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "importSessionBookmarks_importSessionId_importSessions_id_fk": {
+ "name": "importSessionBookmarks_importSessionId_importSessions_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "importSessions",
+ "columnsFrom": [
+ "importSessionId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessionBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "importSessionBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "importSessionBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "importSessions": {
+ "name": "importSessions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rootListId": {
+ "name": "rootListId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "importSessions_userId_idx": {
+ "name": "importSessions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "importSessions_userId_user_id_fk": {
+ "name": "importSessions_userId_user_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "importSessions_rootListId_bookmarkLists_id_fk": {
+ "name": "importSessions_rootListId_bookmarkLists_id_fk",
+ "tableFrom": "importSessions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "rootListId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invites": {
+ "name": "invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "usedAt": {
+ "name": "usedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invites_token_unique": {
+ "name": "invites_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invites_invitedBy_user_id_fk": {
+ "name": "invites_invitedBy_user_id_fk",
+ "tableFrom": "invites",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "passwordResetToken": {
+ "name": "passwordResetToken",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passwordResetToken_token_unique": {
+ "name": "passwordResetToken_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "passwordResetTokens_userId_idx": {
+ "name": "passwordResetTokens_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passwordResetToken_userId_user_id_fk": {
+ "name": "passwordResetToken_userId_user_id_fk",
+ "tableFrom": "passwordResetToken",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "subscriptions": {
+ "name": "subscriptions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeCustomerId": {
+ "name": "stripeCustomerId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stripeSubscriptionId": {
+ "name": "stripeSubscriptionId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'free'"
+ },
+ "priceId": {
+ "name": "priceId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "cancelAtPeriodEnd": {
+ "name": "cancelAtPeriodEnd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": false
+ },
+ "startDate": {
+ "name": "startDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "endDate": {
+ "name": "endDate",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "subscriptions_userId_unique": {
+ "name": "subscriptions_userId_unique",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": true
+ },
+ "subscriptions_userId_idx": {
+ "name": "subscriptions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "subscriptions_stripeCustomerId_idx": {
+ "name": "subscriptions_stripeCustomerId_idx",
+ "columns": [
+ "stripeCustomerId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "subscriptions_userId_user_id_fk": {
+ "name": "subscriptions_userId_user_id_fk",
+ "tableFrom": "subscriptions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "bookmarkQuota": {
+ "name": "bookmarkQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageQuota": {
+ "name": "storageQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "browserCrawlingEnabled": {
+ "name": "browserCrawlingEnabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'UTC'"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 4c045422..a1e3b96e 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -435,6 +435,13 @@
"when": 1754236017965,
"tag": "0061_merge_user_settings",
"breakpoints": true
+ },
+ {
+ "idx": 62,
+ "version": "6",
+ "when": 1759573697911,
+ "tag": "0062_add_import_session",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index d94d3963..c112b075 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -628,6 +628,49 @@ export const subscriptions = sqliteTable(
],
);
+export const importSessions = sqliteTable(
+ "importSessions",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ name: text("name").notNull(),
+ userId: text("userId")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ message: text("message"),
+ rootListId: text("rootListId").references(() => bookmarkLists.id, {
+ onDelete: "set null",
+ }),
+ createdAt: createdAtField(),
+ modifiedAt: modifiedAtField(),
+ },
+ (is) => [index("importSessions_userId_idx").on(is.userId)],
+);
+
+export const importSessionBookmarks = sqliteTable(
+ "importSessionBookmarks",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ importSessionId: text("importSessionId")
+ .notNull()
+ .references(() => importSessions.id, { onDelete: "cascade" }),
+ bookmarkId: text("bookmarkId")
+ .notNull()
+ .references(() => bookmarks.id, { onDelete: "cascade" }),
+ createdAt: createdAtField(),
+ },
+ (isb) => [
+ index("importSessionBookmarks_sessionId_idx").on(isb.importSessionId),
+ index("importSessionBookmarks_bookmarkId_idx").on(isb.bookmarkId),
+ unique().on(isb.importSessionId, isb.bookmarkId),
+ ],
+);
+
// Relations
export const userRelations = relations(users, ({ many, one }) => ({
@@ -637,6 +680,7 @@ export const userRelations = relations(users, ({ many, one }) => ({
rules: many(ruleEngineRulesTable),
invites: many(invites),
subscription: one(subscriptions),
+ importSessions: many(importSessions),
}));
export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({
@@ -660,6 +704,7 @@ export const bookmarkRelations = relations(bookmarks, ({ many, one }) => ({
bookmarksInLists: many(bookmarksInLists),
assets: many(assets),
rssFeeds: many(rssFeedImportsTable),
+ importSessionBookmarks: many(importSessionBookmarks),
}));
export const assetRelations = relations(assets, ({ one }) => ({
@@ -795,3 +840,28 @@ export const passwordResetTokensRelations = relations(
}),
}),
);
+
+export const importSessionsRelations = relations(
+ importSessions,
+ ({ one, many }) => ({
+ user: one(users, {
+ fields: [importSessions.userId],
+ references: [users.id],
+ }),
+ bookmarks: many(importSessionBookmarks),
+ }),
+);
+
+export const importSessionBookmarksRelations = relations(
+ importSessionBookmarks,
+ ({ one }) => ({
+ importSession: one(importSessions, {
+ fields: [importSessionBookmarks.importSessionId],
+ references: [importSessions.id],
+ }),
+ bookmark: one(bookmarks, {
+ fields: [importSessionBookmarks.bookmarkId],
+ references: [bookmarks.id],
+ }),
+ }),
+);
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index ffa9c357..b525d39f 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -693,6 +693,9 @@
"low",
"normal"
]
+ },
+ "importSessionId": {
+ "type": "string"
}
}
},
diff --git a/packages/shared/import-export/importer.test.ts b/packages/shared/import-export/importer.test.ts
index 2ea63846..00f892a9 100644
--- a/packages/shared/import-export/importer.test.ts
+++ b/packages/shared/import-export/importer.test.ts
@@ -85,6 +85,8 @@ describe("importBookmarksFromFile", () => {
},
);
+ const createImportSession = vi.fn(async () => ({ id: "session-1" }));
+
const progress: number[] = [];
const res = await importBookmarksFromFile(
{
@@ -96,6 +98,7 @@ describe("importBookmarksFromFile", () => {
createBookmark,
addBookmarkToLists,
updateBookmarkTags,
+ createImportSession,
},
onProgress: (d, t) => progress.push(d / t),
},
@@ -167,6 +170,7 @@ describe("importBookmarksFromFile", () => {
createBookmark: vi.fn(),
addBookmarkToLists: vi.fn(),
updateBookmarkTags: vi.fn(),
+ createImportSession: vi.fn(async () => ({ id: "session-1" })),
},
},
{ parsers },
@@ -174,6 +178,7 @@ describe("importBookmarksFromFile", () => {
expect(res).toEqual({
counts: { successes: 0, failures: 0, alreadyExisted: 0, total: 0 },
rootListId: null,
+ importSessionId: null,
});
});
@@ -244,6 +249,8 @@ describe("importBookmarksFromFile", () => {
},
);
+ const createImportSession = vi.fn(async () => ({ id: "session-1" }));
+
const progress: number[] = [];
const res = await importBookmarksFromFile(
{
@@ -255,6 +262,7 @@ describe("importBookmarksFromFile", () => {
createBookmark,
addBookmarkToLists,
updateBookmarkTags,
+ createImportSession,
},
onProgress: (d, t) => progress.push(d / t),
},
@@ -353,6 +361,8 @@ describe("importBookmarksFromFile", () => {
},
);
+ const createImportSession = vi.fn(async () => ({ id: "session-1" }));
+
const progress: number[] = [];
const res = await importBookmarksFromFile(
{
@@ -364,6 +374,7 @@ describe("importBookmarksFromFile", () => {
createBookmark,
addBookmarkToLists,
updateBookmarkTags,
+ createImportSession,
},
onProgress: (d, t) => progress.push(d / t),
},
@@ -371,6 +382,7 @@ describe("importBookmarksFromFile", () => {
);
expect(res.rootListId).toBe("Imported");
+ expect(res.importSessionId).toBe("session-1");
// All bookmarks are created successfully, but 2 fail in post-processing
expect(res.counts).toEqual({
diff --git a/packages/shared/import-export/importer.ts b/packages/shared/import-export/importer.ts
index 88c0c3bc..b32c49c1 100644
--- a/packages/shared/import-export/importer.ts
+++ b/packages/shared/import-export/importer.ts
@@ -17,6 +17,7 @@ export interface ImportDeps {
}) => Promise<{ id: string }>;
createBookmark: (
bookmark: ParsedBookmark,
+ sessionId: string,
) => Promise<{ id: string; alreadyExists?: boolean }>;
addBookmarkToLists: (input: {
bookmarkId: string;
@@ -26,6 +27,10 @@ export interface ImportDeps {
bookmarkId: string;
tags: string[];
}) => Promise<void>;
+ createImportSession: (input: {
+ name: string;
+ rootListId: string;
+ }) => Promise<{ id: string }>;
}
export interface ImportOptions {
@@ -38,6 +43,7 @@ export interface ImportOptions {
export interface ImportResult {
counts: ImportCounts;
rootListId: string | null;
+ importSessionId: string | null;
}
export async function importBookmarksFromFile(
@@ -66,10 +72,15 @@ export async function importBookmarksFromFile(
return {
counts: { successes: 0, failures: 0, alreadyExisted: 0, total: 0 },
rootListId: null,
+ importSessionId: null,
};
}
const rootList = await deps.createList({ name: rootListName, icon: "⬆️" });
+ const session = await deps.createImportSession({
+ name: `${source.charAt(0).toUpperCase() + source.slice(1)} Import - ${new Date().toLocaleDateString()}`,
+ rootListId: rootList.id,
+ });
onProgress?.(0, parsedBookmarks.length);
@@ -109,22 +120,28 @@ export async function importBookmarksFromFile(
pathMap[pathKey] = folderList.id;
}
+ let done = 0;
const importPromises = parsedBookmarks.map((bookmark) => async () => {
- const listIds = bookmark.paths.map(
- (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id,
- );
- if (listIds.length === 0) listIds.push(rootList.id);
-
- const created = await deps.createBookmark(bookmark);
- await deps.addBookmarkToLists({ bookmarkId: created.id, listIds });
- if (bookmark.tags && bookmark.tags.length > 0) {
- await deps.updateBookmarkTags({
- bookmarkId: created.id,
- tags: bookmark.tags,
- });
- }
+ try {
+ const listIds = bookmark.paths.map(
+ (path) => pathMap[path.join(PATH_DELIMITER)] || rootList.id,
+ );
+ if (listIds.length === 0) listIds.push(rootList.id);
+
+ const created = await deps.createBookmark(bookmark, session.id);
+ await deps.addBookmarkToLists({ bookmarkId: created.id, listIds });
+ if (bookmark.tags && bookmark.tags.length > 0) {
+ await deps.updateBookmarkTags({
+ bookmarkId: created.id,
+ tags: bookmark.tags,
+ });
+ }
- return created;
+ return created;
+ } finally {
+ done += 1;
+ onProgress?.(done, parsedBookmarks.length);
+ }
});
const resultsPromises = limitConcurrency(importPromises, concurrencyLimit);
@@ -134,7 +151,6 @@ export async function importBookmarksFromFile(
let failures = 0;
let alreadyExisted = 0;
- let done = 0;
for (const r of results) {
if (r.status === "fulfilled") {
if (r.value.alreadyExists) alreadyExisted++;
@@ -142,10 +158,7 @@ export async function importBookmarksFromFile(
} else {
failures++;
}
- done += 1;
- onProgress?.(done, parsedBookmarks.length);
}
-
return {
counts: {
successes,
@@ -154,5 +167,6 @@ export async function importBookmarksFromFile(
total: parsedBookmarks.length,
},
rootListId: rootList.id,
+ importSessionId: session.id,
};
}
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index a22e7632..71cf1012 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -142,6 +142,7 @@ export const zNewBookmarkRequestSchema = z
// A mechanism to prioritize crawling of bookmarks depending on whether
// they were created by a user interaction or by a bulk import.
crawlPriority: z.enum(["low", "normal"]).optional(),
+ importSessionId: z.string().optional(),
})
.and(
z.discriminatedUnion("type", [
diff --git a/packages/shared/types/importSessions.ts b/packages/shared/types/importSessions.ts
new file mode 100644
index 00000000..0c1edd03
--- /dev/null
+++ b/packages/shared/types/importSessions.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+export const zImportSessionStatusSchema = z.enum([
+ "pending",
+ "in_progress",
+ "completed",
+ "failed",
+]);
+export type ZImportSessionStatus = z.infer<typeof zImportSessionStatusSchema>;
+
+export const zImportSessionBookmarkStatusSchema = z.enum([
+ "pending",
+ "processing",
+ "completed",
+ "failed",
+]);
+export type ZImportSessionBookmarkStatus = z.infer<
+ typeof zImportSessionBookmarkStatusSchema
+>;
+
+export const zImportSessionSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ userId: z.string(),
+ message: z.string().nullable(),
+ rootListId: z.string().nullable(),
+ createdAt: z.date(),
+ modifiedAt: z.date().nullable(),
+});
+export type ZImportSession = z.infer<typeof zImportSessionSchema>;
+
+export const zImportSessionWithStatsSchema = zImportSessionSchema.extend({
+ status: z.enum(["pending", "in_progress", "completed", "failed"]),
+ totalBookmarks: z.number(),
+ completedBookmarks: z.number(),
+ failedBookmarks: z.number(),
+ pendingBookmarks: z.number(),
+ processingBookmarks: z.number(),
+});
+export type ZImportSessionWithStats = z.infer<
+ typeof zImportSessionWithStatsSchema
+>;
+
+export const zCreateImportSessionRequestSchema = z.object({
+ name: z.string().min(1).max(255),
+ rootListId: z.string().optional(),
+});
+export type ZCreateImportSessionRequest = z.infer<
+ typeof zCreateImportSessionRequestSchema
+>;
+
+export const zGetImportSessionStatsRequestSchema = z.object({
+ importSessionId: z.string(),
+});
+export type ZGetImportSessionStatsRequest = z.infer<
+ typeof zGetImportSessionStatsRequestSchema
+>;
+
+export const zListImportSessionsRequestSchema = z.object({});
+export type ZListImportSessionsRequest = z.infer<
+ typeof zListImportSessionsRequestSchema
+>;
+
+export const zListImportSessionsResponseSchema = z.object({
+ sessions: z.array(zImportSessionWithStatsSchema),
+});
+export type ZListImportSessionsResponse = z.infer<
+ typeof zListImportSessionsResponseSchema
+>;
+
+export const zDeleteImportSessionRequestSchema = z.object({
+ importSessionId: z.string(),
+});
+export type ZDeleteImportSessionRequest = z.infer<
+ typeof zDeleteImportSessionRequestSchema
+>;
diff --git a/packages/trpc/models/importSessions.ts b/packages/trpc/models/importSessions.ts
new file mode 100644
index 00000000..270c2bce
--- /dev/null
+++ b/packages/trpc/models/importSessions.ts
@@ -0,0 +1,180 @@
+import { TRPCError } from "@trpc/server";
+import { and, count, eq } from "drizzle-orm";
+import { z } from "zod";
+
+import {
+ bookmarkLinks,
+ bookmarks,
+ importSessionBookmarks,
+ importSessions,
+} from "@karakeep/db/schema";
+import {
+ zCreateImportSessionRequestSchema,
+ ZImportSession,
+ ZImportSessionWithStats,
+} from "@karakeep/shared/types/importSessions";
+
+import type { AuthedContext } from "../index";
+import { PrivacyAware } from "./privacy";
+
+export class ImportSession implements PrivacyAware {
+ protected constructor(
+ protected ctx: AuthedContext,
+ public session: ZImportSession,
+ ) {}
+
+ static async fromId(
+ ctx: AuthedContext,
+ importSessionId: string,
+ ): Promise<ImportSession> {
+ const session = await ctx.db.query.importSessions.findFirst({
+ where: and(
+ eq(importSessions.id, importSessionId),
+ eq(importSessions.userId, ctx.user.id),
+ ),
+ });
+
+ if (!session) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Import session not found",
+ });
+ }
+
+ return new ImportSession(ctx, session);
+ }
+
+ static async create(
+ ctx: AuthedContext,
+ input: z.infer<typeof zCreateImportSessionRequestSchema>,
+ ): Promise<ImportSession> {
+ const [session] = await ctx.db
+ .insert(importSessions)
+ .values({
+ name: input.name,
+ userId: ctx.user.id,
+ rootListId: input.rootListId,
+ })
+ .returning();
+
+ return new ImportSession(ctx, session);
+ }
+
+ static async getAll(ctx: AuthedContext): Promise<ImportSession[]> {
+ const sessions = await ctx.db.query.importSessions.findMany({
+ where: eq(importSessions.userId, ctx.user.id),
+ orderBy: (importSessions, { desc }) => [desc(importSessions.createdAt)],
+ limit: 50,
+ });
+
+ return sessions.map((session) => new ImportSession(ctx, session));
+ }
+
+ static async getAllWithStats(
+ ctx: AuthedContext,
+ ): Promise<ZImportSessionWithStats[]> {
+ const sessions = await this.getAll(ctx);
+
+ return await Promise.all(
+ sessions.map(async (session) => {
+ return await session.getWithStats();
+ }),
+ );
+ }
+
+ ensureCanAccess(ctx: AuthedContext): void {
+ if (this.session.userId !== ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access this import session",
+ });
+ }
+ }
+
+ async attachBookmark(bookmarkId: string): Promise<void> {
+ await this.ctx.db.insert(importSessionBookmarks).values({
+ importSessionId: this.session.id,
+ bookmarkId,
+ });
+ }
+
+ async getWithStats(): Promise<ZImportSessionWithStats> {
+ // Get bookmark counts by status
+ const statusCounts = await this.ctx.db
+ .select({
+ crawlStatus: bookmarkLinks.crawlStatus,
+ taggingStatus: bookmarks.taggingStatus,
+ count: count(),
+ })
+ .from(importSessionBookmarks)
+ .innerJoin(
+ importSessions,
+ eq(importSessions.id, importSessionBookmarks.importSessionId),
+ )
+ .leftJoin(bookmarks, eq(bookmarks.id, importSessionBookmarks.bookmarkId))
+ .leftJoin(
+ bookmarkLinks,
+ eq(bookmarkLinks.id, importSessionBookmarks.bookmarkId),
+ )
+ .where(
+ and(
+ eq(importSessionBookmarks.importSessionId, this.session.id),
+ eq(importSessions.userId, this.ctx.user.id),
+ ),
+ )
+ .groupBy(bookmarkLinks.crawlStatus, bookmarks.taggingStatus);
+
+ const stats = {
+ totalBookmarks: 0,
+ completedBookmarks: 0,
+ failedBookmarks: 0,
+ pendingBookmarks: 0,
+ processingBookmarks: 0,
+ };
+
+ statusCounts.forEach((statusCount) => {
+ stats.totalBookmarks += statusCount.count;
+ if (
+ statusCount.crawlStatus === "success" &&
+ statusCount.taggingStatus === "success"
+ ) {
+ stats.completedBookmarks += statusCount.count;
+ } else if (
+ statusCount.crawlStatus === "failure" ||
+ statusCount.taggingStatus === "failure"
+ ) {
+ stats.failedBookmarks += statusCount.count;
+ } else if (
+ statusCount.crawlStatus === "pending" ||
+ statusCount.taggingStatus === "pending"
+ ) {
+ stats.pendingBookmarks += statusCount.count;
+ }
+ });
+
+ return {
+ ...this.session,
+ status: stats.pendingBookmarks > 0 ? "in_progress" : "completed",
+ ...stats,
+ };
+ }
+
+ async delete(): Promise<void> {
+ // Delete the session (cascade will handle the bookmarks)
+ const result = await this.ctx.db
+ .delete(importSessions)
+ .where(
+ and(
+ eq(importSessions.id, this.session.id),
+ eq(importSessions.userId, this.ctx.user.id),
+ ),
+ );
+
+ if (result.changes === 0) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Import session not found",
+ });
+ }
+ }
+}
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
index 651c8d88..1d548ee4 100644
--- a/packages/trpc/routers/_app.ts
+++ b/packages/trpc/routers/_app.ts
@@ -5,6 +5,7 @@ import { assetsAppRouter } from "./assets";
import { bookmarksAppRouter } from "./bookmarks";
import { feedsAppRouter } from "./feeds";
import { highlightsAppRouter } from "./highlights";
+import { importSessionsRouter } from "./importSessions";
import { invitesAppRouter } from "./invites";
import { listsAppRouter } from "./lists";
import { promptsAppRouter } from "./prompts";
@@ -25,6 +26,7 @@ export const appRouter = router({
admin: adminAppRouter,
feeds: feedsAppRouter,
highlights: highlightsAppRouter,
+ importSessions: importSessionsRouter,
webhooks: webhooksAppRouter,
assets: assetsAppRouter,
rules: rulesAppRouter,
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 3399bf19..be3664b3 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -59,6 +59,7 @@ import { authedProcedure, createRateLimitMiddleware, router } from "../index";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
import { getBookmarkIdsFromMatcher } from "../lib/search";
import { Bookmark } from "../models/bookmarks";
+import { ImportSession } from "../models/importSessions";
import { ensureAssetOwnership } from "./assets";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
@@ -272,6 +273,13 @@ export const bookmarksAppRouter = router({
// This doesn't 100% protect from duplicates because of races, but it's more than enough for this usecase.
const alreadyExists = await attemptToDedupLink(ctx, input.url);
if (alreadyExists) {
+ if (input.importSessionId) {
+ const session = await ImportSession.fromId(
+ ctx,
+ input.importSessionId,
+ );
+ await session.attachBookmark(alreadyExists.id);
+ }
return { ...alreadyExists, alreadyExists: true };
}
}
@@ -416,12 +424,16 @@ export const bookmarksAppRouter = router({
};
});
+ if (input.importSessionId) {
+ const session = await ImportSession.fromId(ctx, input.importSessionId);
+ await session.attachBookmark(bookmark.id);
+ }
+
const enqueueOpts: EnqueueOptions = {
// The lower the priority number, the sooner the job will be processed
priority: input.crawlPriority === "low" ? 50 : 0,
};
- // Enqueue crawling request
switch (bookmark.content.type) {
case BookmarkTypes.LINK: {
// The crawling job triggers openai when it's done
@@ -454,6 +466,7 @@ export const bookmarksAppRouter = router({
break;
}
}
+
await triggerRuleEngineOnEvent(
bookmark.id,
[
diff --git a/packages/trpc/routers/importSessions.test.ts b/packages/trpc/routers/importSessions.test.ts
new file mode 100644
index 00000000..b28d1421
--- /dev/null
+++ b/packages/trpc/routers/importSessions.test.ts
@@ -0,0 +1,204 @@
+import { beforeEach, describe, expect, test } from "vitest";
+import { z } from "zod";
+
+import {
+ BookmarkTypes,
+ zNewBookmarkRequestSchema,
+} from "@karakeep/shared/types/bookmarks";
+import {
+ zCreateImportSessionRequestSchema,
+ zDeleteImportSessionRequestSchema,
+ zGetImportSessionStatsRequestSchema,
+} from "@karakeep/shared/types/importSessions";
+import { zNewBookmarkListSchema } from "@karakeep/shared/types/lists";
+
+import type { APICallerType, CustomTestContext } from "../testUtils";
+import { defaultBeforeEach } from "../testUtils";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(true));
+
+describe("ImportSessions Routes", () => {
+ async function createTestBookmark(api: APICallerType, sessionId: string) {
+ const newBookmarkInput: z.infer<typeof zNewBookmarkRequestSchema> = {
+ type: BookmarkTypes.TEXT,
+ text: "Test bookmark text",
+ importSessionId: sessionId,
+ };
+ const createdBookmark =
+ await api.bookmarks.createBookmark(newBookmarkInput);
+ return createdBookmark.id;
+ }
+
+ async function createTestList(api: APICallerType) {
+ const newListInput: z.infer<typeof zNewBookmarkListSchema> = {
+ name: "Test Import List",
+ description: "A test list for imports",
+ icon: "📋",
+ type: "manual",
+ };
+ const createdList = await api.lists.create(newListInput);
+ return createdList.id;
+ }
+
+ test<CustomTestContext>("create import session", async ({ apiCallers }) => {
+ const api = apiCallers[0].importSessions;
+ const listId = await createTestList(apiCallers[0]);
+
+ const newSessionInput: z.infer<typeof zCreateImportSessionRequestSchema> = {
+ name: "Test Import Session",
+ rootListId: listId,
+ };
+
+ const createdSession = await api.createImportSession(newSessionInput);
+
+ expect(createdSession).toMatchObject({
+ id: expect.any(String),
+ });
+
+ // Verify session appears in list
+ const sessions = await api.listImportSessions({});
+ const sessionFromList = sessions.sessions.find(
+ (s) => s.id === createdSession.id,
+ );
+ expect(sessionFromList).toBeDefined();
+ expect(sessionFromList?.name).toEqual(newSessionInput.name);
+ expect(sessionFromList?.rootListId).toEqual(listId);
+ });
+
+ test<CustomTestContext>("create import session without rootListId", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].importSessions;
+
+ const newSessionInput: z.infer<typeof zCreateImportSessionRequestSchema> = {
+ name: "Test Import Session",
+ };
+
+ const createdSession = await api.createImportSession(newSessionInput);
+
+ expect(createdSession).toMatchObject({
+ id: expect.any(String),
+ });
+
+ // Verify session appears in list
+ const sessions = await api.listImportSessions({});
+ const sessionFromList = sessions.sessions.find(
+ (s) => s.id === createdSession.id,
+ );
+ expect(sessionFromList?.rootListId).toBeNull();
+ });
+
+ test<CustomTestContext>("get import session stats", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0];
+
+ const session = await api.importSessions.createImportSession({
+ name: "Test Import Session",
+ });
+ await createTestBookmark(api, session.id);
+ await createTestBookmark(api, session.id);
+
+ const statsInput: z.infer<typeof zGetImportSessionStatsRequestSchema> = {
+ importSessionId: session.id,
+ };
+
+ const stats = await api.importSessions.getImportSessionStats(statsInput);
+
+ expect(stats).toMatchObject({
+ id: session.id,
+ name: "Test Import Session",
+ status: "in_progress",
+ totalBookmarks: 2,
+ pendingBookmarks: 2,
+ completedBookmarks: 0,
+ failedBookmarks: 0,
+ processingBookmarks: 0,
+ });
+ });
+
+ test<CustomTestContext>("list import sessions returns all sessions", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].importSessions;
+
+ const sessionNames = ["Session 1", "Session 2", "Session 3"];
+ for (const name of sessionNames) {
+ await api.createImportSession({ name });
+ }
+
+ const result = await api.listImportSessions({});
+
+ expect(result.sessions).toHaveLength(3);
+ expect(result.sessions.map((session) => session.name)).toEqual(
+ sessionNames,
+ );
+ expect(
+ result.sessions.every((session) => session.totalBookmarks === 0),
+ ).toBe(true);
+ });
+
+ test<CustomTestContext>("delete import session", async ({ apiCallers }) => {
+ const api = apiCallers[0].importSessions;
+
+ const session = await api.createImportSession({
+ name: "Session to Delete",
+ });
+
+ const deleteInput: z.infer<typeof zDeleteImportSessionRequestSchema> = {
+ importSessionId: session.id,
+ };
+
+ const result = await api.deleteImportSession(deleteInput);
+ expect(result.success).toBe(true);
+
+ // Verify session no longer exists
+ await expect(
+ api.getImportSessionStats({
+ importSessionId: session.id,
+ }),
+ ).rejects.toThrow("Import session not found");
+ });
+
+ test<CustomTestContext>("cannot access other user's session", async ({
+ apiCallers,
+ }) => {
+ const api1 = apiCallers[0].importSessions;
+ const api2 = apiCallers[1].importSessions;
+
+ // User 1 creates a session
+ const session = await api1.createImportSession({
+ name: "User 1 Session",
+ });
+
+ // User 2 tries to access it
+ await expect(
+ api2.getImportSessionStats({
+ importSessionId: session.id,
+ }),
+ ).rejects.toThrow("Import session not found");
+
+ await expect(
+ api2.deleteImportSession({
+ importSessionId: session.id,
+ }),
+ ).rejects.toThrow("Import session not found");
+ });
+
+ test<CustomTestContext>("cannot attach other user's bookmark", async ({
+ apiCallers,
+ }) => {
+ const api1 = apiCallers[0];
+ const api2 = apiCallers[1];
+
+ // User 1 creates session and bookmark
+ const session = await api1.importSessions.createImportSession({
+ name: "User 1 Session",
+ });
+
+ // User 1 tries to attach User 2's bookmark
+ await expect(
+ createTestBookmark(api2, session.id), // User 2's bookmark
+ ).rejects.toThrow("Import session not found");
+ });
+});
diff --git a/packages/trpc/routers/importSessions.ts b/packages/trpc/routers/importSessions.ts
new file mode 100644
index 00000000..4bdc4f29
--- /dev/null
+++ b/packages/trpc/routers/importSessions.ts
@@ -0,0 +1,48 @@
+import { z } from "zod";
+
+import {
+ zCreateImportSessionRequestSchema,
+ zDeleteImportSessionRequestSchema,
+ zGetImportSessionStatsRequestSchema,
+ zImportSessionWithStatsSchema,
+ zListImportSessionsRequestSchema,
+ zListImportSessionsResponseSchema,
+} from "@karakeep/shared/types/importSessions";
+
+import { authedProcedure, router } from "../index";
+import { ImportSession } from "../models/importSessions";
+
+export const importSessionsRouter = router({
+ createImportSession: authedProcedure
+ .input(zCreateImportSessionRequestSchema)
+ .output(z.object({ id: z.string() }))
+ .mutation(async ({ input, ctx }) => {
+ const session = await ImportSession.create(ctx, input);
+ return { id: session.session.id };
+ }),
+
+ getImportSessionStats: authedProcedure
+ .input(zGetImportSessionStatsRequestSchema)
+ .output(zImportSessionWithStatsSchema)
+ .query(async ({ input, ctx }) => {
+ const session = await ImportSession.fromId(ctx, input.importSessionId);
+ return await session.getWithStats();
+ }),
+
+ listImportSessions: authedProcedure
+ .input(zListImportSessionsRequestSchema)
+ .output(zListImportSessionsResponseSchema)
+ .query(async ({ ctx }) => {
+ const sessions = await ImportSession.getAllWithStats(ctx);
+ return { sessions };
+ }),
+
+ deleteImportSession: authedProcedure
+ .input(zDeleteImportSessionRequestSchema)
+ .output(z.object({ success: z.boolean() }))
+ .mutation(async ({ input, ctx }) => {
+ const session = await ImportSession.fromId(ctx, input.importSessionId);
+ await session.delete();
+ return { success: true };
+ }),
+});