ObsidianのMarkdownを振り分けるツール
Obsidianで書いたMarkdown(.md)ファイルを、指定したルールに基づいて自動的にフォルダへ振り分けるツールを作成しました。
背景
ObsidianでMarkdownを書く際、いつもVaultのルートにファイルを作ってしまい、雑多な内容のファイルが散らかってしまいます。
Markdownにはテンプレートで基本的なタグをつけているので、これを元にルールベースで振り分けるPythonプログラムを作成しました。
単純にOSの機能でファイルを移動すると、Obsidian Syncがファイルをコピーしてしまうため obsidian-cli のmoveコマンドで移動します。
Larkというライブラリが構文解析器を作ってくれるので、ルール定義にはこれを使います。
機能
- 「TagAとTagBがあり、TagCがないMarkdownをフォルダ"AB"に振り分ける」というようなルールを複数記述できる
- ルールはYaml形式で指定し、and/or/not等の論理式に対応する
- 振り分けには obsidian-cli のmoveコマンドを使う
- 移動前に処理内容を一覧表示し、ユーザーの確認後に実行する
rules.yamlの文法
rules.yaml
には、以下のような形式でルールを記述します。
rules:
- name: rule-1
when: tag:Ai and not tag:Business
then: move to 技術/技術-AI
- name: rule-2
when: tag:Graphics and not tag:Business
then: move to 技術/技術-CG
# ...
各フィールドの意味
-
name
: ルールの識別名(任意の文字列) -
when
: 適用条件。タグの有無を論理式(and, or, not, 括弧)で記述- 例:
tag:Ai and not tag:Business
- 例:
tag:Software or tag:Electronics
- 例:
tag:Crafts and (tag:Art or tag:Handmade)
- 例:
-
then
: マッチ時の処理。move to フォルダ名
の形式で記述
when式の記法
-
tag:タグ名
でタグの有無を判定 -
and
,or
,not
で論理演算 - 括弧
()
でグループ化
例
tag:Ai and not tag:Business
tag:Graphics or tag:Software
tag:Crafts and (tag:Art or tag:Handmade)
使い方
- 必要なパッケージをインストール
pip install -r requirements.txt
- ルールファイル(rules.yaml)を編集
- カレントディレクトリに.mdファイルを配置
- ツールを実行
python obsidian-folder-organizer.py
- 移動内容を確認し、
y
で実行
Pythonプログラム
"""Obsidian Folder Organizer Tool
This tool moves Obsidian Markdown files to folders based on tags.
"""
import os
import glob
import yaml
from lark import Lark, Transformer, v_args
# Grammar definition for rule expressions
RULE_GRAMMAR = r"""
?start: expr
?expr: expr "and" expr -> and_expr
| expr "or" expr -> or_expr
| "not" expr -> not_expr
| "(" expr ")" -> paren_expr
| tag_expr
tag_expr: "tag:" TAGNAME
TAGNAME: /[A-Za-z0-9_-]+/
%import common.WS
%ignore WS
"""
# Transformer for evaluating rule expressions
@v_args(inline=True)
class RuleEval(Transformer):
def __init__(self, tags):
self.tags = set(tags)
def and_expr(self, a, b):
return a and b
def or_expr(self, a, b):
return a or b
def not_expr(self, a):
return not a
def paren_expr(self, a):
return a
def tag_expr(self, tagname):
return str(tagname) in self.tags
def extract_yaml_block(md_path):
"""Extract the YAML block at the beginning of the file and return as a dict. Return None if not found."""
with open(md_path, encoding='utf-8') as f:
lines = f.readlines()
if not lines or lines[0].strip() != '---':
return None
yaml_lines = []
for line in lines[1:]:
if line.strip() == '---':
break
yaml_lines.append(line)
if not yaml_lines:
return None
return yaml.safe_load(''.join(yaml_lines))
def load_rules(yaml_path):
with open(yaml_path, encoding='utf-8') as f:
return yaml.safe_load(f)['rules']
def main():
rules = load_rules('rules.yaml')
parser = Lark(RULE_GRAMMAR, parser='lalr')
move_plan = []
for md_path in glob.glob('*.md'):
meta = extract_yaml_block(md_path)
if not meta or 'tags' not in meta:
continue
tags = meta['tags']
if not isinstance(tags, list):
continue
for rule in rules:
tree = parser.parse(rule['when'])
if RuleEval(tags).transform(tree):
then = rule['then']
if then.startswith('move to '):
folder = then[len('move to '):].strip()
move_plan.append({
'file': md_path,
'rule': rule['name'],
'dest': folder
})
break
if not move_plan:
print('No files to move.')
return
print('The following files will be moved:')
for plan in move_plan:
print(f" {plan['file']} → {plan['dest']} (Rule: {plan['rule']})")
ans = input('Do you want to proceed? (y/N): ').strip().lower()
if ans != 'y':
print('Operation cancelled.')
return
for plan in move_plan:
# Move using obsidian-cli command
cmd = f'obsidian-cli "{plan["file"]}" "{plan["dest"]}"'
ret = os.system(cmd)
if ret == 0:
print(f'{plan["file"]}: {plan["rule"]} → {plan["dest"]} moved (obsidian-cli executed)')
else:
print(f'Failed to move {plan["file"]} to {plan["dest"]} (obsidian-cli error)')
if __name__ == '__main__':
main()