From c03dcfdbbc5a99abdb7517a03482bccf875d1953 Mon Sep 17 00:00:00 2001 From: Yuiki Saito Date: Mon, 12 May 2025 00:11:07 +0900 Subject: feat: Add NETSCAPE-Bookmark-file-1 export format support (#1374) * Add function to export bookmarks in NETSCAPE-Bookmark-file-1 format * Update export endpoint to support NETSCAPE format * Add format selection to export UI * include tags in the export --------- Co-authored-by: Mohamed Bassem --- apps/web/app/api/bookmarks/export/route.tsx | 56 ++++++++++++++++++++------- apps/web/components/settings/ImportExport.tsx | 23 ++++++++++- apps/web/lib/exportBookmarks.ts | 49 +++++++++++++++++++++++ 3 files changed, 112 insertions(+), 16 deletions(-) (limited to 'apps') 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 = { - bookmarks: results.filter((b) => b.content !== null), - }; + if (format === "json") { + // Default JSON format + const exportData: z.infer = { + 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 ( @@ -72,9 +81,21 @@ function ExportButton() {

Export File

{t("settings.import.export_links_and_notes")}

+
+ + +Bookmarks +

Bookmarks

+

`; + + const footer = `

`; + + 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 `

${encodedTitle}`; + }) + .filter(Boolean) + .join("\n"); + + return `${header}\n${bookmarkEntries}\n${footer}`; +} + +function escapeHtml(input: string): string { + const escapeMap: Record = { + "&": "&", + "'": "'", + "`": "`", + '"': """, + "<": "<", + ">": ">", + }; + + return input.replace(/[&'`"<>]/g, (match) => escapeMap[match] || ""); +} -- cgit v1.2.3-70-g09d2