diff --git a/src/obsidian_metadata/models/notes.py b/src/obsidian_metadata/models/notes.py index c398073..9ff169e 100644 --- a/src/obsidian_metadata/models/notes.py +++ b/src/obsidian_metadata/models/notes.py @@ -94,35 +94,6 @@ class Note: _v = re.escape(_v) 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 self, area: MetadataType, @@ -143,13 +114,13 @@ class Note: """ match area: case MetadataType.FRONTMATTER if self.frontmatter.add(key, value): - self.update_frontmatter() + self.write_frontmatter() return True case MetadataType.INLINE: if value is None and self.inline_metadata.add(key): line = f"{key}::" - self.insert(new_string=line, location=location) + self.write_string(new_string=line, location=location) return True new_values = [] @@ -160,7 +131,7 @@ class Note: if 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 case MetadataType.TAGS: @@ -175,7 +146,7 @@ class Note: _v = value if _v.startswith("#"): _v = _v[1:] - self.insert(new_string=f"#{_v}", location=location) + self.write_string(new_string=f"#{_v}", location=location) return True case _: @@ -183,6 +154,28 @@ class Note: 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: """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) 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: key (str): Key to check for. @@ -245,7 +238,7 @@ class Note: 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. + """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. @@ -263,7 +256,7 @@ class Note: if ( area == MetadataType.FRONTMATTER or area == MetadataType.ALL ) and self.frontmatter.delete(key): - self.update_frontmatter() + self.write_frontmatter() changed_value = True if ( area == MetadataType.INLINE or area == MetadataType.ALL @@ -274,7 +267,7 @@ class Note: if ( area == MetadataType.FRONTMATTER or area == MetadataType.ALL ) and self.frontmatter.delete(key, value): - self.update_frontmatter() + self.write_frontmatter() changed_value = True if ( area == MetadataType.INLINE or area == MetadataType.ALL @@ -306,53 +299,6 @@ class Note: 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: """Print the note to the console.""" console.print(self.file_content) @@ -395,7 +341,7 @@ class Note: 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 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. @@ -410,17 +356,17 @@ class Note: changed_value: bool = False if value_2 is None: if self.frontmatter.rename(key, value_1): - self.update_frontmatter() + self.write_frontmatter() changed_value = True if self.inline_metadata.rename(key, value_1): - self._rename_inline_metadata(key, value_1) + self.write_metadata(key, value_1) changed_value = True else: if self.frontmatter.rename(key, value_1, value_2): - self.update_frontmatter() + self.write_frontmatter() changed_value = True 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 if changed_value: @@ -520,7 +466,7 @@ class Note: 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.""" try: current_frontmatter = PATTERNS.frontmatter_block.search(self.file_content).group( @@ -542,24 +488,78 @@ class Note: current_frontmatter = f"{re.escape(current_frontmatter)}\n?" self.sub(current_frontmatter, new_frontmatter, is_regex=True) - def write(self, path: Path = None) -> None: - """Write the note's content to disk. + def write_metadata(self, key: str, value_1: str, value_2: str = None) -> None: + """Write changes to a specific inline metadata key or value. 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 - if self.dry_run: - log.trace(f"DRY RUN: Writing note {p} to disk") + 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, + ) -> 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 - 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 + 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 diff --git a/src/obsidian_metadata/models/vault.py b/src/obsidian_metadata/models/vault.py index b63bc09..72062d6 100644 --- a/src/obsidian_metadata/models/vault.py +++ b/src/obsidian_metadata/models/vault.py @@ -114,7 +114,7 @@ class Vault: return notes_list 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: InsertLocation: Insert location for the note. @@ -130,6 +130,24 @@ class Vault: 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]: """Build list of all markdown files in the vault. @@ -234,7 +252,7 @@ class Vault: for _note in self.notes_in_scope: if _note.has_changes(): log.trace(f"writing to {_note.note_path}") - _note.write() + _note.commit() def delete_backup(self) -> None: """Delete the vault backup.""" diff --git a/tests/notes_test.py b/tests/notes_test.py index 5796fb6..c1c0679 100644 --- a/tests/notes_test.py +++ b/tests/notes_test.py @@ -297,7 +297,7 @@ def test_has_changes(sample_note) -> None: note = Note(note_path=sample_note) 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 note = Note(note_path=sample_note) @@ -316,7 +316,7 @@ def test_has_changes(sample_note) -> None: 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.""" path1, path2 = short_note note = Note(note_path=str(path1)) @@ -353,20 +353,20 @@ Lorem ipsum dolor sit amet. 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() - note.insert(new_string=string2, location=InsertLocation.BOTTOM) + note.write_string(new_string=string2, location=InsertLocation.BOTTOM) 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() - note2.insert(new_string=string1, location=InsertLocation.BOTTOM) + note2.write_string(new_string=string1, location=InsertLocation.BOTTOM) 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.""" path1, path2 = short_note note = Note(note_path=path1) @@ -401,17 +401,17 @@ This is a test string. 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() - 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() - 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() -def test_insert_after_title(short_note) -> None: +def test_write_string_after_title(short_note) -> None: """Test inserting metadata to bottom of note.""" path1, path2 = short_note note = Note(note_path=path1) @@ -446,13 +446,13 @@ This is a test string. 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() - 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() - 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() @@ -470,7 +470,7 @@ def test_print_diff(sample_note, capsys) -> None: """Test printing diff.""" 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() captured = capsys.readouterr() 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") -def test_rename_inline_metadata(sample_note) -> None: +def test_write_metadata(sample_note) -> None: """Test renaming inline metadata.""" 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 - 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 - 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"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📅:: ?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.""" note = Note(note_path=sample_note) note.rename_metadata("frontmatter_Key1", "author name", "some_new_key_here") - note.update_frontmatter() + note.write_frontmatter() new_frontmatter = """--- date_created: '2022-12-22' tags: @@ -831,9 +831,9 @@ shared_key2: shared_key2_value1 assert "```python" in note.file_content 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.update_frontmatter() + note2.write_frontmatter() new_frontmatter = """--- key1: value1 key2: value2 @@ -842,18 +842,18 @@ key2: value2 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.""" note = Note(note_path=sample_note) note.sub(pattern="Heading 1", replacement="Heading 2") - note.write() + note.commit() note = Note(note_path=sample_note) assert "Heading 2" in note.file_content assert "Heading 1" not in note.file_content new_path = Path(tmp_path / "new_note.md") - note.write(new_path) + note.commit(new_path) note2 = Note(note_path=new_path) assert "Heading 2" in note2.file_content assert "Heading 1" not in note2.file_content diff --git a/tests/vault_test.py b/tests/vault_test.py index 4da5484..c69fb3f 100644 --- a/tests/vault_test.py +++ b/tests/vault_test.py @@ -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: """Test adding metadata to the vault.""" vault_path = test_vault