| tags: [ KnowledgeManagement ] categories: [ OS ]
笔记工具
常用工具
推荐使用支持 Markdown 的 Obsidian、mdbook 和 Sphinx + MyST 插件,方便做成书籍样式的文档。Logseq 没有目录结构,还是不习惯,也没 WebDAV 等免费同步插件,明年新版 Logseq 要转 Datomic 数据库了,Markdown 不再是一等公民。TriliumNext 很不错,功能很强大,用 Sqlite 存 Markdown,但可惜没有移动端版本,Joplin 也用 Sqlite 存 Markdown,有移动应用,但功能偏少。思源功能还比较丰富,但文件格式是自定义的 JSON,而且作者有不少黑历史……至于 org-mode 和 Scribble,采用的文件格式太小众了。
- Org-mode
- Racket Scribble
- Logseq:知识管理工具
- TriliumNext Notes
- Joplin
- 思源笔记
- Wiki
- Markdown
- Obsidian: 知识管理工具
- MarkText:仅 Markdown 编辑器
- gitbook-cli: 已不维护
- gitbook: 本地运行的 Web 界面
- mdbook: 用 Rust 编写的 gitbook-cli 替代品
- Sphinx + MyST + Jupyter Book:扩展 Common Markdown,重心在 Python/Jupyter 社区
- Bookdown:基于 R Markdown 的 R 包,重心在 R/RStudio 社区
- Quarto:改进自 R Markdown,基于 Pandoc,使用 JavaScript 实现,Julia/Python/R 中立
- Mkdocs:功能和主题比较弱
- Astro:UI 框架中立,通用静态内容网站
- VitePress:依赖 Vue,通用静态内容网站
- VuePress:依赖 Vue,通用静态内容网站,基本停止开发
- Docusaurus:依赖 React,适合技术文档
- Nextra:依赖 Next.js 的静态内容网站,适合技术文档
- Rspress:使用 Rspack 和 React 的静态网站生成器
- Hugo book theme:模仿书籍样式的博客
- Hugo Hextra theme: 基于 Nextra
- Zola book theme: 模仿书籍样式的博客
Obsidian 插件
推荐如下插件:
- Remotely Save:同步到 WebDAV、S3、OneDrive、Dropbox、Google Drive 等存储;
- Dataview: 类 SQL 语法查询文档;
- Tasks:任务管理;
- Editing Toolbar:编辑界面增加可视化编辑工具栏;
- Mindmap Nextgen:以 Mindmap 形式展示 Markdown,改进自 Mind Map;
- Enhancing Mindmap:更强的闭源版本Markmind的开源版本;
- PlantUML:增加
plantuml
代码块,注意 Obsidian 内置支持mermaid
代码块,类似工具还有 https://www.websequencediagrams.com; - ExcaliDraw: 手绘风格的画图;
- Diagrams.net:DrawIO 画图;
可以用 Rclone、Caddy、Apache、Nginx 搭建 WebDAV 服务器,也可以用下面这段简单的 Golang 代码,注意没认证,只适合家用:
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"golang.org/x/net/webdav"
)
type SimpleFs struct {
root string
}
func (fs *SimpleFs) resolvePath(name string) (string, error) {
fullPath := filepath.Join(fs.root, name)
rel, err := filepath.Rel(fs.root, fullPath)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("Path '%s' is outside of root directory '%s'", name, fs.root)
}
return fullPath, nil
}
func (fs *SimpleFs) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
path, err := fs.resolvePath(name)
if err != nil {
return err
}
return os.MkdirAll(path, perm)
}
func (fs *SimpleFs) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
path, err := fs.resolvePath(name)
if err != nil {
return nil, err
}
return os.OpenFile(path, flag, perm)
}
func (fs *SimpleFs) RemoveAll(ctx context.Context, name string) error {
path, err := fs.resolvePath(name)
if err != nil {
return err
}
return os.RemoveAll(path)
}
func (fs *SimpleFs) Rename(ctx context.Context, oldName, newName string) error {
oldPath, err := fs.resolvePath(oldName)
if err != nil {
return err
}
newPath, err := fs.resolvePath(newName)
if err != nil {
return err
}
return os.Rename(oldPath, newPath)
}
func (fs *SimpleFs) Stat(ctx context.Context, name string) (os.FileInfo, error) {
path, err := fs.resolvePath(name)
if err != nil {
return nil, err
}
return os.Stat(path)
}
func main() {
var rootDir string
var port string
var showHelp bool
flag.StringVar(&rootDir, "root", "webdav-root", "Root directory for WebDAV server")
flag.StringVar(&rootDir, "r", "webdav-root", "Root directory for WebDAV server (shorthand)")
flag.StringVar(&port, "port", "8080", "Port to listen on")
flag.StringVar(&port, "p", "8080", "Port to listen on (shorthand)")
flag.BoolVar(&showHelp, "help", false, "Show help message")
flag.BoolVar(&showHelp, "h", false, "Show help message (shorthand)")
flag.Parse()
if showHelp {
flag.Usage()
os.Exit(0)
}
absRootDir, err := filepath.Abs(rootDir)
if err != nil {
log.Fatalf("Failed to get absolute path for '%s': %v", rootDir, err)
}
rootDir = absRootDir
if err := os.MkdirAll(rootDir, 0755); err != nil {
log.Fatalf("Failed to create root directory '%s': %v", rootDir, err)
}
fs := &SimpleFs{root: rootDir}
handler := &webdav.Handler{
FileSystem: fs,
LockSystem: webdav.NewMemLS(),
Prefix: "/",
}
http.Handle("/", handler)
fmt.Printf("Starting WebDAV server on :%s for root directory %s ...\n", port, rootDir)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
笔记组织
笔记使用 Markdown 格式编写,按目录分类存放,用脚本生成每个目录的 index.md
以及顶层的 SUMMARY.md
(mdbook 使用的目录信息),目录和文件的前缀[0-9]+[.-]?
用于排序,在生成的 index.md
和 SUMMARY.md
中会去掉这个前缀。
目录树:
/
├── Makefile # 由 sphinx-quickstart 生成
├── README.md
├── book.toml # mdbook 的配置文件
├── make.bat # 由 sphinx-quickstart 生成
├── pyproject.toml # uv 使用的 Python 项目文件
├── src
│ ├── 00语文
│ │ └── index.md
│ ├── 01数学
│ │ └── index.md
│ ├── 02外语
│ │ └── index.md
│ ├── 10计算机
│ │ └── index.md
│ ├── 11经济
│ │ └── index.md
│ ├── 20美术
│ │ └── index.md
│ ├── 21音乐
│ │ └── index.md
│ ├── SUMMARY.md # mdbook 的目录信息
│ ├── conf.py # sphinx 的配置文件
│ └── index.md
├── update-index.sh # 更新 index.md 和 SUMMARY.md 的脚本
└── uv.lock
book.toml:
# https://rust-lang.github.io/mdBook/format/configuration/index.html
[book]
authors = ["我的一家"]
language = "zh"
multilingual = false
src = "src"
title = "我的百科"
description = "欢迎来到「我的百科」,我的一家的知识笔记!"
[build]
create-missing = false
[output.html]
mathjax-support = true
[output.html.fold]
enable = true
pyproject.toml:
[project]
name = "MyWiki"
version = "0.1.0"
description = "欢迎来到「我的百科」,我的一家的知识笔记!"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"myst-parser>=4.0.0",
"sphinx-book-theme>=1.1.3",
"sphinx>=8.1.3",
"furo>=2024.8.6",
"jieba>=0.42.1",
]
[pip]
index-url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
[tool.uv]
index-url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
[tool.uv.pip]
index-url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
src/conf.py:
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = '我的百科'
copyright = '2024, 我的一家'
author = '我的一家'
release = '0.1.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ['myst_parser']
templates_path = ['_templates']
exclude_patterns = []
language = 'zh_CN'
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_title = project
html_theme = 'sphinx_book_theme'
#html_theme = 'furo'
html_static_path = ['_static']
update-index.sh:
#!/usr/bin/env bash
set -euo pipefail
shopt -s failglob
shopt -s extglob
PARTS=(基础知识 专业技能 人文艺术)
generate_discipline_indices() {
perl -CSD -Mutf8 -E '
use Encode qw/decode/;
use File::Basename;
use File::Find;
use autodie;
my %h;
find({
wanted => sub {
my $dir = decode("UTF-8", $File::Find::dir, Encode::FB_CROAK | Encode::LEAVE_SRC);
my $file = decode("UTF-8", $_, Encode::FB_CROAK | Encode::LEAVE_SRC);
$h{$dir}{$file} = "" if -f _ && $file =~ /(?<!^index)\.md$/;
},
postprocess => sub {
my $dir = decode("UTF-8", $File::Find::dir, Encode::FB_CROAK | Encode::LEAVE_SRC);
return unless exists $h{$dir};
my @files = sort keys %{ $h{$dir} };
open $fh, ">", "index.md";
print $fh "# 目录\n\n";
my $content = "";
my $toctree = "\n<div style=\"display: none\">\n\n```{toctree}\n" . ":hidden:\n\n";
for my $f (@files) {
my $name = $f;
if ($name =~ s/^[0-9]+[.-]?//) {
die "ERROR: found conflicted \"$dir/$name\" after trimming numbers!\n" if exists $h{$dir}{$name};
}
if ($h{$dir}{$f} eq "") {
$name =~ s/\.md$//;
$content .= "1. [$name]($f)\n";
$toctree .= "$f\n";
} else {
$content .= "1. [$name]($f/index.md)\n";
$content .= $h{$dir}{$f};
$toctree .= "$name <$f/index.md>\n";
}
}
$toctree .= "```\n\n</div>\n";
print $fh $content, $toctree;
close $fh;
delete $h{$dir};
my $parent = dirname($dir);
$dir = basename($dir);
$content =~ s/^/ /gm;
$content =~ s|\(|(\Q$dir\E/|g;
$h{$parent}{$dir} = $content;
},
}, @ARGV)' [0-9]*/
}
generate_home_index() {
cat <<EOF
# 我的百科
欢迎来到「我的百科」,我的一家的知识笔记!
EOF
for i in $(seq 0 2); do
echo
echo "## ${PARTS[$i]}"
echo
j=0
for f in $i*/index.md; do
dir=${f%/index.md}
name=${dir##+([0-9])?([.-])}
echo "$((++j)). [$name]($f)"
done
done
echo
echo '<div style="display: none">'
for i in $(seq 0 2); do
echo
echo '```{toctree}'
echo ":hidden:"
echo ":numbered:"
echo ":caption: ${PARTS[$i]}"
echo
for f in $i*/index.md; do
dir=${f%/index.md}
name=${dir##+([0-9])?([.-])}
echo "$name <$f>"
done
echo '```'
done
echo
echo "</div>"
}
generate_mdbook_summary() {
cat <<EOF
# SUMMARY
[首页](index.md)
EOF
for i in $(seq 0 2); do
echo
echo "# ${PARTS[$i]}"
echo
for f in $i*/index.md; do
dir=${f%/index.md}
name=${dir##+([0-9])?([.-])}
echo "1. [$name]($f)"
perl -CSDA -Mutf8 -nE "next unless /^\s*1\.\s/; s/^/ /; s|\(|(\Q$dir\E/|; print" $f
done
done
}
cd src
generate_discipline_indices
generate_home_index > index.md
generate_mdbook_summary > SUMMARY.md
构建步骤
mdbook
运行如下命令,输出在 book/
目录。
brew install mdbook
./update-index.sh
mdbook build
Sphinx
运行如下命令,输出在 build/
目录。
brew install uv
./update-index.sh
uv run make html
Gitlab Pages
.gitlab-ci.yml:
image: python:3.13-bookworm
# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Python.gitlab-ci.yml
variables:
MDBOOK_VERSION: 0.4.43
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
UV_CACHE_DIR: "$CI_PROJECT_DIR/.cache/uv"
# https://pip.pypa.io/en/stable/topics/caching/
cache:
paths:
- .cache/pip
- .cache/uv
before_script:
- python --version
- pip --version
- curl -LsSf https://astral.sh/uv/install.sh | sh
- curl -LsSf https://github.com/rust-lang/mdBook/releases/download/v$MDBOOK_VERSION/mdbook-v$MDBOOK_VERSION-x86_64-unknown-linux-gnu.tar.gz | tar xvzf -
- ./mdbook --version
- ~/.local/bin/uv --version
pages:
script:
- ./update-index.sh
- ./mdbook build
- sed -i -e 's/^index-url/#index-url/' pyproject.toml
- rm -f uv.lock
- ~/.local/bin/uv run make html
- ~/.local/bin/uv cache prune --ci
- mv build/html public
- mv book public/
# https://docs.gitlab.com/ee/user/project/pages/introduction.html#serving-compressed-assets
- gzip -f -k -r -v public
artifacts:
paths:
- public
rules:
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH