Daydreaming in Brookline, MA

org-mode + github Wikiのインデックスを自動生成する

1 はじめに

前回の記事で、emacsのorg-modeとgithub(/gitlab)でプライベートなWikiを作る提案をしましたが、各ドキュメント(.org ファイル)をカテゴリ分けしてリンクするトップページ(README.orgファイル)の手動作成が課題でした。これまで作成したメモをいくつかプライベートWikiに追加したのですが、それに伴うリンクの手動でのメンテナンスが相当に面倒であることがわかりました。

2 orgidx.py

というわけで、Pythonスクリプトの orgidx.py を作成し、githubで 公開 しました。これは、各 .org ファイルへのリンクを、サブディレクトリ単位でカテゴリー分けして並べたトップページ(README.org)を自動生成するスクリプトです。カテゴリーやリンクとして表示するテキストを付けることができます。

2.1 使い方

  1. Wiki用ディレクトリを用意する(eg, /home/jon/wiki)
  2. その下にカテゴリー(のキー)となるサブディレクトリを作成する
  3. .org ファイルをそれぞれのカテゴリーになるサブディレクトリ下に置く
    • .org ファイルに #+TITLE: メタデータを用意してください
    • #+SUBTITLE: メタデータはオプションで、その.orgファイルの説明などに使ってください。複数の #+SUBTITLE: 行を持つことが出来ます。無くても構いません。
  4. Python 3.9以降をインストールする
    • pip によるライブラリ等の追加は不要です
  5. 好きな場所に orgidx.py を置く
  6. Wiki用の先頭ディテクトリに config.json (下記参照)を用意する。
    • これはしなくてもよいですが、 categ_dict に無いサブディレクトリ名は、そのままカテゴリー文字列として使用されます。
  7. スクリプトを実行する(eg, python orgidx.py /home/jon/wiki)
  8. README.org がトップWikiディレクトリ下に作られる

2.2 ポイント

  • 各リンクに使われる文字列は #+TITLE: メタデータを使います。各 .org ファイルに必ず用意してください
  • +SUBTITLE: メタデータは.orgファイルの説明用です。適宜使用すると使いやすくなると思います。タイトルだけだと何のファイルなのか忘れてしまうことが(私の場合)よくあるので。。。
  • config.json をうまく用意すると、とても見やすくなります
    • なお、複数のサブディレクトリ名が同じカテゴリー文字列を使う場合、これらはまとめられて一つのカテゴリーとなります

2.3 config.json

config.json は以下のようになっています。

{
    "#+TITLE": "My Personal Wiki pages",
    "distrib": "分散システム",
    "storage": "ストレージ",
    "virtual": "コンテナ・VM",
    "linux": "Linux",
    "etc": "未分類"
}

2行目の "#+TITLE" は、これだけが特別扱いで、 README.org のタイトル行文字列を定義します。

3行目以降は、

"<サブディレクトリ名>": "カテゴリー文字列",

の定義が続きます。JSONのルールとして、最後の定義行("etc": "未分類")のみ、行末にカンマ(,)が付いていないことに注意してください。(カンマを付けるとエラーになります)

3 gitlabは?

gitlabで試したところ、gitlabでも .org ファイルのレンダリングをしてくれることがわかりました。前回と今回の記事で紹介したパーソナルWikiページの作り方や、今回のPythonスクリプトがそのままgitlabにも適用可能です。

個人でgithub、オフィスでgitlabといった使い分けが考えられますね。

4 終わりに

プライベートなレポジトリの用意、 .org ファイルのレンダリング、複数デバイスからの閲覧・編集といったことは github(/gitlab) が用意してくれています。唯一、欠けているピースがインデックスの作成だったのですが、今回それを用意しました。良いプライベートWikiライフを!

5 Appendix

短いので、ソースを貼っておきます。

import sys
import os
from collections import defaultdict
import json

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <base_dir>")
        sys.exit(1)
    if not os.path.isdir(basedir := sys.argv[1]):
        print(f"{basedir} is not a valid directory.")
        sys.exit(1)

    basedir = basedir.rstrip("/")

    # dir_name to category string
    categ_dict = {
        "distrib": "分散システム",
        "storage": "ストレージ",
        "virtual": "コンテナ・VM",
        "linux": "Linux",
        "etc": "未分類",
    }
    TITLE_HEAD = "#+TITLE: "
    SUBTITLE_HEAD = "#+SUBTITLE: "
    top_title = "#+TITLE: Personal Wiki Index"

    # Read config.json
    try:
        with open("/".join([basedir, "config.json"])) as f:
            config = json.load(f)
            top_title = "#+TITLE: " + config.pop("#+TITLE", top_title)
            categ_dict = config
    except Exception:
        print("Skip loading config.json")

    # Save README.org if exists
    readme_path = "/".join([basedir, "README.org"])
    readme_bk_path = readme_path + ".bk"
    try:
        os.remove(readme_bk_path)
    except Exception:
        pass
    if os.path.exists(readme_path):
        os.rename(readme_path, readme_bk_path)

    # Create org_dic
    #   category_string: list of (full_path, title) tuples
    #   Note: multiple categories could share the same category_string
    org_dic = defaultdict(list)
    for dpath, _, fnames in os.walk(basedir):
        for fname in fnames:
            if fname.endswith(".org"):
                full_path = "/".join([dpath, fname])
                categ = categ_str = dpath[dpath.rfind("/") + 1 :].lower()
                try:
                    categ_str = categ_dict[categ]
                except Exception:
                    pass
                with open(full_path, "r") as f:
                    title = "No #+TITLE: header!"
                    subtitles = []
                    for line in f:
                        if line.startswith(TITLE_HEAD):
                            title = line[len(TITLE_HEAD) :].rstrip()
                        if line.startswith(SUBTITLE_HEAD):
                            subtitles.append(line[len(SUBTITLE_HEAD) :].rstrip())
                link_path = "." + full_path[len(basedir) :]
                org_dic[categ_str].append((link_path, (title, fname, subtitles)))

    # Generate README.org based on org_dic
    with open(readme_path, "w") as f:
        print(top_title, file=f)
        print("\nPersonal memos\n", file=f)
        for categ_str, link_list in org_dic.items():
            print(f"** {categ_str}", file=f)
            print("", file=f)
            for link in link_list:
                print(f"- [[{link[0]}][{link[1][0]}]] ({link[1][1]})", file=f)
                for subttl in link[1][2]:
                    print(f"  - {subttl}", file=f)
            print("", file=f)