从 WizNote 为知笔记到 Joplin(下)

文章目录

从 WizNote 为知笔记到 Joplin(上) 一文中讲到了我为什么要从为知笔记转到 Joplin。本文讲一讲其中的技术细节。

wiz2joplin 项目是开源的,我在源码中写的注释也很详细,所以本文就不列举所有实现,而是主要讲一下设计思路和需要关注的问题。文中标注了报名和函数名称,方便大家在 wiz2joplin 项目中寻找对应源码查看。

要理解下面讲述的细节,请先阅读:WizNote 为知笔记 macOS 版本本地文件夹分析

读取为知笔记

  1. 从为知笔记本地数据库中读取为知笔记。
  2. 读取为知笔记的目录信息,在为知笔记中称为 location。
  3. 读取为知笔记的 TAG 信息。
  4. 解压缩为知笔记每个文档的压缩包到临时文件夹: w2j.wiz.WizDocument._extract_zip
  5. 解析为知笔记文档源码中的内嵌的图像资源、内链和附件: w2j.parser.parse_wiz_html

整理数据

为知笔记和 Joplin 中有一些相同的部分,也有一些不同的部分。我们在整理数据的时候,需要将它们进行一一对应。

1. 为知笔记的 document 有自己的 guid,Joplin 也使用同样的 guid,两者都是 32 个字符,但为知笔记采用了标准的 8-4-4-4-12 格式,而 Joplin 去掉了分隔符。只需要写两个简单的函数进行转换即可:

 1def towizid(id: str) -> str:
 2    """ 从 joplin 的 id 格式转为 wiz 的 guid 格式
 3    """
 4    one = id[:8]
 5    two = id[8:12]
 6    three = id[12:16]
 7    four = id[16:20]
 8    five = id[20:]
 9    return '-'.join([one, two, three, four, five])
10
11
12def tojoplinid(guid: str) -> str:
13    """ 从 wiz 的 guid 格式转为 joplin 的 id 格式
14    """
15    return ''.join(guid.split('-'))

2. 为知笔记的 TAG 和附件都拥有自己的 GUID,这与 Joplin 的 resource 的 GUID 可以进行一一对应。

3. 为知笔记的文档中的内嵌图像没有 GUID,为知笔记的目录也没有 GUID,但 Joplin 中的内嵌图像属于标准资源,有自己的 GUID,Joplin 中的 notebook/folder 也拥有自己的 GUID。

4. 为知笔记的内链有附件内链和文档内链两种格式,使用正则表达式来提取其中的 GUID 部分:

 1RE_A_START = r'<a href="'
 2RE_A_END = r'">([^<]+)</a>'
 3
 4# 附件内链
 5# 早期的链接没有双斜杠
 6# wiz:open_attachment?guid=8337764c-f89d-4267-bdf2-2e26ff156098
 7# 后期的链接有双斜杠
 8# wiz://open_attachment?guid=52935f17-c1bb-45b7-b443-b7ba1b6f854e
 9RE_OPEN_ATTACHMENT_HREF = r'wiz:/{0,2}(open_\w+)\?guid=([a-z0-9\-]{36})'
10RE_OPEN_ATTACHMENT_OUTERHTML = RE_A_START + RE_OPEN_ATTACHMENT_HREF + RE_A_END
11
12# 文档内链,只需要提取 guid 后面的部分即可
13# wiz://open_document?guid=c6204f26-f966-4626-ad41-1b5fbdb6829e&amp;kbguid=&amp;private_kbguid=69899a48-dc52-11e0-892c-00237def97cc
14RE_OPEN_DOCUMENT_HREF = r'wiz:/{0,2}(open_\w+)\?guid=([a-z0-9\-]{36})&amp;kbguid=&amp;private_kbguid=([a-z0-9\-]{36})'
15RE_OPEN_DOCUMENT_OUTERHTML = RE_A_START + RE_OPEN_DOCUMENT_HREF + RE_A_END

在读取为知笔记文档源码内容的时候还碰到一个问题,就是早期的为知笔记版本采用了 UTF16 编码。如果使用默认的 UTF8 来读取就会报错。此时应该先检测笔记源码的编码再读取。这里的检测使用第三方库 chardet 完成。

 1index_html = note_extract_dir.joinpath('index.html')
 2if not index_html.is_file:
 3    raise FileNotFoundError(f'主文档文件不存在! {index_html} |{title}|')
 4html_body_bytes = index_html.read_bytes()
 5# 早期版本的 html 文件使用的是 UTF-16 LE(BOM) 编码保存。最新的文件是使用 UTF-8(BOM) 编码保存。要判断编码进行解析
 6enc = chardet.detect(html_body_bytes)
 7html_body = html_body_bytes.decode(encoding=enc['encoding'])
 8
 9# 去掉换行符,早期版本的 html 文件使用了 \r\n 换行符,而且会切断 html 标记。替换掉换行符方便正则
10html_body = html_body.replace('\r\n', '')
11html_body = html_body.replace('\n', '')

5. 为知笔记中的图片在文档源码中使用的是 img 标签,使用正则表达式提取:

1# 图像文件在 body 中存在的形式,即使是在 .md 文件中,也依然使用这种形式存在
2RE_IMAGE_OUTERHTML = r'<img .*?src="(index_files/[^"]+)"[^>]*>'

6. 上面解析出来的内链资源和附件资源,都会在 Joplin 中转换成同一种形式: [Title](:/GUID),image 资源则会转换成 ![Title](:/GUID) 形式。

临时数据库

由于部分的为知笔记资源在 Joplin 中没有对应的 GUID,必须将这些资源上传到 Joplin 才能取得 GUID,为了避免整个转换过程的中断导致重头来过(毕竟有 3000 篇),我在转换过程中建立了一个临时数据库,将转换过程写入到数据库中,下次中断的时候,就可以从数据库中取得转换状态了。

下面是数据库的定义:

 1CREATE_SQL: dict[str, str] = {
 2    # 保存 Location 和 Folder 的关系
 3    'l2f': """CREATE TABLE l2f (
 4            location TEXT NOT NULL,
 5            id TEXT,
 6            title TEXT NOT NULL,
 7            parent_location TEXT,
 8            parent_id TEXT,
 9            level INTEGER NOT NULL,
10            PRIMARY KEY (location)
11        );""",
12    # 处理过的文档会保存在这里,在这个表中能找到的文档说明已经转换成功了
13    'note': """CREATE TABLE note (
14            note_id TEXT not NULL,
15            title TEXT not NULL,
16            joplin_folder TEXT NOT NULL,
17            markup_language INTEGER NOT NULL,
18            wiz_location TEXT NOT NULL,
19            PRIMARY KEY (note_id)
20        );""",
21    # 处理过的资源保存在这里,包括 image 和 attachment 资源
22    'resource': """CREATE TABLE resource (
23            resource_id TEXT not NULL,
24            title TEXT NOT NULL,
25            filename TEXT NOT NULL,
26            created_time INTEGER not NULL,
27            resource_type INTEGER NOT NULL,
28            PRIMARY KEY (resource_id)
29        );""",
30    # 保存为知笔记中的内链,也就是 resource 与 note 的关系,使用 文档 guid 和 连接目标 guid 同时作为主键。链接目标 guid 为 joplin 格式
31    'internal_link': """
32        CREATE TABLE internal_link (
33            note_id TEXT not NULL,
34            resource_id TEXT not NULL,
35            title TEXT not NULL,
36            link_type TEXT NOT NULL,
37            PRIMARY KEY (note_id, resource_id)
38        );
39        CREATE INDEX idx_link_type ON internal_link (link_type);
40        CREATE INDEX idx_resource_id ON internal_link (resource_id);
41        """,
42    # 保存为知笔记中的 tag
43    'tag': """
44        CREATE TABLE tag (
45            tag_id TEXT not NULL,
46            title TEXT not NULL,
47            created_time INTEGER not NULL,
48            updated_time INTEGER not NULL,
49            PRIMARY KEY (tag_id)
50        );
51        CREATE UNIQUE INDEX idx_title ON tag (title);
52    """,
53    # 保存tag 与note 的关系
54    'note_tag': """CREATE TABLE note_tag (
55        note_id TEXT not NULL,
56        tag_id TEXT not NULL,
57        title TEXT not NULL,
58        created_time INTEGER not NULL,
59        PRIMARY KEY (note_id, tag_id)
60    );""",
61}

使用 Python 自带的 sqlite3 来创建临时数据库。

上传到 Jopin

1. 同步为知笔记的目录到 Joplin: w2j.adapter.Adapter.sync_folders 以及 w2j.joplin.JoplinDataAPI.post_folder

2. 同步为知笔记的附件和内嵌图像: w2j.adapter.Adapter._upload_wiz_attachment 以及 w2j.adapter.Adapter._upload_wiz_image

3. 同步笔记正文内容到 Joplin: w2j.adapter.Adapter.sync_all 以及 w2j.adapter.Adapter._sync_note

为知笔记的文档有两种,一种标题以 .md 结尾的,为知笔记会将其作为 Markdown 格式来渲染,另一种不带 .md 后缀的就作为 HTML 来渲染。

这里说点题外话:

使用 .md 作为标题后缀,我不知道老魏是处于一个什么样的考量,但我肯定这不是一个优雅的解决方案。

尽管 Markdown 是在为知笔记出现之后才流行起来的,尽管为知笔记运行这么多年可能有一些历史包袱,但面对一个已经如此流行的技术,采用了这样一种「近乎于无厘头」的解决方案,反映出为知笔记团队「懒于深入思考」的现状。

在我分析为知笔记本地数据的时候,经常会碰到这种「无厘头」的折衷方案。 例如:

  1. 前后不一的笔记文本编码,之前用 UTF16,后面改为 UTF8.
  2. 设计混乱的内链方式, wiz:open_attachmentwiz://open_attachment
  3. 拼写错误的数据库列名。

其实只要多花一些思考的时间,这些问题都很容易被优雅地解决。

在同步到 Joplin 的时候,需要区分这两种情况。为知笔记中保存的 .md 文章是一种很奇怪的格式:既不是纯 Markdown,也不是纯 HTML,而是使用 HTML 作为排版,包含纯 Markdown 内容。

需要调用 HTML 渲染引擎来处理,将其中用于格式分隔(一般是 div/p/br)等等渲染成实际在 HTML 中的表现,但保持 Markdown 源码不变。

我找到的最好的 Python 渲染引擎 :inscriptis

下面的 get_text 方法就是这套渲染引擎中提供的。

 1def gen_ilstr(is_markdown: bool, jil: JoplinInternalLink) -> str:
 2    """ 返回被替换的内链
 3    ilstr = internal link str
 4    """
 5    if is_markdown:
 6        body = f'[{jil.title}](:/{jil.resource_id})'
 7        if jil.link_type == 'image':
 8            return '!' + body
 9        return body
10    if jil.link_type == 'image':
11        return f'<img src=":/{jil.resource_id}" alt="{jil.title}">'
12    return f'<a href=":/{jil.resource_id}">{jil.title}</a>'
13
14
15def gen_end_ilstr(is_markdown: bool, jils: list[JoplinInternalLink]):
16    """ 返回 body 底部要加入的内容
17    ilstr = internal link str
18    """
19    if is_markdown:
20        return '\n\n# 附件链接\n\n' + '\n'.join([ '- ' + gen_ilstr(is_markdown, jil) for jil in jils])
21    body = ''.join([ f'<li>{gen_ilstr(is_markdown, jil)}</li>' for jil in jils])
22    return f'<br><br><h1>附件链接</h1><ul>{body}</ul>'
23    
24
25def convert_joplin_body(body: str, is_markdown: bool, internal_links: list[JoplinInternalLink]) -> str:
26    """ 将为知笔记中的 body 转换成 Joplin 内链
27    """
28    insert_to_end: list[JoplinInternalLink] = []
29    for jil in internal_links:
30        # 替换链接
31        if jil.outertext:
32            body = body.replace(jil.outertext, gen_ilstr(is_markdown, jil))
33        # 所有的附件,需要在body 底部加入链接
34        if jil.link_type == 'open_attachment':
35            insert_to_end.append(jil)
36    # 处理 markdown 转换
37    if is_markdown:
38        body = get_text(body)
39    if insert_to_end:
40        body += gen_end_ilstr(is_markdown, insert_to_end)
41    return body

最后,关于同步到 JoplinDataAPI 的正文内容,Joplin 文档讲解得并不详细。我通过抓包 Joplin WebClipper 得到了隐藏的参数。

在将正文提交到 Joplin 的时候,通过这样的参数配置,就能让 Joplin 自动转换 HTML 到 Markdown。效果还挺不错的。

  • body_html 正文内容。
  • convert_to 若值为 markdown 代表将 HTML 转换成 Markdown,若值为 html 则不转换。
  • source_command 若值为 {'name': 'simplifiedPageHtml'} 则设置成简单转换。

下面是更详细的说明。

 1def post_note(self, id: str, title: str, body: str, 
 2    is_markdown: bool, parent_id: str, source_url: str) -> JoplinNote:
 3    """ 创建一个新的 Note
 4    隐藏的 Joplin 参数:通过抓包 Joplin WebClipper
 5    
 6    complete Page Html
 7    source_command
 8    {
 9        'name': 'completePageHtml',
10        'preProcessFor': 'html'
11    }
12    convert_to = html
13
14    simplified Page Html
15    source_command
16    {
17        'name': 'simplifiedPageHtml',
18    }
19    convert_to = markdown
20
21    complete page
22    source_command = markdown
23    {
24        'name': 'completePageHtml',
25        'preProcessFor': 'markdown'
26    }
27    convert_to = markdown
28    """
29    kwargs = {
30        'id': id,
31        'title': title,
32        'parent_id': parent_id,
33        'markup_language': 1,
34    }
35    if source_url:
36        kwargs['source_url'] = source_url
37    if is_markdown:
38        kwargs['body'] = body
39    else:
40        # 使用 joplin 的功能将所有的 html 都转换成 markdown
41        kwargs['body_html'] = body
42        kwargs['convert_to'] = 'markdown'
43        kwargs['source_command'] = {
44            'name': 'simplifiedPageHtml',
45        }
46
47    query = self._build_query()
48    logger.info(f'向 Joplin 增加 note {kwargs}')
49    resp = self.client.post('/notes', params=query, json=kwargs)
50    data = resp.json()
51    if data.get('error'):
52        logger.error(data['error'])
53        raise ValueError(data['error'])
54    return JoplinNote(**data)

全部的重点就在这里了,希望对你有所帮助。

更多细节在源码中,欢迎访问 wiz2joplin 项目以了解更多信息。

设置 Joplin 同步

下面两篇文章详细介绍了 Joplin 同步配置。有了同步功能,笔记软件才完整。建议非程序员使用腾讯云 COS 同步的方式,配置简单,稳定性更有保证。

引用

全文完