Ilence Ye

给 Olinkics 添加导出书签功能

简单记录下我如何给 Olinkics 添加导出书签功能。

生成文件内容

关于导出书签,第一个问题是要把书签以什么样的形式导出。

带着这个问题,我挨个尝试了几个浏览器以及书签管理应用的导出功能,发现它们基本上都提供一个导出为 .html 文件的功能。打开它们导出的文件,我发现它们基本上都长这样:

html
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file. Do not edit! -->
<HTML>
<HEAD>
  <TITLE>Bookmarks</TITLE>
</HEAD>
<BODY>
  <H1>Bookmarks</H1>
  <DL><p>
    <DT><A HREF="http://www.example.com" ADD_DATE="1624936262" LAST_MODIFIED="1624936262">Example Website</A>
    <DT><A HREF="http://www.anotherexample.com" ADD_DATE="1624936300" LAST_MODIFIED="1624936300">Another Example</A>
    <DT><H3>Folder</H3>
    <DL><p>
      <DT><A HREF="http://www.folderlink.com" ADD_DATE="1624936350">Folder Link</A>
    </DL><p>
  </DL><p>
</BODY>
</HTML>

这是一个称之为 NETSCAPE-Bookmark-file-1 的文件格式,主要用来导入和导出用户的浏览器书签。它最早由 Netscape 浏览器使用 (从名字里也可以看出来),而其他浏览器也基本支持这种格式。

  • <A HREF="URL"> 用于表示一个网页书签,HREF 是书签链接的地址。ADD_DATELAST_MODIFIED 是书签的时间戳,表示该书签何时添加和最后一次修改。

那我只要从数据库里拿到书签的数据,然后转换成这种形式就成,代码如下:

ts
type ExportedBookmark = {
  url: string;
  title: string | null;
  tags: string[] | null;
  note: string | null;
  created_at: string;
  updated_at: string;
};

export function generateNetscapeBookmarkFile(bookmarks: ExportedBookmark[]) {
  let bookmarkFileContent = `
  <!DOCTYPE NETSCAPE-Bookmark-file-1>
  <!-- This is an automatically generated file. Do not edit! -->
  <META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
  <TITLE>Bookmarks</TITLE>
  <H1>Bookmarks</H1>
  <DL><p>
  `;

  bookmarks.forEach((bookmark) => {
    const add = Math.floor(new Date(bookmark.created_at).getTime() / 1000);
    const update = Math.floor(new Date(bookmark.updated_at).getTime() / 1000);
    const tags = bookmark.tags?.join(",");
    
    bookmarkFileContent += `
    <DT><A HREF="${bookmark.url}" ADD_DATE="${add}" LAST_MODIFIED="${update}" TAGS="${tags}">${bookmark.title}</A>
    `;
  });

  bookmarkFileContent += "</DL><p>";
  
  return bookmarkFileContent;
}

下载文件

解决了文件内容的问题,下一个问题是:如何把这些内容写入一个文件,然后下载到用户的计算机里。

我问了下 ChatGPT,它给了我下面的代码:

ts
export function download(content: string, filename: string) {
  const blob = new Blob([content], { type: "text/html" });
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = filename;
  a.click();
}

这里有三个关键点,一是 Blob 对象,二是 URL.createObjectURL() 方法,三是 <a> 标签。

Blob 对象,我们可以用它来存储字符串或二进制数据。

  • 它的一个关键特性是,一般是做为一个原始的数据容器存在,通过链接或其他方式来访问。

URL.createObjectURL() 方法:

  • 通过这个方法,浏览器可以将一个 Blob 对象转换成一个 URL。但要注意,这个 URL 是临时的,只在当前页面的生命周期内有效。
  • 举个例子,URL.createObjectURL(blob) 会生成一个类似于 blob:http://example.com/abc123 这样的 URL,它实际上指向了内存中保存的 Blob 对象。这意味着浏览器可以通过这个 URL 加载 Blob 内容,就像访问普通的文件 URL 一样。

关于 <a> 标签,我们通常用它来定义超链接。通常来说,当我们点击一个超链接时,浏览器会打开这个超链接指向的网页或文件。然而, <a> 标签还有一个 download 属性,它告诉浏览器,当用户点击该链接,应该下载链接指向的资源,而不是在浏览器中打开它。

  • a.href = URL.createObjectURL(blob); - 这行代码为 <a> 标签设置了一个指向 Blob 对象的 URL。
  • a.download = filename; - 这行代码为该链接指定了下载的文件名。
  • a.click(); 则模拟了用户点击这个下载链接的行为,从而触发文件的下载。

当执行 a.click() 时,浏览器会:

  • 读取 href 指定的 URL。由于 URL 指向的是一个 Blob 对象,浏览器会读取这个对象里的内容,也就是我们一开始传入的 content
  • 由于指定了 download 属性,所以浏览器会根据该属性指定的文件名下载文件,而不是打开它。

FIN

OK,到此难点全部搞定,可以开始写 UI 了。这里简单演示一下。

tsx
async fetchBookmarks() {
	// ...
}

export function ExportButton() {
  const handleExport = async () => {
    const data = await fetchBookmarks();
    const text = generateNetscapeBookmarkFile(data);
    download(text, "bookmarks.html");
  };

  return <button onClick={handleExport}>Export</button>;
}