diff options
| author | MohamedBassem <me@mbassem.com> | 2024-09-21 16:56:42 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-09-21 17:00:06 +0000 |
| commit | d62c9724b7f4cb728cd5b5496fdcc0eba8330772 (patch) | |
| tree | b0096e53cefdf1e2df2251c3845377697b5b9600 /apps | |
| parent | 52024ab52724b45c08f437f9f10805adefe2bf0e (diff) | |
| download | karakeep-d62c9724b7f4cb728cd5b5496fdcc0eba8330772.tar.zst | |
feature(web): Preserve title, tags and createdAt when importing a netscape html. Fixes #401
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/components/dashboard/settings/ImportExport.tsx | 133 | ||||
| -rw-r--r-- | apps/web/lib/netscapeBookmarkParser.ts | 35 | ||||
| -rw-r--r-- | apps/web/package.json | 1 |
3 files changed, 127 insertions, 42 deletions
diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx index 75de14ac..dcc3c8e8 100644 --- a/apps/web/components/dashboard/settings/ImportExport.tsx +++ b/apps/web/components/dashboard/settings/ImportExport.tsx @@ -1,14 +1,18 @@ "use client"; -import assert from "assert"; import { useRouter } from "next/navigation"; import FilePickerButton from "@/components/ui/file-picker-button"; import { toast } from "@/components/ui/use-toast"; import { parseNetscapeBookmarkFile } from "@/lib/netscapeBookmarkParser"; import { useMutation } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; import { Upload } from "lucide-react"; -import { useCreateBookmarkWithPostHook } from "@hoarder/shared-react/hooks/bookmarks"; +import { + useCreateBookmarkWithPostHook, + useUpdateBookmark, + useUpdateBookmarkTags, +} from "@hoarder/shared-react/hooks/bookmarks"; import { useAddBookmarkToList, useCreateBookmarkList, @@ -17,29 +21,112 @@ import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; export function Import() { const router = useRouter(); - const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); + const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); + const { mutateAsync: updateBookmark } = useUpdateBookmark(); const { mutateAsync: createList } = useCreateBookmarkList(); const { mutateAsync: addToList } = useAddBookmarkToList(); + const { mutateAsync: updateTags } = useUpdateBookmarkTags(); + + const { mutateAsync: parseAndCreateBookmark } = useMutation({ + mutationFn: async (toImport: { + bookmark: { + title: string; + url: string | undefined; + tags: string[]; + addDate?: number; + }; + listId: string; + }) => { + const bookmark = toImport.bookmark; + if (bookmark.url === undefined) { + throw new Error("URL is undefined"); + } + const url = new URL(bookmark.url); + const created = await createBookmark({ + type: BookmarkTypes.LINK, + url: url.toString(), + }); + + await Promise.all([ + // Update title and createdAt if they're set + bookmark.title.length > 0 || bookmark.addDate + ? updateBookmark({ + bookmarkId: created.id, + title: bookmark.title, + createdAt: bookmark.addDate + ? new Date(bookmark.addDate * 1000) + : undefined, + }) + : undefined, + + // Add to import list + addToList({ + bookmarkId: created.id, + listId: toImport.listId, + }).catch((e) => { + if ( + e instanceof TRPCClientError && + e.message.includes("already in the list") + ) { + /* empty */ + } else { + throw e; + } + }), + + // Update tags + updateTags({ + bookmarkId: created.id, + attach: bookmark.tags.map((t) => ({ tagName: t })), + detach: [], + }), + ]); + return created; + }, + }); const { mutateAsync: runUploadBookmarkFile } = useMutation({ mutationFn: async (file: File) => { return await parseNetscapeBookmarkFile(file); }, onSuccess: async (resp) => { - const results = await Promise.allSettled( - resp.map((url) => - createBookmark({ type: BookmarkTypes.LINK, url: url.toString() }), - ), - ); - - const failed = results.filter((r) => r.status == "rejected"); - const successes = results.filter( - (r) => r.status == "fulfilled" && !r.value.alreadyExists, - ); - const alreadyExisted = results.filter( - (r) => r.status == "fulfilled" && r.value.alreadyExists, - ); + const importList = await createList({ + name: `Imported Bookmarks`, + icon: "⬆️", + }); + + let done = 0; + const { id, update } = toast({ + description: `Processed 0 bookmarks of ${resp.length}`, + variant: "default", + }); + + const successes = []; + const failed = []; + const alreadyExisted = []; + // Do the imports one by one + for (const parsedBookmark of resp) { + try { + const result = await parseAndCreateBookmark({ + bookmark: parsedBookmark, + listId: importList.id, + }); + if (result.alreadyExists) { + alreadyExisted.push(parsedBookmark); + } else { + successes.push(parsedBookmark); + } + } catch (e) { + failed.push(parsedBookmark); + } + + update({ + id, + description: `Processed ${done + 1} bookmarks of ${resp.length}`, + }); + done++; + } if (successes.length > 0 || alreadyExisted.length > 0) { toast({ @@ -55,20 +142,6 @@ export function Import() { }); } - const importList = await createList({ - name: `Imported Bookmarks`, - icon: "⬆️", - }); - - if (successes.length > 0) { - await Promise.allSettled( - successes.map((r) => { - assert(r.status == "fulfilled"); - addToList({ bookmarkId: r.value.id, listId: importList.id }); - }), - ); - } - router.push(`/dashboard/lists/${importList.id}`); }, onError: (error) => { diff --git a/apps/web/lib/netscapeBookmarkParser.ts b/apps/web/lib/netscapeBookmarkParser.ts index ac5f3ec2..196c0525 100644 --- a/apps/web/lib/netscapeBookmarkParser.ts +++ b/apps/web/lib/netscapeBookmarkParser.ts @@ -1,20 +1,31 @@ -function extractUrls(html: string): string[] { - const regex = /<a\s+(?:[^>]*?\s+)?href="(http[^"]*)"/gi; - let match; - const urls = []; - - while ((match = regex.exec(html)) !== null) { - urls.push(match[1]); - } - - return urls; -} +// Copied from https://gist.github.com/devster31/4e8c6548fd16ffb75c02e6f24e27f9b9 +import * as cheerio from "cheerio"; export async function parseNetscapeBookmarkFile(file: File) { const textContent = await file.text(); + if (!textContent.startsWith("<!DOCTYPE NETSCAPE-Bookmark-file-1>")) { throw Error("The uploaded html file does not seem to be a bookmark file"); } - return extractUrls(textContent).map((url) => new URL(url)); + const $ = cheerio.load(textContent); + + return $("a") + .map(function (_index, a) { + const $a = $(a); + const addDate = $a.attr("add_date"); + let tags: string[] = []; + try { + tags = $a.attr("tags")?.split(",") ?? []; + } catch (e) { + /* empty */ + } + return { + title: $a.text(), + url: $a.attr("href"), + tags: tags, + addDate: typeof addDate === "undefined" ? undefined : parseInt(addDate), + }; + }) + .get(); } diff --git a/apps/web/package.json b/apps/web/package.json index 5542bb2a..491ad46d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,6 +43,7 @@ "@trpc/react-query": "11.0.0-next-beta.308", "@trpc/server": "11.0.0-next-beta.308", "better-sqlite3": "^9.4.3", + "cheerio": "^1.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "dayjs": "^1.11.10", |
