Skip to content

Commit

Permalink
feature: Allow importing hoarder's own bookmark file. Fixes #527
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Oct 13, 2024
1 parent 2ccc15e commit de9cf0a
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 46 deletions.
55 changes: 13 additions & 42 deletions apps/web/app/api/bookmarks/export/route.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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"`,
},
);
});
}
22 changes: 19 additions & 3 deletions apps/web/components/dashboard/settings/ImportExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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([
Expand All @@ -81,6 +82,7 @@ export function ImportExportRow() {
createdAt: bookmark.addDate
? new Date(bookmark.addDate * 1000)
: undefined,
note: bookmark.notes,
}).catch(() => {
/* empty */
})
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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 && (
Expand Down
60 changes: 60 additions & 0 deletions apps/web/lib/exportBookmarks.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
29 changes: 29 additions & 0 deletions apps/web/lib/importBookmarkParser.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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,
}));
}
2 changes: 1 addition & 1 deletion packages/shared/types/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit de9cf0a

Please sign in to comment.