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

We can move matching files to a separate folder or just create Obsidian base with the list of matching files

move_files = False  # When False, do not move files; create a {target}.base file listing matches.

Check if child path is within parent path

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

Move src into dst_dir. If a file with the same name exists, append a numeric suffix. Returns the final destination path.

 def safe_move(src: Path, dst_dir: Path) -> 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

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.
def file_contains_pattern(
    file_path: Path,
    pattern: str,
    ignore_case: bool = True,
    use_regex: bool = False,
) -> bool:
    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

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.

def move_matching_markdown(
    vault_path: Path,
    pattern: str,
    target: Path,
    ignore_case: bool = True,
    use_regex: bool = False,
    dry_run: bool = False,
) -> None:
    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
    matched_file_stems: list[str] = []  # Collect for .base file when move_files is False

    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
            relative_path = md_file.relative_to(vault_path)
            if dry_run:
                # Dry-run shows what would happen (either move or appear in base file)
                action = "MOVE" if move_files else "LIST"
                print(f"[DRY-RUN:{action}] {relative_path}")
            else:
                if move_files:
                    final_path = safe_move(md_file, target)
                    print(f"Moved: {md_file} -> {final_path}")
                    total_moved += 1
                else:
                    # Collect stem (file name without extension) for base file list
                    matched_file_stems.append(md_file.stem)

    # If we're not moving files and not in dry-run, write the .base file inside the vault root
    base_file_path = None
    if not move_files and not dry_run:
        # Use the (original) target directory name (last path component) for base filename
        base_name = target.name  # after resolution above
        base_file_path = vault_path / f"{base_name}.base"
        unique_stems = []
        seen = set()
        for stem in matched_file_stems:
            if stem not in seen:
                unique_stems.append(stem)
                seen.add(stem)

        def esc(s: str) -> str:
            return s.replace('"', '\\"')

        # Build YAML content
        lines = [
            "views:",
            "  - type: table",
            "    name: Table",
            "    sort:",
            "      - property: file.mtime",
            "        direction: DESC",
            "    filters:",
            "      or:",
        ]
        for stem in unique_stems:
            lines.append(f"        - file.name == \"{esc(stem)}\"")
        content = "\n".join(lines) + "\n"
        try:
            with open(base_file_path, "w", encoding="utf-8") as f:
                f.write(content)
            print(f"Created base file: {base_file_path} ({len(unique_stems)} entries)")
        except Exception as e:
            print(f"Failed to write base file {base_file_path}: {e}")

    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}")
    if move_files:
        print(f"  Target directory:  {target}")
    else:
        if dry_run:
            print(f"  Base file (planned): {vault_path / (target.name + '.base')}")
        else:
            print(f"  Base file:          {base_file_path}")

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
"""