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