从 WizNote 为知笔记到 Joplin(下)
文章目录
从 WizNote 为知笔记到 Joplin(上) 一文中讲到了我为什么要从为知笔记转到 Joplin。本文讲一讲其中的技术细节。
wiz2joplin 项目是开源的,我在源码中写的注释也很详细,所以本文就不列举所有实现,而是主要讲一下设计思路和需要关注的问题。文中标注了报名和函数名称,方便大家在 wiz2joplin 项目中寻找对应源码查看。
要理解下面讲述的细节,请先阅读:WizNote 为知笔记 macOS 版本本地文件夹分析 。
读取为知笔记
- 从为知笔记本地数据库中读取为知笔记。
- 读取为知笔记的目录信息,在为知笔记中称为 location。
- 读取为知笔记的 TAG 信息。
- 解压缩为知笔记每个文档的压缩包到临时文件夹:
w2j.wiz.WizDocument._extract_zip
- 解析为知笔记文档源码中的内嵌的图像资源、内链和附件:
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&kbguid=&private_kbguid=69899a48-dc52-11e0-892c-00237def97cc
14RE_OPEN_DOCUMENT_HREF = r'wiz:/{0,2}(open_\w+)\?guid=([a-z0-9\-]{36})&kbguid=&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 来渲染。
在同步到 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 同步的方式,配置简单,稳定性更有保证。
引用
- WizNote 为知笔记 macOS 版本本地文件夹分析
- 从 WizNote 为知笔记到 Joplin(上)
- wiz2joplin 转换 WinzNote 到 Joplin 的开源工具
- inscriptis Python 下的 HTML 渲染引擎
- 文章ID:2748
- 原文作者:zrong
- 原文链接:https://blog.zengrong.net/post/wiznote2joplin2/
- 版权声明:本作品采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可,非商业转载请注明出处(原文作者,原文链接),商业转载请联系作者获得授权。