aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx16
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx114
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx68
-rw-r--r--apps/web/lib/clientConfig.tsx2
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json4
-rw-r--r--apps/workers/trpc.ts34
-rw-r--r--packages/api/index.ts4
-rw-r--r--packages/api/middlewares/auth.ts17
-rw-r--r--packages/api/package.json2
-rw-r--r--packages/api/routes/rss.ts51
-rw-r--r--packages/api/utils/rss.ts54
-rw-r--r--packages/db/drizzle/0049_add_rss_token.sql1
-rw-r--r--packages/db/drizzle/meta/0049_snapshot.json2013
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts2
-rw-r--r--packages/e2e_tests/tests/api/rss.test.ts155
-rw-r--r--packages/shared-react/tsconfig.json2
-rw-r--r--packages/shared/config.ts5
-rw-r--r--packages/shared/types/bookmarks.ts29
-rw-r--r--packages/shared/types/pagination.ts2
-rw-r--r--packages/trpc/lib/impersonate.ts30
-rw-r--r--packages/trpc/models/bookmarks.ts365
-rw-r--r--packages/trpc/models/lists.ts99
-rw-r--r--packages/trpc/routers/bookmarks.ts262
-rw-r--r--packages/trpc/routers/lists.ts43
-rw-r--r--pnpm-lock.yaml43
26 files changed, 3131 insertions, 293 deletions
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index 9a979686..d0dedfd5 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -6,13 +6,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslation } from "@/lib/i18n/client";
-import { FolderInput, Pencil, Plus, Trash2 } from "lucide-react";
+import { FolderInput, Pencil, Plus, Share, Trash2 } from "lucide-react";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
import { EditListModal } from "../lists/EditListModal";
import DeleteListConfirmationDialog from "./DeleteListConfirmationDialog";
import { MergeListModal } from "./MergeListModal";
+import { ShareListModal } from "./ShareListModal";
export function ListOptions({
list,
@@ -31,9 +32,15 @@ export function ListOptions({
const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
const [mergeListModalOpen, setMergeListModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
+ const [shareModalOpen, setShareModalOpen] = useState(false);
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
+ <ShareListModal
+ open={shareModalOpen}
+ setOpen={setShareModalOpen}
+ list={list}
+ />
<EditListModal
open={newNestedListModalOpen}
setOpen={setNewNestedListModalOpen}
@@ -67,6 +74,13 @@ export function ListOptions({
</DropdownMenuItem>
<DropdownMenuItem
className="flex gap-2"
+ onClick={() => setShareModalOpen(true)}
+ >
+ <Share className="size-4" />
+ <span>{t("lists.share_list")}</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ className="flex gap-2"
onClick={() => setNewNestedListModalOpen(true)}
>
<Plus className="size-4" />
diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx
new file mode 100644
index 00000000..152a3fe4
--- /dev/null
+++ b/apps/web/components/dashboard/lists/RssLink.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useMemo } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button, buttonVariants } from "@/components/ui/button";
+import CopyBtn from "@/components/ui/copy-button";
+import { Input } from "@/components/ui/input";
+import { useClientConfig } from "@/lib/clientConfig";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+import { Loader2, RotateCcw, Rss, Trash2 } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+export default function RssLink({ listId }: { listId: string }) {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+ const apiUtils = api.useUtils();
+
+ const { mutate: regenRssToken, isPending: isRegenPending } =
+ api.lists.regenRssToken.useMutation({
+ onSuccess: () => {
+ apiUtils.lists.getRssToken.invalidate({ listId });
+ },
+ });
+ const { mutate: clearRssToken, isPending: isClearPending } =
+ api.lists.clearRssToken.useMutation({
+ onSuccess: () => {
+ apiUtils.lists.getRssToken.invalidate({ listId });
+ },
+ });
+ const { data: rssToken, isLoading: isTokenLoading } =
+ api.lists.getRssToken.useQuery({ listId });
+
+ const rssUrl = useMemo(() => {
+ if (!rssToken || !rssToken.token) {
+ return null;
+ }
+ return `${clientConfig.publicApiUrl}/v1/rss/lists/${listId}?token=${rssToken.token}`;
+ }, [rssToken]);
+
+ const isLoading = isRegenPending || isClearPending || isTokenLoading;
+
+ return (
+ <div className="flex items-center gap-3 rounded-lg border bg-white p-3">
+ <Badge variant="outline" className="text-xs">
+ RSS
+ </Badge>
+ {!rssUrl ? (
+ <div className="flex items-center gap-2">
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => regenRssToken({ listId })}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <span className="flex items-center">
+ <Rss className="mr-2 h-4 w-4 flex-shrink-0 text-orange-500" />
+ {t("lists.generate_rss_feed")}
+ </span>
+ )}
+ </Button>
+ </div>
+ ) : (
+ <div className="flex min-w-0 flex-1 items-center gap-2">
+ <Input
+ value={rssUrl}
+ readOnly
+ className="h-8 min-w-0 flex-1 font-mono text-xs"
+ />
+ <div className="flex flex-shrink-0 gap-1">
+ <CopyBtn
+ getStringToCopy={() => {
+ return rssUrl;
+ }}
+ className={cn(
+ buttonVariants({ variant: "outline", size: "sm" }),
+ "h-8 w-8 p-0",
+ )}
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => regenRssToken({ listId })}
+ disabled={isLoading}
+ className="h-8 w-8 p-0"
+ >
+ {isLoading ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <RotateCcw className="h-3 w-3" />
+ )}
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => clearRssToken({ listId })}
+ disabled={isLoading}
+ className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ >
+ {isLoading ? (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ ) : (
+ <Trash2 className="h-3 w-3" />
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/ShareListModal.tsx b/apps/web/components/dashboard/lists/ShareListModal.tsx
new file mode 100644
index 00000000..5c7b060e
--- /dev/null
+++ b/apps/web/components/dashboard/lists/ShareListModal.tsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { useTranslation } from "@/lib/i18n/client";
+import { DialogDescription } from "@radix-ui/react-dialog";
+
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+import RssLink from "./RssLink";
+
+export function ShareListModal({
+ open: userOpen,
+ setOpen: userSetOpen,
+ list,
+ children,
+}: {
+ open?: boolean;
+ setOpen?: (v: boolean) => void;
+ list: ZBookmarkList;
+ children?: React.ReactNode;
+}) {
+ const { t } = useTranslation();
+ if (
+ (userOpen !== undefined && !userSetOpen) ||
+ (userOpen === undefined && userSetOpen)
+ ) {
+ throw new Error("You must provide both open and setOpen or neither");
+ }
+ const [customOpen, customSetOpen] = useState(false);
+ const [open, setOpen] = [
+ userOpen ?? customOpen,
+ userSetOpen ?? customSetOpen,
+ ];
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(s) => {
+ setOpen(s);
+ }}
+ >
+ {children && <DialogTrigger asChild>{children}</DialogTrigger>}
+ <DialogContent className="max-w-xl">
+ <DialogHeader>
+ <DialogTitle>{t("lists.share_list")}</DialogTitle>
+ </DialogHeader>
+ <DialogDescription className="mt-4 flex flex-col gap-2">
+ <RssLink listId={list.id} />
+ </DialogDescription>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ {t("actions.close")}
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx
index 2a66de37..03089e49 100644
--- a/apps/web/lib/clientConfig.tsx
+++ b/apps/web/lib/clientConfig.tsx
@@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
import type { ClientConfig } from "@karakeep/shared/config";
export const ClientConfigCtx = createContext<ClientConfig>({
+ publicUrl: "",
+ publicApiUrl: "",
demoMode: undefined,
auth: {
disableSignups: false,
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 48d32f37..d7ee54a3 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -282,6 +282,7 @@
"favourites": "Favourites",
"new_list": "New List",
"edit_list": "Edit List",
+ "share_list": "Share List",
"new_nested_list": "New Nested List",
"merge_list": "Merge List",
"destination_list": "Destination List",
@@ -294,7 +295,8 @@
"smart_list": "Smart List",
"search_query": "Search Query",
"search_query_help": "Learn more about the search query language.",
- "description": "Description (Optional)"
+ "description": "Description (Optional)",
+ "generate_rss_feed": "Generate RSS Feed"
},
"tags": {
"all_tags": "All Tags",
diff --git a/apps/workers/trpc.ts b/apps/workers/trpc.ts
index c5f880ad..28cd2d0b 100644
--- a/apps/workers/trpc.ts
+++ b/apps/workers/trpc.ts
@@ -1,36 +1,8 @@
-import { eq } from "drizzle-orm";
-
-import { db } from "@karakeep/db";
-import { users } from "@karakeep/db/schema";
-import { AuthedContext, createCallerFactory } from "@karakeep/trpc";
+import { createCallerFactory } from "@karakeep/trpc";
+import { buildImpersonatingAuthedContext as buildAuthedContext } from "@karakeep/trpc/lib/impersonate";
import { appRouter } from "@karakeep/trpc/routers/_app";
-/**
- * This is only safe to use in the context of a worker.
- */
-export async function buildImpersonatingAuthedContext(
- userId: string,
-): Promise<AuthedContext> {
- const user = await db.query.users.findFirst({
- where: eq(users.id, userId),
- });
- if (!user) {
- throw new Error("User not found");
- }
-
- return {
- user: {
- id: user.id,
- name: user.name,
- email: user.email,
- role: user.role,
- },
- db,
- req: {
- ip: null,
- },
- };
-}
+export const buildImpersonatingAuthedContext = buildAuthedContext;
/**
* This is only safe to use in the context of a worker.
diff --git a/packages/api/index.ts b/packages/api/index.ts
index 00919f3e..a3ba8d42 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -9,6 +9,7 @@ import assets from "./routes/assets";
import bookmarks from "./routes/bookmarks";
import highlights from "./routes/highlights";
import lists from "./routes/lists";
+import rss from "./routes/rss";
import tags from "./routes/tags";
import users from "./routes/users";
@@ -22,7 +23,8 @@ const v1 = new Hono<{
.route("/lists", lists)
.route("/tags", tags)
.route("/users", users)
- .route("/assets", assets);
+ .route("/assets", assets)
+ .route("/rss", rss);
const app = new Hono<{
Variables: {
diff --git a/packages/api/middlewares/auth.ts b/packages/api/middlewares/auth.ts
index 7f39a6f9..42bca6c8 100644
--- a/packages/api/middlewares/auth.ts
+++ b/packages/api/middlewares/auth.ts
@@ -1,11 +1,26 @@
import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
-import { AuthedContext, createCallerFactory } from "@karakeep/trpc";
+import { AuthedContext, Context, createCallerFactory } from "@karakeep/trpc";
import { appRouter } from "@karakeep/trpc/routers/_app";
const createCaller = createCallerFactory(appRouter);
+export const unauthedMiddleware = createMiddleware<{
+ Variables: {
+ ctx: Context;
+ api: ReturnType<typeof createCaller>;
+ };
+}>(async (c, next) => {
+ if (!c.var.ctx) {
+ throw new HTTPException(401, {
+ message: "Unauthorized",
+ });
+ }
+ c.set("api", createCaller(c.get("ctx")));
+ await next();
+});
+
export const authMiddleware = createMiddleware<{
Variables: {
ctx: AuthedContext;
diff --git a/packages/api/package.json b/packages/api/package.json
index f968ed94..82b2b9d0 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -18,6 +18,7 @@
"@karakeep/shared": "workspace:*",
"@karakeep/trpc": "workspace:*",
"hono": "^4.7.10",
+ "rss": "^1.2.2",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -26,6 +27,7 @@
"@karakeep/tsconfig": "workspace:^0.1.0",
"@types/bcryptjs": "^2.4.6",
"@types/deep-equal": "^1.0.4",
+ "@types/rss": "^0.0.32",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.6.1"
},
diff --git a/packages/api/routes/rss.ts b/packages/api/routes/rss.ts
new file mode 100644
index 00000000..81c9756c
--- /dev/null
+++ b/packages/api/routes/rss.ts
@@ -0,0 +1,51 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import serverConfig from "@karakeep/shared/config";
+import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
+import { List } from "@karakeep/trpc/models/lists";
+
+import { unauthedMiddleware } from "../middlewares/auth";
+import { toRSS } from "../utils/rss";
+
+const app = new Hono().get(
+ "/lists/:listId",
+ zValidator(
+ "query",
+ z.object({
+ token: z.string().min(1),
+ limit: z.coerce
+ .number()
+ .min(1)
+ .max(MAX_NUM_BOOKMARKS_PER_PAGE)
+ .optional(),
+ }),
+ ),
+ unauthedMiddleware,
+ async (c) => {
+ const listId = c.req.param("listId");
+ const searchParams = c.req.valid("query");
+ const token = searchParams.token;
+
+ const res = await List.getForRss(c.var.ctx, listId, token, {
+ limit: searchParams.limit ?? 20,
+ });
+ const list = res.list;
+
+ const rssFeed = toRSS(
+ {
+ title: `Bookmarks from ${list.icon} ${list.name}`,
+ feedUrl: `${serverConfig.publicApiUrl}/v1/rss/lists/${listId}`,
+ siteUrl: `${serverConfig.publicUrl}/dashboard/lists/${listId}`,
+ description: list.description ?? undefined,
+ },
+ res.bookmarks,
+ );
+
+ c.header("Content-Type", "application/rss+xml");
+ return c.body(rssFeed);
+ },
+);
+
+export default app;
diff --git a/packages/api/utils/rss.ts b/packages/api/utils/rss.ts
new file mode 100644
index 00000000..079b3f5a
--- /dev/null
+++ b/packages/api/utils/rss.ts
@@ -0,0 +1,54 @@
+import RSS from "rss";
+
+import serverConfig from "@karakeep/shared/config";
+import {
+ BookmarkTypes,
+ ZPublicBookmark,
+} from "@karakeep/shared/types/bookmarks";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
+
+export function toRSS(
+ params: {
+ title: string;
+ description?: string;
+ feedUrl: string;
+ siteUrl: string;
+ },
+ bookmarks: ZPublicBookmark[],
+) {
+ const feed = new RSS({
+ title: params.title,
+ feed_url: params.feedUrl,
+ site_url: params.siteUrl,
+ description: params.description,
+ generator: "Karakeep",
+ });
+
+ bookmarks
+ .filter(
+ (b) =>
+ b.content.type === BookmarkTypes.LINK ||
+ b.content.type === BookmarkTypes.ASSET,
+ )
+ .forEach((bookmark) => {
+ feed.item({
+ date: bookmark.createdAt,
+ title: bookmark.title ?? "",
+ url:
+ bookmark.content.type === BookmarkTypes.LINK
+ ? bookmark.content.url
+ : bookmark.content.type === BookmarkTypes.ASSET
+ ? `${serverConfig.publicUrl}${getAssetUrl(bookmark.content.assetId)}`
+ : "",
+ guid: bookmark.id,
+ author:
+ bookmark.content.type === BookmarkTypes.LINK
+ ? (bookmark.content.author ?? undefined)
+ : undefined,
+ categories: bookmark.tags,
+ description: bookmark.description ?? "",
+ });
+ });
+
+ return feed.xml({ indent: true });
+}
diff --git a/packages/db/drizzle/0049_add_rss_token.sql b/packages/db/drizzle/0049_add_rss_token.sql
new file mode 100644
index 00000000..c256f859
--- /dev/null
+++ b/packages/db/drizzle/0049_add_rss_token.sql
@@ -0,0 +1 @@
+ALTER TABLE `bookmarkLists` ADD `rssToken` text; \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0049_snapshot.json b/packages/db/drizzle/meta/0049_snapshot.json
new file mode 100644
index 00000000..4dc9f149
--- /dev/null
+++ b/packages/db/drizzle/meta/0049_snapshot.json
@@ -0,0 +1,2013 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "98a6c743-e257-4126-a95f-40850e03ee4e",
+ "prevId": "d3ffc2ee-399e-4652-813d-652b56d649f6",
+ "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
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "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
+ }
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "userSettings": {
+ "name": "userSettings",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "userSettings_userId_user_id_fk": {
+ "name": "userSettings_userId_user_id_fk",
+ "tableFrom": "userSettings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "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'"
+ }
+ },
+ "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 d6219448..85c46b14 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -344,6 +344,13 @@
"when": 1748086734370,
"tag": "0048_add_user_settings",
"breakpoints": true
+ },
+ {
+ "idx": 49,
+ "version": "6",
+ "when": 1748699971545,
+ "tag": "0049_add_rss_token",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 5c109b76..08dbacab 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -336,6 +336,8 @@ export const bookmarkLists = sqliteTable(
(): AnySQLiteColumn => bookmarkLists.id,
{ onDelete: "set null" },
),
+ // Whoever have access to this token can read the content of this list
+ rssToken: text("rssToken"),
},
(bl) => [
index("bookmarkLists_userId_idx").on(bl.userId),
diff --git a/packages/e2e_tests/tests/api/rss.test.ts b/packages/e2e_tests/tests/api/rss.test.ts
new file mode 100644
index 00000000..8a7447e6
--- /dev/null
+++ b/packages/e2e_tests/tests/api/rss.test.ts
@@ -0,0 +1,155 @@
+import { beforeEach, describe, expect, inject, it } from "vitest";
+
+import { BookmarkTypes } from "../../../shared/types/bookmarks";
+import { createTestUser } from "../../utils/api";
+import { getTrpcClient } from "../../utils/trpc";
+
+describe("RSS Feed API", () => {
+ const port = inject("karakeepPort");
+
+ if (!port) {
+ throw new Error("Missing required environment variables");
+ }
+
+ async function fetchRssFeed(listId: string, token: string) {
+ return await fetch(
+ `http://localhost:${port}/api/v1/rss/lists/${listId}?token=${token}`,
+ );
+ }
+
+ async function seedDatabase() {
+ const trpcClient = getTrpcClient(apiKey);
+
+ // Create two lists
+ const manualList = await trpcClient.lists.create.mutate({
+ name: "Test List #1",
+ icon: "🚀",
+ type: "manual",
+ });
+
+ const smartList = await trpcClient.lists.create.mutate({
+ name: "Test List #2",
+ icon: "🚀",
+ type: "smart",
+ query: "is:fav",
+ });
+
+ // Create two bookmarks
+ const createBookmark1 = await trpcClient.bookmarks.createBookmark.mutate({
+ title: "Test Bookmark #1",
+ url: "https://example.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ const createBookmark2 = await trpcClient.bookmarks.createBookmark.mutate({
+ title: "Test Bookmark #2",
+ url: "https://example.com/2",
+ type: BookmarkTypes.LINK,
+ favourited: true,
+ });
+
+ await trpcClient.lists.addToList.mutate({
+ listId: manualList.id,
+ bookmarkId: createBookmark1.id,
+ });
+
+ return { manualList, smartList, createBookmark1, createBookmark2 };
+ }
+
+ let apiKey: string;
+
+ beforeEach(async () => {
+ apiKey = await createTestUser();
+ });
+
+ it("should generate rss feed for manual lists", async () => {
+ const { manualList } = await seedDatabase();
+ const trpcClient = getTrpcClient(apiKey);
+
+ // Enable rss feed
+ const token = await trpcClient.lists.regenRssToken.mutate({
+ listId: manualList.id,
+ });
+
+ const res = await fetchRssFeed(manualList.id, token.token);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/rss+xml");
+
+ const text = await res.text();
+ expect(text).toContain("Test Bookmark #1");
+ expect(text).not.toContain("Test Bookmark #2");
+ });
+
+ it("should generate rss feed for smart lists", async () => {
+ const { smartList } = await seedDatabase();
+ const trpcClient = getTrpcClient(apiKey);
+
+ // Enable rss feed
+ const token = await trpcClient.lists.regenRssToken.mutate({
+ listId: smartList.id,
+ });
+
+ const res = await fetchRssFeed(smartList.id, token.token);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("Content-Type")).toBe("application/rss+xml");
+
+ const text = await res.text();
+ expect(text).not.toContain("Test Bookmark #1");
+ expect(text).toContain("Test Bookmark #2");
+ });
+
+ it("should fail when the token is invalid", async () => {
+ const { smartList } = await seedDatabase();
+ const trpcClient = getTrpcClient(apiKey);
+
+ // Enable rss feed
+ const token = await trpcClient.lists.regenRssToken.mutate({
+ listId: smartList.id,
+ });
+
+ let res = await fetchRssFeed(smartList.id, token.token);
+ expect(res.status).toBe(200);
+
+ // Invalidate the token
+ await trpcClient.lists.regenRssToken.mutate({
+ listId: smartList.id,
+ });
+
+ res = await fetchRssFeed(smartList.id, token.token);
+ expect(res.status).toBe(404);
+ });
+
+ it("should fail when rss gets disabled", async () => {
+ const { smartList } = await seedDatabase();
+ const trpcClient = getTrpcClient(apiKey);
+
+ // Enable rss feed
+ const token = await trpcClient.lists.regenRssToken.mutate({
+ listId: smartList.id,
+ });
+
+ const res = await fetchRssFeed(smartList.id, token.token);
+ expect(res.status).toBe(200);
+
+ // Disable rss feed
+ await trpcClient.lists.clearRssToken.mutate({
+ listId: smartList.id,
+ });
+
+ const res2 = await fetchRssFeed(smartList.id, token.token);
+ expect(res2.status).toBe(404);
+ });
+
+ it("should fail when no token is provided", async () => {
+ const { smartList } = await seedDatabase();
+ const trpcClient = getTrpcClient(apiKey);
+
+ // Enable rss feed
+ await trpcClient.lists.regenRssToken.mutate({
+ listId: smartList.id,
+ });
+
+ const res2 = await fetchRssFeed(smartList.id, "");
+ expect(res2.status).toBe(400);
+ });
+});
diff --git a/packages/shared-react/tsconfig.json b/packages/shared-react/tsconfig.json
index 513582c4..3d6919d0 100644
--- a/packages/shared-react/tsconfig.json
+++ b/packages/shared-react/tsconfig.json
@@ -9,6 +9,6 @@
},
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
- "include": ["**/*.ts", "**/*.tsx", "../shared/utils/bookmarkUtils.ts", "../shared/utils/assetUtils.ts"],
+ "include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index c355f9db..218b46b0 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -17,6 +17,7 @@ const optionalStringBool = () =>
const allEnv = z.object({
API_URL: z.string().url().default("http://localhost:3000"),
+ NEXTAUTH_URL: z.string().url().default("http://localhost:3000"),
DISABLE_SIGNUPS: stringBool("false"),
DISABLE_PASSWORD_AUTH: stringBool("false"),
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"),
@@ -91,6 +92,8 @@ const allEnv = z.object({
const serverConfigSchema = allEnv.transform((val) => {
return {
apiUrl: val.API_URL,
+ publicUrl: val.NEXTAUTH_URL,
+ publicApiUrl: `${val.NEXTAUTH_URL}/api`,
auth: {
disableSignups: val.DISABLE_SIGNUPS,
disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
@@ -181,6 +184,8 @@ const serverConfigSchema = allEnv.transform((val) => {
const serverConfig = serverConfigSchema.parse(process.env);
// Always explicitly pick up stuff from server config to avoid accidentally leaking stuff
export const clientConfig = {
+ publicUrl: serverConfig.publicUrl,
+ publicApiUrl: serverConfig.publicApiUrl,
demoMode: serverConfig.demoMode,
auth: {
disableSignups: serverConfig.auth.disableSignups,
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index 5fe77278..3522fad3 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -242,3 +242,32 @@ export const zSearchBookmarksRequestSchema = z.object({
sortOrder: zSortOrder.optional().default("relevance"),
includeContent: z.boolean().optional().default(false),
});
+
+export const zPublicBookmarkSchema = z.object({
+ id: z.string(),
+ createdAt: z.date(),
+ modifiedAt: z.date().nullable(),
+ title: z.string().nullish(),
+ tags: z.array(z.string()),
+ description: z.string().nullish(),
+ content: z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal(BookmarkTypes.LINK),
+ url: z.string(),
+ author: z.string().nullish(),
+ }),
+ z.object({
+ type: z.literal(BookmarkTypes.TEXT),
+ text: z.string(),
+ }),
+ z.object({
+ type: z.literal(BookmarkTypes.ASSET),
+ assetType: z.enum(["image", "pdf"]),
+ assetId: z.string(),
+ fileName: z.string().nullish(),
+ sourceUrl: z.string().nullish(),
+ }),
+ ]),
+});
+
+export type ZPublicBookmark = z.infer<typeof zPublicBookmarkSchema>;
diff --git a/packages/shared/types/pagination.ts b/packages/shared/types/pagination.ts
index d2312982..3b9dead4 100644
--- a/packages/shared/types/pagination.ts
+++ b/packages/shared/types/pagination.ts
@@ -4,3 +4,5 @@ export const zCursorV2 = z.object({
createdAt: z.date(),
id: z.string(),
});
+
+export type ZCursor = z.infer<typeof zCursorV2>;
diff --git a/packages/trpc/lib/impersonate.ts b/packages/trpc/lib/impersonate.ts
new file mode 100644
index 00000000..f44a2c70
--- /dev/null
+++ b/packages/trpc/lib/impersonate.ts
@@ -0,0 +1,30 @@
+import { eq } from "drizzle-orm";
+
+import { db } from "@karakeep/db";
+import { users } from "@karakeep/db/schema";
+
+import { AuthedContext } from "..";
+
+export async function buildImpersonatingAuthedContext(
+ userId: string,
+): Promise<AuthedContext> {
+ const user = await db.query.users.findFirst({
+ where: eq(users.id, userId),
+ });
+ if (!user) {
+ throw new Error("User not found");
+ }
+
+ return {
+ user: {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ role: user.role,
+ },
+ db,
+ req: {
+ ip: null,
+ },
+ };
+}
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
new file mode 100644
index 00000000..524749f9
--- /dev/null
+++ b/packages/trpc/models/bookmarks.ts
@@ -0,0 +1,365 @@
+import { TRPCError } from "@trpc/server";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ exists,
+ gt,
+ gte,
+ inArray,
+ lt,
+ lte,
+ or,
+} from "drizzle-orm";
+import invariant from "tiny-invariant";
+import { z } from "zod";
+
+import {
+ assets,
+ AssetTypes,
+ bookmarkAssets,
+ bookmarkLinks,
+ bookmarks,
+ bookmarksInLists,
+ bookmarkTags,
+ bookmarkTexts,
+ rssFeedImportsTable,
+ tagsOnBookmarks,
+} from "@karakeep/db/schema";
+import {
+ BookmarkTypes,
+ DEFAULT_NUM_BOOKMARKS_PER_PAGE,
+ ZBookmark,
+ ZBookmarkContent,
+ zGetBookmarksRequestSchema,
+ ZPublicBookmark,
+} from "@karakeep/shared/types/bookmarks";
+import { ZCursor } from "@karakeep/shared/types/pagination";
+import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
+
+import { AuthedContext } from "..";
+import { mapDBAssetTypeToUserType } from "../lib/attachments";
+import { List } from "./lists";
+import { PrivacyAware } from "./privacy";
+
+export class Bookmark implements PrivacyAware {
+ protected constructor(
+ protected ctx: AuthedContext,
+ public bookmark: ZBookmark & { userId: string },
+ ) {}
+
+ ensureCanAccess(ctx: AuthedContext): void {
+ if (this.bookmark.userId != ctx.user.id) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User is not allowed to access resource",
+ });
+ }
+ }
+
+ static fromData(ctx: AuthedContext, data: ZBookmark) {
+ return new Bookmark(ctx, {
+ ...data,
+ userId: ctx.user.id,
+ });
+ }
+
+ static async loadMulti(
+ ctx: AuthedContext,
+ input: z.infer<typeof zGetBookmarksRequestSchema>,
+ ): Promise<{
+ bookmarks: Bookmark[];
+ nextCursor: ZCursor | null;
+ }> {
+ if (input.ids && input.ids.length == 0) {
+ return { bookmarks: [], nextCursor: null };
+ }
+ if (!input.limit) {
+ input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE;
+ }
+ if (input.listId) {
+ const list = await List.fromId(ctx, input.listId);
+ if (list.type === "smart") {
+ input.ids = await list.getBookmarkIds();
+ delete input.listId;
+ }
+ }
+
+ const sq = ctx.db.$with("bookmarksSq").as(
+ ctx.db
+ .select()
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ input.archived !== undefined
+ ? eq(bookmarks.archived, input.archived)
+ : undefined,
+ input.favourited !== undefined
+ ? eq(bookmarks.favourited, input.favourited)
+ : undefined,
+ input.ids ? inArray(bookmarks.id, input.ids) : undefined,
+ input.tagId !== undefined
+ ? exists(
+ ctx.db
+ .select()
+ .from(tagsOnBookmarks)
+ .where(
+ and(
+ eq(tagsOnBookmarks.bookmarkId, bookmarks.id),
+ eq(tagsOnBookmarks.tagId, input.tagId),
+ ),
+ ),
+ )
+ : undefined,
+ input.rssFeedId !== undefined
+ ? exists(
+ ctx.db
+ .select()
+ .from(rssFeedImportsTable)
+ .where(
+ and(
+ eq(rssFeedImportsTable.bookmarkId, bookmarks.id),
+ eq(rssFeedImportsTable.rssFeedId, input.rssFeedId),
+ ),
+ ),
+ )
+ : undefined,
+ input.listId !== undefined
+ ? exists(
+ ctx.db
+ .select()
+ .from(bookmarksInLists)
+ .where(
+ and(
+ eq(bookmarksInLists.bookmarkId, bookmarks.id),
+ eq(bookmarksInLists.listId, input.listId),
+ ),
+ ),
+ )
+ : undefined,
+ input.cursor
+ ? input.sortOrder === "asc"
+ ? or(
+ gt(bookmarks.createdAt, input.cursor.createdAt),
+ and(
+ eq(bookmarks.createdAt, input.cursor.createdAt),
+ gte(bookmarks.id, input.cursor.id),
+ ),
+ )
+ : or(
+ lt(bookmarks.createdAt, input.cursor.createdAt),
+ and(
+ eq(bookmarks.createdAt, input.cursor.createdAt),
+ lte(bookmarks.id, input.cursor.id),
+ ),
+ )
+ : undefined,
+ ),
+ )
+ .limit(input.limit + 1)
+ .orderBy(
+ input.sortOrder === "asc"
+ ? asc(bookmarks.createdAt)
+ : desc(bookmarks.createdAt),
+ desc(bookmarks.id),
+ ),
+ );
+ // TODO: Consider not inlining the tags in the response of getBookmarks as this query is getting kinda expensive
+ const results = await ctx.db
+ .with(sq)
+ .select()
+ .from(sq)
+ .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId))
+ .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id))
+ .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id))
+ .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id))
+ .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id))
+ .leftJoin(assets, eq(assets.bookmarkId, sq.id))
+ .orderBy(desc(sq.createdAt), desc(sq.id));
+
+ const bookmarksRes = results.reduce<Record<string, ZBookmark>>(
+ (acc, row) => {
+ const bookmarkId = row.bookmarksSq.id;
+ if (!acc[bookmarkId]) {
+ let content: ZBookmarkContent;
+ if (row.bookmarkLinks) {
+ content = {
+ type: BookmarkTypes.LINK,
+ url: row.bookmarkLinks.url,
+ title: row.bookmarkLinks.title,
+ description: row.bookmarkLinks.description,
+ imageUrl: row.bookmarkLinks.imageUrl,
+ favicon: row.bookmarkLinks.favicon,
+ htmlContent: input.includeContent
+ ? row.bookmarkLinks.htmlContent
+ : null,
+ crawledAt: row.bookmarkLinks.crawledAt,
+ author: row.bookmarkLinks.author,
+ publisher: row.bookmarkLinks.publisher,
+ datePublished: row.bookmarkLinks.datePublished,
+ dateModified: row.bookmarkLinks.dateModified,
+ };
+ } else if (row.bookmarkTexts) {
+ content = {
+ type: BookmarkTypes.TEXT,
+ text: row.bookmarkTexts.text ?? "",
+ sourceUrl: row.bookmarkTexts.sourceUrl ?? null,
+ };
+ } else if (row.bookmarkAssets) {
+ content = {
+ type: BookmarkTypes.ASSET,
+ assetId: row.bookmarkAssets.assetId,
+ assetType: row.bookmarkAssets.assetType,
+ fileName: row.bookmarkAssets.fileName,
+ sourceUrl: row.bookmarkAssets.sourceUrl ?? null,
+ size: null, // This will get filled in the asset loop
+ content: input.includeContent
+ ? (row.bookmarkAssets.content ?? null)
+ : null,
+ };
+ } else {
+ content = {
+ type: BookmarkTypes.UNKNOWN,
+ };
+ }
+ acc[bookmarkId] = {
+ ...row.bookmarksSq,
+ content,
+ tags: [],
+ assets: [],
+ };
+ }
+
+ if (
+ row.bookmarkTags &&
+ // Duplicates may occur because of the join, so we need to make sure we're not adding the same tag twice
+ !acc[bookmarkId].tags.some((t) => t.id == row.bookmarkTags!.id)
+ ) {
+ invariant(
+ row.tagsOnBookmarks,
+ "if bookmark tag is set, its many-to-many relation must also be set",
+ );
+ acc[bookmarkId].tags.push({
+ ...row.bookmarkTags,
+ attachedBy: row.tagsOnBookmarks.attachedBy,
+ });
+ }
+
+ if (
+ row.assets &&
+ !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id)
+ ) {
+ if (acc[bookmarkId].content.type == BookmarkTypes.LINK) {
+ const content = acc[bookmarkId].content;
+ invariant(content.type == BookmarkTypes.LINK);
+ if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) {
+ content.screenshotAssetId = row.assets.id;
+ }
+ if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) {
+ content.fullPageArchiveAssetId = row.assets.id;
+ }
+ if (row.assets.assetType == AssetTypes.LINK_BANNER_IMAGE) {
+ content.imageAssetId = row.assets.id;
+ }
+ if (row.assets.assetType == AssetTypes.LINK_VIDEO) {
+ content.videoAssetId = row.assets.id;
+ }
+ if (row.assets.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE) {
+ content.precrawledArchiveAssetId = row.assets.id;
+ }
+ acc[bookmarkId].content = content;
+ }
+ if (acc[bookmarkId].content.type == BookmarkTypes.ASSET) {
+ const content = acc[bookmarkId].content;
+ if (row.assets.id == content.assetId) {
+ // If this is the bookmark's main aset, caputure its size.
+ content.size = row.assets.size;
+ }
+ }
+ acc[bookmarkId].assets.push({
+ id: row.assets.id,
+ assetType: mapDBAssetTypeToUserType(row.assets.assetType),
+ });
+ }
+
+ return acc;
+ },
+ {},
+ );
+
+ const bookmarksArr = Object.values(bookmarksRes);
+
+ bookmarksArr.sort((a, b) => {
+ if (a.createdAt != b.createdAt) {
+ return input.sortOrder === "asc"
+ ? a.createdAt.getTime() - b.createdAt.getTime()
+ : b.createdAt.getTime() - a.createdAt.getTime();
+ } else {
+ return b.id.localeCompare(a.id);
+ }
+ });
+
+ let nextCursor = null;
+ if (bookmarksArr.length > input.limit) {
+ const nextItem = bookmarksArr.pop()!;
+ nextCursor = {
+ id: nextItem.id,
+ createdAt: nextItem.createdAt,
+ };
+ }
+
+ return {
+ bookmarks: bookmarksArr.map((b) => Bookmark.fromData(ctx, b)),
+ nextCursor,
+ };
+ }
+
+ asZBookmark(): ZBookmark {
+ return this.bookmark;
+ }
+
+ asPublicBookmark(): ZPublicBookmark {
+ const getContent = (
+ content: ZBookmarkContent,
+ ): ZPublicBookmark["content"] => {
+ switch (content.type) {
+ case BookmarkTypes.LINK: {
+ return {
+ type: BookmarkTypes.LINK,
+ url: content.url,
+ };
+ }
+ case BookmarkTypes.TEXT: {
+ return {
+ type: BookmarkTypes.TEXT,
+ text: content.text,
+ };
+ }
+ case BookmarkTypes.ASSET: {
+ return {
+ type: BookmarkTypes.ASSET,
+ assetType: content.assetType,
+ assetId: content.assetId,
+ fileName: content.fileName,
+ sourceUrl: content.sourceUrl,
+ };
+ }
+ default: {
+ throw new Error("Unknown bookmark content type");
+ }
+ }
+ };
+
+ // WARNING: Everything below is exposed in the public APIs, don't use spreads!
+ return {
+ id: this.bookmark.id,
+ createdAt: this.bookmark.createdAt,
+ modifiedAt: this.bookmark.modifiedAt,
+ title: getBookmarkTitle(this.bookmark),
+ tags: this.bookmark.tags.map((t) => t.name),
+ content: getContent(this.bookmark.content),
+ };
+ }
+}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 21b23593..4413a8cd 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -1,3 +1,4 @@
+import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
import { and, count, eq } from "drizzle-orm";
import invariant from "tiny-invariant";
@@ -13,8 +14,10 @@ import {
zNewBookmarkListSchema,
} from "@karakeep/shared/types/lists";
-import { AuthedContext } from "..";
+import { AuthedContext, Context } from "..";
+import { buildImpersonatingAuthedContext } from "../lib/impersonate";
import { getBookmarkIdsFromMatcher } from "../lib/search";
+import { Bookmark } from "./bookmarks";
import { PrivacyAware } from "./privacy";
export abstract class List implements PrivacyAware {
@@ -58,6 +61,52 @@ export abstract class List implements PrivacyAware {
}
}
+ static async getForRss(
+ ctx: Context,
+ listId: string,
+ token: string,
+ pagination: {
+ limit: number;
+ },
+ ) {
+ const listdb = await ctx.db.query.bookmarkLists.findFirst({
+ where: and(
+ eq(bookmarkLists.id, listId),
+ eq(bookmarkLists.rssToken, token),
+ ),
+ });
+ if (!listdb) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "List not found",
+ });
+ }
+
+ // The token here acts as an authed context, so we can create
+ // an impersonating context for the list owner as long as
+ // we don't leak the context.
+
+ const authedCtx = await buildImpersonatingAuthedContext(listdb.userId);
+ const list = List.fromData(authedCtx, listdb);
+ const bookmarkIds = await list.getBookmarkIds();
+
+ const bookmarks = await Bookmark.loadMulti(authedCtx, {
+ ids: bookmarkIds,
+ includeContent: false,
+ limit: pagination.limit,
+ sortOrder: "desc",
+ });
+
+ return {
+ list: {
+ icon: list.list.icon,
+ name: list.list.name,
+ description: list.list.description,
+ },
+ bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()),
+ };
+ }
+
static async create(
ctx: AuthedContext,
input: z.infer<typeof zNewBookmarkListSchema>,
@@ -79,6 +128,9 @@ export abstract class List implements PrivacyAware {
static async getAll(ctx: AuthedContext): Promise<(ManualList | SmartList)[]> {
const lists = await ctx.db.query.bookmarkLists.findMany({
+ columns: {
+ rssToken: false,
+ },
where: and(eq(bookmarkLists.userId, ctx.user.id)),
});
return lists.map((l) => this.fromData(ctx, l));
@@ -88,7 +140,11 @@ export abstract class List implements PrivacyAware {
const lists = await ctx.db.query.bookmarksInLists.findMany({
where: and(eq(bookmarksInLists.bookmarkId, bookmarkId)),
with: {
- list: true,
+ list: {
+ columns: {
+ rssToken: false,
+ },
+ },
},
});
invariant(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id));
@@ -143,6 +199,45 @@ export abstract class List implements PrivacyAware {
this.list = result[0];
}
+ private async setRssToken(token: string | null) {
+ const result = await this.ctx.db
+ .update(bookmarkLists)
+ .set({ rssToken: token })
+ .where(
+ and(
+ eq(bookmarkLists.id, this.list.id),
+ eq(bookmarkLists.userId, this.ctx.user.id),
+ ),
+ )
+ .returning();
+ if (result.length == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ return result[0].rssToken;
+ }
+
+ async getRssToken(): Promise<string | null> {
+ const [result] = await this.ctx.db
+ .select({ rssToken: bookmarkLists.rssToken })
+ .from(bookmarkLists)
+ .where(
+ and(
+ eq(bookmarkLists.id, this.list.id),
+ eq(bookmarkLists.userId, this.ctx.user.id),
+ ),
+ )
+ .limit(1);
+ return result.rssToken ?? null;
+ }
+
+ async regenRssToken() {
+ return await this.setRssToken(crypto.randomBytes(32).toString("hex"));
+ }
+
+ async clearRssToken() {
+ await this.setRssToken(null);
+ }
+
abstract get type(): "manual" | "smart";
abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>;
abstract getSize(ctx: AuthedContext): Promise<number>;
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index 29a77d8c..04d15d1f 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -1,17 +1,5 @@
import { experimental_trpcMiddleware, TRPCError } from "@trpc/server";
-import {
- and,
- asc,
- desc,
- eq,
- exists,
- gt,
- gte,
- inArray,
- lt,
- lte,
- or,
-} from "drizzle-orm";
+import { and, eq, gt, inArray, lt, or } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -27,11 +15,9 @@ import {
bookmarkAssets,
bookmarkLinks,
bookmarks,
- bookmarksInLists,
bookmarkTags,
bookmarkTexts,
customPrompts,
- rssFeedImportsTable,
tagsOnBookmarks,
} from "@karakeep/db/schema";
import {
@@ -69,7 +55,7 @@ import type { AuthedContext, Context } from "../index";
import { authedProcedure, router } from "../index";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
import { getBookmarkIdsFromMatcher } from "../lib/search";
-import { List } from "../models/lists";
+import { Bookmark } from "../models/bookmarks";
import { ensureAssetOwnership } from "./assets";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
@@ -810,245 +796,11 @@ export const bookmarksAppRouter = router({
.input(zGetBookmarksRequestSchema)
.output(zGetBookmarksResponseSchema)
.query(async ({ input, ctx }) => {
- if (input.ids && input.ids.length == 0) {
- return { bookmarks: [], nextCursor: null };
- }
- if (!input.limit) {
- input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE;
- }
- if (input.listId) {
- const list = await List.fromId(ctx, input.listId);
- if (list.type === "smart") {
- input.ids = await list.getBookmarkIds();
- delete input.listId;
- }
- }
-
- const sq = ctx.db.$with("bookmarksSq").as(
- ctx.db
- .select()
- .from(bookmarks)
- .where(
- and(
- eq(bookmarks.userId, ctx.user.id),
- input.archived !== undefined
- ? eq(bookmarks.archived, input.archived)
- : undefined,
- input.favourited !== undefined
- ? eq(bookmarks.favourited, input.favourited)
- : undefined,
- input.ids ? inArray(bookmarks.id, input.ids) : undefined,
- input.tagId !== undefined
- ? exists(
- ctx.db
- .select()
- .from(tagsOnBookmarks)
- .where(
- and(
- eq(tagsOnBookmarks.bookmarkId, bookmarks.id),
- eq(tagsOnBookmarks.tagId, input.tagId),
- ),
- ),
- )
- : undefined,
- input.rssFeedId !== undefined
- ? exists(
- ctx.db
- .select()
- .from(rssFeedImportsTable)
- .where(
- and(
- eq(rssFeedImportsTable.bookmarkId, bookmarks.id),
- eq(rssFeedImportsTable.rssFeedId, input.rssFeedId),
- ),
- ),
- )
- : undefined,
- input.listId !== undefined
- ? exists(
- ctx.db
- .select()
- .from(bookmarksInLists)
- .where(
- and(
- eq(bookmarksInLists.bookmarkId, bookmarks.id),
- eq(bookmarksInLists.listId, input.listId),
- ),
- ),
- )
- : undefined,
- input.cursor
- ? input.sortOrder === "asc"
- ? or(
- gt(bookmarks.createdAt, input.cursor.createdAt),
- and(
- eq(bookmarks.createdAt, input.cursor.createdAt),
- gte(bookmarks.id, input.cursor.id),
- ),
- )
- : or(
- lt(bookmarks.createdAt, input.cursor.createdAt),
- and(
- eq(bookmarks.createdAt, input.cursor.createdAt),
- lte(bookmarks.id, input.cursor.id),
- ),
- )
- : undefined,
- ),
- )
- .limit(input.limit + 1)
- .orderBy(
- input.sortOrder === "asc"
- ? asc(bookmarks.createdAt)
- : desc(bookmarks.createdAt),
- desc(bookmarks.id),
- ),
- );
- // TODO: Consider not inlining the tags in the response of getBookmarks as this query is getting kinda expensive
- const results = await ctx.db
- .with(sq)
- .select()
- .from(sq)
- .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId))
- .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id))
- .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id))
- .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id))
- .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id))
- .leftJoin(assets, eq(assets.bookmarkId, sq.id))
- .orderBy(desc(sq.createdAt), desc(sq.id));
-
- const bookmarksRes = results.reduce<Record<string, ZBookmark>>(
- (acc, row) => {
- const bookmarkId = row.bookmarksSq.id;
- if (!acc[bookmarkId]) {
- let content: ZBookmarkContent;
- if (row.bookmarkLinks) {
- content = {
- type: BookmarkTypes.LINK,
- url: row.bookmarkLinks.url,
- title: row.bookmarkLinks.title,
- description: row.bookmarkLinks.description,
- imageUrl: row.bookmarkLinks.imageUrl,
- favicon: row.bookmarkLinks.favicon,
- htmlContent: input.includeContent
- ? row.bookmarkLinks.htmlContent
- : null,
- crawledAt: row.bookmarkLinks.crawledAt,
- author: row.bookmarkLinks.author,
- publisher: row.bookmarkLinks.publisher,
- datePublished: row.bookmarkLinks.datePublished,
- dateModified: row.bookmarkLinks.dateModified,
- };
- } else if (row.bookmarkTexts) {
- content = {
- type: BookmarkTypes.TEXT,
- text: row.bookmarkTexts.text ?? "",
- sourceUrl: row.bookmarkTexts.sourceUrl ?? null,
- };
- } else if (row.bookmarkAssets) {
- content = {
- type: BookmarkTypes.ASSET,
- assetId: row.bookmarkAssets.assetId,
- assetType: row.bookmarkAssets.assetType,
- fileName: row.bookmarkAssets.fileName,
- sourceUrl: row.bookmarkAssets.sourceUrl ?? null,
- size: null, // This will get filled in the asset loop
- content: input.includeContent
- ? (row.bookmarkAssets.content ?? null)
- : null,
- };
- } else {
- content = {
- type: BookmarkTypes.UNKNOWN,
- };
- }
- acc[bookmarkId] = {
- ...row.bookmarksSq,
- content,
- tags: [],
- assets: [],
- };
- }
-
- if (
- row.bookmarkTags &&
- // Duplicates may occur because of the join, so we need to make sure we're not adding the same tag twice
- !acc[bookmarkId].tags.some((t) => t.id == row.bookmarkTags!.id)
- ) {
- invariant(
- row.tagsOnBookmarks,
- "if bookmark tag is set, its many-to-many relation must also be set",
- );
- acc[bookmarkId].tags.push({
- ...row.bookmarkTags,
- attachedBy: row.tagsOnBookmarks.attachedBy,
- });
- }
-
- if (
- row.assets &&
- !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id)
- ) {
- if (acc[bookmarkId].content.type == BookmarkTypes.LINK) {
- const content = acc[bookmarkId].content;
- invariant(content.type == BookmarkTypes.LINK);
- if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) {
- content.screenshotAssetId = row.assets.id;
- }
- if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) {
- content.fullPageArchiveAssetId = row.assets.id;
- }
- if (row.assets.assetType == AssetTypes.LINK_BANNER_IMAGE) {
- content.imageAssetId = row.assets.id;
- }
- if (row.assets.assetType == AssetTypes.LINK_VIDEO) {
- content.videoAssetId = row.assets.id;
- }
- if (row.assets.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE) {
- content.precrawledArchiveAssetId = row.assets.id;
- }
- acc[bookmarkId].content = content;
- }
- if (acc[bookmarkId].content.type == BookmarkTypes.ASSET) {
- const content = acc[bookmarkId].content;
- if (row.assets.id == content.assetId) {
- // If this is the bookmark's main aset, caputure its size.
- content.size = row.assets.size;
- }
- }
- acc[bookmarkId].assets.push({
- id: row.assets.id,
- assetType: mapDBAssetTypeToUserType(row.assets.assetType),
- });
- }
-
- return acc;
- },
- {},
- );
-
- const bookmarksArr = Object.values(bookmarksRes);
-
- bookmarksArr.sort((a, b) => {
- if (a.createdAt != b.createdAt) {
- return input.sortOrder === "asc"
- ? a.createdAt.getTime() - b.createdAt.getTime()
- : b.createdAt.getTime() - a.createdAt.getTime();
- } else {
- return b.id.localeCompare(a.id);
- }
- });
-
- let nextCursor = null;
- if (bookmarksArr.length > input.limit) {
- const nextItem = bookmarksArr.pop()!;
- nextCursor = {
- id: nextItem.id,
- createdAt: nextItem.createdAt,
- };
- }
-
- return { bookmarks: bookmarksArr, nextCursor };
+ const res = await Bookmark.loadMulti(ctx, input);
+ return {
+ bookmarks: res.bookmarks.map((b) => b.asZBookmark()),
+ nextCursor: res.nextCursor,
+ };
}),
updateTags: authedProcedure
diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts
index 65cffd2d..bb949962 100644
--- a/packages/trpc/routers/lists.ts
+++ b/packages/trpc/routers/lists.ts
@@ -131,4 +131,47 @@ export const listsAppRouter = router({
const sizes = await Promise.all(lists.map((l) => l.getSize()));
return { stats: new Map(lists.map((l, i) => [l.list.id, sizes[i]])) };
}),
+
+ // Rss endpoints
+ regenRssToken: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ token: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const list = await List.fromId(ctx, input.listId);
+ const token = await list.regenRssToken();
+ return { token: token! };
+ }),
+ clearRssToken: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const list = await List.fromId(ctx, input.listId);
+ await list.clearRssToken();
+ }),
+ getRssToken: authedProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ token: z.string().nullable(),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ const list = await List.fromId(ctx, input.listId);
+ return { token: await list.getRssToken() };
+ }),
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f5e92b9c..34e0175f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -980,6 +980,9 @@ importers:
hono:
specifier: ^4.7.10
version: 4.7.10
+ rss:
+ specifier: ^1.2.2
+ version: 1.2.2
zod:
specifier: ^3.24.2
version: 3.24.2
@@ -999,6 +1002,9 @@ importers:
'@types/deep-equal':
specifier: ^1.0.4
version: 1.0.4
+ '@types/rss':
+ specifier: ^0.0.32
+ version: 0.0.32
vite-tsconfig-paths:
specifier: ^4.3.1
version: 4.3.1(typescript@5.7.3)
@@ -5875,6 +5881,9 @@ packages:
'@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
+ '@types/rss@0.0.32':
+ resolution: {integrity: sha512-2oKNqKyUY4RSdvl5eZR1n2Q9yvw3XTe3mQHsFPn9alaNBxfPnbXBtGP8R0SV8pK1PrVnLul0zx7izbm5/gF5Qw==}
+
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
@@ -11061,6 +11070,10 @@ packages:
resolution: {integrity: sha512-iqDMU9J643BHg8Zp7EMZNLTp6Pgs2f1S2SMnCW2VlUqMs17xCZ5vwVjalBJEGVcUfG+/1ePqeEGcMW3VfzHK5A==}
engines: {node: '>= 10'}
+ mime-db@1.25.0:
+ resolution: {integrity: sha512-5k547tI4Cy+Lddr/hdjNbBEWBwSl8EBc5aSdKvedav8DReADgWJzcYiktaRIw3GtGC1jjwldXtTzvqJZmtvC7w==}
+ engines: {node: '>= 0.6'}
+
mime-db@1.33.0:
resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==}
engines: {node: '>= 0.6'}
@@ -11076,6 +11089,10 @@ packages:
mime-format@2.0.1:
resolution: {integrity: sha512-XxU3ngPbEnrYnNbIX+lYSaYg0M01v6p2ntd2YaFksTu0vayaw5OJvbdRyWs07EYRlLED5qadUZ+xo+XhOvFhwg==}
+ mime-types@2.1.13:
+ resolution: {integrity: sha512-ryBDp1Z/6X90UvjUK3RksH0IBPM137T7cmg4OgD5wQBojlAiUwuok0QeELkim/72EtcYuNlmbkrcGuxj3Kl0YQ==}
+ engines: {node: '>= 0.6'}
+
mime-types@2.1.18:
resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==}
engines: {node: '>= 0.6'}
@@ -13474,6 +13491,9 @@ packages:
rss-parser@3.13.0:
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
+ rss@1.2.2:
+ resolution: {integrity: sha512-xUhRTgslHeCBeHAqaWSbOYTydN2f0tAzNXvzh3stjz7QDhQMzdgHf3pfgNIngeytQflrFPfy6axHilTETr6gDg==}
+
rtlcss@4.1.1:
resolution: {integrity: sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==}
engines: {node: '>=12.0.0'}
@@ -15362,6 +15382,9 @@ packages:
resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==}
engines: {node: '>=4.0.0'}
+ xml@1.0.1:
+ resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
+
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
@@ -21930,6 +21953,9 @@ snapshots:
'@types/retry@0.12.0':
dev: false
+ '@types/rss@0.0.32':
+ dev: true
+
'@types/sax@1.2.7':
dependencies:
'@types/node': 22.13.0
@@ -29482,6 +29508,9 @@ snapshots:
microsoft-capitalize@1.0.5:
dev: false
+ mime-db@1.25.0:
+ dev: false
+
mime-db@1.33.0:
dev: false
@@ -29495,6 +29524,11 @@ snapshots:
charset: 1.0.1
dev: false
+ mime-types@2.1.13:
+ dependencies:
+ mime-db: 1.25.0
+ dev: false
+
mime-types@2.1.18:
dependencies:
mime-db: 1.33.0
@@ -32680,6 +32714,12 @@ snapshots:
xml2js: 0.5.0
dev: false
+ rss@1.2.2:
+ dependencies:
+ mime-types: 2.1.13
+ xml: 1.0.1
+ dev: false
+
rtlcss@4.1.1:
dependencies:
escalade: 3.2.0
@@ -35250,6 +35290,9 @@ snapshots:
xmlbuilder: 11.0.1
dev: false
+ xml@1.0.1:
+ dev: false
+
xmlbuilder@11.0.1:
dev: false