18 Commits

Author SHA1 Message Date
Nathaniel Landau
1977ae362c bump(release): v0.2.0 → v0.3.0 2023-01-30 18:31:08 +00:00
Nathaniel Landau
c0d37eff3b fix(application): improve ux (#10)
* fix(vault): use table for listing notes in scope

* style: alphabetize methods

* fix(application): subcommand usage text formatting

* fix(questions): improve question style
2023-01-30 13:29:18 -05:00
Nathaniel Landau
48174ebde9 build(deps): bump dependencies 2023-01-30 17:04:13 +00:00
Nathaniel Landau
eeaa1e7576 feat(application): add new metadata to frontmatter (#9)
* feat(frontmatter): frontmatter method to add key, values

* build: add pysnooper to aid in debugging

* feat(application): add new frontmatter

* build: clean up dev container

* fix(notes): diff now pretty prints in a table

* docs(readme): update usage information

* docs(readme): fix markdown lists
2023-01-30 11:06:31 -05:00
Nathaniel Landau
ac0090c6c9 build(ruff): update ruff configuration 2023-01-28 22:08:42 +00:00
Nathaniel Landau
42dd73b038 build(devcontainer): ebuild virtual environment 2023-01-28 22:08:07 +00:00
Nathaniel Landau
bc394e2d77 build(deps): update dependencies 2023-01-25 17:57:24 +00:00
Nathaniel Landau
6867c62dcf bump(release): v0.1.1 → v0.2.0 2023-01-25 17:26:29 +00:00
Nathaniel Landau
455a2c9e86 refactor(application): refactor questions to separate class (#7)
* refactor(application): refactor questions to separate class
* test(application): add tests for`Application` class
2023-01-25 12:20:59 -05:00
Nathaniel Landau
1e4fbcb4e2 feat(configuration): support multiple vaults in the configuration file (#6)
When multiple vaults are added to the configuration file, the script will prompt 
you to select one at runtime
2023-01-24 10:32:56 -05:00
Nathaniel Landau
5abab2ad20 docs(readme): fix dumb typo 2023-01-23 01:02:26 +00:00
Nathaniel Landau
b0689b48f1 ci: add allowed endpoints to harden-runner 2023-01-23 00:40:22 +00:00
Nathaniel Landau
9131ce128d bump(release): v0.1.0 → v0.1.1 2023-01-23 00:31:51 +00:00
Nathaniel Landau
b7735760e9 test: add tests for Application class
Need more attention here. Very difficult to test the keyboard interaction with questionary. Going to try using pexpect soon to hopefully add better coverage.
2023-01-23 00:31:08 +00:00
Nathaniel Landau
3fd6866760 fix(notes): diff now prints values in the form [value] 2023-01-22 18:23:50 -05:00
Nathaniel Landau
759fc3434f fix(application): exit after committing changes 2023-01-22 18:23:50 -05:00
Nathaniel Landau
c427a987c1 docs(readme): update install instructions 2023-01-22 18:23:50 -05:00
Nathaniel Landau
9123ee149f ci: harden runner configuration 2023-01-22 17:17:09 +00:00
38 changed files with 2641 additions and 841 deletions

View File

@@ -39,10 +39,7 @@
"--exclude",
"'tests/'"
],
"python.linting.ignorePatterns": [
".vscode/**/*.py",
".venv/**/*.py"
],
"python.linting.ignorePatterns": [".vscode/**/*.py", ".venv/**/*.py"],
"python.venvFolders": ["/home/vscode/.cache/pypoetry/virtualenvs"],
"ruff.importStrategy": "fromEnvironment",
"shellformat.path": "/home/vscode/.local/bin/shfmt",
@@ -55,29 +52,29 @@
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-python.python",
"bierner.markdown-preview-github-styles",
"ms-python.python",
"bierner.markdown-preview-github-styles",
"charliermarsh.ruff",
"donjayamanne.githistory",
"donjayamanne.githistory",
"eamodio.gitlens",
"fcrespo82.markdown-table-formatter",
"foxundermoon.shell-format",
"GitHub.copilot",
"Gruntfuggly.todo-tree",
"mhutchie.git-graph",
"njpwerner.autodocstring",
"oderwat.indent-rainbow",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters",
"samuelcolvin.jinjahtml",
"shardulm94.trailing-spaces",
"streetsidesoftware.code-spell-checker",
"tamasfe.even-better-toml",
"timonwong.shellcheck",
"Tyriar.sort-lines",
"visualstudioexptteam.vscodeintellicode",
"Chouzz.vscode-better-align",
"yzhang.markdown-all-in-one"
"fcrespo82.markdown-table-formatter",
"foxundermoon.shell-format",
"GitHub.copilot",
"Gruntfuggly.todo-tree",
"mhutchie.git-graph",
"njpwerner.autodocstring",
"oderwat.indent-rainbow",
"redhat.vscode-yaml",
"ryanluker.vscode-coverage-gutters",
"samuelcolvin.jinjahtml",
"shardulm94.trailing-spaces",
"streetsidesoftware.code-spell-checker",
"tamasfe.even-better-toml",
"timonwong.shellcheck",
"Tyriar.sort-lines",
"visualstudioexptteam.vscodeintellicode",
"Chouzz.vscode-better-align",
"yzhang.markdown-all-in-one"
],
"features": {
"ghcr.io/devcontainers/features/common-utils:1": {},

View File

@@ -17,14 +17,12 @@ _mainScript_() {
iotop
jq
less
libmagickwand-dev
libxml2-utils
lnav
lsof
nano
net-tools
openssh-server
p7zip-full
python3-pip
shellcheck
unzip
@@ -34,14 +32,22 @@ _mainScript_() {
)
echo ""
header "Installing apt packages"
header "Install apt packages"
_execute_ "sudo apt-get update"
_execute_ "sudo apt-get upgrade -y"
for package in "${APT_PACKAGES[@]}"; do
_execute_ -p "sudo apt-get install -y \"${package}\""
done
if [ -d "${WORKSPACE_DIR}/.venv" ]; then
echo ""
header "Remove existing virtual environment"
_execute_ -pv "rm -rf ${WORKSPACE_DIR}/.venv"
fi
if command -v batcat &>/dev/null; then
echo ""
header "Favor bat over cat"
_execute_ -p "mkdir -p /home/vscode/.local/bin && ln -s /usr/bin/batcat /home/vscode/.local/bin/bat"
fi
@@ -112,7 +118,7 @@ _mainScript_() {
echo ""
header "Install virtual environment with poetry"
if command -v poetry &>/dev/null; then
pushd "/workspaces/obsidian-metadata" &>/dev/null
pushd "${WORKSPACE_DIR}" &>/dev/null
_execute_ -pv "poetry install"
venv_path="$(poetry env info --path)"
echo "" >>"/home/vscode/.zshrc"
@@ -128,13 +134,13 @@ _mainScript_() {
echo ""
header "Initialize pre-commit"
if command -v pre-commit &>/dev/null; then
if [ -d "/workspaces/obsidian-metadata/.git" ]; then
pushd "/workspaces/obsidian-metadata" &>/dev/null
if [ -d "${WORKSPACE_DIR}/.git" ]; then
pushd "${WORKSPACE_DIR}" &>/dev/null
_execute_ -pv "pre-commit install --install-hooks"
_execute_ -pv "pre-commit autoupdate"
popd &>/dev/null
else
warning "Git repository not found in /workspaces/obsidian-metadata. Initialize pre-commit manually."
warning "Git repository not found in ${WORKSPACE_DIR}. Initialize pre-commit manually."
fi
else
warning "pre-commit is not installed"
@@ -154,7 +160,7 @@ DRYRUN=false
declare -a ARGS=()
# Script specific
WORKSPACE_DIR="/workspaces/obsidian-metadata"
# ################################## Custom utility functions (Pasted from repository)
_execute_() {
# DESC:

View File

@@ -20,8 +20,16 @@ jobs:
steps:
- uses: step-security/harden-runner@18bf8ad2ca49c14cbb28b91346d626ccfb00c518 # v2.1.0
with:
egress-policy: audit
egress-policy: block
disable-sudo: true
allowed-endpoints: >
api.github.com:443
files.pythonhosted.org:443
github.com:443
install.python-poetry.org:443
pypi.org:443
python-poetry.org:443
upload.pypi.org:443
- name: Checkout repository
uses: actions/checkout@v3

View File

@@ -5,7 +5,7 @@ default_stages: [commit, manual]
fail_fast: true
repos:
- repo: "https://github.com/commitizen-tools/commitizen"
rev: v2.39.1
rev: v2.40.0
hooks:
- id: commitizen
- id: commitizen-branch
@@ -39,6 +39,7 @@ repos:
- id: check-shebang-scripts-are-executable
- id: check-symlinks
- id: check-toml
exclude: broken_config_file\.toml
- id: check-vcs-permalinks
- id: check-xml
- id: check-yaml
@@ -60,7 +61,7 @@ repos:
entry: yamllint --strict --config-file .yamllint.yml
- repo: "https://github.com/charliermarsh/ruff-pre-commit"
rev: "v0.0.229"
rev: "v0.0.237"
hooks:
- id: ruff
args: ["--extend-ignore", "I001,D301,D401,PLR2004"]

View File

@@ -1,3 +1,30 @@
## v0.3.0 (2023-01-30)
### Feat
- **application**: add new metadata to frontmatter (#9)
### Fix
- **application**: improve ux (#10)
## v0.2.0 (2023-01-25)
### Feat
- **configuration**: support multiple vaults in the configuration file (#6)
### Refactor
- **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
## v0.1.0 (2023-01-22)
### Feat

View File

@@ -1,53 +1,81 @@
[![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)
# obsidian-metadata
A script to make batch updates to metadata in an Obsidian vault. Provides the following capabilities:
A script to make batch updates to metadata in an Obsidian vault. No changes are
made to the Vault until they are explicitly committed.
- in-text tag: delete every occurrence
- in-text tags: Rename tag (`#tag1` -> `#tag2`)
- frontmatter: Delete a key matching a regex pattern and all associated values
- frontmatter: Rename a key
- frontmatter: Delete a value matching a regex pattern from a specified key
- frontmatter: Rename a value from a specified key
- inline metadata: Delete a key matching a regex pattern and all associated values
- inline metadata: Rename a key
- inline metadata: Delete a value matching a regex pattern from a specified key
- inline metadata: Rename a value from a specified key
- vault: Create a backup of the Obsidian vault
[![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.
## Install
`obsidian-metadata` requires Python v3.10 or above.
Use [PIPX](https://pypa.github.io/pipx/) to install this package from Github.
Requires Python v3.10 or above.
```bash
pipx install git+https://${GITHUB_TOKEN}@github.com/natelandau/obsidian-metadata
pip install obsidian-metadata
```
## Disclaimer
**Important:** It is strongly recommended that you back up your vault prior to committing changes. This script makes changes directly to the markdown files in your vault. Once the changes are committed, there is no ability to recreate the original information unless you have a backup. Follow the instructions in the script to create a backup of your vault if needed.
The author of this script is not responsible for any data loss that may occur. Use at your own risk.
## Usage
The script provides a menu of available actions. Make as many changes as you require and review them as you go. No changes are made to the Vault until they are explicitly committed.
Run `obsidian-metadata` from the command line to invoke the script. Add `--help` to view additional options.
[![asciicast](https://asciinema.org/a/553464.svg)](https://asciinema.org/a/553464)
Obsidian-metadata provides a menu of sub-commands.
**Vault Actions**
- 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.
**Add Metadata**
- Add metadata to the frontmatter
- Add to inline metadata (Not yet implemented)
- Add to inline tag (Not yet implemented)
**Rename Metadata**
- Rename a key
- Rename a value
- rename an inline tag
**Delete Metadata**
- Delete a key and associated values
- Delete a value from a key
- Delete an inline tag
**Review Changes**
- View a diff of the changes that will be made
**Commit Changes**
- Commit changes to the vault
### Configuration
`obsidian-metadata` requires a configuration file at `~/.obsidian_metadata.toml`. On first run, this file will be created. Read the comments in this file to configure your preferences. This configuration file contains the following information.
`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
Below is an example with two vaults.
```toml
# Path to your obsidian vault
vault = "/path/to/vault"
["Vault One"] # Name of the vault.
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]
# Path to your obsidian vault
path = "/path/to/vault"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]
["Vault Two"]
path = "/path/to/second_vault"
exclude_paths = [".git", ".obsidian"]
```
To bypass the configuration file and specify a vault to use at runtime use the `--vault-path` option.
# Contributing
@@ -56,7 +84,7 @@ exclude_paths = [".git", ".obsidian"]
There are two ways to contribute to this project.
### 21. Containerized development (Recommended)
### 1. Containerized development (Recommended)
1. Clone this repository. `git clone https://github.com/natelandau/obsidian-metadata`
2. Open the repository in Visual Studio Code

159
poetry.lock generated
View File

@@ -93,7 +93,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7
[[package]]
name = "commitizen"
version = "2.39.1"
version = "2.40.0"
description = "Python commitizen client tool"
category = "dev"
optional = false
@@ -114,7 +114,7 @@ typing-extensions = ">=4.0.1,<5.0.0"
[[package]]
name = "coverage"
version = "7.0.5"
version = "7.1.0"
description = "Code coverage measurement for Python"
category = "dev"
optional = false
@@ -188,7 +188,7 @@ pyflakes = ">=3.0.0,<3.1.0"
[[package]]
name = "identify"
version = "2.5.13"
version = "2.5.16"
description = "File identification library for Python"
category = "dev"
optional = false
@@ -357,7 +357,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
name = "pathspec"
version = "0.10.3"
version = "0.11.0"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
optional = false
@@ -498,6 +498,17 @@ python-versions = ">=3.6"
[package.extras]
plugins = ["importlib-metadata"]
[[package]]
name = "pysnooper"
version = "1.1.1"
description = "A poor man's debugger for Python."
category = "dev"
optional = false
python-versions = "*"
[package.extras]
tests = ["pytest"]
[[package]]
name = "pytest"
version = "7.2.1"
@@ -600,7 +611,7 @@ docs = ["Sphinx (>=3.3,<4.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphin
[[package]]
name = "rich"
version = "13.2.0"
version = "13.3.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main"
optional = false
@@ -608,10 +619,10 @@ python-versions = ">=3.7.0"
[package.dependencies]
markdown-it-py = ">=2.1.0,<3.0.0"
pygments = ">=2.6.0,<3.0.0"
pygments = ">=2.14.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruamel-yaml"
@@ -646,7 +657,7 @@ python-versions = ">=3.7"
[[package]]
name = "setuptools"
version = "66.1.1"
version = "67.0.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "dev"
optional = false
@@ -699,7 +710,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
category = "main"
category = "dev"
optional = false
python-versions = ">=3.7"
@@ -707,7 +718,7 @@ python-versions = ">=3.7"
name = "tomlkit"
version = "0.11.6"
description = "Style preserving TOML library"
category = "dev"
category = "main"
optional = false
python-versions = ">=3.6"
@@ -814,7 +825,7 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "db4a41f0b94c3bf84fa548a6733da1cced1fedc2bdfdc6f80396dddbd5619bfd"
content-hash = "c2deb1e448642f9084ed1e3dfaf96ed8458bac720e63a531acf3507fd1cbe47d"
[metadata.files]
absolufy-imports = [
@@ -860,61 +871,61 @@ colorama = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
commitizen = [
{file = "commitizen-2.39.1-py3-none-any.whl", hash = "sha256:2678c51ed38676435a4ba02e164605b0aacfefcc3f7e0c8d11dd39e367e20577"},
{file = "commitizen-2.39.1.tar.gz", hash = "sha256:1f4b77a6b6cf43fc75e7fc604081add66026a5031c2a5032b2b9e8202bc57d47"},
{file = "commitizen-2.40.0-py3-none-any.whl", hash = "sha256:44b589869529c297d4ef594bb7560388d3367b3ae8af36b0664d2f51a28e8f87"},
{file = "commitizen-2.40.0.tar.gz", hash = "sha256:8f1a09589ffb87bb17df17261423e88299bd63432dbfc4e6fc6657fea23dddc0"},
]
coverage = [
{file = "coverage-7.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a7f23bbaeb2a87f90f607730b45564076d870f1fb07b9318d0c21f36871932b"},
{file = "coverage-7.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c18d47f314b950dbf24a41787ced1474e01ca816011925976d90a88b27c22b89"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef14d75d86f104f03dea66c13188487151760ef25dd6b2dbd541885185f05f40"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66e50680e888840c0995f2ad766e726ce71ca682e3c5f4eee82272c7671d38a2"},
{file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9fed35ca8c6e946e877893bbac022e8563b94404a605af1d1e6accc7eb73289"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d8d04e755934195bdc1db45ba9e040b8d20d046d04d6d77e71b3b34a8cc002d0"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e109f1c9a3ece676597831874126555997c48f62bddbcace6ed17be3e372de8"},
{file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0a1890fca2962c4f1ad16551d660b46ea77291fba2cc21c024cd527b9d9c8809"},
{file = "coverage-7.0.5-cp310-cp310-win32.whl", hash = "sha256:be9fcf32c010da0ba40bf4ee01889d6c737658f4ddff160bd7eb9cac8f094b21"},
{file = "coverage-7.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:cbfcba14a3225b055a28b3199c3d81cd0ab37d2353ffd7f6fd64844cebab31ad"},
{file = "coverage-7.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30b5fec1d34cc932c1bc04017b538ce16bf84e239378b8f75220478645d11fca"},
{file = "coverage-7.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1caed2367b32cc80a2b7f58a9f46658218a19c6cfe5bc234021966dc3daa01f0"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d254666d29540a72d17cc0175746cfb03d5123db33e67d1020e42dae611dc196"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19245c249aa711d954623d94f23cc94c0fd65865661f20b7781210cb97c471c0"},
{file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b05ed4b35bf6ee790832f68932baf1f00caa32283d66cc4d455c9e9d115aafc"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:29de916ba1099ba2aab76aca101580006adfac5646de9b7c010a0f13867cba45"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e057e74e53db78122a3979f908973e171909a58ac20df05c33998d52e6d35757"},
{file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:411d4ff9d041be08fdfc02adf62e89c735b9468f6d8f6427f8a14b6bb0a85095"},
{file = "coverage-7.0.5-cp311-cp311-win32.whl", hash = "sha256:52ab14b9e09ce052237dfe12d6892dd39b0401690856bcfe75d5baba4bfe2831"},
{file = "coverage-7.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:1f66862d3a41674ebd8d1a7b6f5387fe5ce353f8719040a986551a545d7d83ea"},
{file = "coverage-7.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b69522b168a6b64edf0c33ba53eac491c0a8f5cc94fa4337f9c6f4c8f2f5296c"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436e103950d05b7d7f55e39beeb4d5be298ca3e119e0589c0227e6d0b01ee8c7"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c56bec53d6e3154eaff6ea941226e7bd7cc0d99f9b3756c2520fc7a94e6d96"},
{file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a38362528a9115a4e276e65eeabf67dcfaf57698e17ae388599568a78dcb029"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f67472c09a0c7486e27f3275f617c964d25e35727af952869dd496b9b5b7f6a3"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:220e3fa77d14c8a507b2d951e463b57a1f7810a6443a26f9b7591ef39047b1b2"},
{file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ecb0f73954892f98611e183f50acdc9e21a4653f294dfbe079da73c6378a6f47"},
{file = "coverage-7.0.5-cp37-cp37m-win32.whl", hash = "sha256:d8f3e2e0a1d6777e58e834fd5a04657f66affa615dae61dd67c35d1568c38882"},
{file = "coverage-7.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9e662e6fc4f513b79da5d10a23edd2b87685815b337b1a30cd11307a6679148d"},
{file = "coverage-7.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:790e4433962c9f454e213b21b0fd4b42310ade9c077e8edcb5113db0818450cb"},
{file = "coverage-7.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49640bda9bda35b057b0e65b7c43ba706fa2335c9a9896652aebe0fa399e80e6"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d66187792bfe56f8c18ba986a0e4ae44856b1c645336bd2c776e3386da91e1dd"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:276f4cd0001cd83b00817c8db76730938b1ee40f4993b6a905f40a7278103b3a"},
{file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95304068686545aa368b35dfda1cdfbbdbe2f6fe43de4a2e9baa8ebd71be46e2"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:17e01dd8666c445025c29684d4aabf5a90dc6ef1ab25328aa52bedaa95b65ad7"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea76dbcad0b7b0deb265d8c36e0801abcddf6cc1395940a24e3595288b405ca0"},
{file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:50a6adc2be8edd7ee67d1abc3cd20678987c7b9d79cd265de55941e3d0d56499"},
{file = "coverage-7.0.5-cp38-cp38-win32.whl", hash = "sha256:e4ce984133b888cc3a46867c8b4372c7dee9cee300335e2925e197bcd45b9e16"},
{file = "coverage-7.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4a950f83fd3f9bca23b77442f3a2b2ea4ac900944d8af9993743774c4fdc57af"},
{file = "coverage-7.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c2155943896ac78b9b0fd910fb381186d0c345911f5333ee46ac44c8f0e43ab"},
{file = "coverage-7.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54f7e9705e14b2c9f6abdeb127c390f679f6dbe64ba732788d3015f7f76ef637"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ee30375b409d9a7ea0f30c50645d436b6f5dfee254edffd27e45a980ad2c7f4"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b78729038abea6a5df0d2708dce21e82073463b2d79d10884d7d591e0f385ded"},
{file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13250b1f0bd023e0c9f11838bdeb60214dd5b6aaf8e8d2f110c7e232a1bff83b"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c407b1950b2d2ffa091f4e225ca19a66a9bd81222f27c56bd12658fc5ca1209"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c76a3075e96b9c9ff00df8b5f7f560f5634dffd1658bafb79eb2682867e94f78"},
{file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f26648e1b3b03b6022b48a9b910d0ae209e2d51f50441db5dce5b530fad6d9b1"},
{file = "coverage-7.0.5-cp39-cp39-win32.whl", hash = "sha256:ba3027deb7abf02859aca49c865ece538aee56dcb4871b4cced23ba4d5088904"},
{file = "coverage-7.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:949844af60ee96a376aac1ded2a27e134b8c8d35cc006a52903fc06c24a3296f"},
{file = "coverage-7.0.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:b9727ac4f5cf2cbf87880a63870b5b9730a8ae3a4a360241a0fdaa2f71240ff0"},
{file = "coverage-7.0.5.tar.gz", hash = "sha256:051afcbd6d2ac39298d62d340f94dbb6a1f31de06dfaf6fcef7b759dd3860c45"},
{file = "coverage-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b946bbcd5a8231383450b195cfb58cb01cbe7f8949f5758566b881df4b33baf"},
{file = "coverage-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec8e767f13be637d056f7e07e61d089e555f719b387a7070154ad80a0ff31801"},
{file = "coverage-7.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a5a5879a939cb84959d86869132b00176197ca561c664fc21478c1eee60d75"},
{file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b643cb30821e7570c0aaf54feaf0bfb630b79059f85741843e9dc23f33aaca2c"},
{file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32df215215f3af2c1617a55dbdfb403b772d463d54d219985ac7cd3bf124cada"},
{file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33d1ae9d4079e05ac4cc1ef9e20c648f5afabf1a92adfaf2ccf509c50b85717f"},
{file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:29571503c37f2ef2138a306d23e7270687c0efb9cab4bd8038d609b5c2393a3a"},
{file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:63ffd21aa133ff48c4dff7adcc46b7ec8b565491bfc371212122dd999812ea1c"},
{file = "coverage-7.1.0-cp310-cp310-win32.whl", hash = "sha256:4b14d5e09c656de5038a3f9bfe5228f53439282abcab87317c9f7f1acb280352"},
{file = "coverage-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:8361be1c2c073919500b6601220a6f2f98ea0b6d2fec5014c1d9cfa23dd07038"},
{file = "coverage-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da9b41d4539eefd408c46725fb76ecba3a50a3367cafb7dea5f250d0653c1040"},
{file = "coverage-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b15ed7644ae4bee0ecf74fee95808dcc34ba6ace87e8dfbf5cb0dc20eab45a"},
{file = "coverage-7.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d12d076582507ea460ea2a89a8c85cb558f83406c8a41dd641d7be9a32e1274f"},
{file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2617759031dae1bf183c16cef8fcfb3de7617f394c813fa5e8e46e9b82d4222"},
{file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4e4881fa9e9667afcc742f0c244d9364d197490fbc91d12ac3b5de0bf2df146"},
{file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9d58885215094ab4a86a6aef044e42994a2bd76a446dc59b352622655ba6621b"},
{file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ffeeb38ee4a80a30a6877c5c4c359e5498eec095878f1581453202bfacc8fbc2"},
{file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3baf5f126f30781b5e93dbefcc8271cb2491647f8283f20ac54d12161dff080e"},
{file = "coverage-7.1.0-cp311-cp311-win32.whl", hash = "sha256:ded59300d6330be27bc6cf0b74b89ada58069ced87c48eaf9344e5e84b0072f7"},
{file = "coverage-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a43c7823cd7427b4ed763aa7fb63901ca8288591323b58c9cd6ec31ad910f3c"},
{file = "coverage-7.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a726d742816cb3a8973c8c9a97539c734b3a309345236cd533c4883dda05b8d"},
{file = "coverage-7.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc7c85a150501286f8b56bd8ed3aa4093f4b88fb68c0843d21ff9656f0009d6a"},
{file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b4198d85a3755d27e64c52f8c95d6333119e49fd001ae5798dac872c95e0f8"},
{file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb726cb861c3117a553f940372a495fe1078249ff5f8a5478c0576c7be12050"},
{file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:51b236e764840a6df0661b67e50697aaa0e7d4124ca95e5058fa3d7cbc240b7c"},
{file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7ee5c9bb51695f80878faaa5598040dd6c9e172ddcf490382e8aedb8ec3fec8d"},
{file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c31b75ae466c053a98bf26843563b3b3517b8f37da4d47b1c582fdc703112bc3"},
{file = "coverage-7.1.0-cp37-cp37m-win32.whl", hash = "sha256:3b155caf3760408d1cb903b21e6a97ad4e2bdad43cbc265e3ce0afb8e0057e73"},
{file = "coverage-7.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2a60d6513781e87047c3e630b33b4d1e89f39836dac6e069ffee28c4786715f5"},
{file = "coverage-7.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2cba5c6db29ce991029b5e4ac51eb36774458f0a3b8d3137241b32d1bb91f06"},
{file = "coverage-7.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beeb129cacea34490ffd4d6153af70509aa3cda20fdda2ea1a2be870dfec8d52"},
{file = "coverage-7.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c45948f613d5d18c9ec5eaa203ce06a653334cf1bd47c783a12d0dd4fd9c851"},
{file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef382417db92ba23dfb5864a3fc9be27ea4894e86620d342a116b243ade5d35d"},
{file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c7c0d0827e853315c9bbd43c1162c006dd808dbbe297db7ae66cd17b07830f0"},
{file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5cdbb5cafcedea04924568d990e20ce7f1945a1dd54b560f879ee2d57226912"},
{file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9817733f0d3ea91bea80de0f79ef971ae94f81ca52f9b66500c6a2fea8e4b4f8"},
{file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:218fe982371ac7387304153ecd51205f14e9d731b34fb0568181abaf7b443ba0"},
{file = "coverage-7.1.0-cp38-cp38-win32.whl", hash = "sha256:04481245ef966fbd24ae9b9e537ce899ae584d521dfbe78f89cad003c38ca2ab"},
{file = "coverage-7.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ae125d1134bf236acba8b83e74c603d1b30e207266121e76484562bc816344c"},
{file = "coverage-7.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2bf1d5f2084c3932b56b962a683074a3692bce7cabd3aa023c987a2a8e7612f6"},
{file = "coverage-7.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:98b85dd86514d889a2e3dd22ab3c18c9d0019e696478391d86708b805f4ea0fa"},
{file = "coverage-7.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38da2db80cc505a611938d8624801158e409928b136c8916cd2e203970dde4dc"},
{file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3164d31078fa9efe406e198aecd2a02d32a62fecbdef74f76dad6a46c7e48311"},
{file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db61a79c07331e88b9a9974815c075fbd812bc9dbc4dc44b366b5368a2936063"},
{file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ccb092c9ede70b2517a57382a601619d20981f56f440eae7e4d7eaafd1d1d09"},
{file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:33ff26d0f6cc3ca8de13d14fde1ff8efe1456b53e3f0273e63cc8b3c84a063d8"},
{file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d47dd659a4ee952e90dc56c97d78132573dc5c7b09d61b416a9deef4ebe01a0c"},
{file = "coverage-7.1.0-cp39-cp39-win32.whl", hash = "sha256:d248cd4a92065a4d4543b8331660121b31c4148dd00a691bfb7a5cdc7483cfa4"},
{file = "coverage-7.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7ed681b0f8e8bcbbffa58ba26fcf5dbc8f79e7997595bf071ed5430d8c08d6f3"},
{file = "coverage-7.1.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:755e89e32376c850f826c425ece2c35a4fc266c081490eb0a841e7c1cb0d3bda"},
{file = "coverage-7.1.0.tar.gz", hash = "sha256:10188fe543560ec4874f974b5305cd1a8bdcfa885ee00ea3a03733464c4ca265"},
]
decli = [
{file = "decli-0.5.2-py3-none-any.whl", hash = "sha256:d3207bc02d0169bf6ed74ccca09ce62edca0eb25b0ebf8bf4ae3fb8333e15ca0"},
@@ -941,8 +952,8 @@ flake8 = [
{file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"},
]
identify = [
{file = "identify-2.5.13-py2.py3-none-any.whl", hash = "sha256:8aa48ce56e38c28b6faa9f261075dea0a942dfbb42b341b4e711896cbb40f3f7"},
{file = "identify-2.5.13.tar.gz", hash = "sha256:abb546bca6f470228785338a01b539de8a85bbf46491250ae03363956d8ebb10"},
{file = "identify-2.5.16-py2.py3-none-any.whl", hash = "sha256:832832a58ecc1b8f33d5e8cb4f7d3db2f5c7fbe922dfee5f958b48fed691501a"},
{file = "identify-2.5.16.tar.gz", hash = "sha256:c47acedfe6495b1c603ed7e93469b26e839cab38db4155113f36f718f8b3dc47"},
]
iniconfig = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
@@ -1073,8 +1084,8 @@ pastel = [
{file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"},
]
pathspec = [
{file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"},
{file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
{file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"},
{file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"},
]
pdoc = [
{file = "pdoc-12.3.1-py3-none-any.whl", hash = "sha256:c3f24f31286e634de9c76fa6e67bd5c0c5e74360b41dc91e6b82499831eb52d8"},
@@ -1124,6 +1135,10 @@ pygments = [
{file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"},
{file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"},
]
pysnooper = [
{file = "PySnooper-1.1.1-py2.py3-none-any.whl", hash = "sha256:378f13d731a3e04d3d0350e5f295bdd0f1b49fc8a8b8bf2067fe1e5290bd20be"},
{file = "PySnooper-1.1.1.tar.gz", hash = "sha256:d17dc91cca1593c10230dce45e46b1d3ff0f8910f0c38e941edf6ba1260b3820"},
]
pytest = [
{file = "pytest-7.2.1-py3-none-any.whl", hash = "sha256:c7c6ca206e93355074ae32f7403e8ea12163b1163c976fee7d4d84027c162be5"},
{file = "pytest-7.2.1.tar.gz", hash = "sha256:d45e0952f3727241918b8fd0f376f5ff6b301cc0777c6f9a556935c92d8a7d42"},
@@ -1190,8 +1205,8 @@ questionary = [
{file = "questionary-1.10.0.tar.gz", hash = "sha256:600d3aefecce26d48d97eee936fdb66e4bc27f934c3ab6dd1e292c4f43946d90"},
]
rich = [
{file = "rich-13.2.0-py3-none-any.whl", hash = "sha256:7c963f0d03819221e9ac561e1bc866e3f95a02248c1234daa48954e6d381c003"},
{file = "rich-13.2.0.tar.gz", hash = "sha256:f1a00cdd3eebf999a15d85ec498bfe0b1a77efe9b34f645768a54132ef444ac5"},
{file = "rich-13.3.1-py3-none-any.whl", hash = "sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9"},
{file = "rich-13.3.1.tar.gz", hash = "sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f"},
]
ruamel-yaml = [
{file = "ruamel.yaml-0.17.21-py3-none-any.whl", hash = "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7"},
@@ -1252,8 +1267,8 @@ ruff = [
{file = "ruff-0.0.217.tar.gz", hash = "sha256:39b2b1de9330fcf60643bdd6c4c660b457390c686b4ba7101bea019a01446494"},
]
setuptools = [
{file = "setuptools-66.1.1-py3-none-any.whl", hash = "sha256:6f590d76b713d5de4e49fe4fbca24474469f53c83632d5d0fd056f7ff7e8112b"},
{file = "setuptools-66.1.1.tar.gz", hash = "sha256:ac4008d396bc9cd983ea483cb7139c0240a07bbc74ffb6232fceffedc6cf03a8"},
{file = "setuptools-67.0.0-py3-none-any.whl", hash = "sha256:9d790961ba6219e9ff7d9557622d2fe136816a264dd01d5997cfc057d804853d"},
{file = "setuptools-67.0.0.tar.gz", hash = "sha256:883131c5b6efa70b9101c7ef30b2b7b780a4283d5fc1616383cdf22c83cbefe6"},
]
shellingham = [
{file = "shellingham-1.5.0.post1-py2.py3-none-any.whl", hash = "sha256:368bf8c00754fd4f55afb7bbb86e272df77e4dc76ac29dbcbb81a59e9fc15744"},

View File

@@ -11,7 +11,7 @@
name = "obsidian-metadata"
readme = "README.md"
repository = "https://github.com/natelandau/obsidian-metadata"
version = "0.1.0"
version = "0.3.0"
[tool.poetry.scripts] # https://python-poetry.org/docs/pyproject/#scripts
obsidian-metadata = "obsidian_metadata.cli:app"
@@ -23,7 +23,7 @@
rich = "^13.2.0"
ruamel-yaml = "^0.17.21"
shellingham = "^1.4.0"
tomli = "^2.0.1"
tomlkit = "^0.11.6"
typer = "^0.7.0"
[tool.poetry.group.test.dependencies]
@@ -44,6 +44,7 @@
pep8-naming = "^0.13.3"
poethepoet = "^0.18.0"
pre-commit = "^2.21.0"
pysnooper = "^1.1.1"
ruff = "^0.0.217"
typeguard = "^2.13.3"
types-python-dateutil = "^2.8.19.5"
@@ -60,7 +61,6 @@
"D204",
"D213",
"D215",
"D400",
"D404",
"D406",
"D407",
@@ -102,7 +102,7 @@
]
src = ["src", "tests"]
target-version = "py310"
unfixable = ["ERA001", "F401", "F401", "UP007"]
unfixable = ["ERA001", "F401", "F841", "UP007"]
[tool.coverage.report] # https://coverage.readthedocs.io/en/latest/config.html#report
exclude_lines = [
@@ -142,7 +142,7 @@
bump_message = "bump(release): v$current_version → v$new_version"
tag_format = "v$version"
update_changelog_on_bump = true
version = "0.1.0"
version = "0.3.0"
version_files = [
"pyproject.toml:version",
"src/obsidian_metadata/__version__.py:__version__",

View File

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

View File

@@ -1,5 +1,5 @@
"""Config module for obsidian frontmatter."""
from obsidian_metadata._config.config import Config
from obsidian_metadata._config.config import Config, VaultConfig
__all__ = ["Config"]
__all__ = ["Config", "VaultConfig"]

View File

@@ -1,61 +1,89 @@
"""Instantiate the configuration object."""
import re
import shutil
from pathlib import Path
from textwrap import dedent
from typing import Any
import questionary
import rich.repr
import tomlkit
import typer
from obsidian_metadata._utils import alerts, vault_validation
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib # type: ignore [no-redef]
DEFAULT_CONFIG_FILE: Path = Path(__file__).parent / "default.toml"
class ConfigQuestions:
"""Questions to ask the user when creating a configuration file."""
@staticmethod
def ask_for_vault_path() -> Path: # pragma: no cover
"""Ask the user for the path to their vault.
Returns:
Path: The path to the vault.
"""
vault_path = questionary.path(
"Enter a path to Obsidian vault:",
only_directories=True,
validate=ConfigQuestions._validate_valid_dir,
).ask()
if vault_path is None:
raise typer.Exit(code=1)
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:
"""Configuration class."""
"""Representation of a configuration file."""
def __init__(self, config_path: Path = None, vault_path: Path = None) -> None:
self.config_path: Path = self._validate_config_path(Path(config_path))
self.config: dict[str, Any] = self._load_config()
self.config_content: str = self.config_path.read_text()
self.vault_path: Path = self._validate_vault_path(vault_path)
if vault_path is None:
self.config_path: Path = self._validate_config_path(Path(config_path))
self.config: dict[str, Any] = self._load_config()
if self.config == {}:
log.error(f"Configuration file is empty: '{self.config_path}'")
raise typer.Exit(code=1)
else:
self.config_path = None
self.config = {
"command_line_vault": {"path": vault_path, "exclude_paths": [".git", ".obsidian"]}
}
try:
self.exclude_paths: list[Any] = self.config["exclude_paths"]
except KeyError:
self.exclude_paths = []
try:
self.metadata_location: str = self.config["metadata"]["metadata_location"]
except KeyError:
self.metadata_location = "frontmatter"
try:
self.tags_location: str = self.config["metadata"]["tags_location"]
except KeyError:
self.tags_location = "top"
self.vaults: list[VaultConfig] = [
VaultConfig(vault_name=key, vault_config=self.config[key]) for key in self.config
]
except TypeError as e:
log.error(f"Configuration file is invalid: '{self.config_path}'")
raise typer.Exit(code=1) from e
log.debug(f"Loaded configuration from '{self.config_path}'")
log.trace(self.config)
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of Vault."""
"""Define rich representation of the Config object."""
yield "config_path", self.config_path
yield "config_content",
yield "vault_path", self.vault_path
yield "metadata_location", self.metadata_location
yield "tags_location", self.tags_location
yield "exclude_paths", self.exclude_paths
yield "vaults", self.vaults
def _validate_config_path(self, config_path: Path | None) -> Path:
"""Load the configuration path."""
@@ -63,7 +91,7 @@ class Config:
config_path = Path(Path.home() / f".{__package__.split('.')[0]}.toml")
if not config_path.exists():
shutil.copy(DEFAULT_CONFIG_FILE, config_path)
self._write_default_config(config_path)
alerts.info(f"Created default configuration file at '{config_path}'")
return config_path.expanduser().resolve()
@@ -71,46 +99,67 @@ class Config:
def _load_config(self) -> dict[str, Any]:
"""Load the configuration file."""
try:
with self.config_path.open("rb") as f:
return tomllib.load(f)
except tomllib.TOMLDecodeError as e:
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()
config_text = f"""\
# Add another vault by replicating this section and changing the name
["Vault 1"] # Name of the vault.
# Path to your obsidian vault
path = "{vault_path}"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]"""
path_to_config.write_text(dedent(config_text))
@rich.repr.auto
class VaultConfig:
"""Representation of a vault configuration."""
def __init__(self, vault_name: str, vault_config: dict) -> None:
"""Initialize the vault configuration."""
self.name: str = vault_name
self.config: dict = vault_config
try:
self.path = self._validate_vault_path(self.config["path"])
Path(self.config["path"]).expanduser().resolve()
except KeyError:
self.path = None
try:
self.exclude_paths = self.config["exclude_paths"]
except KeyError:
self.exclude_paths = []
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of a vault config."""
yield "name", self.name
yield "config", self.config
yield "path", self.path
yield "exclude_paths", self.exclude_paths
def _validate_vault_path(self, vault_path: Path | None) -> Path:
"""Validate the vault path."""
if vault_path is None:
try:
vault_path = Path(self.config["vault"]).expanduser().resolve()
except KeyError:
vault_path = Path("/I/Do/Not/Exist")
vault_path = Path(vault_path).expanduser().resolve()
if not vault_path.exists(): # pragma: no cover
if not vault_path.exists():
alerts.error(f"Vault path not found: '{vault_path}'")
raise typer.Exit(code=1)
vault_path = questionary.path(
"Enter a path to Obsidian vault:",
only_directories=True,
validate=vault_validation,
).ask()
if vault_path is None:
raise typer.Exit(code=1)
if not vault_path.is_dir():
alerts.error(f"Vault path is not a directory: '{vault_path}'")
raise typer.Exit(code=1)
vault_path = Path(vault_path).expanduser().resolve()
self.write_config_value("vault", str(vault_path))
return vault_path
def write_config_value(self, key: str, value: str | int) -> None:
"""Write a new value to the configuration file.
Args:
key (str): The key to write.
value (str|int): The value to write.
"""
self.config_content = re.sub(
rf"( *{key} = ['\"])[^'\"]*(['\"].*)", rf"\1{value}\2", self.config_content
)
alerts.notice(f"Writing new configuration for '{key}' to '{self.config_path}'")
self.config_path.write_text(self.config_content)

View File

@@ -1,5 +0,0 @@
# Path to your obsidian vault
vault = "/path/to/vault"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]

View File

@@ -9,7 +9,6 @@ from obsidian_metadata._utils.utilities import (
dict_values_to_lists_strings,
docstring_parameter,
remove_markdown_sections,
vault_validation,
version_callback,
)
@@ -17,8 +16,8 @@ __all__ = [
"alerts",
"clean_dictionary",
"clear_screen",
"dict_values_to_lists_strings",
"dict_contains",
"dict_values_to_lists_strings",
"docstring_parameter",
"LoggerManager",
"remove_markdown_sections",

View File

@@ -1,7 +1,6 @@
"""Utility functions."""
import re
from os import name, system
from pathlib import Path
from typing import Any
import typer
@@ -53,7 +52,7 @@ def remove_markdown_sections(
strip_inlinecode: bool = False,
strip_frontmatter: bool = False,
) -> str:
"""Strips markdown sections from text.
"""Strip markdown sections from text.
Args:
text (str): Text to remove code blocks from
@@ -83,17 +82,6 @@ def version_callback(value: bool) -> None:
raise typer.Exit()
def vault_validation(path: str) -> bool | str:
"""Validates the vault path."""
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
def docstring_parameter(*sub: Any) -> Any:
"""Decorator to replace variables within docstrings.
@@ -132,8 +120,8 @@ def clean_dictionary(dictionary: dict[str, Any]) -> dict[str, Any]:
return new_dict
def clear_screen() -> None:
"""Clears the screen."""
def clear_screen() -> None: # pragma: no cover
"""Clear the screen."""
# for windows
_ = system("cls") if name == "nt" else system("clear")
@@ -141,7 +129,7 @@ def clear_screen() -> None:
def dict_contains(
dictionary: dict[str, list[str]], key: str, value: str = None, is_regex: bool = False
) -> bool:
"""Checks if a dictionary contains a key.
"""Check if a dictionary contains a key.
Args:
dictionary (dict): Dictionary to check

View File

@@ -4,11 +4,17 @@
from pathlib import Path
from typing import Optional
import questionary
import typer
from rich import print
from obsidian_metadata._config import Config
from obsidian_metadata._utils import alerts, docstring_parameter, version_callback
from obsidian_metadata._utils import (
alerts,
clear_screen,
docstring_parameter,
version_callback,
)
from obsidian_metadata.models import Application
app = typer.Typer(add_completion=False, no_args_is_help=True, rich_markup_mode="rich")
@@ -64,27 +70,50 @@ 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.
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.
[bold] [/]
[bold underline]Features:[/]
[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.
- [code]in-text tags:[/] delete every occurrence
- [code]in-text tags:[/] Rename tag ([dim]#tag1[/] -> [dim]#tag2[/])
- [code]frontmatter:[/] Delete a key matching a regex pattern and all associated values
- [code]frontmatter:[/] Rename a key
- [code]frontmatter:[/] Delete a value matching a regex pattern from a specified key
- [code]frontmatter:[/] Rename a value from a specified key
- [code]inline metadata:[/] Delete a key matching a regex pattern and all associated values
- [code]inline metadata:[/] Rename a key
- [code]inline metadata:[/] Delete a value matching a regex pattern from a specified key
- [code]inline metadata:[/] Rename a value from a specified key
- [code]vault:[/] Create a backup of the Obsidian vault.
[bold underline]Configuration:[/]
Configuration is specified in a configuration file. On First run, this file will be created at [tan]~/.{0}.env[/]. Any options specified on the command line will override the configuration file.
[bold underline]Usage:[/]
Run [tan]obsidian-metadata[/] from the command line. The script will allow you to make batch updates to metadata in an Obsidian vault. Once you have made your changes, review them prior to committing them to the vault.
[tan]Obsidian-metadata[/] provides a menu of sub-commands.
[bold underline]Vault Actions[/]
• Backup: Create a backup of the vault.
• Delete Backup: Delete a backup of the vault.
[bold underline]Inspect Metadata[/]
• View all metadata in the vault
[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.
[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)[/]
[bold underline]Rename Metadata[/]
• Rename a key
• Rename a value
• rename an inline tag
[bold underline]Delete Metadata[/]
• Delete a key and associated values
• Delete a value from a key
• Delete an inline tag
[bold underline]Review Changes[/]
• View a diff of the changes that will be made
[bold underline]Commit Changes[/]
• Commit changes to the vault
Configuration is specified in a configuration file. On First run, this file will be created at [tan]~/.{0}.env[/]. Any options specified on the command line will override the configuration file.
"""
# Instantiate logger
alerts.LoggerManager( # pragma: no cover
@@ -93,9 +122,6 @@ def main(
log_to_file,
)
config: Config = Config(config_path=config_file, vault_path=vault_path)
application = Application(dry_run=dry_run, config=config)
banner = r"""
___ _ _ _ _
/ _ \| |__ ___(_) __| (_) __ _ _ __
@@ -107,8 +133,29 @@ def main(
| | | | __/ || (_| | (_| | (_| | || (_| |
|_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_|
"""
clear_screen()
print(banner)
application.main_app()
config: Config = Config(config_path=config_file, vault_path=vault_path)
if len(config.vaults) == 0:
typer.echo("No vaults configured. Exiting.")
raise typer.Exit(1)
if len(config.vaults) == 1:
application = Application(dry_run=dry_run, config=config.vaults[0])
else:
vault_names = [vault.name for vault in config.vaults]
vault_name = questionary.select(
"Select a vault to process:",
choices=vault_names,
).ask()
if vault_name is None:
raise typer.Exit(code=1)
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)
application.application_main()
if __name__ == "__main__":

View File

@@ -1,4 +1,5 @@
"""Shared models."""
from obsidian_metadata.models.enums import MetadataType # isort: skip
from obsidian_metadata.models.patterns import Patterns # isort: skip
from obsidian_metadata.models.metadata import (
Frontmatter,
@@ -12,13 +13,14 @@ from obsidian_metadata.models.vault import Vault
from obsidian_metadata.models.application import Application # isort: skip
__all__ = [
"Application",
"Frontmatter",
"InlineMetadata",
"InlineTags",
"LoggerManager",
"MetadataType",
"Note",
"Patterns",
"Application",
"Vault",
"VaultMetadata",
]

View File

@@ -4,13 +4,14 @@
from typing import Any
import questionary
import typer
from rich import print
from obsidian_metadata._config import Config
from obsidian_metadata._utils import alerts, clear_screen
from textwrap import dedent
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._utils import alerts
from obsidian_metadata.models.questions import Questions
from obsidian_metadata.models.enums import MetadataType
PATTERNS = Patterns()
@@ -23,19 +24,305 @@ class Application:
More info: https://questionary.readthedocs.io/en/stable/pages/advanced.html#create-questions-from-dictionaries
"""
def __init__(self, config: Config, dry_run: bool) -> None:
def __init__(self, config: VaultConfig, dry_run: bool) -> None:
self.config = config
self.dry_run = dry_run
self.custom_style = questionary.Style(
[
("separator", "bold fg:#6C6C6C"),
("instruction", "fg:#6C6C6C"),
("highlighted", "bold reverse"),
("pointer", "bold"),
]
self.questions = Questions()
def application_main(self) -> None:
"""Questions for the main application."""
self.load_vault()
while True:
self.vault.info()
match self.questions.ask_application_main(): # noqa: E999
case "vault_actions":
self.application_vault()
case "inspect_metadata":
self.application_inspect_metadata()
case "filter_notes":
self.application_filter()
case "add_metadata":
self.application_add_metadata()
case "rename_metadata":
self.application_rename_metadata()
case "delete_metadata":
self.application_delete_metadata()
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
case _:
break
print("Done!")
return
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))
area = self.questions.ask_area()
match area:
case MetadataType.FRONTMATTER:
key = self.questions.ask_new_key(question="Enter the key for the new metadata")
if key is None:
return
value = self.questions.ask_new_value(
question="Enter the value for the new metadata"
)
if value is None:
return
num_changed = self.vault.add_metadata(area, key, value)
if num_changed == 0:
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")
case _:
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))
choices = [
{"name": "Apply regex filter", "value": "apply_filter"},
{"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:
return
if path_filter == "":
path_filter = None
self.load_vault(path_filter=path_filter)
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")
else:
alerts.success(
f"Loaded {self.vault.num_notes()} notes from {total_notes} total notes"
)
case "list_notes":
self.vault.list_editable_notes()
case _:
return
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))
choices = [
{"name": "View all metadata", "value": "all_metadata"},
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()
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))
choices = [
{"name": "Backup vault", "value": "backup_vault"},
{"name": "Delete vault backup", "value": "delete_backup"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
while True:
match self.questions.ask_selection(choices=choices, question="Select a vault action"):
case "backup_vault":
self.vault.backup()
case "delete_backup":
self.vault.delete_backup()
case _:
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))
choices = [
{"name": "Delete key", "value": "delete_key"},
{"name": "Delete value", "value": "delete_value"},
{"name": "Delete inline tag", "value": "delete_inline_tag"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
match self.questions.ask_selection(
choices=choices, question="Select a metadata type to delete"
):
case "delete_key":
self.delete_key()
case "delete_value":
self.delete_value()
case "delete_inline_tag":
self.delete_inline_tag()
case _:
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))
choices = [
{"name": "Rename key", "value": "rename_key"},
{"name": "Rename value", "value": "rename_value"},
{"name": "Rename inline tag", "value": "rename_inline_tag"},
questionary.Separator(),
{"name": "Back", "value": "back"},
]
match self.questions.ask_selection(
choices=choices, question="Select a metadata type to rename"
):
case "rename_key":
self.rename_key()
case "rename_value":
self.rename_value()
case "rename_inline_tag":
self.rename_inline_tag()
case _:
return
def commit_changes(self) -> bool:
"""Write all changes to disk.
Returns:
True if changes were committed, False otherwise.
"""
changed_notes = self.vault.get_changed_notes()
if len(changed_notes) == 0:
print("\n")
alerts.notice("No changes to commit.\n")
return False
backup = questionary.confirm("Create backup before committing changes").ask()
if backup is None:
return False
if backup:
self.vault.backup()
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
self.vault.write()
alerts.success(f"{len(changed_notes)} changes committed to disk. Exiting")
return True
return False
def delete_inline_tag(self) -> None:
"""Delete an inline tag."""
tag = self.questions.ask_existing_inline_tag(question="Which tag would you like to delete?")
num_changed = self.vault.delete_inline_tag(tag)
if num_changed == 0:
alerts.warning(f"No notes were changed")
return
alerts.success(f"Deleted inline tag: {tag} in {num_changed} notes")
return
def delete_key(self) -> None:
"""Delete a key from the vault."""
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:
return
num_changed = self.vault.delete_metadata(key_to_delete)
if num_changed == 0:
alerts.warning(f"No notes found with a key matching: [reverse]{key_to_delete}[/]")
return
alerts.success(
f"Deleted keys matching: [reverse]{key_to_delete}[/] from {num_changed} notes"
)
clear_screen()
return
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:
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:
return
num_changed = self.vault.delete_metadata(key, value)
if num_changed == 0:
alerts.warning(f"No notes found matching: {key}: {value}")
return
alerts.success(
f"Deleted value [reverse]{value}[/] from key [reverse]{key}[/] in {num_changed} notes"
)
return
def load_vault(self, path_filter: str = None) -> None:
"""Load the vault.
@@ -45,271 +332,72 @@ class Application:
"""
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}")
def main_app(self) -> None: # noqa: C901
"""Questions for the main application."""
self.load_vault()
while True:
self.vault.info()
operation = questionary.select(
"What do you want to do?",
choices=[
questionary.Separator("\n-- VAULT ACTIONS -----------------"),
{"name": "Backup vault", "value": "backup_vault"},
{"name": "Delete vault backup", "value": "delete_backup"},
{"name": "View all metadata", "value": "all_metadata"},
{"name": "List notes in scope", "value": "list_notes"},
{
"name": "Filter the notes being processed by their path",
"value": "filter_notes",
},
questionary.Separator("\n-- INLINE TAG ACTIONS ---------"),
questionary.Separator("Tags in the note body"),
{
"name": "Rename an inline tag",
"value": "rename_inline_tag",
},
{
"name": "Delete an inline tag",
"value": "delete_inline_tag",
},
questionary.Separator("\n-- METADATA ACTIONS -----------"),
questionary.Separator("Frontmatter or inline metadata"),
{"name": "Rename Key", "value": "rename_key"},
{"name": "Delete Key", "value": "delete_key"},
{"name": "Rename Value", "value": "rename_value"},
{"name": "Delete Value", "value": "delete_value"},
questionary.Separator("\n-- REVIEW/COMMIT CHANGES ------"),
{"name": "Review changes", "value": "review_changes"},
{"name": "Commit changes", "value": "commit_changes"},
questionary.Separator("-------------------------------"),
{"name": "Quit", "value": "abort"},
],
use_shortcuts=False,
style=self.custom_style,
).ask()
if operation == "filter_notes":
path_filter = questionary.text(
"Enter a regex to filter notes by path",
validate=lambda text: len(text) > 0,
).ask()
if path_filter is None:
continue
self.load_vault(path_filter=path_filter)
if operation == "all_metadata":
self.vault.metadata.print_metadata()
if operation == "backup_vault":
self.vault.backup()
if operation == "delete_backup":
self.vault.delete_backup()
if operation == "list_notes":
self.vault.list_editable_notes()
if operation == "rename_inline_tag":
self.rename_inline_tag()
if operation == "delete_inline_tag":
self.delete_inline_tag()
if operation == "rename_key":
self.rename_key()
if operation == "delete_key":
self.delete_key()
if operation == "rename_value":
self.rename_value()
if operation == "delete_value":
self.delete_value()
if operation == "review_changes":
self.review_changes()
if operation == "commit_changes":
self.commit_changes()
if operation == "abort":
break
print("Done!")
return
self.questions = Questions(vault=self.vault)
def rename_key(self) -> None:
"""Renames a key in the vault."""
def validate_key(text: str) -> bool:
"""Validate the key name."""
if self.vault.metadata.contains(text):
return True
return False
def validate_new_key(text: str) -> bool:
"""Validate the tag name."""
if PATTERNS.validate_key_text.search(text) is not None:
return False
if len(text) == 0:
return False
return True
original_key = questionary.text(
"Which key would you like to rename?",
validate=validate_key,
).ask()
original_key = self.questions.ask_existing_key(
question="Which key would you like to rename?"
)
if original_key is None:
return
new_key = questionary.text(
"New key name",
validate=validate_new_key,
).ask()
new_key = self.questions.ask_new_key()
if new_key is None:
return
self.vault.rename_metadata(original_key, new_key)
num_changed = self.vault.rename_metadata(original_key, new_key)
if num_changed == 0:
alerts.warning(f"No notes were changed")
return
alerts.success(
f"Renamed [reverse]{original_key}[/] to [reverse]{new_key}[/] in {num_changed} notes"
)
def rename_inline_tag(self) -> None:
"""Rename an inline tag."""
def validate_new_tag(text: str) -> bool:
"""Validate the tag name."""
if PATTERNS.validate_tag_text.search(text) is not None:
return False
if len(text) == 0:
return False
return True
original_tag = questionary.text(
"Which tag would you like to rename?",
validate=lambda text: True
if self.vault.contains_inline_tag(text)
else "Tag not found in vault",
).ask()
original_tag = self.questions.ask_existing_inline_tag(question="Which tag to rename?")
if original_tag is None:
return
new_tag = questionary.text(
"New tag name",
validate=validate_new_tag,
).ask()
new_tag = self.questions.ask_new_tag("New tag")
if new_tag is None:
return
self.vault.rename_inline_tag(original_tag, new_tag)
alerts.success(f"Renamed [reverse]{original_tag}[/] to [reverse]{new_tag}[/]")
return
def delete_inline_tag(self) -> None:
"""Delete an inline tag."""
tag = questionary.text(
"Which tag would you like to delete?",
validate=lambda text: True
if self.vault.contains_inline_tag(text)
else "Tag not found in vault",
).ask()
if tag is None:
num_changed = self.vault.rename_inline_tag(original_tag, new_tag)
if num_changed == 0:
alerts.warning(f"No notes were changed")
return
self.vault.delete_inline_tag(tag)
alerts.success(f"Deleted inline tag: {tag}")
return
def delete_key(self) -> None:
"""Delete a key from the vault."""
while True:
key_to_delete = questionary.text("Regex for the key(s) you'd like to delete?").ask()
if key_to_delete is None:
return
if not self.vault.metadata.contains(key_to_delete, is_regex=True):
alerts.warning(f"No matching keys in the vault: {key_to_delete}")
continue
num_changed = self.vault.delete_metadata(key_to_delete)
if num_changed == 0:
alerts.warning(f"No notes found matching: [reverse]{key_to_delete}[/]")
return
alerts.success(
f"Deleted keys matching: [reverse]{key_to_delete}[/] from {num_changed} notes"
)
break
alerts.success(
f"Renamed [reverse]{original_tag}[/] to [reverse]{new_tag}[/] in {num_changed} notes"
)
return
def rename_value(self) -> None:
"""Rename a value in the vault."""
key = questionary.text(
"Which key contains the value to rename?",
validate=lambda text: True
if self.vault.metadata.contains(text)
else "Key not found in vault",
).ask()
key = self.questions.ask_existing_key(question="Which key contains the value to rename?")
if key is None:
return
value = questionary.text(
"Which value would you like to rename?",
validate=lambda text: True
if self.vault.metadata.contains(key, text)
else f"Value not found in {key}",
).ask()
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:
return
new_value = questionary.text(
"New value?",
validate=lambda text: True
if not self.vault.metadata.contains(key, text)
else f"Value already exists in {key}",
).ask()
new_value = question_key.ask_new_value()
if new_value is None:
return
if self.vault.rename_metadata(key, value, new_value):
alerts.success(f"Renamed [reverse]{key}: {value}[/] to [reverse]{key}: {new_value}[/]")
num_changes = self.vault.rename_metadata(key, value, new_value)
if num_changes == 0:
alerts.warning(f"No notes were changed")
return
def delete_value(self) -> None:
"""Delete a value from the vault."""
while True:
key = questionary.text(
"Which key contains the value to delete?",
).ask()
if key is None:
return
if not self.vault.metadata.contains(key, is_regex=True):
alerts.warning(f"No keys in value match: {key}")
continue
break
while True:
value = questionary.text(
"Regex for the value to delete",
).ask()
if value is None:
return
if not self.vault.metadata.contains(key, value, is_regex=True):
alerts.warning(f"No matching key value pairs found in the vault: {key}: {value}")
continue
num_changed = self.vault.delete_metadata(key, value)
if num_changed == 0:
alerts.warning(f"No notes found matching: [reverse]{key}: {value}[/]")
return
alerts.success(
f"Deleted {num_changed} entries matching: [reverse]{key}[/]: [reverse]{value}[/]"
)
break
return
alerts.success(f"Renamed '{key}:{value}' to '{key}:{new_value}' in {num_changes} notes")
def review_changes(self) -> None:
"""Review all changes in the vault."""
@@ -320,7 +408,9 @@ class Application:
return
print(f"\nFound {len(changed_notes)} changed notes in the vault.\n")
answer = questionary.confirm("View diffs of individual files?", default=False).ask()
answer = self.questions.ask_confirm(
question="View diffs of individual files?", default=False
)
if not answer:
return
@@ -333,38 +423,13 @@ class Application:
choices.append(_selection)
choices.append(questionary.Separator())
choices.append({"name": "Return", "value": "skip"})
choices.append({"name": "Return", "value": "return"})
while True:
note_to_review = questionary.select(
"Select a new to view the diff.",
note_to_review = self.questions.ask_selection(
choices=choices,
use_shortcuts=False,
style=self.custom_style,
).ask()
if note_to_review is None or note_to_review == "skip":
question="Select a new to view the diff",
)
if note_to_review is None or note_to_review == "return":
break
changed_notes[note_to_review].print_diff()
def commit_changes(self) -> None:
"""Write all changes to disk."""
changed_notes = self.vault.get_changed_notes()
if len(changed_notes) == 0:
print("\n")
alerts.notice("No changes to commit.\n")
return
backup = questionary.confirm("Create backup before committing changes").ask()
if backup is None:
return
if backup:
self.vault.backup()
if questionary.confirm(f"Commit {len(changed_notes)} changed files to disk?").ask():
self.vault.write()
alerts.success("Changes committed to disk. Exiting.")
typer.Exit()
return

View File

@@ -0,0 +1,11 @@
"""Enum classes for the obsidian_metadata package."""
from enum import Enum
class MetadataType(Enum):
"""Enum class for the type of metadata."""
FRONTMATTER = "Frontmatter"
INLINE = "Inline Metadata"
TAGS = "Inline Tags"

View File

@@ -58,38 +58,6 @@ class VaultMetadata:
self.dict = dict(sorted(existing_metadata.items()))
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_tags(self) -> None:
"""Print all tags."""
columns = Columns(
sorted(self.dict["tags"]),
equal=True,
expand=True,
title="All tags in Obsidian vault",
)
print(columns)
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
)
table.add_row(f"[bold]{key}[/]", str(values))
Console().print(table)
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a key and/or a value exists in the metadata.
@@ -131,6 +99,38 @@ 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) -> 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
)
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)
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
@@ -197,6 +197,40 @@ class Frontmatter:
return dict_values_to_lists_strings(frontmatter, strip_null_values=True)
def add(self, key: str, value: str | list[str] = None) -> bool:
"""Add a key and value to the frontmatter.
Args:
key (str): Key to add.
value (str, optional): Value to add.
Returns:
bool: True if the metadata was added
"""
if value is None:
if key not in self.dict:
self.dict[key] = []
return True
return False
if key not in self.dict:
if isinstance(value, list):
self.dict[key] = value
return True
self.dict[key] = [value]
return True
if key in self.dict and value not in self.dict[key]:
if isinstance(value, list):
self.dict[key].extend(value)
return True
self.dict[key].append(value)
return True
return False
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a key or value exists in the metadata.
@@ -210,29 +244,6 @@ class Frontmatter:
"""
return dict_contains(self.dict, key, value, is_regex)
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
def delete(self, key: str, value_to_delete: str = None) -> bool:
"""Delete a value or key in the frontmatter. Regex is supported to allow deleting more than one key or value.
@@ -269,6 +280,29 @@ class Frontmatter:
"""
return self.dict != self.dict_original
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the frontmatter.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
def to_yaml(self, sort_keys: bool = False) -> str:
"""Return the frontmatter as a YAML string.
@@ -338,6 +372,19 @@ class InlineMetadata:
return clean_dictionary(inline_metadata)
def add(self, key: str, value: str | list[str] = None) -> bool:
"""Add a key and value to the frontmatter.
Args:
key (str): Key to add.
value (str, optional): Value to add.
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
def contains(self, key: str, value: str = None, is_regex: bool = False) -> bool:
"""Check if a key or value exists in the inline metadata.
@@ -351,29 +398,6 @@ class InlineMetadata:
"""
return dict_contains(self.dict, key, value, is_regex)
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the inline metadata.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
def delete(self, key: str, value_to_delete: str = None) -> bool:
"""Delete a value or key in the inline metadata. Regex is supported to allow deleting more than one key or value.
@@ -410,6 +434,29 @@ class InlineMetadata:
"""
return self.dict != self.dict_original
def rename(self, key: str, value_1: str, value_2: str = None) -> bool:
"""Replace a value in the inline metadata.
Args:
key (str): Key to check.
value_1 (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
value_2 (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if value_2 is None:
if key in self.dict and value_1 not in self.dict:
self.dict[value_1] = self.dict.pop(key)
return True
return False
if key in self.dict and value_1 in self.dict[key]:
self.dict[key] = sorted({value_2 if x == value_1 else x for x in self.dict[key]})
return True
return False
class InlineTags:
"""Representation of inline tags."""
@@ -465,21 +512,6 @@ class InlineTags:
return False
def rename(self, old_tag: str, new_tag: str) -> bool:
"""Replace an inline tag with another string.
Args:
old_tag (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
new_tag (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if old_tag in self.list:
self.list = sorted([new_tag if i == old_tag else i for i in self.list])
return True
return False
def delete(self, tag_to_delete: str) -> bool:
"""Delete a specified inline tag. Regex is supported to allow deleting more than one tag.
@@ -503,3 +535,18 @@ class InlineTags:
bool: True if the metadata has changes.
"""
return self.list != self.list_original
def rename(self, old_tag: str, new_tag: str) -> bool:
"""Replace an inline tag with another string.
Args:
old_tag (str): `With value_2` this is the value to rename. If `value_2` is None this is the renamed key
new_tag (str, Optional): New value.
Returns:
bool: True if a value was renamed
"""
if old_tag in self.list:
self.list = sorted([new_tag if i == old_tag else i for i in self.list])
return True
return False

View File

@@ -7,7 +7,8 @@ from pathlib import Path
import rich.repr
import typer
from rich import print
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
@@ -15,6 +16,7 @@ from obsidian_metadata.models import (
Frontmatter,
InlineMetadata,
InlineTags,
MetadataType,
Patterns,
)
@@ -62,6 +64,84 @@ class Note:
yield "inline_tags", self.inline_tags
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.
Args:
key (str): Key to delete.
value (str, optional): Value to delete.
"""
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
for (_k, _v) in stripped_null_values:
if re.search(key, _k):
if value is None:
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(rf"\[?{_k}:: ?{_v}]?", "", is_regex=True)
return
if re.search(value, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(rf"({_k}::) ?{_v}", r"\1", is_regex=True)
def _rename_inline_metadata(self, key: str, value_1: str, value_2: str = None) -> None:
"""Replaces the inline metadata in the note with the current inline metadata object.
Args:
key (str): Key to rename.
value_1 (str): Value to replace OR new key name (if value_2 is None).
value_2 (str, optional): New value.
"""
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
for (_k, _v) in stripped_null_values:
if re.search(key, _k):
if value_2 is None:
if re.search(rf"{key}[^\w\d_-]+", _k):
key_text = re.split(r"[^\w\d_-]+$", _k)[0]
key_markdown = re.split(r"^[\w\d_-]+", _k)[1]
self.sub(
rf"{key_text}{key_markdown}::",
rf"{value_1}{key_markdown}::",
)
else:
self.sub(f"{_k}::", f"{value_1}::")
else:
if re.search(key, _k) and re.search(value_1, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> bool:
"""Adds metadata to the note.
Args:
area (MetadataType): Area to add metadata to.
key (str): Key to add.
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()
return True
if area is MetadataType.INLINE:
# TODO: implement adding to inline metadata
pass
if area is MetadataType.TAGS:
# TODO: implement adding to intext tags
pass
return False
def append(self, string_to_append: str, allow_multiple: bool = False) -> None:
"""Appends a string to the end of a note.
@@ -117,29 +197,6 @@ class Note:
return False
def _delete_inline_metadata(self, key: str, value: str = None) -> None:
"""Deletes an inline metadata key/value pair from the text of the note. This method does not remove the key/value from the metadata attribute of the note.
Args:
key (str): Key to delete.
value (str, optional): Value to delete.
"""
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
for (_k, _v) in stripped_null_values:
if re.search(key, _k):
if value is None:
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(rf"\[?{_k}:: ?{_v}]?", "", is_regex=True)
return
if re.search(value, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(rf"({_k}::) ?{_v}", r"\1", is_regex=True)
def delete_inline_tag(self, tag: str) -> bool:
"""Deletes an inline tag from the `inline_tags` attribute AND removes the tag from the text of the note if it exists.
@@ -226,54 +283,37 @@ class Note:
diff = difflib.Differ()
result = list(diff.compare(a, b))
table = Table(title=f"\nDiff of {self.note_path.name}", show_header=False, min_width=50)
for line in result:
if line.startswith("+"):
print(f"[green]{line}[/]")
table.add_row(line, style="green")
elif line.startswith("-"):
print(f"[red]{line}[/]")
table.add_row(line, style="red")
def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> None:
"""Substitutes text within the note.
Console().print(table)
Args:
pattern (str): The pattern to replace (plain text or regular expression).
replacement (str): What to replace the pattern with.
is_regex (bool): Whether the pattern is a regex pattern or plain text.
"""
if not is_regex:
pattern = re.escape(pattern)
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
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
if current_frontmatter is None and self.frontmatter.dict == {}:
return
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.
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = f"---\n{new_frontmatter}---\n"
Args:
key (str): Key to rename.
value_1 (str): Value to replace OR new key name (if value_2 is None).
value_2 (str, optional): New value.
if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content
return
"""
all_results = PATTERNS.find_inline_metadata.findall(self.file_content)
stripped_null_values = [tuple(filter(None, x)) for x in all_results]
for (_k, _v) in stripped_null_values:
if re.search(key, _k):
if value_2 is None:
if re.search(rf"{key}[^\w\d_-]+", _k):
key_text = re.split(r"[^\w\d_-]+$", _k)[0]
key_markdown = re.split(r"^[\w\d_-]+", _k)[1]
self.sub(
rf"{key_text}{key_markdown}::",
rf"{value_1}{key_markdown}::",
)
else:
self.sub(f"{_k}::", f"{value_1}::")
else:
if re.search(key, _k) and re.search(value_1, _v):
_k = re.escape(_k)
_v = re.escape(_v)
self.sub(f"{_k}:: ?{_v}", f"{_k}:: {value_2}", is_regex=True)
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.
@@ -329,32 +369,27 @@ class Note:
return False
def replace_frontmatter(self, sort_keys: bool = False) -> None:
"""Replaces the frontmatter in the note with the current frontmatter object."""
try:
current_frontmatter = PATTERNS.frontmatt_block_with_separators.search(
self.file_content
).group("frontmatter")
except AttributeError:
current_frontmatter = None
def sub(self, pattern: str, replacement: str, is_regex: bool = False) -> None:
"""Substitutes text within the note.
if current_frontmatter is None and self.frontmatter.dict == {}:
return
Args:
pattern (str): The pattern to replace (plain text or regular expression).
replacement (str): What to replace the pattern with.
is_regex (bool): Whether the pattern is a regex pattern or plain text.
"""
if not is_regex:
pattern = re.escape(pattern)
new_frontmatter = self.frontmatter.to_yaml(sort_keys=sort_keys)
new_frontmatter = f"---\n{new_frontmatter}---\n"
self.file_content = re.sub(pattern, replacement, self.file_content, re.MULTILINE)
if current_frontmatter is None:
self.file_content = new_frontmatter + self.file_content
return
self.sub(current_frontmatter, new_frontmatter)
def write(self, path: Path | None = None) -> None:
def write(self, path: Path = None) -> None:
"""Writes the note's content to disk.
Args:
path (Path): Path to write the note to. Defaults to the note's path.
Raises:
typer.Exit: If the note's path is not found.
"""
p = self.note_path if path is None else path

View File

@@ -17,13 +17,6 @@ class Patterns:
re.MULTILINE | re.X,
)
frontmatt_block_with_separators: Pattern[str] = re.compile(
r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL
)
frontmatt_block_no_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
find_inline_metadata: Pattern[str] = re.compile(
r""" # First look for in-text key values
(?:^\[| \[) # Find key with starting bracket
@@ -37,5 +30,13 @@ class Patterns:
re.X | re.MULTILINE,
)
validate_tag_text: Pattern[str] = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")
frontmatt_block_with_separators: Pattern[str] = re.compile(
r"^\s*(?P<frontmatter>---.*?---)", flags=re.DOTALL
)
frontmatt_block_no_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]")
validate_tag_text: Pattern[str] = re.compile(r"[ \|,;:\*\(\)\[\]\\\.\n#&]")

View File

@@ -0,0 +1,429 @@
"""Functions for asking questions to the user and validating responses.
This module contains wrappers around questionary to ask questions to the user and validate responses. Mocking questionary has proven very difficult. This functionality is separated from the main application logic to make it easier to test.
Progress towards testing questionary can be found on this issue:
https://github.com/tmbo/questionary/issues/35
"""
import re
from pathlib import Path
from typing import Any
import questionary
import typer
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.patterns import Patterns
from obsidian_metadata.models.vault import Vault
PATTERNS = Patterns()
class Questions:
"""Class for asking questions to the user and validating responses with questionary."""
@staticmethod
def ask_for_vault_path() -> Path: # pragma: no cover
"""Ask the user for the path to their vault.
Returns:
Path: The path to the vault.
"""
vault_path = questionary.path(
"Enter a path to Obsidian vault:",
only_directories=True,
validate=Questions._validate_valid_dir,
).ask()
if vault_path is None:
raise typer.Exit(code=1)
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
def __init__(self, vault: Vault = None, key: str = None) -> None:
"""Initialize the class.
Args:
vault_path (Path, optional): The path to the vault. Defaults to None.
vault (Vault, optional): The vault object. Defaults to None.
key (str, optional): The key to use when validating a key, value pair. Defaults to None.
"""
self.style = questionary.Style(
[
("qmark", "fg:#808080 bold"),
("question", "bold"),
("separator", "fg:#808080"),
("instruction", "fg:#808080"),
("highlighted", "fg:#c0c0c0 bold reverse"),
("text", ""),
("pointer", "bold"),
]
)
self.vault = vault
self.key = key
def _validate_existing_inline_tag(self, text: str) -> bool | str:
"""Validates an existing inline tag.
Returns:
bool | str: True if the tag is valid, otherwise a string with the error message.
"""
if len(text) < 1:
return "Tag cannot be empty"
if not self.vault.contains_inline_tag(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.
Returns:
bool | str: True if the key is valid, otherwise a string with the error message.
"""
if len(text) < 1:
return "Key cannot be empty"
if not self.vault.metadata.contains(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.
Returns:
bool | str: True if the key is valid, otherwise a string with the error message.
"""
if len(text) < 1:
return "Key cannot be empty"
try:
re.compile(text)
except re.error as error:
return f"Invalid regex: {error}"
if not self.vault.metadata.contains(text, is_regex=True):
return f"'{text}' does not exist as a key in the vault"
return True
def _validate_new_key(self, text: str) -> bool | str:
"""Validate the tag name.
Args:
text (str): The key name to validate.
Returns:
bool | str: True if the key is valid, otherwise a string with the error message.
"""
if PATTERNS.validate_key_text.search(text) is not None:
return "Key cannot contain spaces or special characters"
if len(text) == 0:
return "New key cannot be empty"
return True
def _validate_new_tag(self, text: str) -> bool | str:
"""Validate the tag name.
Args:
text (str): The tag name to validate.
Returns:
bool | str: True if the tag is valid, otherwise a string with the error message.
"""
if PATTERNS.validate_tag_text.search(text) is not None:
return "Tag cannot contain spaces or special characters"
if len(text) == 0:
return "New tag cannot be empty"
return True
def _validate_new_value(self, text: str) -> bool | str:
"""Validate a new value.
Args:
text (str): The value to validate.
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 self.key is not None and self.vault.metadata.contains(self.key, text):
return f"{self.key}:{text} already exists"
return True
def _validate_valid_vault_regex(self, text: str) -> bool | str:
"""Validates a valid regex.
Returns:
bool | str: True if the regex is valid, otherwise a string with the error message.
"""
try:
re.compile(text)
except re.error as error:
return f"Invalid regex: {error}"
if self.vault is not None:
for subdir in list(self.vault.vault_path.glob("**/*")):
if re.search(text, str(subdir)):
return True
return "Regex does not match paths in the vault"
return True
def _validate_value(self, text: str) -> bool | str:
"""Validate the value.
Args:
text (str): The value to validate.
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 self.key is not None and not self.vault.metadata.contains(self.key, text):
return f"{self.key}:{text} does not exist"
return True
def _validate_value_exists_regex(self, text: str) -> bool | str:
"""Validate the value.
Args:
text (str): The value to validate.
Returns:
bool | str: True if the value is valid, otherwise a string with the error message.
"""
if len(text) < 1:
return "Regex cannot be empty"
try:
re.compile(text)
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):
return f"No values in {self.key} match regex: {text}"
return True
def ask_area(self) -> MetadataType | str: # pragma: no cover
"""Ask the user for the metadata area to work on.
Returns:
MetadataType: The metadata area to work on.
"""
choices = []
for metadata_type in MetadataType:
choices.append({"name": metadata_type.value, "value": metadata_type})
choices.append(questionary.Separator()) # type: ignore [arg-type]
choices.append({"name": "Cancel", "value": "cancel"})
return self.ask_selection(
choices=choices,
question="Select the type of metadata",
)
def ask_confirm(self, question: str, default: bool = True) -> bool: # pragma: no cover
"""Ask the user to confirm an action.
Args:
question (str): The question to ask.
default (bool, optional): The default value. Defaults to True.
Returns:
bool: True if the user confirms, otherwise False.
"""
return questionary.confirm(
question, default=default, style=self.style, qmark="INPUT |"
).ask()
def ask_existing_inline_tag(self, question: str = "Enter a tag") -> str: # pragma: no cover
"""Ask the user for an existing inline tag."""
return questionary.text(
question,
validate=self._validate_existing_inline_tag,
style=self.style,
qmark="INPUT |",
).ask()
def ask_existing_key(self, question: str = "Enter a key") -> str: # pragma: no cover
"""Ask the user for a metadata key.
Args:
question (str, optional): The question to ask. Defaults to "Enter a key".
Returns:
str: A metadata key that exists in the vault.
"""
return questionary.text(
question, validate=self._validate_key_exists, style=self.style, qmark="INPUT |"
).ask()
def ask_existing_keys_regex(self, question: str = "Regex for keys") -> str: # pragma: no cover
"""Ask the user for a regex for metadata keys.
Args:
question (str, optional): The question to ask. Defaults to "Regex for keys".
Returns:
str: A regex for metadata keys that exist in the vault.
"""
return questionary.text(
question, validate=self._validate_key_exists_regex, style=self.style, qmark="INPUT |"
).ask()
def ask_existing_value(self, question: str = "Enter a value") -> str: # pragma: no cover
"""Ask the user for a metadata value.
Args:
question (str, optional): The question to ask. Defaults to "Enter a value".
Returns:
str: A metadata value.
"""
return questionary.text(
question, validate=self._validate_value, style=self.style, qmark="INPUT |"
).ask()
def ask_filter_path(self) -> str: # pragma: no cover
"""Ask the user for the path to the filter file.
Returns:
str: The regex to use for filtering.
"""
filter_path_regex = questionary.path(
"Regex to filter the notes being processed by their path:",
only_directories=False,
validate=self._validate_valid_vault_regex,
style=self.style,
qmark="INPUT |",
).ask()
if filter_path_regex is None:
raise typer.Exit(code=1)
return filter_path_regex
def ask_existing_value_regex(
self, question: str = "Regex for values"
) -> str: # pragma: no cover
"""Ask the user for a regex for metadata values.
Args:
question (str, optional): The question to ask. Defaults to "Regex for values".
Returns:
str: A regex for metadata values that exist in the vault.
"""
return questionary.text(
question,
validate=self._validate_value_exists_regex,
style=self.style,
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.
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_new_key(self, question: str = "New key name") -> str: # pragma: no cover
"""Ask the user for a new metadata key.
Args:
question (str, optional): The question to ask. Defaults to "New key name".
Returns:
str: A new metadata key.
"""
return questionary.text(
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."""
return questionary.text(
question, validate=self._validate_new_tag, style=self.style, qmark="INPUT |"
).ask()
def ask_new_value(self, question: str = "New value") -> str: # pragma: no cover
"""Ask the user for a new metadata value.
Args:
question (str, optional): The question to ask. Defaults to "New value".
Returns:
str: A new metadata value.
"""
return questionary.text(
question, validate=self._validate_new_value, style=self.style, qmark="INPUT |"
).ask()
def ask_selection(
self, choices: list[Any], question: str = "Select an option"
) -> Any: # pragma: no cover
"""Ask the user to select an item from a list.
Args:
question (str, optional): The question to ask. Defaults to "Select an option".
choices (list[Any]): The list of choices.
Returns:
any: The selected item value.
"""
return questionary.select(
question,
choices=choices,
use_shortcuts=False,
style=self.style,
qmark="INPUT |",
).ask()

View File

@@ -5,15 +5,16 @@ import shutil
from pathlib import Path
import rich.repr
from rich import box
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm
from rich.table import Table
from obsidian_metadata._config import Config
from obsidian_metadata._config import VaultConfig
from obsidian_metadata._utils import alerts
from obsidian_metadata._utils.alerts import logger as log
from obsidian_metadata.models import Note, VaultMetadata
from obsidian_metadata.models import MetadataType, Note, VaultMetadata
@rich.repr.auto
@@ -24,15 +25,13 @@ class Vault:
vault (Path): Path to the vault.
dry_run (bool): Whether to perform a dry run.
backup_path (Path): Path to the backup of the vault.
new_vault (Path): Path to a new vault.
notes (list[Note]): List of all notes in the vault.
"""
def __init__(self, config: Config, dry_run: bool = False, path_filter: str = None):
self.vault_path: Path = config.vault_path
def __init__(self, config: VaultConfig, dry_run: bool = False, path_filter: str = None):
self.vault_path: Path = config.path
self.dry_run: bool = dry_run
self.backup_path: Path = self.vault_path.parent / f"{self.vault_path.name}.bak"
self.new_vault_path: Path = self.vault_path.parent / f"{self.vault_path.name}.new"
self.exclude_paths: list[Path] = []
self.metadata = VaultMetadata()
for p in config.exclude_paths:
@@ -50,17 +49,14 @@ class Vault:
self.notes: list[Note] = [
Note(note_path=p, dry_run=self.dry_run) for p in self.note_paths
]
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})
def __rich_repr__(self) -> rich.repr.Result:
self._rebuild_vault_metadata()
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
"""Define rich representation of Vault."""
yield "vault_path", self.vault_path
yield "dry_run", self.dry_run
yield "backup_path", self.backup_path
yield "new_vault", self.new_vault_path
yield "num_notes", self.num_notes()
yield "exclude_paths", self.exclude_paths
@@ -88,11 +84,48 @@ class Vault:
return notes_list
def _rebuild_vault_metadata(self) -> None:
"""Rebuild vault metadata."""
self.metadata = VaultMetadata()
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
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})
def add_metadata(self, area: MetadataType, key: str, value: str | list[str] = None) -> int:
"""Add metadata to all notes in the vault.
Args:
area (MetadataType): Area of metadata to add to.
key (str): Key to add.
value (str|list, optional): Value to add.
Returns:
int: Number of notes updated.
"""
num_changed = 0
for _note in self.notes:
if _note.add_metadata(area, key, value):
num_changed += 1
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def backup(self) -> None:
"""Backup the vault."""
log.debug("Backing up vault")
if self.dry_run:
alerts.dryrun(f"Backup up vault to: {self.backup_path}")
print("\n")
return
try:
@@ -149,25 +182,25 @@ class Vault:
else:
alerts.info("No backup found")
def delete_inline_tag(self, tag: str) -> bool:
def delete_inline_tag(self, tag: str) -> int:
"""Delete an inline tag in the vault.
Args:
tag (str): Tag to delete.
Returns:
bool: True if tag was deleted.
int: Number of notes that had tag deleted.
"""
changes = False
num_changed = 0
for _note in self.notes:
if _note.delete_inline_tag(tag):
changes = True
num_changed += 1
if changes:
self.metadata.delete(self.notes[0].inline_tags.metadata_key, tag)
return True
return False
if num_changed > 0:
self._rebuild_vault_metadata()
return num_changed
def delete_metadata(self, key: str, value: str = None) -> int:
"""Delete metadata in the vault.
@@ -186,8 +219,8 @@ class Vault:
num_changed += 1
if num_changed > 0:
self.metadata.delete(key, value)
return num_changed
self._rebuild_vault_metadata()
return num_changed
def get_changed_notes(self) -> list[Note]:
@@ -206,15 +239,14 @@ class Vault:
def info(self) -> None:
"""Print information about the vault."""
log.debug("Printing vault info")
table = Table(title="Vault Info", show_header=False)
table = Table(show_header=False)
table.add_row("Vault", str(self.vault_path))
table.add_row("Notes being edited", str(self.num_notes()))
table.add_row("Notes excluded from editing", str(self.num_excluded_notes()))
if self.backup_path.exists():
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 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())))
@@ -222,8 +254,10 @@ class Vault:
def list_editable_notes(self) -> None:
"""Print a list of notes within the scope that are being edited."""
for _note in self.notes:
print(_note.note_path.relative_to(self.vault_path))
table = Table(title="Notes in current scope", show_header=False, box=box.HORIZONTALS)
for _n, _note in enumerate(self.notes, 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."""
@@ -242,7 +276,7 @@ class Vault:
"""
return len(self.notes)
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> bool:
def rename_metadata(self, key: str, value_1: str, value_2: str = None) -> int:
"""Renames a key or key-value pair in the note's metadata.
If no value is provided, will rename an entire key.
@@ -253,19 +287,20 @@ class Vault:
value_2 (str, optional): New value.
Returns:
bool: True if metadata was renamed.
int: Number of notes that had metadata renamed.
"""
changes = False
num_changed = 0
for _note in self.notes:
if _note.rename_metadata(key, value_1, value_2):
changes = True
num_changed += 1
if changes:
self.metadata.rename(key, value_1, value_2)
return True
return False
if num_changed > 0:
self._rebuild_vault_metadata()
def rename_inline_tag(self, old_tag: str, new_tag: str) -> bool:
return num_changed
def rename_inline_tag(self, old_tag: str, new_tag: str) -> int:
"""Rename an inline tag in the vault.
Args:
@@ -273,30 +308,23 @@ class Vault:
new_tag (str): New tag name.
Returns:
bool: True if tag was renamed.
int: Number of notes that had inline tags renamed.
"""
changes = False
num_changed = 0
for _note in self.notes:
if _note.rename_inline_tag(old_tag, new_tag):
changes = True
num_changed += 1
if changes:
self.metadata.rename(self.notes[0].inline_tags.metadata_key, old_tag, new_tag)
return True
return False
if num_changed > 0:
self._rebuild_vault_metadata()
def write(self, new_vault: bool = False) -> None:
return num_changed
def write(self) -> None:
"""Write changes to the vault."""
log.debug("Writing changes to vault...")
if new_vault:
log.debug("Writing changes to backup")
if self.dry_run is False:
for _note in self.notes:
_new_note_path: Path = Path(
self.new_vault_path / Path(_note.note_path).relative_to(self.vault_path)
)
log.debug(f"writing to {_new_note_path}")
_note.write(path=_new_note_path)
else:
for _note in self.notes:
log.debug(f"writing to {_note.note_path}")
log.trace(f"writing to {_note.note_path}")
_note.write()

508
tests/application_test.py Normal file
View File

@@ -0,0 +1,508 @@
# type: ignore
"""Tests for the application module.
How mocking works in this test suite:
1. The application_main() method is mocked using a side effect iterable. This allows us to pass a value in the first run, and then a KeyError in the second run to exit the loop.
2. All questions are mocked using return_value. This allows us to pass in a value to the question and then the method will return that value. This is useful for testing questionary prompts without user input.
"""
import re
from pathlib import Path
import pytest
from obsidian_metadata.models.enums import MetadataType
from tests.helpers import Regex
def test_instantiate_application(test_application) -> None:
"""Test application."""
app = test_application
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
def test_abort(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
return_value="abort",
)
app.application_main()
captured = capsys.readouterr()
assert "Done!" in captured.out
def test_add_metadata_frontmatter_success(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.FRONTMATTER,
)
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_delete_inline_tag(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_inline_tag", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag",
return_value="not_a_tag_in_vault",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_inline_tag", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag",
return_value="breakfast",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Deleted.*\d+.*notes", re.DOTALL)
def test_delete_key(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_key", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_keys_regex",
return_value=r"\d{7}",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes found with a.*key.*matching", re.DOTALL)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_key", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_keys_regex",
return_value=r"d\w+",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(
r"SUCCESS +\|.*Deleted.*keys.*matching:.*d\\w\+.*from.*10", re.DOTALL
)
def test_delete_value(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_value", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="area",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value_regex",
return_value=r"\d{7}",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes found matching:", re.DOTALL)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["delete_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_value", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="area",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value_regex",
return_value=r"^front\w+$",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(
r"SUCCESS +\| Deleted value.*\^front\\w\+\$.*from.*key.*area.*in.*\d+.*notes", re.DOTALL
)
def test_filter_notes_filter(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
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"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_filter_path",
return_value="inline",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Loaded.*\d+.*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
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"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_filter_path",
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 "02 inline/inline 2.md" in captured.out
assert "03 mixed/mixed 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()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["inspect_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["all_metadata", "back"],
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"type +│ article", re.DOTALL)
def test_rename_inline_tag(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_inline_tag", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag",
return_value="not_a_tag",
)
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"WARNING +\| No notes were changed", re.DOTALL)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_inline_tag", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_inline_tag",
return_value="breakfast",
)
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"Renamed.*breakfast.*to.*new_tag.*in.*\d+.*notes", re.DOTALL)
def test_rename_key(test_application, mocker, capsys) -> None:
"""Test renaming a key."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_key", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="tag",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_key",
return_value="new_tags",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert "WARNING | No notes were changed" in captured.out
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_key", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="tags",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_key",
return_value="new_tags",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"Renamed.*tags.*to.*new_tags.*in.*\d+.*notes", re.DOTALL)
def test_rename_value_fail(test_application, mocker, capsys) -> None:
"""Test renaming an inline tag."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_value", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="area",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="not_exists",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_value",
return_value="new_key",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"WARNING +\| No notes were changed", re.DOTALL)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_value", "back"],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="area",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_value",
return_value="frontmatter",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_value",
return_value="new_key",
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(
r"SUCCESS +\| Renamed.*'area:frontmatter'.*to.*'area:new_key'", re.DOTALL
)
assert captured.out == Regex(r".*in.*\d+.*notes.*", re.DOTALL)
def test_review_no_changes(test_application, mocker, capsys) -> None:
"""Review changes when no changes to vault."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["review_changes", KeyError],
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"INFO +\| No changes to review", re.DOTALL)
def test_review_changes(test_application, mocker, capsys) -> None:
"""Review changes when no changes to vault."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["rename_metadata", "review_changes", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_confirm",
return_value=True,
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_existing_key",
return_value="tags",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_new_key",
return_value="new_tags",
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["rename_key", 1, "return"],
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r".*Found.*\d+.*changed notes in the vault.*", re.DOTALL)
assert "- tags:" in captured.out
assert "+ new_tags:" in captured.out
def test_vault_backup(test_application, mocker, capsys) -> None:
"""Test backing up a vault."""
app = test_application
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["vault_actions", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["backup_vault", "back"],
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\|.*application\.bak", re.DOTALL)
def test_vault_delete(test_application, mocker, capsys, tmp_path) -> None:
"""Test backing up a vault."""
app = test_application
backup_path = Path(tmp_path / "application.bak")
backup_path.mkdir()
app.load_vault()
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_application_main",
side_effect=["vault_actions", KeyError],
)
mocker.patch(
"obsidian_metadata.models.application.Questions.ask_selection",
side_effect=["delete_backup", "back"],
)
with pytest.raises(KeyError):
app.application_main()
captured = capsys.readouterr()
assert captured.out == Regex(r"SUCCESS +\| Backup deleted", re.DOTALL)

View File

@@ -4,7 +4,8 @@
from typer.testing import CliRunner
from obsidian_metadata.cli import app
from tests.helpers import Regex
from .helpers import KeyInputs, Regex # noqa: F401
runner = CliRunner()
@@ -14,3 +15,29 @@ def test_version() -> None:
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output == Regex(r"obsidian_metadata: v\d+\.\d+\.\d+$")
def test_application(test_vault, tmp_path) -> None:
"""Test the application."""
vault_path = test_vault
config_path = tmp_path / "config.toml"
result = runner.invoke(
app,
["--vault-path", vault_path, "--config-file", config_path],
# input=KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.DOWN + KeyInputs.ENTER, # noqa: ERA001
)
banner = r"""
___ _ _ _ _
/ _ \| |__ ___(_) __| (_) __ _ _ __
| | | | '_ \/ __| |/ _` | |/ _` | '_ \
| |_| | |_) \__ \ | (_| | | (_| | | | |
\___/|_.__/|___/_|\__,_|_|\__,_|_| |_|
| \/ | ___| |_ __ _ __| | __ _| |_ __ _
| |\/| |/ _ \ __/ _` |/ _` |/ _` | __/ _` |
| | | | __/ || (_| | (_| | (_| | || (_| |
|_| |_|\___|\__\__,_|\__,_|\__,_|\__\__,_|
"""
assert banner in result.output
assert result.exit_code == 1

View File

@@ -1,28 +1,118 @@
# type: ignore
"""Tests for the configuration module."""
import re
from pathlib import Path
from textwrap import dedent
from obsidian_metadata._config import Config
import pytest
import typer
from obsidian_metadata._config.config import Config, ConfigQuestions
def test_first_run(tmp_path):
"""Test creating a config on first run."""
def test_validate_valid_dir() -> None:
"""Test vault validation."""
assert ConfigQuestions._validate_valid_dir("tests/") is True
assert "Path is not a directory" in ConfigQuestions._validate_valid_dir("pyproject.toml")
assert "Path does not exist" in ConfigQuestions._validate_valid_dir("tests/vault2")
def test_broken_config_file(capsys) -> None:
"""Test loading a broken config file."""
config_file = Path("tests/fixtures/broken_config_file.toml")
with pytest.raises(typer.Exit):
Config(config_path=config_file)
captured = capsys.readouterr()
assert "Could not parse" in captured.out
def test_vault_path_errors(tmp_path, capsys) -> None:
"""Test loading a config file with a vault path that doesn't exist."""
config_file = Path(tmp_path / "config.toml")
vault_path = Path(tmp_path / "vault/")
vault_path.mkdir()
with pytest.raises(typer.Exit):
Config(config_path=config_file, vault_path=Path("tests/fixtures/does_not_exist"))
captured = capsys.readouterr()
assert "Vault path not found" in captured.out
config = Config(config_path=config_file, vault_path=vault_path)
with pytest.raises(typer.Exit):
Config(config_path=config_file, vault_path=Path("tests/fixtures/sample_note.md"))
captured = capsys.readouterr()
assert "Vault path is not a directory" in captured.out
def test_multiple_vaults_okay() -> None:
"""Test multiple vaults."""
config_file = Path("tests/fixtures/multiple_vaults.toml")
config = Config(config_path=config_file)
assert config.config == {
"Sample Vault": {
"exclude_paths": [".git", ".obsidian", "ignore_folder"],
"path": "tests/fixtures/sample_vault",
},
"Test Vault": {
"exclude_paths": [".git", ".obsidian", "ignore_folder"],
"path": "tests/fixtures/test_vault",
},
}
assert len(config.vaults) == 2
assert config.vaults[0].name == "Sample Vault"
assert config.vaults[0].path == Path("tests/fixtures/sample_vault").expanduser().resolve()
assert config.vaults[0].exclude_paths == [".git", ".obsidian", "ignore_folder"]
assert config.vaults[1].name == "Test Vault"
assert config.vaults[1].path == Path("tests/fixtures/test_vault").expanduser().resolve()
assert config.vaults[1].exclude_paths == [".git", ".obsidian", "ignore_folder"]
def test_single_vault() -> None:
"""Test multiple vaults."""
config_file = Path("tests/fixtures/test_vault_config.toml")
config = Config(config_path=config_file)
assert config.config == {
"Test Vault": {
"exclude_paths": [".git", ".obsidian", "ignore_folder"],
"path": "tests/fixtures/test_vault",
}
}
assert len(config.vaults) == 1
assert config.vaults[0].name == "Test Vault"
assert config.vaults[0].path == Path("tests/fixtures/test_vault").expanduser().resolve()
assert config.vaults[0].exclude_paths == [".git", ".obsidian", "ignore_folder"]
def test_no_config_no_vault(tmp_path, mocker) -> None:
"""Test creating a config on first run."""
fake_vault = Path(tmp_path / "vault")
fake_vault.mkdir()
mocker.patch(
"obsidian_metadata._config.config.ConfigQuestions.ask_for_vault_path",
return_value=fake_vault,
)
config_file = Path(tmp_path / "config.toml")
Config(config_path=config_file)
content = config_file.read_text()
sample_config = f"""\
# Add another vault by replicating this section and changing the name
["Vault 1"] # Name of the vault.
# Path to your obsidian vault
path = "{str(fake_vault)}"
# Folders within the vault to ignore when indexing metadata
exclude_paths = [".git", ".obsidian"]"""
assert config_file.exists() is True
config.write_config_value("vault", str(vault_path))
content = config_file.read_text()
assert config.vault_path == vault_path
assert re.search(str(vault_path), content) is not None
assert content == dedent(sample_config)
def test_parse_config():
"""Test parsing a config file."""
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=None)
assert config.vault_path == Path(Path.cwd() / "tests/fixtures/test_vault")
new_config = Config(config_path=config_file)
assert new_config.config == {
"Vault 1": {
"path": str(fake_vault),
"exclude_paths": [".git", ".obsidian"],
}
}

View File

@@ -6,6 +6,9 @@ from pathlib import Path
import pytest
from obsidian_metadata._config import Config
from obsidian_metadata.models.application import Application
def remove_all(root: Path):
"""Remove all files and directories in a directory."""
@@ -72,3 +75,27 @@ def test_vault(tmp_path) -> Path:
if backup_dir.exists():
shutil.rmtree(backup_dir)
@pytest.fixture()
def test_application(tmp_path) -> Application:
"""Fixture which creates a sample vault."""
source_dir = Path(__file__).parent / "fixtures" / "sample_vault"
dest_dir = Path(tmp_path / "application")
backup_dir = Path(f"{dest_dir}.bak")
if not source_dir.exists():
raise FileNotFoundError(f"Sample vault not found: {source_dir}")
shutil.copytree(source_dir, dest_dir)
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=dest_dir)
vault_config = config.vaults[0]
app = Application(config=vault_config, dry_run=False)
yield app
# after test - remove fixtures
shutil.rmtree(dest_dir)
if backup_dir.exists():
shutil.rmtree(backup_dir)

View File

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

6
tests/fixtures/multiple_vaults.toml vendored Normal file
View File

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

View File

@@ -1,8 +1,3 @@
vault = "tests/fixtures/sample_vault"
# folders to ignore when parsing content
exclude_paths = [".git", ".obsidian", "ignore_folder"]
[metadata]
metadata_location = "frontmatter" # "frontmatter", "top", "bottom"
tags_location = "top" # "frontmatter", "top", "bottom"
["Sample Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
path = "tests/fixtures/sample_vault"

View File

@@ -8,6 +8,7 @@ tags:
- ignored_file_tag1
author: author name
type: ["article", "note"]
ignored_frontmatter: ignore_me
---
#inline_tag_top1 #inline_tag_top2
#ignored_file_tag2

View File

@@ -1,8 +1,3 @@
vault = "tests/fixtures/test_vault"
# folders to ignore when parsing content
exclude_paths = [".git", ".obsidian", "ignore_folder"]
[metadata]
metadata_location = "frontmatter" # "frontmatter", "top", "bottom"
tags_location = "top" # "frontmatter", "top", "bottom"
["Test Vault"]
exclude_paths = [".git", ".obsidian", "ignore_folder"]
path = "tests/fixtures/test_vault"

View File

@@ -4,6 +4,24 @@
import re
class KeyInputs:
"""Key inputs for testing."""
DOWN = "\x1b[B"
UP = "\x1b[A"
LEFT = "\x1b[D"
RIGHT = "\x1b[C"
ENTER = "\r"
ESCAPE = "\x1b"
CONTROLC = "\x03"
BACK = "\x7f"
SPACE = " "
TAB = "\x09"
ONE = "1"
TWO = "2"
THREE = "3"
class Regex:
"""Assert that a given string meets some expectations.

View File

@@ -222,6 +222,71 @@ def test_frontmatter_contains() -> None:
assert frontmatter.contains("key", r"\w\d_", is_regex=True) is True
def test_frontmatter_add() -> None:
"""Test frontmatter add."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)
assert frontmatter.add("frontmatter_Key1") is False
assert frontmatter.add("added_key") is True
assert frontmatter.dict == {
"added_key": [],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", "added_value") is True
assert frontmatter.dict == {
"added_key": ["added_value"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", "added_value_2") is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key", ["added_value_3", "added_value_4"]) is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key2", ["added_value_1", "added_value_2"]) is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"added_key2": ["added_value_1", "added_value_2"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key3", "added_value_1") is True
assert frontmatter.dict == {
"added_key": ["added_value", "added_value_2", "added_value_3", "added_value_4"],
"added_key2": ["added_value_1", "added_value_2"],
"added_key3": ["added_value_1"],
"frontmatter_Key1": ["frontmatter_Key1_value"],
"frontmatter_Key2": ["article", "note"],
"shared_key1": ["shared_key1_value"],
"tags": ["tag_1", "tag_2", "📅/tag_3"],
}
assert frontmatter.add("added_key3", "added_value_1") is False
def test_frontmatter_rename() -> None:
"""Test frontmatter rename."""
frontmatter = Frontmatter(FRONTMATTER_CONTENT)

View File

@@ -7,6 +7,7 @@ from pathlib import Path
import pytest
import typer
from obsidian_metadata.models.enums import MetadataType
from obsidian_metadata.models.notes import Note
from tests.helpers import Regex
@@ -102,6 +103,65 @@ def test_append(sample_note) -> None:
assert len(re.findall(re.escape(string2), note.file_content)) == 2
def test_add_metadata(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
assert note.frontmatter.dict == {
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"new_key1": [],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
}
assert note.add_metadata(MetadataType.FRONTMATTER, "new_key2", "new_key2_value") is True
assert note.frontmatter.dict == {
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"new_key1": [],
"new_key2": ["new_key2_value"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
}
assert (
note.add_metadata(
MetadataType.FRONTMATTER, "new_key2", ["new_key2_value2", "new_key2_value3"]
)
is True
)
assert note.frontmatter.dict == {
"date_created": ["2022-12-22"],
"frontmatter_Key1": ["author name"],
"frontmatter_Key2": ["article", "note"],
"new_key1": [],
"new_key2": ["new_key2_value", "new_key2_value2", "new_key2_value3"],
"shared_key1": ["shared_key1_value"],
"shared_key2": ["shared_key2_value1"],
"tags": [
"frontmatter_tag1",
"frontmatter_tag2",
"shared_tag",
"📅/frontmatter_tag3",
],
}
def test_contains_inline_tag(sample_note) -> None:
"""Test contains inline tag."""
note = Note(note_path=sample_note)
@@ -212,9 +272,6 @@ def test_print_note(sample_note, capsys) -> None:
def test_print_diff(sample_note, capsys) -> None:
"""Test printing diff."""
note = Note(note_path=sample_note)
note.print_diff()
captured = capsys.readouterr()
assert captured.out == ""
note.append("This is a test string.")
note.print_diff()

112
tests/questions_test.py Normal file
View File

@@ -0,0 +1,112 @@
# type: ignore
"""Test the questions class."""
from pathlib import Path
from obsidian_metadata._config import Config
from obsidian_metadata.models.questions import Questions
from obsidian_metadata.models.vault import Vault
VAULT_PATH = Path("tests/fixtures/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)
def test_validate_valid_dir() -> None:
"""Test vault validation."""
questions = Questions(vault=VAULT)
assert questions._validate_valid_dir("tests/") is True
assert "Path is not a directory" in questions._validate_valid_dir("pyproject.toml")
assert "Path does not exist" in questions._validate_valid_dir("tests/vault2")
def test_validate_valid_regex() -> None:
"""Test regex validation."""
questions = Questions(vault=VAULT)
assert questions._validate_valid_vault_regex(r".*\.md") is True
assert "Invalid regex" in questions._validate_valid_vault_regex("[")
assert "Regex does not match paths" in questions._validate_valid_vault_regex(r"\d\d\d\w\d")
def test_validate_key_exists() -> None:
"""Test key validation."""
questions = Questions(vault=VAULT)
assert "'test' does not exist" in questions._validate_key_exists("test")
assert "Key cannot be empty" in questions._validate_key_exists("")
assert questions._validate_key_exists("frontmatter_Key1") is True
def test_validate_new_key() -> None:
"""Test new key validation."""
questions = Questions(vault=VAULT)
assert "Key cannot contain spaces or special characters" in questions._validate_new_key(
"new key"
)
assert "Key cannot contain spaces or special characters" in questions._validate_new_key(
"new_key!"
)
assert "New key cannot be empty" in questions._validate_new_key("")
assert questions._validate_new_key("new_key") is True
def test_validate_new_tag() -> None:
"""Test new tag validation."""
questions = Questions(vault=VAULT)
assert "New tag cannot be empty" in questions._validate_new_tag("")
assert "Tag cannot contain spaces or special characters" in questions._validate_new_tag(
"new tag"
)
assert questions._validate_new_tag("new_tag") is True
def test_validate_existing_inline_tag() -> None:
"""Test existing tag validation."""
questions = Questions(vault=VAULT)
assert "Tag cannot be empty" in questions._validate_existing_inline_tag("")
assert "'test' does not exist" in questions._validate_existing_inline_tag("test")
assert questions._validate_existing_inline_tag("shared_tag") is True
def test_validate_key_exists_regex() -> None:
"""Test key exists regex validation."""
questions = Questions(vault=VAULT)
assert "'test' does not exist" in questions._validate_key_exists_regex("test")
assert "Key cannot be empty" in questions._validate_key_exists_regex("")
assert "Invalid regex" in questions._validate_key_exists_regex("[")
assert questions._validate_key_exists_regex(r"\w+_Key\d") is True
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("")
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
def test_validate_value_exists_regex() -> None:
"""Test value exists regex validation."""
questions2 = Questions(vault=VAULT, key="frontmatter_Key1")
assert "Invalid regex" in questions2._validate_value_exists_regex("[")
assert "Regex cannot be empty" in questions2._validate_value_exists_regex("")
assert (
questions2._validate_value_exists_regex(r"\d\d\d\w\d")
== r"No values in frontmatter_Key1 match regex: \d\d\d\w\d"
)
assert questions2._validate_value_exists_regex(r"^author \w+") is True
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 "Value cannot be empty" in questions._validate_new_value("")
assert (
questions._validate_new_value("author name")
== "frontmatter_Key1:author name already exists"
)

View File

@@ -7,7 +7,6 @@ from obsidian_metadata._utils import (
dict_contains,
dict_values_to_lists_strings,
remove_markdown_sections,
vault_validation,
)
@@ -67,13 +66,6 @@ def test_dict_values_to_lists_strings():
}
def test_vault_validation():
"""Test vault validation."""
assert vault_validation("tests/") is True
assert "Path is not a directory" in vault_validation("pyproject.toml")
assert "Path does not exist" in vault_validation("tests/vault2")
def test_remove_markdown_sections():
"""Test removing markdown sections."""
text: str = """

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from obsidian_metadata._config import Config
from obsidian_metadata.models import Vault
from obsidian_metadata.models.enums import MetadataType
from tests.helpers import Regex
@@ -12,17 +13,18 @@ def test_vault_creation(test_vault):
"""Test creating a Vault object."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.vault_path == vault_path
assert vault.backup_path == Path(f"{vault_path}.bak")
assert vault.new_vault_path == Path(f"{vault_path}.new")
assert vault.dry_run is False
assert str(vault.exclude_paths[0]) == Regex(r".*\.git")
assert vault.num_notes() == 2
assert vault.num_notes() == 3
assert vault.metadata.dict == {
"Inline Tags": [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
@@ -31,24 +33,29 @@ def test_vault_creation(test_vault):
"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"],
"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"],
}
@@ -56,13 +63,15 @@ 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 = Vault(config=config, path_filter="front")
vault_config = config.vaults[0]
vault = Vault(config=vault_config, path_filter="front")
assert vault.num_notes() == 4
vault_path = sample_vault
config = Config(config_path="tests/fixtures/sample_vault_config.toml", vault_path=vault_path)
vault2 = Vault(config=config, path_filter="mixed")
vault_config = config.vaults[0]
vault2 = Vault(config=vault_config, path_filter="mixed")
assert vault2.num_notes() == 1
@@ -71,7 +80,8 @@ def test_backup(test_vault, capsys):
"""Test backing up the vault."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config, dry_run=False)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
vault.backup()
@@ -89,7 +99,8 @@ def test_backup_dryrun(test_vault, capsys):
"""Test backing up the vault."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config, dry_run=True)
vault_config = config.vaults[0]
vault = Vault(config=vault_config, dry_run=True)
print(f"vault.dry_run: {vault.dry_run}")
vault.backup()
@@ -103,7 +114,8 @@ def test_delete_backup(test_vault, capsys):
"""Test deleting the vault backup."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config, dry_run=False)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
vault.backup()
vault.delete_backup()
@@ -122,7 +134,8 @@ def test_delete_backup_dryrun(test_vault, capsys):
"""Test deleting the vault backup."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config, dry_run=True)
vault_config = config.vaults[0]
vault = Vault(config=vault_config, dry_run=True)
Path.mkdir(vault.backup_path)
vault.delete_backup()
@@ -136,31 +149,131 @@ 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 = Vault(config=config)
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 being edited +\\d+")
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 = Vault(config=config)
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 = Vault(config=config)
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
@@ -172,11 +285,13 @@ def test_delete_inline_tag(test_vault) -> None:
"""Test deleting an inline tag."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.delete_inline_tag("no tag") is False
assert vault.delete_inline_tag("intext_tag2") is True
assert vault.delete_inline_tag("no tag") == 0
assert vault.delete_inline_tag("intext_tag2") == 2
assert vault.metadata.dict["Inline Tags"] == [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
@@ -190,15 +305,16 @@ def test_delete_metadata(test_vault) -> None:
"""Test deleting a metadata key/value."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.delete_metadata("no key") == 0
assert vault.delete_metadata("top_key1", "no_value") == 0
assert vault.delete_metadata("top_key1", "top_key1_value") == 1
assert vault.delete_metadata("top_key1", "top_key1_value") == 2
assert vault.metadata.dict["top_key1"] == []
assert vault.delete_metadata("top_key2") == 1
assert vault.delete_metadata("top_key2") == 2
assert "top_key2" not in vault.metadata.dict
@@ -206,11 +322,13 @@ def test_rename_inline_tag(test_vault) -> None:
"""Test renaming an inline tag."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.rename_inline_tag("no tag", "new_tag") is False
assert vault.rename_inline_tag("intext_tag2", "new_tag") is True
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"] == [
"ignored_file_tag2",
"inline_tag_bottom1",
"inline_tag_bottom2",
"inline_tag_top1",
@@ -225,23 +343,28 @@ def test_rename_metadata(test_vault) -> None:
"""Test renaming a metadata key/value."""
vault_path = test_vault
config = Config(config_path="tests/fixtures/test_vault_config.toml", vault_path=vault_path)
vault = Vault(config=config)
vault_config = config.vaults[0]
vault = Vault(config=vault_config)
assert vault.rename_metadata("no key", "new_key") is False
assert vault.rename_metadata("tags", "nonexistent_value", "new_vaule") is False
assert vault.rename_metadata("no key", "new_key") == 0
assert vault.rename_metadata("tags", "nonexistent_value", "new_vaule") == 0
assert vault.rename_metadata("tags", "frontmatter_tag1", "new_vaule") is True
assert vault.rename_metadata("tags", "frontmatter_tag1", "new_vaule") == 2
assert vault.metadata.dict["tags"] == [
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"new_vaule",
"shared_tag",
"📅/frontmatter_tag3",
]
assert vault.rename_metadata("tags", "new_key") is True
assert vault.rename_metadata("tags", "new_key") == 2
assert "tags" not in vault.metadata.dict
assert vault.metadata.dict["new_key"] == [
"frontmatter_tag2",
"frontmatter_tag3",
"ignored_file_tag1",
"new_vaule",
"shared_tag",
"📅/frontmatter_tag3",