"""Work with metadata items.""" import re from io import StringIO import copy from rich import print from rich.columns import Columns 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, dict_values_to_lists_strings, merge_dictionaries, remove_markdown_sections, ) from obsidian_metadata.models import Patterns # isort: ignore from obsidian_metadata.models.enums import MetadataType PATTERNS = Patterns() INLINE_TAG_KEY: str = "inline_tag" class VaultMetadata: """Representation of all Metadata in the Vault.""" def __init__(self) -> None: self.dict: dict[str, list[str]] = {} self.frontmatter: dict[str, list[str]] = {} self.inline_metadata: dict[str, list[str]] = {} self.tags: list[str] = [] def __repr__(self) -> str: """Representation of all metadata.""" return str(self.dict) def index_metadata( self, area: MetadataType, metadata: dict[str, list[str]] | list[str] ) -> None: """Index pre-existing metadata in the vault. Takes a dictionary as input and merges it with the existing metadata. Does not overwrite existing keys. Args: area (MetadataType): Type of metadata. metadata (dict): Metadata to add. """ if isinstance(metadata, dict): new_metadata = clean_dictionary(metadata) self.dict = merge_dictionaries(self.dict, new_metadata) if area == MetadataType.FRONTMATTER: self.frontmatter = merge_dictionaries(self.frontmatter, new_metadata) if area == MetadataType.INLINE: self.inline_metadata = merge_dictionaries(self.inline_metadata, new_metadata) if area == MetadataType.TAGS and isinstance(metadata, list): self.tags.extend(metadata) self.tags = sorted({s.strip("#") for s in self.tags}) def contains( self, area: MetadataType, key: str = None, value: str = None, is_regex: bool = False ) -> bool: """Check if a key and/or a value exists in the metadata. Args: area (MetadataType): Type of metadata to check. key (str, optional): Key to check. value (str, optional): Value to check. is_regex (bool, optional): Use regex to check. Defaults to False. Returns: bool: True if the key exists. Raises: ValueError: Key must be provided when checking for a key's existence. ValueError: Value must be provided when checking for a tag's existence. """ if area != MetadataType.TAGS and key is None: raise ValueError("Key must be provided when checking for a key's existence.") match area: # noqa: E999 case MetadataType.ALL: if dict_contains(self.dict, key, value, is_regex): return True if key is None and value is not None: if is_regex: return any(re.search(value, tag) for tag in self.tags) return value in self.tags case MetadataType.FRONTMATTER: return dict_contains(self.frontmatter, key, value, is_regex) case MetadataType.INLINE: return dict_contains(self.inline_metadata, key, value, is_regex) case MetadataType.KEYS: return dict_contains(self.dict, key, value, is_regex) case MetadataType.TAGS: if value is None: raise ValueError("Value must be provided when checking for a tag's existence.") if is_regex: return any(re.search(value, tag) for tag in self.tags) return value in self.tags return False def delete(self, key: str, value_to_delete: str = None) -> bool: """Delete a key or a key's value from the metadata. Regex is supported to allow deleting more than one key or value. Args: key (str): Key to check. value_to_delete (str, optional): Value to delete. Returns: bool: True if a value was deleted """ new_dict = copy.deepcopy(self.dict) if value_to_delete is None: for _k in list(new_dict): if re.search(key, _k): del new_dict[_k] else: for _k, _v in new_dict.items(): if re.search(key, _k): new_values = [x for x in _v if not re.search(value_to_delete, x)] new_dict[_k] = sorted(new_values) if new_dict != self.dict: self.dict = dict(new_dict) return True return False def print_metadata(self, area: MetadataType) -> None: """Print metadata to the terminal. Args: area (MetadataType): Type of metadata to print """ dict_to_print: dict[str, list[str]] = None list_to_print: list[str] = None match area: case MetadataType.INLINE: dict_to_print = self.inline_metadata.copy() header = "All inline metadata" case MetadataType.FRONTMATTER: dict_to_print = self.frontmatter.copy() header = "All frontmatter" case MetadataType.TAGS: list_to_print = [] for tag in self.tags: list_to_print.append(f"#{tag}") header = "All inline tags" case MetadataType.KEYS: list_to_print = sorted(self.dict.keys()) header = "All Keys" case MetadataType.ALL: dict_to_print = self.dict.copy() list_to_print = [] for tag in self.tags: list_to_print.append(f"#{tag}") header = "All metadata" if dict_to_print is not None: table = Table(title=header, show_footer=False, show_lines=True) table.add_column("Keys") table.add_column("Values") for key, value in sorted(dict_to_print.items()): values: str | dict[str, list[str]] = ( "\n".join(sorted(value)) if isinstance(value, list) else value ) table.add_row(f"[bold]{key}[/]", str(values)) Console().print(table) if list_to_print is not None: columns = Columns( sorted(list_to_print), equal=True, expand=True, title=header if area != MetadataType.ALL else "All inline tags", ) print(columns) def rename(self, key: str, value_1: str, value_2: str = None) -> bool: """Replace a value in the frontmatter. Args: key (str): Key to check. value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key value_2 (str, Optional): New value. bypass_check (bool, optional): Bypass the check if the key exists. Defaults to False. Returns: bool: True if a value was renamed """ if value_2 is None: if key in self.dict and value_1 not in self.dict: self.dict[value_1] = self.dict.pop(key) return True return False if key in self.dict and value_1 in self.dict[key]: self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]}) return True return False class Frontmatter: """Representation of frontmatter metadata.""" def __init__(self, file_content: str): self.dict: dict[str, list[str]] = self._grab_note_frontmatter(file_content) self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict) def __repr__(self) -> str: # pragma: no cover """Representation of the frontmatter. Returns: str: frontmatter """ return f"Frontmatter(frontmatter={self.dict})" def _grab_note_frontmatter(self, file_content: str) -> dict: """Grab metadata from a note. Args: note_path (Path): Path to the note file. Returns: dict: Metadata from the note. """ try: frontmatter_block: str = PATTERNS.frontmatt_block_strip_separators.search( file_content ).group("frontmatter") except AttributeError: return {} yaml = YAML(typ="safe") frontmatter: dict = yaml.load(frontmatter_block) for k in frontmatter: if frontmatter[k] is None: frontmatter[k] = [] return dict_values_to_lists_strings(frontmatter, strip_null_values=True) def add(self, key: str, value: str | list[str] = None) -> bool: """Add a key and value to the frontmatter. Args: key (str): Key to add. value (str, optional): Value to add. Returns: bool: True if the metadata was added """ if value is None: if key not in self.dict: self.dict[key] = [] return True return False if key not in self.dict: if isinstance(value, list): self.dict[key] = value return True self.dict[key] = [value] return True if key in self.dict and value not in self.dict[key]: if isinstance(value, list): self.dict[key].extend(value) return True self.dict[key].append(value) return True return False def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool: """Check if a key or value exists in the metadata. Args: key (str): Key to check. value (str, optional): Value to check. is_regex (bool, optional): Use regex to check. Defaults to False. Returns: bool: True if the key exists. """ return dict_contains(self.dict, key, value, is_regex) def delete(self, key: str, value_to_delete: str = None) -> bool: """Delete a value or key in the frontmatter. Regex is supported to allow deleting more than one key or value. Args: key (str): If no value, key to delete. If value, key containing the value. value_to_delete (str, optional): Value to delete. Returns: bool: True if a value was deleted """ new_dict = dict(self.dict) if value_to_delete is None: for _k in list(new_dict): if re.search(key, _k): del new_dict[_k] else: for _k, _v in new_dict.items(): if re.search(key, _k): new_values = [x for x in _v if not re.search(value_to_delete, x)] new_dict[_k] = sorted(new_values) if new_dict != self.dict: self.dict = dict(new_dict) return True return False def has_changes(self) -> bool: """Check if the frontmatter has changes. Returns: bool: True if the frontmatter has changes. """ return self.dict != self.dict_original def rename(self, key: str, value_1: str, value_2: str = None) -> bool: """Replace a value in the frontmatter. Args: key (str): Key to check. value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key value_2 (str, Optional): New value. Returns: bool: True if a value was renamed """ if value_2 is None: if key in self.dict and value_1 not in self.dict: self.dict[value_1] = self.dict.pop(key) return True return False if key in self.dict and value_1 in self.dict[key]: self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]}) return True return False def to_yaml(self, sort_keys: bool = False) -> str: """Return the frontmatter as a YAML string. Returns: str: Frontmatter as a YAML string. sort_keys (bool, optional): Sort the keys. Defaults to False. """ dict_to_dump = copy.deepcopy(self.dict) for k in dict_to_dump: if dict_to_dump[k] == []: dict_to_dump[k] = None if isinstance(dict_to_dump[k], list) and len(dict_to_dump[k]) == 1: new_val = dict_to_dump[k][0] dict_to_dump[k] = new_val # type: ignore [assignment] # Converting stream to string from https://stackoverflow.com/questions/47614862/best-way-to-use-ruamel-yaml-to-dump-yaml-to-string-not-to-stream/63179923#63179923 if sort_keys: dict_to_dump = dict(sorted(dict_to_dump.items())) yaml = YAML() yaml.indent(mapping=2, sequence=4, offset=2) string_stream = StringIO() yaml.dump(dict_to_dump, string_stream) yaml_value = string_stream.getvalue() string_stream.close() return yaml_value 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_original: dict[str, list[str]] = copy.deepcopy(self.dict) def __repr__(self) -> str: # pragma: no cover """Representation of inline metadata. Returns: str: inline metadata """ return f"InlineMetadata(inline_metadata={self.dict})" def add(self, key: str, value: str | list[str] = None) -> bool: """Add a key and value to the inline metadata. Args: key (str): Key to add. value (str, optional): Value to add. Returns: bool: True if the metadata was added """ if value is None: if key not in self.dict: self.dict[key] = [] return True return False if key not in self.dict: if isinstance(value, list): self.dict[key] = value return True self.dict[key] = [value] return True if key in self.dict and value not in self.dict[key]: if isinstance(value, list): self.dict[key].extend(value) return True self.dict[key].append(value) return True return False def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool: """Check if a key or value exists in the inline metadata. Args: key (str): Key to check. value (str, Optional): Value to check. is_regex (bool, optional): If True, key and value are treated as regex. Defaults to False. Returns: bool: True if the key exists. """ return dict_contains(self.dict, key, value, is_regex) def delete(self, key: str, value_to_delete: str = None) -> bool: """Delete a value or key in the inline metadata. Regex is supported to allow deleting more than one key or value. Args: key (str): If no value, key to delete. If value, key containing the value. value_to_delete (str, optional): Value to delete. Returns: bool: True if a value was deleted """ new_dict = dict(self.dict) if value_to_delete is None: for _k in list(new_dict): if re.search(key, _k): del new_dict[_k] else: for _k, _v in new_dict.items(): if re.search(key, _k): new_values = [x for x in _v if not re.search(value_to_delete, x)] new_dict[_k] = sorted(new_values) if new_dict != self.dict: self.dict = dict(new_dict) return True 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. Returns: bool: True if the metadata has changes. """ return self.dict != self.dict_original def rename(self, key: str, value_1: str, value_2: str = None) -> bool: """Replace a value in the inline metadata. Args: key (str): Key to check. value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key value_2 (str, Optional): New value. Returns: bool: True if a value was renamed """ if value_2 is None: if key in self.dict and value_1 not in self.dict: self.dict[value_1] = self.dict.pop(key) return True return False if key in self.dict and value_1 in self.dict[key]: self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]}) return True return False class InlineTags: """Representation of inline tags.""" def __init__(self, file_content: str): self.metadata_key = INLINE_TAG_KEY self.list: list[str] = self._grab_inline_tags(file_content) self.list_original: list[str] = self.list.copy() def __repr__(self) -> str: # pragma: no cover """Representation of the inline tags. Returns: str: inline tags """ return f"InlineTags(tags={self.list})" def _grab_inline_tags(self, file_content: str) -> list[str]: """Grab inline tags from a note. Args: file_content (str): Total contents of the note file (frontmatter and content). Returns: list[str]: Inline tags from the note. """ return sorted( PATTERNS.find_inline_tags.findall( remove_markdown_sections( file_content, strip_codeblocks=True, strip_inlinecode=True, ) ) ) def add(self, new_tag: str | list[str]) -> bool: """Add a new inline tag. Args: new_tag (str): Tag to add. Returns: bool: True if a tag was added. """ if isinstance(new_tag, list): for _tag in new_tag: if _tag.startswith("#"): _tag = _tag[1:] if _tag in self.list: return False new_list = self.list.copy() new_list.append(_tag) self.list = sorted(new_list) return True else: if new_tag.startswith("#"): new_tag = new_tag[1:] if new_tag in self.list: return False new_list = self.list.copy() new_list.append(new_tag) self.list = sorted(new_list) return True return False def contains(self, tag: str, is_regex: bool = False) -> bool: """Check if a tag exists in the metadata. Args: tag (str): Tag to check. is_regex (bool, optional): If True, tag is treated as regex. Defaults to False. Returns: bool: True if the tag exists. """ if is_regex is True: return any(re.search(tag, _t) for _t in self.list) if tag in self.list: return True return False def delete(self, tag_to_delete: str) -> bool: """Delete a specified inline tag. Regex is supported to allow deleting more than one tag. Args: tag_to_delete (str, optional): Value to delete. Returns: bool: True if a value was deleted """ new_list = sorted([x for x in self.list if re.search(tag_to_delete, x) is None]) if new_list != self.list: self.list = new_list return True return False def has_changes(self) -> bool: """Check if the metadata has changes. Returns: bool: True if the metadata has changes. """ return self.list != self.list_original def rename(self, old_tag: str, new_tag: str) -> bool: """Replace an inline tag with another string. Args: old_tag (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key new_tag (str, Optional): New value. Returns: bool: True if a value was renamed """ if old_tag in self.list: self.list = sorted([new_tag if i == old_tag else i for i in self.list]) return True return False