aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/lib/exportBookmarks.ts
blob: 5dc26e786132cfa35cea33d9e7460e98aa3b6c90 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
import { z } from "zod";

import { BookmarkTypes, ZBookmark } from "@karakeep/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(),
  archived: z.boolean().optional().default(false),
});

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,
    archived: bookmark.archived,
  };
}

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] || "");
}