mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-13 23:43:47 -05:00
feat: initial application release
This commit is contained in:
367
src/obsidian_metadata/models/notes.py
Normal file
367
src/obsidian_metadata/models/notes.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""Representation of notes and in the vault."""
|
||||
|
||||
|
||||
import difflib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import rich.repr
|
||||
import typer
|
||||
from rich import print
|
||||
|
||||
from obsidian_metadata._utils import alerts
|
||||
from obsidian_metadata._utils.alerts import logger as log
|
||||
from obsidian_metadata.models import (
|
||||
Frontmatter,
|
||||
InlineMetadata,
|
||||
InlineTags,
|
||||
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
|
||||
|
||||
self.frontmatter: Frontmatter = Frontmatter(self.file_content)
|
||||
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 append(self, string_to_append: str, allow_multiple: bool = False) -> None:
|
||||
"""Appends a string to the end of a note.
|
||||
|
||||
Args:
|
||||
string_to_append (str): String to append to the note.
|
||||
allow_multiple (bool): Whether to allow appending the string if it already exists in the note.
|
||||
"""
|
||||
if allow_multiple:
|
||||
self.file_content += f"\n{string_to_append}"
|
||||
else:
|
||||
if len(re.findall(re.escape(string_to_append), self.file_content)) == 0:
|
||||
self.file_content += f"\n{string_to_append}"
|
||||
|
||||
def commit_changes(self) -> None:
|
||||
"""Commits changes to the note to disk."""
|
||||
# TODO: rewrite frontmatter if it has changed
|
||||
pass
|
||||
|
||||
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 metadata.
|
||||
|
||||
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_metadata(self, key: str, value: str = None) -> None:
|
||||
"""Deletes 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 delete_inline_tag(self, tag: str) -> bool:
|
||||
"""Deletes 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) -> bool:
|
||||
"""Deletes a key or key-value pair from the note's metadata. Regex is supported.
|
||||
|
||||
If no value is provided, will delete an entire key.
|
||||
|
||||
Args:
|
||||
key (str): Key to delete.
|
||||
value (str, optional): Value to delete.
|
||||
|
||||
Returns:
|
||||
bool: Whether the key or key-value pair was deleted.
|
||||
"""
|
||||
changed_value: bool = False
|
||||
|
||||
if value is None:
|
||||
if self.frontmatter.delete(key):
|
||||
self.replace_frontmatter()
|
||||
changed_value = True
|
||||
if self.inline_metadata.delete(key):
|
||||
self._delete_inline_metadata(key, value)
|
||||
changed_value = True
|
||||
else:
|
||||
if self.frontmatter.delete(key, value):
|
||||
self.replace_frontmatter()
|
||||
changed_value = True
|
||||
if self.inline_metadata.delete(key, value):
|
||||
self._delete_inline_metadata(key, value)
|
||||
changed_value = True
|
||||
|
||||
if changed_value:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_changes(self) -> bool:
|
||||
"""Checks 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_note(self) -> None:
|
||||
"""Prints the note to the console."""
|
||||
print(self.file_content)
|
||||
|
||||
def print_diff(self) -> None:
|
||||
"""Prints a diff of the note's original state and it's new state."""
|
||||
a = self.original_file_content.splitlines()
|
||||
b = self.file_content.splitlines()
|
||||
|
||||
diff = difflib.Differ()
|
||||
result = list(diff.compare(a, b))
|
||||
for line in result:
|
||||
if line.startswith("+"):
|
||||
print(f"[green]{line}[/]")
|
||||
elif line.startswith("-"):
|
||||
print(f"[red]{line}[/]")
|
||||
|
||||
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 _rename_inline_metadata(self, key: str, value_1: str, value_2: str = None) -> None:
|
||||
"""Replaces the inline metadata in the note with the current inline metadata object.
|
||||
|
||||
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}::")
|
||||
else:
|
||||
if 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 rename_inline_tag(self, tag_1: str, tag_2: str) -> bool:
|
||||
"""Renames an inline tag from the note ONLY if it's not in the metadata as well.
|
||||
|
||||
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:
|
||||
"""Renames a key or key-value pair in the note's metadata.
|
||||
|
||||
If no value is provided, will rename an entire 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.replace_frontmatter()
|
||||
changed_value = True
|
||||
if self.inline_metadata.rename(key, value_1):
|
||||
self._rename_inline_metadata(key, value_1)
|
||||
changed_value = True
|
||||
else:
|
||||
if self.frontmatter.rename(key, value_1, value_2):
|
||||
self.replace_frontmatter()
|
||||
changed_value = True
|
||||
if self.inline_metadata.rename(key, value_1, value_2):
|
||||
self._rename_inline_metadata(key, value_1, value_2)
|
||||
changed_value = True
|
||||
|
||||
if changed_value:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def replace_frontmatter(self, sort_keys: bool = False) -> None:
|
||||
"""Replaces the frontmatter in the note with the current frontmatter object."""
|
||||
try:
|
||||
current_frontmatter = PATTERNS.frontmatt_block_with_separators.search(
|
||||
self.file_content
|
||||
).group("frontmatter")
|
||||
except AttributeError:
|
||||
current_frontmatter = None
|
||||
|
||||
if current_frontmatter is None and self.frontmatter.dict == {}:
|
||||
return
|
||||
|
||||
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
|
||||
new_frontmatter = f"---\n{new_frontmatter}---\n"
|
||||
|
||||
if current_frontmatter is None:
|
||||
self.file_content = new_frontmatter + self.file_content
|
||||
return
|
||||
|
||||
self.sub(current_frontmatter, new_frontmatter)
|
||||
|
||||
def write(self, path: Path | None = None) -> None:
|
||||
"""Writes the note's content to disk.
|
||||
|
||||
Args:
|
||||
path (Path): Path to write the note to. Defaults to the note's path.
|
||||
"""
|
||||
p = self.note_path if path is None else path
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user