diff --git a/apps/browser-extension/src/components/TagsSelector.tsx b/apps/browser-extension/src/components/TagsSelector.tsx index 45cf11d5..5ca3b2d0 100644 --- a/apps/browser-extension/src/components/TagsSelector.tsx +++ b/apps/browser-extension/src/components/TagsSelector.tsx @@ -32,14 +32,14 @@ export function TagsSelector({ bookmarkId }: { bookmarkId: string }) { const { mutate } = useUpdateBookmarkTags({ onMutate: (req) => { req.attach.forEach((t) => currentlyUpdating.add(t.tagId ?? "")); - req.detach.forEach((t) => currentlyUpdating.add(t.tagId)); + req.detach.forEach((t) => currentlyUpdating.add(t.tagId ?? "")); }, onSettled: (_resp, _err, req) => { if (!req) { return; } req.attach.forEach((t) => currentlyUpdating.delete(t.tagId ?? "")); - req.detach.forEach((t) => currentlyUpdating.delete(t.tagId)); + req.detach.forEach((t) => currentlyUpdating.delete(t.tagId ?? "")); }, }); diff --git a/apps/cli/src/commands/bookmarks.ts b/apps/cli/src/commands/bookmarks.ts index 40442ec1..855c0483 100644 --- a/apps/cli/src/commands/bookmarks.ts +++ b/apps/cli/src/commands/bookmarks.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import { addToList } from "@/commands/lists"; import { printError, printObject, @@ -20,7 +21,11 @@ function collect(val: T, acc: T[]) { return acc; } -function normalizeBookmark(bookmark: ZBookmark) { +type Bookmark = Omit & { + tags: string[]; +}; + +function normalizeBookmark(bookmark: ZBookmark): Bookmark { const ret = { ...bookmark, tags: bookmark.tags.map((t) => t.name), @@ -55,10 +60,17 @@ bookmarkCmd [], ) .option("--stdin", "reads the data from stdin and store it as a note") + .option("--list ", "if set, the bookmark(s) will be added to this list") + .option( + "--tag ", + "if set, this tag will be added to the bookmark(s). Specify multiple times to add multiple tags", + collect, + [], + ) .action(async (opts) => { const api = getAPIClient(); - const results: object[] = []; + const results: Bookmark[] = []; const promises = [ ...opts.link.map((url) => @@ -101,6 +113,12 @@ bookmarkCmd await Promise.allSettled(promises); printObject(results); + for (const bookmark of results) { + await updateTags(opts.tag, [], bookmark.id); + if (opts.list) { + await addToList(opts.list, bookmark.id); + } + } }); bookmarkCmd @@ -115,6 +133,48 @@ bookmarkCmd .catch(printError(`Failed to get the bookmark with id "${id}"`)); }); +function printTagMessage( + tags: { tagName: string }[], + bookmarkId: string, + action: "Added" | "Removed", +) { + tags.forEach((tag) => { + printStatusMessage( + true, + `${action} the tag ${tag.tagName} ${action === "Added" ? "to" : "from"} the bookmark with id ${bookmarkId}`, + ); + }); +} + +async function updateTags(addTags: string[], removeTags: string[], id: string) { + const tagsToAdd = addTags.map((addTag) => { + return { tagName: addTag }; + }); + + const tagsToRemove = removeTags.map((removeTag) => { + return { tagName: removeTag }; + }); + + if (tagsToAdd.length > 0 || tagsToRemove.length > 0) { + const api = getAPIClient(); + await api.bookmarks.updateTags + .mutate({ + bookmarkId: id, + attach: tagsToAdd, + detach: tagsToRemove, + }) + .then(() => { + printTagMessage(tagsToAdd, id, "Added"); + printTagMessage(tagsToRemove, id, "Removed"); + }) + .catch( + printError( + `Failed to add/remove tags to/from bookmark with id "${id}"`, + ), + ); + } +} + bookmarkCmd .command("update") .description("update a bookmark") @@ -124,18 +184,34 @@ bookmarkCmd .option("--no-archive", "if set, the bookmark will be unarchived") .option("--favourite", "if set, the bookmark will be favourited") .option("--no-favourite", "if set, the bookmark will be unfavourited") + .option( + "--addtag ", + "if set, this tag will be added to the bookmark. Specify multiple times to add multiple tags", + collect, + [], + ) + .option( + "--removetag ", + "if set, this tag will be removed from the bookmark. Specify multiple times to remove multiple tags", + collect, + [], + ) .argument("", "the id of the bookmark to get") .action(async (id, opts) => { const api = getAPIClient(); - await api.bookmarks.updateBookmark - .mutate({ - bookmarkId: id, - archived: opts.archive, - favourited: opts.favourite, - title: opts.title, - }) - .then(printObject) - .catch(printError(`Failed to update bookmark with id "${id}"`)); + await updateTags(opts.addtag, opts.removetag, id); + + if ("archive" in opts || "favourite" in opts || "title" in opts) { + await api.bookmarks.updateBookmark + .mutate({ + bookmarkId: id, + archived: opts.archive, + favourited: opts.favourite, + title: opts.title, + }) + .then(printObject) + .catch(printError(`Failed to update bookmark with id "${id}"`)); + } }); bookmarkCmd diff --git a/apps/cli/src/commands/lists.ts b/apps/cli/src/commands/lists.ts index 2f85ae7b..855624d6 100644 --- a/apps/cli/src/commands/lists.ts +++ b/apps/cli/src/commands/lists.ts @@ -62,29 +62,33 @@ listsCmd .catch(printError(`Failed to delete list with id "${id}"`)); }); +export async function addToList(listId: string, bookmarkId: string) { + const api = getAPIClient(); + + await api.lists.addToList + .mutate({ + listId, + bookmarkId, + }) + .then( + printSuccess( + `Successfully added bookmark "${bookmarkId}" to list with id "${listId}"`, + ), + ) + .catch( + printError( + `Failed to add bookmark "${bookmarkId}" to list with id "${listId}"`, + ), + ); +} + listsCmd .command("add-bookmark") .description("add a bookmark to list") .requiredOption("--list ", "the id of the list") .requiredOption("--bookmark ", "the id of the bookmark") .action(async (opts) => { - const api = getAPIClient(); - - await api.lists.addToList - .mutate({ - listId: opts.list, - bookmarkId: opts.bookmark, - }) - .then( - printSuccess( - `Successfully added bookmark "${opts.bookmark}" to list with id "${opts.list}"`, - ), - ) - .catch( - printError( - `Failed to add bookmark "${opts.bookmark}" to list with id "${opts.list}"`, - ), - ); + await addToList(opts.list, opts.bookmark); }); listsCmd diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index c9627626..b62c0e05 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -64,6 +64,69 @@ describe("Bookmark Routes", () => { expect(res.favourited).toBeTruthy(); }); + test("update tags on bookmarks", async ({ + apiCallers, + }) => { + const api = apiCallers[0].bookmarks; + + // Create the bookmark + const bookmark = await api.createBookmark({ + url: "https://google.com", + type: "link", + }); + + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [{ tagName: "asdf" }, { tagName: "qwer" }], + detach: [], + }); + + let res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.tags.length).toBe(2); + for (const tag of res.tags) { + if (tag.name !== "qwer" && tag.name !== "asdf") { + throw new Error("tag.name is neither qwer nor asdf"); + } + } + + // Adding the same tags again, doesn't change anything + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [{ tagName: "asdf" }, { tagName: "qwer" }], + detach: [], + }); + res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.tags.length).toBe(2); + + // Empty arrays don't do anything + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [], + detach: [], + }); + res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.tags.length).toBe(2); + + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [], + detach: [{ tagName: "asdf" }, { tagName: "qwer" }], + }); + + res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.tags.length).toBe(0); + + // Removing the same tags again, does not do anything either + await api.updateTags({ + bookmarkId: bookmark.id, + attach: [], + detach: [{ tagName: "asdf" }, { tagName: "qwer" }], + }); + + res = await api.getBookmark({ bookmarkId: bookmark.id }); + expect(res.tags.length).toBe(0); + }); + test("list bookmarks", async ({ apiCallers }) => { const api = apiCallers[0].bookmarks; const emptyBookmarks = await api.getBookmarks({}); diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 15a8c7c0..7119a849 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -615,8 +615,13 @@ export const bookmarksAppRouter = router({ tagName: z.string().optional(), }), ), - // Detach by tag ids - detach: z.array(z.object({ tagId: z.string() })), + detach: z.array( + z.object({ + // At least one of the two must be set + tagId: z.string().optional(), + tagName: z.string().optional(), // Also allow removing by tagName, to make CLI usage easier + }), + ), }), ) .output( @@ -627,25 +632,51 @@ export const bookmarksAppRouter = router({ ) .use(ensureBookmarkOwnership) .mutation(async ({ input, ctx }) => { - return await ctx.db.transaction(async (tx) => { + return ctx.db.transaction(async (tx) => { // Detaches + const idsToRemove: string[] = []; if (input.detach.length > 0) { - await tx.delete(tagsOnBookmarks).where( - and( - eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), - inArray( - tagsOnBookmarks.tagId, - input.detach.map((t) => t.tagId), + const namesToRemove: string[] = []; + input.detach.forEach((detachInfo) => { + if (detachInfo.tagId) { + idsToRemove.push(detachInfo.tagId); + } + if (detachInfo.tagName) { + namesToRemove.push(detachInfo.tagName); + } + }); + + if (namesToRemove.length > 0) { + ( + await tx.query.bookmarkTags.findMany({ + where: and( + eq(bookmarkTags.userId, ctx.user.id), + inArray(bookmarkTags.name, namesToRemove), + ), + columns: { + id: true, + }, + }) + ).forEach((tag) => { + idsToRemove.push(tag.id); + }); + } + + await tx + .delete(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.bookmarkId, input.bookmarkId), + inArray(tagsOnBookmarks.tagId, idsToRemove), ), - ), - ); + ); } if (input.attach.length == 0) { return { bookmarkId: input.bookmarkId, attached: [], - detached: input.detach.map((t) => t.tagId), + detached: idsToRemove, }; } @@ -701,7 +732,7 @@ export const bookmarksAppRouter = router({ return { bookmarkId: input.bookmarkId, attached: allIds, - detached: input.detach.map((t) => t.tagId), + detached: idsToRemove, }; }); }),