diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-10-27 12:03:14 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2024-10-27 12:03:14 +0000 |
| commit | eb7da996a7c2d617d276f296cac07a6fd5648664 (patch) | |
| tree | 4711de55b6f5fed3ac0cf3539099a9c0f115647e /apps/web/components/settings/ImportExport.tsx | |
| parent | 801ba36af5900c84af5a88dea37aa7d2f793fed9 (diff) | |
| download | karakeep-eb7da996a7c2d617d276f296cac07a6fd5648664.tar.zst | |
ui: Redesign the settings page and move it to its own layout
Diffstat (limited to 'apps/web/components/settings/ImportExport.tsx')
| -rw-r--r-- | apps/web/components/settings/ImportExport.tsx | 263 |
1 files changed, 263 insertions, 0 deletions
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx new file mode 100644 index 00000000..1145a42d --- /dev/null +++ b/apps/web/components/settings/ImportExport.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { buttonVariants } from "@/components/ui/button"; +import FilePickerButton from "@/components/ui/file-picker-button"; +import { Progress } from "@/components/ui/progress"; +import { toast } from "@/components/ui/use-toast"; +import { + ParsedBookmark, + parseHoarderBookmarkFile, + parseNetscapeBookmarkFile, + parsePocketBookmarkFile, +} from "@/lib/importBookmarkParser"; +import { cn } from "@/lib/utils"; +import { useMutation } from "@tanstack/react-query"; +import { TRPCClientError } from "@trpc/client"; +import { Download, Upload } from "lucide-react"; + +import { + useCreateBookmarkWithPostHook, + useUpdateBookmark, + useUpdateBookmarkTags, +} from "@hoarder/shared-react/hooks/bookmarks"; +import { + useAddBookmarkToList, + useCreateBookmarkList, +} from "@hoarder/shared-react/hooks/lists"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; + +export function ExportButton() { + return ( + <Link + href="/api/bookmarks/export" + className={cn( + buttonVariants({ variant: "default" }), + "flex items-center gap-2", + )} + > + <Download /> + <p>Export Links and Notes</p> + </Link> + ); +} + +export function ImportExportRow() { + 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(); + const { mutateAsync: addToList } = useAddBookmarkToList(); + const { mutateAsync: updateTags } = useUpdateBookmarkTags(); + + const { mutateAsync: parseAndCreateBookmark } = useMutation({ + mutationFn: async (toImport: { + bookmark: ParsedBookmark; + listId: string; + }) => { + const bookmark = toImport.bookmark; + if (bookmark.content === undefined) { + throw new Error("Content is undefined"); + } + const created = await createBookmark( + bookmark.content.type === BookmarkTypes.LINK + ? { + type: BookmarkTypes.LINK, + url: bookmark.content.url, + } + : { + type: BookmarkTypes.TEXT, + text: bookmark.content.text, + }, + ); + + 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, + note: bookmark.notes, + }).catch(() => { + /* empty */ + }) + : 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 + 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, + source, + }: { + file: File; + source: "html" | "pocket" | "hoarder"; + }) => { + if (source === "html") { + return await parseNetscapeBookmarkFile(file); + } else if (source === "pocket") { + return await parsePocketBookmarkFile(file); + } else if (source === "hoarder") { + return await parseHoarderBookmarkFile(file); + } else { + throw new Error("Unknown source"); + } + }, + onSuccess: async (resp) => { + const importList = await createList({ + name: `Imported Bookmarks`, + icon: "⬆️", + }); + setImportProgress({ done: 0, total: resp.length }); + + 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); + } + setImportProgress((prev) => ({ + done: (prev?.done ?? 0) + 1, + total: resp.length, + })); + } + + if (successes.length > 0 || alreadyExisted.length > 0) { + toast({ + description: `Imported ${successes.length} bookmarks and skipped ${alreadyExisted.length} bookmarks that already existed`, + variant: "default", + }); + } + + if (failed.length > 0) { + toast({ + description: `Failed to import ${failed.length} bookmarks`, + variant: "destructive", + }); + } + + router.push(`/dashboard/lists/${importList.id}`); + }, + onError: (error) => { + toast({ + description: error.message, + variant: "destructive", + }); + }, + }); + + return ( + <div className="flex flex-col gap-3"> + <div className="flex flex-row flex-wrap gap-2"> + <FilePickerButton + loading={false} + 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 + loading={false} + 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> + <FilePickerButton + loading={false} + accept=".json" + multiple={false} + className="flex items-center gap-2" + onFileSelect={(file) => + runUploadBookmarkFile({ file, source: "hoarder" }) + } + > + <Upload /> + <p>Import Bookmarks from Hoarder export</p> + </FilePickerButton> + <ExportButton /> + </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> + ); +} + +export default function ImportExport() { + return ( + <div className="flex w-full flex-col gap-2"> + <p className="mb-4 text-lg font-medium">Import / Export Bookmarks</p> + <ImportExportRow /> + </div> + ); +} |
