feat: add new inline metadata (#15)

* feat: add new inline metadata to notes

* fix: prepend note content after frontmatter

* refactor: cleanup search patterns

* feat(regex): find top of note

* test: add headers

* fix: insert to specified location

* test: improve test coverage

* docs: add inline metadata
This commit is contained in:
Nathaniel Landau
2023-02-04 21:52:54 -05:00
committed by Nathaniel Landau
parent 13513b2a14
commit 17985615b3
28 changed files with 1047 additions and 451 deletions

View File

@@ -17,6 +17,21 @@ from obsidian_metadata._utils.alerts import logger as log
class ConfigQuestions:
"""Questions to ask the user when creating a configuration file."""
@staticmethod
def _validate_valid_dir(path: str) -> bool | str:
"""Validate a valid directory.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
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
@staticmethod
def ask_for_vault_path() -> Path: # pragma: no cover
"""Ask the user for the path to their vault.
@@ -34,21 +49,6 @@ class ConfigQuestions:
return Path(vault_path).expanduser().resolve()
@staticmethod
def _validate_valid_dir(path: str) -> bool | str:
"""Validate a valid directory.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
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
@rich.repr.auto
class Config:
@@ -65,7 +65,11 @@ class Config:
else:
self.config_path = None
self.config = {
"command_line_vault": {"path": vault_path, "exclude_paths": [".git", ".obsidian"]}
"command_line_vault": {
"path": vault_path,
"exclude_paths": [".git", ".obsidian"],
"insert_location": "BOTTOM",
}
}
try:
@@ -84,6 +88,15 @@ class Config:
yield "config_path", self.config_path
yield "vaults", self.vaults
def _load_config(self) -> dict[str, Any]:
"""Load the configuration file."""
try:
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 _validate_config_path(self, config_path: Path | None) -> Path:
"""Load the configuration path."""
if config_path is None:
@@ -95,15 +108,6 @@ class Config:
return config_path.expanduser().resolve()
def _load_config(self) -> dict[str, Any]:
"""Load the configuration file."""
try:
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 = ConfigQuestions.ask_for_vault_path()
@@ -116,7 +120,14 @@ class Config:
path = "{vault_path}"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]"""
exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of:
# TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter.
# BOTTOM: The bottom of the note
insert_location = "BOTTOM"
"""
path_to_config.write_text(dedent(config_text))
@@ -140,7 +151,12 @@ class VaultConfig:
try:
self.exclude_paths = self.config["exclude_paths"]
except KeyError:
self.exclude_paths = []
self.exclude_paths = [".git", ".obsidian"]
try:
self.insert_location = self.config["insert_location"]
except KeyError:
self.insert_location = "BOTTOM"
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of a vault config."""

View File

@@ -118,7 +118,8 @@ def main(
[bold underline]Add Metadata[/]
Add new metadata to your vault.
• Add metadata to the frontmatter
[dim]Add to inline metadata (Not yet implemented)[/]
• Add to inline metadata - Set `insert_location` in the config to
control where the new metadata is inserted. (Default: Bottom)
• [dim]Add to inline tag (Not yet implemented)[/]
[bold underline]Rename Metadata[/]

View File

@@ -1,5 +1,9 @@
"""Shared models."""
from obsidian_metadata.models.enums import MetadataType # isort: skip
from obsidian_metadata.models.enums import (
InsertLocation,
MetadataType,
)
from obsidian_metadata.models.patterns import Patterns # isort: skip
from obsidian_metadata.models.metadata import (
Frontmatter,
@@ -17,11 +21,12 @@ __all__ = [
"Frontmatter",
"InlineMetadata",
"InlineTags",
"InsertLocation",
"LoggerManager",
"MetadataType",
"Note",
"Patterns",
"Vault",
"VaultMetadata",
"VaultFilter",
"VaultMetadata",
]

View File

@@ -32,6 +32,19 @@ class Application:
self.questions = Questions()
self.filters: list[VaultFilter] = []
def _load_vault(self) -> None:
"""Load the vault."""
if len(self.filters) == 0:
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run)
else:
self.vault = Vault(config=self.config, dry_run=self.dry_run, filters=self.filters)
alerts.success(
f"Loaded {len(self.vault.notes_in_scope)} notes from {len(self.vault.all_notes)} total notes"
)
self.questions = Questions(vault=self.vault)
def application_main(self) -> None:
"""Questions for the main application."""
self._load_vault()
@@ -70,31 +83,29 @@ class Application:
area = self.questions.ask_area()
match area:
case MetadataType.FRONTMATTER:
case MetadataType.FRONTMATTER | MetadataType.INLINE:
key = self.questions.ask_new_key(question="Enter the key for the new metadata")
if key is None:
if key is None: # pragma: no cover
return
value = self.questions.ask_new_value(
question="Enter the value for the new metadata"
)
if value is None:
if value is None: # pragma: no cover
return
num_changed = self.vault.add_metadata(area, key, value)
if num_changed == 0:
num_changed = self.vault.add_metadata(
area=area, key=key, value=value, location=self.vault.insert_location
)
if num_changed == 0: # pragma: no cover
alerts.warning(f"No notes were changed")
return
alerts.success(f"Added metadata to {num_changed} notes")
case MetadataType.INLINE:
alerts.warning(f"Adding metadata to {area} is not supported yet")
case MetadataType.TAGS:
alerts.warning(f"Adding metadata to {area} is not supported yet")
case _:
case _: # pragma: no cover
return
def application_filter(self) -> None:
@@ -114,7 +125,7 @@ class Application:
match self.questions.ask_selection(choices=choices, question="Select an action"):
case "apply_path_filter":
path = self.questions.ask_filter_path()
if path is None or path == "":
if path is None or path == "": # pragma: no cover
return
self.filters.append(VaultFilter(path_filter=path))
@@ -122,14 +133,14 @@ class Application:
case "apply_metadata_filter":
key = self.questions.ask_existing_key()
if key is None:
if key is None: # pragma: no cover
return
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_existing_value(
question="Enter the value for the metadata filter",
)
if value is None:
if value is None: # pragma: no cover
return
if value == "":
self.filters.append(VaultFilter(key_filter=key))
@@ -302,7 +313,7 @@ class Application:
self.delete_value()
case "delete_inline_tag":
self.delete_inline_tag()
case _:
case _: # pragma: no cover
return
def application_rename_metadata(self) -> None:
@@ -325,7 +336,7 @@ class Application:
self.rename_value()
case "rename_inline_tag":
self.rename_inline_tag()
case _:
case _: # pragma: no cover
return
def commit_changes(self) -> bool:
@@ -373,7 +384,7 @@ class Application:
key_to_delete = self.questions.ask_existing_keys_regex(
question="Regex for the key(s) you'd like to delete?"
)
if key_to_delete is None:
if key_to_delete is None: # pragma: no cover
return
num_changed = self.vault.delete_metadata(key_to_delete)
@@ -390,12 +401,12 @@ class Application:
def delete_value(self) -> None:
"""Delete a value from the vault."""
key = self.questions.ask_existing_key(question="Which key contains the value to delete?")
if key is None:
if key is None: # pragma: no cover
return
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_existing_value_regex(question="Regex for the value to delete")
if value is None:
if value is None: # pragma: no cover
return
num_changed = self.vault.delete_metadata(key, value)
@@ -409,19 +420,6 @@ class Application:
return
def _load_vault(self) -> None:
"""Load the vault."""
if len(self.filters) == 0:
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run)
else:
self.vault = Vault(config=self.config, dry_run=self.dry_run, filters=self.filters)
alerts.success(
f"Loaded {len(self.vault.notes_in_scope)} notes from {len(self.vault.all_notes)} total notes"
)
self.questions = Questions(vault=self.vault)
def noninteractive_export_csv(self, path: Path) -> None:
"""Export the vault metadata to CSV."""
self._load_vault()
@@ -440,11 +438,11 @@ class Application:
original_key = self.questions.ask_existing_key(
question="Which key would you like to rename?"
)
if original_key is None:
if original_key is None: # pragma: no cover
return
new_key = self.questions.ask_new_key()
if new_key is None:
if new_key is None: # pragma: no cover
return
num_changed = self.vault.rename_metadata(original_key, new_key)
@@ -460,11 +458,11 @@ class Application:
"""Rename an inline tag."""
original_tag = self.questions.ask_existing_inline_tag(question="Which tag to rename?")
if original_tag is None:
if original_tag is None: # pragma: no cover
return
new_tag = self.questions.ask_new_tag("New tag")
if new_tag is None:
if new_tag is None: # pragma: no cover
return
num_changed = self.vault.rename_inline_tag(original_tag, new_tag)
@@ -480,16 +478,16 @@ class Application:
def rename_value(self) -> None:
"""Rename a value in the vault."""
key = self.questions.ask_existing_key(question="Which key contains the value to rename?")
if key is None:
if key is None: # pragma: no cover
return
question_key = Questions(vault=self.vault, key=key)
value = question_key.ask_existing_value(question="Which value would you like to rename?")
if value is None:
if value is None: # pragma: no cover
return
new_value = question_key.ask_new_value()
if new_value is None:
if new_value is None: # pragma: no cover
return
num_changes = self.vault.rename_metadata(key, value, new_value)
@@ -511,7 +509,7 @@ class Application:
answer = self.questions.ask_confirm(
question="View diffs of individual files?", default=False
)
if not answer:
if not answer: # pragma: no cover
return
choices: list[dict[str, Any] | questionary.Separator] = [questionary.Separator()]

View File

@@ -11,3 +11,17 @@ class MetadataType(Enum):
TAGS = "Inline Tags"
KEYS = "Metadata Keys Only"
ALL = "All Metadata"
class InsertLocation(Enum):
"""Location to add metadata to notes.
TOP: Directly after frontmatter.
AFTER_TITLE: After a header following frontmatter.
BOTTOM: The bottom of the note
"""
TOP = "Top"
AFTER_TITLE = "Header"
BOTTOM = "Bottom"

View File

@@ -9,6 +9,8 @@ from rich.console import Console
from rich.table import Table
from ruamel.yaml import YAML
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils import (
clean_dictionary,
dict_contains,
@@ -234,7 +236,7 @@ class Frontmatter:
dict: Metadata from the note.
"""
try:
frontmatter_block: str = PATTERNS.frontmatt_block_no_separators.search(
frontmatter_block: str = PATTERNS.frontmatt_block_strip_separators.search(
file_content
).group("frontmatter")
except AttributeError:
@@ -388,7 +390,7 @@ class InlineMetadata:
"""Representation of inline metadata in the form of `key:: value`."""
def __init__(self, file_content: str):
self.dict: dict[str, list[str]] = self._grab_inline_metadata(file_content)
self.dict: dict[str, list[str]] = self.grab_inline_metadata(file_content)
self.dict_original: dict[str, list[str]] = self.dict.copy()
def __repr__(self) -> str: # pragma: no cover
@@ -399,32 +401,8 @@ class InlineMetadata:
"""
return f"InlineMetadata(inline_metadata={self.dict})"
def _grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
"""Grab inline metadata from a note.
Returns:
dict[str, str]: Inline metadata from the note.
"""
content = remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
strip_frontmatter=True,
)
all_results = PATTERNS.find_inline_metadata.findall(content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for k, v in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
inline_metadata[k] = [str(v)]
return clean_dictionary(inline_metadata)
def add(self, key: str, value: str | list[str] = None) -> bool:
"""Add a key and value to the frontmatter.
"""Add a key and value to the inline metadata.
Args:
key (str): Key to add.
@@ -433,8 +411,26 @@ class InlineMetadata:
Returns:
bool: True if the metadata was added
"""
# TODO: implement adding to inline metadata which requires knowing where in the note the metadata is to be added. In addition, unlike frontmatter, it is not possible to have multiple values for a key.
pass
if value is None:
if key not in self.dict:
self.dict[key] = []
return True
return False
if isinstance(value, list):
value = value[0]
if key not in self.dict:
self.dict[key] = [value]
return True
if key in self.dict and len(self.dict[key]) > 0:
if value in self.dict[key]:
return False
raise ValueError(f"'{key}' not empty")
self.dict[key].append(value)
return True
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a key or value exists in the inline metadata.
@@ -477,6 +473,30 @@ class InlineMetadata:
return False
def grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
"""Grab inline metadata from a note.
Returns:
dict[str, str]: Inline metadata from the note.
"""
content = remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
strip_frontmatter=True,
)
all_results = PATTERNS.find_inline_metadata.findall(content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for k, v in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
inline_metadata[k] = [str(v)]
return clean_dictionary(inline_metadata)
def has_changes(self) -> bool:
"""Check if the metadata has changes.

View File

@@ -9,13 +9,13 @@ import rich.repr
import typer
from rich.console import Console
from rich.table import Table
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import (
Frontmatter,
InlineMetadata,
InlineTags,
InsertLocation,
MetadataType,
Patterns,
)
@@ -117,24 +117,37 @@ class Note:
_v = re.escape(_v)
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> bool:
"""Add metadata to the note.
def add_metadata(
self,
area: MetadataType,
key: str,
value: str | list[str] = None,
location: InsertLocation = None,
) -> bool:
"""Add metadata to the note if it does not already exist.
Args:
area (MetadataType): Area to add metadata to.
key (str): Key to add.
location (InsertLocation, optional): Location to add inline metadata and tags.
value (str, optional): Value to add.
Returns:
bool: Whether the metadata was added.
"""
if area is MetadataType.FRONTMATTER and self.frontmatter.add(key, value):
self.replace_frontmatter()
self.update_frontmatter()
return True
if area is MetadataType.INLINE:
# TODO: implement adding to inline metadata
pass
try:
if area is MetadataType.INLINE and self.inline_metadata.add(key, value):
line = f"{key}:: " if value is None else f"{key}:: {value}"
self.insert(new_string=line, location=location)
return True
except ValueError as e:
log.warning(f"Could not add metadata to {self.note_path}: {e}")
return False
if area is MetadataType.TAGS:
# TODO: implement adding to intext tags
@@ -142,24 +155,6 @@ class Note:
return False
def append(self, string_to_append: str, allow_multiple: bool = False) -> None:
"""Append a string to the end of a note.
Args:
string_to_append (str): String to append to the note.
allow_multiple (bool): Whether to allow appending the string if it already exists in the note.
"""
if allow_multiple:
self.file_content += f"\n{string_to_append}"
else:
if len(re.findall(re.escape(string_to_append), self.file_content)) == 0:
self.file_content += f"\n{string_to_append}"
def commit_changes(self) -> None:
"""Commit changes to the note to disk."""
# TODO: rewrite frontmatter if it has changed
pass
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
"""Check if a note contains the specified inline tag.
@@ -235,14 +230,14 @@ class Note:
if value is None:
if self.frontmatter.delete(key):
self.replace_frontmatter()
self.update_frontmatter()
changed_value = True
if self.inline_metadata.delete(key):
self._delete_inline_metadata(key, value)
changed_value = True
else:
if self.frontmatter.delete(key, value):
self.replace_frontmatter()
self.update_frontmatter()
changed_value = True
if self.inline_metadata.delete(key, value):
self._delete_inline_metadata(key, value)
@@ -272,6 +267,53 @@ class Note:
return False
def insert(
self,
new_string: str,
location: InsertLocation,
allow_multiple: bool = False,
) -> None:
"""Insert a string at the top of a note.
Args:
new_string (str): String to insert at the top of the note.
allow_multiple (bool): Whether to allow inserting the string if it already exists in the note.
location (InsertLocation): Location to insert the string.
"""
if not allow_multiple and len(re.findall(re.escape(new_string), self.file_content)) > 0:
return
match location: # noqa: E999
case InsertLocation.BOTTOM:
self.file_content += f"\n{new_string}"
case InsertLocation.TOP:
try:
top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter")
except AttributeError:
top = ""
if top == "":
self.file_content = f"{new_string}\n{self.file_content}"
else:
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
case InsertLocation.AFTER_TITLE:
try:
top = PATTERNS.top_with_header.search(self.file_content).group("top")
except AttributeError:
top = ""
if top == "":
self.file_content = f"{new_string}\n{self.file_content}"
else:
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
case _:
raise ValueError(f"Invalid location: {location}")
pass
def print_note(self) -> None:
"""Print the note to the console."""
print(self.file_content)
@@ -293,28 +335,6 @@ class Note:
Console().print(table)
def replace_frontmatter(self, sort_keys: bool = False) -> None:
"""Replace the frontmatter in the note with the current frontmatter object."""
try:
current_frontmatter = PATTERNS.frontmatt_block_with_separators.search(
self.file_content
).group("frontmatter")
except AttributeError:
current_frontmatter = None
if current_frontmatter is None and self.frontmatter.dict == {}:
return
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = f"---\n{new_frontmatter}---\n"
if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content
return
current_frontmatter = re.escape(current_frontmatter)
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
def rename_inline_tag(self, tag_1: str, tag_2: str) -> bool:
"""Rename an inline tag from the note ONLY if it's not in the metadata as well.
@@ -351,14 +371,14 @@ class Note:
changed_value: bool = False
if value_2 is None:
if self.frontmatter.rename(key, value_1):
self.replace_frontmatter()
self.update_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1):
self._rename_inline_metadata(key, value_1)
changed_value = True
else:
if self.frontmatter.rename(key, value_1, value_2):
self.replace_frontmatter()
self.update_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1, value_2):
self._rename_inline_metadata(key, value_1, value_2)
@@ -382,6 +402,28 @@ class Note:
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
def update_frontmatter(self, sort_keys: bool = False) -> None:
"""Replace the frontmatter in the note with the current frontmatter object."""
try:
current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group(
"frontmatter"
)
except AttributeError:
current_frontmatter = None
if current_frontmatter is None and self.frontmatter.dict == {}:
return
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = f"---\n{new_frontmatter}---\n"
if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content
return
current_frontmatter = re.escape(current_frontmatter)
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
def write(self, path: Path = None) -> None:
"""Write the note's content to disk.

View File

@@ -13,7 +13,7 @@ class Patterns:
find_inline_tags: Pattern[str] = re.compile(
r"""
(?:^|[ \|_,;:\*\)\[\]\\\.]|(?<!\])\() # Before tag is start of line or separator
(?<!\/\/[\w\d_\.\(\)\/&_-]+) # Before tag is not a link
(?<!\/\/[\w\d_\.\(\)\/&_-]+) # Before tag is not a link
\#([^ \|,;:\*\(\)\[\]\\\.\n#&]+) # Match tag until separator or end of line
""",
re.MULTILINE | re.X,
@@ -22,23 +22,41 @@ class Patterns:
find_inline_metadata: Pattern[str] = re.compile(
r""" # First look for in-text key values
(?:^\[| \[) # Find key with starting bracket
([-_\w\d\/\*\u263a-\U0001f645]+?)::[ ]? # Find key
([-_\w\d\/\*\u263a-\U0001f999]+?)::[ ]? # Find key
(.*?)\] # Find value until closing bracket
| # Else look for key values at start of line
(?:^|[^ \w\d]+| \[) # Any non-word or non-digit character
([-_\w\d\/\*\u263a-\U0001f645]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
([-_\w\d\/\*\u263a-\U0001f9995]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
(.*?)$ # Capture the value
""",
re.X | re.MULTILINE,
)
frontmatt_block_with_separators: Pattern[str] = re.compile(
r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL
)
frontmatt_block_no_separators: Pattern[str] = re.compile(
frontmatter_block: Pattern[str] = re.compile(r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL)
frontmatt_block_strip_separators: Pattern[str] = re.compile(
r"^\s*---(?P<frontmatter>.*?)---", flags=re.DOTALL
)
# This pattern will return a tuple of 4 values, two will be empty and will need to be stripped before processing further
validate_key_text: Pattern[str] = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f645]")
top_with_header: Pattern[str] = re.compile(
r"""^\s* # Start of note
(?P<top> # Capture the top of the note
(---.*?---)? # Frontmatter, if it exists
\s* # Any whitespace
( # Full header, if it exists
\#+[ ] # Match start of any header level
( # Text of header
[\w\d]+ # Word or digit
| # Or
[\[\]\(\)\+\{\}\"'\-\.\/\*\$\| ]+ # Special characters
| # Or
[\u263a-\U0001f999]+ # Emoji
)+ # End of header text
)? # End of full header
) # End capture group
""",
flags=re.DOTALL | re.X,
)
validate_key_text: Pattern[str] = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f999]")
validate_tag_text: Pattern[str] = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")

View File

@@ -12,7 +12,7 @@ from typing import Any
import questionary
import typer
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.enums import InsertLocation, MetadataType
from obsidian_metadata.models.patterns import Patterns
from obsidian_metadata.models.vault import Vault
@@ -76,6 +76,7 @@ class Questions:
("qmark", "bold"),
("question", "bold"),
("separator", "fg:#808080"),
("answer", "fg:#FF9D00 bold"),
("instruction", "fg:#808080"),
("highlighted", "bold underline"),
("text", ""),
@@ -405,6 +406,23 @@ class Questions:
qmark="INPUT |",
).ask()
def ask_metadata_location(
self, question: str = "Where in a note should we add metadata"
) -> InsertLocation: # pragma: no cover
"""Ask the user for the location within a note to place new metadata.
Returns:
InsertLocation: The location within a note to place new metadata.
"""
choices = []
for metadata_location in InsertLocation:
choices.append({"name": metadata_location.value, "value": metadata_location})
return self.ask_selection(
choices=choices,
question="Select the location for the metadata",
)
def ask_new_key(self, question: str = "New key name") -> str: # pragma: no cover
"""Ask the user for a new metadata key.

View File

@@ -13,10 +13,10 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm
from rich.table import Table
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._config.config import Config, VaultConfig
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import MetadataType, Note, VaultMetadata
from obsidian_metadata.models import InsertLocation, MetadataType, Note, VaultMetadata
@dataclass
@@ -46,8 +46,10 @@ class Vault:
dry_run: bool = False,
filters: list[VaultFilter] = [],
):
self.config = config.config
self.vault_path: Path = config.path
self.name = self.vault_path.name
self.insert_location: InsertLocation = self._find_insert_location()
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] = []
@@ -110,6 +112,21 @@ class Vault:
return notes_list
def _find_insert_location(self) -> InsertLocation:
"""Find the insert location for a note.
Returns:
InsertLocation: Insert location for the note.
"""
if self.config["insert_location"].upper() == "TOP":
return InsertLocation.TOP
elif self.config["insert_location"].upper() == "HEADER":
return InsertLocation.AFTER_TITLE
elif self.config["insert_location"].upper() == "BOTTOM":
return InsertLocation.BOTTOM
else:
return InsertLocation.BOTTOM
def _find_markdown_notes(self) -> list[Path]:
"""Build list of all markdown files in the vault.
@@ -145,21 +162,31 @@ class Vault:
metadata=_note.inline_tags.list,
)
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> int:
"""Add metadata to all notes in the vault.
def add_metadata(
self,
area: MetadataType,
key: str,
value: str | list[str] = None,
location: InsertLocation = None,
) -> int:
"""Add metadata to all notes in the vault which do not already contain it.
Args:
area (MetadataType): Area of metadata to add to.
key (str): Key to add.
value (str|list, optional): Value to add.
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
Returns:
int: Number of notes updated.
"""
if location is None:
location = self.insert_location
num_changed = 0
for _note in self.notes_in_scope:
if _note.add_metadata(area, key, value):
if _note.add_metadata(area, key, value, location):
num_changed += 1
if num_changed > 0:
@@ -258,91 +285,6 @@ class Vault:
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_in_scope:
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."""
table = Table(show_header=False)
table.add_row("Vault", str(self.vault_path))
if self.backup_path.exists():
table.add_row("Backup path", str(self.backup_path))
else:
table.add_row("Backup", "None")
table.add_row("Notes in scope", str(len(self.notes_in_scope)))
table.add_row("Notes excluded from scope", str(self.num_excluded_notes()))
table.add_row("Active filters", str(len(self.filters)))
table.add_row("Notes with changes", 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."""
table = Table(title="Notes in current scope", show_header=False, box=box.HORIZONTALS)
for _n, _note in enumerate(self.notes_in_scope, start=1):
table.add_row(str(_n), str(_note.note_path.relative_to(self.vault_path)))
Console().print(table)
def num_excluded_notes(self) -> int:
"""Count number of excluded notes."""
return len(self.all_notes) - len(self.notes_in_scope)
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int:
"""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:
int: Number of notes that had metadata renamed.
"""
num_changed = 0
for _note in self.notes_in_scope:
if _note.rename_metadata(key, value_1, value_2):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault.
Args:
old_tag (str): Old tag name.
new_tag (str): New tag name.
Returns:
int: Number of notes that had inline tags renamed.
"""
num_changed = 0
for _note in self.notes_in_scope:
if _note.rename_inline_tag(old_tag, new_tag):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def export_metadata(self, path: str, format: str = "csv") -> None:
"""Write metadata to a csv file.
@@ -384,3 +326,88 @@ class Vault:
with open(export_file, "w", encoding="UTF8") as f:
json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True)
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_in_scope:
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."""
table = Table(show_header=False)
table.add_row("Vault", str(self.vault_path))
if self.backup_path.exists():
table.add_row("Backup path", str(self.backup_path))
else:
table.add_row("Backup", "None")
table.add_row("Notes in scope", str(len(self.notes_in_scope)))
table.add_row("Notes excluded from scope", str(self.num_excluded_notes()))
table.add_row("Active filters", str(len(self.filters)))
table.add_row("Notes with changes", 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."""
table = Table(title="Notes in current scope", show_header=False, box=box.HORIZONTALS)
for _n, _note in enumerate(self.notes_in_scope, start=1):
table.add_row(str(_n), str(_note.note_path.relative_to(self.vault_path)))
Console().print(table)
def num_excluded_notes(self) -> int:
"""Count number of excluded notes."""
return len(self.all_notes) - len(self.notes_in_scope)
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault.
Args:
old_tag (str): Old tag name.
new_tag (str): New tag name.
Returns:
int: Number of notes that had inline tags renamed.
"""
num_changed = 0
for _note in self.notes_in_scope:
if _note.rename_inline_tag(old_tag, new_tag):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int:
"""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:
int: Number of notes that had metadata renamed.
"""
num_changed = 0
for _note in self.notes_in_scope:
if _note.rename_metadata(key, value_1, value_2):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed