aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-10-13 01:49:13 +0000
committerMohamedBassem <me@mbassem.com>2024-10-13 01:49:13 +0000
commitde9cf0a45227da9d33feabe9c51a71845dad6763 (patch)
tree1b34bacc1cf767d63bf55d5b898afd013cbbeca5
parent2ccc15ea8865966618cf804968a5ca14ae364345 (diff)
downloadkarakeep-de9cf0a45227da9d33feabe9c51a71845dad6763.tar.zst
feature: Allow importing hoarder's own bookmark file. Fixes #527
-rw-r--r--apps/web/app/api/bookmarks/export/route.tsx55
-rw-r--r--apps/web/components/dashboard/settings/ImportExport.tsx22
-rw-r--r--apps/web/lib/exportBookmarks.ts60
-rw-r--r--apps/web/lib/importBookmarkParser.ts29
-rw-r--r--packages/shared/types/bookmarks.ts2
5 files changed, 122 insertions, 46 deletions
diff --git a/apps/web/app/api/bookmarks/export/route.tsx b/apps/web/app/api/bookmarks/export/route.tsx
index aefa76b5..7ae46c56 100644
--- a/apps/web/app/api/bookmarks/export/route.tsx
+++ b/apps/web/app/api/bookmarks/export/route.tsx
@@ -1,35 +1,8 @@
+import { toExportFormat, zExportSchema } from "@/lib/exportBookmarks";
import { api, createContextFromRequest } from "@/server/api/client";
+import { z } from "zod";
-import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
-import {
- BookmarkTypes,
- MAX_NUM_BOOKMARKS_PER_PAGE,
-} from "@hoarder/shared/types/bookmarks";
-
-function toExportFormat(bookmark: ZBookmark) {
- return {
- createdAt: bookmark.createdAt.toISOString(),
- title:
- bookmark.title ??
- (bookmark.content.type === BookmarkTypes.LINK
- ? bookmark.content.title
- : null),
- tags: bookmark.tags.map((t) => t.name),
- type: bookmark.content.type,
- content: {
- type: bookmark.content.type,
- url:
- bookmark.content.type === BookmarkTypes.LINK
- ? bookmark.content.url
- : undefined,
- text:
- bookmark.content.type === BookmarkTypes.TEXT
- ? bookmark.content.text
- : undefined,
- },
- note: bookmark.note,
- };
-}
+import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
@@ -53,17 +26,15 @@ export async function GET(request: Request) {
results = [...results, ...resp.bookmarks.map(toExportFormat)];
}
- return new Response(
- JSON.stringify({
- // Exclude asset types for now
- bookmarks: results.filter((b) => b.type !== BookmarkTypes.ASSET),
- }),
- {
- status: 200,
- headers: {
- "Content-type": "application/json",
- "Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`,
- },
+ const exportData: z.infer<typeof zExportSchema> = {
+ bookmarks: results.filter((b) => b.content !== null),
+ };
+
+ return new Response(JSON.stringify(exportData), {
+ status: 200,
+ headers: {
+ "Content-type": "application/json",
+ "Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`,
},
- );
+ });
}
diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx
index 2bc930f7..f3ef13ef 100644
--- a/apps/web/components/dashboard/settings/ImportExport.tsx
+++ b/apps/web/components/dashboard/settings/ImportExport.tsx
@@ -9,6 +9,7 @@ import { Progress } from "@/components/ui/progress";
import { toast } from "@/components/ui/use-toast";
import {
ParsedBookmark,
+ parseHoarderBookmarkFile,
parseNetscapeBookmarkFile,
parsePocketBookmarkFile,
} from "@/lib/importBookmarkParser";
@@ -66,10 +67,10 @@ export function ImportExportRow() {
if (bookmark.url === undefined) {
throw new Error("URL is undefined");
}
- const url = new URL(bookmark.url);
+ new URL(bookmark.url);
const created = await createBookmark({
type: BookmarkTypes.LINK,
- url: url.toString(),
+ url: bookmark.url,
});
await Promise.all([
@@ -81,6 +82,7 @@ export function ImportExportRow() {
createdAt: bookmark.addDate
? new Date(bookmark.addDate * 1000)
: undefined,
+ note: bookmark.notes,
}).catch(() => {
/* empty */
})
@@ -120,12 +122,14 @@ export function ImportExportRow() {
source,
}: {
file: File;
- source: "html" | "pocket";
+ 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");
}
@@ -213,6 +217,18 @@ export function ImportExportRow() {
<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 && (
diff --git a/apps/web/lib/exportBookmarks.ts b/apps/web/lib/exportBookmarks.ts
new file mode 100644
index 00000000..dd1913fb
--- /dev/null
+++ b/apps/web/lib/exportBookmarks.ts
@@ -0,0 +1,60 @@
+import { z } from "zod";
+
+import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
+
+export const zExportBookmarkSchema = z.object({
+ createdAt: z.number(),
+ title: z.string().nullable(),
+ tags: z.array(z.string()),
+ content: z
+ .discriminatedUnion("type", [
+ z.object({
+ type: z.literal(BookmarkTypes.LINK),
+ url: z.string(),
+ }),
+ z.object({
+ type: z.literal(BookmarkTypes.TEXT),
+ text: z.string(),
+ }),
+ ])
+ .nullable(),
+ note: z.string().nullable(),
+});
+
+export const zExportSchema = z.object({
+ bookmarks: z.array(zExportBookmarkSchema),
+});
+
+export function toExportFormat(
+ bookmark: ZBookmark,
+): z.infer<typeof zExportBookmarkSchema> {
+ let content = null;
+ switch (bookmark.content.type) {
+ case BookmarkTypes.LINK: {
+ content = {
+ type: bookmark.content.type,
+ url: bookmark.content.url,
+ };
+ break;
+ }
+ case BookmarkTypes.TEXT: {
+ content = {
+ type: bookmark.content.type,
+ text: bookmark.content.text,
+ };
+ break;
+ }
+ // Exclude asset types for now
+ }
+ return {
+ createdAt: Math.floor(bookmark.createdAt.getTime() / 1000),
+ title:
+ bookmark.title ??
+ (bookmark.content.type === BookmarkTypes.LINK
+ ? bookmark.content.title ?? null
+ : null),
+ tags: bookmark.tags.map((t) => t.name),
+ content,
+ note: bookmark.note ?? null,
+ };
+}
diff --git a/apps/web/lib/importBookmarkParser.ts b/apps/web/lib/importBookmarkParser.ts
index 1f80e5f4..3134af55 100644
--- a/apps/web/lib/importBookmarkParser.ts
+++ b/apps/web/lib/importBookmarkParser.ts
@@ -1,11 +1,16 @@
// Copied from https://gist.github.com/devster31/4e8c6548fd16ffb75c02e6f24e27f9b9
import * as cheerio from "cheerio";
+import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
+
+import { zExportSchema } from "./exportBookmarks";
+
export interface ParsedBookmark {
title: string;
url?: string;
tags: string[];
addDate?: number;
+ notes?: string;
}
export async function parseNetscapeBookmarkFile(
@@ -68,3 +73,27 @@ export async function parsePocketBookmarkFile(
})
.get();
}
+
+export async function parseHoarderBookmarkFile(
+ file: File,
+): Promise<ParsedBookmark[]> {
+ const textContent = await file.text();
+
+ const parsed = zExportSchema.safeParse(JSON.parse(textContent));
+ if (!parsed.success) {
+ throw new Error(
+ `The uploaded JSON file contains an invalid bookmark file: ${parsed.error.toString()}`,
+ );
+ }
+
+ return parsed.data.bookmarks.map((bookmark) => ({
+ title: bookmark.title ?? "",
+ url:
+ bookmark.content?.type == BookmarkTypes.LINK
+ ? bookmark.content.url
+ : undefined,
+ tags: bookmark.tags,
+ addDate: bookmark.createdAt,
+ notes: bookmark.note ?? undefined,
+ }));
+}
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index f4b4fd4a..50797f29 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -2,7 +2,7 @@ import { z } from "zod";
import { zBookmarkTagSchema } from "./tags";
-const MAX_TITLE_LENGTH = 100;
+const MAX_TITLE_LENGTH = 250;
export const enum BookmarkTypes {
LINK = "link",