Files
obsidian-metadata/src/obsidian_metadata/models/notes.py
2023-03-11 16:20:50 -05:00

599 lines
22 KiB
Python

"""Representation of a not in the vault."""
import copy
import difflib
import re
from pathlib import Path
import rich.repr
import typer
from rich.table import Table
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils.console import console
from obsidian_metadata.models import (
Frontmatter,
InlineMetadata,
InlineTags,
InsertLocation,
MetadataType,
Patterns,
)
PATTERNS = Patterns()
@rich.repr.auto
class Note:
"""Representation of a note in the vault.
Args:
note_path (Path): Path to the note file.
Attributes:
note_path (Path): Path to the note file.
dry_run (bool): Whether to run in dry-run mode.
file_content (str): Total contents of the note file (frontmatter and content).
frontmatter (dict): Frontmatter of the note.
inline_tags (list): List of inline tags in the note.
inline_metadata (dict): Dictionary of inline metadata in the note.
"""
def __init__(self, note_path: Path, dry_run: bool = False):
log.trace(f"Creating Note object for {note_path}")
self.note_path: Path = Path(note_path)
self.dry_run: bool = dry_run
try:
with self.note_path.open():
self.file_content: str = self.note_path.read_text()
except FileNotFoundError as e:
alerts.error(f"Note {self.note_path} not found. Exiting")
raise typer.Exit(code=1) from e
try:
self.frontmatter: Frontmatter = Frontmatter(self.file_content)
except AttributeError as e:
alerts.error(f"Note {self.note_path} has invalid frontmatter.\n{e}")
raise typer.Exit(code=1) from e
self.inline_tags: InlineTags = InlineTags(self.file_content)
self.inline_metadata: InlineMetadata = InlineMetadata(self.file_content)
self.original_file_content: str = self.file_content
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of Vault."""
yield "note_path", self.note_path
yield "dry_run", self.dry_run
yield "frontmatter", self.frontmatter
yield "inline_tags", self.inline_tags
yield "inline_metadata", self.inline_metadata
def add_metadata( # noqa: C901
self,
area: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = None,
) -> bool:
"""Add metadata to the note if it does not already exist. This method adds specified metadata to the appropriate MetadataType object AND writes the new metadata to the note's file.
Args:
area (MetadataType): Area to add metadata to.
key (str, optional): Key to add
location (InsertLocation, optional): Location to add inline metadata and tags.
value (str, optional): Value to add.
Returns:
bool: Whether the metadata was added.
"""
match area:
case MetadataType.FRONTMATTER if self.frontmatter.add(key, value):
self.write_frontmatter()
return True
case MetadataType.INLINE:
if value is None and self.inline_metadata.add(key):
line = f"{key}::"
self.write_string(new_string=line, location=location)
return True
new_values = []
if isinstance(value, list):
new_values = [_v for _v in value if self.inline_metadata.add(key, _v)]
elif self.inline_metadata.add(key, value):
new_values = [value]
if new_values:
for value in new_values:
self.write_string(new_string=f"{key}:: {value}", location=location)
return True
case MetadataType.TAGS:
new_values = []
if isinstance(value, list):
new_values = [_v for _v in value if self.inline_tags.add(_v)]
elif self.inline_tags.add(value):
new_values = [value]
if new_values:
for value in new_values:
_v = value
if _v.startswith("#"):
_v = _v[1:]
self.write_string(new_string=f"#{_v}", location=location)
return True
case _:
return False
return False
def commit(self, path: Path = None) -> None:
"""Write the note's new content to disk. This is a destructive action.
Args:
path (Path): Path to write the note to. Defaults to the note's path.
Raises:
typer.Exit: If the note's path is not found.
"""
p = self.note_path if path is None else path
if self.dry_run:
log.trace(f"DRY RUN: Writing note {p} to disk")
return
try:
with open(p, "w") as f:
log.trace(f"Writing note {p} to disk")
f.write(self.file_content)
except FileNotFoundError as e:
alerts.error(f"Note {p} not found. Exiting")
raise typer.Exit(code=1) from e
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
"""Check if a note contains the specified inline tag.
Args:
tag (str): Tag to check for.
is_regex (bool, optional): Whether to use regex to match the tag.
Returns:
bool: Whether the note has inline tags.
"""
return self.inline_tags.contains(tag, is_regex=is_regex)
def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a note has a key or a key-value pair in its Frontmatter or InlineMetadata.
Args:
key (str): Key to check for.
value (str, optional): Value to check for.
is_regex (bool, optional): Whether to use regex to match the key/value.
Returns:
bool: Whether the note contains the key or key-value pair.
"""
if value is None:
if self.frontmatter.contains(key, is_regex=is_regex) or self.inline_metadata.contains(
key, is_regex=is_regex
):
return True
return False
if self.frontmatter.contains(
key, value, is_regex=is_regex
) or self.inline_metadata.contains(key, value, is_regex=is_regex):
return True
return False
def delete_inline_tag(self, tag: str) -> bool:
"""Delete an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
Args:
tag (str): Tag to delete.
Returns:
bool: Whether the tag was deleted.
"""
new_list = self.inline_tags.list.copy()
for _t in new_list:
if re.search(tag, _t):
_t = re.escape(_t)
self.sub(rf"#{_t}([ \|,;:\*\(\)\[\]\\\.\n#&])", r"\1", is_regex=True)
self.inline_tags.delete(tag)
if new_list != self.inline_tags.list:
return True
return False
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 object and the content of the note. Regex is supported.
If no value is provided, will delete an entire specified key.
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.
"""
changed_value: bool = False
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.contains(key, value):
self.write_delete_inline_metadata(key, value)
self.inline_metadata.delete(key, value)
changed_value = True
if changed_value:
return True
return False
def has_changes(self) -> bool:
"""Check if the note has been updated.
Returns:
bool: Whether the note has been updated.
"""
if self.frontmatter.has_changes():
return True
if self.inline_tags.has_changes():
return True
if self.inline_metadata.has_changes():
return True
if self.file_content != self.original_file_content:
return True
return False
def print_diff(self) -> None:
"""Print a diff of the note's content. Compares original state to it's new state."""
a = self.original_file_content.splitlines()
b = self.file_content.splitlines()
diff = difflib.Differ()
result = list(diff.compare(a, b))
table = Table(title=f"\nDiff of {self.note_path.name}", show_header=False, min_width=50)
for line in result:
if line.startswith("+"):
table.add_row(line, style="green")
elif line.startswith("-"):
table.add_row(line, style="red")
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:
"""Rename an inline tag. Updates the Metadata object and the text of the note.
Args:
tag_1 (str): Tag to rename.
tag_2 (str): New tag name.
Returns:
bool: Whether the tag was renamed.
"""
if tag_1 in self.inline_tags.list:
self.sub(
rf"#{tag_1}([ \|,;:\*\(\)\[\]\\\.\n#&])",
rf"#{tag_2}\1",
is_regex=True,
)
self.inline_tags.rename(tag_1, tag_2)
return True
return False
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Rename a key or key-value pair in the note's InlineMetadata and Frontmatter objects and the content of the note.
If no value is provided, will rename the entire specified key.
Args:
key (str): Key to rename.
value_1 (str): Value to rename or new name of key if no value_2 is provided.
value_2 (str, optional): New value.
Returns:
bool: Whether the note was updated.
"""
changed_value: bool = False
if value_2 is None:
if self.frontmatter.rename(key, value_1):
self.write_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1):
self.write_inline_metadata_change(key, value_1)
changed_value = True
else:
if self.frontmatter.rename(key, value_1, value_2):
self.write_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1, value_2):
self.write_inline_metadata_change(key, value_1, value_2)
changed_value = True
if changed_value:
return True
return False
def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> None:
"""Substitutes text within the note.
Args:
pattern (str): The pattern to replace (plain text or regular expression).
replacement (str): What to replace the pattern with.
is_regex (bool): Whether the pattern is a regex pattern or plain text.
"""
if not is_regex:
pattern = re.escape(pattern)
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
def transpose_metadata( # noqa: C901, PLR0912, PLR0911, PLR0913
self,
begin: MetadataType,
end: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = InsertLocation.BOTTOM,
) -> bool:
"""Move metadata from one metadata object to another. i.e. Frontmatter to InlineMetadata or vice versa.
If no key is specified, will transpose all metadata. If a key is specified, but no value, the entire key will be transposed. if a specific value is specified, just that value will be transposed.
Args:
begin (MetadataType): The type of metadata to transpose from.
end (MetadataType): The type of metadata to transpose to.
key (str, optional): The key to transpose. Defaults to None.
location (InsertLocation, optional): Where to insert the metadata. Defaults to InsertLocation.BOTTOM.
value (str | list[str], optional): The value to transpose. Defaults to None.
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
return False
if isinstance(value, list):
for value_item in value:
if value_item in v:
self.add_metadata(
key=k, value=value_item, area=end, location=location
)
self.delete_metadata(key=k, value=value_item, area=begin)
temp_dict[k].remove(value_item)
has_changes = True
if temp_dict[k] == []:
self.delete_metadata(key=k, area=begin)
return bool(has_changes)
if begin == MetadataType.TAGS:
# TODO: Implement transposing to and from tags
pass
return False
def write_delete_inline_metadata(self, key: str = None, value: str = None) -> bool:
"""For a given inline metadata key and/or key-value pair, delete it from the text of the note. If no key is provided, will delete all inline metadata from the text of the note.
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:
current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group(
"frontmatter"
)
except AttributeError:
current_frontmatter = None
if current_frontmatter is None and self.frontmatter.dict == {}:
return False
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = "" if self.frontmatter.dict == {} else f"---\n{new_frontmatter}---\n"
if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content
return True
current_frontmatter = f"{re.escape(current_frontmatter)}\n?"
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
return True
def write_all_inline_metadata(
self,
location: InsertLocation,
) -> bool:
"""Write all metadata found in the InlineMetadata object to the note at a specified insert location.
Args:
location (InsertLocation): Where to insert the metadata.
Returns:
bool: Whether the note was updated.
"""
if self.inline_metadata.dict != {}:
string = ""
for k, v in sorted(self.inline_metadata.dict.items()):
for value in v:
string += f"{k}:: {value}\n"
if self.write_string(new_string=string, location=location, allow_multiple=True):
return True
return False
def write_inline_metadata_change(self, key: str, value_1: str, value_2: str = None) -> None:
"""Write changes to a specific inline metadata key or value.
Args:
key (str): Key to rename.
value_1 (str): Value to replace OR new key name (if value_2 is None).
value_2 (str, optional): New value.
"""
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_2 is None:
if re.search(rf"{key}[^\\w\\d_-]+", _k):
key_text = re.split(r"[^\\w\\d_-]+$", _k)[0]
key_markdown = re.split(r"^[\\w\\d_-]+", _k)[1]
self.sub(
rf"{key_text}{key_markdown}::",
rf"{value_1}{key_markdown}::",
)
else:
self.sub(f"{_k}::", f"{value_1}::")
elif re.search(key, _k) and re.search(value_1, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
def write_string(
self,
new_string: str,
location: InsertLocation,
allow_multiple: bool = False,
) -> bool:
"""Insert a string into the note at a requested location.
Args:
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.
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:
return False
match location:
case InsertLocation.BOTTOM:
self.file_content += f"\n{new_string}"
return True
case InsertLocation.TOP:
try:
top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter")
except AttributeError:
top = ""
if top == "":
self.file_content = f"{new_string}\n{self.file_content}"
return True
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
return True
case InsertLocation.AFTER_TITLE:
try:
top = PATTERNS.top_with_header.search(self.file_content).group("top")
except AttributeError:
top = ""
if top == "":
self.file_content = f"{new_string}\n{self.file_content}"
return True
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
return True
case _: # pragma: no cover
raise ValueError(f"Invalid location: {location}")