feat: select insert location for new inline metadata

This commit is contained in:
Nathaniel Landau
2023-03-09 21:47:36 -05:00
parent b6a3d115fd
commit 1eb2d30d47
4 changed files with 166 additions and 134 deletions

View File

@@ -94,35 +94,6 @@ class Note:
_v = re.escape(_v) _v = re.escape(_v)
self.sub(rf"({_k}::) ?{_v}", r"\1", is_regex=True) self.sub(rf"({_k}::) ?{_v}", r"\1", is_regex=True)
def _rename_inline_metadata(self, key: str, value_1: str, value_2: str = None) -> None:
"""Replace 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}::")
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 add_metadata( # noqa: C901 def add_metadata( # noqa: C901
self, self,
area: MetadataType, area: MetadataType,
@@ -143,13 +114,13 @@ class Note:
""" """
match area: match area:
case MetadataType.FRONTMATTER if self.frontmatter.add(key, value): case MetadataType.FRONTMATTER if self.frontmatter.add(key, value):
self.update_frontmatter() self.write_frontmatter()
return True return True
case MetadataType.INLINE: case MetadataType.INLINE:
if value is None and self.inline_metadata.add(key): if value is None and self.inline_metadata.add(key):
line = f"{key}::" line = f"{key}::"
self.insert(new_string=line, location=location) self.write_string(new_string=line, location=location)
return True return True
new_values = [] new_values = []
@@ -160,7 +131,7 @@ class Note:
if new_values: if new_values:
for value in new_values: for value in new_values:
self.insert(new_string=f"{key}:: {value}", location=location) self.write_string(new_string=f"{key}:: {value}", location=location)
return True return True
case MetadataType.TAGS: case MetadataType.TAGS:
@@ -175,7 +146,7 @@ class Note:
_v = value _v = value
if _v.startswith("#"): if _v.startswith("#"):
_v = _v[1:] _v = _v[1:]
self.insert(new_string=f"#{_v}", location=location) self.write_string(new_string=f"#{_v}", location=location)
return True return True
case _: case _:
@@ -183,6 +154,28 @@ class Note:
return False return False
def commit(self, path: Path = None) -> None:
"""Write the note's 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: def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
"""Check if a note contains the specified inline tag. """Check if a note contains the specified inline tag.
@@ -196,7 +189,7 @@ class Note:
return self.inline_tags.contains(tag, is_regex=is_regex) return self.inline_tags.contains(tag, is_regex=is_regex)
def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool: 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. """Check if a note has a key or a key-value pair in its Frontmatter or InlineMetadata.
Args: Args:
key (str): Key to check for. key (str): Key to check for.
@@ -245,7 +238,7 @@ 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 metadata. Regex is supported. """Delete a key or key-value pair from the note's Frontmatter or InlineMetadata. Regex is supported.
If no value is provided, will delete an entire key. If no value is provided, will delete an entire key.
@@ -263,7 +256,7 @@ class Note:
if ( if (
area == MetadataType.FRONTMATTER or area == MetadataType.ALL area == MetadataType.FRONTMATTER or area == MetadataType.ALL
) and self.frontmatter.delete(key): ) and self.frontmatter.delete(key):
self.update_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
@@ -274,7 +267,7 @@ class Note:
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, value):
self.update_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
@@ -306,53 +299,6 @@ class Note:
return False return False
def insert(
self,
new_string: str,
location: InsertLocation,
allow_multiple: bool = False,
) -> None:
"""Insert a string at the top of a note.
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.
"""
if not allow_multiple and len(re.findall(re.escape(new_string), self.file_content)) > 0:
return
match location:
case InsertLocation.BOTTOM:
self.file_content += f"\n{new_string}"
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}"
else:
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=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}"
else:
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
case _:
raise ValueError(f"Invalid location: {location}")
pass
def print_note(self) -> None: def print_note(self) -> None:
"""Print the note to the console.""" """Print the note to the console."""
console.print(self.file_content) console.print(self.file_content)
@@ -395,7 +341,7 @@ 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 metadata. """Rename a key or key-value pair in the note's InlineMetadata and Frontmatter objects.
If no value is provided, will rename an entire key. If no value is provided, will rename an entire key.
@@ -410,17 +356,17 @@ class Note:
changed_value: bool = False changed_value: bool = False
if value_2 is None: if value_2 is None:
if self.frontmatter.rename(key, value_1): if self.frontmatter.rename(key, value_1):
self.update_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._rename_inline_metadata(key, value_1) self.write_metadata(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.update_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._rename_inline_metadata(key, value_1, value_2) self.write_metadata(key, value_1, value_2)
changed_value = True changed_value = True
if changed_value: if changed_value:
@@ -520,7 +466,7 @@ class Note:
return False return False
def update_frontmatter(self, sort_keys: bool = False) -> None: def write_frontmatter(self, sort_keys: bool = False) -> None:
"""Replace the frontmatter in the note with the current frontmatter object.""" """Replace the frontmatter in the note with the current frontmatter object."""
try: try:
current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group( current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group(
@@ -542,24 +488,78 @@ class Note:
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)
def write(self, path: Path = None) -> None: def write_metadata(self, key: str, value_1: str, value_2: str = None) -> None:
"""Write the note's content to disk. """Write changes to a specific inline metadata key or value.
Args: Args:
path (Path): Path to write the note to. Defaults to the note's path. 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.
Raises:
typer.Exit: If the note's path is not found.
""" """
p = self.note_path if path is None else path all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
if self.dry_run: stripped_null_values = [tuple(filter(None, x)) for x in all_results]
log.trace(f"DRY RUN: Writing note {p} to disk")
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,
) -> None:
"""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.
"""
if not allow_multiple and len(re.findall(re.escape(new_string), self.file_content)) > 0:
return return
match location:
case InsertLocation.BOTTOM:
self.file_content += f"\n{new_string}"
case InsertLocation.TOP:
try: try:
with open(p, "w") as f: top = PATTERNS.frontmatter_block.search(self.file_content).group("frontmatter")
log.trace(f"Writing note {p} to disk") except AttributeError:
f.write(self.file_content) top = ""
except FileNotFoundError as e:
alerts.error(f"Note {p} not found. Exiting") if top == "":
raise typer.Exit(code=1) from e self.file_content = f"{new_string}\n{self.file_content}"
else:
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=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}"
else:
new_string = f"{top}\n{new_string}"
top = re.escape(top)
self.sub(top, new_string, is_regex=True)
case _:
raise ValueError(f"Invalid location: {location}")
pass

View File

@@ -114,7 +114,7 @@ class Vault:
return notes_list return notes_list
def _find_insert_location(self) -> InsertLocation: def _find_insert_location(self) -> InsertLocation:
"""Find the insert location for a note. """Find the insert location for a note from the configuration file.
Returns: Returns:
InsertLocation: Insert location for the note. InsertLocation: Insert location for the note.
@@ -130,6 +130,24 @@ class Vault:
return InsertLocation.BOTTOM return InsertLocation.BOTTOM
@property
def insert_location(self) -> InsertLocation:
"""Location to insert new or reorganized metadata.
Returns:
InsertLocation: The insert location.
"""
return self._insert_location
@insert_location.setter
def insert_location(self, value: InsertLocation) -> None:
"""Set the insert location for the vault.
Args:
value (InsertLocation): The insert location to set.
"""
self._insert_location = value
def _find_markdown_notes(self) -> list[Path]: def _find_markdown_notes(self) -> list[Path]:
"""Build list of all markdown files in the vault. """Build list of all markdown files in the vault.
@@ -234,7 +252,7 @@ class Vault:
for _note in self.notes_in_scope: for _note in self.notes_in_scope:
if _note.has_changes(): if _note.has_changes():
log.trace(f"writing to {_note.note_path}") log.trace(f"writing to {_note.note_path}")
_note.write() _note.commit()
def delete_backup(self) -> None: def delete_backup(self) -> None:
"""Delete the vault backup.""" """Delete the vault backup."""

View File

@@ -297,7 +297,7 @@ def test_has_changes(sample_note) -> None:
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
assert note.has_changes() is False assert note.has_changes() is False
note.insert("This is a test string.", location=InsertLocation.BOTTOM) note.write_string("This is a test string.", location=InsertLocation.BOTTOM)
assert note.has_changes() is True assert note.has_changes() is True
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
@@ -316,7 +316,7 @@ def test_has_changes(sample_note) -> None:
assert note.has_changes() is True assert note.has_changes() is True
def test_insert_bottom(short_note) -> None: def test_write_string_bottom(short_note) -> None:
"""Test inserting metadata to bottom of note.""" """Test inserting metadata to bottom of note."""
path1, path2 = short_note path1, path2 = short_note
note = Note(note_path=str(path1)) note = Note(note_path=str(path1))
@@ -353,20 +353,20 @@ Lorem ipsum dolor sit amet.
This is a test string. This is a test string.
""" """
note.insert(new_string=string1, location=InsertLocation.BOTTOM) note.write_string(new_string=string1, location=InsertLocation.BOTTOM)
assert note.file_content == correct_content.strip() assert note.file_content == correct_content.strip()
note.insert(new_string=string2, location=InsertLocation.BOTTOM) note.write_string(new_string=string2, location=InsertLocation.BOTTOM)
assert note.file_content == correct_content.strip() assert note.file_content == correct_content.strip()
note.insert(new_string=string2, allow_multiple=True, location=InsertLocation.BOTTOM) note.write_string(new_string=string2, allow_multiple=True, location=InsertLocation.BOTTOM)
assert note.file_content == correct_content2.strip() assert note.file_content == correct_content2.strip()
note2.insert(new_string=string1, location=InsertLocation.BOTTOM) note2.write_string(new_string=string1, location=InsertLocation.BOTTOM)
assert note2.file_content == correct_content3.strip() assert note2.file_content == correct_content3.strip()
def test_insert_after_frontmatter(short_note) -> None: def test_write_string_after_frontmatter(short_note) -> None:
"""Test inserting metadata to bottom of note.""" """Test inserting metadata to bottom of note."""
path1, path2 = short_note path1, path2 = short_note
note = Note(note_path=path1) note = Note(note_path=path1)
@@ -401,17 +401,17 @@ This is a test string.
Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet.
""" """
note.insert(new_string=string1, location=InsertLocation.TOP) note.write_string(new_string=string1, location=InsertLocation.TOP)
assert note.file_content.strip() == correct_content.strip() assert note.file_content.strip() == correct_content.strip()
note.insert(new_string=string2, allow_multiple=True, location=InsertLocation.TOP) note.write_string(new_string=string2, allow_multiple=True, location=InsertLocation.TOP)
assert note.file_content.strip() == correct_content2.strip() assert note.file_content.strip() == correct_content2.strip()
note2.insert(new_string=string1, location=InsertLocation.TOP) note2.write_string(new_string=string1, location=InsertLocation.TOP)
assert note2.file_content.strip() == correct_content3.strip() assert note2.file_content.strip() == correct_content3.strip()
def test_insert_after_title(short_note) -> None: def test_write_string_after_title(short_note) -> None:
"""Test inserting metadata to bottom of note.""" """Test inserting metadata to bottom of note."""
path1, path2 = short_note path1, path2 = short_note
note = Note(note_path=path1) note = Note(note_path=path1)
@@ -446,13 +446,13 @@ This is a test string.
Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet.
""" """
note.insert(new_string=string1, location=InsertLocation.AFTER_TITLE) note.write_string(new_string=string1, location=InsertLocation.AFTER_TITLE)
assert note.file_content.strip() == correct_content.strip() assert note.file_content.strip() == correct_content.strip()
note.insert(new_string=string2, allow_multiple=True, location=InsertLocation.AFTER_TITLE) note.write_string(new_string=string2, allow_multiple=True, location=InsertLocation.AFTER_TITLE)
assert note.file_content.strip() == correct_content2.strip() assert note.file_content.strip() == correct_content2.strip()
note2.insert(new_string=string1, location=InsertLocation.AFTER_TITLE) note2.write_string(new_string=string1, location=InsertLocation.AFTER_TITLE)
assert note2.file_content.strip() == correct_content3.strip() assert note2.file_content.strip() == correct_content3.strip()
@@ -470,7 +470,7 @@ def test_print_diff(sample_note, capsys) -> None:
"""Test printing diff.""" """Test printing diff."""
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
note.insert("This is a test string.", location=InsertLocation.BOTTOM) note.write_string("This is a test string.", location=InsertLocation.BOTTOM)
note.print_diff() note.print_diff()
captured = capsys.readouterr() captured = capsys.readouterr()
assert "+ This is a test string." in captured.out assert "+ This is a test string." in captured.out
@@ -513,20 +513,20 @@ def test_rename_inline_tag(sample_note) -> None:
assert note.file_content != Regex(r"#intext_tag1") assert note.file_content != Regex(r"#intext_tag1")
def test_rename_inline_metadata(sample_note) -> None: def test_write_metadata(sample_note) -> None:
"""Test renaming inline metadata.""" """Test renaming inline metadata."""
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
note._rename_inline_metadata("nonexistent_key", "new_key") note.write_metadata("nonexistent_key", "new_key")
assert note.file_content == note.original_file_content assert note.file_content == note.original_file_content
note._rename_inline_metadata("bottom_key1", "no_value", "new_value") note.write_metadata("bottom_key1", "no_value", "new_value")
assert note.file_content == note.original_file_content assert note.file_content == note.original_file_content
note._rename_inline_metadata("bottom_key1", "new_key") note.write_metadata("bottom_key1", "new_key")
assert note.file_content != Regex(r"bottom_key1::") assert note.file_content != Regex(r"bottom_key1::")
assert note.file_content == Regex(r"new_key::") assert note.file_content == Regex(r"new_key::")
note._rename_inline_metadata("key📅", "📅_key_value", "new_value") note.write_metadata("key📅", "📅_key_value", "new_value")
assert note.file_content != Regex(r"key📅:: ?📅_key_value") assert note.file_content != Regex(r"key📅:: ?📅_key_value")
assert note.file_content == Regex(r"key📅:: ?new_value") assert note.file_content == Regex(r"key📅:: ?new_value")
@@ -804,12 +804,12 @@ def test_transpose_frontmatter(sample_note) -> None:
} }
def test_update_frontmatter(sample_note) -> None: def test_write_frontmatter(sample_note) -> None:
"""Test replacing frontmatter.""" """Test replacing frontmatter."""
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
note.rename_metadata("frontmatter_Key1", "author name", "some_new_key_here") note.rename_metadata("frontmatter_Key1", "author name", "some_new_key_here")
note.update_frontmatter() note.write_frontmatter()
new_frontmatter = """--- new_frontmatter = """---
date_created: '2022-12-22' date_created: '2022-12-22'
tags: tags:
@@ -831,9 +831,9 @@ shared_key2: shared_key2_value1
assert "```python" in note.file_content assert "```python" in note.file_content
note2 = Note(note_path="tests/fixtures/test_vault/no_metadata.md") note2 = Note(note_path="tests/fixtures/test_vault/no_metadata.md")
note2.update_frontmatter() note2.write_frontmatter()
note2.frontmatter.dict = {"key1": "value1", "key2": "value2"} note2.frontmatter.dict = {"key1": "value1", "key2": "value2"}
note2.update_frontmatter() note2.write_frontmatter()
new_frontmatter = """--- new_frontmatter = """---
key1: value1 key1: value1
key2: value2 key2: value2
@@ -842,18 +842,18 @@ key2: value2
assert "Lorem ipsum dolor sit amet" in note2.file_content assert "Lorem ipsum dolor sit amet" in note2.file_content
def test_write(sample_note, tmp_path) -> None: def test_commit(sample_note, tmp_path) -> None:
"""Test writing note to file.""" """Test writing note to file."""
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
note.sub(pattern="Heading 1", replacement="Heading 2") note.sub(pattern="Heading 1", replacement="Heading 2")
note.write() note.commit()
note = Note(note_path=sample_note) note = Note(note_path=sample_note)
assert "Heading 2" in note.file_content assert "Heading 2" in note.file_content
assert "Heading 1" not in note.file_content assert "Heading 1" not in note.file_content
new_path = Path(tmp_path / "new_note.md") new_path = Path(tmp_path / "new_note.md")
note.write(new_path) note.commit(new_path)
note2 = Note(note_path=new_path) note2 = Note(note_path=new_path)
assert "Heading 2" in note2.file_content assert "Heading 2" in note2.file_content
assert "Heading 1" not in note2.file_content assert "Heading 1" not in note2.file_content

View File

@@ -95,6 +95,20 @@ def test_vault_creation(test_vault):
} }
def set_insert_location(test_vault):
"""Test setting a new insert location."""
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.name == "vault"
assert vault.vault_path == vault_path
assert vault.insert_location == InsertLocation.BOTTOM
vault.insert_location = InsertLocation.TOP
assert vault.insert_location == InsertLocation.TOP
def test_add_metadata(test_vault) -> None: def test_add_metadata(test_vault) -> None:
"""Test adding metadata to the vault.""" """Test adding metadata to the vault."""
vault_path = test_vault vault_path = test_vault