1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-06-08

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)

使い方

  1. 必要なパッケージをインストール
    pip install -r requirements.txt
    
  2. ルールファイル(rules.yaml)を編集
  3. カレントディレクトリに.mdファイルを配置
  4. ツールを実行
    python obsidian-folder-organizer.py
    
  5. 移動内容を確認し、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() 

レポジトリ

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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?