mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-18 01:43:39 -05:00
feat(configuration): support multiple vaults in the configuration file (#6)
When multiple vaults are added to the configuration file, the script will prompt you to select one at runtime
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Config module for obsidian frontmatter."""
|
||||
|
||||
from obsidian_metadata._config.config import Config
|
||||
from obsidian_metadata._config.config import Config, VaultConfig
|
||||
|
||||
__all__ = ["Config"]
|
||||
__all__ = ["Config", "VaultConfig"]
|
||||
|
||||
@@ -1,61 +1,52 @@
|
||||
"""Instantiate the configuration object."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
import questionary
|
||||
import rich.repr
|
||||
import tomlkit
|
||||
import typer
|
||||
|
||||
from obsidian_metadata._utils import alerts, vault_validation
|
||||
from obsidian_metadata._utils import Questions, alerts
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore [no-redef]
|
||||
|
||||
DEFAULT_CONFIG_FILE: Path = Path(__file__).parent / "default.toml"
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Config:
|
||||
"""Configuration class."""
|
||||
"""Representation of a configuration file."""
|
||||
|
||||
def __init__(self, config_path: Path = None, vault_path: Path = None) -> None:
|
||||
self.config_path: Path = self._validate_config_path(Path(config_path))
|
||||
self.config: dict[str, Any] = self._load_config()
|
||||
self.config_content: str = self.config_path.read_text()
|
||||
self.vault_path: Path = self._validate_vault_path(vault_path)
|
||||
|
||||
if vault_path is None:
|
||||
self.config_path: Path = self._validate_config_path(Path(config_path))
|
||||
self.config: dict[str, Any] = self._load_config()
|
||||
|
||||
if self.config == {}:
|
||||
log.error(f"Configuration file is empty: '{self.config_path}'")
|
||||
raise typer.Exit(code=1)
|
||||
else:
|
||||
self.config_path = None
|
||||
self.config = {
|
||||
"command_line_vault": {"path": vault_path, "exclude_paths": [".git", ".obsidian"]}
|
||||
}
|
||||
|
||||
try:
|
||||
self.exclude_paths: list[Any] = self.config["exclude_paths"]
|
||||
except KeyError:
|
||||
self.exclude_paths = []
|
||||
|
||||
try:
|
||||
self.metadata_location: str = self.config["metadata"]["metadata_location"]
|
||||
except KeyError:
|
||||
self.metadata_location = "frontmatter"
|
||||
|
||||
try:
|
||||
self.tags_location: str = self.config["metadata"]["tags_location"]
|
||||
except KeyError:
|
||||
self.tags_location = "top"
|
||||
self.vaults: list[VaultConfig] = [
|
||||
VaultConfig(vault_name=key, vault_config=self.config[key]) for key in self.config
|
||||
]
|
||||
except TypeError as e:
|
||||
log.error(f"Configuration file is invalid: '{self.config_path}'")
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
log.debug(f"Loaded configuration from '{self.config_path}'")
|
||||
log.trace(self.config)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
|
||||
"""Define rich representation of Vault."""
|
||||
"""Define rich representation of the Config object."""
|
||||
yield "config_path", self.config_path
|
||||
yield "config_content",
|
||||
yield "vault_path", self.vault_path
|
||||
yield "metadata_location", self.metadata_location
|
||||
yield "tags_location", self.tags_location
|
||||
yield "exclude_paths", self.exclude_paths
|
||||
yield "vaults", self.vaults
|
||||
|
||||
def _validate_config_path(self, config_path: Path | None) -> Path:
|
||||
"""Load the configuration path."""
|
||||
@@ -63,7 +54,7 @@ class Config:
|
||||
config_path = Path(Path.home() / f".{__package__.split('.')[0]}.toml")
|
||||
|
||||
if not config_path.exists():
|
||||
shutil.copy(DEFAULT_CONFIG_FILE, config_path)
|
||||
self._write_default_config(config_path)
|
||||
alerts.info(f"Created default configuration file at '{config_path}'")
|
||||
|
||||
return config_path.expanduser().resolve()
|
||||
@@ -71,46 +62,67 @@ class Config:
|
||||
def _load_config(self) -> dict[str, Any]:
|
||||
"""Load the configuration file."""
|
||||
try:
|
||||
with self.config_path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
with open(self.config_path, encoding="utf-8") as fp:
|
||||
return tomlkit.load(fp)
|
||||
except tomlkit.exceptions.TOMLKitError as e:
|
||||
alerts.error(f"Could not parse '{self.config_path}'")
|
||||
raise typer.Exit(code=1) from e
|
||||
|
||||
def _write_default_config(self, path_to_config: Path) -> None:
|
||||
"""Write the default configuration file when no config file is found."""
|
||||
vault_path = Questions.ask_for_vault_path()
|
||||
|
||||
config_text = f"""\
|
||||
# Add another vault by replicating this section and changing the name
|
||||
["Vault 1"] # Name of the vault.
|
||||
|
||||
# Path to your obsidian vault
|
||||
path = "{vault_path}"
|
||||
|
||||
# Folders within the vault to ignore when indexing metadata
|
||||
exclude_paths = [".git", ".obsidian"]"""
|
||||
|
||||
path_to_config.write_text(dedent(config_text))
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class VaultConfig:
|
||||
"""Representation of a vault configuration."""
|
||||
|
||||
def __init__(self, vault_name: str, vault_config: dict) -> None:
|
||||
"""Initialize the vault configuration."""
|
||||
self.name: str = vault_name
|
||||
self.config: dict = vault_config
|
||||
|
||||
try:
|
||||
self.path = self._validate_vault_path(self.config["path"])
|
||||
|
||||
Path(self.config["path"]).expanduser().resolve()
|
||||
except KeyError:
|
||||
self.path = None
|
||||
|
||||
try:
|
||||
self.exclude_paths = self.config["exclude_paths"]
|
||||
except KeyError:
|
||||
self.exclude_paths = []
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
|
||||
"""Define rich representation of a vault config."""
|
||||
yield "name", self.name
|
||||
yield "config", self.config
|
||||
yield "path", self.path
|
||||
yield "exclude_paths", self.exclude_paths
|
||||
|
||||
def _validate_vault_path(self, vault_path: Path | None) -> Path:
|
||||
"""Validate the vault path."""
|
||||
if vault_path is None:
|
||||
try:
|
||||
vault_path = Path(self.config["vault"]).expanduser().resolve()
|
||||
except KeyError:
|
||||
vault_path = Path("/I/Do/Not/Exist")
|
||||
vault_path = Path(vault_path).expanduser().resolve()
|
||||
|
||||
if not vault_path.exists(): # pragma: no cover
|
||||
if not vault_path.exists():
|
||||
alerts.error(f"Vault path not found: '{vault_path}'")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
vault_path = questionary.path(
|
||||
"Enter a path to Obsidian vault:",
|
||||
only_directories=True,
|
||||
validate=vault_validation,
|
||||
).ask()
|
||||
if vault_path is None:
|
||||
raise typer.Exit(code=1)
|
||||
if not vault_path.is_dir():
|
||||
alerts.error(f"Vault path is not a directory: '{vault_path}'")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
vault_path = Path(vault_path).expanduser().resolve()
|
||||
|
||||
self.write_config_value("vault", str(vault_path))
|
||||
return vault_path
|
||||
|
||||
def write_config_value(self, key: str, value: str | int) -> None:
|
||||
"""Write a new value to the configuration file.
|
||||
|
||||
Args:
|
||||
key (str): The key to write.
|
||||
value (str|int): The value to write.
|
||||
"""
|
||||
self.config_content = re.sub(
|
||||
rf"( *{key} = ['\"])[^'\"]*(['\"].*)", rf"\1{value}\2", self.config_content
|
||||
)
|
||||
|
||||
alerts.notice(f"Writing new configuration for '{key}' to '{self.config_path}'")
|
||||
self.config_path.write_text(self.config_content)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Path to your obsidian vault
|
||||
vault = "/path/to/vault"
|
||||
|
||||
# Folders within the vault to ignore when indexing metadata
|
||||
exclude_paths = [".git", ".obsidian"]
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.alerts import LoggerManager
|
||||
from obsidian_metadata._utils.questions import Questions
|
||||
from obsidian_metadata._utils.utilities import (
|
||||
clean_dictionary,
|
||||
clear_screen,
|
||||
@@ -9,7 +10,6 @@ from obsidian_metadata._utils.utilities import (
|
||||
dict_values_to_lists_strings,
|
||||
docstring_parameter,
|
||||
remove_markdown_sections,
|
||||
vault_validation,
|
||||
version_callback,
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ __all__ = [
|
||||
"dict_contains",
|
||||
"docstring_parameter",
|
||||
"LoggerManager",
|
||||
"Questions",
|
||||
"remove_markdown_sections",
|
||||
"vault_validation",
|
||||
"version_callback",
|
||||
|
||||
33
src/obsidian_metadata/_utils/questions.py
Normal file
33
src/obsidian_metadata/_utils/questions.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Functions for asking questions to the user and validating responses."""
|
||||
from pathlib import Path
|
||||
|
||||
import questionary
|
||||
import typer
|
||||
|
||||
|
||||
class Questions:
|
||||
"""Class for asking questions to the user and validating responses."""
|
||||
|
||||
@staticmethod
|
||||
def ask_for_vault_path() -> Path: # pragma: no cover
|
||||
"""Ask the user for the path to their vault."""
|
||||
vault_path = questionary.path(
|
||||
"Enter a path to Obsidian vault:",
|
||||
only_directories=True,
|
||||
validate=Questions._validate_vault,
|
||||
).ask()
|
||||
if vault_path is None:
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
return Path(vault_path).expanduser().resolve()
|
||||
|
||||
@staticmethod
|
||||
def _validate_vault(path: str) -> bool | str:
|
||||
"""Validates the vault path."""
|
||||
path_to_validate: Path = Path(path).expanduser().resolve()
|
||||
if not path_to_validate.exists():
|
||||
return f"Path does not exist: {path_to_validate}"
|
||||
if not path_to_validate.is_dir():
|
||||
return f"Path is not a directory: {path_to_validate}"
|
||||
|
||||
return True
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Utility functions."""
|
||||
import re
|
||||
from os import name, system
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
@@ -83,17 +82,6 @@ def version_callback(value: bool) -> None:
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
def vault_validation(path: str) -> bool | str:
|
||||
"""Validates the vault path."""
|
||||
path_to_validate: Path = Path(path).expanduser().resolve()
|
||||
if not path_to_validate.exists():
|
||||
return f"Path does not exist: {path_to_validate}"
|
||||
if not path_to_validate.is_dir():
|
||||
return f"Path is not a directory: {path_to_validate}"
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def docstring_parameter(*sub: Any) -> Any:
|
||||
"""Decorator to replace variables within docstrings.
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import questionary
|
||||
import typer
|
||||
from rich import print
|
||||
|
||||
from obsidian_metadata._config import Config
|
||||
from obsidian_metadata._utils import alerts, docstring_parameter, version_callback
|
||||
from obsidian_metadata._utils import (
|
||||
alerts,
|
||||
clear_screen,
|
||||
docstring_parameter,
|
||||
version_callback,
|
||||
)
|
||||
from obsidian_metadata.models import Application
|
||||
|
||||
app = typer.Typer(add_completion=False, no_args_is_help=True, rich_markup_mode="rich")
|
||||
@@ -95,9 +101,6 @@ def main(
|
||||
log_to_file,
|
||||
)
|
||||
|
||||
config: Config = Config(config_path=config_file, vault_path=vault_path)
|
||||
application = Application(dry_run=dry_run, config=config)
|
||||
|
||||
banner = r"""
|
||||
___ _ _ _ _
|
||||
/ _ \| |__ ___(_) __| (_) __ _ _ __
|
||||
@@ -109,7 +112,28 @@ def main(
|
||||
| | | | __/ || (_| | (_| | (_| | || (_| |
|
||||
|_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_|
|
||||
"""
|
||||
clear_screen()
|
||||
print(banner)
|
||||
|
||||
config: Config = Config(config_path=config_file, vault_path=vault_path)
|
||||
if len(config.vaults) == 0:
|
||||
typer.echo("No vaults configured. Exiting.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if len(config.vaults) == 1:
|
||||
application = Application(dry_run=dry_run, config=config.vaults[0])
|
||||
else:
|
||||
vault_names = [vault.name for vault in config.vaults]
|
||||
vault_name = questionary.select(
|
||||
"Select a vault to process:",
|
||||
choices=vault_names,
|
||||
).ask()
|
||||
if vault_name is None:
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
vault_to_use = next(vault for vault in config.vaults if vault.name == vault_name)
|
||||
application = Application(dry_run=dry_run, config=vault_to_use)
|
||||
|
||||
application.main_app()
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ from typing import Any
|
||||
import questionary
|
||||
from rich import print
|
||||
|
||||
from obsidian_metadata._config import Config
|
||||
from obsidian_metadata._utils import alerts, clear_screen
|
||||
from obsidian_metadata._config import VaultConfig
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata.models import Patterns, Vault
|
||||
|
||||
@@ -22,7 +22,7 @@ class Application:
|
||||
More info: https://questionary.readthedocs.io/en/stable/pages/advanced.html#create-questions-from-dictionaries
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, dry_run: bool) -> None:
|
||||
def __init__(self, config: VaultConfig, dry_run: bool) -> None:
|
||||
self.config = config
|
||||
self.dry_run = dry_run
|
||||
self.custom_style = questionary.Style(
|
||||
@@ -45,7 +45,6 @@ class Application:
|
||||
|
||||
def main_app(self) -> None: # noqa: C901
|
||||
"""Questions for the main application."""
|
||||
clear_screen()
|
||||
self.load_vault()
|
||||
|
||||
while True:
|
||||
|
||||
@@ -10,7 +10,7 @@ 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._config import VaultConfig
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata.models import Note, VaultMetadata
|
||||
@@ -27,8 +27,8 @@ class 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
|
||||
def __init__(self, config: VaultConfig, dry_run: bool = False, path_filter: str = None):
|
||||
self.vault_path: Path = config.path
|
||||
self.dry_run: bool = dry_run
|
||||
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
|
||||
self.exclude_paths: list[Path] = []
|
||||
|
||||
Reference in New Issue
Block a user