ObsidianのMarkdownを振り分けるツール

# ObsidianのMarkdownを振り分けるツール

Obsidianで書いたMarkdown（.md）ファイルを、指定したルールに基づいて自動的にフォルダへ振り分けるツールを作成しました。

## 背景

ObsidianでMarkdownを書く際、いつもVaultのルートにファイルを作ってしまい、雑多な内容のファイルが散らかってしまいます。

Markdownにはテンプレートで基本的なタグをつけているので、これを元にルールベースで振り分けるPythonプログラムを作成しました。

単純にOSの機能でファイルを移動すると、Obsidian Syncがファイルをコピーしてしまうため [obsidian-cli](https://github.com/Yakitrak/obsidian-cli) のmoveコマンドで移動します。

Larkというライブラリが構文解析器を作ってくれるので、ルール定義にはこれを使います。

## 機能

- 「TagAとTagBがあり、TagCがないMarkdownをフォルダ"AB"に振り分ける」というようなルールを複数記述できる
- ルールはYaml形式で指定し、and/or/not等の論理式に対応する
- 振り分けには [obsidian-cli](https://github.com/Yakitrak/obsidian-cli) のmoveコマンドを使う
- 移動前に処理内容を一覧表示し、ユーザーの確認後に実行する

## rules.yamlの文法

`rules.yaml` には、以下のような形式でルールを記述します。

```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)`

## 使い方
1. 必要なパッケージをインストール
   ```sh
   pip install -r requirements.txt
   ```
2. ルールファイル（rules.yaml）を編集
3. カレントディレクトリに.mdファイルを配置
4. ツールを実行
   ```sh
   python obsidian-folder-organizer.py
   ```
5. 移動内容を確認し、`y` で実行

## Pythonプログラム

```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() 
```

## レポジトリ

‐ https://github.com/septigram/obsidian-markdown-organizer