10 Commits

Author SHA1 Message Date
Nathaniel Landau
2cca54320c bump(release): v0.4.0 → v0.5.0 2023-02-05 00:00:29 -05:00
Nathaniel Landau
d94d9f2197 feat: add new tags (#16) 2023-02-04 23:34:31 -05:00
Nathaniel Landau
17985615b3 feat: add new inline metadata (#15)
* feat: add new inline metadata to notes

* fix: prepend note content after frontmatter

* refactor: cleanup search patterns

* feat(regex): find top of note

* test: add headers

* fix: insert to specified location

* test: improve test coverage

* docs: add inline metadata
2023-02-04 23:34:31 -05:00
Nathaniel Landau
13513b2a14 ci: use CHANGELOG.md for release notes 2023-02-04 23:34:31 -05:00
Nathaniel Landau
b7b77d998c ci: run tests once on pull requests 2023-02-04 23:34:31 -05:00
Nathaniel Landau
0de95a4be4 refactor: pass Ruff lint rules 2023-02-04 23:34:31 -05:00
Nathaniel Landau
90b737f7b3 bump(release): v0.3.0 → v0.4.0 2023-02-04 23:34:31 -05:00
Nathaniel Landau
8e040aeba4 feat: export metadata (#14)
* docs(readme): fix line breaks

* feat: export metadata to a CSV

* fix: finalize colors for questions

* feat: inspect frontmatter, inline, and tags separately

* feat: export metadata to JSON

* fix: do not count in-page links as tags

* ci(codecov): adjust patch target percentage down

* feat(metadata): export CSV or JSON from command line
2023-02-02 17:09:31 -05:00
Nathaniel Landau
4a29945de2 feat(app): limit scope of notes with one or more filters (#13)
* style: rename `VaultMetadata.add_metadata` to `VaultMetadata.index_metadata`

* refactor(vault): refactor filtering notes

* fix(application): improve usage display

* fix(application): improve colors of questions

* feat(application): limit the scope of notes to be processed with one or more filters

* build(deps): update identify
2023-02-01 15:00:57 -05:00
Nathaniel Landau
6909738218 docs(readme): add badges 2023-01-30 22:00:47 +00:00
41 changed files with 3389 additions and 1924 deletions

View File

@@ -61,6 +61,7 @@
"foxundermoon.shell-format",
"GitHub.copilot",
"Gruntfuggly.todo-tree",
"GrapeCity.gc-excelviewer",
"mhutchie.git-graph",
"njpwerner.autodocstring",
"oderwat.indent-rainbow",

View File

@@ -54,7 +54,7 @@ _mainScript_() {
echo ""
header "Installing shfmt"
if ! command -v shfmt &>/dev/null; then
_execute_ "curl -sS https://webi.sh/shfmt | sh"
_execute_ -pv "curl -sS https://webi.sh/shfmt | sh"
fi
REPOS=(

View File

@@ -1,20 +1,25 @@
---
name: "Python Code Checker"
name: "Automated Tests"
on:
workflow_dispatch:
push:
paths:
- ".github/workflows/python-code-checker.yml"
- ".github/workflows/automated-tests.yml"
- ".github/actions/**"
- "src/**"
- "tests/**"
- "pyproject.toml"
- "poetry.lock"
branches:
- main
pull_request:
types: [opened, reopened]
types:
- opened
- reopened
- synchronize
paths:
- ".github/workflows/python-code-checker.yml"
- ".github/workflows/automated-tests.yml"
- ".github/actions/**"
- "src/**"
- "tests/**"
@@ -62,15 +67,9 @@ jobs:
- name: Lint with Mypy
run: poetry run mypy src/
- name: lint with ruff
run: poetry run ruff --extend-ignore=I001,D301 src/
run: poetry run ruff --extend-ignore=I001,D301,D401,PLR2004,PLR0913 src/
- name: check pyproject.toml
run: poetry run poetry check
- name: lint with black
run: poetry run black --check src/
- name: run vulture
run: poetry run vulture src/
- name: run interrogate
run: poetry run interrogate -c pyproject.toml .
# ----------------------------------------------
# run test suite
@@ -80,6 +79,13 @@ jobs:
poetry run coverage run
poetry run coverage report
poetry run coverage xml
# ----------------------------------------------
# confirm package builds
# ----------------------------------------------
- name: Build package
run: poetry build
# ----------------------------------------------
# upload coverage stats
# ----------------------------------------------

View File

@@ -58,22 +58,35 @@ jobs:
echo $TAG
echo $PROJECT_VERSION
if [[ "$TAG" != "v$PROJECT_VERSION" ]]; then exit 1; fi
# ----------------------------------------------
# Generate release notes
# ----------------------------------------------
- name: Release Notes
run: git log $(git describe HEAD~ --tags --abbrev=0)..HEAD --pretty='format:* %h %s' --no-merges >> ".github/RELEASE-TEMPLATE.md"
echo "current_tag=refs/tags/${TAG}" >> $GITHUB_ENV
# ----------------------------------------------
# Test and then build the package
# ----------------------------------------------
- name: run poetry build
run: |
poetry run poetry check
poetry run coverage run
poetry build
# ----------------------------------------------
# Generate release notes
# ----------------------------------------------
# - name: Release Notes
# run: git log $(git describe HEAD~ --tags --abbrev=0)..HEAD --pretty='format:* %h %s' --no-merges >> ".github/RELEASE-TEMPLATE.md"
- name: Export tag name to env variable
run: |
TAG=$(git describe HEAD --tags --abbrev=0)
echo "CURRENT_TAG=refs/tags/${TAG}" >> $GITHUB_ENV
- name: Get notes
id: generate_notes
uses: anmarkoulis/commitizen-changelog-reader@master
with:
tag_name: ${{ env.CURRENT_TAG }}
changelog: CHANGELOG.md
# ----------------------------------------------
# Build draft release (Note: Will need to manually publish)
@@ -82,7 +95,8 @@ jobs:
- name: Create Release Draft
uses: softprops/action-gh-release@v1
with:
body_path: ".github/RELEASE-TEMPLATE.md"
# body_path: ".github/RELEASE-TEMPLATE.md"
body: ${{join(fromJson(steps.generate_notes.outputs.notes).notes, '')}}
draft: true
files: |
dist/*-${{env.PROJECT_VERSION}}-py3-none-any.whl

View File

@@ -8,6 +8,8 @@ on:
paths:
- ".devcontainer/**"
- ".github/workflows/devcontainer-checker.yml"
branches:
- main
push:
paths:
- ".devcontainer/**"

View File

@@ -61,10 +61,11 @@ repos:
entry: yamllint --strict --config-file .yamllint.yml
- repo: "https://github.com/charliermarsh/ruff-pre-commit"
rev: "v0.0.237"
rev: "v0.0.240"
hooks:
- id: ruff
args: ["--extend-ignore", "I001,D301,D401,PLR2004"]
args: ["--extend-ignore", "I001,D301,D401,PLR2004,PLR0913"]
exclude: tests/
- repo: "https://github.com/jendrikseipp/vulture"
rev: "v2.7"

View File

@@ -1,32 +1,61 @@
## v0.5.0 (2023-02-04)
### Feat
- add new tags (#16)
- add new inline metadata (#15)
- **configuration**: `insert_location` specifies where content is added within notes
### Fix
- find more emojis
## v0.4.0 (2023-02-02)
### Feat
- export metadata (#14)
- export metadata to CSV
- export metadata to JSON
- export CSV or JSON from command line
- limit scope of notes with one or more filters (#13)
### Fix
- do not count in-page links as tags
- improve terminal colors of questions
## v0.3.0 (2023-01-30)
### Feat
- **application**: add new metadata to frontmatter (#9)
- **application**: add new metadata to frontmatter (#9)
### Fix
- **application**: improve ux (#10)
- **application**: improve ux (#10)
## v0.2.0 (2023-01-25)
### Feat
- **configuration**: support multiple vaults in the configuration file (#6)
- **configuration**: support multiple vaults in the configuration file (#6)
### Refactor
- **application**: refactor questions to separate class (#7)
- **application**: refactor questions to separate class (#7)
## v0.1.1 (2023-01-23)
### Fix
- **notes**: diff now prints values in the form `[value]`
- **application**: exit after committing changes
- **notes**: diff now prints values in the form `[value]`
- **application**: exit after committing changes
## v0.1.0 (2023-01-22)
### Feat
- initial application release
- initial application release

100
README.md
View File

@@ -1,15 +1,18 @@
[![Python Code Checker](https://github.com/natelandau/obsidian-metadata/actions/workflows/python-code-checker.yml/badge.svg)](https://github.com/natelandau/obsidian-metadata/actions/workflows/python-code-checker.yml) [![codecov](https://codecov.io/gh/natelandau/obsidian-metadata/branch/main/graph/badge.svg?token=3F2R43SSX4)](https://codecov.io/gh/natelandau/obsidian-metadata)
[![PyPI version](https://badge.fury.io/py/obsidian-metadata.svg)](https://badge.fury.io/py/obsidian-metadata) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/obsidian-metadata) [![Python Code Checker](https://github.com/natelandau/obsidian-metadata/actions/workflows/automated-tests.yml/badge.svg)](https://github.com/natelandau/obsidian-metadata/actions/workflows/automated-tests.yml) [![codecov](https://codecov.io/gh/natelandau/obsidian-metadata/branch/main/graph/badge.svg?token=3F2R43SSX4)](https://codecov.io/gh/natelandau/obsidian-metadata)
# obsidian-metadata
A script to make batch updates to metadata in an Obsidian vault. No changes are
made to the Vault until they are explicitly committed.
made to the Vault until they are explicitly committed.
[![asciicast](https://asciinema.org/a/555789.svg)](https://asciinema.org/a/555789)
## 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.
**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.
## Install
Requires Python v3.10 or above.
```bash
@@ -17,45 +20,74 @@ pip install obsidian-metadata
```
## Usage
Run `obsidian-metadata` from the command line to invoke the script. Add `--help` to view additional options.
Obsidian-metadata provides a menu of sub-commands.
### CLI Commands
- `--config-file`: Specify a custom configuration file location
- `--dry-run`: Make no destructive changes
- `--export-csv`: Specify a path and create a CSV export of all metadata
- `--export-json`: Specify a path and create a JSON export of all metadata
- `--help`: Shows interactive help and exits
- `--log-file`: Specify a log file location
- `--log-to-file`: Will log to a file
- `--vault-path`: Specify a path to an Obsidian Vault
- `--verbose`: Set verbosity level (0=WARN, 1=INFO, 2=DEBUG, 3=TRACE)
- `--version`: Prints the version number and exits
### Running the script
Once installed, run `obsidian-metadata` in your terminal to enter an interactive menu of sub-commands.
**Vault Actions**
- Backup: Create a backup of the vault.
- Delete Backup: Delete a backup of the vault.
- Backup: Create a backup of the vault.
- Delete Backup: Delete a backup of the vault.
**Inspect Metadata**
- View all metadata in the vault
**Filter Notes in Scope**:
Limit the scope of notes to be processed with a regex.
- Apply regex: Set a regex to limit scope
- List notes in scope: List notes that will be processed.
- **View all metadata in the vault**
- View all **frontmatter**
- View all **inline metadata**
- View all **inline tags**
- **Export all metadata to CSV or JSON file**
**Add Metadata**
- Add metadata to the frontmatter
- Add to inline metadata (Not yet implemented)
- Add to inline tag (Not yet implemented)
**Filter Notes in Scope**: Limit the scope of notes to be processed with one or more filters.
**Rename Metadata**
- Rename a key
- Rename a value
- rename an inline tag
- **Path filter (regex)**: Limit scope based on the path or filename
- **Metadata filter**: Limit scope based on a key or key/value pair
- **Tag filter**: Limit scope based on an in-text tag
- **List and clear filters**: List all current filters and clear one or all
- **List notes in scope**: List notes that will be processed.
**Delete Metadata**
- Delete a key and associated values
- Delete a value from a key
- Delete an inline tag
**Add Metadata**: Add new metadata to your vault.
**Review Changes**
- View a diff of the changes that will be made
- **Add new metadata to the frontmatter**
- **Add new inline metadata** - Set `insert_location` in the config to control where the new metadata is inserted. (Default: Bottom)
- **Add new inline tag** - Set `insert_location` in the config to control where the new tag is inserted. (Default: Bottom)
**Commit Changes**
- Commit changes to the vault
**Rename Metadata**: Rename either a key and all associated values, a specific value within a key. or an in-text tag.
- **Rename a key**
- **Rename a value**
- **Rename an inline tag**
**Delete Metadata**: Delete either a key and all associated values, or a specific value.
- **Delete a key and associated values**
- **Delete a value from a key**
- **Delete an inline tag**
**Review Changes**: Prior to committing changes, review all changes that will be made.
- **View a diff of the changes** that will be made
**Commit Changes**: Write the changes to disk. This step is not undoable.
- **Commit changes to the vault**
### Configuration
`obsidian-metadata` requires a configuration file at `~/.obsidian_metadata.toml`. On first run, this file will be created. You can specify a new location for the configuration file with the `--config-file` option.
`obsidian-metadata` requires a configuration file at `~/.obsidian_metadata.toml`. On first run, this file will be created. You can specify a new location for the configuration file with the `--config-file` option.
To add additional vaults, copy the default section and add the appropriate information. The script will prompt you to select a vault if multiple exist in the configuration file
@@ -70,14 +102,20 @@ Below is an example with two vaults.
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of:
# TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter.
# BOTTOM: The bottom of the note
insert_location = "BOTTOM"
["Vault Two"]
path = "/path/to/second_vault"
exclude_paths = [".git", ".obsidian"]
exclude_paths = [".git", ".obsidian", "daily_notes"]
insert_location = "AFTER_TITLE"
```
To bypass the configuration file and specify a vault to use at runtime use the `--vault-path` option.
# Contributing
## Setup: Once per project

View File

@@ -4,8 +4,11 @@ coverage:
project:
default:
target: 50% # the required coverage value
threshold: 1% # the leniency in hitting the target
threshold: 5% # the leniency in hitting the target
patch:
default:
target: 50%
threshold: 5%
ignore:
- tests/

1800
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
name = "obsidian-metadata"
readme = "README.md"
repository = "https://github.com/natelandau/obsidian-metadata"
version = "0.3.0"
version = "0.5.0"
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
obsidian-metadata = "obsidian_metadata.cli:app"
@@ -20,6 +20,7 @@
loguru = "^0.6.0"
python = "^3.10"
questionary = "^1.10.0"
regex = "^2022.10.31"
rich = "^13.2.0"
ruamel-yaml = "^0.17.21"
shellingham = "^1.4.0"
@@ -35,20 +36,19 @@
[tool.poetry.group.dev.dependencies]
absolufy-imports = "^0.3.1"
black = "^22.12.0"
commitizen = "^2.39.1"
coverage = "^7.0.4"
black = "^23.1.0"
commitizen = "^2.40.0"
coverage = "^7.1.0"
interrogate = "^1.5.0"
mypy = "^0.991"
pdoc = "^12.3.1"
pep8-naming = "^0.13.3"
poethepoet = "^0.18.0"
pre-commit = "^2.21.0"
pre-commit = "^3.0.4"
pysnooper = "^1.1.1"
ruff = "^0.0.217"
ruff = "^0.0.240"
typeguard = "^2.13.3"
types-python-dateutil = "^2.8.19.5"
types-pyyaml = "^6.0.12.2"
vulture = "^2.7"
[tool.ruff] # https://github.com/charliermarsh/ruff
@@ -75,6 +75,12 @@
]
ignore-init-module-imports = true
line-length = 100
per-file-ignores = { "cli.py" = [
"PLR0913",
], "tests/*.py" = [
"E999",
"PLR2004",
] }
select = [
"A",
"B",
@@ -140,9 +146,10 @@
[tool.commitizen]
bump_message = "bump(release): v$current_version → v$new_version"
changelog_incremental = true
tag_format = "v$version"
update_changelog_on_bump = true
version = "0.3.0"
version = "0.5.0"
version_files = [
"pyproject.toml:version",
"src/obsidian_metadata/__version__.py:__version__",
@@ -206,7 +213,7 @@
help = "Lint this package"
[[tool.poe.tasks.lint.sequence]]
shell = "ruff --extend-ignore=I001,D301 src/ tests/"
shell = "ruff --extend-ignore=I001,D301,D401,PLR2004,PLR0913 src/"
[[tool.poe.tasks.lint.sequence]]
shell = "black --check src/ tests/"

View File

@@ -1,2 +1,2 @@
"""obsidian-metadata version."""
__version__ = "0.3.0"
__version__ = "0.5.0"

View File

@@ -17,6 +17,21 @@ from obsidian_metadata._utils.alerts import logger as log
class ConfigQuestions:
"""Questions to ask the user when creating a configuration file."""
@staticmethod
def _validate_valid_dir(path: str) -> bool | str:
"""Validate a valid directory.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
path_to_validate: Path = Path(path).expanduser().resolve()
if not path_to_validate.exists():
return f"Path does not exist: {path_to_validate}"
if not path_to_validate.is_dir():
return f"Path is not a directory: {path_to_validate}"
return True
@staticmethod
def ask_for_vault_path() -> Path: # pragma: no cover
"""Ask the user for the path to their vault.
@@ -34,28 +49,12 @@ class ConfigQuestions:
return Path(vault_path).expanduser().resolve()
@staticmethod
def _validate_valid_dir(path: str) -> bool | str:
"""Validates a valid directory.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
"""
path_to_validate: Path = Path(path).expanduser().resolve()
if not path_to_validate.exists():
return f"Path does not exist: {path_to_validate}"
if not path_to_validate.is_dir():
return f"Path is not a directory: {path_to_validate}"
return True
@rich.repr.auto
class Config:
"""Representation of a configuration file."""
def __init__(self, config_path: Path = None, vault_path: Path = None) -> None:
if vault_path is None:
self.config_path: Path = self._validate_config_path(Path(config_path))
self.config: dict[str, Any] = self._load_config()
@@ -66,7 +65,11 @@ class Config:
else:
self.config_path = None
self.config = {
"command_line_vault": {"path": vault_path, "exclude_paths": [".git", ".obsidian"]}
"command_line_vault": {
"path": vault_path,
"exclude_paths": [".git", ".obsidian"],
"insert_location": "BOTTOM",
}
}
try:
@@ -85,6 +88,15 @@ class Config:
yield "config_path", self.config_path
yield "vaults", self.vaults
def _load_config(self) -> dict[str, Any]:
"""Load the configuration file."""
try:
with open(self.config_path, encoding="utf-8") as fp:
return tomlkit.load(fp)
except tomlkit.exceptions.TOMLKitError as e:
alerts.error(f"Could not parse '{self.config_path}'")
raise typer.Exit(code=1) from e
def _validate_config_path(self, config_path: Path | None) -> Path:
"""Load the configuration path."""
if config_path is None:
@@ -96,15 +108,6 @@ class Config:
return config_path.expanduser().resolve()
def _load_config(self) -> dict[str, Any]:
"""Load the configuration file."""
try:
with open(self.config_path, encoding="utf-8") as fp:
return tomlkit.load(fp)
except tomlkit.exceptions.TOMLKitError as e:
alerts.error(f"Could not parse '{self.config_path}'")
raise typer.Exit(code=1) from e
def _write_default_config(self, path_to_config: Path) -> None:
"""Write the default configuration file when no config file is found."""
vault_path = ConfigQuestions.ask_for_vault_path()
@@ -117,7 +120,14 @@ class Config:
path = "{vault_path}"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]"""
exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of:
# TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter.
# BOTTOM: The bottom of the note
insert_location = "BOTTOM"
"""
path_to_config.write_text(dedent(config_text))
@@ -141,7 +151,12 @@ class VaultConfig:
try:
self.exclude_paths = self.config["exclude_paths"]
except KeyError:
self.exclude_paths = []
self.exclude_paths = [".git", ".obsidian"]
try:
self.insert_location = self.config["insert_location"]
except KeyError:
self.insert_location = "BOTTOM"
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of a vault config."""

View File

@@ -8,6 +8,7 @@ from obsidian_metadata._utils.utilities import (
dict_contains,
dict_values_to_lists_strings,
docstring_parameter,
merge_dictionaries,
remove_markdown_sections,
version_callback,
)
@@ -20,6 +21,7 @@ __all__ = [
"dict_values_to_lists_strings",
"docstring_parameter",
"LoggerManager",
"merge_dictionaries",
"remove_markdown_sections",
"vault_validation",
"version_callback",

View File

@@ -1,6 +1,8 @@
"""Logging and alerts."""
import sys
from enum import Enum
from pathlib import Path
from textwrap import wrap
import rich.repr
import typer
@@ -8,6 +10,28 @@ from loguru import logger
from rich import print
class LogLevel(Enum):
"""Enum for log levels."""
TRACE = 5
DEBUG = 10
INFO = 20
SUCCESS = 25
WARNING = 30
ERROR = 40
CRITICAL = 50
EXCEPTION = 60
class VerboseLevel(Enum):
"""Enum for verbose levels."""
WARN = 0
INFO = 1
DEBUG = 2
TRACE = 3
def dryrun(msg: str) -> None:
"""Print a message if the dry run flag is set.
@@ -62,6 +86,29 @@ def info(msg: str) -> None:
print(f"INFO | {msg}")
def usage(msg: str, width: int = 80) -> None:
"""Print a usage message without using logging.
Args:
msg: Message to print
width (optional): Width of the message
"""
for _n, line in enumerate(wrap(msg, width=width)):
if _n == 0:
print(f"[dim]USAGE | {line}")
else:
print(f"[dim] | {line}")
def debug(msg: str) -> None:
"""Print a debug message without using logging.
Args:
msg: Message to print
"""
print(f"[blue]DEBUG | {msg}[/blue]")
def dim(msg: str) -> None:
"""Print a message in dimmed color.
@@ -127,7 +174,7 @@ class LoggerManager:
print("No log file specified")
raise typer.Exit(1)
if self.verbosity >= 3:
if self.verbosity >= VerboseLevel.TRACE.value:
logger.remove()
logger.add(
sys.stderr,
@@ -137,7 +184,7 @@ class LoggerManager:
diagnose=True,
)
self.log_level = 5
elif self.verbosity == 2:
elif self.verbosity == VerboseLevel.DEBUG.value:
logger.remove()
logger.add(
sys.stderr,
@@ -147,7 +194,7 @@ class LoggerManager:
diagnose=True,
)
self.log_level = 10
elif self.verbosity == 1:
elif self.verbosity == VerboseLevel.INFO.value:
logger.remove()
logger.add(
sys.stderr,
@@ -190,7 +237,7 @@ class LoggerManager:
Returns:
bool: True if the current log level is TRACE or lower, False otherwise.
"""
if self.log_level <= 5:
if self.log_level <= LogLevel.TRACE.value:
if msg:
print(msg)
return True
@@ -205,7 +252,7 @@ class LoggerManager:
Returns:
bool: True if the current log level is DEBUG or lower, False otherwise.
"""
if self.log_level <= 10:
if self.log_level <= LogLevel.DEBUG.value:
if msg:
print(msg)
return True
@@ -220,7 +267,7 @@ class LoggerManager:
Returns:
bool: True if the current log level is INFO or lower, False otherwise.
"""
if self.log_level <= 20:
if self.log_level <= LogLevel.INFO.value:
if msg:
print(msg)
return True
@@ -235,7 +282,7 @@ class LoggerManager:
Returns:
bool: True if the current log level is default or lower, False otherwise.
"""
if self.log_level <= 30:
if self.log_level <= LogLevel.WARNING.value:
if msg:
print(msg)
return True

View File

@@ -8,101 +8,6 @@ import typer
from obsidian_metadata.__version__ import __version__
def dict_values_to_lists_strings(dictionary: dict, strip_null_values: bool = False) -> dict:
"""Converts all values in a dictionary to lists of strings.
Args:
dictionary (dict): Dictionary to convert
strip_null (bool): Whether to strip null values
Returns:
dict: Dictionary with all values converted to lists of strings
{key: sorted(new_dict[key]) for key in sorted(new_dict)}
"""
new_dict = {}
if strip_null_values:
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value if item is not None])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
elif value is None or value == "None" or value == "":
new_dict[key] = []
else:
new_dict[key] = [str(value)]
return new_dict
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
else:
new_dict[key] = [str(value)]
return new_dict
def remove_markdown_sections(
text: str,
strip_codeblocks: bool = False,
strip_inlinecode: bool = False,
strip_frontmatter: bool = False,
) -> str:
"""Strip markdown sections from text.
Args:
text (str): Text to remove code blocks from
strip_codeblocks (bool, optional): Strip code blocks. Defaults to False.
strip_inlinecode (bool, optional): Strip inline code. Defaults to False.
strip_frontmatter (bool, optional): Strip frontmatter. Defaults to False.
Returns:
str: Text without code blocks
"""
if strip_codeblocks:
text = re.sub(r"`{3}.*?`{3}", "", text, flags=re.DOTALL)
if strip_inlinecode:
text = re.sub(r"`.*?`", "", text)
if strip_frontmatter:
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
return text # noqa: RET504
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
print(f"{__package__.split('.')[0]}: v{__version__}")
raise typer.Exit()
def docstring_parameter(*sub: Any) -> Any:
"""Decorator to replace variables within docstrings.
Args:
sub (Any): Replacement variables
Usage:
@docstring_parameter("foo", "bar")
def foo():
'''This is a {0} docstring with {1} variables.'''
"""
def dec(obj: Any) -> Any:
"""Format object."""
obj.__doc__ = obj.__doc__.format(*sub)
return obj
return dec
def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
"""Clean up a dictionary by markdown formatting from keys and values.
@@ -155,3 +60,126 @@ def dict_contains(
return any(found_keys)
return key in dictionary and value in dictionary[key]
def dict_values_to_lists_strings(dictionary: dict, strip_null_values: bool = False) -> dict:
"""Convert all values in a dictionary to lists of strings.
Args:
dictionary (dict): Dictionary to convert
strip_null (bool): Whether to strip null values
Returns:
dict: Dictionary with all values converted to lists of strings
{key: sorted(new_dict[key]) for key in sorted(new_dict)}
"""
new_dict = {}
if strip_null_values:
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value if item is not None])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
elif value is None or value == "None" or value == "":
new_dict[key] = []
else:
new_dict[key] = [str(value)]
return new_dict
for key, value in dictionary.items():
if isinstance(value, list):
new_dict[key] = sorted([str(item) for item in value])
elif isinstance(value, dict):
new_dict[key] = dict_values_to_lists_strings(value) # type: ignore[assignment]
else:
new_dict[key] = [str(value)]
return new_dict
def docstring_parameter(*sub: Any) -> Any:
"""Replace variables within docstrings.
Args:
sub (Any): Replacement variables
Usage:
@docstring_parameter("foo", "bar")
def foo():
'''This is a {0} docstring with {1} variables.'''
"""
def dec(obj: Any) -> Any:
"""Format object."""
obj.__doc__ = obj.__doc__.format(*sub)
return obj
return dec
def merge_dictionaries(dict1: dict, dict2: dict) -> dict:
"""Merge two dictionaries.
Args:
dict1 (dict): First dictionary.
dict2 (dict): Second dictionary.
Returns:
dict: Merged dictionary.
"""
for k, v in dict2.items():
if k in dict1:
if isinstance(v, list):
dict1[k].extend(v)
else:
dict1[k] = v
for k, v in dict1.items():
if isinstance(v, list):
dict1[k] = sorted(set(v))
elif isinstance(v, dict): # pragma: no cover
for kk, vv in v.items():
if isinstance(vv, list):
v[kk] = sorted(set(vv))
return dict(sorted(dict1.items()))
def remove_markdown_sections(
text: str,
strip_codeblocks: bool = False,
strip_inlinecode: bool = False,
strip_frontmatter: bool = False,
) -> str:
"""Strip markdown sections from text.
Args:
text (str): Text to remove code blocks from
strip_codeblocks (bool, optional): Strip code blocks. Defaults to False.
strip_inlinecode (bool, optional): Strip inline code. Defaults to False.
strip_frontmatter (bool, optional): Strip frontmatter. Defaults to False.
Returns:
str: Text without code blocks
"""
if strip_codeblocks:
text = re.sub(r"`{3}.*?`{3}", "", text, flags=re.DOTALL)
if strip_inlinecode:
text = re.sub(r"`.*?`", "", text)
if strip_frontmatter:
text = re.sub(r"^\s*---.*?---", "", text, flags=re.DOTALL)
return text # noqa: RET504
def version_callback(value: bool) -> None:
"""Print version and exit."""
if value:
print(f"{__package__.split('.')[0]}: v{__version__}")
raise typer.Exit()

View File

@@ -1,6 +1,5 @@
"""obsidian-metadata CLI."""
from pathlib import Path
from typing import Optional
@@ -28,16 +27,30 @@ HELP_TEXT = """
@app.command()
@docstring_parameter(__package__)
def main(
vault_path: Path = typer.Option(
None,
help="Path to Obsidian vault",
show_default=False,
),
config_file: Path = typer.Option(
Path(Path.home() / f".{__package__}.toml"),
help="Specify a custom path to a configuration file",
show_default=False,
),
export_csv: Path = typer.Option(
None,
help="Exports all metadata to a specified CSV file and exits. (Will overwrite any existing file)",
show_default=False,
dir_okay=False,
file_okay=True,
),
export_json: Path = typer.Option(
None,
help="Exports all metadata to a specified JSON file and exits. (Will overwrite any existing file)",
show_default=False,
dir_okay=False,
file_okay=True,
),
vault_path: Path = typer.Option(
None,
help="Path to Obsidian vault",
show_default=False,
),
dry_run: bool = typer.Option(
False,
"--dry-run",
@@ -70,7 +83,7 @@ def main(
None, "--version", help="Print version and exit", callback=version_callback, is_eager=True
),
) -> None:
r"""A script to make batch updates to metadata in an Obsidian vault. No changes are made to the Vault until they are explicitly committed.
r"""Make batch updates to metadata in an Obsidian vault. No changes are made to the Vault until they are explicitly committed.
[bold] [/]
[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.
@@ -82,36 +95,52 @@ def main(
[tan]Obsidian-metadata[/] provides a menu of sub-commands.
[bold underline]Vault Actions[/]
Create or delete a backup of your vault.
• Backup: Create a backup of the vault.
• Delete Backup: Delete a backup of the vault.
[bold underline]Inspect Metadata[/]
Inspect the metadata in your vault.
• View all metadata in the vault
• View all frontmatter
• View all inline metadata
• View all inline tags
• Export all metadata to CSV or JSON file
[bold underline]Filter Notes in Scope[/]
Limit the scope of notes to be processed with a regex.
Apply regex: Set a regex to limit scope
List notes in scope: List notes that will be processed.
Limit the scope of notes to be processed with one or more filters.
Path filter (regex): Limit scope based on the path or filename
Metadata filter: Limit scope based on a key or key/value pair
• Tag filter: Limit scope based on an in-text tag
• List and clear filters: List all current filters and clear one or all
• List notes in scope: List notes that will be processed.
[bold underline]Add Metadata[/]
• Add metadata to the frontmatter
[dim]Add to inline metadata (Not yet implemented)[/]
[dim]Add to inline tag (Not yet implemented)[/]
Add new metadata to your vault.
Add new metadata to the frontmatter
• Add new inline metadata - Set `insert_location` in the config to
control where the new metadata is inserted. (Default: Bottom)
• Add new inline tag - Set `insert_location` in the config to
control where the new tag is inserted. (Default: Bottom)
[bold underline]Rename Metadata[/]
Rename either a key and all associated values, a specific value within a key. or an in-text tag.
• Rename a key
• Rename a value
• rename an inline tag
[bold underline]Delete Metadata[/]
Delete either a key and all associated values, or a specific value.
• Delete a key and associated values
• Delete a value from a key
• Delete an inline tag
[bold underline]Review Changes[/]
Prior to committing changes, review all changes that will be made.
• View a diff of the changes that will be made
[bold underline]Commit Changes[/]
Write the changes to disk. This step is not undoable.
• Commit changes to the vault
"""
@@ -155,6 +184,15 @@ def main(
vault_to_use = next(vault for vault in config.vaults if vault.name == vault_name)
application = Application(dry_run=dry_run, config=vault_to_use)
if export_json is not None:
path = Path(export_json).expanduser().resolve()
application.noninteractive_export_json(path)
raise typer.Exit(code=0)
if export_csv is not None:
path = Path(export_json).expanduser().resolve()
application.noninteractive_export_csv(path)
raise typer.Exit(code=0)
application.application_main()

View File

@@ -1,5 +1,9 @@
"""Shared models."""
from obsidian_metadata.models.enums import MetadataType # isort: skip
from obsidian_metadata.models.enums import (
InsertLocation,
MetadataType,
)
from obsidian_metadata.models.patterns import Patterns # isort: skip
from obsidian_metadata.models.metadata import (
Frontmatter,
@@ -8,7 +12,7 @@ from obsidian_metadata.models.metadata import (
VaultMetadata,
)
from obsidian_metadata.models.notes import Note
from obsidian_metadata.models.vault import Vault
from obsidian_metadata.models.vault import Vault, VaultFilter
from obsidian_metadata.models.application import Application # isort: skip
@@ -17,10 +21,12 @@ __all__ = [
"Frontmatter",
"InlineMetadata",
"InlineTags",
"InsertLocation",
"LoggerManager",
"MetadataType",
"Note",
"Patterns",
"Vault",
"VaultFilter",
"VaultMetadata",
]

View File

@@ -2,13 +2,15 @@
from typing import Any
from pathlib import Path
import questionary
from rich import print
from textwrap import dedent
from rich import box
from rich.console import Console
from rich.table import Table
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import Patterns, Vault
from obsidian_metadata.models import Patterns, Vault, VaultFilter
from obsidian_metadata._utils import alerts
from obsidian_metadata.models.questions import Questions
from obsidian_metadata.models.enums import MetadataType
@@ -28,10 +30,24 @@ class Application:
self.config = config
self.dry_run = dry_run
self.questions = Questions()
self.filters: list[VaultFilter] = []
def _load_vault(self) -> None:
"""Load the vault."""
if len(self.filters) == 0:
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run)
else:
self.vault = Vault(config=self.config, dry_run=self.dry_run, filters=self.filters)
alerts.success(
f"Loaded {len(self.vault.notes_in_scope)} notes from {len(self.vault.all_notes)} total notes"
)
self.questions = Questions(vault=self.vault)
def application_main(self) -> None:
"""Questions for the main application."""
self.load_vault()
self._load_vault()
while True:
self.vault.info()
@@ -52,11 +68,7 @@ class Application:
case "review_changes":
self.review_changes()
case "commit_changes":
if self.commit_changes():
break
log.error("Commit failed. Please run with -vvv for more info.")
break
self.commit_changes()
case _:
break
@@ -65,79 +77,153 @@ class Application:
def application_add_metadata(self) -> None:
"""Add metadata."""
help_text = """
USAGE | Add Metadata
[dim]Add new metadata to your vault. Currently only supports
adding to the frontmatter of a note.[/]
"""
print(dedent(help_text))
alerts.usage(
"Add new metadata to your vault. Currently only supports adding to the frontmatter of a note."
)
area = self.questions.ask_area()
match area:
case MetadataType.FRONTMATTER:
case MetadataType.FRONTMATTER | MetadataType.INLINE:
key = self.questions.ask_new_key(question="Enter the key for the new metadata")
if key is None:
if key is None: # pragma: no cover
return
value = self.questions.ask_new_value(
question="Enter the value for the new metadata"
)
if value is None:
if value is None: # pragma: no cover
return
num_changed = self.vault.add_metadata(area, key, value)
if num_changed == 0:
num_changed = self.vault.add_metadata(
area=area, key=key, value=value, location=self.vault.insert_location
)
if num_changed == 0: # pragma: no cover
alerts.warning(f"No notes were changed")
return
alerts.success(f"Added metadata to {num_changed} notes")
case MetadataType.INLINE:
alerts.warning(f"Adding metadata to {area} is not supported yet")
case MetadataType.TAGS:
alerts.warning(f"Adding metadata to {area} is not supported yet")
tag = self.questions.ask_new_tag()
if tag is None: # pragma: no cover
return
case _:
num_changed = self.vault.add_metadata(
area=area, value=tag, location=self.vault.insert_location
)
if num_changed == 0: # pragma: no cover
alerts.warning(f"No notes were changed")
return
alerts.success(f"Added metadata to {num_changed} notes")
case _: # pragma: no cover
return
def application_filter(self) -> None:
"""Filter notes."""
help_text = """
USAGE | Filter Notes
[dim]Enter a regex to filter notes by path. This allows you to
specify a subset of notes to update. Leave empty to include
all markdown files.[/]
"""
print(dedent(help_text))
alerts.usage("Limit the scope of notes to be processed with one or more filters.")
choices = [
{"name": "Apply regex filter", "value": "apply_filter"},
{"name": "Apply new regex path filter", "value": "apply_path_filter"},
{"name": "Apply new metadata filter", "value": "apply_metadata_filter"},
{"name": "Apply new in-text tag filter", "value": "apply_tag_filter"},
{"name": "List and clear filters", "value": "list_filters"},
{"name": "List notes in scope", "value": "list_notes"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Select an action"):
case "apply_filter":
path_filter = self.questions.ask_filter_path()
if path_filter is None:
case "apply_path_filter":
path = self.questions.ask_filter_path()
if path is None or path == "": # pragma: no cover
return
if path_filter == "":
path_filter = None
self.filters.append(VaultFilter(path_filter=path))
self._load_vault()
self.load_vault(path_filter=path_filter)
case "apply_metadata_filter":
key = self.questions.ask_existing_key()
if key is None: # pragma: no cover
return
total_notes = self.vault.num_notes() + self.vault.num_excluded_notes()
if path_filter is None:
alerts.success(f"Loaded all {total_notes} total notes")
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_existing_value(
question="Enter the value for the metadata filter",
)
if value is None: # pragma: no cover
return
if value == "":
self.filters.append(VaultFilter(key_filter=key))
else:
alerts.success(
f"Loaded {self.vault.num_notes()} notes from {total_notes} total notes"
)
self.filters.append(VaultFilter(key_filter=key, value_filter=value))
self._load_vault()
case "apply_tag_filter":
tag = self.questions.ask_existing_inline_tag()
if tag is None or tag == "":
return
self.filters.append(VaultFilter(tag_filter=tag))
self._load_vault()
case "list_filters":
if len(self.filters) == 0:
alerts.notice("No filters have been applied")
return
print("")
table = Table(
"Opt",
"Filter",
"Type",
title="Current Filters",
show_header=False,
box=box.HORIZONTALS,
)
for _n, filter in enumerate(self.filters, start=1):
if filter.path_filter is not None:
table.add_row(
str(_n),
f"Path regex: [tan bold]{filter.path_filter}",
end_section=bool(_n == len(self.filters)),
)
elif filter.tag_filter is not None:
table.add_row(
str(_n),
f"Tag filter: [tan bold]{filter.tag_filter}",
end_section=bool(_n == len(self.filters)),
)
elif filter.key_filter is not None and filter.value_filter is None:
table.add_row(
str(_n),
f"Key filter: [tan bold]{filter.key_filter}",
end_section=bool(_n == len(self.filters)),
)
elif filter.key_filter is not None and filter.value_filter is not None:
table.add_row(
str(_n),
f"Key/Value : [tan bold]{filter.key_filter}={filter.value_filter}",
end_section=bool(_n == len(self.filters)),
)
table.add_row(f"{len(self.filters) + 1}", "Clear All")
table.add_row(f"{len(self.filters) + 2}", "Return to Main Menu")
Console().print(table)
num = self.questions.ask_number(
question="Enter the number of the filter to clear"
)
if num is None:
return
if int(num) <= len(self.filters):
self.filters.pop(int(num) - 1)
self._load_vault()
return
if int(num) == len(self.filters) + 1:
self.filters = []
self._load_vault()
return
case "list_notes":
self.vault.list_editable_notes()
@@ -147,32 +233,62 @@ class Application:
def application_inspect_metadata(self) -> None:
"""View metadata."""
help_text = """
USAGE | View Metadata
[dim]Inspect the metadata in your vault. Note, uncommitted
changes will be reflected in these reports[/]
"""
print(dedent(help_text))
alerts.usage(
"Inspect the metadata in your vault. Note, uncommitted changes will be reflected in these reports"
)
choices = [
{"name": "View all metadata", "value": "all_metadata"},
{"name": "View all frontmatter", "value": "all_frontmatter"},
{"name": "View all inline_metadata", "value": "all_inline"},
{"name": "View all keys", "value": "all_keys"},
{"name": "View all inline tags", "value": "all_tags"},
questionary.Separator(),
{"name": "Write all metadata to CSV", "value": "export_csv"},
{"name": "Write all metadata to JSON file", "value": "export_json"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Select a vault action"):
case "all_metadata":
self.vault.metadata.print_metadata()
print("")
self.vault.metadata.print_metadata(area=MetadataType.ALL)
print("")
case "all_frontmatter":
print("")
self.vault.metadata.print_metadata(area=MetadataType.FRONTMATTER)
print("")
case "all_inline":
print("")
self.vault.metadata.print_metadata(area=MetadataType.INLINE)
print("")
case "all_keys":
print("")
self.vault.metadata.print_metadata(area=MetadataType.KEYS)
print("")
case "all_tags":
print("")
self.vault.metadata.print_metadata(area=MetadataType.TAGS)
print("")
case "export_csv":
path = self.questions.ask_path(question="Enter a path for the CSV file")
if path is None:
return
self.vault.export_metadata(path=path, format="csv")
alerts.success(f"Metadata written to {path}")
case "export_json":
path = self.questions.ask_path(question="Enter a path for the JSON file")
if path is None:
return
self.vault.export_metadata(path=path, format="json")
alerts.success(f"Metadata written to {path}")
case _:
return
def application_vault(self) -> None:
"""Vault actions."""
help_text = """
USAGE | Vault Actions
[dim]Create or delete a backup of your vault.[/]
"""
print(dedent(help_text))
alerts.usage("Create or delete a backup of your vault.")
choices = [
{"name": "Backup vault", "value": "backup_vault"},
@@ -191,12 +307,7 @@ class Application:
return
def application_delete_metadata(self) -> None:
help_text = """
USAGE | Delete Metadata
[dim]Delete either a key and all associated values,
or a specific value.[/]
"""
print(dedent(help_text))
alerts.usage("Delete either a key and all associated values, or a specific value.")
choices = [
{"name": "Delete key", "value": "delete_key"},
@@ -214,16 +325,12 @@ class Application:
self.delete_value()
case "delete_inline_tag":
self.delete_inline_tag()
case _:
case _: # pragma: no cover
return
def application_rename_metadata(self) -> None:
"""Rename metadata."""
help_text = """
USAGE | Rename Metadata
[dim]Select the type of metadata to rename.[/]
"""
print(dedent(help_text))
alerts.usage("Select the type of metadata to rename.")
choices = [
{"name": "Rename key", "value": "rename_key"},
@@ -241,7 +348,7 @@ class Application:
self.rename_value()
case "rename_inline_tag":
self.rename_inline_tag()
case _:
case _: # pragma: no cover
return
def commit_changes(self) -> bool:
@@ -264,12 +371,13 @@ class Application:
self.vault.backup()
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
self.vault.commit_changes()
self.vault.write()
if not self.dry_run:
alerts.success(f"{len(changed_notes)} changes committed to disk. Exiting")
return True
return False
return True
def delete_inline_tag(self) -> None:
"""Delete an inline tag."""
@@ -288,7 +396,7 @@ class Application:
key_to_delete = self.questions.ask_existing_keys_regex(
question="Regex for the key(s) you'd like to delete?"
)
if key_to_delete is None:
if key_to_delete is None: # pragma: no cover
return
num_changed = self.vault.delete_metadata(key_to_delete)
@@ -305,12 +413,12 @@ class Application:
def delete_value(self) -> None:
"""Delete a value from the vault."""
key = self.questions.ask_existing_key(question="Which key contains the value to delete?")
if key is None:
if key is None: # pragma: no cover
return
questions2 = Questions(vault=self.vault, key=key)
value = questions2.ask_existing_value_regex(question="Regex for the value to delete")
if value is None:
if value is None: # pragma: no cover
return
num_changed = self.vault.delete_metadata(key, value)
@@ -324,15 +432,17 @@ class Application:
return
def load_vault(self, path_filter: str = None) -> None:
"""Load the vault.
def noninteractive_export_csv(self, path: Path) -> None:
"""Export the vault metadata to CSV."""
self._load_vault()
self.vault.export_metadata(format="json", path=str(path))
alerts.success(f"Exported metadata to {path}")
Args:
path_filter (str, optional): Regex to filter notes by path.
"""
self.vault: Vault = Vault(config=self.config, dry_run=self.dry_run, path_filter=path_filter)
log.info(f"Indexed {self.vault.num_notes()} notes from {self.vault.vault_path}")
self.questions = Questions(vault=self.vault)
def noninteractive_export_json(self, path: Path) -> None:
"""Export the vault metadata to JSON."""
self._load_vault()
self.vault.export_metadata(format="json", path=str(path))
alerts.success(f"Exported metadata to {path}")
def rename_key(self) -> None:
"""Renames a key in the vault."""
@@ -340,11 +450,11 @@ class Application:
original_key = self.questions.ask_existing_key(
question="Which key would you like to rename?"
)
if original_key is None:
if original_key is None: # pragma: no cover
return
new_key = self.questions.ask_new_key()
if new_key is None:
if new_key is None: # pragma: no cover
return
num_changed = self.vault.rename_metadata(original_key, new_key)
@@ -360,11 +470,11 @@ class Application:
"""Rename an inline tag."""
original_tag = self.questions.ask_existing_inline_tag(question="Which tag to rename?")
if original_tag is None:
if original_tag is None: # pragma: no cover
return
new_tag = self.questions.ask_new_tag("New tag")
if new_tag is None:
if new_tag is None: # pragma: no cover
return
num_changed = self.vault.rename_inline_tag(original_tag, new_tag)
@@ -380,16 +490,16 @@ class Application:
def rename_value(self) -> None:
"""Rename a value in the vault."""
key = self.questions.ask_existing_key(question="Which key contains the value to rename?")
if key is None:
if key is None: # pragma: no cover
return
question_key = Questions(vault=self.vault, key=key)
value = question_key.ask_existing_value(question="Which value would you like to rename?")
if value is None:
if value is None: # pragma: no cover
return
new_value = question_key.ask_new_value()
if new_value is None:
if new_value is None: # pragma: no cover
return
num_changes = self.vault.rename_metadata(key, value, new_value)
@@ -411,7 +521,7 @@ class Application:
answer = self.questions.ask_confirm(
question="View diffs of individual files?", default=False
)
if not answer:
if not answer: # pragma: no cover
return
choices: list[dict[str, Any] | questionary.Separator] = [questionary.Separator()]

View File

@@ -9,3 +9,19 @@ class MetadataType(Enum):
FRONTMATTER = "Frontmatter"
INLINE = "Inline Metadata"
TAGS = "Inline Tags"
KEYS = "Metadata Keys Only"
ALL = "All Metadata"
class InsertLocation(Enum):
"""Location to add metadata to notes.
TOP: Directly after frontmatter.
AFTER_TITLE: After a header following frontmatter.
BOTTOM: The bottom of the note
"""
TOP = "Top"
AFTER_TITLE = "Header"
BOTTOM = "Bottom"

View File

@@ -9,16 +9,20 @@ from rich.console import Console
from rich.table import Table
from ruamel.yaml import YAML
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils import (
clean_dictionary,
dict_contains,
dict_values_to_lists_strings,
merge_dictionaries,
remove_markdown_sections,
)
from obsidian_metadata.models import Patterns # isort: ignore
from obsidian_metadata.models.enums import MetadataType
PATTERNS = Patterns()
INLINE_TAG_KEY: str = "Inline Tags"
INLINE_TAG_KEY: str = "inline_tag"
class VaultMetadata:
@@ -26,50 +30,83 @@ class VaultMetadata:
def __init__(self) -> None:
self.dict: dict[str, list[str]] = {}
self.frontmatter: dict[str, list[str]] = {}
self.inline_metadata: dict[str, list[str]] = {}
self.tags: list[str] = []
def __repr__(self) -> str:
"""Representation of all metadata."""
return str(self.dict)
def add_metadata(self, metadata: dict[str, list[str]]) -> None:
"""Add metadata to the vault. Takes a dictionary as input and merges it with the existing metadata. Does not overwrite existing keys.
def index_metadata(
self, area: MetadataType, metadata: dict[str, list[str]] | list[str]
) -> None:
"""Index pre-existing metadata in the vault. Takes a dictionary as input and merges it with the existing metadata. Does not overwrite existing keys.
Args:
area (MetadataType): Type of metadata.
metadata (dict): Metadata to add.
"""
existing_metadata = self.dict
if isinstance(metadata, dict):
new_metadata = clean_dictionary(metadata)
self.dict = merge_dictionaries(self.dict.copy(), new_metadata.copy())
new_metadata = clean_dictionary(metadata)
if area == MetadataType.FRONTMATTER:
self.frontmatter = merge_dictionaries(self.frontmatter.copy(), new_metadata.copy())
for k, v in new_metadata.items():
if k in existing_metadata:
if isinstance(v, list):
existing_metadata[k].extend(v)
else:
existing_metadata[k] = v
if area == MetadataType.INLINE:
self.inline_metadata = merge_dictionaries(
self.inline_metadata.copy(), new_metadata.copy()
)
for k, v in existing_metadata.items():
if isinstance(v, list):
existing_metadata[k] = sorted(set(v))
elif isinstance(v, dict):
for kk, vv in v.items():
if isinstance(vv, list):
v[kk] = sorted(set(vv))
if area == MetadataType.TAGS and isinstance(metadata, list):
self.tags.extend(metadata)
self.tags = sorted({s.strip("#") for s in self.tags})
self.dict = dict(sorted(existing_metadata.items()))
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
def contains(
self, area: MetadataType, key: str = None, value: str = None, is_regex: bool = False
) -> bool:
"""Check if a key and/or a value exists in the metadata.
Args:
key (str): Key to check.
area (MetadataType): Type of metadata to check.
key (str, optional): Key to check.
value (str, optional): Value to check.
is_regex (bool, optional): Use regex to check. Defaults to False.
Returns:
bool: True if the key exists.
Raises:
ValueError: Key must be provided when checking for a key's existence.
ValueError: Value must be provided when checking for a tag's existence.
"""
return dict_contains(self.dict, key, value, is_regex)
if area != MetadataType.TAGS and key is None:
raise ValueError("Key must be provided when checking for a key's existence.")
match area: # noqa: E999
case MetadataType.ALL:
if dict_contains(self.dict, key, value, is_regex):
return True
if key is None and value is not None:
if is_regex:
return any(re.search(value, tag) for tag in self.tags)
return value in self.tags
case MetadataType.FRONTMATTER:
return dict_contains(self.frontmatter, key, value, is_regex)
case MetadataType.INLINE:
return dict_contains(self.inline_metadata, key, value, is_regex)
case MetadataType.KEYS:
return dict_contains(self.dict, key, value, is_regex)
case MetadataType.TAGS:
if value is None:
raise ValueError("Value must be provided when checking for a tag's existence.")
if is_regex:
return any(re.search(value, tag) for tag in self.tags)
return value in self.tags
return False
def delete(self, key: str, value_to_delete: str = None) -> bool:
"""Delete a key or a key's value from the metadata. Regex is supported to allow deleting more than one key or value.
@@ -99,37 +136,55 @@ class VaultMetadata:
return False
def print_keys(self) -> None:
"""Print all metadata keys."""
columns = Columns(
sorted(self.dict.keys()),
equal=True,
expand=True,
title="All metadata keys in Obsidian vault",
)
print(columns)
def print_metadata(self, area: MetadataType) -> None:
"""Print metadata to the terminal.
def print_metadata(self) -> None:
"""Print all metadata."""
table = Table(show_footer=False, show_lines=True)
table.add_column("Keys")
table.add_column("Values")
for key, value in sorted(self.dict.items()):
values: str | dict[str, list[str]] = (
"\n".join(sorted(value)) if isinstance(value, list) else value
Args:
area (MetadataType): Type of metadata to print
"""
dict_to_print: dict[str, list[str]] = None
list_to_print: list[str] = None
match area:
case MetadataType.INLINE:
dict_to_print = self.inline_metadata.copy()
header = "All inline metadata"
case MetadataType.FRONTMATTER:
dict_to_print = self.frontmatter.copy()
header = "All frontmatter"
case MetadataType.TAGS:
list_to_print = []
for tag in self.tags:
list_to_print.append(f"#{tag}")
header = "All inline tags"
case MetadataType.KEYS:
list_to_print = sorted(self.dict.keys())
header = "All Keys"
case MetadataType.ALL:
dict_to_print = self.dict.copy()
list_to_print = []
for tag in self.tags:
list_to_print.append(f"#{tag}")
header = "All metadata"
if dict_to_print is not None:
table = Table(title=header, show_footer=False, show_lines=True)
table.add_column("Keys")
table.add_column("Values")
for key, value in sorted(dict_to_print.items()):
values: str | dict[str, list[str]] = (
"\n".join(sorted(value)) if isinstance(value, list) else value
)
table.add_row(f"[bold]{key}[/]", str(values))
Console().print(table)
if list_to_print is not None:
columns = Columns(
sorted(list_to_print),
equal=True,
expand=True,
title=header if area != MetadataType.ALL else "All inline tags",
)
table.add_row(f"[bold]{key}[/]", str(values))
Console().print(table)
def print_tags(self) -> None:
"""Print all tags."""
columns = Columns(
sorted(self.dict["tags"]),
equal=True,
expand=True,
title="All tags in Obsidian vault",
)
print(columns)
print(columns)
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
@@ -160,7 +215,6 @@ class Frontmatter:
"""Representation of frontmatter metadata."""
def __init__(self, file_content: str):
self.dict: dict[str, list[str]] = self._grab_note_frontmatter(file_content)
self.dict_original: dict[str, list[str]] = self.dict.copy()
@@ -182,7 +236,7 @@ class Frontmatter:
dict: Metadata from the note.
"""
try:
frontmatter_block: str = PATTERNS.frontmatt_block_no_separators.search(
frontmatter_block: str = PATTERNS.frontmatt_block_strip_separators.search(
file_content
).group("frontmatter")
except AttributeError:
@@ -336,8 +390,7 @@ class InlineMetadata:
"""Representation of inline metadata in the form of `key:: value`."""
def __init__(self, file_content: str):
self.dict: dict[str, list[str]] = self._grab_inline_metadata(file_content)
self.dict: dict[str, list[str]] = self.grab_inline_metadata(file_content)
self.dict_original: dict[str, list[str]] = self.dict.copy()
def __repr__(self) -> str: # pragma: no cover
@@ -348,32 +401,8 @@ class InlineMetadata:
"""
return f"InlineMetadata(inline_metadata={self.dict})"
def _grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
"""Grab inline metadata from a note.
Returns:
dict[str, str]: Inline metadata from the note.
"""
content = remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
strip_frontmatter=True,
)
all_results = PATTERNS.find_inline_metadata.findall(content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for (k, v) in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
inline_metadata[k] = [str(v)]
return clean_dictionary(inline_metadata)
def add(self, key: str, value: str | list[str] = None) -> bool:
"""Add a key and value to the frontmatter.
def add(self, key: str, value: str = None) -> bool:
"""Add a key and value to the inline metadata.
Args:
key (str): Key to add.
@@ -382,8 +411,23 @@ class InlineMetadata:
Returns:
bool: True if the metadata was added
"""
# TODO: implement adding to inline metadata which requires knowing where in the note the metadata is to be added. In addition, unlike frontmatter, it is not possible to have multiple values for a key.
pass
if value is None or value == "" or value == "None":
if key not in self.dict:
self.dict[key] = []
return True
return False
if key not in self.dict:
self.dict[key] = [value]
return True
if key in self.dict and len(self.dict[key]) > 0:
if value in self.dict[key]:
return False
raise ValueError(f"'{key}' not empty")
self.dict[key].append(value)
return True
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a key or value exists in the inline metadata.
@@ -426,6 +470,30 @@ class InlineMetadata:
return False
def grab_inline_metadata(self, file_content: str) -> dict[str, list[str]]:
"""Grab inline metadata from a note.
Returns:
dict[str, str]: Inline metadata from the note.
"""
content = remove_markdown_sections(
file_content,
strip_codeblocks=True,
strip_inlinecode=True,
strip_frontmatter=True,
)
all_results = PATTERNS.find_inline_metadata.findall(content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
inline_metadata: dict[str, list[str]] = {}
for k, v in stripped_null_values:
if k in inline_metadata:
inline_metadata[k].append(str(v))
else:
inline_metadata[k] = [str(v)]
return clean_dictionary(inline_metadata)
def has_changes(self) -> bool:
"""Check if the metadata has changes.
@@ -462,7 +530,6 @@ class InlineTags:
"""Representation of inline tags."""
def __init__(self, file_content: str):
self.metadata_key = INLINE_TAG_KEY
self.list: list[str] = self._grab_inline_tags(file_content)
self.list_original: list[str] = self.list.copy()
@@ -494,6 +561,23 @@ class InlineTags:
)
)
def add(self, new_tag: str) -> bool:
"""Add a new inline tag.
Args:
new_tag (str): Tag to add.
Returns:
bool: True if a tag was added.
"""
if new_tag in self.list:
return False
new_list = self.list.copy()
new_list.append(new_tag)
self.list = sorted(new_list)
return True
def contains(self, tag: str, is_regex: bool = False) -> bool:
"""Check if a tag exists in the metadata.

View File

@@ -9,13 +9,13 @@ import rich.repr
import typer
from rich.console import Console
from rich.table import Table
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import (
Frontmatter,
InlineMetadata,
InlineTags,
InsertLocation,
MetadataType,
Patterns,
)
@@ -65,7 +65,7 @@ class Note:
yield "inline_metadata", self.inline_metadata
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.
"""Delete 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.
@@ -74,7 +74,7 @@ class Note:
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:
for _k, _v in stripped_null_values:
if re.search(key, _k):
if value is None:
_k = re.escape(_k)
@@ -88,7 +88,7 @@ class Note:
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:
"""Replaces the inline metadata in the note with the current inline metadata object.
"""Replace the inline metadata in the note with the current inline metadata object.
Args:
key (str): Key to rename.
@@ -99,7 +99,7 @@ class Note:
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:
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):
@@ -117,49 +117,45 @@ class Note:
_v = re.escape(_v)
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> bool:
"""Adds metadata to the note.
def add_metadata(
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.
Args:
area (MetadataType): Area to add metadata to.
key (str): Key to add.
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.
"""
if area is MetadataType.FRONTMATTER and self.frontmatter.add(key, value):
self.replace_frontmatter()
self.update_frontmatter()
return True
if area is MetadataType.INLINE:
# TODO: implement adding to inline metadata
pass
try:
if area is MetadataType.INLINE and self.inline_metadata.add(key, str(value)):
line = f"{key}:: " if value is None else f"{key}:: {value}"
self.insert(new_string=line, location=location)
return True
if area is MetadataType.TAGS:
# TODO: implement adding to intext tags
pass
except ValueError as e:
log.warning(f"Could not add metadata to {self.note_path}: {e}")
return False
if area is MetadataType.TAGS and self.inline_tags.add(str(value)):
line = f"#{value}"
self.insert(new_string=line, location=location)
return True
return False
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.
@@ -198,7 +194,7 @@ class Note:
return False
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.
"""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.
@@ -220,7 +216,7 @@ class Note:
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.
"""Delete a key or key-value pair from the note's metadata. Regex is supported.
If no value is provided, will delete an entire key.
@@ -235,14 +231,14 @@ class Note:
if value is None:
if self.frontmatter.delete(key):
self.replace_frontmatter()
self.update_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()
self.update_frontmatter()
changed_value = True
if self.inline_metadata.delete(key, value):
self._delete_inline_metadata(key, value)
@@ -253,7 +249,7 @@ class Note:
return False
def has_changes(self) -> bool:
"""Checks if the note has been updated.
"""Check if the note has been updated.
Returns:
bool: Whether the note has been updated.
@@ -272,12 +268,59 @@ 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: # noqa: E999
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:
"""Prints the note to the console."""
"""Print 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."""
"""Print a diff of the note's original state and it's new state."""
a = self.original_file_content.splitlines()
b = self.file_content.splitlines()
@@ -293,30 +336,8 @@ class Note:
Console().print(table)
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
current_frontmatter = re.escape(current_frontmatter)
self.sub(current_frontmatter, new_frontmatter, 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.
"""Rename an inline tag from the note ONLY if it's not in the metadata as well.
Args:
tag_1 (str): Tag to rename.
@@ -336,7 +357,7 @@ class Note:
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.
"""Rename a key or key-value pair in the note's metadata.
If no value is provided, will rename an entire key.
@@ -351,14 +372,14 @@ class Note:
changed_value: bool = False
if value_2 is None:
if self.frontmatter.rename(key, value_1):
self.replace_frontmatter()
self.update_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()
self.update_frontmatter()
changed_value = True
if self.inline_metadata.rename(key, value_1, value_2):
self._rename_inline_metadata(key, value_1, value_2)
@@ -382,8 +403,30 @@ class Note:
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
def update_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(
"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
current_frontmatter = re.escape(current_frontmatter)
self.sub(current_frontmatter, new_frontmatter, is_regex=True)
def write(self, path: Path = None) -> None:
"""Writes the note's content to disk.
"""Write the note's content to disk.
Args:
path (Path): Path to write the note to. Defaults to the note's path.
@@ -392,6 +435,9 @@ class Note:
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:

View File

@@ -1,8 +1,9 @@
"""Regexes for parsing frontmatter and note content."""
import re
from dataclasses import dataclass
from typing import Pattern
import regex as re
from regex import Pattern
@dataclass
@@ -11,8 +12,9 @@ class Patterns:
find_inline_tags: Pattern[str] = re.compile(
r"""
(?:^|[ \|_,;:\*\(\)\[\]\\\.]) # Before tag is start of line or separator
\#([^ \|,;:\*\(\)\[\]\\\.\n#&]+) # Match tag until separator or end of line
(?:^|[ \|_,;:\*\)\[\]\\\.]|(?<!\])\() # Before tag is start of line or separator
(?<!\/\/[\w\d_\.\(\)\/&_-]+) # Before tag is not a link
\#([^ \|,;:\*\(\)\[\]\\\.\n#&]+) # Match tag until separator or end of line
""",
re.MULTILINE | re.X,
)
@@ -20,23 +22,41 @@ class Patterns:
find_inline_metadata: Pattern[str] = re.compile(
r""" # First look for in-text key values
(?:^\[| \[) # Find key with starting bracket
([-_\w\d\/\*\u263a-\U0001f645]+?)::[ ]? # Find key
([-_\w\d\/\*\u263a-\U0001f999]+?)::[ ]? # Find key
(.*?)\] # Find value until closing bracket
| # Else look for key values at start of line
(?:^|[^ \w\d]+| \[) # Any non-word or non-digit character
([-_\w\d\/\*\u263a-\U0001f645]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
([-_\w\d\/\*\u263a-\U0001f9995]+?)::(?!\n)(?:[ ](?!\n))? # Capture the key if not a new line
(.*?)$ # Capture the value
""",
re.X | re.MULTILINE,
)
frontmatt_block_with_separators: Pattern[str] = re.compile(
r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL
)
frontmatt_block_no_separators: Pattern[str] = re.compile(
frontmatter_block: Pattern[str] = re.compile(r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL)
frontmatt_block_strip_separators: Pattern[str] = re.compile(
r"^\s*---(?P<frontmatter>.*?)---", flags=re.DOTALL
)
# This pattern will return a tuple of 4 values, two will be empty and will need to be stripped before processing further
validate_key_text: Pattern[str] = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f645]")
top_with_header: Pattern[str] = re.compile(
r"""^\s* # Start of note
(?P<top> # Capture the top of the note
(---.*?---)? # Frontmatter, if it exists
\s* # Any whitespace
( # Full header, if it exists
\#+[ ] # Match start of any header level
( # Text of header
[\w\d]+ # Word or digit
| # Or
[\[\]\(\)\+\{\}\"'\-\.\/\*\$\| ]+ # Special characters
| # Or
[\u263a-\U0001f999]+ # Emoji
)+ # End of header text
)? # End of full header
) # End capture group
""",
flags=re.DOTALL | re.X,
)
validate_key_text: Pattern[str] = re.compile(r"[^-_\w\d\/\*\u263a-\U0001f999]")
validate_tag_text: Pattern[str] = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")

View File

@@ -12,12 +12,21 @@ from typing import Any
import questionary
import typer
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.enums import InsertLocation, MetadataType
from obsidian_metadata.models.patterns import Patterns
from obsidian_metadata.models.vault import Vault
PATTERNS = Patterns()
# Reset the default style of the questionary prompts qmark
questionary.prompts.checkbox.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.common.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.confirm.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.confirm.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.path.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.select.DEFAULT_STYLE = questionary.Style([("qmark", "")])
questionary.prompts.text.DEFAULT_STYLE = questionary.Style([("qmark", "")])
class Questions:
"""Class for asking questions to the user and validating responses with questionary."""
@@ -41,7 +50,7 @@ class Questions:
@staticmethod
def _validate_valid_dir(path: str) -> bool | str:
"""Validates a valid directory.
"""Validate a valid directory.
Returns:
bool | str: True if the path is valid, otherwise a string with the error message.
@@ -64,11 +73,12 @@ class Questions:
"""
self.style = questionary.Style(
[
("qmark", "fg:#808080 bold"),
("qmark", "bold"),
("question", "bold"),
("separator", "fg:#808080"),
("answer", "fg:#FF9D00 bold"),
("instruction", "fg:#808080"),
("highlighted", "fg:#c0c0c0 bold reverse"),
("highlighted", "bold underline"),
("text", ""),
("pointer", "bold"),
]
@@ -77,7 +87,7 @@ class Questions:
self.key = key
def _validate_existing_inline_tag(self, text: str) -> bool | str:
"""Validates an existing inline tag.
"""Validate an existing inline tag.
Returns:
bool | str: True if the tag is valid, otherwise a string with the error message.
@@ -85,13 +95,13 @@ class Questions:
if len(text) < 1:
return "Tag cannot be empty"
if not self.vault.contains_inline_tag(text):
if not self.vault.metadata.contains(area=MetadataType.TAGS, value=text):
return f"'{text}' does not exist as a tag in the vault"
return True
def _validate_key_exists(self, text: str) -> bool | str:
"""Validates a valid key.
"""Validate a valid key.
Returns:
bool | str: True if the key is valid, otherwise a string with the error message.
@@ -99,13 +109,13 @@ class Questions:
if len(text) < 1:
return "Key cannot be empty"
if not self.vault.metadata.contains(text):
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text):
return f"'{text}' does not exist as a key in the vault"
return True
def _validate_key_exists_regex(self, text: str) -> bool | str:
"""Validates a valid key.
"""Validate a valid key.
Returns:
bool | str: True if the key is valid, otherwise a string with the error message.
@@ -118,7 +128,7 @@ class Questions:
except re.error as error:
return f"Invalid regex: {error}"
if not self.vault.metadata.contains(text, is_regex=True):
if not self.vault.metadata.contains(area=MetadataType.KEYS, key=text, is_regex=True):
return f"'{text}' does not exist as a key in the vault"
return True
@@ -169,13 +179,29 @@ class Questions:
if len(text) < 1:
return "Value cannot be empty"
if self.key is not None and self.vault.metadata.contains(self.key, text):
if self.key is not None and self.vault.metadata.contains(
area=MetadataType.ALL, key=self.key, value=text
):
return f"{self.key}:{text} already exists"
return True
def _validate_number(self, text: str) -> bool | str:
"""Validate a number.
Args:
text (str): The number to validate.
Returns:
bool | str: True if the number is valid, otherwise a string with the error message.
"""
if not text.isdigit():
return "Must be an integer"
return True
def _validate_valid_vault_regex(self, text: str) -> bool | str:
"""Validates a valid regex.
"""Validate a valid regex.
Returns:
bool | str: True if the regex is valid, otherwise a string with the error message.
@@ -202,10 +228,12 @@ class Questions:
Returns:
bool | str: True if the value is valid, otherwise a string with the error message.
"""
if len(text) < 1:
return "Value cannot be empty"
if len(text) == 0:
return True
if self.key is not None and not self.vault.metadata.contains(self.key, text):
if self.key is not None and not self.vault.metadata.contains(
area=MetadataType.ALL, key=self.key, value=text
):
return f"{self.key}:{text} does not exist"
return True
@@ -227,11 +255,42 @@ class Questions:
except re.error as error:
return f"Invalid regex: {error}"
if self.key is not None and not self.vault.metadata.contains(self.key, text, is_regex=True):
if self.key is not None and not self.vault.metadata.contains(
area=MetadataType.ALL, key=self.key, value=text, is_regex=True
):
return f"No values in {self.key} match regex: {text}"
return True
def ask_application_main(self) -> str: # pragma: no cover
"""List for the main application interface.
Args:
style (questionary.Style): The style to use for the question.
Returns:
str: The selected application.
"""
return questionary.select(
"What do you want to do?",
choices=[
{"name": "Vault Actions", "value": "vault_actions"},
{"name": "Inspect Metadata", "value": "inspect_metadata"},
{"name": "Filter Notes in Scope", "value": "filter_notes"},
{"name": "Add Metadata", "value": "add_metadata"},
{"name": "Rename Metadata", "value": "rename_metadata"},
{"name": "Delete Metadata", "value": "delete_metadata"},
questionary.Separator("-------------------------------"),
{"name": "Review Changes", "value": "review_changes"},
{"name": "Commit Changes", "value": "commit_changes"},
questionary.Separator("-------------------------------"),
{"name": "Quit", "value": "abort"},
],
use_shortcuts=False,
style=self.style,
qmark="INPUT |",
).ask()
def ask_area(self) -> MetadataType | str: # pragma: no cover
"""Ask the user for the metadata area to work on.
@@ -347,34 +406,22 @@ class Questions:
qmark="INPUT |",
).ask()
def ask_application_main(self) -> str: # pragma: no cover
"""Selectable list for the main application interface.
Args:
style (questionary.Style): The style to use for the question.
def ask_metadata_location(
self, question: str = "Where in a note should we add metadata"
) -> InsertLocation: # pragma: no cover
"""Ask the user for the location within a note to place new metadata.
Returns:
str: The selected application.
InsertLocation: The location within a note to place new metadata.
"""
return questionary.select(
"What do you want to do?",
choices=[
{"name": "Vault Actions", "value": "vault_actions"},
{"name": "Inspect Metadata", "value": "inspect_metadata"},
{"name": "Filter Notes in Scope", "value": "filter_notes"},
{"name": "Add Metadata", "value": "add_metadata"},
{"name": "Rename Metadata", "value": "rename_metadata"},
{"name": "Delete Metadata", "value": "delete_metadata"},
questionary.Separator("-------------------------------"),
{"name": "Review Changes", "value": "review_changes"},
{"name": "Commit Changes", "value": "commit_changes"},
questionary.Separator("-------------------------------"),
{"name": "Quit", "value": "abort"},
],
use_shortcuts=False,
style=self.style,
qmark="INPUT |",
).ask()
choices = []
for metadata_location in InsertLocation:
choices.append({"name": metadata_location.value, "value": metadata_location})
return self.ask_selection(
choices=choices,
question="Select the location for the metadata",
)
def ask_new_key(self, question: str = "New key name") -> str: # pragma: no cover
"""Ask the user for a new metadata key.
@@ -389,8 +436,12 @@ class Questions:
question, validate=self._validate_new_key, style=self.style, qmark="INPUT |"
).ask()
def ask_new_tag(self, question: str = "New tag name") -> str: # pragma: no cover
"""Ask the user for a new inline tag."""
def ask_new_tag(self, question: str = "Enter a new tag") -> str: # pragma: no cover
"""Ask the user for a new tag.
Args:
question (str, optional): The question to ask. Defaults to "Enter a new tag".
"""
return questionary.text(
question, validate=self._validate_new_tag, style=self.style, qmark="INPUT |"
).ask()
@@ -408,6 +459,30 @@ class Questions:
question, validate=self._validate_new_value, style=self.style, qmark="INPUT |"
).ask()
def ask_number(self, question: str = "Enter a number") -> int: # pragma: no cover
"""Ask the user for a number.
Args:
question (str, optional): The question to ask. Defaults to "Enter a number".
Returns:
int: A number.
"""
return questionary.text(
question, validate=self._validate_number, style=self.style, qmark="INPUT |"
).ask()
def ask_path(self, question: str = "Enter a path") -> str: # pragma: no cover
"""Ask the user for a path.
Args:
question (str, optional): The question to ask. Defaults to "Enter a path".
Returns:
str: A path.
"""
return questionary.path(question, style=self.style, qmark="INPUT |").ask()
def ask_selection(
self, choices: list[Any], question: str = "Select an option"
) -> Any: # pragma: no cover

View File

@@ -1,9 +1,11 @@
"""Obsidian vault representation."""
import csv
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
import json
import rich.repr
from rich import box
from rich.console import Console
@@ -11,10 +13,20 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm
from rich.table import Table
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._config.config import Config, VaultConfig
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import MetadataType, Note, VaultMetadata
from obsidian_metadata.models import InsertLocation, MetadataType, Note, VaultMetadata
@dataclass
class VaultFilter:
"""Vault filters."""
path_filter: str = None
key_filter: str = None
value_filter: str = None
tag_filter: str = None
@rich.repr.auto
@@ -28,8 +40,16 @@ class Vault:
notes (list[Note]): List of all notes in the vault.
"""
def __init__(self, config: VaultConfig, dry_run: bool = False, path_filter: str = None):
def __init__(
self,
config: VaultConfig,
dry_run: bool = False,
filters: list[VaultFilter] = [],
):
self.config = config.config
self.vault_path: Path = config.path
self.name = self.vault_path.name
self.insert_location: InsertLocation = self._find_insert_location()
self.dry_run: bool = dry_run
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
self.exclude_paths: list[Path] = []
@@ -37,8 +57,8 @@ class Vault:
for p in config.exclude_paths:
self.exclude_paths.append(Path(self.vault_path / p))
self.path_filter = path_filter
self.note_paths = self._find_markdown_notes(path_filter)
self.filters = filters
self.all_note_paths = self._find_markdown_notes()
with Progress(
SpinnerColumn(),
@@ -46,9 +66,10 @@ class Vault:
transient=True,
) as progress:
progress.add_task(description="Processing notes...", total=None)
self.notes: list[Note] = [
Note(note_path=p, dry_run=self.dry_run) for p in self.note_paths
self.all_notes: list[Note] = [
Note(note_path=p, dry_run=self.dry_run) for p in self.all_note_paths
]
self.notes_in_scope = self._filter_notes()
self._rebuild_vault_metadata()
@@ -57,33 +78,69 @@ class Vault:
yield "vault_path", self.vault_path
yield "dry_run", self.dry_run
yield "backup_path", self.backup_path
yield "num_notes", self.num_notes()
yield "num_notes", len(self.all_notes)
yield "num_notes_in_scope", len(self.notes_in_scope)
yield "exclude_paths", self.exclude_paths
def _find_markdown_notes(self, path_filter: str = None) -> list[Path]:
"""Build list of all markdown files in the vault.
def _filter_notes(self) -> list[Note]:
"""Filter notes by path and metadata using the filters defined in self.filters.
Args:
path_filter (str, optional): Regex to filter notes by path.
Returns:
list[Note]: List of notes matching the filters.
"""
notes_list = self.all_notes.copy()
for _filter in self.filters:
if _filter.path_filter is not None:
notes_list = [
n
for n in notes_list
if re.search(_filter.path_filter, str(n.note_path.relative_to(self.vault_path)))
]
if _filter.tag_filter is not None:
notes_list = [n for n in notes_list if n.contains_inline_tag(_filter.tag_filter)]
if _filter.key_filter is not None and _filter.value_filter is not None:
notes_list = [
n
for n in notes_list
if n.contains_metadata(_filter.key_filter, _filter.value_filter)
]
if _filter.key_filter is not None and _filter.value_filter is None:
notes_list = [n for n in notes_list if n.contains_metadata(_filter.key_filter)]
return notes_list
def _find_insert_location(self) -> InsertLocation:
"""Find the insert location for a note.
Returns:
InsertLocation: Insert location for the note.
"""
if self.config["insert_location"].upper() == "TOP":
return InsertLocation.TOP
elif self.config["insert_location"].upper() == "HEADER":
return InsertLocation.AFTER_TITLE
elif self.config["insert_location"].upper() == "BOTTOM":
return InsertLocation.BOTTOM
else:
return InsertLocation.BOTTOM
def _find_markdown_notes(self) -> list[Path]:
"""Build list of all markdown files in the vault.
Returns:
list[Path]: List of paths to all matching files in the vault.
"""
notes_list = [
return [
p.resolve()
for p in self.vault_path.glob("**/*")
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"]
and not any(item in p.parents for item in self.exclude_paths)
]
if path_filter is not None:
notes_list = [
p for p in notes_list if re.search(path_filter, str(p.relative_to(self.vault_path)))
]
return notes_list
def _rebuild_vault_metadata(self) -> None:
"""Rebuild vault metadata."""
self.metadata = VaultMetadata()
@@ -93,26 +150,43 @@ class Vault:
transient=True,
) as progress:
progress.add_task(description="Processing notes...", total=None)
for _note in self.notes:
self.metadata.add_metadata(_note.frontmatter.dict)
self.metadata.add_metadata(_note.inline_metadata.dict)
self.metadata.add_metadata({_note.inline_tags.metadata_key: _note.inline_tags.list})
for _note in self.notes_in_scope:
self.metadata.index_metadata(
area=MetadataType.FRONTMATTER, metadata=_note.frontmatter.dict
)
self.metadata.index_metadata(
area=MetadataType.INLINE, metadata=_note.inline_metadata.dict
)
self.metadata.index_metadata(
area=MetadataType.TAGS,
metadata=_note.inline_tags.list,
)
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> int:
"""Add metadata to all notes in the vault.
def add_metadata(
self,
area: MetadataType,
key: str = None,
value: str | list[str] = None,
location: InsertLocation = None,
) -> int:
"""Add metadata to all notes in the vault which do not already contain it.
Args:
area (MetadataType): Area of metadata to add to.
key (str): Key to add.
value (str|list, optional): Value to add.
location (InsertLocation, optional): Location to insert metadata. (Defaults to `vault.config.insert_location`)
Returns:
int: Number of notes updated.
"""
if location is None:
location = self.insert_location
num_changed = 0
for _note in self.notes:
if _note.add_metadata(area, key, value):
for _note in self.notes_in_scope:
if _note.add_metadata(area=area, key=key, value=value, location=location):
num_changed += 1
if num_changed > 0:
@@ -143,33 +217,21 @@ class Vault:
alerts.success(f"Vault backed up to: {self.backup_path}")
def contains_inline_tag(self, tag: str, is_regex: bool = False) -> bool:
"""Check if vault contains the given inline tag.
def commit_changes(self) -> None:
"""Commit changes by writing to disk."""
log.debug("Writing changes to vault...")
if self.dry_run:
for _note in self.notes_in_scope:
if _note.has_changes():
alerts.dryrun(
f"writing changes to {_note.note_path.relative_to(self.vault_path)}"
)
return
Args:
tag (str): Tag to check for.
is_regex (bool, optional): Whether to use regex to match tag.
Returns:
bool: True if tag is found in vault.
"""
return any(_note.contains_inline_tag(tag) for _note in self.notes)
def contains_metadata(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if vault contains the given metadata.
Args:
key (str): Key to check for. If value is None, will check vault for key.
value (str, optional): Value to check for.
is_regex (bool, optional): Whether to use regex to match key/value.
Returns:
bool: True if tag is found in vault.
"""
if value is None:
return self.metadata.contains(key, is_regex=is_regex)
return self.metadata.contains(key, value, is_regex=is_regex)
for _note in self.notes_in_scope:
if _note.has_changes():
log.trace(f"writing to {_note.note_path}")
_note.write()
def delete_backup(self) -> None:
"""Delete the vault backup."""
@@ -193,7 +255,7 @@ class Vault:
"""
num_changed = 0
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.delete_inline_tag(tag):
num_changed += 1
@@ -214,7 +276,7 @@ class Vault:
"""
num_changed = 0
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.delete_metadata(key, value):
num_changed += 1
@@ -223,6 +285,48 @@ class Vault:
return num_changed
def export_metadata(self, path: str, format: str = "csv") -> None:
"""Write metadata to a csv file.
Args:
path (Path): Path to write csv file to.
export_as (str, optional): Export as 'csv' or 'json'. Defaults to "csv".
"""
export_file = Path(path).expanduser().resolve()
match format: # noqa: E999
case "csv":
with open(export_file, "w", encoding="UTF8") as f:
writer = csv.writer(f)
writer.writerow(["Metadata Type", "Key", "Value"])
for key, value in self.metadata.frontmatter.items():
if isinstance(value, list):
if len(value) > 0:
for v in value:
writer.writerow(["frontmatter", key, v])
else:
writer.writerow(["frontmatter", key, v])
for key, value in self.metadata.inline_metadata.items():
if isinstance(value, list):
if len(value) > 0:
for v in value:
writer.writerow(["inline_metadata", key, v])
else:
writer.writerow(["frontmatter", key, v])
for tag in self.metadata.tags:
writer.writerow(["tags", "", f"{tag}"])
case "json":
dict_to_dump = {
"frontmatter": self.metadata.dict,
"inline_metadata": self.metadata.inline_metadata,
"tags": self.metadata.tags,
}
with open(export_file, "w", encoding="UTF8") as f:
json.dump(dict_to_dump, f, indent=4, ensure_ascii=False, sort_keys=True)
def get_changed_notes(self) -> list[Note]:
"""Returns a list of notes that have changes.
@@ -230,7 +334,7 @@ class Vault:
list[Note]: List of notes that have changes.
"""
changed_notes = []
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.has_changes():
changed_notes.append(_note)
@@ -245,36 +349,44 @@ class Vault:
table.add_row("Backup path", str(self.backup_path))
else:
table.add_row("Backup", "None")
table.add_row("Notes in scope", str(self.num_notes()))
table.add_row("Notes in scope", str(len(self.notes_in_scope)))
table.add_row("Notes excluded from scope", str(self.num_excluded_notes()))
table.add_row("Active path filter", str(self.path_filter))
table.add_row("Notes with updates", str(len(self.get_changed_notes())))
table.add_row("Active filters", str(len(self.filters)))
table.add_row("Notes with changes", str(len(self.get_changed_notes())))
Console().print(table)
def list_editable_notes(self) -> None:
"""Print a list of notes within the scope that are being edited."""
table = Table(title="Notes in current scope", show_header=False, box=box.HORIZONTALS)
for _n, _note in enumerate(self.notes, start=1):
for _n, _note in enumerate(self.notes_in_scope, start=1):
table.add_row(str(_n), str(_note.note_path.relative_to(self.vault_path)))
Console().print(table)
def num_excluded_notes(self) -> int:
"""Count number of excluded notes."""
excluded_notes = [
p.resolve()
for p in self.vault_path.glob("**/*")
if p.suffix in [".md", ".MD", ".markdown", ".MARKDOWN"] and p not in self.note_paths
]
return len(excluded_notes)
return len(self.all_notes) - len(self.notes_in_scope)
def num_notes(self) -> int:
"""Number of notes in the vault.
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault.
Args:
old_tag (str): Old tag name.
new_tag (str): New tag name.
Returns:
int: Number of notes in the vault.
int: Number of notes that had inline tags renamed.
"""
return len(self.notes)
num_changed = 0
for _note in self.notes_in_scope:
if _note.rename_inline_tag(old_tag, new_tag):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int:
"""Renames a key or key-value pair in the note's metadata.
@@ -291,7 +403,7 @@ class Vault:
"""
num_changed = 0
for _note in self.notes:
for _note in self.notes_in_scope:
if _note.rename_metadata(key, value_1, value_2):
num_changed += 1
@@ -299,32 +411,3 @@ class Vault:
self._rebuild_vault_metadata()
return num_changed
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault.
Args:
old_tag (str): Old tag name.
new_tag (str): New tag name.
Returns:
int: Number of notes that had inline tags renamed.
"""
num_changed = 0
for _note in self.notes:
if _note.rename_inline_tag(old_tag, new_tag):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def write(self) -> None:
"""Write changes to the vault."""
log.debug("Writing changes to vault...")
if self.dry_run is False:
for _note in self.notes:
log.trace(f"writing to {_note.note_path}")
_note.write()

View File

@@ -44,6 +44,36 @@ def test_notice(capsys):
assert captured.out == "NOTICE | This prints in notice\n"
def test_alerts_debug(capsys):
"""Test debug."""
alerts.debug("This prints in debug")
captured = capsys.readouterr()
assert captured.out == "DEBUG | This prints in debug\n"
def test_usage(capsys):
"""Test usage."""
alerts.usage("This prints in usage")
captured = capsys.readouterr()
assert captured.out == "USAGE | This prints in usage\n"
alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"
)
captured = capsys.readouterr()
assert "USAGE | Lorem ipsum dolor sit amet" in captured.out
assert " | incididunt ut labore et dolore magna aliqua" in captured.out
alerts.usage(
"Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
width=20,
)
captured = capsys.readouterr()
assert "USAGE | Lorem ipsum dolor" in captured.out
assert " | sit amet," in captured.out
assert " | adipisicing elit," in captured.out
def test_info(capsys):
"""Test info."""
alerts.info("This prints in info")

View File

@@ -19,19 +19,19 @@ from tests.helpers import Regex
def test_instantiate_application(test_application) -> None:
"""Test application."""
app = test_application
app.load_vault()
app._load_vault()
assert app.dry_run is False
assert app.config.name == "command_line_vault"
assert app.config.exclude_paths == [".git", ".obsidian"]
assert app.dry_run is False
assert app.vault.num_notes() == 13
assert len(app.vault.all_notes) == 13
def test_abort(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
return_value="abort",
@@ -42,10 +42,10 @@ def test_abort(test_application, mocker, capsys) -> None:
assert "Done!" in captured.out
def test_add_metadata_frontmatter_success(test_application, mocker, capsys) -> None:
def test_add_metadata_frontmatter(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["add_metadata", KeyError],
@@ -69,10 +69,60 @@ def test_add_metadata_frontmatter_success(test_application, mocker, capsys) -> N
assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL)
def test_add_metadata_inline(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault."""
app = test_application
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["add_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area",
return_value=MetadataType.INLINE,
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_key",
return_value="new_key",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_value",
return_value="new_key_value",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL)
def test_add_metadata_tag(test_application, mocker, capsys) -> None:
"""Test adding new metadata to the vault."""
app = test_application
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["add_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_area",
return_value=MetadataType.TAGS,
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_tag",
return_value="new_tag",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Added metadata to.*\d+.*notes", re.DOTALL)
def test_delete_inline_tag(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
@@ -113,7 +163,7 @@ def test_delete_inline_tag(test_application, mocker, capsys) -> None:
def test_delete_key(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
@@ -156,7 +206,7 @@ def test_delete_key(test_application, mocker, capsys) -> None:
def test_delete_value(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
@@ -202,17 +252,17 @@ def test_delete_value(test_application, mocker, capsys) -> None:
)
def test_filter_notes_filter(test_application, mocker, capsys) -> None:
def test_filter_notes(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["filter_notes", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["apply_filter", "list_notes", "back"],
side_effect=["apply_path_filter", "list_notes", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_filter_path",
@@ -232,25 +282,61 @@ def test_filter_notes_filter(test_application, mocker, capsys) -> None:
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["apply_filter", "list_notes", "back"],
side_effect=["apply_metadata_filter", "list_notes", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_filter_path",
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="on_one_note",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Loaded all.*\d+.*total notes", re.DOTALL)
assert captured.out == Regex(r"SUCCESS +\| Loaded.*1.*notes from.*\d+.*total", re.DOTALL)
assert "02 inline/inline 2.md" in captured.out
assert "03 mixed/mixed 1.md" not in captured.out
def test_filter_clear(test_application, mocker, capsys) -> None:
"""Test clearing filters."""
app = test_application
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["filter_notes", "filter_notes", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["apply_metadata_filter", "list_filters", "list_notes", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="on_one_note",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_number",
return_value="1",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert "02 inline/inline 2.md" in captured.out
assert "03 mixed/mixed 1.md" in captured.out
assert "01 frontmatter/frontmatter 4.md" in captured.out
assert "04 no metadata/no_metadata_1.md " in captured.out
def test_inspect_metadata_all(test_application, mocker, capsys) -> None:
"""Test backing up a vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["inspect_metadata", KeyError],
@@ -269,7 +355,7 @@ def test_inspect_metadata_all(test_application, mocker, capsys) -> None:
def test_rename_inline_tag(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
@@ -318,7 +404,7 @@ def test_rename_inline_tag(test_application, mocker, capsys) -> None:
def test_rename_key(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
@@ -367,7 +453,7 @@ def test_rename_key(test_application, mocker, capsys) -> None:
def test_rename_value_fail(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
@@ -425,7 +511,7 @@ def test_rename_value_fail(test_application, mocker, capsys) -> None:
def test_review_no_changes(test_application, mocker, capsys) -> None:
"""Review changes when no changes to vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["review_changes", KeyError],
@@ -439,7 +525,7 @@ def test_review_no_changes(test_application, mocker, capsys) -> None:
def test_review_changes(test_application, mocker, capsys) -> None:
"""Review changes when no changes to vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", "review_changes", KeyError],
@@ -471,7 +557,7 @@ def test_review_changes(test_application, mocker, capsys) -> None:
def test_vault_backup(test_application, mocker, capsys) -> None:
"""Test backing up a vault."""
app = test_application
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["vault_actions", KeyError],
@@ -492,7 +578,7 @@ def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None:
app = test_application
backup_path = Path(tmp_path / "application.bak")
backup_path.mkdir()
app.load_vault()
app._load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["vault_actions", KeyError],

View File

@@ -49,6 +49,7 @@ def test_multiple_vaults_okay() -> None:
assert config.config == {
"Sample Vault": {
"exclude_paths": [".git", ".obsidian", "ignore_folder"],
"insert_location": "top",
"path": "tests/fixtures/sample_vault",
},
"Test Vault": {
@@ -74,6 +75,7 @@ def test_single_vault() -> None:
"Test Vault": {
"exclude_paths": [".git", ".obsidian", "ignore_folder"],
"path": "tests/fixtures/test_vault",
"insert_location": "BOTTOM",
}
}
assert len(config.vaults) == 1
@@ -104,7 +106,14 @@ def test_no_config_no_vault(tmp_path, mocker) -> None:
path = "{str(fake_vault)}"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]"""
exclude_paths = [".git", ".obsidian"]
# Location to add metadata. One of:
# TOP: Directly after frontmatter.
# AFTER_TITLE: After a header following frontmatter.
# BOTTOM: The bottom of the note
insert_location = "BOTTOM\"
"""
assert config_file.exists() is True
assert content == dedent(sample_config)
@@ -114,5 +123,6 @@ def test_no_config_no_vault(tmp_path, mocker) -> None:
"Vault 1": {
"path": str(fake_vault),
"exclude_paths": [".git", ".obsidian"],
"insert_location": "BOTTOM",
}
}

View File

@@ -37,6 +37,27 @@ def sample_note(tmp_path) -> Path:
dest_file.unlink()
@pytest.fixture()
def short_note(tmp_path) -> Path:
"""Fixture which creates a temporary short note file."""
source_file1: Path = Path("tests/fixtures/short_textfile.md")
source_file2: Path = Path("tests/fixtures/no_metadata.md")
if not source_file1.exists():
raise FileNotFoundError(f"Original file not found: {source_file1}")
if not source_file2.exists():
raise FileNotFoundError(f"Original file not found: {source_file2}")
dest_file1: Path = Path(tmp_path / source_file1.name)
dest_file2: Path = Path(tmp_path / source_file2.name)
shutil.copy(source_file1, dest_file1)
shutil.copy(source_file2, dest_file2)
yield dest_file1, dest_file2
# after test - remove fixtures
dest_file1.unlink()
dest_file2.unlink()
@pytest.fixture()
def sample_vault(tmp_path) -> Path:
"""Fixture which creates a sample vault."""

View File

@@ -1,6 +1,7 @@
["Sample Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
path = "tests/fixtures/sample_vault"
exclude_paths = [".git", ".obsidian", "ignore_folder"]
insert_location = "top"
path = "tests/fixtures/sample_vault"
["Test Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
path = "tests/fixtures/test_vault"

1
tests/fixtures/no_metadata.md vendored Normal file
View File

@@ -0,0 +1 @@
Lorem ipsum dolor sit amet.

View File

@@ -1,4 +1,3 @@
area:: frontmatter
date_created:: 2022-12-22
date_modified:: 2022-12-22
@@ -6,13 +5,17 @@ author:: John Doe
status:: new
type:: book
type:: article
on_one_note:: one
#food/fruit/apple
#food/fruit/pear
#dinner #lunch #breakfast
# note header
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### header 3
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.

View File

@@ -1,3 +1,4 @@
# Header 1
area:: frontmatter
date_created:: 2022-12-22
@@ -10,9 +11,12 @@ type:: article
#food/fruit/pear
#dinner #lunch #breakfast
## Header 2
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
### Header 3
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.

View File

@@ -1,18 +1,18 @@
---
date_created: 2022-12-22
tags:
- food/fruit/apple
- dinner
- breakfast
- not_food
- food/fruit/apple
- dinner
- breakfast
- not_food
author: John Doe
nested_list:
nested_list_one:
- nested_list_one_a
- nested_list_one_b
nested_list_one:
- nested_list_one_a
- nested_list_one_b
type:
- article
- note
- article
- note
---
area:: mixed
@@ -24,13 +24,16 @@ type:: [[article]]
tags:: from_inline_metadata
**bold_key**:: **bold** key value
# Note header
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
## Header 2
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, [in_text_key:: in-text value] eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? #inline_tag
### header 3
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, #inline_tag2 cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.
#food/fruit/pear

7
tests/fixtures/short_textfile.md vendored Normal file
View File

@@ -0,0 +1,7 @@
---
key: value
---
# header 1
Lorem ipsum dolor sit amet.

View File

@@ -1,3 +1,4 @@
["Test Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
path = "tests/fixtures/test_vault"
exclude_paths = [".git", ".obsidian", "ignore_folder"]
insert_location = "BOTTOM"
path = "tests/fixtures/test_vault"

View File

@@ -2,6 +2,9 @@
"""Test metadata.py."""
from pathlib import Path
import pytest
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.metadata import (
Frontmatter,
InlineMetadata,
@@ -11,6 +14,7 @@ from obsidian_metadata.models.metadata import (
from tests.helpers import Regex
FILE_CONTENT: str = Path("tests/fixtures/test_vault/test1.md").read_text()
TAG_LIST: list[str] = ["tag 1", "tag 2", "tag 3"]
METADATA: dict[str, list[str]] = {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["note", "article"],
@@ -22,6 +26,7 @@ METADATA: dict[str, list[str]] = {
"top_key3": ["top_key3_value"],
"intext_key": ["intext_key_value"],
}
METADATA_2: dict[str, list[str]] = {"key1": ["value1"], "key2": ["value2", "value3"]}
FRONTMATTER_CONTENT: str = """
---
tags:
@@ -64,129 +69,6 @@ repeated_key:: repeated_key_value2
"""
def test_vault_metadata(capsys) -> None:
"""Test VaultMetadata class."""
vm = VaultMetadata()
assert vm.dict == {}
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
vm.print_keys()
captured = capsys.readouterr()
assert captured.out == Regex(r"frontmatter_Key1 +frontmatter_Key2 +intext_key")
vm.print_tags()
captured = capsys.readouterr()
assert captured.out == Regex(r"tag 1 +tag 2 +tag 3")
vm.print_metadata()
captured = capsys.readouterr()
assert captured.out == Regex(r"┃ Keys +┃ Values +┃")
assert captured.out == Regex(r"│ +│ tag 3 +│")
assert captured.out == Regex(r"│ frontmatter_Key1 +│ author name +│")
new_metadata = {"added_key": ["added_value"], "frontmatter_Key2": ["new_value"]}
vm.add_metadata(new_metadata)
assert vm.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "new_value", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
def test_vault_metadata_contains() -> None:
"""Test contains method."""
vm = VaultMetadata()
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.contains("frontmatter_Key1") is True
assert vm.contains("frontmatter_Key2", "article") is True
assert vm.contains("frontmatter_Key3") is False
assert vm.contains("frontmatter_Key2", "no value") is False
assert vm.contains("1$", is_regex=True) is True
assert vm.contains("5$", is_regex=True) is False
assert vm.contains("tags", r"\d", is_regex=True) is True
assert vm.contains("tags", r"^\d", is_regex=True) is False
def test_vault_metadata_delete() -> None:
"""Test delete method."""
vm = VaultMetadata()
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.delete("no key") is False
assert vm.delete("tags", "no value") is False
assert vm.delete("tags", "tag 2") is True
assert vm.dict["tags"] == ["tag 1", "tag 3"]
assert vm.delete("tags") is True
assert "tags" not in vm.dict
def test_vault_metadata_rename() -> None:
"""Test rename method."""
vm = VaultMetadata()
vm.add_metadata(METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.rename("no key", "new key") is False
assert vm.rename("tags", "no tag", "new key") is False
assert vm.rename("tags", "tag 2", "new tag") is True
assert vm.dict["tags"] == ["new tag", "tag 1", "tag 3"]
assert vm.rename("tags", "old_tags") is True
assert vm.dict["old_tags"] == ["new tag", "tag 1", "tag 3"]
assert "tags" not in vm.dict
def test_frontmatter_create() -> None:
"""Test frontmatter creation."""
frontmatter = Frontmatter(INLINE_CONTENT)
@@ -365,6 +247,85 @@ tags:
assert frontmatter.to_yaml(sort_keys=True) == new_frontmatter_sorted
def test_inline_metadata_add() -> None:
"""Test inline add."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.add("bold_key1") is False
assert inline.add("bold_key1", "bold_key1_value") is False
assert inline.add("added_key") is True
assert inline.dict == {
"added_key": [],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.add("added_key1", "added_value") is True
assert inline.dict == {
"added_key": [],
"added_key1": ["added_value"],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
with pytest.raises(ValueError):
assert inline.add("added_key1", "added_value_2") is True
assert inline.dict == {
"added_key": [],
"added_key1": ["added_value"],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.add("added_key", "added_value")
assert inline.dict == {
"added_key": ["added_value"],
"added_key1": ["added_value"],
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
def test_inline_metadata_contains() -> None:
"""Test inline metadata contains method."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("bold_key1") is True
assert inline.contains("bold_key2", "bold_key2_value") is True
assert inline.contains("bold_key3") is False
assert inline.contains("bold_key2", "no value") is False
assert inline.contains(r"\w{4}_key", is_regex=True) is True
assert inline.contains(r"^\d", is_regex=True) is False
assert inline.contains("1$", r"\d_value", is_regex=True) is True
assert inline.contains("key", r"^\d_value", is_regex=True) is False
def test_inline_metadata_create() -> None:
"""Test inline metadata creation."""
inline = InlineMetadata(FRONTMATTER_CONTENT)
@@ -392,46 +353,6 @@ def test_inline_metadata_create() -> None:
}
def test_inline_contains() -> None:
"""Test inline metadata contains method."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.contains("bold_key1") is True
assert inline.contains("bold_key2", "bold_key2_value") is True
assert inline.contains("bold_key3") is False
assert inline.contains("bold_key2", "no value") is False
assert inline.contains(r"\w{4}_key", is_regex=True) is True
assert inline.contains(r"^\d", is_regex=True) is False
assert inline.contains("1$", r"\d_value", is_regex=True) is True
assert inline.contains("key", r"^\d_value", is_regex=True) is False
def test_inline_metadata_rename() -> None:
"""Test inline metadata rename."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.rename("no key", "new key") is False
assert inline.rename("repeated_key", "no value", "new key") is False
assert inline.has_changes() is False
assert inline.rename("repeated_key", "repeated_key_value1", "new value") is True
assert inline.dict["repeated_key"] == ["new value", "repeated_key_value2"]
assert inline.rename("repeated_key", "old_key") is True
assert inline.dict["old_key"] == ["new value", "repeated_key_value2"]
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
def test_inline_metadata_delete() -> None:
"""Test inline metadata delete."""
inline = InlineMetadata(INLINE_CONTENT)
@@ -473,6 +394,57 @@ def test_inline_metadata_delete() -> None:
}
def test_inline_metadata_rename() -> None:
"""Test inline metadata rename."""
inline = InlineMetadata(INLINE_CONTENT)
assert inline.dict == {
"bold_key1": ["bold_key1_value"],
"bold_key2": ["bold_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"in_text_key1": ["in_text_key1_value"],
"in_text_key2": ["in_text_key2_value"],
"link_key": ["link_key_value"],
"repeated_key": ["repeated_key_value1", "repeated_key_value2"],
"tag_key": ["tag_key_value"],
}
assert inline.rename("no key", "new key") is False
assert inline.rename("repeated_key", "no value", "new key") is False
assert inline.has_changes() is False
assert inline.rename("repeated_key", "repeated_key_value1", "new value") is True
assert inline.dict["repeated_key"] == ["new value", "repeated_key_value2"]
assert inline.rename("repeated_key", "old_key") is True
assert inline.dict["old_key"] == ["new value", "repeated_key_value2"]
assert "repeated_key" not in inline.dict
assert inline.has_changes() is True
def test_inline_tags_add() -> None:
"""Test inline tags add."""
tags = InlineTags(INLINE_CONTENT)
assert tags.add("bold_tag") is False
assert tags.add("new_tag") is True
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"new_tag",
"tag_key_value",
]
def test_inline_tags_contains() -> None:
"""Test inline tags contains."""
tags = InlineTags(INLINE_CONTENT)
assert tags.contains("bold_tag") is True
assert tags.contains("no tag") is False
assert tags.contains(r"\w_\w", is_regex=True) is True
assert tags.contains(r"\d_\d", is_regex=True) is False
def test_inline_tags_create() -> None:
"""Test inline tags creation."""
tags = InlineTags(FRONTMATTER_CONTENT)
@@ -496,40 +468,6 @@ def test_inline_tags_create() -> None:
]
def test_inline_tags_contains() -> None:
"""Test inline tags contains."""
tags = InlineTags(INLINE_CONTENT)
assert tags.contains("bold_tag") is True
assert tags.contains("no tag") is False
assert tags.contains(r"\w_\w", is_regex=True) is True
assert tags.contains(r"\d_\d", is_regex=True) is False
def test_inline_tags_rename() -> None:
"""Test inline tags rename."""
tags = InlineTags(INLINE_CONTENT)
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.rename("no tag", "new tag") is False
assert tags.has_changes() is False
assert tags.rename("bold_tag", "new tag") is True
assert tags.list == [
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"new tag",
"tag_key_value",
]
assert tags.has_changes() is True
def test_inline_tags_delete() -> None:
"""Test inline tags delete."""
tags = InlineTags(INLINE_CONTENT)
@@ -554,3 +492,255 @@ def test_inline_tags_delete() -> None:
assert tags.delete(r"\d{3}") is False
assert tags.delete(r"inline_tag_top\d") is True
assert tags.list == ["in_text_tag", "tag_key_value"]
def test_inline_tags_rename() -> None:
"""Test inline tags rename."""
tags = InlineTags(INLINE_CONTENT)
assert tags.list == [
"bold_tag",
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"tag_key_value",
]
assert tags.rename("no tag", "new tag") is False
assert tags.has_changes() is False
assert tags.rename("bold_tag", "new tag") is True
assert tags.list == [
"in_text_tag",
"inline_tag_top1",
"inline_tag_top2",
"new tag",
"tag_key_value",
]
assert tags.has_changes() is True
def test_vault_metadata() -> None:
"""Test VaultMetadata class."""
vm = VaultMetadata()
assert vm.dict == {}
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
vm.index_metadata(area=MetadataType.INLINE, metadata=METADATA_2)
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"key1": ["value1"],
"key2": ["value2", "value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.frontmatter == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.inline_metadata == {"key1": ["value1"], "key2": ["value2", "value3"]}
assert vm.tags == ["tag 1", "tag 2", "tag 3"]
new_metadata = {"added_key": ["added_value"], "frontmatter_Key2": ["new_value"]}
new_tags = ["tag 4", "tag 5"]
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=new_metadata)
vm.index_metadata(area=MetadataType.TAGS, metadata=new_tags)
assert vm.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "new_value", "note"],
"intext_key": ["intext_key_value"],
"key1": ["value1"],
"key2": ["value2", "value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.frontmatter == {
"added_key": ["added_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "new_value", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.inline_metadata == {"key1": ["value1"], "key2": ["value2", "value3"]}
assert vm.tags == ["tag 1", "tag 2", "tag 3", "tag 4", "tag 5"]
def test_vault_metadata_print(capsys) -> None:
"""Test print_metadata method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
vm.index_metadata(area=MetadataType.INLINE, metadata=METADATA_2)
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
vm.print_metadata(area=MetadataType.ALL)
captured = capsys.readouterr()
assert "All metadata" in captured.out
assert "All inline tags" in captured.out
assert "┃ Keys ┃ Values ┃" in captured.out
assert "│ shared_key1 │ shared_key1_value │" in captured.out
assert captured.out == Regex("#tag 1 +#tag 2")
vm.print_metadata(area=MetadataType.FRONTMATTER)
captured = capsys.readouterr()
assert "All frontmatter" in captured.out
assert "┃ Keys ┃ Values ┃" in captured.out
assert "│ shared_key1 │ shared_key1_value │" in captured.out
assert "value1" not in captured.out
vm.print_metadata(area=MetadataType.INLINE)
captured = capsys.readouterr()
assert "All inline" in captured.out
assert "┃ Keys ┃ Values ┃" in captured.out
assert "shared_key1" not in captured.out
assert "│ key1 │ value1 │" in captured.out
vm.print_metadata(area=MetadataType.TAGS)
captured = capsys.readouterr()
assert "All inline tags " in captured.out
assert "┃ Keys ┃ Values ┃" not in captured.out
assert captured.out == Regex("#tag 1 +#tag 2")
vm.print_metadata(area=MetadataType.KEYS)
captured = capsys.readouterr()
assert "All Keys " in captured.out
assert "┃ Keys ┃ Values ┃" not in captured.out
assert captured.out != Regex("#tag 1 +#tag 2")
assert captured.out == Regex("frontmatter_Key1 +frontmatter_Key2")
def test_vault_metadata_contains() -> None:
"""Test contains method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
vm.index_metadata(area=MetadataType.INLINE, metadata=METADATA_2)
vm.index_metadata(area=MetadataType.TAGS, metadata=TAG_LIST)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"key1": ["value1"],
"key2": ["value2", "value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.frontmatter == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.inline_metadata == {"key1": ["value1"], "key2": ["value2", "value3"]}
assert vm.tags == ["tag 1", "tag 2", "tag 3"]
with pytest.raises(ValueError):
vm.contains(area=MetadataType.ALL, value="key1")
assert vm.contains(area=MetadataType.ALL, key="no_key") is False
assert vm.contains(area=MetadataType.ALL, key="key1") is True
assert vm.contains(area=MetadataType.ALL, key="frontmatter_Key2", value="article") is True
assert vm.contains(area=MetadataType.ALL, key="frontmatter_Key2", value="none") is False
assert vm.contains(area=MetadataType.ALL, key="1$", is_regex=True) is True
assert vm.contains(area=MetadataType.ALL, key=r"\d\d", is_regex=True) is False
assert vm.contains(area=MetadataType.FRONTMATTER, key="no_key") is False
assert vm.contains(area=MetadataType.FRONTMATTER, key="frontmatter_Key1") is True
assert (
vm.contains(area=MetadataType.FRONTMATTER, key="frontmatter_Key2", value="article") is True
)
assert vm.contains(area=MetadataType.FRONTMATTER, key="frontmatter_Key2", value="none") is False
assert vm.contains(area=MetadataType.FRONTMATTER, key="1$", is_regex=True) is True
assert vm.contains(area=MetadataType.FRONTMATTER, key=r"\d\d", is_regex=True) is False
assert vm.contains(area=MetadataType.INLINE, key="no_key") is False
assert vm.contains(area=MetadataType.INLINE, key="key1") is True
assert vm.contains(area=MetadataType.INLINE, key="key2", value="value3") is True
assert vm.contains(area=MetadataType.INLINE, key="key2", value="none") is False
assert vm.contains(area=MetadataType.INLINE, key="1$", is_regex=True) is True
assert vm.contains(area=MetadataType.INLINE, key=r"\d\d", is_regex=True) is False
assert vm.contains(area=MetadataType.TAGS, value="no_tag") is False
assert vm.contains(area=MetadataType.TAGS, value="tag 1") is True
assert vm.contains(area=MetadataType.TAGS, value=r"\w+ \d$", is_regex=True) is True
assert vm.contains(area=MetadataType.TAGS, value=r"\w+ \d\d$", is_regex=True) is False
with pytest.raises(ValueError):
vm.contains(area=MetadataType.TAGS, key="key1")
def test_vault_metadata_delete() -> None:
"""Test delete method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.delete("no key") is False
assert vm.delete("tags", "no value") is False
assert vm.delete("tags", "tag 2") is True
assert vm.dict["tags"] == ["tag 1", "tag 3"]
assert vm.delete("tags") is True
assert "tags" not in vm.dict
def test_vault_metadata_rename() -> None:
"""Test rename method."""
vm = VaultMetadata()
vm.index_metadata(area=MetadataType.FRONTMATTER, metadata=METADATA)
assert vm.dict == {
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"intext_key": ["intext_key_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value"],
"tags": ["tag 1", "tag 2", "tag 3"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value"],
}
assert vm.rename("no key", "new key") is False
assert vm.rename("tags", "no tag", "new key") is False
assert vm.rename("tags", "tag 2", "new tag") is True
assert vm.dict["tags"] == ["new tag", "tag 1", "tag 3"]
assert vm.rename("tags", "old_tags") is True
assert vm.dict["old_tags"] == ["new tag", "tag 1", "tag 3"]
assert "tags" not in vm.dict

View File

@@ -7,7 +7,7 @@ from pathlib import Path
import pytest
import typer
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.enums import InsertLocation, MetadataType
from obsidian_metadata.models.notes import Note
from tests.helpers import Regex
@@ -74,38 +74,36 @@ def test_note_create(sample_note) -> None:
assert note.original_file_content == content
def test_append(sample_note) -> None:
"""Test appending to note."""
note = Note(note_path=sample_note)
assert note.dry_run is False
def test_add_metadata_inline(short_note) -> None:
"""Test adding metadata."""
path1, path2 = short_note
note = Note(note_path=path1)
string = "This is a test string."
string2 = "Lorem ipsum dolor sit"
assert note.inline_metadata.dict == {}
assert (
note.add_metadata(MetadataType.INLINE, location=InsertLocation.BOTTOM, key="new_key1")
is True
)
assert note.inline_metadata.dict == {"new_key1": []}
assert "new_key1::" in note.file_content.strip()
note.append(string_to_append=string)
assert string in note.file_content
assert len(re.findall(re.escape(string), note.file_content)) == 1
note.append(string_to_append=string)
assert string in note.file_content
assert len(re.findall(re.escape(string), note.file_content)) == 1
note.append(string_to_append=string, allow_multiple=True)
assert string in note.file_content
assert len(re.findall(re.escape(string), note.file_content)) == 2
note.append(string_to_append=string2)
assert string2 in note.file_content
assert len(re.findall(re.escape(string2), note.file_content)) == 1
note.append(string_to_append=string2, allow_multiple=True)
assert string2 in note.file_content
assert len(re.findall(re.escape(string2), note.file_content)) == 2
assert (
note.add_metadata(MetadataType.INLINE, key="new_key1", location=InsertLocation.BOTTOM)
is False
)
assert (
note.add_metadata(
MetadataType.INLINE, key="new_key2", value="new_value1", location=InsertLocation.TOP
)
is True
)
assert "new_key2:: new_value1" in note.file_content
def test_add_metadata(sample_note) -> None:
def test_add_metadata_frontmatter(sample_note) -> None:
"""Test adding metadata."""
note = Note(note_path=sample_note)
assert note.add_metadata(MetadataType.FRONTMATTER, "frontmatter_Key1") is False
assert note.add_metadata(MetadataType.FRONTMATTER, "shared_key1", "shared_key1_value") is False
assert note.add_metadata(MetadataType.FRONTMATTER, "new_key1") is True
@@ -162,6 +160,30 @@ def test_add_metadata(sample_note) -> None:
}
def test_add_metadata_tag(sample_note) -> None:
"""Test adding inline tags."""
note = Note(note_path=sample_note)
assert (
note.add_metadata(MetadataType.TAGS, value="shared_tag", location=InsertLocation.TOP)
is False
)
assert (
note.add_metadata(MetadataType.TAGS, value="a_new_tag", location=InsertLocation.TOP) is True
)
assert note.inline_tags.list == [
"a_new_tag",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
"inline_tag_top2",
"intext_tag1",
"intext_tag2",
"shared_tag",
]
assert "#a_new_tag" in note.file_content
def test_contains_inline_tag(sample_note) -> None:
"""Test contains inline tag."""
note = Note(note_path=sample_note)
@@ -240,7 +262,7 @@ def test_has_changes(sample_note) -> None:
note = Note(note_path=sample_note)
assert note.has_changes() is False
note.append("This is a test string.")
note.insert("This is a test string.", location=InsertLocation.BOTTOM)
assert note.has_changes() is True
note = Note(note_path=sample_note)
@@ -259,6 +281,146 @@ def test_has_changes(sample_note) -> None:
assert note.has_changes() is True
def test_insert_bottom(short_note) -> None:
"""Test inserting metadata to bottom of note."""
path1, path2 = short_note
note = Note(note_path=str(path1))
note2 = Note(note_path=str(path2))
string1 = "This is a test string."
string2 = "This is"
correct_content = """
---
key: value
---
# header 1
Lorem ipsum dolor sit amet.
This is a test string.
"""
correct_content2 = """
---
key: value
---
# header 1
Lorem ipsum dolor sit amet.
This is a test string.
This is
"""
correct_content3 = """
Lorem ipsum dolor sit amet.
This is a test string.
"""
note.insert(new_string=string1, location=InsertLocation.BOTTOM)
assert note.file_content == correct_content.strip()
note.insert(new_string=string2, location=InsertLocation.BOTTOM)
assert note.file_content == correct_content.strip()
note.insert(new_string=string2, allow_multiple=True, location=InsertLocation.BOTTOM)
assert note.file_content == correct_content2.strip()
note2.insert(new_string=string1, location=InsertLocation.BOTTOM)
assert note2.file_content == correct_content3.strip()
def test_insert_after_frontmatter(short_note) -> None:
"""Test inserting metadata to bottom of note."""
path1, path2 = short_note
note = Note(note_path=path1)
note2 = Note(note_path=path2)
string1 = "This is a test string."
string2 = "This is"
correct_content = """
---
key: value
---
This is a test string.
# header 1
Lorem ipsum dolor sit amet.
"""
correct_content2 = """
---
key: value
---
This is
This is a test string.
# header 1
Lorem ipsum dolor sit amet.
"""
correct_content3 = """
This is a test string.
Lorem ipsum dolor sit amet.
"""
note.insert(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)
assert note.file_content.strip() == correct_content2.strip()
note2.insert(new_string=string1, location=InsertLocation.TOP)
assert note2.file_content.strip() == correct_content3.strip()
def test_insert_after_title(short_note) -> None:
"""Test inserting metadata to bottom of note."""
path1, path2 = short_note
note = Note(note_path=path1)
note2 = Note(note_path=path2)
string1 = "This is a test string."
string2 = "This is"
correct_content = """
---
key: value
---
# header 1
This is a test string.
Lorem ipsum dolor sit amet.
"""
correct_content2 = """
---
key: value
---
# header 1
This is
This is a test string.
Lorem ipsum dolor sit amet.
"""
correct_content3 = """
This is a test string.
Lorem ipsum dolor sit amet.
"""
note.insert(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)
assert note.file_content.strip() == correct_content2.strip()
note2.insert(new_string=string1, location=InsertLocation.AFTER_TITLE)
assert note2.file_content.strip() == correct_content3.strip()
def test_print_note(sample_note, capsys) -> None:
"""Test printing note."""
note = Note(note_path=sample_note)
@@ -273,7 +435,7 @@ def test_print_diff(sample_note, capsys) -> None:
"""Test printing diff."""
note = Note(note_path=sample_note)
note.append("This is a test string.")
note.insert("This is a test string.", location=InsertLocation.BOTTOM)
note.print_diff()
captured = capsys.readouterr()
assert "+ This is a test string." in captured.out
@@ -362,12 +524,12 @@ def test_rename_metadata(sample_note) -> None:
assert note.file_content == Regex(r"new_key:: new_value")
def test_replace_frontmatter(sample_note) -> None:
def test_update_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.replace_frontmatter()
note.update_frontmatter()
new_frontmatter = """---
date_created: '2022-12-22'
tags:
@@ -387,9 +549,9 @@ shared_key2: shared_key2_value1
assert "```python" in note.file_content
note2 = Note(note_path="tests/fixtures/test_vault/no_metadata.md")
note2.replace_frontmatter()
note2.update_frontmatter()
note2.frontmatter.dict = {"key1": "value1", "key2": "value2"}
note2.replace_frontmatter()
note2.update_frontmatter()
new_frontmatter = """---
key1: value1
key2: value2

View File

@@ -5,7 +5,7 @@ import pytest
from obsidian_metadata.models.patterns import Patterns
TAG_CONTENT: str = "#1 #2 **#3** [[#4]] [[#5|test]] #6#notag #7_8 #9/10 #11-12 #13; #14, #15. #16: #17* #18(#19) #20[#21] #22\\ #23& #24# #25 **#26** #📅/tag"
TAG_CONTENT: str = "#1 #2 **#3** [[#4]] [[#5|test]] #6#notag #7_8 #9/10 #11-12 #13; #14, #15. #16: #17* #18(#19) #20[#21] #22\\ #23& #24# #25 **#26** #📅/tag [link](#no_tag) https://example.com/somepage.html_#no_url_tags"
INLINE_METADATA: str = """
**1:: 1**
2_2:: [[2_2]] | 2
@@ -56,10 +56,69 @@ shared_key1: 'shared_key1_value'
"""
def test_regex():
"""Test regexes."""
def test_top_with_header():
"""Test identifying the top of a note."""
pattern = Patterns()
no_fm_or_header = """
Lorem ipsum dolor sit amet.
# header 1
---
horizontal: rule
---
Lorem ipsum dolor sit amet.
"""
fm_and_header: str = """
---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: 'shared_key1_value'
---
# Header 1
more content
---
horizontal: rule
---
"""
fm_and_header_result = """---
tags:
- tag_1
- tag_2
-
- 📅/tag_3
frontmatter_Key1: "frontmatter_Key1_value"
frontmatter_Key2: ["note", "article"]
shared_key1: 'shared_key1_value'
---
# Header 1"""
no_fm = """
### Header's number 3 [📅] "+$2.00" 🤷
---
horizontal: rule
---
"""
no_fm_result = '### Header\'s number 3 [📅] "+$2.00" 🤷'
assert pattern.top_with_header.search(no_fm_or_header).group("top") == ""
assert pattern.top_with_header.search(fm_and_header).group("top") == fm_and_header_result
assert pattern.top_with_header.search(no_fm).group("top") == no_fm_result
def test_find_inline_tags():
"""Test find_inline_tags regex."""
pattern = Patterns()
assert pattern.find_inline_tags.findall(TAG_CONTENT) == [
"1",
"2",
@@ -87,6 +146,11 @@ def test_regex():
"📅/tag",
]
def test_find_inline_metadata():
"""Test find_inline_metadata regex."""
pattern = Patterns()
result = pattern.find_inline_metadata.findall(INLINE_METADATA)
assert result == [
("", "", "1", "1**"),
@@ -99,14 +163,26 @@ def test_regex():
("", "", "emoji_📅_key", "📅emoji_📅_key_value"),
]
found = pattern.frontmatt_block_with_separators.search(FRONTMATTER_CONTENT).group("frontmatter")
def test_find_frontmatter():
"""Test regexes."""
pattern = Patterns()
found = pattern.frontmatter_block.search(FRONTMATTER_CONTENT).group("frontmatter")
assert found == CORRECT_FRONTMATTER_WITH_SEPARATORS
found = pattern.frontmatt_block_no_separators.search(FRONTMATTER_CONTENT).group("frontmatter")
found = pattern.frontmatt_block_strip_separators.search(FRONTMATTER_CONTENT).group(
"frontmatter"
)
assert found == CORRECT_FRONTMATTER_NO_SEPARATORS
with pytest.raises(AttributeError):
pattern.frontmatt_block_no_separators.search(TAG_CONTENT).group("frontmatter")
pattern.frontmatt_block_strip_separators.search(TAG_CONTENT).group("frontmatter")
def test_validators():
"""Test validators."""
pattern = Patterns()
assert pattern.validate_tag_text.search("test_tag") is None
assert pattern.validate_tag_text.search("#asdf").group(0) == "#"
assert pattern.validate_tag_text.search("#asdf").group(0) == "#"

View File

@@ -60,6 +60,14 @@ def test_validate_new_tag() -> None:
assert questions._validate_new_tag("new_tag") is True
def test_validate_number() -> None:
"""Test number validation."""
questions = Questions(vault=VAULT)
assert "Must be an integer" in questions._validate_number("test")
assert "Must be an integer" in questions._validate_number("1.1")
assert questions._validate_number("1") is True
def test_validate_existing_inline_tag() -> None:
"""Test existing tag validation."""
questions = Questions(vault=VAULT)
@@ -80,12 +88,10 @@ def test_validate_key_exists_regex() -> None:
def test_validate_value() -> None:
"""Test value validation."""
questions = Questions(vault=VAULT)
assert questions._validate_value("test") is True
assert "Value cannot be empty" in questions._validate_value("")
assert questions._validate_value("test") is True
questions2 = Questions(vault=VAULT, key="frontmatter_Key1")
assert questions2._validate_value("test") == "frontmatter_Key1:test does not exist"
assert "Value cannot be empty" in questions2._validate_value("")
assert questions2._validate_value("author name") is True
@@ -104,7 +110,7 @@ def test_validate_value_exists_regex() -> None:
def test_validate_new_value() -> None:
"""Test new value validation."""
questions = Questions(vault=VAULT, key="frontmatter_Key1")
assert questions._validate_new_value("new_value") is True
assert questions._validate_new_value("not_exists") is True
assert "Value cannot be empty" in questions._validate_new_value("")
assert (
questions._validate_new_value("author name")

View File

@@ -4,8 +4,8 @@
from pathlib import Path
from obsidian_metadata._config import Config
from obsidian_metadata.models import Vault
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models import Vault, VaultFilter
from obsidian_metadata.models.enums import InsertLocation, MetadataType
from tests.helpers import Regex
@@ -16,23 +16,15 @@ def test_vault_creation(test_vault):
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
assert vault.backup_path == Path(f"{vault_path}.bak")
assert vault.dry_run is False
assert str(vault.exclude_paths[0]) == Regex(r".*\.git")
assert vault.num_notes() == 3
assert len(vault.all_notes) == 3
assert vault.metadata.dict == {
"Inline Tags": [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
"inline_tag_top2",
"intext_tag1",
"intext_tag2",
"shared_tag",
],
"author": ["author name"],
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
@@ -58,22 +50,148 @@ def test_vault_creation(test_vault):
"type": ["article", "note"],
}
assert vault.metadata.tags == [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
"inline_tag_top2",
"intext_tag1",
"intext_tag2",
"shared_tag",
]
assert vault.metadata.inline_metadata == {
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"emoji_📅_key": ["emoji_📅_key_value"],
"intext_key": ["intext_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value2"],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
}
assert vault.metadata.frontmatter == {
"author": ["author name"],
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"type": ["article", "note"],
}
def test_get_filtered_notes(sample_vault) -> None:
"""Test filtering notes."""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
def test_add_metadata(test_vault) -> None:
"""Test adding metadata to the vault."""
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, path_filter="front")
vault = Vault(config=vault_config)
assert vault.num_notes() == 4
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
vault2 = Vault(config=vault_config, path_filter="mixed")
assert vault2.num_notes() == 1
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key") == 3
assert vault.metadata.dict == {
"author": ["author name"],
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"emoji_📅_key": ["emoji_📅_key_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"intext_key": ["intext_value"],
"new_key": [],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
"type": ["article", "note"],
}
assert vault.metadata.frontmatter == {
"author": ["author name"],
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"new_key": [],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"type": ["article", "note"],
}
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") == 3
assert vault.metadata.dict == {
"author": ["author name"],
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"emoji_📅_key": ["emoji_📅_key_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"intext_key": ["intext_value"],
"new_key": [],
"new_key2": ["new_key2_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
"type": ["article", "note"],
}
assert vault.metadata.frontmatter == {
"author": ["author name"],
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"new_key": [],
"new_key2": ["new_key2_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"type": ["article", "note"],
}
def test_backup(test_vault, capsys):
@@ -95,6 +213,34 @@ def test_backup(test_vault, capsys):
assert captured.out == Regex(r"Backup path +\│[\s ]+/[\d\w]+")
def test_commit(test_vault, tmp_path):
"""Test committing changes to content in the vault."""
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)
content = Path(f"{tmp_path}/vault/test1.md").read_text()
assert "new_key: new_key_value" not in content
vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value")
vault.commit_changes()
assert "new_key: new_key_value" not in content
def test_commit_dry_run(test_vault, tmp_path):
"""Test committing changes to content in the vault in dry run mode."""
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, dry_run=True)
content = Path(f"{tmp_path}/vault/test1.md").read_text()
assert "new_key: new_key_value" not in content
vault.add_metadata(MetadataType.FRONTMATTER, "new_key", "new_key_value")
vault.commit_changes()
assert "new_key: new_key_value" not in content
def test_backup_dryrun(test_vault, capsys):
"""Test backing up the vault."""
vault_path = test_vault
@@ -145,142 +291,6 @@ def test_delete_backup_dryrun(test_vault, capsys):
assert vault.backup_path.exists() is True
def test_info(test_vault, capsys):
"""Test printing vault information."""
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)
vault.info()
captured = capsys.readouterr()
assert captured.out == Regex(r"Vault +\│ /[\d\w]+")
assert captured.out == Regex(r"Notes in scope +\\d+")
assert captured.out == Regex(r"Backup +\│ None")
def test_list_editable_notes(test_vault, capsys) -> None:
"""Test listing editable notes."""
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)
vault.list_editable_notes()
captured = capsys.readouterr()
assert captured.out == Regex("Notes in current scope")
assert captured.out == Regex(r"1 +test1\.md")
def test_contains_inline_tag(test_vault) -> None:
"""Test if the vault contains an inline tag."""
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.contains_inline_tag("tag") is False
assert vault.contains_inline_tag("intext_tag2") is True
def test_add_metadata(test_vault) -> None:
"""Test adding metadata to the vault."""
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.add_metadata(MetadataType.FRONTMATTER, "new_key") == 3
assert vault.metadata.dict == {
"Inline Tags": [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
"inline_tag_top2",
"intext_tag1",
"intext_tag2",
"shared_tag",
],
"author": ["author name"],
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"emoji_📅_key": ["emoji_📅_key_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"intext_key": ["intext_value"],
"new_key": [],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
"type": ["article", "note"],
}
assert vault.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") == 3
assert vault.metadata.dict == {
"Inline Tags": [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
"inline_tag_top2",
"intext_tag1",
"intext_tag2",
"shared_tag",
],
"author": ["author name"],
"bottom_key1": ["bottom_key1_value"],
"bottom_key2": ["bottom_key2_value"],
"date_created": ["2022-12-22"],
"emoji_📅_key": ["emoji_📅_key_value"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"ignored_frontmatter": ["ignore_me"],
"intext_key": ["intext_value"],
"new_key": [],
"new_key2": ["new_key2_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1", "shared_key2_value2"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"shared_tag",
"📅/frontmatter_tag3",
],
"top_key1": ["top_key1_value"],
"top_key2": ["top_key2_value"],
"top_key3": ["top_key3_value_as_link"],
"type": ["article", "note"],
}
def test_contains_metadata(test_vault) -> None:
"""Test if the vault contains a metadata key."""
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.contains_metadata("key") is False
assert vault.contains_metadata("top_key1") is True
assert vault.contains_metadata("top_key1", "no_value") is False
assert vault.contains_metadata("top_key1", "top_key1_value") is True
def test_delete_inline_tag(test_vault) -> None:
"""Test deleting an inline tag."""
vault_path = test_vault
@@ -290,7 +300,7 @@ def test_delete_inline_tag(test_vault) -> None:
assert vault.delete_inline_tag("no tag") == 0
assert vault.delete_inline_tag("intext_tag2") == 2
assert vault.metadata.dict["Inline Tags"] == [
assert vault.metadata.tags == [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
@@ -318,6 +328,97 @@ def test_delete_metadata(test_vault) -> None:
assert "top_key2" not in vault.metadata.dict
def test_export_csv(tmp_path, test_vault):
"""Test exporting the vault to a CSV file."""
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)
export_file = Path(f"{tmp_path}/export.csv")
vault.export_metadata(path=export_file, format="csv")
assert export_file.exists() is True
assert "frontmatter,date_created,2022-12-22" in export_file.read_text()
def test_export_json(tmp_path, test_vault):
"""Test exporting the vault to a CSV file."""
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)
export_file = Path(f"{tmp_path}/export.json")
vault.export_metadata(path=export_file, format="json")
assert export_file.exists() is True
assert '"frontmatter": {' in export_file.read_text()
def test_get_filtered_notes(sample_vault) -> None:
"""Test filtering notes."""
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault_config = config.vaults[0]
filters = [VaultFilter(path_filter="front")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 4
filters = [VaultFilter(path_filter="mixed")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 1
filters = [VaultFilter(key_filter="on_one_note")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 1
filters = [VaultFilter(key_filter="type", value_filter="book")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 10
filters = [VaultFilter(tag_filter="brunch")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 1
filters = [VaultFilter(tag_filter="brunch"), VaultFilter(path_filter="inbox")]
vault = Vault(config=vault_config, filters=filters)
assert len(vault.all_notes) == 13
assert len(vault.notes_in_scope) == 0
def test_info(test_vault, capsys):
"""Test printing vault information."""
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)
vault.info()
captured = capsys.readouterr()
assert captured.out == Regex(r"Vault +\│ /[\d\w]+")
assert captured.out == Regex(r"Notes in scope +\\d+")
assert captured.out == Regex(r"Backup +\│ None")
def test_list_editable_notes(test_vault, capsys) -> None:
"""Test listing editable notes."""
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)
vault.list_editable_notes()
captured = capsys.readouterr()
assert captured.out == Regex("Notes in current scope")
assert captured.out == Regex(r"\d +test1\.md")
def test_rename_inline_tag(test_vault) -> None:
"""Test renaming an inline tag."""
vault_path = test_vault
@@ -327,7 +428,7 @@ def test_rename_inline_tag(test_vault) -> None:
assert vault.rename_inline_tag("no tag", "new_tag") == 0
assert vault.rename_inline_tag("intext_tag2", "new_tag") == 2
assert vault.metadata.dict["Inline Tags"] == [
assert vault.metadata.tags == [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",