mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-18 09:53:40 -05:00
feat: transpose metadata (#18)
* feat: transpose between frontmatter and inline metadata * ci: improve codecode patch thresholds * test: remove ansi escape sequences from `capsys.errout` * test: improve fixture for shared keys * build(deps): update dependencies * refactor: use deepcopy * docs: add transpose metadata
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user