From 9695bba2e993b48ae333da622fa459dbaacb9349 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 31 May 2025 18:46:04 +0100 Subject: feat: Generate RSS feeds from lists (#1507) * refactor: Move bookmark utils from shared-react to shared * Expose RSS feeds for lists * Add e2e tests * Slightly improve the look of the share dialog * allow specifying a limit in the rss endpoint --- packages/trpc/routers/bookmarks.ts | 262 +------------------------------------ packages/trpc/routers/lists.ts | 43 ++++++ 2 files changed, 50 insertions(+), 255 deletions(-) (limited to 'packages/trpc/routers') 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>( - (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() }; + }), }); -- cgit v1.2.3-70-g09d2