diff --git a/README.md b/README.md index 1acd15f..397c990 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive - **Delete a value from a key** - **Delete an inline tag** +**Transpose Metadata**: Move metadata from inline to frontmatter or the reverse. + +- **Transpose all metadata** - Moves all frontmatter to inline metadata, or the reverse +- **Transpose key** - Transposes a specific key and all it's values +- **Transpose value**- Transpose a specific key:value pair + **Review Changes**: Prior to committing changes, review all changes that will be made. - **View a diff of the changes** that will be made @@ -122,7 +128,7 @@ To bypass the configuration file and specify a vault to use at runtime use the ` There are two ways to contribute to this project. -### 1. Containerized development (Recommended) +### 1. Containerized development 1. Clone this repository. `git clone https://github.com/natelandau/obsidian-metadata` 2. Open the repository in Visual Studio Code diff --git a/codecov.yml b/codecov.yml index b7f6194..b015d15 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,8 +7,8 @@ coverage: threshold: 5% # the leniency in hitting the target patch: default: - target: 50% - threshold: 5% + target: 50% + threshold: 5% ignore: - tests/ diff --git a/poetry.lock b/poetry.lock index bc2d83d..58e365d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1357,14 +1357,14 @@ files = [ [[package]] name = "virtualenv" -version = "20.17.1" +version = "20.18.0" description = "Virtual Python Environment builder" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, - {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, + {file = "virtualenv-20.18.0-py3-none-any.whl", hash = "sha256:9d61e4ec8d2c0345dab329fb825eb05579043766a4b26a2f66b28948de68c722"}, + {file = "virtualenv-20.18.0.tar.gz", hash = "sha256:f262457a4d7298a6b733b920a196bf8b46c8af15bf1fd9da7142995eff15118e"}, ] [package.dependencies] @@ -1373,8 +1373,8 @@ filelock = ">=3.4.1,<4" platformdirs = ">=2.4,<3" [package.extras] -docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] -testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] +test = ["covdefaults (>=2.2.2)", "coverage (>=7.1)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23)", "pytest (>=7.2.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] [[package]] name = "vulture" diff --git a/src/obsidian_metadata/_utils/utilities.py b/src/obsidian_metadata/_utils/utilities.py index 4f2cd74..3792a06 100644 --- a/src/obsidian_metadata/_utils/utilities.py +++ b/src/obsidian_metadata/_utils/utilities.py @@ -122,7 +122,7 @@ def docstring_parameter(*sub: Any) -> Any: def merge_dictionaries(dict1: dict, dict2: dict) -> dict: - """Merge two dictionaries. + """Merge two dictionaries. When the values are lists, they are merged and sorted. Args: dict1 (dict): First dictionary. diff --git a/src/obsidian_metadata/cli.py b/src/obsidian_metadata/cli.py index 6e8eb10..deb7fb1 100644 --- a/src/obsidian_metadata/cli.py +++ b/src/obsidian_metadata/cli.py @@ -135,6 +135,14 @@ def main( • Delete a value from a key • Delete an inline tag + [bold underline]Transpose Metadata[/] + Move metadata from inline to frontmatter or the reverse. + + • Transpose all metadata - Moves all frontmatter to inline + metadata, or the reverse + • Transpose key - Transposes a specific key and all it's values + • Transpose value- Transpose a specific key:value pair + [bold underline]Review Changes[/] Prior to committing changes, review all changes that will be made. • View a diff of the changes that will be made diff --git a/src/obsidian_metadata/models/application.py b/src/obsidian_metadata/models/application.py index 61f9018..a77b57d 100644 --- a/src/obsidian_metadata/models/application.py +++ b/src/obsidian_metadata/models/application.py @@ -65,6 +65,8 @@ class Application: self.application_rename_metadata() case "delete_metadata": self.application_delete_metadata() + case "transpose_metadata": + self.application_transpose_metadata() case "review_changes": self.review_changes() case "commit_changes": @@ -120,6 +122,51 @@ class Application: case _: # pragma: no cover return + def application_delete_metadata(self) -> None: + alerts.usage("Delete either a key and all associated values, or a specific value.") + + choices = [ + {"name": "Delete key", "value": "delete_key"}, + {"name": "Delete value", "value": "delete_value"}, + {"name": "Delete inline tag", "value": "delete_inline_tag"}, + questionary.Separator(), + {"name": "Back", "value": "back"}, + ] + match self.questions.ask_selection( + choices=choices, question="Select a metadata type to delete" + ): + case "delete_key": + self.delete_key() + case "delete_value": + self.delete_value() + case "delete_inline_tag": + self.delete_inline_tag() + case _: # pragma: no cover + return + + def application_rename_metadata(self) -> None: + """Rename metadata.""" + alerts.usage("Select the type of metadata to rename.") + + choices = [ + {"name": "Rename key", "value": "rename_key"}, + {"name": "Rename value", "value": "rename_value"}, + {"name": "Rename inline tag", "value": "rename_inline_tag"}, + questionary.Separator(), + {"name": "Back", "value": "back"}, + ] + match self.questions.ask_selection( + choices=choices, question="Select a metadata type to rename" + ): + case "rename_key": + self.rename_key() + case "rename_value": + self.rename_value() + case "rename_inline_tag": + self.rename_inline_tag() + case _: # pragma: no cover + return + def application_filter(self) -> None: """Filter notes.""" alerts.usage("Limit the scope of notes to be processed with one or more filters.") @@ -286,6 +333,24 @@ class Application: case _: return + def application_transpose_metadata(self) -> None: + """Transpose metadata.""" + alerts.usage("Transpose metadata from frontmatter to inline or vice versa.") + + choices = [ + {"name": "Transpose frontmatter to inline", "value": "frontmatter_to_inline"}, + {"name": "Transpose inline to frontmatter", "value": "inline_to_frontmatter"}, + ] + match self.questions.ask_selection( + choices=choices, question="Select metadata to transpose" + ): + case "frontmatter_to_inline": + self.transpose_metadata(begin=MetadataType.FRONTMATTER, end=MetadataType.INLINE) + case "inline_to_frontmatter": + self.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) + case _: # pragma: no cover + return + def application_vault(self) -> None: """Vault actions.""" alerts.usage("Create or delete a backup of your vault.") @@ -306,51 +371,6 @@ class Application: case _: return - def application_delete_metadata(self) -> None: - alerts.usage("Delete either a key and all associated values, or a specific value.") - - choices = [ - {"name": "Delete key", "value": "delete_key"}, - {"name": "Delete value", "value": "delete_value"}, - {"name": "Delete inline tag", "value": "delete_inline_tag"}, - questionary.Separator(), - {"name": "Back", "value": "back"}, - ] - match self.questions.ask_selection( - choices=choices, question="Select a metadata type to delete" - ): - case "delete_key": - self.delete_key() - case "delete_value": - self.delete_value() - case "delete_inline_tag": - self.delete_inline_tag() - case _: # pragma: no cover - return - - def application_rename_metadata(self) -> None: - """Rename metadata.""" - alerts.usage("Select the type of metadata to rename.") - - choices = [ - {"name": "Rename key", "value": "rename_key"}, - {"name": "Rename value", "value": "rename_value"}, - {"name": "Rename inline tag", "value": "rename_inline_tag"}, - questionary.Separator(), - {"name": "Back", "value": "back"}, - ] - match self.questions.ask_selection( - choices=choices, question="Select a metadata type to rename" - ): - case "rename_key": - self.rename_key() - case "rename_value": - self.rename_value() - case "rename_inline_tag": - self.rename_inline_tag() - case _: # pragma: no cover - return - def commit_changes(self) -> bool: """Write all changes to disk. @@ -537,3 +557,76 @@ class Application: if note_to_review is None or note_to_review == "return": break changed_notes[note_to_review].print_diff() + + def transpose_metadata(self, begin: MetadataType, end: MetadataType) -> None: + """Transpose metadata from one format to another. + + Args: + begin: The format to transpose from. + end: The format to transpose to. + """ + choices = [ + {"name": f"Transpose all {begin.value} to {end.value}", "value": "transpose_all"}, + {"name": "Transpose a key", "value": "transpose_key"}, + {"name": "Transpose a value", "value": "transpose_value"}, + {"name": "Back", "value": "back"}, + ] + match self.questions.ask_selection(choices=choices, question="Select an action to perform"): + case "transpose_all": + num_changed = self.vault.transpose_metadata( + begin=begin, + end=end, + location=self.vault.insert_location, + ) + + if num_changed == 0: + alerts.warning(f"No notes were changed") + return + + alerts.success(f"Transposed {begin.value} to {end.value} in {num_changed} notes") + case "transpose_key": + key = self.questions.ask_existing_key(question="Which key to transpose?") + if key is None: # pragma: no cover + return + + num_changed = self.vault.transpose_metadata( + begin=begin, + end=end, + key=key, + location=self.vault.insert_location, + ) + + if num_changed == 0: + alerts.warning(f"No notes were changed") + return + + alerts.success( + f"Transposed key: `{key}` from {begin.value} to {end.value} in {num_changed} notes" + ) + case "transpose_value": + key = self.questions.ask_existing_key(question="Which key contains the value?") + if key is None: # pragma: no cover + return + + questions2 = Questions(vault=self.vault, key=key) + value = questions2.ask_existing_value(question="Which value to transpose?") + if value is None: # pragma: no cover + return + + num_changed = self.vault.transpose_metadata( + begin=begin, + end=end, + key=key, + value=value, + location=self.vault.insert_location, + ) + + if num_changed == 0: + alerts.warning(f"No notes were changed") + return + + alerts.success( + f"Transposed key: `{key}:{value}` from {begin.value} to {end.value} in {num_changed} notes" + ) + case _: + return diff --git a/src/obsidian_metadata/models/metadata.py b/src/obsidian_metadata/models/metadata.py index 11d026d..d0660e1 100644 --- a/src/obsidian_metadata/models/metadata.py +++ b/src/obsidian_metadata/models/metadata.py @@ -2,7 +2,7 @@ import re from io import StringIO - +import copy from rich import print from rich.columns import Columns from rich.console import Console @@ -49,15 +49,13 @@ class VaultMetadata: """ if isinstance(metadata, dict): new_metadata = clean_dictionary(metadata) - self.dict = merge_dictionaries(self.dict.copy(), new_metadata.copy()) + self.dict = merge_dictionaries(self.dict, new_metadata) if area == MetadataType.FRONTMATTER: - self.frontmatter = merge_dictionaries(self.frontmatter.copy(), new_metadata.copy()) + self.frontmatter = merge_dictionaries(self.frontmatter, new_metadata) if area == MetadataType.INLINE: - self.inline_metadata = merge_dictionaries( - self.inline_metadata.copy(), new_metadata.copy() - ) + self.inline_metadata = merge_dictionaries(self.inline_metadata, new_metadata) if area == MetadataType.TAGS and isinstance(metadata, list): self.tags.extend(metadata) @@ -118,7 +116,7 @@ class VaultMetadata: Returns: bool: True if a value was deleted """ - new_dict = self.dict.copy() + new_dict = copy.deepcopy(self.dict) if value_to_delete is None: for _k in list(new_dict): @@ -216,7 +214,7 @@ class Frontmatter: def __init__(self, file_content: str): self.dict: dict[str, list[str]] = self._grab_note_frontmatter(file_content) - self.dict_original: dict[str, list[str]] = self.dict.copy() + self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict) def __repr__(self) -> str: # pragma: no cover """Representation of the frontmatter. @@ -364,7 +362,7 @@ class Frontmatter: str: Frontmatter as a YAML string. sort_keys (bool, optional): Sort the keys. Defaults to False. """ - dict_to_dump = self.dict.copy() + dict_to_dump = copy.deepcopy(self.dict) for k in dict_to_dump: if dict_to_dump[k] == []: dict_to_dump[k] = None @@ -391,7 +389,7 @@ class InlineMetadata: def __init__(self, file_content: str): self.dict: dict[str, list[str]] = self.grab_inline_metadata(file_content) - self.dict_original: dict[str, list[str]] = self.dict.copy() + self.dict_original: dict[str, list[str]] = copy.deepcopy(self.dict) def __repr__(self) -> str: # pragma: no cover """Representation of inline metadata. diff --git a/src/obsidian_metadata/models/notes.py b/src/obsidian_metadata/models/notes.py index dbe05f7..baaa269 100644 --- a/src/obsidian_metadata/models/notes.py +++ b/src/obsidian_metadata/models/notes.py @@ -4,7 +4,7 @@ import difflib import re from pathlib import Path - +import copy import rich.repr import typer from rich.console import Console @@ -238,7 +238,9 @@ class Note: return False - def delete_metadata(self, key: str, value: str = None) -> bool: + def delete_metadata( + self, key: str, value: str = None, area: MetadataType = MetadataType.ALL + ) -> bool: """Delete a key or key-value pair from the note's metadata. Regex is supported. If no value is provided, will delete an entire key. @@ -246,6 +248,7 @@ class Note: Args: key (str): Key to delete. value (str, optional): Value to delete. + area (MetadataType, optional): Area to delete metadata from. Defaults to MetadataType.ALL. Returns: bool: Whether the key or key-value pair was deleted. @@ -253,17 +256,25 @@ class Note: changed_value: bool = False if value is None: - if self.frontmatter.delete(key): + if ( + area == MetadataType.FRONTMATTER or area == MetadataType.ALL + ) and self.frontmatter.delete(key): self.update_frontmatter() changed_value = True - if self.inline_metadata.delete(key): + if ( + area == MetadataType.INLINE or area == MetadataType.ALL + ) and self.inline_metadata.delete(key): self._delete_inline_metadata(key, value) changed_value = True else: - if self.frontmatter.delete(key, value): + if ( + area == MetadataType.FRONTMATTER or area == MetadataType.ALL + ) and self.frontmatter.delete(key, value): self.update_frontmatter() changed_value = True - if self.inline_metadata.delete(key, value): + if ( + area == MetadataType.INLINE or area == MetadataType.ALL + ) and self.inline_metadata.delete(key, value): self._delete_inline_metadata(key, value) changed_value = True @@ -426,6 +437,88 @@ class Note: self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE) + def transpose_metadata( + self, + begin: MetadataType, + end: MetadataType, + key: str = None, + value: str | list[str] = None, + location: InsertLocation = InsertLocation.BOTTOM, + ) -> bool: + """Transpose metadata from one type to another. + + 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. + value (str | list[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_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 + else: + 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) + + if has_changes: + return True + else: + return False + + if begin == MetadataType.TAGS: + # TODO: Implement transposing to and from tags + pass + + return False + def update_frontmatter(self, sort_keys: bool = False) -> None: """Replace the frontmatter in the note with the current frontmatter object.""" try: @@ -439,13 +532,16 @@ class Note: return new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys) - new_frontmatter = f"---\n{new_frontmatter}---\n" + if self.frontmatter.dict == {}: + new_frontmatter = "" + else: + new_frontmatter = f"---\n{new_frontmatter}---\n" if current_frontmatter is None: self.file_content = new_frontmatter + self.file_content return - current_frontmatter = re.escape(current_frontmatter) + current_frontmatter = f"{re.escape(current_frontmatter)}\n?" self.sub(current_frontmatter, new_frontmatter, is_regex=True) def write(self, path: Path = None) -> None: diff --git a/src/obsidian_metadata/models/questions.py b/src/obsidian_metadata/models/questions.py index 61574e5..99b81eb 100644 --- a/src/obsidian_metadata/models/questions.py +++ b/src/obsidian_metadata/models/questions.py @@ -280,6 +280,7 @@ class Questions: {"name": "Add Metadata", "value": "add_metadata"}, {"name": "Rename Metadata", "value": "rename_metadata"}, {"name": "Delete Metadata", "value": "delete_metadata"}, + {"name": "Transpose Metadata", "value": "transpose_metadata"}, questionary.Separator("-------------------------------"), {"name": "Review Changes", "value": "review_changes"}, {"name": "Commit Changes", "value": "commit_changes"}, diff --git a/src/obsidian_metadata/models/vault.py b/src/obsidian_metadata/models/vault.py index 748c805..f8acfb2 100644 --- a/src/obsidian_metadata/models/vault.py +++ b/src/obsidian_metadata/models/vault.py @@ -411,3 +411,42 @@ class Vault: self._rebuild_vault_metadata() return num_changed + + def transpose_metadata( + self, + begin: MetadataType, + end: MetadataType, + key: str = None, + value: str | list[str] = None, + location: InsertLocation = None, + ) -> int: + """Transpose metadata from one type to another. + + Args: + begin (MetadataType): Metadata type to transpose from. + end (MetadataType): Metadata type to transpose to. + key (str, optional): Key to transpose. Defaults to None. + value (str, optional): Value to transpose. Defaults to None. + location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`) + + Returns: + int: Number of notes that had metadata transposed. + """ + if location is None: + location = self.insert_location + + num_changed = 0 + for _note in self.notes_in_scope: + if _note.transpose_metadata( + begin=begin, + end=end, + key=key, + value=value, + location=location, + ): + num_changed += 1 + + if num_changed > 0: + self._rebuild_vault_metadata() + + return num_changed diff --git a/tests/application_test.py b/tests/application_test.py index a09507d..7f22240 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 +from tests.helpers import Regex, remove_ansi def test_instantiate_application(test_application) -> None: @@ -38,8 +38,8 @@ def test_abort(test_application, mocker, capsys) -> None: ) app.application_main() - captured = capsys.readouterr() - assert "Done!" in captured.out + captured = remove_ansi(capsys.readouterr().out) + assert "Done!" in captured def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None: @@ -65,8 +65,8 @@ def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) def test_add_metadata_inline(test_application, mocker, capsys) -> None: @@ -92,8 +92,8 @@ def test_add_metadata_inline(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) def test_add_metadata_tag(test_application, mocker, capsys) -> None: @@ -115,8 +115,8 @@ def test_add_metadata_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS +\| Added metadata to \d+ notes", re.DOTALL) def test_delete_inline_tag(test_application, mocker, capsys) -> None: @@ -138,8 +138,8 @@ def test_delete_inline_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert "WARNING | No notes were changed" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -156,8 +156,8 @@ def test_delete_inline_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\| Deleted.*\d+.*notes", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS +\| Deleted inline tag: breakfast in \d+ notes", re.DOTALL) def test_delete_key(test_application, mocker, capsys) -> None: @@ -179,8 +179,8 @@ def test_delete_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"WARNING +\| No notes found with a.*key.*matching", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert r"WARNING | No notes found with a key matching: \d{7}" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -197,10 +197,8 @@ def test_delete_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex( - r"SUCCESS +\|.*Deleted.*keys.*matching:.*d\\w\+.*from.*10", re.DOTALL - ) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS \| Deleted keys matching: d\\w\+ from \d+ notes", re.DOTALL) def test_delete_value(test_application, mocker, capsys) -> None: @@ -225,8 +223,8 @@ def test_delete_value(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"WARNING +\| No notes found matching:", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert r"WARNING | No notes found matching: area: \d{7}" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -246,10 +244,8 @@ def test_delete_value(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex( - r"SUCCESS +\| Deleted value.*\^front\\w\+\$.*from.*key.*area.*in.*\d+.*notes", re.DOTALL - ) + captured = remove_ansi(capsys.readouterr().out) + assert r"SUCCESS | Deleted value ^front\w+$ from key area in 8 notes" in captured def test_filter_notes(test_application, mocker, capsys) -> None: @@ -271,10 +267,10 @@ def test_filter_notes(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\| Loaded.*\d+.*notes from.*\d+.*total", re.DOTALL) - assert "02 inline/inline 2.md" in captured.out - assert "03 mixed/mixed 1.md" not in captured.out + captured = remove_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 mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -326,11 +322,11 @@ def test_filter_clear(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert "02 inline/inline 2.md" in captured.out - assert "03 mixed/mixed 1.md" in captured.out - assert "01 frontmatter/frontmatter 4.md" in captured.out - assert "04 no metadata/no_metadata_1.md " in captured.out + captured = remove_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 + assert "04 no metadata/no_metadata_1.md " in captured def test_inspect_metadata_all(test_application, mocker, capsys) -> None: @@ -348,8 +344,8 @@ def test_inspect_metadata_all(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"type +│ article", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"type +│ article", re.DOTALL) def test_rename_inline_tag(test_application, mocker, capsys) -> None: @@ -375,8 +371,8 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert "No notes were changed" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -397,8 +393,8 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"Renamed.*breakfast.*to.*new_tag.*in.*\d+.*notes", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"Renamed breakfast to new_tag in \d+ notes", re.DOTALL) def test_rename_key(test_application, mocker, capsys) -> None: @@ -424,8 +420,8 @@ def test_rename_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert "WARNING | No notes were changed" in captured.out + captured = remove_ansi(capsys.readouterr().out) + assert "WARNING | No notes were changed" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -446,8 +442,8 @@ def test_rename_key(test_application, mocker, capsys) -> None: with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"Renamed.*tags.*to.*new_tags.*in.*\d+.*notes", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"Renamed tags to new_tags in \d+ notes", re.DOTALL) def test_rename_value_fail(test_application, mocker, capsys) -> None: @@ -476,8 +472,8 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert "WARNING | No notes were changed" in captured mocker.patch( "obsidian_metadata.models.application.Questions.ask_application_main", @@ -501,11 +497,10 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex( - r"SUCCESS +\| Renamed.*'area:frontmatter'.*to.*'area:new_key'", re.DOTALL + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex( + r"SUCCESS +\| Renamed 'area:frontmatter' to 'area:new_key' in \d+ notes", re.DOTALL ) - assert captured.out == Regex(r".*in.*\d+.*notes.*", re.DOTALL) def test_review_no_changes(test_application, mocker, capsys) -> None: @@ -518,8 +513,8 @@ def test_review_no_changes(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"INFO +\| No changes to review", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert "INFO | No changes to review" in captured def test_review_changes(test_application, mocker, capsys) -> None: @@ -544,10 +539,49 @@ def test_review_changes(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r".*Found.*\d+.*changed notes in the vault", re.DOTALL) - assert "- tags:" in captured.out - assert "+ new_tags:" in captured.out + captured = remove_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 + + +def test_transpose_metadata(test_application, mocker, capsys) -> None: + """Transpose metadata.""" + app = test_application + app._load_vault() + + assert app.vault.metadata.inline_metadata["inline_key"] == ["inline_key_value"] + mocker.patch( + "obsidian_metadata.models.application.Questions.ask_application_main", + side_effect=["transpose_metadata", KeyError], + ) + mocker.patch( + "obsidian_metadata.models.application.Questions.ask_selection", + side_effect=["inline_to_frontmatter", "transpose_all"], + ) + 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 "SUCCESS | Transposed Inline Metadata to Frontmatter in 5 notes" in captured + + app = test_application + app._load_vault() + + assert app.vault.metadata.frontmatter["date_created"] == ["2022-12-21", "2022-12-22"] + mocker.patch( + "obsidian_metadata.models.application.Questions.ask_application_main", + side_effect=["transpose_metadata", KeyError], + ) + mocker.patch( + "obsidian_metadata.models.application.Questions.ask_selection", + side_effect=["frontmatter_to_inline", "transpose_all"], + ) + 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 == {} def test_vault_backup(test_application, mocker, capsys) -> None: @@ -565,8 +599,10 @@ def test_vault_backup(test_application, mocker, capsys) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\|.*application\.bak", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex( + r"SUCCESS +\| Vault backed up to:[-\w\d\/\s]+application\.bak", re.DOTALL + ) def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None: @@ -586,5 +622,5 @@ def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None: ) with pytest.raises(KeyError): app.application_main() - captured = capsys.readouterr() - assert captured.out == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL) + captured = remove_ansi(capsys.readouterr().out) + assert captured == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL) diff --git a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 1.md b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 1.md index 5937dc2..a4f1e93 100644 --- a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 1.md +++ b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 1.md @@ -3,25 +3,16 @@ area: frontmatter date_created: 2022-12-22 date_modified: 2022-12-22 tags: - - food/fruit/apple - - food/fruit/pear - - dinner - - lunch - - breakfast -thoughts: - rating: 8 - reviewable: false -levels: - level1: - - level1a - - level1b - level2: - - level2a - - level2b + - food/fruit/apple + - food/fruit/pear + - dinner + - lunch + - breakfast author: John Doe status: new type: ["book", "article", "note", "one-off"] --- + # Page Title H1 # Headings diff --git a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 2.md b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 2.md index d26d90b..ca68579 100644 --- a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 2.md +++ b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 2.md @@ -3,25 +3,16 @@ area: frontmatter date_created: 2022-12-22 date_modified: 2022-11-14 tags: - - food/fruit/apple - - food/fruit/pear - - dinner - - lunch - - breakfast -thoughts: - rating: 8 - reviewable: false -levels: - level1: - - level1a - - level1b - level2: - - level2a - - level2b + - food/fruit/apple + - food/fruit/pear + - dinner + - lunch + - breakfast author: John Doe status: new type: ["book", "article", "note"] --- + # Page Title H1 # Headings diff --git a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 3.md b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 3.md index fd4f5bd..1a5052e 100644 --- a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 3.md +++ b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 3.md @@ -3,25 +3,16 @@ area: frontmatter date_created: 2022-12-22 date_modified: 2022-10-01 tags: - - food/fruit/apple - - food/fruit/pear - - dinner - - lunch - - breakfast -thoughts: - rating: 8 - reviewable: false -levels: - level1: - - level1a - - level1b - level2: - - level2a - - level2b + - food/fruit/apple + - food/fruit/pear + - dinner + - lunch + - breakfast author: John Doe status: new type: ["book", "article", "note"] --- + # Page Title H1 # Headings diff --git a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 4.md b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 4.md index bb7ab33..de246c0 100644 --- a/tests/fixtures/sample_vault/01 frontmatter/frontmatter 4.md +++ b/tests/fixtures/sample_vault/01 frontmatter/frontmatter 4.md @@ -3,21 +3,11 @@ area: frontmatter date_created: 2022-12-22 date_modified: 2022-12-22 tags: - - food/fruit/apple - - food/fruit/pear - - dinner - - lunch - - breakfast -thoughts: - rating: 8 - reviewable: false -levels: - level1: - - level1a - - level1b - level2: - - level2a - - level2b + - food/fruit/apple + - food/fruit/pear + - dinner + - lunch + - breakfast author: John Doe status: new type: ["book", "article", "note"] diff --git a/tests/fixtures/sample_vault/03 mixed/mixed 1.md b/tests/fixtures/sample_vault/03 mixed/mixed 1.md index 28d079e..c59042a 100644 --- a/tests/fixtures/sample_vault/03 mixed/mixed 1.md +++ b/tests/fixtures/sample_vault/03 mixed/mixed 1.md @@ -6,10 +6,6 @@ tags: - breakfast - not_food author: John Doe -nested_list: - nested_list_one: - - nested_list_one_a - - nested_list_one_b type: - article - note diff --git a/tests/fixtures/test_vault/test1.md b/tests/fixtures/test_vault/test1.md index ed3363e..5228ed1 100644 --- a/tests/fixtures/test_vault/test1.md +++ b/tests/fixtures/test_vault/test1.md @@ -1,14 +1,16 @@ --- date_created: 2022-12-22 tags: - - shared_tag - - frontmatter_tag1 - - frontmatter_tag2 - - - - 📅/frontmatter_tag3 + - shared_tag + - frontmatter_tag1 + - frontmatter_tag2 + - + - 📅/frontmatter_tag3 frontmatter_Key1: author name frontmatter_Key2: ["article", "note"] -shared_key1: shared_key1_value +shared_key1: + - shared_key1_value + - shared_key1_value3 shared_key2: shared_key2_value1 --- @@ -18,10 +20,12 @@ 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 -emoji_📅_key:: emoji_📅_key_value +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 diff --git a/tests/helpers.py b/tests/helpers.py index 7812284..85681a4 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -22,6 +22,19 @@ class KeyInputs: THREE = "3" +def remove_ansi(text) -> str: + """Remove ANSI escape sequences from a string. + + Args: + text (str): String to remove ANSI escape sequences from. + + Returns: + str: String without ANSI escape sequences. + """ + ansi_chars = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]") + return ansi_chars.sub("", text) + + class Regex: """Assert that a given string meets some expectations. diff --git a/tests/notes_test.py b/tests/notes_test.py index ee76a4e..4849592 100644 --- a/tests/notes_test.py +++ b/tests/notes_test.py @@ -36,7 +36,7 @@ def test_note_create(sample_note) -> None: "date_created": ["2022-12-22"], "frontmatter_Key1": ["author name"], "frontmatter_Key2": ["article", "note"], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -58,9 +58,9 @@ def test_note_create(sample_note) -> None: assert note.inline_metadata.dict == { "bottom_key1": ["bottom_key1_value"], "bottom_key2": ["bottom_key2_value"], - "emoji_📅_key": ["emoji_📅_key_value"], "intext_key": ["intext_value"], - "shared_key1": ["shared_key1_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"], @@ -127,7 +127,7 @@ def test_add_metadata_frontmatter(sample_note) -> None: "frontmatter_Key1": ["author name"], "frontmatter_Key2": ["article", "note"], "new_key1": [], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -143,7 +143,7 @@ def test_add_metadata_frontmatter(sample_note) -> None: "frontmatter_Key2": ["article", "note"], "new_key1": [], "new_key2": ["new_key2_value"], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -164,7 +164,7 @@ def test_add_metadata_frontmatter(sample_note) -> None: "frontmatter_Key2": ["article", "note"], "new_key1": [], "new_key2": ["new_key2_value", "new_key2_value2", "new_key2_value3"], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -271,6 +271,14 @@ def test_delete_metadata(sample_note) -> Note: assert "bottom_key2" not in note.inline_metadata.dict assert note.file_content != Regex(r"bottom_key2") + assert note.delete_metadata("shared_key1", area=MetadataType.INLINE) is True + assert note.frontmatter.dict["shared_key1"] == ["shared_key1_value", "shared_key1_value3"] + assert "shared_key1" not in note.inline_metadata.dict + + assert note.delete_metadata("shared_key2", area=MetadataType.FRONTMATTER) is True + assert note.inline_metadata.dict["shared_key2"] == ["shared_key2_value2"] + assert "shared_key2" not in note.frontmatter.dict + def test_has_changes(sample_note) -> None: """Test has changes.""" @@ -506,9 +514,9 @@ def test_rename_inline_metadata(sample_note) -> None: assert note.file_content != Regex(r"bottom_key1::") assert note.file_content == Regex(r"new_key::") - note._rename_inline_metadata("emoji_📅_key", "emoji_📅_key_value", "new_value") - assert note.file_content != Regex(r"emoji_📅_key:: ?emoji_📅_key_value") - assert note.file_content == Regex(r"emoji_📅_key:: ?new_value") + note._rename_inline_metadata("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_rename_metadata(sample_note) -> None: @@ -539,6 +547,251 @@ def test_rename_metadata(sample_note) -> None: assert note.file_content == Regex(r"new_key:: new_value") +def test_transpose_frontmatter(sample_note) -> None: + """Test transposing metadata.""" + 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) + 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 + ) + + # Transpose all frontmatter metadata to inline metadata + 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"], + } + + # Transpose when key exists in both frontmatter and inline metadata + note = Note(note_path=sample_note) + assert ( + note.transpose_metadata( + begin=MetadataType.FRONTMATTER, + end=MetadataType.INLINE, + key="shared_key1", + ) + is True + ) + assert note.frontmatter.dict == { + "date_created": ["2022-12-22"], + "frontmatter_Key1": ["author name"], + "frontmatter_Key2": ["article", "note"], + "shared_key2": ["shared_key2_value1"], + "tags": [ + "frontmatter_tag1", + "frontmatter_tag2", + "shared_tag", + "📅/frontmatter_tag3", + ], + } + 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_key1_value3", + ], + "shared_key2": ["shared_key2_value2"], + "top_key1": ["top_key1_value"], + "top_key2": ["top_key2_value"], + "top_key3": ["top_key3_value_as_link"], + } + + # Transpose a single key and it's respective values + note = Note(note_path=sample_note) + assert ( + note.transpose_metadata( + begin=MetadataType.INLINE, + end=MetadataType.FRONTMATTER, + key="top_key1", + ) + is True + ) + 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", + ], + "top_key1": ["top_key1_value"], + } + 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_key2": ["top_key2_value"], + "top_key3": ["top_key3_value_as_link"], + } + + # Transpose a key when it's value is a list + 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 note.frontmatter.dict == { + "date_created": ["2022-12-22"], + "frontmatter_Key1": ["author name"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], + "shared_key2": ["shared_key2_value1"], + "tags": [ + "frontmatter_tag1", + "frontmatter_tag2", + "shared_tag", + "📅/frontmatter_tag3", + ], + } + assert note.inline_metadata.dict == { + "bottom_key1": ["bottom_key1_value"], + "bottom_key2": ["bottom_key2_value"], + "frontmatter_Key2": ["article", "note"], + "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"], + } + + # Transpose a string value from a key + 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 == { + "date_created": ["2022-12-22"], + "frontmatter_Key1": ["author name"], + "frontmatter_Key2": ["article"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], + "shared_key2": ["shared_key2_value1"], + "tags": [ + "frontmatter_tag1", + "frontmatter_tag2", + "shared_tag", + "📅/frontmatter_tag3", + ], + } + assert note.inline_metadata.dict == { + "bottom_key1": ["bottom_key1_value"], + "bottom_key2": ["bottom_key2_value"], + "frontmatter_Key2": ["note"], + "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"], + } + + # Transpose list values from a key + note = Note(note_path=sample_note) + assert ( + note.transpose_metadata( + begin=MetadataType.FRONTMATTER, + end=MetadataType.INLINE, + key="frontmatter_Key2", + value=["note", "article"], + ) + is True + ) + assert note.frontmatter.dict == { + "date_created": ["2022-12-22"], + "frontmatter_Key1": ["author name"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], + "shared_key2": ["shared_key2_value1"], + "tags": [ + "frontmatter_tag1", + "frontmatter_tag2", + "shared_tag", + "📅/frontmatter_tag3", + ], + } + assert note.inline_metadata.dict == { + "bottom_key1": ["bottom_key1_value"], + "bottom_key2": ["bottom_key2_value"], + "frontmatter_Key2": ["note", "article"], + "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"], + } + + def test_update_frontmatter(sample_note) -> None: """Test replacing frontmatter.""" note = Note(note_path=sample_note) @@ -556,7 +809,9 @@ frontmatter_Key1: some_new_key_here frontmatter_Key2: - article - note -shared_key1: shared_key1_value +shared_key1: + - shared_key1_value + - shared_key1_value3 shared_key2: shared_key2_value1 ---""" assert new_frontmatter in note.file_content diff --git a/tests/vault_test.py b/tests/vault_test.py index a53562b..9e7f1c8 100644 --- a/tests/vault_test.py +++ b/tests/vault_test.py @@ -29,12 +29,16 @@ def test_vault_creation(test_vault): "bottom_key1": ["bottom_key1_value"], "bottom_key2": ["bottom_key2_value"], "date_created": ["2022-12-22"], - "emoji_📅_key": ["emoji_📅_key_value"], "frontmatter_Key1": ["author name"], "frontmatter_Key2": ["article", "note"], "ignored_frontmatter": ["ignore_me"], "intext_key": ["intext_value"], - "shared_key1": ["shared_key1_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", @@ -63,9 +67,9 @@ def test_vault_creation(test_vault): assert vault.metadata.inline_metadata == { "bottom_key1": ["bottom_key1_value"], "bottom_key2": ["bottom_key2_value"], - "emoji_📅_key": ["emoji_📅_key_value"], "intext_key": ["intext_value"], - "shared_key1": ["shared_key1_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"], @@ -77,7 +81,7 @@ def test_vault_creation(test_vault): "frontmatter_Key1": ["author name"], "frontmatter_Key2": ["article", "note"], "ignored_frontmatter": ["ignore_me"], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -104,13 +108,17 @@ def test_add_metadata(test_vault) -> None: "bottom_key1": ["bottom_key1_value"], "bottom_key2": ["bottom_key2_value"], "date_created": ["2022-12-22"], - "emoji_📅_key": ["emoji_📅_key_value"], "frontmatter_Key1": ["author name"], "frontmatter_Key2": ["article", "note"], "ignored_frontmatter": ["ignore_me"], "intext_key": ["intext_value"], + "key📅": ["📅_key_value"], "new_key": [], - "shared_key1": ["shared_key1_value"], + "shared_key1": [ + "shared_key1_value", + "shared_key1_value2", + "shared_key1_value3", + ], "shared_key2": ["shared_key2_value1", "shared_key2_value2"], "tags": [ "frontmatter_tag1", @@ -132,7 +140,7 @@ def test_add_metadata(test_vault) -> None: "frontmatter_Key2": ["article", "note"], "ignored_frontmatter": ["ignore_me"], "new_key": [], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -150,14 +158,18 @@ def test_add_metadata(test_vault) -> None: "bottom_key1": ["bottom_key1_value"], "bottom_key2": ["bottom_key2_value"], "date_created": ["2022-12-22"], - "emoji_📅_key": ["emoji_📅_key_value"], "frontmatter_Key1": ["author name"], "frontmatter_Key2": ["article", "note"], "ignored_frontmatter": ["ignore_me"], "intext_key": ["intext_value"], + "key📅": ["📅_key_value"], "new_key": [], "new_key2": ["new_key2_value"], - "shared_key1": ["shared_key1_value"], + "shared_key1": [ + "shared_key1_value", + "shared_key1_value2", + "shared_key1_value3", + ], "shared_key2": ["shared_key2_value1", "shared_key2_value2"], "tags": [ "frontmatter_tag1", @@ -180,7 +192,7 @@ def test_add_metadata(test_vault) -> None: "ignored_frontmatter": ["ignore_me"], "new_key": [], "new_key2": ["new_key2_value"], - "shared_key1": ["shared_key1_value"], + "shared_key1": ["shared_key1_value", "shared_key1_value3"], "shared_key2": ["shared_key2_value1"], "tags": [ "frontmatter_tag1", @@ -470,3 +482,51 @@ def test_rename_metadata(test_vault) -> None: "shared_tag", "📅/frontmatter_tag3", ] + + +def test_transpose_metadata(test_vault) -> None: + """Test transposing metadata.""" + vault_path = test_vault + config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path) + vault_config = config.vaults[0] + vault = Vault(config=vault_config) + + assert vault.transpose_metadata(begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER) == 2 + + assert vault.metadata.inline_metadata == {} + assert vault.metadata.frontmatter == { + "author": ["author name"], + "bottom_key1": ["bottom_key1_value"], + "bottom_key2": ["bottom_key2_value"], + "date_created": ["2022-12-22"], + "frontmatter_Key1": ["author name"], + "frontmatter_Key2": ["article", "note"], + "ignored_frontmatter": ["ignore_me"], + "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", + "frontmatter_tag3", + "ignored_file_tag1", + "shared_tag", + "📅/frontmatter_tag3", + ], + "top_key1": ["top_key1_value"], + "top_key2": ["top_key2_value"], + "top_key3": ["top_key3_value_as_link"], + "type": ["article", "note"], + } + + assert ( + vault.transpose_metadata( + begin=MetadataType.INLINE, end=MetadataType.FRONTMATTER, location=InsertLocation.TOP + ) + == 0 + )