aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-01-18 21:38:21 +0000
committerGitHub <noreply@github.com>2026-01-18 21:38:21 +0000
commite09061bd37c6496685ea0fdabe1d4d01f1b659ad (patch)
tree2896ccceb54e7af8dc006d4bdc89b18af42ca078 /packages
parentedf3f681a77ce6739ca0773af6e5a645e2c91ee9 (diff)
downloadkarakeep-e09061bd37c6496685ea0fdabe1d4d01f1b659ad.tar.zst
feat: Add attachedBy field to update tags endpoint (#2281)
* feat: Add attachedBy field to updateTags endpoint This change allows callers to specify the attachedBy field when updating tags on a bookmark. The field defaults to "human" if not provided, maintaining backward compatibility with existing code. Changes: - Added attachedBy field to zManipulatedTagSchema with default "human" - Updated updateTags endpoint to use the specified attachedBy value - Created mapping logic to correctly assign attachedBy to each tag * fix(cli): migrate bookmark source in migration command * fix * reduce queries --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/e2e_tests/tests/api/bookmarks.test.ts70
-rw-r--r--packages/open-api/karakeep-openapi-spec.json16
-rw-r--r--packages/shared/types/bookmarks.ts3
-rw-r--r--packages/trpc/routers/bookmarks.test.ts68
-rw-r--r--packages/trpc/routers/bookmarks.ts54
5 files changed, 198 insertions, 13 deletions
diff --git a/packages/e2e_tests/tests/api/bookmarks.test.ts b/packages/e2e_tests/tests/api/bookmarks.test.ts
index a6bc85e3..7c14ee61 100644
--- a/packages/e2e_tests/tests/api/bookmarks.test.ts
+++ b/packages/e2e_tests/tests/api/bookmarks.test.ts
@@ -289,6 +289,76 @@ describe("Bookmarks API", () => {
expect(removeTagsRes.status).toBe(200);
});
+ it("should manage tags with attachedBy field", async () => {
+ // Create a new bookmark
+ const { data: createdBookmark, error: createError } = await client.POST(
+ "/bookmarks",
+ {
+ body: {
+ type: "text",
+ title: "Test Bookmark for attachedBy",
+ text: "Testing attachedBy field",
+ },
+ },
+ );
+
+ if (createError) {
+ console.error("Error creating bookmark:", createError);
+ throw createError;
+ }
+ if (!createdBookmark) {
+ throw new Error("Bookmark creation failed");
+ }
+
+ // Add tags with different attachedBy values
+ const { data: addTagsResponse, response: addTagsRes } = await client.POST(
+ "/bookmarks/{bookmarkId}/tags",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ body: {
+ tags: [
+ { tagName: "ai-tag", attachedBy: "ai" },
+ { tagName: "human-tag", attachedBy: "human" },
+ { tagName: "default-tag" }, // Should default to "human"
+ ],
+ },
+ },
+ );
+
+ expect(addTagsRes.status).toBe(200);
+ expect(addTagsResponse!.attached.length).toBe(3);
+
+ // Get the bookmark and verify the attachedBy values
+ const { data: retrievedBookmark } = await client.GET(
+ "/bookmarks/{bookmarkId}",
+ {
+ params: {
+ path: {
+ bookmarkId: createdBookmark.id,
+ },
+ },
+ },
+ );
+
+ expect(retrievedBookmark!.tags.length).toBe(3);
+
+ const aiTag = retrievedBookmark!.tags.find((t) => t.name === "ai-tag");
+ const humanTag = retrievedBookmark!.tags.find(
+ (t) => t.name === "human-tag",
+ );
+ const defaultTag = retrievedBookmark!.tags.find(
+ (t) => t.name === "default-tag",
+ );
+
+ expect(aiTag?.attachedBy).toBe("ai");
+ expect(humanTag?.attachedBy).toBe("human");
+ expect(defaultTag?.attachedBy).toBe("human");
+ });
+
it("should get lists for a bookmark", async () => {
const { data: createdBookmark } = await client.POST("/bookmarks", {
body: {
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index 52c39be1..a1b9dfc4 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -1445,6 +1445,14 @@
},
"tagName": {
"type": "string"
+ },
+ "attachedBy": {
+ "type": "string",
+ "enum": [
+ "ai",
+ "human"
+ ],
+ "default": "human"
}
}
}
@@ -1536,6 +1544,14 @@
},
"tagName": {
"type": "string"
+ },
+ "attachedBy": {
+ "type": "string",
+ "enum": [
+ "ai",
+ "human"
+ ],
+ "default": "human"
}
}
}
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index 1e4f855e..ec27c393 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -1,7 +1,7 @@
import { z } from "zod";
import { zCursorV2 } from "./pagination";
-import { zBookmarkTagSchema } from "./tags";
+import { zAttachedByEnumSchema, zBookmarkTagSchema } from "./tags";
export const MAX_BOOKMARK_TITLE_LENGTH = 1000;
@@ -252,6 +252,7 @@ export const zManipulatedTagSchema = z
// At least one of the two must be set
tagId: z.string().optional(), // If the tag already exists and we know its id we should pass it
tagName: z.string().optional(),
+ attachedBy: zAttachedByEnumSchema.optional().default("human"),
})
.refine((val) => !!val.tagId || !!val.tagName, {
message: "You must provide either a tagId or a tagName",
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index 7e0e65e0..aaee5447 100644
--- a/packages/trpc/routers/bookmarks.test.ts
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -455,6 +455,74 @@ describe("Bookmark Routes", () => {
expect(duplicateTagCount).toEqual(1); // Should only be attached once
});
+ test<CustomTestContext>("updateTags with attachedBy field", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].bookmarks;
+ const bookmark = await api.createBookmark({
+ url: "https://bookmark.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Test 1: Attach tags with different attachedBy values
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [
+ { tagName: "ai-tag", attachedBy: "ai" },
+ { tagName: "human-tag", attachedBy: "human" },
+ { tagName: "default-tag" }, // Should default to "human"
+ ],
+ detach: [],
+ });
+
+ let b = await api.getBookmark({ bookmarkId: bookmark.id });
+ expect(b.tags.length).toEqual(3);
+
+ const aiTag = b.tags.find((t) => t.name === "ai-tag");
+ const humanTag = b.tags.find((t) => t.name === "human-tag");
+ const defaultTag = b.tags.find((t) => t.name === "default-tag");
+
+ expect(aiTag?.attachedBy).toEqual("ai");
+ expect(humanTag?.attachedBy).toEqual("human");
+ expect(defaultTag?.attachedBy).toEqual("human");
+
+ // Test 2: Attach existing tag by ID with different attachedBy
+ // First detach the ai-tag
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [],
+ detach: [{ tagId: aiTag!.id }],
+ });
+
+ // Re-attach the same tag but as human
+ await api.updateTags({
+ bookmarkId: bookmark.id,
+ attach: [{ tagId: aiTag!.id, attachedBy: "human" }],
+ detach: [],
+ });
+
+ b = await api.getBookmark({ bookmarkId: bookmark.id });
+ const reAttachedTag = b.tags.find((t) => t.id === aiTag!.id);
+ expect(reAttachedTag?.attachedBy).toEqual("human");
+
+ // Test 3: Attach existing tag by name with AI attachedBy
+ const bookmark2 = await api.createBookmark({
+ url: "https://bookmark2.com",
+ type: BookmarkTypes.LINK,
+ });
+
+ await api.updateTags({
+ bookmarkId: bookmark2.id,
+ attach: [{ tagName: "ai-tag", attachedBy: "ai" }],
+ detach: [],
+ });
+
+ const b2 = await api.getBookmark({ bookmarkId: bookmark2.id });
+ const aiTagOnB2 = b2.tags.find((t) => t.name === "ai-tag");
+ expect(aiTagOnB2?.attachedBy).toEqual("ai");
+ expect(aiTagOnB2?.id).toEqual(aiTag!.id); // Should be the same tag
+ });
+
test<CustomTestContext>("update bookmark text", async ({ apiCallers }) => {
const api = apiCallers[0].bookmarks;
const createdBookmark = await api.createBookmark({
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index bf960748..37497bcf 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -714,10 +714,10 @@ export const bookmarksAppRouter = router({
)
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- // Helper function to fetch tag IDs from a list of tag identifiers
- const fetchTagIds = async (
+ // Helper function to fetch tag IDs and their names from a list of tag identifiers
+ const fetchTagIdsWithNames = async (
tagIdentifiers: { tagId?: string; tagName?: string }[],
- ): Promise<string[]> => {
+ ): Promise<{ id: string; name: string }[]> => {
const tagIds = tagIdentifiers.flatMap((t) =>
t.tagId ? [t.tagId] : [],
);
@@ -729,7 +729,7 @@ export const bookmarksAppRouter = router({
const [byIds, byNames] = await Promise.all([
tagIds.length > 0
? ctx.db
- .select({ id: bookmarkTags.id })
+ .select({ id: bookmarkTags.id, name: bookmarkTags.name })
.from(bookmarkTags)
.where(
and(
@@ -740,7 +740,7 @@ export const bookmarksAppRouter = router({
: Promise.resolve([]),
tagNames.length > 0
? ctx.db
- .select({ id: bookmarkTags.id })
+ .select({ id: bookmarkTags.id, name: bookmarkTags.name })
.from(bookmarkTags)
.where(
and(
@@ -751,15 +751,25 @@ export const bookmarksAppRouter = router({
: Promise.resolve([]),
]);
- // Union results and deduplicate tag IDs
- const results = [...byIds, ...byNames];
- return [...new Set(results.map((t) => t.id))];
+ // Union results and deduplicate by tag ID
+ const seen = new Set<string>();
+ const results: { id: string; name: string }[] = [];
+
+ for (const tag of [...byIds, ...byNames]) {
+ if (!seen.has(tag.id)) {
+ seen.add(tag.id);
+ results.push({ id: tag.id, name: tag.name });
+ }
+ }
+
+ return results;
};
// Normalize tag names and create new tags outside transaction to reduce transaction duration
const normalizedAttachTags = input.attach.map((tag) => ({
tagId: tag.tagId,
tagName: tag.tagName ? normalizeTagName(tag.tagName) : undefined,
+ attachedBy: tag.attachedBy,
}));
{
@@ -779,11 +789,31 @@ export const bookmarksAppRouter = router({
}
// Fetch tag IDs for attachment/detachment now that we know that they all exist
- const [allIdsToAttach, idsToRemove] = await Promise.all([
- fetchTagIds(normalizedAttachTags),
- fetchTagIds(input.detach),
+ const [attachTagsWithNames, detachTagsWithNames] = await Promise.all([
+ fetchTagIdsWithNames(normalizedAttachTags),
+ fetchTagIdsWithNames(input.detach),
]);
+ // Build the attachedBy map from the fetched results
+ const tagIdToAttachedBy = new Map<string, "ai" | "human">();
+
+ for (const fetchedTag of attachTagsWithNames) {
+ // Find the corresponding input tag
+ const inputTag = normalizedAttachTags.find(
+ (t) =>
+ (t.tagId && t.tagId === fetchedTag.id) ||
+ (t.tagName && t.tagName === fetchedTag.name),
+ );
+
+ if (inputTag) {
+ tagIdToAttachedBy.set(fetchedTag.id, inputTag.attachedBy);
+ }
+ }
+
+ // Extract just the IDs for the transaction
+ const allIdsToAttach = attachTagsWithNames.map((t) => t.id);
+ const idsToRemove = detachTagsWithNames.map((t) => t.id);
+
const res = await ctx.db.transaction(async (tx) => {
// Detaches
if (idsToRemove.length > 0) {
@@ -805,7 +835,7 @@ export const bookmarksAppRouter = router({
allIdsToAttach.map((i) => ({
tagId: i,
bookmarkId: input.bookmarkId,
- attachedBy: "human" as const,
+ attachedBy: tagIdToAttachedBy.get(i) ?? "human",
})),
)
.onConflictDoNothing();