diff options
| -rw-r--r-- | apps/web/app/api/bookmarks/export/route.tsx | 56 | ||||
| -rw-r--r-- | apps/web/components/settings/ImportExport.tsx | 23 | ||||
| -rw-r--r-- | apps/web/lib/exportBookmarks.ts | 49 |
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> = { + "&": "&", + "'": "'", + "`": "`", + '"': """, + "<": "<", + ">": ">", + }; + + return input.replace(/[&'`"<>]/g, (match) => escapeMap[match] || ""); +} |
