mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-18 09:53:40 -05:00
feat: initial application release
This commit is contained in:
505
src/obsidian_metadata/models/metadata.py
Normal file
505
src/obsidian_metadata/models/metadata.py
Normal file
@@ -0,0 +1,505 @@
|
||||
"""Work with metadata items."""
|
||||
|
||||
import re
|
||||
from io import StringIO
|
||||
|
||||
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 import (
|
||||
clean_dictionary,
|
||||
dict_contains,
|
||||
dict_values_to_lists_strings,
|
||||
remove_markdown_sections,
|
||||
)
|
||||
from obsidian_metadata.models import Patterns # isort: ignore
|
||||
|
||||
PATTERNS = Patterns()
|
||||
INLINE_TAG_KEY: str = "Inline Tags"
|
||||
|
||||
|
||||
class VaultMetadata:
|
||||
"""Representation of all Metadata in the Vault."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.dict: dict[str, list[str]] = {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Representation of all metadata."""
|
||||
return str(self.dict)
|
||||
|
||||
def add_metadata(self, metadata: dict[str, list[str]]) -> None:
|
||||
"""Add metadata to the vault. Takes a dictionary as input and merges it with the existing metadata. Does not overwrite existing keys.
|
||||
|
||||
Args:
|
||||
metadata (dict): Metadata to add.
|
||||
"""
|
||||
existing_metadata = self.dict
|
||||
|
||||
new_metadata = clean_dictionary(metadata)
|
||||
|
||||
for k, v in new_metadata.items():
|
||||
if k in existing_metadata:
|
||||
if isinstance(v, list):
|
||||
existing_metadata[k].extend(v)
|
||||
else:
|
||||
existing_metadata[k] = v
|
||||
|
||||
for k, v in existing_metadata.items():
|
||||
if isinstance(v, list):
|
||||
existing_metadata[k] = sorted(set(v))
|
||||
elif isinstance(v, dict):
|
||||
for kk, vv in v.items():
|
||||
if isinstance(vv, list):
|
||||
v[kk] = sorted(set(vv))
|
||||
|
||||
self.dict = dict(sorted(existing_metadata.items()))
|
||||
|
||||
def print_keys(self) -> None:
|
||||
"""Print all metadata keys."""
|
||||
columns = Columns(
|
||||
sorted(self.dict.keys()),
|
||||
equal=True,
|
||||
expand=True,
|
||||
title="All metadata keys in Obsidian vault",
|
||||
)
|
||||
print(columns)
|
||||
|
||||
def print_tags(self) -> None:
|
||||
"""Print all tags."""
|
||||
columns = Columns(
|
||||
sorted(self.dict["tags"]),
|
||||
equal=True,
|
||||
expand=True,
|
||||
title="All tags in Obsidian vault",
|
||||
)
|
||||
print(columns)
|
||||
|
||||
def print_metadata(self) -> None:
|
||||
"""Print all metadata."""
|
||||
table = Table(show_footer=False, show_lines=True)
|
||||
table.add_column("Keys")
|
||||
table.add_column("Values")
|
||||
for key, value in sorted(self.dict.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)
|
||||
|
||||
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
|
||||
"""Check if a key and/or a 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 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 = self.dict.copy()
|
||||
|
||||
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 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]] = self.dict.copy()
|
||||
|
||||
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_no_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 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 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 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 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 = self.dict.copy()
|
||||
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]] = self.dict.copy()
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
"""Representation of inline metadata.
|
||||
|
||||
Returns:
|
||||
str: inline metadata
|
||||
"""
|
||||
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 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 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
|
||||
|
||||
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 has_changes(self) -> bool:
|
||||
"""Check if the metadata has changes.
|
||||
|
||||
Returns:
|
||||
bool: True if the metadata has changes.
|
||||
"""
|
||||
return self.dict != self.dict_original
|
||||
|
||||
|
||||
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 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 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
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user