aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/api/bookmarks/export/route.tsx56
-rw-r--r--apps/web/components/settings/ImportExport.tsx23
-rw-r--r--apps/web/lib/exportBookmarks.ts49
3 files changed, 112 insertions, 16 deletions
diff --git a/apps/web/app/api/bookmarks/export/route.tsx b/apps/web/app/api/bookmarks/export/route.tsx
index e550fcb5..f568b9f7 100644
--- a/apps/web/app/api/bookmarks/export/route.tsx
+++ b/apps/web/app/api/bookmarks/export/route.tsx
@@ -1,15 +1,23 @@
-import { toExportFormat, zExportSchema } from "@/lib/exportBookmarks";
+import { NextRequest } from "next/server";
+import {
+ toExportFormat,
+ toNetscapeFormat,
+ zExportSchema,
+} from "@/lib/exportBookmarks";
import { api, createContextFromRequest } from "@/server/api/client";
import { z } from "zod";
import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
export const dynamic = "force-dynamic";
-export async function GET(request: Request) {
+export async function GET(request: NextRequest) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+
+ const format = request.nextUrl.searchParams.get("format") ?? "json";
+
const req = {
limit: MAX_NUM_BOOKMARKS_PER_PAGE,
useCursorV2: true,
@@ -17,25 +25,43 @@ export async function GET(request: Request) {
};
let resp = await api.bookmarks.getBookmarks(req);
- let results = resp.bookmarks.map(toExportFormat);
+ let bookmarks = resp.bookmarks;
while (resp.nextCursor) {
resp = await api.bookmarks.getBookmarks({
- ...request,
+ ...req,
cursor: resp.nextCursor,
});
- results = [...results, ...resp.bookmarks.map(toExportFormat)];
+ bookmarks = [...bookmarks, ...resp.bookmarks];
}
- const exportData: z.infer<typeof zExportSchema> = {
- bookmarks: results.filter((b) => b.content !== null),
- };
+ if (format === "json") {
+ // Default JSON format
+ const exportData: z.infer<typeof zExportSchema> = {
+ bookmarks: bookmarks
+ .map(toExportFormat)
+ .filter((b) => b.content !== null),
+ };
- return new Response(JSON.stringify(exportData), {
- status: 200,
- headers: {
- "Content-type": "application/json",
- "Content-disposition": `attachment; filename="karakeep-export-${new Date().toISOString()}.json"`,
- },
- });
+ return new Response(JSON.stringify(exportData), {
+ status: 200,
+ headers: {
+ "Content-type": "application/json",
+ "Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`,
+ },
+ });
+ } else if (format === "netscape") {
+ // Netscape format
+ const netscapeContent = toNetscapeFormat(bookmarks);
+
+ return new Response(netscapeContent, {
+ status: 200,
+ headers: {
+ "Content-type": "text/html",
+ "Content-disposition": `attachment; filename="bookmarks-${new Date().toISOString()}.html"`,
+ },
+ });
+ } else {
+ return Response.json({ error: "Invalid format" }, { status: 400 });
+ }
}
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx
index 43b934a6..e2678bbc 100644
--- a/apps/web/components/settings/ImportExport.tsx
+++ b/apps/web/components/settings/ImportExport.tsx
@@ -6,6 +6,13 @@ 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 {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import {
@@ -63,6 +70,8 @@ function ImportCard({
function ExportButton() {
const { t } = useTranslation();
+ const [format, setFormat] = useState<"json" | "netscape">("json");
+
return (
<Card className="transition-all hover:shadow-md">
<CardContent className="flex items-center gap-3 p-4">
@@ -72,9 +81,21 @@ function ExportButton() {
<div className="flex-1">
<h3 className="font-medium">Export File</h3>
<p>{t("settings.import.export_links_and_notes")}</p>
+ <Select
+ value={format}
+ onValueChange={(value) => setFormat(value as "json" | "netscape")}
+ >
+ <SelectTrigger className="mt-2 w-[180px]">
+ <SelectValue placeholder="Format" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="json">JSON (Karakeep format)</SelectItem>
+ <SelectItem value="netscape">HTML (Netscape format)</SelectItem>
+ </SelectContent>
+ </Select>
</div>
<Link
- href="/api/bookmarks/export"
+ href={`/api/bookmarks/export?format=${format}`}
className={cn(
buttonVariants({ variant: "default", size: "sm" }),
"flex items-center gap-2",
diff --git a/apps/web/lib/exportBookmarks.ts b/apps/web/lib/exportBookmarks.ts
index 45db104f..67b0b5da 100644
--- a/apps/web/lib/exportBookmarks.ts
+++ b/apps/web/lib/exportBookmarks.ts
@@ -58,3 +58,52 @@ export function toExportFormat(
note: bookmark.note ?? null,
};
}
+
+export function toNetscapeFormat(bookmarks: ZBookmark[]): string {
+ const header = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
+<!-- This is an automatically generated file.
+ It will be read and overwritten.
+ DO NOT EDIT! -->
+<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
+<TITLE>Bookmarks</TITLE>
+<H1>Bookmarks</H1>
+<DL><p>`;
+
+ const footer = `</DL><p>`;
+
+ const bookmarkEntries = bookmarks
+ .map((bookmark) => {
+ if (bookmark.content?.type !== BookmarkTypes.LINK) {
+ return "";
+ }
+ const addDate = bookmark.createdAt
+ ? `ADD_DATE="${Math.floor(bookmark.createdAt.getTime() / 1000)}"`
+ : "";
+
+ const tagNames = bookmark.tags.map((t) => t.name).join(",");
+ const tags = tagNames.length > 0 ? `TAGS="${tagNames}"` : "";
+
+ const encodedUrl = encodeURI(bookmark.content.url);
+ const displayTitle = bookmark.title ?? bookmark.content.url;
+ const encodedTitle = escapeHtml(displayTitle);
+
+ return ` <DT><A HREF="${encodedUrl}" ${addDate} ${tags}>${encodedTitle}</A>`;
+ })
+ .filter(Boolean)
+ .join("\n");
+
+ return `${header}\n${bookmarkEntries}\n${footer}`;
+}
+
+function escapeHtml(input: string): string {
+ const escapeMap: Record<string, string> = {
+ "&": "&amp;",
+ "'": "&#x27;",
+ "`": "&#x60;",
+ '"': "&quot;",
+ "<": "&lt;",
+ ">": "&gt;",
+ };
+
+ return input.replace(/[&'`"<>]/g, (match) => escapeMap[match] || "");
+}