aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-09-22 14:56:19 +0000
committerMohamedBassem <me@mbassem.com>2024-09-22 15:04:20 +0000
commita770e55520245b7afc2b7a30aa6127eebcb6ea0d (patch)
tree7a2e4041e2d16413ee0e8dd060be41b58253c995 /packages
parent55f5c7f40d6569d0769a3b7a9060db5ec1d3b93b (diff)
downloadkarakeep-a770e55520245b7afc2b7a30aa6127eebcb6ea0d.tar.zst
feature(web): Show attachments and allow users to manipulate them.
Diffstat (limited to 'packages')
-rw-r--r--packages/shared-react/hooks/bookmarks.ts45
-rw-r--r--packages/shared/types/bookmarks.ts16
-rw-r--r--packages/trpc/lib/attachments.ts42
-rw-r--r--packages/trpc/routers/bookmarks.test.ts97
-rw-r--r--packages/trpc/routers/bookmarks.ts137
5 files changed, 328 insertions, 9 deletions
diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts
index 43f97fc1..cba4d107 100644
--- a/packages/shared-react/hooks/bookmarks.ts
+++ b/packages/shared-react/hooks/bookmarks.ts
@@ -175,3 +175,48 @@ export function useBookmarkPostCreationHook() {
return Promise.all(promises);
};
}
+
+export function useAttachBookmarkAsset(
+ ...opts: Parameters<typeof api.bookmarks.attachAsset.useMutation>
+) {
+ const apiUtils = api.useUtils();
+ return api.bookmarks.attachAsset.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta) => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.searchBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId });
+ return opts[0]?.onSuccess?.(res, req, meta);
+ },
+ });
+}
+
+export function useReplaceBookmarkAsset(
+ ...opts: Parameters<typeof api.bookmarks.replaceAsset.useMutation>
+) {
+ const apiUtils = api.useUtils();
+ return api.bookmarks.replaceAsset.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta) => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.searchBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId });
+ return opts[0]?.onSuccess?.(res, req, meta);
+ },
+ });
+}
+
+export function useDetachBookmarkAsset(
+ ...opts: Parameters<typeof api.bookmarks.detachAsset.useMutation>
+) {
+ const apiUtils = api.useUtils();
+ return api.bookmarks.detachAsset.useMutation({
+ ...opts[0],
+ onSuccess: (res, req, meta) => {
+ apiUtils.bookmarks.getBookmarks.invalidate();
+ apiUtils.bookmarks.searchBookmarks.invalidate();
+ apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId });
+ return opts[0]?.onSuccess?.(res, req, meta);
+ },
+ });
+}
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index beefbfb9..c15146f3 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -11,6 +11,18 @@ export const enum BookmarkTypes {
UNKNOWN = "unknown",
}
+export const zAssetTypesSchema = z.enum([
+ "screenshot",
+ "bannerImage",
+ "fullPageArchive",
+]);
+export type ZAssetType = z.infer<typeof zAssetTypesSchema>;
+
+export const zAssetSchema = z.object({
+ id: z.string(),
+ assetType: zAssetTypesSchema,
+});
+
export const zBookmarkedLinkSchema = z.object({
type: z.literal(BookmarkTypes.LINK),
url: z.string().url(),
@@ -63,6 +75,7 @@ export const zBookmarkSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkContentSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmark = z.infer<typeof zBookmarkSchema>;
@@ -71,6 +84,7 @@ const zBookmarkTypeLinkSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkedLinkSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmarkTypeLink = z.infer<typeof zBookmarkTypeLinkSchema>;
@@ -79,6 +93,7 @@ const zBookmarkTypeTextSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkedTextSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmarkTypeText = z.infer<typeof zBookmarkTypeTextSchema>;
@@ -87,6 +102,7 @@ const zBookmarkTypeAssetSchema = zBareBookmarkSchema.merge(
z.object({
tags: z.array(zBookmarkTagSchema),
content: zBookmarkedAssetSchema,
+ assets: z.array(zAssetSchema),
}),
);
export type ZBookmarkTypeAsset = z.infer<typeof zBookmarkTypeAssetSchema>;
diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts
new file mode 100644
index 00000000..6fe1ef40
--- /dev/null
+++ b/packages/trpc/lib/attachments.ts
@@ -0,0 +1,42 @@
+import { z } from "zod";
+
+import { AssetTypes } from "@hoarder/db/schema";
+import { ZAssetType, zAssetTypesSchema } from "@hoarder/shared/types/bookmarks";
+
+export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
+ const map: Record<AssetTypes, z.infer<typeof zAssetTypesSchema>> = {
+ [AssetTypes.LINK_SCREENSHOT]: "screenshot",
+ [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive",
+ [AssetTypes.LINK_BANNER_IMAGE]: "bannerImage",
+ };
+ return map[assetType];
+}
+
+export function mapSchemaAssetTypeToDB(
+ assetType: z.infer<typeof zAssetTypesSchema>,
+): AssetTypes {
+ const map: Record<ZAssetType, AssetTypes> = {
+ screenshot: AssetTypes.LINK_SCREENSHOT,
+ fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ bannerImage: AssetTypes.LINK_BANNER_IMAGE,
+ };
+ return map[assetType];
+}
+
+export function humanFriendlyNameForAssertType(type: ZAssetType) {
+ const map: Record<ZAssetType, string> = {
+ screenshot: "Screenshot",
+ fullPageArchive: "Full Page Archive",
+ bannerImage: "Banner Image",
+ };
+ return map[type];
+}
+
+export function isAllowedToAttachAsset(type: ZAssetType) {
+ const map: Record<ZAssetType, boolean> = {
+ screenshot: true,
+ fullPageArchive: false,
+ bannerImage: true,
+ };
+ return map[type];
+}
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index 802bd992..5219e522 100644
--- a/packages/trpc/routers/bookmarks.test.ts
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -1,7 +1,7 @@
import { assert, beforeEach, describe, expect, test } from "vitest";
-import { bookmarks } from "@hoarder/db/schema";
-import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
+import { assets, AssetTypes, bookmarks } from "@hoarder/db/schema";
+import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks";
import type { CustomTestContext } from "../testUtils";
import { defaultBeforeEach } from "../testUtils";
@@ -326,4 +326,97 @@ describe("Bookmark Routes", () => {
await validateWithLimit(10);
await validateWithLimit(100);
});
+
+ test<CustomTestContext>("mutate assets", async ({ apiCallers, db }) => {
+ const api = apiCallers[0].bookmarks;
+
+ const bookmark = await api.createBookmark({
+ url: "https://google.com",
+ type: BookmarkTypes.LINK,
+ });
+ await Promise.all([
+ db.insert(assets).values({
+ id: "asset1",
+ assetType: AssetTypes.LINK_SCREENSHOT,
+ bookmarkId: bookmark.id,
+ }),
+ db.insert(assets).values({
+ id: "asset2",
+ assetType: AssetTypes.LINK_BANNER_IMAGE,
+ bookmarkId: bookmark.id,
+ }),
+ db.insert(assets).values({
+ id: "asset3",
+ assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ bookmarkId: bookmark.id,
+ }),
+ ]);
+
+ const validateAssets = async (
+ expected: { id: string; assetType: ZAssetType }[],
+ ) => {
+ const b = await api.getBookmark({ bookmarkId: bookmark.id });
+ b.assets.sort((a, b) => a.id.localeCompare(b.id));
+ expect(b.assets).toEqual(expected);
+ };
+
+ await api.attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: "asset4",
+ assetType: "screenshot",
+ },
+ });
+
+ await validateAssets([
+ { id: "asset1", assetType: "screenshot" },
+ { id: "asset2", assetType: "bannerImage" },
+ { id: "asset3", assetType: "fullPageArchive" },
+ { id: "asset4", assetType: "screenshot" },
+ ]);
+
+ await api.replaceAsset({
+ bookmarkId: bookmark.id,
+ oldAssetId: "asset1",
+ newAssetId: "asset5",
+ });
+
+ await validateAssets([
+ { id: "asset2", assetType: "bannerImage" },
+ { id: "asset3", assetType: "fullPageArchive" },
+ { id: "asset4", assetType: "screenshot" },
+ { id: "asset5", assetType: "screenshot" },
+ ]);
+
+ await api.detachAsset({
+ bookmarkId: bookmark.id,
+ assetId: "asset4",
+ });
+
+ await validateAssets([
+ { id: "asset2", assetType: "bannerImage" },
+ { id: "asset3", assetType: "fullPageArchive" },
+ { id: "asset5", assetType: "screenshot" },
+ ]);
+
+ // You're not allowed to attach/replace a fullPageArchive
+ await expect(
+ async () =>
+ await api.replaceAsset({
+ bookmarkId: bookmark.id,
+ oldAssetId: "asset3",
+ newAssetId: "asset4",
+ }),
+ ).rejects.toThrow(/You can't attach this type of asset/);
+ await expect(
+ async () =>
+ await api.attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: "asset6",
+ assetType: "fullPageArchive",
+ },
+ }),
+ ).rejects.toThrow(/You can't attach this type of asset/);
+ });
});
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index eb189def..312c3acc 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -31,6 +31,7 @@ import { getSearchIdxClient } from "@hoarder/shared/search";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
+ zAssetSchema,
zBareBookmarkSchema,
zBookmarkSchema,
zGetBookmarksRequestSchema,
@@ -41,6 +42,11 @@ import {
import type { AuthedContext, Context } from "../index";
import { authedProcedure, router } from "../index";
+import {
+ isAllowedToAttachAsset,
+ mapDBAssetTypeToUserType,
+ mapSchemaAssetTypeToDB,
+} from "../lib/attachments";
export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
ctx: Context;
@@ -79,13 +85,12 @@ interface Asset {
assetType: AssetTypes;
}
-const ASSET_TYE_MAPPING: Record<AssetTypes, string> = {
- [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId",
- [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId",
- [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId",
-};
-
function mapAssetsToBookmarkFields(assets: Asset | Asset[] = []) {
+ const ASSET_TYE_MAPPING: Record<AssetTypes, string> = {
+ [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId",
+ [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId",
+ [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId",
+ };
const assetsArray = Array.isArray(assets) ? assets : [assets];
return assetsArray.reduce((result: Record<string, string>, asset: Asset) => {
result[ASSET_TYE_MAPPING[asset.assetType]] = asset.id;
@@ -208,6 +213,10 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark {
...t.tag,
})),
content,
+ assets: assets.map((a) => ({
+ id: a.id,
+ assetType: mapDBAssetTypeToUserType(a.assetType),
+ })),
...rest,
};
}
@@ -301,6 +310,7 @@ export const bookmarksAppRouter = router({
return {
alreadyExists: false,
tags: [] as ZBookmarkTags[],
+ assets: [],
content,
...bookmark,
};
@@ -599,6 +609,7 @@ export const bookmarksAppRouter = router({
...row.bookmarksSq,
content,
tags: [],
+ assets: [],
};
}
@@ -617,11 +628,18 @@ export const bookmarksAppRouter = router({
});
}
- if (row.assets) {
+ if (
+ row.assets &&
+ !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id)
+ ) {
acc[bookmarkId].content = {
...acc[bookmarkId].content,
...mapAssetsToBookmarkFields(row.assets),
};
+ acc[bookmarkId].assets.push({
+ id: row.assets.id,
+ assetType: mapDBAssetTypeToUserType(row.assets.assetType),
+ });
}
return acc;
@@ -787,4 +805,109 @@ export const bookmarksAppRouter = router({
};
});
}),
+
+ attachAsset: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ asset: zAssetSchema,
+ }),
+ )
+ .output(zAssetSchema)
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ if (!isAllowedToAttachAsset(input.asset.assetType)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't attach this type of asset",
+ });
+ }
+ await ctx.db
+ .insert(assets)
+ .values({
+ id: input.asset.id,
+ assetType: mapSchemaAssetTypeToDB(input.asset.assetType),
+ bookmarkId: input.bookmarkId,
+ })
+ .returning();
+ return input.asset;
+ }),
+ replaceAsset: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ oldAssetId: z.string(),
+ newAssetId: z.string(),
+ }),
+ )
+ .output(z.void())
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const oldAsset = await ctx.db
+ .select()
+ .from(assets)
+ .where(
+ and(
+ eq(assets.id, input.oldAssetId),
+ eq(assets.bookmarkId, input.bookmarkId),
+ ),
+ )
+ .limit(1);
+ if (!oldAsset.length) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ if (
+ !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset[0].assetType))
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "You can't attach this type of asset",
+ });
+ }
+
+ const result = await ctx.db
+ .update(assets)
+ .set({
+ id: input.newAssetId,
+ bookmarkId: input.bookmarkId,
+ })
+ .where(
+ and(
+ eq(assets.id, input.oldAssetId),
+ eq(assets.bookmarkId, input.bookmarkId),
+ ),
+ );
+ if (result.changes == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await deleteAsset({
+ userId: ctx.user.id,
+ assetId: input.oldAssetId,
+ }).catch(() => ({}));
+ }),
+ detachAsset: authedProcedure
+ .input(
+ z.object({
+ bookmarkId: z.string(),
+ assetId: z.string(),
+ }),
+ )
+ .output(z.void())
+ .use(ensureBookmarkOwnership)
+ .mutation(async ({ input, ctx }) => {
+ const result = await ctx.db
+ .delete(assets)
+ .where(
+ and(
+ eq(assets.id, input.assetId),
+ eq(assets.bookmarkId, input.bookmarkId),
+ ),
+ );
+ if (result.changes == 0) {
+ throw new TRPCError({ code: "NOT_FOUND" });
+ }
+ await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch(
+ () => ({}),
+ );
+ }),
});