feat: transpose metadata between frontmatter and inline

This commit is contained in:
Nathaniel Landau
2023-03-11 12:04:26 -05:00
parent 1eb2d30d47
commit 000ac1a16c
14 changed files with 1069 additions and 723 deletions

View File

@@ -61,7 +61,7 @@ repos:
entry: yamllint --strict --config-file .yamllint.yml entry: yamllint --strict --config-file .yamllint.yml
- repo: "https://github.com/charliermarsh/ruff-pre-commit" - repo: "https://github.com/charliermarsh/ruff-pre-commit"
rev: "v0.0.253" rev: "v0.0.254"
hooks: hooks:
- id: ruff - id: ruff
args: ["--extend-ignore", "I001,D301,D401"] args: ["--extend-ignore", "I001,D301,D401"]

View File

@@ -60,6 +60,7 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive
- **List notes in scope**: List notes that will be processed. - **List notes in scope**: List notes that will be processed.
**Add Metadata**: Add new metadata to your vault. **Add Metadata**: Add new metadata to your vault.
When adding a new key to inline metadata, the `insert location` value in the config file will specify where in the note it will be inserted.
- **Add new metadata to the frontmatter** - **Add new metadata to the frontmatter**
- **Add new inline metadata** - Set `insert_location` in the config to control where the new metadata is inserted. (Default: Bottom) - **Add new inline metadata** - Set `insert_location` in the config to control where the new metadata is inserted. (Default: Bottom)
@@ -78,6 +79,7 @@ Once installed, run `obsidian-metadata` in your terminal to enter an interactive
- **Delete an inline tag** - **Delete an inline tag**
**Transpose Metadata**: Move metadata from inline to frontmatter or the reverse. **Transpose Metadata**: Move metadata from inline to frontmatter or the reverse.
When transposing to inline metadata, the `insert location` value in the config file will specify where in the note it will be inserted.
- **Transpose all metadata** - Moves all frontmatter to inline metadata, 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 key** - Transposes a specific key and all it's values
@@ -110,7 +112,7 @@ Below is an example with two vaults.
# Location to add metadata. One of: # Location to add metadata. One of:
# TOP: Directly after frontmatter. # TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter. # AFTER_TITLE: After the first header following frontmatter.
# BOTTOM: The bottom of the note # BOTTOM: The bottom of the note
insert_location = "BOTTOM" insert_location = "BOTTOM"

44
poetry.lock generated
View File

@@ -616,14 +616,14 @@ dev = ["black", "hypothesis", "mypy", "pygments (>=2.14.0)", "pytest", "pytest-c
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "3.1.0" version = "3.1.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "platformdirs-3.1.0-py3-none-any.whl", hash = "sha256:13b08a53ed71021350c9e300d4ea8668438fb0046ab3937ac9a29913a1a1350a"}, {file = "platformdirs-3.1.1-py3-none-any.whl", hash = "sha256:e5986afb596e4bb5bde29a79ac9061aa955b94fca2399b7aaac4090860920dd8"},
{file = "platformdirs-3.1.0.tar.gz", hash = "sha256:accc3665857288317f32c7bebb5a8e482ba717b474f3fc1d18ca7f9214be0cef"}, {file = "platformdirs-3.1.1.tar.gz", hash = "sha256:024996549ee88ec1a9aa99ff7f8fc819bb59e2c3477b410d90a16d32d6e707aa"},
] ]
[package.extras] [package.extras]
@@ -1102,29 +1102,29 @@ files = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.0.253" version = "0.0.254"
description = "An extremely fast Python linter, written in Rust." description = "An extremely fast Python linter, written in Rust."
category = "dev" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.0.253-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:69126b80d4da50a394cfe9da947377841cc6c83b0e05cfe9933672ce5c61bfcf"}, {file = "ruff-0.0.254-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:dd58c500d039fb381af8d861ef456c3e94fd6855c3d267d6c6718c9a9fe07be0"},
{file = "ruff-0.0.253-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:0f44caf5bbdaeacc3cba4ee3369638e4f6e4e71c9ca773d2f3fc3f65e4bfb434"}, {file = "ruff-0.0.254-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:688379050ae05394a6f9f9c8471587fd5dcf22149bd4304a4ede233cc4ef89a1"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8144a2fd6533e7a0dbaaf9a3dde44b8414eebf5a86a1fe21e0471d052a3e9c14"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1429be6d8bd3db0bf5becac3a38bd56f8421447790c50599cd90fd53417ec4"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07603b362f0dad56e30e7ef2f37bf480732ff8bcf52fe4fd6c9445eb42259f42"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:059a380c08e849b6f312479b18cc63bba2808cff749ad71555f61dd930e3c9a2"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68f9a50f48510a443ec57bcf51656bbef47e5972290c450398108ac2a53dfd32"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3f15d5d033fd3dcb85d982d6828ddab94134686fac2c02c13a8822aa03e1321"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed42010c379d42b81b537957b413cf8531a00d0a6270913e8527d9d73c7e0c"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8deba44fd563361c488dedec90dc330763ee0c01ba54e17df54ef5820079e7e0"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba4b3921fa9c59855b66e1a5ef140d0d872f15a83282bff5b5e3e8db89a45aa2"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef20bf798ffe634090ad3dc2e8aa6a055f08c448810a2f800ab716cc18b80107"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:60bda6fd99f9d3919df4362b671a12c83ef83279fc7bc1dc0e1aa689dfd91a71"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0deb1d7226ea9da9b18881736d2d96accfa7f328c67b7410478cc064ad1fa6aa"},
{file = "ruff-0.0.253-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19061d9b5809a0505a233580b48b59b847823ab90e266f8ae40cb31d3708bacf"}, {file = "ruff-0.0.254-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27d39d697fdd7df1f2a32c1063756ee269ad8d5345c471ee3ca450636d56e8c6"},
{file = "ruff-0.0.253-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ee92a7688f327c664891567aa24e4a8cae8635934df95e0dbe65b0e991fcc6e"}, {file = "ruff-0.0.254-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2fc21d060a3197ac463596a97d9b5db2d429395938b270ded61dd60f0e57eb21"},
{file = "ruff-0.0.253-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f0ff811ea61684c6e9284afa701b8388818ab5ef8ebd6144c15c9ba64f459f1e"}, {file = "ruff-0.0.254-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f70dc93bc9db15cccf2ed2a831938919e3e630993eeea6aba5c84bc274237885"},
{file = "ruff-0.0.253-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4548734b2671b80ee4c20aa410d7d2a5b32f087f8759d4f5991c74b8cfa51d7b"}, {file = "ruff-0.0.254-py3-none-musllinux_1_2_i686.whl", hash = "sha256:09c764bc2bd80c974f7ce1f73a46092c286085355a5711126af351b9ae4bea0c"},
{file = "ruff-0.0.253-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e2485f728f04bf3bd6142e55dd2869c769299b73a4bdbe1a795e98332df75561"}, {file = "ruff-0.0.254-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4385cdd30153b7aa1d8f75dfd1ae30d49c918ead7de07e69b7eadf0d5538a1f"},
{file = "ruff-0.0.253-py3-none-win32.whl", hash = "sha256:a66109185382375246d7b0dae2f594801fd8ceb5f8206159c55791aaec9aa4bb"}, {file = "ruff-0.0.254-py3-none-win32.whl", hash = "sha256:c38291bda4c7b40b659e8952167f386e86ec29053ad2f733968ff1d78b4c7e15"},
{file = "ruff-0.0.253-py3-none-win_amd64.whl", hash = "sha256:a64e9f97a6b0bfce924e65fa845f669c969d42c30fb61e1e4d87b2c70d835cb9"}, {file = "ruff-0.0.254-py3-none-win_amd64.whl", hash = "sha256:e15742df0f9a3615fbdc1ee9a243467e97e75bf88f86d363eee1ed42cedab1ec"},
{file = "ruff-0.0.253-py3-none-win_arm64.whl", hash = "sha256:506987ac3bc212cd74bf1ca032756e67ada93c4add3b7541e3549bbad5e0fc40"}, {file = "ruff-0.0.254-py3-none-win_arm64.whl", hash = "sha256:b435afc4d65591399eaf4b2af86e441a71563a2091c386cadf33eaa11064dc09"},
{file = "ruff-0.0.253.tar.gz", hash = "sha256:ab746c843a9673d2637bcbcb45da12ed4d44c0c90f0823484d6dcb660118b539"}, {file = "ruff-0.0.254.tar.gz", hash = "sha256:0eb66c9520151d3bd950ea43b3a088618a8e4e10a5014a72687881e6f3606312"},
] ]
[[package]] [[package]]
@@ -1349,4 +1349,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "30cbfdd2c49ef85a89a19ff225241872fcbbc3648195075118933f4e8e8d4eb4" content-hash = "b77f1653a71eca187c8d47f8b27d2677191fe37d932fc9ae8e696c462d2ea999"

View File

@@ -44,7 +44,7 @@
poethepoet = "^0.18.1" poethepoet = "^0.18.1"
pre-commit = "^3.1.1" pre-commit = "^3.1.1"
pysnooper = "^1.1.1" pysnooper = "^1.1.1"
ruff = "^0.0.253" ruff = "^0.0.254"
typeguard = "^2.13.3" typeguard = "^2.13.3"
types-python-dateutil = "^2.8.19.10" types-python-dateutil = "^2.8.19.10"
vulture = "^2.7" vulture = "^2.7"

View File

@@ -122,9 +122,9 @@ class Config:
# Folders within the vault to ignore when indexing metadata # Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"] exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of: # Location to add new metadata. One of:
# TOP: Directly after frontmatter. # TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter. # AFTER_TITLE: After the first header following frontmatter.
# BOTTOM: The bottom of the note # BOTTOM: The bottom of the note
insert_location = "BOTTOM" insert_location = "BOTTOM"
""" """
@@ -164,6 +164,7 @@ class VaultConfig:
yield "config", self.config yield "config", self.config
yield "path", self.path yield "path", self.path
yield "exclude_paths", self.exclude_paths yield "exclude_paths", self.exclude_paths
yield "insert_location", self.insert_location
def _validate_vault_path(self, vault_path: Path | None) -> Path: def _validate_vault_path(self, vault_path: Path | None) -> Path:
"""Validate the vault path.""" """Validate the vault path."""

View File

@@ -35,7 +35,7 @@ def clear_screen() -> None: # pragma: no cover
def dict_contains( def dict_contains(
dictionary: dict[str, list[str]], key: str, value: str = None, is_regex: bool = False dictionary: dict[str, list[str]], key: str, value: str = None, is_regex: bool = False
) -> bool: ) -> bool:
"""Check if a dictionary contains a key. """Check if a dictionary contains a key or if a specified key contains a value.
Args: Args:
dictionary (dict): Dictionary to check dictionary (dict): Dictionary to check

View File

@@ -23,5 +23,5 @@ class InsertLocation(Enum):
""" """
TOP = "Top" TOP = "Top"
AFTER_TITLE = "Header" AFTER_TITLE = "After title"
BOTTOM = "Bottom" BOTTOM = "Bottom"

View File

@@ -1,4 +1,4 @@
"""Representation of notes and in the vault.""" """Representation of a not in the vault."""
import copy import copy
@@ -71,29 +71,6 @@ class Note:
yield "inline_tags", self.inline_tags yield "inline_tags", self.inline_tags
yield "inline_metadata", self.inline_metadata yield "inline_metadata", self.inline_metadata
def _delete_inline_metadata(self, key: str, value: str = None) -> None:
"""Delete an inline metadata key/value pair from the text of the note. This method does not remove the key/value from the metadata attribute of the note.
Args:
key (str): Key to delete.
value (str, optional): Value to delete.
"""
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
for _k, _v in stripped_null_values:
if re.search(key, _k):
if value is None:
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(rf"\[?{_k}:: ?{_v}]?", "", is_regex=True)
return
if re.search(value, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(rf"({_k}::) ?{_v}", r"\1", is_regex=True)
def add_metadata( # noqa: C901 def add_metadata( # noqa: C901
self, self,
area: MetadataType, area: MetadataType,
@@ -101,7 +78,7 @@ class Note:
value: str | list[str] = None, value: str | list[str] = None,
location: InsertLocation = None, location: InsertLocation = None,
) -> bool: ) -> bool:
"""Add metadata to the note if it does not already exist. """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: Args:
area (MetadataType): Area to add metadata to. area (MetadataType): Area to add metadata to.
@@ -155,7 +132,7 @@ class Note:
return False return False
def commit(self, path: Path = None) -> None: def commit(self, path: Path = None) -> None:
"""Write the note's content to disk. This is a destructive action. """Write the note's new content to disk. This is a destructive action.
Args: Args:
path (Path): Path to write the note to. Defaults to the note's path. path (Path): Path to write the note to. Defaults to the note's path.
@@ -238,9 +215,9 @@ class Note:
def delete_metadata( def delete_metadata(
self, key: str, value: str = None, area: MetadataType = MetadataType.ALL self, key: str, value: str = None, area: MetadataType = MetadataType.ALL
) -> bool: ) -> bool:
"""Delete a key or key-value pair from the note's Frontmatter or InlineMetadata. Regex is supported. """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 key. If no value is provided, will delete an entire specified key.
Args: Args:
key (str): Key to delete. key (str): Key to delete.
@@ -252,28 +229,18 @@ class Note:
""" """
changed_value: bool = False changed_value: bool = False
if value is None: if (
if ( area == MetadataType.FRONTMATTER or area == MetadataType.ALL
area == MetadataType.FRONTMATTER or area == MetadataType.ALL ) and self.frontmatter.delete(key, value):
) and self.frontmatter.delete(key): self.write_frontmatter()
self.write_frontmatter() changed_value = True
changed_value = True
if ( if (
area == MetadataType.INLINE or area == MetadataType.ALL area == MetadataType.INLINE or area == MetadataType.ALL
) and self.inline_metadata.delete(key): ) and self.inline_metadata.contains(key, value):
self._delete_inline_metadata(key, value) self.write_delete_inline_metadata(key, value)
changed_value = True self.inline_metadata.delete(key, value)
else: changed_value = True
if (
area == MetadataType.FRONTMATTER or area == MetadataType.ALL
) and self.frontmatter.delete(key, value):
self.write_frontmatter()
changed_value = True
if (
area == MetadataType.INLINE or area == MetadataType.ALL
) and self.inline_metadata.delete(key, value):
self._delete_inline_metadata(key, value)
changed_value = True
if changed_value: if changed_value:
return True return True
@@ -299,12 +266,8 @@ class Note:
return False return False
def print_note(self) -> None:
"""Print the note to the console."""
console.print(self.file_content)
def print_diff(self) -> None: def print_diff(self) -> None:
"""Print a diff of the note's original state and it's new state.""" """Print a diff of the note's content. Compares original state to it's new state."""
a = self.original_file_content.splitlines() a = self.original_file_content.splitlines()
b = self.file_content.splitlines() b = self.file_content.splitlines()
@@ -320,8 +283,12 @@ class Note:
console.print(table) console.print(table)
def print_note(self) -> None:
"""Print the note to the console."""
console.print(self.file_content)
def rename_inline_tag(self, tag_1: str, tag_2: str) -> bool: def rename_inline_tag(self, tag_1: str, tag_2: str) -> bool:
"""Rename an inline tag from the note ONLY if it's not in the metadata as well. """Rename an inline tag. Updates the Metadata object and the text of the note.
Args: Args:
tag_1 (str): Tag to rename. tag_1 (str): Tag to rename.
@@ -341,9 +308,9 @@ class Note:
return False return False
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> bool: 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. """Rename a key or key-value pair in the note's InlineMetadata and Frontmatter objects and the content of the note.
If no value is provided, will rename an entire key. If no value is provided, will rename the entire specified key.
Args: Args:
key (str): Key to rename. key (str): Key to rename.
@@ -359,14 +326,14 @@ class Note:
self.write_frontmatter() self.write_frontmatter()
changed_value = True changed_value = True
if self.inline_metadata.rename(key, value_1): if self.inline_metadata.rename(key, value_1):
self.write_metadata(key, value_1) self.write_inline_metadata_change(key, value_1)
changed_value = True changed_value = True
else: else:
if self.frontmatter.rename(key, value_1, value_2): if self.frontmatter.rename(key, value_1, value_2):
self.write_frontmatter() self.write_frontmatter()
changed_value = True changed_value = True
if self.inline_metadata.rename(key, value_1, value_2): if self.inline_metadata.rename(key, value_1, value_2):
self.write_metadata(key, value_1, value_2) self.write_inline_metadata_change(key, value_1, value_2)
changed_value = True changed_value = True
if changed_value: if changed_value:
@@ -395,12 +362,15 @@ class Note:
value: str | list[str] = None, value: str | list[str] = None,
location: InsertLocation = InsertLocation.BOTTOM, location: InsertLocation = InsertLocation.BOTTOM,
) -> bool: ) -> bool:
"""Transpose metadata from one type to another. """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.
Args: Args:
begin (MetadataType): The type of metadata to transpose from. begin (MetadataType): The type of metadata to transpose from.
end (MetadataType): The type of metadata to transpose to. end (MetadataType): The type of metadata to transpose to.
key (str, optional): The key to transpose. Defaults to None. 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 | list[str], optional): The value to transpose. Defaults to None.
Returns: Returns:
@@ -466,8 +436,40 @@ class Note:
return False return False
def write_frontmatter(self, sort_keys: bool = False) -> None: def write_delete_inline_metadata(self, key: str = None, value: str = None) -> bool:
"""Replace the frontmatter in the note with the current frontmatter object.""" """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.
IMPORTANT: This method makes no changes to the InlineMetadata object.
Args:
key (str, optional): Key to delete.
value (str, optional): Value to delete.
Returns:
bool: Whether the note was updated.
"""
for _k, _v in self.inline_metadata.dict.items():
if re.search(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)
return True
if re.search(value, _value):
_k = re.escape(_k)
_value = re.escape(_value)
self.sub(rf"({_k}::) ?{_value}", r"\1", is_regex=True)
return True
return False
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.
Returns:
bool: Whether the note was updated.
"""
try: try:
current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group( current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group(
"frontmatter" "frontmatter"
@@ -476,19 +478,43 @@ class Note:
current_frontmatter = None current_frontmatter = None
if current_frontmatter is None and self.frontmatter.dict == {}: if current_frontmatter is None and self.frontmatter.dict == {}:
return return False
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys) new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = "" if self.frontmatter.dict == {} else f"---\n{new_frontmatter}---\n" new_frontmatter = "" if self.frontmatter.dict == {} else f"---\n{new_frontmatter}---\n"
if current_frontmatter is None: if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content self.file_content = new_frontmatter + self.file_content
return return True
current_frontmatter = f"{re.escape(current_frontmatter)}\n?" current_frontmatter = f"{re.escape(current_frontmatter)}\n?"
self.sub(current_frontmatter, new_frontmatter, is_regex=True) self.sub(current_frontmatter, new_frontmatter, is_regex=True)
return True
def write_metadata(self, key: str, value_1: str, value_2: str = None) -> None: 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. """Write changes to a specific inline metadata key or value.
Args: Args:
@@ -503,9 +529,9 @@ class Note:
for _k, _v in stripped_null_values: for _k, _v in stripped_null_values:
if re.search(key, _k): if re.search(key, _k):
if value_2 is None: if value_2 is None:
if re.search(rf"{key}[^\w\d_-]+", _k): if re.search(rf"{key}[^\\w\\d_-]+", _k):
key_text = re.split(r"[^\w\d_-]+$", _k)[0] key_text = re.split(r"[^\\w\\d_-]+$", _k)[0]
key_markdown = re.split(r"^[\w\d_-]+", _k)[1] key_markdown = re.split(r"^[\\w\\d_-]+", _k)[1]
self.sub( self.sub(
rf"{key_text}{key_markdown}::", rf"{key_text}{key_markdown}::",
rf"{value_1}{key_markdown}::", rf"{value_1}{key_markdown}::",
@@ -522,20 +548,24 @@ class Note:
new_string: str, new_string: str,
location: InsertLocation, location: InsertLocation,
allow_multiple: bool = False, allow_multiple: bool = False,
) -> None: ) -> bool:
"""Insert a string into the note at a requested location. """Insert a string into the note at a requested location.
Args: Args:
new_string (str): String to insert at the top of the note. new_string (str): String to insert at the top of the note.
allow_multiple (bool): Whether to allow inserting the string if it already exists in the note. allow_multiple (bool): Whether to allow inserting the string if it already exists in the note.
location (InsertLocation): Location to insert the string. location (InsertLocation): Location to insert the string.
Returns:
bool: Whether the note was updated.
""" """
if not allow_multiple and len(re.findall(re.escape(new_string), self.file_content)) > 0: if not allow_multiple and len(re.findall(re.escape(new_string), self.file_content)) > 0:
return return False
match location: match location:
case InsertLocation.BOTTOM: case InsertLocation.BOTTOM:
self.file_content += f"\n{new_string}" self.file_content += f"\n{new_string}"
return True
case InsertLocation.TOP: case InsertLocation.TOP:
try: try:
top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter") top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter")
@@ -544,10 +574,12 @@ class Note:
if top == "": if top == "":
self.file_content = f"{new_string}\n{self.file_content}" self.file_content = f"{new_string}\n{self.file_content}"
else: return True
new_string = f"{top}\n{new_string}"
top = re.escape(top) new_string = f"{top}\n{new_string}"
self.sub(top, new_string, is_regex=True) top = re.escape(top)
self.sub(top, new_string, is_regex=True)
return True
case InsertLocation.AFTER_TITLE: case InsertLocation.AFTER_TITLE:
try: try:
top = PATTERNS.top_with_header.search(self.file_content).group("top") top = PATTERNS.top_with_header.search(self.file_content).group("top")
@@ -556,10 +588,11 @@ class Note:
if top == "": if top == "":
self.file_content = f"{new_string}\n{self.file_content}" self.file_content = f"{new_string}\n{self.file_content}"
else: return True
new_string = f"{top}\n{new_string}"
top = re.escape(top) new_string = f"{top}\n{new_string}"
self.sub(top, new_string, is_regex=True) top = re.escape(top)
case _: self.sub(top, new_string, is_regex=True)
return True
case _: # pragma: no cover
raise ValueError(f"Invalid location: {location}") raise ValueError(f"Invalid location: {location}")
pass

View File

@@ -82,6 +82,7 @@ class Vault:
yield "num_notes", len(self.all_notes) yield "num_notes", len(self.all_notes)
yield "num_notes_in_scope", len(self.notes_in_scope) yield "num_notes_in_scope", len(self.notes_in_scope)
yield "exclude_paths", self.exclude_paths yield "exclude_paths", self.exclude_paths
yield "insert_location", self.insert_location
def _filter_notes(self) -> list[Note]: def _filter_notes(self) -> list[Note]:
"""Filter notes by path and metadata using the filters defined in self.filters. """Filter notes by path and metadata using the filters defined in self.filters.
@@ -122,7 +123,7 @@ class Vault:
if self.config["insert_location"].upper() == "TOP": if self.config["insert_location"].upper() == "TOP":
return InsertLocation.TOP return InsertLocation.TOP
if self.config["insert_location"].upper() == "HEADER": if self.config["insert_location"].upper() == "AFTER_TITLE":
return InsertLocation.AFTER_TITLE return InsertLocation.AFTER_TITLE
if self.config["insert_location"].upper() == "BOTTOM": if self.config["insert_location"].upper() == "BOTTOM":
@@ -374,6 +375,7 @@ class Vault:
table.add_row("Notes excluded from scope", str(self.num_excluded_notes())) table.add_row("Notes excluded from scope", str(self.num_excluded_notes()))
table.add_row("Active filters", str(len(self.filters))) table.add_row("Active filters", str(len(self.filters)))
table.add_row("Notes with changes", str(len(self.get_changed_notes()))) 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.print(table)

View File

@@ -245,7 +245,7 @@ def test_delete_value(test_application, mocker, capsys) -> None:
with pytest.raises(KeyError): with pytest.raises(KeyError):
app.application_main() app.application_main()
captured = remove_ansi(capsys.readouterr().out) captured = remove_ansi(capsys.readouterr().out)
assert r"SUCCESS | Deleted value ^front\w+$ from key area in 8 notes" in captured assert r"SUCCESS | Deleted value ^front\w+$ from key area in 4 notes" in captured
def test_filter_notes(test_application, mocker, capsys) -> None: def test_filter_notes(test_application, mocker, capsys) -> None:

View File

@@ -108,9 +108,9 @@ def test_no_config_no_vault(tmp_path, mocker) -> None:
# Folders within the vault to ignore when indexing metadata # Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"] exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of: # Location to add new metadata. One of:
# TOP: Directly after frontmatter. # TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter. # AFTER_TITLE: After the first header following frontmatter.
# BOTTOM: The bottom of the note # BOTTOM: The bottom of the note
insert_location = "BOTTOM\" insert_location = "BOTTOM\"
""" """

View File

@@ -38,8 +38,14 @@ def sample_note(tmp_path) -> Path:
@pytest.fixture() @pytest.fixture()
def short_note(tmp_path) -> Path: def short_notes(tmp_path) -> Path:
"""Fixture which creates a temporary short note file.""" """Fixture which creates two temporary note files.
Yields:
Tuple[Path, Path]: Tuple of two temporary note files.
1. Very short note with frontmatter
2. Very short note without any frontmatter
"""
source_file1: Path = Path("tests/fixtures/short_textfile.md") source_file1: Path = Path("tests/fixtures/short_textfile.md")
source_file2: Path = Path("tests/fixtures/no_metadata.md") source_file2: Path = Path("tests/fixtures/no_metadata.md")
if not source_file1.exists(): if not source_file1.exists():

View File

@@ -11,7 +11,7 @@ from obsidian_metadata.models.metadata import (
InlineTags, InlineTags,
VaultMetadata, VaultMetadata,
) )
from tests.helpers import Regex from tests.helpers import Regex, remove_ansi
FILE_CONTENT: str = Path("tests/fixtures/test_vault/test1.md").read_text() FILE_CONTENT: str = Path("tests/fixtures/test_vault/test1.md").read_text()
TAG_LIST: list[str] = ["tag 1", "tag 2", "tag 3"] TAG_LIST: list[str] = ["tag 1", "tag 2", "tag 3"]
@@ -609,39 +609,39 @@ def test_vault_metadata_print(capsys) -> None:
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST) vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
vm.print_metadata(area=MetadataType.ALL) vm.print_metadata(area=MetadataType.ALL)
captured = capsys.readouterr() captured = remove_ansi(capsys.readouterr().out)
assert "All metadata" in captured.out assert "All metadata" in captured
assert "All inline tags" in captured.out assert "All inline tags" in captured
assert "┃ Keys ┃ Values ┃" in captured.out assert "┃ Keys ┃ Values ┃" in captured
assert "│ shared_key1 │ shared_key1_value │" in captured.out assert "│ shared_key1 │ shared_key1_value │" in captured
assert captured.out == Regex("#tag 1 +#tag 2") assert captured == Regex("#tag 1 +#tag 2")
vm.print_metadata(area=MetadataType.FRONTMATTER) vm.print_metadata(area=MetadataType.FRONTMATTER)
captured = capsys.readouterr() captured = remove_ansi(capsys.readouterr().out)
assert "All frontmatter" in captured.out assert "All frontmatter" in captured
assert "┃ Keys ┃ Values ┃" in captured.out assert "┃ Keys ┃ Values ┃" in captured
assert "│ shared_key1 │ shared_key1_value │" in captured.out assert "│ shared_key1 │ shared_key1_value │" in captured
assert "value1" not in captured.out assert "value1" not in captured
vm.print_metadata(area=MetadataType.INLINE) vm.print_metadata(area=MetadataType.INLINE)
captured = capsys.readouterr() captured = remove_ansi(capsys.readouterr().out)
assert "All inline" in captured.out assert "All inline" in captured
assert "┃ Keys ┃ Values ┃" in captured.out assert "┃ Keys ┃ Values ┃" in captured
assert "shared_key1" not in captured.out assert "shared_key1" not in captured
assert "│ key1 │ value1 │" in captured.out assert "│ key1 │ value1 │" in captured
vm.print_metadata(area=MetadataType.TAGS) vm.print_metadata(area=MetadataType.TAGS)
captured = capsys.readouterr() captured = remove_ansi(capsys.readouterr().out)
assert "All inline tags " in captured.out assert "All inline tags " in captured
assert "┃ Keys ┃ Values ┃" not in captured.out assert "┃ Keys ┃ Values ┃" not in captured
assert captured.out == Regex("#tag 1 +#tag 2") assert captured == Regex("#tag 1 +#tag 2")
vm.print_metadata(area=MetadataType.KEYS) vm.print_metadata(area=MetadataType.KEYS)
captured = capsys.readouterr() captured = remove_ansi(capsys.readouterr().out)
assert "All Keys " in captured.out assert "All Keys " in captured
assert "┃ Keys ┃ Values ┃" not in captured.out assert "┃ Keys ┃ Values ┃" not in captured
assert captured.out != Regex("#tag 1 +#tag 2") assert captured != Regex("#tag 1 +#tag 2")
assert captured.out == Regex("frontmatter_Key1 +frontmatter_Key2") assert captured == Regex("frontmatter_Key1 +frontmatter_Key2")
def test_vault_metadata_contains() -> None: def test_vault_metadata_contains() -> None:

File diff suppressed because it is too large Load Diff