From 9dd6f216ad18c09a28eaad67411d3a0e7f57a04f Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Sat, 21 Sep 2024 17:49:47 +0000 Subject: feature(web): Add support for importing bookmarks from Pocket --- .../components/dashboard/settings/ImportExport.tsx | 115 ++++++++++++++------- apps/web/components/ui/progress.tsx | 27 +++++ apps/web/lib/importBookmarkParser.ts | 68 ++++++++++++ apps/web/lib/netscapeBookmarkParser.ts | 31 ------ apps/web/package.json | 1 + 5 files changed, 175 insertions(+), 67 deletions(-) create mode 100644 apps/web/components/ui/progress.tsx create mode 100644 apps/web/lib/importBookmarkParser.ts delete mode 100644 apps/web/lib/netscapeBookmarkParser.ts (limited to 'apps') diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx index dcc3c8e8..4827df93 100644 --- a/apps/web/components/dashboard/settings/ImportExport.tsx +++ b/apps/web/components/dashboard/settings/ImportExport.tsx @@ -1,9 +1,15 @@ "use client"; +import { useState } from "react"; import { useRouter } from "next/navigation"; import FilePickerButton from "@/components/ui/file-picker-button"; +import { Progress } from "@/components/ui/progress"; import { toast } from "@/components/ui/use-toast"; -import { parseNetscapeBookmarkFile } from "@/lib/netscapeBookmarkParser"; +import { + ParsedBookmark, + parseNetscapeBookmarkFile, + parsePocketBookmarkFile, +} from "@/lib/importBookmarkParser"; import { useMutation } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { Upload } from "lucide-react"; @@ -22,6 +28,11 @@ import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; export function Import() { const router = useRouter(); + const [importProgress, setImportProgress] = useState<{ + done: number; + total: number; + } | null>(null); + const { mutateAsync: createBookmark } = useCreateBookmarkWithPostHook(); const { mutateAsync: updateBookmark } = useUpdateBookmark(); const { mutateAsync: createList } = useCreateBookmarkList(); @@ -30,12 +41,7 @@ export function Import() { const { mutateAsync: parseAndCreateBookmark } = useMutation({ mutationFn: async (toImport: { - bookmark: { - title: string; - url: string | undefined; - tags: string[]; - addDate?: number; - }; + bookmark: ParsedBookmark; listId: string; }) => { const bookmark = toImport.bookmark; @@ -57,6 +63,8 @@ export function Import() { createdAt: bookmark.addDate ? new Date(bookmark.addDate * 1000) : undefined, + }).catch(() => { + /* empty */ }) : undefined, @@ -76,31 +84,40 @@ export function Import() { }), // Update tags - updateTags({ - bookmarkId: created.id, - attach: bookmark.tags.map((t) => ({ tagName: t })), - detach: [], - }), + bookmark.tags.length > 0 + ? updateTags({ + bookmarkId: created.id, + attach: bookmark.tags.map((t) => ({ tagName: t })), + detach: [], + }) + : undefined, ]); return created; }, }); const { mutateAsync: runUploadBookmarkFile } = useMutation({ - mutationFn: async (file: File) => { - return await parseNetscapeBookmarkFile(file); + mutationFn: async ({ + file, + source, + }: { + file: File; + source: "html" | "pocket"; + }) => { + if (source === "html") { + return await parseNetscapeBookmarkFile(file); + } else if (source === "pocket") { + return await parsePocketBookmarkFile(file); + } else { + throw new Error("Unknown source"); + } }, onSuccess: async (resp) => { const importList = await createList({ name: `Imported Bookmarks`, icon: "⬆️", }); - - let done = 0; - const { id, update } = toast({ - description: `Processed 0 bookmarks of ${resp.length}`, - variant: "default", - }); + setImportProgress({ done: 0, total: resp.length }); const successes = []; const failed = []; @@ -120,12 +137,10 @@ export function Import() { } catch (e) { failed.push(parsedBookmark); } - - update({ - id, - description: `Processed ${done + 1} bookmarks of ${resp.length}`, - }); - done++; + setImportProgress((prev) => ({ + done: (prev?.done ?? 0) + 1, + total: resp.length, + })); } if (successes.length > 0 || alreadyExisted.length > 0) { @@ -153,16 +168,44 @@ export function Import() { }); return ( -
- - -

Import Bookmarks from HTML file

-
+
+
+ + runUploadBookmarkFile({ file, source: "html" }) + } + > + +

Import Bookmarks from HTML file

+
+ + + runUploadBookmarkFile({ file, source: "pocket" }) + } + > + +

Import Bookmarks from Pocket export

+
+
+ {importProgress && ( +
+

+ Processed {importProgress.done} of {importProgress.total} bookmarks +

+
+ +
+
+ )}
); } diff --git a/apps/web/components/ui/progress.tsx b/apps/web/components/ui/progress.tsx new file mode 100644 index 00000000..d777b615 --- /dev/null +++ b/apps/web/components/ui/progress.tsx @@ -0,0 +1,27 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import * as ProgressPrimitive from "@radix-ui/react-progress"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/lib/importBookmarkParser.ts b/apps/web/lib/importBookmarkParser.ts new file mode 100644 index 00000000..e1b21a79 --- /dev/null +++ b/apps/web/lib/importBookmarkParser.ts @@ -0,0 +1,68 @@ +// Copied from https://gist.github.com/devster31/4e8c6548fd16ffb75c02e6f24e27f9b9 +import * as cheerio from "cheerio"; + +export interface ParsedBookmark { + title: string; + url?: string; + tags: string[]; + addDate?: number; +} + +export async function parseNetscapeBookmarkFile( + file: File, +): Promise { + const textContent = await file.text(); + + if (!textContent.startsWith("")) { + throw Error("The uploaded html file does not seem to be a bookmark file"); + } + + 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(); +} + +export async function parsePocketBookmarkFile( + file: File, +): Promise { + const textContent = await file.text(); + + const $ = cheerio.load(textContent); + + return $("a") + .map(function (_index, a) { + const $a = $(a); + const addDate = $a.attr("time_added"); + let tags: string[] = []; + const tagsStr = $a.attr("tags"); + try { + tags = tagsStr && tagsStr.length > 0 ? tagsStr.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/lib/netscapeBookmarkParser.ts b/apps/web/lib/netscapeBookmarkParser.ts deleted file mode 100644 index 196c0525..00000000 --- a/apps/web/lib/netscapeBookmarkParser.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 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("")) { - throw Error("The uploaded html file does not seem to be a bookmark file"); - } - - 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 491ad46d..a59d4a48 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", -- cgit v1.2.3-70-g09d2