mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-18 09:53:40 -05:00
feat: greatly improve capturing all formats of inline metadata (#41)
feat: greatly improve capturing metadata all formats of inline metadata
This commit is contained in:
@@ -8,11 +8,8 @@ from obsidian_metadata._utils.utilities import (
|
||||
delete_from_dict,
|
||||
dict_contains,
|
||||
dict_keys_to_lower,
|
||||
dict_values_to_lists_strings,
|
||||
docstring_parameter,
|
||||
inline_metadata_from_string,
|
||||
merge_dictionaries,
|
||||
remove_markdown_sections,
|
||||
rename_in_dict,
|
||||
validate_csv_bulk_imports,
|
||||
version_callback,
|
||||
@@ -25,13 +22,10 @@ __all__ = [
|
||||
"delete_from_dict",
|
||||
"dict_contains",
|
||||
"dict_keys_to_lower",
|
||||
"dict_values_to_lists_strings",
|
||||
"docstring_parameter",
|
||||
"LoggerManager",
|
||||
"inline_metadata_from_string",
|
||||
"merge_dictionaries",
|
||||
"rename_in_dict",
|
||||
"remove_markdown_sections",
|
||||
"validate_csv_bulk_imports",
|
||||
"version_callback",
|
||||
]
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
console_no_markup = Console(markup=False)
|
||||
|
||||
@@ -78,48 +78,6 @@ def dict_keys_to_lower(dictionary: dict) -> dict:
|
||||
return {key.lower(): value for key, value in dictionary.items()}
|
||||
|
||||
|
||||
def dict_values_to_lists_strings(
|
||||
dictionary: dict,
|
||||
strip_null_values: bool = False,
|
||||
) -> dict:
|
||||
"""Convert all values in a dictionary to lists of strings.
|
||||
|
||||
Args:
|
||||
dictionary (dict): Dictionary to convert
|
||||
strip_null_values (bool): Whether to strip null values
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with all values converted to lists of strings
|
||||
|
||||
{key: sorted(new_dict[key]) for key in sorted(new_dict)}
|
||||
"""
|
||||
dictionary = copy.deepcopy(dictionary)
|
||||
new_dict = {}
|
||||
|
||||
if strip_null_values:
|
||||
for key, value in dictionary.items():
|
||||
if isinstance(value, list):
|
||||
new_dict[key] = sorted([str(item) for item in value if item is not None])
|
||||
elif isinstance(value, dict):
|
||||
new_dict[key] = dict_values_to_lists_strings(value, strip_null_values=True) # type: ignore[assignment]
|
||||
elif value is None or value == "None" or not value:
|
||||
new_dict[key] = []
|
||||
else:
|
||||
new_dict[key] = [str(value)]
|
||||
|
||||
return new_dict
|
||||
|
||||
for key, value in dictionary.items():
|
||||
if isinstance(value, list):
|
||||
new_dict[key] = sorted([str(item) if item is not None else "" for item in value])
|
||||
elif isinstance(value, dict):
|
||||
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
|
||||
else:
|
||||
new_dict[key] = [str(value) if value is not None else ""]
|
||||
|
||||
return new_dict
|
||||
|
||||
|
||||
def delete_from_dict( # noqa: C901
|
||||
dictionary: dict, key: str, value: str = None, is_regex: bool = False
|
||||
) -> dict:
|
||||
@@ -183,21 +141,6 @@ def docstring_parameter(*sub: Any) -> Any:
|
||||
return dec
|
||||
|
||||
|
||||
def inline_metadata_from_string(string: str) -> list[tuple[Any, ...]]:
|
||||
"""Search for inline metadata in a string and return a list tuples containing (key, value).
|
||||
|
||||
Args:
|
||||
string (str): String to get metadata from
|
||||
|
||||
Returns:
|
||||
tuple[str]: (key, value)
|
||||
"""
|
||||
from obsidian_metadata.models import Patterns
|
||||
|
||||
results = Patterns().find_inline_metadata.findall(string)
|
||||
return [tuple(filter(None, x)) for x in results]
|
||||
|
||||
|
||||
def merge_dictionaries(dict1: dict, dict2: dict) -> dict:
|
||||
"""Merge two dictionaries. When the values are lists, they are merged and sorted.
|
||||
|
||||
@@ -253,35 +196,6 @@ def rename_in_dict(
|
||||
return dictionary
|
||||
|
||||
|
||||
def remove_markdown_sections(
|
||||
text: str,
|
||||
strip_codeblocks: bool = False,
|
||||
strip_inlinecode: bool = False,
|
||||
strip_frontmatter: bool = False,
|
||||
) -> str:
|
||||
"""Strip unwanted markdown sections from text. This is used to remove code blocks and frontmatter from the body of notes before tags and inline metadata are processed.
|
||||
|
||||
Args:
|
||||
text (str): Text to remove code blocks from
|
||||
strip_codeblocks (bool, optional): Strip code blocks. Defaults to False.
|
||||
strip_inlinecode (bool, optional): Strip inline code. Defaults to False.
|
||||
strip_frontmatter (bool, optional): Strip frontmatter. Defaults to False.
|
||||
|
||||
Returns:
|
||||
str: Text without code blocks
|
||||
"""
|
||||
if strip_codeblocks:
|
||||
text = re.sub(r"`{3}.*?`{3}", "", text, flags=re.DOTALL)
|
||||
|
||||
if strip_inlinecode:
|
||||
text = re.sub(r"(?<!`{2})`[^`]+?`", "", text)
|
||||
|
||||
if strip_frontmatter:
|
||||
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def validate_csv_bulk_imports( # noqa: C901
|
||||
csv_path: Path, note_paths: list
|
||||
) -> dict[str, list[dict[str, str]]]:
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
from obsidian_metadata.models.enums import (
|
||||
InsertLocation,
|
||||
MetadataType,
|
||||
Wrapping,
|
||||
)
|
||||
|
||||
from obsidian_metadata.models.patterns import Patterns # isort: skip
|
||||
from obsidian_metadata.models.metadata import (
|
||||
Frontmatter,
|
||||
InlineMetadata,
|
||||
InlineTags,
|
||||
VaultMetadata,
|
||||
)
|
||||
from obsidian_metadata.models.metadata import InlineField, dict_to_yaml
|
||||
from obsidian_metadata.models.notes import Note
|
||||
from obsidian_metadata.models.vault import Vault, VaultFilter
|
||||
|
||||
@@ -18,15 +12,13 @@ from obsidian_metadata.models.application import Application # isort: skip
|
||||
|
||||
__all__ = [
|
||||
"Application",
|
||||
"Frontmatter",
|
||||
"InlineMetadata",
|
||||
"InlineTags",
|
||||
"dict_to_yaml",
|
||||
"InlineField",
|
||||
"InsertLocation",
|
||||
"LoggerManager",
|
||||
"MetadataType",
|
||||
"Note",
|
||||
"Patterns",
|
||||
"Vault",
|
||||
"VaultFilter",
|
||||
"VaultMetadata",
|
||||
"Wrapping",
|
||||
]
|
||||
|
||||
@@ -84,8 +84,8 @@ class Application:
|
||||
"Add new metadata to your vault. Currently only supports adding to the frontmatter of a note."
|
||||
)
|
||||
|
||||
area = self.questions.ask_area()
|
||||
match area:
|
||||
meta_type = self.questions.ask_area()
|
||||
match meta_type:
|
||||
case MetadataType.FRONTMATTER | MetadataType.INLINE:
|
||||
key = self.questions.ask_new_key(question="Enter the key for the new metadata")
|
||||
if key is None: # pragma: no cover
|
||||
@@ -98,7 +98,7 @@ class Application:
|
||||
return
|
||||
|
||||
num_changed = self.vault.add_metadata(
|
||||
area=area, key=key, value=value, location=self.vault.insert_location
|
||||
meta_type=meta_type, key=key, value=value, location=self.vault.insert_location
|
||||
)
|
||||
if num_changed == 0: # pragma: no cover
|
||||
alerts.warning("No notes were changed")
|
||||
@@ -112,7 +112,7 @@ class Application:
|
||||
return
|
||||
|
||||
num_changed = self.vault.add_metadata(
|
||||
area=area, value=tag, location=self.vault.insert_location
|
||||
meta_type=meta_type, value=tag, location=self.vault.insert_location
|
||||
)
|
||||
|
||||
if num_changed == 0: # pragma: no cover
|
||||
@@ -373,23 +373,24 @@ class Application:
|
||||
match self.questions.ask_selection(choices=choices, question="Select an action"):
|
||||
case "all_metadata":
|
||||
console.print("")
|
||||
self.vault.metadata.print_metadata(area=MetadataType.ALL)
|
||||
# TODO: Add a way to print metadata
|
||||
self.vault.print_metadata(meta_type=MetadataType.ALL)
|
||||
console.print("")
|
||||
case "all_frontmatter":
|
||||
console.print("")
|
||||
self.vault.metadata.print_metadata(area=MetadataType.FRONTMATTER)
|
||||
self.vault.print_metadata(meta_type=MetadataType.FRONTMATTER)
|
||||
console.print("")
|
||||
case "all_inline":
|
||||
console.print("")
|
||||
self.vault.metadata.print_metadata(area=MetadataType.INLINE)
|
||||
self.vault.print_metadata(meta_type=MetadataType.INLINE)
|
||||
console.print("")
|
||||
case "all_keys":
|
||||
console.print("")
|
||||
self.vault.metadata.print_metadata(area=MetadataType.KEYS)
|
||||
self.vault.print_metadata(meta_type=MetadataType.KEYS)
|
||||
console.print("")
|
||||
case "all_tags":
|
||||
console.print("")
|
||||
self.vault.metadata.print_metadata(area=MetadataType.TAGS)
|
||||
self.vault.print_metadata(meta_type=MetadataType.TAGS)
|
||||
console.print("")
|
||||
case _:
|
||||
return
|
||||
@@ -503,10 +504,10 @@ class Application:
|
||||
return
|
||||
|
||||
num_changed = self.vault.delete_metadata(
|
||||
key=key_to_delete, area=MetadataType.ALL, is_regex=True
|
||||
key=key_to_delete, meta_type=MetadataType.ALL, is_regex=True
|
||||
)
|
||||
if num_changed == 0:
|
||||
alerts.warning(f"No notes found with a key matching: [reverse]{key_to_delete}[/]")
|
||||
alerts.warning(f"No notes found with a key matching regex: [reverse]{key_to_delete}[/]")
|
||||
return
|
||||
|
||||
alerts.success(
|
||||
@@ -527,7 +528,7 @@ class Application:
|
||||
return
|
||||
|
||||
num_changed = self.vault.delete_metadata(
|
||||
key=key, value=value, area=MetadataType.ALL, is_regex=True
|
||||
key=key, value=value, meta_type=MetadataType.ALL, is_regex=True
|
||||
)
|
||||
if num_changed == 0:
|
||||
alerts.warning(f"No notes found matching: {key}: {value}")
|
||||
|
||||
@@ -3,16 +3,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MetadataType(Enum):
|
||||
"""Enum class for the type of metadata."""
|
||||
|
||||
FRONTMATTER = "Frontmatter"
|
||||
INLINE = "Inline Metadata"
|
||||
TAGS = "Inline Tags"
|
||||
KEYS = "Metadata Keys Only"
|
||||
ALL = "All Metadata"
|
||||
|
||||
|
||||
class InsertLocation(Enum):
|
||||
"""Location to add metadata to notes.
|
||||
|
||||
@@ -25,3 +15,22 @@ class InsertLocation(Enum):
|
||||
TOP = "Top"
|
||||
AFTER_TITLE = "After title"
|
||||
BOTTOM = "Bottom"
|
||||
|
||||
|
||||
class MetadataType(Enum):
|
||||
"""Enum class for the type of metadata."""
|
||||
|
||||
ALL = "Inline, Frontmatter, and Tags"
|
||||
FRONTMATTER = "Frontmatter"
|
||||
INLINE = "Inline Metadata"
|
||||
KEYS = "Metadata Keys Only"
|
||||
META = "Inline and Frontmatter. No Tags"
|
||||
TAGS = "Inline Tags"
|
||||
|
||||
|
||||
class Wrapping(Enum):
|
||||
"""Wrapping for inline metadata within a block of text."""
|
||||
|
||||
BRACKETS = "Brackets"
|
||||
PARENS = "Parentheses"
|
||||
NONE = None
|
||||
|
||||
@@ -1,663 +1,138 @@
|
||||
"""Work with metadata items."""
|
||||
|
||||
import copy
|
||||
|
||||
import re
|
||||
from io import StringIO
|
||||
|
||||
from rich.columns import Columns
|
||||
from rich.table import Table
|
||||
import rich.repr
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from obsidian_metadata._utils import (
|
||||
clean_dictionary,
|
||||
delete_from_dict,
|
||||
dict_contains,
|
||||
dict_values_to_lists_strings,
|
||||
inline_metadata_from_string,
|
||||
merge_dictionaries,
|
||||
remove_markdown_sections,
|
||||
rename_in_dict,
|
||||
)
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata._utils.console import console
|
||||
from obsidian_metadata.models import Patterns # isort: ignore
|
||||
from obsidian_metadata.models.enums import MetadataType
|
||||
from obsidian_metadata.models.exceptions import (
|
||||
FrontmatterError,
|
||||
InlineMetadataError,
|
||||
InlineTagError,
|
||||
)
|
||||
|
||||
PATTERNS = Patterns()
|
||||
INLINE_TAG_KEY: str = "inline_tag"
|
||||
from obsidian_metadata.models.enums import MetadataType, Wrapping
|
||||
|
||||
|
||||
class VaultMetadata:
|
||||
"""Representation of all Metadata in the Vault.
|
||||
def dict_to_yaml(dictionary: dict[str, list[str]], sort_keys: bool = False) -> str:
|
||||
"""Return the a dictionary of {key: [values]} as a YAML string.
|
||||
|
||||
Args:
|
||||
dictionary (dict[str, list[str]]): Dictionary of {key: [values]}.
|
||||
sort_keys (bool, optional): Sort the keys. Defaults to False.
|
||||
|
||||
Returns:
|
||||
str: Frontmatter as a YAML string.
|
||||
sort_keys (bool, optional): Sort the keys. Defaults to False.
|
||||
"""
|
||||
if sort_keys:
|
||||
dictionary = dict(sorted(dictionary.items()))
|
||||
|
||||
for key, value in dictionary.items():
|
||||
if len(value) == 1:
|
||||
dictionary[key] = value[0] # type: ignore [assignment]
|
||||
|
||||
yaml = YAML()
|
||||
yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
string_stream = StringIO()
|
||||
yaml.dump(dictionary, string_stream)
|
||||
yaml_value = string_stream.getvalue()
|
||||
string_stream.close()
|
||||
if yaml_value == "{}\n":
|
||||
return ""
|
||||
return yaml_value
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class InlineField:
|
||||
"""Representation of a single inline field.
|
||||
|
||||
Attributes:
|
||||
dict (dict): Dictionary of all frontmatter and inline metadata. Does not include tags.
|
||||
frontmatter (dict): Dictionary of all frontmatter metadata.
|
||||
inline_metadata (dict): Dictionary of all inline metadata.
|
||||
tags (list): List of all tags.
|
||||
meta_type (MetadataType): Metadata category.
|
||||
clean_key (str): Cleaned key - Key without surround markdown
|
||||
key (str): Metadata key - Complete key found in note
|
||||
key_close (str): Closing key markdown.
|
||||
key_open (str): Opening key markdown.
|
||||
normalized_key (str): Key converted to lowercase w. spaces replaced with dashes
|
||||
normalized_value (str): Value stripped of leading and trailing whitespace.
|
||||
value (str): Metadata value - Complete value found in note.
|
||||
wrapping (Wrapping): Inline metadata may be wrapped with [] or ().
|
||||
"""
|
||||
|
||||
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]
|
||||
def __init__(
|
||||
self,
|
||||
meta_type: MetadataType,
|
||||
key: str,
|
||||
value: str,
|
||||
wrapping: Wrapping = Wrapping.NONE,
|
||||
is_changed: bool = False,
|
||||
) -> 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.
|
||||
self.meta_type = meta_type
|
||||
self.key = key
|
||||
self.value = value
|
||||
self.wrapping = wrapping
|
||||
self.is_changed = is_changed
|
||||
|
||||
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:
|
||||
case MetadataType.ALL:
|
||||
return dict_contains(self.dict, key, value, is_regex)
|
||||
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
|
||||
|
||||
def delete(self, key: str, value_to_delete: str = None) -> bool:
|
||||
"""Delete a key or a value from the VaultMetadata dict object. 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 = delete_from_dict(
|
||||
dictionary=self.dict,
|
||||
key=key,
|
||||
value=value_to_delete,
|
||||
is_regex=True,
|
||||
# Clean keys of surrounding markdown and convert to lowercase
|
||||
self.clean_key, self.normalized_key, self.key_open, self.key_close = (
|
||||
self._clean_key(self.key) if self.key else (None, None, "", "")
|
||||
)
|
||||
|
||||
if new_dict != self.dict:
|
||||
self.dict = dict(new_dict)
|
||||
return True
|
||||
# Normalize value for display
|
||||
self.normalized_value = "-" if re.match(r"^\s*$", self.value) else self.value.strip()
|
||||
|
||||
return False
|
||||
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
|
||||
"""Rich representation of the inline field."""
|
||||
yield "clean_key", self.clean_key
|
||||
yield "is_changed", self.is_changed
|
||||
yield "key_close", self.key_close
|
||||
yield "key_open", self.key_open
|
||||
yield "key", self.key
|
||||
yield "meta_type", self.meta_type.value
|
||||
yield "normalized_key", self.normalized_key
|
||||
yield "normalized_value", self.normalized_value
|
||||
yield "value", self.value
|
||||
yield "wrapping", self.wrapping.value
|
||||
|
||||
def print_metadata(self, area: MetadataType) -> None:
|
||||
"""Print metadata to the terminal.
|
||||
|
||||
Args:
|
||||
area (MetadataType): Type of metadata to print
|
||||
"""
|
||||
dict_to_print = None
|
||||
list_to_print = None
|
||||
match area:
|
||||
case MetadataType.INLINE:
|
||||
dict_to_print = self.inline_metadata
|
||||
header = "All inline metadata"
|
||||
case MetadataType.FRONTMATTER:
|
||||
dict_to_print = self.frontmatter
|
||||
header = "All frontmatter"
|
||||
case MetadataType.TAGS:
|
||||
list_to_print = [f"#{x}" for x in self.tags]
|
||||
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
|
||||
list_to_print = [f"#{x}" for x in self.tags]
|
||||
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",
|
||||
)
|
||||
console.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.
|
||||
|
||||
Returns:
|
||||
bool: True if a value was renamed
|
||||
"""
|
||||
new_dict = rename_in_dict(dictionary=self.dict, key=key, value_1=value_1, value_2=value_2)
|
||||
|
||||
if new_dict != self.dict:
|
||||
self.dict = dict(new_dict)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class Frontmatter:
|
||||
"""Representation of frontmatter metadata."""
|
||||
|
||||
def __init__(self, file_content: str) -> None:
|
||||
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:
|
||||
file_content (str): Content of the note.
|
||||
|
||||
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")
|
||||
yaml.allow_unicode = False
|
||||
try:
|
||||
frontmatter: dict = yaml.load(frontmatter_block)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise FrontmatterError(e) from e
|
||||
|
||||
if frontmatter is None or frontmatter == [None]:
|
||||
return {}
|
||||
|
||||
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: # noqa: PLR0911
|
||||
"""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)
|
||||
self.dict[key] = list(sorted(set(self.dict[key])))
|
||||
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, is_regex: bool = False) -> bool:
|
||||
"""Delete a value or key in the frontmatter. Regex is supported to allow deleting more than one key or value.
|
||||
|
||||
Args:
|
||||
is_regex (bool, optional): Use regex to check. Defaults to False.
|
||||
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 = delete_from_dict(
|
||||
dictionary=self.dict,
|
||||
key=key,
|
||||
value=value_to_delete,
|
||||
is_regex=is_regex,
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Compare two InlineField objects."""
|
||||
if not isinstance(other, InlineField):
|
||||
return NotImplemented
|
||||
return (
|
||||
self.key == other.key
|
||||
and self.value == other.value
|
||||
and self.meta_type == other.meta_type
|
||||
)
|
||||
|
||||
if new_dict != self.dict:
|
||||
self.dict = dict(new_dict)
|
||||
return True
|
||||
def __hash__(self) -> int:
|
||||
"""Hash the InlineField object."""
|
||||
return hash((self.key, self.value, self.meta_type))
|
||||
|
||||
return False
|
||||
def _clean_key(self, text: str) -> tuple[str, str, str, str]:
|
||||
"""Remove markdown from the key.
|
||||
|
||||
def delete_all(self) -> None:
|
||||
"""Delete all Frontmatter from the note."""
|
||||
self.dict = {}
|
||||
Creates the following attributes:
|
||||
|
||||
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.
|
||||
clean_key : The key stripped of opening and closing markdown
|
||||
normalized_key: The key converted to lowercase with spaces replaced with dashes
|
||||
key_open : The opening markdown
|
||||
key_close : The closing markdown.
|
||||
|
||||
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.
|
||||
text (str): Key to clean.
|
||||
|
||||
Returns:
|
||||
bool: True if a value was renamed
|
||||
tuple[str, str, str, str]: Cleaned key, normalized key, opening markdown, closing markdown.
|
||||
"""
|
||||
new_dict = rename_in_dict(dictionary=self.dict, key=key, value_1=value_1, value_2=value_2)
|
||||
|
||||
if new_dict != self.dict:
|
||||
self.dict = dict(new_dict)
|
||||
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) -> None:
|
||||
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 _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,
|
||||
)
|
||||
found_inline_metadata = inline_metadata_from_string(content)
|
||||
inline_metadata: dict[str, list[str]] = {}
|
||||
|
||||
try:
|
||||
for k, v in found_inline_metadata:
|
||||
if not k:
|
||||
log.trace(f"Skipping empty key associated with value: {v}")
|
||||
continue
|
||||
if k in inline_metadata:
|
||||
inline_metadata[k].append(str(v))
|
||||
else:
|
||||
inline_metadata[k] = [str(v)]
|
||||
except ValueError as e:
|
||||
raise InlineMetadataError(
|
||||
f"Error parsing inline metadata: {found_inline_metadata}"
|
||||
) from e
|
||||
except AttributeError as e:
|
||||
raise InlineMetadataError(
|
||||
f"Error parsing inline metadata: {found_inline_metadata}"
|
||||
) from e
|
||||
|
||||
return clean_dictionary(inline_metadata)
|
||||
|
||||
def add(self, key: str, value: str | list[str] = None) -> bool: # noqa: PLR0911
|
||||
"""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)
|
||||
self.dict[key] = list(sorted(set(self.dict[key])))
|
||||
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, is_regex: bool = False) -> bool:
|
||||
"""Delete a value or key in the inline metadata. Regex is supported to allow deleting more than one key or value.
|
||||
|
||||
Args:
|
||||
is_regex (bool, optional): If True, key and value are treated as regex. Defaults to False.
|
||||
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 = delete_from_dict(
|
||||
dictionary=self.dict,
|
||||
key=key,
|
||||
value=value_to_delete,
|
||||
is_regex=is_regex,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
"""
|
||||
new_dict = rename_in_dict(dictionary=self.dict, key=key, value_1=value_1, value_2=value_2)
|
||||
|
||||
if new_dict != self.dict:
|
||||
self.dict = dict(new_dict)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class InlineTags:
|
||||
"""Representation of inline tags."""
|
||||
|
||||
def __init__(self, file_content: str) -> None:
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
return sorted(
|
||||
PATTERNS.find_inline_tags.findall(
|
||||
remove_markdown_sections(
|
||||
file_content,
|
||||
strip_codeblocks=True,
|
||||
strip_inlinecode=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
except AttributeError as e:
|
||||
raise InlineTagError("Error parsing inline tags.") from e
|
||||
except TypeError as e:
|
||||
raise InlineTagError("Error parsing inline tags.") from e
|
||||
except ValueError as e:
|
||||
raise InlineTagError("Error parsing inline tags.") from e
|
||||
|
||||
def add(self, new_tag: str | list[str]) -> bool:
|
||||
"""Add a new inline tag.
|
||||
|
||||
Args:
|
||||
new_tag (str, list[str]): Tag to add.
|
||||
|
||||
Returns:
|
||||
bool: True if a tag was added.
|
||||
"""
|
||||
added_tag = False
|
||||
if isinstance(new_tag, list):
|
||||
for _tag in new_tag:
|
||||
if _tag.startswith("#"):
|
||||
_tag = _tag[1:]
|
||||
if _tag in self.list:
|
||||
continue
|
||||
self.list.append(_tag)
|
||||
added_tag = True
|
||||
|
||||
if added_tag:
|
||||
self.list = sorted(self.list)
|
||||
return True
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
new_tag (str): New value
|
||||
|
||||
Returns:
|
||||
bool: True if a value was renamed
|
||||
"""
|
||||
if old_tag in self.list and new_tag is not None and new_tag:
|
||||
self.list = sorted({new_tag if i == old_tag else i for i in self.list})
|
||||
return True
|
||||
return False
|
||||
cleaned = text
|
||||
if tmp := re.search(r"^([\*#_ `~]+)", text):
|
||||
key_open = tmp.group(0)
|
||||
cleaned = re.sub(rf"^{re.escape(key_open)}", "", text)
|
||||
else:
|
||||
key_open = ""
|
||||
|
||||
if tmp := re.search(r"([\*#_ `~]+)$", text):
|
||||
key_close = tmp.group(0)
|
||||
cleaned = re.sub(rf"{re.escape(key_close)}$", "", cleaned)
|
||||
else:
|
||||
key_close = ""
|
||||
|
||||
normalized = cleaned.replace(" ", "-").lower()
|
||||
|
||||
return cleaned, normalized, key_open, key_close
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
194
src/obsidian_metadata/models/parsers.py
Normal file
194
src/obsidian_metadata/models/parsers.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Parsers for Obsidian metadata files."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import emoji
|
||||
import regex as re
|
||||
|
||||
from obsidian_metadata.models.enums import Wrapping
|
||||
|
||||
|
||||
@dataclass
|
||||
class Parser:
|
||||
"""Regex parsers for Obsidian metadata files.
|
||||
|
||||
All methods return a list of matches
|
||||
"""
|
||||
|
||||
# Reusable regex patterns
|
||||
internal_link = r"\[\[[^\[\]]*?\]\]" # An Obsidian link of the form [[<link>]]
|
||||
chars_not_in_tags = r"\u2000-\u206F\u2E00-\u2E7F'!\"#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s"
|
||||
|
||||
# Compiled regex patterns
|
||||
tag = re.compile(
|
||||
r"""
|
||||
(?:
|
||||
(?:^|\s|\\{2}) # If tarts with newline, space, or "\\""
|
||||
(?P<tag>\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+) # capture tag
|
||||
| # Else
|
||||
(?:(?<=
|
||||
\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+
|
||||
)) # if lookbehind is a tag
|
||||
(?P<tag>\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+) # capture tag
|
||||
| # Else
|
||||
(*FAIL)
|
||||
)
|
||||
""",
|
||||
re.X,
|
||||
)
|
||||
frontmatter_complete = re.compile(r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL)
|
||||
frontmatter_data = re.compile(
|
||||
r"(?P<open>^\s*---)(?P<frontmatter>.*?)(?P<close>---)", flags=re.DOTALL
|
||||
)
|
||||
code_block = re.compile(r"```.*?```", flags=re.DOTALL)
|
||||
inline_code = re.compile(r"(?<!`{2})`[^`]+?` ?")
|
||||
inline_metadata = re.compile(
|
||||
r"""
|
||||
(?: # Conditional
|
||||
(?= # If opening wrapper is a bracket or parenthesis
|
||||
(
|
||||
(?<!\[)\[(?!\[) # Single bracket
|
||||
| # Or
|
||||
(?<!\()\((?!\() # Single parenthesis
|
||||
)
|
||||
)
|
||||
(?: # Conditional
|
||||
(?= # If opening wrapper is a bracket
|
||||
(?<!\[)\[(?!\[) # Single bracket
|
||||
)
|
||||
(?<!\[)(?P<open>\[)(?!\[) # Open bracket
|
||||
(?P<key>[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key
|
||||
(?<!:)::(?!:) # Separator
|
||||
(?P<value>.*?) # Value
|
||||
(?<!\])(?P<close>\])(?!\]) # Close bracket
|
||||
| # Else if opening wrapper is a parenthesis
|
||||
(?<!\()(?P<open>\()(?!\() # Open parens
|
||||
(?P<key>[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key
|
||||
(?<!:)::(?!:) # Separator
|
||||
(?P<value>.*?) # Value
|
||||
(?<!\))(?P<close>\))(?!\)) # Close parenthesis
|
||||
)
|
||||
| # Else grab entire line
|
||||
(?P<key>[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key
|
||||
(?<!:)::(?!:) # Separator
|
||||
(?P<value>.*) # Value
|
||||
)
|
||||
|
||||
""",
|
||||
re.X | re.I,
|
||||
)
|
||||
top_with_header = re.compile(
|
||||
r"""^\s* # Start of note
|
||||
(?P<top> # Capture the top of the note
|
||||
.* # Anything above the first header
|
||||
\#+[ ].*?[\r\n] # Full header, if it exists
|
||||
) # End capture group
|
||||
""",
|
||||
flags=re.DOTALL | re.X,
|
||||
)
|
||||
validate_key_text = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f999]")
|
||||
validate_tag_text = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")
|
||||
|
||||
def return_inline_metadata(self, line: str) -> list[tuple[str, str, Wrapping]] | None:
|
||||
"""Return a list of metadata matches for a single line.
|
||||
|
||||
Args:
|
||||
line (str): The text to search.
|
||||
|
||||
Returns:
|
||||
list[tuple[str, str, Wrapping]] | None: A list of tuples containing the key, value, and wrapping type.
|
||||
"""
|
||||
sep = r"(?<!:)::(?!:)"
|
||||
if not re.search(sep, line):
|
||||
return None
|
||||
|
||||
# Replace emoji with text
|
||||
line = emoji.demojize(line, delimiters=(";", ";"))
|
||||
|
||||
matches = []
|
||||
for match in self.inline_metadata.finditer(line):
|
||||
match match.group("open"):
|
||||
case "[":
|
||||
wrapper = Wrapping.BRACKETS
|
||||
case "(":
|
||||
wrapper = Wrapping.PARENS
|
||||
case _:
|
||||
wrapper = Wrapping.NONE
|
||||
|
||||
matches.append(
|
||||
(
|
||||
emoji.emojize(match.group("key"), delimiters=(";", ";")),
|
||||
emoji.emojize(match.group("value"), delimiters=(";", ";")),
|
||||
wrapper,
|
||||
)
|
||||
)
|
||||
|
||||
return matches
|
||||
|
||||
def return_frontmatter(self, text: str, data_only: bool = False) -> str | None:
|
||||
"""Return a list of metadata matches.
|
||||
|
||||
Args:
|
||||
text (str): The text to search.
|
||||
data_only (bool, optional): If True, only return the frontmatter data and strip the "---" lines from the returned string. Defaults to False
|
||||
|
||||
Returns:
|
||||
str | None: The frontmatter block, or None if no frontmatter is found.
|
||||
"""
|
||||
if data_only:
|
||||
result = self.frontmatter_data.search(text)
|
||||
else:
|
||||
result = self.frontmatter_complete.search(text)
|
||||
|
||||
if result:
|
||||
return result.group("frontmatter").strip()
|
||||
return None
|
||||
|
||||
def return_tags(self, text: str) -> list[str]:
|
||||
"""Return a list of tags.
|
||||
|
||||
Args:
|
||||
text (str): The text to search.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of tags.
|
||||
"""
|
||||
return [
|
||||
t.group("tag")
|
||||
for t in self.tag.finditer(text)
|
||||
if not re.match(r"^#[0-9]+$", t.group("tag"))
|
||||
]
|
||||
|
||||
def return_top_with_header(self, text: str) -> str:
|
||||
"""Returns the top content of a string until the end of the first markdown header found.
|
||||
|
||||
Args:
|
||||
text (str): The text to search.
|
||||
|
||||
Returns:
|
||||
str: The top content of the string.
|
||||
"""
|
||||
result = self.top_with_header.search(text)
|
||||
if result:
|
||||
return result.group("top")
|
||||
return None
|
||||
|
||||
def strip_frontmatter(self, text: str, data_only: bool = False) -> str:
|
||||
"""Strip frontmatter from a string.
|
||||
|
||||
Args:
|
||||
text (str): The text to search.
|
||||
data_only (bool, optional): If True, only strip the frontmatter data and leave the '---' lines. Defaults to False
|
||||
"""
|
||||
if data_only:
|
||||
return self.frontmatter_data.sub(r"\g<open>\n\g<close>", text)
|
||||
|
||||
return self.frontmatter_complete.sub("", text)
|
||||
|
||||
def strip_code_blocks(self, text: str) -> str:
|
||||
"""Strip code blocks from a string."""
|
||||
return self.code_block.sub("", text)
|
||||
|
||||
def strip_inline_code(self, text: str) -> str:
|
||||
"""Strip inline code from a string."""
|
||||
return self.inline_code.sub("", text)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""Regexes for parsing frontmatter and note content."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import regex as re
|
||||
from regex import Pattern
|
||||
|
||||
|
||||
@dataclass
|
||||
class Patterns:
|
||||
"""Regex patterns for parsing frontmatter and note content."""
|
||||
|
||||
find_inline_tags: Pattern[str] = re.compile(
|
||||
r"""
|
||||
(?:^|[ \|_,;:\*\)\[\]\\\.]|(?<!\])\() # Before tag is start of line or separator
|
||||
(?<!\/\/[\w\d_\.\(\)\/&_-]+) # Before tag is not a link
|
||||
\#([^ \|,;:\*\(\)\[\]\\\.\n#&]+) # Match tag until separator or end of line
|
||||
""",
|
||||
re.MULTILINE | re.X,
|
||||
)
|
||||
|
||||
find_inline_metadata: Pattern[str] = re.compile(
|
||||
r""" # First look for in-text key values
|
||||
(?:^\[| \[) # Find key with starting bracket
|
||||
([-_\w\d\/\*\u263a-\U0001f999]+?)::[ ]? # Find key
|
||||
(.*?)\] # Find value until closing bracket
|
||||
| # Else look for key values at start of line
|
||||
(?:^|[^ \w\d]+|^ *>?[-\d\|]?\.? ) # Any non-word or non-digit character
|
||||
([-_\w\d\/\*\u263a-\U0001f9995]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
|
||||
(.*?)$ # Capture the value
|
||||
""",
|
||||
re.X | re.MULTILINE,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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#&]")
|
||||
@@ -13,10 +13,10 @@ import questionary
|
||||
import typer
|
||||
|
||||
from obsidian_metadata.models.enums import InsertLocation, MetadataType
|
||||
from obsidian_metadata.models.patterns import Patterns
|
||||
from obsidian_metadata.models.parsers import Parser
|
||||
from obsidian_metadata.models.vault import Vault
|
||||
|
||||
PATTERNS = Patterns()
|
||||
P = Parser()
|
||||
|
||||
# Reset the default style of the questionary prompts qmark
|
||||
questionary.prompts.checkbox.DEFAULT_STYLE = questionary.Style([("qmark", "")])
|
||||
@@ -95,7 +95,7 @@ class Questions:
|
||||
if len(text) < 1:
|
||||
return "Tag cannot be empty"
|
||||
|
||||
if not self.vault.metadata.contains(area=MetadataType.TAGS, value=text):
|
||||
if not self.vault.contains_metadata(meta_type=MetadataType.TAGS, key=None, value=text):
|
||||
return f"'{text}' does not exist as a tag in the vault"
|
||||
|
||||
return True
|
||||
@@ -109,7 +109,7 @@ class Questions:
|
||||
if len(text) < 1:
|
||||
return "Key cannot be empty"
|
||||
|
||||
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text):
|
||||
if not self.vault.contains_metadata(meta_type=MetadataType.META, key=text):
|
||||
return f"'{text}' does not exist as a key in the vault"
|
||||
|
||||
return True
|
||||
@@ -128,7 +128,7 @@ class Questions:
|
||||
except re.error as error:
|
||||
return f"Invalid regex: {error}"
|
||||
|
||||
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text, is_regex=True):
|
||||
if not self.vault.contains_metadata(meta_type=MetadataType.META, key=text, is_regex=True):
|
||||
return f"'{text}' does not exist as a key in the vault"
|
||||
|
||||
return True
|
||||
@@ -142,7 +142,7 @@ class Questions:
|
||||
Returns:
|
||||
bool | str: True if the key is valid, otherwise a string with the error message.
|
||||
"""
|
||||
if PATTERNS.validate_key_text.search(text) is not None:
|
||||
if P.validate_key_text.search(text) is not None:
|
||||
return "Key cannot contain spaces or special characters"
|
||||
|
||||
if len(text) == 0:
|
||||
@@ -159,7 +159,7 @@ class Questions:
|
||||
Returns:
|
||||
bool | str: True if the tag is valid, otherwise a string with the error message.
|
||||
"""
|
||||
if PATTERNS.validate_tag_text.search(text) is not None:
|
||||
if P.validate_tag_text.search(text) is not None:
|
||||
return "Tag cannot contain spaces or special characters"
|
||||
|
||||
if len(text) == 0:
|
||||
@@ -179,8 +179,8 @@ class Questions:
|
||||
if len(text) < 1:
|
||||
return "Value cannot be empty"
|
||||
|
||||
if self.key is not None and self.vault.metadata.contains(
|
||||
area=MetadataType.ALL, key=self.key, value=text
|
||||
if self.key is not None and self.vault.contains_metadata(
|
||||
meta_type=MetadataType.ALL, key=self.key, value=text
|
||||
):
|
||||
return f"{self.key}:{text} already exists"
|
||||
|
||||
@@ -248,8 +248,8 @@ class Questions:
|
||||
if len(text) == 0:
|
||||
return True
|
||||
|
||||
if self.key is not None and not self.vault.metadata.contains(
|
||||
area=MetadataType.ALL, key=self.key, value=text
|
||||
if self.key is not None and not self.vault.contains_metadata(
|
||||
meta_type=MetadataType.ALL, key=self.key, value=text
|
||||
):
|
||||
return f"{self.key}:{text} does not exist"
|
||||
|
||||
@@ -272,8 +272,8 @@ class Questions:
|
||||
except re.error as error:
|
||||
return f"Invalid regex: {error}"
|
||||
|
||||
if self.key is not None and not self.vault.metadata.contains(
|
||||
area=MetadataType.ALL, key=self.key, value=text, is_regex=True
|
||||
if self.key is not None and not self.vault.contains_metadata(
|
||||
meta_type=MetadataType.ALL, key=self.key, value=text, is_regex=True
|
||||
):
|
||||
return f"No values in {self.key} match regex: {text}"
|
||||
|
||||
|
||||
@@ -11,14 +11,15 @@ from typing import Any
|
||||
import rich.repr
|
||||
import typer
|
||||
from rich import box
|
||||
from rich.columns import Columns
|
||||
from rich.prompt import Confirm
|
||||
from rich.table import Table
|
||||
|
||||
from obsidian_metadata._config.config import VaultConfig
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils import alerts, dict_contains, merge_dictionaries
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata._utils.console import console
|
||||
from obsidian_metadata.models import InsertLocation, MetadataType, Note, VaultMetadata
|
||||
from obsidian_metadata._utils.console import console, console_no_markup
|
||||
from obsidian_metadata.models import InsertLocation, MetadataType, Note
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -54,7 +55,9 @@ class Vault:
|
||||
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.metadata = VaultMetadata()
|
||||
self.frontmatter: dict[str, list[str]] = {}
|
||||
self.inline_meta: dict[str, list[str]] = {}
|
||||
self.tags: list[str] = []
|
||||
self.exclude_paths: list[Path] = []
|
||||
|
||||
for p in config.exclude_paths:
|
||||
@@ -104,16 +107,33 @@ class Vault:
|
||||
]
|
||||
|
||||
if _filter.tag_filter is not None:
|
||||
notes_list = [n for n in notes_list if n.contains_tag(_filter.tag_filter)]
|
||||
notes_list = [
|
||||
n
|
||||
for n in notes_list
|
||||
if n.contains_metadata(
|
||||
MetadataType.TAGS, search_key="", search_value=_filter.tag_filter
|
||||
)
|
||||
]
|
||||
|
||||
if _filter.key_filter is not None and _filter.value_filter is not None:
|
||||
notes_list = [
|
||||
n
|
||||
for n in notes_list
|
||||
if n.contains_metadata(_filter.key_filter, _filter.value_filter)
|
||||
if n.contains_metadata(
|
||||
meta_type=MetadataType.META,
|
||||
search_key=_filter.key_filter,
|
||||
search_value=_filter.value_filter,
|
||||
)
|
||||
]
|
||||
|
||||
if _filter.key_filter is not None and _filter.value_filter is None:
|
||||
notes_list = [n for n in notes_list if n.contains_metadata(_filter.key_filter)]
|
||||
notes_list = [
|
||||
n
|
||||
for n in notes_list
|
||||
if n.contains_metadata(
|
||||
MetadataType.META, search_key=_filter.key_filter, search_value=None
|
||||
)
|
||||
]
|
||||
|
||||
return notes_list
|
||||
|
||||
@@ -167,37 +187,60 @@ class Vault:
|
||||
]
|
||||
|
||||
def _rebuild_vault_metadata(self) -> None:
|
||||
"""Rebuild vault metadata."""
|
||||
self.metadata = VaultMetadata()
|
||||
"""Rebuild vault metadata. Indexes all frontmatter, inline metadata, and tags and adds them to dictionary objects."""
|
||||
with console.status(
|
||||
"Processing notes... [dim](Can take a while for a large vault)[/]",
|
||||
spinner="bouncingBall",
|
||||
):
|
||||
vault_frontmatter = {}
|
||||
vault_inline_meta = {}
|
||||
vault_tags = []
|
||||
for _note in self.notes_in_scope:
|
||||
self.metadata.index_metadata(
|
||||
area=MetadataType.FRONTMATTER, metadata=_note.frontmatter.dict
|
||||
)
|
||||
self.metadata.index_metadata(
|
||||
area=MetadataType.INLINE, metadata=_note.inline_metadata.dict
|
||||
)
|
||||
self.metadata.index_metadata(
|
||||
area=MetadataType.TAGS,
|
||||
metadata=_note.tags.list,
|
||||
)
|
||||
for field in _note.metadata:
|
||||
match field.meta_type:
|
||||
case MetadataType.FRONTMATTER:
|
||||
if field.clean_key not in vault_frontmatter:
|
||||
vault_frontmatter[field.clean_key] = (
|
||||
[field.normalized_value]
|
||||
if field.normalized_value != "-"
|
||||
else []
|
||||
)
|
||||
elif field.normalized_value != "-":
|
||||
vault_frontmatter[field.clean_key].append(field.normalized_value)
|
||||
case MetadataType.INLINE:
|
||||
if field.clean_key not in vault_inline_meta:
|
||||
vault_inline_meta[field.clean_key] = (
|
||||
[field.normalized_value]
|
||||
if field.normalized_value != "-"
|
||||
else []
|
||||
)
|
||||
elif field.normalized_value != "-":
|
||||
vault_inline_meta[field.clean_key].append(field.normalized_value)
|
||||
case MetadataType.TAGS:
|
||||
if field.normalized_value not in vault_tags:
|
||||
vault_tags.append(field.normalized_value)
|
||||
|
||||
self.frontmatter = {
|
||||
k: sorted(list(set(v))) for k, v in sorted(vault_frontmatter.items())
|
||||
}
|
||||
self.inline_meta = {
|
||||
k: sorted(list(set(v))) for k, v in sorted(vault_inline_meta.items())
|
||||
}
|
||||
self.tags = sorted(list(set(vault_tags)))
|
||||
|
||||
def add_metadata(
|
||||
self,
|
||||
area: MetadataType,
|
||||
meta_type: MetadataType,
|
||||
key: str = None,
|
||||
value: str | list[str] = None,
|
||||
value: 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.
|
||||
meta_type (MetadataType): Area of metadata to add to.
|
||||
key (str): Key to add.
|
||||
value (str|list, optional): Value to add.
|
||||
value (str, optional): Value to add.
|
||||
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
|
||||
|
||||
Returns:
|
||||
@@ -209,7 +252,9 @@ class Vault:
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.add_metadata(area=area, key=key, value=value, location=location):
|
||||
if _note.add_metadata(
|
||||
meta_type=meta_type, added_key=key, added_value=value, location=location
|
||||
):
|
||||
log.trace(f"Added metadata to {_note.note_path}")
|
||||
num_changed += 1
|
||||
|
||||
@@ -257,6 +302,43 @@ class Vault:
|
||||
log.trace(f"writing to {_note.note_path}")
|
||||
_note.commit()
|
||||
|
||||
def contains_metadata(
|
||||
self, meta_type: MetadataType, key: str, value: str = None, is_regex: bool = False
|
||||
) -> bool:
|
||||
"""Check if the vault contains metadata.
|
||||
|
||||
Args:
|
||||
meta_type (MetadataType): Area of metadata to check.
|
||||
key (str): Key to check.
|
||||
value (str, optional): Value to check. Defaults to None.
|
||||
is_regex (bool, optional): Whether the value is a regex. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bool: Whether the vault contains the metadata.
|
||||
"""
|
||||
if meta_type == MetadataType.FRONTMATTER and key is not None:
|
||||
return dict_contains(self.frontmatter, key, value, is_regex)
|
||||
|
||||
if meta_type == MetadataType.INLINE and key is not None:
|
||||
return dict_contains(self.inline_meta, key, value, is_regex)
|
||||
|
||||
if meta_type == MetadataType.TAGS and value is not None:
|
||||
if not is_regex:
|
||||
value = f"^{re.escape(value)}$"
|
||||
return any(re.search(value, item) for item in self.tags)
|
||||
|
||||
if meta_type == MetadataType.META:
|
||||
return self.contains_metadata(
|
||||
MetadataType.FRONTMATTER, key, value, is_regex
|
||||
) or self.contains_metadata(MetadataType.INLINE, key, value, is_regex)
|
||||
|
||||
if meta_type == MetadataType.ALL:
|
||||
return self.contains_metadata(
|
||||
MetadataType.TAGS, key, value, is_regex
|
||||
) or self.contains_metadata(MetadataType.META, key, value, is_regex)
|
||||
|
||||
return False
|
||||
|
||||
def delete_backup(self) -> None:
|
||||
"""Delete the vault backup."""
|
||||
log.debug("Deleting vault backup")
|
||||
@@ -280,7 +362,7 @@ class Vault:
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.delete_tag(tag):
|
||||
if _note.delete_metadata(MetadataType.TAGS, value=tag):
|
||||
log.trace(f"Deleted tag from {_note.note_path}")
|
||||
num_changed += 1
|
||||
|
||||
@@ -293,13 +375,13 @@ class Vault:
|
||||
self,
|
||||
key: str,
|
||||
value: str = None,
|
||||
area: MetadataType = MetadataType.ALL,
|
||||
meta_type: MetadataType = MetadataType.ALL,
|
||||
is_regex: bool = False,
|
||||
) -> int:
|
||||
"""Delete metadata in the vault.
|
||||
|
||||
Args:
|
||||
area (MetadataType): Area of metadata to delete from.
|
||||
meta_type (MetadataType): Area of metadata to delete from.
|
||||
is_regex (bool): Whether to use regex for key and value. Defaults to False.
|
||||
key (str): Key to delete. Regex is supported
|
||||
value (str, optional): Value to delete. Regex is supported
|
||||
@@ -310,7 +392,7 @@ class Vault:
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.delete_metadata(key=key, value=value, area=area, is_regex=is_regex):
|
||||
if _note.delete_metadata(meta_type=meta_type, key=key, value=value, is_regex=is_regex):
|
||||
log.trace(f"Deleted metadata from {_note.note_path}")
|
||||
num_changed += 1
|
||||
|
||||
@@ -319,7 +401,7 @@ class Vault:
|
||||
|
||||
return num_changed
|
||||
|
||||
def export_metadata(self, path: str, export_format: str = "csv") -> None: # noqa: C901
|
||||
def export_metadata(self, path: str, export_format: str = "csv") -> None:
|
||||
"""Write metadata to a csv file.
|
||||
|
||||
Args:
|
||||
@@ -337,28 +419,28 @@ class Vault:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["Metadata Type", "Key", "Value"])
|
||||
|
||||
for key, value in self.metadata.frontmatter.items():
|
||||
if isinstance(value, list):
|
||||
if len(value) > 0:
|
||||
for v in value:
|
||||
writer.writerow(["frontmatter", key, v])
|
||||
else:
|
||||
for key, value in self.frontmatter.items():
|
||||
if len(value) > 0:
|
||||
for v in value:
|
||||
writer.writerow(["frontmatter", key, v])
|
||||
else:
|
||||
writer.writerow(["frontmatter", key, ""])
|
||||
|
||||
for key, value in self.metadata.inline_metadata.items():
|
||||
if isinstance(value, list):
|
||||
if len(value) > 0:
|
||||
for v in value:
|
||||
writer.writerow(["inline_metadata", key, v])
|
||||
else:
|
||||
writer.writerow(["frontmatter", key, v])
|
||||
for tag in self.metadata.tags:
|
||||
for key, value in self.inline_meta.items():
|
||||
if len(value) > 0:
|
||||
for v in value:
|
||||
writer.writerow(["inline_metadata", key, v])
|
||||
else:
|
||||
writer.writerow(["inline_metadata", key, ""])
|
||||
|
||||
for tag in self.tags:
|
||||
writer.writerow(["tags", "", f"{tag}"])
|
||||
|
||||
case "json":
|
||||
dict_to_dump = {
|
||||
"frontmatter": self.metadata.dict,
|
||||
"inline_metadata": self.metadata.inline_metadata,
|
||||
"tags": self.metadata.tags,
|
||||
"frontmatter": self.frontmatter,
|
||||
"inline_metadata": self.inline_meta,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
with export_file.open(mode="w", encoding="utf-8") as f:
|
||||
@@ -380,26 +462,21 @@ class Vault:
|
||||
writer.writerow(["path", "type", "key", "value"])
|
||||
|
||||
for _note in self.all_notes:
|
||||
for key, value in _note.frontmatter.dict.items():
|
||||
for v in value:
|
||||
writer.writerow(
|
||||
[_note.note_path.relative_to(self.vault_path), "frontmatter", key, v]
|
||||
)
|
||||
|
||||
for key, value in _note.inline_metadata.dict.items():
|
||||
for v in value:
|
||||
writer.writerow(
|
||||
[
|
||||
_note.note_path.relative_to(self.vault_path),
|
||||
"inline_metadata",
|
||||
key,
|
||||
v,
|
||||
]
|
||||
)
|
||||
|
||||
for tag in _note.tags.list:
|
||||
for field in sorted(
|
||||
_note.metadata,
|
||||
key=lambda x: (
|
||||
x.meta_type.name,
|
||||
x.clean_key,
|
||||
x.normalized_value,
|
||||
),
|
||||
):
|
||||
writer.writerow(
|
||||
[_note.note_path.relative_to(self.vault_path), "tag", "", f"{tag}"]
|
||||
[
|
||||
_note.note_path.relative_to(self.vault_path),
|
||||
field.meta_type.name,
|
||||
field.clean_key if field.clean_key is not None else "",
|
||||
field.normalized_value if field.normalized_value != "-" else "",
|
||||
]
|
||||
)
|
||||
|
||||
def get_changed_notes(self) -> list[Note]:
|
||||
@@ -430,14 +507,14 @@ class Vault:
|
||||
table.add_row("Notes with changes", str(len(self.get_changed_notes())))
|
||||
table.add_row("Insert Location", str(self.insert_location.value))
|
||||
|
||||
console.print(table)
|
||||
console_no_markup.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)
|
||||
console_no_markup.print(table)
|
||||
|
||||
def move_inline_metadata(self, location: InsertLocation) -> int:
|
||||
"""Move all inline metadata to the selected location.
|
||||
@@ -451,11 +528,15 @@ class Vault:
|
||||
num_changed = 0
|
||||
|
||||
for _note in self.notes_in_scope:
|
||||
if _note.write_delete_inline_metadata():
|
||||
log.trace(f"Deleted inline metadata from {_note.note_path}")
|
||||
if _note.transpose_metadata(
|
||||
begin=MetadataType.INLINE,
|
||||
end=MetadataType.INLINE,
|
||||
key=None,
|
||||
value=None,
|
||||
location=location,
|
||||
):
|
||||
log.trace(f"Moved inline metadata in {_note.note_path}")
|
||||
num_changed += 1
|
||||
_note.write_all_inline_metadata(location)
|
||||
log.trace(f"Wrote all inline metadata to {_note.note_path}")
|
||||
|
||||
if num_changed > 0:
|
||||
self._rebuild_vault_metadata()
|
||||
@@ -466,6 +547,50 @@ class Vault:
|
||||
"""Count number of excluded notes."""
|
||||
return len(self.all_notes) - len(self.notes_in_scope)
|
||||
|
||||
def print_metadata(self, meta_type: MetadataType = MetadataType.ALL) -> None:
|
||||
"""Print metadata for the vault."""
|
||||
dict_to_print = None
|
||||
list_to_print = None
|
||||
match meta_type:
|
||||
case MetadataType.INLINE:
|
||||
dict_to_print = self.inline_meta
|
||||
header = "All inline metadata"
|
||||
case MetadataType.FRONTMATTER:
|
||||
dict_to_print = self.frontmatter
|
||||
header = "All frontmatter"
|
||||
case MetadataType.TAGS:
|
||||
list_to_print = [f"#{x}" for x in self.tags]
|
||||
header = "All inline tags"
|
||||
case MetadataType.KEYS:
|
||||
list_to_print = sorted(
|
||||
merge_dictionaries(self.frontmatter, self.inline_meta).keys()
|
||||
)
|
||||
header = "All Keys"
|
||||
case MetadataType.ALL:
|
||||
dict_to_print = merge_dictionaries(self.frontmatter, self.inline_meta)
|
||||
list_to_print = [f"#{x}" for x in self.tags]
|
||||
header = "All metadata"
|
||||
|
||||
if dict_to_print is not None:
|
||||
table = Table(title=header, show_footer=False, show_lines=True)
|
||||
table.add_column("Keys", style="bold")
|
||||
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"{key}", str(values))
|
||||
console_no_markup.print(table)
|
||||
|
||||
if list_to_print is not None:
|
||||
columns = Columns(
|
||||
sorted(list_to_print),
|
||||
equal=True,
|
||||
expand=True,
|
||||
title=header if meta_type != MetadataType.ALL else "All inline tags",
|
||||
)
|
||||
console_no_markup.print(columns)
|
||||
|
||||
def rename_tag(self, old_tag: str, new_tag: str) -> int:
|
||||
"""Rename an inline tag in the vault.
|
||||
|
||||
@@ -518,7 +643,7 @@ class Vault:
|
||||
begin: MetadataType,
|
||||
end: MetadataType,
|
||||
key: str = None,
|
||||
value: str | list[str] = None,
|
||||
value: str = None,
|
||||
location: InsertLocation = None,
|
||||
) -> int:
|
||||
"""Transpose metadata from one type to another.
|
||||
@@ -546,15 +671,15 @@ class Vault:
|
||||
location=location,
|
||||
):
|
||||
num_changed += 1
|
||||
log.trace(f"Transposed metadata in {_note.note_path}")
|
||||
|
||||
if num_changed > 0:
|
||||
self._rebuild_vault_metadata()
|
||||
log.trace(f"Transposed metadata in {_note.note_path}")
|
||||
|
||||
return num_changed
|
||||
|
||||
def update_from_dict(self, dictionary: dict[str, Any]) -> int:
|
||||
"""Update note metadata from a dictionary. This is a destructive operation. All metadata in the specified notes not in the dictionary will be removed.
|
||||
"""Update note metadata from a dictionary. This method is used when updating note metadata from a CSV file. This is a destructive operation. All existing metadata in the specified notes not in the dictionary will be removed.
|
||||
|
||||
Requires a dictionary with the note path as the key and a dictionary of metadata as the value. Each key must have a list of associated dictionaries in the following format:
|
||||
|
||||
@@ -577,25 +702,32 @@ class Vault:
|
||||
if str(path) in dictionary:
|
||||
log.debug(f"Bulk update metadata for '{path}'")
|
||||
num_changed += 1
|
||||
_note.delete_all_metadata()
|
||||
|
||||
# Deleta all existing metadata in the note
|
||||
_note.delete_metadata(meta_type=MetadataType.META, key=r".*", is_regex=True)
|
||||
_note.delete_metadata(meta_type=MetadataType.TAGS, value=r".*", is_regex=True)
|
||||
|
||||
# Add the new metadata
|
||||
for row in dictionary[str(path)]:
|
||||
if row["type"].lower() == "frontmatter":
|
||||
_note.add_metadata(
|
||||
area=MetadataType.FRONTMATTER, key=row["key"], value=row["value"]
|
||||
meta_type=MetadataType.FRONTMATTER,
|
||||
added_key=row["key"],
|
||||
added_value=row["value"],
|
||||
)
|
||||
|
||||
if row["type"].lower() == "inline_metadata":
|
||||
_note.add_metadata(
|
||||
area=MetadataType.INLINE,
|
||||
key=row["key"],
|
||||
value=row["value"],
|
||||
meta_type=MetadataType.INLINE,
|
||||
added_key=row["key"],
|
||||
added_value=row["value"],
|
||||
location=self.insert_location,
|
||||
)
|
||||
|
||||
if row["type"].lower() == "tag":
|
||||
_note.add_metadata(
|
||||
area=MetadataType.TAGS,
|
||||
value=row["value"],
|
||||
meta_type=MetadataType.TAGS,
|
||||
added_value=row["value"],
|
||||
location=self.insert_location,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user