aboutsummaryrefslogtreecommitdiffstats
path: root/packages/trpc/routers
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-05-31 18:46:04 +0100
committerGitHub <noreply@github.com>2025-05-31 18:46:04 +0100
commit9695bba2e993b48ae333da622fa459dbaacb9349 (patch)
treec6bffbcdd73151671343f27012e82bea5a05ab6b /packages/trpc/routers
parentb218118b84291de4a9c1cd400dc58afab7054b78 (diff)
downloadkarakeep-9695bba2e993b48ae333da622fa459dbaacb9349.tar.zst
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
Diffstat (limited to 'packages/trpc/routers')
-rw-r--r--packages/trpc/routers/bookmarks.ts262
-rw-r--r--packages/trpc/routers/lists.ts43
2 files changed, 50 insertions, 255 deletions
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() };
+ }),
});