279 lines
7.8 KiB
TypeScript
279 lines
7.8 KiB
TypeScript
"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,
|
|
parseOmnivoreBookmarkFile,
|
|
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" | "omnivore" | "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 if (source === "omnivore") {
|
|
return await parseOmnivoreBookmarkFile(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=".csv"
|
|
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: "omnivore" })
|
|
}
|
|
>
|
|
<Upload />
|
|
<p>Import Bookmarks from Omnivore 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>
|
|
);
|
|
}
|