diff options
| -rw-r--r-- | apps/web/components/dashboard/settings/ImportExport.tsx | 115 | ||||
| -rw-r--r-- | apps/web/components/ui/progress.tsx | 27 | ||||
| -rw-r--r-- | apps/web/lib/importBookmarkParser.ts | 68 | ||||
| -rw-r--r-- | apps/web/lib/netscapeBookmarkParser.ts | 31 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | docs/docs/10-import.md | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 94 |
7 files changed, 270 insertions, 68 deletions
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 ( - <div> - <FilePickerButton - accept=".html" - multiple={false} - className="flex items-center gap-2" - onFileSelect={runUploadBookmarkFile} - > - <Upload /> - <p>Import Bookmarks from HTML file</p> - </FilePickerButton> + <div className="flex flex-col gap-3"> + <div className="flex flex-row gap-2"> + <FilePickerButton + accept=".html" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "html" }) + } + > + <Upload /> + <p>Import Bookmarks from HTML file</p> + </FilePickerButton> + + <FilePickerButton + accept=".html" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "pocket" }) + } + > + <Upload /> + <p>Import Bookmarks from Pocket export</p> + </FilePickerButton> + </div> + {importProgress && ( + <div className="flex flex-col gap-2"> + <p className="shrink-0 text-sm"> + Processed {importProgress.done} of {importProgress.total} bookmarks + </p> + <div className="w-full"> + <Progress + value={(importProgress.done * 100) / importProgress.total} + /> + </div> + </div> + )} </div> ); } 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<typeof ProgressPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> +>(({ className, value, ...props }, ref) => ( + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-4 w-full overflow-hidden rounded-full bg-secondary", + className, + )} + {...props} + > + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }} + /> + </ProgressPrimitive.Root> +)); +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<ParsedBookmark[]> { + 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"); + } + + 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<ParsedBookmark[]> { + 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("<!DOCTYPE NETSCAPE-Bookmark-file-1>")) { - 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", diff --git a/docs/docs/10-import.md b/docs/docs/10-import.md index bf3a6829..b4e31d44 100644 --- a/docs/docs/10-import.md +++ b/docs/docs/10-import.md @@ -2,7 +2,7 @@ ## Import using the WebUI -Hoarder supports importing bookmarks using the Netscape HTML Format. Titles, tags and addition date will be preserved during the import. An automatically created list will contain all the imported bookmarks. +Hoarder supports importing bookmarks using the Netscape HTML Format and Pocket's HTML format. Titles, tags and addition date will be preserved during the import. An automatically created list will contain all the imported bookmarks. To import the bookmark file, go to the settings and click "Import Bookmarks from HTML file". diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 915e81c7..c235ea43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-progress': + specifier: ^1.1.0 + version: 1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -3133,6 +3136,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.0': + resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.0.1': resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} peerDependencies: @@ -3142,6 +3154,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.0': + resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.0.5': resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} peerDependencies: @@ -3312,6 +3333,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.0': + resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.0': + resolution: {integrity: sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.0.4': resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: @@ -3378,6 +3425,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.0': + resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-tabs@1.0.4': resolution: {integrity: sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==} peerDependencies: @@ -16590,6 +16646,12 @@ snapshots: react: 18.2.0 dev: false + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.2.58)(react@18.2.0)': + dependencies: + '@types/react': 18.2.58 + react: 18.2.0 + dev: false + '@radix-ui/react-context@1.0.1(@types/react@18.2.58)(react@18.2.0)': dependencies: '@babel/runtime': 7.23.9 @@ -16597,6 +16659,12 @@ snapshots: react: 18.2.0 dev: false + '@radix-ui/react-context@1.1.0(@types/react@18.2.58)(react@18.2.0)': + dependencies: + '@types/react': 18.2.58 + react: 18.2.0 + dev: false + '@radix-ui/react-dialog@1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.23.9 @@ -16795,6 +16863,25 @@ snapshots: react-dom: 18.2.0(react@18.2.0) dev: false + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-slot': 1.1.0(@types/react@18.2.58)(react@18.2.0) + '@types/react': 18.2.58 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + '@radix-ui/react-progress@1.1.0(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.0(@types/react@18.2.58)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@types/react': 18.2.58 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.23.9 @@ -16886,6 +16973,13 @@ snapshots: react: 18.2.0 dev: false + '@radix-ui/react-slot@1.1.0(@types/react@18.2.58)(react@18.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.58)(react@18.2.0) + '@types/react': 18.2.58 + react: 18.2.0 + dev: false + '@radix-ui/react-tabs@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.23.9 |
