From 2e61a92ad1491927695558abe7c8eb02ec08939f Mon Sep 17 00:00:00 2001 From: Nathaniel Landau Date: Fri, 5 May 2023 13:09:59 -0400 Subject: [PATCH] feat: greatly improve capturing all formats of inline metadata (#41) feat: greatly improve capturing metadata all formats of inline metadata --- README.md | 29 + poetry.lock | 16 +- pyproject.toml | 1 + src/obsidian_metadata/_utils/__init__.py | 6 - src/obsidian_metadata/_utils/console.py | 1 + src/obsidian_metadata/_utils/utilities.py | 86 -- src/obsidian_metadata/models/__init__.py | 18 +- src/obsidian_metadata/models/application.py | 25 +- src/obsidian_metadata/models/enums.py | 29 +- src/obsidian_metadata/models/metadata.py | 743 ++--------- src/obsidian_metadata/models/notes.py | 1007 +++++++++------ src/obsidian_metadata/models/parsers.py | 194 +++ src/obsidian_metadata/models/patterns.py | 62 - src/obsidian_metadata/models/questions.py | 26 +- src/obsidian_metadata/models/vault.py | 294 +++-- tests/alerts_test.py | 120 +- tests/application_test.py | 71 +- tests/cli_test.py | 6 +- tests/config_test.py | 2 +- tests/conftest.py | 2 +- tests/fixtures/broken_frontmatter.md | 6 - tests/fixtures/sample_note.md | 39 - tests/fixtures/test_vault/sample_note.md | 42 + tests/fixtures/test_vault/test1.md | 47 - tests/helpers.py | 2 +- tests/metadata_frontmatter_test.py | 531 -------- tests/metadata_inline_test.py | 455 ------- tests/metadata_tags_test.py | 367 ------ tests/metadata_test.py | 209 ++++ tests/metadata_vault_test.py | 814 ------------ tests/notes/note_init_test.py | 230 ++++ tests/notes/note_methods_test.py | 1095 ++++++++++++++++ tests/notes_test.py | 1233 ------------------- tests/parsers_test.py | 364 ++++++ tests/patterns_test.py | 225 ---- tests/questions_test.py | 23 +- tests/utilities_test.py | 488 -------- tests/vault_test.py | 681 +++++----- 38 files changed, 3634 insertions(+), 5955 deletions(-) create mode 100644 src/obsidian_metadata/models/parsers.py delete mode 100644 src/obsidian_metadata/models/patterns.py delete mode 100644 tests/fixtures/broken_frontmatter.md delete mode 100644 tests/fixtures/sample_note.md create mode 100644 tests/fixtures/test_vault/sample_note.md delete mode 100644 tests/fixtures/test_vault/test1.md delete mode 100644 tests/metadata_frontmatter_test.py delete mode 100644 tests/metadata_inline_test.py delete mode 100644 tests/metadata_tags_test.py create mode 100644 tests/metadata_test.py delete mode 100644 tests/metadata_vault_test.py create mode 100644 tests/notes/note_init_test.py create mode 100644 tests/notes/note_methods_test.py delete mode 100644 tests/notes_test.py create mode 100644 tests/parsers_test.py delete mode 100644 tests/patterns_test.py diff --git a/README.md b/README.md index 2d26c84..3bd1d4a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,35 @@ When transposing to inline metadata, the `insert location` value in the config f - **Commit changes to the vault** +### Known Limitations + +Multi-level frontmatter is not supported. + +```yaml +# This works perfectly well +--- +key: "value" +key2: + - one + - two + - three +key3: ["foo", "bar", "baz"] +key4: value + +# This will not work +--- +key1: + key2: + - one + - two + - three + key3: + - one + - two + - three +--- +``` + ### Configuration `obsidian-metadata` requires a configuration file at `~/.obsidian_metadata.toml`. On first run, this file will be created. You can specify a new location for the configuration file with the `--config-file` option. diff --git a/poetry.lock b/poetry.lock index b38e717..900abbd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -251,6 +251,20 @@ files = [ {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, ] +[[package]] +name = "emoji" +version = "2.2.0" +description = "Emoji for Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "emoji-2.2.0.tar.gz", hash = "sha256:a2986c21e4aba6b9870df40ef487a17be863cb7778dcf1c01e25917b7cd210bb"}, +] + +[package.extras] +dev = ["coverage", "coveralls", "pytest"] + [[package]] name = "exceptiongroup" version = "1.1.1" @@ -1321,4 +1335,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "791c4a20b082a0ae43b35023ff9db5c9cc212f44c4ec5180a10042970f796af5" +content-hash = "26942a873c9b2cf86691e8cfee4ad0eaa673254b189010ec6600b448fdbad831" diff --git a/pyproject.toml b/pyproject.toml index fd23e04..516f195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ [tool.poetry.dependencies] charset-normalizer = "2.1.0" + emoji = "^2.2.0" loguru = "^0.6.0" python = "^3.10" questionary = "^1.10.0" diff --git a/src/obsidian_metadata/_utils/__init__.py b/src/obsidian_metadata/_utils/__init__.py index 3740228..2b0f206 100644 --- a/src/obsidian_metadata/_utils/__init__.py +++ b/src/obsidian_metadata/_utils/__init__.py @@ -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", ] diff --git a/src/obsidian_metadata/_utils/console.py b/src/obsidian_metadata/_utils/console.py index 8d3adc8..0ba7fee 100644 --- a/src/obsidian_metadata/_utils/console.py +++ b/src/obsidian_metadata/_utils/console.py @@ -2,3 +2,4 @@ from rich.console import Console console = Console() +console_no_markup = Console(markup=False) diff --git a/src/obsidian_metadata/_utils/utilities.py b/src/obsidian_metadata/_utils/utilities.py index 63018c0..9c3b1c4 100644 --- a/src/obsidian_metadata/_utils/utilities.py +++ b/src/obsidian_metadata/_utils/utilities.py @@ -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"(? dict[str, list[dict[str, str]]]: diff --git a/src/obsidian_metadata/models/__init__.py b/src/obsidian_metadata/models/__init__.py index da643d2..98dc3b0 100644 --- a/src/obsidian_metadata/models/__init__.py +++ b/src/obsidian_metadata/models/__init__.py @@ -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", ] diff --git a/src/obsidian_metadata/models/application.py b/src/obsidian_metadata/models/application.py index 5cdccf9..5f11ce2 100644 --- a/src/obsidian_metadata/models/application.py +++ b/src/obsidian_metadata/models/application.py @@ -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}") diff --git a/src/obsidian_metadata/models/enums.py b/src/obsidian_metadata/models/enums.py index 896569d..655bc66 100644 --- a/src/obsidian_metadata/models/enums.py +++ b/src/obsidian_metadata/models/enums.py @@ -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 diff --git a/src/obsidian_metadata/models/metadata.py b/src/obsidian_metadata/models/metadata.py index 22b0ad9..c9f912f 100644 --- a/src/obsidian_metadata/models/metadata.py +++ b/src/obsidian_metadata/models/metadata.py @@ -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 diff --git a/src/obsidian_metadata/models/notes.py b/src/obsidian_metadata/models/notes.py index ae58dcd..b8cce06 100644 --- a/src/obsidian_metadata/models/notes.py +++ b/src/obsidian_metadata/models/notes.py @@ -10,25 +10,26 @@ import rich.repr import typer from charset_normalizer import from_path from rich.table import Table +from ruamel.yaml import YAML -from obsidian_metadata._utils import alerts, inline_metadata_from_string +from obsidian_metadata._utils import alerts from obsidian_metadata._utils.alerts import logger as log -from obsidian_metadata._utils.console import console +from obsidian_metadata._utils.console import console_no_markup from obsidian_metadata.models import ( - Frontmatter, - InlineMetadata, - InlineTags, + InlineField, InsertLocation, MetadataType, - Patterns, + Wrapping, + dict_to_yaml, ) from obsidian_metadata.models.exceptions import ( FrontmatterError, InlineMetadataError, InlineTagError, ) +from obsidian_metadata.models.parsers import Parser -PATTERNS = Patterns() +P = Parser() @rich.repr.auto @@ -68,9 +69,8 @@ class Note: raise typer.Exit(code=1) from e try: - self.frontmatter: Frontmatter = Frontmatter(self.file_content) - self.inline_metadata: InlineMetadata = InlineMetadata(self.file_content) - self.tags: InlineTags = InlineTags(self.file_content) + self.metadata = self._grab_all_metadata(self.file_content) + self.original_metadata = copy.deepcopy(self.metadata) except FrontmatterError as e: alerts.error(f"Invalid frontmatter: {self.note_path}\n{e}") raise typer.Exit(code=1) from e @@ -85,70 +85,347 @@ class Note: """Define rich representation of Vault.""" yield "dry_run", self.dry_run yield "encoding", self.encoding - yield "frontmatter", self.frontmatter - yield "inline_metadata", self.inline_metadata yield "note_path", self.note_path - yield "tags", self.tags - def add_metadata( # noqa: C901 + def _grab_all_metadata(self, text: str) -> list[InlineField]: + """Grab all metadata from the note and create list of InlineField objects.""" + all_metadata = [] # List of all metadata to be returned + + # First parse the frontmatter + frontmatter_block = P.return_frontmatter(text, data_only=True) + if frontmatter_block: + 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 + + for key, value in frontmatter.items(): + if isinstance(value, dict): + raise FrontmatterError( + f"Nested frontmatter is not supported.\nKey: {key}\n Value: {value}" + ) + if isinstance(value, list): + for item in value: + all_metadata.append( + InlineField( + meta_type=MetadataType.FRONTMATTER, + key=key, + value=str(item), + ) + ) + else: + all_metadata.append( + InlineField( + meta_type=MetadataType.FRONTMATTER, + key=key, + value=str(value), + ) + ) + + # Then strip all frontmatter, code blocks, and inline code from the text and parse tags and inline metadata + text = P.strip_frontmatter(P.strip_code_blocks(P.strip_inline_code(text))) + + # Parse text line by line + for _line in text.splitlines(): + tags = [ + InlineField(meta_type=MetadataType.TAGS, key=None, value=tag.lstrip("#")) + for tag in P.return_tags(_line) + ] + all_metadata.extend(tags) + + inline_metadata = P.return_inline_metadata(_line) + if inline_metadata: + # for item in inline_metadata: + for key, value, wrapper in inline_metadata: + all_metadata.append( + InlineField( + meta_type=MetadataType.INLINE, + key=key, + value=value, + wrapping=wrapper, + ) + ) + + return list(set(all_metadata)) + + def _delete_inline_metadata(self, source: InlineField) -> bool: + """Delete a specified inline metadata field from the note. + + Args: + source (InlineField): InlineField object to delete. + + Returns: + bool: True if successful, False if not. + """ + if source.meta_type != MetadataType.INLINE: + log.error("Must provide inline metadata to _sub_inline_metadata") + raise typer.Exit(code=1) + + remove_string = f"{re.escape(source.key)}::{re.escape(source.value)}" + if source.wrapping == Wrapping.NONE: + return self.sub( + rf"( *> *){remove_string}\s+|{remove_string}(\s+|$)", + "", + is_regex=True, + ) + + if source.wrapping == Wrapping.PARENS: + return self.sub( + rf" ?\({remove_string}\)", + "", + is_regex=True, + ) + + if source.wrapping == Wrapping.BRACKETS: + return self.sub( + rf" ?\[{remove_string}\]", + "", + is_regex=True, + ) + + return False + + def _edit_inline_metadata( + self, source: InlineField, new_key: str, new_value: str = None + ) -> InlineField: + """Edit an inline metadata field. Takes an InlineField object and a new key and/or value and edits the inline metadata in the object and note accordingly. + + Args: + source (InlineField): InlineField object to edit. + new_key (str, optional): New key to use. + new_value (str, optional): New value to use. + + Returns: + InlineField: New InlineField object. + """ + if source.meta_type != MetadataType.INLINE: + log.error("Must provide inline metadata to _sub_inline_metadata") + raise typer.Exit(code=1) + + new_inline_field = InlineField( + meta_type=MetadataType.INLINE, + key=f"{source.key_open}{new_key}{source.key_close}", + value=new_value if new_value else source.value, + wrapping=source.wrapping, + is_changed=True, + ) + + if source.wrapping == Wrapping.NONE: + self.sub( + f"{source.key}::{source.value}", + f"{new_inline_field.key}:: {new_inline_field.value.lstrip()}", + ) + + if source.wrapping == Wrapping.PARENS: + self.sub( + pattern=f"({source.key}::{source.value})", + replacement=f"({new_inline_field.key}:: {new_inline_field.value.lstrip()})", + ) + if source.wrapping == Wrapping.BRACKETS: + self.sub( + pattern=f"[{source.key}::{source.value}]", + replacement=f"[{new_inline_field.key}:: {new_inline_field.value.lstrip()}]", + ) + + self.metadata.remove(source) + self.metadata.append(new_inline_field) + return new_inline_field + + def _find_matching_fields( + self, meta_type: MetadataType, key: str = None, value: str = None, is_regex: bool = False + ) -> list[InlineField]: + """Create a list of InlineField objects matching the specified key and/or value. + + - When key and value are None, all fields of the specified type are returned. + - When value is None, all fields of the specified type with the specified key are returned. + - When key is None, all fields of the specified type with the specified value are returned. + + + Args: + meta_type (MetadataType): Type of metadata to search for. + key (str, optional): Key to match. + value (str, optional): Value to match. + is_regex (bool, optional): Whether to treat the key and value as regex. + + Returns: + list[InlineField]: List of matching InlineField objects. + + # TODO: Add support for fields where value is a [[link]] + """ + if meta_type == MetadataType.TAGS and value: + value = value.lstrip("#") + + if not is_regex: + key = f"^{re.escape(key)}$" if key else None + value = f"^{re.escape(value)}$" if value else None + + matching_inline_fields = [] + if key is None and value is None: + matching_inline_fields.extend([x for x in self.metadata if x.meta_type == meta_type]) + elif value is None: + matching_inline_fields.extend( + [ + x + for x in self.metadata + if x.meta_type == meta_type and re.search(key, x.clean_key) + ] + ) + elif key is None: + matching_inline_fields.extend( + [ + x + for x in self.metadata + if x.meta_type == meta_type and re.search(value, x.normalized_value) + ] + ) + else: + matching_inline_fields.extend( + [ + x + for x in self.metadata + if x.meta_type == meta_type + and re.search(key, x.clean_key) + and re.search(value, x.normalized_value) + ] + ) + + return matching_inline_fields + + def _update_inline_metadata( + self, source: InlineField, new_key: str = None, new_value: str = None + ) -> bool: + """Update an inline metadata field. Takes an InlineField object and a new key and/or value and updates the inline metadata in the object and note accordingly. + + Args: + source (InlineField): InlineField object to update. + new_key (str, optional): New key to use. + new_value (str, optional): New value to use. + + Returns: + bool: True if successful, False if not. + + # TODO: Add support for fields where value is a [[link]] + """ + if source.meta_type != MetadataType.INLINE: + log.error("Must provide inline metadata to _sub_inline_metadata") + raise typer.Exit(code=1) + + if new_key is None and new_value is None: + log.error("Must provide new key or value to _sub_inline_metadata") + raise typer.Exit(code=1) + + original_key = re.escape(source.key) + original_value = re.escape(source.value) + + source.key = f"{source.key_open}{new_key}{source.key_close}" if new_key else source.key + source.clean_key = ( + f"{source.key_open}{new_key}{source.key_close}" if new_key else source.clean_key + ) + source.normalized_key = ( + new_key.replace(" ", "-").lower() if new_key else source.normalized_key + ) + source.value = f" {new_value.lstrip()}" if new_value else source.value + source.normalized_value = new_value if new_value else source.normalized_value + source.is_changed = True + + match source.wrapping: + case Wrapping.NONE: + return self.sub( + f"{original_key}:: ?{original_value}", + f"{source.key}::{source.value}", + is_regex=True, + ) + case Wrapping.PARENS: + return self.sub( + rf"\({original_key}:: ?{original_value}\)", + f"({source.key}::{source.value})", + is_regex=True, + ) + case Wrapping.BRACKETS: + return self.sub( + rf"\[{original_key}::{original_value}\]", + f"[{source.key}::{source.value}]", + is_regex=True, + ) + + def add_metadata( self, - area: MetadataType, - key: str = None, - value: str | list[str] = None, + meta_type: MetadataType, + added_key: str = None, + added_value: str = None, location: InsertLocation = None, ) -> bool: """Add metadata to the note if it does not already exist. This method adds specified metadata to the appropriate MetadataType object AND writes the new metadata to the note's file. Args: - area (MetadataType): Area to add metadata to. - key (str, optional): Key to add + added_key (str, optional): Key to add + added_value (str, optional): Value to add. location (InsertLocation, optional): Location to add inline metadata and tags. - value (str, optional): Value to add. + meta_type (MetadataType): Area to add metadata to. Returns: bool: Whether the metadata was added. """ - match area: - case MetadataType.FRONTMATTER if self.frontmatter.add(key, value): - self.write_frontmatter() - return True + match meta_type: + case MetadataType.FRONTMATTER | MetadataType.INLINE: + if added_key is None or re.match(r"^\s*$", added_key): + log.error("A valid key must be specified.") + raise typer.Exit(code=1) + if self.contains_metadata(meta_type, added_key, added_value): + return False - case MetadataType.INLINE: - if value is None and self.inline_metadata.add(key): - line = f"{key}::" - self.write_string(new_string=line, location=location) - return True + new_meta = InlineField( + meta_type=meta_type, key=added_key, value=added_value, is_changed=True + ) - new_values = [] - if isinstance(value, list): - new_values = [_v for _v in value if self.inline_metadata.add(key, _v)] - elif self.inline_metadata.add(key, value): - new_values = [value] - - if new_values: - for value in new_values: - self.write_string(new_string=f"{key}:: {value}", location=location) - return True + match meta_type: + case MetadataType.FRONTMATTER: + self.metadata.append(new_meta) + self.write_frontmatter() + return True + case MetadataType.INLINE: + self.metadata.append(new_meta) + self.write_string( + f"{added_key}:: {added_value}", location + ) if added_value else self.write_string(f"{added_key}::", location) + return True case MetadataType.TAGS: - new_values = [] - if isinstance(value, list): - new_values = [_v for _v in value if self.tags.add(_v)] - elif self.tags.add(value): - new_values = [value] + if added_value is None or re.match(r"^\s*$", added_value): + log.error("A tag must be specified to add.") + raise typer.Exit(code=1) - if new_values: - for value in new_values: - _v = value - if _v.startswith("#"): - _v = _v[1:] - self.write_string(new_string=f"#{_v}", location=location) - return True + new_tags = P.return_tags(f"#{added_value.lstrip('#')}") + + if len(new_tags) == 0: + log.error("A valid tag must be specified.") + raise typer.Exit(code=1) + + tag_added = False + for tag in new_tags: + if self.contains_metadata(meta_type, None, tag.lstrip("#")): + continue + + new_tag = InlineField( + meta_type=MetadataType.TAGS, + key=None, + value=tag.lstrip("#"), + is_changed=True, + ) + + tag_added = True + self.metadata.append(new_tag) + self.write_string(f"#{new_tag.value}", location) + + return tag_added case _: - return False - - return False + log.error( + f"Invalid metadata type '{meta_type}' was provided to note.add_metadata()." + ) + raise typer.Exit(code=1) def commit(self, path: Path = None) -> None: """Write the note's new content to disk. This is a destructive action. @@ -171,131 +448,193 @@ class Note: alerts.error(f"Note {p} not found. Exiting") raise typer.Exit(code=1) from e - def contains_tag(self, tag: str, is_regex: bool = False) -> bool: - """Check if a note contains the specified inline tag. - - Args: - tag (str): Tag to check for. - is_regex (bool, optional): Whether to use regex to match the tag. - - Returns: - bool: Whether the note has inline tags. - """ - return self.tags.contains(tag, is_regex=is_regex) - - def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool: - """Check if a note has a key or a key-value pair in its Frontmatter or InlineMetadata. - - Args: - key (str): Key to check for. - value (str, optional): Value to check for. - is_regex (bool, optional): Whether to use regex to match the key/value. - - Returns: - bool: Whether the note contains the key or key-value pair. - """ - if value is None: - if self.frontmatter.contains(key, is_regex=is_regex) or self.inline_metadata.contains( - key, is_regex=is_regex - ): - return True - return False - - if self.frontmatter.contains( - key, value, is_regex=is_regex - ) or self.inline_metadata.contains(key, value, is_regex=is_regex): - return True - - return False - - def delete_all_metadata(self) -> None: - """Delete all metadata from the note. Removes all frontmatter and inline metadata and tags from the body of the note and from the associated metadata objects.""" - for key in self.inline_metadata.dict: - self.delete_metadata(key=key, area=MetadataType.INLINE) - - for tag in self.tags.list: - self.delete_tag(tag=tag) - - self.frontmatter.delete_all() - self.write_frontmatter() - - def delete_tag(self, tag: str) -> bool: - """Delete an inline tag from the `tags` attribute AND removes the tag from the text of the note if it exists. - - Args: - tag (str): Tag to delete. - - Returns: - bool: Whether the tag was deleted. - """ - new_list = self.tags.list.copy() - - for _t in new_list: - if re.search(tag, _t): - _t = re.escape(_t) - self.sub(rf"#{_t}([ \|,;:\*\(\)\[\]\\\.\n#&])", r"\1", is_regex=True) - self.tags.delete(tag) - - if new_list != self.tags.list: - return True - - return False - - def delete_metadata( + def contains_metadata( # noqa: PLR0911 self, - key: str, - value: str = None, - area: MetadataType = MetadataType.ALL, + meta_type: MetadataType, + search_key: str, + search_value: str = None, is_regex: bool = False, ) -> bool: - """Delete a key or key-value pair from the note's Metadata object and the content of the note. Regex is supported. - - If no value is provided, will delete an entire specified key. + """Check if a note contains the specified metadata. Args: - area (MetadataType, optional): Area to delete metadata from. Defaults to MetadataType.ALL. + meta_type (MetadataType): Metadata type to check for. + search_key (str): Key to check for. + search_value (str, optional): Value to check for. is_regex (bool, optional): Whether to use regex to match the key/value. - key (str): Key to delete. - value (str, optional): Value to delete. Returns: - bool: Whether the key or key-value pair was deleted. + bool: Whether the note contains the metadata. """ - changed_value: bool = False + if meta_type == MetadataType.ALL: + return self.contains_metadata( + MetadataType.META, search_key, search_value, is_regex + ) or self.contains_metadata(MetadataType.TAGS, search_key, search_value, is_regex) - if ( - area == MetadataType.FRONTMATTER or area == MetadataType.ALL - ) and self.frontmatter.delete(key=key, value_to_delete=value, is_regex=is_regex): - self.write_frontmatter() - changed_value = True + if meta_type == MetadataType.META: + return self.contains_metadata( + MetadataType.FRONTMATTER, search_key, search_value, is_regex + ) or self.contains_metadata(MetadataType.INLINE, search_key, search_value, is_regex) - if ( - area == MetadataType.INLINE or area == MetadataType.ALL - ) and self.inline_metadata.contains(key, value): - self.write_delete_inline_metadata(key=key, value=value, is_regex=is_regex) - self.inline_metadata.delete(key=key, value_to_delete=value, is_regex=is_regex) - changed_value = True + if meta_type == MetadataType.FRONTMATTER or meta_type == MetadataType.INLINE: + if search_key is None or re.match(r"^\s*$", search_key): + return False + + search_key = re.escape(search_key) if not is_regex else search_key + + if search_value is None: + return any( + re.search(search_key, item.clean_key) + for item in self.metadata + if item.meta_type == meta_type + ) + + search_value = re.escape(search_value) if not is_regex else search_value + + return any( + re.search(search_value, str(item.normalized_value)) + for item in self.metadata + if item.meta_type == meta_type and re.search(search_key, str(item.clean_key)) + ) + + if meta_type == MetadataType.TAGS: + if search_key is not None or search_value is None or re.match(r"^\s*$", search_value): + return False + + search_value = search_value.lstrip("#") + search_value = re.escape(search_value) if not is_regex else search_value + + return any( + re.search(search_value, str(item.normalized_value)) + for item in self.metadata + if item.meta_type == meta_type + ) - if changed_value: - return True return False + def delete_metadata( # noqa: PLR0912, C901 + self, meta_type: MetadataType, key: str = None, value: str = None, is_regex: bool = False + ) -> bool: + """Delete specified metadata from the note. Removes the metadata from the note and the metadata list. When a key is provided without a value, all values associated with that key are deleted. + + Args: + meta_type (MetadataType): Metadata type to delete. + key (str, optional): Key to delete. + value (str, optional): Value to delete. + is_regex (bool, optional): Whether to use regex to match the key/value. + + Returns: + bool: Whether metadata was deleted. + """ + removed_frontmatter = False + meta_to_delete = [] + if meta_type == MetadataType.META: + if key is None or re.match(r"^\s*$", key): + log.error("A valid key must be specified.") + raise typer.Exit(code=1) + + meta_to_delete.extend( + self._find_matching_fields(MetadataType.FRONTMATTER, key, value, is_regex) + ) + meta_to_delete.extend( + self._find_matching_fields(MetadataType.INLINE, key, value, is_regex) + ) + + elif meta_type == MetadataType.ALL: + if key is not None and not re.match(r"^\s*$", key): + meta_to_delete.extend( + self._find_matching_fields(MetadataType.FRONTMATTER, key, value, is_regex) + ) + meta_to_delete.extend( + self._find_matching_fields(MetadataType.INLINE, key, value, is_regex) + ) + + if key is None and value is not None and not re.match(r"^\s*$", value): + meta_to_delete.extend( + self._find_matching_fields(MetadataType.TAGS, key, value, is_regex) + ) + + elif meta_type == MetadataType.FRONTMATTER or meta_type == MetadataType.INLINE: + if key is None or re.match(r"^\s*$", key): + log.error("A valid key must be specified.") + raise typer.Exit(code=1) + + meta_to_delete.extend(self._find_matching_fields(meta_type, key, value, is_regex)) + + elif meta_type == MetadataType.TAGS: + if key is not None or (value is None or re.match(r"^\s*$", value)): + log.error("A valid tag must be specified.") + raise typer.Exit(code=1) + + meta_to_delete.extend(self._find_matching_fields(meta_type, key, value, is_regex)) + + if len(meta_to_delete) == 0: + return False + + for field in meta_to_delete: + match field.meta_type: + case MetadataType.FRONTMATTER: + removed_frontmatter = True + self.metadata.remove(field) + + case MetadataType.INLINE: + if self._delete_inline_metadata(field): + self.metadata.remove(field) + else: + log.warning( + f"Failed to delete {field.clean_key} from {self.note_path.name}" + ) + + case MetadataType.TAGS: + if self.sub( + f"#{re.escape(field.value)}([{P.chars_not_in_tags}])", "\1", is_regex=True + ): + self.metadata.remove(field) + else: + log.warning(f"Failed to delete #{field.value} from {self.note_path.name}") + return False + + if removed_frontmatter: + self.write_frontmatter() + + return True + + def delete_all_metadata(self) -> bool: + """Delete all metadata from the note. Removes all frontmatter and inline metadata and tags from the body of the note and from the associated InlineField objects. + + Returns: + bool: Whether metadata was deleted. + """ + deleted_frontmatter = False + meta_to_delete = copy.deepcopy(self.metadata) + + for field in meta_to_delete: + if field.meta_type == MetadataType.FRONTMATTER: + deleted_frontmatter = True + self.metadata.remove(field) + else: + self.delete_metadata( + field.meta_type, field.clean_key, field.normalized_value, is_regex=False + ) + + if deleted_frontmatter: + self.write_frontmatter() + + if len(self.metadata) > 0: + return False + + return True + def has_changes(self) -> bool: """Check if the note has been updated. Returns: bool: Whether the note has been updated. """ - if self.frontmatter.has_changes(): - return True - - if self.tags.has_changes(): - return True - - if self.inline_metadata.has_changes(): - return True - - if self.file_content != self.original_file_content: + if ( + self.original_metadata != self.metadata + or self.original_file_content != self.file_content + ): return True return False @@ -315,31 +654,11 @@ class Note: elif line.startswith("-"): table.add_row(line, style="red") - console.print(table) + console_no_markup.print(table) def print_note(self) -> None: """Print the note to the console.""" - console.print(self.file_content) - - def rename_tag(self, tag_1: str, tag_2: str) -> bool: - """Rename an inline tag. Updates the Metadata object and the text of the note. - - Args: - tag_1 (str): Tag to rename. - tag_2 (str): New tag name. - - Returns: - bool: Whether the tag was renamed. - """ - if tag_1 in self.tags.list: - self.sub( - rf"#{tag_1}([ \|,;:\*\(\)\[\]\\\.\n#&])", - rf"#{tag_2}\1", - is_regex=True, - ) - self.tags.rename(tag_1, tag_2) - return True - return False + console_no_markup.print(self.file_content) def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> bool: """Rename a key or key-value pair in the note's InlineMetadata and Frontmatter objects and the content of the note. @@ -354,180 +673,183 @@ class Note: Returns: bool: Whether the note was updated. """ - changed_value: bool = False + # TODO: Add support for TAGS + fields_to_rename = [] if value_2 is None: - if self.frontmatter.rename(key, value_1): - self.write_frontmatter() - changed_value = True - if self.inline_metadata.rename(key, value_1): - self.write_inline_metadata_change(key, value_1) - changed_value = True + fields_to_rename.extend( + self._find_matching_fields(meta_type=MetadataType.INLINE, key=key) + ) + fields_to_rename.extend( + self._find_matching_fields(meta_type=MetadataType.FRONTMATTER, key=key) + ) else: - if self.frontmatter.rename(key, value_1, value_2): - self.write_frontmatter() - changed_value = True - if self.inline_metadata.rename(key, value_1, value_2): - self.write_inline_metadata_change(key, value_1, value_2) - changed_value = True + fields_to_rename.extend( + self._find_matching_fields(meta_type=MetadataType.INLINE, key=key, value=value_1) + ) + fields_to_rename.extend( + self._find_matching_fields( + meta_type=MetadataType.FRONTMATTER, key=key, value=value_1 + ) + ) - if changed_value: - return True + if len(fields_to_rename) == 0: + return False - return False + frontmatter_is_changed = False + for field in fields_to_rename: + if field.meta_type == MetadataType.FRONTMATTER: + frontmatter_is_changed = True + field.is_changed = True + if value_2 is None: + field.clean_key = value_1 + field.key = value_1 + field.normalized_key = value_1.replace(" ", "-").lower() + else: + field.value = value_2 + field.normalized_value = value_2.strip() - def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> None: + if field.meta_type == MetadataType.INLINE: + field.is_changed = True + if value_2 is None: + self._update_inline_metadata(field, new_key=value_1) + else: + self._update_inline_metadata(field, new_value=value_2) + + if frontmatter_is_changed: + self.write_frontmatter() + + return True + + def rename_tag(self, old_tag: str, new_tag: str) -> bool: + """Rename a tag in the note's body and tags. + + Args: + old_tag (str): Tag to rename. + new_tag (str): New tag name. + + Returns: + bool: Whether the note was updated. + """ + old_tag = old_tag.lstrip("#").strip() + new_tag = new_tag.lstrip("#").strip() + fields_to_rename = [ + x for x in self.metadata if x.meta_type == MetadataType.TAGS and x.value == old_tag + ] + + if len(fields_to_rename) == 0: + return False + + for field in fields_to_rename: + field.is_changed = True + self.sub(rf"#{re.escape(field.value)}", f"#{new_tag}", is_regex=True) + field.value = new_tag + field.normalized_value = new_tag + + return True + + def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> bool: """Substitutes text within the note. Args: pattern (str): The pattern to replace (plain text or regular expression). replacement (str): What to replace the pattern with. is_regex (bool): Whether the pattern is a regex pattern or plain text. + + Returns: + bool: Whether text was substituted. """ if not is_regex: pattern = re.escape(pattern) - self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE) + self.file_content, num_subs = re.subn(pattern, replacement, self.file_content, re.MULTILINE) - def transpose_metadata( # noqa: C901, PLR0912, PLR0911 + return num_subs > 0 + + def transpose_metadata( self, begin: MetadataType, end: MetadataType, key: str = None, - value: str | list[str] = None, + value: str = None, location: InsertLocation = InsertLocation.BOTTOM, ) -> bool: """Move metadata from one metadata object to another. i.e. Frontmatter to InlineMetadata or vice versa. - If no key is specified, will transpose all metadata. If a key is specified, but no value, the entire key will be transposed. if a specific value is specified, just that value will be transposed. + If the beginning and end type of the metadata are the same, will move the metadata within the same type to the specified location. + + If no key is specified, will transpose all metadata. If a key is specified, but no value, the key and all associated values will be transposed. If a specific value is specified, just that value will be transposed. Args: begin (MetadataType): The type of metadata to transpose from. end (MetadataType): The type of metadata to transpose to. key (str, optional): The key to transpose. Defaults to None. location (InsertLocation, optional): Where to insert the metadata. Defaults to InsertLocation.BOTTOM. - value (str | list[str], optional): The value to transpose. Defaults to None. + value (str, optional): The value to transpose. Defaults to None. Returns: bool: Whether the note was updated. """ - if (begin == MetadataType.FRONTMATTER or begin == MetadataType.INLINE) and ( - end == MetadataType.FRONTMATTER or end == MetadataType.INLINE - ): - if begin == MetadataType.FRONTMATTER: - begin_dict = self.frontmatter.dict - else: - begin_dict = self.inline_metadata.dict + if begin == MetadataType.FRONTMATTER and end == MetadataType.FRONTMATTER: + return False - if begin_dict == {}: - return False - - if key is None: # Transpose all metadata when no key is provided - for _key, _value in begin_dict.items(): - self.add_metadata(key=_key, value=_value, area=end, location=location) - self.delete_metadata(key=_key, area=begin) - return True - - has_changes = False - temp_dict = copy.deepcopy(begin_dict) - for k, v in begin_dict.items(): - if key == k: - if value is None: - self.add_metadata(key=k, value=v, area=end, location=location) - self.delete_metadata(key=k, area=begin) - return True - - if value == v: - self.add_metadata(key=k, value=v, area=end, location=location) - self.delete_metadata(key=k, area=begin) - return True - - if isinstance(value, str): - if value in v: - self.add_metadata(key=k, value=value, area=end, location=location) - self.delete_metadata(key=k, value=value, area=begin) - return True - - return False - - if isinstance(value, list): - for value_item in value: - if value_item in v: - self.add_metadata( - key=k, value=value_item, area=end, location=location - ) - self.delete_metadata(key=k, value=value_item, area=begin) - temp_dict[k].remove(value_item) - has_changes = True - - if temp_dict[k] == []: - self.delete_metadata(key=k, area=begin) - - return bool(has_changes) - - if begin == MetadataType.TAGS: + if begin == MetadataType.TAGS or end == MetadataType.TAGS: # TODO: Implement transposing to and from tags - pass + return False - return False + if key is None: # When no key is provided, transpose all metadata + meta_to_transpose = [x for x in self.metadata if x.meta_type == begin] + else: + meta_to_transpose = self._find_matching_fields(begin, key, value) - def write_delete_inline_metadata( - self, key: str = None, value: str = None, is_regex: bool = False - ) -> bool: - """For a given inline metadata key and/or key-value pair, delete it from the text of the note. If no key is provided, will delete all inline metadata from the text of the note. + if len(meta_to_transpose) == 0: + return False - IMPORTANT: This method makes no changes to the InlineMetadata object. + for field in sorted( + meta_to_transpose, + reverse=location != InsertLocation.BOTTOM, + key=lambda x: (x.clean_key, x.normalized_value), + ): + self.delete_metadata(begin, field.clean_key, field.normalized_value) + self.add_metadata( + end, + field.clean_key, + field.normalized_value if field.normalized_value != "-" else "", + location, + ) - Args: - is_regex (bool, optional): Whether the key is a regex pattern or plain text. Defaults to False. - key (str, optional): Key to delete. - value (str, optional): Value to delete. - - Returns: - bool: Whether the note was updated. - """ - if self.inline_metadata.dict != {}: - if key is None: - for _k, _v in self.inline_metadata.dict.items(): - for _value in _v: - _k = re.escape(_k) - _value = re.escape(_value) - self.sub(rf"\[?{_k}:: ?\[?\[?{_value}\]?\]?", "", is_regex=True) - return True - - for _k, _v in self.inline_metadata.dict.items(): - if (is_regex and re.search(key, _k)) or (not is_regex and key == _k): - for _value in _v: - if value is None: - _k = re.escape(_k) - _value = re.escape(_value) - self.sub(rf"\[?{_k}:: \[?\[?{_value}\]?\]?", "", is_regex=True) - elif (is_regex and re.search(value, _value)) or ( - not is_regex and value == _value - ): - _k = re.escape(_k) - _value = re.escape(_value) - self.sub(rf"\[?({_k}::) ?\[?\[?{_value}\]?\]?", r"\1", is_regex=True) - return True - return False + return True def write_frontmatter(self, sort_keys: bool = False) -> bool: - """Replace the frontmatter in the note with the current Frontmatter object. If the Frontmatter object is empty, will delete the frontmatter from the note. + """Replace the frontmatter in the note with the current metadata. If not frontmatter exists the entire block will be removed from the note. + + Args: + sort_keys (bool, optional): Whether to sort the keys in the frontmatter alphabetically. Returns: - bool: Whether the note was updated. + bool: Whether frontmatter was written to the note. """ + # First we find the current frontmatter block in the note. try: - current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group( - "frontmatter" - ) + current_frontmatter = P.return_frontmatter(self.file_content, data_only=False) except AttributeError: current_frontmatter = None - if current_frontmatter is None and self.frontmatter.dict == {}: + frontmatter_objects_as_dict: dict[str, list[str]] = {} + for k, v in [ + (x.key, x.value) for x in self.metadata if x.meta_type == MetadataType.FRONTMATTER + ]: + frontmatter_objects_as_dict.setdefault(k, []).append(v) + + # Make no changes when there are no changes to make: + if current_frontmatter is None and len(frontmatter_objects_as_dict) == 0: return False - new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys) - new_frontmatter = "" if self.frontmatter.dict == {} else f"---\n{new_frontmatter}---\n" + # TODO: Make no changes if frontmatter in content is the same as all frontmatter metadata objects + + # Update frontmatter in the note + new_frontmatter = dict_to_yaml(frontmatter_objects_as_dict, sort_keys=sort_keys) + + new_frontmatter = "" if not new_frontmatter else f"---\n{new_frontmatter}---\n" if current_frontmatter is None: self.file_content = new_frontmatter + self.file_content @@ -537,57 +859,6 @@ class Note: self.sub(current_frontmatter, new_frontmatter, is_regex=True) return True - def write_all_inline_metadata( - self, - location: InsertLocation, - ) -> bool: - """Write all metadata found in the InlineMetadata object to the note at a specified insert location. - - Args: - location (InsertLocation): Where to insert the metadata. - - Returns: - bool: Whether the note was updated. - """ - if self.inline_metadata.dict != {}: - string = "" - for k, v in sorted(self.inline_metadata.dict.items()): - for value in v: - string += f"{k}:: {value}\n" - - if self.write_string(new_string=string, location=location, allow_multiple=True): - return True - - return False - - def write_inline_metadata_change(self, key: str, value_1: str, value_2: str = None) -> None: - """Write changes to a specific inline metadata key or value. - - Args: - key (str): Key to rename. - value_1 (str): Value to replace OR new key name (if value_2 is None). - value_2 (str, optional): New value. - - """ - found_inline_metadata = inline_metadata_from_string(self.file_content) - - for _k, _v in found_inline_metadata: - if re.search(key, _k): - if value_2 is None: - if re.search(rf"{key}[^\\w\\d_-]+", _k): - key_text = re.split(r"[^\\w\\d_-]+$", _k)[0] - key_markdown = re.split(r"^[\\w\\d_-]+", _k)[1] - self.sub( - rf"{key_text}{key_markdown}::", - rf"{value_1}{key_markdown}::", - ) - else: - self.sub(f"{_k}::", f"{value_1}::") - elif re.search(key, _k) and re.search(value_1, _v): - _k = re.escape(_k) - _v = re.escape(_v) - self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True) - def write_string( self, new_string: str, @@ -611,31 +882,27 @@ class Note: case InsertLocation.BOTTOM: self.file_content += f"\n{new_string}" return True + case InsertLocation.TOP: - try: - top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter") - except AttributeError: - top = "" + frontmatter = P.return_frontmatter(self.file_content) - if not top: + if frontmatter is None: self.file_content = f"{new_string}\n{self.file_content}" return True - new_string = f"{top}\n{new_string}" - top = re.escape(top) - self.sub(top, new_string, is_regex=True) + new_string = f"{frontmatter}\n{new_string}" + ecaped_frontmatter = re.escape(frontmatter) + self.sub(ecaped_frontmatter, new_string, is_regex=True) return True - case InsertLocation.AFTER_TITLE: - try: - top = PATTERNS.top_with_header.search(self.file_content).group("top") - except AttributeError: - top = "" - if not top: + case InsertLocation.AFTER_TITLE: + top = P.return_top_with_header(self.file_content) + + if top is None: self.file_content = f"{new_string}\n{self.file_content}" return True - new_string = f"{top}\n{new_string}" + new_string = f"{top.strip()}\n{new_string}\n" top = re.escape(top) self.sub(top, new_string, is_regex=True) return True diff --git a/src/obsidian_metadata/models/parsers.py b/src/obsidian_metadata/models/parsers.py new file mode 100644 index 0000000..9e1d68f --- /dev/null +++ b/src/obsidian_metadata/models/parsers.py @@ -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 [[]] + 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\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+) # capture tag + | # Else + (?:(?<= + \#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+ + )) # if lookbehind is a tag + (?P\#[^\u2000-\u206F\u2E00-\u2E7F'!\"\#\$%&\(\)\*+,\.:;<=>?@\^`\{\|\}~\[\]\\\s]+) # capture tag + | # Else + (*FAIL) + ) + """, + re.X, + ) + frontmatter_complete = re.compile(r"^\s*(?P---.*?---)", flags=re.DOTALL) + frontmatter_data = re.compile( + r"(?P^\s*---)(?P.*?)(?P---)", flags=re.DOTALL + ) + code_block = re.compile(r"```.*?```", flags=re.DOTALL) + inline_code = re.compile(r"(?\[)(?!\[) # Open bracket + (?P[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key + (?.*?) # Value + (?\])(?!\]) # Close bracket + | # Else if opening wrapper is a parenthesis + (?\()(?!\() # Open parens + (?P[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key + (?.*?) # Value + (?\))(?!\)) # Close parenthesis + ) + | # Else grab entire line + (?P[0-9\p{Letter}\w\s_/-;\*\~`]+?) # Find key + (?.*) # Value + ) + + """, + re.X | re.I, + ) + top_with_header = re.compile( + r"""^\s* # Start of note + (?P # 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"(? 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\n\g", 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) diff --git a/src/obsidian_metadata/models/patterns.py b/src/obsidian_metadata/models/patterns.py deleted file mode 100644 index a19ee08..0000000 --- a/src/obsidian_metadata/models/patterns.py +++ /dev/null @@ -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""" - (?:^|[ \|_,;:\*\)\[\]\\\.]|(??[-\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---.*?---)", flags=re.DOTALL) - frontmatt_block_strip_separators: Pattern[str] = re.compile( - r"^\s*---(?P.*?)---", 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 # 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#&]") diff --git a/src/obsidian_metadata/models/questions.py b/src/obsidian_metadata/models/questions.py index c9787c9..1936f62 100644 --- a/src/obsidian_metadata/models/questions.py +++ b/src/obsidian_metadata/models/questions.py @@ -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}" diff --git a/src/obsidian_metadata/models/vault.py b/src/obsidian_metadata/models/vault.py index c0fe40b..31b1857 100644 --- a/src/obsidian_metadata/models/vault.py +++ b/src/obsidian_metadata/models/vault.py @@ -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, ) diff --git a/tests/alerts_test.py b/tests/alerts_test.py index ee8a2de..4f0ed7b 100644 --- a/tests/alerts_test.py +++ b/tests/alerts_test.py @@ -6,87 +6,87 @@ import pytest from obsidian_metadata._utils import alerts from obsidian_metadata._utils.alerts import logger as log -from tests.helpers import Regex +from tests.helpers import Regex, strip_ansi def test_dryrun(capsys): """Test dry run.""" alerts.dryrun("This prints in dry run") - captured = capsys.readouterr() - assert captured.out == "DRYRUN | This prints in dry run\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "DRYRUN | This prints in dry run\n" def test_success(capsys): """Test success.""" alerts.success("This prints in success") - captured = capsys.readouterr() - assert captured.out == "SUCCESS | This prints in success\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "SUCCESS | This prints in success\n" def test_error(capsys): """Test success.""" alerts.error("This prints in error") - captured = capsys.readouterr() - assert captured.out == "ERROR | This prints in error\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "ERROR | This prints in error\n" def test_warning(capsys): """Test warning.""" alerts.warning("This prints in warning") - captured = capsys.readouterr() - assert captured.out == "WARNING | This prints in warning\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "WARNING | This prints in warning\n" def test_notice(capsys): """Test notice.""" alerts.notice("This prints in notice") - captured = capsys.readouterr() - assert captured.out == "NOTICE | This prints in notice\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "NOTICE | This prints in notice\n" def test_alerts_debug(capsys): """Test debug.""" alerts.debug("This prints in debug") - captured = capsys.readouterr() - assert captured.out == "DEBUG | This prints in debug\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "DEBUG | This prints in debug\n" def test_usage(capsys): """Test usage.""" alerts.usage("This prints in usage") - captured = capsys.readouterr() - assert captured.out == "USAGE | This prints in usage\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "USAGE | This prints in usage\n" alerts.usage( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", width=80, ) - captured = capsys.readouterr() - assert "USAGE | Lorem ipsum dolor sit amet" in captured.out - assert " | incididunt ut labore et dolore magna aliqua" in captured.out + captured = strip_ansi(capsys.readouterr().out) + assert "USAGE | Lorem ipsum dolor sit amet" in captured + assert " | incididunt ut labore et dolore magna aliqua" in captured alerts.usage( "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", width=20, ) - captured = capsys.readouterr() - assert "USAGE | Lorem ipsum dolor" in captured.out - assert " | sit amet," in captured.out - assert " | adipisicing elit," in captured.out + captured = strip_ansi(capsys.readouterr().out) + assert "USAGE | Lorem ipsum dolor" in captured + assert " | sit amet," in captured + assert " | adipisicing elit," in captured def test_info(capsys): """Test info.""" alerts.info("This prints in info") - captured = capsys.readouterr() - assert captured.out == "INFO | This prints in info\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "INFO | This prints in info\n" def test_dim(capsys): """Test info.""" alerts.dim("This prints in dim") - captured = capsys.readouterr() - assert captured.out == "This prints in dim\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "This prints in dim\n" @pytest.mark.parametrize( @@ -106,74 +106,74 @@ def test_logging(capsys, tmp_path, verbosity, log_to_file) -> None: if verbosity >= 3: assert logging.is_trace() is True - captured = capsys.readouterr() - assert not captured.out + captured = strip_ansi(capsys.readouterr().out) + assert not captured assert logging.is_trace("trace text") is True - captured = capsys.readouterr() - assert captured.out == "trace text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "trace text\n" log.trace("This is Trace logging") - captured = capsys.readouterr() - assert captured.err == Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$") + cap_error = strip_ansi(capsys.readouterr().err) + assert cap_error == Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$") else: assert logging.is_trace("trace text") is False - captured = capsys.readouterr() - assert captured.out != "trace text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured != "trace text\n" log.trace("This is Trace logging") - captured = capsys.readouterr() - assert captured.err != Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$") + cap_error = strip_ansi(capsys.readouterr().err) + assert cap_error != Regex(r"^TRACE \| This is Trace logging \([\w\._:]+:\d+\)$") if verbosity >= 2: assert logging.is_debug() is True - captured = capsys.readouterr() - assert not captured.out + captured = strip_ansi(capsys.readouterr().out) + assert not captured assert logging.is_debug("debug text") is True - captured = capsys.readouterr() - assert captured.out == "debug text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "debug text\n" log.debug("This is Debug logging") - captured = capsys.readouterr() - assert captured.err == Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$") + captured = strip_ansi(capsys.readouterr().err) + assert captured == Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$") else: assert logging.is_debug("debug text") is False - captured = capsys.readouterr() - assert captured.out != "debug text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured != "debug text\n" log.debug("This is Debug logging") - captured = capsys.readouterr() - assert captured.err != Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$") + captured = strip_ansi(capsys.readouterr().err) + assert captured != Regex(r"^DEBUG \| This is Debug logging \([\w\._:]+:\d+\)$") if verbosity >= 1: assert logging.is_info() is True - captured = capsys.readouterr() - assert not captured.out + captured = strip_ansi(capsys.readouterr().out) + assert not captured assert logging.is_info("info text") is True - captured = capsys.readouterr() - assert captured.out == "info text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "info text\n" log.info("This is Info logging") - captured = capsys.readouterr() - assert captured.err == "INFO | This is Info logging\n" + captured = strip_ansi(capsys.readouterr().err) + assert captured == "INFO | This is Info logging\n" else: assert logging.is_info("info text") is False - captured = capsys.readouterr() - assert captured.out != "info text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured != "info text\n" log.info("This is Info logging") - captured = capsys.readouterr() - assert not captured.out + captured = strip_ansi(capsys.readouterr().out) + assert not captured assert logging.is_default() is True - captured = capsys.readouterr() - assert not captured.out + captured = strip_ansi(capsys.readouterr().out) + assert not captured assert logging.is_default("default text") is True - captured = capsys.readouterr() - assert captured.out == "default text\n" + captured = strip_ansi(capsys.readouterr().out) + assert captured == "default text\n" if log_to_file: assert tmp_log.exists() is True diff --git a/tests/application_test.py b/tests/application_test.py index 5636298..eb52772 100644 --- a/tests/application_test.py +++ b/tests/application_test.py @@ -13,7 +13,7 @@ from pathlib import Path import pytest from obsidian_metadata.models.enums import MetadataType -from tests.helpers import Regex, remove_ansi +from tests.helpers import Regex, strip_ansi def test_instantiate_application(test_application) -> None: @@ -48,7 +48,7 @@ def test_abort(test_application, mocker, capsys) -> None: ) app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "Done!" in captured @@ -80,7 +80,7 @@ def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) @@ -112,7 +112,7 @@ def test_add_metadata_inline(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) @@ -140,7 +140,7 @@ def test_add_metadata_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) @@ -168,7 +168,7 @@ def test_delete_tag_1(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS +\| Deleted inline tag: breakfast in \d+ notes", re.DOTALL) @@ -196,7 +196,7 @@ def test_delete_tag_2(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "WARNING | No notes were changed" in captured @@ -219,8 +219,8 @@ def test_delete_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) - assert r"WARNING | No notes found with a key matching: \d{7}" in captured + captured = strip_ansi(capsys.readouterr().out) + assert r"WARNING | No notes found with a key matching regex: \d{7}" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -237,7 +237,7 @@ def test_delete_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS \| Deleted keys matching: d\\w\+ from \d+ notes", re.DOTALL) @@ -263,7 +263,7 @@ def test_delete_value(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert r"WARNING | No notes found matching: area: \d{7}" in captured mocker.patch( @@ -284,8 +284,8 @@ def test_delete_value(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) - assert r"SUCCESS | Deleted value ^front\w+$ from key area in 4 notes" in captured + captured = strip_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS | Deleted value \^front\\w\+\$ from key area in \d+ notes") def test_filter_notes(test_application, mocker, capsys) -> None: @@ -307,7 +307,7 @@ def test_filter_notes(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS +\| Loaded \d+ notes from \d+ total", re.DOTALL) assert "02 inline/inline 2.md" in captured assert "03 mixed/mixed 1.md" not in captured @@ -362,7 +362,7 @@ def test_filter_clear(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "02 inline/inline 2.md" in captured assert "03 mixed/mixed 1.md" in captured assert "01 frontmatter/frontmatter 4.md" in captured @@ -384,8 +384,11 @@ def test_inspect_metadata_all(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) - assert captured == Regex(r"type +โ”‚ article", re.DOTALL) + captured = strip_ansi(capsys.readouterr().out) + assert captured == Regex(r"tags +โ”‚ bar ") + assert captured == Regex(r"status +โ”‚ new ") + assert captured == Regex(r"in_text_key +โ”‚ in-text value") + assert "#breakfast" in captured def test_rename_tag(test_application, mocker, capsys) -> None: @@ -411,7 +414,7 @@ def test_rename_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "No notes were changed" in captured mocker.patch( @@ -433,7 +436,7 @@ def test_rename_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"Renamed breakfast to new_tag in \d+ notes", re.DOTALL) @@ -460,7 +463,7 @@ def test_rename_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "WARNING | No notes were changed" in captured mocker.patch( @@ -482,7 +485,7 @@ def test_rename_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"Renamed tags to new_tags in \d+ notes", re.DOTALL) @@ -512,7 +515,7 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "WARNING | No notes were changed" in captured mocker.patch( @@ -537,7 +540,7 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex( r"SUCCESS +\| Renamed 'area:frontmatter' to 'area:new_key' in \d+ notes", re.DOTALL ) @@ -553,7 +556,7 @@ def test_review_no_changes(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert "INFO | No changes to review" in captured @@ -579,7 +582,7 @@ def test_review_changes(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r".*Found \d+ changed notes in the vault", re.DOTALL) assert "- tags:" in captured assert "+ new_tags:" in captured @@ -595,7 +598,7 @@ def test_transpose_metadata_1(test_application, mocker, capsys) -> None: app = test_application app._load_vault() - assert app.vault.metadata.inline_metadata["inline_key"] == ["inline_key_value"] + assert app.vault.inline_meta["inline_key"] == ["inline_key_value"] mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", side_effect=["reorganize_metadata", KeyError], @@ -607,9 +610,9 @@ def test_transpose_metadata_1(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - assert app.vault.metadata.inline_metadata == {} - assert app.vault.metadata.frontmatter["inline_key"] == ["inline_key_value"] - captured = remove_ansi(capsys.readouterr().out) + assert app.vault.inline_meta == {} + assert app.vault.frontmatter["inline_key"] == ["inline_key_value"] + captured = strip_ansi(capsys.readouterr().out) assert "SUCCESS | Transposed Inline Metadata to Frontmatter in 5 notes" in captured @@ -623,7 +626,7 @@ def test_transpose_metadata_2(test_application, mocker) -> None: app = test_application app._load_vault() - assert app.vault.metadata.frontmatter["date_created"] == ["2022-12-21", "2022-12-22"] + assert app.vault.frontmatter["date_created"] == ["2022-12-21", "2022-12-22"] mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", side_effect=["reorganize_metadata", KeyError], @@ -634,8 +637,8 @@ def test_transpose_metadata_2(test_application, mocker) -> None: ) with pytest.raises(KeyError): app.application_main() - assert app.vault.metadata.inline_metadata["date_created"] == ["2022-12-21", "2022-12-22"] - assert app.vault.metadata.frontmatter == {} + assert app.vault.inline_meta["date_created"] == ["2022-12-21", "2022-12-22"] + assert app.vault.frontmatter == {} def test_vault_backup(test_application, mocker, capsys) -> None: @@ -653,7 +656,7 @@ def test_vault_backup(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex( r"SUCCESS +\| Vault backed up to:[-\w\d\/\s]+application\.bak", re.DOTALL ) @@ -676,5 +679,5 @@ def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = remove_ansi(capsys.readouterr().out) + captured = strip_ansi(capsys.readouterr().out) assert captured == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL) diff --git a/tests/cli_test.py b/tests/cli_test.py index 4638d1e..a20db0c 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -7,6 +7,7 @@ from pathlib import Path from typer.testing import CliRunner from obsidian_metadata.cli import app +from tests.helpers import Regex, strip_ansi from .helpers import KeyInputs, Regex # noqa: F401 @@ -37,6 +38,8 @@ def test_application(tmp_path) -> None: # input=KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER, # noqa: ERA001 ) + output = strip_ansi(result.output) + banner = r""" ___ _ _ _ _ / _ \| |__ ___(_) __| (_) __ _ _ __ @@ -49,7 +52,8 @@ def test_application(tmp_path) -> None: |_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_| """ - assert banner in result.output + assert banner in output + assert output == Regex(r"SUCCESS \| Loaded \d+ notes from \d+ total notes") assert result.exit_code == 1 diff --git a/tests/config_test.py b/tests/config_test.py index 1ffdcf3..edc1965 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -36,7 +36,7 @@ def test_vault_path_errors(tmp_path, capsys) -> None: assert "Vault path not found" in captured.out with pytest.raises(typer.Exit): - Config(config_path=config_file, vault_path=Path("tests/fixtures/sample_note.md")) + Config(config_path=config_file, vault_path=Path("tests/fixtures/test_vault/sample_note.md")) captured = capsys.readouterr() assert "Vault path is not a directory" in captured.out diff --git a/tests/conftest.py b/tests/conftest.py index 2ab7e6b..25ba00b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,7 +32,7 @@ def remove_all(root: Path): @pytest.fixture() def sample_note(tmp_path) -> Path: """Fixture which creates a temporary note file.""" - source_file: Path = Path("tests/fixtures/test_vault/test1.md") + source_file: Path = Path("tests/fixtures/test_vault/sample_note.md") if not source_file.exists(): raise FileNotFoundError(f"Original file not found: {source_file}") diff --git a/tests/fixtures/broken_frontmatter.md b/tests/fixtures/broken_frontmatter.md deleted file mode 100644 index 364580f..0000000 --- a/tests/fixtures/broken_frontmatter.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -tags: -invalid = = "content" ---- - -Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est la diff --git a/tests/fixtures/sample_note.md b/tests/fixtures/sample_note.md deleted file mode 100644 index 9ed4690..0000000 --- a/tests/fixtures/sample_note.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -date_created: 2022-12-22 -tags: - - food/fruit/apple - - dinner - - breakfast - - not_food -author: John Doe -nested_list: - nested_list_one: - - nested_list_one_a - - nested_list_one_b -type: -- article -- note ---- - -area:: mixed -date_modified:: 2022-12-22 -status:: new -type:: book -inline_key:: inline_key_value -type:: [[article]] -tags:: from_inline_metadata -**bold_key**:: **bold** key value - - - - -Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - -Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, [in_text_key:: in-text value] eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? #inline_tag - -At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, #inline_tag2 cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. - -#food/fruit/pear -#food/fruit/orange -#dinner #breakfast -#brunch diff --git a/tests/fixtures/test_vault/sample_note.md b/tests/fixtures/test_vault/sample_note.md new file mode 100644 index 0000000..9f1fea4 --- /dev/null +++ b/tests/fixtures/test_vault/sample_note.md @@ -0,0 +1,42 @@ +--- +date_created: 2022-12-22 # confirm dates are translated to strings +tags: + - foo + - bar +frontmatter1: foo +frontmatter2: ["bar", "baz", "qux"] +๐ŸŒฑ: ๐ŸŒฟ +# Nested lists are not supported +# invalid: +# invalid: +# - invalid +# - invalid2 +--- + +# Heading 1 + +inline1:: foo +inline1::bar baz +**inline2**:: [[foo]] +_inline3_:: value +๐ŸŒฑ::๐ŸŒฟ +key with space:: foo + +> inline4:: foo + +inline5:: + +foo bar [intext1:: foo] baz `#invalid` qux (intext2:: foo) foobar. #tag1 Foo bar #tag2 baz qux. [[link]] + +The quick brown fox jumped over the lazy dog. + +# tag3 + +--- + +## invalid: invalid + +```python +invalid:: invalid +#invalid +``` diff --git a/tests/fixtures/test_vault/test1.md b/tests/fixtures/test_vault/test1.md deleted file mode 100644 index c0ed52e..0000000 --- a/tests/fixtures/test_vault/test1.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -date_created: 2022-12-22 -tags: - - shared_tag - - frontmatter_tag1 - - frontmatter_tag2 - - ๐Ÿ“…/frontmatter_tag3 -frontmatter_Key1: author name -frontmatter_Key2: ["article", "note"] -shared_key1: - - shared_key1_value - - shared_key1_value3 -shared_key2: shared_key2_value1 ---- - -#inline_tag_top1 #inline_tag_top2 - -top_key1:: top_key1_value -**top_key2:: top_key2_value** -top_key3:: [[top_key3_value_as_link]] -shared_key1:: shared_key1_value -shared_key1:: shared_key1_value2 -shared_key2:: shared_key2_value2 -key๐Ÿ“…:: ๐Ÿ“…_key_value - -# Heading 1 - -Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. #intext_tag1 Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu [intext_key:: intext_value] fugiat nulla (#intext_tag2) pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est lab - -```python -#ffffff -# This is sample text with tags and metadata -#in_codeblock_tag1 -#ffffff; -codeblock_key:: some text -in_codeblock_key:: in_codeblock_value -The quick brown fox jumped over the #in_codeblock_tag2 -``` - -Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab `this is #inline_code_tag1` illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? `this is #inline_code_tag2` Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pari - -bottom_key1:: bottom_key1_value -bottom_key2:: bottom_key2_value - -#inline_tag_bottom1 -#inline_tag_bottom2 -#shared_tag diff --git a/tests/helpers.py b/tests/helpers.py index 85681a4..f40a53d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -22,7 +22,7 @@ class KeyInputs: THREE = "3" -def remove_ansi(text) -> str: +def strip_ansi(text) -> str: """Remove ANSI escape sequences from a string. Args: diff --git a/tests/metadata_frontmatter_test.py b/tests/metadata_frontmatter_test.py deleted file mode 100644 index a4bc256..0000000 --- a/tests/metadata_frontmatter_test.py +++ /dev/null @@ -1,531 +0,0 @@ -# type: ignore -"""Test the Frontmatter object from metadata.py.""" - -import pytest - -from obsidian_metadata.models.exceptions import FrontmatterError -from obsidian_metadata.models.metadata import Frontmatter - -FRONTMATTER_CONTENT: str = """ ---- -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: "shared_key1_value" ---- -more content - ---- -horizontal: rule ---- -""" - -INLINE_CONTENT = """\ -repeated_key:: repeated_key_value1 -#inline_tag_top1,#inline_tag_top2 -**bold_key1**:: bold_key1_value -**bold_key2:: bold_key2_value** -link_key:: [[link_key_value]] -tag_key:: #tag_key_value -emoji_๐Ÿ“…_key:: emoji_๐Ÿ“…_key_value -**#bold_tag** - -Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. [in_text_key1:: in_text_key1_value] Ut enim ad minim veniam, quis nostrud exercitation [in_text_key2:: in_text_key2_value] ullamco laboris nisi ut aliquip ex ea commodo consequat. #in_text_tag Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - -```python -#ffffff -# This is sample text [no_key:: value]with tags and metadata -#in_codeblock_tag1 -#ffffff; -in_codeblock_key:: in_codeblock_value -The quick brown fox jumped over the #in_codeblock_tag2 -``` -repeated_key:: repeated_key_value2 -""" - - -def test_create_1() -> None: - """Test frontmatter creation. - - GIVEN valid frontmatter content - WHEN a Frontmatter object is created - THEN parse the YAML frontmatter and add it to the object - """ - frontmatter = Frontmatter(INLINE_CONTENT) - assert frontmatter.dict == {} - - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.dict == { - "frontmatter_Key1": ["frontmatter_Key1_value"], - "frontmatter_Key2": ["article", "note"], - "shared_key1": ["shared_key1_value"], - "tags": ["tag_1", "tag_2", "๐Ÿ“…/tag_3"], - } - assert frontmatter.dict_original == { - "frontmatter_Key1": ["frontmatter_Key1_value"], - "frontmatter_Key2": ["article", "note"], - "shared_key1": ["shared_key1_value"], - "tags": ["tag_1", "tag_2", "๐Ÿ“…/tag_3"], - } - - -def test_create_2() -> None: - """Test frontmatter creation error. - - GIVEN invalid frontmatter content - WHEN a Frontmatter object is created - THEN raise ValueError - """ - fn = """--- -tags: tag -invalid = = "content" ---- - """ - with pytest.raises(FrontmatterError): - Frontmatter(fn) - - -def test_create_3(): - """Test frontmatter creation error. - - GIVEN empty frontmatter content - WHEN a Frontmatter object is created - THEN set the dict to an empty dict - """ - content = "---\n\n---" - frontmatter = Frontmatter(content) - assert frontmatter.dict == {} - - -def test_create_4(): - """Test frontmatter creation error. - - GIVEN empty frontmatter content with a yaml marker - WHEN a Frontmatter object is created - THEN set the dict to an empty dict - """ - content = "---\n-\n---" - frontmatter = Frontmatter(content) - assert frontmatter.dict == {} - - -def test_add_1(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with an existing key - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - - assert frontmatter.add("frontmatter_Key1") is False - - -def test_add_2(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with an existing key and existing value - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.add("frontmatter_Key1", "frontmatter_Key1_value") is False - - -def test_add_3(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with a new key - THEN return True and add the key to the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.add("added_key") is True - assert "added_key" in frontmatter.dict - - -def test_add_4(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with a new key and a new value - THEN return True and add the key and the value to the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.add("added_key", "added_value") is True - assert frontmatter.dict["added_key"] == ["added_value"] - - -def test_add_5(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with an existing key and a new value - THEN return True and add the value to the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.add("frontmatter_Key1", "new_value") is True - assert frontmatter.dict["frontmatter_Key1"] == ["frontmatter_Key1_value", "new_value"] - - -def test_add_6(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with an existing key and a list of new values - THEN return True and add the values to the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.add("frontmatter_Key1", ["new_value", "new_value2"]) is True - assert frontmatter.dict["frontmatter_Key1"] == [ - "frontmatter_Key1_value", - "new_value", - "new_value2", - ] - - -def test_add_7(): - """Test frontmatter add() method. - - GIVEN a Frontmatter object - WHEN the add() method is called with an existing key and a list of values including an existing value - THEN return True and add the new values to the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert ( - frontmatter.add("frontmatter_Key1", ["frontmatter_Key1_value", "new_value", "new_value2"]) - is True - ) - assert frontmatter.dict["frontmatter_Key1"] == [ - "frontmatter_Key1_value", - "new_value", - "new_value2", - ] - - -def test_contains_1(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key - THEN return True if the key is found - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains("frontmatter_Key1") is True - - -def test_contains_2(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key - THEN return False if the key is not found - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains("no_key") is False - - -def test_contains_3(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key and a value - THEN return True if the key and value is found - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains("frontmatter_Key2", "article") is True - - -def test_contains_4(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key and a value - THEN return False if the key and value is not found - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains("frontmatter_Key2", "no value") is False - - -def test_contains_5(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key regex - THEN return True if a key matches the regex - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains(r"\d$", is_regex=True) is True - - -def test_contains_6(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key regex - THEN return False if no key matches the regex - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains(r"^\d", is_regex=True) is False - - -def test_contains_7(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key and value regex - THEN return True if a value matches the regex - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True - - -def test_contains_8(): - """Test frontmatter contains() method. - - GIVEN a Frontmatter object - WHEN the contains() method is called with a key and value regex - THEN return False if a value does not match the regex - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.contains("key", r"_\d", is_regex=True) is False - - -def test_delete_1(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with a key that does not exist - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete("no key") is False - - -def test_delete_2(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with an existing key and a value that does not exist - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete("tags", "no value") is False - - -def test_delete_3(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with a regex that does not match any keys - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete(r"\d{3}", is_regex=True) is False - - -def test_delete_4(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with an existing key and a regex that does not match any values - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete("tags", r"\d{5}", is_regex=True) is False - - -def test_delete_5(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with an existing key and an existing value - THEN return True and delete the value from the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete("tags", "tag_2") is True - assert "tag_2" not in frontmatter.dict["tags"] - assert "tags" in frontmatter.dict - - -def test_delete_6(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with an existing key - THEN return True and delete the key from the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete("tags") is True - assert "tags" not in frontmatter.dict - - -def test_delete_7(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with a regex that matches a key - THEN return True and delete the matching keys from the dict - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete(r"front\w+", is_regex=True) is True - assert "frontmatter_Key1" not in frontmatter.dict - assert "frontmatter_Key2" not in frontmatter.dict - - -def test_delete_8(): - """Test frontmatter delete() method. - - GIVEN a Frontmatter object - WHEN the delete() method is called with an existing key and a regex that matches values - THEN return True and delete the matching values - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.delete("tags", r"\w+_[23]", is_regex=True) is True - assert "tag_2" not in frontmatter.dict["tags"] - assert "๐Ÿ“…/tag_3" not in frontmatter.dict["tags"] - assert "tag_1" in frontmatter.dict["tags"] - - -def test_delete_all(): - """Test Frontmatter delete_all method. - - GIVEN Frontmatter with multiple keys - WHEN delete_all is called - THEN all keys and values are deleted - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - frontmatter.delete_all() - assert frontmatter.dict == {} - - -def test_has_changes_1(): - """Test frontmatter has_changes() method. - - GIVEN a Frontmatter object - WHEN no changes have been made to the object - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.has_changes() is False - - -def test_has_changes_2(): - """Test frontmatter has_changes() method. - - GIVEN a Frontmatter object - WHEN changes have been made to the object - THEN return True - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - frontmatter.dict["new key"] = ["new value"] - assert frontmatter.has_changes() is True - - -def test_rename_1(): - """Test frontmatter rename() method. - - GIVEN a Frontmatter object - WHEN the rename() method is called with a key - THEN return False if the key is not found - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.rename("no key", "new key") is False - - -def test_rename_2(): - """Test frontmatter rename() method. - - GIVEN a Frontmatter object - WHEN the rename() method is called with an existing key and non-existing value - THEN return False - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.rename("tags", "no tag", "new key") is False - - -def test_rename_3(): - """Test frontmatter rename() method. - - GIVEN a Frontmatter object - WHEN the rename() method is called with an existing key - THEN return True and rename the key - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.rename("frontmatter_Key1", "new key") is True - assert "frontmatter_Key1" not in frontmatter.dict - assert frontmatter.dict["new key"] == ["frontmatter_Key1_value"] - - -def test_rename_4(): - """Test frontmatter rename() method. - - GIVEN a Frontmatter object - WHEN the rename() method is called with an existing key and value - THEN return True and rename the value - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.rename("tags", "tag_2", "new tag") is True - assert "tag_2" not in frontmatter.dict["tags"] - assert "new tag" in frontmatter.dict["tags"] - - -def test_rename_5(): - """Test frontmatter rename() method. - - GIVEN a Frontmatter object - WHEN the rename() method is called with an existing key and value and the new value already exists - THEN return True and remove the old value leaving one instance of the new value - """ - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.rename("tags", "tag_1", "tag_2") is True - assert "tag_1" not in frontmatter.dict["tags"] - assert frontmatter.dict["tags"] == ["tag_2", "๐Ÿ“…/tag_3"] - - -def test_to_yaml_1(): - """Test Frontmatter to_yaml method. - - GIVEN a dictionary - WHEN the to_yaml method is called - THEN return a string with the yaml representation of the dictionary - """ - new_frontmatter: str = """\ -tags: - - tag_1 - - tag_2 - - ๐Ÿ“…/tag_3 -frontmatter_Key1: frontmatter_Key1_value -frontmatter_Key2: - - article - - note -shared_key1: shared_key1_value -""" - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.to_yaml() == new_frontmatter - - -def test_to_yaml_2(): - """Test Frontmatter to_yaml method. - - GIVEN a dictionary - WHEN the to_yaml method is called with sort_keys=True - THEN return a string with the sorted yaml representation of the dictionary - """ - new_frontmatter_sorted: str = """\ -frontmatter_Key1: frontmatter_Key1_value -frontmatter_Key2: - - article - - note -shared_key1: shared_key1_value -tags: - - tag_1 - - tag_2 - - ๐Ÿ“…/tag_3 -""" - frontmatter = Frontmatter(FRONTMATTER_CONTENT) - assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted diff --git a/tests/metadata_inline_test.py b/tests/metadata_inline_test.py deleted file mode 100644 index b84c78c..0000000 --- a/tests/metadata_inline_test.py +++ /dev/null @@ -1,455 +0,0 @@ -# type: ignore -"""Test inline metadata from metadata.py.""" -import pytest - -from obsidian_metadata.models.exceptions import InlineMetadataError -from obsidian_metadata.models.metadata import InlineMetadata - -FRONTMATTER_CONTENT: str = """ ---- -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: "shared_key1_value" ---- -more content - ---- -horizontal: rule ---- -""" - -INLINE_CONTENT = """\ -key1:: value1 -key1:: value2 -key1:: value3 -key2:: value1 -Paragraph of text with an [inline_key:: value1] and [inline_key:: value2] and [inline_key:: value3] which should do it. -> blockquote_key:: value1 -> blockquote_key:: value2 - -- list_key:: value1 -- list_key:: value2 - -1. list_key:: value1 -2. list_key:: value2 -""" - - -def test__grab_inline_metadata_1(): - """Test grab inline metadata. - - GIVEN content that has no inline metadata - WHEN grab_inline_metadata is called - THEN an empty dict is returned - - """ - content = """ ---- -frontmatter_key1: frontmatter_key1_value ---- -not_a_key: not_a_value -``` -key:: in_codeblock -``` - """ - inline = InlineMetadata(content) - assert inline.dict == {} - - -def test__grab_inline_metadata_2(): - """Test grab inline metadata. - - GIVEN content that has inline metadata - WHEN grab_inline_metadata is called - THEN the inline metadata is parsed and returned as a dict - - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.dict == { - "blockquote_key": ["value1", "value2"], - "inline_key": ["value1", "value2", "value3"], - "key1": ["value1", "value2", "value3"], - "key2": ["value1"], - "list_key": ["value1", "value2", "value1", "value2"], - } - - -def test__grab_inline_metadata_3(mocker): - """Test grab inline metadata. - - GIVEN content that has inline metadata - WHEN an error occurs parsing the inline metadata - THEN raise an InlineMetadataError and pass the error message - """ - mocker.patch( - "obsidian_metadata.models.metadata.inline_metadata_from_string", - return_value=[("key")], - ) - with pytest.raises(InlineMetadataError, match=r"Error parsing inline metadata: \['key'\]"): - InlineMetadata("") - - -def test_add_1(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with an existing key - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("key1") is False - - -def test_add_2(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with an existing key and existing value - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("key1", "value1") is False - - -def test_add_3(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with a new key - THEN return True and add the key to the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("added_key") is True - assert "added_key" in inline.dict - - -def test_add_4(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with a new key and a new value - THEN return True and add the key and the value to the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("added_key", "added_value") is True - assert inline.dict["added_key"] == ["added_value"] - - -def test_add_5(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with an existing key and a new value - THEN return True and add the value to the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("key1", "new_value") is True - assert inline.dict["key1"] == ["value1", "value2", "value3", "new_value"] - - -def test_add_6(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with an existing key and a list of new values - THEN return True and add the values to the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("key2", ["new_value", "new_value2"]) is True - assert inline.dict["key2"] == ["new_value", "new_value2", "value1"] - - -def test_add_7(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with an existing key and a list of values including an existing value - THEN return True and add the new values to the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("key1", ["value1", "new_value", "new_value2"]) is True - assert inline.dict["key1"] == ["new_value", "new_value2", "value1", "value2", "value3"] - - -def test_add_8(): - """Test InlineMetadata add() method. - - GIVEN a InlineMetadata object - WHEN the add() method is called with a new key and a list of values - THEN return True and add the new values to the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.add("new_key", ["value1", "new_value", "new_value2"]) is True - assert inline.dict["new_key"] == ["value1", "new_value", "new_value2"] - - -def test_contains_1(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key - THEN return True if the key is found - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains("key1") is True - - -def test_contains_2(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key - THEN return False if the key is not found - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains("no_key") is False - - -def test_contains_3(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key and a value - THEN return True if the key and value is found - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains("key1", "value1") is True - - -def test_contains_4(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key and a value - THEN return False if the key and value is not found - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains("key1", "no value") is False - - -def test_contains_5(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key regex - THEN return True if a key matches the regex - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains(r"\d$", is_regex=True) is True - - -def test_contains_6(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key regex - THEN return False if no key matches the regex - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains(r"^\d", is_regex=True) is False - - -def test_contains_7(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key and value regex - THEN return True if a value matches the regex - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains(r"key\d", r"\w\d", is_regex=True) is True - - -def test_contains_8(): - """Test InlineMetadata contains() method. - - GIVEN a InlineMetadata object - WHEN the contains() method is called with a key and value regex - THEN return False if a value does not match the regex - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.contains("key1", r"_\d", is_regex=True) is False - - -def test_delete_1(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with a key that does not exist - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete("no key") is False - - -def test_delete_2(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with an existing key and a value that does not exist - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete("key1", "no value") is False - - -def test_delete_3(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with a regex that does not match any keys - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete(r"\d{3}", is_regex=True) is False - - -def test_delete_4(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with an existing key and a regex that does not match any values - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete("key1", r"\d{5}", is_regex=True) is False - - -def test_delete_5(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with an existing key and an existing value - THEN return True and delete the value from the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete("key1", "value1") is True - assert "value1" not in inline.dict["key1"] - assert "key1" in inline.dict - - -def test_delete_6(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with an existing key - THEN return True and delete the key from the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete("key1") is True - assert "key1" not in inline.dict - - -def test_delete_7(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with a regex that matches a key - THEN return True and delete the matching keys from the dict - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete(r"key\w+", is_regex=True) is True - assert "key1" not in inline.dict - assert "key2" not in inline.dict - - -def test_delete_8(): - """Test InlineMetadata delete() method. - - GIVEN a InlineMetadata object - WHEN the delete() method is called with an existing key and a regex that matches values - THEN return True and delete the matching values - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.delete("key1", r"\w+\d", is_regex=True) is True - assert "value1" not in inline.dict["key1"] - assert "value2" not in inline.dict["key1"] - assert "value3" not in inline.dict["key1"] - - -def test_has_changes_1(): - """Test InlineMetadata has_changes() method. - - GIVEN a InlineMetadata object - WHEN no changes have been made to the object - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.has_changes() is False - - -def test_has_changes_2(): - """Test InlineMetadata has_changes() method. - - GIVEN a InlineMetadata object - WHEN changes have been made to the object - THEN return True - """ - inline = InlineMetadata(INLINE_CONTENT) - inline.dict["new key"] = ["new value"] - assert inline.has_changes() is True - - -def test_rename_1(): - """Test InlineMetadata rename() method. - - GIVEN a InlineMetadata object - WHEN the rename() method is called with a key - THEN return False if the key is not found - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.rename("no key", "new key") is False - - -def test_rename_2(): - """Test InlineMetadata rename() method. - - GIVEN a InlineMetadata object - WHEN the rename() method is called with an existing key and non-existing value - THEN return False - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.rename("key1", "no value", "new value") is False - - -def test_rename_3(): - """Test InlineMetadata rename() method. - - GIVEN a InlineMetadata object - WHEN the rename() method is called with an existing key - THEN return True and rename the key - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.rename("key1", "new key") is True - assert "key1" not in inline.dict - assert inline.dict["new key"] == ["value1", "value2", "value3"] - - -def test_rename_4(): - """Test InlineMetadata rename() method. - - GIVEN a InlineMetadata object - WHEN the rename() method is called with an existing key and value - THEN return True and rename the value - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.rename("key1", "value1", "new value") is True - assert "value1" not in inline.dict["key1"] - assert "new value" in inline.dict["key1"] - - -def test_rename_5(): - """Test InlineMetadata rename() method. - - GIVEN a InlineMetadata object - WHEN the rename() method is called with an existing key and value and the new value already exists - THEN return True and remove the old value leaving one instance of the new value - """ - inline = InlineMetadata(INLINE_CONTENT) - assert inline.rename("key1", "value1", "value2") is True - assert inline.dict["key1"] == ["value2", "value3"] diff --git a/tests/metadata_tags_test.py b/tests/metadata_tags_test.py deleted file mode 100644 index 2fd7c43..0000000 --- a/tests/metadata_tags_test.py +++ /dev/null @@ -1,367 +0,0 @@ -# type: ignore -"""Test inline tags from metadata.py.""" - -from obsidian_metadata.models.metadata import InlineTags - -CONTENT = """\ -#tag1 #tag2 -> #tag3 -**#tag4** -I am a sentence with #tag5 and #tag6 in the middle -#tag๐Ÿ™ˆ7 -#tag/8 -#tag/๐Ÿ‘‹/9 -""" - - -def test__grab_inline_tags_1() -> None: - """Test _grab_inline_tags() method. - - GIVEN a string with a codeblock - WHEN the method is called - THEN the codeblock is ignored - """ - content = """ -some text - -```python -#tag1 -#tag2 -``` - -``` -#tag3 -#tag4 -``` - """ - tags = InlineTags(content) - assert tags.list == [] - assert tags.list_original == [] - - -def test__grab_inline_tags_2() -> None: - """Test _grab_inline_tags() method. - - GIVEN a string with tags - WHEN the method is called - THEN the tags are extracted - """ - tags = InlineTags(CONTENT) - assert tags.list == [ - "tag/8", - "tag/๐Ÿ‘‹/9", - "tag1", - "tag2", - "tag3", - "tag4", - "tag5", - "tag6", - "tag๐Ÿ™ˆ7", - ] - assert tags.list_original == [ - "tag/8", - "tag/๐Ÿ‘‹/9", - "tag1", - "tag2", - "tag3", - "tag4", - "tag5", - "tag6", - "tag๐Ÿ™ˆ7", - ] - - -def test_add_1(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a tag that exists in the list - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.add("tag1") is False - - -def test_add_2(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a new tag - THEN return True and add the tag to the list - """ - tags = InlineTags(CONTENT) - assert tags.add("new_tag") is True - assert "new_tag" in tags.list - - -def test_add_3(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a list of new tags - THEN return True and add the tags to the list - """ - tags = InlineTags(CONTENT) - new_tags = ["new_tag1", "new_tag2"] - assert tags.add(new_tags) is True - assert "new_tag1" in tags.list - assert "new_tag2" in tags.list - - -def test_add_4(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a list of tags, some of which already exist - THEN return True and add only the new tags to the list - """ - tags = InlineTags(CONTENT) - new_tags = ["new_tag1", "new_tag2", "tag1", "tag2"] - assert tags.add(new_tags) is True - assert tags.list == [ - "new_tag1", - "new_tag2", - "tag/8", - "tag/๐Ÿ‘‹/9", - "tag1", - "tag2", - "tag3", - "tag4", - "tag5", - "tag6", - "tag๐Ÿ™ˆ7", - ] - - -def test_add_5(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a list of tags which are already in the list - THEN return False - """ - tags = InlineTags(CONTENT) - new_tags = ["tag1", "tag2"] - assert tags.add(new_tags) is False - assert "tag1" in tags.list - assert "tag2" in tags.list - - -def test_add_6(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a list of tags which have a # in the name - THEN strip the # from the tag name - """ - tags = InlineTags(CONTENT) - new_tags = ["#tag1", "#tag2", "#new_tag"] - assert tags.add(new_tags) is True - assert tags.list == [ - "new_tag", - "tag/8", - "tag/๐Ÿ‘‹/9", - "tag1", - "tag2", - "tag3", - "tag4", - "tag5", - "tag6", - "tag๐Ÿ™ˆ7", - ] - - -def test_add_7(): - """Test add() method. - - GIVEN a InlineTag object - WHEN the add() method is called with a tag which has a # in the name - THEN strip the # from the tag name - """ - tags = InlineTags(CONTENT) - assert tags.add("#tag1") is False - assert tags.add("#new_tag") is True - assert "new_tag" in tags.list - - -def test_contains_1(): - """Test contains() method. - - GIVEN a InlineTag object - WHEN the contains() method is called with a tag that exists in the list - THEN return True - """ - tags = InlineTags(CONTENT) - assert tags.contains("tag1") is True - - -def test_contains_2(): - """Test contains() method. - - GIVEN a InlineTag object - WHEN the contains() method is called with a tag that does not exist in the list - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.contains("no_tag") is False - - -def test_contains_3(): - """Test contains() method. - - GIVEN a InlineTag object - WHEN the contains() method is called with a regex that matches a tag in the list - THEN return True - """ - tags = InlineTags(CONTENT) - assert tags.contains(r"tag\d", is_regex=True) is True - - -def test_contains_4(): - """Test contains() method. - - GIVEN a InlineTag object - WHEN the contains() method is called with a regex that does not match any tags in the list - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.contains(r"tag\d\d", is_regex=True) is False - - -def test_delete_1(): - """Test delete() method. - - GIVEN a InlineTag object - WHEN the delete() method is called with a tag that exists in the list - THEN return True and remove the tag from the list - """ - tags = InlineTags(CONTENT) - assert tags.delete("tag1") is True - assert "tag1" not in tags.list - - -def test_delete_2(): - """Test delete() method. - - GIVEN a InlineTag object - WHEN the delete() method is called with a tag that does not exist in the list - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.delete("no_tag") is False - - -def test_delete_3(): - """Test delete() method. - - GIVEN a InlineTag object - WHEN the delete() method is called with a regex that matches a tag in the list - THEN return True and remove the tag from the list - """ - tags = InlineTags(CONTENT) - assert tags.delete(r"tag\d") is True - assert tags.list == ["tag/8", "tag/๐Ÿ‘‹/9", "tag๐Ÿ™ˆ7"] - - -def test_delete_4(): - """Test delete() method. - - GIVEN a InlineTag object - WHEN the delete() method is called with a regex that does not match any tags in the list - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.delete(r"tag\d\d") is False - - -def test_has_changes_1(): - """Test has_changes() method. - - GIVEN a InlineTag object - WHEN the has_changes() method is called - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.has_changes() is False - - -def test_has_changes_2(): - """Test has_changes() method. - - GIVEN a InlineTag object - WHEN the has_changes() method after the list has been updated - THEN return True - """ - tags = InlineTags(CONTENT) - tags.list = ["new_tag"] - assert tags.has_changes() is True - - -def test_rename_1(): - """Test rename() method. - - GIVEN a InlineTag object - WHEN the rename() method is called with a tag that exists in the list - THEN return True and rename the tag in the list - """ - tags = InlineTags(CONTENT) - assert tags.rename("tag1", "new_tag") is True - assert "tag1" not in tags.list - assert "new_tag" in tags.list - - -def test_rename_2(): - """Test rename() method. - - GIVEN a InlineTag object - WHEN the rename() method is called with a tag that does not exist in the list - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.rename("no_tag", "new_tag") is False - assert "new_tag" not in tags.list - - -def test_rename_3(): - """Test rename() method. - - GIVEN a InlineTag object - WHEN the rename() method is called with a tag that exists and the new tag name already exists in the list - THEN return True and ensure the new tag name is only in the list once - """ - tags = InlineTags(CONTENT) - assert tags.rename(r"tag1", "tag2") is True - assert tags.list == [ - "tag/8", - "tag/๐Ÿ‘‹/9", - "tag2", - "tag3", - "tag4", - "tag5", - "tag6", - "tag๐Ÿ™ˆ7", - ] - - -def test_rename_4(): - """Test rename() method. - - GIVEN a InlineTag object - WHEN the rename() method is called with a new tag value that is None - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.rename("tag1", None) is False - assert "tag1" in tags.list - - -def test_rename_5(): - """Test rename() method. - - GIVEN a InlineTag object - WHEN the rename() method is called with a new tag value that is empty - THEN return False - """ - tags = InlineTags(CONTENT) - assert tags.rename("tag1", "") is False - assert "tag1" in tags.list diff --git a/tests/metadata_test.py b/tests/metadata_test.py new file mode 100644 index 0000000..7440558 --- /dev/null +++ b/tests/metadata_test.py @@ -0,0 +1,209 @@ +# type: ignore +"""Test the InlineField class.""" + +import pytest + +from obsidian_metadata.models.enums import MetadataType, Wrapping +from obsidian_metadata.models.metadata import InlineField, dict_to_yaml + + +def test_dict_to_yaml_1(): + """Test dict_to_yaml() function. + + GIVEN a dictionary + WHEN values contain lists + THEN confirm the output is not sorted + """ + test_dict = {"k2": ["v1", "v2"], "k1": ["v1", "v2"]} + assert dict_to_yaml(test_dict) == "k2:\n - v1\n - v2\nk1:\n - v1\n - v2\n" + + +def test_dict_to_yaml_2(): + """Test dict_to_yaml() function. + + GIVEN a dictionary + WHEN values contain lists and sort_keys is True + THEN confirm the output is sorted + """ + test_dict = {"k2": ["v1", "v2"], "k1": ["v1", "v2"]} + assert dict_to_yaml(test_dict, sort_keys=True) == "k1:\n - v1\n - v2\nk2:\n - v1\n - v2\n" + + +def test_dict_to_yaml_3(): + """Test dict_to_yaml() function. + + GIVEN a dictionary + WHEN values contain a list with a single value + THEN confirm single-value lists are converted to strings + """ + test_dict = {"k2": ["v1"], "k1": ["v1", "v2"]} + assert dict_to_yaml(test_dict, sort_keys=True) == "k1:\n - v1\n - v2\nk2: v1\n" + + +def test_init_1(): + """Test creating an InlineField object. + + GIVEN an inline tag + WHEN an InlineField object is created + THEN confirm the object's attributes match the expected values + """ + obj = InlineField( + meta_type=MetadataType.TAGS, + key=None, + value="tag1", + ) + assert obj.meta_type == MetadataType.TAGS + assert obj.key is None + assert obj.value == "tag1" + assert obj.normalized_value == "tag1" + assert obj.wrapping == Wrapping.NONE + assert obj.clean_key is None + assert obj.normalized_key is None + assert not obj.key_open + assert not obj.key_close + assert obj.is_changed is False + + +def test_init_2(): + """Test creating an InlineField object. + + GIVEN an inline key/value pair + WHEN an InlineField object is created + THEN confirm the object's attributes match the expected values + """ + obj = InlineField(meta_type=MetadataType.INLINE, key="key", value="value") + assert obj.meta_type == MetadataType.INLINE + assert obj.key == "key" + assert obj.value == "value" + assert obj.normalized_value == "value" + assert obj.wrapping == Wrapping.NONE + assert obj.clean_key == "key" + assert obj.normalized_key == "key" + assert not obj.key_open + assert not obj.key_close + assert obj.is_changed is False + + obj = InlineField( + meta_type=MetadataType.INLINE, + key="key", + value="value", + wrapping=Wrapping.PARENS, + ) + assert obj.meta_type == MetadataType.INLINE + assert obj.key == "key" + assert obj.value == "value" + assert obj.normalized_value == "value" + assert obj.wrapping == Wrapping.PARENS + assert obj.clean_key == "key" + assert obj.normalized_key == "key" + assert not obj.key_open + assert not obj.key_close + assert obj.is_changed is False + + obj = InlineField( + meta_type=MetadataType.INLINE, + key="**key**", + value="value", + wrapping=Wrapping.BRACKETS, + ) + assert obj.meta_type == MetadataType.INLINE + assert obj.key == "**key**" + assert obj.value == "value" + assert obj.normalized_value == "value" + assert obj.wrapping == Wrapping.BRACKETS + assert obj.clean_key == "key" + assert obj.normalized_key == "key" + assert obj.key_open == "**" + assert obj.key_close == "**" + assert obj.is_changed is False + + +@pytest.mark.parametrize( + ( + "original", + "cleaned", + "normalized", + "key_open", + "key_close", + ), + [ + ("foo", "foo", "foo", "", ""), + ("๐ŸŒฑ/๐ŸŒฟ", "๐ŸŒฑ/๐ŸŒฟ", "๐ŸŒฑ/๐ŸŒฟ", "", ""), + ("FOO 1", "FOO 1", "foo-1", "", ""), + ("**key foo**", "key foo", "key-foo", "**", "**"), + ("## KEY", "KEY", "key", "## ", ""), + ], +) +def test_init_3(original, cleaned, normalized, key_open, key_close): + """Test creating an InlineField object. + + GIVEN an InlineField object is created + WHEN the key needs to be normalized + THEN confirm clean_key() returns the expected value + """ + obj = InlineField(meta_type=MetadataType.INLINE, key=original, value="value") + assert obj.clean_key == cleaned + assert obj.normalized_key == normalized + assert obj.key_open == key_open + assert obj.key_close == key_close + + +@pytest.mark.parametrize( + ("original", "normalized"), + [("foo", "foo"), ("๐ŸŒฑ/๐ŸŒฟ", "๐ŸŒฑ/๐ŸŒฟ"), (" value ", "value"), (" ", "-"), ("", "-")], +) +def test_init_4(original, normalized): + """Test creating an InlineField object. + + GIVEN an InlineField object is created + WHEN the value needs to be normalized + THEN create the normalized_value attribute + """ + obj = InlineField(meta_type=MetadataType.INLINE, key="key", value=original) + assert obj.value == original + assert obj.normalized_value == normalized + + +def test_inline_field_init_5(): + """Test updating the is_changed attribute. + + GIVEN creating an object + WHEN is_changed set to True at init + THEN confirm is_changed is True + """ + obj = InlineField(meta_type=MetadataType.TAGS, key="key", value="tag1", is_changed=True) + assert obj.is_changed is True + + +def test_inline_field_init_6(): + """Test updating the is_changed attribute. + + GIVEN creating an object + WHEN is_changed set to True at after init + THEN confirm is_changed is True + """ + obj = InlineField(meta_type=MetadataType.TAGS, key="key", value="tag1", is_changed=False) + assert obj.is_changed is False + obj.is_changed = True + assert obj.is_changed is True + + +def test_inline_field_init_4(): + """Test updating the is_changed attribute. + + GIVEN creating an object + WHEN key_open and key_close are set after init + THEN confirm they are set correctly + """ + obj = InlineField( + meta_type=MetadataType.INLINE, + key="_key_", + value="value", + is_changed=False, + ) + assert obj.key_open == "_" + assert obj.key_close == "_" + obj.key_open = "**" + obj.key_close = "**" + assert obj.key_open == "**" + assert obj.key_close == "**" diff --git a/tests/metadata_vault_test.py b/tests/metadata_vault_test.py deleted file mode 100644 index af1cd3f..0000000 --- a/tests/metadata_vault_test.py +++ /dev/null @@ -1,814 +0,0 @@ -# type: ignore -"""Test VaultMetadata object from metadata.py.""" -import pytest - -from obsidian_metadata.models.enums import MetadataType -from obsidian_metadata.models.metadata import ( - VaultMetadata, -) -from tests.helpers import Regex, remove_ansi - - -def test_vault_metadata__init_1() -> None: - """Test VaultMetadata class.""" - vm = VaultMetadata() - assert vm.dict == {} - assert vm.frontmatter == {} - assert vm.inline_metadata == {} - assert vm.tags == [] - - -def test_index_metadata_1(): - """Test index_metadata() method. - - GIVEN a dictionary to add - WHEN the target area is FRONTMATTER and the old dictionary is empty - THEN the new dictionary is added to the target area - """ - vm = VaultMetadata() - new_dict = {"key1": ["value1"], "key2": ["value2", "value3"]} - vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=new_dict) - assert vm.dict == new_dict - assert vm.frontmatter == new_dict - - -def test_index_metadata_2(): - """Test index_metadata() method. - - GIVEN a dictionary to add - WHEN the target area is FRONTMATTER and the old dictionary is not empty - THEN the new dictionary is merged with the old dictionary - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"], "other_key": ["value1"]} - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - - new_dict = {"key1": ["value1"], "key2": ["value1", "value3"], "key3": ["value1"]} - - vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=new_dict) - assert vm.dict == { - "key1": ["value1"], - "key2": ["value1", "value2", "value3"], - "key3": ["value1"], - "other_key": ["value1"], - } - assert vm.frontmatter == { - "key1": ["value1"], - "key2": ["value1", "value2", "value3"], - "key3": ["value1"], - } - - -def test_index_metadata_3(): - """Test index_metadata() method. - - GIVEN a dictionary to add - WHEN the target area is INLINE and the old dictionary is empty - THEN the new dictionary is added to the target area - """ - vm = VaultMetadata() - new_dict = {"key1": ["value1"], "key2": ["value2", "value3"]} - vm.index_metadata(area=MetadataType.INLINE, metadata=new_dict) - assert vm.dict == new_dict - assert vm.inline_metadata == new_dict - - -def test_index_metadata_4(): - """Test index_metadata() method. - - GIVEN a dictionary to add - WHEN the target area is INLINE and the old dictionary is not empty - THEN the new dictionary is merged with the old dictionary - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"], "other_key": ["value1"]} - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - - new_dict = {"key1": ["value1"], "key2": ["value1", "value3"], "key3": ["value1"]} - - vm.index_metadata(area=MetadataType.INLINE, metadata=new_dict) - assert vm.dict == { - "key1": ["value1"], - "key2": ["value1", "value2", "value3"], - "key3": ["value1"], - "other_key": ["value1"], - } - assert vm.inline_metadata == { - "key1": ["value1"], - "key2": ["value1", "value2", "value3"], - "key3": ["value1"], - } - - -def test_index_metadata_5(): - """Test index_metadata() method. - - GIVEN a dictionary to add - WHEN the target area is TAGS and the old list is empty - THEN the new list is added to the target area - """ - vm = VaultMetadata() - new_list = ["tag1", "tag2", "tag3"] - vm.index_metadata(area=MetadataType.TAGS, metadata=new_list) - assert vm.dict == {} - assert vm.tags == new_list - - -def test_index_metadata_6(): - """Test index_metadata() method. - - GIVEN a dictionary to add - WHEN the target area is TAGS and the old list is not empty - THEN the new list is merged with the old list - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - new_list = ["tag1", "tag2", "tag4", "tag5"] - - vm.index_metadata(area=MetadataType.TAGS, metadata=new_list) - assert vm.dict == {} - assert vm.tags == ["tag1", "tag2", "tag3", "tag4", "tag5"] - - -def test_contains_1(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.FRONTMATTER, key="key1") is True - - -def test_contains_2(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.FRONTMATTER, key="key3") is False - - -def test_contains_3(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key and value that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.FRONTMATTER, key="key2", value="value1") is True - - -def test_contains_4(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key and value that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.FRONTMATTER, key="key2", value="value3") is False - - -def test_contains_5(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.FRONTMATTER, key=r"\w+\d", is_regex=True) is True - - -def test_contains_6(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.FRONTMATTER, key=r"^\d", is_regex=True) is False - - -def test_contains_7(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key and value that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert ( - vm.contains(area=MetadataType.FRONTMATTER, key="key2", value=r"\w\d", is_regex=True) is True - ) - - -def test_contains_8(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked for a key and value that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert ( - vm.contains(area=MetadataType.FRONTMATTER, key="key2", value=r"^\d", is_regex=True) is False - ) - - -def test_contains_9(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN FRONTMATTER is checked with a key is None - THEN raise a ValueError - """ - vm = VaultMetadata() - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - with pytest.raises(ValueError, match="Key must be provided"): - vm.contains(area=MetadataType.FRONTMATTER, value="value1") - - -def test_contains_10(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key="key1") is True - - -def test_contains_11(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key="key3") is False - - -def test_contains_12(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key and value that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key="key2", value="value1") is True - - -def test_contains_13(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key and value that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key="key2", value="value3") is False - - -def test_contains_14(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key=r"\w+\d", is_regex=True) is True - - -def test_contains_15(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key=r"^\d", is_regex=True) is False - - -def test_contains_16(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key and value that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key="key2", value=r"\w\d", is_regex=True) is True - - -def test_contains_17(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked for a key and value that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.INLINE, key="key2", value=r"^\d", is_regex=True) is False - - -def test_contains_18(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN INLINE is checked with a key is None - THEN raise a ValueError - """ - vm = VaultMetadata() - vm.inline_metadata = {"key1": ["value1"], "key2": ["value1", "value2"]} - with pytest.raises(ValueError, match="Key must be provided"): - vm.contains(area=MetadataType.INLINE, value="value1") - - -def test_contains_19(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN TAGS is checked for a key but not a value - THEN raise a ValueError - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - with pytest.raises(ValueError, match="Value must be provided"): - vm.contains(area=MetadataType.TAGS, key="key1") - - -def test_contains_20(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN TAGS is checked for a value that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - assert vm.contains(area=MetadataType.TAGS, value="tag1") is True - - -def test_contains_21(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN TAGS is checked for a value that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - assert vm.contains(area=MetadataType.TAGS, value="value1") is False - - -def test_contains_22(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN TAGS is checked for a key regex but no value - THEN True is returned - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - with pytest.raises(ValueError, match="Value must be provided"): - vm.contains(area=MetadataType.TAGS, key=r"\w", is_regex=True) - - -def test_contains_23(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN TAGS is checked for a value that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - assert vm.contains(area=MetadataType.TAGS, value=r"^\d", is_regex=True) is False - - -def test_contains_24(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN TAGS is checked for a value that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.tags = ["tag1", "tag2", "tag3"] - assert vm.contains(area=MetadataType.TAGS, value=r"^tag\d", is_regex=True) is True - - -def test_contains_25(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key="key1") is True - - -def test_contains_26(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key="key3") is False - - -def test_contains_27(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key and value that exists - THEN True is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key="key2", value="value1") is True - - -def test_contains_28(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key and value that does not exist - THEN False is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key="key2", value="value3") is False - - -def test_contains_29(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key=r"\w+\d", is_regex=True) is True - - -def test_contains_30(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key=r"^\d", is_regex=True) is False - - -def test_contains_31(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key and value that exists with regex - THEN True is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key="key2", value=r"\w\d", is_regex=True) is True - - -def test_contains_32(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked for a key and value that does not exist with regex - THEN False is returned - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.contains(area=MetadataType.ALL, key="key2", value=r"^\d", is_regex=True) is False - - -def test_contains_33(): - """Test contains() method. - - GIVEN a VaultMetadata object - WHEN ALL is checked with a key is None - THEN raise a ValueError - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - with pytest.raises(ValueError, match="Key must be provided"): - vm.contains(area=MetadataType.ALL, value="value1") - - -def test_delete_1(): - """Test delete() method. - - GIVEN a VaultMetadata object - WHEN a key is deleted - THEN return True and the key is removed - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.delete(key="key1") is True - assert vm.dict == {"key2": ["value1", "value2"]} - - -def test_delete_2(): - """Test delete() method. - - GIVEN a VaultMetadata object - WHEN a key is deleted that does not exist - THEN return False and the key is not removed - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.delete(key="key3") is False - assert vm.dict == {"key1": ["value1"], "key2": ["value1", "value2"]} - - -def test_delete_3(): - """Test delete() method. - - GIVEN a VaultMetadata object - WHEN a key and value are specified - THEN return True and remove the value - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.delete(key="key2", value_to_delete="value1") is True - assert vm.dict == {"key1": ["value1"], "key2": ["value2"]} - - -def test_delete_4(): - """Test delete() method. - - GIVEN a VaultMetadata object - WHEN a key and nonexistent value are specified - THEN return False - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.delete(key="key2", value_to_delete="value11") is False - assert vm.dict == {"key1": ["value1"], "key2": ["value1", "value2"]} - - -def test_rename_1(): - """Test VaultMetadata rename() method. - - GIVEN a VaultMetadata object - WHEN the rename() method is called with a key - THEN return False if the key is not found - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.rename("no key", "new key") is False - - -def test_rename_2(): - """Test VaultMetadata rename() method. - - GIVEN a VaultMetadata object - WHEN the rename() method is called with an existing key and non-existing value - THEN return False - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.rename("key1", "no value", "new value") is False - - -def test_rename_3(): - """Test VaultMetadata rename() method. - - GIVEN a VaultMetadata object - WHEN the rename() method is called with an existing key - THEN return True and rename the key - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.rename("key1", "new key") is True - assert vm.dict == {"key2": ["value1", "value2"], "new key": ["value1"]} - - -def test_rename_4(): - """Test VaultMetadata rename() method. - - GIVEN a VaultMetadata object - WHEN the rename() method is called with an existing key and value - THEN return True and rename the value - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.rename("key1", "value1", "new value") is True - assert vm.dict == {"key1": ["new value"], "key2": ["value1", "value2"]} - - -def test_rename_5(): - """Test VaultMetadata rename() method. - - GIVEN a VaultMetadata object - WHEN the rename() method is called with an existing key and value and the new value already exists - THEN return True and remove the old value leaving one instance of the new value - """ - vm = VaultMetadata() - vm.dict = {"key1": ["value1"], "key2": ["value1", "value2"]} - assert vm.rename("key2", "value1", "value2") is True - assert vm.dict == {"key1": ["value1"], "key2": ["value2"]} - - -def test_print_metadata_1(capsys): - """Test print_metadata() method. - - GIVEN calling print_metadata() with a VaultMetadata object - WHEN ALL is specified - THEN print all the metadata - """ - vm = VaultMetadata() - vm.dict = { - "key1": ["value1", "value2"], - "key2": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - vm.inline_metadata = { - "key1": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.tags = ["tag1", "tag2", "tag3"] - - vm.print_metadata(area=MetadataType.ALL) - captured = remove_ansi(capsys.readouterr().out) - assert "All metadata" in captured - assert captured == Regex("โ”ƒ Keys +โ”ƒ Values +โ”ƒ") - assert captured == Regex("โ”‚ key1 +โ”‚ value1 +โ”‚") - assert captured == Regex("โ”‚ key2 +โ”‚ value1 +โ”‚") - assert captured == Regex("โ”‚ key4 +โ”‚ value1 +โ”‚") - assert "All inline tags" in captured - assert captured == Regex("#tag1 +#tag2") - - -def test_print_metadata_2(capsys): - """Test print_metadata() method. - - GIVEN calling print_metadata() with a VaultMetadata object - WHEN FRONTMATTER is specified - THEN print all the metadata - """ - vm = VaultMetadata() - vm.dict = { - "key1": ["value1", "value2"], - "key2": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - vm.inline_metadata = { - "key1": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.tags = ["tag1", "tag2", "tag3"] - - vm.print_metadata(area=MetadataType.FRONTMATTER) - captured = remove_ansi(capsys.readouterr().out) - assert "All frontmatter" in captured - assert captured == Regex("โ”ƒ Keys +โ”ƒ Values +โ”ƒ") - assert captured == Regex("โ”‚ key1 +โ”‚ value1 +โ”‚") - assert captured == Regex("โ”‚ key2 +โ”‚ value1 +โ”‚") - assert captured != Regex("โ”‚ key4 +โ”‚ value1 +โ”‚") - assert "All inline tags" not in captured - assert captured != Regex("#tag1 +#tag2") - - -def test_print_metadata_3(capsys): - """Test print_metadata() method. - - GIVEN calling print_metadata() with a VaultMetadata object - WHEN INLINE is specified - THEN print all the metadata - """ - vm = VaultMetadata() - vm.dict = { - "key1": ["value1", "value2"], - "key2": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - vm.inline_metadata = { - "key1": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.tags = ["tag1", "tag2", "tag3"] - - vm.print_metadata(area=MetadataType.INLINE) - captured = remove_ansi(capsys.readouterr().out) - assert "All inline" in captured - assert captured == Regex("โ”ƒ Keys +โ”ƒ Values +โ”ƒ") - assert captured == Regex("โ”‚ key1 +โ”‚ value1 +โ”‚") - assert captured != Regex("โ”‚ key2 +โ”‚ value1 +โ”‚") - assert captured == Regex("โ”‚ key4 +โ”‚ value1 +โ”‚") - assert "All inline tags" not in captured - assert captured != Regex("#tag1 +#tag2") - - -def test_print_metadata_4(capsys): - """Test print_metadata() method. - - GIVEN calling print_metadata() with a VaultMetadata object - WHEN TAGS is specified - THEN print all the tags - """ - vm = VaultMetadata() - vm.dict = { - "key1": ["value1", "value2"], - "key2": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - vm.inline_metadata = { - "key1": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.tags = ["tag1", "tag2", "tag3"] - - vm.print_metadata(area=MetadataType.TAGS) - captured = remove_ansi(capsys.readouterr().out) - assert "All inline tags" in captured - assert captured != Regex("โ”ƒ Keys +โ”ƒ Values +โ”ƒ") - assert captured != Regex("โ”‚ key1 +โ”‚ value1 +โ”‚") - assert captured != Regex("โ”‚ key2 +โ”‚ value1 +โ”‚") - assert captured != Regex("โ”‚ key4 +โ”‚ value1 +โ”‚") - assert captured == Regex("#tag1 +#tag2 +#tag3") - - -def test_print_metadata_5(capsys): - """Test print_metadata() method. - - GIVEN calling print_metadata() with a VaultMetadata object - WHEN KEYS is specified - THEN print all the tags - """ - vm = VaultMetadata() - vm.dict = { - "key1": ["value1", "value2"], - "key2": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.frontmatter = {"key1": ["value1"], "key2": ["value1", "value2"]} - vm.inline_metadata = { - "key1": ["value1", "value2"], - "key3": ["value1"], - "key4": ["value1", "value2"], - } - vm.tags = ["tag1", "tag2", "tag3"] - - vm.print_metadata(area=MetadataType.KEYS) - captured = remove_ansi(capsys.readouterr().out) - assert "All Keys" in captured - assert captured != Regex("โ”ƒ Keys +โ”ƒ Values +โ”ƒ") - assert captured != Regex("โ”‚ key1 +โ”‚ value1 +โ”‚") - assert captured != Regex("โ”‚ key2 +โ”‚ value1 +โ”‚") - assert captured != Regex("โ”‚ key4 +โ”‚ value1 +โ”‚") - assert captured != Regex("#tag1 +#tag2 +#tag3") - assert captured == Regex("key1 +key2 +key3 +key4") diff --git a/tests/notes/note_init_test.py b/tests/notes/note_init_test.py new file mode 100644 index 0000000..79c974b --- /dev/null +++ b/tests/notes/note_init_test.py @@ -0,0 +1,230 @@ +# type: ignore +"""Test notes.py.""" + +from pathlib import Path + +import pytest +import typer + +from obsidian_metadata.models.enums import MetadataType +from obsidian_metadata.models.exceptions import FrontmatterError +from obsidian_metadata.models.metadata import InlineField +from obsidian_metadata.models.notes import Note + + +def test_note_not_exists() -> None: + """Test target not found. + + GIVEN a path to a non-existent file + WHEN a Note object is created pointing to that file + THEN a typer.Exit exception is raised + """ + with pytest.raises(typer.Exit): + Note(note_path="nonexistent_file.md") + + +def test_create_note_1(sample_note): + """Test creating a note object. + + GIVEN a path to a markdown file + WHEN a Note object is created pointing to that file + THEN the Note object is created + """ + note = Note(note_path=sample_note, dry_run=True) + assert note.note_path == Path(sample_note) + assert note.dry_run is True + assert note.encoding == "utf_8" + assert len(note.metadata) == 20 + + with sample_note.open(): + content = sample_note.read_text() + + assert note.file_content == content + assert note.original_file_content == content + + +def test_create_note_2(tmp_path) -> None: + """Test creating a note object. + + GIVEN a text file with invalid frontmatter + WHEN the note is initialized + THEN a typer exit is raised + """ + note_path = Path(tmp_path) / "broken_frontmatter.md" + note_path.touch() + note_path.write_text( + """--- +tags: +invalid = = "content" +--- +""" + ) + with pytest.raises(typer.Exit): + Note(note_path=note_path) + + +def test_create_note_3(tmp_path) -> None: + """Test creating a note object. + + GIVEN a text file with invalid frontmatter + WHEN the note is initialized + THEN a typer exit is raised + """ + note_path = Path(tmp_path) / "broken_frontmatter.md" + note_path.touch() + note_path.write_text( + """--- +nested1: + nested2: "content" + nested3: + - "content" + - "content" +--- +""" + ) + with pytest.raises(typer.Exit): + Note(note_path=note_path) + + +def test_create_note_6(tmp_path): + """Test creating a note object. + + GIVEN a text file + WHEN there is no content in the file + THEN a note is returned with no metadata or content + """ + note_path = Path(tmp_path) / "empty_file.md" + note_path.touch() + note = Note(note_path=note_path) + assert note.note_path == note_path + assert not note.file_content + assert not note.original_file_content + assert note.metadata == [] + + +def test__grab_metadata_1(tmp_path): + """Test the _grab_metadata method. + + GIVEN a text file + WHEN there is frontmatter + THEN the frontmatter is returned in the metadata list + """ + note_path = Path(tmp_path) / "test_file.md" + note_path.touch() + note_path.write_text( + """ +--- +key1: value1 +key2: 2022-12-22 +key3: + - value3 + - value4 +key4: +key5: "value5" +--- + """ + ) + note = Note(note_path=note_path) + assert sorted(note.metadata, key=lambda x: (x.key, x.value)) == [ + InlineField(meta_type=MetadataType.FRONTMATTER, key="key1", value="value1"), + InlineField(meta_type=MetadataType.FRONTMATTER, key="key2", value="2022-12-22"), + InlineField(meta_type=MetadataType.FRONTMATTER, key="key3", value="value3"), + InlineField(meta_type=MetadataType.FRONTMATTER, key="key3", value="value4"), + InlineField(meta_type=MetadataType.FRONTMATTER, key="key4", value="None"), + InlineField(meta_type=MetadataType.FRONTMATTER, key="key5", value="value5"), + ] + + +def test__grab_metadata_2(tmp_path): + """Test the _grab_metadata method. + + GIVEN a text file + WHEN there is inline metadata + THEN the inline metadata is returned in the metadata list + """ + note_path = Path(tmp_path) / "test_file.md" + note_path.touch() + note_path.write_text( + """ + +key1::value1 +key2::2022-12-22 +foo [key3::value3] bar +key4::value4 +foo (key4::value) bar +key5::value5 + + """ + ) + note = Note(note_path=note_path) + assert sorted(note.metadata, key=lambda x: (x.key, x.value)) == [ + InlineField(meta_type=MetadataType.INLINE, key="key1", value="value1"), + InlineField(meta_type=MetadataType.INLINE, key="key2", value="2022-12-22"), + InlineField(meta_type=MetadataType.INLINE, key="key3", value="value3"), + InlineField(meta_type=MetadataType.INLINE, key="key4", value="value"), + InlineField(meta_type=MetadataType.INLINE, key="key4", value="value4"), + InlineField(meta_type=MetadataType.INLINE, key="key5", value="value5"), + ] + + +def test__grab_metadata_3(tmp_path): + """Test the _grab_metadata method. + + GIVEN a text file + WHEN there are tags + THEN the tags are returned in the metadata list + """ + note_path = Path(tmp_path) / "test_file.md" + note_path.touch() + note_path.write_text("#tag1\n#tag2") + note = Note(note_path=note_path) + assert sorted(note.metadata, key=lambda x: x.value) == [ + InlineField(meta_type=MetadataType.TAGS, key=None, value="tag1"), + InlineField(meta_type=MetadataType.TAGS, key=None, value="tag2"), + ] + + +def test__grab_metadata_4(tmp_path): + """Test the _grab_metadata method. + + GIVEN a text file + WHEN there are tags, frontmatter, and inline metadata + THEN all metadata is returned + """ + note_path = Path(tmp_path) / "test_file.md" + note_path.touch() + note_path.write_text( + """\ +--- +key1: value1 +--- +key2::value2 +#tag1\n#tag2""" + ) + note = Note(note_path=note_path) + assert sorted(note.metadata, key=lambda x: x.value) == [ + InlineField(meta_type=MetadataType.TAGS, key=None, value="tag1"), + InlineField(meta_type=MetadataType.TAGS, key=None, value="tag2"), + InlineField(meta_type=MetadataType.FRONTMATTER, key="key1", value="value1"), + InlineField(meta_type=MetadataType.INLINE, key="key2", value="value2"), + ] + + +def test__grab_metadata_5(tmp_path): + """Test the _grab_metadata method. + + GIVEN a text file + WHEN invalid metadata is present + THEN raise a FrontmatterError + """ + note_path = Path(tmp_path) / "broken_frontmatter.md" + note_path.touch() + note_path.write_text( + """--- +tags: +invalid = = "content" +--- +""" + ) + with pytest.raises(typer.Exit): + Note(note_path=note_path) diff --git a/tests/notes/note_methods_test.py b/tests/notes/note_methods_test.py new file mode 100644 index 0000000..f4d3dd0 --- /dev/null +++ b/tests/notes/note_methods_test.py @@ -0,0 +1,1095 @@ +# type: ignore +"""Test for metadata methods within Note class.""" +from pathlib import Path + +import pytest +import typer +from tests.helpers import Regex + +from obsidian_metadata._utils.console import console +from obsidian_metadata.models.enums import InsertLocation, MetadataType +from obsidian_metadata.models.notes import Note + + +@pytest.mark.parametrize( + ("content", "new_content"), + [ + ("foo bar\nkey:: value", "foo bar\n"), + ("foo\nkey:: value\nbar", "foo\nbar"), + ("foo\n key:: value \nbar\nbaz", "foo\nbar\nbaz"), + ("> blockquote\n> key:: value\n > blockquote2", "> blockquote\n> blockquote2"), + ("foo (**key**:: value) bar", "foo bar"), + ("foo [**key**:: value] bar", "foo bar"), + ], +) +def test__delete_inline_metadata_1(tmp_path, content, new_content): + """Test _edit_inline_metadata() method. + + GIVEN a note object with inline metadata + WHEN changing a key and value in the inline metadata + THEN the metadata object and the content is updated + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text(content) + note = Note(note_path=note_path) + source_field = note.metadata[0] + assert note._delete_inline_metadata(source_field) is True + assert note.file_content == new_content + + +@pytest.mark.parametrize( + ("content", "new_key", "new_content"), + [ + ("key:: value", "new_key", "new_key:: value"), + ("key::value", "๐ŸŒฑ", "๐ŸŒฑ:: value"), + ("foo (**key**:: value) bar", "new_key", "foo (**new_key**:: value) bar"), + ("foo [key:: value] bar", "new_key", "foo [new_key:: value] bar"), + ], +) +def test__edit_inline_metadata_1(tmp_path, content, new_key, new_content): + """Test _edit_inline_metadata() method. + + GIVEN a note object with inline metadata + WHEN changing a key in the inline metadata + THEN the metadata object and the content is updated + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text(content) + note = Note(note_path=note_path) + source_field = note.metadata[0] + new_field = note._edit_inline_metadata(source=source_field, new_key=new_key) + assert new_content in note.file_content + assert len(note.metadata) == 1 + assert new_field in note.metadata + + +@pytest.mark.parametrize( + ("content", "new_key", "new_value", "new_content"), + [ + ("key:: value", "new_key", "", "new_key::"), + ("key::value", "๐ŸŒฑ", "new_value", "๐ŸŒฑ:: new_value"), + ("foo (**key**:: value) bar", "key", "new value", "foo (**key**:: new value) bar"), + ("foo [key:: value] bar", "new_key", "new value", "foo [new_key:: new value] bar"), + ], +) +def test__edit_inline_metadata_2(tmp_path, content, new_key, new_value, new_content): + """Test _edit_inline_metadata() method. + + GIVEN a note object with inline metadata + WHEN changing a key and value in the inline metadata + THEN the metadata object and the content is updated + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text(content) + note = Note(note_path=note_path) + source_field = note.metadata[0] + new_field = note._edit_inline_metadata( + source=source_field, new_key=new_key, new_value=new_value + ) + assert new_content in note.file_content + assert len(note.metadata) == 1 + assert new_field in note.metadata + + +@pytest.mark.parametrize( + ("meta_type", "key", "value", "is_regex", "expected"), + [ + (MetadataType.FRONTMATTER, None, None, False, 8), + (MetadataType.FRONTMATTER, None, None, True, 8), + (MetadataType.FRONTMATTER, "frontmatter1", None, False, 1), + (MetadataType.FRONTMATTER, r"\w+2", None, True, 3), + (MetadataType.FRONTMATTER, "frontmatter1", "foo", False, 1), + (MetadataType.FRONTMATTER, "frontmatter1", r"\w+", True, 1), + (MetadataType.FRONTMATTER, r"\w+1", "foo", True, 1), + (MetadataType.FRONTMATTER, "frontmatter1", "XXX", False, 0), + (MetadataType.FRONTMATTER, "frontmatterXX", None, False, 0), + (MetadataType.FRONTMATTER, r"^\d", "XXX", False, 0), + (MetadataType.FRONTMATTER, "frontmatterXX", r"^\d+", False, 0), + (MetadataType.INLINE, None, None, False, 10), + (MetadataType.INLINE, None, None, True, 10), + (MetadataType.INLINE, "inline1", None, False, 2), + (MetadataType.INLINE, r"\w+2", None, True, 2), + (MetadataType.INLINE, "inline1", "foo", False, 1), + (MetadataType.INLINE, "inline1", r"\w+", True, 2), + (MetadataType.INLINE, r"\w+1", "foo", True, 2), + (MetadataType.INLINE, "inline1", "XXX", False, 0), + (MetadataType.INLINE, "inlineXX", None, False, 0), + (MetadataType.INLINE, r"^\d", "XXX", False, 0), + (MetadataType.INLINE, "frontmatterXX", r"^\d+", False, 0), + (MetadataType.TAGS, None, None, False, 2), + (MetadataType.TAGS, None, None, True, 2), + (MetadataType.TAGS, None, r"^\w+", True, 2), + (MetadataType.TAGS, None, "tag1", False, 1), + (MetadataType.TAGS, None, "XXX", False, 0), + (MetadataType.TAGS, None, r"^\d+", True, 0), + ], +) +def test__find_matching_fields_1(sample_note, meta_type, key, value, is_regex, expected): + """Test _find_matching_fields() method. + + GIVEN a note object + WHEN searching for matching fields + THEN return a list of matching fields + """ + note = Note(note_path=sample_note) + assert ( + len( + note._find_matching_fields(meta_type=meta_type, key=key, value=value, is_regex=is_regex) + ) + == expected + ) + + +@pytest.mark.parametrize( + ("meta_type"), + [(MetadataType.META), (MetadataType.FRONTMATTER), (MetadataType.TAGS)], +) +def test__update_inline_metadata_1(sample_note, meta_type): + """Test _update_inline_metadata() method. + + GIVEN a note object + WHEN updating inline metadata with invalid metadata type + THEN raise an error + """ + note = Note(note_path=sample_note) + source_field = [x for x in note.metadata][0] + source_field.meta_type = meta_type + + with pytest.raises(typer.Exit): + note._update_inline_metadata(source_field, new_key="inline1", new_value="new value") + + +def test__update_inline_metadata_2(sample_note): + """Test _update_inline_metadata() method. + + GIVEN a note object + WHEN updating inline metadata without a new key or value + THEN raise an error + """ + note = Note(note_path=sample_note) + source_field = [x for x in note.metadata][0] + source_field.meta_type = MetadataType.INLINE + + with pytest.raises(typer.Exit): + note._update_inline_metadata(source_field, new_key=None, new_value=None) + + +@pytest.mark.parametrize( + ("orig_key", "orig_value", "new_key", "new_value", "new_content"), + [ + ("inline2", None, "NEW_KEY", None, "**NEW_KEY**:: [[foo]]"), + (None, "value", None, "NEW_VALUE", "_inline3_:: NEW_VALUE"), + ("intext2", "foo", "NEW_KEY", "NEW_VALUE", "(NEW_KEY:: NEW_VALUE)"), + ("intext1", None, "NEW_KEY", None, "[NEW_KEY:: foo]"), + ], +) +def test__update_inline_metadata_3( + sample_note, orig_key, orig_value, new_key, new_value, new_content +): + """Test _update_inline_metadata() method. + + GIVEN a note object + WHEN updating inline metadata with a new key + THEN update the content of the note and the metadata object + """ + note = Note(note_path=sample_note) + + if orig_key is None: + source_inlinefield = [ + x + for x in note.metadata + if x.meta_type == MetadataType.INLINE and x.normalized_value == orig_value + ][0] + assert ( + note._update_inline_metadata(source_inlinefield, new_key=new_key, new_value=new_value) + is True + ) + assert source_inlinefield.normalized_value == new_value + + elif orig_value is None: + source_inlinefield = [ + x + for x in note.metadata + if x.meta_type == MetadataType.INLINE and x.normalized_key == orig_key + ][0] + assert ( + note._update_inline_metadata(source_inlinefield, new_key=new_key, new_value=new_value) + is True + ) + assert source_inlinefield.normalized_key == new_key.lower() + + else: + source_inlinefield = [ + x + for x in note.metadata + if x.meta_type == MetadataType.INLINE + if x.normalized_key == orig_key + if x.normalized_value == orig_value + ][0] + assert ( + note._update_inline_metadata(source_inlinefield, new_key=new_key, new_value=new_value) + is True + ) + assert source_inlinefield.normalized_key == new_key.lower() + assert source_inlinefield.normalized_value == new_value + + assert new_content in note.file_content + assert source_inlinefield.is_changed is True + + +def test_add_metadata_1(sample_note): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding invalid metadata types + THEN raise an error + """ + note = Note(note_path=sample_note) + + with pytest.raises(typer.Exit): + note.add_metadata(meta_type=MetadataType.META, added_key="foo", added_value="bar") + + +@pytest.mark.parametrize( + ("meta_type", "key", "value"), + [ + (MetadataType.FRONTMATTER, "frontmatter1", "foo"), + (MetadataType.INLINE, "inline1", "foo"), + (MetadataType.TAGS, None, "tag1"), + ], +) +def test_add_metadata_2(sample_note, meta_type, key, value): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding metadata which already exists + THEN return False + """ + note = Note(note_path=sample_note) + assert note.add_metadata(meta_type=meta_type, added_key=key, added_value=value) is False + + +def test_add_metadata_3(sample_note): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding a blank tag + THEN raise an error + """ + note = Note(note_path=sample_note) + with pytest.raises(typer.Exit): + note.add_metadata(meta_type=MetadataType.TAGS, added_key=None, added_value="") + + +def test_add_metadata_4(sample_note): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding an invalid tag + THEN raise an error + """ + note = Note(note_path=sample_note) + with pytest.raises(typer.Exit): + note.add_metadata(meta_type=MetadataType.TAGS, added_key=None, added_value="[*") + + +def test_add_metadata_5(sample_note): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding an empty key + THEN raise an error + """ + note = Note(note_path=sample_note) + + with pytest.raises(typer.Exit): + note.add_metadata(meta_type=MetadataType.FRONTMATTER, added_key=" ", added_value="bar") + + +def test_add_metadata_6(sample_note): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding no key but a value + THEN raise an error + """ + note = Note(note_path=sample_note) + + with pytest.raises(typer.Exit): + note.add_metadata(meta_type=MetadataType.FRONTMATTER, added_key=None, added_value="bar") + + +@pytest.mark.parametrize( + ("metatype", "key", "value", "expected"), + [ + (MetadataType.FRONTMATTER, "test", "value", "test: value"), + (MetadataType.INLINE, "test", "value", "test:: value"), + (MetadataType.TAGS, None, "testtag", "#testtag"), + (MetadataType.TAGS, None, "#testtag", "#testtag"), + ], +) +def test_add_metadata_7(sample_note, metatype, key, value, expected): + """Test add_metadata() method. + + GIVEN a note object + WHEN when adding metadata + THEN the metadata is added to the note + """ + note = Note(note_path=sample_note) + assert note.contains_metadata(meta_type=metatype, search_key=key, search_value=value) is False + assert expected not in note.file_content + assert ( + note.add_metadata( + meta_type=metatype, added_key=key, added_value=value, location=InsertLocation.TOP + ) + is True + ) + assert note.contains_metadata(meta_type=metatype, search_key=key, search_value=value) is True + assert expected in note.file_content + + +def test_commit_1(sample_note, tmp_path) -> None: + """Test commit() method. + + GIVEN a note object with commit() called + WHEN the note is modified + THEN the updated note is committed to the file system + """ + note = Note(note_path=sample_note) + note.sub(pattern="Heading 1", replacement="Heading 2") + + note.commit() + note = Note(note_path=sample_note) + assert "Heading 2" in note.file_content + assert "Heading 1" not in note.file_content + + new_path = Path(tmp_path / "new_note.md") + note.commit(new_path) + note2 = Note(note_path=new_path) + assert "Heading 2" in note2.file_content + assert "Heading 1" not in note2.file_content + + +def test_commit_2(sample_note) -> None: + """Test commit() method. + + GIVEN a note object with commit() called + WHEN the note is modified and dry_run is True + THEN the note is not committed to the file system + """ + note = Note(note_path=sample_note, dry_run=True) + note.sub(pattern="Heading 1", replacement="Heading 2") + + note.commit() + note = Note(note_path=sample_note) + assert "Heading 1" in note.file_content + + +@pytest.mark.parametrize( + ("meta_type", "key", "value", "is_regex", "expected"), + [ + # Key does not match + (MetadataType.META, "not_a_key", None, False, False), + (MetadataType.META, r"^\d+", None, True, False), + (MetadataType.TAGS, "tag1", None, False, False), + (MetadataType.TAGS, r"^f\w+1", None, True, False), + (MetadataType.INLINE, "frontamtter1", None, False, False), + (MetadataType.INLINE, r"^f\w+1", None, True, False), + (MetadataType.FRONTMATTER, "inline1", None, False, False), + (MetadataType.FRONTMATTER, r"^i\w+1", None, True, False), + # Key matches, no value provided + (MetadataType.META, "frontmatter1", None, False, True), + (MetadataType.META, r"^f\w+2", None, True, True), + (MetadataType.FRONTMATTER, "frontmatter1", None, False, True), + (MetadataType.FRONTMATTER, r"^f\w+2", None, True, True), + (MetadataType.INLINE, "inline1", None, False, True), + (MetadataType.INLINE, r"^i\w+1", None, True, True), + # Key matches, value does not match + (MetadataType.META, "frontmatter1", "not_a_value", False, False), + (MetadataType.META, r"^f\w+1", r"^\d+", True, False), + (MetadataType.FRONTMATTER, "frontmatter1", "not_a_value", False, False), + (MetadataType.FRONTMATTER, r"^f\w+1", r"^\d+", True, False), + (MetadataType.INLINE, "inline1", "not_a_value", False, False), + (MetadataType.INLINE, r"^i\w+1", r"^\d+", True, False), + (MetadataType.TAGS, None, "not_a_value", False, False), + (MetadataType.TAGS, None, r"^\d+", True, False), + # Key and values match + (MetadataType.META, "frontmatter1", "foo", False, True), + (MetadataType.META, r"^f\w+1", r"[a-z]{3}", True, True), + (MetadataType.FRONTMATTER, "frontmatter1", "foo", False, True), + (MetadataType.FRONTMATTER, r"^f\w+1", r"[a-z]{3}", True, True), + (MetadataType.INLINE, "inline1", "foo", False, True), + (MetadataType.INLINE, r"^i\w+1", r"[a-z]{3}", True, True), + (MetadataType.TAGS, None, "#tag1", False, True), + (MetadataType.TAGS, None, r"^\w+1", True, True), + # Confirm MetaType.ALL works + (MetadataType.ALL, "not_a_key", None, False, False), + (MetadataType.ALL, r"^\d+", None, True, False), + (MetadataType.ALL, "frontmatter1", None, False, True), + (MetadataType.ALL, r"^i\w+1", None, True, True), + (MetadataType.ALL, "not_a_key", r"\w+", True, False), + (MetadataType.ALL, "frontmatter1", "not_a_value", False, False), + (MetadataType.ALL, r"^f\w+1", r"^\d+", True, False), + (MetadataType.ALL, "inline1", "not_a_value", False, False), + (MetadataType.ALL, r"^i\w+1", r"^\d+", True, False), + (MetadataType.ALL, None, "not_a_value", False, False), + (MetadataType.ALL, None, r"^\d+", True, False), + (MetadataType.ALL, "frontmatter1", "foo", False, True), + (MetadataType.ALL, r"^f\w+1", r"[a-z]{3}", True, True), + (MetadataType.ALL, "frontmatter1", "foo", False, True), + (MetadataType.ALL, r"^f\w+1", r"[a-z]{3}", True, True), + (MetadataType.ALL, "inline1", "foo", False, True), + (MetadataType.ALL, r"^i\w+1", r"[a-z]{3}", True, True), + (MetadataType.ALL, None, "#tag1", False, True), + (MetadataType.ALL, None, r"^\w+1", True, True), + ], +) +def test_contains_metadata_1(sample_note, meta_type, key, value, is_regex, expected): + """Test contains_metadata() method. + + GIVEN a note object containing metadata + WHEN the contains_metadata method is called + THEN return the correct value + """ + note = Note(note_path=sample_note) + assert ( + note.contains_metadata( + meta_type=meta_type, search_key=key, search_value=value, is_regex=is_regex + ) + is expected + ) + + +@pytest.mark.parametrize( + ("meta_type", "key", "value"), + [ + (MetadataType.META, "", "foo"), + (MetadataType.FRONTMATTER, "", "foo"), + (MetadataType.INLINE, "", "foo"), + (MetadataType.TAGS, "foo", ""), + (MetadataType.META, None, "foo"), + (MetadataType.FRONTMATTER, None, "foo"), + (MetadataType.INLINE, None, "foo"), + (MetadataType.TAGS, "foo", None), + ], +) +def test_delete_metadata_1( + sample_note, + meta_type, + key, + value, +): + """Test delete_metadata() method. + + GIVEN a note object + WHEN when deleting invalid metadata types + THEN raise an error + """ + note = Note(note_path=sample_note) + + with pytest.raises(typer.Exit): + note.delete_metadata(meta_type=meta_type, key=key, value=value) + + +@pytest.mark.parametrize( + ("meta_type", "key", "value", "is_regex"), + [ + (MetadataType.META, "frontmatter1", None, False), + (MetadataType.FRONTMATTER, "frontmatter1", None, False), + (MetadataType.INLINE, "inline1", None, False), + (MetadataType.META, "inline1", None, False), + (MetadataType.META, "frontmatter1", "foo", False), + (MetadataType.FRONTMATTER, "frontmatter1", "foo", False), + (MetadataType.INLINE, "inline1", "foo", False), + (MetadataType.TAGS, None, "#tag1", False), + (MetadataType.TAGS, None, "tag1", False), + (MetadataType.META, r"\w\d", None, True), + (MetadataType.FRONTMATTER, r"\w\d$", None, True), + (MetadataType.INLINE, r"\w\d$", None, True), + (MetadataType.META, r"\w\d$", None, True), + (MetadataType.META, "frontmatter1", r"\w+", True), + (MetadataType.FRONTMATTER, "frontmatter1", r"\w+", True), + (MetadataType.INLINE, "inline1", r"\w+", True), + (MetadataType.TAGS, None, r"\w\d$", True), + (MetadataType.ALL, None, "#tag1", False), + (MetadataType.ALL, None, r"\w\d$", True), + (MetadataType.ALL, "inline1", r"\w+", True), + (MetadataType.ALL, "frontmatter1", r"\w+", True), + (MetadataType.ALL, "frontmatter1", "foo", False), + (MetadataType.ALL, "inline1", None, False), + ], +) +def test_delete_metadata_2(sample_note, meta_type, key, value, is_regex): + """Test delete_metadata() method. + + GIVEN a note object + WHEN when deleting metadata + THEN the metadata is deleted from the note + """ + note = Note(note_path=sample_note) + assert ( + note.contains_metadata( + meta_type=meta_type, search_key=key, search_value=value, is_regex=is_regex + ) + is True + ) + assert ( + note.delete_metadata(meta_type=meta_type, key=key, value=value, is_regex=is_regex) is True + ) + + assert ( + note.contains_metadata( + meta_type=meta_type, search_key=key, search_value=value, is_regex=is_regex + ) + is False + ) + + +@pytest.mark.parametrize( + ("meta_type", "key", "value", "is_regex"), + [ + (MetadataType.META, "frontmatterXXXX", None, False), + (MetadataType.FRONTMATTER, "frontmatterXXXX", None, False), + (MetadataType.INLINE, "inlineXXXX", None, False), + (MetadataType.META, "inlineXXXX", None, False), + (MetadataType.META, "frontmatter1", "XXXX", False), + (MetadataType.FRONTMATTER, "frontmatter1", "XXXX", False), + (MetadataType.INLINE, "inline1", "XXXX", False), + (MetadataType.TAGS, None, "#not_existing", False), + (MetadataType.TAGS, None, "XXXX", False), + (MetadataType.META, r"\d{8}", None, True), + (MetadataType.FRONTMATTER, r"\d{8}", None, True), + (MetadataType.INLINE, r"\d{8}", None, True), + (MetadataType.META, r"\d{8}", None, True), + (MetadataType.META, "frontmatter1", r"\d{8}", True), + (MetadataType.FRONTMATTER, "frontmatter1", r"\d{8}", True), + (MetadataType.INLINE, "inline1", r"\d{8}", True), + (MetadataType.TAGS, None, r"\d{8}", True), + ], +) +def test_delete_metadata_3(sample_note, meta_type, key, value, is_regex): + """Test delete_metadata() method. + + GIVEN a note object + WHEN when deleting metadata that does not exist + THEN return False + """ + note = Note(note_path=sample_note) + + assert ( + note.delete_metadata(meta_type=meta_type, key=key, value=value, is_regex=is_regex) is False + ) + + +def test_delete_all_metadata_1(sample_note) -> None: + """Test delete_all_metadata() method. + + GIVEN a note object + WHEN when deleting all metadata + THEN all metadata is deleted from the note + """ + note = Note(note_path=sample_note) + assert note.delete_all_metadata() is True + assert note.metadata == [] + assert "inline1:: foo" not in note.file_content + assert "#tag1" not in note.file_content + assert "frontmatter1: foo" not in note.file_content + + +def test_has_changes(sample_note) -> None: + """Test has_changes() method. + + GIVEN a note object + WHEN has_changes() is called + THEN the method returns True if the note has changes and False if not + """ + note = Note(note_path=sample_note) + assert note.has_changes() is False + note.write_string("This is a test string.", location=InsertLocation.BOTTOM) + assert note.has_changes() is True + + note = Note(note_path=sample_note) + assert note.has_changes() is False + note.delete_all_metadata() + assert note.has_changes() is True + + +def test_print_diff(sample_note, capsys) -> None: + """Test print_diff() method. + + GIVEN a note object + WHEN print_diff() is called + THEN the note's diff is printed to stdout + """ + note = Note(note_path=sample_note) + + note.write_string("This is a test string.", location=InsertLocation.BOTTOM) + note.print_diff() + captured = capsys.readouterr() + assert "+ This is a test string." in captured.out + + note.sub("The quick brown fox", "The quick brown hedgehog") + note.print_diff() + captured = capsys.readouterr() + assert "- The quick brown fox" in captured.out + assert "+ The quick brown hedgehog" in captured.out + + +def test_print_note(sample_note, capsys) -> None: + """Test print_note() method. + + GIVEN a note object + WHEN print_note() is called + THEN the note's new content is printed to stdout + """ + note = Note(note_path=sample_note) + note.print_note() + captured = capsys.readouterr() + assert "```python" in captured.out + assert "---" in captured.out + assert "#invalid" in captured.out + assert note.file_content == Regex(r"\[\[link\]\]") + + +@pytest.mark.parametrize( + ("key", "value1", "value2", "content", "result"), + [ + ("frontmatter1", "NEW_KEY", None, "NEW_KEY: foo", True), + ("inline1", "NEW_KEY", None, "NEW_KEY:: foo\nNEW_KEY::bar baz", True), + ("intext1", "NEW_KEY", None, "[NEW_KEY:: foo]", True), + ("frontmatter1", "foo", "NEW VALUE", "frontmatter1: NEW VALUE", True), + ("inline1", "bar baz", "NEW VALUE", "inline1:: foo\ninline1:: NEW VALUE", True), + ("intext1", "foo", "NEW VALUE", "[intext1:: NEW VALUE]", True), + ("XXX", "NEW_KEY", None, "NEW_KEY: foo", False), + ("frontmatter1", "XXX", "foo", "NEW_KEY: foo", False), + ("intext1", "XXX", "foo", "NEW_KEY: foo", False), + ], +) +def test_rename_metadata_1(sample_note, key, value1, value2, content, result) -> None: + """Test rename_metadata() method. + + GIVEN a note object + WHEN rename_metadata() is called + THEN the metadata objects and note content are updated if the metadata exists and the method returns False if not + """ + note = Note(note_path=sample_note) + assert note.rename_metadata(key=key, value_1=value1, value_2=value2) is result + + if result: + assert content in note.file_content + if value2 is None: + assert [x for x in note.metadata if x.clean_key == key] == [] + assert len([x for x in note.metadata if x.clean_key == value1]) > 0 + else: + assert [ + x for x in note.metadata if x.clean_key == key and x.normalized_value == value1 + ] == [] + assert ( + len( + [ + x + for x in note.metadata + if x.clean_key == key and x.normalized_value == value2 + ] + ) + > 0 + ) + + else: + assert note.has_changes() is False + + +def test_rename_tag_1(sample_note) -> None: + """Test rename_tag() method.". + + GIVEN a note object + WHEN rename_tag() is called + THEN the tag is renamed in the note's file content + """ + note = Note(note_path=sample_note) + assert note.rename_tag(old_tag="#tag1", new_tag="#tag3") is True + assert "#tag1" not in note.file_content + assert "#tag3" in note.file_content + assert ( + len([x for x in note.metadata if x.meta_type == MetadataType.TAGS and x.value == "tag1"]) + == 0 + ) + assert ( + len([x for x in note.metadata if x.meta_type == MetadataType.TAGS and x.value == "tag3"]) + == 1 + ) + + +def test_rename_tag_2(sample_note) -> None: + """Test rename_tag() method.". + + GIVEN a note object + WHEN rename_tag() is called with a tag that does not exist + THEN the method returns False + """ + note = Note(note_path=sample_note) + assert note.rename_tag(old_tag="not a tag", new_tag="#tag3") is False + + +def test_sub_1(sample_note) -> None: + """Test the sub() method. + + GIVEN a note object + WHEN sub() is called with a string that exists in the note + THEN the string is replaced in the note's file content + """ + note = Note(note_path=sample_note) + note.sub(" Foo bar ", "") + assert " Foo bar " not in note.file_content + assert "#tag1#tag2" in note.file_content + + +def test_sub_2(sample_note) -> None: + """Test the sub() method. + + GIVEN a note object + WHEN sub() is called with a string that exists in the note using regex + THEN the string is replaced in the note's file content + """ + note = Note(note_path=sample_note) + note.sub(r"\[.*\]", "", is_regex=True) + assert "[intext1:: foo]" not in note.file_content + + +def test_sub_3(sample_note) -> None: + """Test the sub() method. + + GIVEN a note object + WHEN sub() is called and matches nothing + THEN no changes are made to the note's file content + """ + note = Note(note_path=sample_note) + note2 = Note(note_path=sample_note) + note.sub("nonexistent", "") + assert note.file_content == note2.file_content + + +@pytest.mark.parametrize( + ("begin", "end", "key", "value", "location"), + [ + (MetadataType.FRONTMATTER, MetadataType.INLINE, "XXX", "XXX", InsertLocation.TOP), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "frontmatter1", "XXX", InsertLocation.TOP), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "XXX", "XXX", InsertLocation.TOP), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "intext2", "XXX", InsertLocation.TOP), + ], +) +def test_transpose_metadata_1(sample_note, begin, end, key, value, location): + """Test transpose_metadata() method. + + GIVEN a note object + WHEN transpose_metadata() is called without matching metadata + THEN the method returns False + """ + note = Note(note_path=sample_note) + assert not note.transpose_metadata( + begin=begin, end=end, key=key, value=value, location=location + ) + + +@pytest.mark.parametrize( + ("begin", "end", "key", "value", "location", "content"), + [ + ( + MetadataType.FRONTMATTER, + MetadataType.INLINE, + "frontmatter2", + None, + InsertLocation.TOP, + "---\nfrontmatter2:: bar\nfrontmatter2:: baz\nfrontmatter2:: qux", + ), + ( + MetadataType.INLINE, + MetadataType.FRONTMATTER, + "inline1", + None, + InsertLocation.TOP, + "inline1:\n - foo\n - bar baz\n---", + ), + ( + MetadataType.INLINE, + MetadataType.FRONTMATTER, + "intext2", + "foo", + InsertLocation.TOP, + "intext2: foo\n---", + ), + ( + MetadataType.INLINE, + MetadataType.FRONTMATTER, + "inline1", + "foo", + InsertLocation.TOP, + "inline1: foo\n---", + ), + ( + MetadataType.INLINE, + MetadataType.INLINE, + None, + None, + InsertLocation.BOTTOM, + "```\n\ninline1:: bar baz\ninline1:: foo\ninline2:: [[foo]]\ninline3:: value\ninline4:: foo\ninline5::\nintext1:: foo\nintext2:: foo\nkey with space:: foo\n๐ŸŒฑ:: ๐ŸŒฟ", + ), + ], +) +def test_transpose_metadata_2(sample_note, begin, end, key, value, location, content): + """Test transpose_metadata() method. + + GIVEN a note object + WHEN transpose_metadata() is called + THEN the method returns True and all metadata with the specified keys & values is transposed + """ + note = Note(note_path=sample_note) + if value is None: + original_fields = [x for x in note.metadata if x.key == key and x.meta_type == begin] + else: + original_fields = [ + x + for x in note.metadata + if x.key == key and x.normalized_value == value and x.meta_type == begin + ] + + assert ( + note.transpose_metadata(begin=begin, end=end, key=key, value=value, location=location) + is True + ) + + if value is None: + new_fields = [x for x in note.metadata if x.key == key and x.meta_type == end] + else: + new_fields = [ + x + for x in note.metadata + if x.key == key + and x.normalized_value == value + and x.meta_type == end + and x.is_changed is True + ] + + assert len(new_fields) == len(original_fields) + + if value is None: + assert len([x for x in note.metadata if x.key == key and x.meta_type == begin]) == 0 + else: + assert ( + len( + [ + x + for x in note.metadata + if x.key == key and x.normalized_value == value and x.meta_type == begin + ] + ) + == 0 + ) + + assert content in note.file_content + + +def test_transpose_metadata_3(sample_note): + """Test transpose_metadata() method. + + GIVEN a note object + WHEN transpose_metadata() is called with only Frontmatter + THEN the method returns False + """ + note = Note(note_path=sample_note) + assert not note.transpose_metadata( + begin=MetadataType.FRONTMATTER, + end=MetadataType.FRONTMATTER, + key="frontmatter1", + value="foo", + ) + + +def test_write_frontmatter_1(tmp_path) -> None: + """Test writing frontmatter. + + GIVEN a note with no frontmatter + WHEN there are no frontmatter metadata objects + THEN return False + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text( + """ +# Header1 +inline:: only +no frontmatter +""" + ) + note = Note(note_path=note_path) + assert note.write_frontmatter() is False + + +def test_write_frontmatter_2(tmp_path) -> None: + """Test writing frontmatter. + + GIVEN a note with frontmatter + WHEN there is no frontmatter to write + THEN all frontmatter in the note is removed + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text( + """ +--- +key: value +--- + +# Header1 +inline:: only +no frontmatter +""" + ) + new_content = """ + +# Header1 +inline:: only +no frontmatter +""" + note = Note(note_path=note_path) + note.metadata = [] + assert note.write_frontmatter() is True + assert note.file_content == new_content + + +def test_write_frontmatter_3(tmp_path) -> None: + """Test writing frontmatter. + + GIVEN a note with frontmatter + WHEN there is new frontmatter to write and no frontmatter in the note content + THEN new frontmatter is added + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text( + """ +# Header1 +inline:: only +no frontmatter +""" + ) + new_note = """\ +--- +key: value +--- + +# Header1 +inline:: only +no frontmatter +""" + note = Note(note_path=note_path) + note.add_metadata(meta_type=MetadataType.FRONTMATTER, added_key="key", added_value="value") + assert note.write_frontmatter() is True + assert note.file_content == new_note + + +def test_write_frontmatter_4(tmp_path) -> None: + """Test writing frontmatter. + + GIVEN a note with frontmatter + WHEN there is new frontmatter to write and existing frontmatter in the note + THEN new frontmatter is added + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text( + """\ +--- +key: value +--- + +# Header1 +inline:: only +no frontmatter +""" + ) + new_note = """\ +--- +key: value +key2: value2 +--- + +# Header1 +inline:: only +no frontmatter +""" + + note = Note(note_path=note_path) + note.add_metadata(meta_type=MetadataType.FRONTMATTER, added_key="key2", added_value="value2") + assert note.write_frontmatter() is True + assert note.file_content == new_note + + +@pytest.mark.parametrize( + ("location"), [InsertLocation.TOP, InsertLocation.BOTTOM, InsertLocation.AFTER_TITLE] +) +def test_write_string_1(tmp_path, location) -> None: + """Test write_string() method. + + GIVEN a note with no content + WHEN a string is written to the note + THEN the string is written to the note + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text("") + note = Note(note_path=note_path) + + note.write_string("foo", location=location) + assert note.file_content.strip() == "foo" + + +@pytest.mark.parametrize( + ("location", "result"), + [ + (InsertLocation.TOP, "baz\n\n# Header1\nfoo bar"), + (InsertLocation.BOTTOM, "# Header1\nfoo bar\nbaz"), + (InsertLocation.AFTER_TITLE, "# Header1\nbaz\nfoo bar"), + ], +) +def test_write_string_2(tmp_path, location, result) -> None: + """Test write_string() method. + + GIVEN a note with no frontmatter + WHEN a string is written to the note + THEN the string is written to the correct location + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text("\n# Header1\nfoo bar") + note = Note(note_path=note_path) + + note.write_string("baz", location=location) + assert note.file_content.strip() == result + + +@pytest.mark.parametrize( + ("location", "result"), + [ + (InsertLocation.TOP, "---\nkey: value\n---\nbaz\n# Header1\nfoo bar"), + (InsertLocation.BOTTOM, "---\nkey: value\n---\n# Header1\nfoo bar\nbaz"), + (InsertLocation.AFTER_TITLE, "---\nkey: value\n---\n# Header1\nbaz\nfoo bar"), + ], +) +def test_write_string_3(tmp_path, location, result) -> None: + """Test write_string() method. + + GIVEN a note with frontmatter + WHEN a string is written to the note + THEN the string is written to the correct location + """ + note_path = Path(tmp_path) / "note.md" + note_path.touch() + note_path.write_text("---\nkey: value\n---\n# Header1\nfoo bar") + note = Note(note_path=note_path) + + note.write_string("baz", location=location) + assert note.file_content.strip() == result diff --git a/tests/notes_test.py b/tests/notes_test.py deleted file mode 100644 index 144cb4b..0000000 --- a/tests/notes_test.py +++ /dev/null @@ -1,1233 +0,0 @@ -# type: ignore -"""Test notes.py.""" - -import re -from pathlib import Path - -import pytest -import typer - -from obsidian_metadata.models.enums import InsertLocation, MetadataType -from obsidian_metadata.models.exceptions import InlineMetadataError, InlineTagError -from obsidian_metadata.models.notes import Note -from tests.helpers import Regex - - -def test_note_not_exists() -> None: - """Test target not found. - - GIVEN a path to a non-existent file - WHEN a Note object is created pointing to that file - THEN a typer.Exit exception is raised - """ - with pytest.raises(typer.Exit): - Note(note_path="nonexistent_file.md") - - -def test_create_note_1(sample_note): - """Test creating a note object. - - GIVEN a path to a markdown file - WHEN a Note object is created pointing to that file - THEN the Note object is created - """ - note = Note(note_path=sample_note, dry_run=True) - assert note.note_path == Path(sample_note) - assert note.dry_run is True - assert "Lorem ipsum dolor" in note.file_content - assert note.encoding == "utf_8" - assert note.frontmatter.dict == { - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "shared_key1": ["shared_key1_value", "shared_key1_value3"], - "shared_key2": ["shared_key2_value1"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - } - - assert note.tags.list == [ - "inline_tag_bottom1", - "inline_tag_bottom2", - "inline_tag_top1", - "inline_tag_top2", - "intext_tag1", - "intext_tag2", - "shared_tag", - ] - assert note.inline_metadata.dict == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "shared_key1": ["shared_key1_value", "shared_key1_value2"], - "shared_key2": ["shared_key2_value2"], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - - with sample_note.open(): - content = sample_note.read_text() - - assert note.file_content == content - assert note.original_file_content == content - - -def test_create_note_2() -> None: - """Test creating a note object. - - GIVEN a text file with invalid frontmatter - WHEN the note is initialized - THEN a typer exit is raised - """ - broken_fm = Path("tests/fixtures/broken_frontmatter.md") - with pytest.raises(typer.Exit): - Note(note_path=broken_fm) - - -def test_create_note_3(sample_note, mocker) -> None: - """Test creating a note object. - - GIVEN a text file with invalid inline metadata - WHEN the note is initialized - THEN a typer exit is raised - """ - mocker.patch( - "obsidian_metadata.models.notes.InlineMetadata", - side_effect=InlineMetadataError("error message"), - ) - - with pytest.raises(typer.Exit): - Note(note_path=sample_note) - - -def test_create_note_4(sample_note, mocker) -> None: - """Test creating a note object. - - GIVEN a text file - WHEN there is an error parsing the inline tags - THEN a typer exit is raised - """ - mocker.patch( - "obsidian_metadata.models.notes.InlineTags", - side_effect=InlineTagError("error message"), - ) - - with pytest.raises(typer.Exit): - Note(note_path=sample_note) - - -def test_add_metadata_method_1(short_notes): - """Test adding metadata. - - GIVEN calling the add_metadata method - WHEN a key is passed without a value - THEN the key is added to to the InlineMetadata object and the file content - """ - note = Note(note_path=short_notes[0]) - assert note.inline_metadata.dict == {} - - assert ( - note.add_metadata(MetadataType.INLINE, location=InsertLocation.BOTTOM, key="new_key1") - is True - ) - assert note.inline_metadata.dict == {"new_key1": []} - assert "new_key1::" in note.file_content.strip() - - -def test_add_metadata_method_2(short_notes): - """Test adding metadata. - - GIVEN calling the add_metadata method - WHEN a key is passed with a value - THEN the key and value is added to to the InlineMetadata object and the file content - """ - note = Note(note_path=short_notes[0]) - assert note.inline_metadata.dict == {} - - assert ( - note.add_metadata( - MetadataType.INLINE, key="new_key2", value="new_value1", location=InsertLocation.TOP - ) - is True - ) - assert note.inline_metadata.dict == {"new_key2": ["new_value1"]} - assert "new_key2:: new_value1" in note.file_content - - -def test_add_metadata_method_3(short_notes): - """Test adding metadata. - - GIVEN calling the add_metadata method - WHEN a key is passed that already exists - THEN the the method returns False - """ - note = Note(note_path=short_notes[0]) - note.inline_metadata.dict = {"new_key1": []} - assert ( - note.add_metadata(MetadataType.INLINE, location=InsertLocation.BOTTOM, key="new_key1") - is False - ) - - -def test_add_metadata_method_4(short_notes): - """Test adding metadata. - - GIVEN calling the add_metadata method - WHEN a key is passed with a value that already exists - THEN the the method returns False - """ - note = Note(note_path=short_notes[0]) - note.inline_metadata.dict = {"new_key2": ["new_value1"]} - assert ( - note.add_metadata( - MetadataType.INLINE, key="new_key2", value="new_value1", location=InsertLocation.TOP - ) - is False - ) - - -def test_add_metadata_method_5(sample_note): - """Test add_metadata() method. - - GIVEN a note with frontmatter - WHEN add_metadata() is called with a key or value that already exists in the frontmatter - THEN the method returns False - """ - note = Note(note_path=sample_note) - assert note.add_metadata(MetadataType.FRONTMATTER, "frontmatter_Key1") is False - assert note.add_metadata(MetadataType.FRONTMATTER, "shared_key1", "shared_key1_value") is False - - -def test_add_metadata_method_6(sample_note): - """Test add_metadata() method. - - GIVEN a note with frontmatter - WHEN add_metadata() is called with a new key - THEN the key is added to the frontmatter - """ - note = Note(note_path=sample_note) - assert "new_key1" not in note.frontmatter.dict - assert note.add_metadata(MetadataType.FRONTMATTER, "new_key1") is True - assert "new_key1" in note.frontmatter.dict - - -def test_add_metadata_method_7(sample_note): - """Test add_metadata() method. - - GIVEN a note with frontmatter - WHEN add_metadata() is called with a new key and value - THEN the key and value is added to the frontmatter - """ - note = Note(note_path=sample_note) - assert "new_key" not in note.frontmatter.dict - assert note.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_value") is True - assert note.frontmatter.dict["new_key"] == ["new_value"] - - -def test_add_metadata_method_8(sample_note): - """Test add_metadata() method. - - GIVEN a note with frontmatter - WHEN add_metadata() is called with an existing key and new value - THEN the new value is appended to the existing key - """ - note = Note(note_path=sample_note) - assert "new_key" not in note.frontmatter.dict - assert note.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_value") is True - assert note.frontmatter.dict["new_key"] == ["new_value"] - assert note.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_value2") is True - assert note.frontmatter.dict["new_key"] == ["new_value", "new_value2"] - - -def test_add_metadata_method_9(sample_note): - """Test add_metadata() method. - - GIVEN a note object - WHEN add_metadata() is with an existing tag - THEN the method returns False - """ - note = Note(note_path=sample_note) - assert ( - note.add_metadata(MetadataType.TAGS, value="shared_tag", location=InsertLocation.TOP) - is False - ) - - -def test_add_metadata_method_10(sample_note): - """Test add_metadata() method. - - GIVEN a note object - WHEN add_metadata() is called with a new tag - THEN the tag is added to the InlineTags object and the file content - """ - note = Note(note_path=sample_note) - assert "new_tag2" not in note.tags.list - assert ( - note.add_metadata(MetadataType.TAGS, value="new_tag2", location=InsertLocation.BOTTOM) - is True - ) - assert "new_tag2" in note.tags.list - assert "#new_tag2" in note.file_content - - -def test_commit_1(sample_note, tmp_path) -> None: - """Test commit() method. - - GIVEN a note object with commit() called - WHEN the note is modified - THEN the updated note is committed to the file system - """ - note = Note(note_path=sample_note) - note.sub(pattern="Heading 1", replacement="Heading 2") - - note.commit() - note = Note(note_path=sample_note) - assert "Heading 2" in note.file_content - assert "Heading 1" not in note.file_content - - new_path = Path(tmp_path / "new_note.md") - note.commit(new_path) - note2 = Note(note_path=new_path) - assert "Heading 2" in note2.file_content - assert "Heading 1" not in note2.file_content - - -def test_commit_2(sample_note) -> None: - """Test commit() method. - - GIVEN a note object with commit() called - WHEN the note is modified and dry_run is True - THEN the note is not committed to the file system - """ - note = Note(note_path=sample_note, dry_run=True) - note.sub(pattern="Heading 1", replacement="Heading 2") - - note.commit() - note = Note(note_path=sample_note) - assert "Heading 1" in note.file_content - - -def test_contains_tag(sample_note) -> None: - """Test contains_tag method. - - GIVEN a note object - WHEN contains_tag() is called - THEN the method returns True if the tag is found and False if not - - """ - note = Note(note_path=sample_note) - assert note.contains_tag("intext_tag1") is True - assert note.contains_tag("nonexistent_tag") is False - assert note.contains_tag(r"\d$", is_regex=True) is True - assert note.contains_tag(r"^\d", is_regex=True) is False - - -def test_contains_metadata(sample_note) -> None: - """Test contains_metadata method. - - GIVEN a note object - WHEN contains_metadata() is called - THEN the method returns True if the key and/or value are found and False if not - - """ - note = Note(note_path=sample_note) - - assert note.contains_metadata("no key") is False - assert note.contains_metadata("frontmatter_Key2") is True - assert note.contains_metadata(r"^\d", is_regex=True) is False - assert note.contains_metadata(r"^[\w_]+\d", is_regex=True) is True - assert note.contains_metadata("frontmatter_Key2", "no value") is False - assert note.contains_metadata("frontmatter_Key2", "article") is True - assert note.contains_metadata("bottom_key1", "bottom_key1_value") is True - assert note.contains_metadata(r"bottom_key\d$", r"bottom_key\d_value", is_regex=True) is True - - -def test_delete_all_metadata(sample_note): - """Test delete_all_metadata() method. - - GIVEN a note object - WHEN delete_all_metadata() is called - THEN all tags, frontmatter, and inline metadata are deleted - """ - note = Note(note_path=sample_note) - note.delete_all_metadata() - assert note.tags.list == [] - assert note.frontmatter.dict == {} - assert note.inline_metadata.dict == {} - assert note.file_content == Regex("consequat. Duis") - assert "codeblock_key:: some text" in note.file_content - assert "#ffffff" in note.file_content - assert "---" not in note.file_content - - -def test_delete_tag(sample_note) -> None: - """Test delete_tag method. - - GIVEN a note object - WHEN delete_tag() is called - THEN the method returns True if the tag is found and deleted and False if not - """ - note = Note(note_path=sample_note) - assert note.delete_tag("not_a_tag") is False - assert note.delete_tag("intext_tag[1]") is True - assert "intext_tag1" not in note.tags.list - assert note.file_content == Regex("consequat. Duis") - - -def test_delete_metadata_1(sample_note): - """Test delete_metadata() method. - - GIVEN a note object - WHEN delete_metadata() is called with a keys or values that do not exist - THEN the method returns False - """ - note = Note(note_path=sample_note) - assert note.delete_metadata("nonexistent_key") is False - assert note.delete_metadata("frontmatter_Key1", "no value") is False - - -def test_delete_metadata_2(sample_note): - """Test delete_metadata() method. - - GIVEN a note object - WHEN delete_metadata() is called with a frontmatter key and no value - THEN the entire key and all values are deleted - """ - note = Note(note_path=sample_note) - assert "frontmatter_Key1" in note.frontmatter.dict - assert note.delete_metadata("frontmatter_Key1") is True - assert "frontmatter_Key1" not in note.frontmatter.dict - - -def test_delete_metadata_3(sample_note): - """Test delete_metadata() method. - - GIVEN a note object - WHEN delete_metadata() is called with a frontmatter key and value - THEN the value is deleted from the key - """ - note = Note(note_path=sample_note) - assert note.frontmatter.dict["frontmatter_Key2"] == ["article", "note"] - assert note.delete_metadata("frontmatter_Key2", "article") is True - assert note.frontmatter.dict["frontmatter_Key2"] == ["note"] - - -def test_delete_metadata_4(sample_note): - """Test delete_metadata() method. - - GIVEN a note object - WHEN delete_metadata() is called with an inline key and value - THEN the value is deleted from the InlineMetadata object and the file content - """ - note = Note(note_path=sample_note) - assert note.inline_metadata.dict["bottom_key1"] == ["bottom_key1_value"] - assert note.file_content == Regex(r"bottom_key1:: bottom_key1_value\n") - assert note.delete_metadata("bottom_key1", "bottom_key1_value") is True - assert note.inline_metadata.dict["bottom_key1"] == [] - assert note.file_content == Regex(r"bottom_key1::\n") - - -def test_delete_metadata_5(sample_note): - """Test delete_metadata() method. - - GIVEN a note object - WHEN delete_metadata() is called with an inline key and no value - THEN the key and all values are deleted from the InlineMetadata object and the file content - """ - note = Note(note_path=sample_note) - assert note.inline_metadata.dict["bottom_key2"] == ["bottom_key2_value"] - assert note.delete_metadata("bottom_key2") is True - assert "bottom_key2" not in note.inline_metadata.dict - assert note.file_content != Regex(r"bottom_key2") - - -def test_delete_metadata_6(sample_note): - """Test delete_metadata() method. - - GIVEN a note object - WHEN delete_metadata() is called with an inline key and a single value - THEN the specified value is removed from the InlineMetadata object and the file content and remaining values are untouched - """ - note = Note(note_path=sample_note) - assert note.inline_metadata.dict["shared_key1"] == ["shared_key1_value", "shared_key1_value2"] - assert ( - note.delete_metadata("shared_key1", "shared_key1_value2", area=MetadataType.INLINE) is True - ) - assert note.inline_metadata.dict["shared_key1"] == ["shared_key1_value"] - assert note.file_content == Regex(r"shared_key1_value") - assert note.file_content != Regex(r"shared_key1_value2") - - -def test_has_changes(sample_note) -> None: - """Test has_changes() method. - - GIVEN a note object - WHEN has_changes() is called - THEN the method returns True if the note has changes and False if not - """ - note = Note(note_path=sample_note) - assert note.has_changes() is False - note.write_string("This is a test string.", location=InsertLocation.BOTTOM) - assert note.has_changes() is True - - note = Note(note_path=sample_note) - assert note.has_changes() is False - note.delete_metadata("frontmatter_Key1") - assert note.has_changes() is True - - note = Note(note_path=sample_note) - assert note.has_changes() is False - note.delete_metadata("bottom_key2") - assert note.has_changes() is True - - note = Note(note_path=sample_note) - assert note.has_changes() is False - note.delete_tag("intext_tag1") - assert note.has_changes() is True - - -def test_print_diff(sample_note, capsys) -> None: - """Test print_diff() method. - - GIVEN a note object - WHEN print_diff() is called - THEN the note's diff is printed to stdout - """ - note = Note(note_path=sample_note) - - note.write_string("This is a test string.", location=InsertLocation.BOTTOM) - note.print_diff() - captured = capsys.readouterr() - assert "+ This is a test string." in captured.out - - note.sub("The quick brown fox", "The quick brown hedgehog") - note.print_diff() - captured = capsys.readouterr() - assert "- The quick brown fox" in captured.out - assert "+ The quick brown hedgehog" in captured.out - - -def test_print_note(sample_note, capsys) -> None: - """Test print_note() method. - - GIVEN a note object - WHEN print_note() is called - THEN the note's new content is printed to stdout - """ - note = Note(note_path=sample_note) - note.print_note() - captured = capsys.readouterr() - assert "```python" in captured.out - assert "---" in captured.out - assert "#shared_tag" in captured.out - - -def test_rename_tag_1(sample_note) -> None: - """Test rename_tag() method. - - GIVEN a note object - WHEN rename_tag() is called with a tag that does not exist - THEN the method returns False - """ - note = Note(note_path=sample_note) - assert note.rename_tag("no_note_tag", "intext_tag2") is False - - -def test_rename_tag_2(sample_note) -> None: - """Test rename_tag() method. - - GIVEN a note object - WHEN rename_tag() is called with a tag exists - THEN the tag is renamed in the InlineTag object and the file content - """ - note = Note(note_path=sample_note) - assert "intext_tag1" in note.tags.list - assert note.rename_tag("intext_tag1", "intext_tag26") is True - assert "intext_tag1" not in note.tags.list - assert "intext_tag26" in note.tags.list - assert note.file_content == Regex(r"#intext_tag26") - assert note.file_content != Regex(r"#intext_tag1") - - -def test_rename_metadata_1(sample_note) -> None: - """Test rename_metadata() method. - - GIVEN a note object - WHEN rename_metadata() is called with a key and/or value that does not exist - THEN the method returns False - """ - note = Note(note_path=sample_note) - assert note.rename_metadata("nonexistent_key", "new_key") is False - assert note.rename_metadata("frontmatter_Key1", "nonexistent_value", "article") is False - - -def test_rename_metadata_2(sample_note) -> None: - """Test rename_metadata() method. - - GIVEN a note object - WHEN rename_metadata() is called with key that matches a frontmatter key - THEN the key is renamed in the Frontmatter object and the file content - """ - note = Note(note_path=sample_note) - assert note.frontmatter.dict["frontmatter_Key1"] == ["author name"] - assert note.rename_metadata("frontmatter_Key1", "new_key") is True - assert "frontmatter_Key1" not in note.frontmatter.dict - assert "new_key" in note.frontmatter.dict - assert note.frontmatter.dict["new_key"] == ["author name"] - assert note.file_content == Regex(r"new_key: author name") - - -def test_rename_metadata_3(sample_note) -> None: - """Test rename_metadata() method. - - GIVEN a note object - WHEN rename_metadata() is called with key/value that matches a frontmatter key/value - THEN the key/value is renamed in the Frontmatter object and the file content - """ - note = Note(note_path=sample_note) - assert note.frontmatter.dict["frontmatter_Key2"] == ["article", "note"] - assert note.rename_metadata("frontmatter_Key2", "article", "new_key") is True - assert note.frontmatter.dict["frontmatter_Key2"] == ["new_key", "note"] - assert note.file_content == Regex(r" - new_key") - assert note.file_content != Regex(r" - article") - - -def test_rename_metadata_4(sample_note) -> None: - """Test rename_metadata() method. - - GIVEN a note object - WHEN rename_metadata() is called with key that matches an inline key - THEN the key is renamed in the InlineMetada object and the file content - """ - note = Note(note_path=sample_note) - assert note.rename_metadata("bottom_key1", "new_key") is True - assert "bottom_key1" not in note.inline_metadata.dict - assert "new_key" in note.inline_metadata.dict - assert note.file_content == Regex(r"new_key:: bottom_key1_value") - - -def test_rename_metadata_5(sample_note) -> None: - """Test rename_metadata() method. - - GIVEN a note object - WHEN rename_metadata() is called with key/value that matches an inline key/value - THEN the key/value is renamed in the InlineMetada object and the file content - """ - note = Note(note_path=sample_note) - assert note.rename_metadata("bottom_key1", "bottom_key1_value", "new_value") is True - assert note.inline_metadata.dict["bottom_key1"] == ["new_value"] - assert note.file_content == Regex(r"bottom_key1:: new_value") - - -def test_sub(sample_note) -> None: - """Test the sub() method. - - GIVEN a note object - WHEN sub() is called with a string that exists in the note - THEN the string is replaced in the note's file content - """ - note = Note(note_path=sample_note) - note.sub("#shared_tag", "#unshared_tags", is_regex=True) - assert note.file_content != Regex(r"#shared_tag") - assert note.file_content == Regex(r"#unshared_tags") - - note.sub(" ut ", "") - assert note.file_content != Regex(r" ut ") - assert note.file_content == Regex(r"laboriosam, nisialiquid ex ea") - - -def test_transpose_metadata_1(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN a metadata object is empty - THEN the method returns False - """ - note = Note(note_path=sample_note) - note.frontmatter.dict = {} - assert note.transpose_metadata(begin=MetadataType.FRONTMATTER, end=MetadataType.INLINE) is False - - note = Note(note_path=sample_note) - note.inline_metadata.dict = {} - assert note.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) is False - - -def test_transpose_metadata_2(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN a specified key and/or value does not exist - THEN the method returns False - """ - note = Note(note_path=sample_note) - assert ( - note.transpose_metadata( - begin=MetadataType.FRONTMATTER, - end=MetadataType.INLINE, - key="not_a_key", - ) - is False - ) - assert ( - note.transpose_metadata( - begin=MetadataType.FRONTMATTER, - end=MetadataType.INLINE, - key="frontmatter_Key2", - value="not_a_value", - ) - is False - ) - assert ( - note.transpose_metadata( - begin=MetadataType.FRONTMATTER, - end=MetadataType.INLINE, - key="frontmatter_Key2", - value=["not_a_value", "not_a_value2"], - ) - is False - ) - - -def test_transpose_metadata_3(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN FRONTMATTER to INLINE and no key or value is specified - THEN all frontmatter is removed and added to the inline metadata object and the file content - """ - note = Note(note_path=sample_note) - assert note.transpose_metadata(begin=MetadataType.FRONTMATTER, end=MetadataType.INLINE) is True - assert note.frontmatter.dict == {} - assert note.inline_metadata.dict == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "shared_key1": [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ], - "shared_key2": ["shared_key2_value2", "shared_key2_value1"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - - -def test_transpose_metadata_4(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN INLINE to FRONTMATTER and no key or value is specified - THEN all inline metadata is removed and added to the frontmatter object and the file content - """ - note = Note(note_path=sample_note) - assert note.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) is True - assert note.inline_metadata.dict == {} - assert note.frontmatter.dict == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "shared_key1": [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ], - "shared_key2": ["shared_key2_value1", "shared_key2_value2"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - - -def test_transpose_metadata_5(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN a key exists in both frontmatter and inline metadata - THEN the values for the key are merged in the specified metadata object - """ - note = Note(note_path=sample_note) - assert note.frontmatter.dict["shared_key1"] == ["shared_key1_value", "shared_key1_value3"] - assert note.inline_metadata.dict["shared_key1"] == ["shared_key1_value", "shared_key1_value2"] - assert ( - note.transpose_metadata( - begin=MetadataType.FRONTMATTER, - end=MetadataType.INLINE, - key="shared_key1", - ) - is True - ) - assert "shared_key1" not in note.frontmatter.dict - assert note.inline_metadata.dict["shared_key1"] == [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ] - - -def test_transpose_metadata_6(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN a specified key with no value is specified - THEN the key is removed from the specified metadata object and added to the target metadata object - """ - note = Note(note_path=sample_note) - assert "top_key1" not in note.frontmatter.dict - assert "top_key1" in note.inline_metadata.dict - assert ( - note.transpose_metadata( - begin=MetadataType.INLINE, - end=MetadataType.FRONTMATTER, - key="top_key1", - ) - is True - ) - assert "top_key1" not in note.inline_metadata.dict - assert note.frontmatter.dict["top_key1"] == ["top_key1_value"] - - -def test_transpose_metadata_7(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN a specified value is a list - THEN the key/value is removed from the specified metadata object and added to the target metadata object - """ - note = Note(note_path=sample_note) - assert ( - note.transpose_metadata( - begin=MetadataType.FRONTMATTER, - end=MetadataType.INLINE, - key="frontmatter_Key2", - value=["article", "note"], - ) - is True - ) - assert "frontmatter_Key2" not in note.frontmatter.dict - assert note.inline_metadata.dict["frontmatter_Key2"] == ["article", "note"] - - -def test_transpose_metadata_8(sample_note): - """Test transpose_metadata() method. - - GIVEN a note object with transpose_metadata() is called - WHEN a specified value is a string - THEN the key/value is removed from the specified metadata object and added to the target metadata object - """ - note = Note(note_path=sample_note) - assert ( - note.transpose_metadata( - begin=MetadataType.FRONTMATTER, - end=MetadataType.INLINE, - key="frontmatter_Key2", - value="note", - ) - is True - ) - assert note.frontmatter.dict["frontmatter_Key2"] == ["article"] - assert note.inline_metadata.dict["frontmatter_Key2"] == ["note"] - - -def test_write_delete_inline_metadata_1(sample_note) -> None: - """Twrite_delete_inline_metadata() method. - - GIVEN a note object with write_delete_inline_metadata() called - WHEN a key is specified that is not in the inline metadata - THEN the file content is not changed - - """ - note = Note(note_path=sample_note) - note.write_delete_inline_metadata("nonexistent_key") - assert note.file_content == note.original_file_content - note.write_delete_inline_metadata("frontmatter_Key1") - assert note.file_content == note.original_file_content - - -def test_write_delete_inline_metadata_2(sample_note) -> None: - """Twrite_delete_inline_metadata() method. - - GIVEN a note object with write_delete_inline_metadata() called - WHEN a key is specified that is within a body of text - THEN the key and all associated values are removed from the note content - - """ - note = Note(note_path=sample_note) - note.write_delete_inline_metadata("intext_key", is_regex=False) - assert note.file_content == Regex(r"dolore eu fugiat", re.DOTALL) - - -def test_write_delete_inline_metadata_3(sample_note) -> None: - """Twrite_delete_inline_metadata() method. - - GIVEN a note object with write_delete_inline_metadata() called - WHEN a key is specified that is not within a body of text - THEN the key/value is removed from the note content - """ - note = Note(note_path=sample_note) - note.write_delete_inline_metadata("bottom_key2", "bottom_key2_value", is_regex=False) - assert note.file_content != Regex(r"bottom_key2_value") - assert note.file_content == Regex(r"bottom_key2::") - note.write_delete_inline_metadata("bottom_key1") - assert note.file_content != Regex(r"bottom_key1::") - - -def test_write_delete_inline_metadata_4(sample_note) -> None: - """Twrite_delete_inline_metadata() method. - - GIVEN a note object with write_delete_inline_metadata() called - WHEN no key or value is specified - THEN all inline metadata is removed from the note content - """ - note = Note(note_path=sample_note) - note.write_delete_inline_metadata() - assert note.file_content == Regex(r"codeblock_key::") - assert note.file_content != Regex(r"key๐Ÿ“…::") - assert note.file_content != Regex(r"top_key1::") - assert note.file_content != Regex(r"top_key3::") - assert note.file_content != Regex(r"intext_key::") - assert note.file_content != Regex(r"shared_key1::") - assert note.file_content != Regex(r"shared_key2::") - assert note.file_content != Regex(r"bottom_key1::") - assert note.file_content != Regex(r"bottom_key2::") - - -def test_write_frontmatter_1(sample_note) -> None: - """Test writing frontmatter. - - GIVEN a note with frontmatter - WHEN the frontmatter object is different - THEN the old frontmatter is replaced with the new frontmatter - """ - note = Note(note_path=sample_note) - - assert note.rename_metadata("frontmatter_Key1", "author name", "some_new_key_here") is True - assert note.write_frontmatter() is True - new_frontmatter = """--- -date_created: '2022-12-22' -tags: - - frontmatter_tag1 - - frontmatter_tag2 - - shared_tag - - ๐Ÿ“…/frontmatter_tag3 -frontmatter_Key1: some_new_key_here -frontmatter_Key2: - - article - - note -shared_key1: - - shared_key1_value - - shared_key1_value3 -shared_key2: shared_key2_value1 ----""" - assert new_frontmatter in note.file_content - assert "# Heading 1" in note.file_content - assert "```python" in note.file_content - - -def test_write_frontmatter_2() -> None: - """Test replacing frontmatter. - - GIVEN a note with no frontmatter - WHEN the frontmatter object has values - THEN the frontmatter is added to the note - """ - note = Note(note_path="tests/fixtures/test_vault/no_metadata.md") - - note.frontmatter.dict = {"key1": "value1", "key2": "value2"} - assert note.write_frontmatter() is True - new_frontmatter = """--- -key1: value1 -key2: value2 ----""" - assert new_frontmatter in note.file_content - assert "Lorem ipsum dolor sit amet" in note.file_content - - -def test_write_frontmatter_3(sample_note) -> None: - """Test replacing frontmatter. - - GIVEN a note with frontmatter - WHEN the frontmatter object is empty - THEN the frontmatter is removed from the note - """ - note = Note(note_path=sample_note) - - note.frontmatter.dict = {} - assert note.write_frontmatter() is True - assert "---" not in note.file_content - assert note.file_content != Regex("date_created:") - assert "Lorem ipsum dolor sit amet" in note.file_content - - -def test_write_frontmatter_4() -> None: - """Test replacing frontmatter. - - GIVEN a note with no frontmatter - WHEN the frontmatter object is empty - THEN the frontmatter is not added to the note - """ - note = Note(note_path="tests/fixtures/test_vault/no_metadata.md") - note.frontmatter.dict = {} - assert note.write_frontmatter() is False - assert "---" not in note.file_content - assert "Lorem ipsum dolor sit amet" in note.file_content - - -def test_write_all_inline_metadata_1(sample_note) -> None: - """Test write_all_inline_metadata() method. - - GIVEN a note object with write_metadata_all() called - WHEN the note has inline metadata - THEN the inline metadata is written to the note - """ - note = Note(note_path=sample_note) - metadata_block = """ -bottom_key1:: bottom_key1_value -bottom_key2:: bottom_key2_value -intext_key:: intext_value -key๐Ÿ“…:: ๐Ÿ“…_key_value -shared_key1:: shared_key1_value -shared_key1:: shared_key1_value2 -shared_key2:: shared_key2_value2 -top_key1:: top_key1_value -top_key2:: top_key2_value -top_key3:: top_key3_value_as_link""" - assert metadata_block not in note.file_content - assert note.write_all_inline_metadata(location=InsertLocation.BOTTOM) is True - assert metadata_block in note.file_content - - -def test_write_all_inline_metadata_2(sample_note) -> None: - """Test write_all_inline_metadata() method. - - GIVEN a note object with write_metadata_all() called - WHEN the note has no inline metadata - THEN write_all_inline_metadata returns False - """ - note = Note(note_path=sample_note) - note.inline_metadata.dict = {} - assert note.write_all_inline_metadata(location=InsertLocation.BOTTOM) is False - - -def test_write_inline_metadata_change_1(sample_note): - """Test write_inline_metadata_change() method. - - GIVEN a note object with write_inline_metadata_change() called - WHEN the key and/or value is not in the note - THEN the key and/or value is not added to the note - """ - note = Note(note_path=sample_note) - - note.write_inline_metadata_change("nonexistent_key", "new_key") - assert note.file_content == note.original_file_content - note.write_inline_metadata_change("bottom_key1", "no_value", "new_value") - assert note.file_content == note.original_file_content - - -def test_write_inline_metadata_change_2(sample_note): - """Test write_inline_metadata_change() method. - - GIVEN a note object with write_inline_metadata_change() called - WHEN the key is in the note - THEN the key is changed to the new key - """ - note = Note(note_path=sample_note) - - note.write_inline_metadata_change("bottom_key1", "new_key") - assert note.file_content != Regex(r"bottom_key1::") - assert note.file_content == Regex(r"new_key:: bottom_key1_value") - - -def test_write_inline_metadata_change_3(sample_note): - """Test write_inline_metadata_change() method. - - GIVEN a note object with write_inline_metadata_change() called - WHEN the key and value is in the note - THEN the value is changed - """ - note = Note(note_path=sample_note) - note.write_inline_metadata_change("key๐Ÿ“…", "๐Ÿ“…_key_value", "new_value") - assert note.file_content != Regex(r"key๐Ÿ“…:: ?๐Ÿ“…_key_value") - assert note.file_content == Regex(r"key๐Ÿ“…:: ?new_value") - - -def test_write_string_1(short_notes) -> None: - """Test the write_string() method. - - GIVEN a note object with write_string() called - WHEN the specified location is BOTTOM - THEN the string is written to the bottom of the note - """ - path1, path2 = short_notes - note = Note(note_path=str(path1)) - note2 = Note(note_path=str(path2)) - - string1 = "This is a test string." - string2 = "This is" - - correct_content = """ ---- -key: value ---- - -# header 1 - -Lorem ipsum dolor sit amet. - -This is a test string. - """ - correct_content2 = """ ---- -key: value ---- - -# header 1 - -Lorem ipsum dolor sit amet. - -This is a test string. -This is - """ - correct_content3 = """ -Lorem ipsum dolor sit amet. - -This is a test string. - """ - note.write_string(new_string=string1, location=InsertLocation.BOTTOM) - assert note.file_content == correct_content.strip() - - note.write_string(new_string=string2, location=InsertLocation.BOTTOM) - assert note.file_content == correct_content.strip() - - note.write_string(new_string=string2, allow_multiple=True, location=InsertLocation.BOTTOM) - assert note.file_content == correct_content2.strip() - - note2.write_string(new_string=string1, location=InsertLocation.BOTTOM) - assert note2.file_content == correct_content3.strip() - - -def test_write_string_2(short_notes) -> None: - """Test the write_string() method. - - GIVEN a note object with write_string() called - WHEN the specified location is TOP - THEN the string is written to the top of the note - """ - path1, path2 = short_notes - note = Note(note_path=str(path1)) - note2 = Note(note_path=str(path2)) - - string1 = "This is a test string." - string2 = "This is" - correct_content = """ ---- -key: value ---- -This is a test string. - -# header 1 - -Lorem ipsum dolor sit amet. - """ - - correct_content2 = """ ---- -key: value ---- -This is -This is a test string. - -# header 1 - -Lorem ipsum dolor sit amet. - """ - correct_content3 = """ -This is a test string. -Lorem ipsum dolor sit amet. - """ - - note.write_string(new_string=string1, location=InsertLocation.TOP) - assert note.file_content.strip() == correct_content.strip() - - note.write_string(new_string=string2, allow_multiple=True, location=InsertLocation.TOP) - assert note.file_content.strip() == correct_content2.strip() - - note2.write_string(new_string=string1, location=InsertLocation.TOP) - assert note2.file_content.strip() == correct_content3.strip() - - -def test_write_string_3(short_notes) -> None: - """Test the write_string() method. - - GIVEN a note object with write_string() called - WHEN the specified location is AFTER_TITLE - THEN the string is written after the title of the note - """ - path1, path2 = short_notes - note = Note(note_path=str(path1)) - note2 = Note(note_path=str(path2)) - - string1 = "This is a test string." - string2 = "This is" - correct_content = """ ---- -key: value ---- - -# header 1 -This is a test string. - -Lorem ipsum dolor sit amet. - """ - - correct_content2 = """ ---- -key: value ---- - -# header 1 -This is -This is a test string. - -Lorem ipsum dolor sit amet. - """ - correct_content3 = """ -This is a test string. -Lorem ipsum dolor sit amet. - """ - - note.write_string(new_string=string1, location=InsertLocation.AFTER_TITLE) - assert note.file_content.strip() == correct_content.strip() - - note.write_string(new_string=string2, allow_multiple=True, location=InsertLocation.AFTER_TITLE) - assert note.file_content.strip() == correct_content2.strip() - - note2.write_string(new_string=string1, location=InsertLocation.AFTER_TITLE) - assert note2.file_content.strip() == correct_content3.strip() diff --git a/tests/parsers_test.py b/tests/parsers_test.py new file mode 100644 index 0000000..d8618b2 --- /dev/null +++ b/tests/parsers_test.py @@ -0,0 +1,364 @@ +# type: ignore +"""Test the parsers module.""" + +import re + +import pytest + +from obsidian_metadata.models.enums import Wrapping +from obsidian_metadata.models.parsers import Parser + +P = Parser() + + +def test_identify_internal_link_1(): + """Test the internal_link attribute. + + GIVEN a string with an external link + WHEN the internal_link attribute is called within a regex + THEN the external link is not found + """ + assert re.findall(P.internal_link, "[link](https://example.com/somepage.html)") == [] + + +def test_identify_internal_link_2(): + """Test the internal_link attribute. + + GIVEN a string with out any links + WHEN the internal_link attribute is called within a regex + THEN no links are found + """ + assert re.findall(P.internal_link, "foo bar baz") == [] + + +def test_identify_internal_link_3(): + """Test the internal_link attribute. + + GIVEN a string with an internal link + WHEN the internal_link attribute is called within a regex + THEN the internal link is found + """ + assert re.findall(P.internal_link, "[[internal_link]]") == ["[[internal_link]]"] + assert re.findall(P.internal_link, "[[internal_link|text]]") == ["[[internal_link|text]]"] + assert re.findall(P.internal_link, "[[test/Main.md]]") == ["[[test/Main.md]]"] + assert re.findall(P.internal_link, "[[%Man &Machine + Mind%]]") == ["[[%Man &Machine + Mind%]]"] + assert re.findall(P.internal_link, "[[Hello \\| There]]") == ["[[Hello \\| There]]"] + assert re.findall(P.internal_link, "[[\\||Yes]]") == ["[[\\||Yes]]"] + assert re.findall(P.internal_link, "[[test/Main|Yes]]") == ["[[test/Main|Yes]]"] + assert re.findall(P.internal_link, "[[2020#^14df]]") == ["[[2020#^14df]]"] + assert re.findall(P.internal_link, "!foo[[bar]]baz") == ["[[bar]]"] + assert re.findall(P.internal_link, "[[]]") == ["[[]]"] + + +def test_return_frontmatter_1(): + """Test the return_frontmatter method. + + GIVEN a string with frontmatter + WHEN the return_frontmatter method is called + THEN the frontmatter is returned + """ + content = """ +--- +key: value +--- +# Hello World +""" + assert P.return_frontmatter(content) == "---\nkey: value\n---" + + +def test_return_frontmatter_2(): + """Test the return_frontmatter method. + + GIVEN a string without frontmatter + WHEN the return_frontmatter method is called + THEN None is returned + """ + content = """ +# Hello World +--- +key: value +--- +""" + assert P.return_frontmatter(content) is None + + +def test_return_frontmatter_3(): + """Test the return_frontmatter method. + + GIVEN a string with frontmatter + WHEN the return_frontmatter method is called with data_only=True + THEN the frontmatter is returned + """ + content = """ +--- +key: value +key2: value2 +--- +# Hello World +""" + assert P.return_frontmatter(content, data_only=True) == "key: value\nkey2: value2" + + +def test_return_frontmatter_4(): + """Test the return_frontmatter method. + + GIVEN a string without frontmatter + WHEN the return_frontmatter method is called with data_only=True + THEN None is returned + """ + content = """ +# Hello World +--- +key: value +--- +""" + assert P.return_frontmatter(content, data_only=True) is None + + +def test_return_inline_metadata_1(): + """Test the return_inline_metadata method. + + GIVEN a string with no inline metadata + WHEN the return_inline_metadata method is called + THEN return None + """ + assert P.return_inline_metadata("foo bar baz") is None + assert P.return_inline_metadata("foo:bar baz") is None + assert P.return_inline_metadata("foo:::bar baz") is None + assert P.return_inline_metadata("[foo:::bar] baz") is None + + +@pytest.mark.parametrize( + ("string", "returned"), + [ + ("[k1:: v1]", [("k1", " v1", Wrapping.BRACKETS)]), + ("(k/1:: v/1)", [("k/1", " v/1", Wrapping.PARENS)]), + ( + "[k1::v1] and (k2:: v2)", + [("k1", "v1", Wrapping.BRACKETS), ("k2", " v2", Wrapping.PARENS)], + ), + ("(dรฉbut::dรฉbut)", [("dรฉbut", "dรฉbut", Wrapping.PARENS)]), + ("[๐Ÿ˜‰::๐Ÿš€]", [("๐Ÿ˜‰", "๐Ÿš€", Wrapping.BRACKETS)]), + ( + "(๐Ÿ›ธrocket๐Ÿš€ship:: a ๐ŸŽ… [console] game)", + [("๐Ÿ›ธrocket๐Ÿš€ship", " a ๐ŸŽ… [console] game", Wrapping.PARENS)], + ), + ], +) +def test_return_inline_metadata_2(string, returned): + """Test the return_inline_metadata method. + + GIVEN a string with inline metadata within a wrapping + WHEN the return_inline_metadata method is called + THEN return the wrapped inline metadata + """ + assert P.return_inline_metadata(string) == returned + + +@pytest.mark.parametrize( + ("string", "returned"), + [ + ("k1::v1", [("k1", "v1", Wrapping.NONE)]), + ("๐Ÿ˜‰::๐Ÿš€", [("๐Ÿ˜‰", "๐Ÿš€", Wrapping.NONE)]), + ("k1:: w/ !@#$| ", [("k1", " w/ !@#$| ", Wrapping.NONE)]), + ("ใ‚ฏใƒชใ‚นใƒžใ‚น:: ๅฎถๅบญ็”จใ‚ฒใƒผใƒ ๆฉŸ", [("ใ‚ฏใƒชใ‚นใƒžใ‚น", " ๅฎถๅบญ็”จใ‚ฒ\u30fcใƒ ๆฉŸ", Wrapping.NONE)]), + ("Noรซl:: Un jeu de console", [("Noรซl", " Un jeu de console", Wrapping.NONE)]), + ("๐ŸŽ…:: a console game", [("๐ŸŽ…", " a console game", Wrapping.NONE)]), + ("๐Ÿ›ธrocket๐Ÿš€ship:: a ๐ŸŽ… console game", [("๐Ÿ›ธrocket๐Ÿš€ship", " a ๐ŸŽ… console game", Wrapping.NONE)]), + (">flag::irish flag ๐Ÿ‡ฎ๐Ÿ‡ช", [("flag", "irish flag ๐Ÿ‡ฎ๐Ÿ‡ช", Wrapping.NONE)]), + ("foo::[bar] baz", [("foo", "[bar] baz", Wrapping.NONE)]), + ("foo::bar) baz", [("foo", "bar) baz", Wrapping.NONE)]), + ("[foo::bar baz", [("foo", "bar baz", Wrapping.NONE)]), + ("_foo_::bar baz", [("_foo_", "bar baz", Wrapping.NONE)]), + ("**foo**::bar_baz", [("**foo**", "bar_baz", Wrapping.NONE)]), + ("`foo`::`bar baz`", [("`foo`", "`bar baz`", Wrapping.NONE)]), + ], +) +def test_return_inline_metadata_3(string, returned): + """Test the return_inline_metadata method. + + GIVEN a string with inline metadata without a wrapping + WHEN the return_inline_metadata method is called + THEN return the wrapped inline metadata + """ + assert P.return_inline_metadata(string) == returned + + +@pytest.mark.parametrize( + ("string", "returned"), + [ + ("#foo", ["#foo"]), + ("#tag1 #tag2 #tag3", ["#tag1", "#tag2", "#tag3"]), + ("#foo.bar", ["#foo"]), + ("#foo-bar_baz#", ["#foo-bar_baz"]), + ("#daily/2021/20/08", ["#daily/2021/20/08"]), + ("#๐ŸŒฑ/๐ŸŒฟ", ["#๐ŸŒฑ/๐ŸŒฟ"]), + ("#dรฉbut", ["#dรฉbut"]), + ("#/some/๐Ÿš€/tag", ["#/some/๐Ÿš€/tag"]), + (r"\\#foo", ["#foo"]), + ("#f#oo", ["#f", "#oo"]), + ("#foo#bar#baz", ["#foo", "#bar", "#baz"]), + ], +) +def test_return_tags_1(string, returned): + """Test the return_tags method. + + GIVEN a string with tags + WHEN the return_tags method is called + THEN the valid tags are returned + """ + assert P.return_tags(string) == returned + + +@pytest.mark.parametrize( + ("string"), + [ + ("##foo# ##bar # baz ##"), + ("##foo"), + ("foo##bar"), + ("#1123"), + ("foo bar"), + ("aa#foo"), + ("$#foo"), + ], +) +def test_return_tags_2(string): + """Test the return_tags method. + + GIVEN a string without valid tags + WHEN the return_tags method is called + THEN None is returned + """ + assert P.return_tags(string) == [] + + +def test_return_top_with_header_1(): + """Test the return_top_with_header method. + + GIVEN a string with frontmatter above a first markdown header + WHEN return_top_with_header is called + THEN return the content up to the end of the first header + """ + content = """ +--- +key: value +--- +# Hello World + +foo bar baz +""" + assert P.return_top_with_header(content) == "---\nkey: value\n---\n# Hello World\n" + + +def test_return_top_with_header_2(): + """Test the return_top_with_header method. + + GIVEN a string with content above a first markdown header on the first line + WHEN return_top_with_header is called + THEN return the content up to the end of the first header + """ + content = "\n\n### Hello World\nfoo bar\nfoo bar" + assert P.return_top_with_header(content) == "### Hello World\n" + + +def test_return_top_with_header_3(): + """Test the return_top_with_header method. + + GIVEN a string with no markdown headers + WHEN return_top_with_header is called + THEN return None + """ + content = "Hello World\nfoo bar\nfoo bar" + assert not P.return_top_with_header(content) + + +def test_return_top_with_header_4(): + """Test the return_top_with_header method. + + GIVEN a string with no markdown headers + WHEN return_top_with_header is called + THEN return None + """ + content = "qux bar baz\nbaz\nfoo\n### bar\n# baz foo bar" + assert P.return_top_with_header(content) == "qux bar baz\nbaz\nfoo\n### bar\n" + + +def test_strip_frontmatter_1(): + """Test the strip_frontmatter method. + + GIVEN a string with frontmatter + WHEN the strip_frontmatter method is called + THEN the frontmatter is removed + """ + content = """ +--- +key: value +--- +# Hello World +""" + assert P.strip_frontmatter(content).strip() == "# Hello World" + + +def test_strip_frontmatter_2(): + """Test the strip_frontmatter method. + + GIVEN a string without frontmatter + WHEN the strip_frontmatter method is called + THEN nothing is removed + """ + content = """ +# Hello World +--- +key: value +--- +""" + assert P.strip_frontmatter(content) == content + + +def test_strip_frontmatter_3(): + """Test the strip_frontmatter method. + + GIVEN a string with frontmatter + WHEN the strip_frontmatter method is called with data_only=True + THEN the frontmatter is removed + """ + content = """ +--- +key: value +--- +# Hello World +""" + assert P.strip_frontmatter(content, data_only=True).strip() == "---\n---\n# Hello World" + + +def test_strip_frontmatter_4(): + """Test the strip_frontmatter method. + + GIVEN a string without frontmatter + WHEN the strip_frontmatter method is called with data_only=True + THEN nothing is removed + """ + content = """ +# Hello World +--- +key: value +--- +""" + assert P.strip_frontmatter(content, data_only=True) == content + + +def test_strip_inline_code_1(): + """Test the strip_inline_code method. + + GIVEN a string with inline code + WHEN the strip_inline_code method is called + THEN the inline code is removed + """ + assert P.strip_inline_code("Foo `bar` baz `Qux` ```bar\n```") == "Foo baz ```bar\n```" + assert P.strip_inline_code("Foo `bar` baz `Qux` ```bar\n```") == "Foo baz ```bar\n```" + + +def test_validators(): + """Test validators.""" + assert P.validate_tag_text.search("test_tag") is None + assert P.validate_tag_text.search("#asdf").group(0) == "#" diff --git a/tests/patterns_test.py b/tests/patterns_test.py deleted file mode 100644 index 0cf22eb..0000000 --- a/tests/patterns_test.py +++ /dev/null @@ -1,225 +0,0 @@ -# type: ignore -"""Tests for the regex module.""" - -import pytest - -from obsidian_metadata.models.patterns import Patterns - -TAG_CONTENT: str = "#1 #2 **#3** [[#4]] [[#5|test]] #6#notag #7_8 #9/10 #11-12 #13; #14, #15. #16: #17* #18(#19) #20[#21] #22\\ #23& #24# #25 **#26** #๐Ÿ“…/tag [link](#no_tag) https://example.com/somepage.html_#no_url_tags" - -FRONTMATTER_CONTENT: str = """ ---- -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: 'shared_key1_value' ---- -more content - ---- -horizontal: rule ---- -""" -CORRECT_FRONTMATTER_WITH_SEPARATORS: str = """--- -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: 'shared_key1_value' ----""" -CORRECT_FRONTMATTER_NO_SEPARATORS: str = """ -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: 'shared_key1_value' -""" - - -def test_top_with_header(): - """Test identifying the top of a note.""" - pattern = Patterns() - - no_fm_or_header = """ - - -Lorem ipsum dolor sit amet. - -# header 1 ---- -horizontal: rule ---- -Lorem ipsum dolor sit amet. -""" - fm_and_header: str = """ ---- -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: 'shared_key1_value' ---- - -# Header 1 -more content - ---- -horizontal: rule ---- -""" - fm_and_header_result = """--- -tags: - - tag_1 - - tag_2 - - - - ๐Ÿ“…/tag_3 -frontmatter_Key1: "frontmatter_Key1_value" -frontmatter_Key2: ["note", "article"] -shared_key1: 'shared_key1_value' ---- - -# Header 1""" - no_fm = """ - - ### Header's number 3 [๐Ÿ“…] "+$2.00" ๐Ÿคท - --- - horizontal: rule - --- - """ - no_fm_result = '### Header\'s number 3 [๐Ÿ“…] "+$2.00" ๐Ÿคท' - - assert not pattern.top_with_header.search(no_fm_or_header).group("top") - assert pattern.top_with_header.search(fm_and_header).group("top") == fm_and_header_result - assert pattern.top_with_header.search(no_fm).group("top") == no_fm_result - - -def test_find_inline_tags(): - """Test find_inline_tags regex.""" - pattern = Patterns() - assert pattern.find_inline_tags.findall(TAG_CONTENT) == [ - "1", - "2", - "3", - "4", - "5", - "6", - "7_8", - "9/10", - "11-12", - "13", - "14", - "15", - "16", - "17", - "18", - "19", - "20", - "21", - "22", - "23", - "24", - "25", - "26", - "๐Ÿ“…/tag", - ] - - -def test_find_inline_metadata(): - """Test find_inline_metadata regex.""" - pattern = Patterns() - content = """ -**1:: 1** -2_2:: [[2_2]] | 2 -asdfasdf [3:: 3] asdfasdf [7::7] asdf -[4:: 4] [5:: 5] -> 6:: 6 -**8**:: **8** -10:: -๐Ÿ“…11:: 11/๐Ÿ“…/11 -emoji_๐Ÿ“…_key::emoji_๐Ÿ“…_key_value -key1:: value1 -key1:: value2 -key1:: value3 - indented_key:: value1 -Paragraph of text with an [inline_key:: value1] and [inline_key:: value2] and [inline_key:: value3] which should do it. -> blockquote_key:: value1 -> blockquote_key:: value2 - -- list_key:: value1 -- list_key:: [[value2]] - -1. list_key:: value1 -2. list_key:: value2 - -| table_key:: value1 | table_key:: value2 | ---- -frontmatter_key1: frontmatter_key1_value ---- -not_a_key: not_a_value -paragraph metadata:: key in text - """ - - result = pattern.find_inline_metadata.findall(content) - assert result == [ - ("", "", "1", "1**"), - ("", "", "2_2", "[[2_2]] | 2"), - ("3", "3", "", ""), - ("7", "7", "", ""), - ("", "", "4", "4] [5:: 5]"), - ("", "", "6", "6"), - ("", "", "8**", "**8**"), - ("", "", "11", "11/๐Ÿ“…/11"), - ("", "", "emoji_๐Ÿ“…_key", "emoji_๐Ÿ“…_key_value"), - ("", "", "key1", "value1"), - ("", "", "key1", "value2"), - ("", "", "key1", "value3"), - ("", "", "indented_key", "value1"), - ("inline_key", "value1", "", ""), - ("inline_key", "value2", "", ""), - ("inline_key", "value3", "", ""), - ("", "", "blockquote_key", "value1"), - ("", "", "blockquote_key", "value2"), - ("", "", "list_key", "value1"), - ("", "", "list_key", "[[value2]]"), - ("", "", "list_key", "value1"), - ("", "", "list_key", "value2"), - ("", "", "table_key", "value1 | table_key:: value2 |"), - ("", "", "metadata", "key in text"), - ] - - -def test_find_frontmatter(): - """Test regexes.""" - pattern = Patterns() - found = pattern.frontmatter_block.search(FRONTMATTER_CONTENT).group("frontmatter") - assert found == CORRECT_FRONTMATTER_WITH_SEPARATORS - - found = pattern.frontmatt_block_strip_separators.search(FRONTMATTER_CONTENT).group( - "frontmatter" - ) - assert found == CORRECT_FRONTMATTER_NO_SEPARATORS - - with pytest.raises(AttributeError): - pattern.frontmatt_block_strip_separators.search(TAG_CONTENT).group("frontmatter") - - -def test_validators(): - """Test validators.""" - pattern = Patterns() - - assert pattern.validate_tag_text.search("test_tag") is None - assert pattern.validate_tag_text.search("#asdf").group(0) == "#" - assert pattern.validate_tag_text.search("#asdf").group(0) == "#" diff --git a/tests/questions_test.py b/tests/questions_test.py index 6c793ac..426c408 100644 --- a/tests/questions_test.py +++ b/tests/questions_test.py @@ -34,7 +34,7 @@ def test_validate_key_exists() -> None: questions = Questions(vault=VAULT) assert "'test' does not exist" in questions._validate_key_exists("test") assert "Key cannot be empty" in questions._validate_key_exists("") - assert questions._validate_key_exists("frontmatter_Key1") is True + assert questions._validate_key_exists("frontmatter1") is True def test_validate_new_key() -> None: @@ -82,7 +82,7 @@ def test_validate_key_exists_regex() -> None: assert "'test' does not exist" in questions._validate_key_exists_regex("test") assert "Key cannot be empty" in questions._validate_key_exists_regex("") assert "Invalid regex" in questions._validate_key_exists_regex("[") - assert questions._validate_key_exists_regex(r"\w+_Key\d") is True + assert questions._validate_key_exists_regex(r"f\w+\d") is True def test_validate_value() -> None: @@ -90,29 +90,26 @@ def test_validate_value() -> None: questions = Questions(vault=VAULT) assert questions._validate_value("test") is True - questions2 = Questions(vault=VAULT, key="frontmatter_Key1") - assert questions2._validate_value("test") == "frontmatter_Key1:test does not exist" - assert questions2._validate_value("author name") is True + questions2 = Questions(vault=VAULT, key="frontmatter1") + assert questions2._validate_value("test") == "frontmatter1:test does not exist" + assert questions2._validate_value("foo") is True def test_validate_value_exists_regex() -> None: """Test value exists regex validation.""" - questions2 = Questions(vault=VAULT, key="frontmatter_Key1") + questions2 = Questions(vault=VAULT, key="frontmatter1") assert "Invalid regex" in questions2._validate_value_exists_regex("[") assert "Regex cannot be empty" in questions2._validate_value_exists_regex("") assert ( questions2._validate_value_exists_regex(r"\d\d\d\w\d") - == r"No values in frontmatter_Key1 match regex: \d\d\d\w\d" + == r"No values in frontmatter1 match regex: \d\d\d\w\d" ) - assert questions2._validate_value_exists_regex(r"^author \w+") is True + assert questions2._validate_value_exists_regex(r"^f\w{2}$") is True def test_validate_new_value() -> None: """Test new value validation.""" - questions = Questions(vault=VAULT, key="frontmatter_Key1") + questions = Questions(vault=VAULT, key="frontmatter1") assert questions._validate_new_value("not_exists") is True assert "Value cannot be empty" in questions._validate_new_value("") - assert ( - questions._validate_new_value("author name") - == "frontmatter_Key1:author name already exists" - ) + assert questions._validate_new_value("foo") == "frontmatter1:foo already exists" diff --git a/tests/utilities_test.py b/tests/utilities_test.py index cde6f0b..cf9a792 100644 --- a/tests/utilities_test.py +++ b/tests/utilities_test.py @@ -6,13 +6,9 @@ import typer from obsidian_metadata._utils import ( clean_dictionary, - delete_from_dict, dict_contains, dict_keys_to_lower, - dict_values_to_lists_strings, - inline_metadata_from_string, merge_dictionaries, - remove_markdown_sections, rename_in_dict, validate_csv_bulk_imports, ) @@ -84,163 +80,6 @@ def test_clean_dictionary_6(): } -def test_delete_from_dict_1(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values - WHEN the delete_from_dict() function is called with a key that exists - THEN the key should be deleted from the dictionary and the original dictionary should not be modified - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key1") == { - "key2": ["value2", "value3"], - "key3": "value4", - } - assert test_dict == {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - -def test_delete_from_dict_2(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values - WHEN the delete_from_dict() function is called with a key that does not exist - THEN the dictionary should not be modified - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key5") == test_dict - - -def test_delete_from_dict_3(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values in a list - WHEN the delete_from_dict() function is called with a key and value that exists - THEN the value should be deleted from the specified key in dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key2", value="value3") == { - "key1": ["value1"], - "key2": ["value2"], - "key3": "value4", - } - - -def test_delete_from_dict_4(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key and value that exists - THEN the value and key should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key3", value="value4") == { - "key1": ["value1"], - "key2": ["value2", "value3"], - } - - -def test_delete_from_dict_5(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key and value that does not exist - THEN the dictionary should not be modified - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key3", value="value5") == test_dict - - -def test_delete_from_dict_6(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key regex that matches - THEN the matching keys should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key[23]", is_regex=True) == { - "key1": ["value1"] - } - - -def test_delete_from_dict_7(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key regex that does not match - THEN no keys should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key=r"key\d\d", is_regex=True) == test_dict - - -def test_delete_from_dict_8(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key and value regex that matches - THEN the matching keys should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key2", value=r"\w+", is_regex=True) == { - "key1": ["value1"], - "key2": [], - "key3": "value4", - } - - -def test_delete_from_dict_9(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key and value regex that does not match - THEN no keys should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert ( - delete_from_dict(dictionary=test_dict, key=r"key2", value=r"^\d", is_regex=True) - == test_dict - ) - - -def test_delete_from_dict_10(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key and value regex that matches - THEN the matching keys should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict(dictionary=test_dict, key="key3", value=r"\w+", is_regex=True) == { - "key1": ["value1"], - "key2": ["value2", "value3"], - } - - -def test_delete_from_dict_11(): - """Test delete_from_dict() function. - - GIVEN a dictionary with values as strings - WHEN the delete_from_dict() function is called with a key regex that matches multiple and values that match - THEN the values matching the associated keys should be deleted from the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"], "key3": "value4"} - - assert delete_from_dict( - dictionary=test_dict, key=r"key[23]", value=r"\w+[34]$", is_regex=True - ) == {"key1": ["value1"], "key2": ["value2"]} - - def test_dict_contains_1(): """Test dict_contains() function. @@ -342,140 +181,6 @@ def test_dict_keys_to_lower() -> None: assert dict_keys_to_lower(test_dict) == {"key1": "Value1", "key2": "Value2", "key3": "Value3"} -def test_dict_values_to_lists_strings_1(): - """Test the dict_values_to_lists_strings() function. - - GIVEN a dictionary passed to the dict_values_to_lists_strings() function - WHEN the dictionary is empty - THEN the function should return an empty dictionary - """ - assert dict_values_to_lists_strings({}) == {} - assert dict_values_to_lists_strings({}, strip_null_values=True) == {} - - -def test_dict_values_to_lists_strings_2(): - """Test the dict_values_to_lists_strings() function. - - GIVEN a dictionary passed to the dict_values_to_lists_strings() function - WHEN the dictionary values are already lists of strings - THEN the function should return the dictionary - """ - test_dict = {"key1": ["value1"], "key2": ["value2", "value3"]} - assert dict_values_to_lists_strings(test_dict) == { - "key1": ["value1"], - "key2": ["value2", "value3"], - } - assert dict_values_to_lists_strings(test_dict, strip_null_values=True) == { - "key1": ["value1"], - "key2": ["value2", "value3"], - } - - -def test_dict_values_to_lists_strings_3(): - """Test the dict_values_to_lists_strings() function. - - GIVEN a dictionary passed to the dict_values_to_lists_strings() function - WHEN the a value is None and strip_null_values is False - THEN then convert None to an empty string - """ - test_dict = {"key1": None, "key2": ["value", None]} - assert dict_values_to_lists_strings(test_dict) == {"key1": [""], "key2": ["", "value"]} - - -def test_dict_values_to_lists_strings_4(): - """Test the dict_values_to_lists_strings() function. - - GIVEN a dictionary passed to the dict_values_to_lists_strings() function - WHEN the a value is None and strip_null_values is True - THEN remove null values - """ - test_dict = {"key1": None, "key2": ["value", None]} - assert dict_values_to_lists_strings(test_dict, strip_null_values=True) == { - "key1": [], - "key2": ["value"], - } - - -def test_dict_values_to_lists_strings_5(): - """Test the dict_values_to_lists_strings() function. - - GIVEN a dictionary passed to the dict_values_to_lists_strings() function - WHEN the a value is a string "None" and strip_null_values is True or False - THEN ensure the value is not removed - """ - test_dict = {"key1": "None", "key2": [None, "None"]} - assert dict_values_to_lists_strings(test_dict) == {"key1": ["None"], "key2": ["", "None"]} - assert dict_values_to_lists_strings(test_dict, strip_null_values=True) == { - "key1": [], - "key2": ["None"], - } - - -def test_dict_values_to_lists_strings_6(): - """Test the dict_values_to_lists_strings() function. - - GIVEN a dictionary passed to the dict_values_to_lists_strings() function - WHEN the a value is another dictionary - THEN ensure the values in the inner dictionary are converted to lists of strings - """ - test_dict = {"key1": {"key2": "value2", "key3": ["value3", None]}} - assert dict_values_to_lists_strings(test_dict) == { - "key1": {"key2": ["value2"], "key3": ["", "value3"]} - } - assert dict_values_to_lists_strings(test_dict, strip_null_values=True) == { - "key1": {"key2": ["value2"], "key3": ["value3"]} - } - - -def test_inline_metadata_from_string_1(): - """Test inline_metadata_from_string() function. - - GIVEN a string - WHEN the string is empty - THEN the function should return an empty list. - """ - assert inline_metadata_from_string("") == [] - - -def test_inline_metadata_from_string_2(): - """Test inline_metadata_from_string() function. - - GIVEN a string - WHEN the string contains nothing matching the inline metadata regex - THEN the function should return an empty list. - """ - assert inline_metadata_from_string("this is content that has no inline metadata") == [] - - -def test_inline_metadata_from_string_3(): - """Test inline_metadata_from_string() function. - - GIVEN a string - WHEN the string contains inline metadata - THEN the function should return the key value pair as a tuple within a list. - """ - assert inline_metadata_from_string("test::test") == [("test", "test")] - - -def test_inline_metadata_from_string_4(): - """Test inline_metadata_from_string() function. - - GIVEN a string - WHEN the string contains multiple matches of inline metadata - THEN the function should return the key value pairs as a tuple within a list. - """ - content = """ - test::test - paragraph [key::value] paragraph - > test2::test2 - """ - assert inline_metadata_from_string(content) == [ - ("test", "test"), - ("key", "value"), - ("test2", "test2"), - ] - - def test_merge_dictionaries_1(): """Test merge_dictionaries() function. @@ -661,199 +366,6 @@ def test_rename_in_dict_5(): } -def test_remove_markdown_sections_1(): - """Test remove_markdown_sections() function. - - GIVEN a string with markdown sections - WHEN the remove_markdown_sections() function is called with the default arguments - THEN return the string without removing any markdown sections - """ - text: str = """ ---- -key: value ---- - -# heading - -```bash -echo "Hello world" -``` - -Lorem ipsum `inline_code` lorem ipsum. -``` -echo "foo bar" -``` - ---- -dd ---- - """ - - assert remove_markdown_sections(text) == text - - -def test_remove_markdown_sections_2(): - """Test remove_markdown_sections() function. - - GIVEN a string with markdown sections - WHEN the remove_markdown_sections() function is called with strip_codeblocks set to True - THEN return the string without the codeblocks - """ - text: str = """ ---- -key: value ---- - -# heading - -```bash -echo "Hello world" -``` - -Lorem ipsum `inline_code` lorem ipsum. -``` -echo "foo bar" -``` - ---- -dd ---- - """ - result = remove_markdown_sections(text, strip_codeblocks=True) - assert "inline_code" in result - assert "```bash" not in result - assert "```" not in result - assert "foo" not in result - assert "world" not in result - assert "key: value" in result - assert "heading" in result - assert "Lorem ipsum" in result - assert "---\n" in result - assert "dd" in result - - -def test_remove_markdown_sections_3(): - """Test remove_markdown_sections() function. - - GIVEN a string with markdown sections - WHEN the remove_markdown_sections() function is called with strip_inlinecode set to True - THEN return the string without the inline code - """ - text: str = """ ---- -key: value ---- - -# heading - -```bash -echo "Hello world" -``` - -Lorem ipsum `inline_code` lorem ipsum. -``` -echo "foo bar" -``` - ---- -dd ---- - """ - result = remove_markdown_sections(text, strip_inlinecode=True) - assert "`inline_code`" not in result - assert "```bash" in result - assert "```" in result - assert "foo" in result - assert "world" in result - assert "key: value" in result - assert "heading" in result - assert "Lorem ipsum" in result - assert "---\n" in result - assert "dd" in result - - -def test_remove_markdown_sections_4(): - """Test remove_markdown_sections() function. - - GIVEN a string with markdown sections - WHEN the remove_markdown_sections() function is called with strip_frontmatter set to True - THEN return the string without the frontmatter - """ - text: str = """ ---- -key: value ---- - -# heading - -```bash -echo "Hello world" -``` - -Lorem ipsum `inline_code` lorem ipsum. -``` -echo "foo bar" -``` - ---- -dd ---- - """ - result = remove_markdown_sections(text, strip_frontmatter=True) - assert "`inline_code`" in result - assert "```bash" in result - assert "```" in result - assert "foo" in result - assert "world" in result - assert "key: value" not in result - assert "heading" in result - assert "Lorem ipsum" in result - assert "---\n" in result - assert "dd" in result - - -def test_remove_markdown_sections_5(): - """Test remove_markdown_sections() function. - - GIVEN a string with markdown sections - WHEN the remove_markdown_sections() function is called with all arguments set to True - THEN return the string without the frontmatter, inline code, and codeblocks - """ - text: str = """ ---- -key: value ---- - -# heading - -```bash -echo "Hello world" -``` - -Lorem ipsum `inline_code` lorem ipsum. -``` -echo "foo bar" -``` - ---- -dd ---- - """ - result = remove_markdown_sections( - text, strip_frontmatter=True, strip_inlinecode=True, strip_codeblocks=True - ) - assert "`inline_code`" not in result - assert "bash" not in result - assert "```" not in result - assert "foo" not in result - assert "world" not in result - assert "key: value" not in result - assert "heading" in result - assert "Lorem ipsum" in result - assert "---\n" in result - assert "dd" in result - - def test_validate_csv_bulk_imports_1(tmp_path): """Test the validate_csv_bulk_imports function. diff --git a/tests/vault_test.py b/tests/vault_test.py index c12ee62..59b45a0 100644 --- a/tests/vault_test.py +++ b/tests/vault_test.py @@ -1,16 +1,17 @@ # type: ignore """Tests for the Vault module.""" +import re from pathlib import Path import pytest import typer -from rich import print from obsidian_metadata._config import Config +from obsidian_metadata._utils.console import console from obsidian_metadata.models import Vault, VaultFilter from obsidian_metadata.models.enums import InsertLocation, MetadataType -from tests.helpers import Regex +from tests.helpers import Regex, strip_ansi def test_vault_creation(test_vault, tmp_path): @@ -28,65 +29,33 @@ def test_vault_creation(test_vault, tmp_path): assert vault.dry_run is False assert str(vault.exclude_paths[0]) == Regex(r".*\.git") assert len(vault.all_notes) == 2 - - assert vault.metadata.dict == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], + assert vault.frontmatter == { "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "shared_key1": [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ], - "shared_key2": ["shared_key2_value1", "shared_key2_value2"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], + "frontmatter1": ["foo"], + "frontmatter2": ["bar", "baz", "qux"], + "tags": ["bar", "foo"], + "๐ŸŒฑ": ["๐ŸŒฟ"], } - - assert vault.metadata.tags == [ - "inline_tag_bottom1", - "inline_tag_bottom2", - "inline_tag_top1", - "inline_tag_top2", - "intext_tag1", - "intext_tag2", - "shared_tag", + assert vault.inline_meta == { + "inline1": ["bar baz", "foo"], + "inline2": ["[[foo]]"], + "inline3": ["value"], + "inline4": ["foo"], + "inline5": [], + "intext1": ["foo"], + "intext2": ["foo"], + "key with space": ["foo"], + "๐ŸŒฑ": ["๐ŸŒฟ"], + } + assert vault.tags == ["tag1", "tag2"] + assert vault.exclude_paths == [ + tmp_path / "vault" / ".git", + tmp_path / "vault" / ".obsidian", + tmp_path / "vault" / "ignore_folder", ] - assert vault.metadata.inline_metadata == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "shared_key1": ["shared_key1_value", "shared_key1_value2"], - "shared_key2": ["shared_key2_value2"], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - assert vault.metadata.frontmatter == { - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "shared_key1": ["shared_key1_value", "shared_key1_value3"], - "shared_key2": ["shared_key2_value1"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - } + assert vault.filters == [] + assert len(vault.all_note_paths) == 2 + assert len(vault.notes_in_scope) == 2 def set_insert_location(test_vault): @@ -104,139 +73,36 @@ def set_insert_location(test_vault): assert vault.insert_location == InsertLocation.BOTTOM -def test_add_metadata_1(test_vault) -> None: - """Test adding metadata to the vault. +@pytest.mark.parametrize( + ("meta_type", "key", "value", "expected"), + [ + (MetadataType.FRONTMATTER, "new_key", "new_value", 2), + (MetadataType.FRONTMATTER, "frontmatter1", "new_value", 2), + (MetadataType.INLINE, "new_key", "new_value", 2), + (MetadataType.INLINE, "inline5", "new_value", 2), + (MetadataType.INLINE, "inline1", "foo", 1), + (MetadataType.TAGS, None, "new_value", 2), + (MetadataType.TAGS, None, "tag1", 1), + ], +) +def test_add_metadata(test_vault, meta_type, key, value, expected): + """Test add_metadata method. GIVEN a vault object - WHEN a new metadata key is added - THEN the metadata is added to the vault + WHEN metadata is added + THEN add the metadata and return the number of notes updated """ vault = Vault(config=test_vault) + assert vault.add_metadata(meta_type, key, value) == expected - assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key") == 2 - assert vault.metadata.dict == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "new_key": [], - "shared_key1": [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ], - "shared_key2": ["shared_key2_value1", "shared_key2_value2"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - assert vault.metadata.frontmatter == { - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "new_key": [], - "shared_key1": ["shared_key1_value", "shared_key1_value3"], - "shared_key2": ["shared_key2_value1"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - } + if meta_type == MetadataType.FRONTMATTER: + assert value in vault.frontmatter[key] + if meta_type == MetadataType.INLINE: + assert value in vault.inline_meta[key] -def test_add_metadata_2(test_vault) -> None: - """Test adding metadata to the vault. - - GIVEN a vault object - WHEN a new metadata key and value is added - THEN the metadata is added to the vault - """ - vault = Vault(config=test_vault) - assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") == 2 - assert vault.metadata.dict == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "new_key2": ["new_key2_value"], - "shared_key1": [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ], - "shared_key2": ["shared_key2_value1", "shared_key2_value2"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - assert vault.metadata.frontmatter == { - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "new_key2": ["new_key2_value"], - "shared_key1": ["shared_key1_value", "shared_key1_value3"], - "shared_key2": ["shared_key2_value1"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - } - - -def test_commit_changes_1(test_vault, tmp_path): - """Test committing changes to content in the vault. - - GIVEN a vault object - WHEN the commit_changes method is called - THEN the changes are committed to the vault - """ - vault = Vault(config=test_vault) - - content = Path(f"{tmp_path}/vault/test1.md").read_text() - assert "new_key: new_key_value" not in content - vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value") - vault.commit_changes() - committed_content = Path(f"{tmp_path}/vault/test1.md").read_text() - assert "new_key: new_key_value" in committed_content - - -def test_commit_changes_2(test_vault, tmp_path): - """Test committing changes to content in the vault in dry run mode. - - GIVEN a vault object - WHEN dry_run is set to True - THEN no changes are committed to the vault - """ - vault = Vault(config=test_vault, dry_run=True) - content = Path(f"{tmp_path}/vault/test1.md").read_text() - assert "new_key: new_key_value" not in content - - vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value") - vault.commit_changes() - committed_content = Path(f"{tmp_path}/vault/test1.md").read_text() - assert "new_key: new_key_value" not in committed_content + if meta_type == MetadataType.TAGS: + assert value in vault.tags def test_backup_1(test_vault, capsys): @@ -276,6 +142,92 @@ def test_backup_2(test_vault, capsys): assert captured.out == Regex(r"DRYRUN +| Backup up vault to") +@pytest.mark.parametrize( + ("meta_type", "key", "value", "is_regex", "expected"), + [ + (MetadataType.FRONTMATTER, "frontmatter1", None, False, True), + (MetadataType.FRONTMATTER, "frontmatter1", "foo", False, True), + (MetadataType.FRONTMATTER, "no_key", None, False, False), + (MetadataType.FRONTMATTER, "frontmatter1", "no_value", False, False), + (MetadataType.FRONTMATTER, r"f\w+\d", None, True, True), + (MetadataType.FRONTMATTER, r"f\w+\d", r"\w+", True, True), + (MetadataType.FRONTMATTER, r"^\d+", None, True, False), + (MetadataType.FRONTMATTER, r"frontmatter1", r"^\d+", True, False), + (MetadataType.INLINE, "intext1", None, False, True), + (MetadataType.INLINE, "intext1", "foo", False, True), + (MetadataType.INLINE, "no_key", None, False, False), + (MetadataType.INLINE, "intext1", "no_value", False, False), + (MetadataType.INLINE, r"i\w+\d", None, True, True), + (MetadataType.INLINE, r"i\w+\d", r"\w+", True, True), + (MetadataType.INLINE, r"^\d+", None, True, False), + (MetadataType.INLINE, r"intext1", r"^\d+", True, False), + (MetadataType.TAGS, None, "tag1", False, True), + (MetadataType.TAGS, None, "no tag", False, False), + (MetadataType.TAGS, None, r"^\w+\d", True, True), + (MetadataType.TAGS, None, r"^\d", True, False), + ##############3 + (MetadataType.META, "frontmatter1", None, False, True), + (MetadataType.META, "frontmatter1", "foo", False, True), + (MetadataType.META, "no_key", None, False, False), + (MetadataType.META, "frontmatter1", "no_value", False, False), + (MetadataType.META, r"f\w+\d", None, True, True), + (MetadataType.META, r"f\w+\d", r"\w+", True, True), + (MetadataType.META, r"^\d+", None, True, False), + (MetadataType.META, r"frontmatter1", r"^\d+", True, False), + (MetadataType.META, r"i\w+\d", None, True, True), + (MetadataType.ALL, None, "tag1", False, True), + (MetadataType.ALL, None, "no tag", False, False), + (MetadataType.ALL, None, r"^\w+\d", True, True), + (MetadataType.ALL, None, r"^\d", True, False), + (MetadataType.ALL, "frontmatter1", "foo", False, True), + (MetadataType.ALL, r"i\w+\d", None, True, True), + ], +) +def test_contains_metadata(test_vault, meta_type, key, value, is_regex, expected): + """Test the contains_metadata method. + + GIVEN a vault object + WHEN the contains_metadata method is called + THEN the method returns True if the metadata is found + """ + vault = Vault(config=test_vault) + assert vault.contains_metadata(meta_type, key, value, is_regex) == expected + + +def test_commit_changes_1(test_vault, tmp_path): + """Test committing changes to content in the vault. + + GIVEN a vault object + WHEN the commit_changes method is called + THEN the changes are committed to the vault + """ + vault = Vault(config=test_vault) + + content = Path(f"{tmp_path}/vault/sample_note.md").read_text() + assert "new_key: new_key_value" not in content + vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value") + vault.commit_changes() + committed_content = Path(f"{tmp_path}/vault/sample_note.md").read_text() + assert "new_key: new_key_value" in committed_content + + +def test_commit_changes_2(test_vault, tmp_path): + """Test committing changes to content in the vault in dry run mode. + + GIVEN a vault object + WHEN dry_run is set to True + THEN no changes are committed to the vault + """ + vault = Vault(config=test_vault, dry_run=True) + content = Path(f"{tmp_path}/vault/sample_note.md").read_text() + assert "new_key: new_key_value" not in content + + vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value") + vault.commit_changes() + committed_content = Path(f"{tmp_path}/vault/sample_note.md").read_text() + assert "new_key: new_key_value" not in committed_content + + def test_delete_backup_1(test_vault, capsys): """Test deleting the vault backup. @@ -315,75 +267,64 @@ def test_delete_backup_2(test_vault, capsys): assert vault.backup_path.exists() is True -def test_delete_tag_1(test_vault) -> None: - """Test delete_tag() method. +@pytest.mark.parametrize( + ("tag_to_delete", "expected"), + [ + ("tag1", 1), + ("tag2", 1), + ("tag3", 0), + ], +) +def test_delete_tag(test_vault, tag_to_delete, expected): + """Test delete_tag method. GIVEN a vault object WHEN the delete_tag method is called - THEN the inline tag is deleted + THEN delete tags if found and return the number of notes updated """ vault = Vault(config=test_vault) - assert vault.delete_tag("intext_tag2") == 1 - assert vault.metadata.tags == [ - "inline_tag_bottom1", - "inline_tag_bottom2", - "inline_tag_top1", - "inline_tag_top2", - "intext_tag1", - "shared_tag", - ] + assert vault.delete_tag(tag_to_delete) == expected + assert tag_to_delete not in vault.tags -def test_delete_tag_2(test_vault) -> None: - """Test delete_tag() method. +@pytest.mark.parametrize( + ("meta_type", "key_to_delete", "value_to_delete", "expected"), + [ + (MetadataType.FRONTMATTER, "frontmatter1", "foo", 1), + (MetadataType.FRONTMATTER, "frontmatter1", None, 1), + (MetadataType.FRONTMATTER, "frontmatter1", "bar", 0), + (MetadataType.FRONTMATTER, "frontmatter2", "bar", 1), + (MetadataType.META, "frontmatter1", "foo", 1), + (MetadataType.INLINE, "frontmatter1", "foo", 0), + (MetadataType.INLINE, "inline1", "foo", 1), + (MetadataType.INLINE, "inline1", None, 1), + ], +) +def test_delete_metadata(test_vault, meta_type, key_to_delete, value_to_delete, expected): + """Test delete_metadata method. GIVEN a vault object - WHEN the delete_tag method is called with a tag that does not exist - THEN no changes are made + WHEN the delete_metadata method is called + THEN delete metadata if found and return the number of notes updated """ vault = Vault(config=test_vault) + assert ( + vault.delete_metadata(meta_type=meta_type, key=key_to_delete, value=value_to_delete) + == expected + ) - assert vault.delete_tag("no tag") == 0 + if meta_type == MetadataType.FRONTMATTER or meta_type == MetadataType.META: + if value_to_delete is None: + assert key_to_delete not in vault.frontmatter + elif key_to_delete in vault.frontmatter: + assert value_to_delete not in vault.frontmatter[key_to_delete] - -def test_delete_metadata_1(test_vault) -> None: - """Test deleting a metadata key/value. - - GIVEN a vault object - WHEN the delete_metadata method is called with a key and value - THEN the specified metadata key/value is deleted - """ - vault = Vault(config=test_vault) - - assert vault.delete_metadata("top_key1", "top_key1_value") == 1 - assert vault.metadata.dict["top_key1"] == [] - - -def test_delete_metadata_2(test_vault) -> None: - """Test deleting a metadata key/value. - - GIVEN a vault object - WHEN the delete_metadata method is called with a key - THEN the specified metadata key is deleted - """ - vault = Vault(config=test_vault) - - assert vault.delete_metadata("top_key2") == 1 - assert "top_key2" not in vault.metadata.dict - - -def test_delete_metadata_3(test_vault) -> None: - """Test deleting a metadata key/value. - - GIVEN a vault object - WHEN the delete_metadata method is called with a key and/or value that does not exist - THEN no changes are made - """ - vault = Vault(config=test_vault) - - assert vault.delete_metadata("no key") == 0 - assert vault.delete_metadata("top_key1", "no_value") == 0 + if meta_type == MetadataType.INLINE or meta_type == MetadataType.META: + if value_to_delete is None: + assert key_to_delete not in vault.inline_meta + elif key_to_delete in vault.inline_meta: + assert value_to_delete not in vault.inline_meta[key_to_delete] def test_export_csv_1(tmp_path, test_vault): @@ -394,11 +335,16 @@ def test_export_csv_1(tmp_path, test_vault): THEN the vault metadata is exported to a CSV file """ vault = Vault(config=test_vault) - export_file = Path(f"{tmp_path}/export.csv") + export_file = tmp_path / "export.csv" vault.export_metadata(path=export_file, export_format="csv") assert export_file.exists() is True - assert "frontmatter,date_created,2022-12-22" in export_file.read_text() + result = export_file.read_text() + assert "Metadata Type,Key,Value" in result + assert "frontmatter,date_created,2022-12-22" in result + assert "inline_metadata,๐ŸŒฑ,๐ŸŒฟ" in result + assert "inline_metadata,inline5,\n" in result + assert "tags,,tag1" in result def test_export_csv_2(tmp_path, test_vault): @@ -409,7 +355,7 @@ def test_export_csv_2(tmp_path, test_vault): THEN an error is raised """ vault = Vault(config=test_vault) - export_file = Path(f"{tmp_path}/does_not_exist/export.csv") + export_file = tmp_path / "does_not_exist" / "export.csv" with pytest.raises(typer.Exit): vault.export_metadata(path=export_file, export_format="csv") @@ -424,11 +370,14 @@ def test_export_json(tmp_path, test_vault): THEN the vault metadata is exported to a JSON file """ vault = Vault(config=test_vault) - export_file = Path(f"{tmp_path}/export.json") + export_file = tmp_path / "export.json" vault.export_metadata(path=export_file, export_format="json") assert export_file.exists() is True - assert '"frontmatter": {' in export_file.read_text() + result = export_file.read_text() + assert '"frontmatter": {' in result + assert '"inline_metadata": {' in result + assert '"tags": [' in result def test_export_notes_to_csv_1(tmp_path, test_vault): @@ -439,15 +388,17 @@ def test_export_notes_to_csv_1(tmp_path, test_vault): THEN the notes are exported to a CSV file """ vault = Vault(config=test_vault) - export_file = Path(f"{tmp_path}/export.csv") + export_file = tmp_path / "export.csv" vault.export_notes_to_csv(path=export_file) assert export_file.exists() is True - assert "path,type,key,value" in export_file.read_text() - assert "test1.md,frontmatter,shared_key1,shared_key1_value" in export_file.read_text() - assert "test1.md,inline_metadata,shared_key1,shared_key1_value" in export_file.read_text() - assert "test1.md,tag,,shared_tag" in export_file.read_text() - assert "test1.md,frontmatter,tags,๐Ÿ“…/frontmatter_tag3" in export_file.read_text() - assert "test1.md,inline_metadata,key๐Ÿ“…,๐Ÿ“…_key_value" in export_file.read_text() + result = export_file.read_text() + assert "path,type,key,value" in result + assert "sample_note.md,FRONTMATTER,date_created,2022-12-22" in result + assert "sample_note.md,FRONTMATTER,๐ŸŒฑ,๐ŸŒฟ" in result + assert "sample_note.md,INLINE,inline2,[[foo]]" in result + assert "sample_note.md,INLINE,inline1,bar baz" in result + assert "sample_note.md,TAGS,,tag1" in result + assert "sample_note.md,INLINE,inline5,\n" in result def test_export_notes_to_csv_2(test_vault): @@ -531,7 +482,7 @@ def test_get_filtered_notes_4(sample_vault) -> None: filters = [VaultFilter(tag_filter="brunch")] vault = Vault(config=vault_config, filters=filters) assert len(vault.all_notes) == 13 - assert len(vault.notes_in_scope) == 1 + assert len(vault.notes_in_scope) == 0 def test_get_filtered_notes_5(sample_vault) -> None: @@ -550,6 +501,21 @@ def test_get_filtered_notes_5(sample_vault) -> None: assert len(vault.notes_in_scope) == 0 +def test_get_changed_notes(test_vault, tmp_path): + """Test get_changed_notes() method. + + GIVEN a vault object + WHEN the get_changed_notes method is called + THEN the changed notes are returned + """ + vault = Vault(config=test_vault) + assert vault.get_changed_notes() == [] + vault.delete_metadata(key="frontmatter1", meta_type=MetadataType.FRONTMATTER) + changed_notes = vault.get_changed_notes() + assert len(changed_notes) == 1 + assert changed_notes[0].note_path == tmp_path / "vault" / "sample_note.md" + + def test_info(test_vault, capsys): """Test info() method. @@ -561,10 +527,10 @@ def test_info(test_vault, capsys): vault.info() - captured = capsys.readouterr() - assert captured.out == Regex(r"Vault +\โ”‚ /[\d\w]+") - assert captured.out == Regex(r"Notes in scope +\โ”‚ \d+") - assert captured.out == Regex(r"Backup +\โ”‚ None") + captured = strip_ansi(capsys.readouterr().out) + assert captured == Regex(r"Vault +\โ”‚ /[\d\w]+") + assert captured == Regex(r"Notes in scope +\โ”‚ \d+") + assert captured == Regex(r"Backup +\โ”‚ None") def test_list_editable_notes(test_vault, capsys) -> None: @@ -579,7 +545,7 @@ def test_list_editable_notes(test_vault, capsys) -> None: vault.list_editable_notes() captured = capsys.readouterr() assert captured.out == Regex("Notes in current scope") - assert captured.out == Regex(r"\d +test1\.md") + assert captured.out == Regex(r"\d +sample_note\.md") def test_move_inline_metadata_1(test_vault) -> None: @@ -594,6 +560,40 @@ def test_move_inline_metadata_1(test_vault) -> None: assert vault.move_inline_metadata(location=InsertLocation.TOP) == 1 +@pytest.mark.parametrize( + ("meta_type", "expected_regex"), + [ + ( + MetadataType.ALL, + r"All metadata.*Keys +โ”ƒ Values +โ”ƒ.*frontmatter1 +โ”‚ foo.*inline1 +โ”‚ bar baz.*tags +โ”‚ bar.*All inline tags.*#tag1.*#tag2", + ), + ( + MetadataType.FRONTMATTER, + r"All frontmatter.*Keys +โ”ƒ Values +โ”ƒ.*frontmatter1 +โ”‚ foo.*tags +โ”‚ bar", + ), + ( + MetadataType.INLINE, + r"All inline metadata.*Keys +โ”ƒ Values +โ”ƒ.*inline2 +โ”‚ \[\[foo\]\]", + ), + ( + MetadataType.TAGS, + r"All inline tags.*#tag1.*#tag2", + ), + ], +) +def test_print_metadata(test_vault, capsys, meta_type, expected_regex) -> None: + """Test print_metadata() method. + + GIVEN a vault object + WHEN the print_metadata() method is called + THEN the metadata is printed + """ + vault = Vault(config=test_vault) + vault.print_metadata(meta_type=meta_type) + captured = strip_ansi(capsys.readouterr().out) + assert captured == Regex(expected_regex, re.DOTALL) + + def test_rename_tag_1(test_vault) -> None: """Test rename_tag() method. @@ -603,16 +603,9 @@ def test_rename_tag_1(test_vault) -> None: """ vault = Vault(config=test_vault) - assert vault.rename_tag("intext_tag2", "new_tag") == 1 - assert vault.metadata.tags == [ - "inline_tag_bottom1", - "inline_tag_bottom2", - "inline_tag_top1", - "inline_tag_top2", - "intext_tag1", - "new_tag", - "shared_tag", - ] + assert vault.rename_tag("tag1", "new_tag") == 1 + assert "tag1" not in vault.tags + assert "new_tag" in vault.tags def test_rename_tag_2(test_vault) -> None: @@ -625,9 +618,21 @@ def test_rename_tag_2(test_vault) -> None: vault = Vault(config=test_vault) assert vault.rename_tag("no tag", "new_tag") == 0 + assert "new_tag" not in vault.tags -def test_rename_metadata_1(test_vault) -> None: +@pytest.mark.parametrize( + ("key", "value1", "value2", "expected"), + [ + ("no key", "new_value", None, 0), + ("frontmatter1", "no_value", "new_value", 0), + ("frontmatter1", "foo", "new_value", 1), + ("inline1", "foo", "new_value", 1), + ("frontmatter1", "new_key", None, 1), + ("inline1", "new_key", None, 1), + ], +) +def test_rename_metadata(test_vault, key, value1, value2, expected) -> None: """Test rename_metadata() method. GIVEN a vault object @@ -636,90 +641,63 @@ def test_rename_metadata_1(test_vault) -> None: """ vault = Vault(config=test_vault) - assert vault.rename_metadata("no key", "new_key") == 0 - assert vault.rename_metadata("tags", "nonexistent_value", "new_vaule") == 0 + assert vault.rename_metadata(key, value1, value2) == expected + + if expected > 0 and value2 is None: + assert key not in vault.frontmatter + assert key not in vault.inline_meta + + if expected > 0 and value2: + if key in vault.frontmatter: + assert value1 not in vault.frontmatter[key] + assert value2 in vault.frontmatter[key] + if key in vault.inline_meta: + assert value1 not in vault.inline_meta[key] + assert value2 in vault.inline_meta[key] -def test_rename_metadata_2(test_vault) -> None: - """Test rename_metadata() method. - - GIVEN a vault object - WHEN the rename_metadata() method with a key and no value - THEN the metadata key is renamed - """ - vault = Vault(config=test_vault) - - assert vault.rename_metadata("tags", "new_key") == 1 - assert "tags" not in vault.metadata.dict - assert vault.metadata.dict["new_key"] == [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ] - - -def test_rename_metadata_3(test_vault) -> None: - """Test rename_metadata() method. - - GIVEN a vault object - WHEN the rename_metadata() method is called with a key and value - THEN the metadata key/value is renamed - """ - vault = Vault(config=test_vault) - - assert vault.rename_metadata("tags", "frontmatter_tag1", "new_vaule") == 1 - assert vault.metadata.dict["tags"] == [ - "frontmatter_tag2", - "new_vaule", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ] - - -def test_transpose_metadata(test_vault) -> None: +@pytest.mark.parametrize( + ("begin", "end", "key", "value", "expected"), + [ + # no matches + (MetadataType.INLINE, MetadataType.FRONTMATTER, "no key", None, 0), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "no key", "new_value", 0), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "inline1", "new_value", 0), + (MetadataType.FRONTMATTER, MetadataType.INLINE, "no key", None, 0), + (MetadataType.FRONTMATTER, MetadataType.INLINE, "no key", "new_value", 0), + (MetadataType.FRONTMATTER, MetadataType.INLINE, "frontmatter1", "new_value", 0), + # entire keys + (MetadataType.FRONTMATTER, MetadataType.INLINE, "frontmatter1", None, 1), + (MetadataType.FRONTMATTER, MetadataType.INLINE, "frontmatter2", None, 1), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "inline1", None, 1), + # specific values + (MetadataType.FRONTMATTER, MetadataType.INLINE, "frontmatter1", "foo", 1), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "inline1", "bar baz", 1), + (MetadataType.INLINE, MetadataType.FRONTMATTER, "inline2", "[[foo]]", 1), + ], +) +def test_transpose_metadata_1(test_vault, begin, end, key, value, expected) -> None: """Test transpose_metadata() method. GIVEN a vault object WHEN the transpose_metadata() method is called - THEN the metadata is transposed + THEN the number of notes with transposed metadata is returned and the vault metadata is updated """ vault = Vault(config=test_vault) - assert vault.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) == 1 + assert vault.transpose_metadata(begin=begin, end=end, key=key, value=value) == expected - assert vault.metadata.inline_metadata == {} - assert vault.metadata.frontmatter == { - "bottom_key1": ["bottom_key1_value"], - "bottom_key2": ["bottom_key2_value"], - "date_created": ["2022-12-22"], - "frontmatter_Key1": ["author name"], - "frontmatter_Key2": ["article", "note"], - "intext_key": ["intext_value"], - "key๐Ÿ“…": ["๐Ÿ“…_key_value"], - "shared_key1": [ - "shared_key1_value", - "shared_key1_value2", - "shared_key1_value3", - ], - "shared_key2": ["shared_key2_value1", "shared_key2_value2"], - "tags": [ - "frontmatter_tag1", - "frontmatter_tag2", - "shared_tag", - "๐Ÿ“…/frontmatter_tag3", - ], - "top_key1": ["top_key1_value"], - "top_key2": ["top_key2_value"], - "top_key3": ["top_key3_value_as_link"], - } - - assert ( - vault.transpose_metadata( - begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER, location=InsertLocation.TOP - ) - == 0 - ) + if expected > 0: + if begin == MetadataType.INLINE and value is None: + assert key not in vault.inline_meta + assert key in vault.frontmatter + elif begin == MetadataType.FRONTMATTER and value is None: + assert key not in vault.frontmatter + assert key in vault.inline_meta + elif begin == MetadataType.INLINE and value: + assert value in vault.frontmatter[key] + elif begin == MetadataType.FRONTMATTER and value: + assert value in vault.inline_meta[key] def test_update_from_dict_1(test_vault): @@ -729,11 +707,11 @@ def test_update_from_dict_1(test_vault): WHEN no dictionary keys match paths in the vault THEN no notes are updated and 0 is returned """ - vault = Vault(config=test_vault) update_dict = { "path1": {"type": "frontmatter", "key": "new_key", "value": "new_value"}, "path2": {"type": "frontmatter", "key": "new_key", "value": "new_value"}, } + vault = Vault(config=test_vault) assert vault.update_from_dict(update_dict) == 0 assert vault.get_changed_notes() == [] @@ -763,17 +741,18 @@ def test_update_from_dict_3(test_vault): vault = Vault(config=test_vault) update_dict = { - "test1.md": [ + "sample_note.md": [ {"type": "frontmatter", "key": "new_key", "value": "new_value"}, {"type": "inline_metadata", "key": "new_key2", "value": "new_value"}, {"type": "tag", "key": "", "value": "new_tag"}, ] } assert vault.update_from_dict(update_dict) == 1 - assert vault.get_changed_notes()[0].note_path.name == "test1.md" - assert vault.get_changed_notes()[0].frontmatter.dict == {"new_key": ["new_value"]} - assert vault.get_changed_notes()[0].inline_metadata.dict == {"new_key2": ["new_value"]} - assert vault.get_changed_notes()[0].tags.list == ["new_tag"] - assert vault.metadata.frontmatter == {"new_key": ["new_value"]} - assert vault.metadata.inline_metadata == {"new_key2": ["new_value"]} - assert vault.metadata.tags == ["new_tag"] + + note = vault.get_changed_notes()[0] + + assert note.note_path.name == "sample_note.md" + assert len(note.metadata) == 3 + assert vault.frontmatter == {"new_key": ["new_value"]} + assert vault.inline_meta == {"new_key2": ["new_value"]} + assert vault.tags == ["new_tag"]