diff options
| author | MohamedBassem <me@mbassem.com> | 2024-05-06 18:05:27 +0100 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-05-06 18:05:27 +0100 |
| commit | 32b5a025568dcc5788a8a2afc19bf07264e01a63 (patch) | |
| tree | 7ad808c667148154c9244cb3def56315da89ef52 | |
| parent | 02ef4bfc89e66fdf6593dd744aef53adee57b861 (diff) | |
| download | karakeep-32b5a025568dcc5788a8a2afc19bf07264e01a63.tar.zst | |
feature: Dedup links on creation. Fixes #49
| -rw-r--r-- | apps/mobile/app/dashboard/add-link.tsx | 9 | ||||
| -rw-r--r-- | apps/mobile/app/sharing.tsx | 12 | ||||
| -rw-r--r-- | apps/mobile/lib/upload.ts | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/EditorCard.tsx | 20 | ||||
| -rw-r--r-- | packages/db/schema.ts | 4 | ||||
| -rw-r--r-- | packages/trpc/index.ts | 5 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 12 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 218 |
8 files changed, 176 insertions, 106 deletions
diff --git a/apps/mobile/app/dashboard/add-link.tsx b/apps/mobile/app/dashboard/add-link.tsx index d913ac01..5096a9e7 100644 --- a/apps/mobile/app/dashboard/add-link.tsx +++ b/apps/mobile/app/dashboard/add-link.tsx @@ -3,17 +3,24 @@ import { Text, View } from "react-native"; import { useRouter } from "expo-router"; import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; +import { useToast } from "@/components/ui/Toast"; import { api } from "@/lib/trpc"; export default function AddNote() { const [text, setText] = useState(""); const [error, setError] = useState<string | undefined>(); + const { toast } = useToast(); const router = useRouter(); const invalidateAllBookmarks = api.useUtils().bookmarks.getBookmarks.invalidate; const { mutate } = api.bookmarks.createBookmark.useMutation({ - onSuccess: () => { + onSuccess: (resp) => { + if (resp.alreadyExists) { + toast({ + message: "Bookmark already exists", + }); + } invalidateAllBookmarks(); if (router.canGoBack()) { router.replace("../"); diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx index 7624474a..7339a017 100644 --- a/apps/mobile/app/sharing.tsx +++ b/apps/mobile/app/sharing.tsx @@ -12,12 +12,16 @@ import type { ZBookmark } from "@hoarder/shared/types/bookmarks"; type Mode = | { type: "idle" } | { type: "success"; bookmarkId: string } + | { type: "alreadyExists"; bookmarkId: string } | { type: "error" }; function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { - const onSaved = (d: ZBookmark) => { + const onSaved = (d: ZBookmark & { alreadyExists: boolean }) => { invalidateAllBookmarks(); - setMode({ type: "success", bookmarkId: d.id }); + setMode({ + type: d.alreadyExists ? "alreadyExists" : "success", + bookmarkId: d.id, + }); }; const { hasShareIntent, shareIntent, resetShareIntent } = @@ -86,6 +90,10 @@ export default function Sharing() { comp = <Text className="text-4xl text-foreground">Hoarded!</Text>; break; } + case "alreadyExists": { + comp = <Text className="text-4xl text-foreground">Already Hoarded!</Text>; + break; + } case "error": { comp = <Text className="text-4xl text-foreground">Error!</Text>; break; diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts index 9eb40e01..0b6db549 100644 --- a/apps/mobile/lib/upload.ts +++ b/apps/mobile/lib/upload.ts @@ -12,7 +12,7 @@ import { api } from "./trpc"; export function useUploadAsset( settings: Settings, options: { - onSuccess?: (bookmark: ZBookmark) => void; + onSuccess?: (bookmark: ZBookmark & { alreadyExists: boolean }) => void; onError?: (e: string) => void; }, ) { diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index f6ea0c9a..7c036c04 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -1,5 +1,6 @@ import type { SubmitErrorHandler, SubmitHandler } from "react-hook-form"; import { useEffect, useImperativeHandle, useRef } from "react"; +import Link from "next/link"; import { ActionButton } from "@/components/ui/action-button"; import { Form, FormControl, FormItem } from "@/components/ui/form"; import InfoTooltip from "@/components/ui/info-tooltip"; @@ -10,6 +11,7 @@ import { useClientConfig } from "@/lib/clientConfig"; import { useBookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; +import { ExternalLink } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -50,7 +52,23 @@ export default function EditorCard({ className }: { className?: string }) { useFocusOnKeyPress(inputRef); const { mutate, isPending } = useCreateBookmarkWithPostHook({ - onSuccess: () => { + onSuccess: (resp) => { + if (resp.alreadyExists) { + toast({ + description: ( + <div className="flex items-center gap-1"> + Bookmark already exists. + <Link + className="flex underline-offset-4 hover:underline" + href={`/dashboard/preview/${resp.id}`} + > + Open <ExternalLink className="ml-1 size-4" /> + </Link> + </div> + ), + variant: "default", + }); + } form.reset(); }, onError: () => { diff --git a/packages/db/schema.ts b/packages/db/schema.ts index e323c25f..fa0777f4 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -147,6 +147,10 @@ export const bookmarkLinks = sqliteTable("bookmarkLinks", { crawlStatus: text("crawlStatus", { enum: ["pending", "failure", "success"], }).default("pending"), +}, (bl) => { + return { + urlIdx: index("bookmarkLinks_url_idx").on(bl.url), + }; }); export const bookmarkTexts = sqliteTable("bookmarkTexts", { diff --git a/packages/trpc/index.ts b/packages/trpc/index.ts index 4055fa5d..5f351a8e 100644 --- a/packages/trpc/index.ts +++ b/packages/trpc/index.ts @@ -17,6 +17,11 @@ export interface Context { db: typeof db; } +export interface AuthedContext { + user: User; + db: typeof db; +} + // Avoid exporting the entire t-object // since it's not very descriptive. // For instance, the use of a t variable diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index 603c18fd..1bcd3279 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -116,18 +116,18 @@ describe("Bookmark Routes", () => { test<CustomTestContext>("update tags", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; - let bookmark = await api.createBookmark({ + const createdBookmark = await api.createBookmark({ url: "https://google.com", type: "link", }); await api.updateTags({ - bookmarkId: bookmark.id, + bookmarkId: createdBookmark.id, attach: [{ tagName: "tag1" }, { tagName: "tag2" }], detach: [], }); - bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); + let bookmark = await api.getBookmark({ bookmarkId: createdBookmark.id }); expect(bookmark.tags.map((t) => t.name).sort()).toEqual(["tag1", "tag2"]); const tag1Id = bookmark.tags.filter((t) => t.name == "tag1")[0].id; @@ -157,17 +157,17 @@ describe("Bookmark Routes", () => { test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; - let bookmark = await api.createBookmark({ + const createdBookmark = await api.createBookmark({ text: "HELLO WORLD", type: "text", }); await api.updateBookmarkText({ - bookmarkId: bookmark.id, + bookmarkId: createdBookmark.id, text: "WORLD HELLO", }); - bookmark = await api.getBookmark({ bookmarkId: bookmark.id }); + const bookmark = await api.getBookmark({ bookmarkId: createdBookmark.id }); assert(bookmark.content.type == "text"); expect(bookmark.content.text).toEqual("WORLD HELLO"); }); diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 1e154e7b..a7db564b 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -35,7 +35,7 @@ import { zUpdateBookmarksRequestSchema, } from "@hoarder/shared/types/bookmarks"; -import type { Context } from "../index"; +import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ @@ -70,6 +70,45 @@ export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ return opts.next(); }); +async function getBookmark(ctx: AuthedContext, bookmarkId: string) { + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: and(eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, bookmarkId)), + with: { + tagsOnBookmarks: { + with: { + tag: true, + }, + }, + link: true, + text: true, + asset: true, + }, + }); + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + return toZodSchema(bookmark); +} + +async function attemptToDedupLink(ctx: AuthedContext, url: string) { + const result = await ctx.db + .select({ + id: bookmarkLinks.id, + }) + .from(bookmarkLinks) + .leftJoin(bookmarks, eq(bookmarks.id, bookmarkLinks.id)) + .where(and(eq(bookmarkLinks.url, url), eq(bookmarks.userId, ctx.user.id))); + + if (result.length == 0) { + return null; + } + return getBookmark(ctx, result[0].id); +} + async function dummyDrizzleReturnType() { const x = await DONT_USE_db.query.bookmarks.findFirst({ with: { @@ -147,82 +186,94 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark { export const bookmarksAppRouter = router({ createBookmark: authedProcedure .input(zNewBookmarkRequestSchema) - .output(zBookmarkSchema) + .output( + zBookmarkSchema.merge( + z.object({ + alreadyExists: z.boolean().optional().default(false), + }), + ), + ) .mutation(async ({ input, ctx }) => { - const bookmark = await ctx.db.transaction( - async (tx): Promise<ZBookmark> => { - const bookmark = ( - await tx - .insert(bookmarks) - .values({ - userId: ctx.user.id, - }) - .returning() - )[0]; - - let content: ZBookmarkContent; - - switch (input.type) { - case "link": { - const link = ( - await tx - .insert(bookmarkLinks) - .values({ - id: bookmark.id, - url: input.url.trim(), - }) - .returning() - )[0]; - content = { - type: "link", - ...link, - }; - break; - } - case "text": { - const text = ( - await tx - .insert(bookmarkTexts) - .values({ id: bookmark.id, text: input.text }) - .returning() - )[0]; - content = { - type: "text", - text: text.text ?? "", - }; - break; - } - case "asset": { - const [asset] = await tx - .insert(bookmarkAssets) + if (input.type == "link") { + // This doesn't 100% protect from duplicates because of races but it's more than enough for this usecase. + const alreadyExists = await attemptToDedupLink(ctx, input.url); + if (alreadyExists) { + return { ...alreadyExists, alreadyExists: true }; + } + } + const bookmark = await ctx.db.transaction(async (tx) => { + const bookmark = ( + await tx + .insert(bookmarks) + .values({ + userId: ctx.user.id, + }) + .returning() + )[0]; + + let content: ZBookmarkContent; + + switch (input.type) { + case "link": { + const link = ( + await tx + .insert(bookmarkLinks) .values({ id: bookmark.id, - assetType: input.assetType, - assetId: input.assetId, - content: null, - metadata: null, - fileName: input.fileName ?? null, + url: input.url.trim(), }) - .returning(); - content = { - type: "asset", - assetType: asset.assetType, - assetId: asset.assetId, - }; - break; - } - case "unknown": { - throw new TRPCError({ code: "BAD_REQUEST" }); - } + .returning() + )[0]; + content = { + type: "link", + ...link, + }; + break; + } + case "text": { + const text = ( + await tx + .insert(bookmarkTexts) + .values({ id: bookmark.id, text: input.text }) + .returning() + )[0]; + content = { + type: "text", + text: text.text ?? "", + }; + break; + } + case "asset": { + const [asset] = await tx + .insert(bookmarkAssets) + .values({ + id: bookmark.id, + assetType: input.assetType, + assetId: input.assetId, + content: null, + metadata: null, + fileName: input.fileName ?? null, + }) + .returning(); + content = { + type: "asset", + assetType: asset.assetType, + assetId: asset.assetId, + }; + break; + } + case "unknown": { + throw new TRPCError({ code: "BAD_REQUEST" }); } + } - return { - tags: [] as ZBookmarkTags[], - content, - ...bookmark, - }; - }, - ); + return { + alreadyExists: false, + tags: [] as ZBookmarkTags[], + content, + ...bookmark, + }; + }); // Enqueue crawling request switch (bookmark.content.type) { @@ -360,30 +411,7 @@ export const bookmarksAppRouter = router({ .output(zBookmarkSchema) .use(ensureBookmarkOwnership) .query(async ({ input, ctx }) => { - const bookmark = await ctx.db.query.bookmarks.findFirst({ - where: and( - eq(bookmarks.userId, ctx.user.id), - eq(bookmarks.id, input.bookmarkId), - ), - with: { - tagsOnBookmarks: { - with: { - tag: true, - }, - }, - link: true, - text: true, - asset: true, - }, - }); - if (!bookmark) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Bookmark not found", - }); - } - - return toZodSchema(bookmark); + return await getBookmark(ctx, input.bookmarkId); }), searchBookmarks: authedProcedure .input( |
