mirror of
https://github.com/natelandau/obsidian-metadata.git
synced 2025-11-16 00:43:48 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9131ce128d | ||
|
|
b7735760e9 | ||
|
|
3fd6866760 | ||
|
|
759fc3434f | ||
|
|
c427a987c1 | ||
|
|
9123ee149f |
5
.github/workflows/pypi-release.yml
vendored
5
.github/workflows/pypi-release.yml
vendored
@@ -20,8 +20,11 @@ jobs:
|
||||
steps:
|
||||
- uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: block
|
||||
disable-sudo: true
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
upload.pypi.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## v0.1.1 (2023-01-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- **notes**: diff now prints values in the form `[value]`
|
||||
- **application**: exit after committing changes
|
||||
|
||||
## v0.1.0 (2023-01-22)
|
||||
|
||||
### Feat
|
||||
|
||||
33
README.md
33
README.md
@@ -2,34 +2,29 @@
|
||||
# obsidian-metadata
|
||||
A script to make batch updates to metadata in an Obsidian vault. Provides the following capabilities:
|
||||
|
||||
- in-text tag: delete every occurrence
|
||||
- in-text tags: Rename tag (`#tag1` -> `#tag2`)
|
||||
- frontmatter: Delete a key matching a regex pattern and all associated values
|
||||
- frontmatter: Rename a key
|
||||
- frontmatter: Delete a value matching a regex pattern from a specified key
|
||||
- frontmatter: Rename a value from a specified key
|
||||
- inline metadata: Delete a key matching a regex pattern and all associated values
|
||||
- inline metadata: Rename a key
|
||||
- inline metadata: Delete a value matching a regex pattern from a specified key
|
||||
- inline metadata: Rename a value from a specified key
|
||||
- vault: Create a backup of the Obsidian vault
|
||||
- `in-text tag`: delete every occurrence
|
||||
- `in-text tags`: Rename tag (`#tag1` -> `#tag2`)
|
||||
- `frontmatter`: Delete a key matching a regex pattern and all associated values
|
||||
- `frontmatter`: Rename a key
|
||||
- `frontmatter`: Delete a value matching a regex pattern from a specified key
|
||||
- `frontmatter`: Rename a value from a specified key
|
||||
- `inline metadata`: Delete a key matching a regex pattern and all associated values
|
||||
- `inline metadata`: Rename a key
|
||||
- `inline metadata`: Delete a value matching a regex pattern from a specified key
|
||||
- `inline metadata`: Rename a value from a specified key
|
||||
- `vault`: Create a backup of the Obsidian vault
|
||||
|
||||
|
||||
## Install
|
||||
`obsidian-metadata` requires Python v3.10 or above.
|
||||
|
||||
|
||||
Use [PIPX](https://pypa.github.io/pipx/) to install this package from Github.
|
||||
|
||||
```bash
|
||||
pipx install git+https://${GITHUB_TOKEN}@github.com/natelandau/obsidian-metadata
|
||||
pip install obsidian-metadata
|
||||
```
|
||||
|
||||
|
||||
## Disclaimer
|
||||
**Important:** It is strongly recommended that you back up your vault prior to committing changes. This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed.
|
||||
|
||||
The author of this script is not responsible for any data loss that may occur. Use at your own risk.
|
||||
## Important Disclaimer
|
||||
**It is strongly recommended that you back up your vault prior to committing changes.** This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed. The author of this script is not responsible for any data loss that may occur. Use at your own risk.
|
||||
|
||||
## Usage
|
||||
The script provides a menu of available actions. Make as many changes as you require and review them as you go. No changes are made to the Vault until they are explicitly committed.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
name = "obsidian-metadata"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/natelandau/obsidian-metadata"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
|
||||
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
|
||||
obsidian-metadata = "obsidian_metadata.cli:app"
|
||||
@@ -142,7 +142,7 @@
|
||||
bump_message = "bump(release): v$current_version → v$new_version"
|
||||
tag_format = "v$version"
|
||||
update_changelog_on_bump = true
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
version_files = [
|
||||
"pyproject.toml:version",
|
||||
"src/obsidian_metadata/__version__.py:__version__",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""obsidian-metadata version."""
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
@@ -132,7 +132,7 @@ def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
|
||||
return new_dict
|
||||
|
||||
|
||||
def clear_screen() -> None:
|
||||
def clear_screen() -> None: # pragma: no cover
|
||||
"""Clears the screen."""
|
||||
# for windows
|
||||
_ = system("cls") if name == "nt" else system("clear")
|
||||
|
||||
@@ -82,7 +82,9 @@ def main(
|
||||
- [code]vault:[/] Create a backup of the Obsidian vault.
|
||||
|
||||
[bold underline]Usage:[/]
|
||||
Run [tan]obsidian-metadata[/] from the command line. The script will allow you to make batch updates to metadata in an Obsidian vault. Once you have made your changes, review them prior to committing them to the vault.
|
||||
[tan]Obsidian-metadata[/] allows you to make batch updates to metadata in an Obsidian vault. Once you have made your changes, review them prior to committing them to the vault. The script provides a menu of available actions. Make as many changes as you require and review them as you go. No changes are made to the Vault until they are explicitly committed.
|
||||
|
||||
[bold underline]It is strongly recommended that you back up your vault prior to committing changes.[/] This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed. The author of this script is not responsible for any data loss that may occur. Use at your own risk.
|
||||
|
||||
Configuration is specified in a configuration file. On First run, this file will be created at [tan]~/.{0}.env[/]. Any options specified on the command line will override the configuration file.
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
from typing import Any
|
||||
|
||||
import questionary
|
||||
import typer
|
||||
from rich import print
|
||||
|
||||
from obsidian_metadata._config import Config
|
||||
@@ -35,8 +34,6 @@ class Application:
|
||||
]
|
||||
)
|
||||
|
||||
clear_screen()
|
||||
|
||||
def load_vault(self, path_filter: str = None) -> None:
|
||||
"""Load the vault.
|
||||
|
||||
@@ -48,6 +45,7 @@ class Application:
|
||||
|
||||
def main_app(self) -> None: # noqa: C901
|
||||
"""Questions for the main application."""
|
||||
clear_screen()
|
||||
self.load_vault()
|
||||
|
||||
while True:
|
||||
@@ -132,8 +130,8 @@ class Application:
|
||||
if operation == "review_changes":
|
||||
self.review_changes()
|
||||
|
||||
if operation == "commit_changes":
|
||||
self.commit_changes()
|
||||
if operation == "commit_changes" and self.commit_changes():
|
||||
break
|
||||
|
||||
if operation == "abort":
|
||||
break
|
||||
@@ -346,25 +344,29 @@ class Application:
|
||||
break
|
||||
changed_notes[note_to_review].print_diff()
|
||||
|
||||
def commit_changes(self) -> None:
|
||||
"""Write all changes to disk."""
|
||||
def commit_changes(self) -> bool:
|
||||
"""Write all changes to disk.
|
||||
|
||||
Returns:
|
||||
True if changes were committed, False otherwise.
|
||||
"""
|
||||
changed_notes = self.vault.get_changed_notes()
|
||||
|
||||
if len(changed_notes) == 0:
|
||||
print("\n")
|
||||
alerts.notice("No changes to commit.\n")
|
||||
return
|
||||
return False
|
||||
|
||||
backup = questionary.confirm("Create backup before committing changes").ask()
|
||||
if backup is None:
|
||||
return
|
||||
return False
|
||||
if backup:
|
||||
self.vault.backup()
|
||||
|
||||
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
|
||||
|
||||
self.vault.write()
|
||||
alerts.success("Changes committed to disk. Exiting.")
|
||||
typer.Exit()
|
||||
alerts.success(f"{len(changed_notes)} changes committed to disk. Exiting")
|
||||
return True
|
||||
|
||||
return
|
||||
return False
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -228,9 +227,9 @@ class Note:
|
||||
result = list(diff.compare(a, b))
|
||||
for line in result:
|
||||
if line.startswith("+"):
|
||||
print(f"[green]{line}[/]")
|
||||
print(f"\033[92m{line}\033[0m")
|
||||
elif line.startswith("-"):
|
||||
print(f"[red]{line}[/]")
|
||||
print(f"\033[91m{line}\033[0m")
|
||||
|
||||
def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> None:
|
||||
"""Substitutes text within the note.
|
||||
@@ -348,13 +347,17 @@ class Note:
|
||||
self.file_content = new_frontmatter + self.file_content
|
||||
return
|
||||
|
||||
self.sub(current_frontmatter, new_frontmatter)
|
||||
current_frontmatter = re.escape(current_frontmatter)
|
||||
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
|
||||
|
||||
def write(self, path: Path | None = None) -> None:
|
||||
def write(self, path: Path = None) -> None:
|
||||
"""Writes the note's content to disk.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ class Vault:
|
||||
vault (Path): Path to the vault.
|
||||
dry_run (bool): Whether to perform a dry run.
|
||||
backup_path (Path): Path to the backup of the vault.
|
||||
new_vault (Path): Path to a new vault.
|
||||
notes (list[Note]): List of all notes in the vault.
|
||||
"""
|
||||
|
||||
@@ -32,7 +31,6 @@ class Vault:
|
||||
self.vault_path: Path = config.vault_path
|
||||
self.dry_run: bool = dry_run
|
||||
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
|
||||
self.new_vault_path: Path = self.vault_path.parent / f"{self.vault_path.name}.new"
|
||||
self.exclude_paths: list[Path] = []
|
||||
self.metadata = VaultMetadata()
|
||||
for p in config.exclude_paths:
|
||||
@@ -55,12 +53,11 @@ class Vault:
|
||||
self.metadata.add_metadata(_note.inline_metadata.dict)
|
||||
self.metadata.add_metadata({_note.inline_tags.metadata_key: _note.inline_tags.list})
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
|
||||
"""Define rich representation of Vault."""
|
||||
yield "vault_path", self.vault_path
|
||||
yield "dry_run", self.dry_run
|
||||
yield "backup_path", self.backup_path
|
||||
yield "new_vault", self.new_vault_path
|
||||
yield "num_notes", self.num_notes()
|
||||
yield "exclude_paths", self.exclude_paths
|
||||
|
||||
@@ -285,18 +282,10 @@ class Vault:
|
||||
return True
|
||||
return False
|
||||
|
||||
def write(self, new_vault: bool = False) -> None:
|
||||
def write(self) -> None:
|
||||
"""Write changes to the vault."""
|
||||
log.debug("Writing changes to vault...")
|
||||
if new_vault:
|
||||
log.debug("Writing changes to backup")
|
||||
if self.dry_run is False:
|
||||
for _note in self.notes:
|
||||
_new_note_path: Path = Path(
|
||||
self.new_vault_path / Path(_note.note_path).relative_to(self.vault_path)
|
||||
)
|
||||
log.debug(f"writing to {_new_note_path}")
|
||||
_note.write(path=_new_note_path)
|
||||
else:
|
||||
for _note in self.notes:
|
||||
log.debug(f"writing to {_note.note_path}")
|
||||
log.trace(f"writing to {_note.note_path}")
|
||||
_note.write()
|
||||
|
||||
18
tests/application_test.py
Normal file
18
tests/application_test.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# type: ignore
|
||||
"""Tests for the application module."""
|
||||
|
||||
|
||||
from obsidian_metadata._config import Config
|
||||
from obsidian_metadata.models.application import Application
|
||||
|
||||
|
||||
def test_load_vault(test_vault) -> None:
|
||||
"""Test application."""
|
||||
vault_path = test_vault
|
||||
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
|
||||
app = Application(config=config, dry_run=False)
|
||||
app.load_vault()
|
||||
|
||||
assert app.dry_run is False
|
||||
assert app.config == config
|
||||
assert app.vault.num_notes() == 2
|
||||
@@ -4,7 +4,8 @@
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from obsidian_metadata.cli import app
|
||||
from tests.helpers import Regex
|
||||
|
||||
from .helpers import KeyInputs, Regex # noqa: F401
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -14,3 +15,29 @@ def test_version() -> None:
|
||||
result = runner.invoke(app, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output == Regex(r"obsidian_metadata: v\d+\.\d+\.\d+$")
|
||||
|
||||
|
||||
def test_application(test_vault, tmp_path) -> None:
|
||||
"""Test the application."""
|
||||
vault_path = test_vault
|
||||
config_path = tmp_path / "config.toml"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["--vault-path", vault_path, "--config-file", config_path],
|
||||
# input=KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER, # noqa: ERA001
|
||||
)
|
||||
|
||||
banner = r"""
|
||||
___ _ _ _ _
|
||||
/ _ \| |__ ___(_) __| (_) __ _ _ __
|
||||
| | | | '_ \/ __| |/ _` | |/ _` | '_ \
|
||||
| |_| | |_) \__ \ | (_| | | (_| | | | |
|
||||
\___/|_.__/|___/_|\__,_|_|\__,_|_| |_|
|
||||
| \/ | ___| |_ __ _ __| | __ _| |_ __ _
|
||||
| |\/| |/ _ \ __/ _` |/ _` |/ _` | __/ _` |
|
||||
| | | | __/ || (_| | (_| | (_| | || (_| |
|
||||
|_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_|
|
||||
"""
|
||||
|
||||
assert banner in result.output
|
||||
assert result.exit_code == 1
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
import re
|
||||
|
||||
|
||||
class KeyInputs:
|
||||
"""Key inputs for testing."""
|
||||
|
||||
DOWN = "\x1b[B"
|
||||
UP = "\x1b[A"
|
||||
LEFT = "\x1b[D"
|
||||
RIGHT = "\x1b[C"
|
||||
ENTER = "\r"
|
||||
ESCAPE = "\x1b"
|
||||
CONTROLC = "\x03"
|
||||
BACK = "\x7f"
|
||||
SPACE = " "
|
||||
TAB = "\x09"
|
||||
ONE = "1"
|
||||
TWO = "2"
|
||||
THREE = "3"
|
||||
|
||||
|
||||
class Regex:
|
||||
"""Assert that a given string meets some expectations.
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ def test_vault_creation(test_vault):
|
||||
|
||||
assert vault.vault_path == vault_path
|
||||
assert vault.backup_path == Path(f"{vault_path}.bak")
|
||||
assert vault.new_vault_path == Path(f"{vault_path}.new")
|
||||
assert vault.dry_run is False
|
||||
assert str(vault.exclude_paths[0]) == Regex(r".*\.git")
|
||||
assert vault.num_notes() == 2
|
||||
|
||||
Reference in New Issue
Block a user