Skip to content

Commit

Permalink
feature(web): Add support for importing bookmarks from Pocket
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Sep 21, 2024
1 parent d62c972 commit 9dd6f21
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 68 deletions.
115 changes: 79 additions & 36 deletions apps/web/components/dashboard/settings/ImportExport.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -57,6 +63,8 @@ export function Import() {
createdAt: bookmark.addDate
? new Date(bookmark.addDate * 1000)
: undefined,
}).catch(() => {
/* empty */
})
: undefined,

Expand All @@ -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 = [];
Expand All @@ -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) {
Expand Down Expand Up @@ -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>
);
}
Expand Down
27 changes: 27 additions & 0 deletions apps/web/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -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 };
68 changes: 68 additions & 0 deletions apps/web/lib/importBookmarkParser.ts
Original file line number Diff line number Diff line change
@@ -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();
}
31 changes: 0 additions & 31 deletions apps/web/lib/netscapeBookmarkParser.ts

This file was deleted.

1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/10-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".

Expand Down
Loading

0 comments on commit 9dd6f21

Please sign in to comment.