mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-16 08:53:48 -05:00
feat: initial application release
This commit is contained in:
302
src/obsidian_metadata/models/vault.py
Normal file
302
src/obsidian_metadata/models/vault.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Obsidian vault representation."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import rich.repr
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
from rich.prompt import Confirm
|
||||
from rich.table import Table
|
||||
|
||||
from obsidian_metadata._config import Config
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata.models import Note, VaultMetadata
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Vault:
|
||||
"""Representation of the Obsidian vault.
|
||||
|
||||
Attributes:
|
||||
vault (Path): Path to the vault.
|
||||
dry_run (bool): Whether to perform a dry run.
|
||||
backup_path (Path): Path to the backup of the vault.
|
||||
new_vault (Path): Path to a new vault.
|
||||
notes (list[Note]): List of all notes in the vault.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, dry_run: bool = False, path_filter: str = None):
|
||||
self.vault_path: Path = config.vault_path
|
||||
self.dry_run: bool = dry_run
|
||||
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
|
||||
self.new_vault_path: Path = self.vault_path.parent / f"{self.vault_path.name}.new"
|
||||
self.exclude_paths: list[Path] = []
|
||||
self.metadata = VaultMetadata()
|
||||
for p in config.exclude_paths:
|
||||
self.exclude_paths.append(Path(self.vault_path / p))
|
||||
|
||||
self.path_filter = path_filter
|
||||
self.note_paths = self._find_markdown_notes(path_filter)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
transient=True,
|
||||
) as progress:
|
||||
progress.add_task(description="Processing notes...", total=None)
|
||||
self.notes: list[Note] = [
|
||||
Note(note_path=p, dry_run=self.dry_run) for p in self.note_paths
|
||||
]
|
||||
for _note in self.notes:
|
||||
self.metadata.add_metadata(_note.frontmatter.dict)
|
||||
self.metadata.add_metadata(_note.inline_metadata.dict)
|
||||
self.metadata.add_metadata({_note.inline_tags.metadata_key: _note.inline_tags.list})
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
"""Define rich representation of Vault."""
|
||||
yield "vault_path", self.vault_path
|
||||
yield "dry_run", self.dry_run
|
||||
yield "backup_path", self.backup_path
|
||||
yield "new_vault", self.new_vault_path
|
||||
yield "num_notes", self.num_notes()
|
||||
yield "exclude_paths", self.exclude_paths
|
||||
|
||||
def _find_markdown_notes(self, path_filter: str = None) -> list[Path]:
|
||||
"""Build list of all markdown files in the vault.
|
||||
|
||||
Args:
|
||||
path_filter (str, optional): Regex to filter notes by path.
|
||||
|
||||
Returns:
|
||||
list[Path]: List of paths to all matching files in the vault.
|
||||
|
||||
"""
|
||||
notes_list = [
|
||||
p.resolve()
|
||||
for p in self.vault_path.glob("**/*")
|
||||
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"]
|
||||
and not any(item in p.parents for item in self.exclude_paths)
|
||||
]
|
||||
|
||||
if path_filter is not None:
|
||||
notes_list = [
|
||||
p for p in notes_list if re.search(path_filter, str(p.relative_to(self.vault_path)))
|
||||
]
|
||||
|
||||
return notes_list
|
||||
|
||||
def backup(self) -> None:
|
||||
"""Backup the vault."""
|
||||
log.debug("Backing up vault")
|
||||
if self.dry_run:
|
||||
alerts.dryrun(f"Backup up vault to: {self.backup_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
shutil.copytree(self.vault_path, self.backup_path)
|
||||
|
||||
except FileExistsError: # pragma: no cover
|
||||
log.debug("Backup already exists")
|
||||
if not Confirm.ask("Vault backup already exists. Overwrite?"):
|
||||
alerts.info("Exiting backup not overwritten.")
|
||||
return
|
||||
|
||||
log.debug("Overwriting backup")
|
||||
shutil.rmtree(self.backup_path)
|
||||
shutil.copytree(self.vault_path, self.backup_path)
|
||||
|
||||
alerts.success(f"Vault backed up to: {self.backup_path}")
|
||||
|
||||
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
|
||||
"""Check if vault contains the given inline tag.
|
||||
|
||||
Args:
|
||||
tag (str): Tag to check for.
|
||||
is_regex (bool, optional): Whether to use regex to match tag.
|
||||
|
||||
Returns:
|
||||
bool: True if tag is found in vault.
|
||||
"""
|
||||
return any(_note.contains_inline_tag(tag) for _note in self.notes)
|
||||
|
||||
def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool:
|
||||
"""Check if vault contains the given metadata.
|
||||
|
||||
Args:
|
||||
key (str): Key to check for. If value is None, will check vault for key.
|
||||
value (str, optional): Value to check for.
|
||||
is_regex (bool, optional): Whether to use regex to match key/value.
|
||||
|
||||
Returns:
|
||||
bool: True if tag is found in vault.
|
||||
"""
|
||||
if value is None:
|
||||
return self.metadata.contains(key, is_regex=is_regex)
|
||||
|
||||
return self.metadata.contains(key, value, is_regex=is_regex)
|
||||
|
||||
def delete_backup(self) -> None:
|
||||
"""Delete the vault backup."""
|
||||
log.debug("Deleting vault backup")
|
||||
if self.backup_path.exists() and self.dry_run is False:
|
||||
shutil.rmtree(self.backup_path)
|
||||
alerts.success("Backup deleted")
|
||||
elif self.backup_path.exists() and self.dry_run is True:
|
||||
alerts.dryrun("Delete backup")
|
||||
else:
|
||||
alerts.info("No backup found")
|
||||
|
||||
def delete_inline_tag(self, tag: str) -> bool:
|
||||
"""Delete an inline tag in the vault.
|
||||
|
||||
Args:
|
||||
tag (str): Tag to delete.
|
||||
|
||||
Returns:
|
||||
bool: True if tag was deleted.
|
||||
"""
|
||||
changes = False
|
||||
|
||||
for _note in self.notes:
|
||||
if _note.delete_inline_tag(tag):
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
self.metadata.delete(self.notes[0].inline_tags.metadata_key, tag)
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_metadata(self, key: str, value: str = None) -> int:
|
||||
"""Delete metadata in the vault.
|
||||
|
||||
Args:
|
||||
key (str): Key to delete. Regex is supported
|
||||
value (str, optional): Value to delete. Regex is supported
|
||||
|
||||
Returns:
|
||||
int: Number of notes that had metadata deleted.
|
||||
"""
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes:
|
||||
if _note.delete_metadata(key, value):
|
||||
num_changed += 1
|
||||
|
||||
if num_changed > 0:
|
||||
self.metadata.delete(key, value)
|
||||
return num_changed
|
||||
return num_changed
|
||||
|
||||
def get_changed_notes(self) -> list[Note]:
|
||||
"""Returns a list of notes that have changes.
|
||||
|
||||
Returns:
|
||||
list[Note]: List of notes that have changes.
|
||||
"""
|
||||
changed_notes = []
|
||||
for _note in self.notes:
|
||||
if _note.has_changes():
|
||||
changed_notes.append(_note)
|
||||
|
||||
changed_notes = sorted(changed_notes, key=lambda x: x.note_path)
|
||||
return changed_notes
|
||||
|
||||
def info(self) -> None:
|
||||
"""Print information about the vault."""
|
||||
log.debug("Printing vault info")
|
||||
table = Table(title="Vault Info", show_header=False)
|
||||
table.add_row("Vault", str(self.vault_path))
|
||||
table.add_row("Notes being edited", str(self.num_notes()))
|
||||
table.add_row("Notes excluded from editing", str(self.num_excluded_notes()))
|
||||
if self.backup_path.exists():
|
||||
table.add_row("Backup path", str(self.backup_path))
|
||||
else:
|
||||
table.add_row("Backup", "None")
|
||||
table.add_row("Active path filter", str(self.path_filter))
|
||||
table.add_row("Notes with updates", str(len(self.get_changed_notes())))
|
||||
|
||||
Console().print(table)
|
||||
|
||||
def list_editable_notes(self) -> None:
|
||||
"""Print a list of notes within the scope that are being edited."""
|
||||
for _note in self.notes:
|
||||
print(_note.note_path.relative_to(self.vault_path))
|
||||
|
||||
def num_excluded_notes(self) -> int:
|
||||
"""Count number of excluded notes."""
|
||||
excluded_notes = [
|
||||
p.resolve()
|
||||
for p in self.vault_path.glob("**/*")
|
||||
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"] and p not in self.note_paths
|
||||
]
|
||||
return len(excluded_notes)
|
||||
|
||||
def num_notes(self) -> int:
|
||||
"""Number of notes in the vault.
|
||||
|
||||
Returns:
|
||||
int: Number of notes in the vault.
|
||||
"""
|
||||
return len(self.notes)
|
||||
|
||||
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> bool:
|
||||
"""Renames a key or key-value pair in the note's metadata.
|
||||
|
||||
If no value is provided, will rename an entire key.
|
||||
|
||||
Args:
|
||||
key (str): Key to rename.
|
||||
value_1 (str): Value to rename or new name of key if no value_2 is provided.
|
||||
value_2 (str, optional): New value.
|
||||
|
||||
Returns:
|
||||
bool: True if metadata was renamed.
|
||||
"""
|
||||
changes = False
|
||||
for _note in self.notes:
|
||||
if _note.rename_metadata(key, value_1, value_2):
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
self.metadata.rename(key, value_1, value_2)
|
||||
return True
|
||||
return False
|
||||
|
||||
def rename_inline_tag(self, old_tag: str, new_tag: str) -> bool:
|
||||
"""Rename an inline tag in the vault.
|
||||
|
||||
Args:
|
||||
old_tag (str): Old tag name.
|
||||
new_tag (str): New tag name.
|
||||
|
||||
Returns:
|
||||
bool: True if tag was renamed.
|
||||
"""
|
||||
changes = False
|
||||
for _note in self.notes:
|
||||
if _note.rename_inline_tag(old_tag, new_tag):
|
||||
changes = True
|
||||
|
||||
if changes:
|
||||
self.metadata.rename(self.notes[0].inline_tags.metadata_key, old_tag, new_tag)
|
||||
return True
|
||||
return False
|
||||
|
||||
def write(self, new_vault: bool = False) -> None:
|
||||
"""Write changes to the vault."""
|
||||
log.debug("Writing changes to vault...")
|
||||
if new_vault:
|
||||
log.debug("Writing changes to backup")
|
||||
for _note in self.notes:
|
||||
_new_note_path: Path = Path(
|
||||
self.new_vault_path / Path(_note.note_path).relative_to(self.vault_path)
|
||||
)
|
||||
log.debug(f"writing to {_new_note_path}")
|
||||
_note.write(path=_new_note_path)
|
||||
else:
|
||||
for _note in self.notes:
|
||||
log.debug(f"writing to {_note.note_path}")
|
||||
_note.write()
|
||||
Reference in New Issue
Block a user