常用工具

推荐使用支持 Markdown 的 Obsidian、mdbook 和 Sphinx + MyST 插件,方便做成书籍样式的文档。Logseq 没有目录结构,还是不习惯,也没 WebDAV 等免费同步插件,明年新版 Logseq 要转 Datomic 数据库了,Markdown 不再是一等公民。TriliumNext 很不错,功能很强大,用 Sqlite 存 Markdown,但可惜没有移动端版本,Joplin 也用 Sqlite 存 Markdown,有移动应用,但功能偏少。思源功能还比较丰富,但文件格式是自定义的 JSON,而且作者有不少黑历史……至于 org-mode 和 Scribble,采用的文件格式太小众了。

Obsidian 插件

推荐如下插件:

  1. Remotely Save:同步到 WebDAV、S3、OneDrive、Dropbox、Google Drive 等存储;
  2. Dataview: 类 SQL 语法查询文档;
  3. Tasks:任务管理;
  4. Editing Toolbar:编辑界面增加可视化编辑工具栏;
  5. Mindmap Nextgen:以 Mindmap 形式展示 Markdown,改进自 Mind Map
  6. Enhancing Mindmap:更强的闭源版本Markmind的开源版本;
  7. PlantUML:增加 plantuml 代码块,注意 Obsidian 内置支持 mermaid 代码块,类似工具还有 https://www.websequencediagrams.com
  8. ExcaliDraw: 手绘风格的画图;
  9. Diagrams.net:DrawIO 画图;

可以用 RcloneCaddyApacheNginx 搭建 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.mdSUMMARY.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