Obsidian Bases

Move Markdown files in an Obsidian vault that contain a given string/pattern to a specified folder.

import argparse
import os
import re
import shutil
from pathlib import Path


def is_within(child: Path, parent: Path) -> bool:
    try:
        child.resolve().relative_to(parent.resolve())
        return True
    except ValueError:
        return False


def safe_move(src: Path, dst_dir: Path) -> Path:
    """
    Move src into dst_dir. If a file with the same name exists, append a numeric suffix.
    Returns the final destination path.
    """
    dst_dir.mkdir(parents=True, exist_ok=True)
    target = dst_dir / src.name
    if not target.exists():
        shutil.move(str(src), str(target))
        return target

    stem = target.stem
    suffix = target.suffix
    i = 1
    while True:
        candidate = dst_dir / f"{stem} ({i}){suffix}"
        if not candidate.exists():
            shutil.move(str(src), str(candidate))
            return candidate
        i += 1


def file_contains_pattern(
    file_path: Path,
    pattern: str,
    ignore_case: bool = True,
    use_regex: bool = False,
) -> bool:
    """
    Returns True if the file contains the pattern.
    - By default, treats pattern as a plain substring (not regex).
    - Set use_regex=True to interpret pattern as a regular expression.
    """
    if use_regex:
        flags = re.IGNORECASE if ignore_case else 0
        rx = re.compile(pattern, flags)
        try:
            with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
                for line in f:
                    if rx.search(line):
                        return True
        except Exception:
            return False
        return False
    else:
        needle = pattern.lower() if ignore_case else pattern
        try:
            with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
                if ignore_case:
                    for line in f:
                        if needle in line.lower():
                            return True
                else:
                    for line in f:
                        if needle in line:
                            return True
        except Exception:
            return False
        return False


def move_matching_markdown(
    vault_path: Path,
    pattern: str,
    target: Path,
    ignore_case: bool = True,
    use_regex: bool = False,
    dry_run: bool = False,
) -> None:
    """
    Move .md files under vault_path that contain pattern to target.
    - target may be absolute or relative; if relative, it's created under vault_path.
    - Skips files already inside target directory.
    """
    if not vault_path.exists() or not vault_path.is_dir():
        raise ValueError(f"Vault path does not exist or is not a directory: {vault_path}")

    # Resolve target directory: if not absolute, create inside the vault
    if not target.is_absolute():
        target = (vault_path / target).resolve()
    else:
        target = target.resolve()

    total_scanned = 0
    total_matched = 0
    total_moved = 0
    total_skipped_in_target = 0

    for md_file in vault_path.rglob("*.md"):
        if not md_file.is_file():
            continue
        # Skip files already inside the target folder
        if is_within(md_file, target):
            total_skipped_in_target += 1
            continue

        total_scanned += 1

        if file_contains_pattern(md_file, pattern, ignore_case=ignore_case, use_regex=use_regex):
            total_matched += 1
            if dry_run:
                relative_path = md_file.relative_to(vault_path)
                print(f"[DRY-RUN] {relative_path}")
            else:
                final_path = safe_move(md_file, target)
                print(f"Moved: {md_file} -> {final_path}")
                total_moved += 1

    print("\nSummary:")
    print(f"  Scanned .md files: {total_scanned}")
    print(f"  Matched pattern:   {total_matched}")
    print(f"  Moved files:       {total_moved}{' (dry-run)' if dry_run else ''}")
    print(f"  Already in target: {total_skipped_in_target}")
    print(f"  Target directory:  {target}")

Parse arguments

def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Move Markdown files in an Obsidian vault that contain a given string/pattern to a specified folder."
    )
    parser.add_argument(
        "--vault",
        required=True,
        type=Path,
        help='Path to your Obsidian vault directory (e.g., "/path/to/ObsidianVault").',
    )
    parser.add_argument(
        "--pattern",
        required=True,
        type=str,
        help='String to search for in files (default: plain substring; use --regex for regex). Example: "ipqs".',
    )
    parser.add_argument(
        "--target",
        required=True,
        type=Path,
        help='Target folder to move matched files into. If relative (e.g., "ipqs"), it will be created under the vault.',
    )
    parser.add_argument(
        "--case-sensitive",
        action="store_true",
        help="Make the search case-sensitive (default is case-insensitive).",
    )
    parser.add_argument(
        "--regex",
        action="store_true",
        help="Treat pattern as a regular expression (default is plain substring).",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be moved without making changes.",
    )
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    move_matching_markdown(
        vault_path=args.vault,
        pattern=args.pattern,
        target=args.target,
        ignore_case=not args.case_sensitive,
        use_regex=args.regex,
        dry_run=args.dry_run,
    )

"""
Examples:

1) Move all .md files containing 'ipqs' (case-insensitive) into an 'ipqs' folder inside the vault:
   python script.py --vault "/path/to/ObsidianVault" --pattern "ipqs" --target "ipqs"

2) Dry-run (no changes):
   python script.py --vault "/path/to/ObsidianVault" --pattern "ipqs" --target "ipqs" --dry-run

3) Using a regex pattern (case-insensitive):
   python script.py --vault "/path/to/ObsidianVault" --pattern "\\bipqs\\b" --target "ipqs" --regex
"""